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 (
)
}
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.
{/* Header — ohne Wahrscheinlichkeitsangabe (§7.3) */}
{companyName}
{signal.title && (
{signal.title}
)}
{/* Stats row */}
} label={signal.locationHint} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />
{signal.areaSqmEstimate && (
} label={`~${signal.areaSqmEstimate.toLocaleString('de-CH')} m²`} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />
)}
{/* Source link */}
Quelle: {sourceLabel}
{signal.source.publishedAt && ` · ${new Date(signal.source.publishedAt).toLocaleDateString('de-CH')}`}
{signal.source.url && (
Quelle öffnen
)}
{/*
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 && (
Erstelltes Suchprofil
{profilText}
{/*
«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 && (
}
onClick={() => onStartMatching(signal.id)}
sx={{ textTransform: 'none', fontWeight: 600, mt: 1.5 }}
>
Matching starten
)}
)}
{/* Warum Chance beziehungsweise Risiko (§2) */}
Warum {risiko ? 'Risiko' : 'Chance'}
{rationale}
{/*
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 && (
Betroffenes Objekt
{signal.affectedTenant && (
{signal.affectedTenant}
)}
{signal.propertyId ? (
) : (
!signal.affectedTenant && (
Kein internes Objekt eindeutig zugeordnet.
)
)}
)}
{/* Zeitliche Einordnung mit Fundstelle (Runde 10, §2.9) */}
{signal.temporalClass && (
Zeitliche Relevanz
{signal.eventDate && (
Ereigniszeit laut Quelle: {signal.eventDate}
)}
{signal.temporalEvidence && (
{signal.temporalEvidence}
)}
)}
{/* Bestätigte Angaben — unbestätigte Fakten werden weggelassen (§7.3) */}
{(signal.confirmedFacts?.length ?? 0) > 0 && (
Bestätigte Angaben
{signal.confirmedFacts!.map((f, i) => (
{f}
))}
)}
{/* Weitere Quellen, aus denen Livia verdichtet hat (§1) */}
{(signal.researchSources?.length ?? 0) > 0 && (
Weitere Quellen
{signal.researchSources!.map((s, i) => (
{SOURCE_TYPE_LABELS[s.type] ?? s.type}
{s.publishedAt && ` · ${new Date(s.publishedAt).toLocaleDateString('de-CH')}`}
{s.url && (
öffnen
)}
))}
)}
{/* Extrahierte Kontaktdaten */}
Extrahierte Kontaktdaten
{contacts.length === 0 ? (
Keine gesicherten Kontaktdaten vorhanden — manuelle Recherche empfohlen
) : (
{contacts.map((c, i) => )}
)}
{/* Matching bleibt Noras Arbeit — auf Livias Seite ausgeblendet (§1). */}
{showMatching && (
<>
{risiko
? `Objekte für internes Re-Letting (${relevant.length})`
: `Passende Objekte im Portfolio (${relevant.length})`}
{relevant.length === 0 ? (
Kein Portfolioobjekt über {MIN_MATCH_PCT}% Flächenmatch — manuelle Prüfung empfohlen
) : (
relevant.map(({ p, fit }) => (
))
)}
>
)}
{/*
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).
*/}
{footer}
)
}
export { LeadDetail }