diff --git a/src/mock-data/matches.ts b/src/mock-data/matches.ts index ba0c86d..e94efc1 100644 --- a/src/mock-data/matches.ts +++ b/src/mock-data/matches.ts @@ -1759,4 +1759,202 @@ export const mockMatches: Match[] = [ createdAt: '2026-05-20T08:20:00Z', updatedAt: '2026-05-20T08:20:00Z', }, + + // ─────────────────────────────────────────────────────────────────────────── + // DIRECT LISTING MATCHES (prop-d01 … prop-d06) + // ─────────────────────────────────────────────────────────────────────────── + + { + id: 'match-065', + propertyId: 'prop-d01', + needId: 'need-005', + matchScore: 83, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 88, softFactorScore: 80, confidenceModifier: 0.80, dataQualityModifier: 0.78, totalScore: 83 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.17, score: 95, contribution: 16.2, explanation: '320m² liegt im Zielkorridor (300–700m²)' }, + { criterion: 'Standort', weight: 0.22, score: 90, contribution: 19.8, explanation: 'Zürich Altstetten — bevorzugte Region Zürich' }, + { criterion: 'Budget', weight: 0.17, score: 88, contribution: 14.9, explanation: 'CHF 380/m²/J unter Maximum CHF 576/m²/J' }, + ], + negativeFactors: [ + { criterion: 'ÖV', weight: 0.10, score: 65, contribution: 6.5, explanation: '8 Minuten zur S-Bahn — leicht über Wunsch-Maximum (10 Min.)' }, + ], + tradeoffs: [], + explainabilitySummary: 'Sehr guter Match für TechStart GmbH: Fläche, Standort Zürich und Budget passen. Minimaler ÖV-Abzug.', + confidenceLevel: 0.80, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: [], + organizationId: 'org-wincasa', + createdAt: '2026-04-15T09:00:00Z', + updatedAt: '2026-04-15T09:00:00Z', + }, + + { + id: 'match-066', + propertyId: 'prop-d01', + needId: 'need-001', + matchScore: 64, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 68, softFactorScore: 72, confidenceModifier: 0.80, dataQualityModifier: 0.78, totalScore: 64 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.18, score: 88, contribution: 15.8, explanation: 'Zürich Altstetten trifft bevorzugte Region' }, + { criterion: 'Budget', weight: 0.14, score: 90, contribution: 12.6, explanation: 'CHF 380/m²/J unter Maximum CHF 540/m²/J' }, + ], + negativeFactors: [ + { criterion: 'Fläche', weight: 0.18, score: 40, contribution: 7.2, explanation: '320m² unterschreitet Minimum von 600m² erheblich' }, + ], + tradeoffs: [ + { criterion: 'Fläche', concern: '320m² — halb so gross wie Mindestbedarf (600m²)', severity: 'HIGH', mitigation: 'Evtl. Erweiterungsoptionen im Gebäude prüfen' }, + ], + explainabilitySummary: 'Standort und Budget passen für Innovatech AG, aber Fläche deutlich zu klein. Nur als Notfall-Option.', + confidenceLevel: 0.80, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Fläche unter Minimum'], + organizationId: 'org-wincasa', + createdAt: '2026-04-15T09:05:00Z', + updatedAt: '2026-04-15T09:05:00Z', + }, + + { + id: 'match-067', + propertyId: 'prop-d02', + needId: 'need-002', + matchScore: 66, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 70, softFactorScore: 78, confidenceModifier: 0.85, dataQualityModifier: 0.82, totalScore: 66 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.18, score: 98, contribution: 17.6, explanation: 'Pratteln — explizit bevorzugte Region für Schweizer Logistik GmbH' }, + { criterion: 'Budget', weight: 0.18, score: 95, contribution: 17.1, explanation: 'CHF 145/m²/J weit unter Maximum CHF 216/m²/J' }, + { criterion: 'Hallenhöhe', weight: 0.10, score: 90, contribution: 9.0, explanation: '6.5m Hallenhöhe — Mindestanforderung 6m erfüllt' }, + ], + negativeFactors: [ + { criterion: 'Fläche', weight: 0.23, score: 22, contribution: 5.1, explanation: '580m² vs. Minimum 1500m² — erhebliche Unterdeckung' }, + ], + tradeoffs: [ + { criterion: 'Fläche', concern: 'Nur 39% des Mindestbedarfs gedeckt', severity: 'HIGH', mitigation: 'Kombinierbar mit benachbarten Einheiten falls verfügbar' }, + ], + explainabilitySummary: 'Idealer Standort und Preis für Schweizer Logistik GmbH — Fläche reicht aber nicht aus.', + confidenceLevel: 0.85, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Fläche zu gering'], + organizationId: 'org-wincasa', + createdAt: '2026-04-20T10:00:00Z', + updatedAt: '2026-04-20T10:00:00Z', + }, + + { + id: 'match-068', + propertyId: 'prop-d03', + needId: 'need-005', + matchScore: 61, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 65, softFactorScore: 70, confidenceModifier: 0.78, dataQualityModifier: 0.74, totalScore: 61 }, + positiveFactors: [ + { criterion: 'Budget', weight: 0.17, score: 100, contribution: 17.0, explanation: 'CHF 295/m²/J deutlich unter Maximum CHF 576/m²/J' }, + { criterion: 'Ausbau', weight: 0.08, score: 85, contribution: 6.8, explanation: 'Vollausbau trifft "Moderner Ausbau"-Anforderung' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.22, score: 30, contribution: 6.6, explanation: 'Bern liegt ausserhalb bevorzugter Regionen Zürich/Zug' }, + { criterion: 'Fläche', weight: 0.17, score: 62, contribution: 10.5, explanation: '240m² leicht unter Minimum 300m²' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Bern liegt nicht in Zürich/Zug — TechStart GmbH hat keine Niederlassung in Bern', severity: 'MEDIUM', mitigation: 'Falls Remote-Team Bern ausgebaut wird, attraktive Option' }, + ], + explainabilitySummary: 'Gutes Budget-Match für TechStart, aber Standort Bern ist nicht ihre bevorzugte Region.', + confidenceLevel: 0.78, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Standort ausserhalb Präferenz'], + organizationId: 'org-wincasa', + createdAt: '2026-04-22T11:00:00Z', + updatedAt: '2026-04-22T11:00:00Z', + }, + + { + id: 'match-069', + propertyId: 'prop-d04', + needId: 'need-004', + matchScore: 62, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 68, softFactorScore: 72, confidenceModifier: 0.72, dataQualityModifier: 0.68, totalScore: 62 }, + positiveFactors: [ + { criterion: 'Budget', weight: 0.12, score: 100, contribution: 12.0, explanation: 'CHF 360/m²/J weit unter Maximum CHF 1200/m²/J' }, + { criterion: 'Schaufensterfront', weight: 0.12, score: 100, contribution: 12.0, explanation: 'Schaufensterfront vorhanden — Pflichtkriterium erfüllt' }, + { criterion: 'Erdgeschoss', weight: 0.15, score: 100, contribution: 15.0, explanation: 'EG-Lage wie gefordert' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.20, score: 32, contribution: 6.4, explanation: 'Winterthur vs. Zürich Innenstadt — andere Stadt' }, + { criterion: 'Fläche', weight: 0.10, score: 55, contribution: 5.5, explanation: '185m² leicht unter Minimum 200m²' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Winterthur Stadtzentrum ≠ Zürich Innenstadt', severity: 'MEDIUM', mitigation: 'Winterthur hat hohe Fussgängerfrequenz, günstigere Miete' }, + ], + explainabilitySummary: 'Gute Retail-Attribute, aber Winterthur ist nicht das gesuchte Zürich-Innenstadt-Format.', + confidenceLevel: 0.72, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Standort differiert'], + organizationId: 'org-wincasa', + createdAt: '2026-02-15T14:00:00Z', + updatedAt: '2026-02-15T14:00:00Z', + }, + + { + id: 'match-070', + propertyId: 'prop-d05', + needId: 'need-002', + matchScore: 81, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 85, softFactorScore: 82, confidenceModifier: 0.88, dataQualityModifier: 0.85, totalScore: 81 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.18, score: 98, contribution: 17.6, explanation: 'Muttenz — explizit bevorzugte Region für Schweizer Logistik GmbH' }, + { criterion: 'Budget', weight: 0.18, score: 100, contribution: 18.0, explanation: 'CHF 130/m²/J deutlich unter Maximum CHF 216/m²/J' }, + { criterion: 'Hallenhöhe', weight: 0.10, score: 100, contribution: 10.0, explanation: '9m — deutlich über Mindestanforderung 6m' }, + { criterion: 'Ladelucken', weight: 0.08, score: 100, contribution: 8.0, explanation: '4 Ladelucken vorhanden — Anforderung erfüllt' }, + ], + negativeFactors: [ + { criterion: 'Fläche', weight: 0.23, score: 65, contribution: 14.9, explanation: '1200m² leicht unter Minimum 1500m² (-20%)' }, + ], + tradeoffs: [ + { criterion: 'Fläche', concern: '1200m² vs. Minimum 1500m² — 20% Unterdeckung', severity: 'LOW', mitigation: 'Erweiterungspotenzial auf Nachbargrundstück vorhanden' }, + ], + explainabilitySummary: 'Starker Logistik-Match: Muttenz, Hallenhöhe, Budget und Ladelucken passen. Fläche leicht unter Minimum.', + confidenceLevel: 0.88, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: [], + organizationId: 'org-wincasa', + createdAt: '2026-05-01T08:00:00Z', + updatedAt: '2026-05-01T08:00:00Z', + }, + + { + id: 'match-071', + propertyId: 'prop-d06', + needId: 'need-005', + matchScore: 70, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 75, softFactorScore: 72, confidenceModifier: 0.70, dataQualityModifier: 0.65, totalScore: 70 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.17, score: 95, contribution: 16.2, explanation: '450m² liegt im Zielkorridor (300–700m²)' }, + { criterion: 'Budget', weight: 0.17, score: 100, contribution: 17.0, explanation: 'CHF 260/m²/J deutlich unter Maximum CHF 576/m²/J' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.22, score: 42, contribution: 9.2, explanation: 'Luzern liegt ausserhalb bevorzugter Regionen Zürich/Zug/Baar' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Luzern ist 45 Min. von Zürich entfernt', severity: 'MEDIUM', mitigation: 'Attraktiver Preis und S-Bahn-Direktverbindung könnten Kompromiss sein' }, + ], + explainabilitySummary: 'Fläche und Budget passen sehr gut für TechStart GmbH. Luzern ist nicht ihre Wunschregion, aber preislich attraktiv.', + confidenceLevel: 0.70, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Standort ausserhalb Präferenz', 'Inserat inaktiv'], + organizationId: 'org-wincasa', + createdAt: '2026-01-15T09:00:00Z', + updatedAt: '2026-01-15T09:00:00Z', + }, ] diff --git a/src/pages/supply/MyListings.tsx b/src/pages/supply/MyListings.tsx index 695a5bf..92e485e 100644 --- a/src/pages/supply/MyListings.tsx +++ b/src/pages/supply/MyListings.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { memo, useState } from 'react' import { useNavigate } from 'react-router' import { Alert, @@ -10,6 +10,7 @@ import { DialogActions, DialogContent, DialogTitle, + Drawer, IconButton, Switch, Tooltip, @@ -17,6 +18,8 @@ import { } from '@mui/material' import { Plus, Trash2 } from 'lucide-react' import { useProperties, useUpdateProperty, useRemoveProperty } from '../../hooks/useProperties' +import { useMatchesByProperty } from '../../hooks/useMatches' +import { PropertyDetailView } from '../../components/supply' import type { Property } from '../../domain/property' const ASSET_LABELS: Record = { @@ -28,11 +31,131 @@ const ASSET_LABELS: Record = { MIXED: 'Gemischt', } +const GRID_COLS = '2fr 1fr 80px 100px 110px 120px 80px 48px' + function formatDate(iso?: string) { if (!iso) return '–' return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' }) } +function matchColor(score: number): string { + if (score >= 80) return '#1a7a4a' + if (score >= 60) return '#d97706' + return '#dc2626' +} + +interface RowProps { + p: Property + isLast: boolean + onDetail: (id: string) => void + onToggleStatus: (p: Property) => void + onDelete: (p: Property) => void + updatePending: boolean + removePending: boolean +} + +const ListingRow = memo(function ListingRow({ p, isLast, onDetail, onToggleStatus, onDelete, updatePending, removePending }: RowProps) { + const { data: matches = [] } = useMatchesByProperty(p.id) + const topScore = matches.length > 0 ? Math.max(...matches.map(m => m.matchScore)) : 0 + + return ( + onDetail(p.id)} + sx={{ + display: 'grid', + gridTemplateColumns: GRID_COLS, + px: 2, py: 1.25, + alignItems: 'center', + borderBottom: isLast ? 'none' : '1px solid #f1f5f9', + cursor: 'pointer', + '&:hover': { bgcolor: '#f8fafc' }, + transition: 'background 0.1s', + }} + > + {/* Title + type */} + + + {p.title} + + + + + {/* City */} + + {p.location.city} + + + {/* Area */} + + {p.areaSqm.toLocaleString('de-CH')} m² + + + {/* Rent */} + + CHF {p.rentPricePerSqm.toLocaleString('de-CH')} + + + {/* Match count */} + e.stopPropagation()}> + {matches.length > 0 ? ( + `${m.matchScore}% · need-${m.needId.slice(-3)}`).join('\n')}> + + + ) : ( + + )} + + + {/* Created */} + + {formatDate(p.createdAt)} + + + {/* Status toggle */} + e.stopPropagation()}> + + onToggleStatus(p)} + disabled={updatePending} + sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }} + /> + + + + {/* Delete */} + e.stopPropagation()}> + + onDelete(p)} + disabled={removePending} + sx={{ color: '#94a3b8', '&:hover': { color: '#ef4444' } }} + > + + + + + + ) +}) + export default function MyListings() { const navigate = useNavigate() @@ -40,6 +163,7 @@ export default function MyListings() { const updateProperty = useUpdateProperty() const removeProperty = useRemoveProperty() + const [detailId, setDetailId] = useState(null) const [confirmDelete, setConfirmDelete] = useState(null) const [actionError, setActionError] = useState(null) @@ -47,19 +171,13 @@ export default function MyListings() { const next = p.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE' updateProperty.mutate( { id: p.id, input: { status: next } }, - { - onError: () => { - setActionError('Status konnte nicht geändert werden.') - }, - }, + { onError: () => setActionError('Status konnte nicht geändert werden.') }, ) } function handleDelete(p: Property) { removeProperty.mutate(p.id, { - onSuccess: () => { - setConfirmDelete(null) - }, + onSuccess: () => setConfirmDelete(null), onError: () => { setActionError('Inserat konnte nicht gelöscht werden.') setConfirmDelete(null) @@ -68,7 +186,7 @@ export default function MyListings() { } return ( - + Inserate @@ -116,98 +234,46 @@ export default function MyListings() { {/* Header */} - {['Inserat', 'Ort', 'Fläche', 'Preis/m²/J', 'Erstellt', 'Aktiv', ''].map(h => ( + {['Inserat', 'Ort', 'Fläche', 'Preis/m²/J', 'Suchabos', 'Erstellt', 'Aktiv', ''].map(h => ( {h} ))} - {listings.map((p, i) => { - const isLast = i === listings.length - 1 - return ( - - {/* Title + type */} - - - {p.title} - - - - - {/* City */} - - {p.location.city} - - - {/* Area */} - - {p.areaSqm.toLocaleString('de-CH')} m² - - - {/* Rent */} - - CHF {p.rentPricePerSqm.toLocaleString('de-CH')} - - - {/* Created */} - - {formatDate(p.createdAt)} - - - {/* Status toggle */} - - - handleToggleStatus(p)} - disabled={updateProperty.isPending} - sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }} - /> - - - - {/* Delete */} - - - setConfirmDelete(p)} - disabled={removeProperty.isPending} - sx={{ color: '#94a3b8', '&:hover': { color: '#ef4444' } }} - > - - - - - - ) - })} + {listings.map((p, i) => ( + + ))} )} - {/* Delete confirmation dialog */} + {/* Detail / edit drawer */} + setDetailId(null)} + slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560 } } } }} + > + {detailId && ( + setDetailId(null)} /> + )} + + + {/* Delete confirmation */} setConfirmDelete(null)} maxWidth="xs" fullWidth> Inserat löschen?