feat: F007 property repository & detail view
- useProperties: added usePropertyById, usePropertyMatches, usePropertySignals hooks - propertyService.getProperties(), matchService.getMatchesForProperty(), futureSignalService.getSignalsForProperty() added - PropertyFilterBar: asset type chips, availability select, sort select, search - PropertyTable: sortable columns, row selection highlight, loading skeletons, empty/error states, Eye/Plus/Bookmark actions - PropertyCard: decision-object card with header, primary, matchability, data quality and action zones; card states (selected, compareSelected, stale, low-confidence) - PropertyDetailView: 7-tab detail panel (Übersicht, Hard Facts, Soft Factors, Matchability, Datenqualität, Quelle, Signale) with low-quality banner, missing field UX, data update CTA - PropertyDetailSkeleton: loading state - Properties page: two-panel layout (table + sliding detail panel) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Tab,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { X, ExternalLink } from 'lucide-react'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { Match } from '../../domain/match'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
import { FreshnessStatus } from '../../domain/enums'
|
||||
import { usePropertyById, usePropertyMatches, usePropertySignals } from '../../hooks/useProperties'
|
||||
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
|
||||
import {
|
||||
getAssetTypeColor,
|
||||
getAssetTypeLabel,
|
||||
getAvailabilityChipColor,
|
||||
getAvailabilityLabel,
|
||||
getResultTypeColor,
|
||||
getResultTypeLabel,
|
||||
qualityColor,
|
||||
} from './propertyHelpers'
|
||||
|
||||
interface PropertyDetailViewProps {
|
||||
propertyId: string
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string | number | boolean | null }) {
|
||||
return (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{value !== undefined && value !== null && value !== '' ? (
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, mt: 0.25 }}>
|
||||
{typeof value === 'boolean' ? (value ? 'Ja' : 'Nein') : String(value)}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled" sx={{ mt: 0.25 }}>—</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGrid({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.5 }}>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ title }: { title: string }) {
|
||||
return (
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, mt: 0.5 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
function ScoreRow({ label, value }: { label: string; value?: number }) {
|
||||
if (value === undefined || value === null) {
|
||||
return (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
<Typography variant="caption" color="text.disabled">—</Typography>
|
||||
</Box>
|
||||
<LinearProgress variant="determinate" value={0} sx={{ height: 4, borderRadius: 2, opacity: 0.3 }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
const pct = value > 1 ? value : value * 100
|
||||
return (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>{Math.round(pct)}</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(100, pct)}
|
||||
color={pct >= 70 ? 'success' : pct >= 40 ? 'warning' : 'error'}
|
||||
sx={{ height: 4, borderRadius: 2 }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab panels ────────────────────────────────────────────────────────────────
|
||||
|
||||
function OverviewPanel({ p }: { p: Property }) {
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 2.5 }}>
|
||||
{[
|
||||
{ label: 'Fläche', value: `${p.areaSqm.toLocaleString('de-CH')} m²` },
|
||||
{ label: 'Miete/m²/Jahr', value: p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm}` : undefined },
|
||||
{ label: 'Verfügbar ab', value: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined },
|
||||
].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>
|
||||
<Typography variant="body1" sx={{ fontWeight: 700, mt: 0.25 }}>
|
||||
{value ?? <span style={{ color: '#94a3b8' }}>k.A.</span>}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{p.description && (
|
||||
<>
|
||||
<SectionTitle title="Beschreibung" />
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.6, color: 'text.secondary', mb: 2 }}>
|
||||
{p.description}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
<SectionTitle title="Datenqualität" />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" color="text.secondary">Score</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{Math.round(p.dataQuality.score * 100)}%</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={p.dataQuality.score * 100}
|
||||
color={qualityColor(p.dataQuality.score)}
|
||||
sx={{ height: 8, borderRadius: 4, mb: 1 }}
|
||||
/>
|
||||
{p.dataQuality.warnings.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{p.dataQuality.warnings.map((w, i) => (
|
||||
<Typography key={i} variant="caption" sx={{ display: 'block', color: 'warning.main' }}>⚠ {w}</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function HardFactsPanel({ p }: { p: Property }) {
|
||||
const hf = p.hardFacts
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle title="Standort & Objekt" />
|
||||
<FieldGrid>
|
||||
<Field label="Titel" value={p.title} />
|
||||
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
|
||||
<Field label="Strasse" value={`${p.address.street} ${p.address.houseNumber}`} />
|
||||
<Field label="PLZ / Stadt" value={`${p.address.postalCode} ${p.address.city}`} />
|
||||
<Field label="Kanton" value={p.location.canton} />
|
||||
<Field label="Region" value={p.location.region} />
|
||||
<Field label="Land" value={p.address.country} />
|
||||
<Field label="Nutzungsart" value={hf?.usageType} />
|
||||
</FieldGrid>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<SectionTitle title="Fläche & Miete" />
|
||||
<FieldGrid>
|
||||
<Field label="Fläche (m²)" value={`${p.areaSqm.toLocaleString('de-CH')} m²`} />
|
||||
<Field label="Fläche Min (m²)" value={p.areaSqmMin ? `${p.areaSqmMin.toLocaleString('de-CH')} m²` : undefined} />
|
||||
<Field label="Fläche Max (m²)" value={p.areaSqmMax ? `${p.areaSqmMax.toLocaleString('de-CH')} m²` : undefined} />
|
||||
<Field label="Miete/m²/Jahr" value={p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm}` : undefined} />
|
||||
<Field label="Jahresmiete (CHF/m²)" value={p.rentChfSqmYear ? `CHF ${p.rentChfSqmYear}` : undefined} />
|
||||
<Field label="Monatsmiete gesamt" value={p.totalRentMonthly ? `CHF ${p.totalRentMonthly.toLocaleString('de-CH')}` : undefined} />
|
||||
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
|
||||
<Field label="Vertragsdauer" value={p.contractDurationMonths ? `${p.contractDurationMonths} Monate` : undefined} />
|
||||
</FieldGrid>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<SectionTitle title="Verfügbarkeit" />
|
||||
<FieldGrid>
|
||||
<Field label="Status" value={getAvailabilityLabel(p.availabilityStatus)} />
|
||||
<Field label="Verfügbar ab" value={p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined} />
|
||||
<Field label="Verfügbarkeitstyp" value={p.availabilityType} />
|
||||
</FieldGrid>
|
||||
{hf && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<SectionTitle title="Technische Details" />
|
||||
<FieldGrid>
|
||||
<Field label="Etage" value={hf.floor ?? p.floorLevel} />
|
||||
<Field label="Ausbaustandard" value={hf.fitOut} />
|
||||
<Field label="Parkplätze" value={hf.parking} />
|
||||
<Field label="ÖV-Score" value={hf.publicTransportScore} />
|
||||
<Field label="Deckenhöhe (m)" value={hf.ceilingHeightM} />
|
||||
<Field label="Laderampen" value={hf.loadingDocksCount} />
|
||||
<Field label="Stromanschluss (kVA)" value={hf.powerSupplyKva} />
|
||||
<Field label="Serverraum" value={hf.hasServerRoom} />
|
||||
<Field label="Barrierefrei" value={hf.isBarrierFree} />
|
||||
</FieldGrid>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function SoftFactorsPanel({ p }: { p: Property }) {
|
||||
const sf = p.softFactors
|
||||
if (!sf) {
|
||||
return <Typography variant="body2" color="text.secondary">Keine Soft Factors vorhanden.</Typography>
|
||||
}
|
||||
const prestige = sf.prestigeScore ?? sf.prestige
|
||||
const visibility = sf.visibilityScore
|
||||
const footfall = sf.footfallScore
|
||||
const commuter = sf.commuterAccessScore ?? sf.accessibility
|
||||
const talent = sf.talentAccessScore ?? sf.talentAccess
|
||||
const esg = sf.esgScore
|
||||
const flex = sf.flexibilityScore
|
||||
const expansion = sf.expansionPotentialScore
|
||||
const tax = sf.taxEnvironmentScore
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle title="Weiche Faktoren" />
|
||||
<ScoreRow label="Prestige / Lage" value={prestige} />
|
||||
<ScoreRow label="Sichtbarkeit" value={visibility} />
|
||||
<ScoreRow label="Passantenfrequenz" value={footfall} />
|
||||
<ScoreRow label="Pendlerzugang" value={commuter} />
|
||||
<ScoreRow label="Talentpool-Zugang" value={talent} />
|
||||
<ScoreRow label="ESG-Score" value={esg} />
|
||||
<ScoreRow label="Flexibilität" value={flex} />
|
||||
<ScoreRow label="Expansionspotenzial" value={expansion} />
|
||||
<ScoreRow label="Steuerumfeld" value={tax} />
|
||||
{sf.parkingSpots !== undefined && (
|
||||
<Field label="Parkplätze" value={sf.parkingSpots} />
|
||||
)}
|
||||
{sf.publicTransportMinutes !== undefined && (
|
||||
<Field label="ÖV-Erreichbarkeit (Min.)" value={sf.publicTransportMinutes} />
|
||||
)}
|
||||
{sf.infrastructureNotes && (
|
||||
<Field label="Infrastruktur-Notizen" value={sf.infrastructureNotes} />
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function MatchabilityPanel({ matches }: { matches: Match[] }) {
|
||||
if (matches.length === 0) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine Matches für dieses Objekt vorhanden.
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle title={`${matches.length} Matches gefunden`} />
|
||||
{matches.map(m => (
|
||||
<Box key={m.id} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1, mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>Bedarf: {m.needId}</Typography>
|
||||
<Chip
|
||||
label={`Score ${m.matchScore}`}
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
bgcolor: m.matchScore >= 80 ? '#1a7a4a' : m.matchScore >= 60 ? '#d97706' : '#c0392b',
|
||||
color: 'white',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{m.positiveFactors?.slice(0, 3).map((f, i) => (
|
||||
<Typography key={i} variant="caption" sx={{ display: 'block', color: '#1a7a4a' }}>✓ {f.explanation ?? f.criterion}</Typography>
|
||||
))}
|
||||
{m.missingData && m.missingData.length > 0 && (
|
||||
<Typography variant="caption" sx={{ color: 'warning.main', display: 'block', mt: 0.5 }}>
|
||||
⚠ {m.missingData.length} fehlende Datenfelder
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function DataQualityPanel({ p }: { p: Property }) {
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle title="Datenqualität" />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" color="text.secondary">Gesamtscore</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{Math.round(p.dataQuality.score * 100)}%</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={p.dataQuality.score * 100}
|
||||
color={qualityColor(p.dataQuality.score)}
|
||||
sx={{ height: 8, borderRadius: 4, mb: 2 }}
|
||||
/>
|
||||
|
||||
{p.dataQuality.missingCriticalFields.length > 0 && (
|
||||
<>
|
||||
<SectionTitle title="Kritische fehlende Felder" />
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 2 }}>
|
||||
{p.dataQuality.missingCriticalFields.map(f => (
|
||||
<Chip key={f} label={f} size="small" color="error" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{p.dataQuality.missingOptionalFields.length > 0 && (
|
||||
<>
|
||||
<SectionTitle title="Optionale fehlende Felder" />
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 2 }}>
|
||||
{p.dataQuality.missingOptionalFields.map(f => (
|
||||
<Chip key={f} label={f} size="small" color="warning" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{p.dataQuality.warnings.length > 0 && (
|
||||
<>
|
||||
<SectionTitle title="Warnungen" />
|
||||
{p.dataQuality.warnings.map((w, i) => (
|
||||
<Alert key={i} severity="warning" sx={{ mb: 1, py: 0 }}>{w}</Alert>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Button variant="outlined" size="small" sx={{ textTransform: 'none' }}>
|
||||
Datenaktualisierung anfragen
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function SourcePanel({ p }: { p: Property }) {
|
||||
const freshnessColor = p.dataQuality.freshness === FreshnessStatus.FRESH
|
||||
? 'success'
|
||||
: p.dataQuality.freshness === FreshnessStatus.STALE
|
||||
? 'warning'
|
||||
: 'error'
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle title="Quellinformationen" />
|
||||
<FieldGrid>
|
||||
<Field label="Quelltyp" value={p.sourceType} />
|
||||
<Field label="Quellenbezeichnung" value={p.sourceLabel} />
|
||||
<Field label="Letzte Quellenaktualisierung" value={p.sourceUpdatedAt ? new Date(p.sourceUpdatedAt).toLocaleDateString('de-CH') : undefined} />
|
||||
<Field label="Letzte Verifikation" value={p.dataQuality.lastVerifiedAt ? new Date(p.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH') : undefined} />
|
||||
</FieldGrid>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">Frische:</Typography>
|
||||
<Chip label={p.dataQuality.freshness} size="small" color={freshnessColor as 'success' | 'warning' | 'error'} />
|
||||
</Box>
|
||||
{p.sourceUrl && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
endIcon={<ExternalLink size={13} />}
|
||||
href={p.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Originalquelle öffnen
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{p.sourceMeta && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<SectionTitle title="Provenance-Details" />
|
||||
<FieldGrid>
|
||||
<Field label="Externe ID" value={p.sourceMeta.externalId} />
|
||||
<Field label="Quelle Label" value={p.sourceMeta.sourceLabel} />
|
||||
</FieldGrid>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function SignalsPanel({ signals }: { signals: FutureSignal[] }) {
|
||||
if (signals.length === 0) {
|
||||
return <Typography variant="body2" color="text.secondary">Keine Zukunftssignale für dieses Objekt.</Typography>
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle title={`${signals.length} Zukunftssignale`} />
|
||||
{signals.map(s => (
|
||||
<Box key={s.id} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1, mb: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.25 }}>
|
||||
{s.title ?? s.signalType}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{s.locationHint} · Konfidenz {Math.round(s.confidenceScore * 100)}% · {s.timeHorizonMonths} Monate
|
||||
</Typography>
|
||||
{s.disclaimer && (
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', display: 'block', mt: 0.5 }}>
|
||||
{s.disclaimer}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
const TABS = ['Übersicht', 'Hard Facts', 'Soft Factors', 'Matchability', 'Datenqualität', 'Quelle', 'Signale'] as const
|
||||
|
||||
export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewProps) {
|
||||
const [tab, setTab] = useState(0)
|
||||
const { data: property, isLoading } = usePropertyById(propertyId)
|
||||
const { data: matches = [] } = usePropertyMatches(propertyId)
|
||||
const { data: signals = [] } = usePropertySignals(propertyId)
|
||||
|
||||
if (isLoading) return <PropertyDetailSkeleton />
|
||||
if (!property) {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Alert severity="error">Objekt nicht gefunden.</Alert>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const isLowQuality = property.dataQuality.score < 0.6
|
||||
const hasCriticalGaps = property.dataQuality.missingCriticalFields.length > 0
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ p: 2.5, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Chip label={getAssetTypeLabel(property.assetType)} size="small" sx={{ bgcolor: getAssetTypeColor(property.assetType), color: 'white', fontSize: '0.68rem' }} />
|
||||
<Chip label={getResultTypeLabel(property.resultType)} size="small" sx={{ bgcolor: getResultTypeColor(property.resultType), color: 'white', fontSize: '0.68rem' }} />
|
||||
<Chip label={getAvailabilityLabel(property.availabilityStatus)} size="small" color={getAvailabilityChipColor(property.availabilityStatus)} variant="outlined" />
|
||||
</Box>
|
||||
{onClose && (
|
||||
<IconButton size="small" onClick={onClose} sx={{ flexShrink: 0, ml: 1 }}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.3, mb: 0.25 }}>
|
||||
{property.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
|
||||
</Typography>
|
||||
|
||||
{isLowQuality && (
|
||||
<Alert severity="warning" sx={{ mt: 1, py: 0.25, fontSize: '0.75rem' }}>
|
||||
Niedrige Datenqualität ({Math.round(property.dataQuality.score * 100)}%) — Angaben können unvollständig sein.
|
||||
</Alert>
|
||||
)}
|
||||
{hasCriticalGaps && !isLowQuality && (
|
||||
<Alert severity="warning" sx={{ mt: 1, py: 0.25, fontSize: '0.75rem' }}>
|
||||
Kritische Felder fehlen: {property.dataQuality.missingCriticalFields.slice(0, 3).join(', ')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, v) => setTab(v)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
sx={{
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
flexShrink: 0,
|
||||
'& .MuiTab-root': { textTransform: 'none', fontSize: '0.8rem', minWidth: 'auto', px: 1.5 },
|
||||
}}
|
||||
>
|
||||
{TABS.map(label => (
|
||||
<Tab key={label} label={label} />
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{/* Tab content */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }}>
|
||||
{tab === 0 && <OverviewPanel p={property} />}
|
||||
{tab === 1 && <HardFactsPanel p={property} />}
|
||||
{tab === 2 && <SoftFactorsPanel p={property} />}
|
||||
{tab === 3 && <MatchabilityPanel matches={matches} />}
|
||||
{tab === 4 && <DataQualityPanel p={property} />}
|
||||
{tab === 5 && <SourcePanel p={property} />}
|
||||
{tab === 6 && <SignalsPanel signals={signals} />}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user