From 3756675b6fd47b1de8dacb49c3c1d3a9918794b9 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Tue, 19 May 2026 23:02:05 +0200 Subject: [PATCH] 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 --- src/components/badges/ResultTypeBadge.tsx | 3 +- src/components/cards/SourceTypeBadge.tsx | 3 +- .../compare/CompareColumnHeader.tsx | 3 +- src/components/layout/CompareTray.tsx | 1 + .../match-card/IntelligenceMatchCard.tsx | 13 +++++-- .../match-card/MatchCardCompareMini.tsx | 7 ++-- src/components/match-card/MatchCardHeader.tsx | 12 ++++++- .../match-detail/MatchDetailHeader.tsx | 3 +- src/components/results/ResultFilterBar.tsx | 36 +++++++++++++++---- src/components/results/ResultTypeBadge.tsx | 3 +- src/components/shortlist/ShortlistDetail.tsx | 2 +- .../shortlist/ShortlistItemCard.tsx | 3 +- src/components/supply/propertyHelpers.ts | 4 ++- src/domain/enums.ts | 1 + src/domain/pipeline.ts | 2 +- src/domain/scoring.ts | 1 + src/domain/unifiedResult.ts | 2 +- src/features/matching/rankingEngine.ts | 4 +-- src/features/matching/scoreCalculator.ts | 2 ++ src/hooks/useUnifiedResults.ts | 4 +-- src/lib/constants.ts | 3 +- src/lib/ds.ts | 1 + src/lib/permissions.ts | 1 + src/mock-data/pipelineItems.ts | 4 +-- src/mock-data/properties.ts | 10 +++--- src/pages/demand/Compare.tsx | 3 +- src/pages/demand/MatchDetail.tsx | 3 +- src/pages/demand/Pipeline.tsx | 4 ++- src/pages/demand/Results.tsx | 19 +++++++--- 29 files changed, 115 insertions(+), 42 deletions(-) diff --git a/src/components/badges/ResultTypeBadge.tsx b/src/components/badges/ResultTypeBadge.tsx index 230d948..8f602c4 100644 --- a/src/components/badges/ResultTypeBadge.tsx +++ b/src/components/badges/ResultTypeBadge.tsx @@ -1,5 +1,5 @@ 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 { DS_COLORS } from '../../lib/ds' import { RESULT_TYPE_LABELS } from '../../lib/constants' @@ -12,6 +12,7 @@ interface ResultTypeBadgeProps { const ICONS: Record = { VERIFIED_PORTFOLIO: ShieldCheck, EXTERNAL_MARKET: Globe, + MAISON_WORK: Building2, FUTURE_AVAILABILITY: Sparkles, } diff --git a/src/components/cards/SourceTypeBadge.tsx b/src/components/cards/SourceTypeBadge.tsx index ca92967..357036c 100644 --- a/src/components/cards/SourceTypeBadge.tsx +++ b/src/components/cards/SourceTypeBadge.tsx @@ -1,5 +1,5 @@ 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 { RESULT_TYPE_LABELS } from '../../lib/constants' @@ -11,6 +11,7 @@ interface SourceTypeBadgeProps { const CONFIG: Record = { 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 }, + 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 }, } diff --git a/src/components/compare/CompareColumnHeader.tsx b/src/components/compare/CompareColumnHeader.tsx index 92685f5..c1b7c42 100644 --- a/src/components/compare/CompareColumnHeader.tsx +++ b/src/components/compare/CompareColumnHeader.tsx @@ -4,7 +4,8 @@ import type { UnifiedMatchResult } from '../../domain/unifiedResult' const TYPE_META: Record = { 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' }, } diff --git a/src/components/layout/CompareTray.tsx b/src/components/layout/CompareTray.tsx index 115ef5f..0ea3356 100644 --- a/src/components/layout/CompareTray.tsx +++ b/src/components/layout/CompareTray.tsx @@ -8,6 +8,7 @@ import { useLayoutStore } from '../../stores/layoutStore' const TYPE_DOT: Record = { VERIFIED_PORTFOLIO: '#1e3a5f', EXTERNAL_MARKET: '#d97706', + MAISON_WORK: '#0369a1', FUTURE_AVAILABILITY: '#7c3aed', } diff --git a/src/components/match-card/IntelligenceMatchCard.tsx b/src/components/match-card/IntelligenceMatchCard.tsx index f2f9dba..757850a 100644 --- a/src/components/match-card/IntelligenceMatchCard.tsx +++ b/src/components/match-card/IntelligenceMatchCard.tsx @@ -21,7 +21,8 @@ import type { MatchCardViewModel } from './MatchCardViewModel' const RESULT_TYPE_META: Record = { 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' }, } @@ -300,8 +301,16 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro )} - + + {vm.resultType === 'VERIFIED_PORTFOLIO' && ( + } + 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 } }} + /> + )} )} diff --git a/src/components/match-card/MatchCardCompareMini.tsx b/src/components/match-card/MatchCardCompareMini.tsx index 5955298..07ca6b5 100644 --- a/src/components/match-card/MatchCardCompareMini.tsx +++ b/src/components/match-card/MatchCardCompareMini.tsx @@ -4,9 +4,10 @@ import { MatchScoreDisplay } from './MatchScoreDisplay' import type { MatchCardViewModel } from './MatchCardViewModel' const RESULT_TYPE_META: Record = { - VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' }, - EXTERNAL_MARKET: { label: 'Extern', color: '#d97706' }, - FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' }, + VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' }, + EXTERNAL_MARKET: { label: 'Direkt', color: '#d97706' }, + MAISON_WORK: { label: 'Maison Work', color: '#0369a1' }, + FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' }, } interface Props { diff --git a/src/components/match-card/MatchCardHeader.tsx b/src/components/match-card/MatchCardHeader.tsx index 488dff9..26f1c52 100644 --- a/src/components/match-card/MatchCardHeader.tsx +++ b/src/components/match-card/MatchCardHeader.tsx @@ -1,10 +1,12 @@ import { Box, Chip } from '@mui/material' +import { Building2 } from 'lucide-react' import { MatchScoreDisplay } from './MatchScoreDisplay' import type { MatchCardViewModel } from './MatchCardViewModel' const RESULT_TYPE_META: Record = { 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' }, } @@ -40,6 +42,14 @@ export function MatchCardHeader({ vm, compact }: Props) { size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 11 }} /> + {vm.resultType === 'VERIFIED_PORTFOLIO' && ( + } + 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 && ( )} diff --git a/src/components/match-detail/MatchDetailHeader.tsx b/src/components/match-detail/MatchDetailHeader.tsx index 53b9e21..4a91701 100644 --- a/src/components/match-detail/MatchDetailHeader.tsx +++ b/src/components/match-detail/MatchDetailHeader.tsx @@ -7,7 +7,8 @@ import type { FutureSignal } from '../../domain/futureSignal' const RESULT_TYPE_META: Record = { 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' }, } diff --git a/src/components/results/ResultFilterBar.tsx b/src/components/results/ResultFilterBar.tsx index 6df071e..b654c48 100644 --- a/src/components/results/ResultFilterBar.tsx +++ b/src/components/results/ResultFilterBar.tsx @@ -1,8 +1,8 @@ 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' -type FilterSource = Exclude | 'ALL' +type FilterSource = Exclude | 'ALL' type SortBy = 'score' | 'rent' | 'area' interface Props { @@ -12,12 +12,15 @@ interface Props { onSortChange: (v: SortBy) => void showSchattenmarkt: boolean onShowSchattenmarktChange: (v: boolean) => void + showOwnProperties?: boolean + onShowOwnPropertiesChange?: (v: boolean) => void + isPropertyManager?: boolean } const FILTER_OPTIONS: { value: FilterSource; label: string; color: string }[] = [ - { value: 'ALL', label: 'Alle', color: '#1e3a5f' }, - { value: 'VERIFIED_PORTFOLIO', label: 'Portfolio', color: '#1e3a5f' }, - { value: 'EXTERNAL_MARKET', label: 'Marktinserate', color: '#d97706' }, + { value: 'ALL', label: 'Alle', color: '#1e3a5f' }, + { value: 'EXTERNAL_MARKET', label: 'Direktinserat', color: '#b45309' }, + { value: 'MAISON_WORK', label: 'Maison Work', color: '#0369a1' }, ] const SORT_OPTIONS: { value: SortBy; label: string }[] = [ @@ -33,11 +36,14 @@ export function ResultFilterBar({ onSortChange, showSchattenmarkt, onShowSchattenmarktChange, + showOwnProperties, + onShowOwnPropertiesChange, + isPropertyManager, }: Props) { return ( - {/* Source filter + Schattenmarkt toggle */} + {/* Source filter + toggles */} {FILTER_OPTIONS.map(({ value, label, color }) => { @@ -78,6 +84,24 @@ export function ResultFilterBar({ '& .MuiChip-icon': { ml: 0.75 }, }} /> + + {/* Eigene Objekte toggle — only for Property Managers */} + {isPropertyManager && ( + } + 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 }, + }} + /> + )} {/* Sort */} diff --git a/src/components/results/ResultTypeBadge.tsx b/src/components/results/ResultTypeBadge.tsx index dd35a98..426d5ed 100644 --- a/src/components/results/ResultTypeBadge.tsx +++ b/src/components/results/ResultTypeBadge.tsx @@ -3,7 +3,8 @@ import { Chip } from '@mui/material' const TYPE_META: Record = { 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' }, } diff --git a/src/components/shortlist/ShortlistDetail.tsx b/src/components/shortlist/ShortlistDetail.tsx index 9cbacfe..feab206 100644 --- a/src/components/shortlist/ShortlistDetail.tsx +++ b/src/components/shortlist/ShortlistDetail.tsx @@ -69,7 +69,7 @@ export function ShortlistDetail({ shortlist }: Props) { matchId: item.resultId, needId: '', 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, match: { id: item.resultId, matchScore: item.matchScore, confidenceLevel: item.confidenceScore ?? 0.8 } as any, } diff --git a/src/components/shortlist/ShortlistItemCard.tsx b/src/components/shortlist/ShortlistItemCard.tsx index 6c5e994..6e21573 100644 --- a/src/components/shortlist/ShortlistItemCard.tsx +++ b/src/components/shortlist/ShortlistItemCard.tsx @@ -6,7 +6,8 @@ import type { ShortlistItem } from '../../domain/shortlist' const RESULT_TYPE_LABEL: Record = { VERIFIED_PORTFOLIO: 'Portfolio', - EXTERNAL_MARKET: 'Markt', + EXTERNAL_MARKET: 'Direkt', + MAISON_WORK: 'Maison Work', FUTURE_AVAILABILITY: 'Signal', } diff --git a/src/components/supply/propertyHelpers.ts b/src/components/supply/propertyHelpers.ts index db405e2..d556b83 100644 --- a/src/components/supply/propertyHelpers.ts +++ b/src/components/supply/propertyHelpers.ts @@ -53,7 +53,8 @@ export function getAvailabilityChipColor(status: AvailabilityStatus): 'success' export function getResultTypeLabel(type: ResultType): string { const labels: Record = { VERIFIED_PORTFOLIO: 'Portfolio', - EXTERNAL_MARKET: 'Markt', + EXTERNAL_MARKET: 'Direktinserat', + MAISON_WORK: 'Maison Work', FUTURE_AVAILABILITY: 'Zukunft', } return labels[type] ?? type @@ -63,6 +64,7 @@ export function getResultTypeColor(type: ResultType): string { const colors: Record = { VERIFIED_PORTFOLIO: '#1e3a5f', EXTERNAL_MARKET: '#1a7a4a', + MAISON_WORK: '#0369a1', FUTURE_AVAILABILITY: '#7c3aed', } return colors[type] ?? '#6b7280' diff --git a/src/domain/enums.ts b/src/domain/enums.ts index 67a7d64..8211397 100644 --- a/src/domain/enums.ts +++ b/src/domain/enums.ts @@ -15,6 +15,7 @@ export type AssetType = typeof AssetType[keyof typeof AssetType] export const ResultType = { VERIFIED_PORTFOLIO: 'VERIFIED_PORTFOLIO', EXTERNAL_MARKET: 'EXTERNAL_MARKET', + MAISON_WORK: 'MAISON_WORK', FUTURE_AVAILABILITY: 'FUTURE_AVAILABILITY', } as const export type ResultType = typeof ResultType[keyof typeof ResultType] diff --git a/src/domain/pipeline.ts b/src/domain/pipeline.ts index 307f60b..8418818 100644 --- a/src/domain/pipeline.ts +++ b/src/domain/pipeline.ts @@ -5,7 +5,7 @@ export interface PipelineItem { title: string location: string matchScore: number - resultType: 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'FUTURE_AVAILABILITY' + resultType: 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY' stage: PipelineStage areaLabel?: string rentLabel?: string diff --git a/src/domain/scoring.ts b/src/domain/scoring.ts index c2ee43f..7fbbc6d 100644 --- a/src/domain/scoring.ts +++ b/src/domain/scoring.ts @@ -43,6 +43,7 @@ export const CONFIDENCE_MODIFIER = { VERIFIED_HIGH: +3, // VERIFIED_PORTFOLIO + confidenceScore >= 0.80 VERIFIED_MEDIUM: 0, // VERIFIED_PORTFOLIO + confidenceScore < 0.80 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 LOW_CONFIDENCE: -10, // confidenceScore < 0.50 (stacks with above) } as const diff --git a/src/domain/unifiedResult.ts b/src/domain/unifiedResult.ts index 51d86ac..99e9c0b 100644 --- a/src/domain/unifiedResult.ts +++ b/src/domain/unifiedResult.ts @@ -21,7 +21,7 @@ export interface VerifiedPortfolioResult extends UnifiedResultBase { } export interface ExternalMarketResult extends UnifiedResultBase { - resultType: 'EXTERNAL_MARKET' + resultType: 'EXTERNAL_MARKET' | 'MAISON_WORK' property: Property match: Match } diff --git a/src/features/matching/rankingEngine.ts b/src/features/matching/rankingEngine.ts index cb0f0c0..08a64fb 100644 --- a/src/features/matching/rankingEngine.ts +++ b/src/features/matching/rankingEngine.ts @@ -191,8 +191,8 @@ export function rankMatches(matches: Match[]): Match[] { return [...matches].sort((a, b) => { // Primary: matchScore descending if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore - // Secondary: VERIFIED > EXTERNAL > FUTURE - const typeOrder = { VERIFIED_PORTFOLIO: 0, EXTERNAL_MARKET: 1, FUTURE_AVAILABILITY: 2 } + // Secondary: VERIFIED > EXTERNAL / MAISON_WORK > FUTURE + const typeOrder: Record = { VERIFIED_PORTFOLIO: 0, EXTERNAL_MARKET: 1, MAISON_WORK: 1, FUTURE_AVAILABILITY: 2 } const aOrder = typeOrder[a.resultType ?? 'EXTERNAL_MARKET'] ?? 1 const bOrder = typeOrder[b.resultType ?? 'EXTERNAL_MARKET'] ?? 1 if (aOrder !== bOrder) return aOrder - bOrder diff --git a/src/features/matching/scoreCalculator.ts b/src/features/matching/scoreCalculator.ts index 32f208f..ba774ca 100644 --- a/src/features/matching/scoreCalculator.ts +++ b/src/features/matching/scoreCalculator.ts @@ -303,6 +303,8 @@ export function calcConfidenceModifier(property: Property): number { mod += CONFIDENCE_MODIFIER.FUTURE_AVAILABILITY } else if (property.resultType === ResultType.EXTERNAL_MARKET) { mod += CONFIDENCE_MODIFIER.EXTERNAL_MARKET + } else if (property.resultType === ResultType.MAISON_WORK) { + mod += CONFIDENCE_MODIFIER.MAISON_WORK } else { // VERIFIED_PORTFOLIO mod += property.confidenceScore >= 0.80 diff --git a/src/hooks/useUnifiedResults.ts b/src/hooks/useUnifiedResults.ts index b358e7b..b9a6218 100644 --- a/src/hooks/useUnifiedResults.ts +++ b/src/hooks/useUnifiedResults.ts @@ -50,12 +50,12 @@ export function useUnifiedResults(needId?: string) { const property = properties.find(p => p.id === (match.resultId ?? match.propertyId)) if (!property) return [] - if (rt === 'EXTERNAL_MARKET') { + if (rt === 'EXTERNAL_MARKET' || rt === 'MAISON_WORK') { const result: ExternalMarketResult = { matchId: match.id, needId: match.needId, matchScore: match.matchScore, - resultType: 'EXTERNAL_MARKET', + resultType: rt as 'EXTERNAL_MARKET' | 'MAISON_WORK', property, match, } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 7aa7f0b..4ce06fa 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -67,7 +67,8 @@ export const ASSET_TYPE_LABELS: Record = { // Result type display labels export const RESULT_TYPE_LABELS: Record = { VERIFIED_PORTFOLIO: 'Verified Portfolio', - EXTERNAL_MARKET: 'Marktinserat', + EXTERNAL_MARKET: 'Direktinserat', + MAISON_WORK: 'Maison Work', FUTURE_AVAILABILITY: 'Zukunftssignal', } diff --git a/src/lib/ds.ts b/src/lib/ds.ts index 5e4c2af..5b2c9f1 100644 --- a/src/lib/ds.ts +++ b/src/lib/ds.ts @@ -8,6 +8,7 @@ export const DS_COLORS = { resultType: { VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' }, 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' }, }, confidence: { diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index b3070d4..7085b7e 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -100,6 +100,7 @@ export function canViewProperty( return property.organizationId === user.organizationId } if (property.resultType === ResultType.EXTERNAL_MARKET) return true + if (property.resultType === ResultType.MAISON_WORK) return true if (property.resultType === ResultType.FUTURE_AVAILABILITY) { return hasPermission(user, Permission.FUTURE_SIGNAL_VIEW) } diff --git a/src/mock-data/pipelineItems.ts b/src/mock-data/pipelineItems.ts index e583016..f4dd231 100644 --- a/src/mock-data/pipelineItems.ts +++ b/src/mock-data/pipelineItems.ts @@ -3,8 +3,8 @@ import type { PipelineItem } from '../domain/pipeline' 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-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-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-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: '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-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' }, diff --git a/src/mock-data/properties.ts b/src/mock-data/properties.ts index bc424dd..a3d39dc 100644 --- a/src/mock-data/properties.ts +++ b/src/mock-data/properties.ts @@ -594,7 +594,7 @@ export const mockProperties: Property[] = [ id: 'prop-015', title: 'Bürofläche Kasernenplatz Luzern', 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 } }, address: { street: 'Kasernenplatz', houseNumber: '3', postalCode: '6003', city: 'Luzern', country: 'CH' }, areaSqm: 650, @@ -629,7 +629,7 @@ export const mockProperties: Property[] = [ id: 'prop-016', title: 'Logistikhalle Muttenz Rheinfelderstrasse', 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 } }, address: { street: 'Rheinfelderstrasse', houseNumber: '80', postalCode: '4132', city: 'Muttenz', country: 'CH' }, areaSqm: 2200, @@ -735,7 +735,7 @@ export const mockProperties: Property[] = [ id: 'prop-019', title: 'Produktionsfläche Voltastrasse Basel', 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 } }, address: { street: 'Voltastrasse', houseNumber: '62', postalCode: '4056', city: 'Basel', country: 'CH' }, areaSqm: 1900, @@ -765,7 +765,7 @@ export const mockProperties: Property[] = [ id: 'prop-020', title: 'Bürofläche Industriestrasse Zug', 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 } }, address: { street: 'Industriestrasse', houseNumber: '45', postalCode: '6300', city: 'Zug', country: 'CH' }, areaSqm: 820, @@ -800,7 +800,7 @@ export const mockProperties: Property[] = [ id: 'prop-021', title: 'Surface commerciale Rue du Rhône', 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 } }, address: { street: 'Rue du Rhône', houseNumber: '48', postalCode: '1204', city: 'Genf', country: 'CH' }, areaSqm: 250, diff --git a/src/pages/demand/Compare.tsx b/src/pages/demand/Compare.tsx index f670a60..0e13946 100644 --- a/src/pages/demand/Compare.tsx +++ b/src/pages/demand/Compare.tsx @@ -51,7 +51,8 @@ function getSig(item: UnifiedMatchResult) { const TYPE_META: Record = { 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' }, } diff --git a/src/pages/demand/MatchDetail.tsx b/src/pages/demand/MatchDetail.tsx index 77c0991..30fa618 100644 --- a/src/pages/demand/MatchDetail.tsx +++ b/src/pages/demand/MatchDetail.tsx @@ -31,7 +31,8 @@ const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing']) const RESULT_TYPE_META: Record = { 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' }, } diff --git a/src/pages/demand/Pipeline.tsx b/src/pages/demand/Pipeline.tsx index e01503f..acdc89b 100644 --- a/src/pages/demand/Pipeline.tsx +++ b/src/pages/demand/Pipeline.tsx @@ -21,13 +21,15 @@ const NEXT_STAGE_LABEL: Partial> = { const RESULT_TYPE_LABEL: Record = { VERIFIED_PORTFOLIO: 'Portfolio', - EXTERNAL_MARKET: 'Markt', + EXTERNAL_MARKET: 'Direktinserat', + MAISON_WORK: 'Maison Work', FUTURE_AVAILABILITY: 'Schattenmarkt', } const RESULT_TYPE_COLOR: Record = { VERIFIED_PORTFOLIO: '#1e3a5f', EXTERNAL_MARKET: '#d97706', + MAISON_WORK: '#0369a1', FUTURE_AVAILABILITY: '#7c3aed', } diff --git a/src/pages/demand/Results.tsx b/src/pages/demand/Results.tsx index d13ad0a..27d785c 100644 --- a/src/pages/demand/Results.tsx +++ b/src/pages/demand/Results.tsx @@ -1,5 +1,5 @@ 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 { useQuery, useQueryClient } from '@tanstack/react-query' import { useUnifiedResults } from '../../hooks/useUnifiedResults' @@ -13,10 +13,11 @@ import { UnifiedResultFeed, } from '../../components/results' import { AddToShortlistDialog } from '../../components/shortlist' +import { useSessionStore } from '../../stores/sessionStore' import type { ResultType } from '../../domain/enums' import type { UnifiedMatchResult } from '../../domain/unifiedResult' -type FilterSource = Exclude | 'ALL' +type FilterSource = Exclude | 'ALL' type SortBy = 'score' | 'rent' | 'area' function sortResults(results: UnifiedMatchResult[], sortBy: SortBy): UnifiedMatchResult[] { @@ -34,9 +35,11 @@ export default function Results() { const navigate = useNavigate() const location = useLocation() const queryClient = useQueryClient() + const { currentUser } = useSessionStore() const [filterSource, setFilterSource] = useState('ALL') const [sortBy, setSortBy] = useState('score') const [showSchattenmarkt, setShowSchattenmarkt] = useState(true) + const [showOwnProperties, setShowOwnProperties] = useState(false) const [view, setView] = useState<'list' | 'grid'>(() => (localStorage.getItem('view-results') as 'list' | 'grid') ?? 'list' ) @@ -69,6 +72,8 @@ export default function Results() { const { data: results = [], isLoading } = useUnifiedResults(activeNeed?.id) 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 => showSchattenmarkt || r.resultType !== 'FUTURE_AVAILABILITY') @@ -76,6 +81,7 @@ export default function Results() { const verifiedCount = results.filter(r => r.resultType === 'VERIFIED_PORTFOLIO').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 strongCount = results.filter(r => r.matchScore >= 80).length 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} metrics={[ ...(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: 'externer Markt', value: externalCount, severity: 'neutral' as const }] : []), + ...(externalCount > 0 ? [{ label: 'Direktinserate', 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 }] : []), ...(missingDataCount > 0 ? [{ label: 'mit Datenlücken', value: missingDataCount, severity: 'warning' as const }] : []), ]} @@ -171,12 +177,15 @@ export default function Results() { onSortChange={setSortBy} showSchattenmarkt={showSchattenmarkt} onShowSchattenmarktChange={setShowSchattenmarkt} + showOwnProperties={showOwnProperties} + onShowOwnPropertiesChange={setShowOwnProperties} + isPropertyManager={currentUser?.role === 'PROPERTY_MANAGER'} /> {isLoading ? ( ) : sorted.length === 0 ? ( - + ) : ( )}