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 && (