From c75d3c8121e10ea4dffebf5b9f10825754bed0a9 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Mon, 18 May 2026 13:25:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20F029=20intelligence=20card=20system=20?= =?UTF-8?q?=E2=80=94=20premium=20AI-native=20CRE=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gold/Silver/Bronze FIFA-style score badges overlaid on location imagery. List/grid view toggle on Results and Properties pages (persisted in localStorage). IntelligenceMatchCard for match results grid view; PropertyIntelligenceCard for property grid view with warm brown theme. LocationPreview component with Google Maps link. 10 mock properties enriched with Unsplash building images. Co-Authored-By: Claude Sonnet 4.6 --- .../match-card/IntelligenceMatchCard.tsx | 160 ++++++++++++++++++ src/components/results/ResultFeedHeader.tsx | 24 ++- src/components/results/UnifiedResultCard.tsx | 36 +++- src/components/results/UnifiedResultFeed.tsx | 16 +- src/components/shared/LocationPreview.tsx | 87 ++++++++++ src/components/shared/ViewToggle.tsx | 46 +++++ src/components/shared/index.ts | 4 + src/components/shared/scoreTheme.ts | 37 ++++ .../supply/PropertyIntelligenceCard.tsx | 141 +++++++++++++++ src/components/supply/index.ts | 1 + src/mock-data/properties.ts | 9 + src/pages/demand/Results.tsx | 7 +- src/pages/supply/Properties.tsx | 39 +++-- 13 files changed, 581 insertions(+), 26 deletions(-) create mode 100644 src/components/match-card/IntelligenceMatchCard.tsx create mode 100644 src/components/shared/LocationPreview.tsx create mode 100644 src/components/shared/ViewToggle.tsx create mode 100644 src/components/shared/index.ts create mode 100644 src/components/shared/scoreTheme.ts create mode 100644 src/components/supply/PropertyIntelligenceCard.tsx diff --git a/src/components/match-card/IntelligenceMatchCard.tsx b/src/components/match-card/IntelligenceMatchCard.tsx new file mode 100644 index 0000000..83b5ac3 --- /dev/null +++ b/src/components/match-card/IntelligenceMatchCard.tsx @@ -0,0 +1,160 @@ +import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material' +import { CheckCircle2 } from 'lucide-react' +import { LocationPreview } from '../shared/LocationPreview' +import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme' +import type { MatchCardViewModel } from './MatchCardViewModel' + +const RESULT_TYPE_META: Record = { + VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, + EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' }, + FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, +} + +interface Props { + vm: MatchCardViewModel + imageUrl?: string + lat?: number + lng?: number + cityLabel?: string +} + +export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) { + const tier = getScoreTier(vm.matchScore) + const theme = SCORE_THEME[tier] + const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' } + const topReason = vm.reasons[0] + + const isFuture = vm.resultType === 'FUTURE_AVAILABILITY' + + return ( + + {/* Image zone */} + + + + {/* Score badge — overlaid top-left */} + + + {vm.matchScore} + + + {theme.label} + + + + {/* Result type chip — overlaid top-right */} + + + + + + {/* Card body */} + + + {vm.title} + + + {[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')} + + + {vm.explainabilitySummary && ( + <> + + + {vm.explainabilitySummary} + + + )} + + {topReason && ( + <> + + + + + {topReason.label} + + + + )} + + {/* Actions */} + + {vm.actions.map(a => ( + + ))} + + + + {/* FUTURE_AVAILABILITY disclaimer */} + {vm.disclaimer && ( + + {vm.disclaimer} + + )} + + ) +} diff --git a/src/components/results/ResultFeedHeader.tsx b/src/components/results/ResultFeedHeader.tsx index daae81b..cd908ab 100644 --- a/src/components/results/ResultFeedHeader.tsx +++ b/src/components/results/ResultFeedHeader.tsx @@ -1,21 +1,29 @@ import { Box, Typography } from '@mui/material' +import { ViewToggle } from '../shared' interface Props { total: number verifiedCount: number externalCount: number futureCount: number + view?: 'list' | 'grid' + onViewChange?: (v: 'list' | 'grid') => void } -export function ResultFeedHeader({ total, verifiedCount, externalCount, futureCount }: Props) { +export function ResultFeedHeader({ total, verifiedCount, externalCount, futureCount, view = 'list', onViewChange }: Props) { return ( - - - {total} Treffer gefunden - - - {verifiedCount} Verified · {externalCount} Extern · {futureCount} Signale - + + + + {total} Empfehlungen + + + {verifiedCount} Verifiziert · {externalCount} Extern · {futureCount} Marktsignale + + + {onViewChange && ( + + )} ) } diff --git a/src/components/results/UnifiedResultCard.tsx b/src/components/results/UnifiedResultCard.tsx index 537e982..9eb5277 100644 --- a/src/components/results/UnifiedResultCard.tsx +++ b/src/components/results/UnifiedResultCard.tsx @@ -1,5 +1,6 @@ import { useNavigate } from 'react-router' import { MatchCardCompact } from '../match-card/MatchCardCompact' +import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard' import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter' import { useCompareStore } from '../../stores/compareStore' import { useShortlistStore } from '../../stores/shortlistStore' @@ -8,16 +9,17 @@ import type { MatchCardAction } from '../match-card/MatchCardViewModel' interface Props { result: UnifiedMatchResult + view?: 'list' | 'grid' } function getResultTitle(result: UnifiedMatchResult): string { if (result.resultType !== 'FUTURE_AVAILABILITY') { - return (result as any).property?.title ?? result.matchId + return result.property?.title ?? result.matchId } - return (result as any).signal?.companyName ?? result.matchId + return result.signal?.companyName ?? result.matchId } -export function UnifiedResultCard({ result }: Props) { +export function UnifiedResultCard({ result, view = 'list' }: Props) { const navigate = useNavigate() const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore() const { openAddDialog } = useShortlistStore() @@ -52,7 +54,7 @@ export function UnifiedResultCard({ result }: Props) { }, { id: 'details', - label: 'Details', + label: 'Details →', actionType: 'OPEN_DETAIL', variant: 'primary', onClick: () => navigate(`/demand/results/${result.matchId}`), @@ -60,5 +62,31 @@ export function UnifiedResultCard({ result }: Props) { ] const vm = buildMatchCardViewModel(result, actions) + + if (view === 'grid') { + const imageUrl = result.resultType !== 'FUTURE_AVAILABILITY' + ? result.property.images?.[0] + : undefined + const lat = result.resultType !== 'FUTURE_AVAILABILITY' + ? result.property.location.coordinates?.lat + : undefined + const lng = result.resultType !== 'FUTURE_AVAILABILITY' + ? result.property.location.coordinates?.lng + : undefined + const cityLabel = result.resultType !== 'FUTURE_AVAILABILITY' + ? result.property.location.city + : result.signal.locationHint ?? undefined + + return ( + + ) + } + return } diff --git a/src/components/results/UnifiedResultFeed.tsx b/src/components/results/UnifiedResultFeed.tsx index 0aaad78..b958e72 100644 --- a/src/components/results/UnifiedResultFeed.tsx +++ b/src/components/results/UnifiedResultFeed.tsx @@ -1,15 +1,27 @@ +import { Box } from '@mui/material' import type { UnifiedMatchResult } from '../../domain/unifiedResult' import { UnifiedResultCard } from './UnifiedResultCard' interface Props { results: UnifiedMatchResult[] + view?: 'list' | 'grid' } -export function UnifiedResultFeed({ results }: Props) { +export function UnifiedResultFeed({ results, view = 'list' }: Props) { + if (view === 'grid') { + return ( + + {results.map(result => ( + + ))} + + ) + } + return ( <> {results.map(result => ( - + ))} ) diff --git a/src/components/shared/LocationPreview.tsx b/src/components/shared/LocationPreview.tsx new file mode 100644 index 0000000..5d003ea --- /dev/null +++ b/src/components/shared/LocationPreview.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react' +import { Box, IconButton, Tooltip, Typography } from '@mui/material' +import { MapPin, Map } from 'lucide-react' + +interface Props { + imageUrl?: string + lat?: number + lng?: number + address?: string + cityLabel?: string + height?: number +} + +// TODO: replace placeholder with Google Maps Static API: +// https://maps.googleapis.com/maps/api/staticmap?center={lat},{lng}&zoom=15&size=800x400&key=YOUR_KEY + +function buildMapsUrl(lat?: number, lng?: number, address?: string): string { + if (lat != null && lng != null) { + return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}` + } + if (address) { + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}` + } + return 'https://www.google.com/maps' +} + +export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180 }: Props) { + const [imgError, setImgError] = useState(false) + const mapsUrl = buildMapsUrl(lat, lng, address) + const showImage = !!imageUrl && !imgError + + return ( + + {showImage ? ( + {cityLabel setImgError(true)} + style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} + /> + ) : ( + + + {cityLabel && ( + + {cityLabel} + + )} + + )} + + {/* Google Maps button */} + + + + + + + ) +} diff --git a/src/components/shared/ViewToggle.tsx b/src/components/shared/ViewToggle.tsx new file mode 100644 index 0000000..4885a27 --- /dev/null +++ b/src/components/shared/ViewToggle.tsx @@ -0,0 +1,46 @@ +import { Box, IconButton, Tooltip } from '@mui/material' +import { LayoutGrid, LayoutList } from 'lucide-react' + +interface Props { + view: 'list' | 'grid' + onChange: (v: 'list' | 'grid') => void +} + +export function ViewToggle({ view, onChange }: Props) { + return ( + + + onChange('list')} + sx={{ + borderRadius: 0, + width: 32, + height: 32, + bgcolor: view === 'list' ? '#1e3a5f' : 'transparent', + color: view === 'list' ? 'white' : '#64748b', + '&:hover': { bgcolor: view === 'list' ? '#162d4a' : '#f1f5f9' }, + }} + > + + + + + onChange('grid')} + sx={{ + borderRadius: 0, + width: 32, + height: 32, + bgcolor: view === 'grid' ? '#1e3a5f' : 'transparent', + color: view === 'grid' ? 'white' : '#64748b', + '&:hover': { bgcolor: view === 'grid' ? '#162d4a' : '#f1f5f9' }, + }} + > + + + + + ) +} diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts new file mode 100644 index 0000000..cbd14c5 --- /dev/null +++ b/src/components/shared/index.ts @@ -0,0 +1,4 @@ +export { ViewToggle } from './ViewToggle' +export { LocationPreview } from './LocationPreview' +export { getScoreTier, SCORE_THEME } from './scoreTheme' +export type { ScoreTier } from './scoreTheme' diff --git a/src/components/shared/scoreTheme.ts b/src/components/shared/scoreTheme.ts new file mode 100644 index 0000000..3d572f2 --- /dev/null +++ b/src/components/shared/scoreTheme.ts @@ -0,0 +1,37 @@ +export type ScoreTier = 'gold' | 'silver' | 'bronze' + +export function getScoreTier(score: number): ScoreTier { + if (score >= 90) return 'gold' + if (score >= 75) return 'silver' + return 'bronze' +} + +export const SCORE_THEME = { + gold: { + gradient: 'linear-gradient(135deg,#f8e642 0%,#d4920e 100%)', + border: '#c9900c', + glow: 'rgba(212,146,14,0.35)', + text: '#7a4f00', + label: 'Top Match', + cardBorder: '#fbbf24', + cardBg: 'linear-gradient(160deg,#fffbeb,#fef3c7)', + }, + silver: { + gradient: 'linear-gradient(135deg,#f1f5f9 0%,#cbd5e1 100%)', + border: '#94a3b8', + glow: 'rgba(148,163,184,0.30)', + text: '#334155', + label: 'Starkes Match', + cardBorder: '#cbd5e1', + cardBg: 'linear-gradient(160deg,#f8fafc,#f1f5f9)', + }, + bronze: { + gradient: 'linear-gradient(135deg,#fde8c8 0%,#d4956a 100%)', + border: '#c07a46', + glow: 'rgba(192,122,70,0.25)', + text: '#7c3d0c', + label: 'Gutes Match', + cardBorder: '#f5d0a9', + cardBg: 'linear-gradient(160deg,#fdf6f0,#fef3e8)', + }, +} as const diff --git a/src/components/supply/PropertyIntelligenceCard.tsx b/src/components/supply/PropertyIntelligenceCard.tsx new file mode 100644 index 0000000..1996b5e --- /dev/null +++ b/src/components/supply/PropertyIntelligenceCard.tsx @@ -0,0 +1,141 @@ +import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material' +import { LocationPreview } from '../shared/LocationPreview' +import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers' +import type { Property } from '../../domain/property' + +interface Props { + property: Property + onSelect: (id: string) => void +} + +function availabilityBadgeColor(status: string): string { + if (status === 'AVAILABLE_NOW') return '#1a7a4a' + if (status === 'AVAILABLE_SOON') return '#d97706' + return '#64748b' +} + +export 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' + + return ( + onSelect(p.id)} + > + {/* Image zone */} + + + + {/* Availability badge */} + + + + + {/* Asset type chip */} + + + + + + {/* Card body */} + + + {p.title} + + + {p.location.city}{p.location.district ? ` · ${p.location.district}` : ''} + + + {/* Key specs */} + + + + {p.contractDurationMonths && ( + + )} + + + {/* Soft factors */} + {p.softFactors && ( + + {p.softFactors.prestige != null && ( + + )} + {p.softFactors.accessibility != null && ( + + )} + + )} + + {/* Confidence */} + + + Datenkonfidenz + {confPct}% + + + + + + + + ) +} diff --git a/src/components/supply/index.ts b/src/components/supply/index.ts index 1d61c2b..43ab247 100644 --- a/src/components/supply/index.ts +++ b/src/components/supply/index.ts @@ -15,3 +15,4 @@ export { PropertyFilterBar } from './PropertyFilterBar' export type { PropertyTableFilters } from './PropertyFilterBar' export { PropertyDetailView } from './PropertyDetailView' export { PropertyDetailSkeleton } from './PropertyDetailSkeleton' +export { PropertyIntelligenceCard } from './PropertyIntelligenceCard' diff --git a/src/mock-data/properties.ts b/src/mock-data/properties.ts index bf49fc2..dc59e74 100644 --- a/src/mock-data/properties.ts +++ b/src/mock-data/properties.ts @@ -41,6 +41,7 @@ export const mockProperties: Property[] = [ contractDurationMonths: 60, ancillaryCosts: 5.5, riskLevel: RiskLevel.LOW, + images: ['https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&h=400&fit=crop'], organizationId: 'org-wincasa', createdAt: '2025-01-10T08:00:00Z', updatedAt: '2025-04-28T10:30:00Z', @@ -79,6 +80,7 @@ export const mockProperties: Property[] = [ contractDurationMonths: 36, ancillaryCosts: 3.0, riskLevel: RiskLevel.LOW, + images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'], organizationId: 'org-wincasa', createdAt: '2024-11-20T09:00:00Z', updatedAt: '2025-05-05T11:00:00Z', @@ -119,6 +121,7 @@ export const mockProperties: Property[] = [ contractDurationMonths: 48, ancillaryCosts: 5.0, riskLevel: RiskLevel.LOW, + images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], organizationId: 'org-wincasa', createdAt: '2025-02-01T09:00:00Z', updatedAt: '2025-05-01T08:00:00Z', @@ -158,6 +161,7 @@ export const mockProperties: Property[] = [ contractDurationMonths: 48, ancillaryCosts: 4.5, riskLevel: RiskLevel.LOW, + images: ['https://images.unsplash.com/photo-1481277542470-605612bd2d61?w=800&h=400&fit=crop'], organizationId: 'org-wincasa', createdAt: '2025-01-20T10:00:00Z', updatedAt: '2025-04-30T09:00:00Z', @@ -196,6 +200,7 @@ export const mockProperties: Property[] = [ contractDurationMonths: 60, ancillaryCosts: 2.8, riskLevel: RiskLevel.LOW, + images: ['https://images.unsplash.com/photo-1566438480900-0b5b967f4a25?w=800&h=400&fit=crop'], organizationId: 'org-wincasa', createdAt: '2024-12-10T08:00:00Z', updatedAt: '2025-05-02T10:00:00Z', @@ -234,6 +239,7 @@ export const mockProperties: Property[] = [ contractDurationMonths: 60, ancillaryCosts: 8.0, riskLevel: RiskLevel.LOW, + images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'], organizationId: 'org-wincasa', createdAt: '2025-01-05T10:00:00Z', updatedAt: '2025-05-06T11:00:00Z', @@ -272,6 +278,7 @@ export const mockProperties: Property[] = [ contractDurationMonths: 120, ancillaryCosts: 2.5, riskLevel: RiskLevel.LOW, + images: ['https://images.unsplash.com/photo-1581091226825-a6a2a5aee158?w=800&h=400&fit=crop'], organizationId: 'org-wincasa', createdAt: '2024-10-15T09:00:00Z', updatedAt: '2025-05-03T10:00:00Z', @@ -311,6 +318,7 @@ export const mockProperties: Property[] = [ contractDurationMonths: 36, ancillaryCosts: 6.0, riskLevel: RiskLevel.LOW, + images: ['https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=800&h=400&fit=crop'], organizationId: 'org-wincasa', createdAt: '2025-02-10T11:00:00Z', updatedAt: '2025-04-29T08:00:00Z', @@ -350,6 +358,7 @@ export const mockProperties: Property[] = [ contractDurationMonths: 48, ancillaryCosts: 5.0, riskLevel: RiskLevel.LOW, + images: ['https://images.unsplash.com/photo-1524758631624-e2822e304c36?w=800&h=400&fit=crop'], organizationId: 'org-wincasa', createdAt: '2025-01-15T09:00:00Z', updatedAt: '2025-04-25T10:00:00Z', diff --git a/src/pages/demand/Results.tsx b/src/pages/demand/Results.tsx index 35a6a9a..b9a3b40 100644 --- a/src/pages/demand/Results.tsx +++ b/src/pages/demand/Results.tsx @@ -36,6 +36,9 @@ export default function Results() { const queryClient = useQueryClient() const [filterSource, setFilterSource] = useState('ALL') const [sortBy, setSortBy] = useState('score') + const [view, setView] = useState<'list' | 'grid'>(() => + (localStorage.getItem('view-results') as 'list' | 'grid') ?? 'list' + ) // When coming from NeedBuilder, invalidate so the freshly created need is included const activeNeedIdFromNav = (location.state as { activeNeedId?: string } | null)?.activeNeedId @@ -86,6 +89,8 @@ export default function Results() { verifiedCount={verifiedCount} externalCount={externalCount} futureCount={futureCount} + view={view} + onViewChange={v => { setView(v); localStorage.setItem('view-results', v) }} /> {!isLoading && results.length > 0 && ( @@ -169,7 +174,7 @@ export default function Results() { ) : sorted.length === 0 ? ( ) : ( - + )} diff --git a/src/pages/supply/Properties.tsx b/src/pages/supply/Properties.tsx index cdc805b..a6e34a0 100644 --- a/src/pages/supply/Properties.tsx +++ b/src/pages/supply/Properties.tsx @@ -3,8 +3,9 @@ import { Box, Drawer } from '@mui/material' import { useNavigate } from 'react-router' import { PageHeader } from '../../components/layout' import { DecisionContextPanel } from '../../components/ui' +import { ViewToggle } from '../../components/shared' import { useProperties } from '../../hooks/useProperties' -import { PropertyFilterBar, PropertyTable, PropertyDetailView } from '../../components/supply' +import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply' import type { PropertyTableFilters } from '../../components/supply' import type { Property } from '../../domain/property' @@ -50,6 +51,9 @@ export default function Properties() { const navigate = useNavigate() const [selectedId, setSelectedId] = useState(null) const [filters, setFilters] = useState({}) + const [view, setView] = useState<'list' | 'grid'>(() => + (localStorage.getItem('view-properties') as 'list' | 'grid') ?? 'list' + ) const { data: properties = [], isLoading, isError } = useProperties() const filtered = applyFilters(properties, filters) @@ -65,7 +69,6 @@ export default function Properties() { p => p.dataQuality.freshness === 'STALE' || p.dataQuality.freshness === 'OUTDATED' ) - // Unique missing fields across all objects const allMissingFields = [...new Set( properties.flatMap(p => p.dataQuality.missingCriticalFields) )].slice(0, 4) @@ -75,6 +78,12 @@ export default function Properties() { { setView(v); localStorage.setItem('view-properties', v) }} + /> + } /> {!isLoading && properties.length > 0 && ( @@ -123,15 +132,23 @@ export default function Properties() { - + {view === 'grid' ? ( + + {filtered.map(p => ( + + ))} + + ) : ( + + )}