7 Commits

Author SHA1 Message Date
Benjamin Sutter fca113e7ab feat(demand): market rent estimate (asking vs. fair) + pre-market expected price
- lib/rentEstimate: deterministic market estimate from Standort-Intelligence (median rent benchmark + vacancy/trend) → fair rent, verdict BELOW/AT/ABOVE, deltaPct, rationale; suggestFutureRent indexes today's price by city rent trend
- MarketPricePanel in MatchDetail: asking vs. fair market rent + verdict badge + rationale; shows pre-market expected price (heute → erwartet)
- unit.expectedRentPerSqm: future pre-market price; scorer prefers it over current rent for FUTURE_AVAILABILITY
- UnitStructurePanel editor: expected-price field (with indexed suggestion) shown for pre-market-released units
- tests: rentEstimate verdict thresholds + future-rent indexing

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:30:06 +02:00
54 changed files with 2418 additions and 558 deletions
+11 -24
View File
@@ -20,8 +20,8 @@ import {
import { CompareCell, MissingDataCell } from './index'
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
// ── Local helper ──────────────────────────────────────────────────────────────
@@ -155,11 +155,7 @@ export function CompareTableBody({
const prop = getProp(item)
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Grundausbau', FULL: 'Vollausbau', PREMIUM: 'Premium-Ausbau',
}
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
const fitOutByLandlord = prop.hardFacts?.fitOutByLandlord
const monthlyRent = prop.totalRentMonthly
?? Math.round(prop.rentPricePerSqm * prop.areaSqm / 12)
// ancillaryCosts stored as CHF/m²/Monat
@@ -170,21 +166,12 @@ export function CompareTableBody({
const fitOutLabel = fitOut ? (FIT_OUT_LABELS[fitOut] ?? fitOut) : null
const mabPerSqm = prop.hardFacts?.mieterausbaubeitragPerSqm ?? 0
// Amortised fit-out monthly cost (mid-range estimate)
let fitOutMonthly = 0
let fitOutMonthlyLabel: string | null = null
if (fitOut && !READY_TO_MOVE_IN.has(fitOut)) {
const inv = calcFitOutInvestment(fitOut, prop.areaSqm, mabPerSqm, 0)
if (inv && !inv.isFullyCovered) {
const months = FITOUT_AMORTIZATION_YEARS * 12
const midMin = Math.round(inv.netTotal.min / months)
const midMax = Math.round(inv.netTotal.max / months)
fitOutMonthly = Math.round((midMin + midMax) / 2)
fitOutMonthlyLabel = midMin === midMax
? `${midMin.toLocaleString('de-CH')}`
: `${midMin.toLocaleString('de-CH')}${midMax.toLocaleString('de-CH')}`
}
}
// Annuitätischer Ausbau-Aufschlag pro Monat (= 0 bei Vermieter-Übernahme / bezugsfertig)
const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({
fitOut, rentPricePerSqm: prop.rentPricePerSqm, mabPerSqm, fitOutByLandlord,
})
const fitOutMonthly = Math.round(fitOutPerSqm * prop.areaSqm / 12)
const fitOutMonthlyLabel = fitOutMonthly > 0 ? fitOutMonthly.toLocaleString('de-CH') : null
const totalMonthly = monthlyRent
+ (monthlyNebenkosten ?? 0)
@@ -205,7 +192,7 @@ export function CompareTableBody({
)}
{fitOutMonthlyLabel && (
<Typography variant="caption" color="text.secondary">
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau ÷ {FITOUT_AMORTIZATION_YEARS} J.)
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau annuit. {FITOUT_AMORTIZATION_YEARS} J. / {Math.round(FITOUT_ANNUITY_RATE * 100)}%)
</Typography>
)}
<Typography variant="body2" sx={{ fontWeight: 700, color: '#152642', borderTop: '1px solid #e2e8f0', pt: 0.5, mt: 0.25 }}>
@@ -213,7 +200,7 @@ export function CompareTableBody({
</Typography>
{fitOutLabel && (
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.25 }}>
Ausbau: {fitOutLabel}{READY_TO_MOVE_IN.has(fitOut ?? '') ? ' (bezugsfertig)' : ''}
Ausbau: {fitOutLabel}{fitOutByLandlord ? ' (im Mietzins)' : fitOutMonthly === 0 ? ' (bezugsfertig)' : ''}
</Typography>
)}
</Box>
@@ -73,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 */}
+160
View File
@@ -0,0 +1,160 @@
import { memo } from 'react'
import { Box, Button, Typography } from '@mui/material'
import { Ruler } from 'lucide-react'
import { useNavigate } from 'react-router'
import { ROUTES } from '../../lib/constants'
import type { Property } from '../../domain/property'
import type { VerifiedPortfolioResult } from '../../domain/unifiedResult'
import type { Match } from '../../domain/match'
import { MatchStrength, ResultType } from '../../domain/enums'
import { useInquiryStore } from '../../stores/inquiryStore'
import { usePipelineStore } from '../../stores/pipelineStore'
import { useCompareStore } from '../../stores/compareStore'
interface Props {
property: Property
score: number
needId: string
}
function buildSyntheticResult(property: Property, needId: string, score: number, matchId: string): VerifiedPortfolioResult {
const strength = score >= 85 ? MatchStrength.STRONG : score >= 70 ? MatchStrength.MODERATE : MatchStrength.WEAK
const syntheticMatch = {
id: matchId,
needId,
propertyId: property.id,
matchScore: score,
matchStrength: strength,
scoreBreakdown: { hardMatchScore: score, softFactorScore: score, confidenceModifier: 0, dataQualityModifier: 0, totalScore: score },
confidenceLevel: score / 100,
} as unknown as Match
return {
matchId,
needId,
matchScore: score,
resultType: ResultType.VERIFIED_PORTFOLIO,
property,
match: syntheticMatch,
}
}
export const PropertyMatchRow = memo(function PropertyMatchRow({ property, score, needId }: Props) {
const navigate = useNavigate()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const openSavedDialog = usePipelineStore(s => s.openSavedDialog)
const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore()
const scoreColor = score >= 85 ? '#16a34a' : score >= 70 ? '#d97706' : '#dc2626'
const scoreBg = score >= 85 ? '#dcfce7' : score >= 70 ? '#fef3c7' : '#fee2e2'
// Match IDs in the store use double-underscore format: m__propId__prop__needId
const matchId = `m__${property.id}__prop__${needId}`
const inCompare = isInCompare(matchId)
function handleInquire() {
openInquiryDialog({
propertyTitle: property.title,
location: property.location.city,
matchScore: score,
matchId,
propertyId: property.id,
areaLabel: property.areaSqm ? `${property.areaSqm}` : undefined,
rentLabel: property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
})
}
function handleShortlist() {
openSavedDialog({
resultId: matchId,
resultType: 'VERIFIED_PORTFOLIO',
title: property.title,
matchScore: score,
location: property.location.city,
propertyId: property.id,
propertyAddress: `${property.title}, ${property.location.city}`,
areaLabel: property.areaSqm ? `${property.areaSqm}` : undefined,
rentLabel: property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
})
}
function handleCompare() {
if (inCompare) {
removeFromCompare(matchId)
} else if (!isFull()) {
addToCompare(buildSyntheticResult(property, needId, score, matchId))
}
}
function handleDetails() {
navigate(`/demand/results/${matchId}`)
}
function handleUnit() {
navigate(`/demand/property/${property.id}`)
}
return (
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, p: 1.25 }}>
{property.images?.[0] ? (
<Box component="img" src={property.images[0]} alt=""
sx={{ width: 52, height: 52, borderRadius: 1, objectFit: 'cover', flexShrink: 0 }} />
) : (
<Box sx={{ width: 52, height: 52, borderRadius: 1, bgcolor: '#e0e7ff', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Ruler size={18} color="#3730a3" />
</Box>
)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.825rem', color: '#0f172a' }} noWrap>
{property.title}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
{property.location.city} · {property.areaSqm} m²
</Typography>
</Box>
<Box sx={{ bgcolor: scoreBg, color: scoreColor, px: 1, py: 0.375, borderRadius: 1, fontWeight: 700, fontSize: '0.8rem', flexShrink: 0 }}>
{score}%
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.75, px: 1.25, pb: 1.25, flexWrap: 'wrap' }}>
<ActionButton onClick={handleInquire} primary>Anfrage</ActionButton>
<ActionButton onClick={handleShortlist}>Merken</ActionButton>
<ActionButton onClick={handleCompare} disabled={!inCompare && isFull()}>
{inCompare ? 'Im Vergleich' : 'Vergleichen'}
</ActionButton>
<ActionButton onClick={handleDetails}>Details </ActionButton>
<ActionButton onClick={handleUnit}>Zur Einheit </ActionButton>
</Box>
</Box>
)
})
function ActionButton({ children, onClick, primary, disabled }: {
children: React.ReactNode
onClick: () => void
primary?: boolean
disabled?: boolean
}) {
return (
<Button
size="small"
variant={primary ? 'contained' : 'outlined'}
onClick={onClick}
disabled={disabled}
sx={{
textTransform: 'none',
fontWeight: 600,
fontSize: '0.75rem',
py: 0.25,
px: 1,
minWidth: 0,
...(primary
? { bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }
: { borderColor: '#cbd5e1', color: '#475569', '&:hover': { borderColor: '#152642', color: '#152642' } }),
}}
>
{children}
</Button>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { memo } from 'react'
import { Box, Chip, Paper, Typography } from '@mui/material'
import { MapPin } from 'lucide-react'
import type { Need } from '../../domain/need'
import { ASSET_TYPE_LABELS } from '../../lib/constants'
interface Props {
need: Need
selected: boolean
matchCount: number
topScore: number
onClick: () => void
}
function statusConfig(status: Need['status']): { label: string; bg: string; fg: string } {
if (status === 'DRAFT') return { label: 'Entwurf', bg: '#fef3c7', fg: '#92400e' }
return { label: 'Aktiv', bg: '#dcfce7', fg: '#166534' }
}
export const SavedNeedCard = memo(function SavedNeedCard({ need, selected, matchCount, topScore, onClick }: Props) {
const statusCfg = statusConfig(need.status)
const locationText = need.preferredLocations.slice(0, 2).join(', ')
const scoreColor = topScore >= 80 ? '#16a34a' : topScore >= 65 ? '#d97706' : '#dc2626'
return (
<Paper
onClick={onClick}
elevation={0}
sx={{
p: 1.5,
borderRadius: 1.5,
border: '1px solid',
borderColor: selected ? '#152642' : '#e2e8f0',
bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': { borderColor: selected ? '#152642' : '#94a3b8', boxShadow: '0 2px 6px rgba(15,23,42,0.06)' },
display: 'flex',
flexDirection: 'column',
gap: 0.75,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small"
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
<Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
</Box>
{matchCount > 0 && (
<Box sx={{ bgcolor: '#dcfce7', color: '#15803d', borderRadius: 1, px: 0.75, py: 0.125, fontSize: '0.68rem', fontWeight: 700, flexShrink: 0 }}>
{matchCount} Treffer
</Box>
)}
</Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', color: '#0f172a', lineHeight: 1.3 }}>
{need.companyName}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<MapPin size={11} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>{locationText || '—'}</Typography>
</Box>
{topScore > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#f1f5f9', overflow: 'hidden' }}>
<Box sx={{ width: `${topScore}%`, height: '100%', bgcolor: scoreColor }} />
</Box>
<Typography variant="caption" sx={{ fontSize: '0.72rem', fontWeight: 700, color: scoreColor, flexShrink: 0 }}>
{topScore}%
</Typography>
</Box>
)}
</Paper>
)
})
+326
View File
@@ -0,0 +1,326 @@
import { memo, useMemo, useState } from 'react'
import {
Accordion, AccordionDetails, AccordionSummary,
Box, Button, Chip, Divider, LinearProgress, Slider, Switch, TextField, Typography,
} from '@mui/material'
import { Bell, Calendar, CheckCircle2, ChevronDown, MapPin, Ruler, Target, Wallet } from 'lucide-react'
import type { Need, NotificationConfig, UpdateNeedInput } from '../../domain/need'
import type { Property } from '../../domain/property'
import { ASSET_TYPE_LABELS } from '../../lib/constants'
import { confidenceHex } from '../../lib/utils'
import { useMatchesByNeed } from '../../hooks/useMatches'
import { PropertyMatchRow } from './PropertyMatchRow'
const WEIGHT_LABELS: Record<string, string> = {
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Timing',
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansionspotenzial',
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Publikumsverkehr',
talentAccess: 'Talentzugang', esg: 'ESG', taxEnvironment: 'Steuerumgebung',
}
function statusConfig(status: Need['status']): { label: string; bg: string; fg: string } {
if (status === 'DRAFT') return { label: 'Entwurf', bg: '#fef3c7', fg: '#92400e' }
return { label: 'Aktiv', bg: '#dcfce7', fg: '#166534' }
}
interface Props {
need: Need
properties: Property[]
onStart: () => void
onEdit: () => void
onArchive: () => void
onUpdate: (data: UpdateNeedInput) => void
}
export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, onStart, onEdit, onArchive, onUpdate }: Props) {
const statusCfg = statusConfig(need.status)
const conf = need.confidenceInCriteria ?? 0
const confColor = confidenceHex(conf)
const [notif, setNotif] = useState<NotificationConfig>(() => need.notificationConfig ?? { enabled: true, minScore: 80 })
const [notifDirty, setNotifDirty] = useState(false)
function handleNotifChange(patch: Partial<NotificationConfig>) {
setNotif(prev => ({ ...prev, ...patch }))
setNotifDirty(true)
}
function handleNotifSave() {
onUpdate({ notificationConfig: notif })
setNotifDirty(false)
}
const { data: matches = [] } = useMatchesByNeed(need.id)
const notifThreshold = need.notificationConfig?.minScore ?? 80
const matchCount = matches.filter(m => m.matchScore >= notifThreshold).length
const scoredProperties = useMemo(() => {
const seen = new Set<string>()
return matches
.map(m => {
const property = properties.find(p => p.id === m.propertyId)
return property ? { property, score: Math.round(m.matchScore) } : null
})
.filter((x): x is { property: Property; score: number } => x !== null)
.sort((a, b) => b.score - a.score)
.filter(x => {
if (seen.has(x.property.id)) return false
seen.add(x.property.id)
return true
})
}, [matches, properties])
const topScore = scoredProperties.length > 0 ? scoredProperties[0].score : 0
const topProperties = scoredProperties.slice(0, 3)
const topWeights = useMemo(
() => Object.entries(need.weightingProfile).filter(([, v]) => v > 0.03).sort(([, a], [, b]) => b - a).slice(0, 6),
[need.weightingProfile],
)
return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small"
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
<Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.25rem' }}>
{need.companyName}
</Typography>
{need.contactName && (
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.25 }}>{need.contactName}</Typography>
)}
</Box>
<Box>
<SectionLabel>Suchkriterien</SectionLabel>
<Box sx={{ mt: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 1.25 }}>
<CriteriaBox icon={<MapPin size={13} />} label="Standort" value={need.preferredLocations.join(', ') || '—'} />
<CriteriaBox icon={<Ruler size={13} />} label="Fläche" value={`${need.requiredArea.min}${need.requiredArea.max}`} />
<CriteriaBox icon={<Wallet size={13} />} label="Budget" value={`CHF ${need.budgetRange.maxPerSqm} /m²`} />
<CriteriaBox icon={<Calendar size={13} />} label="Einzug ab" value={need.timing.earliestMoveIn || '—'} />
</Box>
</Box>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<SectionLabel>Konfidenz der Kriterien</SectionLabel>
<Typography variant="caption" sx={{ fontWeight: 700, color: confColor, fontSize: '0.8rem' }}>
{Math.round(conf * 100)}%
</Typography>
</Box>
<LinearProgress variant="determinate" value={conf * 100} sx={{
height: 6, borderRadius: 3, bgcolor: '#f1f5f9',
'& .MuiLinearProgress-bar': { bgcolor: confColor, borderRadius: 3 },
}} />
</Box>
{(need.mustCriteriaText?.length ?? 0) > 0 && (
<Box>
<SectionLabel>Must-have Kriterien</SectionLabel>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{need.mustCriteriaText!.map((c, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={14} color="#16a34a" />
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
</Box>
))}
</Box>
</Box>
)}
{topWeights.length > 0 && (
<Accordion elevation={0} disableGutters sx={{
border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden',
'&:before': { display: 'none' }, bgcolor: 'white',
}}>
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Gewichtete Präferenzen
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{topWeights.map(([key, val]) => {
const pct = Math.round(val * 100)
return (
<Box key={key} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
{WEIGHT_LABELS[key] ?? key}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, borderRadius: 1 }}>
{pct}%
</Typography>
</Box>
<Box sx={{ height: 5, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
</Box>
</Box>
)
})}
</Box>
</AccordionDetails>
</Accordion>
)}
<Accordion elevation={0} disableGutters sx={{
border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden',
'&:before': { display: 'none' }, bgcolor: 'white',
}}>
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Bell size={13} color={notif.enabled ? '#152642' : '#94a3b8'} />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Benachrichtigungen
</Typography>
{notif.enabled && (
<Box sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontSize: '0.65rem', fontWeight: 700, px: 0.75, borderRadius: 0.75 }}>
ab {notif.minScore}%
</Box>
)}
</Box>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Benachrichtigung aktiv</Typography>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
Neue Treffer oberhalb der Schwelle melden
</Typography>
</Box>
<Switch
size="small"
checked={notif.enabled}
onChange={e => handleNotifChange({ enabled: e.target.checked })}
sx={{ '& .MuiSwitch-thumb': { bgcolor: notif.enabled ? '#152642' : undefined } }}
/>
</Box>
{notif.enabled && (
<>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Match-Schwelle</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', fontSize: '0.8rem', bgcolor: '#e0e7ff', px: 0.75, borderRadius: 0.75 }}>
{notif.minScore}%
</Typography>
</Box>
<Slider
value={notif.minScore}
min={60} max={95} step={5}
onChange={(_, v) => handleNotifChange({ minScore: v as number })}
sx={{ color: '#152642', '& .MuiSlider-thumb': { width: 14, height: 14 } }}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>60%</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>95%</Typography>
</Box>
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', mb: 0.5 }}>
E-Mail (optional)
</Typography>
<TextField
size="small"
fullWidth
placeholder="name@firma.ch"
value={notif.emailAddress ?? ''}
onChange={e => handleNotifChange({ emailAddress: e.target.value || undefined })}
inputProps={{ type: 'email' }}
sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.85rem' } }}
helperText="Im Produktivsystem wird bei neuen Treffern eine E-Mail ausgelöst"
/>
</Box>
</>
)}
{notifDirty && (
<Button
variant="contained"
size="small"
onClick={handleNotifSave}
sx={{ textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' }, alignSelf: 'flex-end' }}
>
Speichern
</Button>
)}
</Box>
</AccordionDetails>
</Accordion>
{scoredProperties.length > 0 && (
<>
<Divider />
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
<Target size={14} color="#15803d" />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Passende Objekte
</Typography>
<Box sx={{ ml: 'auto', bgcolor: '#dcfce7', color: '#15803d', borderRadius: 1, px: 1, py: 0.125, fontWeight: 700, fontSize: '0.72rem' }}>
{matchCount} Treffer
</Box>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{topProperties.map(({ property, score }) => (
<PropertyMatchRow key={property.id} property={property} score={score} needId={need.id} />
))}
</Box>
{matchCount > 3 && (
<Box
onClick={onStart}
sx={{ mt: 1.25, textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600,
py: 1, border: '1px dashed #cbd5e1', borderRadius: 1.5, '&:hover': { bgcolor: '#f1f5f9', borderColor: '#152642' }, transition: 'all 0.15s' }}
>
Alle {matchCount} Ergebnisse anzeigen
</Box>
)}
</Box>
</>
)}
</Box>
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', px: 3, py: 2, bgcolor: 'white', display: 'flex', gap: 1.5 }}>
<Button variant="outlined" size="large" onClick={onEdit}
sx={{ flex: 1, textTransform: 'none', fontWeight: 600, borderColor: '#cbd5e1', color: '#475569' }}>
Bearbeiten
</Button>
<Button variant="contained" size="large" onClick={onStart}
sx={{ flex: 2, textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>
Suche starten
</Button>
</Box>
</Box>
)
})
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{children}
</Typography>
)
}
function CriteriaBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
{icon}
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{label}
</Typography>
</Box>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
</Box>
)
}
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useMemo, useState } from 'react'
import { Box, Button, CircularProgress, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, Plus, Search } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useNeedProfiles, useArchiveNeed, useUpdateNeed } from '../../hooks/useNeeds'
import { useProperties } from '../../hooks/useProperties'
import { useMatches } from '../../hooks/useMatches'
import { ResultType } from '../../domain/enums'
import { ROUTES } from '../../lib/constants'
import { EmptyState } from '../ui'
import { SavedNeedCard } from './SavedNeedCard'
import { SavedNeedDetail } from './SavedNeedDetail'
import { needToParsedCriteria } from '../../services/aiSearch/needSearchMapper'
interface Props {
onNewSearch: (prefillNeedId?: string) => void
}
export function SavedProfilesTab({ onNewSearch }: Props) {
const navigate = useNavigate()
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const { data: needs = [], isLoading } = useNeedProfiles()
const { data: properties = [] } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
const { data: allMatches = [] } = useMatches()
const archiveMutation = useArchiveNeed()
const updateMutation = useUpdateNeed()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [mobileView, setMobileView] = useState<'list' | 'detail'>('list')
const visible = useMemo(
() =>
needs
.filter(n => !n.status || n.status === 'ACTIVE' || n.status === 'DRAFT')
.sort((a, b) => {
const aActive = !a.status || a.status === 'ACTIVE'
const bActive = !b.status || b.status === 'ACTIVE'
if (aActive !== bActive) return aActive ? -1 : 1
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
}),
[needs],
)
const { matchCounts, topScores } = useMemo(() => {
const counts: Record<string, number> = {}
const tops: Record<string, number> = {}
for (const n of visible) {
const needMatches = allMatches.filter(m => m.needId === n.id)
const threshold = n.notificationConfig?.minScore ?? 80
counts[n.id] = needMatches.filter(m => m.matchScore >= threshold).length
tops[n.id] = needMatches.length > 0 ? Math.round(Math.max(...needMatches.map(m => m.matchScore))) : 0
}
return { matchCounts: counts, topScores: tops }
}, [visible, allMatches])
useEffect(() => {
if (!isMobile && !selectedId && visible.length > 0) setSelectedId(visible[0].id)
}, [visible, selectedId, isMobile])
const selectedNeed = visible.find(n => n.id === selectedId) ?? null
function handleSelect(id: string) {
setSelectedId(id)
if (isMobile) setMobileView('detail')
}
function handleStart(needId: string) {
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
}
function handleEdit(needId: string) {
onNewSearch(needId)
}
if (isLoading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', pt: 8 }}><CircularProgress size={24} /></Box>
}
if (visible.length === 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pt: 8, gap: 2 }}>
<EmptyState title="Keine Suchprofile" description="Sie haben noch keine Suchprofile gespeichert." />
<Button variant="outlined" startIcon={<Plus size={16} />} onClick={() => onNewSearch()} sx={{ textTransform: 'none', mt: 1 }}>
Neue Suche erstellen
</Button>
</Box>
)
}
const list = (
<Box sx={{ width: 280, minWidth: 280, flexShrink: 0, borderRight: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<Search size={14} color="#7c3aed" />
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>Suchprofile</Typography>
<Box sx={{ ml: 'auto', px: 1, py: 0.125, borderRadius: 1, bgcolor: '#152642', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
{visible.length}
</Box>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', p: 1.25, display: 'flex', flexDirection: 'column', gap: 1 }}>
{visible.map(n => (
<SavedNeedCard
key={n.id}
need={n}
selected={n.id === selectedId}
matchCount={matchCounts[n.id] ?? 0}
topScore={topScores[n.id] ?? 0}
onClick={() => handleSelect(n.id)}
/>
))}
</Box>
<Box sx={{ flexShrink: 0, p: 1.5, borderTop: '1px solid #e2e8f0' }}>
<Button fullWidth variant="outlined" size="small" startIcon={<Plus size={14} />} onClick={() => onNewSearch()}
sx={{ textTransform: 'none', fontSize: '0.8rem' }}>
Neue Suche
</Button>
</Box>
</Box>
)
if (isMobile) {
if (mobileView === 'detail' && selectedNeed) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton size="small" onClick={() => setMobileView('list')}><ArrowLeft size={18} /></IconButton>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }} noWrap>{selectedNeed.companyName}</Typography>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<SavedNeedDetail
need={selectedNeed}
properties={properties}
onStart={() => handleStart(selectedNeed.id)}
onEdit={() => handleEdit(selectedNeed.id)}
onArchive={() => archiveMutation.mutate(selectedNeed.id)}
onUpdate={(data) => updateMutation.mutate({ id: selectedNeed.id, ...data })}
/>
</Box>
</Box>
)
}
return <Box sx={{ height: '100%', overflow: 'hidden' }}>{list}</Box>
}
return (
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
{list}
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{selectedNeed ? (
<SavedNeedDetail
need={selectedNeed}
properties={properties}
onStart={() => handleStart(selectedNeed.id)}
onEdit={() => handleEdit(selectedNeed.id)}
onArchive={() => archiveMutation.mutate(selectedNeed.id)}
onUpdate={(data) => updateMutation.mutate({ id: selectedNeed.id, ...data })}
/>
) : (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<EmptyState icon={<Search size={40} />} title="Profil auswählen" description="Wählen Sie ein Suchprofil, um Details zu sehen." />
</Box>
)}
</Box>
</Box>
)
}
+4
View File
@@ -9,3 +9,7 @@ export { NeedCardPreview } from './NeedCardPreview'
export { NeedInput } from './NeedInput'
export { VoiceNeedInput } from './VoiceNeedInput'
export { WeightingEditor } from './WeightingEditor'
export { PropertyMatchRow } from './PropertyMatchRow'
export { SavedNeedCard } from './SavedNeedCard'
export { SavedNeedDetail } from './SavedNeedDetail'
export { SavedProfilesTab } from './SavedProfilesTab'
+6 -1
View File
@@ -21,16 +21,21 @@ export function AppShell() {
const sidebarCollapsed = useLayoutStore(s => s.sidebarCollapsed)
const setActiveWorkspace = useLayoutStore(s => s.setActiveWorkspace)
const toggleSidebar = useLayoutStore(s => s.toggleSidebar)
const setSidebarCollapsed = useLayoutStore(s => s.setSidebarCollapsed)
const { currentUser } = useSessionStore()
const navigate = useNavigate()
const location = useLocation()
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
const isCompact = useMediaQuery(theme.breakpoints.down('xl'))
const [mobileOpen, setMobileOpen] = useState(false)
// Close mobile menu on route change
useEffect(() => { setMobileOpen(false) }, [location.pathname])
// Auto-collapse sidebar on compact screens (laptops < 1536px)
useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed])
// Sync active workspace with URL
useEffect(() => {
const detected = getWorkspaceFromPath(location.pathname)
+99 -9
View File
@@ -1,22 +1,74 @@
import { useState } from 'react'
import { Badge, IconButton, Popover, Typography } from '@mui/material'
import { useMemo, useState } from 'react'
import { Badge, Box, Divider, IconButton, Popover, Typography } from '@mui/material'
import { Bell } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useNeedProfiles } from '../../hooks/useNeeds'
import { useMatches } from '../../hooks/useMatches'
import { ROUTES } from '../../lib/constants'
function loadSeenCounts(): Record<string, number> {
try { return JSON.parse(localStorage.getItem('notif-seen-counts') ?? '{}') }
catch { return {} }
}
function saveSeenCounts(counts: Record<string, number>) {
localStorage.setItem('notif-seen-counts', JSON.stringify(counts))
}
export function NotificationButton() {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const [seenCounts, setSeenCounts] = useState<Record<string, number>>(loadSeenCounts)
const navigate = useNavigate()
const { data: needs = [] } = useNeedProfiles()
const { data: allMatches = [] } = useMatches()
const profilesWithMatches = useMemo(() => {
const active = needs.filter(n =>
(!n.status || n.status === 'ACTIVE' || n.status === 'DRAFT') &&
(n.notificationConfig?.enabled !== false),
)
return active
.map(n => {
const minScore = n.notificationConfig?.minScore ?? 80
return {
need: n,
count: allMatches.filter(m => m.needId === n.id && m.matchScore >= minScore).length,
minScore,
}
})
.filter(x => x.count > 0)
}, [needs, allMatches])
// Badge only lights up for profiles with matches the user hasn't seen yet
const newProfiles = useMemo(
() => profilesWithMatches.filter(x => x.count > (seenCounts[x.need.id] ?? 0)),
[profilesWithMatches, seenCounts],
)
const badgeCount = newProfiles.length
function handleOpen(e: React.MouseEvent<HTMLElement>) {
setAnchorEl(e.currentTarget)
// Mark all current matches as seen
const updated = { ...seenCounts }
for (const { need, count } of profilesWithMatches) {
updated[need.id] = count
}
setSeenCounts(updated)
saveSeenCounts(updated)
}
function handleClose() {
setAnchorEl(null)
function handleClose() { setAnchorEl(null) }
function goToResults(needId: string) {
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
handleClose()
}
return (
<>
<IconButton size="small" sx={{ color: '#64748b' }} onClick={handleOpen}>
<Badge badgeContent={0} color="error">
<Badge badgeContent={badgeCount || null} color="error">
<Bell size={20} />
</Badge>
</IconButton>
@@ -27,12 +79,50 @@ export function NotificationButton() {
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{ paper: { sx: { width: 280, p: 2 } } }}
slotProps={{ paper: { sx: { width: 300, p: 2 } } }}
>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Benachrichtigungen</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Keine neuen Benachrichtigungen
<Typography variant="subtitle2" sx={{ mb: 1.25, fontWeight: 700, color: '#0f172a' }}>
Neue Treffer
</Typography>
{profilesWithMatches.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Keine aktiven Benachrichtigungen
</Typography>
) : (
<>
{profilesWithMatches.map(({ need, count, minScore }) => (
<Box
key={need.id}
onClick={() => goToResults(need.id)}
sx={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
px: 1, py: 0.875, borderRadius: 1, cursor: 'pointer',
'&:hover': { bgcolor: '#f8fafc' }, transition: 'background 0.1s',
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ fontSize: '0.825rem', color: '#1e293b' }} noWrap>
{need.companyName}
</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>
ab {minScore}%
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#15803d', fontWeight: 700, flexShrink: 0, ml: 1.5, fontSize: '0.78rem' }}>
{count} Treffer
</Typography>
</Box>
))}
<Divider sx={{ my: 1.25 }} />
<Box
onClick={() => { navigate(ROUTES.DEMAND.AI_SEARCH); handleClose() }}
sx={{ textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600, py: 0.25, '&:hover': { color: '#16304d' } }}
>
Alle Suchprofile anzeigen
</Box>
</>
)}
</Popover>
</>
)
+2 -2
View File
@@ -32,8 +32,8 @@ export function RightContextPanel() {
right: 0,
top: 56,
height: 'calc(100vh - 56px)',
width: 320,
transform: isRightPanelOpen ? 'translateX(0)' : 'translateX(320px)',
width: { xs: '85vw', sm: 300, lg: 320 },
transform: isRightPanelOpen ? 'translateX(0)' : 'translateX(110%)',
transition: 'transform 0.25s ease',
bgcolor: '#fff',
borderLeft: '1px solid #e2e8f0',
@@ -0,0 +1,82 @@
import { Box, Chip, CircularProgress, Paper, Typography } from '@mui/material'
import { Lightbulb, MessageSquareQuote } from 'lucide-react'
import { useFitOutAdvice } from '../../hooks/useAI'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
const RECOMMENDATION_META: Record<string, { label: string; bg: string; border: string; fg: string }> = {
MIETERAUSBAU: { label: 'Mieterausbau', bg: DS_SURFACE.blue.bg, border: DS_SURFACE.blue.border, fg: DS_TEXT.signalDark },
BKZ: { label: 'Baukostenzuschuss (BKZ)', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
MAB_AMORTISATION: { label: 'MAB-Amortisation', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
}
interface Props {
fitOut: string
areaSqm: number
mabPerSqm: number
rentPricePerSqm: number
requiredFitOut?: string
tenantBudgetPerSqm?: number
}
export function FitOutAdvicePanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, requiredFitOut, tenantBudgetPerSqm }: Props) {
const { data, isLoading, isError } = useFitOutAdvice({
fitOut,
areaSqm,
mabPerSqm,
requiredFitOut,
tenantBudgetPerSqm,
monthlyRentPerSqm: Math.round(rentPricePerSqm / 12),
})
// Fallback: KI-Ausfall blockiert nie — Panel wird einfach nicht gezeigt
if (isError) return null
if (isLoading || !data) {
return (
<Paper sx={{ p: 2.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<CircularProgress size={16} />
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}>KI-Ausbauempfehlung wird erstellt</Typography>
</Paper>
)
}
const advice = data.data
const meta = RECOMMENDATION_META[advice.recommendation] ?? RECOMMENDATION_META.MIETERAUSBAU
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Lightbulb size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>KI-Ausbauempfehlung</Typography>
<Chip
label={meta.label}
size="small"
sx={{ ml: 'auto', bgcolor: meta.bg, color: meta.fg, fontWeight: 700, fontSize: 11, height: 22, border: `1px solid ${meta.border}` }}
/>
</Box>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.75 }}>{advice.headline}</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 1.5, lineHeight: 1.55 }}>
{advice.explanation}
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 1, px: 1.5, mb: 1.5, bgcolor: DS_SURFACE.neutral.bg, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 600 }}>Geschätzte Nettoinvestition</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{advice.estimatedNetInvestment}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<MessageSquareQuote size={14} color={DS_TEXT.muted} style={{ marginTop: 3, flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, lineHeight: 1.5 }}>
<strong>Verhandlungstipp:</strong> {advice.negotiationTip}
</Typography>
</Box>
{data.provenance?.fallbackUsed && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1.25 }}>
Hinweis: KI nicht verfügbar Richtwert-basierte Empfehlung.
</Typography>
)}
</Paper>
)
}
+27 -46
View File
@@ -1,33 +1,23 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { HardHat } from 'lucide-react'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
}
const AMORTIZATION_YEARS = FITOUT_AMORTIZATION_YEARS
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
const RATE_PCT = Math.round(FITOUT_ANNUITY_RATE * 100)
interface Props {
fitOut: string
areaSqm: number
mabPerSqm: number
rentPricePerSqm: number
tenantBudgetPerSqm?: number
fitOutByLandlord?: boolean
}
function chf(value: number): string {
return `CHF ${Math.round(value).toLocaleString('de-CH')}.`
}
function chfRange(min: number, max: number): string {
if (Math.round(min) === Math.round(max)) return chf(min)
return `${chf(min)} ${chf(max)}`
}
interface RowProps { label: string; value: string; sub?: string; isTotal?: boolean; isWarning?: boolean }
function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
@@ -49,35 +39,26 @@ function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
)
}
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, tenantBudgetPerSqm = 0 }: Props) {
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, fitOutByLandlord }: Props) {
const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({ fitOut, rentPricePerSqm, mabPerSqm, fitOutByLandlord })
const rentPerYear = rentPricePerSqm * areaSqm
const isReadyToMoveIn = READY_TO_MOVE_IN.has(fitOut)
const fitOutPerYear = fitOutPerSqm * areaSqm
const totalPerYear = rentPerYear + fitOutPerYear
const fitOutLabel = FIT_OUT_LABELS[fitOut] ?? fitOut
const investment = isReadyToMoveIn
? null
: calcFitOutInvestment(fitOut, areaSqm, mabPerSqm, tenantBudgetPerSqm)
const fitOutPerYear = investment && !investment.isFullyCovered ? {
min: Math.round(investment.netTotal.min / AMORTIZATION_YEARS),
max: Math.round(investment.netTotal.max / AMORTIZATION_YEARS),
} : { min: 0, max: 0 }
const totalPerYear = {
min: rentPerYear + fitOutPerYear.min,
max: rentPerYear + fitOutPerYear.max,
}
const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3
const disclaimer = isReadyToMoveIn
? null
: `Ausbaukosten nach CRB/BKP-Normen, amortisiert über ${AMORTIZATION_YEARS} Jahre. Tatsächliche Kosten je nach Ausbauumfang.`
// Kein Aufschlag → entweder Vermieter übernimmt oder Fläche bereits bezugsfertig
const hasSurcharge = fitOutPerYear > 0
const isWarning = totalPerYear > rentPerYear * 1.3
const disclaimer = hasSurcharge
? `Ausbaukosten nach CRB/BKP-Normen, annuitätisch über ${FITOUT_AMORTIZATION_YEARS} Jahre zu ${RATE_PCT}% p.a. Tatsächliche Kosten je nach Ausbauumfang.`
: null
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<HardHat size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Reale Jahresbelastung</Typography>
{!isReadyToMoveIn && (
{hasSurcharge && (
<Chip
label="CRB/BKP Richtwerte"
size="small"
@@ -92,32 +73,32 @@ export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, t
value={chf(rentPerYear)}
sub={`CHF ${rentPricePerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`}
/>
{isReadyToMoveIn ? (
{fitOutByLandlord ? (
<Row
label="Ausbau"
value="CHF 0."
sub={`${fitOutLabel} — Ausbau im Mietzins enthalten`}
/>
) : !hasSurcharge ? (
<Row
label="Ausbau"
value="CHF 0."
sub={`${fitOutLabel} — bezugsfertig`}
/>
) : investment?.isFullyCovered ? (
<Row
label="Ausbau (amort.)"
value="CHF 0."
sub={`CHF ${mabPerSqm}/m² MAB deckt Ausbaukosten vollständig`}
/>
) : (
<Row
label={`Ausbau (amort. ${AMORTIZATION_YEARS} J.)`}
value={chfRange(fitOutPerYear.min, fitOutPerYear.max)}
label={`Ausbau (annuit. ${FITOUT_AMORTIZATION_YEARS} J. / ${RATE_PCT}%)`}
value={chf(fitOutPerYear)}
sub={
`CHF ${investment?.grossPerSqm.min}${investment?.grossPerSqm.max}/m² (${fitOutLabel})` +
`${fitOutLabel}` +
(mabPerSqm > 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
` × ${areaSqm.toLocaleString('de-CH')} ÷ ${AMORTIZATION_YEARS} J.`
` — CHF ${fitOutPerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`
}
/>
)}
<Row
label="Total/Jahr"
value={chfRange(totalPerYear.min, totalPerYear.max)}
value={chf(totalPerYear)}
isTotal
isWarning={isWarning}
/>
@@ -0,0 +1,70 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { TrendingUp } from 'lucide-react'
import { estimateMarketRent, type RentVerdict } from '../../lib/rentEstimate'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
const VERDICT_META: Record<RentVerdict, { label: string; bg: string; border: string; fg: string }> = {
BELOW: { label: 'Unter Markt', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
AT: { label: 'Marktkonform', bg: DS_SURFACE.blue.bg, border: DS_SURFACE.blue.border, fg: DS_TEXT.signalDark },
ABOVE: { label: 'Über Markt', bg: DS_SURFACE.warning.bg, border: DS_SURFACE.warning.border, fg: DS_TEXT.warning },
}
interface Props {
city: string
assetType: string
askingRentPerSqm: number
futureRentPerSqm?: number // Pre-Market: erwarteter künftiger Preis
}
function chf(v: number): string {
return `CHF ${Math.round(v).toLocaleString('de-CH')}/m²`
}
export function MarketPricePanel({ city, assetType, askingRentPerSqm, futureRentPerSqm }: Props) {
const est = estimateMarketRent(city, assetType, askingRentPerSqm)
if (!est) return null
const meta = VERDICT_META[est.verdict]
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TrendingUp size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Marktpreis-Einschätzung</Typography>
<Chip
label={meta.label}
size="small"
sx={{ ml: 'auto', bgcolor: meta.bg, color: meta.fg, fontWeight: 700, fontSize: 11, height: 22, border: `1px solid ${meta.border}` }}
/>
</Box>
<Box sx={{ display: 'flex', gap: 2, mb: 1.25 }}>
<Box sx={{ flex: 1, p: 1.25, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Angebotsmiete</Typography>
<Typography variant="body1" sx={{ fontWeight: 700 }}>{chf(est.askingRentPerSqm)}</Typography>
</Box>
<Box sx={{ flex: 1, p: 1.25, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Faire Marktmiete</Typography>
<Typography variant="body1" sx={{ fontWeight: 700 }}>
{chf(est.fairRentPerSqm)}
<Box component="span" sx={{ ml: 0.75, fontSize: 12, fontWeight: 600, color: meta.fg }}>
{est.deltaPct > 0 ? `+${est.deltaPct}%` : `${est.deltaPct}%`}
</Box>
</Typography>
</Box>
</Box>
{futureRentPerSqm != null && futureRentPerSqm > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 1, px: 1.5, mb: 1.25, bgcolor: DS_SURFACE.purple.bg, border: `1px solid ${DS_SURFACE.purple.border}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 600 }}>Erwartet (Pre-Market)</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{chf(est.askingRentPerSqm)} <Box component="span" sx={{ color: DS_TEXT.muted }}></Box> {chf(futureRentPerSqm)}
</Typography>
</Box>
)}
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block' }}>
{est.rationale}
</Typography>
</Paper>
)
}
+2
View File
@@ -13,4 +13,6 @@ export { SourceProvenancePanel } from './SourceProvenancePanel'
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
export { NextActionsPanel } from './NextActionsPanel'
export { FitOutCostPanel } from './FitOutCostPanel'
export { FitOutAdvicePanel } from './FitOutAdvicePanel'
export { MarketPricePanel } from './MarketPricePanel'
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
@@ -1,11 +1,16 @@
import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
// Stufen, die noch Mieterausbau benötigen — nur dann ist die Träger-Frage relevant
const NEEDS_FIT_OUT = new Set(['SHELL', 'BASIC'])
interface Props {
floor: string
onFloorChange: (v: string) => void
fitOut: string
onFitOutChange: (v: string) => void
fitOutByLandlord: boolean | undefined
onFitOutByLandlordChange: (v: boolean) => void
parking: string
onParkingChange: (v: string) => void
ceilingHeight: string
@@ -21,12 +26,14 @@ interface Props {
export function TechnicalDetailsSection({
floor, onFloorChange,
fitOut, onFitOutChange,
fitOutByLandlord, onFitOutByLandlordChange,
parking, onParkingChange,
ceilingHeight, onCeilingHeightChange,
mieterausbaubeitrag, onMieterausbaubeitragChange,
isFlexible, onIsFlexibleChange,
minLettableSqm, onMinLettableSqmChange,
}: Props) {
const showFitOutResponsibility = NEEDS_FIT_OUT.has(fitOut)
return (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Technische Details (optional)</Typography>
@@ -44,14 +51,6 @@ export function TechnicalDetailsSection({
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))}
</TextField>
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
value={mieterausbaubeitrag}
onChange={e => onMieterausbaubeitragChange(e.target.value)}
size="small" type="number" fullWidth
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters"
/>
<TextField
label="Parkplätze" value={parking}
onChange={e => onParkingChange(e.target.value)}
@@ -64,6 +63,46 @@ export function TechnicalDetailsSection({
/>
</Box>
{/* Ausbau-Träger — nur relevant, wenn die Fläche noch Ausbau benötigt (Rohbau/Edelrohbau) */}
{showFitOutResponsibility && (
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9' }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
Wer baut aus? <Box component="span" sx={{ color: '#dc2626' }}>*</Box>
</Typography>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, flexWrap: 'wrap' }}>
<ToggleButtonGroup
exclusive
size="small"
value={fitOutByLandlord === undefined ? null : fitOutByLandlord ? 'landlord' : 'tenant'}
onChange={(_, v) => { if (v) onFitOutByLandlordChange(v === 'landlord') }}
>
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter übernimmt</ToggleButton>
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter baut aus</ToggleButton>
</ToggleButtonGroup>
{fitOutByLandlord === undefined ? (
<Typography variant="caption" sx={{ flex: 1, minWidth: 220, mt: 0.5, color: '#b45309' }}>
Pflichtangabe bei Rohbau/Edelrohbau bitte wählen, wer den Ausbau trägt.
</Typography>
) : fitOutByLandlord ? (
<Typography variant="caption" color="text.secondary" sx={{ flex: 1, minWidth: 220, mt: 0.5 }}>
Ausbau im Mietzins enthalten bitte die <strong>bezugsfertige</strong> Miete eintragen.
Es wird kein Aufschlag berechnet.
</Typography>
) : (
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
value={mieterausbaubeitrag}
onChange={e => onMieterausbaubeitragChange(e.target.value)}
size="small" type="number" sx={{ width: 240 }}
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters — optional, leer = kein Beitrag"
/>
)}
</Box>
</Box>
)}
{/* Divisibility */}
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', gap: 2 }}>
<FormControlLabel
@@ -32,7 +32,7 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: ()
return (
<Box sx={{
width: { xs: '100%', md: 340 }, flexShrink: 0,
width: { xs: '100%', md: 300, lg: 320, xl: 340 }, flexShrink: 0,
display: 'flex', flexDirection: 'column',
bgcolor: 'white', borderLeft: `1px solid ${DS_BORDER.default}`, overflow: 'hidden',
}}>
@@ -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 },
{
+285 -157
View File
@@ -1,26 +1,185 @@
import { useState } from 'react'
import {
Drawer,
Box,
Typography,
IconButton,
Chip,
Divider,
TextField,
Button,
Switch,
FormControlLabel,
Tooltip,
Drawer, Box, Typography, IconButton, Chip, Divider,
TextField, Button, Select, MenuItem, FormControl, InputLabel, Autocomplete,
} from '@mui/material'
import { X, MapPin, Calendar, DollarSign, Eye, Activity, FileText, ExternalLink } from 'lucide-react'
import { X, Calendar, Activity, FileText, ExternalLink, Eye } from 'lucide-react'
import { useReminderStore } from '../../stores/reminderStore'
import { useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
import { usePropertyById } from '../../hooks/useProperties'
import {
useReminder, useCompleteReminder, useDismissReminder,
useSnoozeReminder, useCreateReminder,
} from '../../hooks/useReminders'
import { usePropertyById, useProperties } from '../../hooks/useProperties'
import { ReminderPriorityBadge } from './ReminderPriorityBadge'
import { ReminderTypeBadge } from './ReminderTypeBadge'
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
import { ReminderStatus } from '../../domain/reminder'
import { SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, STATUS_CHIP_COLOR, STATUS_LABEL, SectionTitle, DateRow, ActivityEntry } from './reminderDetailHelpers'
import { ReminderStatus, ReminderType, ReminderPriority } from '../../domain/reminder'
import type { Property } from '../../domain/property'
import {
SHADOW_RISK_COLOR, SHADOW_RISK_LABEL,
STATUS_CHIP_COLOR, STATUS_LABEL,
SectionTitle, DateRow, ActivityEntry,
} from './reminderDetailHelpers'
// ── Constants ────────────────────────────────────────────────────────────────
const TYPE_LABELS: Record<ReminderType, string> = {
LEASE_EXPIRY: 'Mietablauf',
BREAK_OPTION: 'Break-Option',
RENT_REVIEW: 'Mietanpassung',
INSPECTION: 'Inspektion',
INSURANCE_RENEWAL: 'Versicherung',
MAINTENANCE: 'Unterhalt',
SCHATTENMARKT_RELEASE: 'Pre-Market',
CUSTOM: 'Individuell',
}
const TYPE_SECOND_DATE: Partial<Record<ReminderType, 'contractEndDate' | 'breakOptionDate' | 'eventDate'>> = {
[ReminderType.LEASE_EXPIRY]: 'contractEndDate',
[ReminderType.BREAK_OPTION]: 'breakOptionDate',
[ReminderType.RENT_REVIEW]: 'eventDate',
[ReminderType.SCHATTENMARKT_RELEASE]: 'eventDate',
}
const TYPE_SECOND_LABEL: Partial<Record<ReminderType, string>> = {
[ReminderType.LEASE_EXPIRY]: 'Vertragsende',
[ReminderType.BREAK_OPTION]: 'Break-Option',
[ReminderType.RENT_REVIEW]: 'Ereignisdatum',
[ReminderType.SCHATTENMARKT_RELEASE]: 'Ereignisdatum',
}
const MOCK_TODAY = new Date('2026-05-20')
function calcPriority(dueDateStr: string): ReminderPriority {
const days = Math.ceil((new Date(dueDateStr).getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24))
if (days <= 14) return ReminderPriority.URGENT
if (days <= 30) return ReminderPriority.HIGH
if (days <= 60) return ReminderPriority.MEDIUM
return ReminderPriority.LOW
}
// ── Create Form ───────────────────────────────────────────────────────────────
function CreateForm({ onClose }: { onClose: () => void }) {
const { data: properties = [] } = useProperties()
const create = useCreateReminder()
const [type, setType] = useState<ReminderType>(ReminderType.LEASE_EXPIRY)
const [selectedProperty, setSelectedProperty] = useState<Property | null>(null)
const [tenantName, setTenantName] = useState('')
const [dueDate, setDueDate] = useState('')
const [note, setNote] = useState('')
function handlePropertyChange(_: unknown, prop: Property | null) {
setSelectedProperty(prop)
setTenantName(prop?.currentTenant ?? '')
}
function handleSubmit() {
if (!selectedProperty || !dueDate) return
create.mutate(
{
type,
priority: calcPriority(dueDate),
status: ReminderStatus.ACTIVE,
propertyId: selectedProperty.id,
propertyTitle: selectedProperty.title,
propertyCity: selectedProperty.location.city,
propertyDistrict: selectedProperty.location.district,
tenantName: tenantName || '—',
dueDate,
eventDate: dueDate,
areaSqm: selectedProperty.areaSqm,
currentRentPerSqm: selectedProperty.rentPricePerSqm,
currency: 'CHF',
shadowMarketRisk: 'NONE',
schattenmarktEnabled: false,
note: note || undefined,
organizationId: selectedProperty.organizationId ?? 'org-1',
},
{ onSuccess: onClose },
)
}
const canSubmit = !!selectedProperty && !!dueDate && !create.isPending
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* Typ */}
<FormControl size="small" fullWidth>
<InputLabel>Typ</InputLabel>
<Select
value={type}
label="Typ"
onChange={e => setType(e.target.value as ReminderType)}
>
{Object.values(ReminderType).map(t => (
<MenuItem key={t} value={t}>{TYPE_LABELS[t]}</MenuItem>
))}
</Select>
</FormControl>
{/* Objekt */}
<Autocomplete
options={properties}
getOptionLabel={p => p.title}
value={selectedProperty}
onChange={handlePropertyChange}
size="small"
renderInput={params => (
<TextField {...params} label="Objekt" placeholder="Objekt auswählen…" />
)}
/>
{/* Mieter — nur wenn Objekt ausgewählt */}
{selectedProperty && (
<TextField
label="Mieter"
size="small"
fullWidth
value={tenantName}
onChange={e => setTenantName(e.target.value)}
placeholder="Mietername eingeben…"
/>
)}
{/* Fälligkeitsdatum */}
<TextField
label="Fälligkeitsdatum"
type="date"
size="small"
fullWidth
value={dueDate}
onChange={e => setDueDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
{/* Notiz */}
<TextField
label="Notiz (optional)"
multiline
rows={3}
size="small"
fullWidth
value={note}
onChange={e => setNote(e.target.value)}
placeholder="Kontext oder Hinweise…"
/>
<Button
variant="contained"
fullWidth
disabled={!canSubmit}
onClick={handleSubmit}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
{create.isPending ? 'Wird erstellt…' : 'Reminder erstellen'}
</Button>
</Box>
)
}
// ── Main Drawer ───────────────────────────────────────────────────────────────
export function ReminderDetailDrawer() {
const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore()
@@ -38,74 +197,79 @@ export function ReminderDetailDrawer() {
setSelectedId(null)
}
const isNew = !selectedId
const isCreateMode = !selectedId
const secondDateKey = reminder ? TYPE_SECOND_DATE[reminder.type] : undefined
const secondDateLabel = reminder ? TYPE_SECOND_LABEL[reminder.type] : undefined
const secondDateValue = secondDateKey && reminder ? reminder[secondDateKey] : undefined
const showSecondDate = secondDateValue && reminder && secondDateValue !== reminder.dueDate
const showPreMarket = reminder &&
reminder.shadowMarketRisk !== 'NONE' &&
reminder.shadowMarketRisk !== 'LOW'
const isActionable = reminder &&
(reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED)
return (
<Drawer
anchor="right"
open={drawerOpen}
onClose={handleClose}
slotProps={{ paper: { sx: { width: 480, display: 'flex', flexDirection: 'column' } } }}
slotProps={{ paper: { sx: { width: 460, display: 'flex', flexDirection: 'column' } } }}
>
{/* Header */}
<Box
sx={{
px: 2.5,
py: 2,
borderBottom: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'flex-start',
gap: 1,
flexShrink: 0,
}}
>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0 }}>
{isNew ? (
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Neuer Reminder</Typography>
) : reminder ? (
<>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<ReminderTypeBadge type={reminder.type} />
<ReminderPriorityBadge priority={reminder.priority} />
<Chip
label={STATUS_LABEL[reminder.status]}
color={STATUS_CHIP_COLOR[reminder.status]}
size="small"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
</Box>
<ReminderDaysIndicator dueDate={reminder.dueDate} />
</>
) : null}
{/* ── Header ── */}
<Box sx={{ px: 2.5, py: 2, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0 }}>
{isCreateMode ? (
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a' }}>
Neuer Reminder
</Typography>
) : reminder ? (
<>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<ReminderTypeBadge type={reminder.type} />
<ReminderPriorityBadge priority={reminder.priority} />
<Chip
label={STATUS_LABEL[reminder.status]}
color={STATUS_CHIP_COLOR[reminder.status]}
size="small"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
</Box>
<ReminderDaysIndicator dueDate={reminder.dueDate} />
</>
) : null}
</Box>
<IconButton size="small" onClick={handleClose} sx={{ flexShrink: 0, color: '#94a3b8' }}>
<X size={18} />
</IconButton>
</Box>
<IconButton size="small" onClick={handleClose} sx={{ flexShrink: 0 }}>
<X size={18} />
</IconButton>
</Box>
{/* Scrollable body */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }} className="flex flex-col gap-5">
{isNew && (
<Typography variant="body2" color="text.secondary">
Neue Reminder-Erstellung noch nicht implementiert.
</Typography>
)}
{/* ── Body ── */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5, display: 'flex', flexDirection: 'column' }}>
{/* Create mode */}
{isCreateMode && <CreateForm onClose={handleClose} />}
{/* Detail mode */}
{reminder && (
<>
{/* 2. Property */}
<Box>
<SectionTitle icon={<MapPin size={15} color="#64748b" />}>Objekt</SectionTitle>
<Box className="flex flex-col gap-1">
<Typography variant="body2" sx={{ fontWeight: 600 }}>{reminder.propertyTitle}</Typography>
<Typography variant="caption" color="text.secondary">
{reminder.propertyCity}{reminder.propertyDistrict ? ` · ${reminder.propertyDistrict}` : ''}
</Typography>
<Typography variant="caption" color="text.secondary">Mieter: {reminder.tenantName}</Typography>
<Typography variant="caption" color="text.secondary">Fläche: {reminder.areaSqm.toLocaleString('de-CH')} m²</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* 1. Objekt */}
<Box sx={{ mb: 2.5 }}>
<Typography variant="body1" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.25 }}>
{reminder.propertyTitle}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
{reminder.propertyCity}{reminder.propertyDistrict ? ` · ${reminder.propertyDistrict}` : ''}&nbsp;·&nbsp;{reminder.tenantName}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
{reminderProperty?.leaseContractUrl ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
<FileText size={12} color="#152642" />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<FileText size={12} color="#64748b" />
<Button
size="small"
variant="outlined"
@@ -113,94 +277,58 @@ export function ReminderDetailDrawer() {
href={reminderProperty.leaseContractUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', fontSize: '0.68rem', py: 0.125, px: 0.75, borderColor: '#cbd5e1', color: '#152642' }}
sx={{
textTransform: 'none', fontSize: '0.68rem',
py: 0.125, px: 0.75, borderColor: '#cbd5e1', color: '#334155',
}}
>
{reminderProperty.leaseContractName ?? 'Mietvertrag'}
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<FileText size={12} color="#cbd5e1" />
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>Kein Mietvertrag hinterlegt</Typography>
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>Kein Vertrag</Typography>
</Box>
)}
{showPreMarket && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4 }}>
<Eye size={11} color={SHADOW_RISK_COLOR[reminder.shadowMarketRisk]} />
<Typography variant="caption" sx={{
color: SHADOW_RISK_COLOR[reminder.shadowMarketRisk],
fontWeight: 600, fontSize: '0.68rem',
}}>
Pre-Mkt: {SHADOW_RISK_LABEL[reminder.shadowMarketRisk]}
</Typography>
</Box>
)}
</Box>
</Box>
<Divider />
<Divider sx={{ mb: 2.5 }} />
{/* 3. Dates */}
<Box>
<SectionTitle icon={<Calendar size={15} color="#64748b" />}>Daten &amp; Fristen</SectionTitle>
<Box className="flex flex-col gap-1.5">
<DateRow label="Fälligkeitsdatum" value={reminder.dueDate} />
<DateRow label="Ereignisdatum" value={reminder.eventDate} />
<DateRow label="Vertragsende" value={reminder.contractEndDate} />
<DateRow label="Break-Option" value={reminder.breakOptionDate} />
{/* 2. Fristen */}
<Box sx={{ mb: 2.5 }}>
<SectionTitle icon={<Calendar size={14} color="#64748b" />}>Fristen</SectionTitle>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<DateRow label="Fälligkeit" value={reminder.dueDate} />
{showSecondDate && (
<DateRow label={secondDateLabel!} value={secondDateValue} />
)}
{reminder.snoozedUntil && (
<DateRow label="Schlummern bis" value={reminder.snoozedUntil} />
<DateRow label="Schlummert bis" value={reminder.snoozedUntil} />
)}
</Box>
</Box>
<Divider />
<Divider sx={{ mb: 2.5 }} />
{/* 4. Financials */}
<Box>
<SectionTitle icon={<DollarSign size={15} color="#64748b" />}>Finanzen</SectionTitle>
<Box className="flex flex-col gap-1.5">
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1 }}>
<Typography variant="caption" color="text.secondary">Miete/m²</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{reminder.currency} {reminder.currentRentPerSqm.toLocaleString('de-CH')} / Monat
</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1 }}>
<Typography variant="caption" color="text.secondary">Total / Monat</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{reminder.currency} {(reminder.currentRentPerSqm * reminder.areaSqm).toLocaleString('de-CH')}
</Typography>
</Box>
</Box>
</Box>
<Divider />
{/* 5. Pre-Market */}
<Box>
<SectionTitle icon={<Eye size={15} color="#64748b" />}>Pre-Market Risiko</SectionTitle>
<Box className="flex flex-col gap-2">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: SHADOW_RISK_COLOR[reminder.shadowMarketRisk],
}}
/>
<Typography variant="body2" sx={{ fontWeight: 600, color: SHADOW_RISK_COLOR[reminder.shadowMarketRisk] }}>
{SHADOW_RISK_LABEL[reminder.shadowMarketRisk]}
</Typography>
</Box>
<Tooltip title="Nur Anzeige — Änderung über Objektverwaltung">
<FormControlLabel
control={<Switch checked={reminder.schattenmarktEnabled} size="small" readOnly />}
label={
<Typography variant="caption">
Pre-Market aktiviert
</Typography>
}
/>
</Tooltip>
</Box>
</Box>
<Divider />
{/* 6. Note */}
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Notiz</Typography>
{/* 3. Notiz */}
<Box sx={{ mb: isActionable ? 0 : 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>
Notiz
</Typography>
<TextField
multiline
rows={3}
@@ -213,24 +341,24 @@ export function ReminderDetailDrawer() {
/>
</Box>
{/* Actions */}
{(reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED) && (
{/* 4. Aktionen */}
{isActionable && (
<>
<Divider />
<Box className="flex flex-col gap-2">
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Aktionen</Typography>
<Box className="flex gap-2">
<Divider sx={{ my: 2.5 }} />
<Box sx={{ mb: 2.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>
Aktionen
</Typography>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
size="small"
variant="contained"
color="success"
sx={{ textTransform: 'none', flex: 1 }}
sx={{ textTransform: 'none', flex: 1, fontWeight: 600 }}
onClick={() => complete.mutate({ id: reminder.id, note: noteValue || undefined })}
>
Erledigt
</Button>
<Button
size="small"
variant="outlined"
color="error"
sx={{ textTransform: 'none', flex: 1 }}
@@ -239,7 +367,7 @@ export function ReminderDetailDrawer() {
Verwerfen
</Button>
</Box>
<Box className="flex gap-2 items-center">
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type="date"
size="small"
@@ -248,10 +376,10 @@ export function ReminderDetailDrawer() {
sx={{ flex: 1, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
/>
<Button
size="small"
variant="outlined"
size="small"
disabled={!snoozeDate}
sx={{ textTransform: 'none', whiteSpace: 'nowrap' }}
sx={{ textTransform: 'none', whiteSpace: 'nowrap', color: '#64748b', borderColor: '#e2e8f0' }}
onClick={() => snoozeDate && snooze.mutate({ id: reminder.id, until: snoozeDate })}
>
Schlummern bis
@@ -261,18 +389,18 @@ export function ReminderDetailDrawer() {
</>
)}
<Divider />
<Divider sx={{ mb: 2.5 }} />
{/* 7. Activity */}
{/* 5. Verlauf */}
<Box>
<SectionTitle icon={<Activity size={15} color="#64748b" />}>Aktivitätslog</SectionTitle>
<SectionTitle icon={<Activity size={14} color="#64748b" />}>Verlauf</SectionTitle>
<Box>
{[...reminder.activity].reverse().map((entry, i) => (
<ActivityEntry key={i} entry={entry} />
))}
</Box>
</Box>
</>
</Box>
)}
</Box>
</Drawer>
+2 -2
View File
@@ -28,7 +28,7 @@ const HORIZON_CONFIG: { key: FilterHorizon; label: string; color: string; bg: st
{ key: 'LATER', label: 'Später', color: '#64748b', bg: '#f8fafc', border: '#e8e7e4' },
]
const LIST_COLS = '100px 130px 1fr 140px 90px 100px 90px'
const LIST_COLS = '90px 130px 1fr 140px 80px 90px'
function applyFilters(
reminders: Reminder[],
@@ -141,7 +141,7 @@ export function ReminderFeed() {
borderBottom: '1px solid #e2e8f0',
}}
>
{['Priorität', 'Typ', 'Objekt / Mieter', 'Fälligkeit', 'Fläche', 'Status', 'Aktionen'].map(h => (
{['Priorität', 'Typ', 'Objekt / Mieter', 'Fälligkeit', 'Status', 'Aktionen'].map(h => (
<Typography key={h} variant="caption" sx={{ fontWeight: 600, color: '#64748b', fontSize: '0.7rem' }}>
{h}
</Typography>
+106 -64
View File
@@ -6,7 +6,7 @@ import { useReminderStore } from '../../stores/reminderStore'
import type { FilterHorizon } from '../../stores/reminderStore'
import { ReminderType } from '../../domain/reminder'
interface KpiCardProps {
interface SecondaryKpiProps {
icon: React.ReactNode
label: string
value: number | string
@@ -15,35 +15,34 @@ interface KpiCardProps {
onClick: () => void
}
function KpiCard({ icon, label, value, color, active, onClick }: KpiCardProps) {
function SecondaryKpi({ icon, label, value, color, active, onClick }: SecondaryKpiProps) {
return (
<Box
onClick={onClick}
sx={{
flex: 1,
px: 1.5,
py: 1.125,
px: 2,
py: 1.5,
display: 'flex',
alignItems: 'center',
gap: 1,
border: active ? `1.5px solid ${color}` : '1px solid #e8e7e4',
borderRadius: 1.5,
bgcolor: active ? `${color}08` : 'white',
flexDirection: 'column',
gap: 0.25,
cursor: 'pointer',
borderRadius: 1.5,
border: active ? `1.5px solid ${color}` : '1px solid #e8e7e4',
bgcolor: active ? `${color}08` : 'white',
transition: 'border-color 0.15s, background 0.15s',
minWidth: 0,
'&:hover': { bgcolor: `${color}06`, borderColor: color },
}}
>
<Box sx={{ opacity: active ? 1 : 0.55, flexShrink: 0 }}>{icon}</Box>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 700, color, lineHeight: 1.1, fontSize: '1.1rem' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ opacity: active ? 1 : 0.5, flexShrink: 0 }}>{icon}</Box>
<Typography sx={{ fontWeight: 700, color, fontSize: '1.25rem', lineHeight: 1 }}>
{value}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', whiteSpace: 'nowrap', fontSize: '0.68rem' }}>
{label}
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
{label}
</Typography>
</Box>
)
}
@@ -60,69 +59,112 @@ export function ReminderKpiBar() {
)
function toggleHorizon(h: FilterHorizon) {
if (filterHorizon === h) {
setFilterHorizon('ALL')
} else {
setFilterHorizon(h)
setFilterType('ALL') // clear type filter so count matches
}
if (filterHorizon === h) setFilterHorizon('ALL')
else { setFilterHorizon(h); setFilterType('ALL') }
}
function togglePreMarket() {
if (filterType === ReminderType.SCHATTENMARKT_RELEASE) {
setFilterType('ALL')
} else {
setFilterType(ReminderType.SCHATTENMARKT_RELEASE)
setFilterHorizon('ALL') // clear horizon filter so count matches
}
if (filterType === ReminderType.SCHATTENMARKT_RELEASE) setFilterType('ALL')
else { setFilterType(ReminderType.SCHATTENMARKT_RELEASE); setFilterHorizon('ALL') }
}
if (isLoading) {
return (
<Box className="flex gap-3">
{[1, 2, 3, 4].map(i => (
<Skeleton key={i} variant="rounded" height={68} sx={{ flex: 1 }} />
))}
<Box sx={{ display: 'flex', gap: 2 }}>
<Skeleton variant="rounded" height={76} sx={{ flex: '0 0 220px' }} />
<Box sx={{ flex: 1, display: 'flex', gap: 2 }}>
{[1, 2, 3].map(i => <Skeleton key={i} variant="rounded" height={76} sx={{ flex: 1 }} />)}
</Box>
</Box>
)
}
const ins = data ?? { overdueCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 }
const isOverdue = ins.overdueCount > 0
const overdueActive = filterHorizon === 'OVERDUE'
return (
<Box className="flex gap-3">
<KpiCard
icon={<AlertOctagon size={18} color="#dc2626" />}
label="Überfällig"
value={ins.overdueCount}
color="#dc2626"
active={filterHorizon === 'OVERDUE'}
<Box sx={{ display: 'flex', gap: 2, alignItems: 'stretch' }}>
{/* Focal card — Überfällig */}
<Box
onClick={() => toggleHorizon('OVERDUE')}
/>
<KpiCard
icon={<Calendar size={18} color="#ea580c" />}
label="Diese Woche"
value={ins.dueThisWeek}
color="#ea580c"
active={filterHorizon === 'THIS_WEEK'}
onClick={() => toggleHorizon('THIS_WEEK')}
/>
<KpiCard
icon={<CalendarDays size={18} color="#0369a1" />}
label="Dieser Monat"
value={ins.dueThisMonth}
color="#0369a1"
active={filterHorizon === 'THIS_MONTH'}
onClick={() => toggleHorizon('THIS_MONTH')}
/>
<KpiCard
icon={<Eye size={18} color="#be185d" />}
label="Pre-Market Risiko"
value={ins.schattenmarktReadyCount}
color="#be185d"
active={filterType === ReminderType.SCHATTENMARKT_RELEASE}
onClick={togglePreMarket}
/>
sx={{
flex: '0 0 200px',
px: 2.5,
py: 2,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 0.5,
borderRadius: 1.5,
cursor: 'pointer',
border: overdueActive
? '1.5px solid #dc2626'
: isOverdue
? '1.5px solid #fca5a5'
: '1px solid #e8e7e4',
bgcolor: overdueActive
? '#fef2f2'
: isOverdue
? '#fff5f5'
: 'white',
transition: 'border-color 0.15s, background 0.15s',
'&:hover': { borderColor: '#dc2626', bgcolor: '#fef2f2' },
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AlertOctagon size={16} color={isOverdue ? '#dc2626' : '#cbd5e1'} />
<Typography
sx={{
fontWeight: 800,
fontSize: '2rem',
lineHeight: 1,
color: isOverdue ? '#dc2626' : '#94a3b8',
letterSpacing: '-0.02em',
}}
>
{ins.overdueCount}
</Typography>
</Box>
<Typography
variant="caption"
sx={{
color: isOverdue ? '#dc2626' : '#94a3b8',
fontWeight: isOverdue ? 600 : 400,
fontSize: '0.75rem',
}}
>
Überfällig
</Typography>
</Box>
{/* Secondary KPIs */}
<Box sx={{ flex: 1, display: 'flex', gap: 2 }}>
<SecondaryKpi
icon={<Calendar size={14} color="#ea580c" />}
label="Diese Woche"
value={ins.dueThisWeek}
color="#ea580c"
active={filterHorizon === 'THIS_WEEK'}
onClick={() => toggleHorizon('THIS_WEEK')}
/>
<SecondaryKpi
icon={<CalendarDays size={14} color="#0369a1" />}
label="Dieser Monat"
value={ins.dueThisMonth}
color="#0369a1"
active={filterHorizon === 'THIS_MONTH'}
onClick={() => toggleHorizon('THIS_MONTH')}
/>
<SecondaryKpi
icon={<Eye size={14} color="#be185d" />}
label="Pre-Market Risiko"
value={ins.schattenmarktReadyCount}
color="#be185d"
active={filterType === ReminderType.SCHATTENMARKT_RELEASE}
onClick={togglePreMarket}
/>
</Box>
</Box>
)
}
+19 -22
View File
@@ -70,10 +70,10 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
onClick={handleRowClick}
sx={{
display: 'grid',
gridTemplateColumns: '100px 130px 1fr 140px 90px 100px 90px',
gridTemplateColumns: '90px 130px 1fr 140px 80px 90px',
alignItems: 'center',
gap: 1,
pl: 1.5,
pl: 2,
pr: 2,
py: 1.25,
borderBottom: '1px solid #f1f5f9',
@@ -84,8 +84,8 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
transition: 'background-color 0.1s',
}}
>
{/* Priority badge */}
<Box>
{/* Priority dot */}
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<ReminderPriorityBadge priority={reminder.priority} />
</Box>
@@ -112,25 +112,22 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
<ReminderDaysIndicator dueDate={reminder.dueDate} />
</Box>
{/* Area */}
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{reminder.areaSqm.toLocaleString('de-CH')} m²
</Typography>
{/* Status chip */}
{/* Status — only shown for non-default states */}
<Box>
<Chip
label={STATUS_LABEL[reminder.status]}
size="small"
sx={{
height: 20,
fontSize: '0.7rem',
fontWeight: 500,
bgcolor: STATUS_STYLE[reminder.status]?.bg ?? '#f8fafc',
color: STATUS_STYLE[reminder.status]?.color ?? '#64748b',
border: `1px solid ${STATUS_STYLE[reminder.status]?.border ?? '#e2e8f0'}`,
}}
/>
{reminder.status !== ReminderStatus.ACTIVE && (
<Chip
label={STATUS_LABEL[reminder.status]}
size="small"
sx={{
height: 20,
fontSize: '0.7rem',
fontWeight: 500,
bgcolor: STATUS_STYLE[reminder.status]?.bg ?? '#f8fafc',
color: STATUS_STYLE[reminder.status]?.color ?? '#64748b',
border: `1px solid ${STATUS_STYLE[reminder.status]?.border ?? '#e2e8f0'}`,
}}
/>
)}
</Box>
{/* Actions */}
@@ -5,7 +5,7 @@ const CONFIG: Record<ReminderPriority, { color: string; label: string }> = {
URGENT: { color: '#dc2626', label: 'Dringend' },
HIGH: { color: '#ea580c', label: 'Hoch' },
MEDIUM: { color: '#ca8a04', label: 'Mittel' },
LOW: { color: '#64748b', label: 'Niedrig' },
LOW: { color: '#94a3b8', label: 'Niedrig' },
}
interface Props {
@@ -15,9 +15,9 @@ interface Props {
export function ReminderPriorityBadge({ priority }: Props) {
const { color, label } = CONFIG[priority]
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: color, flexShrink: 0 }} />
<Typography variant="caption" sx={{ color, fontWeight: 600, fontSize: '0.7rem' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6 }}>
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: color, flexShrink: 0 }} />
<Typography sx={{ color, fontWeight: 600, fontSize: '0.7rem', lineHeight: 1, whiteSpace: 'nowrap' }}>
{label}
</Typography>
</Box>
+168 -3
View File
@@ -1,14 +1,28 @@
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 { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, MenuItem, Switch, TextField, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material'
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 { suggestFutureRent } from '../../lib/rentEstimate'
import { floorLabel } from './PropertyDetailHelpers'
const UNIT_NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
interface UnitDraft {
rentPricePerSqm?: number
availableFrom?: string
fitOut?: string
fitOutByLandlord?: boolean
mieterausbaubeitragPerSqm?: number
parkingSpots?: number
expectedRentPerSqm?: number
}
function MatchPill({ m }: { m: UnitNeedMatch }) {
const bg = m.matchScore >= 85 ? '#fef3c7' : '#e0e7ff'
const color = m.matchScore >= 85 ? '#92400e' : '#3730a3'
@@ -50,8 +64,40 @@ 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 [unitDraft, setUnitDraft] = useState<UnitDraft>({})
const updateUnit = useUpdateUnit(p.id)
function openUnitEditor(u: PropertyUnit) {
setUnitDraft({
rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm,
availableFrom: u.availableFrom,
fitOut: u.fitOut ?? '',
fitOutByLandlord: u.fitOutByLandlord,
mieterausbaubeitragPerSqm: u.mieterausbaubeitragPerSqm,
parkingSpots: u.parkingSpots,
expectedRentPerSqm: u.expectedRentPerSqm,
})
setEditingUnit(u.id)
}
function saveUnitEditor(unitId: string) {
const effFit = (unitDraft.fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined
const needsBuild = effFit === 'SHELL' || effFit === 'BASIC'
updateUnit.mutate({
unitId,
data: {
rentPricePerSqm: unitDraft.rentPricePerSqm,
availableFrom: unitDraft.availableFrom || undefined,
fitOut: effFit,
fitOutByLandlord: needsBuild ? unitDraft.fitOutByLandlord : undefined,
mieterausbaubeitragPerSqm: needsBuild && unitDraft.fitOutByLandlord === false ? unitDraft.mieterausbaubeitragPerSqm : undefined,
parkingSpots: unitDraft.parkingSpots,
expectedRentPerSqm: unitDraft.expectedRentPerSqm,
},
})
setEditingUnit(null)
}
const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
const unitMatches = useUnitMatchesMap(freeUnits, p)
@@ -229,6 +275,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 +332,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={() => (editingUnit === u.id ? setEditingUnit(null) : openUnitEditor(u))}
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 +354,117 @@ 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.parkingSpots != null && (
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, fontSize: '0.6rem', display: 'block' }}>
{u.parkingSpots} PP
</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>
{/* Inline editor: price + availability per unit */}
<Collapse in={editingUnit === u.id}>
<Box sx={{ px: 2, py: 1.25, bgcolor: '#f8fafc', borderBottom: !isLastUnit ? `1px solid ${DS_BORDER.default}` : 'none' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: u.available ? '1fr 1fr' : '1fr', gap: 1.25 }}>
<TextField
label="Preis (CHF/m²/Jahr)" type="number" size="small"
value={unitDraft.rentPricePerSqm ?? ''}
onChange={e => setUnitDraft(d => ({ ...d, rentPricePerSqm: parseInt(e.target.value) || undefined }))}
slotProps={{ htmlInput: { min: 0, step: 10 } }}
helperText="Leer = Objekt-Preis"
/>
{u.available && (
<TextField
label="Verfügbar ab" type="date" size="small"
slotProps={{ inputLabel: { shrink: true } }}
value={unitDraft.availableFrom ?? ''}
onChange={e => setUnitDraft(d => ({ ...d, availableFrom: e.target.value }))}
helperText="Leer = sofort"
/>
)}
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.25, mt: 1.25 }}>
<TextField
select size="small" label="Ausbaustandard"
value={unitDraft.fitOut ?? ''}
onChange={e => setUnitDraft(d => ({ ...d, fitOut: e.target.value }))}
>
<MenuItem value="">Wie Objekt{p.hardFacts?.fitOut ? ` (${FIT_OUT_LABELS[p.hardFacts.fitOut] ?? p.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 (Zuteilung)" type="number" size="small"
value={unitDraft.parkingSpots ?? ''}
onChange={e => setUnitDraft(d => ({ ...d, parkingSpots: parseInt(e.target.value) || undefined }))}
slotProps={{ htmlInput: { min: 0 } }}
helperText={p.hardFacts?.parking != null ? `aus Objekt-Pool: ${p.hardFacts.parking}` : 'Leer = Objekt-Wert'}
/>
</Box>
{u.schattenmarktRelease?.enabled && (() => {
const suggestion = suggestFutureRent(p.location?.city ?? '', unitDraft.rentPricePerSqm ?? u.rentPricePerSqm ?? p.rentPricePerSqm)
return (
<Box sx={{ mt: 1.25 }}>
<TextField
label="Erwarteter Preis Pre-Market (CHF/m²)" type="number" size="small" fullWidth
value={unitDraft.expectedRentPerSqm ?? ''}
onChange={e => setUnitDraft(d => ({ ...d, expectedRentPerSqm: parseInt(e.target.value) || undefined }))}
slotProps={{ htmlInput: { min: 0, step: 10 } }}
helperText={suggestion ? `Vorschlag (indexiert): CHF ${suggestion}/m² — leer = heutiger Preis` : 'Leer = heutiger Preis'}
/>
</Box>
)
})()}
{UNIT_NEEDS_BUILD.has((unitDraft.fitOut || p.hardFacts?.fitOut) ?? '') && (
<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={unitDraft.fitOutByLandlord === undefined ? null : unitDraft.fitOutByLandlord ? 'landlord' : 'tenant'}
onChange={(_, v) => { if (v) setUnitDraft(d => ({ ...d, fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) })) }}
>
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter</ToggleButton>
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter</ToggleButton>
</ToggleButtonGroup>
{unitDraft.fitOutByLandlord === false && (
<TextField
label="MAB (CHF/m²)" type="number" size="small" sx={{ width: 160 }}
value={unitDraft.mieterausbaubeitragPerSqm ?? ''}
onChange={e => setUnitDraft(d => ({ ...d, 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 sx={{ display: 'flex', gap: 1, mt: 1.25, justifyContent: 'flex-end' }}>
<Button size="small" onClick={() => setEditingUnit(null)} sx={{ textTransform: 'none', color: DS_TEXT.muted }}>
Abbrechen
</Button>
<Button size="small" variant="contained" onClick={() => saveUnitEditor(u.id)} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>
Speichern
</Button>
</Box>
</Box>
</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' }}>
+7
View File
@@ -63,6 +63,12 @@ export interface WeightedPreference {
description?: string
}
export interface NotificationConfig {
enabled: boolean
minScore: number // 0100, default 80
emailAddress?: string
}
export interface Need {
id: string
companyName: string
@@ -103,6 +109,7 @@ export interface Need {
confidenceInCriteria: number
extractedFromText?: string
notes?: string
notificationConfig?: NotificationConfig
organizationId?: string
createdAt: string
updatedAt: string
+2
View File
@@ -56,6 +56,8 @@ export interface PropertyHardFacts {
floor?: number
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
mieterausbaubeitragPerSqm?: number
/** true = Vermieter übernimmt den Ausbau & preist ihn in die Miete ein (kein Aufschlag, kein MAB). */
fitOutByLandlord?: boolean
parking?: number
publicTransportScore?: number
usageType?: string
+8
View File
@@ -17,6 +17,14 @@ export interface PropertyUnit {
areaSqm: number
available: boolean
rentPricePerSqm?: number // annual CHF/m²; falls back to property.rentPricePerSqm
availableFrom?: string // explicit availability date for a free unit (ISO); else derived from leases
// Unit-level overrides — fall back to property.hardFacts.* when unset.
// Relevant where units of one property are matched individually (pre-market, multi-unit).
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
fitOutByLandlord?: boolean // wer trägt den Ausbau (Default: Objekt-Wert)
mieterausbaubeitragPerSqm?: number // MAB der Einheit (Default: Objekt-Wert)
parkingSpots?: number // aus dem Objekt-Pool zugeteilte Parkplätze
expectedRentPerSqm?: number // erwarteter künftiger Preis (Pre-Market), ≠ heutige Sollmiete
leases?: Lease[] // Mietverträge — current, historical, future
/** @deprecated Use leases[].tenant.companyName */
currentTenant?: string
@@ -181,6 +181,27 @@ describe('calculateScore', () => {
expect(output.excludedReason).toBeTruthy()
})
// ── Ausbau-Annuität im Budget-Faktor ────────────────────────────────────────
function budgetFactor(need: ReturnType<typeof makeNeed>, prop: ReturnType<typeof makeProperty>) {
return calculateScore(need, prop).allHardFactors.find(f => f.criterion === 'budget')!
}
it('tenant-borne SHELL lowers the budget factor vs a fitted-out (FULL) property', () => {
const need = makeNeed({ budgetRange: { maxPerSqm: 50, currency: 'CHF' } })
const base = { rentPricePerSqm: 48, areaSqm: 300 }
const shell = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL' } })
const full = makeProperty({ ...base, hardFacts: { fitOut: 'FULL' } })
expect(budgetFactor(need, shell).score).toBeLessThan(budgetFactor(need, full).score)
})
it('landlord-borne SHELL keeps the budget factor (no fit-out surcharge)', () => {
const need = makeNeed({ budgetRange: { maxPerSqm: 50, currency: 'CHF' } })
const base = { rentPricePerSqm: 48, areaSqm: 300 }
const tenant = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL' } })
const landlord = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL', fitOutByLandlord: true } })
expect(budgetFactor(need, landlord).score).toBeGreaterThan(budgetFactor(need, tenant).score)
})
it('produces identical scores on repeated calls with the same inputs (determinism)', () => {
const need = makeNeed()
const prop = makeProperty()
+13 -11
View File
@@ -13,6 +13,7 @@ import type {
import { getCityIntelligence } from '../../lib/locationIntelligence'
import { formatUnitTitle, formatMultiUnitFloors, getUnitAvailability } from '../../domain/unit'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { resolveUnitFacts } from '../../lib/unitFacts'
import { AvailabilityStatus } from '../../domain/enums'
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
@@ -152,29 +153,30 @@ export function buildMatchCardViewModel(
preMarketUnit: unit,
preMarketAllUnits: property?.units,
fitOutLabel: (() => {
const fitOut = property?.hardFacts?.fitOut
// Einheit-Werte haben Vorrang vor Objekt-Werten (Pre-Market / Mehr-Einheiten)
if (!property) return undefined
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
if (!fitOut) return undefined
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
const mab = property.hardFacts?.mieterausbaubeitragPerSqm
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return `${LABELS[fitOut]} — bezugsfertig`
if (mab) return `${LABELS[fitOut]} + CHF ${mab} MAB`
if (mabPerSqm) return `${LABELS[fitOut]} + CHF ${mabPerSqm} MAB`
return LABELS[fitOut] ?? fitOut
})(),
fitOutViable: (() => {
const fitOut = property?.hardFacts?.fitOut
if (!property) return undefined
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
if (!fitOut) return undefined
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return true
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
if (fitOut === 'BASIC' && mab >= 150) return true
if (fitOut === 'SHELL' && mab >= 350) return true
if (fitOut === 'BASIC' && mabPerSqm >= 150) return true
if (fitOut === 'SHELL' && mabPerSqm >= 350) return true
return undefined
})(),
fitOutInvestment: (() => {
const fitOut = property?.hardFacts?.fitOut
if (!property) return undefined
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
if (!fitOut || fitOut === 'FULL' || fitOut === 'PREMIUM') return undefined
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
const area = property.areaSqm ?? 0
return calcFitOutInvestment(fitOut, area, mab, 0) ?? undefined
const area = unit?.areaSqm ?? property.areaSqm ?? 0
return calcFitOutInvestment(fitOut, area, mabPerSqm, 0) ?? undefined
})(),
isDivisible: property?.units ? property.units.length > 1 : false,
minDivisibleUnitSqm: (() => {
+23 -9
View File
@@ -15,6 +15,7 @@ import { analyzeTradeOffs, analyzeRisks, identifyMissingData } from './tradeOffA
import { generateNextBestActions } from './rankingEngine'
import { softFactorEnrichmentService } from '../../services/softFactorEnrichmentService'
import { scoreMustHaves } from './mustHaveScorer'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
// ── Profile resolution ────────────────────────────────────────────────────────
@@ -159,7 +160,16 @@ function scoreLocation(need: Need, property: Property, weight: number): ScoreFac
function scoreBudget(need: Need, property: Property, weight: number): ScoreFactor {
const maxBudget = need.budgetRange?.maxPerSqm ?? 0
const rent = property.rentPricePerSqm
// Effektive Jahresbelastung: Kaltmiete + annuitätischer Ausbau-Aufschlag (nur wenn Mieter ausbaut)
const { effectivePerSqm: rent, fitOutPerSqm } = effectiveAnnualBurdenPerSqm({
fitOut: property.hardFacts?.fitOut,
rentPricePerSqm: property.rentPricePerSqm,
mabPerSqm: property.hardFacts?.mieterausbaubeitragPerSqm ?? 0,
fitOutByLandlord: property.hardFacts?.fitOutByLandlord,
})
// Erklärt den Ausbau-Anteil transparent, wenn er den Score beeinflusst
const fitOutNote = fitOutPerSqm > 0 ? ` (inkl. CHF ${fitOutPerSqm}/m² Ausbau-Annuität)` : ''
let score: number
let explanation: string
@@ -171,18 +181,18 @@ function scoreBudget(need: Need, property: Property, weight: number): ScoreFacto
const ratio = rent / maxBudget
// Very cheap can indicate quality issues — slight penalty below 50% of budget
score = ratio >= 0.50 ? 100 : 88
explanation = `Miete CHF ${rent}/m² liegt ${Math.round((1 - ratio) * 100)}% unter Budget CHF ${maxBudget}/m²`
explanation = `Effektive Belastung CHF ${rent}/m² liegt ${Math.round((1 - ratio) * 100)}% unter Budget CHF ${maxBudget}/m²${fitOutNote}`
} else {
const overRatio = rent / maxBudget
if (overRatio <= HARD_FILTER.BUDGET_MODERATE_RATIO) {
score = 75
explanation = `Miete CHF ${rent}/m² leicht über Budget (+${Math.round((overRatio - 1) * 100)}%)`
explanation = `Effektive Belastung CHF ${rent}/m² leicht über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
} else if (overRatio <= HARD_FILTER.BUDGET_SEVERE_RATIO) {
score = 45
explanation = `Miete CHF ${rent}/m² merklich über Budget (+${Math.round((overRatio - 1) * 100)}%)`
explanation = `Effektive Belastung CHF ${rent}/m² merklich über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
} else {
score = 20
explanation = `Miete CHF ${rent}/m² stark über Budget (+${Math.round((overRatio - 1) * 100)}%)`
explanation = `Effektive Belastung CHF ${rent}/m² stark über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
}
}
@@ -610,8 +620,12 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
]
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
// ── Final score: hard/soft weighted sum, clamped 0–100 ───────────────────
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40 - hardFilter.severePenalty
// ── Final score: hard/soft weighted sum + Datenqualität/Konfidenz-Modifikatoren,
// abzgl. severePenalty, clamped 0100 (Trust-first: schwache Datenlage senkt den Score) ──
const dataQualityModifier = calcDataQualityModifier(property)
const confidenceModifier = calcConfidenceModifier(property)
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40
+ dataQualityModifier + confidenceModifier - hardFilter.severePenalty
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
// ── Factor classification — only use weighted soft factors for positive/negative ──
@@ -639,8 +653,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
finalScore,
hardMatchScore,
softFactorScore,
dataQualityModifier: 0,
confidenceModifier: 0,
dataQualityModifier,
confidenceModifier,
positiveFactors,
negativeFactors,
allHardFactors: hardFactors,
+11 -2
View File
@@ -1,6 +1,6 @@
import { useMutation } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import { aiService, parseListingText } from '../services/aiService'
import type { OfferEmailPayload } from '../services/aiService'
import type { OfferEmailPayload, FitOutAdviceInput } from '../services/aiService'
export function useParseNeed() {
return useMutation({
@@ -25,3 +25,12 @@ export function useParseListingText() {
mutationFn: (text: string) => parseListingText(text),
})
}
export function useFitOutAdvice(input: FitOutAdviceInput | null) {
return useQuery({
queryKey: ['fitOutAdvice', input],
queryFn: () => aiService.generateFitOutAdvice(input!),
enabled: !!input,
staleTime: Infinity,
})
}
+25 -3
View File
@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { needService } from '../services/needService'
import type { CreateNeedInput } from '../domain/need'
import { useSessionStore } from '../stores/sessionStore'
import type { CreateNeedInput, UpdateNeedInput } from '../domain/need'
interface UseNeedsOptions {
refetchOnMount?: boolean | 'always'
@@ -8,9 +9,10 @@ interface UseNeedsOptions {
}
export function useNeeds(options?: UseNeedsOptions) {
const orgId = useSessionStore(s => s.currentUser?.organizationId)
return useQuery({
queryKey: ['needs'],
queryFn: () => needService.getAll(),
queryKey: ['needs', orgId],
queryFn: () => needService.getAll({ organizationId: orgId }),
select: (res) => res.data ?? [],
...options,
})
@@ -36,3 +38,23 @@ export function useCreateNeed() {
},
})
}
export function useUpdateNeed() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, ...data }: { id: string } & UpdateNeedInput) => needService.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['needs'] })
},
})
}
export function useArchiveNeed() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => needService.update(id, { status: 'INACTIVE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['needs'] })
},
})
}
+13 -5
View File
@@ -15,6 +15,8 @@ function validate(fields: {
city: string
areaSqm: string
rentPerSqm: string
fitOut: string
fitOutByLandlord: boolean | undefined
}): string | null {
if (!fields.street.trim()) return 'Strasse erforderlich'
if (!fields.postalCode.trim()) return 'PLZ erforderlich'
@@ -23,6 +25,9 @@ function validate(fields: {
|| Number(fields.areaSqm) <= 0) return 'Gültige Fläche eingeben'
if (!fields.rentPerSqm || isNaN(Number(fields.rentPerSqm))
|| Number(fields.rentPerSqm) <= 0) return 'Gültigen Mietpreis eingeben'
// Bei Rohbau/Edelrohbau muss der Ausbau-Träger aktiv gewählt werden
if ((fields.fitOut === 'SHELL' || fields.fitOut === 'BASIC')
&& fields.fitOutByLandlord === undefined) return 'Bitte wählen, wer den Ausbau trägt (Vermieter oder Mieter)'
return null
}
@@ -43,6 +48,7 @@ export interface NewListingFormState {
// Technical
floor: string
fitOut: string
fitOutByLandlord: boolean | undefined
parking: string
ceilingHeight: string
mieterausbaubeitrag: string
@@ -80,6 +86,7 @@ export interface NewListingFormHandlers {
setSoftLevel: (key: string, value: string) => void
setFloor: (v: string) => void
setFitOut: (v: string) => void
setFitOutByLandlord: (v: boolean | undefined) => void
setParking: (v: string) => void
setCeilingHeight: (v: string) => void
setMieterausbaubeitrag: (v: string) => void
@@ -117,6 +124,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
const [softLevels, setSoftLevels] = useState<Record<string, string>>(() => ({ ...emptySoftLevels(), ...pre.softLevels }))
const [floor, setFloor] = useState(pre.floor ?? '')
const [fitOut, setFitOut] = useState(pre.fitOut ?? '')
const [fitOutByLandlord, setFitOutByLandlord] = useState<boolean | undefined>(pre.fitOutByLandlord)
const [parking, setParking] = useState(pre.parking != null ? String(pre.parking) : '')
const [ceilingHeight, setCeilingHeight]= useState(pre.ceilingHeight != null ? String(pre.ceilingHeight) : '')
const [mieterausbaubeitrag, setMieterausbaubeitrag] = useState(pre.mieterausbaubeitragPerSqm != null ? String(pre.mieterausbaubeitragPerSqm) : '')
@@ -161,7 +169,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
}
function handleSubmit() {
const err = validate({ street, postalCode, city, areaSqm, rentPerSqm })
const err = validate({ street, postalCode, city, areaSqm, rentPerSqm, fitOut, fitOutByLandlord })
if (err) { setError(err); return }
setError(null)
createProperty.mutate(
@@ -169,7 +177,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
assetType, street, houseNumber, postalCode, city,
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
availableFrom, description, softLevels,
floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
floor, fitOut, fitOutByLandlord, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm,
}),
{
@@ -185,7 +193,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('')
setContactName(''); setContactEmail(''); setContactPhone('')
setSoftLevels(emptySoftLevels())
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
setFloor(''); setFitOut(''); setFitOutByLandlord(undefined); setParking(''); setCeilingHeight('')
setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('')
setImages([]); setImageInput(''); setFloorPlanUrl('')
setAiText(''); setAiApplied(false)
@@ -195,7 +203,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
return {
assetType, areaSqm, rentPerSqm, availableFrom, description,
street, houseNumber, postalCode, city,
softLevels, floor, fitOut, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
softLevels, floor, fitOut, fitOutByLandlord, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
contactName, contactEmail, contactPhone,
images, imageInput, floorPlanUrl, aiText, aiApplied,
error, created,
@@ -205,7 +213,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setAssetType, setAreaSqm, setRentPerSqm, setAvailableFrom, setDescription,
setStreet, setHouseNumber, setPostalCode, setCity,
setSoftLevel,
setFloor, setFitOut, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
setFloor, setFitOut, setFitOutByLandlord, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
setContactName, setContactEmail, setContactPhone,
setImageInput, setFloorPlanUrl, setAiText,
addImage, removeImage,
+16
View File
@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { reminderService } from '../services/reminderService'
import type { Reminder } from '../domain/reminder'
import { useToastStore } from '../stores/toastStore'
export function useReminders() {
@@ -69,6 +70,21 @@ export function useSnoozeReminder() {
})
}
export function useCreateReminder() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: Omit<Reminder, 'id' | 'activity' | 'createdAt' | 'updatedAt'>) =>
reminderService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reminders'] })
queryClient.invalidateQueries({ queryKey: ['reminder-insights'] })
},
onError: () => {
useToastStore.getState().showToast('Reminder konnte nicht erstellt werden.', 'error')
},
})
}
export function useUpdateReminder() {
const queryClient = useQueryClient()
return useMutation({
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { estimateMarketRent, suggestFutureRent } from '../rentEstimate'
describe('estimateMarketRent', () => {
it('flags asking rent clearly above the city median as ABOVE', () => {
// Zürich OFFICE median = 42
const est = estimateMarketRent('Zürich', 'OFFICE', 60)
expect(est).not.toBeNull()
expect(est!.verdict).toBe('ABOVE')
expect(est!.deltaPct).toBeGreaterThan(5)
expect(est!.fairRentPerSqm).toBe(42)
})
it('flags asking rent clearly below median as BELOW', () => {
const est = estimateMarketRent('Zürich', 'OFFICE', 30)
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', 43)
expect(est!.verdict).toBe('AT')
})
it('returns null for unknown city', () => {
expect(estimateMarketRent('Atlantis', 'OFFICE', 40)).toBeNull()
})
it('suggestFutureRent indexes by the city rent trend', () => {
// Zürich rentTrend12m = +4.2% → 100 * 1.042 = 104 (rounded)
expect(suggestFutureRent('Zürich', 100)).toBe(104)
})
})
+12
View File
@@ -142,3 +142,15 @@ export const FIT_OUT_COST_CHF_PER_SQM: Record<string, { min: number; max: number
// Assumed amortization period for fit-out investment — change here to affect all cost calculations
export const FITOUT_AMORTIZATION_YEARS = 5
// Kalkulationszins p.a. für die annuitätische Amortisation der Ausbauinvestition (Schweizer Richtwert)
export const FITOUT_ANNUITY_RATE = 0.05
// Zentrale Ausbaustandard-Labels — branchenüblich + internationale Synonyme (CAT A/B, Core & Shell).
// Single Source of Truth für Dropdown, Panel, Score, Vergleich.
export const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau · Core & Shell',
BASIC: 'Edelrohbau · CAT A',
FULL: 'Ausgebaut · CAT B',
PREMIUM: 'Vollausgebaut · Plug & Play',
}
+42 -1
View File
@@ -1,15 +1,49 @@
import { FIT_OUT_COST_CHF_PER_SQM } from './constants'
import { FIT_OUT_COST_CHF_PER_SQM, FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE } from './constants'
export interface FitOutInvestment {
grossPerSqm: { min: number; max: number }
mabOffset: number
netPerSqm: { min: number; max: number }
netTotal: { min: number; max: number }
annualBurden: { min: number; max: number }
isFullyCovered: boolean
shortLabel: string
detailLabel: string
}
// Annuitätenfaktor: a = i(1+i)^n / ((1+i)^n 1); bei i=0 → 1/n (lineare Amortisation als Fallback)
export function annuityFactor(rate: number, years: number): number {
if (years <= 0) return 0
if (rate === 0) return 1 / years
const q = Math.pow(1 + rate, years)
return (rate * q) / (q - 1)
}
// Bezugsfertige Stufen — kein Ausbau-Aufschlag (Mieter muss nicht mehr investieren)
const FITOUT_READY = new Set(['FULL', 'PREMIUM'])
/**
* Effektive Jahresbelastung pro m². Grundsatz: die quotierte Miete gilt IMMER als realer Preis
* die Plattform erfindet keinen Aufschlag. Nur wenn der Mieter ausbaut (SHELL/BASIC), wird die
* versteckte Ausbaukost annuitätisch sichtbar gemacht (abzgl. MAB). Übernimmt der Vermieter, ist der
* Ausbau bereits in der Miete enthalten kein Aufschlag, keine Mieter-Capex.
*/
export function effectiveAnnualBurdenPerSqm(p: {
fitOut?: string
rentPricePerSqm: number
mabPerSqm: number
fitOutByLandlord?: boolean
}): { rentPerSqm: number; fitOutPerSqm: number; effectivePerSqm: number } {
if (p.fitOutByLandlord || FITOUT_READY.has(p.fitOut ?? '')) {
return { rentPerSqm: p.rentPricePerSqm, fitOutPerSqm: 0, effectivePerSqm: p.rentPricePerSqm }
}
const bm = FIT_OUT_COST_CHF_PER_SQM[p.fitOut ?? '']
const grossMid = bm ? (bm.min + bm.max) / 2 : 0
const net = Math.max(0, grossMid - p.mabPerSqm)
const fitOutPerSqm = Math.round(net * annuityFactor(FITOUT_ANNUITY_RATE, FITOUT_AMORTIZATION_YEARS))
return { rentPerSqm: p.rentPricePerSqm, fitOutPerSqm, effectivePerSqm: p.rentPricePerSqm + fitOutPerSqm }
}
function formatChfK(value: number): string {
if (value >= 1000) return `CHF ${(value / 1000).toLocaleString('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 1 })} Mio.`
return `CHF ${Math.round(value / 1000)}k`
@@ -34,6 +68,12 @@ export function calcFitOutInvestment(
max: Math.round(netMax * areaSqm),
}
const factor = annuityFactor(FITOUT_ANNUITY_RATE, FITOUT_AMORTIZATION_YEARS)
const annualBurden = {
min: Math.round(netTotal.min * factor),
max: Math.round(netTotal.max * factor),
}
const isFullyCovered = netTotal.max <= 0
const shortLabel = isFullyCovered
@@ -51,6 +91,7 @@ export function calcFitOutInvestment(
mabOffset: mabPerSqm,
netPerSqm: { min: netMin, max: netMax },
netTotal,
annualBurden,
isFullyCovered,
shortLabel,
detailLabel,
+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,
}
}
+170 -36
View File
@@ -2,10 +2,10 @@ import { AssetType } from '../domain/enums'
import type { Need } from '../domain/need'
export const mockNeeds: Need[] = [
// --- need-001: Innovatech AG — OFFICE Zürich ---
// --- need-001: OFFICE Zürich-West ---
{
id: 'need-001',
companyName: 'Innovatech AG',
companyName: 'Bürofläche Zürich-West',
contactName: 'Sandra Meier',
assetType: AssetType.OFFICE,
requiredArea: { min: 600, max: 1000 },
@@ -38,10 +38,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-01T09:00:00Z',
},
// --- need-002: Schweizer Logistik GmbH — LOGISTICS Basel ---
// --- need-002: LOGISTICS Basel ---
{
id: 'need-002',
companyName: 'Schweizer Logistik GmbH',
companyName: 'Logistikfläche Basel',
contactName: 'Thomas Brun',
assetType: AssetType.LOGISTICS,
requiredArea: { min: 1500, max: 4000 },
@@ -72,10 +72,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-04-10T11:00:00Z',
},
// --- need-003: Pharma Holding AG — OFFICE Basel ---
// --- need-003: OFFICE Basel ---
{
id: 'need-003',
companyName: 'Pharma Holding AG',
companyName: 'Bürofläche Basel Repräsentanz',
contactName: 'Ursula Schmid',
assetType: AssetType.OFFICE,
requiredArea: { min: 500, max: 800 },
@@ -110,10 +110,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-05T14:00:00Z',
},
// --- need-004: Retailer Zürich AG — RETAIL Zürich ---
// --- need-004: RETAIL Zürich ---
{
id: 'need-004',
companyName: 'Retailer Zürich AG',
companyName: 'Ladenlokal Zürich Innenstadt',
contactName: 'Marco Colombo',
assetType: AssetType.RETAIL,
requiredArea: { min: 200, max: 500 },
@@ -144,10 +144,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-04-22T08:00:00Z',
},
// --- need-005: TechStart GmbH — OFFICE Zug/Zürich ---
// --- need-005: OFFICE Zug/Zürich ---
{
id: 'need-005',
companyName: 'TechStart GmbH',
companyName: 'Bürofläche Zug / Zürich',
contactName: 'Florian Keller',
assetType: AssetType.OFFICE,
requiredArea: { min: 300, max: 700 },
@@ -179,10 +179,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-08T10:00:00Z',
},
// --- need-006: Lager & Spedition AG — LOGISTICS Winterthur ---
// --- need-006: LOGISTICS Winterthur ---
{
id: 'need-006',
companyName: 'Lager & Spedition AG',
companyName: 'Lagerfläche Winterthur',
contactName: 'Beat Zimmermann',
assetType: AssetType.LOGISTICS,
requiredArea: { min: 1200, max: 3000 },
@@ -209,10 +209,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-09T16:00:00Z',
},
// --- need-007: Creative Studios AG — MIXED Zürich/Bern ---
// --- need-007: MIXED Zürich-West ---
{
id: 'need-007',
companyName: 'Creative Studios AG',
companyName: 'Gewerbefläche Zürich-West',
contactName: 'Nora Hauser',
assetType: AssetType.MIXED,
requiredArea: { min: 800, max: 1500 },
@@ -244,10 +244,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-03T11:00:00Z',
},
// --- need-008: Berner Produzenten GmbH — PRODUCTION Bern ---
// --- need-008: PRODUCTION Bern ---
{
id: 'need-008',
companyName: 'Berner Produzenten GmbH',
companyName: 'Produktionshalle Bern',
contactName: 'Hans Lüthi',
assetType: AssetType.PRODUCTION,
requiredArea: { min: 2000, max: 4000 },
@@ -274,10 +274,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-04-20T12:00:00Z',
},
// --- need-009: Geneva Commerce SA — RETAIL Genf ---
// --- need-009: RETAIL Genf ---
{
id: 'need-009',
companyName: 'Geneva Commerce SA',
companyName: 'Commerce Genève Centre',
contactName: 'Pierre Dupont',
assetType: AssetType.RETAIL,
requiredArea: { min: 150, max: 400 },
@@ -308,10 +308,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-04-18T09:00:00Z',
},
// --- need-011: Stadtladen Bern GmbH — RETAIL Bern Innenstadt ---
// --- need-011: RETAIL Bern Innenstadt ---
{
id: 'need-011',
companyName: 'Stadtladen Bern GmbH',
companyName: 'Ladenlokal Bern Altstadt',
contactName: 'Katrin Müller',
assetType: AssetType.RETAIL,
requiredArea: { min: 200, max: 400 },
@@ -342,10 +342,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-18T10:00:00Z',
},
// --- need-010: St.Galler Büros AG — OFFICE St.Gallen ---
// --- need-010: OFFICE St.Gallen ---
{
id: 'need-010',
companyName: 'St.Galler Büros AG',
companyName: 'Bürofläche St. Gallen',
contactName: 'Brigitte Fässler',
assetType: AssetType.OFFICE,
requiredArea: { min: 400, max: 800 },
@@ -377,10 +377,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-06T13:00:00Z',
},
// --- need-uc-a: Renato's Fahrradhändler — Use Case A ---
// --- need-uc-a: RETAIL Zürich EG ---
{
id: 'need-uc-a',
companyName: 'Velo City GmbH',
companyName: 'Ladenlokal Zürich EG Schaufenster',
contactName: 'Renato Marchetti',
assetType: AssetType.RETAIL,
requiredArea: { min: 120, max: 160 },
@@ -411,10 +411,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-22T10:00:00Z',
},
// --- need-uc-b: Renato's Umzugsfirma — Use Case B ---
// --- need-uc-b: OFFICE Zürich-West klein ---
{
id: 'need-uc-b',
companyName: 'Alp Transit Umzüge GmbH',
companyName: 'Bürofläche Zürich-West klein',
contactName: 'Renato Marchetti',
assetType: AssetType.OFFICE,
requiredArea: { min: 140, max: 200 },
@@ -445,10 +445,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-22T10:00:00Z',
},
// --- need-012: Gewerbe Solutions AG — LIGHT_INDUSTRIAL Pratteln ---
// --- need-012: LIGHT_INDUSTRIAL Pratteln ---
{
id: 'need-012',
companyName: 'Gewerbe Solutions AG',
companyName: 'Gewerbefläche Pratteln',
contactName: 'Andreas Weber',
assetType: AssetType.LIGHT_INDUSTRIAL,
requiredArea: { min: 400, max: 750 },
@@ -477,10 +477,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2026-03-01T09:00:00Z',
},
// --- need-013: Bern Consulting AG — OFFICE Bern ---
// --- need-013: OFFICE Bern ---
{
id: 'need-013',
companyName: 'Bern Consulting AG',
companyName: 'Bürofläche Bern Bahnhof',
contactName: 'Sabine Gerber',
assetType: AssetType.OFFICE,
requiredArea: { min: 180, max: 320 },
@@ -512,10 +512,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2026-04-05T10:00:00Z',
},
// --- need-014: Winterthur Handel GmbH — RETAIL Winterthur ---
// --- need-014: RETAIL Winterthur ---
{
id: 'need-014',
companyName: 'Winterthur Handel GmbH',
companyName: 'Retailfläche Winterthur',
contactName: 'Pascal Brunner',
assetType: AssetType.RETAIL,
requiredArea: { min: 150, max: 250 },
@@ -546,10 +546,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2026-03-20T11:00:00Z',
},
// --- need-015: Luzern Advisory GmbH — OFFICE Luzern ---
// --- need-015: OFFICE Luzern ---
{
id: 'need-015',
companyName: 'Luzern Advisory GmbH',
companyName: 'Bürofläche Luzern',
contactName: 'Markus Bucher',
assetType: AssetType.OFFICE,
requiredArea: { min: 350, max: 600 },
@@ -581,10 +581,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2026-04-10T14:00:00Z',
},
// --- need-uc-c: Renato's Vermögensverwalter — Use Case C ---
// --- need-uc-c: OFFICE Zürich Premium ---
{
id: 'need-uc-c',
companyName: 'Wealth Advisory Partners AG',
companyName: 'Repräsentanzbüro Zürich Premium',
contactName: 'Renato Marchetti',
assetType: AssetType.OFFICE,
requiredArea: { min: 450, max: 560 },
@@ -616,4 +616,138 @@ export const mockNeeds: Need[] = [
createdAt: '2025-05-20T10:00:00Z',
updatedAt: '2025-05-22T10:00:00Z',
},
// --- org-mobimo: eigene Suchprofile der Mobimo Management AG ---
{
id: 'need-mob-001',
companyName: 'Bürofläche Zürich City',
contactName: 'Sandra Koch',
assetType: AssetType.OFFICE,
requiredArea: { min: 300, max: 600 },
preferredLocations: ['Zürich', 'Zürich City', 'Zürich Kreis 1', 'Zürich Kreis 4'],
excludedLocations: [],
budgetRange: { maxPerSqm: 480, maxMonthlyTotal: 20000, currency: 'CHF' },
timing: {
earliestMoveIn: '2025-10-01',
latestMoveIn: '2026-03-01',
contractDurationMonths: 60,
flexibleTiming: true,
},
mustCriteriaText: ['Gute ÖV-Anbindung', 'Repräsentative Lage', 'Klimaanlage'],
softFactors: {
minPrestige: 70,
minAccessibility: 80,
requireParking: false,
maxPublicTransportMinutes: 8,
},
weightingProfile: {
area: 0.18, location: 0.24, budget: 0.16, timing: 0.12,
prestige: 0.10, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.04,
visibility: 0.02, footfall: 0.01, talentAccess: 0.01, esg: 0.01, taxEnvironment: 0.00,
},
confidenceInCriteria: 0.91,
notificationConfig: { enabled: true, minScore: 80, emailAddress: 'sandra.koch@mobimo.ch' },
organizationId: 'org-mobimo',
createdAt: '2025-05-10T09:00:00Z',
updatedAt: '2025-06-01T10:00:00Z',
},
{
id: 'need-mob-002',
companyName: 'Showroom Bern Innenstadt',
contactName: 'Sandra Koch',
assetType: AssetType.RETAIL,
requiredArea: { min: 150, max: 300 },
preferredLocations: ['Bern', 'Bern Innenstadt', 'Bern Gurtengasse', 'Bern Marktgasse'],
excludedLocations: [],
budgetRange: { maxPerSqm: 380, maxMonthlyTotal: 8000, currency: 'CHF' },
timing: {
earliestMoveIn: '2025-11-01',
latestMoveIn: '2026-06-01',
contractDurationMonths: 36,
flexibleTiming: false,
},
mustCriteriaText: ['Schaufenster Erdgeschoss', 'Hoher Publikumsverkehr', 'Liefermöglichkeit'],
softFactors: {
minPrestige: 65,
minAccessibility: 75,
requireParking: false,
maxPublicTransportMinutes: 5,
},
weightingProfile: {
area: 0.14, location: 0.28, budget: 0.15, timing: 0.10,
prestige: 0.08, accessibility: 0.07, expansionPotential: 0.02, flexibility: 0.03,
visibility: 0.08, footfall: 0.04, talentAccess: 0.00, esg: 0.01, taxEnvironment: 0.00,
},
confidenceInCriteria: 0.87,
notificationConfig: { enabled: false, minScore: 75 },
organizationId: 'org-mobimo',
createdAt: '2025-04-20T11:00:00Z',
updatedAt: '2025-05-15T09:00:00Z',
},
{
id: 'need-mob-003',
companyName: 'Lager & Service Schaffhausen',
contactName: 'Sandra Koch',
assetType: AssetType.LOGISTICS,
requiredArea: { min: 800, max: 1500 },
preferredLocations: ['Schaffhausen', 'Neuhausen am Rheinfall', 'Thayngen'],
excludedLocations: [],
budgetRange: { maxPerSqm: 160, currency: 'CHF' },
timing: {
earliestMoveIn: '2026-01-01',
latestMoveIn: '2026-09-01',
contractDurationMonths: 48,
flexibleTiming: true,
},
mustCriteriaText: ['Autobahnanschluss < 10 Min', 'Rampe / Ladetor', 'Büroanteil mind. 80 m²'],
softFactors: {
requireParking: true,
},
weightingProfile: {
area: 0.22, location: 0.20, budget: 0.20, timing: 0.12,
prestige: 0.01, accessibility: 0.12, expansionPotential: 0.05, flexibility: 0.03,
visibility: 0.01, footfall: 0.00, talentAccess: 0.01, esg: 0.02, taxEnvironment: 0.01,
},
confidenceInCriteria: 0.83,
notificationConfig: { enabled: true, minScore: 85 },
organizationId: 'org-mobimo',
createdAt: '2025-06-01T08:00:00Z',
updatedAt: '2025-06-10T14:00:00Z',
},
{
id: 'need-mob-004',
companyName: 'Repräsentanzfläche Zürich-West',
contactName: 'Sandra Koch',
assetType: AssetType.OFFICE,
requiredArea: { min: 500, max: 900 },
preferredLocations: ['Zürich-West', 'Zürich Kreis 5', 'Zürich Kreis 4', 'Zürich Hardbrücke'],
excludedLocations: [],
budgetRange: { maxPerSqm: 520, maxMonthlyTotal: 35000, currency: 'CHF' },
timing: {
earliestMoveIn: '2026-03-01',
latestMoveIn: '2026-12-01',
contractDurationMonths: 84,
flexibleTiming: false,
},
mustCriteriaText: ['Moderner Neubau oder Kernsanierung', 'Kollaborative Fläche', 'Fahrradabstellplätze', 'Minergie oder LEED-zertifiziert'],
softFactors: {
minPrestige: 75,
minAccessibility: 85,
requireParking: false,
maxPublicTransportMinutes: 6,
},
weightingProfile: {
area: 0.16, location: 0.22, budget: 0.14, timing: 0.10,
prestige: 0.12, accessibility: 0.09, expansionPotential: 0.05, flexibility: 0.04,
visibility: 0.02, footfall: 0.01, talentAccess: 0.03, esg: 0.02, taxEnvironment: 0.00,
},
confidenceInCriteria: 0.93,
organizationId: 'org-mobimo',
createdAt: '2025-03-15T13:00:00Z',
updatedAt: '2025-06-05T11:00:00Z',
},
]
+1 -1
View File
@@ -153,7 +153,7 @@ export const mockProperties: Property[] = [
parkingSpots: 8,
publicTransportMinutes: 5,
},
hardFacts: { fitOut: 'BASIC' },
hardFacts: { fitOut: 'BASIC', fitOutByLandlord: true },
floorLevel: 2,
expansionPotentialSqm: 200,
contractDurationMonths: 48,
+13 -13
View File
@@ -27,7 +27,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Mieter hat bisher keine Verlängerungsabsicht signalisiert. Erstkontakt dringend.',
activity: [
{ at: '2026-04-01T08:00:00Z', by: 'Anna Meier', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-04-01T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-10T14:30:00Z', by: 'Thomas Huber', action: 'NOTED', note: 'Mieter angerufen, kein Rückruf erhalten' },
],
organizationId: 'org-wincasa',
@@ -56,7 +56,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: true,
note: 'Break-Option läuft am 01.09 ab — Frist zur Ausübung ist 90 Tage vorher, also bis 03.06.',
activity: [
{ at: '2026-03-15T09:00:00Z', by: 'Anna Meier', action: 'CREATED' },
{ at: '2026-03-15T09:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-05T11:00:00Z', by: 'Anna Meier', action: 'NOTED', note: 'Mieterdossier vorbereitet' },
],
organizationId: 'org-wincasa',
@@ -83,7 +83,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Gebäudeversicherung läuft am 30.06 ab. Police-Nummer ZG-2022-9912.',
activity: [
{ at: '2026-04-20T10:00:00Z', by: 'Sandra Wyss', action: 'CREATED' },
{ at: '2026-04-20T10:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-12T15:00:00Z', by: 'Sandra Wyss', action: 'NOTED', note: 'Offerte von Mobiliar angefordert' },
],
organizationId: 'org-wincasa',
@@ -140,7 +140,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: true,
note: 'Indexierte Mietanpassung per 01.10 möglich. LIK-Index prüfen.',
activity: [
{ at: '2026-04-05T08:00:00Z', by: 'Anna Meier', action: 'CREATED' },
{ at: '2026-04-05T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-18T09:00:00Z', by: 'Anna Meier', action: 'NOTED', note: 'LIK-Daten für Q1 2026 abrufbar' },
],
organizationId: 'org-wincasa',
@@ -168,7 +168,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Frist für Vertragsverhandlung: 9 Monate vor Ablauf. Markt Bern Industrie angespannt.',
activity: [
{ at: '2026-03-01T08:00:00Z', by: 'Sandra Wyss', action: 'CREATED' },
{ at: '2026-03-01T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-03-01T08:00:00Z',
@@ -195,7 +195,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Einheit noch nicht für Pre-Market freigegeben. 14 Monate Lead Time empfohlen.',
activity: [
{ at: '2026-04-18T10:00:00Z', by: 'Thomas Huber', action: 'CREATED', note: 'Pre-Market-Freigabe ausstehend' },
{ at: '2026-04-18T10:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten & Marktdaten erstellt' },
{ at: '2026-05-02T11:00:00Z', by: 'Thomas Huber', action: 'NOTED', note: 'Eigentümer informiert, Freigabe ausstehend' },
],
organizationId: 'org-wincasa',
@@ -250,7 +250,7 @@ export const mockReminders: Reminder[] = [
snoozedUntil: '2026-05-27',
note: 'Mieter erwägt Flächenreduktion. Gespräch vereinbart für 27.05.',
activity: [
{ at: '2026-04-02T09:00:00Z', by: 'Anna Meier', action: 'CREATED' },
{ at: '2026-04-02T09:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-14T16:00:00Z', by: 'Anna Meier', action: 'SNOOZED', note: 'Bis nach Gespräch mit Mieter zurückgestellt' },
],
organizationId: 'org-wincasa',
@@ -279,7 +279,7 @@ export const mockReminders: Reminder[] = [
shadowMarketRisk: ShadowMarketRisk.LOW,
schattenmarktEnabled: true,
activity: [
{ at: '2026-03-20T10:00:00Z', by: 'Sandra Wyss', action: 'CREATED' },
{ at: '2026-03-20T10:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-03-20T10:00:00Z',
@@ -306,7 +306,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Erstgespräch über Verlängerung bis 01.07 einleiten.',
activity: [
{ at: '2026-03-10T08:00:00Z', by: 'Thomas Huber', action: 'CREATED' },
{ at: '2026-03-10T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-02T09:00:00Z', by: 'Thomas Huber', action: 'NOTED', note: 'Vermieterseite wünscht Mietpreiserhöhung +5%' },
],
organizationId: 'org-wincasa',
@@ -359,7 +359,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Pre-Market Freigabe 9 Monate vor Vertragsende. Eigentümer-Freigabe einholen.',
activity: [
{ at: '2026-04-25T11:00:00Z', by: 'Anna Meier', action: 'CREATED' },
{ at: '2026-04-25T11:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten & Marktdaten erstellt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-04-25T11:00:00Z',
@@ -415,7 +415,7 @@ export const mockReminders: Reminder[] = [
shadowMarketRisk: ShadowMarketRisk.MEDIUM,
schattenmarktEnabled: true,
activity: [
{ at: '2026-02-01T08:00:00Z', by: 'Anna Meier', action: 'CREATED', note: 'Vertragsablauf-Erinnerung (4 Monate)' },
{ at: '2026-02-01T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-02-01T08:00:00Z',
@@ -440,7 +440,7 @@ export const mockReminders: Reminder[] = [
shadowMarketRisk: ShadowMarketRisk.NONE,
schattenmarktEnabled: false,
activity: [
{ at: '2026-04-20T08:00:00Z', by: 'Sandra Wyss', action: 'CREATED' },
{ at: '2026-04-20T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-04-20T08:00:00Z',
@@ -522,7 +522,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Mietpreisanpassung +2.8% vereinbart, ab 01.09 gültig.',
activity: [
{ at: '2026-02-15T08:00:00Z', by: 'Anna Meier', action: 'CREATED' },
{ at: '2026-02-15T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-04-14T11:00:00Z', by: 'Anna Meier', action: 'NOTED', note: 'Mieter hat Anpassung akzeptiert' },
{ at: '2026-04-15T14:00:00Z', by: 'Anna Meier', action: 'COMPLETED', note: 'Nachtrag unterzeichnet' },
],
+95 -62
View File
@@ -1,6 +1,6 @@
import { useRef, useState } from 'react'
import { Box, Typography } from '@mui/material'
import { useNavigate } from 'react-router'
import { useEffect, useRef, useState } from 'react'
import { Box, Tab, Tabs, Typography } from '@mui/material'
import { useLocation, useNavigate } from 'react-router'
import { useQueryClient } from '@tanstack/react-query'
import {
NeedBuilderProgress,
@@ -11,12 +11,15 @@ import {
} from '../../components/demand'
import { AISearchActionBar } from '../../components/demand/AISearchActionBar'
import { AISearchSavePreview } from '../../components/demand/AISearchSavePreview'
import { SavedProfilesTab } from '../../components/demand/SavedProfilesTab'
import { AddToPipelineDialog } from '../../components/shortlist'
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
import { useParseNeed } from '../../hooks/useAI'
import { useCreateNeed } from '../../hooks/useNeeds'
import { useCreateNeed, useNeed, useNeedProfiles } from '../../hooks/useNeeds'
import { useDefaultWeights } from '../../hooks/useWeighting'
import { NeedBuilderStep } from '../../domain/needBuilder'
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
import { generateSummary, buildNeedInput } from '../../services/aiSearch/needSearchMapper'
import { generateSummary, buildNeedInput, needToParsedCriteria } from '../../services/aiSearch/needSearchMapper'
type ActionIntent = 'search' | 'save-profile'
@@ -41,8 +44,25 @@ export default function AISearch() {
const [isAnonymous, setIsAnonymous] = useState(false)
const [error, setError] = useState<string | null>(null)
const [tab, setTab] = useState(0)
const { data: savedProfiles = [] } = useNeedProfiles()
const locationPrefillId = (useLocation().state as { prefillNeedId?: string } | null)?.prefillNeedId
const [internalPrefillId, setInternalPrefillId] = useState<string | undefined>()
const prefillNeedId = internalPrefillId ?? locationPrefillId
const { data: prefillNeed } = useNeed(prefillNeedId ?? '')
const isManualTextRef = useRef(false)
useEffect(() => {
if (!prefillNeed) return
setCriteria(needToParsedCriteria(prefillNeed))
setWeights(prefillNeed.weightingProfile as Record<WeightingKey, number>)
setNeedTitle(prefillNeed.companyName)
setWeightingKey(k => k + 1)
setTab(0)
}, [prefillNeed])
function handleCriteriaChange(next: ParsedNeedCriteria) {
setCriteria(next)
if (!isManualTextRef.current) {
@@ -56,7 +76,6 @@ export default function AISearch() {
isManualTextRef.current = text !== ''
setIsAutoGen(false)
setInputText(text)
// Clear stale parse results when user edits the text manually
setCriteria({})
setParseResult(null)
}
@@ -198,16 +217,15 @@ export default function AISearch() {
const isProcessing = step === NeedBuilderStep.PARSING || step === NeedBuilderStep.SAVING
const isSaveStep = step === NeedBuilderStep.READY_TO_SAVE || step === NeedBuilderStep.SAVING
const overallConfidence = parseResult
? (() => {
const entries = Object.entries(parseResult.confidenceByField)
return entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
})()
: 0
const vals = parseResult ? Object.values(parseResult.confidenceByField) : []
const overallConfidence = vals.length > 0 ? vals.reduce((s, v) => s + v, 0) / vals.length : 0
const savedCount = savedProfiles.filter(n => !n.status || n.status === 'ACTIVE' || n.status === 'DRAFT').length
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<AddToPipelineDialog />
<InquiryQuickDialog />
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0 }}>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Flächensuche</Typography>
<Typography variant="body2" color="text.secondary">
@@ -215,61 +233,76 @@ export default function AISearch() {
</Typography>
</Box>
<NeedBuilderProgress step={step} />
<Tabs
value={tab}
onChange={(_, v: number) => setTab(v)}
sx={{ borderBottom: '1px solid #e2e8f0', px: 3, bgcolor: 'white', flexShrink: 0, minHeight: 44 }}
>
<Tab label="Neue Suche" sx={{ textTransform: 'none', fontWeight: 600, fontSize: '0.875rem', minHeight: 44, py: 0 }} />
<Tab label={`Gespeicherte Profile (${savedCount})`} sx={{ textTransform: 'none', fontWeight: 600, fontSize: '0.875rem', minHeight: 44, py: 0 }} />
</Tabs>
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{tab === 0 && (
<>
<NeedBuilderProgress step={step} />
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{/* IDLE: full form */}
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 1200, mx: 'auto' }}>
<VoiceNeedInput
text={inputText}
onTextChange={handleTextChange}
isAutoGen={isAutoGen}
onAiSubmit={handleAiAutofill}
isAnalyzing={step === NeedBuilderStep.PARSING}
/>
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
<NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} />
<WeightingEditor
key={weightingKey}
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: { md: 900, xl: 1200 }, mx: 'auto' }}>
<VoiceNeedInput
text={inputText}
onTextChange={handleTextChange}
isAutoGen={isAutoGen}
onAiSubmit={handleAiAutofill}
isAnalyzing={step === NeedBuilderStep.PARSING}
/>
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
<NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} />
<WeightingEditor
key={weightingKey}
weights={weights}
onChange={setWeights}
assetType={criteria.assetType}
/>
</Box>
<AISearchActionBar
canProceed={canProceed}
isSearching={isProcessing && intent === 'search'}
isSavingProfile={isProcessing && intent === 'save-profile'}
onSearch={() => handleAction('search')}
onSaveProfile={() => handleAction('save-profile')}
/>
</Box>
)}
{isSaveStep && parseResult && editedCriteria && (
<AISearchSavePreview
criteria={editedCriteria}
weights={weights}
onChange={setWeights}
assetType={criteria.assetType}
parseResult={parseResult}
needTitle={needTitle}
overallConfidence={overallConfidence}
isSaving={step === NeedBuilderStep.SAVING}
isAnonymous={isAnonymous}
onNeedTitleChange={setNeedTitle}
onAnonymousChange={setIsAnonymous}
onBack={() => setStep(NeedBuilderStep.IDLE)}
onSave={handleSaveProfile}
/>
</Box>
<AISearchActionBar
canProceed={canProceed}
isSearching={isProcessing && intent === 'search'}
isSavingProfile={isProcessing && intent === 'save-profile'}
onSearch={() => handleAction('search')}
onSaveProfile={() => handleAction('save-profile')}
/>
)}
{step === NeedBuilderStep.ERROR && (
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
)}
</Box>
)}
</>
)}
{/* Save preview step */}
{isSaveStep && parseResult && editedCriteria && (
<AISearchSavePreview
criteria={editedCriteria}
weights={weights}
parseResult={parseResult}
needTitle={needTitle}
overallConfidence={overallConfidence}
isSaving={step === NeedBuilderStep.SAVING}
isAnonymous={isAnonymous}
onNeedTitleChange={setNeedTitle}
onAnonymousChange={setIsAnonymous}
onBack={() => setStep(NeedBuilderStep.IDLE)}
onSave={handleSaveProfile}
/>
)}
{/* Error */}
{step === NeedBuilderStep.ERROR && (
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
)}
</Box>
{tab === 1 && (
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<SavedProfilesTab onNewSearch={(id) => { setInternalPrefillId(id); setTab(0) }} />
</Box>
)}
</Box>
)
}
+2 -2
View File
@@ -134,8 +134,8 @@ export default function Anfragen() {
{/* ── Left panel ── */}
<Box
sx={{
width: { xs: mobileShowChat ? 0 : '100%', md: 320 },
minWidth: { md: 320 },
width: { xs: mobileShowChat ? 0 : '100%', md: 280, lg: 300, xl: 320 },
minWidth: { md: 280, lg: 300, xl: 320 },
flexShrink: 0,
display: 'flex', flexDirection: 'column',
borderRight: `1px solid ${DS_BORDER.default}`,
+28 -3
View File
@@ -8,7 +8,7 @@ import { useInquiryStore } from '../../stores/inquiryStore'
import { AddToPipelineDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import { getCityIntelligence, lookupCityCoords } from '../../lib/locationIntelligence'
import { NextActionsPanel, FitOutCostPanel } from '../../components/match-detail'
import { NextActionsPanel, FitOutCostPanel, FitOutAdvicePanel, MarketPricePanel } from '../../components/match-detail'
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
import { useMatchDetailData } from '../../hooks/useMatchDetailData'
import { useMatchDetail } from '../../hooks/useMatches'
@@ -39,6 +39,7 @@ export default function MatchDetail() {
const { openInquiryDialog } = useInquiryStore()
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
const detailUnit = property?.units?.find(u => u.id === match?.unitId)
if (isLoading) {
return (
@@ -171,7 +172,7 @@ export default function MatchDetail() {
/>
{/* ── Main content ── */}
<Box sx={{ maxWidth: { sm: 780, md: 960, lg: 1100 }, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ maxWidth: { sm: 700, md: 860, lg: 1000, xl: 1100 }, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* ── Section A: Warum dieser Match? ── */}
<Paper sx={{ p: 2.5 }}>
@@ -220,7 +221,31 @@ export default function MatchDetail() {
areaSqm={property.areaSqm}
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
rentPricePerSqm={property.rentPricePerSqm}
tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm ?? 0}
fitOutByLandlord={property.hardFacts.fitOutByLandlord}
/>
)}
{/* ── KI-Ausbauempfehlung: nur wenn Mieter ausbaut (SHELL/BASIC, kein Vermieter-Übernahme) ── */}
{!isFuture && property?.hardFacts?.fitOut
&& !property.hardFacts.fitOutByLandlord
&& (property.hardFacts.fitOut === 'SHELL' || property.hardFacts.fitOut === 'BASIC') && (
<FitOutAdvicePanel
fitOut={property.hardFacts.fitOut}
areaSqm={property.areaSqm}
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
rentPricePerSqm={property.rentPricePerSqm}
requiredFitOut={need?.requiredFitOut}
tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm}
/>
)}
{/* ── Marktpreis-Einschätzung (Angebot vs. Markt; bei Pre-Market inkl. Zukunftspreis) ── */}
{property?.location?.city && property.rentPricePerSqm > 0 && (
<MarketPricePanel
city={property.location.city}
assetType={property.assetType}
askingRentPerSqm={detailUnit?.rentPricePerSqm ?? property.rentPricePerSqm}
futureRentPerSqm={isFuture ? detailUnit?.expectedRentPerSqm : undefined}
/>
)}
+1 -1
View File
@@ -109,7 +109,7 @@ export default function Pipeline() {
return (
<Box
key={stage.key}
sx={{ minWidth: syncedSelected ? 190 : 230, maxWidth: syncedSelected ? 230 : 270, flexShrink: 0, display: 'flex', flexDirection: 'column' }}
sx={{ minWidth: syncedSelected ? 170 : 200, maxWidth: syncedSelected ? 210 : 260, flexShrink: 0, display: 'flex', flexDirection: 'column' }}
>
<Box sx={{ py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontWeight: 700, color: stage.color, fontSize: '0.8125rem' }}>
+1 -1
View File
@@ -425,7 +425,7 @@ export default function MyListings() {
anchor="right"
open={!!detailId}
onClose={() => setDetailId(null)}
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560 } } } }}
slotProps={{ paper: { sx: { width: { xs: '100%', sm: '90vw', md: 480, lg: 540, xl: 560 } } } }}
>
{detailId && (
<PropertyDetailView propertyId={detailId} onClose={() => setDetailId(null)} hideTabs={['Matchability', 'Marktsignale']} />
+1
View File
@@ -81,6 +81,7 @@ export default function NewListing() {
<TechnicalDetailsSection
floor={form.floor} onFloorChange={form.setFloor}
fitOut={form.fitOut} onFitOutChange={form.setFitOut}
fitOutByLandlord={form.fitOutByLandlord} onFitOutByLandlordChange={form.setFitOutByLandlord}
parking={form.parking} onParkingChange={form.setParking}
ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight}
mieterausbaubeitrag={form.mieterausbaubeitrag} onMieterausbaubeitragChange={form.setMieterausbaubeitrag}
+1 -1
View File
@@ -247,7 +247,7 @@ export default function Properties() {
anchor="right"
open={!!selectedId}
onClose={() => setSelectedId(null)}
slotProps={{ paper: { sx: { width: isMobile ? '100vw' : 650, boxShadow: '-4px 0 24px rgba(0,0,0,0.10)' } } }}
slotProps={{ paper: { sx: { width: isMobile ? '100vw' : { md: 520, lg: 580, xl: 640 }, boxShadow: '-4px 0 24px rgba(0,0,0,0.10)' } } }}
>
{selectedId && (
<PropertyDetailView propertyId={selectedId} onClose={() => setSelectedId(null)} />
+7 -4
View File
@@ -30,12 +30,14 @@ export const LEVEL_TO_SCORE: Record<string, number | undefined> = {
LOW: 0.30, MEDIUM: 0.55, HIGH: 0.85, '': undefined,
}
import { FIT_OUT_LABELS } from '../../lib/constants'
export const FIT_OUT_OPTIONS = [
{ value: '', label: 'Keine Angabe' },
{ value: 'SHELL', label: 'Rohbau' },
{ value: 'BASIC', label: 'Basis-Ausbau' },
{ value: 'FULL', label: 'Vollausbau' },
{ value: 'PREMIUM', label: 'Premiumausbau' },
{ value: 'SHELL', label: FIT_OUT_LABELS.SHELL },
{ value: 'BASIC', label: FIT_OUT_LABELS.BASIC },
{ value: 'FULL', label: FIT_OUT_LABELS.FULL },
{ value: 'PREMIUM', label: FIT_OUT_LABELS.PREMIUM },
]
export interface LocationState {
@@ -51,6 +53,7 @@ export interface LocationState {
propertyId?: string
floor?: string
fitOut?: string
fitOutByLandlord?: boolean
parking?: number
ceilingHeight?: number
mieterausbaubeitragPerSqm?: number
+5 -2
View File
@@ -15,6 +15,7 @@ export function buildCreatePropertyInput(fields: {
softLevels: Record<string, string>
floor: string
fitOut: string
fitOutByLandlord?: boolean
parking: string
ceilingHeight: string
images: string[]
@@ -26,7 +27,7 @@ export function buildCreatePropertyInput(fields: {
const {
assetType, street, houseNumber, postalCode, city,
areaSqm, rentPerSqm, availableFrom, description,
softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
softLevels, floor, fitOut, fitOutByLandlord, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm,
} = fields
@@ -45,7 +46,9 @@ export function buildCreatePropertyInput(fields: {
const hf = {
floor: floor ? parseInt(floor) : undefined,
fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined,
mieterausbaubeitragPerSqm: mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
fitOutByLandlord,
// MAB nur relevant, wenn der Mieter ausbaut — sonst nicht speichern
mieterausbaubeitragPerSqm: !fitOutByLandlord && mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
parking: parking ? parseInt(parking) : undefined,
ceilingHeightM: ceilingHeight ? parseFloat(ceilingHeight) : undefined,
}
+2
View File
@@ -22,5 +22,7 @@ export type {
DataQualityInput,
DataQualitySummary,
MarketSignalClassification,
FitOutAdvice,
FitOutAdviceInput,
} from './ai/IAIService'
export { parseListingText } from './ai/mock/listingParser'
+14 -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,13 +72,16 @@ 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)
@@ -101,7 +105,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))
+2
View File
@@ -17,6 +17,7 @@ interface LayoutState {
// Actions
setActiveWorkspace: (workspace: WorkspaceType) => void
toggleSidebar: () => void
setSidebarCollapsed: (collapsed: boolean) => void
openRightPanel: (type: RightPanelContentType) => void
closeRightPanel: () => void
toggleRightPanel: (type: RightPanelContentType) => void
@@ -30,6 +31,7 @@ export const useLayoutStore = create<LayoutState>((set, get) => ({
setActiveWorkspace: (workspace) => set({ activeWorkspace: workspace }),
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
openRightPanel: (type) => set({ isRightPanelOpen: true, rightPanelContentType: type }),
closeRightPanel: () => set({ isRightPanelOpen: false, rightPanelContentType: null }),
toggleRightPanel: (type) => {