feat: Reale Jahresbelastung, must-have fixes, fitOut data coverage
Reale Jahresbelastung (Change 6): - FitOutCostPanel zeigt Jahresmiete + amortisierte Ausbaukosten für alle fitOut-Werte - FULL/PREMIUM: Ausbau CHF 0 (bezugsfertig), SHELL/BASIC: CRB/BKP-Richtwerte amortisiert über 5 J. - Match-Card-Chip zeigt geschätzte Investition (orange wenn >100k) - FitOutInvestment-Typ + calcFitOutInvestment() in fitOutUtils.ts - BackendAIService + MockAIService mit generateFitOutAdvice (IAIService-Interface) Must-have Kriterien (Nicht prüfbar Fix): - ÖV-Anbindung: Minutengrenze aus Freitext extrahiert, gegen publicTransportMinutes geprüft - Mindestfläche: m²-Wert aus Freitext extrahiert, gegen areaSqm geprüft - Ausbaugrad: neues Keyword-Rule für FULL/PREMIUM fitOut-Datenpflege: - fitOut-Werte zu 32 fehlenden Properties ergänzt (Logistik=SHELL, Standard=BASIC, Modern=FULL) - MAB-Werte (200/150/250 CHF/m²) zu 3 BASIC-Objekten hinzugefügt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Box, Button, Divider, LinearProgress, TextField, Typography } from '@mui/material'
|
||||
import { Box, Button, Divider, LinearProgress, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { ExternalLink, FileText } from 'lucide-react'
|
||||
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
||||
import { PropertyMap } from '../shared'
|
||||
@@ -179,15 +179,59 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
|
||||
|
||||
{/* Object details */}
|
||||
<SectionTitle title="Objekt & Lage" />
|
||||
<FieldGrid>
|
||||
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
|
||||
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
|
||||
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
|
||||
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} />
|
||||
<Field label="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
|
||||
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
|
||||
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
|
||||
</FieldGrid>
|
||||
{editing ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 2 }}>
|
||||
<TextField
|
||||
select label="Ausbaustandard"
|
||||
size="small" fullWidth
|
||||
value={draft.hardFacts?.fitOut ?? p.hardFacts?.fitOut ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined } })}
|
||||
>
|
||||
{[
|
||||
{ value: '', label: 'Keine Angabe' },
|
||||
{ value: 'SHELL', label: 'Rohbau' },
|
||||
{ value: 'BASIC', label: 'Basisausbau' },
|
||||
{ value: 'FULL', label: 'Vollausbau' },
|
||||
{ value: 'PREMIUM', label: 'Premiumausbau' },
|
||||
].map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Mieterausbaubeitrag (CHF/m²)"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
helperText="Beitrag des Vermieters zum Ausbau"
|
||||
value={draft.hardFacts?.mieterausbaubeitragPerSqm ?? p.hardFacts?.mieterausbaubeitragPerSqm ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined } })}
|
||||
/>
|
||||
<TextField
|
||||
label="Parkplätze"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
value={draft.hardFacts?.parking ?? p.hardFacts?.parking ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), parking: parseInt(e.target.value) || undefined } })}
|
||||
/>
|
||||
<TextField
|
||||
label="Deckenhöhe (m)"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { step: 0.1, min: 2 } }}
|
||||
value={draft.hardFacts?.ceilingHeightM ?? p.hardFacts?.ceilingHeightM ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), ceilingHeightM: parseFloat(e.target.value) || undefined } })}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<FieldGrid>
|
||||
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
|
||||
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
|
||||
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
|
||||
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} />
|
||||
{p.hardFacts?.mieterausbaubeitragPerSqm && (
|
||||
<Field label="Mieterausbaubeitrag" value={`CHF ${p.hardFacts.mieterausbaubeitragPerSqm}/m²`} />
|
||||
)}
|
||||
<Field label="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
|
||||
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
|
||||
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
|
||||
</FieldGrid>
|
||||
)}
|
||||
|
||||
{/* Map */}
|
||||
{p.location.coordinates && (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Tooltip, Typography } from '@mui/material'
|
||||
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { ChevronDown, ChevronUp, Layers, Users } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
|
||||
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
|
||||
import { useUpdateUnit } from '../../hooks/useProperties'
|
||||
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||
import { floorLabel } from './PropertyDetailHelpers'
|
||||
|
||||
@@ -27,6 +28,9 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
const navigate = useNavigate()
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [expandedUnit, setExpandedUnit] = useState<string | null>(null)
|
||||
const [editingFlexUnit, setEditingFlexUnit] = useState<string | null>(null)
|
||||
const [flexDraft, setFlexDraft] = useState<Record<string, number | undefined>>({})
|
||||
const updateUnit = useUpdateUnit(p.id)
|
||||
|
||||
const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
|
||||
|
||||
@@ -114,9 +118,65 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
{u.available ? (
|
||||
<>
|
||||
<Chip label="Frei" size="small" sx={{ height: 16, fontSize: '0.6rem', bgcolor: DS_SURFACE.success.bg, color: '#166534', border: `1px solid ${DS_SURFACE.success.border}` }} />
|
||||
{u.isFlexible && (
|
||||
<Chip label="Teilfläche möglich" size="small" sx={{ height: 16, fontSize: '0.58rem', bgcolor: DS_SURFACE.blue.bg, color: '#1d4ed8', border: `1px solid ${DS_SURFACE.blue.border}` }} />
|
||||
|
||||
{/* Teilbar toggle */}
|
||||
<Tooltip title={u.isFlexible ? 'Teilbar — Mindesteinheit bearbeiten' : 'Als teilbar markieren'}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={u.isFlexible ?? false}
|
||||
onChange={e => {
|
||||
const flexible = e.target.checked
|
||||
if (flexible) {
|
||||
setEditingFlexUnit(u.id)
|
||||
setFlexDraft(d => ({ ...d, [u.id]: u.minLettableSqm }))
|
||||
} else {
|
||||
updateUnit.mutate({ unitId: u.id, data: { isFlexible: false, minLettableSqm: undefined } })
|
||||
setEditingFlexUnit(null)
|
||||
}
|
||||
}}
|
||||
sx={{ '& .MuiSwitch-thumb': { width: 10, height: 10 }, '& .MuiSwitch-switchBase': { p: '4px' }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#1d4ed8' } }}
|
||||
/>
|
||||
<Typography sx={{ fontSize: '0.58rem', color: u.isFlexible ? '#1d4ed8' : DS_TEXT.disabled, fontWeight: u.isFlexible ? 700 : 400 }}>
|
||||
Teilbar
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
{/* Inline min-unit editor */}
|
||||
{editingFlexUnit === u.id && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
placeholder="Min m²"
|
||||
value={flexDraft[u.id] ?? ''}
|
||||
onChange={e => setFlexDraft(d => ({ ...d, [u.id]: parseInt(e.target.value) || undefined }))}
|
||||
sx={{ width: 72, '& .MuiInputBase-input': { fontSize: '0.68rem', py: 0.375, px: 0.75 } }}
|
||||
slotProps={{ htmlInput: { min: 10, max: u.areaSqm, step: 10 } }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
sx={{ fontSize: '0.58rem', py: 0.25, px: 0.75, minWidth: 0, bgcolor: '#1d4ed8', '&:hover': { bgcolor: '#1e40af' } }}
|
||||
onClick={() => {
|
||||
updateUnit.mutate({ unitId: u.id, data: { isFlexible: true, minLettableSqm: flexDraft[u.id] } })
|
||||
setEditingFlexUnit(null)
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
sx={{ fontSize: '0.58rem', py: 0.25, px: 0.5, minWidth: 0, color: DS_TEXT.muted }}
|
||||
onClick={() => setEditingFlexUnit(null)}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
|
||||
Reference in New Issue
Block a user