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 && ( )} )} {/* 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 }