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:
Benjamin SutterandClaude Opus 5 committed 2026-09-13 00:02:06 +02:00
1 parent 314a6a36e3
commit c6011ac093
15 files changed
+1338 -275

No files matched your search

+61 -46
View File
@@ -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>
)