15 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
57 changed files with 1831 additions and 532 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,17 +42,14 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', lineHeight: 1.3 }}>
{need.title}
</Typography>
<Chip
label={statusCfg.label}
size="small"
sx={{
bgcolor: statusCfg.bg,
color: statusCfg.fg,
fontWeight: 600,
fontSize: '0.65rem',
height: 20,
}}
/>
<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 }}
/>
</Box>
</Box>
{need.tenantCompany && (
@@ -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>
)
}
+182 -186
View File
@@ -1,242 +1,248 @@
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}
</Typography>
{need.aiSummary}
</Alert>
</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 }}
>
Gewichtete Präferenzen
</Typography>
<Box sx={{ mt: 1, 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 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,
}}
>
{pct}%
</Typography>
<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 }}>
{need.weightedPreferences.map((p, idx) => {
const pct = Math.round(p.weight * 100)
return (
<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 }}>
{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>
{p.description && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
{p.description}
</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>
{p.description && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
{p.description}
</Typography>
)}
</Box>
)
})}
</Box>
</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 }}>
<Button
variant="contained"
size="large"
fullWidth
onClick={() => openWizard(need.id, need.title)}
disabled={disabled}
sx={{
textTransform: 'none',
bgcolor: '#152642',
fontWeight: 600,
'&:hover': { bgcolor: '#16304d' },
}}
>
Angebot erstellen
</Button>
</Box>
{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' } }}
>
Angebot erstellen
</Button>
</Box>
</Box>
)
@@ -244,24 +250,14 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
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'
@@ -73,16 +73,18 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
/>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Eigenes Ausbaubudget (max. CHF/m²)</Typography>
<TextField
size="small" type="number" fullWidth placeholder="z.B. 200"
value={c.fitOutBudgetMaxPerSqm ?? ''}
onChange={e => set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }}
helperText="Ihr Beitrag — exkl. MAB des Vermieters"
/>
</Box>
{c.requiredFitOut && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Eigenes Ausbaubudget (max. CHF/m²)</Typography>
<TextField
size="small" type="number" fullWidth placeholder="z.B. 200"
value={c.fitOutBudgetMaxPerSqm ?? ''}
onChange={e => set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }}
helperText="Überbrückt Flächen unter Ihrem Mindest-Ausbaustandard — Sie tragen die Differenz selbst (exkl. MAB des Vermieters)."
/>
</Box>
)}
</Box>
{/* Divisibility */}
+2 -2
View File
@@ -27,13 +27,13 @@ export function AppShell() {
const location = useLocation()
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
const isCompact = useMediaQuery(theme.breakpoints.down('xl'))
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 on compact screens (laptops < 1536px)
// Auto-collapse sidebar only on smaller screens (< 1200px); laptops keep the full nav + account
useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed])
// Sync active workspace with URL
@@ -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={{
@@ -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>
)
}
+1
View File
@@ -14,4 +14,5 @@ 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'
@@ -9,7 +9,7 @@ interface Props {
onFloorChange: (v: string) => void
fitOut: string
onFitOutChange: (v: string) => void
fitOutByLandlord: boolean
fitOutByLandlord: boolean | undefined
onFitOutByLandlordChange: (v: boolean) => void
parking: string
onParkingChange: (v: string) => void
@@ -66,19 +66,25 @@ export function TechnicalDetailsSection({
{/* 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?</Typography>
<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 ? 'landlord' : 'tenant'}
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 ? (
{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.
+27 -4
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>
{onViewChange && (
<ViewToggle view={view} onChange={onViewChange} />
)}
<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>
)
}
+123 -89
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,100 +36,126 @@ 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' },
}}
>
{/* Unit info + anonym toggle (shown when enabled) */}
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: DS_TEXT.primary }}>
{floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''}
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>
{u.areaSqm.toLocaleString('de-CH')} m²
</Typography>
{us.enabled && (
<Tooltip title="Adresse und Objektname werden im Matching-Feed ausgeblendet — Interessenten sehen nur Fläche, Typ und Standortregion">
<Box
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5, mt: 0.625,
px: 0.75, py: 0.25, borderRadius: 1,
border: '1px solid',
borderColor: us.anonymous ? '#ddd6fe' : '#e2e8f0',
bgcolor: us.anonymous ? '#f5f3ff' : 'transparent',
cursor: 'pointer',
transition: 'background 0.15s, border-color 0.15s',
}}
onClick={() => {
const next = { ...us, anonymous: !us.anonymous }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
saveUnit(u.id, us.enabled, us.availableFrom, !us.anonymous)
}}
>
<EyeOff size={11} color={us.anonymous ? DS_PRE_MARKET.accent : '#94a3b8'} />
<Typography sx={{ fontSize: '0.62rem', fontWeight: us.anonymous ? 700 : 400, color: us.anonymous ? DS_PRE_MARKET.accent : '#94a3b8', lineHeight: 1 }}>
Anonym
</Typography>
<Switch
size="small"
checked={us.anonymous}
onChange={(_, checked) => {
const next = { ...us, anonymous: checked }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
saveUnit(u.id, us.enabled, us.availableFrom, checked)
}}
onClick={e => e.stopPropagation()}
<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 }}>
{floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''}
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>
{u.areaSqm.toLocaleString('de-CH')} m²
</Typography>
{us.enabled && (
<Tooltip title="Adresse und Objektname werden im Matching-Feed ausgeblendet — Interessenten sehen nur Fläche, Typ und Standortregion">
<Box
sx={{
width: 28, height: 16, p: 0,
'& .MuiSwitch-switchBase': { p: '2px', '&.Mui-checked': { transform: 'translateX(12px)', color: '#fff' } },
'& .MuiSwitch-thumb': { width: 12, height: 12 },
'& .MuiSwitch-track': { borderRadius: 8, bgcolor: '#cbd5e1' },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
display: 'inline-flex', alignItems: 'center', gap: 0.5, mt: 0.625,
px: 0.75, py: 0.25, borderRadius: 1,
border: '1px solid',
borderColor: us.anonymous ? '#ddd6fe' : '#e2e8f0',
bgcolor: us.anonymous ? '#f5f3ff' : 'transparent',
cursor: 'pointer',
transition: 'background 0.15s, border-color 0.15s',
}}
/>
</Box>
</Tooltip>
)}
</Box>
onClick={() => {
const next = { ...us, anonymous: !us.anonymous }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
saveUnit(u.id, us.enabled, us.availableFrom, !us.anonymous)
}}
>
<EyeOff size={11} color={us.anonymous ? DS_PRE_MARKET.accent : '#94a3b8'} />
<Typography sx={{ fontSize: '0.62rem', fontWeight: us.anonymous ? 700 : 400, color: us.anonymous ? DS_PRE_MARKET.accent : '#94a3b8', lineHeight: 1 }}>
Anonym
</Typography>
<Switch
size="small"
checked={us.anonymous}
onChange={(_, checked) => {
const next = { ...us, anonymous: checked }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
saveUnit(u.id, us.enabled, us.availableFrom, checked)
}}
onClick={e => e.stopPropagation()}
sx={{
width: 28, height: 16, p: 0,
'& .MuiSwitch-switchBase': { p: '2px', '&.Mui-checked': { transform: 'translateX(12px)', color: '#fff' } },
'& .MuiSwitch-thumb': { width: 12, height: 12 },
'& .MuiSwitch-track': { borderRadius: 8, bgcolor: '#cbd5e1' },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
}}
/>
</Box>
</Tooltip>
)}
</Box>
{/* Available-from date */}
<TextField
type="date"
size="small"
value={us.availableFrom}
disabled={!us.enabled}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 }, mt: 0.125 }}
onChange={e => {
const next = { ...us, availableFrom: e.target.value }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
if (us.enabled) saveUnit(u.id, true, e.target.value, us.anonymous)
}}
/>
{/* Enabled toggle */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: DS_PRE_MARKET.accent }} />}
<Switch
{/* Available-from date */}
<TextField
type="date"
size="small"
checked={us.enabled}
onChange={(_, checked) => {
const next = { ...us, enabled: checked }
value={us.availableFrom}
disabled={!us.enabled}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 }, mt: 0.125 }}
onChange={e => {
const next = { ...us, availableFrom: e.target.value }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
saveUnit(u.id, checked, us.availableFrom, us.anonymous)
}}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: DS_PRE_MARKET.accent },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
if (us.enabled) saveUnit(u.id, true, e.target.value, us.anonymous)
}}
/>
{/* 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}
onChange={(_, checked) => {
const next = { ...us, enabled: checked }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
saveUnit(u.id, checked, us.availableFrom, us.anonymous)
}}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: DS_PRE_MARKET.accent },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
}}
/>
</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>
)
})}
@@ -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 }}>
<TextField
select label="Ausbaustandard"
size="small" fullWidth
value={draft.hardFacts?.fitOut ?? p.hardFacts?.fitOut ?? ''}
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined } })}
>
{[
{ value: '', label: 'Keine Angabe' },
{ value: 'SHELL', label: 'Rohbau' },
{ value: 'BASIC', label: 'Basisausbau' },
{ value: 'FULL', label: 'Vollausbau' },
{ value: 'PREMIUM', label: 'Premiumausbau' },
].map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
</TextField>
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
size="small" fullWidth type="number"
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters zum Ausbau"
value={draft.hardFacts?.mieterausbaubeitragPerSqm ?? p.hardFacts?.mieterausbaubeitragPerSqm ?? ''}
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined } })}
/>
<TextField
label="Parkplätze"
size="small" fullWidth type="number"
slotProps={{ htmlInput: { min: 0 } }}
value={draft.hardFacts?.parking ?? p.hardFacts?.parking ?? ''}
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), parking: parseInt(e.target.value) || undefined } })}
/>
<TextField
label="Deckenhöhe (m)"
size="small" fullWidth type="number"
slotProps={{ htmlInput: { step: 0.1, min: 2 } }}
value={draft.hardFacts?.ceilingHeightM ?? p.hardFacts?.ceilingHeightM ?? ''}
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), ceilingHeightM: parseFloat(e.target.value) || undefined } })}
/>
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
<TextField
select label="Ausbaustandard"
size="small" fullWidth
value={editFitOut}
onChange={e => patchHF({ fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined })}
>
{FIT_OUT_OPTIONS.map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
</TextField>
<TextField
label="Parkplätze"
size="small" fullWidth type="number"
slotProps={{ htmlInput: { min: 0 } }}
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={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 },
{
+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 }
+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
+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: (() => {
+10 -1
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { aiService, parseListingText } from '../services/aiService'
import type { OfferEmailPayload, FitOutAdviceInput } from '../services/aiService'
import type { OfferEmailPayload, FitOutAdviceInput, PreMarketRentInput } from '../services/aiService'
export function useParseNeed() {
return useMutation({
@@ -34,3 +34,12 @@ export function useFitOutAdvice(input: FitOutAdviceInput | null) {
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({
+10 -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,7 +48,7 @@ export interface NewListingFormState {
// Technical
floor: string
fitOut: string
fitOutByLandlord: boolean
fitOutByLandlord: boolean | undefined
parking: string
ceilingHeight: string
mieterausbaubeitrag: string
@@ -81,7 +86,7 @@ export interface NewListingFormHandlers {
setSoftLevel: (key: string, value: string) => void
setFloor: (v: string) => void
setFitOut: (v: string) => void
setFitOutByLandlord: (v: boolean) => void
setFitOutByLandlord: (v: boolean | undefined) => void
setParking: (v: string) => void
setCeilingHeight: (v: string) => void
setMieterausbaubeitrag: (v: string) => void
@@ -119,7 +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(pre.fitOutByLandlord ?? false)
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) : '')
@@ -164,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(
@@ -188,7 +193,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('')
setContactName(''); setContactEmail(''); setContactPhone('')
setSoftLevels(emptySoftLevels())
setFloor(''); setFitOut(''); setFitOutByLandlord(false); setParking(''); setCeilingHeight('')
setFloor(''); setFitOut(''); setFitOutByLandlord(undefined); setParking(''); setCeilingHeight('')
setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('')
setImages([]); setImageInput(''); setFloorPlanUrl('')
setAiText(''); setAiApplied(false)
+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)
})
})
+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',
},
],
},
]
+49 -35
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'
@@ -18,10 +17,9 @@ import { useSessionStore } from '../../stores/sessionStore'
// ── Config ────────────────────────────────────────────────────────────────────
const FILTER_TABS = [
{ key: 'all', label: 'Alle' },
{ key: 'new', label: 'Neu' },
{ key: 'in_progress', label: 'Aktiv' },
{ key: 'answered', label: 'Beantwortet' },
{ key: 'all', label: 'Alle' },
{ 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
@@ -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 => (
+12 -1
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, FitOutAdvicePanel } 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 (
@@ -238,6 +239,16 @@ export default function MatchDetail() {
/>
)}
{/* ── 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}
/>
)}
{/* ── Full analysis toggle + expanded panels ── */}
<MatchDetailScoreBreakdown
match={match}
+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
@@ -46,7 +46,7 @@ export function buildCreatePropertyInput(fields: {
const hf = {
floor: floor ? parseInt(floor) : undefined,
fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined,
fitOutByLandlord: fitOutByLandlord || undefined,
fitOutByLandlord,
// MAB nur relevant, wenn der Mieter ausbaut — sonst nicht speichern
mieterausbaubeitragPerSqm: !fitOutByLandlord && mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
parking: parking ? parseInt(parking) : 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}`)
+2
View File
@@ -24,5 +24,7 @@ export type {
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] }))
},
}))