feat(agenten): Runde 4 — «Meine Agenten» mit fünf digitalen Mitarbeitenden
Setzt das Umsetzungsbriefing Runde 4 im bestehenden Frontend um. Informationsarchitektur - «Teamübersicht» heisst «Meine Agenten»; ihr bisheriger Inhalt (Kreisgrafik, Auswertung, Verbindungszusammenfassung) ist entfallen. - Personalverwaltung, Bearbeitungsverlauf und Kanäle & Systeme sind jetzt Reiter derselben Seite (?section=), gestaltet wie die Auswahl im Bearbeitungsverlauf. Es wird nur der gewählte Bereich gerendert. - Die alten Unterseitenpfade leiten um, damit Lesezeichen nicht brechen. Agentenbestand - Reto und Lea vollständig entfernt — aus Navigation, Dossiers, Protokoll, Verbindungen, Vorgängen und Porträtbestand. - Retos Aufgaben liegen bei Bruno: Nachbereitung, WhatsApp-Anruf, Protokoll und Kundennotiz, Ablage im CRM. - Livia ist «Exposé Master»: Lageberichte, Inserate, Angebotsbroschüren. - Sidebar führt Ferdi, Bruno, Livia, Nora, Sina mit Porträt und Funktion. Agentenseiten - Ein gemeinsamer AgentWorkspaceHero auf allen fünf Seiten. - Ferdi: Auswertungskarten, Priorität und Typfarben entfallen; Fälligkeit nur bei fünf Tagen oder weniger rot; Objektlinks nach «Meine Objekte»; neu die Terminplanung im verbundenen Kalender mit typgerechtem PDF-Ausschnitt. - Sina: reduzierte, filter- und sortierbare Objektübersicht; Detailansicht direkt editierbar, leere Pflichtfelder rot umrandet. - Nora: Signale ohne Prozentsätze und Konfidenzstufen, nur belegbare Angaben; Mehrfachauswahl leitet Objekte an Livia weiter. - Livia: aktive und archivierte Leads, Arbeitsbereich gleitet an den oberen Rand; dreistufiger Exposé-Prozess Hochladen → Exposé → Export. - Bruno: neue Seite mit Auftragsliste und Vor-/Nachbereitungs-Drawer; Glocke warnt bei Besichtigung unter 24 Stunden ohne Bericht. Datenschicht - Neu: Kalender, Exposé-Leads, Exposé-Entwürfe, Besichtigungsaufträge — je Domain, Provider, Service und Hook. - IAIService um generateExposeText erweitert; der Entwurf nutzt ausschliesslich erfasste Objektdaten und meldet Lücken, statt sie zu füllen. - Alle Objektverweise zeigen auf reale Einträge aus «Meine Objekte»; neue Detailroute /supply/properties/:propertyId. Offen: Chat, Kalender, CRM und DMS sind Frontend-Simulation ohne Anbindung. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
cc5582b7fd
commit
d200d4e930
352 files changed
+8069
-8512
No files matched your search
@@ -0,0 +1,219 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
|
||||
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 { agentWorkspaceById } from '../../lib/agentWorkspaces'
|
||||
import { SOURCE_TYPE_LABELS } from '../../lib/constants'
|
||||
import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
|
||||
import { ContactRow, SignalPropertyRow } from './MarketLeadPanels'
|
||||
import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
const NORA = agentWorkspaceById('nora')!
|
||||
|
||||
/**
|
||||
* Detailansicht eines erkannten Nachfragesignals.
|
||||
*
|
||||
* Eigene Datei — die Liste links und das Detail rechts teilen sich nur die
|
||||
* Hilfsfunktionen, nicht den Aufbau.
|
||||
*/
|
||||
|
||||
// ── Right pane detail ────────────────────────────────────────────────────────
|
||||
// `MIN_MATCH_PCT` und `confirmableContacts` liegen in `marketLeadHelpers.tsx`.
|
||||
|
||||
/**
|
||||
* Wrapper, der die Detailansicht über die Signal-ID neu aufbaut.
|
||||
*
|
||||
* Damit ist die Objektauswahl an das Signal gebunden, ohne sie in einem Effekt
|
||||
* 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 LeadDetailBody({ lead }: { lead: MarketLead }) {
|
||||
const { signal, matchingProperties } = lead
|
||||
const sourceLabel = SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type
|
||||
const companyName = signal.companyName ?? signal.locationHint
|
||||
|
||||
const forwardLead = useCreateExposeLead()
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
|
||||
// Objektvorschläge unterhalb der Schwelle werden gar nicht erst gezeigt.
|
||||
const relevant = useMemo(
|
||||
() =>
|
||||
matchingProperties
|
||||
.map(p => ({ p, fit: signal.areaSqmEstimate ? areaFitPct(p.areaSqm, signal.areaSqmEstimate) : null }))
|
||||
.filter(({ fit }) => fit === null || fit >= MIN_MATCH_PCT),
|
||||
[matchingProperties, signal.areaSqmEstimate],
|
||||
)
|
||||
|
||||
const contacts = useMemo(() => confirmableContacts(signal.extractedContacts), [signal.extractedContacts])
|
||||
|
||||
const toggle = useCallback((id: string) => {
|
||||
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
|
||||
}, [])
|
||||
|
||||
function handleForward() {
|
||||
const count = selectedIds.length
|
||||
forwardLead.mutate(
|
||||
{
|
||||
prospect: companyName,
|
||||
contacts: contacts.map(c => c.value),
|
||||
locationHint: signal.locationHint,
|
||||
propertyIds: selectedIds,
|
||||
sourceSignalId: signal.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelectedIds([])
|
||||
showToast(`An Livia weitergeleitet — ${count} Objekt(e) für ${companyName}.`, 'success')
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '100%', overflowY: 'auto' }}>
|
||||
{/* Header — ohne Wahrscheinlichkeitsangabe (§7.3) */}
|
||||
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.5 }}>
|
||||
{companyName}
|
||||
</Typography>
|
||||
{signal.title && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
{signal.title}
|
||||
</Typography>
|
||||
)}
|
||||
{/* Stats row */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.25 }}>
|
||||
<Chip icon={<MapPin size={11} />} label={signal.locationHint} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />
|
||||
{signal.areaSqmEstimate && (
|
||||
<Chip icon={<Ruler size={11} />} label={`~${signal.areaSqmEstimate.toLocaleString('de-CH')} m²`} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />
|
||||
)}
|
||||
<Chip label={`${signal.timeHorizonMonths} Monate`} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />
|
||||
</Box>
|
||||
{/* Source link */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.75, px: 1.25, bgcolor: DS_SLATE[50], borderRadius: 1, border: '1px solid #e2e8f0' }}>
|
||||
<Typography variant="caption" sx={{ color: DS_SLATE[500], flexShrink: 0 }}>
|
||||
Quelle: <strong>{sourceLabel}</strong>
|
||||
{signal.source.publishedAt && ` · ${new Date(signal.source.publishedAt).toLocaleDateString('de-CH')}`}
|
||||
</Typography>
|
||||
{signal.source.url && (
|
||||
<Box
|
||||
component="a"
|
||||
href={signal.source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 'auto', color: DS_BRAND.main, fontSize: '0.75rem', fontWeight: 600, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
|
||||
>
|
||||
Quelle öffnen <ExternalLink size={11} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</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). */}
|
||||
{(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" />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.primary, fontSize: '0.75rem' }}>
|
||||
Das meint Nora
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
|
||||
{signal.aiSummary ?? signal.strategicInterpretation}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Bestätigte Angaben — unbestätigte Fakten werden weggelassen (§7.3) */}
|
||||
{(signal.confirmedFacts?.length ?? 0) > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
|
||||
Bestätigte Angaben
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
|
||||
{signal.confirmedFacts!.map((f, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<CheckCircle2 size={13} color="#64748b" style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: DS_SLATE[800], fontWeight: 500 }}>{f}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Extrahierte Kontaktdaten */}
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1, fontSize: '0.65rem' }}>
|
||||
Extrahierte Kontaktdaten
|
||||
</Typography>
|
||||
{contacts.length === 0 ? (
|
||||
<Alert severity="info" sx={{ fontSize: '0.8rem', py: 0.5 }}>
|
||||
Keine gesicherten Kontaktdaten vorhanden — manuelle Recherche empfohlen
|
||||
</Alert>
|
||||
) : (
|
||||
<Box sx={{ bgcolor: DS_SLATE[50], border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.75, pt: 0.5, pb: 0.25 }}>
|
||||
{contacts.map((c, i) => <ContactRow key={i} contact={c} />)}
|
||||
</Box>
|
||||
)}
|
||||
</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})
|
||||
</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>
|
||||
|
||||
{signal.disclaimer && (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ lineHeight: 1.4 }}>
|
||||
{signal.disclaimer}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export { LeadDetail }
|
||||
Reference in new issue
Block a user