18 Commits

Author SHA1 Message Date
Benjamin Sutter 362514681a feat(demand): Ergebnis-Grid — Spalten-Umschalter, sauberere Karten, mehr Abstand
- 3/5/10-Spalten-Umschalter im Ergebnis-Header (nur Grid-Ansicht, in localStorage gemerkt) analog zu "Meine Objekte"
- Karten-Bild auf aspectRatio 15/8 statt fester Höhe → keine Verzerrung bei beliebiger Spaltenzahl
- Orts-Overlay vom Bild entfernt → Match-Score überdeckt das Ort-Label nicht mehr
- Sidebar bleibt bis < lg (1200px) ausgeklappt → Navigation + Account auf Laptops sichtbar
- Größere vertikale Abstände im Ergebnis-Feed

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:56:35 +02:00
Benjamin Sutter 07cea21143 feat(supply): Anfragencenter — embedded property cards in public need detail
Add EmbeddedPropertyCard and surface matching properties inside the
public need detail view. Add needToParsedCriteria() mapper to convert a
stored Need back into parsed criteria for re-running search/matching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:03:36 +02:00
Benjamin Sutter be5523bcee fix(supply): Anfragencenter parse error — stray quote in JSX attribute string
The "Gesendet" empty-state description embedded an ASCII double-quote inside a German „…" pair, which Vite/oxc parsed as the end of the attribute string (PARSE_ERROR). Reworded without embedded quotes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:36:39 +02:00
Benjamin Sutter a8b54af0b8 feat(messages): unified conversation inbox — connect demand ↔ supply, offers reach the seeker
Phase 1 of the messaging rework: one conversation/thread model (Inquiry) is the single source of truth, read perspectivally by both sides.

- domain/inquiry: add kind (INQUIRY|OFFER), tenantOrgId (demand org), offeredPropertyIds
- provider/service: InquiryFilters gains tenantOrgId+kind; new createInquiry (demand→supply) & createOffer (supply→demand) materialize threads; addMessage bumps unread; reply carries senderType (tenant vs supply_user)
- hooks: useCreateInquiry, useCreateOffer; reply payload carries sender perspective
- demand: InquiryQuickDialog → createInquiry via provider (session-based tenant/org, no hardcode); Anfragen.tsx reads via React Query (tenantOrgId) with Gesendet/Erhalten tabs, replies via provider, shows offered properties for OFFER threads; inquiryStore reduced to dialog-only (removes Zustand server-data island)
- supply: OfferChatComposer send now materializes an OFFER conversation into the seeker's inbox (routed via latentNeed.tenantOrgId); Anfragencenter gains a "Gesendet" tab; ActiveInquiriesTab filters by perspective (org + kind)
- seed: merge demand inquiries with tenantOrgId=org-mobimo + 2 OFFER seeds; provider seeds from both sets

Closes the loop: a sent inquiry reaches the Verwaltung and a sent offer reaches the Nachfrager — both answerable in one thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:32:13 +02:00
Benjamin Sutter 6206447dae fix(demand): new search profile inherits creator's organizationId
A newly created need (from "Jetzt suchen") had no organizationId, so it fell out of the org-filtered need list — the results "Aktive Suche" banner then showed a different (first saved) profile while the result cards already reflected the new search. needService.create now injects the current user's org, so banner and results stay consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:57:14 +02:00
Benjamin Sutter 8cb6f21581 fix(matching): exclude external scraped listings from demand results
External third-party scrapes (Homegate/ImmoScout/newhome/MatchOffice) were mis-tagged as VERIFIED_PORTFOLIO in mock data and surfaced as "own platform" matches. Skip them in match generation — demand results now only contain genuine platform objects, Maison Work, and Future Availability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:51:16 +02:00
Benjamin Sutter 29be4f6e54 refactor(supply): pre-market unit config inline — attributes first, then price (no double price)
- Released pre-market unit now shows a single always-visible config: fit-out/parking/cost-bearer (auto-save) THEN the KI price recommendation with explicit deviation vs. current price
- Removes the duplicate price (base price no longer shown here; only the pre-market expected price is set), and removes the edit-only pencil for released units
- Not-yet-released units keep the compact read strip + pencil editor

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:42:51 +02:00
Benjamin Sutter 09095a1a32 fix(ai): realistic pre-market rent recommendation — annual units + anchor on current price
- getMarketRent now returns CHF/m²/YEAR (stored medians are monthly → ×12); fixes annual-vs-monthly mismatch that also affected MarketPricePanel and NegotiationInsightsPanel
- recommendPreMarketRent: anchor on the unit's current rent and adjust within a bounded ±15% by market momentum (vacancy=supply, demand strength, days-on-market, trend) instead of jumping to the city-wide (prime-skewed) median — no more "double the price" suggestions; median only used as a headroom check
- tests updated to annual market values

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:39:37 +02:00
Benjamin Sutter fb029cf0bc feat(ai): AI pre-market rent recommendation from regional comparables, supply & demand
- IAIService.recommendPreMarketRent: recommended price + range, verdict (UNDERPRICED/FAIR/AMBITIOUS), drivers, rationale, confidence
- MockAIService: deterministic recommendation from locationIntelligence — regional comp median, vacancy (supply), demand strength + days-on-market, rent trend (forward for pre-market)
- BackendAIService: LLM prompt with market context + mock fallback
- usePreMarketRentRecommendation hook; PreMarketPriceAdvisor component shows the recommendation per released unit with verdict ("zu günstig" when underpriced) + adjustable expected price + "Empfehlung übernehmen"
- Replaces the simple indexed suggestion with a market-driven AI recommendation that flags underpricing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:22:14 +02:00
Benjamin Sutter 3e1e945661 fix(supply): unit edits now persist + regroup editor + expected price as pre-market KI view
- MockupUnitProvider.update: immutable update (new unit/units/property refs) so React Query detects the change and re-renders — fixes "parking/price edit doesn't take effect"
- UnitFieldsEditor: group Ausbaustandard + Wer-baut-aus + MAB into one "Ausbau" section; conditions (price/availability/parking) on top; removed the plain expected-price field
- PreMarketUnitGrid: expected price is now a KI recommendation view (indexed suggestion + rationale) shown only for released units, with an adjustable override + "Vorschlag übernehmen"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:14:27 +02:00
Benjamin Sutter 74f9660581 refactor(supply): per-unit details always visible + shared editor in unit list AND pre-market
- Extract UnitFieldsEditor (price, availability, Ausbaustandard, Wer-baut-aus, MAB, parking allocation, expected price) — single source for per-unit editing
- UnitStructurePanel: always-visible per-unit details strip (fit-out, cost-bearer, parking, expected price) in read mode; pencil opens the shared editor
- PreMarketUnitGrid: per-unit details shown + same editor reachable per unit (pencil) so fit-out/price/parking are adjustable right in the release flow
- Removes the previous edit-only / fragmented per-unit fields

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:03:50 +02:00
Benjamin Sutter fca113e7ab feat(demand): market rent estimate (asking vs. fair) + pre-market expected price
- lib/rentEstimate: deterministic market estimate from Standort-Intelligence (median rent benchmark + vacancy/trend) → fair rent, verdict BELOW/AT/ABOVE, deltaPct, rationale; suggestFutureRent indexes today's price by city rent trend
- MarketPricePanel in MatchDetail: asking vs. fair market rent + verdict badge + rationale; shows pre-market expected price (heute → erwartet)
- unit.expectedRentPerSqm: future pre-market price; scorer prefers it over current rent for FUTURE_AVAILABILITY
- UnitStructurePanel editor: expected-price field (with indexed suggestion) shown for pre-market-released units
- tests: rentEstimate verdict thresholds + future-rent indexing

Note: rent estimate is a deterministic market-data calc (not an LLM) — honest & testable; can be routed through IAIService later if a real model is wanted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:53:00 +02:00
Benjamin Sutter fd6ed105bb feat(supply): per-unit fit-out/cost-bearer/MAB/parking with object fallback (resolveUnitFacts)
- unit: add fitOutByLandlord, mieterausbaubeitragPerSqm, parkingSpots (parking = allocation from object pool)
- lib/unitFacts.resolveUnitFacts: single resolver (unit value ?? object value) used by scorer + card adapter
- matchSyncService: pre-market unit scored via resolved facts (fit-out, MAB, cost-bearer, parking allocation)
- matchCardAdapter: fit-out label/viability/investment via resolveUnitFacts
- UnitStructurePanel: consolidate ALL per-unit overrides into the one expandable row editor (price, availability, Ausbaustandard, Wer-baut-aus, MAB, parking allocation); show parking on row
- PreMarketUnitGrid/Panel: drop the duplicate per-unit fit-out select (now in the unit editor)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:44:56 +02:00
Benjamin Sutter 647493bf3c feat(supply): per-unit price/availability editor + mandatory fit-out cost-bearer for shell/edelrohbau
- unit.availableFrom: explicit per-unit availability date
- UnitStructurePanel: inline expandable editor (pencil) for per-unit price + availability via useUpdateUnit
- fitOutByLandlord now boolean|undefined ("not chosen"); toggle has no default for SHELL/BASIC
- NewListing: validate() blocks save until cost-bearer chosen for Rohbau/Edelrohbau
- PropertyDetailView.saveEdit: same guard for existing listings; required asterisk + hint in both forms
- newListingMapper persists explicit tenant(false) choice (was collapsed to undefined)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:26:45 +02:00
Benjamin Sutter 5d26dd4a6c feat(supply): unit-centric fit-out & overview — per-unit fit-out, editable demand budget, multi-unit price/availability
- domain/unit: optional per-unit fitOut override (falls back to property.hardFacts.fitOut)
- matchSyncService: pre-market unit scored with its own fitOut; matchCardAdapter prefers unit fitOut
- PreMarketUnitGrid/PreMarketPanel: per-unit Ausbaustandard override in the release list (default "Wie Objekt")
- PropertyDetailOverview: edit/read "Wer baut aus?" for existing listings (SHELL/BASIC), central FIT_OUT_LABELS
- Phase 1 unit-centric overview: header shows area+count, price as range, "Verfügbar ab → pro Einheit"; unit table shows per-unit price + availability; "Objekt & Lage" labels marked as object-defaults for multi-unit
- NeedExtendedRequirements: "Eigenes Ausbaubudget" only shown when min fit-out set + clearer helper text
- UnitStructurePanel: spin-off prefill carries fitOutByLandlord

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:06:31 +02:00
Benjamin Sutter e169f8e310 feat(matching): annuity-based fit-out cost in score + fix unapplied DQ/confidence modifiers
Mieterausbau / fit-out economics — surface true total cost of occupancy:
- Annuity calc (annuityFactor + effectiveAnnualBurdenPerSqm) replaces straight-line ÷5; FITOUT_ANNUITY_RATE=5%
- New hardFacts.fitOutByLandlord: "Wer baut aus?" toggle in NewListing — landlord-borne fit-out is priced into rent (no surcharge), tenant-borne SHELL/BASIC adds annuitized cost minus MAB
- scoreBudget now compares effective annual burden (rent + fit-out annuity) vs budget instead of cold rent only; FULL/PREMIUM and landlord-borne unchanged
- FitOutCostPanel + CompareTableBody compute annuitized, tenant-aware burden
- Central FIT_OUT_LABELS with industry/international vocabulary (Rohbau·Core&Shell, Edelrohbau·CAT A, etc.)
- Activate existing generateFitOutAdvice via useFitOutAdvice hook + new FitOutAdvicePanel (MIETERAUSBAU/BKZ/MAB-Amortisation + negotiation tip), shown for tenant-borne SHELL/BASIC
- MAB field only asked when tenant builds out (optional) — one new toggle, no extra data burden for property managers

