feat: add Maison Work source type, rename ExternalMarket to Direktinserat, hide own portfolio from demand search by default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-19 23:02:05 +02:00
parent a521155bb2
commit 3756675b6f
29 changed files with 115 additions and 42 deletions
+2 -1
View File
@@ -1,5 +1,5 @@
import { Chip } from '@mui/material' import { Chip } from '@mui/material'
import { ShieldCheck, Globe, Sparkles } from 'lucide-react' import { ShieldCheck, Globe, Building2, Sparkles } from 'lucide-react'
import type { ResultType } from '../../domain/enums' import type { ResultType } from '../../domain/enums'
import { DS_COLORS } from '../../lib/ds' import { DS_COLORS } from '../../lib/ds'
import { RESULT_TYPE_LABELS } from '../../lib/constants' import { RESULT_TYPE_LABELS } from '../../lib/constants'
@@ -12,6 +12,7 @@ interface ResultTypeBadgeProps {
const ICONS: Record<ResultType, React.ElementType> = { const ICONS: Record<ResultType, React.ElementType> = {
VERIFIED_PORTFOLIO: ShieldCheck, VERIFIED_PORTFOLIO: ShieldCheck,
EXTERNAL_MARKET: Globe, EXTERNAL_MARKET: Globe,
MAISON_WORK: Building2,
FUTURE_AVAILABILITY: Sparkles, FUTURE_AVAILABILITY: Sparkles,
} }
+2 -1
View File
@@ -1,5 +1,5 @@
import { Chip } from '@mui/material' import { Chip } from '@mui/material'
import { ShieldCheck, Globe, Sparkles } from 'lucide-react' import { ShieldCheck, Globe, Building2, Sparkles } from 'lucide-react'
import type { ResultType } from '../../domain/enums' import type { ResultType } from '../../domain/enums'
import { RESULT_TYPE_LABELS } from '../../lib/constants' import { RESULT_TYPE_LABELS } from '../../lib/constants'
@@ -11,6 +11,7 @@ interface SourceTypeBadgeProps {
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = { const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck }, VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
EXTERNAL_MARKET: { bg: 'rgba(217,119,6,0.1)', color: '#b45309', Icon: Globe }, EXTERNAL_MARKET: { bg: 'rgba(217,119,6,0.1)', color: '#b45309', Icon: Globe },
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 },
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles }, FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
} }
@@ -4,7 +4,8 @@ import type { UnifiedMatchResult } from '../../domain/unifiedResult'
const TYPE_META: Record<string, { label: string; color: string }> = { const TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' }, EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
} }
+1
View File
@@ -8,6 +8,7 @@ import { useLayoutStore } from '../../stores/layoutStore'
const TYPE_DOT: Record<string, string> = { const TYPE_DOT: Record<string, string> = {
VERIFIED_PORTFOLIO: '#1e3a5f', VERIFIED_PORTFOLIO: '#1e3a5f',
EXTERNAL_MARKET: '#d97706', EXTERNAL_MARKET: '#d97706',
MAISON_WORK: '#0369a1',
FUTURE_AVAILABILITY: '#7c3aed', FUTURE_AVAILABILITY: '#7c3aed',
} }
@@ -21,7 +21,8 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = { const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' }, EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Schattenmarkt', color: '#7c3aed' }, FUTURE_AVAILABILITY: { label: 'Schattenmarkt', color: '#7c3aed' },
} }
@@ -300,8 +301,16 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
</Typography> </Typography>
)} )}
</Box> </Box>
<Box sx={{ position: 'absolute', top: 10, right: 44 }}> <Box sx={{ position: 'absolute', top: 10, right: 44, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} /> <Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
{vm.resultType === 'VERIFIED_PORTFOLIO' && (
<Chip
icon={<Building2 size={9} color="#1e3a5f" />}
label="Ihr Objekt"
size="small"
sx={{ bgcolor: 'rgba(30,58,95,0.85)', color: 'white', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }}
/>
)}
</Box> </Box>
</Box> </Box>
)} )}
@@ -5,7 +5,8 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = { const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' }, VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Extern', color: '#d97706' }, EXTERNAL_MARKET: { label: 'Direkt', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' }, FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
} }
+11 -1
View File
@@ -1,10 +1,12 @@
import { Box, Chip } from '@mui/material' import { Box, Chip } from '@mui/material'
import { Building2 } from 'lucide-react'
import { MatchScoreDisplay } from './MatchScoreDisplay' import { MatchScoreDisplay } from './MatchScoreDisplay'
import type { MatchCardViewModel } from './MatchCardViewModel' import type { MatchCardViewModel } from './MatchCardViewModel'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = { const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' }, EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
} }
@@ -40,6 +42,14 @@ export function MatchCardHeader({ vm, compact }: Props) {
size="small" size="small"
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 11 }} sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 11 }}
/> />
{vm.resultType === 'VERIFIED_PORTFOLIO' && (
<Chip
icon={<Building2 size={10} color="#1e3a5f" />}
label="Ihr Objekt"
size="small"
sx={{ bgcolor: 'rgba(30,58,95,0.12)', color: '#1e3a5f', fontWeight: 700, fontSize: 11, '& .MuiChip-icon': { ml: 0.5 } }}
/>
)}
{vm.assetType && ( {vm.assetType && (
<Chip label={vm.assetType} size="small" variant="outlined" sx={{ fontSize: 11 }} /> <Chip label={vm.assetType} size="small" variant="outlined" sx={{ fontSize: 11 }} />
)} )}
@@ -7,7 +7,8 @@ import type { FutureSignal } from '../../domain/futureSignal'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = { const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' }, EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
} }
+29 -5
View File
@@ -1,8 +1,8 @@
import { Box, Card, Chip, Divider, Stack, Typography } from '@mui/material' import { Box, Card, Chip, Divider, Stack, Typography } from '@mui/material'
import { Zap } from 'lucide-react' import { Building2, Zap } from 'lucide-react'
import type { ResultType } from '../../domain/enums' import type { ResultType } from '../../domain/enums'
type FilterSource = Exclude<ResultType, 'FUTURE_AVAILABILITY'> | 'ALL' type FilterSource = Exclude<ResultType, 'FUTURE_AVAILABILITY' | 'VERIFIED_PORTFOLIO'> | 'ALL'
type SortBy = 'score' | 'rent' | 'area' type SortBy = 'score' | 'rent' | 'area'
interface Props { interface Props {
@@ -12,12 +12,15 @@ interface Props {
onSortChange: (v: SortBy) => void onSortChange: (v: SortBy) => void
showSchattenmarkt: boolean showSchattenmarkt: boolean
onShowSchattenmarktChange: (v: boolean) => void onShowSchattenmarktChange: (v: boolean) => void
showOwnProperties?: boolean
onShowOwnPropertiesChange?: (v: boolean) => void
isPropertyManager?: boolean
} }
const FILTER_OPTIONS: { value: FilterSource; label: string; color: string }[] = [ const FILTER_OPTIONS: { value: FilterSource; label: string; color: string }[] = [
{ value: 'ALL', label: 'Alle', color: '#1e3a5f' }, { value: 'ALL', label: 'Alle', color: '#1e3a5f' },
{ value: 'VERIFIED_PORTFOLIO', label: 'Portfolio', color: '#1e3a5f' }, { value: 'EXTERNAL_MARKET', label: 'Direktinserat', color: '#b45309' },
{ value: 'EXTERNAL_MARKET', label: 'Marktinserate', color: '#d97706' }, { value: 'MAISON_WORK', label: 'Maison Work', color: '#0369a1' },
] ]
const SORT_OPTIONS: { value: SortBy; label: string }[] = [ const SORT_OPTIONS: { value: SortBy; label: string }[] = [
@@ -33,11 +36,14 @@ export function ResultFilterBar({
onSortChange, onSortChange,
showSchattenmarkt, showSchattenmarkt,
onShowSchattenmarktChange, onShowSchattenmarktChange,
showOwnProperties,
onShowOwnPropertiesChange,
isPropertyManager,
}: Props) { }: Props) {
return ( return (
<Card sx={{ p: 1.5, mb: 2 }}> <Card sx={{ p: 1.5, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1 }}>
{/* Source filter + Schattenmarkt toggle */} {/* Source filter + toggles */}
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.5 }}>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}> <Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
{FILTER_OPTIONS.map(({ value, label, color }) => { {FILTER_OPTIONS.map(({ value, label, color }) => {
@@ -78,6 +84,24 @@ export function ResultFilterBar({
'& .MuiChip-icon': { ml: 0.75 }, '& .MuiChip-icon': { ml: 0.75 },
}} }}
/> />
{/* Eigene Objekte toggle — only for Property Managers */}
{isPropertyManager && (
<Chip
size="small"
clickable
icon={<Building2 size={11} color={showOwnProperties ? '#1e3a5f' : '#94a3b8'} />}
label="Eigene Objekte einblenden"
onClick={() => onShowOwnPropertiesChange?.(!showOwnProperties)}
sx={{
bgcolor: showOwnProperties ? 'rgba(30,58,95,0.10)' : 'transparent',
color: showOwnProperties ? '#1e3a5f' : 'text.disabled',
border: `1px solid ${showOwnProperties ? '#1e3a5f' : '#e2e8f0'}`,
fontWeight: showOwnProperties ? 600 : 400,
'& .MuiChip-icon': { ml: 0.75 },
}}
/>
)}
</Box> </Box>
{/* Sort */} {/* Sort */}
+2 -1
View File
@@ -3,7 +3,8 @@ import { Chip } from '@mui/material'
const TYPE_META: Record<string, { label: string; color: string }> = { const TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' }, EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
} }
+1 -1
View File
@@ -69,7 +69,7 @@ export function ShortlistDetail({ shortlist }: Props) {
matchId: item.resultId, matchId: item.resultId,
needId: '', needId: '',
matchScore: item.matchScore, matchScore: item.matchScore,
resultType: item.resultType as 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET', resultType: item.resultType as 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK',
property: { id: item.propertyId ?? item.resultId, title: item.title } as any, property: { id: item.propertyId ?? item.resultId, title: item.title } as any,
match: { id: item.resultId, matchScore: item.matchScore, confidenceLevel: item.confidenceScore ?? 0.8 } as any, match: { id: item.resultId, matchScore: item.matchScore, confidenceLevel: item.confidenceScore ?? 0.8 } as any,
} }
@@ -6,7 +6,8 @@ import type { ShortlistItem } from '../../domain/shortlist'
const RESULT_TYPE_LABEL: Record<string, string> = { const RESULT_TYPE_LABEL: Record<string, string> = {
VERIFIED_PORTFOLIO: 'Portfolio', VERIFIED_PORTFOLIO: 'Portfolio',
EXTERNAL_MARKET: 'Markt', EXTERNAL_MARKET: 'Direkt',
MAISON_WORK: 'Maison Work',
FUTURE_AVAILABILITY: 'Signal', FUTURE_AVAILABILITY: 'Signal',
} }
+3 -1
View File
@@ -53,7 +53,8 @@ export function getAvailabilityChipColor(status: AvailabilityStatus): 'success'
export function getResultTypeLabel(type: ResultType): string { export function getResultTypeLabel(type: ResultType): string {
const labels: Record<string, string> = { const labels: Record<string, string> = {
VERIFIED_PORTFOLIO: 'Portfolio', VERIFIED_PORTFOLIO: 'Portfolio',
EXTERNAL_MARKET: 'Markt', EXTERNAL_MARKET: 'Direktinserat',
MAISON_WORK: 'Maison Work',
FUTURE_AVAILABILITY: 'Zukunft', FUTURE_AVAILABILITY: 'Zukunft',
} }
return labels[type] ?? type return labels[type] ?? type
@@ -63,6 +64,7 @@ export function getResultTypeColor(type: ResultType): string {
const colors: Record<string, string> = { const colors: Record<string, string> = {
VERIFIED_PORTFOLIO: '#1e3a5f', VERIFIED_PORTFOLIO: '#1e3a5f',
EXTERNAL_MARKET: '#1a7a4a', EXTERNAL_MARKET: '#1a7a4a',
MAISON_WORK: '#0369a1',
FUTURE_AVAILABILITY: '#7c3aed', FUTURE_AVAILABILITY: '#7c3aed',
} }
return colors[type] ?? '#6b7280' return colors[type] ?? '#6b7280'
+1
View File
@@ -15,6 +15,7 @@ export type AssetType = typeof AssetType[keyof typeof AssetType]
export const ResultType = { export const ResultType = {
VERIFIED_PORTFOLIO: 'VERIFIED_PORTFOLIO', VERIFIED_PORTFOLIO: 'VERIFIED_PORTFOLIO',
EXTERNAL_MARKET: 'EXTERNAL_MARKET', EXTERNAL_MARKET: 'EXTERNAL_MARKET',
MAISON_WORK: 'MAISON_WORK',
FUTURE_AVAILABILITY: 'FUTURE_AVAILABILITY', FUTURE_AVAILABILITY: 'FUTURE_AVAILABILITY',
} as const } as const
export type ResultType = typeof ResultType[keyof typeof ResultType] export type ResultType = typeof ResultType[keyof typeof ResultType]
+1 -1
View File
@@ -5,7 +5,7 @@ export interface PipelineItem {
title: string title: string
location: string location: string
matchScore: number matchScore: number
resultType: 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'FUTURE_AVAILABILITY' resultType: 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY'
stage: PipelineStage stage: PipelineStage
areaLabel?: string areaLabel?: string
rentLabel?: string rentLabel?: string
+1
View File
@@ -43,6 +43,7 @@ export const CONFIDENCE_MODIFIER = {
VERIFIED_HIGH: +3, // VERIFIED_PORTFOLIO + confidenceScore >= 0.80 VERIFIED_HIGH: +3, // VERIFIED_PORTFOLIO + confidenceScore >= 0.80
VERIFIED_MEDIUM: 0, // VERIFIED_PORTFOLIO + confidenceScore < 0.80 VERIFIED_MEDIUM: 0, // VERIFIED_PORTFOLIO + confidenceScore < 0.80
EXTERNAL_MARKET: -3, // EXTERNAL_MARKET result type EXTERNAL_MARKET: -3, // EXTERNAL_MARKET result type
MAISON_WORK: -2, // MAISON_WORK — Maison Work API (slightly more trusted than scraped market)
FUTURE_AVAILABILITY: -15, // FUTURE_AVAILABILITY — never treat as confirmed availability FUTURE_AVAILABILITY: -15, // FUTURE_AVAILABILITY — never treat as confirmed availability
LOW_CONFIDENCE: -10, // confidenceScore < 0.50 (stacks with above) LOW_CONFIDENCE: -10, // confidenceScore < 0.50 (stacks with above)
} as const } as const
+1 -1
View File
@@ -21,7 +21,7 @@ export interface VerifiedPortfolioResult extends UnifiedResultBase {
} }
export interface ExternalMarketResult extends UnifiedResultBase { export interface ExternalMarketResult extends UnifiedResultBase {
resultType: 'EXTERNAL_MARKET' resultType: 'EXTERNAL_MARKET' | 'MAISON_WORK'
property: Property property: Property
match: Match match: Match
} }
+2 -2
View File
@@ -191,8 +191,8 @@ export function rankMatches(matches: Match[]): Match[] {
return [...matches].sort((a, b) => { return [...matches].sort((a, b) => {
// Primary: matchScore descending // Primary: matchScore descending
if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore
// Secondary: VERIFIED > EXTERNAL > FUTURE // Secondary: VERIFIED > EXTERNAL / MAISON_WORK > FUTURE
const typeOrder = { VERIFIED_PORTFOLIO: 0, EXTERNAL_MARKET: 1, FUTURE_AVAILABILITY: 2 } const typeOrder: Record<string, number> = { VERIFIED_PORTFOLIO: 0, EXTERNAL_MARKET: 1, MAISON_WORK: 1, FUTURE_AVAILABILITY: 2 }
const aOrder = typeOrder[a.resultType ?? 'EXTERNAL_MARKET'] ?? 1 const aOrder = typeOrder[a.resultType ?? 'EXTERNAL_MARKET'] ?? 1
const bOrder = typeOrder[b.resultType ?? 'EXTERNAL_MARKET'] ?? 1 const bOrder = typeOrder[b.resultType ?? 'EXTERNAL_MARKET'] ?? 1
if (aOrder !== bOrder) return aOrder - bOrder if (aOrder !== bOrder) return aOrder - bOrder
+2
View File
@@ -303,6 +303,8 @@ export function calcConfidenceModifier(property: Property): number {
mod += CONFIDENCE_MODIFIER.FUTURE_AVAILABILITY mod += CONFIDENCE_MODIFIER.FUTURE_AVAILABILITY
} else if (property.resultType === ResultType.EXTERNAL_MARKET) { } else if (property.resultType === ResultType.EXTERNAL_MARKET) {
mod += CONFIDENCE_MODIFIER.EXTERNAL_MARKET mod += CONFIDENCE_MODIFIER.EXTERNAL_MARKET
} else if (property.resultType === ResultType.MAISON_WORK) {
mod += CONFIDENCE_MODIFIER.MAISON_WORK
} else { } else {
// VERIFIED_PORTFOLIO // VERIFIED_PORTFOLIO
mod += property.confidenceScore >= 0.80 mod += property.confidenceScore >= 0.80
+2 -2
View File
@@ -50,12 +50,12 @@ export function useUnifiedResults(needId?: string) {
const property = properties.find(p => p.id === (match.resultId ?? match.propertyId)) const property = properties.find(p => p.id === (match.resultId ?? match.propertyId))
if (!property) return [] if (!property) return []
if (rt === 'EXTERNAL_MARKET') { if (rt === 'EXTERNAL_MARKET' || rt === 'MAISON_WORK') {
const result: ExternalMarketResult = { const result: ExternalMarketResult = {
matchId: match.id, matchId: match.id,
needId: match.needId, needId: match.needId,
matchScore: match.matchScore, matchScore: match.matchScore,
resultType: 'EXTERNAL_MARKET', resultType: rt as 'EXTERNAL_MARKET' | 'MAISON_WORK',
property, property,
match, match,
} }
+2 -1
View File
@@ -67,7 +67,8 @@ export const ASSET_TYPE_LABELS: Record<string, string> = {
// Result type display labels // Result type display labels
export const RESULT_TYPE_LABELS: Record<string, string> = { export const RESULT_TYPE_LABELS: Record<string, string> = {
VERIFIED_PORTFOLIO: 'Verified Portfolio', VERIFIED_PORTFOLIO: 'Verified Portfolio',
EXTERNAL_MARKET: 'Marktinserat', EXTERNAL_MARKET: 'Direktinserat',
MAISON_WORK: 'Maison Work',
FUTURE_AVAILABILITY: 'Zukunftssignal', FUTURE_AVAILABILITY: 'Zukunftssignal',
} }
+1
View File
@@ -8,6 +8,7 @@ export const DS_COLORS = {
resultType: { resultType: {
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' }, VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' },
EXTERNAL_MARKET: { bg: 'rgba(180,83,9,0.10)', fg: '#b45309' }, EXTERNAL_MARKET: { bg: 'rgba(180,83,9,0.10)', fg: '#b45309' },
MAISON_WORK: { bg: 'rgba(3,105,161,0.10)', fg: '#0369a1' },
FUTURE_AVAILABILITY: { bg: 'rgba(109,40,217,0.10)', fg: '#6d28d9' }, FUTURE_AVAILABILITY: { bg: 'rgba(109,40,217,0.10)', fg: '#6d28d9' },
}, },
confidence: { confidence: {
+1
View File
@@ -100,6 +100,7 @@ export function canViewProperty(
return property.organizationId === user.organizationId return property.organizationId === user.organizationId
} }
if (property.resultType === ResultType.EXTERNAL_MARKET) return true if (property.resultType === ResultType.EXTERNAL_MARKET) return true
if (property.resultType === ResultType.MAISON_WORK) return true
if (property.resultType === ResultType.FUTURE_AVAILABILITY) { if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
return hasPermission(user, Permission.FUTURE_SIGNAL_VIEW) return hasPermission(user, Permission.FUTURE_SIGNAL_VIEW)
} }
+2 -2
View File
@@ -3,8 +3,8 @@ import type { PipelineItem } from '../domain/pipeline'
export const mockPipelineItems: PipelineItem[] = [ export const mockPipelineItems: PipelineItem[] = [
{ id: 'pl-001', title: 'Bürofläche Zollstrasse 12', location: 'Zürich, Zürich-West', matchScore: 91, resultType: 'VERIFIED_PORTFOLIO', stage: 'NEGOTIATION', areaLabel: '850 m²', rentLabel: 'CHF 42/m²', availabilityLabel: '2025-09-01', addedAt: '2025-05-01T10:00:00Z', updatedAt: '2025-05-15T14:00:00Z', assignedTo: 'B. Sutter' }, { id: 'pl-001', title: 'Bürofläche Zollstrasse 12', location: 'Zürich, Zürich-West', matchScore: 91, resultType: 'VERIFIED_PORTFOLIO', stage: 'NEGOTIATION', areaLabel: '850 m²', rentLabel: 'CHF 42/m²', availabilityLabel: '2025-09-01', addedAt: '2025-05-01T10:00:00Z', updatedAt: '2025-05-15T14:00:00Z', assignedTo: 'B. Sutter' },
{ id: 'pl-002', title: 'Gewerbe Hardturmstrasse', location: 'Zürich-West', matchScore: 87, resultType: 'VERIFIED_PORTFOLIO', stage: 'VISITED', areaLabel: '1200 m²', rentLabel: 'CHF 38/m²', availabilityLabel: '2025-10-01', addedAt: '2025-05-03T10:00:00Z', updatedAt: '2025-05-14T09:00:00Z' }, { id: 'pl-002', title: 'Gewerbe Hardturmstrasse', location: 'Zürich-West', matchScore: 87, resultType: 'VERIFIED_PORTFOLIO', stage: 'VISITED', areaLabel: '1200 m²', rentLabel: 'CHF 38/m²', availabilityLabel: '2025-10-01', addedAt: '2025-05-03T10:00:00Z', updatedAt: '2025-05-14T09:00:00Z' },
{ id: 'pl-003', title: 'Büro Binzstrasse 23', location: 'Zürich, Binz', matchScore: 83, resultType: 'EXTERNAL_MARKET', stage: 'VISITED', areaLabel: '720 m²', rentLabel: 'CHF 47/m²', addedAt: '2025-05-04T10:00:00Z', updatedAt: '2025-05-13T10:00:00Z' }, { id: 'pl-003', title: 'Büro Binzstrasse 23', location: 'Zürich, Binz', matchScore: 83, resultType: 'MAISON_WORK', stage: 'VISITED', areaLabel: '720 m²', rentLabel: 'CHF 47/m²', addedAt: '2025-05-04T10:00:00Z', updatedAt: '2025-05-13T10:00:00Z' },
{ id: 'pl-004', title: 'Bürofläche Bahnhofstrasse', location: 'Zürich, Innenstadt', matchScore: 79, resultType: 'EXTERNAL_MARKET', stage: 'QUALIFIED', areaLabel: '950 m²', rentLabel: 'CHF 85/m²', addedAt: '2025-05-05T10:00:00Z', updatedAt: '2025-05-12T10:00:00Z', notes: 'Budget zu hoch — prüfen' }, { id: 'pl-004', title: 'Bürofläche Bahnhofstrasse', location: 'Zürich, Innenstadt', matchScore: 79, resultType: 'MAISON_WORK', stage: 'QUALIFIED', areaLabel: '950 m²', rentLabel: 'CHF 85/m²', addedAt: '2025-05-05T10:00:00Z', updatedAt: '2025-05-12T10:00:00Z', notes: 'Budget zu hoch — prüfen' },
{ id: 'pl-005', title: 'DataCloud Systems AG', location: 'Zürich-West / Technopark', matchScore: 76, resultType: 'FUTURE_AVAILABILITY', stage: 'QUALIFIED', areaLabel: '~600 m²', availabilityLabel: '~10 Monate', addedAt: '2025-05-06T10:00:00Z', updatedAt: '2025-05-11T10:00:00Z' }, { id: 'pl-005', title: 'DataCloud Systems AG', location: 'Zürich-West / Technopark', matchScore: 76, resultType: 'FUTURE_AVAILABILITY', stage: 'QUALIFIED', areaLabel: '~600 m²', availabilityLabel: '~10 Monate', addedAt: '2025-05-06T10:00:00Z', updatedAt: '2025-05-11T10:00:00Z' },
{ id: 'pl-006', title: 'Neubau Wankdorf Business', location: 'Bern, Wankdorf', matchScore: 72, resultType: 'FUTURE_AVAILABILITY', stage: 'DISCOVERED', areaLabel: '~4500 m²', availabilityLabel: '~24 Monate', addedAt: '2025-05-07T10:00:00Z', updatedAt: '2025-05-10T10:00:00Z' }, { id: 'pl-006', title: 'Neubau Wankdorf Business', location: 'Bern, Wankdorf', matchScore: 72, resultType: 'FUTURE_AVAILABILITY', stage: 'DISCOVERED', areaLabel: '~4500 m²', availabilityLabel: '~24 Monate', addedAt: '2025-05-07T10:00:00Z', updatedAt: '2025-05-10T10:00:00Z' },
{ id: 'pl-007', title: 'Bürofläche Stadthaus Bern', location: 'Bern Innenstadt', matchScore: 88, resultType: 'VERIFIED_PORTFOLIO', stage: 'CLOSED_WON', areaLabel: '650 m²', rentLabel: 'CHF 52/m²', availabilityLabel: '2025-07-01', addedAt: '2025-04-10T10:00:00Z', updatedAt: '2025-05-08T10:00:00Z', assignedTo: 'B. Sutter', notes: 'Vertrag unterschrieben' }, { id: 'pl-007', title: 'Bürofläche Stadthaus Bern', location: 'Bern Innenstadt', matchScore: 88, resultType: 'VERIFIED_PORTFOLIO', stage: 'CLOSED_WON', areaLabel: '650 m²', rentLabel: 'CHF 52/m²', availabilityLabel: '2025-07-01', addedAt: '2025-04-10T10:00:00Z', updatedAt: '2025-05-08T10:00:00Z', assignedTo: 'B. Sutter', notes: 'Vertrag unterschrieben' },
+5 -5
View File
@@ -594,7 +594,7 @@ export const mockProperties: Property[] = [
id: 'prop-015', id: 'prop-015',
title: 'Bürofläche Kasernenplatz Luzern', title: 'Bürofläche Kasernenplatz Luzern',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.MAISON_WORK,
location: { city: 'Luzern', district: 'Innenstadt', canton: 'LU', country: 'CH', coordinates: { lat: 47.0502, lng: 8.3093 } }, location: { city: 'Luzern', district: 'Innenstadt', canton: 'LU', country: 'CH', coordinates: { lat: 47.0502, lng: 8.3093 } },
address: { street: 'Kasernenplatz', houseNumber: '3', postalCode: '6003', city: 'Luzern', country: 'CH' }, address: { street: 'Kasernenplatz', houseNumber: '3', postalCode: '6003', city: 'Luzern', country: 'CH' },
areaSqm: 650, areaSqm: 650,
@@ -629,7 +629,7 @@ export const mockProperties: Property[] = [
id: 'prop-016', id: 'prop-016',
title: 'Logistikhalle Muttenz Rheinfelderstrasse', title: 'Logistikhalle Muttenz Rheinfelderstrasse',
assetType: AssetType.LOGISTICS, assetType: AssetType.LOGISTICS,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.MAISON_WORK,
location: { city: 'Muttenz', district: 'Industriezone', canton: 'BL', country: 'CH', coordinates: { lat: 47.5202, lng: 7.6422 } }, location: { city: 'Muttenz', district: 'Industriezone', canton: 'BL', country: 'CH', coordinates: { lat: 47.5202, lng: 7.6422 } },
address: { street: 'Rheinfelderstrasse', houseNumber: '80', postalCode: '4132', city: 'Muttenz', country: 'CH' }, address: { street: 'Rheinfelderstrasse', houseNumber: '80', postalCode: '4132', city: 'Muttenz', country: 'CH' },
areaSqm: 2200, areaSqm: 2200,
@@ -735,7 +735,7 @@ export const mockProperties: Property[] = [
id: 'prop-019', id: 'prop-019',
title: 'Produktionsfläche Voltastrasse Basel', title: 'Produktionsfläche Voltastrasse Basel',
assetType: AssetType.PRODUCTION, assetType: AssetType.PRODUCTION,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.MAISON_WORK,
location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.5720, lng: 7.5882 } }, location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.5720, lng: 7.5882 } },
address: { street: 'Voltastrasse', houseNumber: '62', postalCode: '4056', city: 'Basel', country: 'CH' }, address: { street: 'Voltastrasse', houseNumber: '62', postalCode: '4056', city: 'Basel', country: 'CH' },
areaSqm: 1900, areaSqm: 1900,
@@ -765,7 +765,7 @@ export const mockProperties: Property[] = [
id: 'prop-020', id: 'prop-020',
title: 'Bürofläche Industriestrasse Zug', title: 'Bürofläche Industriestrasse Zug',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.MAISON_WORK,
location: { city: 'Zug', district: 'Industrie', canton: 'ZG', country: 'CH', coordinates: { lat: 47.1688, lng: 8.5228 } }, location: { city: 'Zug', district: 'Industrie', canton: 'ZG', country: 'CH', coordinates: { lat: 47.1688, lng: 8.5228 } },
address: { street: 'Industriestrasse', houseNumber: '45', postalCode: '6300', city: 'Zug', country: 'CH' }, address: { street: 'Industriestrasse', houseNumber: '45', postalCode: '6300', city: 'Zug', country: 'CH' },
areaSqm: 820, areaSqm: 820,
@@ -800,7 +800,7 @@ export const mockProperties: Property[] = [
id: 'prop-021', id: 'prop-021',
title: 'Surface commerciale Rue du Rhône', title: 'Surface commerciale Rue du Rhône',
assetType: AssetType.RETAIL, assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET, resultType: ResultType.MAISON_WORK,
location: { city: 'Genf', district: 'Centre', canton: 'GE', country: 'CH', coordinates: { lat: 46.2044, lng: 6.1432 } }, location: { city: 'Genf', district: 'Centre', canton: 'GE', country: 'CH', coordinates: { lat: 46.2044, lng: 6.1432 } },
address: { street: 'Rue du Rhône', houseNumber: '48', postalCode: '1204', city: 'Genf', country: 'CH' }, address: { street: 'Rue du Rhône', houseNumber: '48', postalCode: '1204', city: 'Genf', country: 'CH' },
areaSqm: 250, areaSqm: 250,
+2 -1
View File
@@ -51,7 +51,8 @@ function getSig(item: UnifiedMatchResult) {
const TYPE_META: Record<string, { label: string; color: string }> = { const TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' }, EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
} }
+2 -1
View File
@@ -31,7 +31,8 @@ const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = { const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' }, EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
} }
+3 -1
View File
@@ -21,13 +21,15 @@ const NEXT_STAGE_LABEL: Partial<Record<PipelineStage, string>> = {
const RESULT_TYPE_LABEL: Record<PipelineItem['resultType'], string> = { const RESULT_TYPE_LABEL: Record<PipelineItem['resultType'], string> = {
VERIFIED_PORTFOLIO: 'Portfolio', VERIFIED_PORTFOLIO: 'Portfolio',
EXTERNAL_MARKET: 'Markt', EXTERNAL_MARKET: 'Direktinserat',
MAISON_WORK: 'Maison Work',
FUTURE_AVAILABILITY: 'Schattenmarkt', FUTURE_AVAILABILITY: 'Schattenmarkt',
} }
const RESULT_TYPE_COLOR: Record<PipelineItem['resultType'], string> = { const RESULT_TYPE_COLOR: Record<PipelineItem['resultType'], string> = {
VERIFIED_PORTFOLIO: '#1e3a5f', VERIFIED_PORTFOLIO: '#1e3a5f',
EXTERNAL_MARKET: '#d97706', EXTERNAL_MARKET: '#d97706',
MAISON_WORK: '#0369a1',
FUTURE_AVAILABILITY: '#7c3aed', FUTURE_AVAILABILITY: '#7c3aed',
} }
+14 -5
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Box, Button, Card, Typography } from '@mui/material' import { Box, Button, Card, Chip, Typography } from '@mui/material'
import { useNavigate, useLocation } from 'react-router' import { useNavigate, useLocation } from 'react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useUnifiedResults } from '../../hooks/useUnifiedResults' import { useUnifiedResults } from '../../hooks/useUnifiedResults'
@@ -13,10 +13,11 @@ import {
UnifiedResultFeed, UnifiedResultFeed,
} from '../../components/results' } from '../../components/results'
import { AddToShortlistDialog } from '../../components/shortlist' import { AddToShortlistDialog } from '../../components/shortlist'
import { useSessionStore } from '../../stores/sessionStore'
import type { ResultType } from '../../domain/enums' import type { ResultType } from '../../domain/enums'
import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { UnifiedMatchResult } from '../../domain/unifiedResult'
type FilterSource = Exclude<ResultType, 'FUTURE_AVAILABILITY'> | 'ALL' type FilterSource = Exclude<ResultType, 'FUTURE_AVAILABILITY' | 'VERIFIED_PORTFOLIO'> | 'ALL'
type SortBy = 'score' | 'rent' | 'area' type SortBy = 'score' | 'rent' | 'area'
function sortResults(results: UnifiedMatchResult[], sortBy: SortBy): UnifiedMatchResult[] { function sortResults(results: UnifiedMatchResult[], sortBy: SortBy): UnifiedMatchResult[] {
@@ -34,9 +35,11 @@ export default function Results() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { currentUser } = useSessionStore()
const [filterSource, setFilterSource] = useState<FilterSource>('ALL') const [filterSource, setFilterSource] = useState<FilterSource>('ALL')
const [sortBy, setSortBy] = useState<SortBy>('score') const [sortBy, setSortBy] = useState<SortBy>('score')
const [showSchattenmarkt, setShowSchattenmarkt] = useState(true) const [showSchattenmarkt, setShowSchattenmarkt] = useState(true)
const [showOwnProperties, setShowOwnProperties] = useState(false)
const [view, setView] = useState<'list' | 'grid'>(() => const [view, setView] = useState<'list' | 'grid'>(() =>
(localStorage.getItem('view-results') as 'list' | 'grid') ?? 'list' (localStorage.getItem('view-results') as 'list' | 'grid') ?? 'list'
) )
@@ -69,6 +72,8 @@ export default function Results() {
const { data: results = [], isLoading } = useUnifiedResults(activeNeed?.id) const { data: results = [], isLoading } = useUnifiedResults(activeNeed?.id)
const filtered = results const filtered = results
// Exclude VERIFIED_PORTFOLIO by default; include only when toggled on by Property Manager
.filter(r => r.resultType !== 'VERIFIED_PORTFOLIO' || showOwnProperties)
.filter(r => filterSource === 'ALL' || r.resultType === filterSource) .filter(r => filterSource === 'ALL' || r.resultType === filterSource)
.filter(r => showSchattenmarkt || r.resultType !== 'FUTURE_AVAILABILITY') .filter(r => showSchattenmarkt || r.resultType !== 'FUTURE_AVAILABILITY')
@@ -76,6 +81,7 @@ export default function Results() {
const verifiedCount = results.filter(r => r.resultType === 'VERIFIED_PORTFOLIO').length const verifiedCount = results.filter(r => r.resultType === 'VERIFIED_PORTFOLIO').length
const externalCount = results.filter(r => r.resultType === 'EXTERNAL_MARKET').length const externalCount = results.filter(r => r.resultType === 'EXTERNAL_MARKET').length
const maisonWorkCount = results.filter(r => r.resultType === 'MAISON_WORK').length
const futureCount = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY').length const futureCount = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY').length
const strongCount = results.filter(r => r.matchScore >= 80).length const strongCount = results.filter(r => r.matchScore >= 80).length
const missingDataCount = results.filter(r => const missingDataCount = results.filter(r =>
@@ -101,8 +107,8 @@ export default function Results() {
context={activeNeed ? `Suche: ${activeNeed.assetType} · ${activeNeed.requiredArea.min}${activeNeed.requiredArea.max} m² · ${activeNeed.preferredLocations.join(', ')}` : undefined} context={activeNeed ? `Suche: ${activeNeed.assetType} · ${activeNeed.requiredArea.min}${activeNeed.requiredArea.max} m² · ${activeNeed.preferredLocations.join(', ')}` : undefined}
metrics={[ metrics={[
...(strongCount > 0 ? [{ label: 'starke Treffer (≥80)', value: strongCount, severity: 'positive' as const }] : []), ...(strongCount > 0 ? [{ label: 'starke Treffer (≥80)', value: strongCount, severity: 'positive' as const }] : []),
...(verifiedCount > 0 ? [{ label: 'verifiziertes Portfolio', value: verifiedCount, severity: 'neutral' as const }] : []), ...(externalCount > 0 ? [{ label: 'Direktinserate', value: externalCount, severity: 'neutral' as const }] : []),
...(externalCount > 0 ? [{ label: 'externer Markt', value: externalCount, severity: 'neutral' as const }] : []), ...(maisonWorkCount > 0 ? [{ label: 'Maison Work', value: maisonWorkCount, severity: 'neutral' as const }] : []),
...(futureCount > 0 ? [{ label: 'Zukunftssignale', value: futureCount, severity: 'warning' as const }] : []), ...(futureCount > 0 ? [{ label: 'Zukunftssignale', value: futureCount, severity: 'warning' as const }] : []),
...(missingDataCount > 0 ? [{ label: 'mit Datenlücken', value: missingDataCount, severity: 'warning' as const }] : []), ...(missingDataCount > 0 ? [{ label: 'mit Datenlücken', value: missingDataCount, severity: 'warning' as const }] : []),
]} ]}
@@ -171,12 +177,15 @@ export default function Results() {
onSortChange={setSortBy} onSortChange={setSortBy}
showSchattenmarkt={showSchattenmarkt} showSchattenmarkt={showSchattenmarkt}
onShowSchattenmarktChange={setShowSchattenmarkt} onShowSchattenmarktChange={setShowSchattenmarkt}
showOwnProperties={showOwnProperties}
onShowOwnPropertiesChange={setShowOwnProperties}
isPropertyManager={currentUser?.role === 'PROPERTY_MANAGER'}
/> />
{isLoading ? ( {isLoading ? (
<FeedSkeleton /> <FeedSkeleton />
) : sorted.length === 0 ? ( ) : sorted.length === 0 ? (
<FeedEmptyState filtered={filterSource !== 'ALL' || !showSchattenmarkt} /> <FeedEmptyState filtered={filterSource !== 'ALL' || !showSchattenmarkt || !showOwnProperties} />
) : ( ) : (
<UnifiedResultFeed results={sorted} view={view} /> <UnifiedResultFeed results={sorted} view={view} />
)} )}