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:
@@ -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')} m²` : 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} m²`} 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>
|
||||
)
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
Reference in New Issue
Block a user