feat(agenten): Runde 8 — Livia recherchiert, Nora matcht, Ferdi liest Verträge

Livia wird von der Exposé-Master zur Research- & Market-Intelligence-Agentin.
Sie schaut nach draussen: verknüpfte Newsquellen, Geschäftsberichte,
Handelsregister und amtliche Publikationen werden zu qualitativen Leads
verdichtet — Unternehmen, Veränderung, Ort, Zeitpunkt, Begründung, Quellen.
Die Exposé-Funktion bleibt vollständig erhalten, ist aber nachgeordnet und
liegt im zweiten Reiter ihres Arbeitsplatzes.

Das Matching bleibt ausdrücklich bei Nora. Beide Arbeitsplätze teilen sich
dieselbe Liste (`UnifiedLeadList`) und dieselbe Detailansicht (`LeadDetail`);
bei Livia läuft sie mit `showMatching={false}`. Damit ist die Grenze im Code
gezogen und nicht bloss in der Beschriftung. Es gibt keine zweite Lead-Ablage:
ein Lead, zwei Arbeitsplätze.

Neu ist die Einordnung Chance/Risiko als Spalte in Noras Liste und als Block
in der Detailansicht, je mit Begründung und konkreter nächster Handlung. Bei
einem Risiko wird das betroffene Mietverhältnis benannt und auf internes
Re-Letting hingewiesen. `leadSignalTypeOf()` leitet die Einordnung für
Altbestände aus dem Signaltyp ab, damit keine Migration nötig ist.

Thomas bekommt denselben Chat-Einstieg wie die fünf anderen — dieselbe
Komponente, dieselbe Sticky-Mechanik, keine Sonderlösung. Dafür scrollt seine
Seite als Ganzes; die drei Verwaltungsbereiche behalten ihren inneren Scroll.

Ferdi wird vom Reminder-Agenten zur Contract Intelligence: Kündigungsfristen,
echte und unechte Optionen, Verhandlungsfenster und Vertragsgespräche. Der
Unterschied zwischen echter und unechter Option ist der Kern — die echte
Option hat eine Frist, die verfällt, die unechte nur ein Fenster, in dem
verhandelt werden sollte. Alle neuen Termine orientieren sich am
Entscheidungszeitpunkt, nicht am Vertragsende: `decisionDate` und `dueDate`
liegen bewusst davor.

Auslastungsanzeige nachgezogen: Livia zählt ihre Recherche statt offener
Exposés, Nora den Abgleich statt des Findens.

Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen, 423/423 Tests grün,
Produktionsbuild erfolgreich.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin SutterandClaude Opus 5 committed 2026-08-22 00:35:31 +02:00
1 parent 55274488d1
commit ba8dc39483
30 files changed
+1525 -370

No files matched your search

