feat: collapsible filter bar, compact property cards, offer wizard field selection

- Properties page: collapsible decision/filter strip (localStorage state), slim
  single-row Datenpflege status bar (chips + button), 4-column card grid
- PropertyIntelligenceCard: reduced image height (160→120px), tighter body padding
- OfferWizard (latente Anfragen): new "Felder wählen" step between property
  selection and PDF preview; uses ReportObjectFieldSelector per property
- MockPdfPreview: adds second PDF page showing selected properties side by side
  with photo, title, location and all chosen hard/soft fact fields
- offerWizardStore: adds select_fields step type and fieldSelections state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-26 15:36:32 +02:00
parent 36570c5bdc
commit a825672197
8 changed files with 486 additions and 141 deletions
@@ -1,18 +1,86 @@
import { Box, Typography } from '@mui/material'
import { FileText } from 'lucide-react'
import { Box, Divider, Typography } from '@mui/material'
import { Building2, FileText, MapPin } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { mockProperties } from '../../mock-data/properties'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import type { Property } from '../../domain/property'
interface MockPdfPreviewProps {
fields: Record<string, string>
fallbackFields: Record<string, string>
fieldSelections: ReportObjectFieldSelection[]
}
export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps) {
const FIELD_LABELS: Partial<Record<ReportObjectFieldKey, string>> = {
areaSqm: 'Fläche',
rentPricePerSqm: 'Mietpreis',
availabilityDate: 'Verfügbar ab',
leaseTerm: 'Mietlaufzeit',
breakoutOption: 'Breakout-Option',
currentTenant: 'Aktueller Mieter',
propertyNumber: 'Objektnummer',
assetType: 'Asset Type',
floor: 'Stockwerk',
parking: 'Parkplätze',
fitOut: 'Ausbaugrad',
isBarrierFree: 'Barrierefrei',
ceilingHeightM: 'Raumhöhe',
floorLoad: 'Bodenlast',
loadingDocksCount: 'Anlieferung',
goodsLift: 'Warenaufzug',
passengerLift: 'Personenaufzug',
powerSupplyKva: 'Stromanschluss',
hasServerRoom: 'Serverraum',
internet: 'Internet',
deliveryAccess: 'Zufahrt',
publicTransportScore: 'ÖV-Anbindung',
prestigeScore: 'Prestige',
visibilityScore: 'Sichtbarkeit',
footfallScore: 'Passantenfrequenz',
commuterAccessScore: 'Pendlererreichbarkeit',
talentAccessScore: 'Talent Access',
esgScore: 'ESG',
flexibilityScore: 'Flexibilität',
expansionPotentialScore: 'Expansionspotenzial',
taxEnvironmentScore: 'Steuerumfeld',
microLocation: 'Mikrostandort',
competitionEnvironment: 'Konkurrenzumfeld',
infrastructure: 'Infrastruktur',
description: 'Beschreibung',
}
function getFieldValue(property: Property, key: ReportObjectFieldKey): string | null {
switch (key) {
case 'areaSqm': return property.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')}` : null
case 'rentPricePerSqm': return property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/J` : null
case 'availabilityDate': return property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH') : null
case 'leaseTerm': return property.leaseTerm ?? null
case 'breakoutOption': return property.breakoutOption != null ? (property.breakoutOption ? 'Ja' : 'Nein') : null
case 'currentTenant': return property.currentTenant ?? null
case 'propertyNumber': return property.propertyNumber ?? null
case 'assetType': return property.assetType ?? null
case 'floor': return property.floorLevel != null ? String(property.floorLevel) : null
case 'parking': return property.softFactors?.parkingSpots != null ? `${property.softFactors.parkingSpots} Pl.` : null
case 'ceilingHeightM': return property.hardFacts?.ceilingHeightM != null ? `${property.hardFacts.ceilingHeightM} m` : null
case 'loadingDocksCount': return property.hardFacts?.loadingDocksCount != null ? String(property.hardFacts.loadingDocksCount) : null
case 'powerSupplyKva': return property.hardFacts?.powerSupplyKva != null ? `${property.hardFacts.powerSupplyKva} kVA` : null
case 'hasServerRoom': return property.hardFacts?.hasServerRoom != null ? (property.hardFacts.hasServerRoom ? 'Ja' : 'Nein') : null
case 'isBarrierFree': return property.hardFacts?.isBarrierFree != null ? (property.hardFacts.isBarrierFree ? 'Ja' : 'Nein') : null
case 'fitOut': return property.hardFacts?.fitOut ?? null
case 'publicTransportScore': return property.softFactors?.publicTransportMinutes != null ? `${property.softFactors.publicTransportMinutes} Min.` : null
case 'prestigeScore': return property.softFactors?.prestige != null ? `${property.softFactors.prestige}/100` : null
case 'visibilityScore': return property.softFactors?.visibilityScore != null ? `${property.softFactors.visibilityScore}/100` : null
case 'description': return property.description ?? null
default: return null
}
}
export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: MockPdfPreviewProps) {
const needTitle = useOfferWizardStore(s => s.needTitle)
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
const properties = mockProperties.filter(p => propertyIds.includes(p.id))
const hasPropertyPage = fieldSelections.length > 0 && properties.length > 0
const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
@@ -29,6 +97,7 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
fontFamily: '"Georgia", "Times New Roman", serif',
}}
>
{/* ── Page 1 — Offer letter ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
<FileText size={16} />
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
@@ -90,6 +159,93 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
<Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
{v('closing')}
</Typography>
{/* ── Page 2 — Property details side by side ── */}
{hasPropertyPage && (
<>
<Box sx={{ my: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}>
Seite 2 Objektdetails
</Typography>
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: `repeat(${Math.min(properties.length, 3)}, 1fr)`,
gap: 1.5,
}}
>
{properties.map(p => {
const selection = fieldSelections.find(fs => fs.propertyId === p.id)
const optionalFields = selection?.selectedOptionalFields ?? []
const image = p.images?.[0]
return (
<Box
key={p.id}
sx={{
border: '1px solid #e2e8f0',
borderRadius: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Photo */}
{image ? (
<Box
component="img"
src={image}
alt={p.title}
sx={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }}
/>
) : (
<Box sx={{ height: 90, bgcolor: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Building2 size={22} color="#94a3b8" />
</Box>
)}
{/* Info */}
<Box sx={{ p: 1.25, flex: 1 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.78rem', color: '#0f172a', lineHeight: 1.3, mb: 0.4 }}>
{p.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3, mb: 1 }}>
<MapPin size={10} color="#94a3b8" />
<Typography sx={{ fontSize: '0.68rem', color: '#64748b' }}>
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
</Typography>
</Box>
{/* Selected fields */}
{optionalFields.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
{optionalFields.map(key => {
const val = getFieldValue(p, key)
if (!val) return null
return (
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', gap: 0.5 }}>
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8' }}>
{FIELD_LABELS[key] ?? key}
</Typography>
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, color: '#0f172a', textAlign: 'right' }}>
{val}
</Typography>
</Box>
)
})}
</Box>
)}
</Box>
</Box>
)
})}
</Box>
</>
)}
</Box>
)
}
@@ -0,0 +1,87 @@
import { useEffect } from 'react'
import { Box, Button, Stack, Typography } from '@mui/material'
import { ArrowLeft } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useProperties } from '../../hooks/useProperties'
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
const DEFAULT_FIELDS: ReportObjectFieldKey[] = ['areaSqm', 'rentPricePerSqm', 'availabilityDate']
const MANDATORY_FIELDS: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
export function OfferFieldSelectionStep() {
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
const setFieldSelections = useOfferWizardStore(s => s.setFieldSelections)
const updateFieldSelection = useOfferWizardStore(s => s.updateFieldSelection)
const setStep = useOfferWizardStore(s => s.setStep)
const { data: allProperties = [] } = useProperties()
const selectedProperties = allProperties.filter(p => propertyIds.includes(p.id))
// Initialise selections when properties are loaded or change
useEffect(() => {
if (selectedProperties.length === 0) return
const existing = new Set(fieldSelections.map(fs => fs.propertyId))
const missing = selectedProperties.filter(p => !existing.has(p.id))
if (missing.length > 0) {
setFieldSelections([
...fieldSelections,
...missing.map(p => ({
propertyId: p.id,
mandatoryFields: MANDATORY_FIELDS,
selectedOptionalFields: DEFAULT_FIELDS,
})),
])
}
// run only when property list or selections change length
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedProperties.length, fieldSelections.length])
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ flex: 1, overflowY: 'auto', p: 3 }}>
<Typography variant="body2" sx={{ color: '#64748b', mb: 2.5 }}>
Wählen Sie die Datenfelder, die auf der Objektseite des Angebots angezeigt werden sollen.
Pflichtfelder (Titel, Standort, Foto) sind immer enthalten.
</Typography>
<Stack spacing={2}>
{selectedProperties.map(p => {
const selection: ReportObjectFieldSelection =
fieldSelections.find(fs => fs.propertyId === p.id) ?? {
propertyId: p.id,
mandatoryFields: MANDATORY_FIELDS,
selectedOptionalFields: DEFAULT_FIELDS,
}
return (
<ReportObjectFieldSelector
key={p.id}
propertyTitle={p.title}
value={selection}
onChange={v => updateFieldSelection(p.id, v)}
/>
)
})}
</Stack>
</Box>
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
<Button
startIcon={<ArrowLeft size={14} />}
onClick={() => setStep('select_properties')}
sx={{ textTransform: 'none' }}
>
Zurück
</Button>
<Button
variant="contained"
onClick={() => setStep('pdf_review')}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
</Box>
</Box>
)
}
@@ -66,6 +66,8 @@ export function OfferPdfReviewStep() {
}
}
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
const needTitle = useOfferWizardStore(s => s.needTitle)
const addAttachment = useOfferWizardStore(s => s.addAttachment)
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
@@ -128,7 +130,7 @@ export function OfferPdfReviewStep() {
</Typography>
</Box>
) : (
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} />
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} fieldSelections={fieldSelections} />
)}
</Box>
@@ -70,7 +70,7 @@ export function OfferPropertySelectionStep() {
return
}
setOfferDraftId(res.data.id)
setStep('pdf_review')
setStep('select_fields')
}
if (!need) {
@@ -12,22 +12,25 @@ import {
import { X } from 'lucide-react'
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
import { OfferFieldSelectionStep } from './OfferFieldSelectionStep'
import { OfferPdfReviewStep } from './OfferPdfReviewStep'
import { OfferCheckedAction } from './OfferCheckedAction'
import { OfferChatComposer } from './OfferChatComposer'
// 'checked' is internal — merged into pdf_review step; only 3 steps shown
// 'checked' is internal — merged into pdf_review step; 4 visible steps
const STEPS: { key: OfferStep; label: string }[] = [
{ key: 'select_properties', label: 'Objekte wählen' },
{ key: 'select_fields', label: 'Felder wählen' },
{ key: 'pdf_review', label: 'Vorschau & Prüfen' },
{ key: 'send', label: 'Senden' },
]
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
select_properties: 0,
pdf_review: 1,
checked: 1, // same visual step as pdf_review
send: 2,
select_fields: 1,
pdf_review: 2,
checked: 2, // same visual step as pdf_review
send: 3,
}
export function OfferWizard() {
@@ -114,6 +117,7 @@ export function OfferWizard() {
{/* Step content */}
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{currentStep === 'select_properties' && <OfferPropertySelectionStep />}
{currentStep === 'select_fields' && <OfferFieldSelectionStep />}
{currentStep === 'pdf_review' && <OfferPdfReviewStep />}
{currentStep === 'checked' && <OfferCheckedAction />}
{currentStep === 'send' && <OfferChatComposer />}
@@ -1,6 +1,6 @@
import { memo } from 'react'
import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material'
import { LocationPreview } from '../shared/LocationPreview'
import { Box, Chip, LinearProgress, Typography } from '@mui/material'
import { MapPin, Maximize2, TrendingUp, Calendar } from 'lucide-react'
import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
import type { Property } from '../../domain/property'
@@ -9,133 +9,176 @@ interface Props {
onSelect: (id: string) => void
}
function availabilityBadgeColor(status: string): string {
if (status === 'AVAILABLE_NOW') return '#1a7a4a'
if (status === 'AVAILABLE_SOON') return '#d97706'
return '#64748b'
function availabilityColor(status: string) {
if (status === 'AVAILABLE_NOW') return { bg: '#dcfce7', text: '#15803d' }
if (status === 'AVAILABLE_SOON') return { bg: '#fef9c3', text: '#a16207' }
return { bg: '#f1f5f9', text: '#64748b' }
}
export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({ property: p, onSelect }: Props) {
const confPct = Math.round(p.confidenceScore * 100)
const confColor = p.confidenceScore >= 0.75 ? '#1a7a4a' : p.confidenceScore >= 0.55 ? '#d97706' : '#c0392b'
const confColor = p.confidenceScore >= 0.75 ? '#15803d' : p.confidenceScore >= 0.55 ? '#d97706' : '#dc2626'
const annualRent = Math.round(p.areaSqm * p.rentPricePerSqm / 1000)
const avail = availabilityColor(p.availabilityStatus)
const assetColor = getAssetTypeColor(p.assetType)
const hasImage = !!p.images?.[0]
return (
<Box
onClick={() => onSelect(p.id)}
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '1px solid #e8d5c4',
background: 'linear-gradient(160deg,#fdf8f3,#faf0e6)',
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
border: '1px solid #e2e8f0',
bgcolor: 'white',
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
display: 'flex',
flexDirection: 'column',
transition: 'box-shadow 0.15s, transform 0.1s',
cursor: 'pointer',
transition: 'box-shadow 0.15s, transform 0.12s',
'&:hover': {
boxShadow: '0 6px 24px rgba(0,0,0,0.10)',
transform: 'translateY(-1px)',
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
transform: 'translateY(-2px)',
borderColor: '#cbd5e1',
},
}}
onClick={() => onSelect(p.id)}
>
{/* Image zone */}
<Box sx={{ position: 'relative' }}>
<LocationPreview
imageUrl={p.images?.[0]}
lat={p.location.coordinates?.lat}
lng={p.location.coordinates?.lng}
address={`${p.address.street} ${p.address.houseNumber}, ${p.address.city}`}
cityLabel={p.location.city}
height={175}
{/* ── Image / header ── */}
<Box sx={{ position: 'relative', height: 120, flexShrink: 0, bgcolor: '#f1f5f9', overflow: 'hidden' }}>
{hasImage ? (
<Box
component="img"
src={p.images![0]}
alt={p.title}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{/* Availability badge */}
<Box sx={{ position: 'absolute', top: 10, left: 10 }}>
<Chip
label={getAvailabilityLabel(p.availabilityStatus)}
size="small"
) : (
<Box
sx={{
bgcolor: availabilityBadgeColor(p.availabilityStatus),
color: 'white',
fontWeight: 700,
fontSize: 10,
height: 20,
width: '100%', height: '100%',
background: `linear-gradient(135deg, ${assetColor}22 0%, ${assetColor}44 100%)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
/>
>
<Typography sx={{ fontSize: '2.5rem', opacity: 0.3 }}>🏢</Typography>
</Box>
)}
{/* Asset type chip */}
<Box sx={{ position: 'absolute', top: 10, right: 44 }}>
{/* Overlay gradient */}
<Box sx={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.45) 0%, transparent 55%)' }} />
{/* Top badges */}
<Box sx={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 0.5 }}>
<Chip
label={getAssetTypeLabel(p.assetType)}
size="small"
sx={{
bgcolor: getAssetTypeColor(p.assetType),
color: 'white',
fontWeight: 600,
fontSize: 10,
height: 20,
}}
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700, bgcolor: assetColor, color: 'white', borderRadius: 1 }}
/>
<Chip
label={getAvailabilityLabel(p.availabilityStatus)}
size="small"
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700, bgcolor: avail.bg, color: avail.text, borderRadius: 1 }}
/>
</Box>
{/* Bottom: annual rent on image */}
<Box sx={{ position: 'absolute', bottom: 8, left: 10, right: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<Box>
<Typography sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.65rem', lineHeight: 1 }}>Jahresmiete ca.</Typography>
<Typography sx={{ color: 'white', fontWeight: 800, fontSize: '1.05rem', lineHeight: 1.2 }}>
CHF {annualRent}k
</Typography>
</Box>
<Typography sx={{ color: 'rgba(255,255,255,0.9)', fontSize: '0.72rem', fontWeight: 600 }}>
{p.rentPricePerSqm} /m²/J
</Typography>
</Box>
</Box>
{/* Card body */}
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 0, flex: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
{/* ── Card body ── */}
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: 1, flex: 1 }}>
{/* Title + location */}
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: '#0f172a', fontSize: '0.875rem' }} noWrap>
{p.title}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, mb: 1.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.3 }}>
<MapPin size={11} color="#94a3b8" />
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.72rem' }}>
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
</Typography>
</Box>
</Box>
{/* Key specs */}
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.25 }}>
<Chip label={`${p.areaSqm}`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
<Chip label={`CHF ${p.rentPricePerSqm}/m²/Jahr`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
{p.contractDurationMonths && (
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
{/* Key metrics row */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
<Maximize2 size={10} color="#64748b" />
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Fläche</Typography>
</Box>
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
{p.areaSqm.toLocaleString('de-CH')} m²
</Typography>
</Box>
{p.availabilityDate && (
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
<Calendar size={10} color="#64748b" />
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Ab</Typography>
</Box>
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
{new Date(p.availabilityDate).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })}
</Typography>
</Box>
)}
{p.softFactors?.prestige != null && (
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
<TrendingUp size={10} color="#64748b" />
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Prestige</Typography>
</Box>
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
{p.softFactors.prestige}
</Typography>
</Box>
)}
</Box>
{/* Soft factors */}
{/* Soft factor chips */}
{p.softFactors && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.25 }}>
{p.softFactors.prestige != null && (
<Chip label={`Prestige ${p.softFactors.prestige}`} size="small"
sx={{ fontSize: 10, bgcolor: 'rgba(30,58,95,0.08)', color: '#1e3a5f', height: 20 }} />
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{p.softFactors.publicTransportMinutes != null && (
<Chip label={`🚇 ${p.softFactors.publicTransportMinutes} min ÖV`} size="small"
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f0f9ff', color: '#0369a1' }} />
)}
{p.softFactors.accessibility != null && (
<Chip label={`ÖV ${p.softFactors.publicTransportMinutes ?? p.softFactors.accessibility}min`} size="small"
sx={{ fontSize: 10, bgcolor: 'rgba(30,58,95,0.08)', color: '#1e3a5f', height: 20 }} />
{p.hardFacts?.fitOut && (
<Chip label={p.hardFacts.fitOut} size="small"
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f5f3ff', color: '#6d28d9' }} />
)}
{p.contractDurationMonths && (
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f1f5f9', color: '#475569' }} />
)}
</Box>
)}
{/* Confidence */}
<Box sx={{ mt: 'auto', pt: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">Datenkonfidenz</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: confColor }}>{confPct}%</Typography>
{/* Confidence bar */}
<Box sx={{ mt: 'auto' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography sx={{ fontSize: '0.67rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Datenkonfidenz</Typography>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: confColor }}>{confPct}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={confPct}
sx={{
height: 5, borderRadius: 3, bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': { bgcolor: confColor },
height: 4, borderRadius: 2, bgcolor: '#f1f5f9',
'& .MuiLinearProgress-bar': { bgcolor: confColor, borderRadius: 2 },
}}
/>
</Box>
<Button
size="small"
variant="text"
onClick={e => { e.stopPropagation(); onSelect(p.id) }}
sx={{ mt: 1.25, alignSelf: 'flex-end', textTransform: 'none', fontSize: '0.75rem', color: '#1e3a5f', px: 0 }}
>
Details anzeigen
</Button>
</Box>
</Box>
)
+83 -42
View File
@@ -1,8 +1,8 @@
import { useState, useMemo } from 'react'
import { Box, Drawer, useMediaQuery, useTheme } from '@mui/material'
import { Box, Button, Chip, Collapse, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
import { useNavigate } from 'react-router'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { PageHeader } from '../../components/layout'
import { DecisionContextPanel } from '../../components/ui'
import { ViewToggle } from '../../components/shared'
import { useProperties } from '../../hooks/useProperties'
import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply'
@@ -56,6 +56,9 @@ export default function Properties() {
const [view, setView] = useState<'list' | 'grid'>(() =>
(localStorage.getItem('view-properties') as 'list' | 'grid') ?? 'list'
)
const [headerOpen, setHeaderOpen] = useState(() =>
localStorage.getItem('props-header-open') !== 'false'
)
const { data: properties = [], isLoading, isError } = useProperties()
@@ -103,54 +106,92 @@ export default function Properties() {
}
/>
{/* Collapse toggle strip */}
<Box
onClick={() => {
const next = !headerOpen
setHeaderOpen(next)
localStorage.setItem('props-header-open', String(next))
}}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 3,
py: 0.5,
bgcolor: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
cursor: 'pointer',
flexShrink: 0,
userSelect: 'none',
'&:hover': { bgcolor: '#f1f5f9' },
}}
>
<Typography sx={{ fontSize: '0.68rem', color: '#94a3b8', letterSpacing: 0.5, textTransform: 'uppercase' }}>
Filter & Übersicht
</Typography>
{headerOpen ? <ChevronUp size={12} color="#94a3b8" /> : <ChevronDown size={12} color="#94a3b8" />}
</Box>
<Collapse in={headerOpen}>
{!isLoading && properties.length > 0 && (
<DecisionContextPanel
decision="Welche Objekte sind matchbereit — und wo blockieren Datenlücken Matches?"
context="Objekte mit fehlenden Pflichtfeldern oder Konfidenz < 55% erscheinen im Match-Center nicht oder mit niedrigem Rang."
metrics={[
{ label: 'matchbereit', value: matchReady.length, severity: matchReady.length > 0 ? 'positive' : 'warning' },
...(criticalGaps.length > 0
? [{ label: 'kritische Datenlücken', value: criticalGaps.length, severity: 'critical' as const }]
: []
),
...(lowConfidence.length > 0
? [{ label: 'Konfidenz < 55%', value: lowConfidence.length, severity: 'warning' as const }]
: []
),
...(staleOrOutdated.length > 0
? [{ label: 'veraltete Daten', value: staleOrOutdated.length, severity: 'warning' as const }]
: []
),
]}
missing={allMissingFields.length > 0
? [`Fehlende Pflichtfelder bei ${criticalGaps.length} Objekten: ${allMissingFields.join(', ')}`]
: []
}
risks={[
...(criticalGaps.length > 0
? [`${criticalGaps.length} Objekte werden potenziellen Mietern nicht angezeigt`]
: []
),
...(staleOrOutdated.length > 0
? [`${staleOrOutdated.length} Objekte mit veralteten Preisen oder Verfügbarkeiten`]
: []
),
]}
actions={[
{
label: 'Datenpflege starten',
primary: criticalGaps.length > 0 || staleOrOutdated.length > 0,
onClick: () => navigate('/supply/data-quality'),
},
]}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2.5,
py: 0.75,
bgcolor: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
borderLeft: '3px solid #1e3a5f',
flexShrink: 0,
gap: 1,
}}
>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip
label={`${matchReady.length} matchbereit`}
size="small"
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600,
bgcolor: matchReady.length > 0 ? '#f0fdf4' : '#fef9c3',
color: matchReady.length > 0 ? '#1a7a4a' : '#92400e' }}
/>
{criticalGaps.length > 0 && (
<Chip
label={`${criticalGaps.length} kritische Datenlücken`}
size="small"
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#fef2f2', color: '#991b1b' }}
/>
)}
{staleOrOutdated.length > 0 && (
<Chip
label={`${staleOrOutdated.length} veraltete Daten`}
size="small"
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#fef9c3', color: '#92400e' }}
/>
)}
</Box>
<Button
size="small"
variant={criticalGaps.length > 0 || staleOrOutdated.length > 0 ? 'contained' : 'outlined'}
onClick={() => navigate('/supply/data-quality')}
sx={criticalGaps.length > 0 || staleOrOutdated.length > 0
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.5, flexShrink: 0 }
: { textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.5, flexShrink: 0 }
}
>
Datenpflege
</Button>
</Box>
)}
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
</Collapse>
<Box sx={{ flex: 1, overflowY: 'auto' }}>
{view === 'grid' ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2, p: 2 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 1.5, p: 1.5 }}>
{filtered.map(p => (
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} />
))}
+13 -1
View File
@@ -1,7 +1,8 @@
import { create } from 'zustand'
import type { Attachment } from '../domain/inquiry'
import type { ReportObjectFieldSelection } from '../domain/inquiryReport'
export type OfferStep = 'select_properties' | 'pdf_review' | 'checked' | 'send'
export type OfferStep = 'select_properties' | 'select_fields' | 'pdf_review' | 'checked' | 'send'
interface OfferWizardState {
isOpen: boolean
@@ -11,6 +12,7 @@ interface OfferWizardState {
currentStep: OfferStep
offerDraftId: string | null
editableFields: Record<string, string>
fieldSelections: ReportObjectFieldSelection[]
pdfPreviewReady: boolean
messageDraft: string
messageSubject: string
@@ -22,6 +24,8 @@ interface OfferWizardState {
setStep(step: OfferStep): void
setOfferDraftId(id: string): void
updateField(fieldId: string, value: string): void
setFieldSelections(selections: ReportObjectFieldSelection[]): void
updateFieldSelection(propertyId: string, selection: ReportObjectFieldSelection): void
setPdfReady(): void
setMessageDraft(text: string): void
setMessageSubject(s: string): void
@@ -38,6 +42,7 @@ export const useOfferWizardStore = create<OfferWizardState>((set) => ({
currentStep: 'select_properties',
offerDraftId: null,
editableFields: {},
fieldSelections: [],
pdfPreviewReady: false,
messageDraft: '',
messageSubject: '',
@@ -51,6 +56,7 @@ export const useOfferWizardStore = create<OfferWizardState>((set) => ({
currentStep: 'select_properties',
offerDraftId: null,
editableFields: {},
fieldSelections: [],
pdfPreviewReady: false,
messageDraft: '',
messageSubject: '',
@@ -68,6 +74,11 @@ export const useOfferWizardStore = create<OfferWizardState>((set) => ({
setOfferDraftId: (id) => set({ offerDraftId: id }),
updateField: (fieldId, value) =>
set((s) => ({ editableFields: { ...s.editableFields, [fieldId]: value } })),
setFieldSelections: (fieldSelections) => set({ fieldSelections }),
updateFieldSelection: (propertyId, selection) =>
set((s) => ({
fieldSelections: s.fieldSelections.map(fs => fs.propertyId === propertyId ? selection : fs),
})),
setPdfReady: () => set({ pdfPreviewReady: true }),
setMessageDraft: (messageDraft) => set({ messageDraft }),
setMessageSubject: (messageSubject) => set({ messageSubject }),
@@ -83,6 +94,7 @@ export const useOfferWizardStore = create<OfferWizardState>((set) => ({
currentStep: 'select_properties',
offerDraftId: null,
editableFields: {},
fieldSelections: [],
pdfPreviewReady: false,
messageDraft: '',
messageSubject: '',