From ba8dc394834c8ffde03b7de18d96583a1c6a9de8 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sat, 22 Aug 2026 00:35:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(agenten):=20Runde=208=20=E2=80=94=20Livia?= =?UTF-8?q?=20recherchiert,=20Nora=20matcht,=20Ferdi=20liest=20Vertr=C3=A4?= =?UTF-8?q?ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/expose/ExposeLeadSection.tsx | 100 ++++ src/components/expose/index.ts | 5 +- src/components/market-leads/LeadDetail.tsx | 185 ++++++-- .../market-leads/LeadSignalTypeBadge.tsx | 45 ++ .../market-leads/UnifiedLeadList.tsx | 63 ++- src/components/market-leads/index.ts | 3 + src/components/research/ResearchWorkspace.tsx | 88 ++++ src/components/research/index.ts | 3 + .../supply/ReminderContractPanel.tsx | 118 +++++ .../supply/ReminderDetailDrawer.tsx | 20 + src/components/supply/ReminderTypeBadge.tsx | 27 +- src/domain/futureSignal.ts | 33 ++ src/domain/reminder.ts | 61 +++ src/domain/unifiedLead.ts | 9 +- src/hooks/useAgentWorkload.ts | 8 +- src/hooks/useMarketLeads.ts | 20 +- src/hooks/useUnifiedLeads.ts | 4 +- src/lib/agentWorkspaces.ts | 2 +- src/lib/constants.ts | 61 +++ src/lib/ds.ts | 10 + src/lib/leadSignal.ts | 56 +++ src/mock-data/agentDirectory.ts | 2 +- src/mock-data/agents/livia.ts | 431 ++++++++++-------- src/mock-data/futureSignals.ts | 123 +++++ src/mock-data/reminders.ts | 188 ++++++++ src/pages/supply/MarketIntelligence.tsx | 8 +- src/pages/supply/MeineAgenten.tsx | 42 +- src/pages/supply/MyListings.tsx | 107 ++--- .../__tests__/agentWorkloadService.test.ts | 29 +- src/services/agentWorkloadService.ts | 44 +- 30 files changed, 1525 insertions(+), 370 deletions(-) create mode 100644 src/components/expose/ExposeLeadSection.tsx create mode 100644 src/components/market-leads/LeadSignalTypeBadge.tsx create mode 100644 src/components/research/ResearchWorkspace.tsx create mode 100644 src/components/research/index.ts create mode 100644 src/components/supply/ReminderContractPanel.tsx create mode 100644 src/lib/leadSignal.ts diff --git a/src/components/expose/ExposeLeadSection.tsx b/src/components/expose/ExposeLeadSection.tsx new file mode 100644 index 0000000..8492025 --- /dev/null +++ b/src/components/expose/ExposeLeadSection.tsx @@ -0,0 +1,100 @@ +import { useCallback, useMemo, useState } from 'react' +import { Alert, Box, Tab, Tabs } from '@mui/material' +import { LeadTable } from './LeadTable' +import { PanelLoadingState, ErrorState } from '../ui' +import { useExposeLeads } from '../../hooks/useExposeLeads' +import { useProperties } from '../../hooks/useProperties' +import { ExposeLeadStatus } from '../../domain/exposeLead' +import { EXPOSE_LEAD_TABS, EXPOSE_LEAD_TAB_LABELS } from '../../lib/constants' +import type { ExposeLeadTab } from '../../lib/constants' + +interface Props { + /** Scrollcontainer der Seite — die geöffnete Zeile gleitet darin nach oben. */ + scrollRef: React.RefObject +} + +/** + * Der Exposé-Bereich auf Livias Arbeitsplatz (Runde 4, §8; Runde 8, §1). + * + * Aus `MyListings.tsx` herausgelöst, als die Recherche mit Runde 8 zur + * Hauptaufgabe wurde. Die Exposé-Funktion ist damit nachgeordnet, aber + * unverändert vollständig — Leadliste, dreistufiger Assistent, Pflichtfelder + * und Export arbeiten weiter wie zuvor. + */ +export function ExposeLeadSection({ scrollRef }: Props) { + const [tab, setTab] = useState(EXPOSE_LEAD_TABS.ACTIVE) + const [selectedId, setSelectedId] = useState(null) + + const { data: leads = [], isLoading, isError, refetch } = useExposeLeads() + const { data: properties = [] } = useProperties() + + const visible = useMemo(() => { + const wanted = tab === EXPOSE_LEAD_TABS.ARCHIVED ? ExposeLeadStatus.ARCHIVED : ExposeLeadStatus.ACTIVE + return leads + .filter(l => l.status === wanted) + .sort((a, b) => b.receivedAt.localeCompare(a.receivedAt)) + }, [leads, tab]) + + /** + * Beim Öffnen gleitet die gewählte Zeile an den oberen Rand des sichtbaren + * Inhaltsbereichs; die nachfolgenden Leads rücken nach unten und dürfen aus + * dem Blickfeld verschwinden (Runde 4, §8.4). Umgesetzt mit `scrollTo` auf dem + * bestehenden Scrollcontainer — dafür braucht es keine Animationsbibliothek. + * + * Bei `prefers-reduced-motion` wird ohne Bewegung direkt gesprungen. + */ + const handleSelect = useCallback((id: string, row: HTMLElement | null) => { + const next = id === selectedId ? null : id + setSelectedId(next) + if (!next || !row || !scrollRef.current) return + + const container = scrollRef.current + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches + const top = container.scrollTop + row.getBoundingClientRect().top - container.getBoundingClientRect().top + + // Nach dem Zustandswechsel scrollen, sonst misst der Browser die alte Höhe. + requestAnimationFrame(() => { + container.scrollTo({ top, behavior: reduced ? 'auto' : 'smooth' }) + }) + }, [selectedId, scrollRef]) + + const handleTabChange = useCallback((next: ExposeLeadTab) => { + setSelectedId(null) + setTab(next) + }, []) + + if (isLoading) return + if (isError) return refetch()} /> + + return ( + + + Nachgeordnete Aufgabe. Livias Schwerpunkt liegt seit Runde 8 auf der Marktrecherche — + Exposés entstehen weiterhin hier, wenn ein Objekt vermarktet werden soll. + + + handleTabChange(v)} + sx={{ mb: 2, '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9rem' } }} + > + {Object.values(EXPOSE_LEAD_TABS).map(value => ( + + ))} + + + + + ) +} diff --git a/src/components/expose/index.ts b/src/components/expose/index.ts index f38eb7f..e4419ee 100644 --- a/src/components/expose/index.ts +++ b/src/components/expose/index.ts @@ -1,7 +1,10 @@ -// Property On — Livia, Exposé Master. Barrel-Export (CLAUDE.md §13). +// Property On — Livia, Exposé-Bereich (nachgeordnet seit Runde 8). Barrel-Export (CLAUDE.md §13). export { LeadTable, LEAD_COLS } from './LeadTable' export { LeadWorkspace } from './LeadWorkspace' +// Runde 8, §1: der Exposé-Bereich als nachgeordneter Abschnitt auf Livias Seite. +export { ExposeLeadSection } from './ExposeLeadSection' + export { ExposeWizard } from './ExposeWizard' export { ExposeMediaManager } from './ExposeMediaManager' export { ExposeFieldGroup } from './ExposeFieldGroup' diff --git a/src/components/market-leads/LeadDetail.tsx b/src/components/market-leads/LeadDetail.tsx index 7539c06..be59ff6 100644 --- a/src/components/market-leads/LeadDetail.tsx +++ b/src/components/market-leads/LeadDetail.tsx @@ -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 +function LeadDetail({ lead, showMatching = true }: LeadDetailProps) { + return } -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([]) @@ -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 }) { {/* Header — ohne Wahrscheinlichkeitsangabe (§7.3) */} + + + {companyName} @@ -118,14 +137,14 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) { - {/* «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) && ( - + - Das meint Nora + Das hat Livia recherchiert @@ -134,6 +153,33 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) { )} + {/* Warum Chance beziehungsweise Risiko (§2) */} + + + Warum {risiko ? 'Risiko' : 'Chance'} + + + {rationale} + + + + {/* Bei Risiko: das betroffene Mietverhältnis benennen (§2) */} + {risiko && (signal.affectedTenant || signal.propertyId) && ( + + + Betroffenes Mietverhältnis + + {signal.affectedTenant && ( + + {signal.affectedTenant} + + )} + {signal.propertyId && ( + + )} + + )} + {/* Bestätigte Angaben — unbestätigte Fakten werden weggelassen (§7.3) */} {(signal.confirmedFacts?.length ?? 0) > 0 && ( @@ -151,6 +197,36 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) { )} + {/* 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 */} @@ -169,42 +245,57 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) { )} - - - {/* Passende Objekte im Portfolio */} - - - Passende Objekte im Portfolio ({relevant.length}) + {/* Nächste Handlung — der eigentliche Zweck des Leads (§2) */} + + + Empfohlene nächste Handlung + + + {action} - {relevant.length === 0 ? ( - - Kein Portfolioobjekt über {MIN_MATCH_PCT}% Flächenmatch — manuelle Prüfung empfohlen - - ) : ( - <> - {relevant.map(({ p, fit }) => ( - toggle(p.id)} - /> - ))} - - - )} + {/* 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 }) => ( + toggle(p.id)} + /> + ))} + + + )} + + + )} + {signal.disclaimer && ( {signal.disclaimer} diff --git a/src/components/market-leads/LeadSignalTypeBadge.tsx b/src/components/market-leads/LeadSignalTypeBadge.tsx new file mode 100644 index 0000000..9e6380d --- /dev/null +++ b/src/components/market-leads/LeadSignalTypeBadge.tsx @@ -0,0 +1,45 @@ +import { memo } from 'react' +import { Chip } from '@mui/material' +import { TrendingDown, TrendingUp } from 'lucide-react' +import type { LeadSignalType } from '../../domain/futureSignal' +import { LEAD_SIGNAL_TYPE_LABELS } from '../../lib/constants' +import { DS_COLORS } from '../../lib/ds' + +/** + * Kennzeichen «Chance» oder «Risiko» eines Recherche-Leads (Runde 8, §2). + * + * Eine Komponente für Liste und Detailansicht — derselbe Signaltyp darf an + * zwei Stellen nicht zwei Farben haben. Beschriftung aus `constants.ts`, + * Farben aus `DS_COLORS.leadSignal` (CLAUDE.md §8). + * + * Mit Pfeilsymbol, nicht nur mit Farbe: Rot und Grün allein unterscheiden zu + * wenig, wenn jemand sie nicht auseinanderhalten kann. + */ +export const LeadSignalTypeBadge = memo(function LeadSignalTypeBadge({ + type, +}: { + type: LeadSignalType +}) { + const meta = DS_COLORS.leadSignal[type] + const Icon = type === 'RISIKO' ? TrendingDown : TrendingUp + + return ( + } + label={LEAD_SIGNAL_TYPE_LABELS[type] ?? type} + size="small" + sx={{ + height: 20, + fontSize: '0.68rem', + fontWeight: 700, + letterSpacing: 0.2, + color: meta.fg, + bgcolor: meta.bg, + border: '1px solid', + borderColor: meta.fg, + '& .MuiChip-label': { px: 0.75 }, + '& .MuiChip-icon': { color: meta.fg, ml: 0.5, mr: -0.25 }, + }} + /> + ) +}) diff --git a/src/components/market-leads/UnifiedLeadList.tsx b/src/components/market-leads/UnifiedLeadList.tsx index 59bd19d..5741ff4 100644 --- a/src/components/market-leads/UnifiedLeadList.tsx +++ b/src/components/market-leads/UnifiedLeadList.tsx @@ -3,22 +3,36 @@ import { Box, Button, Skeleton, Typography } from '@mui/material' import { Inbox } from 'lucide-react' import type { UnifiedLead } from '../../domain/unifiedLead' import { LeadChannelBadge } from './LeadChannelBadge' +import { LeadSignalTypeBadge } from './LeadSignalTypeBadge' import { DS_SLATE, DS_TEXT } from '../../lib/ds' -/** Spaltenraster im Stil der übrigen Agentenlisten (Livia, Bruno). */ -const COLS = '120px 1.5fr 1.4fr 150px' +/** + * Spaltenraster im Stil der übrigen Agentenlisten (Bruno, Ferdi). + * + * Seit Runde 8 (§2) mit der Spalte «Signaltyp». Sie steht direkt nach der + * Herkunft, weil Chance oder Risiko darüber entscheidet, ob die Zeile + * überhaupt heute gelesen werden muss. + * + * Auf Livias Arbeitsplatz entfällt die Kanalspalte: dort stammt jeder Eintrag + * aus ihrer eigenen Recherche, und eine Spalte mit immer demselben Wert ist + * keine Information. + */ +const COLS_WITH_CHANNEL = '120px 110px 1.5fr 1.4fr 150px' +const COLS_WITHOUT_CHANNEL = '110px 1.5fr 1.4fr 150px' -const COLUMN_LABELS = ['Kanal', 'Interessent', 'Anliegen', 'Eingang'] +const LABELS_WITH_CHANNEL = ['Kanal', 'Signaltyp', 'Interessent', 'Anliegen', 'Eingang'] +const LABELS_WITHOUT_CHANNEL = ['Signaltyp', 'Unternehmen', 'Veränderung', 'Eingang'] function formatDate(iso: string): string { return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' }) } const LeadRow = memo(function LeadRow({ - lead, selected, onSelect, + lead, selected, showChannel, onSelect, }: { lead: UnifiedLead selected: boolean + showChannel: boolean onSelect: (lead: UnifiedLead) => void }) { return ( @@ -29,7 +43,7 @@ const LeadRow = memo(function LeadRow({ onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(lead) } }} sx={{ display: 'grid', - gridTemplateColumns: COLS, + gridTemplateColumns: showChannel ? COLS_WITH_CHANNEL : COLS_WITHOUT_CHANNEL, alignItems: 'center', gap: 1, px: 2, py: 1.5, @@ -41,8 +55,17 @@ const LeadRow = memo(function LeadRow({ transition: 'background-color 0.1s', }} > + {showChannel && ( + + + + )} + + {/* Signaltyp — nur Recherche-Leads tragen eine Einordnung (§2). */} - + {lead.leadSignalType + ? + : —} @@ -72,30 +95,41 @@ export interface UnifiedLeadListProps { onSelect: (lead: UnifiedLead) => void /** Nur gesetzt, wenn ein Filter aktiv ist — dann bietet der Leerzustand die Rücksetzung an. */ onResetFilter?: () => void + /** Kanalspalte anzeigen. Bei Nora ja, auf Livias Recherche-Liste nein (§1). */ + showChannel?: boolean + /** Text des Leerzustands — bei Livia geht es um Recherche, nicht um Kanäle. */ + emptyText?: string } /** - * Gemeinsame Leadliste aller drei Kanäle (Runde 5, §12). + * Gemeinsame Leadliste (Runde 5, §12; Runde 8, §1–§2). * - * Ersetzt die beiden getrennten Reiterlisten. Jede Zeile trägt ihr - * Kanalkennzeichen ganz links — die Herkunft ist die erste Information, die - * beim Überfliegen zählt, weil sie bestimmt, wie belastbar der Lead ist. + * Dieselbe Komponente trägt Noras Kanalliste und Livias Recherche-Liste. Zwei + * fast gleiche Listen nebeneinander wären zwei Stellen für jede künftige + * Änderung — und genau davor warnt die Vorgabe der Runde. */ -export function UnifiedLeadList({ leads, isLoading, selectedId, onSelect, onResetFilter }: UnifiedLeadListProps) { +export function UnifiedLeadList({ + leads, isLoading, selectedId, onSelect, onResetFilter, + showChannel = true, + emptyText = 'Für diesen Kanaltyp liegen derzeit keine Leads vor.', +}: UnifiedLeadListProps) { + const cols = showChannel ? COLS_WITH_CHANNEL : COLS_WITHOUT_CHANNEL + const labels = showChannel ? LABELS_WITH_CHANNEL : LABELS_WITHOUT_CHANNEL + return ( {/* Kopfzeile */} - {COLUMN_LABELS.map(label => ( + {labels.map(label => ( - Für diesen Kanaltyp liegen derzeit keine Leads vor. + {emptyText} {onResetFilter && (