feat(livia): Runde 10 Prompt 2 — eine Leadliste, dynamische Quellen, zeitliche Relevanz
Livia arbeitet nicht mehr neben dem Leadbestand, sondern in ihn hinein. - Systemzugänge tragen Titel, Beschreibung und Adresse; die Liste zeigt je Eintrag nur noch Aktiv/Inaktiv und Lesen/Schreiben — kein Berechtigungsbalken, kein Auge-Icon, keine Lese-/Schreibgruppen - Die Quellenregistry ist dynamisch: Der Lauf liest genau die aktiven Lesezugänge mit Adresse aus Livias Personalblatt. Für die drei gepflegten Quellen greift weiterhin ihr eigener Leser, für alles andere ein allgemeiner — eine Übersichtsseite, bis zu zehn Unterseiten, kein Spider. Freie Adressen werden auf http/https geprüft und gegen interne Netze gesperrt - Manuell abgelegte PDF- und Word-Dokumente fliessen in denselben Lauf. Text wird beim Ablegen nativ extrahiert (ZIP + Flate über DecompressionStream, ohne neue Abhängigkeit), Datei und Text liegen in IndexedDB - Neuer harter Filter «Zeitliche Relevanz»: das Modell beurteilt die Ereigniszeit der Veränderung, nicht das Publikationsdatum. «unknown» fällt bewusst nicht durch — ein erfundenes Datum wäre die schlechtere Antwort - Quellentypen und Beobachtungsraum sind Chips mit Freitext; jeder Wert lässt sich einzeln entfernen, auch die vorgegebenen - Info-Knopf bei «Zeitliche Relevanz» und «Mindestrelevanz» erklärt, wie der Wert entsteht — ohne erfundene Prozentgewichte - Gefundene Leads gehen in den zentralen Signalbestand statt in eine zweite Ergebnisliste; die Herkunft steht am Signal (origin: RESEARCH_RUN) - Der Lauf erzeugt einen echten Protokolleintrag mit den Zahlen des Laufs - Lead-Detail: zeitliche Einordnung mit Fundstelle, semantisches Suchprofil bei Chancen mit «Matching starten», und bei Risiko ausdrücklich «Kein internes Objekt eindeutig zugeordnet» statt einer beliebigen Mock-Immobilie - Aufgabenliste nach §11 bereinigt, «Unsichere Signale in Review Queue legen» von Nora übernommen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1c4b3f7dd8
commit
30daa77937
33 files changed
+2275
-301
No files matched your search
@@ -1,10 +1,10 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Alert, Box, Chip, Divider, Typography } from '@mui/material'
|
||||
import { CheckCircle2, ExternalLink, MapPin, Ruler } from 'lucide-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 } from '../../lib/constants'
|
||||
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'
|
||||
@@ -31,6 +31,12 @@ interface LeadDetailProps {
|
||||
lead: MarketLead
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,11 +45,22 @@ interface LeadDetailProps {
|
||||
* 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 }: LeadDetailProps) {
|
||||
return <LeadDetailBody key={lead.signal.id} lead={lead} showMatching={showMatching} />
|
||||
function LeadDetail({ lead, showMatching = true, onStartMatching }: LeadDetailProps) {
|
||||
return (
|
||||
<LeadDetailBody
|
||||
key={lead.signal.id}
|
||||
lead={lead}
|
||||
showMatching={showMatching}
|
||||
onStartMatching={onStartMatching}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching: boolean }) {
|
||||
function LeadDetailBody({ lead, showMatching, onStartMatching }: {
|
||||
lead: MarketLead
|
||||
showMatching: boolean
|
||||
onStartMatching?: (signalId: string) => void
|
||||
}) {
|
||||
const { signal, matchingProperties } = lead
|
||||
const sourceLabel = SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type
|
||||
const companyName = signal.companyName ?? signal.locationHint
|
||||
@@ -133,19 +150,95 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Bei Risiko: das betroffene Mietverhältnis benennen (§2) */}
|
||||
{risiko && (signal.affectedTenant || signal.propertyId) && (
|
||||
{/*
|
||||
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 Mietverhältnis
|
||||
Betroffenes Objekt
|
||||
</Typography>
|
||||
{signal.affectedTenant && (
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], mb: 0.25 }}>
|
||||
{signal.affectedTenant}
|
||||
</Typography>
|
||||
)}
|
||||
{signal.propertyId && (
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Semantisches Suchprofil (Runde 10, §§2.13B/2.14).
|
||||
|
||||
Es steht nur bei Chance-Leads, weil es einen Flächenbedarf beschreibt —
|
||||
bei einem Risiko gibt es keinen Nachfrager, für den man suchen könnte.
|
||||
Der Text ist derselbe, den Nora abgleicht; erzeugt wird er einmal, von
|
||||
Livia. Ein zweiter Text an dieser Stelle wären zwei Aussagen über
|
||||
denselben Bedarf.
|
||||
*/}
|
||||
{!risiko && signal.semanticSearchProfile && (
|
||||
<Box sx={{ bgcolor: DS_SLATE[50], border: '1px solid #e2e8f0', borderRadius: 1.5, p: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
|
||||
Semantisches Suchprofil
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.65, whiteSpace: 'pre-line' }}>
|
||||
{signal.semanticSearchProfile}
|
||||
</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 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user