Fix: DQ/confidence modifiers were computed but never applied to finalScore (hardcoded 0 in output) — now folded into rawFinal and exposed. Trust-first: weak data quality lowers the score. Resolves 3 pre-existing red tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:19:58 +02:00
Benjamin Sutter 7a0909e36a feat: saved profiles — per-profile notifications, real match scores, action buttons
- Add per-profile notification config (toggle, score threshold, email) with Accordion UI in SavedNeedDetail
- NotificationButton: badge only lights up for unseen matches, dismisses on open (localStorage), uses per-profile minScore
- SavedNeedDetail: replace deterministicMatchScore with real match data (useMatchesByNeed), sort by score desc, deduplicate by property
- SavedProfilesTab & SavedNeedCard: top score bar now shows best real match score instead of criteria confidence
- "Treffer" count uses per-profile notificationConfig.minScore (≥80 default) consistently across card, panel, and notification badge
- Extract PropertyMatchRow to own file with Anfrage/Merken/Vergleichen/Details→/Zur Einheit→ action buttons
- Details → navigates to /demand/results/:matchId using real match ID format (m__propId__prop__needId)
- Add InquiryQuickDialog + AddToPipelineDialog to AISearch.tsx so dialogs work from SavedProfilesTab
- Org-isolation: useNeeds filters by organizationId, org-mobimo gets 4 own search profiles
- Mock data: all 18 org-wincasa need names changed to descriptive profile names; 4 new org-mobimo needs added
- useUpdateNeed mutation hook for updating need fields (notificationConfig etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 20:53:49 +02:00
Benjamin Sutter 6aa4f96bd2 feat: responsive layout + Reminder Manager redesign
- Auto-collapse sidebar at <1536px (all laptops), expand at ≥1536px
- Responsive drawer/panel widths across Pipeline, Properties, MyListings, Anfragen, MatchDetail, AISearch
- Reminder Manager: dot+label priority badge, Fläche column removed, status only for non-active rows
- ReminderKpiBar: focal Überfällig card, three secondary KPIs
- ReminderDetailDrawer: Task Panel redesign — Finanzen removed, Notiz promoted, Pre-Market as inline badge, full create form
- useCreateReminder hook wired to reminderService.create with cache invalidation
- Mock data: contract-derived reminders (LEASE_EXPIRY, BREAK_OPTION, RENT_REVIEW, INSURANCE_RENEWAL, SCHATTENMARKT_RELEASE) now show auto-creation note in Verlauf

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:30:06 +02:00
91 changed files with 3724 additions and 1005 deletions
+48
View File
@@ -41,6 +41,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"playwright": "^1.61.0",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
@@ -4330,6 +4331,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+1
View File
@@ -47,6 +47,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"playwright": "^1.61.0",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
import { useActiveInquiries } from '../../hooks/useInquiries'
import type { InquiryFilters } from '../../provider/IInquiryProvider'
import { EmptyState } from '../ui'
import { InquiryList } from './InquiryList'
import { InquiryCardGrid } from './InquiryCardGrid'
@@ -17,8 +18,14 @@ function loadViewMode(): ViewMode {
return stored === 'grid' ? 'grid' : 'list'
}
export function ActiveInquiriesTab() {
const { data: inquiries = [], isLoading } = useActiveInquiries()
interface Props {
filters?: InquiryFilters
emptyTitle?: string
emptyDescription?: string
}
export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Props = {}) {
const { data: inquiries = [], isLoading } = useActiveInquiries(filters)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [view, setView] = useState<ViewMode>(loadViewMode())
const theme = useTheme()
@@ -41,8 +48,8 @@ export function ActiveInquiriesTab() {
return (
<EmptyState
icon={<Inbox size={40} />}
title="Keine aktiven Anfragen"
description="Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier."
title={emptyTitle ?? 'Keine aktiven Anfragen'}
description={emptyDescription ?? 'Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier.'}
/>
)
}
@@ -0,0 +1,146 @@
import { Box, Button, Checkbox, Paper, Typography } from '@mui/material'
import { Building2, MapPin } from 'lucide-react'
import type { Property } from '../../domain/property'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { assetTypeLabel } from './latentNeedUtils'
interface EmbeddedPropertyCardProps {
property: Property
matchScore: number
selected: boolean
onToggle: () => void
reason: string
onQuickOffer: () => void
disabled?: boolean
}
export function EmbeddedPropertyCard({
property,
matchScore,
selected,
onToggle,
reason,
onQuickOffer,
disabled,
}: EmbeddedPropertyCardProps) {
const tier = getScoreTier(matchScore)
const theme = SCORE_THEME[tier]
const image = property.images?.[0]
return (
<Paper
elevation={0}
sx={{
borderRadius: 2,
border: '1px solid',
borderColor: selected ? '#152642' : '#e2e8f0',
bgcolor: selected ? '#f8fafc' : 'white',
overflow: 'hidden',
transition: 'all 0.15s',
'&:hover': { borderColor: selected ? '#152642' : '#94a3b8', boxShadow: '0 2px 8px rgba(15,23,42,0.07)' },
}}
>
{/* Info row */}
<Box sx={{ display: 'flex', gap: 2, p: 2 }}>
<Box
sx={{
width: 72,
height: 72,
flexShrink: 0,
borderRadius: 1.5,
bgcolor: '#e8e7e4',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{!image && <Building2 size={24} color="#94a3b8" />}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '0.9rem', lineHeight: 1.3 }}>
{property.title}
</Typography>
<Box
sx={{
background: theme.gradient,
color: theme.text,
px: 1.25,
py: 0.375,
borderRadius: 1,
fontSize: '0.9rem',
fontWeight: 700,
flexShrink: 0,
border: `1px solid ${theme.border}`,
}}
>
{matchScore}%
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, color: '#64748b' }}>
<MapPin size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m²
</Typography>
</Box>
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: '#15803d', fontWeight: 600, display: 'block', mt: 0.5 }}>
{reason}
</Typography>
</Box>
</Box>
{/* Action row */}
<Box
sx={{
borderTop: '1px solid #f1f5f9',
px: 2,
py: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: '#fafafa',
}}
>
<Box
onClick={!disabled ? onToggle : undefined}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: disabled ? 'default' : 'pointer' }}
>
<Checkbox
checked={selected}
onChange={onToggle}
onClick={e => e.stopPropagation()}
size="small"
disabled={disabled}
sx={{ p: 0.5 }}
/>
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: '#475569', fontWeight: 500 }}>
Für Angebot auswählen
</Typography>
</Box>
<Button
size="small"
variant="outlined"
onClick={onQuickOffer}
disabled={disabled}
sx={{
textTransform: 'none',
fontSize: '0.775rem',
fontWeight: 600,
borderColor: '#152642',
color: '#152642',
py: 0.5,
px: 1.5,
minWidth: 0,
'&:hover': { bgcolor: '#152642', color: 'white', borderColor: '#152642' },
}}
>
Angebot
</Button>
</Box>
</Paper>
)
}
@@ -5,7 +5,6 @@ import { usePublicNeeds, useLatentNeedById } from '../../hooks/useLatentNeeds'
import { EmptyState } from '../ui'
import { PublicNeedList } from './PublicNeedList'
import { PublicNeedDetail } from './PublicNeedDetail'
import { OwnPropertyMatchList } from './OwnPropertyMatchList'
type MobileView = 'list' | 'detail' | 'properties'
@@ -112,7 +111,6 @@ export function LatentInquiriesTab() {
</Box>
)}
</Box>
{selectedNeed && <OwnPropertyMatchList need={selectedNeed} />}
</Box>
)
}
@@ -2,6 +2,9 @@ import { Box, Button, CircularProgress, IconButton, TextField, Typography } from
import { ArrowLeft, FileText, Paperclip, Send, X } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useSendOffer } from '../../hooks/useOffers'
import { useCreateOffer } from '../../hooks/useInquiries'
import { useLatentNeedById } from '../../hooks/useLatentNeeds'
import { useSessionStore } from '../../stores/sessionStore'
import { useToastStore } from '../../stores/toastStore'
import { AiOfferEmailButton } from './AiOfferEmailButton'
import { mockProperties } from '../../mock-data/properties'
@@ -24,6 +27,9 @@ export function OfferChatComposer() {
const reset = useOfferWizardStore(s => s.reset)
const sendOffer = useSendOffer()
const createOffer = useCreateOffer()
const { data: latentNeed } = useLatentNeedById(needId)
const currentUser = useSessionStore(s => s.currentUser)
const showToast = useToastStore(s => s.showToast)
const propertyTitles = selectedPropertyIds.map(id => {
@@ -60,6 +66,19 @@ export function OfferChatComposer() {
showToast(`Fehler: ${res.error}`, 'error')
return
}
// Angebot als Konversation im Nachfrager-Postfach materialisieren
await createOffer.mutateAsync({
organizationId: currentUser?.organizationId ?? 'org-wincasa',
tenantOrgId: latentNeed?.tenantOrgId ?? 'org-mobimo',
needId: needId ?? '',
propertyId: selectedPropertyIds[0] ?? '',
offeredPropertyIds: selectedPropertyIds,
subject,
message: body,
senderName: currentUser?.organizationName ?? 'Wincasa AG',
recipientName: latentNeed?.tenantCompany,
attachments,
})
showToast('Angebot erfolgreich gesendet', 'success')
reset()
}
@@ -1,5 +1,5 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { MapPin, Ruler } from 'lucide-react'
import { MapPin } from 'lucide-react'
import type { LatentNeed } from '../../domain/latentNeed'
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
@@ -9,6 +9,12 @@ interface PublicNeedCardProps {
onClick: () => void
}
function opportunityColor(status: 'public' | 'paused' | 'expired'): string {
if (status === 'public') return '#16a34a'
if (status === 'paused') return '#d97706'
return '#94a3b8'
}
export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps) {
const statusCfg = latentStatusBadge(need.status)
return (
@@ -36,18 +42,15 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', lineHeight: 1.3 }}>
{need.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: opportunityColor(need.status) }} />
<Chip
label={statusCfg.label}
size="small"
sx={{
bgcolor: statusCfg.bg,
color: statusCfg.fg,
fontWeight: 600,
fontSize: '0.65rem',
height: 20,
}}
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }}
/>
</Box>
</Box>
{need.tenantCompany && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
@@ -74,12 +77,6 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
{need.desiredLocation}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#475569', fontSize: '0.75rem' }}>
<Ruler size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{need.sizeRange.min}{need.sizeRange.max} m²
</Typography>
</Box>
</Paper>
)
}
+154 -158
View File
@@ -1,206 +1,159 @@
import { Alert, Box, Button, Chip, Typography } from '@mui/material'
import { CheckCircle2, Calendar, MapPin, Wallet, Info, Ruler, Sparkles } from 'lucide-react'
import { useMemo, useState } from 'react'
import {
Accordion, AccordionDetails, AccordionSummary,
Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Typography,
} from '@mui/material'
import { Calendar, CheckCircle2, ChevronDown, Info, MapPin, Ruler, Sparkles, Target, Wallet } from 'lucide-react'
import type { LatentNeed } from '../../domain/latentNeed'
import { ResultType, UserRole } from '../../domain/enums'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useSessionStore } from '../../stores/sessionStore'
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
import { UserRole } from '../../domain/enums'
import { useProperties } from '../../hooks/useProperties'
import { assetTypeLabel, deterministicMatchScore, latentStatusBadge } from './latentNeedUtils'
import { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
interface PublicNeedDetailProps {
need: LatentNeed
}
function buildReason(propType: string, needType: string, city: string, location: string): string {
if (propType === needType) {
const match = location.toLowerCase().includes(city.toLowerCase()) || city.toLowerCase().includes(location.toLowerCase())
return match ? 'Nutzungstyp und Standort passen sehr gut' : 'Passender Nutzungstyp, alternative Lage'
}
return 'Alternatives Profil — Detailprüfung empfohlen'
}
export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
const openWizard = useOfferWizardStore(s => s.open)
const setSelectedProperties = useOfferWizardStore(s => s.setSelectedProperties)
const selectedIds = useOfferWizardStore(s => s.selectedPropertyIds)
const toggleProperty = useOfferWizardStore(s => s.toggleProperty)
const currentUser = useSessionStore(s => s.currentUser)
const statusCfg = latentStatusBadge(need.status)
const [showAll, setShowAll] = useState(false)
const { data: properties = [], isLoading: propertiesLoading } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
const disabled = currentUser?.role === UserRole.OWNER_VIEWER || need.status !== 'public'
const scored = useMemo(
() =>
properties
.map(p => ({
property: p,
score: deterministicMatchScore(p.id, need.id),
reason: buildReason(p.assetType, need.assetType, p.location.city, need.desiredLocation),
}))
.sort((a, b) => b.score - a.score),
[properties, need],
)
const visible = showAll ? scored : scored.slice(0, 3)
const remaining = scored.length - 3
function handleQuickOffer(propertyId: string) {
setSelectedProperties([propertyId])
openWizard(need.id, need.title)
}
return (
<Box
sx={{
flex: 1,
overflowY: 'auto',
bgcolor: '#f8fafc',
display: 'flex',
flexDirection: 'column',
}}
>
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
{/* Scrollable content */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* Header */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Chip
label={assetTypeLabel(need.assetType)}
size="small"
sx={{
bgcolor: '#e0e7ff',
color: '#3730a3',
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
}}
/>
<Chip
label={statusCfg.label}
size="small"
sx={{
bgcolor: statusCfg.bg,
color: statusCfg.fg,
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
}}
/>
<Chip label={assetTypeLabel(need.assetType)} size="small"
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
<Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.35rem', mb: 0.5 }}>
{need.title}
</Typography>
{need.tenantCompany && (
<Typography variant="body2" sx={{ color: '#64748b' }}>
{need.tenantCompany}
</Typography>
<Typography variant="body2" sx={{ color: '#64748b' }}>{need.tenantCompany}</Typography>
)}
</Box>
{/* AI Summary */}
{/* KI-Entscheidungsbrief */}
{need.aiSummary && (
<Alert
icon={<Sparkles size={16} />}
severity="info"
sx={{
bgcolor: '#eff6ff',
borderRadius: 1.5,
border: '1px solid #bfdbfe',
'& .MuiAlert-icon': { color: '#1d4ed8' },
'& .MuiAlert-message': { color: '#1e3a8a', fontSize: '0.875rem' },
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1d4ed8', display: 'block', mb: 0.5 }}>
KI-Analyse
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2, borderColor: '#bfdbfe', bgcolor: '#eff6ff' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
<Sparkles size={16} color="#1d4ed8" />
<Typography variant="caption"
sx={{ fontWeight: 700, color: '#1d4ed8', textTransform: 'uppercase', letterSpacing: 0.8, fontSize: '0.7rem' }}>
KI-Entscheidungsbrief
</Typography>
</Box>
<Typography variant="body1" sx={{ color: '#1e3a8a', lineHeight: 1.7, fontSize: '0.9375rem' }}>
{need.aiSummary}
</Alert>
</Typography>
</Paper>
)}
{/* Suchprofil grid */}
{/* Suchprofil */}
<Box>
<Typography
variant="caption"
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Suchprofil
</Typography>
<Box
sx={{
mt: 1,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: 1.5,
}}
>
<Box sx={{ mt: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 1.5 }}>
<ProfileBox icon={<MapPin size={14} />} label="Standort" value={need.desiredLocation} />
<ProfileBox
icon={<Ruler size={14} />}
label="Fläche"
value={`${need.sizeRange.min}${need.sizeRange.max}`}
/>
<ProfileBox icon={<Ruler size={14} />} label="Fläche" value={`${need.sizeRange.min}${need.sizeRange.max}`} />
<ProfileBox
icon={<Wallet size={14} />}
label="Budget"
value={
need.budgetRange
? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}${
need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'
} /m²`
: 'Flexibel'
}
value={need.budgetRange
? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}${need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'} /m²`
: 'Flexibel'}
/>
<ProfileBox icon={<Calendar size={14} />} label="Timing" value={need.timing} />
</Box>
</Box>
{/* Must-have Kriterien */}
{/* Must-haves */}
{need.mustHaveCriteria.length > 0 && (
<Box>
<Typography
variant="caption"
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Must-have Kriterien
</Typography>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{need.mustHaveCriteria.map((c, idx) => (
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={14} color="#16a34a" />
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>
{c}
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
</Box>
))}
</Box>
</Box>
)}
{/* Gewichtete Präferenzen */}
{/* Gewichtete Präferenzen — Accordion */}
{need.weightedPreferences.length > 0 && (
<Box>
<Typography
variant="caption"
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
>
<Accordion elevation={0} disableGutters
sx={{ border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden', '&:before': { display: 'none' }, bgcolor: 'white' }}>
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Gewichtete Präferenzen
</Typography>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{need.weightedPreferences.map((p, idx) => {
const pct = Math.round(p.weight * 100)
return (
<Box
key={idx}
sx={{
bgcolor: 'white',
border: '1px solid #e2e8f0',
borderRadius: 1.5,
px: 1.5,
py: 1,
}}
>
<Box key={idx} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
{p.criterion}
</Typography>
<Typography
variant="caption"
sx={{
fontWeight: 700,
color: '#152642',
fontSize: '0.75rem',
bgcolor: '#e0e7ff',
px: 1,
py: 0.125,
borderRadius: 1,
}}
>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>{p.criterion}</Typography>
<Typography variant="caption"
sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, py: 0.125, borderRadius: 1 }}>
{pct}%
</Typography>
</Box>
<Box
sx={{
height: 6,
borderRadius: 3,
bgcolor: '#e8e7e4',
overflow: 'hidden',
}}
>
<Box
sx={{
width: `${pct}%`,
height: '100%',
bgcolor: '#152642',
transition: 'width 0.3s',
}}
/>
<Box sx={{ height: 6, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
</Box>
{p.description && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
@@ -211,57 +164,100 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
)
})}
</Box>
</Box>
</AccordionDetails>
</Accordion>
)}
{/* Top Empfehlungen */}
<Box>
<Divider sx={{ mb: 2.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Target size={14} color="#152642" />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Ihre Top Empfehlungen
</Typography>
</Box>
{propertiesLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress size={20} /></Box>
) : scored.length === 0 ? (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.8rem' }}>
Keine Portfolio-Objekte vorhanden
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{visible.map(({ property, score, reason }) => (
<EmbeddedPropertyCard
key={property.id}
property={property}
matchScore={score}
selected={selectedIds.includes(property.id)}
onToggle={() => toggleProperty(property.id)}
reason={reason}
onQuickOffer={() => handleQuickOffer(property.id)}
disabled={disabled}
/>
))}
{remaining > 0 && (
<Box
onClick={() => setShowAll(s => !s)}
sx={{
py: 1.25,
textAlign: 'center',
cursor: 'pointer',
color: '#64748b',
fontSize: '0.8rem',
fontWeight: 500,
border: '1px dashed #e2e8f0',
borderRadius: 1.5,
'&:hover': { color: '#152642', borderColor: '#152642', bgcolor: '#f8fafc' },
transition: 'all 0.15s',
}}
>
{showAll ? '▲ Weniger anzeigen' : `▼ Alle ${scored.length} Objekte anzeigen (+${remaining} weitere)`}
</Box>
)}
</Box>
)}
</Box>
</Box>
{/* Sticky footer CTA */}
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', px: 3, py: 2, bgcolor: 'white' }}>
{disabled && need.status !== 'public' && (
<Alert severity="warning" icon={<Info size={16} />} sx={{ fontSize: '0.8125rem' }}>
<Alert severity="warning" icon={<Info size={16} />} sx={{ fontSize: '0.8125rem', mb: 1.5 }}>
Dieser Bedarf ist aktuell {statusCfg.label.toLowerCase()} kein Angebot möglich.
</Alert>
)}
<Box sx={{ mt: 'auto', pt: 1 }}>
{selectedIds.length > 0 && !disabled && (
<Typography variant="caption" sx={{ color: '#475569', display: 'block', mb: 1, textAlign: 'center' }}>
{selectedIds.length} Objekt{selectedIds.length !== 1 ? 'e' : ''} ausgewählt
</Typography>
)}
<Button
variant="contained"
size="large"
fullWidth
onClick={() => openWizard(need.id, need.title)}
disabled={disabled}
sx={{
textTransform: 'none',
bgcolor: '#152642',
fontWeight: 600,
'&:hover': { bgcolor: '#16304d' },
}}
sx={{ textTransform: 'none', bgcolor: '#152642', fontWeight: 600, '&:hover': { bgcolor: '#16304d' } }}
>
Angebot erstellen
</Button>
</Box>
</Box>
</Box>
)
}
function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Box
sx={{
bgcolor: 'white',
border: '1px solid #e2e8f0',
borderRadius: 1.5,
px: 1.5,
py: 1,
}}
>
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
{icon}
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{label}
</Typography>
</Box>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>
{value}
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
</Box>
)
}
@@ -16,8 +16,8 @@ export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNe
return (
<Box
sx={{
width: fullWidth ? '100%' : 280,
minWidth: fullWidth ? 'unset' : 280,
width: fullWidth ? '100%' : 260,
minWidth: fullWidth ? 'unset' : 260,
flexShrink: 0,
borderRight: fullWidth ? 'none' : '1px solid #e2e8f0',
bgcolor: 'white',
+1
View File
@@ -15,6 +15,7 @@ export { PublicNeedCard } from './PublicNeedCard'
export { PublicNeedDetail } from './PublicNeedDetail'
export { OwnPropertyMatchList } from './OwnPropertyMatchList'
export { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
export { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
export { OfferCreationPanel } from './OfferCreationPanel'
export { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
export { OfferPdfReviewStep } from './OfferPdfReviewStep'
+11 -24
View File
@@ -20,8 +20,8 @@ import {
import { CompareCell, MissingDataCell } from './index'
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
// ── Local helper ──────────────────────────────────────────────────────────────
@@ -155,11 +155,7 @@ export function CompareTableBody({
const prop = getProp(item)
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Grundausbau', FULL: 'Vollausbau', PREMIUM: 'Premium-Ausbau',
}
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
const fitOutByLandlord = prop.hardFacts?.fitOutByLandlord
const monthlyRent = prop.totalRentMonthly
?? Math.round(prop.rentPricePerSqm * prop.areaSqm / 12)
// ancillaryCosts stored as CHF/m²/Monat
@@ -170,21 +166,12 @@ export function CompareTableBody({
const fitOutLabel = fitOut ? (FIT_OUT_LABELS[fitOut] ?? fitOut) : null
const mabPerSqm = prop.hardFacts?.mieterausbaubeitragPerSqm ?? 0
// Amortised fit-out monthly cost (mid-range estimate)
let fitOutMonthly = 0
let fitOutMonthlyLabel: string | null = null
if (fitOut && !READY_TO_MOVE_IN.has(fitOut)) {
const inv = calcFitOutInvestment(fitOut, prop.areaSqm, mabPerSqm, 0)
if (inv && !inv.isFullyCovered) {
const months = FITOUT_AMORTIZATION_YEARS * 12
const midMin = Math.round(inv.netTotal.min / months)
const midMax = Math.round(inv.netTotal.max / months)
fitOutMonthly = Math.round((midMin + midMax) / 2)
fitOutMonthlyLabel = midMin === midMax
? `${midMin.toLocaleString('de-CH')}`
: `${midMin.toLocaleString('de-CH')}${midMax.toLocaleString('de-CH')}`
}
}
// Annuitätischer Ausbau-Aufschlag pro Monat (= 0 bei Vermieter-Übernahme / bezugsfertig)
const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({
fitOut, rentPricePerSqm: prop.rentPricePerSqm, mabPerSqm, fitOutByLandlord,
})
const fitOutMonthly = Math.round(fitOutPerSqm * prop.areaSqm / 12)
const fitOutMonthlyLabel = fitOutMonthly > 0 ? fitOutMonthly.toLocaleString('de-CH') : null
const totalMonthly = monthlyRent
+ (monthlyNebenkosten ?? 0)
@@ -205,7 +192,7 @@ export function CompareTableBody({
)}
{fitOutMonthlyLabel && (
<Typography variant="caption" color="text.secondary">
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau ÷ {FITOUT_AMORTIZATION_YEARS} J.)
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau annuit. {FITOUT_AMORTIZATION_YEARS} J. / {Math.round(FITOUT_ANNUITY_RATE * 100)}%)
</Typography>
)}
<Typography variant="body2" sx={{ fontWeight: 700, color: '#152642', borderTop: '1px solid #e2e8f0', pt: 0.5, mt: 0.25 }}>
@@ -213,7 +200,7 @@ export function CompareTableBody({
</Typography>
{fitOutLabel && (
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.25 }}>
Ausbau: {fitOutLabel}{READY_TO_MOVE_IN.has(fitOut ?? '') ? ' (bezugsfertig)' : ''}
Ausbau: {fitOutLabel}{fitOutByLandlord ? ' (im Mietzins)' : fitOutMonthly === 0 ? ' (bezugsfertig)' : ''}
</Typography>
)}
</Box>
@@ -73,6 +73,7 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
/>
</Box>
{c.requiredFitOut && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Eigenes Ausbaubudget (max. CHF/m²)</Typography>
<TextField
@@ -80,9 +81,10 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
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"
helperText="Überbrückt Flächen unter Ihrem Mindest-Ausbaustandard — Sie tragen die Differenz selbst (exkl. MAB des Vermieters)."
/>
</Box>
)}
</Box>
{/* Divisibility */}
+160
View File
@@ -0,0 +1,160 @@
import { memo } from 'react'
import { Box, Button, Typography } from '@mui/material'
import { Ruler } from 'lucide-react'
import { useNavigate } from 'react-router'
import { ROUTES } from '../../lib/constants'
import type { Property } from '../../domain/property'
import type { VerifiedPortfolioResult } from '../../domain/unifiedResult'
import type { Match } from '../../domain/match'
import { MatchStrength, ResultType } from '../../domain/enums'
import { useInquiryStore } from '../../stores/inquiryStore'
import { usePipelineStore } from '../../stores/pipelineStore'
import { useCompareStore } from '../../stores/compareStore'
interface Props {
property: Property
score: number
needId: string
}
function buildSyntheticResult(property: Property, needId: string, score: number, matchId: string): VerifiedPortfolioResult {
const strength = score >= 85 ? MatchStrength.STRONG : score >= 70 ? MatchStrength.MODERATE : MatchStrength.WEAK
const syntheticMatch = {
id: matchId,
needId,
propertyId: property.id,
matchScore: score,
matchStrength: strength,
scoreBreakdown: { hardMatchScore: score, softFactorScore: score, confidenceModifier: 0, dataQualityModifier: 0, totalScore: score },
confidenceLevel: score / 100,
} as unknown as Match
return {
matchId,
needId,
matchScore: score,
resultType: ResultType.VERIFIED_PORTFOLIO,
property,
match: syntheticMatch,
}
}
export const PropertyMatchRow = memo(function PropertyMatchRow({ property, score, needId }: Props) {
const navigate = useNavigate()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const openSavedDialog = usePipelineStore(s => s.openSavedDialog)
const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore()
const scoreColor = score >= 85 ? '#16a34a' : score >= 70 ? '#d97706' : '#dc2626'
const scoreBg = score >= 85 ? '#dcfce7' : score >= 70 ? '#fef3c7' : '#fee2e2'
// Match IDs in the store use double-underscore format: m__propId__prop__needId
const matchId = `m__${property.id}__prop__${needId}`
const inCompare = isInCompare(matchId)
function handleInquire() {
openInquiryDialog({
propertyTitle: property.title,
location: property.location.city,
matchScore: score,
matchId,
propertyId: property.id,
areaLabel: property.areaSqm ? `${property.areaSqm}` : undefined,
rentLabel: property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
})
}
function handleShortlist() {
openSavedDialog({
resultId: matchId,
resultType: 'VERIFIED_PORTFOLIO',
title: property.title,
matchScore: score,
location: property.location.city,
propertyId: property.id,
propertyAddress: `${property.title}, ${property.location.city}`,
areaLabel: property.areaSqm ? `${property.areaSqm}` : undefined,
rentLabel: property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
})
}
function handleCompare() {
if (inCompare) {
removeFromCompare(matchId)
} else if (!isFull()) {
addToCompare(buildSyntheticResult(property, needId, score, matchId))
}
}
function handleDetails() {
navigate(`/demand/results/${matchId}`)
}
function handleUnit() {
navigate(`/demand/property/${property.id}`)
}
return (
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, p: 1.25 }}>
{property.images?.[0] ? (
<Box component="img" src={property.images[0]} alt=""
sx={{ width: 52, height: 52, borderRadius: 1, objectFit: 'cover', flexShrink: 0 }} />
) : (
<Box sx={{ width: 52, height: 52, borderRadius: 1, bgcolor: '#e0e7ff', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Ruler size={18} color="#3730a3" />
</Box>
)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.825rem', color: '#0f172a' }} noWrap>
{property.title}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
{property.location.city} · {property.areaSqm} m²
</Typography>
</Box>
<Box sx={{ bgcolor: scoreBg, color: scoreColor, px: 1, py: 0.375, borderRadius: 1, fontWeight: 700, fontSize: '0.8rem', flexShrink: 0 }}>
{score}%
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.75, px: 1.25, pb: 1.25, flexWrap: 'wrap' }}>
<ActionButton onClick={handleInquire} primary>Anfrage</ActionButton>
<ActionButton onClick={handleShortlist}>Merken</ActionButton>
<ActionButton onClick={handleCompare} disabled={!inCompare && isFull()}>
{inCompare ? 'Im Vergleich' : 'Vergleichen'}
</ActionButton>
<ActionButton onClick={handleDetails}>Details </ActionButton>
<ActionButton onClick={handleUnit}>Zur Einheit </ActionButton>
</Box>
</Box>
)
})
function ActionButton({ children, onClick, primary, disabled }: {
children: React.ReactNode
onClick: () => void
primary?: boolean
disabled?: boolean
}) {
return (
<Button
size="small"
variant={primary ? 'contained' : 'outlined'}
onClick={onClick}
disabled={disabled}
sx={{
textTransform: 'none',
fontWeight: 600,
fontSize: '0.75rem',
py: 0.25,
px: 1,
minWidth: 0,
...(primary
? { bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }
: { borderColor: '#cbd5e1', color: '#475569', '&:hover': { borderColor: '#152642', color: '#152642' } }),
}}
>
{children}
</Button>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { memo } from 'react'
import { Box, Chip, Paper, Typography } from '@mui/material'
import { MapPin } from 'lucide-react'
import type { Need } from '../../domain/need'
import { ASSET_TYPE_LABELS } from '../../lib/constants'
interface Props {
need: Need
selected: boolean
matchCount: number
topScore: number
onClick: () => void
}
function statusConfig(status: Need['status']): { label: string; bg: string; fg: string } {
if (status === 'DRAFT') return { label: 'Entwurf', bg: '#fef3c7', fg: '#92400e' }
return { label: 'Aktiv', bg: '#dcfce7', fg: '#166534' }
}
export const SavedNeedCard = memo(function SavedNeedCard({ need, selected, matchCount, topScore, onClick }: Props) {
const statusCfg = statusConfig(need.status)
const locationText = need.preferredLocations.slice(0, 2).join(', ')
const scoreColor = topScore >= 80 ? '#16a34a' : topScore >= 65 ? '#d97706' : '#dc2626'
return (
<Paper
onClick={onClick}
elevation={0}
sx={{
p: 1.5,
borderRadius: 1.5,
border: '1px solid',
borderColor: selected ? '#152642' : '#e2e8f0',
bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': { borderColor: selected ? '#152642' : '#94a3b8', boxShadow: '0 2px 6px rgba(15,23,42,0.06)' },
display: 'flex',
flexDirection: 'column',
gap: 0.75,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small"
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
<Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
</Box>
{matchCount > 0 && (
<Box sx={{ bgcolor: '#dcfce7', color: '#15803d', borderRadius: 1, px: 0.75, py: 0.125, fontSize: '0.68rem', fontWeight: 700, flexShrink: 0 }}>
{matchCount} Treffer
</Box>
)}
</Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', color: '#0f172a', lineHeight: 1.3 }}>
{need.companyName}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<MapPin size={11} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>{locationText || '—'}</Typography>
</Box>
{topScore > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#f1f5f9', overflow: 'hidden' }}>
<Box sx={{ width: `${topScore}%`, height: '100%', bgcolor: scoreColor }} />
</Box>
<Typography variant="caption" sx={{ fontSize: '0.72rem', fontWeight: 700, color: scoreColor, flexShrink: 0 }}>
{topScore}%
</Typography>
</Box>
)}
</Paper>
)
})
+326
View File
@@ -0,0 +1,326 @@
import { memo, useMemo, useState } from 'react'
import {
Accordion, AccordionDetails, AccordionSummary,
Box, Button, Chip, Divider, LinearProgress, Slider, Switch, TextField, Typography,
} from '@mui/material'
import { Bell, Calendar, CheckCircle2, ChevronDown, MapPin, Ruler, Target, Wallet } from 'lucide-react'
import type { Need, NotificationConfig, UpdateNeedInput } from '../../domain/need'
import type { Property } from '../../domain/property'
import { ASSET_TYPE_LABELS } from '../../lib/constants'
import { confidenceHex } from '../../lib/utils'
import { useMatchesByNeed } from '../../hooks/useMatches'
import { PropertyMatchRow } from './PropertyMatchRow'
const WEIGHT_LABELS: Record<string, string> = {
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Timing',
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansionspotenzial',
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Publikumsverkehr',
talentAccess: 'Talentzugang', esg: 'ESG', taxEnvironment: 'Steuerumgebung',
}
function statusConfig(status: Need['status']): { label: string; bg: string; fg: string } {
if (status === 'DRAFT') return { label: 'Entwurf', bg: '#fef3c7', fg: '#92400e' }
return { label: 'Aktiv', bg: '#dcfce7', fg: '#166534' }
}
interface Props {
need: Need
properties: Property[]
onStart: () => void
onEdit: () => void
onArchive: () => void
onUpdate: (data: UpdateNeedInput) => void
}
export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, onStart, onEdit, onArchive, onUpdate }: Props) {
const statusCfg = statusConfig(need.status)
const conf = need.confidenceInCriteria ?? 0
const confColor = confidenceHex(conf)
const [notif, setNotif] = useState<NotificationConfig>(() => need.notificationConfig ?? { enabled: true, minScore: 80 })
const [notifDirty, setNotifDirty] = useState(false)
function handleNotifChange(patch: Partial<NotificationConfig>) {
setNotif(prev => ({ ...prev, ...patch }))
setNotifDirty(true)
}
function handleNotifSave() {
onUpdate({ notificationConfig: notif })
setNotifDirty(false)
}
const { data: matches = [] } = useMatchesByNeed(need.id)
const notifThreshold = need.notificationConfig?.minScore ?? 80
const matchCount = matches.filter(m => m.matchScore >= notifThreshold).length
const scoredProperties = useMemo(() => {
const seen = new Set<string>()
return matches
.map(m => {
const property = properties.find(p => p.id === m.propertyId)
return property ? { property, score: Math.round(m.matchScore) } : null
})
.filter((x): x is { property: Property; score: number } => x !== null)
.sort((a, b) => b.score - a.score)
.filter(x => {
if (seen.has(x.property.id)) return false
seen.add(x.property.id)
return true
})
}, [matches, properties])
const topScore = scoredProperties.length > 0 ? scoredProperties[0].score : 0
const topProperties = scoredProperties.slice(0, 3)
const topWeights = useMemo(
() => Object.entries(need.weightingProfile).filter(([, v]) => v > 0.03).sort(([, a], [, b]) => b - a).slice(0, 6),
[need.weightingProfile],
)
return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small"
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
<Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.25rem' }}>
{need.companyName}
</Typography>
{need.contactName && (
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.25 }}>{need.contactName}</Typography>
)}
</Box>
<Box>
<SectionLabel>Suchkriterien</SectionLabel>
<Box sx={{ mt: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 1.25 }}>
<CriteriaBox icon={<MapPin size={13} />} label="Standort" value={need.preferredLocations.join(', ') || '—'} />
<CriteriaBox icon={<Ruler size={13} />} label="Fläche" value={`${need.requiredArea.min}${need.requiredArea.max}`} />
<CriteriaBox icon={<Wallet size={13} />} label="Budget" value={`CHF ${need.budgetRange.maxPerSqm} /m²`} />
<CriteriaBox icon={<Calendar size={13} />} label="Einzug ab" value={need.timing.earliestMoveIn || '—'} />
</Box>
</Box>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<SectionLabel>Konfidenz der Kriterien</SectionLabel>
<Typography variant="caption" sx={{ fontWeight: 700, color: confColor, fontSize: '0.8rem' }}>
{Math.round(conf * 100)}%
</Typography>
</Box>
<LinearProgress variant="determinate" value={conf * 100} sx={{
height: 6, borderRadius: 3, bgcolor: '#f1f5f9',
'& .MuiLinearProgress-bar': { bgcolor: confColor, borderRadius: 3 },
}} />
</Box>
{(need.mustCriteriaText?.length ?? 0) > 0 && (
<Box>
<SectionLabel>Must-have Kriterien</SectionLabel>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{need.mustCriteriaText!.map((c, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={14} color="#16a34a" />
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
</Box>
))}
</Box>
</Box>
)}
{topWeights.length > 0 && (
<Accordion elevation={0} disableGutters sx={{
border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden',
'&:before': { display: 'none' }, bgcolor: 'white',
}}>
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Gewichtete Präferenzen
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{topWeights.map(([key, val]) => {
const pct = Math.round(val * 100)
return (
<Box key={key} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
{WEIGHT_LABELS[key] ?? key}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, borderRadius: 1 }}>
{pct}%
</Typography>
</Box>
<Box sx={{ height: 5, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
</Box>
</Box>
)
})}
</Box>
</AccordionDetails>
</Accordion>
)}
<Accordion elevation={0} disableGutters sx={{
border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden',
'&:before': { display: 'none' }, bgcolor: 'white',
}}>
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Bell size={13} color={notif.enabled ? '#152642' : '#94a3b8'} />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Benachrichtigungen
</Typography>
{notif.enabled && (
<Box sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontSize: '0.65rem', fontWeight: 700, px: 0.75, borderRadius: 0.75 }}>
ab {notif.minScore}%
</Box>
)}
</Box>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Benachrichtigung aktiv</Typography>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
Neue Treffer oberhalb der Schwelle melden
</Typography>
</Box>
<Switch
size="small"
checked={notif.enabled}
onChange={e => handleNotifChange({ enabled: e.target.checked })}
sx={{ '& .MuiSwitch-thumb': { bgcolor: notif.enabled ? '#152642' : undefined } }}
/>
</Box>
{notif.enabled && (
<>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Match-Schwelle</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', fontSize: '0.8rem', bgcolor: '#e0e7ff', px: 0.75, borderRadius: 0.75 }}>
{notif.minScore}%
</Typography>
</Box>
<Slider
value={notif.minScore}
min={60} max={95} step={5}
onChange={(_, v) => handleNotifChange({ minScore: v as number })}
sx={{ color: '#152642', '& .MuiSlider-thumb': { width: 14, height: 14 } }}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>60%</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>95%</Typography>
</Box>
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', mb: 0.5 }}>
E-Mail (optional)
</Typography>
<TextField
size="small"
fullWidth
placeholder="name@firma.ch"
value={notif.emailAddress ?? ''}
onChange={e => handleNotifChange({ emailAddress: e.target.value || undefined })}
inputProps={{ type: 'email' }}
sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.85rem' } }}
helperText="Im Produktivsystem wird bei neuen Treffern eine E-Mail ausgelöst"
/>
</Box>
</>
)}
{notifDirty && (
<Button
variant="contained"
size="small"
onClick={handleNotifSave}
sx={{ textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' }, alignSelf: 'flex-end' }}
>
Speichern
</Button>
)}
</Box>
</AccordionDetails>
</Accordion>
{scoredProperties.length > 0 && (
<>
<Divider />
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
<Target size={14} color="#15803d" />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Passende Objekte
</Typography>
<Box sx={{ ml: 'auto', bgcolor: '#dcfce7', color: '#15803d', borderRadius: 1, px: 1, py: 0.125, fontWeight: 700, fontSize: '0.72rem' }}>
{matchCount} Treffer
</Box>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{topProperties.map(({ property, score }) => (
<PropertyMatchRow key={property.id} property={property} score={score} needId={need.id} />
))}
</Box>
{matchCount > 3 && (
<Box
onClick={onStart}
sx={{ mt: 1.25, textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600,
py: 1, border: '1px dashed #cbd5e1', borderRadius: 1.5, '&:hover': { bgcolor: '#f1f5f9', borderColor: '#152642' }, transition: 'all 0.15s' }}
>
Alle {matchCount} Ergebnisse anzeigen
</Box>
)}
</Box>
</>
)}
</Box>
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', px: 3, py: 2, bgcolor: 'white', display: 'flex', gap: 1.5 }}>
<Button variant="outlined" size="large" onClick={onEdit}
sx={{ flex: 1, textTransform: 'none', fontWeight: 600, borderColor: '#cbd5e1', color: '#475569' }}>
Bearbeiten
</Button>
<Button variant="contained" size="large" onClick={onStart}
sx={{ flex: 2, textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>
Suche starten
</Button>
</Box>
</Box>
)
})
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{children}
</Typography>
)
}
function CriteriaBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
{icon}
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{label}
</Typography>
</Box>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
</Box>
)
}
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useMemo, useState } from 'react'
import { Box, Button, CircularProgress, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, Plus, Search } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useNeedProfiles, useArchiveNeed, useUpdateNeed } from '../../hooks/useNeeds'
import { useProperties } from '../../hooks/useProperties'
import { useMatches } from '../../hooks/useMatches'
import { ResultType } from '../../domain/enums'
import { ROUTES } from '../../lib/constants'
import { EmptyState } from '../ui'
import { SavedNeedCard } from './SavedNeedCard'
import { SavedNeedDetail } from './SavedNeedDetail'
import { needToParsedCriteria } from '../../services/aiSearch/needSearchMapper'
interface Props {
onNewSearch: (prefillNeedId?: string) => void
}
export function SavedProfilesTab({ onNewSearch }: Props) {
const navigate = useNavigate()
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const { data: needs = [], isLoading } = useNeedProfiles()
const { data: properties = [] } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
const { data: allMatches = [] } = useMatches()
const archiveMutation = useArchiveNeed()
const updateMutation = useUpdateNeed()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [mobileView, setMobileView] = useState<'list' | 'detail'>('list')
const visible = useMemo(
() =>
needs
.filter(n => !n.status || n.status === 'ACTIVE' || n.status === 'DRAFT')
.sort((a, b) => {
const aActive = !a.status || a.status === 'ACTIVE'
const bActive = !b.status || b.status === 'ACTIVE'
if (aActive !== bActive) return aActive ? -1 : 1
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
}),
[needs],
)
const { matchCounts, topScores } = useMemo(() => {
const counts: Record<string, number> = {}
const tops: Record<string, number> = {}
for (const n of visible) {
const needMatches = allMatches.filter(m => m.needId === n.id)
const threshold = n.notificationConfig?.minScore ?? 80
counts[n.id] = needMatches.filter(m => m.matchScore >= threshold).length
tops[n.id] = needMatches.length > 0 ? Math.round(Math.max(...needMatches.map(m => m.matchScore))) : 0
}
return { matchCounts: counts, topScores: tops }
}, [visible, allMatches])
useEffect(() => {
if (!isMobile && !selectedId && visible.length > 0) setSelectedId(visible[0].id)
}, [visible, selectedId, isMobile])
const selectedNeed = visible.find(n => n.id === selectedId) ?? null
function handleSelect(id: string) {
setSelectedId(id)
if (isMobile) setMobileView('detail')
}
function handleStart(needId: string) {
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
}
function handleEdit(needId: string) {
onNewSearch(needId)
}
if (isLoading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', pt: 8 }}><CircularProgress size={24} /></Box>
}
if (visible.length === 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pt: 8, gap: 2 }}>
<EmptyState title="Keine Suchprofile" description="Sie haben noch keine Suchprofile gespeichert." />
<Button variant="outlined" startIcon={<Plus size={16} />} onClick={() => onNewSearch()} sx={{ textTransform: 'none', mt: 1 }}>
Neue Suche erstellen
</Button>
</Box>
)
}
const list = (
<Box sx={{ width: 280, minWidth: 280, flexShrink: 0, borderRight: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<Search size={14} color="#7c3aed" />
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>Suchprofile</Typography>
<Box sx={{ ml: 'auto', px: 1, py: 0.125, borderRadius: 1, bgcolor: '#152642', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
{visible.length}
</Box>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', p: 1.25, display: 'flex', flexDirection: 'column', gap: 1 }}>
{visible.map(n => (
<SavedNeedCard
key={n.id}
need={n}
selected={n.id === selectedId}
matchCount={matchCounts[n.id] ?? 0}
topScore={topScores[n.id] ?? 0}
onClick={() => handleSelect(n.id)}
/>
))}
</Box>
<Box sx={{ flexShrink: 0, p: 1.5, borderTop: '1px solid #e2e8f0' }}>
<Button fullWidth variant="outlined" size="small" startIcon={<Plus size={14} />} onClick={() => onNewSearch()}
sx={{ textTransform: 'none', fontSize: '0.8rem' }}>
Neue Suche
</Button>
</Box>
</Box>
)
if (isMobile) {
if (mobileView === 'detail' && selectedNeed) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton size="small" onClick={() => setMobileView('list')}><ArrowLeft size={18} /></IconButton>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }} noWrap>{selectedNeed.companyName}</Typography>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<SavedNeedDetail
need={selectedNeed}
properties={properties}
onStart={() => handleStart(selectedNeed.id)}
onEdit={() => handleEdit(selectedNeed.id)}
onArchive={() => archiveMutation.mutate(selectedNeed.id)}
onUpdate={(data) => updateMutation.mutate({ id: selectedNeed.id, ...data })}
/>
</Box>
</Box>
)
}
return <Box sx={{ height: '100%', overflow: 'hidden' }}>{list}</Box>
}
return (
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
{list}
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{selectedNeed ? (
<SavedNeedDetail
need={selectedNeed}
properties={properties}
onStart={() => handleStart(selectedNeed.id)}
onEdit={() => handleEdit(selectedNeed.id)}
onArchive={() => archiveMutation.mutate(selectedNeed.id)}
onUpdate={(data) => updateMutation.mutate({ id: selectedNeed.id, ...data })}
/>
) : (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<EmptyState icon={<Search size={40} />} title="Profil auswählen" description="Wählen Sie ein Suchprofil, um Details zu sehen." />
</Box>
)}
</Box>
</Box>
)
}
+4
View File
@@ -9,3 +9,7 @@ export { NeedCardPreview } from './NeedCardPreview'
export { NeedInput } from './NeedInput'
export { VoiceNeedInput } from './VoiceNeedInput'
export { WeightingEditor } from './WeightingEditor'
export { PropertyMatchRow } from './PropertyMatchRow'
export { SavedNeedCard } from './SavedNeedCard'
export { SavedNeedDetail } from './SavedNeedDetail'
export { SavedProfilesTab } from './SavedProfilesTab'
+6 -1
View File
@@ -21,16 +21,21 @@ export function AppShell() {
const sidebarCollapsed = useLayoutStore(s => s.sidebarCollapsed)
const setActiveWorkspace = useLayoutStore(s => s.setActiveWorkspace)
const toggleSidebar = useLayoutStore(s => s.toggleSidebar)
const setSidebarCollapsed = useLayoutStore(s => s.setSidebarCollapsed)
const { currentUser } = useSessionStore()
const navigate = useNavigate()
const location = useLocation()
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
const isCompact = useMediaQuery(theme.breakpoints.down('lg'))
const [mobileOpen, setMobileOpen] = useState(false)
// Close mobile menu on route change
useEffect(() => { setMobileOpen(false) }, [location.pathname])
// Auto-collapse sidebar only on smaller screens (< 1200px); laptops keep the full nav + account
useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed])
// Sync active workspace with URL
useEffect(() => {
const detected = getWorkspaceFromPath(location.pathname)
+99 -9
View File
@@ -1,22 +1,74 @@
import { useState } from 'react'
import { Badge, IconButton, Popover, Typography } from '@mui/material'
import { useMemo, useState } from 'react'
import { Badge, Box, Divider, IconButton, Popover, Typography } from '@mui/material'
import { Bell } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useNeedProfiles } from '../../hooks/useNeeds'
import { useMatches } from '../../hooks/useMatches'
import { ROUTES } from '../../lib/constants'
function loadSeenCounts(): Record<string, number> {
try { return JSON.parse(localStorage.getItem('notif-seen-counts') ?? '{}') }
catch { return {} }
}
function saveSeenCounts(counts: Record<string, number>) {
localStorage.setItem('notif-seen-counts', JSON.stringify(counts))
}
export function NotificationButton() {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const [seenCounts, setSeenCounts] = useState<Record<string, number>>(loadSeenCounts)
const navigate = useNavigate()
const { data: needs = [] } = useNeedProfiles()
const { data: allMatches = [] } = useMatches()
const profilesWithMatches = useMemo(() => {
const active = needs.filter(n =>
(!n.status || n.status === 'ACTIVE' || n.status === 'DRAFT') &&
(n.notificationConfig?.enabled !== false),
)
return active
.map(n => {
const minScore = n.notificationConfig?.minScore ?? 80
return {
need: n,
count: allMatches.filter(m => m.needId === n.id && m.matchScore >= minScore).length,
minScore,
}
})
.filter(x => x.count > 0)
}, [needs, allMatches])
// Badge only lights up for profiles with matches the user hasn't seen yet
const newProfiles = useMemo(
() => profilesWithMatches.filter(x => x.count > (seenCounts[x.need.id] ?? 0)),
[profilesWithMatches, seenCounts],
)
const badgeCount = newProfiles.length
function handleOpen(e: React.MouseEvent<HTMLElement>) {
setAnchorEl(e.currentTarget)
// Mark all current matches as seen
const updated = { ...seenCounts }
for (const { need, count } of profilesWithMatches) {
updated[need.id] = count
}
setSeenCounts(updated)
saveSeenCounts(updated)
}
function handleClose() {
setAnchorEl(null)
function handleClose() { setAnchorEl(null) }
function goToResults(needId: string) {
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
handleClose()
}
return (
<>
<IconButton size="small" sx={{ color: '#64748b' }} onClick={handleOpen}>
<Badge badgeContent={0} color="error">
<Badge badgeContent={badgeCount || null} color="error">
<Bell size={20} />
</Badge>
</IconButton>
@@ -27,12 +79,50 @@ export function NotificationButton() {
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{ paper: { sx: { width: 280, p: 2 } } }}
slotProps={{ paper: { sx: { width: 300, p: 2 } } }}
>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Benachrichtigungen</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Keine neuen Benachrichtigungen
<Typography variant="subtitle2" sx={{ mb: 1.25, fontWeight: 700, color: '#0f172a' }}>
Neue Treffer
</Typography>
{profilesWithMatches.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Keine aktiven Benachrichtigungen
</Typography>
) : (
<>
{profilesWithMatches.map(({ need, count, minScore }) => (
<Box
key={need.id}
onClick={() => goToResults(need.id)}
sx={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
px: 1, py: 0.875, borderRadius: 1, cursor: 'pointer',
'&:hover': { bgcolor: '#f8fafc' }, transition: 'background 0.1s',
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ fontSize: '0.825rem', color: '#1e293b' }} noWrap>
{need.companyName}
</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>
ab {minScore}%
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#15803d', fontWeight: 700, flexShrink: 0, ml: 1.5, fontSize: '0.78rem' }}>
{count} Treffer
</Typography>
</Box>
))}
<Divider sx={{ my: 1.25 }} />
<Box
onClick={() => { navigate(ROUTES.DEMAND.AI_SEARCH); handleClose() }}
sx={{ textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600, py: 0.25, '&:hover': { color: '#16304d' } }}
>
Alle Suchprofile anzeigen
</Box>
</>
)}
</Popover>
</>
)
+2 -2
View File
@@ -32,8 +32,8 @@ export function RightContextPanel() {
right: 0,
top: 56,
height: 'calc(100vh - 56px)',
width: 320,
transform: isRightPanelOpen ? 'translateX(0)' : 'translateX(320px)',
width: { xs: '85vw', sm: 300, lg: 320 },
transform: isRightPanelOpen ? 'translateX(0)' : 'translateX(110%)',
transition: 'transform 0.25s ease',
bgcolor: '#fff',
borderLeft: '1px solid #e2e8f0',
@@ -16,11 +16,9 @@ interface Props {
lat?: number
lng?: number
cityLabel?: string
overlayTypeLabel?: string
overlayLocationLabel?: string
}
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overlayTypeLabel, overlayLocationLabel }: Props) {
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) {
const tier = getScoreTier(vm.matchScore)
const theme = SCORE_THEME[tier]
const { currentUser } = useSessionStore()
@@ -59,7 +57,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
{/* ── Hero zone ── */}
<Box sx={{ position: 'relative' }}>
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={200} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} aspectRatio="15/8" />
{/* Score badge — bottom-left editorial */}
<Box sx={{
@@ -0,0 +1,82 @@
import { Box, Chip, CircularProgress, Paper, Typography } from '@mui/material'
import { Lightbulb, MessageSquareQuote } from 'lucide-react'
import { useFitOutAdvice } from '../../hooks/useAI'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
const RECOMMENDATION_META: Record<string, { label: string; bg: string; border: string; fg: string }> = {
MIETERAUSBAU: { label: 'Mieterausbau', bg: DS_SURFACE.blue.bg, border: DS_SURFACE.blue.border, fg: DS_TEXT.signalDark },
BKZ: { label: 'Baukostenzuschuss (BKZ)', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
MAB_AMORTISATION: { label: 'MAB-Amortisation', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
}
interface Props {
fitOut: string
areaSqm: number
mabPerSqm: number
rentPricePerSqm: number
requiredFitOut?: string
tenantBudgetPerSqm?: number
}
export function FitOutAdvicePanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, requiredFitOut, tenantBudgetPerSqm }: Props) {
const { data, isLoading, isError } = useFitOutAdvice({
fitOut,
areaSqm,
mabPerSqm,
requiredFitOut,
tenantBudgetPerSqm,
monthlyRentPerSqm: Math.round(rentPricePerSqm / 12),
})
// Fallback: KI-Ausfall blockiert nie — Panel wird einfach nicht gezeigt
if (isError) return null
if (isLoading || !data) {
return (
<Paper sx={{ p: 2.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<CircularProgress size={16} />
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}>KI-Ausbauempfehlung wird erstellt</Typography>
</Paper>
)
}
const advice = data.data
const meta = RECOMMENDATION_META[advice.recommendation] ?? RECOMMENDATION_META.MIETERAUSBAU
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Lightbulb size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>KI-Ausbauempfehlung</Typography>
<Chip
label={meta.label}
size="small"
sx={{ ml: 'auto', bgcolor: meta.bg, color: meta.fg, fontWeight: 700, fontSize: 11, height: 22, border: `1px solid ${meta.border}` }}
/>
</Box>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.75 }}>{advice.headline}</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 1.5, lineHeight: 1.55 }}>
{advice.explanation}
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 1, px: 1.5, mb: 1.5, bgcolor: DS_SURFACE.neutral.bg, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 600 }}>Geschätzte Nettoinvestition</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{advice.estimatedNetInvestment}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<MessageSquareQuote size={14} color={DS_TEXT.muted} style={{ marginTop: 3, flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, lineHeight: 1.5 }}>
<strong>Verhandlungstipp:</strong> {advice.negotiationTip}
</Typography>
</Box>
{data.provenance?.fallbackUsed && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1.25 }}>
Hinweis: KI nicht verfügbar Richtwert-basierte Empfehlung.
</Typography>
)}
</Paper>
)
}
+27 -46
View File
@@ -1,33 +1,23 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { HardHat } from 'lucide-react'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
}
const AMORTIZATION_YEARS = FITOUT_AMORTIZATION_YEARS
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
const RATE_PCT = Math.round(FITOUT_ANNUITY_RATE * 100)
interface Props {
fitOut: string
areaSqm: number
mabPerSqm: number
rentPricePerSqm: number
tenantBudgetPerSqm?: number
fitOutByLandlord?: boolean
}
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) {
@@ -49,35 +39,26 @@ function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
)
}
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, tenantBudgetPerSqm = 0 }: Props) {
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, fitOutByLandlord }: Props) {
const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({ fitOut, rentPricePerSqm, mabPerSqm, fitOutByLandlord })
const rentPerYear = rentPricePerSqm * areaSqm
const isReadyToMoveIn = READY_TO_MOVE_IN.has(fitOut)
const fitOutPerYear = fitOutPerSqm * areaSqm
const totalPerYear = rentPerYear + fitOutPerYear
const fitOutLabel = FIT_OUT_LABELS[fitOut] ?? fitOut
const investment = isReadyToMoveIn
? null
: calcFitOutInvestment(fitOut, areaSqm, mabPerSqm, tenantBudgetPerSqm)
const fitOutPerYear = investment && !investment.isFullyCovered ? {
min: Math.round(investment.netTotal.min / AMORTIZATION_YEARS),
max: Math.round(investment.netTotal.max / AMORTIZATION_YEARS),
} : { min: 0, max: 0 }
const totalPerYear = {
min: rentPerYear + fitOutPerYear.min,
max: rentPerYear + fitOutPerYear.max,
}
const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3
const disclaimer = isReadyToMoveIn
? null
: `Ausbaukosten nach CRB/BKP-Normen, amortisiert über ${AMORTIZATION_YEARS} Jahre. Tatsächliche Kosten je nach Ausbauumfang.`
// Kein Aufschlag → entweder Vermieter übernimmt oder Fläche bereits bezugsfertig
const hasSurcharge = fitOutPerYear > 0
const isWarning = totalPerYear > rentPerYear * 1.3
const disclaimer = hasSurcharge
? `Ausbaukosten nach CRB/BKP-Normen, annuitätisch über ${FITOUT_AMORTIZATION_YEARS} Jahre zu ${RATE_PCT}% p.a. Tatsächliche Kosten je nach Ausbauumfang.`
: null
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 && (
{hasSurcharge && (
<Chip
label="CRB/BKP Richtwerte"
size="small"
@@ -92,32 +73,32 @@ export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, t
value={chf(rentPerYear)}
sub={`CHF ${rentPricePerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`}
/>
{isReadyToMoveIn ? (
{fitOutByLandlord ? (
<Row
label="Ausbau"
value="CHF 0."
sub={`${fitOutLabel} — Ausbau im Mietzins enthalten`}
/>
) : !hasSurcharge ? (
<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)}
label={`Ausbau (annuit. ${FITOUT_AMORTIZATION_YEARS} J. / ${RATE_PCT}%)`}
value={chf(fitOutPerYear)}
sub={
`CHF ${investment?.grossPerSqm.min}${investment?.grossPerSqm.max}/m² (${fitOutLabel})` +
`${fitOutLabel}` +
(mabPerSqm > 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
` × ${areaSqm.toLocaleString('de-CH')} ÷ ${AMORTIZATION_YEARS} J.`
` — CHF ${fitOutPerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`
}
/>
)}
<Row
label="Total/Jahr"
value={chfRange(totalPerYear.min, totalPerYear.max)}
value={chf(totalPerYear)}
isTotal
isWarning={isWarning}
/>
@@ -6,7 +6,9 @@ import {
import { Send } from 'lucide-react'
import { useToastStore } from '../../stores/toastStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useMoveStage } from '../../hooks/usePipeline'
import { useCreateInquiry } from '../../hooks/useInquiries'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
@@ -19,7 +21,8 @@ export function InquiryQuickDialog() {
const dialogOpen = useInquiryStore(s => s.dialogOpen)
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
const addInquiry = useInquiryStore(s => s.addInquiry)
const currentUser = useSessionStore(s => s.currentUser)
const createInquiry = useCreateInquiry()
const { mutate: moveStage } = useMoveStage()
const [message, setMessage] = useState('')
@@ -32,7 +35,18 @@ export function InquiryQuickDialog() {
function handleSend() {
if (!pendingInquiry) return
addInquiry(pendingInquiry, message)
createInquiry.mutate({
organizationId: 'org-wincasa', // Supply-Org des Objekts (Mock: Wincasa)
tenantOrgId: currentUser?.organizationId ?? 'org-mobimo',
propertyId: pendingInquiry.propertyId ?? 'unknown',
tenantName: currentUser?.name ?? 'Demand User',
tenantCompany: currentUser?.organizationName,
tenantEmail: currentUser?.email,
propertyAddress: pendingInquiry.propertyTitle,
subject: `Anfrage: ${pendingInquiry.propertyTitle}`,
message,
matchScore: pendingInquiry.matchScore,
})
if (pendingInquiry.pipelineItemId) {
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
}
@@ -0,0 +1,70 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { TrendingUp } from 'lucide-react'
import { estimateMarketRent, type RentVerdict } from '../../lib/rentEstimate'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
const VERDICT_META: Record<RentVerdict, { label: string; bg: string; border: string; fg: string }> = {
BELOW: { label: 'Unter Markt', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
AT: { label: 'Marktkonform', bg: DS_SURFACE.blue.bg, border: DS_SURFACE.blue.border, fg: DS_TEXT.signalDark },
ABOVE: { label: 'Über Markt', bg: DS_SURFACE.warning.bg, border: DS_SURFACE.warning.border, fg: DS_TEXT.warning },
}
interface Props {
city: string
assetType: string
askingRentPerSqm: number
futureRentPerSqm?: number // Pre-Market: erwarteter künftiger Preis
}
function chf(v: number): string {
return `CHF ${Math.round(v).toLocaleString('de-CH')}/m²`
}
export function MarketPricePanel({ city, assetType, askingRentPerSqm, futureRentPerSqm }: Props) {
const est = estimateMarketRent(city, assetType, askingRentPerSqm)
if (!est) return null
const meta = VERDICT_META[est.verdict]
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TrendingUp size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Marktpreis-Einschätzung</Typography>
<Chip
label={meta.label}
size="small"
sx={{ ml: 'auto', bgcolor: meta.bg, color: meta.fg, fontWeight: 700, fontSize: 11, height: 22, border: `1px solid ${meta.border}` }}
/>
</Box>
<Box sx={{ display: 'flex', gap: 2, mb: 1.25 }}>
<Box sx={{ flex: 1, p: 1.25, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Angebotsmiete</Typography>
<Typography variant="body1" sx={{ fontWeight: 700 }}>{chf(est.askingRentPerSqm)}</Typography>
</Box>
<Box sx={{ flex: 1, p: 1.25, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Faire Marktmiete</Typography>
<Typography variant="body1" sx={{ fontWeight: 700 }}>
{chf(est.fairRentPerSqm)}
<Box component="span" sx={{ ml: 0.75, fontSize: 12, fontWeight: 600, color: meta.fg }}>
{est.deltaPct > 0 ? `+${est.deltaPct}%` : `${est.deltaPct}%`}
</Box>
</Typography>
</Box>
</Box>
{futureRentPerSqm != null && futureRentPerSqm > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 1, px: 1.5, mb: 1.25, bgcolor: DS_SURFACE.purple.bg, border: `1px solid ${DS_SURFACE.purple.border}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 600 }}>Erwartet (Pre-Market)</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{chf(est.askingRentPerSqm)} <Box component="span" sx={{ color: DS_TEXT.muted }}></Box> {chf(futureRentPerSqm)}
</Typography>
</Box>
)}
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block' }}>
{est.rationale}
</Typography>
</Paper>
)
}
+2
View File
@@ -13,4 +13,6 @@ export { SourceProvenancePanel } from './SourceProvenancePanel'
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
export { NextActionsPanel } from './NextActionsPanel'
export { FitOutCostPanel } from './FitOutCostPanel'
export { FitOutAdvicePanel } from './FitOutAdvicePanel'
export { MarketPricePanel } from './MarketPricePanel'
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
@@ -1,11 +1,16 @@
import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
// Stufen, die noch Mieterausbau benötigen — nur dann ist die Träger-Frage relevant
const NEEDS_FIT_OUT = new Set(['SHELL', 'BASIC'])
interface Props {
floor: string
onFloorChange: (v: string) => void
fitOut: string
onFitOutChange: (v: string) => void
fitOutByLandlord: boolean | undefined
onFitOutByLandlordChange: (v: boolean) => void
parking: string
onParkingChange: (v: string) => void
ceilingHeight: string
@@ -21,12 +26,14 @@ interface Props {
export function TechnicalDetailsSection({
floor, onFloorChange,
fitOut, onFitOutChange,
fitOutByLandlord, onFitOutByLandlordChange,
parking, onParkingChange,
ceilingHeight, onCeilingHeightChange,
mieterausbaubeitrag, onMieterausbaubeitragChange,
isFlexible, onIsFlexibleChange,
minLettableSqm, onMinLettableSqmChange,
}: Props) {
const showFitOutResponsibility = NEEDS_FIT_OUT.has(fitOut)
return (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Technische Details (optional)</Typography>
@@ -44,14 +51,6 @@ 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)}
@@ -64,6 +63,46 @@ export function TechnicalDetailsSection({
/>
</Box>
{/* Ausbau-Träger — nur relevant, wenn die Fläche noch Ausbau benötigt (Rohbau/Edelrohbau) */}
{showFitOutResponsibility && (
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9' }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
Wer baut aus? <Box component="span" sx={{ color: '#dc2626' }}>*</Box>
</Typography>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, flexWrap: 'wrap' }}>
<ToggleButtonGroup
exclusive
size="small"
value={fitOutByLandlord === undefined ? null : fitOutByLandlord ? 'landlord' : 'tenant'}
onChange={(_, v) => { if (v) onFitOutByLandlordChange(v === 'landlord') }}
>
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter übernimmt</ToggleButton>
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter baut aus</ToggleButton>
</ToggleButtonGroup>
{fitOutByLandlord === undefined ? (
<Typography variant="caption" sx={{ flex: 1, minWidth: 220, mt: 0.5, color: '#b45309' }}>
Pflichtangabe bei Rohbau/Edelrohbau bitte wählen, wer den Ausbau trägt.
</Typography>
) : fitOutByLandlord ? (
<Typography variant="caption" color="text.secondary" sx={{ flex: 1, minWidth: 220, mt: 0.5 }}>
Ausbau im Mietzins enthalten bitte die <strong>bezugsfertige</strong> Miete eintragen.
Es wird kein Aufschlag berechnet.
</Typography>
) : (
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
value={mieterausbaubeitrag}
onChange={e => onMieterausbaubeitragChange(e.target.value)}
size="small" type="number" sx={{ width: 240 }}
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters — optional, leer = kein Beitrag"
/>
)}
</Box>
</Box>
)}
{/* Divisibility */}
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', gap: 2 }}>
<FormControlLabel
@@ -32,7 +32,7 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: ()
return (
<Box sx={{
width: { xs: '100%', md: 340 }, flexShrink: 0,
width: { xs: '100%', md: 300, lg: 320, xl: 340 }, flexShrink: 0,
display: 'flex', flexDirection: 'column',
bgcolor: 'white', borderLeft: `1px solid ${DS_BORDER.default}`, overflow: 'hidden',
}}>
+24 -1
View File
@@ -8,9 +8,11 @@ interface Props {
futureCount: number
view?: 'list' | 'grid'
onViewChange?: (v: 'list' | 'grid') => void
columns?: 3 | 5 | 10
onColumnsChange?: (n: 3 | 5 | 10) => void
}
export function ResultFeedHeader({ total, platformCount, maisonWorkCount, futureCount, view = 'list', onViewChange }: Props) {
export function ResultFeedHeader({ total, platformCount, maisonWorkCount, futureCount, view = 'list', onViewChange, columns = 3, onColumnsChange }: Props) {
return (
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
@@ -21,9 +23,30 @@ export function ResultFeedHeader({ total, platformCount, maisonWorkCount, future
{platformCount} Plattform · {maisonWorkCount} Maison Work · {futureCount} Future Availability
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{view === 'grid' && onColumnsChange && (
<Box sx={{ display: 'flex', border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
{([3, 5, 10] as const).map(n => (
<Box
key={n}
onClick={() => onColumnsChange(n)}
sx={{
width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: '0.72rem', fontWeight: 600,
bgcolor: columns === n ? '#152642' : 'transparent',
color: columns === n ? 'white' : '#64748b',
'&:hover': { bgcolor: columns === n ? '#162d4a' : '#f1f5f9' },
}}
>
{n}
</Box>
))}
</Box>
)}
{onViewChange && (
<ViewToggle view={view} onChange={onViewChange} />
)}
</Box>
</Box>
)
}
@@ -2,7 +2,6 @@ import { useNavigate } from 'react-router'
import { MatchCardCompact } from '../match-card/MatchCardCompact'
import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard'
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
import { resolveImageLabel } from '../../lib/propertyImageResolver'
import { useCompareStore } from '../../stores/compareStore'
import { usePipelineStore } from '../../stores/pipelineStore'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
@@ -98,17 +97,6 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
? result.property.location.city
: result.signal.locationHint ?? undefined
const overlayLabels = result.resultType !== 'FUTURE_AVAILABILITY'
? resolveImageLabel({
assetType: result.property.assetType,
city: result.property.location.city,
district: result.property.location.district,
prestige: result.property.softFactors?.prestige,
floorLevel: result.property.floorLevel,
propertyId: result.property.id,
})
: null
return (
<IntelligenceMatchCard
vm={vm}
@@ -116,8 +104,6 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
lat={lat}
lng={lng}
cityLabel={cityLabel}
overlayTypeLabel={overlayLabels?.type}
overlayLocationLabel={overlayLabels?.location}
/>
)
}
+4 -3
View File
@@ -6,6 +6,7 @@ import { SCORE_THEME } from '../shared/scoreTheme'
interface Props {
results: UnifiedMatchResult[]
view?: 'list' | 'grid'
columns?: 3 | 5 | 10
}
const TIER_CONFIG = [
@@ -40,7 +41,7 @@ interface TierHeaderProps {
function TierHeader({ label, sublabel, tierKey, count, isFirst }: TierHeaderProps) {
const theme = SCORE_THEME[tierKey]
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: isFirst ? 0 : 3, mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: isFirst ? 0 : 4.5, mb: 2.5 }}>
<Box
sx={{
width: 4,
@@ -72,7 +73,7 @@ function TierHeader({ label, sublabel, tierKey, count, isFirst }: TierHeaderProp
)
}
export function UnifiedResultFeed({ results, view = 'list' }: Props) {
export function UnifiedResultFeed({ results, view = 'list', columns = 3 }: Props) {
// All results — real properties and Schattenmarkt signals — are sorted into
// the same score tiers so they appear in a single unified ranked list.
const tiers = TIER_CONFIG.map(t => ({ ...t, items: results.filter(t.filter) }))
@@ -80,7 +81,7 @@ export function UnifiedResultFeed({ results, view = 'list' }: Props) {
function renderCards(items: UnifiedMatchResult[]) {
return view === 'grid' ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 3 }}>
{items.map(result => (
<UnifiedResultCard key={result.matchId} result={result} view="grid" />
))}
+4 -2
View File
@@ -9,6 +9,8 @@ interface Props {
address?: string
cityLabel?: string
height?: number
/** When set, the hero scales proportionally with width (e.g. '15/8') instead of using a fixed pixel height. */
aspectRatio?: string
overlayTypeLabel?: string
overlayLocationLabel?: string
}
@@ -23,13 +25,13 @@ function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
return 'https://www.google.com/maps'
}
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180, overlayTypeLabel, overlayLocationLabel }: Props) {
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180, aspectRatio, overlayTypeLabel, overlayLocationLabel }: Props) {
const [imgError, setImgError] = useState(false)
const mapsUrl = buildMapsUrl(lat, lng, address)
const showImage = !!imageUrl && !imgError
return (
<Box sx={{ position: 'relative', width: '100%', height, overflow: 'hidden', flexShrink: 0 }}>
<Box sx={{ position: 'relative', width: '100%', ...(aspectRatio ? { aspectRatio } : { height }), overflow: 'hidden', flexShrink: 0 }}>
{showImage ? (
<img
src={imageUrl}
+1
View File
@@ -188,6 +188,7 @@ export function PreMarketPanel({ p }: { p: Property }) {
{(p.units?.length ?? 0) > 0 && (
<PreMarketUnitGrid
property={p}
units={p.units!}
unitStates={unitStates}
unitSaving={unitSaving}
@@ -0,0 +1,170 @@
import { useState } from 'react'
import { Box, Button, Chip, CircularProgress, Divider, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { Sparkles } from 'lucide-react'
import type { Property, PropertyUnit } from '../../domain/property'
import { usePreMarketRentRecommendation } from '../../hooks/useAI'
import { useUpdateUnit } from '../../hooks/useProperties'
import { resolveUnitFacts } from '../../lib/unitFacts'
import { FIT_OUT_LABELS } from '../../lib/constants'
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
const NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
const VERDICT_META: Record<string, { label: string; bg: string; fg: string }> = {
UNDERPRICED: { label: 'zu günstig', bg: '#fef3c7', fg: '#92400e' },
FAIR: { label: 'marktgerecht', bg: '#dcfce7', fg: '#166534' },
AMBITIOUS: { label: 'ambitioniert', bg: '#fee2e2', fg: '#991b1b' },
}
interface Draft {
fitOut?: string
fitOutByLandlord?: boolean
mieterausbaubeitragPerSqm?: number
parkingSpots?: number
expectedRentPerSqm?: number
}
interface Props {
property: Property
unit: PropertyUnit
}
/** Pre-Market-Konfiguration einer Einheit: erst Ausbau/Parkplätze, dann KI-Preisempfehlung. */
export function PreMarketPriceAdvisor({ property, unit }: Props) {
const updateUnit = useUpdateUnit(property.id)
const [d, setD] = useState<Draft>(() => ({
fitOut: unit.fitOut ?? '',
fitOutByLandlord: unit.fitOutByLandlord,
mieterausbaubeitragPerSqm: unit.mieterausbaubeitragPerSqm,
parkingSpots: unit.parkingSpots,
expectedRentPerSqm: unit.expectedRentPerSqm,
}))
const facts = resolveUnitFacts(property, unit)
const current = facts.rentPricePerSqm
const effFit = d.fitOut || property.hardFacts?.fitOut
const needsBuild = NEEDS_BUILD.has(effFit ?? '')
const { data, isLoading, isError } = usePreMarketRentRecommendation({
city: property.location?.city ?? '',
assetType: property.assetType,
areaSqm: unit.areaSqm,
currentRentPerSqm: current,
availableFrom: unit.schattenmarktRelease?.availableFrom,
})
const rec = data?.data
const verdict = rec ? VERDICT_META[rec.verdict] : null
const save = (patch: Partial<PropertyUnit>) => updateUnit.mutate({ unitId: unit.id, data: patch })
return (
<Box sx={{ mt: 0.75, p: 1.25, borderRadius: 1, bgcolor: 'white', border: `1px solid ${DS_SURFACE.purple.border}` }}>
{/* 1) Ausbau & Ausstattung */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 1 }}>
<TextField
select size="small" label="Ausbaustandard"
value={d.fitOut ?? ''}
onChange={e => {
const v = e.target.value
const nb = v === 'SHELL' || v === 'BASIC'
setD(p => ({ ...p, fitOut: v }))
save({ fitOut: (v || undefined) as PropertyUnit['fitOut'], ...(nb ? {} : { fitOutByLandlord: undefined, mieterausbaubeitragPerSqm: undefined }) })
}}
>
<MenuItem value="">Wie Objekt{property.hardFacts?.fitOut ? ` (${FIT_OUT_LABELS[property.hardFacts.fitOut] ?? property.hardFacts.fitOut})` : ''}</MenuItem>
<MenuItem value="SHELL">{FIT_OUT_LABELS.SHELL}</MenuItem>
<MenuItem value="BASIC">{FIT_OUT_LABELS.BASIC}</MenuItem>
<MenuItem value="FULL">{FIT_OUT_LABELS.FULL}</MenuItem>
<MenuItem value="PREMIUM">{FIT_OUT_LABELS.PREMIUM}</MenuItem>
</TextField>
<TextField
label="Parkplätze" type="number" size="small"
value={d.parkingSpots ?? ''}
onChange={e => setD(p => ({ ...p, parkingSpots: parseInt(e.target.value) || undefined }))}
onBlur={e => save({ parkingSpots: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0 } }}
helperText={property.hardFacts?.parking != null ? `Pool: ${property.hardFacts.parking}` : undefined}
/>
</Box>
{needsBuild && (
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1 }}>
<Typography variant="caption" color="text.secondary">Wer baut aus?</Typography>
<ToggleButtonGroup
exclusive size="small"
value={d.fitOutByLandlord === undefined ? null : d.fitOutByLandlord ? 'landlord' : 'tenant'}
onChange={(_, v) => {
if (!v) return
const landlord = v === 'landlord'
setD(p => ({ ...p, fitOutByLandlord: landlord, ...(landlord ? { mieterausbaubeitragPerSqm: undefined } : {}) }))
save({ fitOutByLandlord: landlord, ...(landlord ? { mieterausbaubeitragPerSqm: undefined } : {}) })
}}
>
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter</ToggleButton>
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter</ToggleButton>
</ToggleButtonGroup>
{d.fitOutByLandlord === false && (
<TextField
label="MAB (CHF/m²)" type="number" size="small" sx={{ width: 140 }}
value={d.mieterausbaubeitragPerSqm ?? ''}
onChange={e => setD(p => ({ ...p, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))}
onBlur={e => save({ mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
/>
)}
</Box>
)}
<Divider sx={{ my: 1.25 }} />
{/* 2) KI-Preisempfehlung */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
<Sparkles size={12} color={DS_PRE_MARKET.accent} />
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, color: '#5b21b6' }}>KI-Preisempfehlung Pre-Market</Typography>
{verdict && <Chip label={verdict.label} size="small" sx={{ height: 16, fontSize: '0.6rem', fontWeight: 700, bgcolor: verdict.bg, color: verdict.fg, ml: 'auto' }} />}
</Box>
{isLoading ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<CircularProgress size={12} sx={{ color: DS_PRE_MARKET.accent }} />
<Typography sx={{ fontSize: '0.65rem', color: '#6d28d9' }}>Marktanalyse läuft</Typography>
</Box>
) : isError || !rec ? (
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>Keine Empfehlung verfügbar.</Typography>
) : (
<>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: '#5b21b6' }}>
Empfehlung: CHF {rec.recommendedPerSqm}/m²
<Box component="span" sx={{ fontWeight: 400, color: '#6d28d9' }}> (CHF {rec.rangeMinPerSqm}{rec.rangeMaxPerSqm})</Box>
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: '#6d28d9', mb: 0.25 }}>
Heute CHF {current}/m²
<Box component="span" sx={{ fontWeight: 700, ml: 0.5 }}>
{rec.deltaVsCurrentPct > 0 ? `+${rec.deltaVsCurrentPct}%` : `${rec.deltaVsCurrentPct}%`} vs. heute
</Box>
</Typography>
<Typography sx={{ fontSize: '0.6rem', color: '#7c3aed', mb: 0.625 }}>
{rec.drivers.join(' · ')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<TextField
type="number" size="small" label="Pre-Market-Preis"
placeholder={`z.B. ${rec.recommendedPerSqm}`}
value={d.expectedRentPerSqm ?? ''}
onChange={e => setD(p => ({ ...p, expectedRentPerSqm: parseInt(e.target.value) || undefined }))}
onBlur={e => save({ expectedRentPerSqm: parseInt(e.target.value) || undefined })}
slotProps={{ inputLabel: { shrink: true }, htmlInput: { min: 0, step: 10 } }}
sx={{ width: 150, '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }}
/>
<Button
size="small"
onClick={() => { setD(p => ({ ...p, expectedRentPerSqm: rec.recommendedPerSqm })); save({ expectedRentPerSqm: rec.recommendedPerSqm }) }}
sx={{ textTransform: 'none', fontSize: '0.68rem', color: DS_PRE_MARKET.accent }}
>
Empfehlung übernehmen
</Button>
</Box>
</>
)}
</Box>
)
}
+49 -15
View File
@@ -1,8 +1,13 @@
import { Box, CircularProgress, Switch, TextField, Tooltip, Typography } from '@mui/material'
import { EyeOff } from 'lucide-react'
import { useState } from 'react'
import { Box, CircularProgress, Collapse, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
import { EyeOff, Pencil } from 'lucide-react'
import type { Property } from '../../domain/property'
import { DS_PRE_MARKET, DS_TEXT } from '../../lib/ds'
import { DS_BORDER, DS_PRE_MARKET, DS_TEXT } from '../../lib/ds'
import { FIT_OUT_LABELS } from '../../lib/constants'
import { resolveUnitFacts } from '../../lib/unitFacts'
import { floorLabel } from './PropertyDetailHelpers'
import { UnitFieldsEditor } from './UnitFieldsEditor'
import { PreMarketPriceAdvisor } from './PreMarketPriceAdvisor'
type PropertyUnit = NonNullable<Property['units']>[number]
@@ -13,6 +18,7 @@ export interface UnitReleaseState {
}
interface Props {
property: Property
units: PropertyUnit[]
unitStates: Record<string, UnitReleaseState>
unitSaving: Record<string, boolean>
@@ -20,7 +26,9 @@ interface Props {
saveUnit: (unitId: string, enabled: boolean, availableFrom: string, anonymous: boolean) => Promise<void>
}
export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) {
export function PreMarketUnitGrid({ property, units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) {
const [editing, setEditing] = useState<string | null>(null)
return (
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}>
@@ -28,16 +36,18 @@ export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates
</Typography>
{units.map(u => {
const us = unitStates[u.id] ?? { enabled: false, availableFrom: '', anonymous: false }
const f = resolveUnitFacts(property, u)
const fitLabel = f.fitOut ? (FIT_OUT_LABELS[f.fitOut] ?? f.fitOut) : null
const needsBuild = f.fitOut === 'SHELL' || f.fitOut === 'BASIC'
const detailParts: string[] = []
if (fitLabel) detailParts.push(fitLabel)
if (needsBuild) detailParts.push(`Träger: ${f.fitOutByLandlord ? 'Vermieter' : 'Mieter'}`)
if (f.parkingSpots != null) detailParts.push(`${f.parkingSpots} PP`)
detailParts.push(u.expectedRentPerSqm ? `erwartet CHF ${u.expectedRentPerSqm}/m²` : `CHF ${f.rentPricePerSqm}/m²`)
return (
<Box
key={u.id}
sx={{
display: 'grid', gridTemplateColumns: '1fr 140px auto',
gap: 1, alignItems: 'start', py: 0.875,
borderBottom: '1px solid #f3e8ff',
'&:last-child': { borderBottom: 'none' },
}}
>
<Box key={u.id} sx={{ borderBottom: '1px solid #f3e8ff', '&:last-child': { borderBottom: 'none' }, py: 0.875 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 140px auto', gap: 1, alignItems: 'start' }}>
{/* Unit info + anonym toggle (shown when enabled) */}
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: DS_TEXT.primary }}>
@@ -105,9 +115,16 @@ export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates
}}
/>
{/* Enabled toggle */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
{/* Edit (nur vor Freigabe — danach inline) + enabled toggle */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.25 }}>
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: DS_PRE_MARKET.accent }} />}
{!us.enabled && (
<Tooltip title="Ausbau, Preis & Parkplätze dieser Einheit bearbeiten">
<IconButton size="small" onClick={() => setEditing(editing === u.id ? null : u.id)} sx={{ p: 0.25, color: editing === u.id ? DS_PRE_MARKET.accent : DS_TEXT.disabled }}>
<Pencil size={12} />
</IconButton>
</Tooltip>
)}
<Switch
size="small"
checked={us.enabled}
@@ -123,6 +140,23 @@ export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates
/>
</Box>
</Box>
{/* Vor Freigabe: Lese-Details + Stift-Editor. Nach Freigabe: volle Inline-Konfiguration. */}
{us.enabled ? (
<PreMarketPriceAdvisor property={property} unit={u} />
) : (
<>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.62rem', display: 'block', mt: 0.25 }}>
{detailParts.join(' · ')}
</Typography>
<Collapse in={editing === u.id}>
<Box sx={{ border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, mt: 0.75, overflow: 'hidden' }}>
<UnitFieldsEditor property={property} unit={u} onClose={() => setEditing(null)} />
</Box>
</Collapse>
</>
)}
</Box>
)
})}
</Box>
@@ -1,4 +1,4 @@
import { Box, Button, Divider, LinearProgress, MenuItem, TextField, Typography } from '@mui/material'
import { Box, Button, Divider, LinearProgress, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { ExternalLink, FileText } from 'lucide-react'
import type { Property, UpdatePropertyInput } from '../../domain/property'
import { PropertyMap } from '../shared'
@@ -6,6 +6,11 @@ import { getAssetTypeLabel, qualityColor } from './propertyHelpers'
import { Field, FieldGrid, SectionTitle } from './PropertyDetailHelpers'
import { UnitStructurePanel } from './UnitStructurePanel'
import { PreMarketPanel } from './PreMarketPanel'
import { FIT_OUT_LABELS } from '../../lib/constants'
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
// Stufen, die noch Mieterausbau benötigen — nur dann ist die Träger-Frage relevant
const FIT_OUT_NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
interface PropertyDetailOverviewProps {
p: Property
@@ -15,16 +20,38 @@ interface PropertyDetailOverviewProps {
}
export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: PropertyDetailOverviewProps) {
const rentLabel = p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : undefined
// Mehr-Einheiten-Objekt: Preis als Spanne, Fläche mit Anzahl, Verfügbarkeit pro Einheit
const units = p.units ?? []
const isMultiUnit = units.length > 1
const unitPrices = units.map(u => u.rentPricePerSqm ?? p.rentPricePerSqm).filter(v => v > 0)
const priceMin = unitPrices.length ? Math.min(...unitPrices) : p.rentPricePerSqm
const priceMax = unitPrices.length ? Math.max(...unitPrices) : p.rentPricePerSqm
const rentLabel = isMultiUnit && priceMin !== priceMax
? `CHF ${priceMin.toLocaleString('de-CH')}${priceMax.toLocaleString('de-CH')}`
: p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : undefined
const areaLabel = isMultiUnit
? `${p.areaSqm.toLocaleString('de-CH')} m² · ${units.length} Einheiten`
: `${p.areaSqm.toLocaleString('de-CH')}`
const availLabel = isMultiUnit
? 'pro Einheit ↓'
: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined
// Effektive (Draft-überlagerte) hardFacts für den Bearbeiten-Modus
const hf = draft.hardFacts ?? p.hardFacts ?? {}
const editFitOut = hf.fitOut ?? ''
const editByLandlord = hf.fitOutByLandlord
const showBuildResponsibility = FIT_OUT_NEEDS_BUILD.has(editFitOut)
const patchHF = (patch: Partial<NonNullable<Property['hardFacts']>>) =>
onDraftChange({ ...draft, hardFacts: { ...hf, ...patch } })
return (
<Box>
{/* Key metrics */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 0.75 }}>
{[
{ label: 'Fläche', value: `${p.areaSqm.toLocaleString('de-CH')}` },
{ label: 'CHF/m²/Jahr', value: rentLabel },
{ label: 'Verfügbar ab', value: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined },
{ label: 'Fläche', value: areaLabel },
{ label: isMultiUnit ? 'CHF/m²/Jahr (Spanne)' : 'CHF/m²/Jahr', value: rentLabel },
{ label: 'Verfügbar ab', value: availLabel },
].map(({ label, value }) => (
<Box key={label} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary">{label}</Typography>
@@ -132,54 +159,78 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
{/* Object details */}
<SectionTitle title="Objekt & Lage" />
{editing ? (
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 2 }}>
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
<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={editFitOut}
onChange={e => patchHF({ 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>)}
{FIT_OUT_OPTIONS.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 } })}
value={hf.parking ?? ''}
onChange={e => patchHF({ 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 } })}
value={hf.ceilingHeightM ?? ''}
onChange={e => patchHF({ ceilingHeightM: parseFloat(e.target.value) || undefined })}
/>
</Box>
{showBuildResponsibility && (
<Box sx={{ mt: 1.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Wer baut aus? <Box component="span" sx={{ color: '#dc2626' }}>*</Box>
</Typography>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
<ToggleButtonGroup
exclusive size="small"
value={editByLandlord === undefined ? null : editByLandlord ? 'landlord' : 'tenant'}
onChange={(_, v) => { if (v) patchHF({ fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) }) }}
>
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter übernimmt</ToggleButton>
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter baut aus</ToggleButton>
</ToggleButtonGroup>
{editByLandlord === undefined && (
<Typography variant="caption" sx={{ color: '#b45309' }}>
Pflichtangabe bei Rohbau/Edelrohbau
</Typography>
)}
{editByLandlord === false && (
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
size="small" type="number" sx={{ width: 240 }}
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters — optional"
value={hf.mieterausbaubeitragPerSqm ?? ''}
onChange={e => patchHF({ mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined })}
/>
)}
</Box>
</Box>
)}
</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={isMultiUnit ? 'Ausbaustandard (Objekt-Standard)' : 'Ausbaustandard'} value={p.hardFacts?.fitOut ? (FIT_OUT_LABELS[p.hardFacts.fitOut] ?? p.hardFacts.fitOut) : undefined} />
{p.hardFacts?.fitOut && FIT_OUT_NEEDS_BUILD.has(p.hardFacts.fitOut) && (
<Field label="Ausbau-Träger" value={p.hardFacts.fitOutByLandlord ? 'Vermieter (im Mietzins)' : 'Mieter'} />
)}
{p.hardFacts?.mieterausbaubeitragPerSqm && !p.hardFacts.fitOutByLandlord && (
<Field label="Mieterausbaubeitrag" value={`CHF ${p.hardFacts.mieterausbaubeitragPerSqm}/m²`} />
)}
<Field label="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
<Field label={isMultiUnit ? 'Parkplätze (Objekt-Pool)' : '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} />
<Field label="Importiert aus" value={p.importedFrom} />
@@ -51,6 +51,13 @@ export function PropertyDetailView({ propertyId, onClose, hideTabs = [] }: Prope
function saveEdit() {
if (!property) return
// Träger-Pflicht bei Rohbau/Edelrohbau (SHELL/BASIC)
const effFitOut = draft.hardFacts?.fitOut ?? property.hardFacts?.fitOut
const effByLandlord = draft.hardFacts?.fitOutByLandlord ?? property.hardFacts?.fitOutByLandlord
if ((effFitOut === 'SHELL' || effFitOut === 'BASIC') && effByLandlord === undefined) {
showToast('Bitte wählen, wer den Ausbau trägt (Vermieter oder Mieter).', 'error')
return
}
updateProperty.mutate(
{ id: property.id, input: draft },
{
+265 -137
View File
@@ -1,26 +1,185 @@
import { useState } from 'react'
import {
Drawer,
Box,
Typography,
IconButton,
Chip,
Divider,
TextField,
Button,
Switch,
FormControlLabel,
Tooltip,
Drawer, Box, Typography, IconButton, Chip, Divider,
TextField, Button, Select, MenuItem, FormControl, InputLabel, Autocomplete,
} from '@mui/material'
import { X, MapPin, Calendar, DollarSign, Eye, Activity, FileText, ExternalLink } from 'lucide-react'
import { X, Calendar, Activity, FileText, ExternalLink, Eye } from 'lucide-react'
import { useReminderStore } from '../../stores/reminderStore'
import { useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
import { usePropertyById } from '../../hooks/useProperties'
import {
useReminder, useCompleteReminder, useDismissReminder,
useSnoozeReminder, useCreateReminder,
} from '../../hooks/useReminders'
import { usePropertyById, useProperties } 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, SectionTitle, DateRow, ActivityEntry } from './reminderDetailHelpers'
import { ReminderStatus, ReminderType, ReminderPriority } from '../../domain/reminder'
import type { Property } from '../../domain/property'
import {
SHADOW_RISK_COLOR, SHADOW_RISK_LABEL,
STATUS_CHIP_COLOR, STATUS_LABEL,
SectionTitle, DateRow, ActivityEntry,
} from './reminderDetailHelpers'
// ── Constants ────────────────────────────────────────────────────────────────
const TYPE_LABELS: Record<ReminderType, string> = {
LEASE_EXPIRY: 'Mietablauf',
BREAK_OPTION: 'Break-Option',
RENT_REVIEW: 'Mietanpassung',
INSPECTION: 'Inspektion',
INSURANCE_RENEWAL: 'Versicherung',
MAINTENANCE: 'Unterhalt',
SCHATTENMARKT_RELEASE: 'Pre-Market',
CUSTOM: 'Individuell',
}
const TYPE_SECOND_DATE: Partial<Record<ReminderType, 'contractEndDate' | 'breakOptionDate' | 'eventDate'>> = {
[ReminderType.LEASE_EXPIRY]: 'contractEndDate',
[ReminderType.BREAK_OPTION]: 'breakOptionDate',
[ReminderType.RENT_REVIEW]: 'eventDate',
[ReminderType.SCHATTENMARKT_RELEASE]: 'eventDate',
}
const TYPE_SECOND_LABEL: Partial<Record<ReminderType, string>> = {
[ReminderType.LEASE_EXPIRY]: 'Vertragsende',
[ReminderType.BREAK_OPTION]: 'Break-Option',
[ReminderType.RENT_REVIEW]: 'Ereignisdatum',
[ReminderType.SCHATTENMARKT_RELEASE]: 'Ereignisdatum',
}
const MOCK_TODAY = new Date('2026-05-20')
function calcPriority(dueDateStr: string): ReminderPriority {
const days = Math.ceil((new Date(dueDateStr).getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24))
if (days <= 14) return ReminderPriority.URGENT
if (days <= 30) return ReminderPriority.HIGH
if (days <= 60) return ReminderPriority.MEDIUM
return ReminderPriority.LOW
}
// ── Create Form ───────────────────────────────────────────────────────────────
function CreateForm({ onClose }: { onClose: () => void }) {
const { data: properties = [] } = useProperties()
const create = useCreateReminder()
const [type, setType] = useState<ReminderType>(ReminderType.LEASE_EXPIRY)
const [selectedProperty, setSelectedProperty] = useState<Property | null>(null)
const [tenantName, setTenantName] = useState('')
const [dueDate, setDueDate] = useState('')
const [note, setNote] = useState('')
function handlePropertyChange(_: unknown, prop: Property | null) {
setSelectedProperty(prop)
setTenantName(prop?.currentTenant ?? '')
}
function handleSubmit() {
if (!selectedProperty || !dueDate) return
create.mutate(
{
type,
priority: calcPriority(dueDate),
status: ReminderStatus.ACTIVE,
propertyId: selectedProperty.id,
propertyTitle: selectedProperty.title,
propertyCity: selectedProperty.location.city,
propertyDistrict: selectedProperty.location.district,
tenantName: tenantName || '—',
dueDate,
eventDate: dueDate,
areaSqm: selectedProperty.areaSqm,
currentRentPerSqm: selectedProperty.rentPricePerSqm,
currency: 'CHF',
shadowMarketRisk: 'NONE',
schattenmarktEnabled: false,
note: note || undefined,
organizationId: selectedProperty.organizationId ?? 'org-1',
},
{ onSuccess: onClose },
)
}
const canSubmit = !!selectedProperty && !!dueDate && !create.isPending
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* Typ */}
<FormControl size="small" fullWidth>
<InputLabel>Typ</InputLabel>
<Select
value={type}
label="Typ"
onChange={e => setType(e.target.value as ReminderType)}
>
{Object.values(ReminderType).map(t => (
<MenuItem key={t} value={t}>{TYPE_LABELS[t]}</MenuItem>
))}
</Select>
</FormControl>
{/* Objekt */}
<Autocomplete
options={properties}
getOptionLabel={p => p.title}
value={selectedProperty}
onChange={handlePropertyChange}
size="small"
renderInput={params => (
<TextField {...params} label="Objekt" placeholder="Objekt auswählen…" />
)}
/>
{/* Mieter — nur wenn Objekt ausgewählt */}
{selectedProperty && (
<TextField
label="Mieter"
size="small"
fullWidth
value={tenantName}
onChange={e => setTenantName(e.target.value)}
placeholder="Mietername eingeben…"
/>
)}
{/* Fälligkeitsdatum */}
<TextField
label="Fälligkeitsdatum"
type="date"
size="small"
fullWidth
value={dueDate}
onChange={e => setDueDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
{/* Notiz */}
<TextField
label="Notiz (optional)"
multiline
rows={3}
size="small"
fullWidth
value={note}
onChange={e => setNote(e.target.value)}
placeholder="Kontext oder Hinweise…"
/>
<Button
variant="contained"
fullWidth
disabled={!canSubmit}
onClick={handleSubmit}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
{create.isPending ? 'Wird erstellt…' : 'Reminder erstellen'}
</Button>
</Box>
)
}
// ── Main Drawer ───────────────────────────────────────────────────────────────
export function ReminderDetailDrawer() {
const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore()
@@ -38,30 +197,35 @@ export function ReminderDetailDrawer() {
setSelectedId(null)
}
const isNew = !selectedId
const isCreateMode = !selectedId
const secondDateKey = reminder ? TYPE_SECOND_DATE[reminder.type] : undefined
const secondDateLabel = reminder ? TYPE_SECOND_LABEL[reminder.type] : undefined
const secondDateValue = secondDateKey && reminder ? reminder[secondDateKey] : undefined
const showSecondDate = secondDateValue && reminder && secondDateValue !== reminder.dueDate
const showPreMarket = reminder &&
reminder.shadowMarketRisk !== 'NONE' &&
reminder.shadowMarketRisk !== 'LOW'
const isActionable = reminder &&
(reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED)
return (
<Drawer
anchor="right"
open={drawerOpen}
onClose={handleClose}
slotProps={{ paper: { sx: { width: 480, display: 'flex', flexDirection: 'column' } } }}
>
{/* Header */}
<Box
sx={{
px: 2.5,
py: 2,
borderBottom: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'flex-start',
gap: 1,
flexShrink: 0,
}}
slotProps={{ paper: { sx: { width: 460, display: 'flex', flexDirection: 'column' } } }}
>
{/* ── Header ── */}
<Box sx={{ px: 2.5, py: 2, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0 }}>
{isNew ? (
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Neuer Reminder</Typography>
{isCreateMode ? (
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a' }}>
Neuer Reminder
</Typography>
) : reminder ? (
<>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
@@ -78,34 +242,34 @@ export function ReminderDetailDrawer() {
</>
) : null}
</Box>
<IconButton size="small" onClick={handleClose} sx={{ flexShrink: 0 }}>
<IconButton size="small" onClick={handleClose} sx={{ flexShrink: 0, color: '#94a3b8' }}>
<X size={18} />
</IconButton>
</Box>
</Box>
{/* Scrollable body */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }} className="flex flex-col gap-5">
{isNew && (
<Typography variant="body2" color="text.secondary">
Neue Reminder-Erstellung noch nicht implementiert.
</Typography>
)}
{/* ── Body ── */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5, display: 'flex', flexDirection: 'column' }}>
{/* Create mode */}
{isCreateMode && <CreateForm onClose={handleClose} />}
{/* Detail mode */}
{reminder && (
<>
{/* 2. Property */}
<Box>
<SectionTitle icon={<MapPin size={15} color="#64748b" />}>Objekt</SectionTitle>
<Box className="flex flex-col gap-1">
<Typography variant="body2" sx={{ fontWeight: 600 }}>{reminder.propertyTitle}</Typography>
<Typography variant="caption" color="text.secondary">
{reminder.propertyCity}{reminder.propertyDistrict ? ` · ${reminder.propertyDistrict}` : ''}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* 1. Objekt */}
<Box sx={{ mb: 2.5 }}>
<Typography variant="body1" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.25 }}>
{reminder.propertyTitle}
</Typography>
<Typography variant="caption" color="text.secondary">Mieter: {reminder.tenantName}</Typography>
<Typography variant="caption" color="text.secondary">Fläche: {reminder.areaSqm.toLocaleString('de-CH')} m²</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
{reminder.propertyCity}{reminder.propertyDistrict ? ` · ${reminder.propertyDistrict}` : ''}&nbsp;·&nbsp;{reminder.tenantName}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
{reminderProperty?.leaseContractUrl ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
<FileText size={12} color="#152642" />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<FileText size={12} color="#64748b" />
<Button
size="small"
variant="outlined"
@@ -113,94 +277,58 @@ export function ReminderDetailDrawer() {
href={reminderProperty.leaseContractUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', fontSize: '0.68rem', py: 0.125, px: 0.75, borderColor: '#cbd5e1', color: '#152642' }}
sx={{
textTransform: 'none', fontSize: '0.68rem',
py: 0.125, px: 0.75, borderColor: '#cbd5e1', color: '#334155',
}}
>
{reminderProperty.leaseContractName ?? 'Mietvertrag'}
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<FileText size={12} color="#cbd5e1" />
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>Kein Mietvertrag hinterlegt</Typography>
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>Kein Vertrag</Typography>
</Box>
)}
{showPreMarket && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4 }}>
<Eye size={11} color={SHADOW_RISK_COLOR[reminder.shadowMarketRisk]} />
<Typography variant="caption" sx={{
color: SHADOW_RISK_COLOR[reminder.shadowMarketRisk],
fontWeight: 600, fontSize: '0.68rem',
}}>
Pre-Mkt: {SHADOW_RISK_LABEL[reminder.shadowMarketRisk]}
</Typography>
</Box>
)}
</Box>
</Box>
<Divider />
<Divider sx={{ mb: 2.5 }} />
{/* 3. Dates */}
<Box>
<SectionTitle icon={<Calendar size={15} color="#64748b" />}>Daten &amp; Fristen</SectionTitle>
<Box className="flex flex-col gap-1.5">
<DateRow label="Fälligkeitsdatum" value={reminder.dueDate} />
<DateRow label="Ereignisdatum" value={reminder.eventDate} />
<DateRow label="Vertragsende" value={reminder.contractEndDate} />
<DateRow label="Break-Option" value={reminder.breakOptionDate} />
{/* 2. Fristen */}
<Box sx={{ mb: 2.5 }}>
<SectionTitle icon={<Calendar size={14} color="#64748b" />}>Fristen</SectionTitle>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<DateRow label="Fälligkeit" value={reminder.dueDate} />
{showSecondDate && (
<DateRow label={secondDateLabel!} value={secondDateValue} />
)}
{reminder.snoozedUntil && (
<DateRow label="Schlummern bis" value={reminder.snoozedUntil} />
<DateRow label="Schlummert bis" value={reminder.snoozedUntil} />
)}
</Box>
</Box>
<Divider />
<Divider sx={{ mb: 2.5 }} />
{/* 4. Financials */}
<Box>
<SectionTitle icon={<DollarSign size={15} color="#64748b" />}>Finanzen</SectionTitle>
<Box className="flex flex-col gap-1.5">
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1 }}>
<Typography variant="caption" color="text.secondary">Miete/m²</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{reminder.currency} {reminder.currentRentPerSqm.toLocaleString('de-CH')} / Monat
{/* 3. Notiz */}
<Box sx={{ mb: isActionable ? 0 : 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>
Notiz
</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1 }}>
<Typography variant="caption" color="text.secondary">Total / Monat</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{reminder.currency} {(reminder.currentRentPerSqm * reminder.areaSqm).toLocaleString('de-CH')}
</Typography>
</Box>
</Box>
</Box>
<Divider />
{/* 5. Pre-Market */}
<Box>
<SectionTitle icon={<Eye size={15} color="#64748b" />}>Pre-Market Risiko</SectionTitle>
<Box className="flex flex-col gap-2">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: SHADOW_RISK_COLOR[reminder.shadowMarketRisk],
}}
/>
<Typography variant="body2" sx={{ fontWeight: 600, color: SHADOW_RISK_COLOR[reminder.shadowMarketRisk] }}>
{SHADOW_RISK_LABEL[reminder.shadowMarketRisk]}
</Typography>
</Box>
<Tooltip title="Nur Anzeige — Änderung über Objektverwaltung">
<FormControlLabel
control={<Switch checked={reminder.schattenmarktEnabled} size="small" readOnly />}
label={
<Typography variant="caption">
Pre-Market aktiviert
</Typography>
}
/>
</Tooltip>
</Box>
</Box>
<Divider />
{/* 6. Note */}
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Notiz</Typography>
<TextField
multiline
rows={3}
@@ -213,24 +341,24 @@ export function ReminderDetailDrawer() {
/>
</Box>
{/* Actions */}
{(reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED) && (
{/* 4. Aktionen */}
{isActionable && (
<>
<Divider />
<Box className="flex flex-col gap-2">
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Aktionen</Typography>
<Box className="flex gap-2">
<Divider sx={{ my: 2.5 }} />
<Box sx={{ mb: 2.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>
Aktionen
</Typography>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
size="small"
variant="contained"
color="success"
sx={{ textTransform: 'none', flex: 1 }}
sx={{ textTransform: 'none', flex: 1, fontWeight: 600 }}
onClick={() => complete.mutate({ id: reminder.id, note: noteValue || undefined })}
>
Erledigt
</Button>
<Button
size="small"
variant="outlined"
color="error"
sx={{ textTransform: 'none', flex: 1 }}
@@ -239,7 +367,7 @@ export function ReminderDetailDrawer() {
Verwerfen
</Button>
</Box>
<Box className="flex gap-2 items-center">
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type="date"
size="small"
@@ -248,10 +376,10 @@ export function ReminderDetailDrawer() {
sx={{ flex: 1, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
/>
<Button
size="small"
variant="outlined"
size="small"
disabled={!snoozeDate}
sx={{ textTransform: 'none', whiteSpace: 'nowrap' }}
sx={{ textTransform: 'none', whiteSpace: 'nowrap', color: '#64748b', borderColor: '#e2e8f0' }}
onClick={() => snoozeDate && snooze.mutate({ id: reminder.id, until: snoozeDate })}
>
Schlummern bis
@@ -261,18 +389,18 @@ export function ReminderDetailDrawer() {
</>
)}
<Divider />
<Divider sx={{ mb: 2.5 }} />
{/* 7. Activity */}
{/* 5. Verlauf */}
<Box>
<SectionTitle icon={<Activity size={15} color="#64748b" />}>Aktivitätslog</SectionTitle>
<SectionTitle icon={<Activity size={14} color="#64748b" />}>Verlauf</SectionTitle>
<Box>
{[...reminder.activity].reverse().map((entry, i) => (
<ActivityEntry key={i} entry={entry} />
))}
</Box>
</Box>
</>
</Box>
)}
</Box>
</Drawer>
+2 -2
View File
@@ -28,7 +28,7 @@ const HORIZON_CONFIG: { key: FilterHorizon; label: string; color: string; bg: st
{ key: 'LATER', label: 'Später', color: '#64748b', bg: '#f8fafc', border: '#e8e7e4' },
]
const LIST_COLS = '100px 130px 1fr 140px 90px 100px 90px'
const LIST_COLS = '90px 130px 1fr 140px 80px 90px'
function applyFilters(
reminders: Reminder[],
@@ -141,7 +141,7 @@ export function ReminderFeed() {
borderBottom: '1px solid #e2e8f0',
}}
>
{['Priorität', 'Typ', 'Objekt / Mieter', 'Fälligkeit', 'Fläche', 'Status', 'Aktionen'].map(h => (
{['Priorität', 'Typ', 'Objekt / Mieter', 'Fälligkeit', 'Status', 'Aktionen'].map(h => (
<Typography key={h} variant="caption" sx={{ fontWeight: 600, color: '#64748b', fontSize: '0.7rem' }}>
{h}
</Typography>
+87 -45
View File
@@ -6,7 +6,7 @@ import { useReminderStore } from '../../stores/reminderStore'
import type { FilterHorizon } from '../../stores/reminderStore'
import { ReminderType } from '../../domain/reminder'
interface KpiCardProps {
interface SecondaryKpiProps {
icon: React.ReactNode
label: string
value: number | string
@@ -15,36 +15,35 @@ interface KpiCardProps {
onClick: () => void
}
function KpiCard({ icon, label, value, color, active, onClick }: KpiCardProps) {
function SecondaryKpi({ icon, label, value, color, active, onClick }: SecondaryKpiProps) {
return (
<Box
onClick={onClick}
sx={{
flex: 1,
px: 1.5,
py: 1.125,
px: 2,
py: 1.5,
display: 'flex',
alignItems: 'center',
gap: 1,
border: active ? `1.5px solid ${color}` : '1px solid #e8e7e4',
borderRadius: 1.5,
bgcolor: active ? `${color}08` : 'white',
flexDirection: 'column',
gap: 0.25,
cursor: 'pointer',
borderRadius: 1.5,
border: active ? `1.5px solid ${color}` : '1px solid #e8e7e4',
bgcolor: active ? `${color}08` : 'white',
transition: 'border-color 0.15s, background 0.15s',
minWidth: 0,
'&:hover': { bgcolor: `${color}06`, borderColor: color },
}}
>
<Box sx={{ opacity: active ? 1 : 0.55, flexShrink: 0 }}>{icon}</Box>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 700, color, lineHeight: 1.1, fontSize: '1.1rem' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ opacity: active ? 1 : 0.5, flexShrink: 0 }}>{icon}</Box>
<Typography sx={{ fontWeight: 700, color, fontSize: '1.25rem', lineHeight: 1 }}>
{value}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', whiteSpace: 'nowrap', fontSize: '0.68rem' }}>
</Box>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
{label}
</Typography>
</Box>
</Box>
)
}
@@ -60,63 +59,105 @@ export function ReminderKpiBar() {
)
function toggleHorizon(h: FilterHorizon) {
if (filterHorizon === h) {
setFilterHorizon('ALL')
} else {
setFilterHorizon(h)
setFilterType('ALL') // clear type filter so count matches
}
if (filterHorizon === h) setFilterHorizon('ALL')
else { setFilterHorizon(h); setFilterType('ALL') }
}
function togglePreMarket() {
if (filterType === ReminderType.SCHATTENMARKT_RELEASE) {
setFilterType('ALL')
} else {
setFilterType(ReminderType.SCHATTENMARKT_RELEASE)
setFilterHorizon('ALL') // clear horizon filter so count matches
}
if (filterType === ReminderType.SCHATTENMARKT_RELEASE) setFilterType('ALL')
else { setFilterType(ReminderType.SCHATTENMARKT_RELEASE); setFilterHorizon('ALL') }
}
if (isLoading) {
return (
<Box className="flex gap-3">
{[1, 2, 3, 4].map(i => (
<Skeleton key={i} variant="rounded" height={68} sx={{ flex: 1 }} />
))}
<Box sx={{ display: 'flex', gap: 2 }}>
<Skeleton variant="rounded" height={76} sx={{ flex: '0 0 220px' }} />
<Box sx={{ flex: 1, display: 'flex', gap: 2 }}>
{[1, 2, 3].map(i => <Skeleton key={i} variant="rounded" height={76} sx={{ flex: 1 }} />)}
</Box>
</Box>
)
}
const ins = data ?? { overdueCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 }
const isOverdue = ins.overdueCount > 0
const overdueActive = filterHorizon === 'OVERDUE'
return (
<Box className="flex gap-3">
<KpiCard
icon={<AlertOctagon size={18} color="#dc2626" />}
label="Überfällig"
value={ins.overdueCount}
color="#dc2626"
active={filterHorizon === 'OVERDUE'}
<Box sx={{ display: 'flex', gap: 2, alignItems: 'stretch' }}>
{/* Focal card — Überfällig */}
<Box
onClick={() => toggleHorizon('OVERDUE')}
/>
<KpiCard
icon={<Calendar size={18} color="#ea580c" />}
sx={{
flex: '0 0 200px',
px: 2.5,
py: 2,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 0.5,
borderRadius: 1.5,
cursor: 'pointer',
border: overdueActive
? '1.5px solid #dc2626'
: isOverdue
? '1.5px solid #fca5a5'
: '1px solid #e8e7e4',
bgcolor: overdueActive
? '#fef2f2'
: isOverdue
? '#fff5f5'
: 'white',
transition: 'border-color 0.15s, background 0.15s',
'&:hover': { borderColor: '#dc2626', bgcolor: '#fef2f2' },
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AlertOctagon size={16} color={isOverdue ? '#dc2626' : '#cbd5e1'} />
<Typography
sx={{
fontWeight: 800,
fontSize: '2rem',
lineHeight: 1,
color: isOverdue ? '#dc2626' : '#94a3b8',
letterSpacing: '-0.02em',
}}
>
{ins.overdueCount}
</Typography>
</Box>
<Typography
variant="caption"
sx={{
color: isOverdue ? '#dc2626' : '#94a3b8',
fontWeight: isOverdue ? 600 : 400,
fontSize: '0.75rem',
}}
>
Überfällig
</Typography>
</Box>
{/* Secondary KPIs */}
<Box sx={{ flex: 1, display: 'flex', gap: 2 }}>
<SecondaryKpi
icon={<Calendar size={14} color="#ea580c" />}
label="Diese Woche"
value={ins.dueThisWeek}
color="#ea580c"
active={filterHorizon === 'THIS_WEEK'}
onClick={() => toggleHorizon('THIS_WEEK')}
/>
<KpiCard
icon={<CalendarDays size={18} color="#0369a1" />}
<SecondaryKpi
icon={<CalendarDays size={14} color="#0369a1" />}
label="Dieser Monat"
value={ins.dueThisMonth}
color="#0369a1"
active={filterHorizon === 'THIS_MONTH'}
onClick={() => toggleHorizon('THIS_MONTH')}
/>
<KpiCard
icon={<Eye size={18} color="#be185d" />}
<SecondaryKpi
icon={<Eye size={14} color="#be185d" />}
label="Pre-Market Risiko"
value={ins.schattenmarktReadyCount}
color="#be185d"
@@ -124,5 +165,6 @@ export function ReminderKpiBar() {
onClick={togglePreMarket}
/>
</Box>
</Box>
)
}
+7 -10
View File
@@ -70,10 +70,10 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
onClick={handleRowClick}
sx={{
display: 'grid',
gridTemplateColumns: '100px 130px 1fr 140px 90px 100px 90px',
gridTemplateColumns: '90px 130px 1fr 140px 80px 90px',
alignItems: 'center',
gap: 1,
pl: 1.5,
pl: 2,
pr: 2,
py: 1.25,
borderBottom: '1px solid #f1f5f9',
@@ -84,8 +84,8 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
transition: 'background-color 0.1s',
}}
>
{/* Priority badge */}
<Box>
{/* Priority dot */}
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<ReminderPriorityBadge priority={reminder.priority} />
</Box>
@@ -112,13 +112,9 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
<ReminderDaysIndicator dueDate={reminder.dueDate} />
</Box>
{/* Area */}
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{reminder.areaSqm.toLocaleString('de-CH')} m²
</Typography>
{/* Status chip */}
{/* Status — only shown for non-default states */}
<Box>
{reminder.status !== ReminderStatus.ACTIVE && (
<Chip
label={STATUS_LABEL[reminder.status]}
size="small"
@@ -131,6 +127,7 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
border: `1px solid ${STATUS_STYLE[reminder.status]?.border ?? '#e2e8f0'}`,
}}
/>
)}
</Box>
{/* Actions */}
@@ -5,7 +5,7 @@ const CONFIG: Record<ReminderPriority, { color: string; label: string }> = {
URGENT: { color: '#dc2626', label: 'Dringend' },
HIGH: { color: '#ea580c', label: 'Hoch' },
MEDIUM: { color: '#ca8a04', label: 'Mittel' },
LOW: { color: '#64748b', label: 'Niedrig' },
LOW: { color: '#94a3b8', label: 'Niedrig' },
}
interface Props {
@@ -15,9 +15,9 @@ interface Props {
export function ReminderPriorityBadge({ priority }: Props) {
const { color, label } = CONFIG[priority]
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: color, flexShrink: 0 }} />
<Typography variant="caption" sx={{ color, fontWeight: 600, fontSize: '0.7rem' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6 }}>
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: color, flexShrink: 0 }} />
<Typography sx={{ color, fontWeight: 600, fontSize: '0.7rem', lineHeight: 1, whiteSpace: 'nowrap' }}>
{label}
</Typography>
</Box>
+133
View File
@@ -0,0 +1,133 @@
import { useState } from 'react'
import { Box, Button, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import type { Property, PropertyUnit } from '../../domain/property'
import { useUpdateUnit } from '../../hooks/useProperties'
import { FIT_OUT_LABELS } from '../../lib/constants'
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
const NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
interface UnitDraft {
rentPricePerSqm?: number
availableFrom?: string
fitOut?: string
fitOutByLandlord?: boolean
mieterausbaubeitragPerSqm?: number
parkingSpots?: number
}
interface Props {
property: Property
unit: PropertyUnit
onClose: () => void
}
/** Shared per-unit editor: conditions (price, availability), fit-out group, parking allocation. */
export function UnitFieldsEditor({ property, unit, onClose }: Props) {
const updateUnit = useUpdateUnit(property.id)
const [d, setD] = useState<UnitDraft>(() => ({
rentPricePerSqm: unit.rentPricePerSqm ?? property.rentPricePerSqm,
availableFrom: unit.availableFrom,
fitOut: unit.fitOut ?? '',
fitOutByLandlord: unit.fitOutByLandlord,
mieterausbaubeitragPerSqm: unit.mieterausbaubeitragPerSqm,
parkingSpots: unit.parkingSpots,
}))
const effFit = d.fitOut || property.hardFacts?.fitOut
const needsBuild = NEEDS_BUILD.has(effFit ?? '')
function save() {
const fit = (d.fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined
const nb = fit === 'SHELL' || fit === 'BASIC'
updateUnit.mutate({
unitId: unit.id,
data: {
rentPricePerSqm: d.rentPricePerSqm,
availableFrom: d.availableFrom || undefined,
fitOut: fit,
fitOutByLandlord: nb ? d.fitOutByLandlord : undefined,
mieterausbaubeitragPerSqm: nb && d.fitOutByLandlord === false ? d.mieterausbaubeitragPerSqm : undefined,
parkingSpots: d.parkingSpots,
},
})
onClose()
}
return (
<Box sx={{ px: 2, py: 1.5, bgcolor: '#f8fafc', borderTop: `1px solid ${DS_BORDER.default}` }}>
{/* Konditionen */}
<Box sx={{ display: 'grid', gridTemplateColumns: unit.available ? '1fr 1fr 1fr' : '1fr 1fr', gap: 1.25 }}>
<TextField
label="Preis (CHF/m²/Jahr)" type="number" size="small"
value={d.rentPricePerSqm ?? ''}
onChange={e => setD(p => ({ ...p, rentPricePerSqm: parseInt(e.target.value) || undefined }))}
slotProps={{ htmlInput: { min: 0, step: 10 } }}
helperText="Leer = Objekt-Preis"
/>
{unit.available && (
<TextField
label="Verfügbar ab" type="date" size="small"
slotProps={{ inputLabel: { shrink: true } }}
value={d.availableFrom ?? ''}
onChange={e => setD(p => ({ ...p, availableFrom: e.target.value }))}
helperText="Leer = sofort"
/>
)}
<TextField
label="Parkplätze (Zuteilung)" type="number" size="small"
value={d.parkingSpots ?? ''}
onChange={e => setD(p => ({ ...p, parkingSpots: parseInt(e.target.value) || undefined }))}
slotProps={{ htmlInput: { min: 0 } }}
helperText={property.hardFacts?.parking != null ? `Objekt-Pool: ${property.hardFacts.parking}` : 'Leer = Objekt-Wert'}
/>
</Box>
{/* Ausbau — Standard + Träger + MAB als Gruppe */}
<Box sx={{ mt: 1.5, p: 1.5, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, bgcolor: 'white' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.4, display: 'block', mb: 1 }}>
Ausbau
</Typography>
<TextField
select size="small" label="Ausbaustandard" fullWidth
value={d.fitOut ?? ''}
onChange={e => setD(p => ({ ...p, fitOut: e.target.value }))}
>
<MenuItem value="">Wie Objekt{property.hardFacts?.fitOut ? ` (${FIT_OUT_LABELS[property.hardFacts.fitOut] ?? property.hardFacts.fitOut})` : ''}</MenuItem>
<MenuItem value="SHELL">{FIT_OUT_LABELS.SHELL}</MenuItem>
<MenuItem value="BASIC">{FIT_OUT_LABELS.BASIC}</MenuItem>
<MenuItem value="FULL">{FIT_OUT_LABELS.FULL}</MenuItem>
<MenuItem value="PREMIUM">{FIT_OUT_LABELS.PREMIUM}</MenuItem>
</TextField>
{needsBuild && (
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1.25 }}>
<Typography variant="caption" color="text.secondary">Wer baut aus?</Typography>
<ToggleButtonGroup
exclusive size="small"
value={d.fitOutByLandlord === undefined ? null : d.fitOutByLandlord ? 'landlord' : 'tenant'}
onChange={(_, v) => { if (v) setD(p => ({ ...p, fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) })) }}
>
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter</ToggleButton>
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter</ToggleButton>
</ToggleButtonGroup>
{d.fitOutByLandlord === false && (
<TextField
label="MAB (CHF/m²)" type="number" size="small" sx={{ width: 150 }}
value={d.mieterausbaubeitragPerSqm ?? ''}
onChange={e => setD(p => ({ ...p, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))}
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
/>
)}
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}>leer = wie Objekt</Typography>
</Box>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1, mt: 1.5, justifyContent: 'flex-end' }}>
<Button size="small" onClick={onClose} sx={{ textTransform: 'none', color: DS_TEXT.muted }}>Abbrechen</Button>
<Button size="small" variant="contained" onClick={save} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>Speichern</Button>
</Box>
</Box>
)
}
+54 -2
View File
@@ -1,13 +1,16 @@
import { useMemo, useState } from 'react'
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
import { ChevronDown, ChevronUp, ExternalLink, FileText, Layers, Users } from 'lucide-react'
import { ChevronDown, ChevronUp, ExternalLink, FileText, Layers, Pencil, Users } from 'lucide-react'
import { useNavigate } from 'react-router'
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
import type { Lease } from '../../domain/lease'
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 { FIT_OUT_LABELS } from '../../lib/constants'
import { resolveUnitFacts } from '../../lib/unitFacts'
import { floorLabel } from './PropertyDetailHelpers'
import { UnitFieldsEditor } from './UnitFieldsEditor'
function MatchPill({ m }: { m: UnitNeedMatch }) {
const bg = m.matchScore >= 85 ? '#fef3c7' : '#e0e7ff'
@@ -50,6 +53,7 @@ export function UnitStructurePanel({ p }: { p: Property }) {
const [expandedUnit, setExpandedUnit] = useState<string | null>(null)
const [editingFlexUnit, setEditingFlexUnit] = useState<string | null>(null)
const [flexDraft, setFlexDraft] = useState<Record<string, number | undefined>>({})
const [editingUnit, setEditingUnit] = useState<string | null>(null)
const updateUnit = useUpdateUnit(p.id)
const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
@@ -229,6 +233,7 @@ export function UnitStructurePanel({ p }: { p: Property }) {
propertyId: p.id,
floor: floorLabel(u),
fitOut: p.hardFacts?.fitOut,
fitOutByLandlord: p.hardFacts?.fitOutByLandlord,
parking: p.hardFacts?.parking,
ceilingHeight: p.hardFacts?.ceilingHeightM,
mieterausbaubeitragPerSqm: p.hardFacts?.mieterausbaubeitragPerSqm,
@@ -285,9 +290,19 @@ export function UnitStructurePanel({ p }: { p: Property }) {
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</IconButton>
)}
{/* Edit price / availability per unit */}
<Tooltip title="Preis & Verfügbarkeit bearbeiten">
<IconButton
size="small"
onClick={() => setEditingUnit(editingUnit === u.id ? null : u.id)}
sx={{ p: 0.25, color: editingUnit === u.id ? '#2563eb' : DS_TEXT.disabled }}
>
<Pencil size={11} />
</IconButton>
</Tooltip>
</Box>
{/* Area */}
{/* Area + Preis + Verfügbarkeit pro Einheit */}
<Box sx={{ textAlign: 'right' }}>
<Typography variant="caption" sx={{ color: '#374151', fontWeight: u.available ? 600 : 400 }}>
{(u.offeredSqm ?? u.areaSqm).toLocaleString('de-CH')} m²
@@ -297,9 +312,46 @@ export function UnitStructurePanel({ p }: { p: Property }) {
ab {u.minLettableSqm} m²
</Typography>
)}
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.6rem', display: 'block' }}>
CHF {(u.rentPricePerSqm ?? p.rentPricePerSqm).toLocaleString('de-CH')}/m²
</Typography>
{u.available && (() => {
const av = u.availableFrom ?? u.schattenmarktRelease?.availableFrom
return (
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, fontSize: '0.6rem', display: 'block' }}>
{av ? `ab ${new Date(av).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })}` : 'sofort'}
</Typography>
)
})()}
</Box>
</Box>
{/* Always-visible per-unit details (read) */}
{(() => {
const f = resolveUnitFacts(p, u)
const fitLabel = f.fitOut ? (FIT_OUT_LABELS[f.fitOut] ?? f.fitOut) : null
const needsBuild = f.fitOut === 'SHELL' || f.fitOut === 'BASIC'
const parts: string[] = []
if (fitLabel) parts.push(`Ausbau: ${fitLabel}`)
if (needsBuild) parts.push(`Träger: ${f.fitOutByLandlord ? 'Vermieter' : 'Mieter'}`)
if (needsBuild && !f.fitOutByLandlord && f.mabPerSqm > 0) parts.push(`MAB CHF ${f.mabPerSqm}/m²`)
if (f.parkingSpots != null) parts.push(`${f.parkingSpots} PP`)
if (u.expectedRentPerSqm) parts.push(`erwartet CHF ${u.expectedRentPerSqm}/m²`)
if (parts.length === 0) return null
return (
<Box sx={{ px: 1.5, pb: 0.625, mt: -0.25, borderBottom: isLastRow ? 'none' : `1px solid ${DS_BORDER.muted}` }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.63rem' }}>
{parts.join(' · ')}
</Typography>
</Box>
)
})()}
{/* Inline editor: full per-unit fields */}
<Collapse in={editingUnit === u.id}>
<UnitFieldsEditor property={p} unit={u} onClose={() => setEditingUnit(null)} />
</Collapse>
{/* Expanded: matches for free units */}
<Collapse in={isExpanded && u.available}>
<Box sx={{ px: 2, py: 1, bgcolor: DS_SURFACE.neutral.bg, borderBottom: !isLastUnit ? `1px solid ${DS_BORDER.default}` : 'none' }}>
+10
View File
@@ -20,10 +20,20 @@ export interface InquiryMessage {
export type InquiryStatus = 'new' | 'in_progress' | 'answered' | 'archived'
/** Konversationstyp: Nachfrager-initiierte Anfrage vs. Verwaltung-initiiertes Angebot. */
export type InquiryKind = 'INQUIRY' | 'OFFER'
export interface Inquiry {
id: string
/** Supply-Organisation (Eigentümer/Verwaltung des Objekts). */
organizationId: string
/** Nachfrager-Organisation — für das Demand-Postfach. */
tenantOrgId?: string
/** INQUIRY (Demand→Supply) oder OFFER (Supply→Demand). Default INQUIRY. */
kind?: InquiryKind
propertyId: string
/** Bei OFFER: alle im Angebot enthaltenen Objekte. */
offeredPropertyIds?: string[]
needId?: string
tenantName: string
tenantCompany?: string
+2
View File
@@ -5,6 +5,8 @@ export interface LatentNeed {
id: string
title: string
tenantCompany?: string
/** Nachfrager-Organisation — Empfänger eines Angebots (Demo-Default-Routing: org-mobimo). */
tenantOrgId?: string
assetType: AssetType
desiredLocation: string
sizeRange: { min: number; max: number }
+7
View File
@@ -63,6 +63,12 @@ export interface WeightedPreference {
description?: string
}
export interface NotificationConfig {
enabled: boolean
minScore: number // 0100, default 80
emailAddress?: string
}
export interface Need {
id: string
companyName: string
@@ -103,6 +109,7 @@ export interface Need {
confidenceInCriteria: number
extractedFromText?: string
notes?: string
notificationConfig?: NotificationConfig
organizationId?: string
createdAt: string
updatedAt: string
+2
View File
@@ -56,6 +56,8 @@ export interface PropertyHardFacts {
floor?: number
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
mieterausbaubeitragPerSqm?: number
/** true = Vermieter übernimmt den Ausbau & preist ihn in die Miete ein (kein Aufschlag, kein MAB). */
fitOutByLandlord?: boolean
parking?: number
publicTransportScore?: number
usageType?: string
+8
View File
@@ -17,6 +17,14 @@ export interface PropertyUnit {
areaSqm: number
available: boolean
rentPricePerSqm?: number // annual CHF/m²; falls back to property.rentPricePerSqm
availableFrom?: string // explicit availability date for a free unit (ISO); else derived from leases
// Unit-level overrides — fall back to property.hardFacts.* when unset.
// Relevant where units of one property are matched individually (pre-market, multi-unit).
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
fitOutByLandlord?: boolean // wer trägt den Ausbau (Default: Objekt-Wert)
mieterausbaubeitragPerSqm?: number // MAB der Einheit (Default: Objekt-Wert)
parkingSpots?: number // aus dem Objekt-Pool zugeteilte Parkplätze
expectedRentPerSqm?: number // erwarteter künftiger Preis (Pre-Market), ≠ heutige Sollmiete
leases?: Lease[] // Mietverträge — current, historical, future
/** @deprecated Use leases[].tenant.companyName */
currentTenant?: string
@@ -181,6 +181,27 @@ describe('calculateScore', () => {
expect(output.excludedReason).toBeTruthy()
})
// ── Ausbau-Annuität im Budget-Faktor ────────────────────────────────────────
function budgetFactor(need: ReturnType<typeof makeNeed>, prop: ReturnType<typeof makeProperty>) {
return calculateScore(need, prop).allHardFactors.find(f => f.criterion === 'budget')!
}
it('tenant-borne SHELL lowers the budget factor vs a fitted-out (FULL) property', () => {
const need = makeNeed({ budgetRange: { maxPerSqm: 50, currency: 'CHF' } })
const base = { rentPricePerSqm: 48, areaSqm: 300 }
const shell = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL' } })
const full = makeProperty({ ...base, hardFacts: { fitOut: 'FULL' } })
expect(budgetFactor(need, shell).score).toBeLessThan(budgetFactor(need, full).score)
})
it('landlord-borne SHELL keeps the budget factor (no fit-out surcharge)', () => {
const need = makeNeed({ budgetRange: { maxPerSqm: 50, currency: 'CHF' } })
const base = { rentPricePerSqm: 48, areaSqm: 300 }
const tenant = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL' } })
const landlord = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL', fitOutByLandlord: true } })
expect(budgetFactor(need, landlord).score).toBeGreaterThan(budgetFactor(need, tenant).score)
})
it('produces identical scores on repeated calls with the same inputs (determinism)', () => {
const need = makeNeed()
const prop = makeProperty()
+13 -11
View File
@@ -13,6 +13,7 @@ import type {
import { getCityIntelligence } from '../../lib/locationIntelligence'
import { formatUnitTitle, formatMultiUnitFloors, getUnitAvailability } from '../../domain/unit'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { resolveUnitFacts } from '../../lib/unitFacts'
import { AvailabilityStatus } from '../../domain/enums'
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
@@ -152,29 +153,30 @@ export function buildMatchCardViewModel(
preMarketUnit: unit,
preMarketAllUnits: property?.units,
fitOutLabel: (() => {
const fitOut = property?.hardFacts?.fitOut
// Einheit-Werte haben Vorrang vor Objekt-Werten (Pre-Market / Mehr-Einheiten)
if (!property) return undefined
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
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`
if (mabPerSqm) return `${LABELS[fitOut]} + CHF ${mabPerSqm} MAB`
return LABELS[fitOut] ?? fitOut
})(),
fitOutViable: (() => {
const fitOut = property?.hardFacts?.fitOut
if (!property) return undefined
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
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
if (fitOut === 'BASIC' && mabPerSqm >= 150) return true
if (fitOut === 'SHELL' && mabPerSqm >= 350) return true
return undefined
})(),
fitOutInvestment: (() => {
const fitOut = property?.hardFacts?.fitOut
if (!property) return undefined
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
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
const area = unit?.areaSqm ?? property.areaSqm ?? 0
return calcFitOutInvestment(fitOut, area, mabPerSqm, 0) ?? undefined
})(),
isDivisible: property?.units ? property.units.length > 1 : false,
minDivisibleUnitSqm: (() => {
+23 -9
View File
@@ -15,6 +15,7 @@ import { analyzeTradeOffs, analyzeRisks, identifyMissingData } from './tradeOffA
import { generateNextBestActions } from './rankingEngine'
import { softFactorEnrichmentService } from '../../services/softFactorEnrichmentService'
import { scoreMustHaves } from './mustHaveScorer'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
// ── Profile resolution ────────────────────────────────────────────────────────
@@ -159,7 +160,16 @@ function scoreLocation(need: Need, property: Property, weight: number): ScoreFac
function scoreBudget(need: Need, property: Property, weight: number): ScoreFactor {
const maxBudget = need.budgetRange?.maxPerSqm ?? 0
const rent = property.rentPricePerSqm
// Effektive Jahresbelastung: Kaltmiete + annuitätischer Ausbau-Aufschlag (nur wenn Mieter ausbaut)
const { effectivePerSqm: rent, fitOutPerSqm } = effectiveAnnualBurdenPerSqm({
fitOut: property.hardFacts?.fitOut,
rentPricePerSqm: property.rentPricePerSqm,
mabPerSqm: property.hardFacts?.mieterausbaubeitragPerSqm ?? 0,
fitOutByLandlord: property.hardFacts?.fitOutByLandlord,
})
// Erklärt den Ausbau-Anteil transparent, wenn er den Score beeinflusst
const fitOutNote = fitOutPerSqm > 0 ? ` (inkl. CHF ${fitOutPerSqm}/m² Ausbau-Annuität)` : ''
let score: number
let explanation: string
@@ -171,18 +181,18 @@ function scoreBudget(need: Need, property: Property, weight: number): ScoreFacto
const ratio = rent / maxBudget
// Very cheap can indicate quality issues — slight penalty below 50% of budget
score = ratio >= 0.50 ? 100 : 88
explanation = `Miete CHF ${rent}/m² liegt ${Math.round((1 - ratio) * 100)}% unter Budget CHF ${maxBudget}/m²`
explanation = `Effektive Belastung CHF ${rent}/m² liegt ${Math.round((1 - ratio) * 100)}% unter Budget CHF ${maxBudget}/m²${fitOutNote}`
} else {
const overRatio = rent / maxBudget
if (overRatio <= HARD_FILTER.BUDGET_MODERATE_RATIO) {
score = 75
explanation = `Miete CHF ${rent}/m² leicht über Budget (+${Math.round((overRatio - 1) * 100)}%)`
explanation = `Effektive Belastung CHF ${rent}/m² leicht über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
} else if (overRatio <= HARD_FILTER.BUDGET_SEVERE_RATIO) {
score = 45
explanation = `Miete CHF ${rent}/m² merklich über Budget (+${Math.round((overRatio - 1) * 100)}%)`
explanation = `Effektive Belastung CHF ${rent}/m² merklich über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
} else {
score = 20
explanation = `Miete CHF ${rent}/m² stark über Budget (+${Math.round((overRatio - 1) * 100)}%)`
explanation = `Effektive Belastung CHF ${rent}/m² stark über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
}
}
@@ -610,8 +620,12 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
]
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
// ── Final score: hard/soft weighted sum, clamped 0–100 ───────────────────
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40 - hardFilter.severePenalty
// ── Final score: hard/soft weighted sum + Datenqualität/Konfidenz-Modifikatoren,
// abzgl. severePenalty, clamped 0100 (Trust-first: schwache Datenlage senkt den Score) ──
const dataQualityModifier = calcDataQualityModifier(property)
const confidenceModifier = calcConfidenceModifier(property)
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40
+ dataQualityModifier + confidenceModifier - hardFilter.severePenalty
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
// ── Factor classification — only use weighted soft factors for positive/negative ──
@@ -639,8 +653,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
finalScore,
hardMatchScore,
softFactorScore,
dataQualityModifier: 0,
confidenceModifier: 0,
dataQualityModifier,
confidenceModifier,
positiveFactors,
negativeFactors,
allHardFactors: hardFactors,
+20 -2
View File
@@ -1,6 +1,6 @@
import { useMutation } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import { aiService, parseListingText } from '../services/aiService'
import type { OfferEmailPayload } from '../services/aiService'
import type { OfferEmailPayload, FitOutAdviceInput, PreMarketRentInput } from '../services/aiService'
export function useParseNeed() {
return useMutation({
@@ -25,3 +25,21 @@ export function useParseListingText() {
mutationFn: (text: string) => parseListingText(text),
})
}
export function useFitOutAdvice(input: FitOutAdviceInput | null) {
return useQuery({
queryKey: ['fitOutAdvice', input],
queryFn: () => aiService.generateFitOutAdvice(input!),
enabled: !!input,
staleTime: Infinity,
})
}
export function usePreMarketRentRecommendation(input: PreMarketRentInput | null) {
return useQuery({
queryKey: ['preMarketRent', input],
queryFn: () => aiService.recommendPreMarketRent(input!),
enabled: !!input && !!input.city,
staleTime: Infinity,
})
}
+28 -3
View File
@@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { inquiryService } from '../services/inquiryService'
import type { InquiryFilters } from '../provider/IInquiryProvider'
import type { Attachment } from '../domain/inquiry'
import type { InquiryFilters, CreateInquiryInput, CreateOfferInput } from '../provider/IInquiryProvider'
import type { Attachment, InquiryMessage } from '../domain/inquiry'
import { useToastStore } from '../stores/toastStore'
export function useActiveInquiries(filters?: InquiryFilters) {
@@ -29,7 +29,7 @@ export function useSendInquiryReply() {
payload,
}: {
inquiryId: string
payload: { subject?: string; body: string; attachments?: Attachment[] }
payload: { subject?: string; body: string; attachments?: Attachment[]; senderType?: InquiryMessage['senderType']; senderName?: string }
}) => inquiryService.sendInquiryReply(inquiryId, payload),
onSuccess: (_data, { inquiryId }) => {
qc.invalidateQueries({ queryKey: ['inquiry', inquiryId] })
@@ -41,6 +41,31 @@ export function useSendInquiryReply() {
})
}
/** Demand→Supply: neue Anfrage erstellen (ersetzt die alte Zustand-Insel). */
export function useCreateInquiry() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: CreateInquiryInput) => inquiryService.createInquiry(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['inquiries'] })
},
onError: () => {
useToastStore.getState().showToast('Anfrage konnte nicht gesendet werden.', 'error')
},
})
}
/** Supply→Demand: Angebot als Konversation im Nachfrager-Postfach materialisieren. */
export function useCreateOffer() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: CreateOfferInput) => inquiryService.createOffer(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['inquiries'] })
},
})
}
export function useMarkThreadAsRead() {
const qc = useQueryClient()
return useMutation({
+25 -3
View File
@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { needService } from '../services/needService'
import type { CreateNeedInput } from '../domain/need'
import { useSessionStore } from '../stores/sessionStore'
import type { CreateNeedInput, UpdateNeedInput } from '../domain/need'
interface UseNeedsOptions {
refetchOnMount?: boolean | 'always'
@@ -8,9 +9,10 @@ interface UseNeedsOptions {
}
export function useNeeds(options?: UseNeedsOptions) {
const orgId = useSessionStore(s => s.currentUser?.organizationId)
return useQuery({
queryKey: ['needs'],
queryFn: () => needService.getAll(),
queryKey: ['needs', orgId],
queryFn: () => needService.getAll({ organizationId: orgId }),
select: (res) => res.data ?? [],
...options,
})
@@ -36,3 +38,23 @@ export function useCreateNeed() {
},
})
}
export function useUpdateNeed() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, ...data }: { id: string } & UpdateNeedInput) => needService.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['needs'] })
},
})
}
export function useArchiveNeed() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => needService.update(id, { status: 'INACTIVE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['needs'] })
},
})
}
+13 -5
View File
@@ -15,6 +15,8 @@ function validate(fields: {
city: string
areaSqm: string
rentPerSqm: string
fitOut: string
fitOutByLandlord: boolean | undefined
}): string | null {
if (!fields.street.trim()) return 'Strasse erforderlich'
if (!fields.postalCode.trim()) return 'PLZ erforderlich'
@@ -23,6 +25,9 @@ function validate(fields: {
|| Number(fields.areaSqm) <= 0) return 'Gültige Fläche eingeben'
if (!fields.rentPerSqm || isNaN(Number(fields.rentPerSqm))
|| Number(fields.rentPerSqm) <= 0) return 'Gültigen Mietpreis eingeben'
// Bei Rohbau/Edelrohbau muss der Ausbau-Träger aktiv gewählt werden
if ((fields.fitOut === 'SHELL' || fields.fitOut === 'BASIC')
&& fields.fitOutByLandlord === undefined) return 'Bitte wählen, wer den Ausbau trägt (Vermieter oder Mieter)'
return null
}
@@ -43,6 +48,7 @@ export interface NewListingFormState {
// Technical
floor: string
fitOut: string
fitOutByLandlord: boolean | undefined
parking: string
ceilingHeight: string
mieterausbaubeitrag: string
@@ -80,6 +86,7 @@ export interface NewListingFormHandlers {
setSoftLevel: (key: string, value: string) => void
setFloor: (v: string) => void
setFitOut: (v: string) => void
setFitOutByLandlord: (v: boolean | undefined) => void
setParking: (v: string) => void
setCeilingHeight: (v: string) => void
setMieterausbaubeitrag: (v: string) => void
@@ -117,6 +124,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
const [softLevels, setSoftLevels] = useState<Record<string, string>>(() => ({ ...emptySoftLevels(), ...pre.softLevels }))
const [floor, setFloor] = useState(pre.floor ?? '')
const [fitOut, setFitOut] = useState(pre.fitOut ?? '')
const [fitOutByLandlord, setFitOutByLandlord] = useState<boolean | undefined>(pre.fitOutByLandlord)
const [parking, setParking] = useState(pre.parking != null ? String(pre.parking) : '')
const [ceilingHeight, setCeilingHeight]= useState(pre.ceilingHeight != null ? String(pre.ceilingHeight) : '')
const [mieterausbaubeitrag, setMieterausbaubeitrag] = useState(pre.mieterausbaubeitragPerSqm != null ? String(pre.mieterausbaubeitragPerSqm) : '')
@@ -161,7 +169,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
}
function handleSubmit() {
const err = validate({ street, postalCode, city, areaSqm, rentPerSqm })
const err = validate({ street, postalCode, city, areaSqm, rentPerSqm, fitOut, fitOutByLandlord })
if (err) { setError(err); return }
setError(null)
createProperty.mutate(
@@ -169,7 +177,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
assetType, street, houseNumber, postalCode, city,
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
availableFrom, description, softLevels,
floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
floor, fitOut, fitOutByLandlord, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm,
}),
{
@@ -185,7 +193,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('')
setContactName(''); setContactEmail(''); setContactPhone('')
setSoftLevels(emptySoftLevels())
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
setFloor(''); setFitOut(''); setFitOutByLandlord(undefined); setParking(''); setCeilingHeight('')
setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('')
setImages([]); setImageInput(''); setFloorPlanUrl('')
setAiText(''); setAiApplied(false)
@@ -195,7 +203,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
return {
assetType, areaSqm, rentPerSqm, availableFrom, description,
street, houseNumber, postalCode, city,
softLevels, floor, fitOut, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
softLevels, floor, fitOut, fitOutByLandlord, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
contactName, contactEmail, contactPhone,
images, imageInput, floorPlanUrl, aiText, aiApplied,
error, created,
@@ -205,7 +213,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setAssetType, setAreaSqm, setRentPerSqm, setAvailableFrom, setDescription,
setStreet, setHouseNumber, setPostalCode, setCity,
setSoftLevel,
setFloor, setFitOut, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
setFloor, setFitOut, setFitOutByLandlord, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
setContactName, setContactEmail, setContactPhone,
setImageInput, setFloorPlanUrl, setAiText,
addImage, removeImage,
+16
View File
@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { reminderService } from '../services/reminderService'
import type { Reminder } from '../domain/reminder'
import { useToastStore } from '../stores/toastStore'
export function useReminders() {
@@ -69,6 +70,21 @@ export function useSnoozeReminder() {
})
}
export function useCreateReminder() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: Omit<Reminder, 'id' | 'activity' | 'createdAt' | 'updatedAt'>) =>
reminderService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reminders'] })
queryClient.invalidateQueries({ queryKey: ['reminder-insights'] })
},
onError: () => {
useToastStore.getState().showToast('Reminder konnte nicht erstellt werden.', 'error')
},
})
}
export function useUpdateReminder() {
const queryClient = useQueryClient()
return useMutation({
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { estimateMarketRent, suggestFutureRent } from '../rentEstimate'
describe('estimateMarketRent', () => {
// Zürich OFFICE median = 42/Monat → 504/Jahr (getMarketRent rechnet ×12)
it('flags asking rent clearly above the city median as ABOVE', () => {
const est = estimateMarketRent('Zürich', 'OFFICE', 600)
expect(est).not.toBeNull()
expect(est!.verdict).toBe('ABOVE')
expect(est!.deltaPct).toBeGreaterThan(5)
expect(est!.fairRentPerSqm).toBe(504)
})
it('flags asking rent clearly below median as BELOW', () => {
const est = estimateMarketRent('Zürich', 'OFFICE', 400)
expect(est!.verdict).toBe('BELOW')
expect(est!.deltaPct).toBeLessThan(-5)
})
it('treats near-median asking rent as AT (within ±5%)', () => {
const est = estimateMarketRent('Zürich', 'OFFICE', 510)
expect(est!.verdict).toBe('AT')
})
it('returns null for unknown city', () => {
expect(estimateMarketRent('Atlantis', 'OFFICE', 480)).toBeNull()
})
it('suggestFutureRent indexes by the city rent trend', () => {
// Zürich rentTrend12m = +4.2% → 100 * 1.042 = 104 (rounded)
expect(suggestFutureRent('Zürich', 100)).toBe(104)
})
})
+12
View File
@@ -142,3 +142,15 @@ export const FIT_OUT_COST_CHF_PER_SQM: Record<string, { min: number; max: number
// Assumed amortization period for fit-out investment — change here to affect all cost calculations
export const FITOUT_AMORTIZATION_YEARS = 5
// Kalkulationszins p.a. für die annuitätische Amortisation der Ausbauinvestition (Schweizer Richtwert)
export const FITOUT_ANNUITY_RATE = 0.05
// Zentrale Ausbaustandard-Labels — branchenüblich + internationale Synonyme (CAT A/B, Core & Shell).
// Single Source of Truth für Dropdown, Panel, Score, Vergleich.
export const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau · Core & Shell',
BASIC: 'Edelrohbau · CAT A',
FULL: 'Ausgebaut · CAT B',
PREMIUM: 'Vollausgebaut · Plug & Play',
}
+42 -1
View File
@@ -1,15 +1,49 @@
import { FIT_OUT_COST_CHF_PER_SQM } from './constants'
import { FIT_OUT_COST_CHF_PER_SQM, FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE } from './constants'
export interface FitOutInvestment {
grossPerSqm: { min: number; max: number }
mabOffset: number
netPerSqm: { min: number; max: number }
netTotal: { min: number; max: number }
annualBurden: { min: number; max: number }
isFullyCovered: boolean
shortLabel: string
detailLabel: string
}
// Annuitätenfaktor: a = i(1+i)^n / ((1+i)^n 1); bei i=0 → 1/n (lineare Amortisation als Fallback)
export function annuityFactor(rate: number, years: number): number {
if (years <= 0) return 0
if (rate === 0) return 1 / years
const q = Math.pow(1 + rate, years)
return (rate * q) / (q - 1)
}
// Bezugsfertige Stufen — kein Ausbau-Aufschlag (Mieter muss nicht mehr investieren)
const FITOUT_READY = new Set(['FULL', 'PREMIUM'])
/**
* Effektive Jahresbelastung pro m². Grundsatz: die quotierte Miete gilt IMMER als realer Preis
* die Plattform erfindet keinen Aufschlag. Nur wenn der Mieter ausbaut (SHELL/BASIC), wird die
* versteckte Ausbaukost annuitätisch sichtbar gemacht (abzgl. MAB). Übernimmt der Vermieter, ist der
* Ausbau bereits in der Miete enthalten kein Aufschlag, keine Mieter-Capex.
*/
export function effectiveAnnualBurdenPerSqm(p: {
fitOut?: string
rentPricePerSqm: number
mabPerSqm: number
fitOutByLandlord?: boolean
}): { rentPerSqm: number; fitOutPerSqm: number; effectivePerSqm: number } {
if (p.fitOutByLandlord || FITOUT_READY.has(p.fitOut ?? '')) {
return { rentPerSqm: p.rentPricePerSqm, fitOutPerSqm: 0, effectivePerSqm: p.rentPricePerSqm }
}
const bm = FIT_OUT_COST_CHF_PER_SQM[p.fitOut ?? '']
const grossMid = bm ? (bm.min + bm.max) / 2 : 0
const net = Math.max(0, grossMid - p.mabPerSqm)
const fitOutPerSqm = Math.round(net * annuityFactor(FITOUT_ANNUITY_RATE, FITOUT_AMORTIZATION_YEARS))
return { rentPerSqm: p.rentPricePerSqm, fitOutPerSqm, effectivePerSqm: p.rentPricePerSqm + fitOutPerSqm }
}
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`
@@ -34,6 +68,12 @@ export function calcFitOutInvestment(
max: Math.round(netMax * areaSqm),
}
const factor = annuityFactor(FITOUT_ANNUITY_RATE, FITOUT_AMORTIZATION_YEARS)
const annualBurden = {
min: Math.round(netTotal.min * factor),
max: Math.round(netTotal.max * factor),
}
const isFullyCovered = netTotal.max <= 0
const shortLabel = isFullyCovered
@@ -51,6 +91,7 @@ export function calcFitOutInvestment(
mabOffset: mabPerSqm,
netPerSqm: { min: netMin, max: netMax },
netTotal,
annualBurden,
isFullyCovered,
shortLabel,
detailLabel,
+9 -7
View File
@@ -6,9 +6,9 @@ export interface CityIntelligence {
purchasingPowerIndex: number // Kaufkraft-Index (CH = 100)
dominantIndustryClusters: string[]
plannedInfrastructure: { project: string; timeline: string; impact: string }[]
medianRentOffice: number // CHF/m² für Bürofläche
medianRentLogistics: number
medianRentRetail: number
medianRentOffice: number // CHF/m²/Monat (Bürofläche) — getMarketRent rechnet auf Jahr um
medianRentLogistics: number // CHF/m²/Monat
medianRentRetail: number // CHF/m²/Monat
avgDaysOnMarket: number // Durchschnittliche Tage bis Vermietung
demandStrength: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
taxIndexCanton: number // Steuerindex 100 = CH-Mittel
@@ -137,13 +137,15 @@ export function getCityIntelligence(city: string): CityIntelligence | null {
return key ? CITY_INTELLIGENCE[key] : null
}
/** Median-Marktmiete in CHF/m²/**Jahr** (Daten sind monatlich gespeichert → ×12), passend zu property.rentPricePerSqm. */
export function getMarketRent(city: string, assetType: string): number | null {
const intel = getCityIntelligence(city)
if (!intel) return null
if (assetType === 'OFFICE') return intel.medianRentOffice
if (assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL') return intel.medianRentLogistics
if (assetType === 'RETAIL') return intel.medianRentRetail
return intel.medianRentOffice
const monthly =
assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL' ? intel.medianRentLogistics
: assetType === 'RETAIL' ? intel.medianRentRetail
: intel.medianRentOffice
return monthly * 12
}
// ── City coordinates (WGS84) ─────────────────────────────────────────────────
+42
View File
@@ -0,0 +1,42 @@
import { getMarketRent, getCityIntelligence } from './locationIntelligence'
export type RentVerdict = 'BELOW' | 'AT' | 'ABOVE'
export interface RentEstimate {
fairRentPerSqm: number // Median-Marktmiete als faire Benchmark
askingRentPerSqm: number
verdict: RentVerdict // Angebot vs. Markt
deltaPct: number // +über / unter Markt (gerundet)
rationale: string
confidence: 'LOW' | 'MEDIUM' | 'HIGH'
}
/**
* Markt-Einschätzung der Angebotsmiete: Vergleich gegen die Median-Marktmiete
* (aus Standort-Intelligence) plus Leerstand/Miettrend als Kontext. Deterministisch.
*/
export function estimateMarketRent(city: string, assetType: string, askingRentPerSqm: number): RentEstimate | null {
const market = getMarketRent(city, assetType)
const intel = getCityIntelligence(city)
if (market == null || !intel || askingRentPerSqm <= 0) return null
const fair = market
const deltaPct = Math.round(((askingRentPerSqm - fair) / fair) * 100)
const verdict: RentVerdict = deltaPct > 5 ? 'ABOVE' : deltaPct < -5 ? 'BELOW' : 'AT'
const trendNote = `Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}% (12 M)`
const verdictText =
verdict === 'ABOVE' ? `Angebot ${deltaPct}% über Marktmedian.`
: verdict === 'BELOW' ? `Angebot ${Math.abs(deltaPct)}% unter Marktmedian.`
: 'Angebot marktkonform.'
const rationale = `Median ${city}: CHF ${fair}/m² · Leerstand ${intel.vacancyRatePct}% · ${trendNote}. ${verdictText}`
const confidence = intel.demandStrength === 'LOW' ? 'LOW' : intel.avgDaysOnMarket > 70 ? 'MEDIUM' : 'HIGH'
return { fairRentPerSqm: fair, askingRentPerSqm, verdict, deltaPct, rationale, confidence }
}
/** Indexierter Vorschlag für den künftigen Preis (Pre-Market): Heutepreis × (1 + Miettrend). */
export function suggestFutureRent(city: string, currentRentPerSqm: number): number | null {
const intel = getCityIntelligence(city)
if (!intel || currentRentPerSqm <= 0) return null
return Math.round(currentRentPerSqm * (1 + intel.rentTrend12m / 100))
}
+25
View File
@@ -0,0 +1,25 @@
import type { Property, PropertyUnit } from '../domain/property'
export interface ResolvedUnitFacts {
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
mabPerSqm: number
fitOutByLandlord?: boolean
parkingSpots?: number
rentPricePerSqm: number
}
/**
* Effektive Ausbau-/Preis-/Parkplatz-Werte für eine Einheit: Einheit-Wert ?? Objekt-Wert.
* Parkplätze: Einheit-Zuteilung; bei Einzel-Einheit-Objekten der ganze Objekt-Pool als Fallback.
*/
export function resolveUnitFacts(property: Property, unit?: PropertyUnit | null): ResolvedUnitFacts {
const hf = property.hardFacts
const singleUnit = (property.units?.length ?? 0) <= 1
return {
fitOut: unit?.fitOut ?? hf?.fitOut,
mabPerSqm: unit?.mieterausbaubeitragPerSqm ?? hf?.mieterausbaubeitragPerSqm ?? 0,
fitOutByLandlord: unit?.fitOutByLandlord ?? hf?.fitOutByLandlord,
parkingSpots: unit?.parkingSpots ?? (singleUnit ? hf?.parking : undefined),
rentPricePerSqm: unit?.rentPricePerSqm ?? property.rentPricePerSqm,
}
}
+46
View File
@@ -5,6 +5,8 @@ export const mockDemandInquiries: Inquiry[] = [
{
id: 'dinq-001',
organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'INQUIRY',
propertyId: 'prop-technopark',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
@@ -39,6 +41,8 @@ export const mockDemandInquiries: Inquiry[] = [
{
id: 'dinq-002',
organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'INQUIRY',
propertyId: 'prop-hardturmpark',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
@@ -73,6 +77,8 @@ export const mockDemandInquiries: Inquiry[] = [
{
id: 'dinq-003',
organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'INQUIRY',
propertyId: 'prop-sihlcity',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
@@ -130,6 +136,8 @@ export const mockDemandInquiries: Inquiry[] = [
{
id: 'dinq-004',
organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'INQUIRY',
propertyId: 'prop-bahnhofzug',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
@@ -186,6 +194,8 @@ export const mockDemandInquiries: Inquiry[] = [
{
id: 'dinq-005',
organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'INQUIRY',
propertyId: 'prop-dreispitz',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
@@ -238,4 +248,40 @@ export const mockDemandInquiries: Inquiry[] = [
},
],
},
// 6 — OFFER (Verwaltung → Nachfrager, proaktiv aus Latente Anfragen)
{
id: 'off-seed-001',
organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'OFFER',
propertyId: 'prop-001',
offeredPropertyIds: ['prop-001', 'prop-093'],
tenantName: 'Mobimo Management AG',
propertyManagerCompany: 'Wincasa AG',
propertyAddress: '2 Objekte im Angebot',
subject: 'Passende Büroflächen zu Ihrem Bedarf',
message:
'Sehr geehrte Damen und Herren,\n\nbasierend auf Ihrem Suchprofil haben wir zwei passende Büroflächen für Sie zusammengestellt. Details finden Sie im beigefügten Angebot.\n\nFreundliche Grüsse\nWincasa AG',
status: 'new',
unreadCount: 1,
isRead: false,
createdAt: '2026-05-24T10:00:00Z',
updatedAt: '2026-05-24T10:00:00Z',
thread: [
{
id: 'off-seed-001-1',
inquiryId: 'off-seed-001',
senderType: 'supply_user',
senderName: 'Wincasa AG',
subject: 'Passende Büroflächen zu Ihrem Bedarf',
body:
'Sehr geehrte Damen und Herren,\n\nbasierend auf Ihrem Suchprofil haben wir zwei passende Büroflächen für Sie zusammengestellt. Details finden Sie im beigefügten Angebot.\n\nFreundliche Grüsse\nWincasa AG',
attachments: [
{ id: 'off-att-001', fileName: 'Angebot_Bueroflaechen_Zuerich.pdf', fileType: 'application/pdf', fileSize: 312500, generated: true },
],
createdAt: '2026-05-24T10:00:00Z',
},
],
},
]
+170 -36
View File
@@ -2,10 +2,10 @@ import { AssetType } from '../domain/enums'
import type { Need } from '../domain/need'
export const mockNeeds: Need[] = [
// --- need-001: Innovatech AG — OFFICE Zürich ---
// --- need-001: OFFICE Zürich-West ---
{
id: 'need-001',
companyName: 'Innovatech AG',
companyName: 'Bürofläche Zürich-West',
contactName: 'Sandra Meier',
assetType: AssetType.OFFICE,
requiredArea: { min: 600, max: 1000 },
@@ -38,10 +38,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-01T09:00:00Z',
},
// --- need-002: Schweizer Logistik GmbH — LOGISTICS Basel ---
// --- need-002: LOGISTICS Basel ---
{
id: 'need-002',
companyName: 'Schweizer Logistik GmbH',
companyName: 'Logistikfläche Basel',
contactName: 'Thomas Brun',
assetType: AssetType.LOGISTICS,
requiredArea: { min: 1500, max: 4000 },
@@ -72,10 +72,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-04-10T11:00:00Z',
},
// --- need-003: Pharma Holding AG — OFFICE Basel ---
// --- need-003: OFFICE Basel ---
{
id: 'need-003',
companyName: 'Pharma Holding AG',
companyName: 'Bürofläche Basel Repräsentanz',
contactName: 'Ursula Schmid',
assetType: AssetType.OFFICE,
requiredArea: { min: 500, max: 800 },
@@ -110,10 +110,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-05T14:00:00Z',
},
// --- need-004: Retailer Zürich AG — RETAIL Zürich ---
// --- need-004: RETAIL Zürich ---
{
id: 'need-004',
companyName: 'Retailer Zürich AG',
companyName: 'Ladenlokal Zürich Innenstadt',
contactName: 'Marco Colombo',
assetType: AssetType.RETAIL,
requiredArea: { min: 200, max: 500 },
@@ -144,10 +144,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-04-22T08:00:00Z',
},
// --- need-005: TechStart GmbH — OFFICE Zug/Zürich ---
// --- need-005: OFFICE Zug/Zürich ---
{
id: 'need-005',
companyName: 'TechStart GmbH',
companyName: 'Bürofläche Zug / Zürich',
contactName: 'Florian Keller',
assetType: AssetType.OFFICE,
requiredArea: { min: 300, max: 700 },
@@ -179,10 +179,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-08T10:00:00Z',
},
// --- need-006: Lager & Spedition AG — LOGISTICS Winterthur ---
// --- need-006: LOGISTICS Winterthur ---
{
id: 'need-006',
companyName: 'Lager & Spedition AG',
companyName: 'Lagerfläche Winterthur',
contactName: 'Beat Zimmermann',
assetType: AssetType.LOGISTICS,
requiredArea: { min: 1200, max: 3000 },
@@ -209,10 +209,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-09T16:00:00Z',
},
// --- need-007: Creative Studios AG — MIXED Zürich/Bern ---
// --- need-007: MIXED Zürich-West ---
{
id: 'need-007',
companyName: 'Creative Studios AG',
companyName: 'Gewerbefläche Zürich-West',
contactName: 'Nora Hauser',
assetType: AssetType.MIXED,
requiredArea: { min: 800, max: 1500 },
@@ -244,10 +244,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-03T11:00:00Z',
},
// --- need-008: Berner Produzenten GmbH — PRODUCTION Bern ---
// --- need-008: PRODUCTION Bern ---
{
id: 'need-008',
companyName: 'Berner Produzenten GmbH',
companyName: 'Produktionshalle Bern',
contactName: 'Hans Lüthi',
assetType: AssetType.PRODUCTION,
requiredArea: { min: 2000, max: 4000 },
@@ -274,10 +274,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-04-20T12:00:00Z',
},
// --- need-009: Geneva Commerce SA — RETAIL Genf ---
// --- need-009: RETAIL Genf ---
{
id: 'need-009',
companyName: 'Geneva Commerce SA',
companyName: 'Commerce Genève Centre',
contactName: 'Pierre Dupont',
assetType: AssetType.RETAIL,
requiredArea: { min: 150, max: 400 },
@@ -308,10 +308,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-04-18T09:00:00Z',
},
// --- need-011: Stadtladen Bern GmbH — RETAIL Bern Innenstadt ---
// --- need-011: RETAIL Bern Innenstadt ---
{
id: 'need-011',
companyName: 'Stadtladen Bern GmbH',
companyName: 'Ladenlokal Bern Altstadt',
contactName: 'Katrin Müller',
assetType: AssetType.RETAIL,
requiredArea: { min: 200, max: 400 },
@@ -342,10 +342,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-18T10:00:00Z',
},
// --- need-010: St.Galler Büros AG — OFFICE St.Gallen ---
// --- need-010: OFFICE St.Gallen ---
{
id: 'need-010',
companyName: 'St.Galler Büros AG',
companyName: 'Bürofläche St. Gallen',
contactName: 'Brigitte Fässler',
assetType: AssetType.OFFICE,
requiredArea: { min: 400, max: 800 },
@@ -377,10 +377,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-06T13:00:00Z',
},
// --- need-uc-a: Renato's Fahrradhändler — Use Case A ---
// --- need-uc-a: RETAIL Zürich EG ---
{
id: 'need-uc-a',
companyName: 'Velo City GmbH',
companyName: 'Ladenlokal Zürich EG Schaufenster',
contactName: 'Renato Marchetti',
assetType: AssetType.RETAIL,
requiredArea: { min: 120, max: 160 },
@@ -411,10 +411,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-22T10:00:00Z',
},
// --- need-uc-b: Renato's Umzugsfirma — Use Case B ---
// --- need-uc-b: OFFICE Zürich-West klein ---
{
id: 'need-uc-b',
companyName: 'Alp Transit Umzüge GmbH',
companyName: 'Bürofläche Zürich-West klein',
contactName: 'Renato Marchetti',
assetType: AssetType.OFFICE,
requiredArea: { min: 140, max: 200 },
@@ -445,10 +445,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-22T10:00:00Z',
},
// --- need-012: Gewerbe Solutions AG — LIGHT_INDUSTRIAL Pratteln ---
// --- need-012: LIGHT_INDUSTRIAL Pratteln ---
{
id: 'need-012',
companyName: 'Gewerbe Solutions AG',
companyName: 'Gewerbefläche Pratteln',
contactName: 'Andreas Weber',
assetType: AssetType.LIGHT_INDUSTRIAL,
requiredArea: { min: 400, max: 750 },
@@ -477,10 +477,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2026-03-01T09:00:00Z',
},
// --- need-013: Bern Consulting AG — OFFICE Bern ---
// --- need-013: OFFICE Bern ---
{
id: 'need-013',
companyName: 'Bern Consulting AG',
companyName: 'Bürofläche Bern Bahnhof',
contactName: 'Sabine Gerber',
assetType: AssetType.OFFICE,
requiredArea: { min: 180, max: 320 },
@@ -512,10 +512,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2026-04-05T10:00:00Z',
},
// --- need-014: Winterthur Handel GmbH — RETAIL Winterthur ---
// --- need-014: RETAIL Winterthur ---
{
id: 'need-014',
companyName: 'Winterthur Handel GmbH',
companyName: 'Retailfläche Winterthur',
contactName: 'Pascal Brunner',
assetType: AssetType.RETAIL,
requiredArea: { min: 150, max: 250 },
@@ -546,10 +546,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2026-03-20T11:00:00Z',
},
// --- need-015: Luzern Advisory GmbH — OFFICE Luzern ---
// --- need-015: OFFICE Luzern ---
{
id: 'need-015',
companyName: 'Luzern Advisory GmbH',
companyName: 'Bürofläche Luzern',
contactName: 'Markus Bucher',
assetType: AssetType.OFFICE,
requiredArea: { min: 350, max: 600 },
@@ -581,10 +581,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2026-04-10T14:00:00Z',
},
// --- need-uc-c: Renato's Vermögensverwalter — Use Case C ---
// --- need-uc-c: OFFICE Zürich Premium ---
{
id: 'need-uc-c',
companyName: 'Wealth Advisory Partners AG',
companyName: 'Repräsentanzbüro Zürich Premium',
contactName: 'Renato Marchetti',
assetType: AssetType.OFFICE,
requiredArea: { min: 450, max: 560 },
@@ -616,4 +616,138 @@ export const mockNeeds: Need[] = [
createdAt: '2025-05-20T10:00:00Z',
updatedAt: '2025-05-22T10:00:00Z',
},
// --- org-mobimo: eigene Suchprofile der Mobimo Management AG ---
{
id: 'need-mob-001',
companyName: 'Bürofläche Zürich City',
contactName: 'Sandra Koch',
assetType: AssetType.OFFICE,
requiredArea: { min: 300, max: 600 },
preferredLocations: ['Zürich', 'Zürich City', 'Zürich Kreis 1', 'Zürich Kreis 4'],
excludedLocations: [],
budgetRange: { maxPerSqm: 480, maxMonthlyTotal: 20000, currency: 'CHF' },
timing: {
earliestMoveIn: '2025-10-01',
latestMoveIn: '2026-03-01',
contractDurationMonths: 60,
flexibleTiming: true,
},
mustCriteriaText: ['Gute ÖV-Anbindung', 'Repräsentative Lage', 'Klimaanlage'],
softFactors: {
minPrestige: 70,
minAccessibility: 80,
requireParking: false,
maxPublicTransportMinutes: 8,
},
weightingProfile: {
area: 0.18, location: 0.24, budget: 0.16, timing: 0.12,
prestige: 0.10, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.04,
visibility: 0.02, footfall: 0.01, talentAccess: 0.01, esg: 0.01, taxEnvironment: 0.00,
},
confidenceInCriteria: 0.91,
notificationConfig: { enabled: true, minScore: 80, emailAddress: 'sandra.koch@mobimo.ch' },
organizationId: 'org-mobimo',
createdAt: '2025-05-10T09:00:00Z',
updatedAt: '2025-06-01T10:00:00Z',
},
{
id: 'need-mob-002',
companyName: 'Showroom Bern Innenstadt',
contactName: 'Sandra Koch',
assetType: AssetType.RETAIL,
requiredArea: { min: 150, max: 300 },
preferredLocations: ['Bern', 'Bern Innenstadt', 'Bern Gurtengasse', 'Bern Marktgasse'],
excludedLocations: [],
budgetRange: { maxPerSqm: 380, maxMonthlyTotal: 8000, currency: 'CHF' },
timing: {
earliestMoveIn: '2025-11-01',
latestMoveIn: '2026-06-01',
contractDurationMonths: 36,
flexibleTiming: false,
},
mustCriteriaText: ['Schaufenster Erdgeschoss', 'Hoher Publikumsverkehr', 'Liefermöglichkeit'],
softFactors: {
minPrestige: 65,
minAccessibility: 75,
requireParking: false,
maxPublicTransportMinutes: 5,
},
weightingProfile: {
area: 0.14, location: 0.28, budget: 0.15, timing: 0.10,
prestige: 0.08, accessibility: 0.07, expansionPotential: 0.02, flexibility: 0.03,
visibility: 0.08, footfall: 0.04, talentAccess: 0.00, esg: 0.01, taxEnvironment: 0.00,
},
confidenceInCriteria: 0.87,
notificationConfig: { enabled: false, minScore: 75 },
organizationId: 'org-mobimo',
createdAt: '2025-04-20T11:00:00Z',
updatedAt: '2025-05-15T09:00:00Z',
},
{
id: 'need-mob-003',
companyName: 'Lager & Service Schaffhausen',
contactName: 'Sandra Koch',
assetType: AssetType.LOGISTICS,
requiredArea: { min: 800, max: 1500 },
preferredLocations: ['Schaffhausen', 'Neuhausen am Rheinfall', 'Thayngen'],
excludedLocations: [],
budgetRange: { maxPerSqm: 160, currency: 'CHF' },
timing: {
earliestMoveIn: '2026-01-01',
latestMoveIn: '2026-09-01',
contractDurationMonths: 48,
flexibleTiming: true,
},
mustCriteriaText: ['Autobahnanschluss < 10 Min', 'Rampe / Ladetor', 'Büroanteil mind. 80 m²'],
softFactors: {
requireParking: true,
},
weightingProfile: {
area: 0.22, location: 0.20, budget: 0.20, timing: 0.12,
prestige: 0.01, accessibility: 0.12, expansionPotential: 0.05, flexibility: 0.03,
visibility: 0.01, footfall: 0.00, talentAccess: 0.01, esg: 0.02, taxEnvironment: 0.01,
},
confidenceInCriteria: 0.83,
notificationConfig: { enabled: true, minScore: 85 },
organizationId: 'org-mobimo',
createdAt: '2025-06-01T08:00:00Z',
updatedAt: '2025-06-10T14:00:00Z',
},
{
id: 'need-mob-004',
companyName: 'Repräsentanzfläche Zürich-West',
contactName: 'Sandra Koch',
assetType: AssetType.OFFICE,
requiredArea: { min: 500, max: 900 },
preferredLocations: ['Zürich-West', 'Zürich Kreis 5', 'Zürich Kreis 4', 'Zürich Hardbrücke'],
excludedLocations: [],
budgetRange: { maxPerSqm: 520, maxMonthlyTotal: 35000, currency: 'CHF' },
timing: {
earliestMoveIn: '2026-03-01',
latestMoveIn: '2026-12-01',
contractDurationMonths: 84,
flexibleTiming: false,
},
mustCriteriaText: ['Moderner Neubau oder Kernsanierung', 'Kollaborative Fläche', 'Fahrradabstellplätze', 'Minergie oder LEED-zertifiziert'],
softFactors: {
minPrestige: 75,
minAccessibility: 85,
requireParking: false,
maxPublicTransportMinutes: 6,
},
weightingProfile: {
area: 0.16, location: 0.22, budget: 0.14, timing: 0.10,
prestige: 0.12, accessibility: 0.09, expansionPotential: 0.05, flexibility: 0.04,
visibility: 0.02, footfall: 0.01, talentAccess: 0.03, esg: 0.02, taxEnvironment: 0.00,
},
confidenceInCriteria: 0.93,
organizationId: 'org-mobimo',
createdAt: '2025-03-15T13:00:00Z',
updatedAt: '2025-06-05T11:00:00Z',
},
]
+1 -1
View File
@@ -153,7 +153,7 @@ export const mockProperties: Property[] = [
parkingSpots: 8,
publicTransportMinutes: 5,
},
hardFacts: { fitOut: 'BASIC' },
hardFacts: { fitOut: 'BASIC', fitOutByLandlord: true },
floorLevel: 2,
expansionPotentialSqm: 200,
contractDurationMonths: 48,
+13 -13
View File
@@ -27,7 +27,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Mieter hat bisher keine Verlängerungsabsicht signalisiert. Erstkontakt dringend.',
activity: [
{ at: '2026-04-01T08:00:00Z', by: 'Anna Meier', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-04-01T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-10T14:30:00Z', by: 'Thomas Huber', action: 'NOTED', note: 'Mieter angerufen, kein Rückruf erhalten' },
],
organizationId: 'org-wincasa',
@@ -56,7 +56,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: true,
note: 'Break-Option läuft am 01.09 ab — Frist zur Ausübung ist 90 Tage vorher, also bis 03.06.',
activity: [
{ at: '2026-03-15T09:00:00Z', by: 'Anna Meier', action: 'CREATED' },
{ at: '2026-03-15T09:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-05T11:00:00Z', by: 'Anna Meier', action: 'NOTED', note: 'Mieterdossier vorbereitet' },
],
organizationId: 'org-wincasa',
@@ -83,7 +83,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Gebäudeversicherung läuft am 30.06 ab. Police-Nummer ZG-2022-9912.',
activity: [
{ at: '2026-04-20T10:00:00Z', by: 'Sandra Wyss', action: 'CREATED' },
{ at: '2026-04-20T10:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-12T15:00:00Z', by: 'Sandra Wyss', action: 'NOTED', note: 'Offerte von Mobiliar angefordert' },
],
organizationId: 'org-wincasa',
@@ -140,7 +140,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: true,
note: 'Indexierte Mietanpassung per 01.10 möglich. LIK-Index prüfen.',
activity: [
{ at: '2026-04-05T08:00:00Z', by: 'Anna Meier', action: 'CREATED' },
{ at: '2026-04-05T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-18T09:00:00Z', by: 'Anna Meier', action: 'NOTED', note: 'LIK-Daten für Q1 2026 abrufbar' },
],
organizationId: 'org-wincasa',
@@ -168,7 +168,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Frist für Vertragsverhandlung: 9 Monate vor Ablauf. Markt Bern Industrie angespannt.',
activity: [
{ at: '2026-03-01T08:00:00Z', by: 'Sandra Wyss', action: 'CREATED' },
{ at: '2026-03-01T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-03-01T08:00:00Z',
@@ -195,7 +195,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Einheit noch nicht für Pre-Market freigegeben. 14 Monate Lead Time empfohlen.',
activity: [
{ at: '2026-04-18T10:00:00Z', by: 'Thomas Huber', action: 'CREATED', note: 'Pre-Market-Freigabe ausstehend' },
{ at: '2026-04-18T10:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten & Marktdaten erstellt' },
{ at: '2026-05-02T11:00:00Z', by: 'Thomas Huber', action: 'NOTED', note: 'Eigentümer informiert, Freigabe ausstehend' },
],
organizationId: 'org-wincasa',
@@ -250,7 +250,7 @@ export const mockReminders: Reminder[] = [
snoozedUntil: '2026-05-27',
note: 'Mieter erwägt Flächenreduktion. Gespräch vereinbart für 27.05.',
activity: [
{ at: '2026-04-02T09:00:00Z', by: 'Anna Meier', action: 'CREATED' },
{ at: '2026-04-02T09:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-14T16:00:00Z', by: 'Anna Meier', action: 'SNOOZED', note: 'Bis nach Gespräch mit Mieter zurückgestellt' },
],
organizationId: 'org-wincasa',
@@ -279,7 +279,7 @@ export const mockReminders: Reminder[] = [
shadowMarketRisk: ShadowMarketRisk.LOW,
schattenmarktEnabled: true,
activity: [
{ at: '2026-03-20T10:00:00Z', by: 'Sandra Wyss', action: 'CREATED' },
{ at: '2026-03-20T10:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-03-20T10:00:00Z',
@@ -306,7 +306,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Erstgespräch über Verlängerung bis 01.07 einleiten.',
activity: [
{ at: '2026-03-10T08:00:00Z', by: 'Thomas Huber', action: 'CREATED' },
{ at: '2026-03-10T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-02T09:00:00Z', by: 'Thomas Huber', action: 'NOTED', note: 'Vermieterseite wünscht Mietpreiserhöhung +5%' },
],
organizationId: 'org-wincasa',
@@ -359,7 +359,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Pre-Market Freigabe 9 Monate vor Vertragsende. Eigentümer-Freigabe einholen.',
activity: [
{ at: '2026-04-25T11:00:00Z', by: 'Anna Meier', action: 'CREATED' },
{ at: '2026-04-25T11:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten & Marktdaten erstellt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-04-25T11:00:00Z',
@@ -415,7 +415,7 @@ export const mockReminders: Reminder[] = [
shadowMarketRisk: ShadowMarketRisk.MEDIUM,
schattenmarktEnabled: true,
activity: [
{ at: '2026-02-01T08:00:00Z', by: 'Anna Meier', action: 'CREATED', note: 'Vertragsablauf-Erinnerung (4 Monate)' },
{ at: '2026-02-01T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-02-01T08:00:00Z',
@@ -440,7 +440,7 @@ export const mockReminders: Reminder[] = [
shadowMarketRisk: ShadowMarketRisk.NONE,
schattenmarktEnabled: false,
activity: [
{ at: '2026-04-20T08:00:00Z', by: 'Sandra Wyss', action: 'CREATED' },
{ at: '2026-04-20T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-04-20T08:00:00Z',
@@ -522,7 +522,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Mietpreisanpassung +2.8% vereinbart, ab 01.09 gültig.',
activity: [
{ at: '2026-02-15T08:00:00Z', by: 'Anna Meier', action: 'CREATED' },
{ at: '2026-02-15T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-04-14T11:00:00Z', by: 'Anna Meier', action: 'NOTED', note: 'Mieter hat Anpassung akzeptiert' },
{ at: '2026-04-15T14:00:00Z', by: 'Anna Meier', action: 'COMPLETED', note: 'Nachtrag unterzeichnet' },
],
+51 -18
View File
@@ -1,6 +1,6 @@
import { useRef, useState } from 'react'
import { Box, Typography } from '@mui/material'
import { useNavigate } from 'react-router'
import { useEffect, useRef, useState } from 'react'
import { Box, Tab, Tabs, Typography } from '@mui/material'
import { useLocation, useNavigate } from 'react-router'
import { useQueryClient } from '@tanstack/react-query'
import {
NeedBuilderProgress,
@@ -11,12 +11,15 @@ import {
} from '../../components/demand'
import { AISearchActionBar } from '../../components/demand/AISearchActionBar'
import { AISearchSavePreview } from '../../components/demand/AISearchSavePreview'
import { SavedProfilesTab } from '../../components/demand/SavedProfilesTab'
import { AddToPipelineDialog } from '../../components/shortlist'
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
import { useParseNeed } from '../../hooks/useAI'
import { useCreateNeed } from '../../hooks/useNeeds'
import { useCreateNeed, useNeed, useNeedProfiles } from '../../hooks/useNeeds'
import { useDefaultWeights } from '../../hooks/useWeighting'
import { NeedBuilderStep } from '../../domain/needBuilder'
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
import { generateSummary, buildNeedInput } from '../../services/aiSearch/needSearchMapper'
import { generateSummary, buildNeedInput, needToParsedCriteria } from '../../services/aiSearch/needSearchMapper'
type ActionIntent = 'search' | 'save-profile'
@@ -41,8 +44,25 @@ export default function AISearch() {
const [isAnonymous, setIsAnonymous] = useState(false)
const [error, setError] = useState<string | null>(null)
const [tab, setTab] = useState(0)
const { data: savedProfiles = [] } = useNeedProfiles()
const locationPrefillId = (useLocation().state as { prefillNeedId?: string } | null)?.prefillNeedId
const [internalPrefillId, setInternalPrefillId] = useState<string | undefined>()
const prefillNeedId = internalPrefillId ?? locationPrefillId
const { data: prefillNeed } = useNeed(prefillNeedId ?? '')
const isManualTextRef = useRef(false)
useEffect(() => {
if (!prefillNeed) return
setCriteria(needToParsedCriteria(prefillNeed))
setWeights(prefillNeed.weightingProfile as Record<WeightingKey, number>)
setNeedTitle(prefillNeed.companyName)
setWeightingKey(k => k + 1)
setTab(0)
}, [prefillNeed])
function handleCriteriaChange(next: ParsedNeedCriteria) {
setCriteria(next)
if (!isManualTextRef.current) {
@@ -56,7 +76,6 @@ export default function AISearch() {
isManualTextRef.current = text !== ''
setIsAutoGen(false)
setInputText(text)
// Clear stale parse results when user edits the text manually
setCriteria({})
setParseResult(null)
}
@@ -198,16 +217,15 @@ export default function AISearch() {
const isProcessing = step === NeedBuilderStep.PARSING || step === NeedBuilderStep.SAVING
const isSaveStep = step === NeedBuilderStep.READY_TO_SAVE || step === NeedBuilderStep.SAVING
const overallConfidence = parseResult
? (() => {
const entries = Object.entries(parseResult.confidenceByField)
return entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
})()
: 0
const vals = parseResult ? Object.values(parseResult.confidenceByField) : []
const overallConfidence = vals.length > 0 ? vals.reduce((s, v) => s + v, 0) / vals.length : 0
const savedCount = savedProfiles.filter(n => !n.status || n.status === 'ACTIVE' || n.status === 'DRAFT').length
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<AddToPipelineDialog />
<InquiryQuickDialog />
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0 }}>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Flächensuche</Typography>
<Typography variant="body2" color="text.secondary">
@@ -215,13 +233,22 @@ export default function AISearch() {
</Typography>
</Box>
<NeedBuilderProgress step={step} />
<Tabs
value={tab}
onChange={(_, v: number) => setTab(v)}
sx={{ borderBottom: '1px solid #e2e8f0', px: 3, bgcolor: 'white', flexShrink: 0, minHeight: 44 }}
>
<Tab label="Neue Suche" sx={{ textTransform: 'none', fontWeight: 600, fontSize: '0.875rem', minHeight: 44, py: 0 }} />
<Tab label={`Gespeicherte Profile (${savedCount})`} sx={{ textTransform: 'none', fontWeight: 600, fontSize: '0.875rem', minHeight: 44, py: 0 }} />
</Tabs>
{tab === 0 && (
<>
<NeedBuilderProgress step={step} />
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{/* IDLE: full form */}
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 1200, mx: 'auto' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: { md: 900, xl: 1200 }, mx: 'auto' }}>
<VoiceNeedInput
text={inputText}
onTextChange={handleTextChange}
@@ -248,7 +275,6 @@ export default function AISearch() {
</Box>
)}
{/* Save preview step */}
{isSaveStep && parseResult && editedCriteria && (
<AISearchSavePreview
criteria={editedCriteria}
@@ -265,11 +291,18 @@ export default function AISearch() {
/>
)}
{/* Error */}
{step === NeedBuilderStep.ERROR && (
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
)}
</Box>
</>
)}
{tab === 1 && (
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<SavedProfilesTab onNewSearch={(id) => { setInternalPrefillId(id); setTab(0) }} />
</Box>
)}
</Box>
)
}
+50 -36
View File
@@ -5,10 +5,9 @@ import {
InputAdornment, Alert,
} from '@mui/material'
import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react'
import { mockDemandInquiries } from '../../mock-data/demandInquiries'
import { useInquiryStore } from '../../stores/inquiryStore'
import { useActiveInquiries, useSendInquiryReply, useMarkThreadAsRead } from '../../hooks/useInquiries'
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
import type { InquiryMessage } from '../../domain/inquiry'
import { mockProperties } from '../../mock-data/properties'
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
@@ -19,9 +18,8 @@ import { useSessionStore } from '../../stores/sessionStore'
const FILTER_TABS = [
{ key: 'all', label: 'Alle' },
{ key: 'new', label: 'Neu' },
{ key: 'in_progress', label: 'Aktiv' },
{ key: 'answered', label: 'Beantwortet' },
{ key: 'INQUIRY', label: 'Gesendet' },
{ key: 'OFFER', label: 'Erhalten' },
]
// ── Anfragen page ─────────────────────────────────────────────────────────────
@@ -36,20 +34,23 @@ export default function Anfragen() {
const preselectedId = searchParams.get('inquiry')
const { currentUser } = useSessionStore()
const storeInquiries = useInquiryStore(s => s.sentInquiries)
const [inquiries, setInquiries] = useState(mockDemandInquiries)
const allInquiries = [...storeInquiries, ...inquiries]
const { data: allInquiries = [] } = useActiveInquiries({ tenantOrgId: currentUser?.organizationId })
const sendReply = useSendInquiryReply()
const markRead = useMarkThreadAsRead()
const [selectedId, setSelectedId] = useState<string | null>(
preselectedId ?? mockDemandInquiries[0]?.id ?? null
)
const [selectedId, setSelectedId] = useState<string | null>(preselectedId ?? null)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const [kindFilter, setKindFilter] = useState('all')
const [replyText, setReplyText] = useState('')
const [mobileShowChat, setMobileShowChat] = useState(!!preselectedId)
const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(null)
const threadRef = useRef<HTMLDivElement>(null)
// Default-Auswahl: erste Konversation, sobald geladen
useEffect(() => {
if (!selectedId && allInquiries.length > 0) setSelectedId(allInquiries[0].id)
}, [allInquiries, selectedId])
const filtered = allInquiries.filter(inq => {
const q = search.toLowerCase()
const matchesSearch = !q ||
@@ -59,12 +60,14 @@ export default function Anfragen() {
(inq.propertyAddress?.toLowerCase().includes(q) ?? false) ||
(inq.propertyManagerName?.toLowerCase().includes(q) ?? false) ||
(inq.propertyManagerCompany?.toLowerCase().includes(q) ?? false)
const matchesStatus = statusFilter === 'all' || inq.status === statusFilter
return matchesSearch && matchesStatus
const matchesKind = kindFilter === 'all' || (inq.kind ?? 'INQUIRY') === kindFilter
return matchesSearch && matchesKind
})
const selected = allInquiries.find(i => i.id === selectedId) ?? null
const totalUnread = allInquiries.reduce((sum, i) => sum + i.unreadCount, 0)
const offeredProperties = (selected?.kind === 'OFFER' ? selected.offeredPropertyIds ?? [] : [])
.map(id => mockProperties.find(p => p.id === id)).filter(Boolean)
// Pipeline link for currently selected inquiry
const linkedPipelineItem = selected?.propertyId
@@ -86,7 +89,7 @@ export default function Anfragen() {
function handleSelect(id: string) {
setSelectedId(id)
setInquiries(prev => prev.map(i => i.id === id ? { ...i, isRead: true, unreadCount: 0 } : i))
markRead.mutate(id)
setMobileShowChat(true)
setKiAlert(null)
}
@@ -95,20 +98,10 @@ export default function Anfragen() {
if (!replyText.trim() || !selectedId) return
const text = replyText.trim()
const msg: InquiryMessage = {
id: `msg-${Date.now()}`,
sendReply.mutate({
inquiryId: selectedId,
senderType: 'tenant',
senderName: 'Sie',
body: text,
attachments: [],
createdAt: new Date().toISOString(),
}
setInquiries(prev => prev.map(i =>
i.id === selectedId
? { ...i, thread: [...i.thread, msg], status: 'in_progress', updatedAt: new Date().toISOString() }
: i
))
payload: { body: text, senderType: 'tenant', senderName: currentUser?.name ?? 'Sie' },
})
setReplyText('')
// KI: detect stage transition from message content
@@ -134,8 +127,8 @@ export default function Anfragen() {
{/* ── Left panel ── */}
<Box
sx={{
width: { xs: mobileShowChat ? 0 : '100%', md: 320 },
minWidth: { md: 320 },
width: { xs: mobileShowChat ? 0 : '100%', md: 280, lg: 300, xl: 320 },
minWidth: { md: 280, lg: 300, xl: 320 },
flexShrink: 0,
display: 'flex', flexDirection: 'column',
borderRight: `1px solid ${DS_BORDER.default}`,
@@ -160,13 +153,13 @@ export default function Anfragen() {
/>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{FILTER_TABS.map(tab => (
<Chip key={tab.key} label={tab.label} size="small" onClick={() => setStatusFilter(tab.key)}
<Chip key={tab.key} label={tab.label} size="small" onClick={() => setKindFilter(tab.key)}
sx={{
height: 22, fontSize: '0.7rem', cursor: 'pointer',
bgcolor: statusFilter === tab.key ? 'primary.main' : DS_BG.subtle,
color: statusFilter === tab.key ? 'white' : DS_TEXT.secondary,
fontWeight: statusFilter === tab.key ? 700 : 400,
'&:hover': { bgcolor: statusFilter === tab.key ? 'primary.dark' : DS_BG.muted },
bgcolor: kindFilter === tab.key ? 'primary.main' : DS_BG.subtle,
color: kindFilter === tab.key ? 'white' : DS_TEXT.secondary,
fontWeight: kindFilter === tab.key ? 700 : 400,
'&:hover': { bgcolor: kindFilter === tab.key ? 'primary.dark' : DS_BG.muted },
}}
/>
))}
@@ -291,6 +284,27 @@ export default function Anfragen() {
</Box>
)}
{/* Angebot: enthaltene Objekte */}
{offeredProperties.length > 0 && (
<Box sx={{ px: { xs: 2, md: 3 }, pt: 1.5, flexShrink: 0 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.4, display: 'block', mb: 0.75 }}>
Enthaltene Objekte ({offeredProperties.length})
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{offeredProperties.map(p => p && (
<Chip
key={p.id}
icon={<Building2 size={12} />}
label={`${p.title} · CHF ${p.rentPricePerSqm}/m²`}
size="small"
onClick={() => navigate(`/demand/property/${p.id}`)}
sx={{ cursor: 'pointer', bgcolor: DS_SURFACE.blue.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.blue.border}`, fontSize: '0.72rem', '& .MuiChip-icon': { color: DS_TEXT.signalDark } }}
/>
))}
</Box>
</Box>
)}
{/* Thread */}
<Box ref={threadRef} sx={{ flex: 1, overflowY: 'auto', px: { xs: 2, md: 3 }, py: 2.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{selected.thread.map(msg => (
+28 -3
View File
@@ -8,7 +8,7 @@ import { useInquiryStore } from '../../stores/inquiryStore'
import { AddToPipelineDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import { getCityIntelligence, lookupCityCoords } from '../../lib/locationIntelligence'
import { NextActionsPanel, FitOutCostPanel } from '../../components/match-detail'
import { NextActionsPanel, FitOutCostPanel, FitOutAdvicePanel, MarketPricePanel } from '../../components/match-detail'
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
import { useMatchDetailData } from '../../hooks/useMatchDetailData'
import { useMatchDetail } from '../../hooks/useMatches'
@@ -39,6 +39,7 @@ export default function MatchDetail() {
const { openInquiryDialog } = useInquiryStore()
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
const detailUnit = property?.units?.find(u => u.id === match?.unitId)
if (isLoading) {
return (
@@ -171,7 +172,7 @@ export default function MatchDetail() {
/>
{/* ── Main content ── */}
<Box sx={{ maxWidth: { sm: 780, md: 960, lg: 1100 }, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ maxWidth: { sm: 700, md: 860, lg: 1000, xl: 1100 }, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* ── Section A: Warum dieser Match? ── */}
<Paper sx={{ p: 2.5 }}>
@@ -220,7 +221,31 @@ export default function MatchDetail() {
areaSqm={property.areaSqm}
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
rentPricePerSqm={property.rentPricePerSqm}
tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm ?? 0}
fitOutByLandlord={property.hardFacts.fitOutByLandlord}
/>
)}
{/* ── KI-Ausbauempfehlung: nur wenn Mieter ausbaut (SHELL/BASIC, kein Vermieter-Übernahme) ── */}
{!isFuture && property?.hardFacts?.fitOut
&& !property.hardFacts.fitOutByLandlord
&& (property.hardFacts.fitOut === 'SHELL' || property.hardFacts.fitOut === 'BASIC') && (
<FitOutAdvicePanel
fitOut={property.hardFacts.fitOut}
areaSqm={property.areaSqm}
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
rentPricePerSqm={property.rentPricePerSqm}
requiredFitOut={need?.requiredFitOut}
tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm}
/>
)}
{/* ── Marktpreis-Einschätzung (Angebot vs. Markt; bei Pre-Market inkl. Zukunftspreis) ── */}
{property?.location?.city && property.rentPricePerSqm > 0 && (
<MarketPricePanel
city={property.location.city}
assetType={property.assetType}
askingRentPerSqm={detailUnit?.rentPricePerSqm ?? property.rentPricePerSqm}
futureRentPerSqm={isFuture ? detailUnit?.expectedRentPerSqm : undefined}
/>
)}
+1 -1
View File
@@ -109,7 +109,7 @@ export default function Pipeline() {
return (
<Box
key={stage.key}
sx={{ minWidth: syncedSelected ? 190 : 230, maxWidth: syncedSelected ? 230 : 270, flexShrink: 0, display: 'flex', flexDirection: 'column' }}
sx={{ minWidth: syncedSelected ? 170 : 200, maxWidth: syncedSelected ? 210 : 260, flexShrink: 0, display: 'flex', flexDirection: 'column' }}
>
<Box sx={{ py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontWeight: 700, color: stage.color, fontSize: '0.8125rem' }}>
+7 -2
View File
@@ -44,6 +44,9 @@ export default function Results() {
const [view, setView] = useState<'list' | 'grid'>(() =>
(localStorage.getItem('view-results') as 'list' | 'grid') ?? 'list'
)
const [columns, setColumns] = useState<3 | 5 | 10>(() =>
(Number(localStorage.getItem('view-results-cols')) as 3 | 5 | 10) || 3
)
// When coming from NeedBuilder, invalidate so the freshly created need is included
const activeNeedIdFromNav = (location.state as { activeNeedId?: string } | null)?.activeNeedId
@@ -107,9 +110,11 @@ export default function Results() {
futureCount={futureCount}
view={view}
onViewChange={v => { setView(v); localStorage.setItem('view-results', v) }}
columns={columns}
onColumnsChange={n => { setColumns(n); localStorage.setItem('view-results-cols', String(n)) }}
/>
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, pb: 10 }}>
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 4, pb: 12 }}>
{/* Aktive Suche */}
{activeNeed && (
@@ -169,7 +174,7 @@ export default function Results() {
</Box>
</Box>
)}
<UnifiedResultFeed results={sorted} view={view} />
<UnifiedResultFeed results={sorted} view={view} columns={columns} />
</>
)}
</Box>
+12 -2
View File
@@ -2,11 +2,13 @@ import { useState } from 'react'
import { useLocation } from 'react-router'
import { Box, Tab, Tabs, Typography } from '@mui/material'
import { ActiveInquiriesTab, LatentInquiriesTab, OfferWizard } from '../../components/anfragencenter'
import { useSessionStore } from '../../stores/sessionStore'
export default function Anfragencenter() {
const location = useLocation()
const initialTab = (location.state as { tab?: number } | null)?.tab ?? 0
const [tab, setTab] = useState(initialTab)
const orgId = useSessionStore(s => s.currentUser?.organizationId)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ borderBottom: '1px solid #e8e7e4', px: 3, bgcolor: 'white', flexShrink: 0 }}>
@@ -21,12 +23,20 @@ export default function Anfragencenter() {
}}
>
<Tab label="Aktive Anfragen" />
<Tab label="Gesendet" />
<Tab label="Latente Anfragen" />
</Tabs>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden' }}>
{tab === 0 && <ActiveInquiriesTab />}
{tab === 1 && <LatentInquiriesTab />}
{tab === 0 && <ActiveInquiriesTab filters={{ organizationId: orgId, kind: 'INQUIRY' }} />}
{tab === 1 && (
<ActiveInquiriesTab
filters={{ organizationId: orgId, kind: 'OFFER' }}
emptyTitle="Keine gesendeten Angebote"
emptyDescription="Angebote aus den latenten Anfragen erscheinen hier, sobald Sie sie versenden."
/>
)}
{tab === 2 && <LatentInquiriesTab />}
</Box>
<OfferWizard />
</Box>
+1 -1
View File
@@ -425,7 +425,7 @@ export default function MyListings() {
anchor="right"
open={!!detailId}
onClose={() => setDetailId(null)}
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560 } } } }}
slotProps={{ paper: { sx: { width: { xs: '100%', sm: '90vw', md: 480, lg: 540, xl: 560 } } } }}
>
{detailId && (
<PropertyDetailView propertyId={detailId} onClose={() => setDetailId(null)} hideTabs={['Matchability', 'Marktsignale']} />
+1
View File
@@ -81,6 +81,7 @@ export default function NewListing() {
<TechnicalDetailsSection
floor={form.floor} onFloorChange={form.setFloor}
fitOut={form.fitOut} onFitOutChange={form.setFitOut}
fitOutByLandlord={form.fitOutByLandlord} onFitOutByLandlordChange={form.setFitOutByLandlord}
parking={form.parking} onParkingChange={form.setParking}
ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight}
mieterausbaubeitrag={form.mieterausbaubeitrag} onMieterausbaubeitragChange={form.setMieterausbaubeitrag}
+1 -1
View File
@@ -247,7 +247,7 @@ export default function Properties() {
anchor="right"
open={!!selectedId}
onClose={() => setSelectedId(null)}
slotProps={{ paper: { sx: { width: isMobile ? '100vw' : 650, boxShadow: '-4px 0 24px rgba(0,0,0,0.10)' } } }}
slotProps={{ paper: { sx: { width: isMobile ? '100vw' : { md: 520, lg: 580, xl: 640 }, boxShadow: '-4px 0 24px rgba(0,0,0,0.10)' } } }}
>
{selectedId && (
<PropertyDetailView propertyId={selectedId} onClose={() => setSelectedId(null)} />
+7 -4
View File
@@ -30,12 +30,14 @@ export const LEVEL_TO_SCORE: Record<string, number | undefined> = {
LOW: 0.30, MEDIUM: 0.55, HIGH: 0.85, '': undefined,
}
import { FIT_OUT_LABELS } from '../../lib/constants'
export const FIT_OUT_OPTIONS = [
{ value: '', label: 'Keine Angabe' },
{ value: 'SHELL', label: 'Rohbau' },
{ value: 'BASIC', label: 'Basis-Ausbau' },
{ value: 'FULL', label: 'Vollausbau' },
{ value: 'PREMIUM', label: 'Premiumausbau' },
{ value: 'SHELL', label: FIT_OUT_LABELS.SHELL },
{ value: 'BASIC', label: FIT_OUT_LABELS.BASIC },
{ value: 'FULL', label: FIT_OUT_LABELS.FULL },
{ value: 'PREMIUM', label: FIT_OUT_LABELS.PREMIUM },
]
export interface LocationState {
@@ -51,6 +53,7 @@ export interface LocationState {
propertyId?: string
floor?: string
fitOut?: string
fitOutByLandlord?: boolean
parking?: number
ceilingHeight?: number
mieterausbaubeitragPerSqm?: number
+5 -2
View File
@@ -15,6 +15,7 @@ export function buildCreatePropertyInput(fields: {
softLevels: Record<string, string>
floor: string
fitOut: string
fitOutByLandlord?: boolean
parking: string
ceilingHeight: string
images: string[]
@@ -26,7 +27,7 @@ export function buildCreatePropertyInput(fields: {
const {
assetType, street, houseNumber, postalCode, city,
areaSqm, rentPerSqm, availableFrom, description,
softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
softLevels, floor, fitOut, fitOutByLandlord, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm,
} = fields
@@ -45,7 +46,9 @@ 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,
fitOutByLandlord,
// MAB nur relevant, wenn der Mieter ausbaut — sonst nicht speichern
mieterausbaubeitragPerSqm: !fitOutByLandlord && mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
parking: parking ? parseInt(parking) : undefined,
ceilingHeightM: ceilingHeight ? parseFloat(ceilingHeight) : undefined,
}
+36 -3
View File
@@ -1,17 +1,50 @@
import type { Inquiry, InquiryStatus, InquiryMessage } from '../domain/inquiry'
import type { Inquiry, InquiryStatus, InquiryKind, InquiryMessage, Attachment } from '../domain/inquiry'
export interface InquiryFilters {
status?: InquiryStatus
propertyId?: string
organizationId?: string
organizationId?: string // Supply-Perspektive
tenantOrgId?: string // Demand-Perspektive
kind?: InquiryKind
}
/** Demand→Supply: neue Anfrage zu einem Objekt. */
export interface CreateInquiryInput {
organizationId: string // Supply-Org (Objekt-Eigentümer)
tenantOrgId: string // Demand-Org (Absender)
propertyId: string
needId?: string
tenantName: string
tenantCompany?: string
tenantEmail?: string
propertyAddress?: string
subject: string
message: string
matchScore?: number
}
/** Supply→Demand: Angebot zu einem latenten Bedarf. */
export interface CreateOfferInput {
organizationId: string // Supply-Org (Absender)
tenantOrgId: string // Demand-Org (Empfänger, aus need.organizationId)
needId: string
propertyId: string // primäres Objekt (Kompatibilität)
offeredPropertyIds: string[]
subject: string
message: string
senderName: string // Verwaltungs-/Org-Name
recipientName?: string
attachments?: Attachment[]
}
export interface IInquiryProvider {
getAll(filters?: InquiryFilters): Promise<Inquiry[]>
getById(id: string): Promise<Inquiry | null>
createInquiry(input: CreateInquiryInput): Promise<Inquiry>
createOffer(input: CreateOfferInput): Promise<Inquiry>
updateStatus(id: string, status: InquiryStatus): Promise<Inquiry>
markThreadAsRead(id: string): Promise<Inquiry>
getUnreadCount(): Promise<number>
getUnreadCount(filters?: InquiryFilters): Promise<number>
addMessage(
inquiryId: string,
msg: Omit<InquiryMessage, 'id' | 'inquiryId' | 'createdAt'>,
+92 -9
View File
@@ -1,20 +1,99 @@
import type { IInquiryProvider, InquiryFilters } from './IInquiryProvider'
import type { IInquiryProvider, InquiryFilters, CreateInquiryInput, CreateOfferInput } from './IInquiryProvider'
import type { Inquiry, InquiryStatus, InquiryMessage } from '../domain/inquiry'
import { mockInquiries } from '../mock-data/inquiries'
import { mockDemandInquiries } from '../mock-data/demandInquiries'
const store: Inquiry[] = mockInquiries.map(i => ({ ...i, thread: [...i.thread] }))
// Eine gemeinsame Konversationsmenge: Supply-Eingang (mockInquiries) + Demand-Perspektive
// (mockDemandInquiries, inkl. erhaltener Angebote). Beide Seiten lesen perspektivisch gefiltert.
const store: Inquiry[] = [...mockInquiries, ...mockDemandInquiries].map(i => ({ ...i, thread: [...i.thread] }))
function applyFilters(results: Inquiry[], filters?: InquiryFilters): Inquiry[] {
let out = results
if (filters?.status) out = out.filter(i => i.status === filters.status)
if (filters?.propertyId) out = out.filter(i => i.propertyId === filters.propertyId)
if (filters?.organizationId) out = out.filter(i => i.organizationId === filters.organizationId)
if (filters?.tenantOrgId) out = out.filter(i => i.tenantOrgId === filters.tenantOrgId)
if (filters?.kind) out = out.filter(i => (i.kind ?? 'INQUIRY') === filters.kind)
return out
}
export const MockupInquiryProvider: IInquiryProvider = {
async getAll(filters?: InquiryFilters): Promise<Inquiry[]> {
let results = [...store]
if (filters?.status) results = results.filter(i => i.status === filters.status)
if (filters?.propertyId) results = results.filter(i => i.propertyId === filters.propertyId)
if (filters?.organizationId) results = results.filter(i => i.organizationId === filters.organizationId)
return results.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
return applyFilters([...store], filters).sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
},
async getById(id: string): Promise<Inquiry | null> {
return store.find(i => i.id === id) ?? null
},
async createInquiry(input: CreateInquiryInput): Promise<Inquiry> {
const now = new Date().toISOString()
const id = `inq-${crypto.randomUUID().slice(0, 8)}`
const inquiry: Inquiry = {
id,
kind: 'INQUIRY',
organizationId: input.organizationId,
tenantOrgId: input.tenantOrgId,
propertyId: input.propertyId,
needId: input.needId,
tenantName: input.tenantName,
tenantCompany: input.tenantCompany,
tenantEmail: input.tenantEmail,
propertyAddress: input.propertyAddress,
subject: input.subject,
message: input.message,
status: 'new',
unreadCount: 1, // neu für die Verwaltung (Empfänger)
isRead: false,
matchScore: input.matchScore,
createdAt: now,
updatedAt: now,
thread: [{
id: crypto.randomUUID(),
inquiryId: id,
senderType: 'tenant',
senderName: input.tenantCompany ?? input.tenantName,
subject: input.subject,
body: input.message,
attachments: [],
createdAt: now,
}],
}
store.unshift(inquiry)
return inquiry
},
async createOffer(input: CreateOfferInput): Promise<Inquiry> {
const now = new Date().toISOString()
const id = `off-${crypto.randomUUID().slice(0, 8)}`
const inquiry: Inquiry = {
id,
kind: 'OFFER',
organizationId: input.organizationId,
tenantOrgId: input.tenantOrgId,
propertyId: input.propertyId,
offeredPropertyIds: input.offeredPropertyIds,
needId: input.needId,
tenantName: input.recipientName ?? '',
propertyManagerCompany: input.senderName,
subject: input.subject,
message: input.message,
status: 'new',
unreadCount: 1, // neu für den Nachfrager (Empfänger)
isRead: false,
createdAt: now,
updatedAt: now,
thread: [{
id: crypto.randomUUID(),
inquiryId: id,
senderType: 'supply_user',
senderName: input.senderName,
subject: input.subject,
body: input.message,
attachments: input.attachments ?? [],
createdAt: now,
}],
}
store.unshift(inquiry)
return inquiry
},
async updateStatus(id: string, status: InquiryStatus): Promise<Inquiry> {
const idx = store.findIndex(i => i.id === id)
if (idx === -1) throw new Error(`Inquiry ${id} not found`)
@@ -33,8 +112,8 @@ export const MockupInquiryProvider: IInquiryProvider = {
}
return store[idx]
},
async getUnreadCount(): Promise<number> {
return store.reduce((sum, i) => sum + (i.unreadCount ?? 0), 0)
async getUnreadCount(filters?: InquiryFilters): Promise<number> {
return applyFilters([...store], filters).reduce((sum, i) => sum + (i.unreadCount ?? 0), 0)
},
async addMessage(
inquiryId: string,
@@ -51,6 +130,10 @@ export const MockupInquiryProvider: IInquiryProvider = {
store[idx] = {
...store[idx],
thread: [...store[idx].thread, message],
// Neue Nachricht der Gegenseite → für den Empfänger ungelesen
unreadCount: (store[idx].unreadCount ?? 0) + 1,
isRead: false,
status: store[idx].status === 'new' ? 'in_progress' : store[idx].status,
updatedAt: message.createdAt,
}
return store[idx]
+8 -3
View File
@@ -17,11 +17,16 @@ export const MockupUnitProvider: IUnitProvider = {
return getAllUnits().find(u => u.id === unitId) ?? null
},
async update(unitId: string, data: Partial<PropertyUnit>) {
for (const prop of propertyStore) {
for (let i = 0; i < propertyStore.length; i++) {
const prop = propertyStore[i]
const idx = (prop.units ?? []).findIndex(u => u.id === unitId)
if (idx === -1) continue
prop.units![idx] = { ...prop.units![idx], ...data }
return prop.units![idx]
// Immutable update: neue Referenzen (Unit, units-Array, Property), damit React Query
// die Änderung via Structural-Sharing erkennt und neu rendert.
const nextUnits = [...prop.units!]
nextUnits[idx] = { ...nextUnits[idx], ...data }
propertyStore[i] = { ...prop, units: nextUnits }
return nextUnits[idx]
}
throw new Error(`Unit ${unitId} not found`)
},
+24
View File
@@ -176,6 +176,27 @@ export interface FitOutAdvice {
estimatedNetInvestment: string
}
// ── Pre-market rent recommendation ─────────────────────────────────────────────
export interface PreMarketRentInput {
city: string
assetType: string
areaSqm: number
currentRentPerSqm: number
availableFrom?: string // ISO — Pre-Market liegt in der Zukunft
}
export interface PreMarketRentRecommendation {
recommendedPerSqm: number
rangeMinPerSqm: number
rangeMaxPerSqm: number
verdict: 'UNDERPRICED' | 'FAIR' | 'AMBITIOUS' // Bewertung des heutigen Preises
deltaVsCurrentPct: number // Empfehlung vs. heutiger Preis
drivers: string[] // Vergleichsmiete, Angebot, Nachfrage, Trend …
rationale: string
confidence: 'LOW' | 'MEDIUM' | 'HIGH'
}
// ── Legacy types (kept for backward compatibility) ────────────────────────────
export interface CriteriaExtractionResult {
@@ -220,6 +241,9 @@ export interface IAIService {
// Fit-out investment advice (demand side)
generateFitOutAdvice(input: FitOutAdviceInput): Promise<AIResponse<FitOutAdvice>>
// Pre-market rent recommendation (supply side) — based on regional comparables, supply & demand
recommendPreMarketRent(input: PreMarketRentInput): Promise<AIResponse<PreMarketRentRecommendation>>
// Legacy methods
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>>
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>>
@@ -57,6 +57,8 @@ import type {
MarketSignalClassification,
FitOutAdviceInput,
FitOutAdvice,
PreMarketRentInput,
PreMarketRentRecommendation,
} from '../IAIService'
import { ServiceErrorCode } from '../../types'
import { AppError } from '../../errors'
@@ -82,6 +84,7 @@ import { buildDecisionBriefPrompt } from '../prompts/decisionBriefPrompt'
import { buildDataQualityPrompt } from '../prompts/dataQualityPrompt'
import { buildMarketSignalPrompt } from '../prompts/marketSignalPrompt'
import { MockAIService } from '../mock/MockAIService'
import { getCityIntelligence, getMarketRent } from '../../../lib/locationIntelligence'
// ── Config ────────────────────────────────────────────────────────────────────
@@ -606,6 +609,41 @@ Bitte analysiere die Situation und empfiehl die beste Option für den Mieter.`
}, () => MockAIService.generateFitOutAdvice(input))
},
// ── recommendPreMarketRent ──────────────────────────────────────────────────
recommendPreMarketRent(input: PreMarketRentInput): Promise<AIResponse<PreMarketRentRecommendation>> {
return withFallback('recommendPreMarketRent', async () => {
const intel = getCityIntelligence(input.city)
const comp = getMarketRent(input.city, input.assetType)
const system = `Du bist Schweizer Gewerbeimmobilien-Marktanalyst. Empfiehl einen Pre-Market-Mietpreis (CHF/m²/Jahr) auf Basis regionaler Vergleichsmieten, Angebot (Leerstand) und Nachfrage. Antworte als JSON:
{
"recommendedPerSqm": number,
"rangeMinPerSqm": number,
"rangeMaxPerSqm": number,
"verdict": "UNDERPRICED" | "FAIR" | "AMBITIOUS",
"deltaVsCurrentPct": number,
"drivers": ["kurze Treiber auf Deutsch"],
"rationale": "2-3 Sätze Begründung auf Deutsch",
"confidence": "LOW" | "MEDIUM" | "HIGH"
}`
const user = `Stadt: ${input.city}
Nutzung: ${input.assetType}
Fläche: ${input.areaSqm} m²
Heutiger Preis: CHF ${input.currentRentPerSqm}/m²
Vergleichsmiete (Median): ${comp ?? 'unbekannt'}
Leerstand: ${intel?.vacancyRatePct ?? '?'}%
Nachfrage: ${intel?.demandStrength ?? '?'}
Miettrend 12M: ${intel?.rentTrend12m ?? '?'}%
Ø Vermietungsdauer: ${intel?.avgDaysOnMarket ?? '?'} Tage`
const raw = await chat(system, user)
const json = extractJSON<PreMarketRentRecommendation>(raw)
if (!json || typeof json.recommendedPerSqm !== 'number') {
const fb = await MockAIService.recommendPreMarketRent(input)
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return { data: json, provenance: makeProvenance('ai', false, true) }
}, () => MockAIService.recommendPreMarketRent(input))
},
// ── Legacy: extractCriteria ─────────────────────────────────────────────────
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>> {
return withFallback('extractCriteria', async () => {
+71
View File
@@ -12,12 +12,15 @@ import type {
MarketSignalClassification,
FitOutAdviceInput,
FitOutAdvice,
PreMarketRentInput,
PreMarketRentRecommendation,
} from '../IAIService'
import { mockProvenance } from '../IAIService'
import { aiTraceStore } from '../tracing'
import { mockParseNeed } from './needParser'
import { buildComparisonSummary } from './compareBuilder'
import { buildMockDecisionBrief } from './decisionBrief'
import { getCityIntelligence, getMarketRent } from '../../../lib/locationIntelligence'
const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 }
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
@@ -338,6 +341,74 @@ export const MockAIService: IAIService = {
return { data, provenance: mockProvenance() }
}),
// ── recommendPreMarketRent ──────────────────────────────────────────────────
recommendPreMarketRent: (input: PreMarketRentInput) =>
traceMock('recommendPreMarketRent', async () => {
await delay(SIMULATED_DELAY.fast)
const intel = getCityIntelligence(input.city)
const comp = getMarketRent(input.city, input.assetType)
const current = input.currentRentPerSqm
const ASSET_LABELS: Record<string, string> = { OFFICE: 'Bürofläche', LOGISTICS: 'Logistikfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', RETAIL: 'Retailfläche', PRODUCTION: 'Produktionsfläche' }
const assetLabel = ASSET_LABELS[input.assetType] ?? 'Fläche'
// Ohne regionale Vergleichsdaten: nur grobe Schätzung, niedrige Konfidenz
if (!intel || comp == null) {
const rec = Math.round(current * 1.02)
const data: PreMarketRentRecommendation = {
recommendedPerSqm: rec, rangeMinPerSqm: Math.round(rec * 0.93), rangeMaxPerSqm: Math.round(rec * 1.07),
verdict: 'FAIR', deltaVsCurrentPct: 0,
drivers: ['Keine regionalen Vergleichsdaten verfügbar'],
rationale: 'Keine ausreichenden Marktdaten für diese Region — Empfehlung beruht auf dem heutigen Preis.',
confidence: 'LOW',
}
return { data, provenance: mockProvenance() }
}
// Empfehlung am HEUTIGEN Preis des Objekts verankert (realistisch) und nur begrenzt
// nach Marktmomentum (Angebot/Nachfrage/Trend) angepasst — keine Sprünge auf den
// stadtweiten Median (segment-grob). Vergleichsmiete dient nur als Spielraum-Check.
let adj = intel.rentTrend12m / 100 // Trend vorwärts (Pre-Market liegt in der Zukunft)
if (intel.vacancyRatePct < 2.5) adj += 0.04
else if (intel.vacancyRatePct < 4) adj += 0.02
else if (intel.vacancyRatePct > 5.5) adj -= 0.05
else if (intel.vacancyRatePct > 4.5) adj -= 0.02
adj += { VERY_HIGH: 0.05, HIGH: 0.025, MEDIUM: 0, LOW: -0.04 }[intel.demandStrength]
if (intel.avgDaysOnMarket < 35) adj += 0.015
else if (intel.avgDaysOnMarket > 75) adj -= 0.025
// Spielraum-Check: liegt der heutige Preis bereits über dem regionalen Marktband → kaum Luft nach oben
if (comp != null && current >= comp) adj = Math.min(adj, 0.02)
adj = Math.max(-0.10, Math.min(0.15, adj)) // realistischer Rahmen: 10 % … +15 %
const recommended = Math.round(current * (1 + adj))
const rangeMin = Math.round(recommended * 0.95)
const rangeMax = Math.round(recommended * 1.05)
const deltaVsCurrentPct = Math.round(adj * 100)
const verdict: PreMarketRentRecommendation['verdict'] =
deltaVsCurrentPct >= 5 ? 'UNDERPRICED' : deltaVsCurrentPct <= -4 ? 'AMBITIOUS' : 'FAIR'
const supplyLabel = intel.vacancyRatePct < 3 ? 'sehr knappes Angebot' : intel.vacancyRatePct > 5 ? 'entspanntes Angebot' : 'ausgeglichenes Angebot'
const demandLabel = { VERY_HIGH: 'sehr hohe Nachfrage', HIGH: 'hohe Nachfrage', MEDIUM: 'mittlere Nachfrage', LOW: 'schwache Nachfrage' }[intel.demandStrength]
const drivers = [
`Leerstand ${intel.vacancyRatePct}% (${supplyLabel})`,
demandLabel,
`Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}% (12 M)`,
`Ø Vermietungsdauer ${intel.avgDaysOnMarket} Tage`,
]
const verdictText =
verdict === 'UNDERPRICED' ? `Marktumfeld lässt Spielraum nach oben (+${deltaVsCurrentPct}% ggü. heute).`
: verdict === 'AMBITIOUS' ? `Marktumfeld eher schwächer (${deltaVsCurrentPct}% ggü. heute) — vorsichtig ansetzen.`
: 'Heutiger Preis ist marktgerecht.'
const rationale = `${assetLabel} in ${input.city}: ${supplyLabel}, ${demandLabel}, Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}%. Empfehlung für Pre-Market: CHF ${recommended}/m² (CHF ${rangeMin}${rangeMax}) — verankert am heutigen Preis CHF ${current}/m². ${verdictText}`
const confidence: PreMarketRentRecommendation['confidence'] =
intel.demandStrength === 'LOW' || intel.avgDaysOnMarket > 75 ? 'MEDIUM' : 'HIGH'
const data: PreMarketRentRecommendation = {
recommendedPerSqm: recommended, rangeMinPerSqm: rangeMin, rangeMaxPerSqm: rangeMax,
verdict, deltaVsCurrentPct, drivers, rationale, confidence,
}
return { data, provenance: mockProvenance() }
}),
// Legacy methods
extractCriteria: (_input: string) =>
traceMock('extractCriteria', async () => ({
+28 -1
View File
@@ -1,12 +1,39 @@
import { AssetType } from '../../domain/enums'
import type { ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
import type { CreateNeedInput } from '../../domain/need'
import type { CreateNeedInput, Need } from '../../domain/need'
const ASSET_LABELS_TEXT: Record<string, string> = {
OFFICE: 'Bürofläche', RETAIL: 'Retail-Fläche', LOGISTICS: 'Logistikfläche',
PRODUCTION: 'Produktionsfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', MIXED: 'gemischte Fläche',
}
export function needToParsedCriteria(need: Need): ParsedNeedCriteria {
return {
assetType: need.assetType,
areaRange: need.requiredArea,
preferredLocations: need.preferredLocations,
budgetRange: need.budgetRange,
timing: need.timing,
mustHaveCriteria: need.mustCriteriaText,
softFactors: need.softFactors,
requireGroundFloor: need.requireGroundFloor,
requiredFitOut: need.requiredFitOut,
requiredParkingMin: need.requiredParkingMin,
requireAirConditioning: need.requireAirConditioning,
requireLoadingDock: need.requireLoadingDock,
requireBarrierFree: need.requireBarrierFree,
minCeilingHeightM: need.minCeilingHeightM,
minContractDurationMonths: need.minContractDurationMonths,
searchRadius: need.searchRadius,
isAnonymous: need.isAnonymous,
requiresDivisibility: need.requiresDivisibility,
minDivisibleUnit: need.minDivisibleUnit,
fitOutBudgetMaxPerSqm: need.fitOutBudgetMaxPerSqm,
notes: need.notes,
companyName: need.companyName,
}
}
export function generateSummary(c: ParsedNeedCriteria): string {
const parts: string[] = []
if (c.assetType) parts.push(`Suche ${ASSET_LABELS_TEXT[c.assetType] ?? c.assetType}`)
+4
View File
@@ -22,5 +22,9 @@ export type {
DataQualityInput,
DataQualitySummary,
MarketSignalClassification,
FitOutAdvice,
FitOutAdviceInput,
PreMarketRentInput,
PreMarketRentRecommendation,
} from './ai/IAIService'
export { parseListingText } from './ai/mock/listingParser'
+26 -6
View File
@@ -1,6 +1,6 @@
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
import type { InquiryFilters } from '../provider/IInquiryProvider'
import type { Inquiry, Attachment } from '../domain/inquiry'
import type { InquiryFilters, CreateInquiryInput, CreateOfferInput } from '../provider/IInquiryProvider'
import type { Inquiry, Attachment, InquiryMessage } from '../domain/inquiry'
import type { ListResponse, ItemResponse } from './types'
import { throwServiceError } from './errors'
@@ -10,6 +10,8 @@ export interface InquiryReplyPayload {
subject?: string
body: string
attachments?: Attachment[]
senderType?: InquiryMessage['senderType'] // default 'supply_user'
senderName?: string
}
export const inquiryService = {
@@ -31,14 +33,32 @@ export const inquiryService = {
}
},
async createInquiry(input: CreateInquiryInput): Promise<ItemResponse<Inquiry>> {
try {
const data = await provider.createInquiry(input)
return { data }
} catch (err) {
throwServiceError(err)
}
},
async createOffer(input: CreateOfferInput): Promise<ItemResponse<Inquiry>> {
try {
const data = await provider.createOffer(input)
return { data }
} catch (err) {
throwServiceError(err)
}
},
async sendInquiryReply(
inquiryId: string,
payload: InquiryReplyPayload,
): Promise<ItemResponse<Inquiry>> {
try {
const data = await provider.addMessage(inquiryId, {
senderType: 'supply_user',
senderName: 'Wincasa AG',
senderType: payload.senderType ?? 'supply_user',
senderName: payload.senderName ?? 'Wincasa AG',
subject: payload.subject,
body: payload.body,
attachments: payload.attachments ?? [],
@@ -58,9 +78,9 @@ export const inquiryService = {
}
},
async getUnreadCount(): Promise<ItemResponse<number>> {
async getUnreadCount(filters?: InquiryFilters): Promise<ItemResponse<number>> {
try {
const data = await provider.getUnreadCount()
const data = await provider.getUnreadCount(filters)
return { data }
} catch (err) {
throwServiceError(err)
+25 -2
View File
@@ -12,6 +12,7 @@ import type { Need } from '../domain/need'
import type { Property } from '../domain/property'
import { getEffectiveUnits } from '../domain/property'
import { calculateScore } from '../features/matching/scoreCalculator'
import { resolveUnitFacts } from '../lib/unitFacts'
function strengthFromScore(s: number): MatchStrength {
if (s >= 75) return MatchStrength.STRONG
@@ -71,24 +72,38 @@ function scoreProperty(
overrideArea?: number,
overridePrice?: number,
overrideResultType?: string,
overrideHardFacts?: Partial<NonNullable<Property['hardFacts']>>,
): MatchEngineOutput {
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined) {
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined || overrideHardFacts !== undefined) {
return calculateScore(need, {
...prop,
areaSqm: overrideArea ?? prop.areaSqm,
rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm,
resultType: (overrideResultType ?? prop.resultType) as ResultType,
// Einheit-Werte überschreiben Objekt-Werte (Fallback bleibt prop.hardFacts)
hardFacts: overrideHardFacts ? { ...prop.hardFacts, ...overrideHardFacts } : prop.hardFacts,
})
}
return calculateScore(need, prop)
}
// Externe Inserate aus Drittquellen (Scrapes) sind keine Plattform-Inventar-Objekte und
// gehören nicht in die Treffer (nur eigene Plattform-Objekte, Maison Work, Future Availability).
const EXTERNAL_SCRAPE_SOURCES = new Set(['HOMEGATE_SCRAPE', 'IMMOSCOUT_SCRAPE', 'NEWHOME_SCRAPE', 'MATCHOFFICE_SCRAPE'])
function isExternalListing(prop: Property): boolean {
return prop.resultType === ResultType.VERIFIED_PORTFOLIO
&& !!prop.sourceType && EXTERNAL_SCRAPE_SOURCES.has(prop.sourceType)
}
/** Generate matches for a single need against all properties and push them into matchStore. */
export function generateMatchesForNeed(need: Need): void {
const now = new Date().toISOString()
const MIN_SCORE = 22
for (const prop of propertyStore) {
// Externe Scrape-Inserate, die fälschlich als Plattform-Objekt getaggt sind → nicht matchen
if (isExternalListing(prop)) continue
const hasExplicitUnits = (prop.units ?? []).length > 0
if (hasExplicitUnits) {
@@ -101,7 +116,15 @@ export function generateMatchesForNeed(need: Need): void {
}
for (const unit of prop.units!) {
if (!unit.schattenmarktRelease?.enabled) continue
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY)
const facts = resolveUnitFacts(prop, unit)
// Pre-Market: erwarteter künftiger Preis hat Vorrang vor der heutigen Sollmiete
const unitPrice = unit.expectedRentPerSqm ?? unit.rentPricePerSqm ?? prop.rentPricePerSqm
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unitPrice, ResultType.FUTURE_AVAILABILITY, {
fitOut: facts.fitOut,
mieterausbaubeitragPerSqm: facts.mabPerSqm,
fitOutByLandlord: facts.fitOutByLandlord,
parking: facts.parkingSpots,
})
if (unitOutput.excluded || unitOutput.finalScore < MIN_SCORE) continue
const resultId = `schattenmarkt-${prop.id}-${unit.id}`
matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now))
+5 -1
View File
@@ -3,6 +3,7 @@ import type { NeedFilters } from '../provider/INeedProvider'
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
import type { ListResponse, ItemResponse } from './types'
import { throwServiceError } from './errors'
import { useSessionStore } from '../stores/sessionStore'
const provider = MockupNeedProvider
@@ -25,7 +26,10 @@ export const needService = {
},
async create(input: CreateNeedInput): Promise<ItemResponse<Need>> {
try {
const data = await provider.create(input)
// Neues Suchprofil der Organisation des Erstellers zuordnen (sonst fällt es aus der
// org-gefilterten Liste — Aktive-Suche-Leiste zeigt sonst ein fremdes Profil)
const orgId = useSessionStore.getState().currentUser?.organizationId
const data = await provider.create({ ...input, organizationId: input.organizationId ?? orgId })
return { data }
} catch (err) {
throwServiceError(err)
+2 -46
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import type { Inquiry, InquiryMessage } from '../domain/inquiry'
// Reine UI-State: der Anfrage-Dialog. Versendete Anfragen leben jetzt im Provider
// (MockupInquiryProvider) und werden via React Query gelesen — nicht mehr hier.
export interface PendingInquiry {
propertyTitle: string
location: string
@@ -13,15 +14,10 @@ export interface PendingInquiry {
}
interface InquiryStore {
// Dialog state
dialogOpen: boolean
pendingInquiry: PendingInquiry | null
openInquiryDialog: (item: PendingInquiry) => void
closeInquiryDialog: () => void
// Sent inquiries (in-memory, persists for the session)
sentInquiries: Inquiry[]
addInquiry: (item: PendingInquiry, message: string) => void
}
export const useInquiryStore = create<InquiryStore>((set) => ({
@@ -29,44 +25,4 @@ export const useInquiryStore = create<InquiryStore>((set) => ({
pendingInquiry: null,
openInquiryDialog: (item) => set({ dialogOpen: true, pendingInquiry: item }),
closeInquiryDialog: () => set({ dialogOpen: false, pendingInquiry: null }),
sentInquiries: [],
addInquiry: (item, message) => {
const now = new Date().toISOString()
const msgId = `msg-sent-${Date.now()}`
const id = `sent-${Date.now()}`
const thread: InquiryMessage = {
id: msgId,
inquiryId: id,
senderType: 'tenant',
senderName: 'Admin User',
body: message,
attachments: [],
createdAt: now,
}
const inquiry: Inquiry = {
id,
organizationId: 'org-wincasa',
propertyId: item.propertyId ?? 'unknown',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
tenantEmail: 'admin@ideal-sharing.ch',
propertyAddress: item.propertyTitle,
propertyManagerName: 'Verwalter',
propertyManagerCompany: '',
subject: 'Anfrage: ' + item.propertyTitle,
message,
status: 'new',
unreadCount: 0,
isRead: true,
matchScore: item.matchScore,
createdAt: now,
updatedAt: now,
thread: [thread],
}
set(state => ({ sentInquiries: [inquiry, ...state.sentInquiries] }))
},
}))
+2
View File
@@ -17,6 +17,7 @@ interface LayoutState {
// Actions
setActiveWorkspace: (workspace: WorkspaceType) => void
toggleSidebar: () => void
setSidebarCollapsed: (collapsed: boolean) => void
openRightPanel: (type: RightPanelContentType) => void
closeRightPanel: () => void
toggleRightPanel: (type: RightPanelContentType) => void
@@ -30,6 +31,7 @@ export const useLayoutStore = create<LayoutState>((set, get) => ({
setActiveWorkspace: (workspace) => set({ activeWorkspace: workspace }),
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
openRightPanel: (type) => set({ isRightPanelOpen: true, rightPanelContentType: type }),
closeRightPanel: () => set({ isRightPanelOpen: false, rightPanelContentType: null }),
toggleRightPanel: (type) => {