Files
property-match/src/components/market-leads/LeadDetail.tsx
T
Benjamin SutterandClaude Opus 5 c6011ac093 feat(nora): Runde 10 Prompt 3 — Property Matching gegen den eigenen Bestand
Nora arbeitet jetzt mit dem Objektbestand statt mit Quellen.

- Lead-Detail auf ganzer Seite statt in der Schublade; darunter der neue
  Bereich «Property Match». Derselbe Baustein wie bisher, nur mit einem
  Layoutschalter — keine zweite Detailansicht
- «Das hat Livia recherchiert» heisst «Erstelltes Suchprofil» und zeigt
  lead.semanticSearchProfile. Ein Kasten, nicht zwei: Bestandsleads fallen auf
  ihren bisherigen Text zurück
- Matching-Logik in features/matching/leadPropertyMatcher.ts: Das Suchprofil
  wird nach Merkmalen gelesen, die darin tatsächlich vorkommen, und gegen das
  Objekt geprüft. Ein fehlendes Objektmerkmal geht nicht in die Rechnung ein —
  es gilt als nicht bekannt, nicht als erfüllt. Deterministisch, kein Zufall
- Trefferliste ist dieselbe Tabelle wie «Meine Objekte», ergänzt um eine erste
  Spalte «Match». Die übrigen Spalten werden anteilig gestaucht, damit keine
  waagrechte Bildlaufleiste entsteht
- «Belegte Objekte einbeziehen», Vorgabe aus; Umschalten rechnet neu
- MatchRun als eigene Entität mit Persistenz. Genau ein Eintrag je Lauf, auch
  bei der Übergabe aus Livia — eine Sperre verhindert den zweiten Lauf durch
  Neuaufbau der Komponente
- Aus Livia übergebene Leads starten das Matching einmal; ein normal geöffneter
  Lead startet nichts
- Kennzahlen «Anzahl durchgeführte Matchings» und «Anzahl betroffene Objekte»
  kommen aus dem laufenden Stand, nicht aus dem Katalog
- Aufgaben, Systeme (ERP statt Zefix/SHAB/Webquellen) und Einstellungen auf
  das Matching zugeschnitten
- Info-Knopf erklärt den Match-Wert entlang der tatsächlichen Rechnung

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:02:06 +02:00

364 lines
17 KiB
TypeScript