+138 -47
View File
@@ -4,25 +4,36 @@ import { CheckCircle2, ExternalLink, Forward, MapPin, Ruler } from 'lucide-react
import type { MarketLead } from '../../hooks/useMarketLeads'
import { useCreateExposeLead } from '../../hooks/useExposeLeads'
import { useToastStore } from '../../stores/toastStore'
import { AgentAvatar } from '../team'
import { AgentAvatar, ObjectDeepLink } from '../team'
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
import { SOURCE_TYPE_LABELS } from '../../lib/constants'
import { isRisiko, leadSignalTypeOf, recommendedActionOf, signalRationaleOf } from '../../lib/leadSignal'
import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
import { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
import { ContactRow, SignalPropertyRow } from './MarketLeadPanels'
import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
const NORA = agentWorkspaceById('nora')!
const LIVIA = agentWorkspaceById('livia')!
/**
* Detailansicht eines erkannten Nachfragesignals.
* Detailansicht eines Recherche-Leads.
*
* Eigene Datei — die Liste links und das Detail rechts teilen sich nur die
* Hilfsfunktionen, nicht den Aufbau.
* 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
/** Matching-Block und Weiterleitung anzeigen. Bei Nora ja, bei Livia nein (§1). */
showMatching?: boolean
}
/**
* Wrapper, der die Detailansicht über die Signal-ID neu aufbaut.
*
@@ -30,15 +41,20 @@ const NORA = agentWorkspaceById('nora')!
* zurücksetzen zu müssen — ein neues Signal ist eine neue Komponente, und die
* startet ohnehin ohne Auswahl.
*/
function LeadDetail({ lead }: { lead: MarketLead }) {
return <LeadDetailBody key={lead.signal.id} lead={lead} />
function LeadDetail({ lead, showMatching = true }: LeadDetailProps) {
return <LeadDetailBody key={lead.signal.id} lead={lead} showMatching={showMatching} />
}
function LeadDetailBody({ lead }: { lead: MarketLead }) {
function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching: boolean }) {
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)
const action = recommendedActionOf(signal)
const forwardLead = useCreateExposeLead()
const showToast = useToastStore(s => s.showToast)
const [selectedIds, setSelectedIds] = useState<string[]>([])
@@ -71,7 +87,7 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
{
onSuccess: () => {
setSelectedIds([])
showToast(`An Livia weitergeleitet — ${count} Objekt(e) für ${companyName}.`, 'success')
showToast(`Zur Exposé-Erstellung vorgemerkt — ${count} Objekt(e) für ${companyName}.`, 'success')
},
},
)
@@ -81,6 +97,9 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
<Box sx={{ 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 }}>
<LeadSignalTypeBadge type={signalType} />
</Box>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.5 }}>
{companyName}
</Typography>
@@ -118,14 +137,14 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
</Box>
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* «Das meint Nora» — das Analyse-Kästchen bleibt, es trägt jetzt aber
ihr Gesicht statt eines generischen KI-Symbols (§7.3). */}
{/* 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) && (
<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: NORA.id, name: NORA.name, role: NORA.role }} size="small" />
<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 meint Nora
Das hat Livia recherchiert
</Typography>
</Box>
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
@@ -134,6 +153,33 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
</Box>
)}
{/* Warum Chance beziehungsweise Risiko (§2) */}
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
Warum {risiko ? 'Risiko' : 'Chance'}
</Typography>
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
{rationale}
</Typography>
</Box>
{/* Bei Risiko: das betroffene Mietverhältnis benennen (§2) */}
{risiko && (signal.affectedTenant || signal.propertyId) && (
<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
</Typography>
{signal.affectedTenant && (
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], mb: 0.25 }}>
{signal.affectedTenant}
</Typography>
)}
{signal.propertyId && (
<ObjectDeepLink propertyId={signal.propertyId} label="Objekt in «Meine Objekte» öffnen" />
)}
</Box>
)}
{/* Bestätigte Angaben — unbestätigte Fakten werden weggelassen (§7.3) */}
{(signal.confirmedFacts?.length ?? 0) > 0 && (
<Box>
@@ -151,6 +197,36 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
</Box>
)}
{/* Weitere Quellen, aus denen Livia verdichtet hat (§1) */}
{(signal.researchSources?.length ?? 0) > 0 && (
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
Weitere Quellen
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{signal.researchSources!.map((s, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[800] }}>
{SOURCE_TYPE_LABELS[s.type] ?? s.type}
{s.publishedAt && ` · ${new Date(s.publishedAt).toLocaleDateString('de-CH')}`}
</Typography>
{s.url && (
<Box
component="a"
href={s.url}
target="_blank"
rel="noopener noreferrer"
sx={{ display: 'flex', alignItems: 'center', gap: 0.4, color: DS_BRAND.main, fontSize: '0.75rem', fontWeight: 600, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
>
öffnen <ExternalLink size={10} />
</Box>
)}
</Box>
))}
</Box>
</Box>
)}
<Divider />
{/* Extrahierte Kontaktdaten */}
@@ -169,42 +245,57 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
)}
</Box>
<Divider />
{/* Passende Objekte im Portfolio */}
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
Passende Objekte im Portfolio ({relevant.length})
{/* Nächste Handlung — der eigentliche Zweck des Leads (§2) */}
<Box sx={{ bgcolor: 'white', border: '1px solid', borderColor: DS_BRAND.main, borderRadius: 1.5, p: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_BRAND.main, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
Empfohlene nächste Handlung
</Typography>
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
{action}
</Typography>
{relevant.length === 0 ? (
<Alert severity="warning" sx={{ fontSize: '0.8rem', py: 0.5 }}>
Kein Portfolioobjekt über {MIN_MATCH_PCT}% Flächenmatch — manuelle Prüfung empfohlen
</Alert>
) : (
<>
{relevant.map(({ p, fit }) => (
<SignalPropertyRow
key={p.id}
p={p}
fit={fit}
selected={selectedIds.includes(p.id)}
onToggle={() => toggle(p.id)}
/>
))}
<Button
variant="contained"
startIcon={<Forward size={15} />}
disabled={selectedIds.length === 0 || forwardLead.isPending}
onClick={handleForward}
sx={{ mt: 1, textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }}
>
An Livia zur Exposé-Erstellung weiterleiten
{selectedIds.length > 0 && ` (${selectedIds.length})`}
</Button>
</>
)}
</Box>
{/* Matching bleibt Noras Arbeit — auf Livias Seite ausgeblendet (§1). */}
{showMatching && (
<>
<Divider />
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
{risiko
? `Objekte für internes Re-Letting (${relevant.length})`
: `Passende Objekte im Portfolio (${relevant.length})`}
</Typography>
{relevant.length === 0 ? (
<Alert severity="warning" sx={{ fontSize: '0.8rem', py: 0.5 }}>
Kein Portfolioobjekt über {MIN_MATCH_PCT}% Flächenmatch — manuelle Prüfung empfohlen
</Alert>
) : (
<>
{relevant.map(({ p, fit }) => (
<SignalPropertyRow
key={p.id}
p={p}
fit={fit}
selected={selectedIds.includes(p.id)}
onToggle={() => toggle(p.id)}
/>
))}
<Button
variant="contained"
startIcon={<Forward size={15} />}
disabled={selectedIds.length === 0 || forwardLead.isPending}
onClick={handleForward}
sx={{ mt: 1, textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }}
>
Für Exposé-Erstellung vormerken
{selectedIds.length > 0 && ` (${selectedIds.length})`}
</Button>
</>
)}
</Box>
</>
)}
{signal.disclaimer && (
<Typography variant="caption" color="text.disabled" sx={{ lineHeight: 1.4 }}>
{signal.disclaimer}