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>
This commit is contained in:
1 parent
314a6a36e3
commit
c6011ac093
15 files changed
+1338
-275
No files matched your search
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
@@ -29,6 +30,17 @@ const LIVIA = agentWorkspaceById('livia')!
|
||||
|
||||
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
|
||||
/**
|
||||
@@ -45,21 +57,25 @@ 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, onStartMatching }: LeadDetailProps) {
|
||||
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 }: {
|
||||
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
|
||||
@@ -80,8 +96,13 @@ function LeadDetailBody({ lead, showMatching, onStartMatching }: {
|
||||
|
||||
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 (
|
||||
<Box sx={{ height: '100%', overflowY: 'auto' }}>
|
||||
// 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 }}>
|
||||
@@ -124,19 +145,48 @@ function LeadDetailBody({ lead, showMatching, onStartMatching }: {
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{/* Die Recherche stammt seit Runde 8 von Livia — das Kästchen trägt
|
||||
deshalb ihr Gesicht. Der Matching-Teil weiter unten bleibt Noras. */}
|
||||
{(signal.aiSummary ?? signal.strategicInterpretation) && (
|
||||
{/*
|
||||
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' }}>
|
||||
Das hat Livia recherchiert
|
||||
Erstelltes Suchprofil
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
|
||||
{signal.aiSummary ?? signal.strategicInterpretation}
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -205,43 +255,6 @@ function LeadDetailBody({ lead, showMatching, onStartMatching }: {
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Bestätigte Angaben — unbestätigte Fakten werden weggelassen (§7.3) */}
|
||||
{(signal.confirmedFacts?.length ?? 0) > 0 && (
|
||||
@@ -339,6 +352,8 @@ function LeadDetailBody({ lead, showMatching, onStartMatching }: {
|
||||
Fehler soll aber überall gleich lauten (§4, §5).
|
||||
*/}
|
||||
<AiDisclaimer />
|
||||
|
||||
{footer}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user