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:
Benjamin SutterandClaude Opus 5 committed 2026-08-05 14:43:16 +02:00
1 parent cc5582b7fd
commit d200d4e930
352 files changed
+8069 -8512

No files matched your search

+219
View File
@@ -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 }