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>
364 lines
17 KiB
TypeScript
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 }
|