import { useMemo } from 'react'
import type { ReactNode } from 'react'
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
import { CheckCircle2, ExternalLink, MapPin, Ruler, Target } from 'lucide-react'
import type { MarketLead } from '../../hooks/useMarketLeads'
import { AgentAvatar, ObjectDeepLink } from '../team'
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
import { SOURCE_TYPE_LABELS, TEMPORAL_CLASS_LABELS } from '../../lib/constants'
import { isRisiko, leadSignalTypeOf, signalRationaleOf } from '../../lib/leadSignal'
import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
import { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
import { AiDisclaimer } from '../ui'
import { ContactRow, SignalPropertyRow } from './MarketLeadPanels'
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
const LIVIA = agentWorkspaceById('livia')!
/**
* Detailansicht eines Recherche-Leads.
*
* Seit Runde 8 (§1–§2) trägt sie die Arbeitsteilung sichtbar: der obere Teil —
* Recherche, Begründung, Quellen — stammt von Livia, der Matching-Teil unten
* ist Noras Arbeit. Auf Livias eigenem Arbeitsplatz wird derselbe Baustein mit
* `showMatching={false}` gerendert; damit ist ausgeschlossen, dass die
* Matching-Darstellung versehentlich zu ihr wandert.
*/
// ── Right pane detail ────────────────────────────────────────────────────────
// `MIN_MATCH_PCT` und `confirmableContacts` liegen in `marketLeadHelpers.tsx`.
interface LeadDetailProps {
lead: MarketLead
/**
* Ganze Seite statt Schublade (Runde 10, §3.2).
*
* Nur ein Layoutschalter, keine zweite Ansicht: Dieselben Bausteine in
* derselben Reihenfolge, nur ohne eigenen Bildlauf und mit mehr Platz.
* Eine Vollbildfassung daneben zu bauen hiesse, jede künftige Änderung
* zweimal zu machen.
*/
fullPage?: boolean
/** Was unterhalb der Leadangaben erscheint — bei Nora der Property-Match-Bereich. */
footer?: ReactNode
/** Matching-Block anzeigen. Bei Nora ja, bei Livia nein (§1). */
showMatching?: boolean
/**
* Übergabe an Nora (Runde 10, §2.14). Nur gesetzt, wo der Knopf hingehört —
* auf Noras eigener Seite wäre «Matching starten» hier ein zweiter Weg zu
* derselben Handlung, die dort schon im Kopf des Matching-Bereichs steht.
*/
onStartMatching?: (signalId: string) => void
}
/**
* Wrapper, der die Detailansicht über die Signal-ID neu aufbaut.
*
* Ein neues Signal ist damit eine neue Komponente und startet mit frischem
* Zustand, statt ihn in einem Effekt zurücksetzen zu müssen.
*/
function LeadDetail({ lead, showMatching = true, onStartMatching, fullPage = false, footer }: LeadDetailProps) {
return (
<LeadDetailBody
key={lead.signal.id}
lead={lead}
showMatching={showMatching}
onStartMatching={onStartMatching}
fullPage={fullPage}
footer={footer}
/>
)
}
function LeadDetailBody({ lead, showMatching, onStartMatching, fullPage, footer }: {
lead: MarketLead
showMatching: boolean
onStartMatching?: (signalId: string) => void
fullPage: boolean
footer?: ReactNode
}) {
const { signal, matchingProperties } = lead
const sourceLabel = SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type
const companyName = signal.companyName ?? signal.locationHint
const signalType = leadSignalTypeOf(signal)
const risiko = isRisiko(signal)
const rationale = signalRationaleOf(signal)
// Objektvorschläge unterhalb der Schwelle werden gar nicht erst gezeigt.
const relevant = useMemo(
() =>
matchingProperties
.map(p => ({ p, fit: signal.areaSqmEstimate ? areaFitPct(p.areaSqm, signal.areaSqmEstimate) : null }))
.filter(({ fit }) => fit === null || fit >= MIN_MATCH_PCT),
[matchingProperties, signal.areaSqmEstimate],
)
const contacts = useMemo(() => confirmableContacts(signal.extractedContacts), [signal.extractedContacts])
// Einzige Wahrheit ist `semanticSearchProfile`; die beiden anderen sind der
// Rückfall für Leads, die vor Runde 10 erfasst wurden.
const profilText = signal.semanticSearchProfile ?? signal.aiSummary ?? signal.strategicInterpretation
return (
// Auf der Seite scrollt die Seite, in der Schublade die Schublade.
<Box sx={fullPage ? { width: '100%' } : { height: '100%', overflowY: 'auto' }}>
{/* Header — ohne Wahrscheinlichkeitsangabe (§7.3) */}
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
<LeadSignalTypeBadge type={signalType} />
</Box>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.5 }}>
{companyName}
</Typography>
{signal.title && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{signal.title}
</Typography>
)}
{/* Stats row */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.25 }}>
<Chip icon={<MapPin size={11} />} label={signal.locationHint} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />
{signal.areaSqmEstimate && (
<Chip icon={<Ruler size={11} />} label={`~${signal.areaSqmEstimate.toLocaleString('de-CH')} m²`} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />
)}
<Chip label={`${signal.timeHorizonMonths} Monate`} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />
</Box>
{/* Source link */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.75, px: 1.25, bgcolor: DS_SLATE[50], borderRadius: 1, border: '1px solid #e2e8f0' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], flexShrink: 0 }}>
Quelle: <strong>{sourceLabel}</strong>
{signal.source.publishedAt && ` · ${new Date(signal.source.publishedAt).toLocaleDateString('de-CH')}`}
</Typography>
{signal.source.url && (
<Box
component="a"
href={signal.source.url}
target="_blank"
rel="noopener noreferrer"
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 'auto', color: DS_BRAND.main, fontSize: '0.75rem', fontWeight: 600, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
>
Quelle öffnen <ExternalLink size={11} />
</Box>
)}
</Box>
</Box>
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/*
Das erstellte Suchprofil (Runde 10, §§2.13B/3.1).
Ein Kasten, nicht zwei: Bis Runde 9 hiess er «Das hat Livia
recherchiert» und trug ihre Zusammenfassung; seit Runde 10 steht
darin das Nachfrageprofil, mit dem Nora arbeitet. Beides
nebeneinander zu zeigen wären zwei Texte über denselben Bedarf, und
beim ersten Widerspruch wüsste niemand, welcher gilt.
Der Rückfall auf `aiSummary` ist die Migration für Bestandsleads:
Sie haben kein Profil, aber den Text, der bisher an dieser Stelle
stand — er bleibt sichtbar, statt ein leeres Feld zu hinterlassen.
*/}
{profilText && (
<Box sx={{ bgcolor: DS_SLATE[50], border: '1px solid #e2e8f0', borderRadius: 1.5, p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<AgentAvatar agent={{ id: LIVIA.id, name: LIVIA.name, role: LIVIA.role }} size="small" />
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.primary, fontSize: '0.75rem' }}>
Erstelltes Suchprofil
</Typography>
</Box>
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6, whiteSpace: 'pre-line' }}>
{profilText}
</Typography>
{/*
«Matching starten» übergibt denselben Lead an Nora — über die
Lead-ID, nicht über eine Kopie. Das Kennzeichen im Zustand sagt
Nora, dass der Lauf sofort starten darf; wer den Lead später
normal öffnet, löst nichts von selbst aus (§2.14).
*/}
{onStartMatching && !risiko && (
<Button
variant="contained"
size="small"
startIcon={<Target size={14} aria-hidden />}
onClick={() => onStartMatching(signal.id)}
sx={{ textTransform: 'none', fontWeight: 600, mt: 1.5 }}
>
Matching starten
</Button>
)}
</Box>
)}
{/* Warum Chance beziehungsweise Risiko (§2) */}
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
Warum {risiko ? 'Risiko' : 'Chance'}
</Typography>
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
{rationale}
</Typography>
</Box>
{/*
Bei Risiko: das betroffene Mietverhältnis benennen (§2, Runde 10 §2.13A).
Seit Runde 10 steht der Kasten auch dann, wenn nichts zuordenbar ist —
mit genau diesem Satz. Ein fehlender Kasten liesse offen, ob niemand
nachgesehen hat oder ob nichts betroffen ist; eine beliebige
Mock-Immobilie zu zeigen wäre die schlechtere Antwort von beiden.
*/}
{risiko && (
<Box sx={{ border: '1px solid', borderColor: DS_COLORS.leadSignal.RISIKO.fg, bgcolor: DS_COLORS.leadSignal.RISIKO.bg, borderRadius: 1.5, p: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_COLORS.leadSignal.RISIKO.fg, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
Betroffenes Objekt
</Typography>
{signal.affectedTenant && (
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], mb: 0.25 }}>
{signal.affectedTenant}
</Typography>
)}
{signal.propertyId ? (
<ObjectDeepLink propertyId={signal.propertyId} label="Objekt in «Meine Objekte» öffnen" />
) : (
!signal.affectedTenant && (
<Typography variant="body2" sx={{ color: DS_SLATE[800] }}>
Kein internes Objekt eindeutig zugeordnet.
</Typography>
)
)}
</Box>
)}
{/* Zeitliche Einordnung mit Fundstelle (Runde 10, §2.9) */}
{signal.temporalClass && (
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
Zeitliche Relevanz
</Typography>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={TEMPORAL_CLASS_LABELS[signal.temporalClass]}
size="small"
sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[700], fontSize: '0.75rem', fontWeight: 600 }}
/>
{signal.eventDate && (
<Typography variant="caption" sx={{ color: DS_SLATE[600] }}>
Ereigniszeit laut Quelle: {signal.eventDate}
</Typography>
)}
</Box>
{signal.temporalEvidence && (
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6, mt: 0.75 }}>
{signal.temporalEvidence}
</Typography>
)}
</Box>
)}
{/* Bestätigte Angaben — unbestätigte Fakten werden weggelassen (§7.3) */}
{(signal.confirmedFacts?.length ?? 0) > 0 && (
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
Bestätigte Angaben
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
{signal.confirmedFacts!.map((f, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<CheckCircle2 size={13} color="#64748b" style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_SLATE[800], fontWeight: 500 }}>{f}</Typography>
</Box>
))}
</Box>
</Box>
)}
{/* Weitere Quellen, aus denen Livia verdichtet hat (§1) */}
{(signal.researchSources?.length ?? 0) > 0 && (
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
Weitere Quellen
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{signal.researchSources!.map((s, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[800] }}>
{SOURCE_TYPE_LABELS[s.type] ?? s.type}
{s.publishedAt && ` · ${new Date(s.publishedAt).toLocaleDateString('de-CH')}`}
</Typography>
{s.url && (
<Box
component="a"
href={s.url}
target="_blank"
rel="noopener noreferrer"
sx={{ display: 'flex', alignItems: 'center', gap: 0.4, color: DS_BRAND.main, fontSize: '0.75rem', fontWeight: 600, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
>
öffnen <ExternalLink size={10} />
</Box>
)}
</Box>
))}
</Box>
</Box>
)}
<Divider />
{/* Extrahierte Kontaktdaten */}
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1, fontSize: '0.65rem' }}>
Extrahierte Kontaktdaten
</Typography>
{contacts.length === 0 ? (
<Alert severity="info" sx={{ fontSize: '0.8rem', py: 0.5 }}>
Keine gesicherten Kontaktdaten vorhanden — manuelle Recherche empfohlen
</Alert>
) : (
<Box sx={{ bgcolor: DS_SLATE[50], border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.75, pt: 0.5, pb: 0.25 }}>
{contacts.map((c, i) => <ContactRow key={i} contact={c} />)}
</Box>
)}
</Box>
{/* Matching bleibt Noras Arbeit — auf Livias Seite ausgeblendet (§1). */}
{showMatching && (
<>
<Divider />
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
{risiko
? `Objekte für internes Re-Letting (${relevant.length})`
: `Passende Objekte im Portfolio (${relevant.length})`}
</Typography>
{relevant.length === 0 ? (
<Alert severity="warning" sx={{ fontSize: '0.8rem', py: 0.5 }}>
Kein Portfolioobjekt über {MIN_MATCH_PCT}% Flächenmatch — manuelle Prüfung empfohlen
</Alert>
) : (
relevant.map(({ p, fit }) => (
<SignalPropertyRow key={p.id} p={p} fit={fit} />
))
)}
</Box>
</>
)}
{/*
An dieser Stelle stand bis Runde 9 der Quellen-Vermerk des Signals
(«Aus öffentlichen Meldungen verdichtet …»). Er ist dem einheitlichen
KI-Hinweis gewichen: Die Herkunft steht ohnehin oben bei der Quelle,
und ein Satz je Signal war nie derselbe — der Hinweis auf mögliche
Fehler soll aber überall gleich lauten (§4, §5).
*/}
<AiDisclaimer />
{footer}
</Box>
</Box>
)
}
export { LeadDetail }