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

+100
View File
@@ -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<HTMLDivElement | null>
}
/**
* 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<ExposeLeadTab>(EXPOSE_LEAD_TABS.ACTIVE)
const [selectedId, setSelectedId] = useState<string | null>(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 <PanelLoadingState />
if (isError) return <ErrorState message="Die Leads konnten nicht geladen werden." onRetry={() => refetch()} />
return (
<Box>
<Alert severity="info" sx={{ mb: 2, fontSize: '0.8rem', py: 0.5 }}>
Nachgeordnete Aufgabe. Livias Schwerpunkt liegt seit Runde 8 auf der Marktrecherche —
Exposés entstehen weiterhin hier, wenn ein Objekt vermarktet werden soll.
</Alert>
<Tabs
value={tab}
onChange={(_, v: ExposeLeadTab) => handleTabChange(v)}
sx={{ mb: 2, '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9rem' } }}
>
{Object.values(EXPOSE_LEAD_TABS).map(value => (
<Tab key={value} value={value} label={EXPOSE_LEAD_TAB_LABELS[value]} />
))}
</Tabs>
<LeadTable
leads={visible}
properties={properties}
selectedId={selectedId}
onSelect={handleSelect}
withWorkspace={tab === EXPOSE_LEAD_TABS.ACTIVE}
emptyText={
tab === EXPOSE_LEAD_TABS.ACTIVE
? 'Keine aktiven Exposé-Aufträge. Über die Detailansicht eines Leads bei Nora lassen sich Objekte hierher vormerken.'
: 'Noch keine archivierten Leads.'
}
/>
</Box>
)
}
+4 -1
View File
@@ -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 { LeadTable, LEAD_COLS } from './LeadTable'
export { LeadWorkspace } from './LeadWorkspace' 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 { ExposeWizard } from './ExposeWizard'
export { ExposeMediaManager } from './ExposeMediaManager' export { ExposeMediaManager } from './ExposeMediaManager'
export { ExposeFieldGroup } from './ExposeFieldGroup' export { ExposeFieldGroup } from './ExposeFieldGroup'
+138 -47
View File
@@ -4,25 +4,36 @@ import { CheckCircle2, ExternalLink, Forward, MapPin, Ruler } from 'lucide-react
import type { MarketLead } from '../../hooks/useMarketLeads' import type { MarketLead } from '../../hooks/useMarketLeads'
import { useCreateExposeLead } from '../../hooks/useExposeLeads' import { useCreateExposeLead } from '../../hooks/useExposeLeads'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import { AgentAvatar } from '../team' import { AgentAvatar, ObjectDeepLink } from '../team'
import { agentWorkspaceById } from '../../lib/agentWorkspaces' import { agentWorkspaceById } from '../../lib/agentWorkspaces'
import { SOURCE_TYPE_LABELS } from '../../lib/constants' import { SOURCE_TYPE_LABELS } from '../../lib/constants'
import { isRisiko, leadSignalTypeOf, recommendedActionOf, signalRationaleOf } from '../../lib/leadSignal'
import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers' import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
import { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
import { ContactRow, SignalPropertyRow } from './MarketLeadPanels' 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 * Seit Runde 8 (§1–§2) trägt sie die Arbeitsteilung sichtbar: der obere Teil —
* Hilfsfunktionen, nicht den Aufbau. * 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 ──────────────────────────────────────────────────────── // ── Right pane detail ────────────────────────────────────────────────────────
// `MIN_MATCH_PCT` und `confirmableContacts` liegen in `marketLeadHelpers.tsx`. // `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. * 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 * zurücksetzen zu müssen — ein neues Signal ist eine neue Komponente, und die
* startet ohnehin ohne Auswahl. * startet ohnehin ohne Auswahl.
*/ */
function LeadDetail({ lead }: { lead: MarketLead }) { function LeadDetail({ lead, showMatching = true }: LeadDetailProps) {
return <LeadDetailBody key={lead.signal.id} lead={lead} /> 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 { signal, matchingProperties } = lead
const sourceLabel = SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type const sourceLabel = SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type
const companyName = signal.companyName ?? signal.locationHint 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 forwardLead = useCreateExposeLead()
const showToast = useToastStore(s => s.showToast) const showToast = useToastStore(s => s.showToast)
const [selectedIds, setSelectedIds] = useState<string[]>([]) const [selectedIds, setSelectedIds] = useState<string[]>([])
@@ -71,7 +87,7 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
{ {
onSuccess: () => { onSuccess: () => {
setSelectedIds([]) 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' }}> <Box sx={{ height: '100%', overflowY: 'auto' }}>
{/* Header — ohne Wahrscheinlichkeitsangabe (§7.3) */} {/* Header — ohne Wahrscheinlichkeitsangabe (§7.3) */}
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0' }}> <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 }}> <Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.5 }}>
{companyName} {companyName}
</Typography> </Typography>
@@ -118,14 +137,14 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
</Box> </Box>
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}> <Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* «Das meint Nora» — das Analyse-Kästchen bleibt, es trägt jetzt aber {/* Die Recherche stammt seit Runde 8 von Livia — das Kästchen trägt
ihr Gesicht statt eines generischen KI-Symbols (§7.3). */} deshalb ihr Gesicht. Der Matching-Teil weiter unten bleibt Noras. */}
{(signal.aiSummary ?? signal.strategicInterpretation) && ( {(signal.aiSummary ?? signal.strategicInterpretation) && (
<Box sx={{ bgcolor: DS_SLATE[50], border: '1px solid #e2e8f0', borderRadius: 1.5, p: 2 }}> <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 }}> <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' }}> <Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.primary, fontSize: '0.75rem' }}>
Das meint Nora Das hat Livia recherchiert
</Typography> </Typography>
</Box> </Box>
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}> <Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
@@ -134,6 +153,33 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
</Box> </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) */} {/* Bestätigte Angaben — unbestätigte Fakten werden weggelassen (§7.3) */}
{(signal.confirmedFacts?.length ?? 0) > 0 && ( {(signal.confirmedFacts?.length ?? 0) > 0 && (
<Box> <Box>
@@ -151,6 +197,36 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
</Box> </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 /> <Divider />
{/* Extrahierte Kontaktdaten */} {/* Extrahierte Kontaktdaten */}
@@ -169,42 +245,57 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
)} )}
</Box> </Box>
<Divider /> {/* 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 }}>
{/* Passende Objekte im Portfolio */} <Typography variant="caption" sx={{ fontWeight: 700, color: DS_BRAND.main, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
<Box> Empfohlene nächste Handlung
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}> </Typography>
Passende Objekte im Portfolio ({relevant.length}) <Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
{action}
</Typography> </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> </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 && ( {signal.disclaimer && (
<Typography variant="caption" color="text.disabled" sx={{ lineHeight: 1.4 }}> <Typography variant="caption" color="text.disabled" sx={{ lineHeight: 1.4 }}>
{signal.disclaimer} {signal.disclaimer}
@@ -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 (
<Chip
icon={<Icon size={11} />}
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 },
}}
/>
)
})
+49 -14
View File
@@ -3,22 +3,36 @@ import { Box, Button, Skeleton, Typography } from '@mui/material'
import { Inbox } from 'lucide-react' import { Inbox } from 'lucide-react'
import type { UnifiedLead } from '../../domain/unifiedLead' import type { UnifiedLead } from '../../domain/unifiedLead'
import { LeadChannelBadge } from './LeadChannelBadge' import { LeadChannelBadge } from './LeadChannelBadge'
import { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
import { DS_SLATE, DS_TEXT } from '../../lib/ds' 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 { function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' }) return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
} }
const LeadRow = memo(function LeadRow({ const LeadRow = memo(function LeadRow({
lead, selected, onSelect, lead, selected, showChannel, onSelect,
}: { }: {
lead: UnifiedLead lead: UnifiedLead
selected: boolean selected: boolean
showChannel: boolean
onSelect: (lead: UnifiedLead) => void onSelect: (lead: UnifiedLead) => void
}) { }) {
return ( return (
@@ -29,7 +43,7 @@ const LeadRow = memo(function LeadRow({
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(lead) } }} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(lead) } }}
sx={{ sx={{
display: 'grid', display: 'grid',
gridTemplateColumns: COLS, gridTemplateColumns: showChannel ? COLS_WITH_CHANNEL : COLS_WITHOUT_CHANNEL,
alignItems: 'center', alignItems: 'center',
gap: 1, gap: 1,
px: 2, py: 1.5, px: 2, py: 1.5,
@@ -41,8 +55,17 @@ const LeadRow = memo(function LeadRow({
transition: 'background-color 0.1s', transition: 'background-color 0.1s',
}} }}
> >
{showChannel && (
<Box>
<LeadChannelBadge channel={lead.channel} />
</Box>
)}
{/* Signaltyp — nur Recherche-Leads tragen eine Einordnung (§2). */}
<Box> <Box>
<LeadChannelBadge channel={lead.channel} /> {lead.leadSignalType
? <LeadSignalTypeBadge type={lead.leadSignalType} />
: <Typography variant="caption" sx={{ color: DS_TEXT.muted }}>—</Typography>}
</Box> </Box>
<Box sx={{ minWidth: 0 }}> <Box sx={{ minWidth: 0 }}>
@@ -72,30 +95,41 @@ export interface UnifiedLeadListProps {
onSelect: (lead: UnifiedLead) => void onSelect: (lead: UnifiedLead) => void
/** Nur gesetzt, wenn ein Filter aktiv ist — dann bietet der Leerzustand die Rücksetzung an. */ /** Nur gesetzt, wenn ein Filter aktiv ist — dann bietet der Leerzustand die Rücksetzung an. */
onResetFilter?: () => void 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 * Dieselbe Komponente trägt Noras Kanalliste und Livias Recherche-Liste. Zwei
* Kanalkennzeichen ganz links — die Herkunft ist die erste Information, die * fast gleiche Listen nebeneinander wären zwei Stellen für jede künftige
* beim Überfliegen zählt, weil sie bestimmt, wie belastbar der Lead ist. * Ä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 ( return (
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden', bgcolor: 'white' }}> <Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden', bgcolor: 'white' }}>
{/* Kopfzeile */} {/* Kopfzeile */}
<Box <Box
sx={{ sx={{
display: 'grid', display: 'grid',
gridTemplateColumns: COLS, gridTemplateColumns: cols,
gap: 1, gap: 1,
px: 2, py: 1, px: 2, py: 1,
bgcolor: DS_SLATE[50], bgcolor: DS_SLATE[50],
borderBottom: '1px solid #e2e8f0', borderBottom: '1px solid #e2e8f0',
}} }}
> >
{COLUMN_LABELS.map(label => ( {labels.map(label => (
<Typography <Typography
key={label} key={label}
variant="caption" variant="caption"
@@ -118,7 +152,7 @@ export function UnifiedLeadList({ leads, isLoading, selectedId, onSelect, onRese
<Box sx={{ px: 3, py: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5 }}> <Box sx={{ px: 3, py: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5 }}>
<Inbox size={28} color={DS_SLATE[400]} /> <Inbox size={28} color={DS_SLATE[400]} />
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, textAlign: 'center' }}> <Typography variant="body2" sx={{ color: DS_TEXT.secondary, textAlign: 'center' }}>
Für diesen Kanaltyp liegen derzeit keine Leads vor. {emptyText}
</Typography> </Typography>
{onResetFilter && ( {onResetFilter && (
<Button size="small" variant="outlined" onClick={onResetFilter} sx={{ textTransform: 'none' }}> <Button size="small" variant="outlined" onClick={onResetFilter} sx={{ textTransform: 'none' }}>
@@ -132,6 +166,7 @@ export function UnifiedLeadList({ leads, isLoading, selectedId, onSelect, onRese
key={`${lead.channel}-${lead.id}`} key={`${lead.channel}-${lead.id}`}
lead={lead} lead={lead}
selected={lead.id === selectedId} selected={lead.id === selectedId}
showChannel={showChannel}
onSelect={onSelect} onSelect={onSelect}
/> />
))} ))}
+3
View File
@@ -6,6 +6,9 @@ export { CONTACT_ICONS, MIN_MATCH_PCT, areaFitPct, confirmableContacts } from '.
// Runde 5, §12/§13: gemeinsame Liste über KI-Signale, Netzwerk und CRM. // Runde 5, §12/§13: gemeinsame Liste über KI-Signale, Netzwerk und CRM.
export { LeadChannelBadge } from './LeadChannelBadge' export { LeadChannelBadge } from './LeadChannelBadge'
// Runde 8, §2: Chance/Risiko-Kennzeichen, geteilt von Livia und Nora.
export { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
export { LeadChannelFilterBar } from './LeadChannelFilterBar' export { LeadChannelFilterBar } from './LeadChannelFilterBar'
export { UnifiedLeadList } from './UnifiedLeadList' export { UnifiedLeadList } from './UnifiedLeadList'
export { CrmLeadDetail } from './CrmLeadDetail' export { CrmLeadDetail } from './CrmLeadDetail'
@@ -0,0 +1,88 @@
import { useCallback, useMemo, useState } from 'react'
import { Box, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
import { LeadDetail, UnifiedLeadList } from '../market-leads'
import { useUnifiedLeads } from '../../hooks/useUnifiedLeads'
import { LeadChannel } from '../../domain/unifiedLead'
import type { UnifiedLead } from '../../domain/unifiedLead'
import { LeadSignalType } from '../../domain/futureSignal'
import { DS_TEXT } from '../../lib/ds'
/**
* Livias Recherche-Arbeitsplatz (Runde 8, §1).
*
* Die Liste ist dieselbe Komponente wie bei Nora — bewusst, damit sich beide
* Arbeitsplätze gleich lesen und eine spätere Änderung nur an einer Stelle
* anfällt. Zwei Unterschiede: die Kanalspalte entfällt, weil hier jeder Eintrag
* aus Livias eigener Recherche stammt, und die Detailansicht läuft mit
* `showMatching={false}`.
*
* Damit ist die Grenze zwischen den beiden Agentinnen im Code gezogen und nicht
* bloss in der Beschriftung: Livia recherchiert und verdichtet, das Matching
* gegen den eigenen Bestand bleibt Noras Arbeit (§1, §2).
*
* Gelesen wird aus demselben Bestand, den Nora anzeigt. Es gibt keine zweite
* Lead-Ablage und keine Übergabekopie — ein Lead, zwei Arbeitsplätze.
*/
export function ResearchWorkspace() {
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const [selected, setSelected] = useState<UnifiedLead | null>(null)
const { data: leads, isLoading } = useUnifiedLeads()
// Zählung und Filterung in einem Durchgang über dieselbe Liste (CLAUDE.md §10.4).
const { research, chancen, risiken } = useMemo(() => {
const rows: UnifiedLead[] = []
let c = 0
let r = 0
for (const lead of leads) {
if (lead.channel !== LeadChannel.KI_SIGNAL) continue
rows.push(lead)
if (lead.leadSignalType === LeadSignalType.RISIKO) r += 1
else c += 1
}
return { research: rows, chancen: c, risiken: r }
}, [leads])
const closeDetail = useCallback(() => setSelected(null), [])
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{isLoading
? 'Leads werden geladen …'
: `${research.length} Leads aus externen Quellen · ${chancen} Chancen, ${risiken} Risiken`}
</Typography>
<UnifiedLeadList
leads={research}
isLoading={isLoading}
selectedId={selected?.id ?? null}
onSelect={setSelected}
showChannel={false}
emptyText="Noch keine Leads aus der Marktbeobachtung. Sobald eine verknüpfte Quelle eine relevante Veränderung meldet, erscheint sie hier."
/>
<Drawer
anchor="right"
open={!!selected}
onClose={closeDetail}
slotProps={{
paper: {
sx: {
width: isMobile ? '100vw' : { md: 520, lg: 600, xl: 660 },
boxShadow: '-4px 0 24px rgba(0,0,0,0.10)',
},
},
}}
>
{selected?.channel === LeadChannel.KI_SIGNAL && (
<LeadDetail
lead={{ signal: selected.signal, matchingProperties: selected.matchingProperties }}
showMatching={false}
/>
)}
</Drawer>
</Box>
)
}
+3
View File
@@ -0,0 +1,3 @@
// Livia — Markt-Recherche (Runde 8, §1). Barrel-Export (CLAUDE.md §13).
export { ResearchWorkspace } from './ResearchWorkspace'
@@ -0,0 +1,118 @@
import { Box, Typography } from '@mui/material'
import { FileSignature } from 'lucide-react'
import type { Reminder } from '../../domain/reminder'
import { ReminderType } from '../../domain/reminder'
import { REMINDER_TYPE_HINTS, REMINDER_TYPE_LABELS } from '../../lib/constants'
import { SectionTitle, DateRow } from './reminderDetailHelpers'
import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
/**
* Der Vertragsteil eines Reminders (Runde 8, §4).
*
* Beantwortet in dieser Reihenfolge: welcher Vertrag und welche Fundstelle,
* welcher Zeitpunkt wurde erkannt, weshalb ist er relevant, was ist zu tun.
*
* Der erkannte Zeitpunkt steht bewusst vor dem Vertragsende — bei einer echten
* Option ist die Ausübungsfrist die Nachricht, das Vertragsende nur ihr Anlass.
* Ein Reminder, der auf das Vertragsende zeigt, kommt für jede Entscheidung zu
* spät, die ihn nötig gemacht hat.
*
* Der Block erscheint nur, wenn tatsächlich Vertragsangaben vorliegen. Ein
* Unterhaltstermin hat keine Fundstelle in einem Mietvertrag, und eine leere
* Überschrift wäre die Behauptung, es fehle etwas.
*/
export function ReminderContractPanel({ reminder }: { reminder: Reminder }) {
const hasWindow = Boolean(reminder.negotiationWindowFrom || reminder.negotiationWindowTo)
const hasContent = Boolean(
reminder.contractRef ||
reminder.decisionDate ||
reminder.relevanceReason ||
reminder.recommendedAction ||
reminder.noticePeriodMonths != null ||
hasWindow,
)
if (!hasContent) return null
const isRealOption = reminder.type === ReminderType.REAL_OPTION
const decisionLabel = isRealOption ? 'Ausübung bis' : 'Entscheid bis'
return (
<Box sx={{ mb: 2.5 }}>
<SectionTitle icon={<FileSignature size={14} color="#64748b" />}>Vertrag</SectionTitle>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{reminder.contractRef && (
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1, alignItems: 'baseline' }}>
<Typography variant="caption" color="text.secondary">Dokument</Typography>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{reminder.contractRef}</Typography>
{reminder.contractClause && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{reminder.contractClause}
</Typography>
)}
</Box>
</Box>
)}
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1, alignItems: 'baseline' }}>
<Typography variant="caption" color="text.secondary">Erkannter Typ</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{REMINDER_TYPE_LABELS[reminder.type] ?? reminder.type}
</Typography>
</Box>
{/* Der massgebliche Zeitpunkt — nicht das Vertragsende. */}
<DateRow label={decisionLabel} value={reminder.decisionDate} />
{reminder.noticePeriodMonths != null && (
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1, alignItems: 'baseline' }}>
<Typography variant="caption" color="text.secondary">Kündigungsfrist</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{reminder.noticePeriodMonths} Monate
</Typography>
</Box>
)}
{hasWindow && (
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1, alignItems: 'baseline' }}>
<Typography variant="caption" color="text.secondary">Verhandlungsfenster</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{reminder.negotiationWindowFrom
? new Date(reminder.negotiationWindowFrom).toLocaleDateString('de-CH')
: 'offen'}
{' – '}
{reminder.negotiationWindowTo
? new Date(reminder.negotiationWindowTo).toLocaleDateString('de-CH')
: 'offen'}
</Typography>
</Box>
)}
</Box>
{/* Weshalb der Zeitpunkt zählt */}
{(reminder.relevanceReason || REMINDER_TYPE_HINTS[reminder.type]) && (
<Box sx={{ mt: 1.75, bgcolor: DS_SLATE[50], border: '1px solid #e2e8f0', borderRadius: 1.5, p: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.5, fontSize: '0.65rem' }}>
Weshalb relevant
</Typography>
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.55 }}>
{reminder.relevanceReason ?? REMINDER_TYPE_HINTS[reminder.type]}
</Typography>
</Box>
)}
{/* Was als Nächstes zu tun ist */}
{reminder.recommendedAction && (
<Box sx={{ mt: 1.25, border: '1px solid', borderColor: DS_BRAND.main, borderRadius: 1.5, p: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_BRAND.main, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.5, fontSize: '0.65rem' }}>
Empfohlene nächste Handlung
</Typography>
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.55 }}>
{reminder.recommendedAction}
</Typography>
</Box>
)}
</Box>
)
}
@@ -13,17 +13,28 @@ import { ReminderTypeBadge } from './ReminderTypeBadge'
import { ReminderDaysIndicator } from './ReminderDaysIndicator' import { ReminderDaysIndicator } from './ReminderDaysIndicator'
import { ReminderStatusBadge } from './ReminderStatusBadge' import { ReminderStatusBadge } from './ReminderStatusBadge'
import { ReminderCalendarPlanner } from './ReminderCalendarPlanner' import { ReminderCalendarPlanner } from './ReminderCalendarPlanner'
import { ReminderContractPanel } from './ReminderContractPanel'
import { ReminderStatus, ReminderType } from '../../domain/reminder' import { ReminderStatus, ReminderType } from '../../domain/reminder'
import { SectionTitle, DateRow } from './reminderDetailHelpers' import { SectionTitle, DateRow } from './reminderDetailHelpers'
import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds' import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
// ── Constants ──────────────────────────────────────────────────────────────── // ── Constants ────────────────────────────────────────────────────────────────
/**
* Das zweite Datum je Termintyp. Bei den Vertragstypen aus Runde 8 (§4) ist das
* bewusst das Vertragsende — als Einordnung, nicht als Fälligkeit. Die
* Fälligkeit steht darüber und liegt früher; genau das ist der Punkt.
*/
const TYPE_SECOND_DATE: Partial<Record<ReminderType, 'contractEndDate' | 'breakOptionDate' | 'eventDate'>> = { const TYPE_SECOND_DATE: Partial<Record<ReminderType, 'contractEndDate' | 'breakOptionDate' | 'eventDate'>> = {
[ReminderType.LEASE_EXPIRY]: 'contractEndDate', [ReminderType.LEASE_EXPIRY]: 'contractEndDate',
[ReminderType.BREAK_OPTION]: 'breakOptionDate', [ReminderType.BREAK_OPTION]: 'breakOptionDate',
[ReminderType.RENT_REVIEW]: 'eventDate', [ReminderType.RENT_REVIEW]: 'eventDate',
[ReminderType.SCHATTENMARKT_RELEASE]: 'eventDate', [ReminderType.SCHATTENMARKT_RELEASE]: 'eventDate',
[ReminderType.NOTICE_PERIOD]: 'contractEndDate',
[ReminderType.REAL_OPTION]: 'contractEndDate',
[ReminderType.UNREAL_OPTION]: 'contractEndDate',
[ReminderType.NEGOTIATION_WINDOW]: 'contractEndDate',
[ReminderType.CONTRACT_TALK]: 'contractEndDate',
} }
const TYPE_SECOND_LABEL: Partial<Record<ReminderType, string>> = { const TYPE_SECOND_LABEL: Partial<Record<ReminderType, string>> = {
@@ -31,6 +42,11 @@ const TYPE_SECOND_LABEL: Partial<Record<ReminderType, string>> = {
[ReminderType.BREAK_OPTION]: 'Break-Option', [ReminderType.BREAK_OPTION]: 'Break-Option',
[ReminderType.RENT_REVIEW]: 'Ereignisdatum', [ReminderType.RENT_REVIEW]: 'Ereignisdatum',
[ReminderType.SCHATTENMARKT_RELEASE]: 'Ereignisdatum', [ReminderType.SCHATTENMARKT_RELEASE]: 'Ereignisdatum',
[ReminderType.NOTICE_PERIOD]: 'Vertragsende',
[ReminderType.REAL_OPTION]: 'Vertragsende',
[ReminderType.UNREAL_OPTION]: 'Vertragsende',
[ReminderType.NEGOTIATION_WINDOW]: 'Vertragsende',
[ReminderType.CONTRACT_TALK]: 'Vertragsende',
} }
// ── Drawer ─────────────────────────────────────────────────────────────────── // ── Drawer ───────────────────────────────────────────────────────────────────
@@ -131,6 +147,10 @@ export function ReminderDetailDrawer() {
<Divider sx={{ mb: 2.5 }} /> <Divider sx={{ mb: 2.5 }} />
{/* 2b. Vertrag — welcher Vertrag, welcher Zeitpunkt, weshalb,
was tun (Runde 8, §4). Erscheint nur bei Vertragsterminen. */}
<ReminderContractPanel reminder={reminder} />
{/* 3. Terminplanung — steht, wo bis Runde 3 der Notizbereich war */} {/* 3. Terminplanung — steht, wo bis Runde 3 der Notizbereich war */}
<Box sx={{ mb: 2.5 }}> <Box sx={{ mb: 2.5 }}>
<ReminderCalendarPlanner reminder={reminder} property={reminderProperty} /> <ReminderCalendarPlanner reminder={reminder} property={reminderProperty} />
+19 -8
View File
@@ -8,6 +8,11 @@ import {
Wrench, Wrench,
Eye, Eye,
Tag, Tag,
CalendarClock,
Handshake,
KeyRound,
MessagesSquare,
Scissors,
} from 'lucide-react' } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import type { ReminderType } from '../../domain/reminder' import type { ReminderType } from '../../domain/reminder'
@@ -20,14 +25,20 @@ import { DS_TEXT } from '../../lib/ds'
* deshalb in derselben Standard-Schriftfarbe; unterschieden wird über das Symbol. * deshalb in derselben Standard-Schriftfarbe; unterschieden wird über das Symbol.
*/ */
const CONFIG: Record<ReminderType, { Icon: LucideIcon; label: string }> = { const CONFIG: Record<ReminderType, { Icon: LucideIcon; label: string }> = {
LEASE_EXPIRY: { Icon: FileText, label: 'Mietablauf' }, LEASE_EXPIRY: { Icon: FileText, label: 'Mietablauf' },
BREAK_OPTION: { Icon: ArrowRightLeft, label: 'Break-Option' }, BREAK_OPTION: { Icon: ArrowRightLeft, label: 'Break-Option' },
RENT_REVIEW: { Icon: TrendingUp, label: 'Mietanpassung' }, RENT_REVIEW: { Icon: TrendingUp, label: 'Mietanpassung' },
INSPECTION: { Icon: ClipboardCheck, label: 'Inspektion' }, INSPECTION: { Icon: ClipboardCheck, label: 'Inspektion' },
INSURANCE_RENEWAL: { Icon: Shield, label: 'Versicherung' }, INSURANCE_RENEWAL: { Icon: Shield, label: 'Versicherung' },
MAINTENANCE: { Icon: Wrench, label: 'Unterhalt' }, MAINTENANCE: { Icon: Wrench, label: 'Unterhalt' },
SCHATTENMARKT_RELEASE:{ Icon: Eye, label: 'Pre-Market' }, SCHATTENMARKT_RELEASE:{ Icon: Eye, label: 'Pre-Market' },
CUSTOM: { Icon: Tag, label: 'Individuell' }, CUSTOM: { Icon: Tag, label: 'Individuell' },
// Contract Intelligence (Runde 8, §4)
NOTICE_PERIOD: { Icon: Scissors, label: 'Kündigungsfrist' },
REAL_OPTION: { Icon: KeyRound, label: 'Echte Option' },
UNREAL_OPTION: { Icon: Handshake, label: 'Unechte Option' },
NEGOTIATION_WINDOW: { Icon: CalendarClock, label: 'Verhandlungsfenster' },
CONTRACT_TALK: { Icon: MessagesSquare, label: 'Vertragsgespräch' },
} }
interface Props { interface Props {
+33
View File
@@ -71,4 +71,37 @@ export interface FutureSignal {
// KI-extracted contact information from web crawler sources // KI-extracted contact information from web crawler sources
extractedContacts?: ExtractedContact[] extractedContacts?: ExtractedContact[]
// ── Recherche-Lead von Livia (Runde 8, §1–§2) ──────────────────────────────
/**
* Chance oder Risiko. Optional, damit bestehende Signale gültig bleiben —
* fehlt der Wert, leitet ihn `leadSignalTypeOf()` aus `signalType` ab.
*/
leadSignalType?: LeadSignalType
/** Warum ist das eine Chance beziehungsweise ein Risiko? */
signalRationale?: string
/** Die konkrete nächste Handlung für den Bewirtschafter — kein Fazit, ein Auftrag. */
recommendedAction?: string
/** Bei Risiko: das Mietverhältnis, das betroffen sein könnte. */
affectedTenant?: string
/** Externe Quellen, aus denen Livia den Lead verdichtet hat. */
researchSources?: SignalSource[]
} }
// ── Chance / Risiko (Runde 8, §2) ────────────────────────────────────────────
/**
* Bewertungsrichtung eines Leads.
*
* Bewusst getrennt von `signalType` und `signalDirection`: der Signaltyp sagt,
* *was* passiert (Expansion, Restrukturierung), die Richtung, ob Fläche gesucht
* oder frei wird. Chance und Risiko beantworten die Frage davor — soll der
* Bewirtschafter etwas gewinnen oder etwas verhindern? Danach richtet sich die
* empfohlene Handlung, und nur deshalb steht die Spalte in der Liste.
*/
export const LeadSignalType = {
CHANCE: 'CHANCE',
RISIKO: 'RISIKO',
} as const
export type LeadSignalType = typeof LeadSignalType[keyof typeof LeadSignalType]
+61
View File
@@ -1,3 +1,17 @@
/**
* Die Termintypen, die Ferdi führt.
*
* Mit Runde 8 (§4) kommen die Vertragstypen dazu: Ferdi verwaltet nicht mehr
* nur allgemeine Erinnerungen, sondern erkennt die Zeitpunkte, an denen ein
* Gewerbemietvertrag eine Entscheidung verlangt.
*
* Die Unterscheidung zwischen echter und unechter Option ist keine Spitzfindigkeit,
* sondern der Kern des Ganzen: die **echte Option** gibt dem Mieter ein einseitiges
* Gestaltungsrecht — verstreicht die Ausübungsfrist, ist das Recht erloschen, und
* zwar unwiderruflich. Die **unechte Option** ist bloss eine Absichtserklärung,
* über eine Fortsetzung zu verhandeln; hier gibt es keine Frist, die verfällt,
* sondern ein Fenster, in dem verhandelt werden sollte.
*/
export const ReminderType = { export const ReminderType = {
LEASE_EXPIRY: 'LEASE_EXPIRY', LEASE_EXPIRY: 'LEASE_EXPIRY',
BREAK_OPTION: 'BREAK_OPTION', BREAK_OPTION: 'BREAK_OPTION',
@@ -7,9 +21,30 @@ export const ReminderType = {
MAINTENANCE: 'MAINTENANCE', MAINTENANCE: 'MAINTENANCE',
SCHATTENMARKT_RELEASE: 'SCHATTENMARKT_RELEASE', SCHATTENMARKT_RELEASE: 'SCHATTENMARKT_RELEASE',
CUSTOM: 'CUSTOM', CUSTOM: 'CUSTOM',
// ── Contract Intelligence (Runde 8, §4) ────────────────────────────────────
/** Kündigungsfrist — ab wann eine Kündigung noch fristgerecht möglich ist. */
NOTICE_PERIOD: 'NOTICE_PERIOD',
/** Echte Option — verbindlicher Ausübungszeitpunkt, danach erloschen. */
REAL_OPTION: 'REAL_OPTION',
/** Unechte Option — Zeitfenster für Verhandlungen über eine Fortsetzung. */
UNREAL_OPTION: 'UNREAL_OPTION',
/** Verhandlungsfenster ohne Option, etwa vor einer Staffelmietanpassung. */
NEGOTIATION_WINDOW: 'NEGOTIATION_WINDOW',
/** Anstehendes Vertragsgespräch mit dem Mieter. */
CONTRACT_TALK: 'CONTRACT_TALK',
} as const } as const
export type ReminderType = typeof ReminderType[keyof typeof ReminderType] export type ReminderType = typeof ReminderType[keyof typeof ReminderType]
/** Welche Vertragstypen aus der Dokumentanalyse stammen (Runde 8, §4). */
export const CONTRACT_REMINDER_TYPES: ReminderType[] = [
ReminderType.NOTICE_PERIOD,
ReminderType.REAL_OPTION,
ReminderType.UNREAL_OPTION,
ReminderType.NEGOTIATION_WINDOW,
ReminderType.CONTRACT_TALK,
]
export const ReminderPriority = { export const ReminderPriority = {
URGENT: 'URGENT', // ≤14 days URGENT: 'URGENT', // ≤14 days
HIGH: 'HIGH', // 15–30 days HIGH: 'HIGH', // 15–30 days
@@ -68,6 +103,32 @@ export interface Reminder {
note?: string note?: string
snoozedUntil?: string // ISO date snoozedUntil?: string // ISO date
// ── Contract Intelligence (Runde 8, §4) ────────────────────────────────────
/**
* Der Zeitpunkt, an dem tatsächlich entschieden werden muss.
*
* Bewusst getrennt von `contractEndDate`: Ferdi orientiert sich nicht am
* Vertragsende, sondern am früheren Entscheidungs-, Kündigungs-, Options-
* oder Verhandlungszeitpunkt. Wer erst am Vertragsende erinnert, erinnert an
* etwas, das nicht mehr zu ändern ist.
*/
decisionDate?: string
/** Kündigungsfrist in Monaten, wie im Vertrag vereinbart. */
noticePeriodMonths?: number
/** Bei unechten Optionen: ab wann verhandelt werden sollte. */
negotiationWindowFrom?: string
/** Bei unechten Optionen: bis wann eine Einigung stehen sollte. */
negotiationWindowTo?: string
/** Bezeichnung des Vertrags oder Nachtrags, aus dem der Termin stammt. */
contractRef?: string
/** Fundstelle im Dokument — Ziffer, Artikel oder Seite. */
contractClause?: string
/** Warum dieser Zeitpunkt relevant ist, in einem Satz. */
relevanceReason?: string
/** Die empfohlene nächste Handlung — kein Fazit, ein Auftrag. */
recommendedAction?: string
activity: ReminderActivity[] activity: ReminderActivity[]
organizationId: string organizationId: string
+8 -1
View File
@@ -1,6 +1,6 @@
import type { MarktHinweis } from './marktHinweis' import type { MarktHinweis } from './marktHinweis'
import type { CrmLead } from './crmLead' import type { CrmLead } from './crmLead'
import type { FutureSignal } from './futureSignal' import type { FutureSignal, LeadSignalType } from './futureSignal'
import type { Property } from './property' import type { Property } from './property'
/** /**
@@ -45,6 +45,13 @@ interface UnifiedLeadBase {
meta: string meta: string
/** ISO-Zeitstempel; einzige gemeinsame Grundlage für die Sortierung. */ /** ISO-Zeitstempel; einzige gemeinsame Grundlage für die Sortierung. */
receivedAt: string receivedAt: string
/**
* Chance oder Risiko (Runde 8, §2). Nur Recherche-Leads von Livia tragen den
* Wert — ein Netzwerk-Hinweis oder ein CRM-Lead ist weder das eine noch das
* andere, und ihn einzufärben hiesse, eine Einordnung zu behaupten, die
* niemand vorgenommen hat.
*/
leadSignalType?: LeadSignalType
} }
export type UnifiedLead = export type UnifiedLead =
+3 -5
View File
@@ -1,7 +1,6 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useReminders } from './useReminders' import { useReminders } from './useReminders'
import { useVisitAssignments } from './useVisitAssignments' import { useVisitAssignments } from './useVisitAssignments'
import { useExposeLeads } from './useExposeLeads'
import { useUnifiedLeads } from './useUnifiedLeads' import { useUnifiedLeads } from './useUnifiedLeads'
import { useProperties } from './useProperties' import { useProperties } from './useProperties'
import { computeAgentWorkload } from '../services/agentWorkloadService' import { computeAgentWorkload } from '../services/agentWorkloadService'
@@ -18,19 +17,18 @@ import type { AgentWorkload } from '../domain/agentWorkload'
export function useAgentWorkload(): { data: Record<string, AgentWorkload>; isLoading: boolean } { export function useAgentWorkload(): { data: Record<string, AgentWorkload>; isLoading: boolean } {
const { data: reminders = [], isLoading: remindersLoading } = useReminders() const { data: reminders = [], isLoading: remindersLoading } = useReminders()
const { data: visits = [], isLoading: visitsLoading } = useVisitAssignments() const { data: visits = [], isLoading: visitsLoading } = useVisitAssignments()
const { data: exposeLeads = [], isLoading: exposeLoading } = useExposeLeads()
const { data: leads, isLoading: leadsLoading } = useUnifiedLeads() const { data: leads, isLoading: leadsLoading } = useUnifiedLeads()
const { data: properties = [], isLoading: propertiesLoading } = useProperties() const { data: properties = [], isLoading: propertiesLoading } = useProperties()
const data = useMemo( const data = useMemo(
() => computeAgentWorkload({ reminders, visits, exposeLeads, leads, properties }), () => computeAgentWorkload({ reminders, visits, leads, properties }),
[reminders, visits, exposeLeads, leads, properties], [reminders, visits, leads, properties],
) )
return { return {
data, data,
isLoading: isLoading:
remindersLoading || visitsLoading || exposeLoading || remindersLoading || visitsLoading ||
leadsLoading || propertiesLoading, leadsLoading || propertiesLoading,
} }
} }
+18 -2
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useFutureSignals } from './useFutureSignals' import { useFutureSignals } from './useFutureSignals'
import { useProperties } from './useProperties' import { useProperties } from './useProperties'
import { isRisiko } from '../lib/leadSignal'
import type { FutureSignal } from '../domain/futureSignal' import type { FutureSignal } from '../domain/futureSignal'
import type { Property } from '../domain/property' import type { Property } from '../domain/property'
@@ -9,14 +10,29 @@ export interface MarketLead {
matchingProperties: Property[] matchingProperties: Property[]
} }
/**
* Die von Livia recherchierten Leads, angereichert um passende Portfolioobjekte.
*
* Seit Runde 8 (§1) ist die Recherche Livias Arbeit und das Matching Noras.
* Beide lesen denselben Signalbestand — es gibt bewusst keine zweite
* Lead-Ablage, sonst müsste jede Weitergabe kopieren und jede Kopie altern.
* Livia zeigt aus diesem Hook nur das Signal, Nora zusätzlich `matchingProperties`.
*
* Aufgenommen werden Nachfragesignale (Chance) sowie ausdrücklich als Risiko
* eingestufte Signale (§2). Ein Risiko betrifft ein bestehendes Mietverhältnis
* und zeigt deshalb in die Gegenrichtung — es aus der Liste zu lassen, hiesse
* genau die Fälle zu verlieren, für die Frühwarnung überhaupt gedacht ist.
*/
export function useMarketLeads(): { data: MarketLead[]; isLoading: boolean } { export function useMarketLeads(): { data: MarketLead[]; isLoading: boolean } {
const { data: signals = [], isLoading: signalsLoading } = useFutureSignals() const { data: signals = [], isLoading: signalsLoading } = useFutureSignals()
const { data: properties = [], isLoading: propertiesLoading } = useProperties() const { data: properties = [], isLoading: propertiesLoading } = useProperties()
const data = useMemo((): MarketLead[] => { const data = useMemo((): MarketLead[] => {
const demandSignals = signals.filter(s => s.signalDirection === 'DEMAND') const leadSignals = signals.filter(
s => s.signalDirection === 'DEMAND' || (s.leadSignalType != null && isRisiko(s)),
)
return demandSignals.map(signal => { return leadSignals.map(signal => {
const signalCity = signal.locationHint.split(',')[0].trim().toLowerCase() const signalCity = signal.locationHint.split(',')[0].trim().toLowerCase()
const matchingProperties = properties.filter(p => { const matchingProperties = properties.filter(p => {
+3 -1
View File
@@ -3,6 +3,7 @@ import { useMarketLeads } from './useMarketLeads'
import { useMarktHinweise } from './useMarktHinweise' import { useMarktHinweise } from './useMarktHinweise'
import { useCrmLeads } from './useCrmLeads' import { useCrmLeads } from './useCrmLeads'
import { LeadChannel } from '../domain/unifiedLead' import { LeadChannel } from '../domain/unifiedLead'
import { leadSignalTypeOf } from '../lib/leadSignal'
import type { UnifiedLead } from '../domain/unifiedLead' import type { UnifiedLead } from '../domain/unifiedLead'
import type { MarktHinweis } from '../domain/marktHinweis' import type { MarktHinweis } from '../domain/marktHinweis'
import type { CrmLead } from '../domain/crmLead' import type { CrmLead } from '../domain/crmLead'
@@ -36,9 +37,10 @@ function fromMarketLead(lead: MarketLead): UnifiedLead {
id: signal.id, id: signal.id,
channel: LeadChannel.KI_SIGNAL, channel: LeadChannel.KI_SIGNAL,
title: signal.companyName ?? signal.locationHint, title: signal.companyName ?? signal.locationHint,
subtitle: signal.title ?? 'Erkanntes Nachfragesignal', subtitle: signal.title ?? 'Erkanntes Marktsignal',
meta: `${signal.locationHint} · Horizont ${signal.timeHorizonMonths} Mo.`, meta: `${signal.locationHint} · Horizont ${signal.timeHorizonMonths} Mo.`,
receivedAt: signal.updatedAt || signal.createdAt, receivedAt: signal.updatedAt || signal.createdAt,
leadSignalType: leadSignalTypeOf(signal),
signal, signal,
matchingProperties: lead.matchingProperties, matchingProperties: lead.matchingProperties,
} }
+1 -1
View File
@@ -25,7 +25,7 @@ export interface AgentWorkspace {
export const AGENT_WORKSPACES: AgentWorkspace[] = [ export const AGENT_WORKSPACES: AgentWorkspace[] = [
{ id: 'ferdi', name: 'Ferdi', role: 'Fristen-Wächter', path: ROUTES.SUPPLY.AGENT_FERDI }, { id: 'ferdi', name: 'Ferdi', role: 'Fristen-Wächter', path: ROUTES.SUPPLY.AGENT_FERDI },
{ id: 'bruno', name: 'Bruno', role: 'Besichtigungsassistent', path: ROUTES.SUPPLY.AGENT_BRUNO }, { id: 'bruno', name: 'Bruno', role: 'Besichtigungsassistent', path: ROUTES.SUPPLY.AGENT_BRUNO },
{ id: 'livia', name: 'Livia', role: 'Exposé Master', path: ROUTES.SUPPLY.AGENT_LIVIA }, { id: 'livia', name: 'Livia', role: 'Research & Market Intelligence', path: ROUTES.SUPPLY.AGENT_LIVIA },
{ id: 'nora', name: 'Nora', role: 'Marktchancen / Leads', path: ROUTES.SUPPLY.AGENT_NORA }, { id: 'nora', name: 'Nora', role: 'Marktchancen / Leads', path: ROUTES.SUPPLY.AGENT_NORA },
{ id: 'sina', name: 'Sina', role: 'Datenpflege', path: ROUTES.SUPPLY.AGENT_SINA }, { id: 'sina', name: 'Sina', role: 'Datenpflege', path: ROUTES.SUPPLY.AGENT_SINA },
] ]
+61
View File
@@ -225,6 +225,30 @@ export const REMINDER_TYPE_LABELS: Record<string, string> = {
MAINTENANCE: 'Unterhalt', MAINTENANCE: 'Unterhalt',
SCHATTENMARKT_RELEASE: 'Pre-Market', SCHATTENMARKT_RELEASE: 'Pre-Market',
CUSTOM: 'Individuell', CUSTOM: 'Individuell',
// Contract Intelligence (Runde 8, §4)
NOTICE_PERIOD: 'Kündigungsfrist',
REAL_OPTION: 'Echte Option',
UNREAL_OPTION: 'Unechte Option',
NEGOTIATION_WINDOW: 'Verhandlungsfenster',
CONTRACT_TALK: 'Vertragsgespräch',
}
/**
* Was der jeweilige Termintyp verlangt — eine Zeile, die den Unterschied
* zwischen echter und unechter Option auch dem erklärt, der ihn nicht kennt
* (Runde 8, §4).
*/
export const REMINDER_TYPE_HINTS: Record<string, string> = {
NOTICE_PERIOD:
'Letzter Zeitpunkt für eine fristgerechte Kündigung. Danach läuft der Vertrag weiter.',
REAL_OPTION:
'Einseitiges Gestaltungsrecht des Mieters mit verbindlicher Ausübungsfrist. Wird die Frist versäumt, erlischt das Recht.',
UNREAL_OPTION:
'Kein Gestaltungsrecht, sondern die Abrede, rechtzeitig über eine Fortsetzung zu verhandeln. Massgeblich ist das Verhandlungsfenster, nicht eine Frist.',
NEGOTIATION_WINDOW:
'Zeitfenster, in dem Vermieter und Mieter über die Fortsetzung sprechen sollten.',
CONTRACT_TALK:
'Vereinbartes oder anstehendes Gespräch zum Vertrag.',
} }
// Reminder status display labels // Reminder status display labels
@@ -270,6 +294,16 @@ export const DATA_QUALITY_LABELS: Record<string, string> = {
/** Hauptmenüpunkt und Titel der Agenten-Hauptseite. */ /** Hauptmenüpunkt und Titel der Agenten-Hauptseite. */
export const MY_AGENTS_LABEL = 'Meine Agenten' export const MY_AGENTS_LABEL = 'Meine Agenten'
/**
* Thomas' Begrüssung über den drei Verwaltungsbereichen (Runde 8, §3).
*
* Ein Satz in Alltagssprache statt einer Funktionsbeschreibung: Thomas ist der
* Vorgesetzte der fünf, und die Seite soll sich anfühlen wie der Gang zu ihm
* und nicht wie eine Systemverwaltung.
*/
export const THOMAS_INTRO =
'Ich führe die fünf digitalen Mitarbeitenden. Hier sehen Sie, wer was macht, was zuletzt erledigt wurde und an welchen Systemen die Agenten hängen.'
/** /**
* Runde 5, §2.2: Der Menüeintrag heisst «Startseite», die Objektverwaltung * Runde 5, §2.2: Der Menüeintrag heisst «Startseite», die Objektverwaltung
* darunter heisst «Meine Objekte». Beide Bezeichnungen zeigen auf dieselbe * darunter heisst «Meine Objekte». Beide Bezeichnungen zeigen auf dieselbe
@@ -316,6 +350,33 @@ export const AGENT_CHAT_PROMPT = 'Wie kann ich dir heute weiterhelfen?'
*/ */
export const AGENT_CHAT_PROMPT_SHORT = 'Wie kann ich dir helfen?' export const AGENT_CHAT_PROMPT_SHORT = 'Wie kann ich dir helfen?'
// ── Livia — Recherche-Leads, Chance/Risiko (Runde 8, §1–§2) ──────────────────
export const LEAD_SIGNAL_TYPE_LABELS: Record<string, string> = {
CHANCE: 'Chance',
RISIKO: 'Risiko',
}
/** Reiter auf Livias Arbeitsplatz. Recherche führt, Exposé ist nachgeordnet (§1). */
export const LIVIA_SECTIONS = {
RESEARCH: 'recherche',
EXPOSE: 'expose',
} as const
export type LiviaSection = typeof LIVIA_SECTIONS[keyof typeof LIVIA_SECTIONS]
export const LIVIA_SECTION_ORDER: LiviaSection[] = [
LIVIA_SECTIONS.RESEARCH,
LIVIA_SECTIONS.EXPOSE,
]
export const LIVIA_SECTION_LABELS: Record<LiviaSection, string> = {
[LIVIA_SECTIONS.RESEARCH]: 'Markt-Recherche',
[LIVIA_SECTIONS.EXPOSE]: 'Exposés',
}
/** Query-Parameter, damit der Zurück-Knopf den Reiter kennt. */
export const LIVIA_SECTION_PARAM = 'bereich'
// ── Livia — Leads und Exposé (Runde 4, §8) ─────────────────────────────────── // ── Livia — Leads und Exposé (Runde 4, §8) ───────────────────────────────────
export const EXPOSE_LEAD_TABS = { export const EXPOSE_LEAD_TABS = {
+10
View File
@@ -40,6 +40,16 @@ export const DS_COLORS = {
LOW: { bg: 'rgba(185,28,28,0.10)', fg: '#b91c1c' }, LOW: { bg: 'rgba(185,28,28,0.10)', fg: '#b91c1c' },
INCOMPLETE: { bg: 'rgba(127,0,0,0.12)', fg: '#7f1d1d' }, INCOMPLETE: { bg: 'rgba(127,0,0,0.12)', fg: '#7f1d1d' },
}, },
/**
* Chance und Risiko eines Recherche-Leads (Runde 8, §2). Bewusst dieselben
* Grün- und Rottöne wie bei Konfidenz und Risiko — im Bestand bedeutet Grün
* schon «gut für uns», ein dritter Farbcode für dieselbe Aussage wäre eine
* neue Vokabel ohne neuen Inhalt.
*/
leadSignal: {
CHANCE: { bg: 'rgba(29,99,66,0.10)', fg: '#1d6342' },
RISIKO: { bg: 'rgba(185,28,28,0.10)', fg: '#b91c1c' },
},
heat: { heat: {
VERY_HOT: { bg: '#fef3c7', border: '#fcd34d', icon: '#a16207', text: '#92400e' }, VERY_HOT: { bg: '#fef3c7', border: '#fcd34d', icon: '#a16207', text: '#92400e' },
HOT: { bg: '#fff7ed', border: '#fed7aa', icon: '#a16207', text: '#92400e' }, HOT: { bg: '#fff7ed', border: '#fed7aa', icon: '#a16207', text: '#92400e' },
+56
View File
@@ -0,0 +1,56 @@
/**
* Property On — Chance/Risiko-Einordnung eines Recherche-Leads (Runde 8, §2).
*
* Die Einordnung steht am Signal selbst (`leadSignalType`). Für Bestände, die
* vor Runde 8 erfasst wurden, leitet `leadSignalTypeOf()` sie aus dem
* Signaltyp ab — so bleibt die Liste vollständig, ohne dass jeder Altbestand
* migriert werden muss.
*
* Die Ableitung folgt einer einzigen Frage: entsteht Flächenbedarf (Chance)
* oder droht Flächenfreisetzung an einem bestehenden Mietverhältnis (Risiko)?
*/
import { SignalType } from '../domain/enums'
import { LeadSignalType } from '../domain/futureSignal'
import type { FutureSignal } from '../domain/futureSignal'
const DERIVED: Record<SignalType, LeadSignalType> = {
[SignalType.EXPANSION]: LeadSignalType.CHANCE,
[SignalType.PROJECT_DEVELOPMENT]: LeadSignalType.CHANCE,
[SignalType.CONSTRUCTION_PROJECT]: LeadSignalType.CHANCE,
[SignalType.RESTRUCTURING]: LeadSignalType.RISIKO,
[SignalType.POSSIBLE_MOVE_OUT]: LeadSignalType.RISIKO,
[SignalType.SPACE_CONSOLIDATION]: LeadSignalType.RISIKO,
[SignalType.LEASE_EXPIRY]: LeadSignalType.RISIKO,
}
/** Erfasster Wert schlägt Ableitung — die Redaktion weiss mehr als die Regel. */
export function leadSignalTypeOf(signal: FutureSignal): LeadSignalType {
return signal.leadSignalType ?? DERIVED[signal.signalType] ?? LeadSignalType.CHANCE
}
export function isRisiko(signal: FutureSignal): boolean {
return leadSignalTypeOf(signal) === LeadSignalType.RISIKO
}
/**
* Rückfalltexte für Begründung und Handlung (Runde 8, §2).
*
* Jeder Lead muss beantworten, warum er eine Chance oder ein Risiko ist und was
* als Nächstes zu tun ist. Steht am Signal nichts Eigenes, greifen diese Texte —
* ein leeres Feld hiesse sonst «keine Handlung nötig», und das ist eine andere
* Aussage als «noch nicht ausformuliert».
*/
export function signalRationaleOf(signal: FutureSignal): string {
if (signal.signalRationale) return signal.signalRationale
return isRisiko(signal)
? 'Negatives externes Signal zu einem Unternehmen, das als Mieter geführt wird oder werden könnte — es deutet auf möglichen Flächenabbau hin.'
: 'Positives externes Signal, das auf neuen oder zusätzlichen Flächenbedarf hindeutet und sich mit dem eigenen Bestand abgleichen lässt.'
}
export function recommendedActionOf(signal: FutureSignal): string {
if (signal.recommendedAction) return signal.recommendedAction
return isRisiko(signal)
? 'Bestehendes Mietverhältnis prüfen und einen möglichen Kündigungs- oder Reduktionsbedarf frühzeitig verifizieren. Die betroffene Fläche für internes Re-Letting vorbereiten, damit Ersatzmieter identifiziert sind, bevor die Kündigung eintrifft.'
: 'Passendes internes Objekt identifizieren, Offerte vorbereiten und den potenziellen Nachfrager kontaktieren, solange die Flächensuche noch offen ist.'
}
+1 -1
View File
@@ -42,7 +42,7 @@ export const agentDirectory: AgentDirectoryEntry[] = [
{ {
id: 'livia', id: 'livia',
name: 'Livia', name: 'Livia',
role: 'Exposé Master', role: 'Research & Market Intelligence',
email: 'livia@property-on.ch', email: 'livia@property-on.ch',
level: AgentLevel.CORE, level: AgentLevel.CORE,
department: AgentDepartment.CENTRAL_SERVICES, department: AgentDepartment.CENTRAL_SERVICES,
+249 -182
View File
@@ -1,8 +1,19 @@
/** /**
* Property On — Personalblatt «Livia», Lage-Analystin (Vermarktung). * Property On — Personalblatt «Livia», Research & Market Intelligence (Vermarktung).
* *
* Fachliche Wahrheit aus dem Agenten-Katalog: Rolle, Personalnummer, E-Mail und * Runde 8, §1: Livia ist neu die Agentin, die nach draussen schaut. Sie liest
* die elf Katalogaufgaben werden hier unverändert abgebildet. * laufend Geschäftsberichte, Unternehmensmeldungen, Handelsregisterdaten und
* Wirtschaftsnachrichten und verdichtet daraus qualitative Leads für die
* Bewirtschaftung — sinngemäss: jeden Tag Zeitung lesen und den Markt verstehen.
*
* Die frühere Exposé- und Vermarktungsfunktion ist damit nicht verschwunden,
* aber nachgeordnet: sie liegt auf Livias Arbeitsplatz im zweiten Reiter. Das
* ist Absicht — ein Exposé entsteht weiterhin bei ihr, es ist nur nicht mehr
* das, wofür sie eingestellt ist.
*
* Ausdrücklich **nicht** bei Livia: das Matching gegen den eigenen Bestand.
* Sie recherchiert und erzeugt Leads; die Verbindung zu internen Flächen ist
* Noras Arbeit (§1, §2).
*/ */
import type { TeamAgent } from '../../domain/teamAgent' import type { TeamAgent } from '../../domain/teamAgent'
@@ -22,7 +33,7 @@ import {
export const liviaAgent: TeamAgent = { export const liviaAgent: TeamAgent = {
id: 'livia', id: 'livia',
name: 'Livia', name: 'Livia',
role: 'Exposé Master', role: 'Research & Market Intelligence',
personnelNumber: 'PO-ZD-21', personnelNumber: 'PO-ZD-21',
department: 'Vermarktung', department: 'Vermarktung',
email: 'livia@property-on.ch', email: 'livia@property-on.ch',
@@ -30,194 +41,182 @@ export const liviaAgent: TeamAgent = {
status: AgentStatus.ACTIVE, status: AgentStatus.ACTIVE,
autonomyLevel: AgentAutonomyLevel.AUTONOMOUS, autonomyLevel: AgentAutonomyLevel.AUTONOMOUS,
shortDescription: shortDescription:
'Erstellt Lageberichte, Inserate sowie Angebots- und Offertenbroschüren aus den Daten und Bildern aus «Meine Objekte».', 'Beobachtet laufend den Markt ausserhalb des eigenen Bestands und verdichtet Geschäftsberichte, Unternehmensmeldungen und Wirtschaftsnachrichten zu handlungsrelevanten Leads.',
autonomyNote: autonomyNote:
'Autonom. Livia recherchiert, schreibt und frischt Lageberichte, Inserate und Exposés ohne vorgängige Freigabe auf. Jede Aussage wird mit Quelle und Abrufdatum protokolliert, damit die Vermarktung jeden Satz nachprüfen kann.', 'Autonom. Livia recherchiert, verdichtet und legt Leads ohne vorgängige Freigabe vor. Sie entscheidet nichts und kontaktiert niemanden — jeder Lead trägt Quelle und Abrufdatum, damit die Bewirtschaftung jede Aussage nachprüfen kann, bevor sie handelt.',
profile: { profile: {
purpose: purpose:
'Livia macht aus Objektdaten, Lageinformationen und vorhandenen Bildern ein verkaufsfähiges Dokument: Lagebericht, Inserat oder vollständige Angebots- beziehungsweise Offertenbroschüre. Grundlage sind ausschliesslich die Objekte aus «Meine Objekte».', 'Livia nimmt der Bewirtschaftung das tägliche Lesen ab. Sie verfolgt Geschäfts- und Jahresberichte, Handelsregistermutationen, Unternehmensmeldungen und Wirtschaftsnachrichten und macht daraus qualitative Leads: welches Unternehmen sich wie verändert, wo, ab wann, und warum daraus Flächenbedarf oder Flächenfreisetzung entstehen könnte.',
input: [ input: [
'Objektdaten und Objektbilder aus «Meine Objekte»', 'Newsseiten und Wirtschaftsnachrichten, als URL verknüpft',
'Leads, die Nora erkannt und ein Bewirtschafter zur Exposé-Erstellung weitergeleitet hat', 'Geschäfts- und Jahresberichte kotierter und privater Unternehmen',
'Objektadresse aus dem Vermarktungsauftrag', 'Handelsregister- und Unternehmensinformationen (Zefix)',
'Öffentliche Umfeld- und Verkehrsdaten zu Quartier, Anbindung und Nahversorgung', 'Amtliche Publikationen zu Bauprojekten, Zonenänderungen und Firmenmutationen',
'Amtliche Publikationen zu Bauprojekten, Zonenänderungen und Umfeldentwicklungen', 'Mitteilungen von Handelskammern und Branchenverbänden',
'Bestehende Objektunterlagen aus der Dokumentenablage', 'Unternehmensmeldungen und Medienmitteilungen',
], ],
coreFlow: [ coreFlow: [
'Adresse aufnehmen und dem richtigen Quartier zuordnen, etwa Zürich Kreis 5 oder Winterthur Neuhegi.', 'Verknüpfte Quellen im eingestellten Rhythmus abgehen und neue Meldungen erfassen.',
'Umfeld im festgelegten Radius abgehen: Nachbarschaft, Erdgeschossnutzungen, Erreichbarkeit, Einkaufen und Verpflegung.', 'Meldung dem Unternehmen und dem Wirtschaftsraum zuordnen, etwa Zürich Kreis 5 oder Basel Dreispitz.',
'Gemeinde und Region einordnen: Wirtschaftsstruktur, Steuerlage, Entwicklung der Nachfrage.', 'Erkennen, um welche Veränderung es geht: Expansion, Personalwachstum, neuer Geschäftsbereich, Investition, Filialexpansion, M&A, Restrukturierung, Standortschliessung oder Einstellung der Geschäftstätigkeit.',
'Amtliche Publikationen und Bauprojekte im Umfeld sichten und bewerten, ob sie für das Objekt relevant sind.', 'Einordnen, ob daraus Flächenbedarf (Chance) oder Flächenfreisetzung an einem bestehenden Mietverhältnis (Risiko) entstehen könnte.',
'Aus den gesammelten Bausteinen einen Kurztext fürs Inserat und einen Langtext fürs Exposé schreiben.', 'Den Lead strukturiert festhalten: Unternehmen, Veränderung, Ort, Zeitpunkt, Begründung, Quellen und Relevanz für das eigene Portfolio.',
'Quellen anhängen, Ergebnis je Objekt ablegen und quartalsweise gegen den aktuellen Stand prüfen.', 'Quellen mit Abrufdatum anhängen und den Lead an die Lead-Liste übergeben, wo Nora ihn gegen den Bestand matcht.',
], ],
output: [ output: [
'Lagebericht mit Mikro-, Makro- und Umfeldteil', 'Qualitative Leads mit Unternehmen, Veränderung, Ort, Zeitpunkt und Begründung',
'Kurztext für Inserat und Portalauftritt', 'Einordnung als Chance oder Risiko, je mit empfohlener nächster Handlung',
'Angebots- beziehungsweise Offertenbroschüre als PDF oder Word',
'Quellenverzeichnis mit Abrufdatum je Aussage', 'Quellenverzeichnis mit Abrufdatum je Aussage',
'Änderungshinweise, sobald sich das Umfeld spürbar verändert', 'Hinweis auf betroffene bestehende Mietverhältnisse bei negativen Signalen',
'Nachgeordnet: Exposés und Angebotsbroschüren aus den Objekten in «Meine Objekte»',
], ],
}, },
responsibilities: [ responsibilities: [
'Lageberichte für kommerziell genutzte Immobilien erstellen und nachvollziehbar belegen', 'Verknüpfte Newsseiten und externe Quellen laufend auswerten',
'Inserate aus den Objektdaten in «Meine Objekte» erstellen', 'Expansionen, neue Niederlassungen und neue Standorte erkennen',
'Angebots- und Offertenbroschüren aus Objektdaten, Lageinformationen und vorhandenen Bildern erstellen', 'Personalwachstum, neue Geschäftsbereiche und Investitionen erkennen',
'Weitergeleitete Leads von Nora zügig zu einem Exposé verarbeiten', 'Retail- und Filialexpansion sowie Produktionsausbau erkennen',
'Erreichbarkeit, Nahversorgung und geplante Umfeldentwicklungen sauber trennen und darstellen', 'M&A-Aktivitäten, Restrukturierungen und Standortschliessungen erkennen',
'Einstellung von Geschäftstätigkeiten und vergleichbare negative Entwicklungen erkennen',
'Jeden Lead als Chance oder Risiko einordnen und die Einordnung begründen',
'Jede Aussage mit einer nachprüfbaren Quelle und einem Abrufdatum hinterlegen', 'Jede Aussage mit einer nachprüfbaren Quelle und einem Abrufdatum hinterlegen',
'Fehlende Pflichtangaben als Lücke ausweisen, statt plausible Werte zu erfinden', 'Keinen Newsfeed erzeugen, sondern verdichten — nur was zu einer Handlung führen kann',
'Lageberichte quartalsweise auffrischen und relevante Abweichungen an die Vermarktung melden', 'Nachgeordnet: Exposés und Angebotsbroschüren aus den Objektdaten erstellen',
], ],
tasks: [ tasks: [
{ {
id: 'livia-task-01', id: 'livia-task-01',
title: 'Objektadresse aufnehmen', title: 'Verknüpfte Quellen abgehen',
description: description:
'Übernimmt Strasse, Hausnummer, Postleitzahl und Ort aus dem Vermarktungsauftrag, prüft die Schreibweise gegen die amtlichen Adressdaten und ordnet das Objekt einem Quartier zu, etwa Zürich Kreis 5, Winterthur Neuhegi oder Basel Gundeldingen.', 'Ruft die hinterlegten Newsseiten, Portale für Unternehmensmeldungen und Branchendienste im eingestellten Rhythmus ab und erfasst neue Meldungen. Bereits verarbeitete Meldungen werden übersprungen, damit derselbe Sachverhalt nicht zweimal als Lead erscheint.',
enabled: true, enabled: true,
schedule: 'bei Eingang eines Vermarktungsauftrags', schedule: 'täglich 06:00',
requiresApproval: false, requiresApproval: false,
dependsOnSystem: AgentSystemType.IMMOTOP2, dependsOnSystem: AgentSystemType.PUBLIC_WEB,
}, },
{ {
id: 'livia-task-02', id: 'livia-task-02',
title: 'Mikrolage analysieren', title: 'Geschäfts- und Jahresberichte auswerten',
description: description:
'Beschreibt das unmittelbare Umfeld im eingestellten Radius: Nachbarschaft, Erdgeschossnutzungen, Lärm- und Immissionslage, Publikumsfrequenz sowie Parkierung. Trennt beobachtbare Fakten klar von Einschätzungen.', 'Liest publizierte Geschäfts- und Jahresberichte auf Aussagen zu Standorten, Kapazitäten, Investitionen und Personalbestand. Zahlen werden mit Berichtsjahr und Fundstelle übernommen, nicht als Trend interpretiert.',
enabled: true, enabled: true,
schedule: 'nach der Adressaufnahme', schedule: 'wöchentlich, montags 06:30',
requiresApproval: false, requiresApproval: false,
dependsOnSystem: AgentSystemType.PUBLIC_WEB, dependsOnSystem: AgentSystemType.PUBLIC_WEB,
}, },
{ {
id: 'livia-task-03', id: 'livia-task-03',
title: 'Makrolage einordnen', title: 'Handelsregister- und Unternehmensinformationen verfolgen',
description: description:
'Ordnet Gemeinde und Wirtschaftsraum ein: Bevölkerungs- und Beschäftigtenentwicklung, Branchenmix, Steuerlage und Bedeutung des Standorts innerhalb der Region, etwa Zug im Wirtschaftsraum Zentralschweiz oder St. Gallen als Zentrum der Ostschweiz.', 'Verfolgt Mutationen im Handelsregister: Neugründungen, Sitzverlegungen, Zweckänderungen, Fusionen und Liquidationen. Eine Sitzverlegung ist der belastbarste Hinweis auf einen Standortwechsel, den es öffentlich gibt.',
enabled: true, enabled: true,
schedule: 'nach der Mikrolage-Analyse', schedule: 'täglich 06:15',
requiresApproval: false, requiresApproval: false,
dependsOnSystem: AgentSystemType.PUBLIC_WEB, dependsOnSystem: AgentSystemType.ZEFIX,
}, },
{ {
id: 'livia-task-04', id: 'livia-task-04',
title: 'Erreichbarkeit und Nahversorgung darstellen', title: 'Amtliche Publikationen sichten',
description: description:
'Stellt Anbindung an öffentlichen Verkehr, Autobahnanschluss, Velo- und Fusswege sowie Einkaufen, Verpflegung, Kinderbetreuung und Gesundheitsversorgung dar — jeweils mit Distanz oder Wegzeit statt mit unbelegten Werbeaussagen.', 'Sichtet amtliche Publikationen zu Baugesuchen, Zonenänderungen und Arealentwicklungen und prüft, ob ein Unternehmen aus dem Beobachtungsbereich betroffen ist.',
enabled: true, enabled: true,
schedule: 'nach der Makrolage-Einordnung', schedule: 'täglich 06:20',
requiresApproval: false,
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
},
{
id: 'livia-task-05',
title: 'Geplante Projekte und Umfeldentwicklungen aufnehmen',
description:
'Sichtet amtliche Publikationen und Bauprojekte im Umfeld — Arealentwicklungen, Zonenänderungen, Verkehrsvorhaben, neue Haltestellen — und hält fest, ab wann sie für das Objekt spürbar werden, etwa das Entwicklungsgebiet Bern Wankdorf.',
enabled: true,
schedule: 'montags 07:00',
requiresApproval: false, requiresApproval: false,
dependsOnSystem: AgentSystemType.SHAB, dependsOnSystem: AgentSystemType.SHAB,
}, },
{ {
id: 'livia-task-06', id: 'livia-task-05',
title: 'Kurzen Inserattext erstellen', title: 'Wachstumssignale erkennen',
description: description:
'Verdichtet die Lagebausteine auf einen Kurztext für Inserat und Portalauftritt, innerhalb des eingestellten Zeichenlimits und in der gewählten Firmen-Tonalität.', 'Erkennt Expansionen, neue Niederlassungen, neue Standorte, Personalwachstum, neue Geschäftsbereiche, Investitionen, Produktionsausbau und Retail-/Filialexpansion. Massgeblich ist die belegte Ankündigung, nicht die Vermutung aus einem Stelleninserat allein.',
enabled: true, enabled: true,
schedule: 'sobald alle Lagebausteine vorliegen', schedule: 'im Anschluss an jeden Quellenlauf',
requiresApproval: false,
},
{
id: 'livia-task-06',
title: 'Negative Entwicklungen erkennen',
description:
'Erkennt M&A-Aktivitäten, Restrukturierungen, Standortschliessungen und die Einstellung von Geschäftstätigkeiten. Diese Signale sind die wertvollsten, weil sie bestehende Mietverhältnisse betreffen und Vorlauf verschaffen.',
enabled: true,
schedule: 'im Anschluss an jeden Quellenlauf',
requiresApproval: false, requiresApproval: false,
}, },
{ {
id: 'livia-task-07', id: 'livia-task-07',
title: 'Ausführlichen Exposé-Text erstellen', title: 'Lead als Chance oder Risiko einordnen',
description: description:
'Schreibt den Langtext für das Exposé mit Mikrolage, Makrolage, Erreichbarkeit, Nahversorgung und Umfeldentwicklung in getrennten Abschnitten, damit sich Interessenten gezielt einlesen können.', 'Ordnet jeden Lead ein: Chance, wenn neuer oder zusätzlicher Flächenbedarf entstehen könnte; Risiko, wenn ein bestehendes Mietverhältnis betroffen sein könnte. Die Einordnung wird begründet, nicht bloss gesetzt.',
enabled: true, enabled: true,
schedule: 'direkt im Anschluss an den Kurztext', schedule: 'bei jedem erzeugten Lead',
requiresApproval: false, requiresApproval: false,
}, },
{ {
id: 'livia-task-08', id: 'livia-task-08',
title: 'Quellen hinterlegen', title: 'Lead strukturiert ausformulieren',
description: description:
'Hängt jeder Aussage die verwendete Quelle mit Bezeichnung und Abrufdatum an. Lässt sich eine Aussage nicht belegen, wird sie gestrichen statt abgeschwächt.', 'Beantwortet je Lead: Welches Unternehmen? Was verändert sich? Wo? Wann? Warum könnte daraus Flächenbedarf oder Flächenfreisetzung entstehen? Welche Quellen sprechen dafür? Kein Fliesstext aus Schlagzeilen, sondern beantwortete Fragen.',
enabled: true, enabled: true,
schedule: 'bei jeder Texterstellung', schedule: 'bei jedem erzeugten Lead',
requiresApproval: false, requiresApproval: false,
}, },
{ {
id: 'livia-task-09', id: 'livia-task-09',
title: 'Ergebnisse je Objekt lokal zwischenspeichern', title: 'Nächste Handlung vorschlagen',
description: description:
'Legt Kurztext, Langtext und Quellenverzeichnis je Objekt in der Dokumentenablage ab, sodass der nächste Arbeitsauftrag auf dem bestehenden Stand aufsetzt und nichts doppelt recherchiert wird.', 'Formuliert je Lead die konkrete nächste Handlung. Bei einer Chance: passendes internes Objekt identifizieren, Offerte vorbereiten, Nachfrager kontaktieren. Bei einem Risiko: Mietverhältnis prüfen, Kündigungsrisiko verifizieren, Fläche fürs Re-Letting vorbereiten.',
enabled: true, enabled: true,
schedule: 'nach jedem abgeschlossenen Arbeitsauftrag', schedule: 'bei jedem erzeugten Lead',
requiresApproval: false, requiresApproval: false,
dependsOnSystem: AgentSystemType.DMS,
}, },
{ {
id: 'livia-task-10', id: 'livia-task-10',
title: 'Quartalsweisen Aktualisierungslauf simulieren', title: 'Quellen hinterlegen',
description: description:
'Führt den «Quartals-Refresh» durch: prüft alle abgelegten Lagebeschriebe gegen den aktuellen Stand der Quellen, markiert veraltete Abschnitte und erneuert sie. Der Lauf wird protokolliert und ist je Objekt nachvollziehbar.', 'Hängt jeder Aussage die verwendete Quelle mit Bezeichnung und Abrufdatum an. Lässt sich eine Aussage nicht belegen, wird sie gestrichen statt abgeschwächt.',
enabled: true, enabled: true,
schedule: 'quartalsweise am ersten Werktag 05:30', schedule: 'bei jedem erzeugten Lead',
requiresApproval: false, requiresApproval: false,
dependsOnSystem: AgentSystemType.DMS,
}, },
{ {
id: 'livia-task-11', id: 'livia-task-11',
title: 'Relevante Änderungen melden', title: 'Lead an die Lead-Liste übergeben',
description: description:
'Meldet der Vermarktung nur, was den Lagebeschrieb wirklich verändert — neue Haltestelle, bewilligte Arealentwicklung, wegfallende Nahversorgung — mit Hinweis auf den betroffenen Textabschnitt und die Quelle.', 'Übergibt den fertigen Lead an die gemeinsame Lead-Liste, in der Nora ihn gegen den eigenen Bestand matcht. Es wird kein zweiter Datensatz angelegt — derselbe Lead, zwei Arbeitsplätze.',
enabled: true, enabled: true,
schedule: 'bei erkannter Abweichung im Aktualisierungslauf', schedule: 'unmittelbar nach dem Ausformulieren',
requiresApproval: false, requiresApproval: false,
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT, dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
}, },
{ {
id: 'livia-task-12', id: 'livia-task-12',
title: 'Weitergeleiteten Lead aufnehmen', title: 'Doppelmeldungen unterdrücken',
description: description:
'Nimmt Leads entgegen, die Nora erkannt und ein Bewirtschafter zur Exposé-Erstellung weitergeleitet hat, samt der mitgegebenen Objektempfehlung. Die Objekte stammen ausschliesslich aus «Meine Objekte».', 'Prüft vor der Übergabe, ob zum selben Unternehmen und Sachverhalt bereits ein Lead vorliegt. Eine zweite Meldung derselben Sache verdrängt die Verdichtung, für die Livia da ist.',
enabled: true, enabled: true,
schedule: 'bei Weiterleitung', schedule: 'vor jeder Übergabe',
requiresApproval: false, requiresApproval: false,
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
}, },
{ {
id: 'livia-task-13', id: 'livia-task-13',
title: 'Objektbilder aus «Meine Objekte» importieren', title: 'Wochenüberblick zustellen',
description: description:
'Importiert alle im Objektbestand hinterlegten Bilder automatisch in das Exposé, vermeidet Doppelimporte und übernimmt Kategorie, Reihenfolge und Titelbild-Markierung, soweit vorhanden.', 'Stellt der Bewirtschaftung montags einen Überblick der Leads der Vorwoche zu, getrennt nach Chance und Risiko, mit den jeweils empfohlenen Handlungen.',
enabled: true, enabled: true,
schedule: 'beim Start eines Exposé-Auftrags', schedule: 'montags 07:00',
requiresApproval: false, requiresApproval: false,
dependsOnSystem: AgentSystemType.IMMOTOP2, dependsOnChannel: AgentChannelType.EMAIL,
}, },
{ {
id: 'livia-task-14', id: 'livia-task-14',
title: 'Exposé-Felder aus den Objektdaten befüllen', title: 'Exposé aus Objektdaten erstellen',
description: description:
'Befüllt Eckdaten, Lage, Flächen, Preise, Ausstattung sowie Energie und Technik aus den in «Meine Objekte» verfügbaren Angaben. Nicht verfügbare Pflichtfelder bleiben leer und werden als fehlend markiert — es werden keine plausiblen Werte erfunden.', 'Nachgeordnete Aufgabe: erstellt aus den Daten und Bildern eines Objekts in «Meine Objekte» ein Exposé beziehungsweise eine Angebotsbroschüre. Nicht verfügbare Pflichtfelder bleiben leer und werden als fehlend markiert — es werden keine plausiblen Werte erfunden.',
enabled: true, enabled: true,
schedule: 'nach dem Bildimport', schedule: 'auf Auslösung im Exposé-Bereich',
requiresApproval: false,
dependsOnSystem: AgentSystemType.IMMOTOP2,
},
{
id: 'livia-task-15',
title: 'Angebots- beziehungsweise Offertenbroschüre erstellen',
description:
'Setzt aus den erfassten Objektdaten, Texten, Bildern und Anhängen die Broschüre in der Firmen-CI zusammen und stellt sie als PDF und Word zum Export bereit. Speichern und Erstellen bleiben getrennte Schritte.',
enabled: true,
schedule: 'auf Auslösung im Exposé-Arbeitsbereich',
requiresApproval: false, requiresApproval: false,
dependsOnSystem: AgentSystemType.DMS, dependsOnSystem: AgentSystemType.DMS,
}, },
@@ -229,12 +228,12 @@ export const liviaAgent: TeamAgent = {
type: AgentChannelType.WORKSPACE_CHAT, type: AgentChannelType.WORKSPACE_CHAT,
direction: AgentChannelDirection.BOTH, direction: AgentChannelDirection.BOTH,
description: description:
'Livia nimmt Objektadressen und Rückfragen im Arbeitsbereich entgegen und liefert Kurztext, Langtext und Änderungshinweise direkt im Kanal der Vermarktung.', 'Livia nimmt Beobachtungsaufträge und Rückfragen im Arbeitsbereich entgegen und legt dort ihre Leads mit Begründung und Quellen ab.',
status: AgentConnectionStatus.CONNECTED, status: AgentConnectionStatus.CONNECTED,
enabled: true, enabled: true,
config: { config: {
displayName: 'Livia — Lage & Umfeld', displayName: 'Livia — Markt & Recherche',
inboxAddress: 'arbeitsbereich/vermarktung/lage', inboxAddress: 'arbeitsbereich/vermarktung/recherche',
defaultRecipients: ['vermarktung@property-on.ch'], defaultRecipients: ['vermarktung@property-on.ch'],
autoReplyEnabled: true, autoReplyEnabled: true,
}, },
@@ -244,7 +243,7 @@ export const liviaAgent: TeamAgent = {
type: AgentChannelType.EMAIL, type: AgentChannelType.EMAIL,
direction: AgentChannelDirection.OUTBOUND, direction: AgentChannelDirection.OUTBOUND,
description: description:
'Versand der fertigen Lagebeschriebe und der Quartalsmeldung an Vermarktung und Bewirtschaftung. Eingehende Mails werden nicht bearbeitet.', 'Versand des Wochenüberblicks und einzelner dringender Leads an Vermarktung und Bewirtschaftung. Eingehende Mails werden nicht bearbeitet.',
status: AgentConnectionStatus.CONNECTED, status: AgentConnectionStatus.CONNECTED,
enabled: true, enabled: true,
config: { config: {
@@ -263,8 +262,18 @@ export const liviaAgent: TeamAgent = {
access: AgentAccessLevel.READ, access: AgentAccessLevel.READ,
status: AgentConnectionStatus.CONNECTED, status: AgentConnectionStatus.CONNECTED,
usage: usage:
'Öffentliche Umfeld- und Verkehrsdaten: Haltestellen und Fahrpläne, Wegzeiten, Einkaufen und Verpflegung, Gemeindeangaben zu Bevölkerung und Beschäftigung.', 'Verknüpfte Newsseiten und Wirtschaftsnachrichten, Geschäfts- und Jahresberichte, Mitteilungen von Handelskammern und Branchenverbänden sowie Unternehmensmeldungen. Die konkreten Adressen stehen in der Einstellung «Verknüpfte Newsquellen».',
permissionNote: 'Nur lesend. Livia ruft ausschliesslich frei zugängliche Seiten ab.', permissionNote:
'Nur lesend. Livia ruft ausschliesslich frei zugängliche Seiten ab und meldet sich nirgends an.',
},
{
id: 'livia-sys-zefix',
type: AgentSystemType.ZEFIX,
access: AgentAccessLevel.READ,
status: AgentConnectionStatus.CONNECTED,
usage:
'Handelsregister- und Unternehmensinformationen: Neugründungen, Sitzverlegungen, Zweckänderungen, Fusionen und Liquidationen.',
permissionNote: 'Nur lesend. Einträge werden als Quelle mit Publikationsdatum übernommen.',
}, },
{ {
id: 'livia-sys-shab', id: 'livia-sys-shab',
@@ -272,7 +281,7 @@ export const liviaAgent: TeamAgent = {
access: AgentAccessLevel.READ, access: AgentAccessLevel.READ,
status: AgentConnectionStatus.CONNECTED, status: AgentConnectionStatus.CONNECTED,
usage: usage:
'Amtliche Publikationen zu Baugesuchen, Zonenänderungen und Arealentwicklungen im Umfeld des Objekts.', 'Amtliche Publikationen zu Baugesuchen, Zonenänderungen, Arealentwicklungen und Firmenmutationen.',
permissionNote: 'Nur lesend. Meldungen werden als Quelle mit Publikationsdatum übernommen.', permissionNote: 'Nur lesend. Meldungen werden als Quelle mit Publikationsdatum übernommen.',
}, },
{ {
@@ -281,8 +290,9 @@ export const liviaAgent: TeamAgent = {
access: AgentAccessLevel.READ, access: AgentAccessLevel.READ,
status: AgentConnectionStatus.CONNECTED, status: AgentConnectionStatus.CONNECTED,
usage: usage:
'Objektstammdaten und Adressen aus der Bewirtschaftung als Ausgangspunkt jedes Lagebeschriebs.', 'Objektstammdaten und Mieterschaft — ausschliesslich, um bei einem negativen Signal zu erkennen, ob ein bestehendes Mietverhältnis betroffen ist.',
permissionNote: 'Nur lesend. Livia verändert keine Stammdaten in der Bewirtschaftungslösung.', permissionNote:
'Nur lesend. Livia verändert keine Stammdaten und nimmt kein Matching gegen den Bestand vor — das ist Noras Aufgabe.',
}, },
{ {
id: 'livia-sys-dms', id: 'livia-sys-dms',
@@ -290,9 +300,9 @@ export const liviaAgent: TeamAgent = {
access: AgentAccessLevel.READ_WRITE, access: AgentAccessLevel.READ_WRITE,
status: AgentConnectionStatus.CONNECTED, status: AgentConnectionStatus.CONNECTED,
usage: usage:
'Ablage von Kurztext, Langtext und Quellenverzeichnis je Objekt sowie Zwischenstand für den Quartalslauf.', 'Ablage der Leads mit Quellenverzeichnis sowie der nachgeordneten Exposés und Angebotsbroschüren.',
permissionNote: permissionNote:
'Schreibt ausschliesslich in den Ordner «Lagebeschriebe» des jeweiligen Objekts; bestehende Fassungen bleiben als Version erhalten.', 'Schreibt ausschliesslich in die Ordner «Marktrecherche» und «Exposés»; bestehende Fassungen bleiben als Version erhalten.',
}, },
{ {
id: 'livia-sys-workspace', id: 'livia-sys-workspace',
@@ -300,39 +310,136 @@ export const liviaAgent: TeamAgent = {
access: AgentAccessLevel.READ_WRITE, access: AgentAccessLevel.READ_WRITE,
status: AgentConnectionStatus.CONNECTED, status: AgentConnectionStatus.CONNECTED,
usage: usage:
'Aufträge, Rückfragen und Änderungshinweise im Arbeitsbereich der Vermarktung, inklusive Protokoll der Aktualisierungsläufe.', 'Leads, Rückfragen und der Wochenüberblick im Arbeitsbereich der Vermarktung, inklusive Protokoll der Quellenläufe.',
permissionNote: permissionNote:
'Schreibt nur in den eigenen Bereich «Lage & Umfeld»; Objektdossiers anderer Teams bleiben unberührt.', 'Schreibt nur in den eigenen Bereich «Markt & Recherche»; Objektdossiers anderer Teams bleiben unberührt.',
}, },
], ],
settings: [ settings: [
{ {
id: 'livia-set-short-limit', id: 'livia-set-news-sources',
label: 'Kurztext-Zeichenlimit', label: 'Verknüpfte Newsquellen',
description: 'Obergrenze für den Lagetext im Inserat und auf den Portalen.', description:
kind: AgentSettingKind.NUMBER, 'Newsseiten und Portale, die Livia täglich abgeht. Weitere Adressen lassen sich hier ergänzen — es braucht keine zweite Quellenverwaltung.',
kind: AgentSettingKind.MULTI_SELECT,
group: AgentSettingGroup.SPECIFIC, group: AgentSettingGroup.SPECIFIC,
value: 600, value: [
unit: 'Zeichen', 'https://www.handelszeitung.ch',
min: 200, 'https://www.nzz.ch/wirtschaft',
max: 1200, 'https://www.finanzundwirtschaft.ch',
'https://www.shab.ch',
],
options: [
{ value: 'https://www.handelszeitung.ch', label: 'Handelszeitung' },
{ value: 'https://www.nzz.ch/wirtschaft', label: 'NZZ Wirtschaft' },
{ value: 'https://www.finanzundwirtschaft.ch', label: 'Finanz und Wirtschaft' },
{ value: 'https://www.shab.ch', label: 'SHAB — Amtliche Publikationen' },
{ value: 'https://www.zefix.ch', label: 'Zefix — Handelsregister' },
{ value: 'https://www.cash.ch/news', label: 'cash — Unternehmensnachrichten' },
{ value: 'https://www.zhk.ch', label: 'Zürcher Handelskammer' },
{ value: 'https://www.hkbb.ch', label: 'Handelskammer beider Basel' },
{ value: 'https://www.moneyhouse.ch', label: 'Moneyhouse — Unternehmensdaten' },
],
}, },
{ {
id: 'livia-set-long-limit', id: 'livia-set-source-types',
label: 'Langtext-Zeichenlimit', label: 'Relevante Quellentypen',
description: 'Obergrenze für den ausführlichen Lageteil im Exposé.', description: 'Welche Arten von Quellen Livia für die Leaderzeugung heranzieht.',
kind: AgentSettingKind.MULTI_SELECT,
group: AgentSettingGroup.SPECIFIC,
value: [
'geschaeftsberichte',
'handelsregister',
'unternehmensmeldungen',
'wirtschaftsnews',
'amtliche_publikationen',
],
options: [
{ value: 'geschaeftsberichte', label: 'Geschäfts- und Jahresberichte' },
{ value: 'handelskammern', label: 'Handelskammern' },
{ value: 'handelsregister', label: 'Handelsregister- und Unternehmensinformationen' },
{ value: 'unternehmensmeldungen', label: 'Unternehmensmeldungen' },
{ value: 'wirtschaftsnews', label: 'Wirtschaftsnews-Anbieter' },
{ value: 'amtliche_publikationen', label: 'Amtliche Publikationen' },
{ value: 'branchendienste', label: 'Markt- und Brancheninformationen' },
],
},
{
id: 'livia-set-signal-types',
label: 'Beobachtete Veränderungen',
description: 'Welche Unternehmensveränderungen Livia als Lead meldet.',
kind: AgentSettingKind.MULTI_SELECT,
group: AgentSettingGroup.SPECIFIC,
value: [
'expansion',
'personalwachstum',
'geschaeftsbereiche',
'investitionen',
'retail_expansion',
'ma',
'restrukturierung',
'schliessung',
'einstellung',
],
options: [
{ value: 'expansion', label: 'Expansion, neue Niederlassungen und Standorte' },
{ value: 'personalwachstum', label: 'Personalwachstum und organisatorisches Wachstum' },
{ value: 'geschaeftsbereiche', label: 'Neue Geschäftsbereiche' },
{ value: 'investitionen', label: 'Investitionen und Produktionsausbau' },
{ value: 'retail_expansion', label: 'Retail- und Filialexpansion' },
{ value: 'ma', label: 'M&A-Aktivitäten' },
{ value: 'restrukturierung', label: 'Restrukturierungen' },
{ value: 'schliessung', label: 'Standortschliessungen' },
{ value: 'einstellung', label: 'Einstellung der Geschäftstätigkeit' },
],
},
{
id: 'livia-set-scan-interval',
label: 'Rhythmus der Quellenläufe',
description: 'Wie oft Livia die verknüpften Quellen abgeht.',
kind: AgentSettingKind.SELECT,
group: AgentSettingGroup.SPECIFIC,
value: 'taeglich',
options: [
{ value: 'stuendlich', label: 'stündlich' },
{ value: 'taeglich', label: 'täglich' },
{ value: 'woechentlich', label: 'wöchentlich' },
],
},
{
id: 'livia-set-observation-area',
label: 'Beobachtungsraum',
description: 'Wirtschaftsräume, für die Livia Meldungen als relevant einstuft.',
kind: AgentSettingKind.MULTI_SELECT,
group: AgentSettingGroup.SPECIFIC,
value: ['zuerich', 'basel', 'zentralschweiz', 'ostschweiz', 'bern'],
options: [
{ value: 'zuerich', label: 'Wirtschaftsraum Zürich' },
{ value: 'basel', label: 'Wirtschaftsraum Basel' },
{ value: 'zentralschweiz', label: 'Zentralschweiz' },
{ value: 'ostschweiz', label: 'Ostschweiz' },
{ value: 'bern', label: 'Bern und Mittelland' },
{ value: 'romandie', label: 'Romandie' },
{ value: 'tessin', label: 'Tessin' },
],
},
{
id: 'livia-set-min-relevance',
label: 'Mindestrelevanz für einen Lead',
description:
'Unterhalb dieses Werts wird eine Meldung nicht zum Lead. Der Schwellenwert ist der Unterschied zwischen Verdichtung und Newsfeed.',
kind: AgentSettingKind.NUMBER, kind: AgentSettingKind.NUMBER,
group: AgentSettingGroup.SPECIFIC, group: AgentSettingGroup.SPECIFIC,
value: 2400, value: 60,
unit: 'Zeichen', unit: '%',
min: 800, min: 0,
max: 5000, max: 100,
}, },
{ {
id: 'livia-set-tonality', id: 'livia-set-tonality',
label: 'Firmen-Tonalität', label: 'Firmen-Tonalität',
description: 'Sprachstil, in dem Kurz- und Langtext verfasst werden.', description: 'Sprachstil der Leadtexte und der nachgeordneten Exposé-Texte.',
kind: AgentSettingKind.SELECT, kind: AgentSettingKind.SELECT,
group: AgentSettingGroup.GENERAL, group: AgentSettingGroup.GENERAL,
value: 'sachlich', value: 'sachlich',
@@ -342,75 +449,35 @@ export const liviaAgent: TeamAgent = {
{ value: 'zurueckhaltend', label: 'zurückhaltend' }, { value: 'zurueckhaltend', label: 'zurückhaltend' },
], ],
}, },
{
id: 'livia-set-refresh-interval',
label: 'Aktualisierungsintervall',
description: 'Rhythmus, in dem bestehende Lagebeschriebe gegen die Quellen geprüft werden.',
kind: AgentSettingKind.SELECT,
group: AgentSettingGroup.SPECIFIC,
value: 'quartalsweise',
options: [
{ value: 'monatlich', label: 'monatlich' },
{ value: 'quartalsweise', label: 'quartalsweise' },
{ value: 'halbjaehrlich', label: 'halbjährlich' },
{ value: 'jaehrlich', label: 'jährlich' },
],
},
{
id: 'livia-set-analysis-radius',
label: 'Radius der Umfeldanalyse',
description: 'Umkreis um die Objektadresse, der für die Mikrolage ausgewertet wird.',
kind: AgentSettingKind.NUMBER,
group: AgentSettingGroup.SPECIFIC,
value: 800,
unit: 'Meter',
min: 200,
max: 3000,
},
{
id: 'livia-set-source-types',
label: 'Relevante Quellentypen',
description: 'Welche Quellen Livia für Lagebeschriebe und Aktualisierungsläufe heranzieht.',
kind: AgentSettingKind.MULTI_SELECT,
group: AgentSettingGroup.SPECIFIC,
value: ['amtliche_publikationen', 'verkehrsdaten', 'bauprojekte', 'nahversorgung'],
options: [
{ value: 'amtliche_publikationen', label: 'amtliche Publikationen' },
{ value: 'verkehrsdaten', label: 'Verkehrsdaten' },
{ value: 'bauprojekte', label: 'Bauprojekte' },
{ value: 'nahversorgung', label: 'Nahversorgung' },
{ value: 'medienberichte', label: 'Medienberichte' },
],
},
], ],
metrics: [ metrics: [
{ {
id: 'livia-metric-objects', id: 'livia-metric-leads',
label: 'Analysierte Objekte', label: 'Erzeugte Leads',
value: '184', value: '96',
hint: 'Objekte mit vollständigem Lagebeschrieb, unter anderem in Zürich, Winterthur, Zug und St. Gallen.', hint: 'Aus externen Quellen verdichtete Leads der letzten zwölf Monate, Chance und Risiko zusammen.',
}, },
{ {
id: 'livia-metric-current-blocks', id: 'livia-metric-chance',
label: 'Aktuelle Lagebausteine', label: 'Davon Chancen',
value: '162', value: '61',
hint: 'Bausteine, die im letzten Aktualisierungslauf gegen die Quellen bestätigt wurden.', hint: 'Signale, die auf neuen oder zusätzlichen Flächenbedarf hindeuten.',
}, },
{ {
id: 'livia-metric-due-refresh', id: 'livia-metric-risk',
label: 'Fällige Aktualisierungen', label: 'Davon Risiken',
value: '9', value: '35',
hint: 'Objekte, deren Lagebeschrieb im nächsten Quartals-Refresh erneuert wird.', hint: 'Signale, die ein bestehendes Mietverhältnis betreffen könnten.',
}, },
{ {
id: 'livia-metric-detected-changes', id: 'livia-metric-sources',
label: 'Erkannte Umfeldänderungen', label: 'Verknüpfte Quellen',
value: '14', value: '4',
hint: 'Seit dem letzten Lauf gemeldete Änderungen, etwa die Arealentwicklung Winterthur Neuhegi.', hint: 'Aktuell hinterlegte Newsseiten und Portale; weitere lassen sich in den Einstellungen ergänzen.',
}, },
], ],
headlineMetricId: 'livia-metric-objects', headlineMetricId: 'livia-metric-leads',
lastRun: '2026-05-20T06:00:00.000Z', lastRun: '2026-05-20T06:00:00.000Z',
} }
+123
View File
@@ -1,4 +1,5 @@
import { SignalType, RiskLevel } from '../domain/enums' import { SignalType, RiskLevel } from '../domain/enums'
import { LeadSignalType } from '../domain/futureSignal'
import type { FutureSignal } from '../domain/futureSignal' import type { FutureSignal } from '../domain/futureSignal'
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -705,4 +706,126 @@ export const mockFutureSignals: FutureSignal[] = [
{ type: 'LINKEDIN', value: 'linkedin.com/company/berntech-innovation', confidence: 'HIGH', label: 'LinkedIn Seite' }, { type: 'LINKEDIN', value: 'linkedin.com/company/berntech-innovation', confidence: 'HIGH', label: 'LinkedIn Seite' },
], ],
}, },
// ── Recherche-Leads von Livia (Runde 8, §1–§2) ─────────────────────────────
//
// Diese beiden Signale zeigen die Einordnung ausdrücklich statt sie über den
// Signaltyp abzuleiten: eines als Chance, eines als Risiko. Das Risiko ist der
// wichtigere Fall — es betrifft ein bestehendes Mietverhältnis und verschafft
// der Bewirtschaftung Vorlauf, bevor die Kündigung eintrifft.
// signal-020: Chance — Retail-Filialexpansion aus einem Geschäftsbericht.
{
id: 'signal-020',
signalType: SignalType.EXPANSION,
signalDirection: 'DEMAND',
leadSignalType: LeadSignalType.CHANCE,
companyName: 'Alpenbäckerei Truttmann AG',
locationHint: 'Zürich, Kreis 4 / Kreis 5',
title: 'Alpenbäckerei Truttmann kündigt zwölf neue Filialen an — drei davon in Zürich',
areaSqmEstimate: 180,
probability: 0.72,
confidenceScore: 0.81,
timeHorizonMonths: 14,
source: {
type: 'COMPANY_REPORT',
url: 'https://example.com/truttmann/geschaeftsbericht-2025',
publishedAt: '2026-04-28',
credibility: 'HIGH',
},
sensitivityLevel: 'PUBLIC',
disclaimer: 'Aus öffentlich publiziertem Geschäftsbericht und Medienberichterstattung verdichtet. Kein bestätigter Flächenauftrag.',
riskLevel: RiskLevel.LOW,
relevanceScore: 0.78,
isVerified: false,
organizationId: 'org-wincasa',
createdAt: '2026-05-04T06:10:00Z',
updatedAt: '2026-05-18T06:10:00Z',
aiSummary:
'Der Geschäftsbericht 2025 der Alpenbäckerei Truttmann AG nennt auf Seite 14 ausdrücklich zwölf geplante Filialeröffnungen bis Ende 2027, davon drei im Raum Zürich. Als Suchprofil ist im Bericht «Erdgeschosslagen mit hoher Fussgängerfrequenz, 150–220 m²» genannt. Die Handelszeitung hat am 30.04. nachgezogen und den CFO mit der Aussage zitiert, die Standortsuche in Zürich habe begonnen. Zwei Standortleiterstellen für Zürich sind seit Mitte April ausgeschrieben.',
marketIndicators: [
'Geschäftsbericht 2025, Seite 14: zwölf Filialeröffnungen bis Ende 2027',
'CFO-Zitat vom 30.04.2026: Standortsuche in Zürich hat begonnen',
'Zwei Standortleiterstellen für Zürich seit 15.04.2026 ausgeschrieben',
],
signalRationale:
'Das Unternehmen hat den Flächenbedarf selbst publiziert und das Suchprofil mitgeliefert — Nutzung, Grösse und Lageanforderung sind bekannt. Damit lässt sich unmittelbar gegen den eigenen Bestand prüfen, statt erst den Bedarf zu erraten.',
recommendedAction:
'Erdgeschossflächen zwischen 150 und 220 m² in Kreis 4 und 5 zusammenstellen und dem CFO eine Auswahl vorlegen, bevor die Suche über einen Makler ausgeschrieben wird.',
confirmedFacts: [
'Expansionsprogramm im Geschäftsbericht 2025 publiziert',
'Raum Zürich ausdrücklich als Zielmarkt genannt',
'Suchprofil 150–220 m² Erdgeschoss im Bericht enthalten',
],
unconfirmedFacts: ['Konkrete Quartiere', 'Zeitpunkt der einzelnen Eröffnungen', 'Ob Kauf oder Miete'],
researchSources: [
{ type: 'COMPANY_REPORT', url: 'https://example.com/truttmann/geschaeftsbericht-2025', publishedAt: '2026-04-28', credibility: 'HIGH' },
{ type: 'PRESS', url: 'https://www.handelszeitung.ch/unternehmen/truttmann-expansion', publishedAt: '2026-04-30', credibility: 'HIGH' },
{ type: 'JOB_POSTING', url: 'https://example.com/truttmann/jobs/standortleitung-zuerich', publishedAt: '2026-04-15', credibility: 'MEDIUM' },
],
extractedContacts: [
{ type: 'WEBSITE', value: 'https://truttmann.example.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
{ type: 'CONTACT_PERSON', value: 'Marco Truttmann (CFO)', confidence: 'HIGH', label: 'Aus Geschäftsbericht und Medienbericht' },
],
},
// signal-021: Risiko — Restrukturierung bei einem bestehenden Mieter.
{
id: 'signal-021',
signalType: SignalType.RESTRUCTURING,
signalDirection: 'SUPPLY',
leadSignalType: LeadSignalType.RISIKO,
companyName: 'Spedition Rhein GmbH',
propertyId: 'prop-002',
affectedTenant: 'Spedition Rhein GmbH — Lagerfläche Hardstrasse 44, Basel',
locationHint: 'Basel, Kleinhüningen',
title: 'Spedition Rhein bündelt Lagerstandorte — Basel Kleinhüningen betroffen',
areaSqmEstimate: 1900,
probability: 0.64,
confidenceScore: 0.7,
timeHorizonMonths: 11,
source: {
type: 'PRESS',
url: 'https://www.handelszeitung.ch/unternehmen/spedition-rhein-restrukturierung',
publishedAt: '2026-05-06',
credibility: 'HIGH',
},
sensitivityLevel: 'INTERNAL',
disclaimer: 'Aus öffentlichen Meldungen verdichtet. Keine Kündigung und keine Absichtserklärung des Mieters — Frühindikator, kein bestätigter Auszug.',
riskLevel: RiskLevel.HIGH,
relevanceScore: 0.9,
isVerified: false,
organizationId: 'org-wincasa',
createdAt: '2026-05-07T06:15:00Z',
updatedAt: '2026-05-19T06:15:00Z',
aiSummary:
'Die Muttergesellschaft der Spedition Rhein GmbH hat am 06.05. eine Bündelung der Lagerlogistik auf zwei Zentralstandorte angekündigt, genannt sind Pratteln und Kaiseraugst. Der Standort Basel Kleinhüningen ist in der Mitteilung nicht als Zentralstandort aufgeführt. Im Handelsregister ist am 11.05. eine Zweckanpassung eingetragen worden, die den Betrieb eigener Lagerflächen nicht mehr nennt. Die Spedition Rhein GmbH ist Mieterin der Lagerfläche Hardstrasse 44.',
marketIndicators: [
'Konzernmitteilung vom 06.05.2026: Bündelung auf Pratteln und Kaiseraugst',
'Basel Kleinhüningen in der Mitteilung nicht als Zentralstandort genannt',
'Handelsregister-Mutation vom 11.05.2026: Zweckanpassung ohne eigene Lagerflächen',
],
signalRationale:
'Der Mieter dieser Fläche gehört zu einem Konzern, der seine Lagerlogistik auf zwei andere Standorte bündelt. Kleinhüningen fehlt in beiden Aufzählungen. Das ist noch keine Kündigung, aber der früheste belegbare Hinweis darauf, dass 1 900 m² freigesetzt werden könnten.',
recommendedAction:
'Das Mietverhältnis Hardstrasse 44 prüfen: Laufzeit, Kündigungsfrist und Break-Optionen. Das Gespräch mit der Geschäftsleitung suchen, bevor die Kündigung eintrifft, und parallel die Fläche für internes Re-Letting vorbereiten — bei 1 900 m² Lagerfläche in Kleinhüningen ist mit mehreren Monaten Vermarktungsdauer zu rechnen.',
confirmedFacts: [
'Konzernmitteilung zur Standortbündelung publiziert',
'Handelsregister-Mutation vom 11.05.2026 eingetragen',
'Spedition Rhein GmbH ist Mieterin der Hardstrasse 44',
],
unconfirmedFacts: [
'Ob der Standort Basel ganz oder teilweise aufgegeben wird',
'Zeitpunkt einer möglichen Kündigung',
'Ob eine Konzerngesellschaft die Fläche übernimmt',
],
researchSources: [
{ type: 'PRESS', url: 'https://www.handelszeitung.ch/unternehmen/spedition-rhein-restrukturierung', publishedAt: '2026-05-06', credibility: 'HIGH' },
{ type: 'MARKET_DATA', url: 'https://www.zefix.ch/de/search/entity/list', publishedAt: '2026-05-11', credibility: 'HIGH' },
],
extractedContacts: [
{ type: 'WEBSITE', value: 'https://spedition-rhein.example.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
{ type: 'CONTACT_PERSON', value: 'Beatrice Vogt (Geschäftsführung)', confidence: 'HIGH', label: 'Handelsregistereintrag' },
],
},
] ]
+188
View File
@@ -586,4 +586,192 @@ export const mockReminders: Reminder[] = [
createdAt: '2026-03-10T08:00:00Z', createdAt: '2026-03-10T08:00:00Z',
updatedAt: '2026-04-19T11:00:00Z', updatedAt: '2026-04-19T11:00:00Z',
}, },
// ─── Contract Intelligence (Runde 8, §4) ───────────────────────────────────
//
// Diese Termine stammen aus der Vertragsanalyse, nicht aus einer Terminliste.
// Gemeinsam ist ihnen, dass `dueDate` und `decisionDate` deutlich vor
// `contractEndDate` liegen — Ferdi erinnert an den Entscheid, nicht an das
// Ende. Ein Reminder auf das Vertragsende käme für jede Entscheidung zu spät,
// die ihn nötig gemacht hat.
// Echte Option — die Ausübungsfrist verfällt, danach ist das Recht erloschen.
{
id: 'rem-022',
type: ReminderType.REAL_OPTION,
priority: ReminderPriority.URGENT,
status: ReminderStatus.ACTIVE,
propertyId: 'prop-012',
propertyTitle: 'Bürofläche Stadtturm Zug',
propertyCity: 'Zug',
propertyDistrict: 'Zentrum',
tenantName: 'FinTech Zug AG',
dueDate: '2026-05-29',
eventDate: '2026-06-30',
contractEndDate: '2027-06-30',
decisionDate: '2026-06-30',
noticePeriodMonths: 12,
contractRef: 'Mietvertrag FinTech Zug AG, 01.07.2019',
contractClause: 'Ziffer 4.2, Verlängerungsoption',
relevanceReason:
'Der Mieter hat ein einseitiges Recht, den Vertrag um fünf Jahre zu verlängern — ausübbar nur bis zum 30.06.2026. Verstreicht der Termin, erlischt das Recht endgültig und die Fläche wird per 30.06.2027 frei.',
recommendedAction:
'Beim Mieter aktiv nachfragen, ob die Option ausgeübt wird. Bleibt die Antwort aus, die Neuvermarktung ab Juli vorbereiten — zwölf Monate Vorlauf sind für eine Bürofläche dieser Grösse in Zug knapp bemessen.',
areaSqm: 640,
currentRentPerSqm: 395,
currency: 'CHF',
shadowMarketRisk: ShadowMarketRisk.HIGH,
schattenmarktEnabled: true,
note: 'Option wurde bei Vertragsabschluss verhandelt und ist im Nachtrag 1 bestätigt.',
activity: [
{ at: '2026-02-02T08:00:00Z', by: 'Ferdi', action: 'CREATED', note: 'Aus Mietvertrag Ziffer 4.2 erkannt, durch Bewirtschaftung bestätigt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-02-02T08:00:00Z',
updatedAt: '2026-02-02T08:00:00Z',
},
// Unechte Option — keine Frist, die verfällt, sondern ein Verhandlungsfenster.
{
id: 'rem-023',
type: ReminderType.UNREAL_OPTION,
priority: ReminderPriority.HIGH,
status: ReminderStatus.ACTIVE,
propertyId: 'prop-008',
propertyTitle: 'Bürofläche Dreispitz Areal 9',
propertyCity: 'Basel',
propertyDistrict: 'Dreispitz',
tenantName: 'Pharma Research GmbH',
dueDate: '2026-06-15',
eventDate: '2026-06-15',
contractEndDate: '2027-12-31',
decisionDate: '2026-09-30',
negotiationWindowFrom: '2026-06-15',
negotiationWindowTo: '2026-09-30',
noticePeriodMonths: 6,
contractRef: 'Mietvertrag Pharma Research GmbH, 15.01.2018',
contractClause: 'Ziffer 6.4, Absichtserklärung zur Fortsetzung',
relevanceReason:
'Der Vertrag enthält keine echte Option, sondern die Abrede, spätestens 15 Monate vor Ablauf über eine Fortsetzung zu verhandeln. Es gibt hier keine Frist, die verfällt — wohl aber ein Fenster, nach dem sich der Mieter anderweitig bindet.',
recommendedAction:
'Gesprächstermin bis Mitte Juni ansetzen und mit einer Indikation zu Mietzins und Laufzeit hineingehen. Bis Ende September sollte eine Einigung stehen, damit im Fall einer Absage noch ein Jahr für die Neuvermietung bleibt.',
areaSqm: 1180,
currentRentPerSqm: 310,
currency: 'CHF',
shadowMarketRisk: ShadowMarketRisk.MEDIUM,
schattenmarktEnabled: false,
note: 'Mieter hat 2025 in den Laborausbau investiert — eine Fortsetzung ist wahrscheinlich, aber nicht zugesichert.',
activity: [
{ at: '2026-03-05T08:00:00Z', by: 'Ferdi', action: 'CREATED', note: 'Aus Mietvertrag Ziffer 6.4 erkannt, durch Bewirtschaftung bestätigt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-03-05T08:00:00Z',
updatedAt: '2026-03-05T08:00:00Z',
},
// Kündigungsfrist — der letzte Zeitpunkt für eine fristgerechte Kündigung.
{
id: 'rem-024',
type: ReminderType.NOTICE_PERIOD,
priority: ReminderPriority.HIGH,
status: ReminderStatus.ACTIVE,
propertyId: 'prop-014',
propertyTitle: 'Logistikhalle Pratteln Nord',
propertyCity: 'Pratteln',
propertyDistrict: 'Industriezone',
tenantName: 'Handels- und Lagerbetrieb AG',
dueDate: '2026-06-10',
eventDate: '2026-06-30',
contractEndDate: '2026-12-31',
decisionDate: '2026-06-30',
noticePeriodMonths: 6,
contractRef: 'Mietvertrag Handels- und Lagerbetrieb AG, 01.01.2021',
contractClause: 'Ziffer 3.1, Kündigung auf Vertragsende',
relevanceReason:
'Eine Kündigung auf den 31.12.2026 muss spätestens am 30.06.2026 beim Mieter eintreffen. Ab dem 01.07. verlängert sich der Vertrag automatisch um ein weiteres Jahr.',
recommendedAction:
'Bis zum 10.06. entscheiden, ob das Mietverhältnis fortgesetzt wird. Soll gekündigt werden, das Schreiben so aufsetzen, dass der Zugang bis zum 30.06. nachweisbar ist.',
areaSqm: 2400,
currentRentPerSqm: 128,
currency: 'CHF',
shadowMarketRisk: ShadowMarketRisk.MEDIUM,
schattenmarktEnabled: false,
activity: [
{ at: '2026-01-15T08:00:00Z', by: 'Ferdi', action: 'CREATED', note: 'Aus Mietvertrag Ziffer 3.1 erkannt, durch Bewirtschaftung bestätigt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-01-15T08:00:00Z',
updatedAt: '2026-01-15T08:00:00Z',
},
// Verhandlungsfenster vor dem Auslaufen einer Staffelmiete.
{
id: 'rem-025',
type: ReminderType.NEGOTIATION_WINDOW,
priority: ReminderPriority.MEDIUM,
status: ReminderStatus.ACTIVE,
propertyId: 'prop-001',
propertyTitle: 'Bürofläche Zollstrasse 12',
propertyCity: 'Zürich',
propertyDistrict: 'Zürich-West',
tenantName: 'MediaGroup Schweiz AG',
dueDate: '2026-07-01',
eventDate: '2026-10-01',
contractEndDate: '2028-10-31',
decisionDate: '2026-10-01',
negotiationWindowFrom: '2026-07-01',
negotiationWindowTo: '2026-10-01',
contractRef: 'Nachtrag 2 zum Mietvertrag MediaGroup Schweiz AG, 12.09.2023',
contractClause: 'Ziffer 2, Staffelmiete',
relevanceReason:
'Die vereinbarte Staffel läuft per 01.10.2026 aus. Danach greift die Indexklausel — über die Anschlussregelung ist vorher zu verhandeln, sonst gilt automatisch die für den Vermieter ungünstigere Variante.',
recommendedAction:
'Ab Juli die Vergleichsmieten im Kreis 5 zusammenstellen und dem Mieter bis spätestens Ende September eine Anschlussregelung vorlegen.',
areaSqm: 850,
currentRentPerSqm: 456,
currency: 'CHF',
shadowMarketRisk: ShadowMarketRisk.LOW,
schattenmarktEnabled: false,
activity: [
{ at: '2026-04-08T08:00:00Z', by: 'Ferdi', action: 'CREATED', note: 'Aus Nachtrag 2 Ziffer 2 erkannt, durch Bewirtschaftung bestätigt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-04-08T08:00:00Z',
updatedAt: '2026-04-08T08:00:00Z',
},
// Anstehendes Vertragsgespräch aus einem Begehungsprotokoll.
{
id: 'rem-026',
type: ReminderType.CONTRACT_TALK,
priority: ReminderPriority.MEDIUM,
status: ReminderStatus.ACTIVE,
propertyId: 'prop-022',
propertyTitle: 'Bürofläche St.Gallen Centrum 7',
propertyCity: 'St. Gallen',
propertyDistrict: 'Centrum',
tenantName: 'Werbeatelier SG GmbH',
dueDate: '2026-06-24',
eventDate: '2026-06-24',
contractEndDate: '2027-03-31',
decisionDate: '2026-06-24',
noticePeriodMonths: 3,
contractRef: 'Mietvertrag Werbeatelier SG GmbH, 01.04.2022',
contractClause: 'Protokoll der Begehung vom 14.03.2026',
relevanceReason:
'Der Mieter hat bei der letzten Begehung eine Flächenreduktion um rund einen Drittel angesprochen. Zugesagt war ein Gespräch im zweiten Quartal — es hat bislang nicht stattgefunden.',
recommendedAction:
'Gespräch vor Ende Juni ansetzen und klären, ob die Reduktion weiterverfolgt wird. Fällt sie an, bleibt genug Zeit, die Teilfläche zusammen mit dem angrenzenden Abschnitt neu anzubieten.',
areaSqm: 410,
currentRentPerSqm: 268,
currency: 'CHF',
shadowMarketRisk: ShadowMarketRisk.MEDIUM,
schattenmarktEnabled: true,
activity: [
{ at: '2026-03-16T08:00:00Z', by: 'Ferdi', action: 'CREATED', note: 'Aus dem Begehungsprotokoll erkannt, durch Bewirtschaftung bestätigt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-03-16T08:00:00Z',
updatedAt: '2026-03-16T08:00:00Z',
},
] ]
+7 -1
View File
@@ -17,12 +17,18 @@ import { DS_TEXT } from '../../lib/ds'
const NORA = agentWorkspaceById('nora')! const NORA = agentWorkspaceById('nora')!
/** /**
* Nora — Marktchancen und Leads (Runde 4, §7; Runde 5, §11–§13). * Nora — interne Intelligence und Matching (Runde 4, §7; Runde 5, §11–§13;
* Runde 8, §2).
* *
* Die Seite führt seit Runde 5 eine einzige Leadliste über alle drei Kanäle * Die Seite führt seit Runde 5 eine einzige Leadliste über alle drei Kanäle
* statt zweier Reiterlisten. Jede Zeile trägt ihre Kanalherkunft, der frühere * statt zweier Reiterlisten. Jede Zeile trägt ihre Kanalherkunft, der frühere
* Reiterbalken ist zum Filter «Kanaltyp» geworden. * Reiterbalken ist zum Filter «Kanaltyp» geworden.
* *
* Mit Runde 8 hat sich die Arbeitsteilung gedreht: die Leads im Kanal
* «KI-Signal» kommen aus Livias Marktrecherche, Nora verbindet sie mit dem
* eigenen Bestand. Die Liste bleibt bewusst dieselbe — neu ist allein die
* Spalte «Signaltyp», die Chance von Risiko trennt.
*
* Der Aufbau ist zugleich die Reparatur der Scroll-Navigation (§11): vorher * Der Aufbau ist zugleich die Reparatur der Scroll-Navigation (§11): vorher
* lag der Chat-Einstieg ausserhalb des Scrollbereichs und zwei Spalten * lag der Chat-Einstieg ausserhalb des Scrollbereichs und zwei Spalten
* scrollten getrennt darunter — deshalb wanderte Noras Chat als einziger nie * scrollten getrennt darunter — deshalb wanderte Noras Chat als einziger nie
+32 -10
View File
@@ -3,6 +3,7 @@ import { useSearchParams } from 'react-router'
import { Box } from '@mui/material' import { Box } from '@mui/material'
import { import {
TeamPageHeader, TeamPageHeader,
AgentWorkspaceHero,
AgentWorkspaceTabs, AgentWorkspaceTabs,
PersonnelSection, PersonnelSection,
HistorySection, HistorySection,
@@ -13,20 +14,29 @@ import {
AGENT_SECTION_ORDER, AGENT_SECTION_ORDER,
AGENT_SECTION_PARAM, AGENT_SECTION_PARAM,
MY_AGENTS_LABEL, MY_AGENTS_LABEL,
THOMAS_INTRO,
} from '../../lib/constants' } from '../../lib/constants'
import type { AgentSection } from '../../lib/constants' import type { AgentSection } from '../../lib/constants'
import { AGENT_SUPERVISOR } from '../../lib/agentWorkspaces'
function resolveSection(raw: string | null): AgentSection { function resolveSection(raw: string | null): AgentSection {
return AGENT_SECTION_ORDER.find(s => s === raw) ?? AGENT_SECTIONS.PERSONNEL return AGENT_SECTION_ORDER.find(s => s === raw) ?? AGENT_SECTIONS.PERSONNEL
} }
/** /**
* Hauptseite «Meine Agenten». * Hauptseite «Meine Agenten» — der Arbeitsplatz von Thomas (Runde 8, §3).
* *
* Ersetzt die frühere Teamübersicht vollständig: Kreisgrafik, Auswertung und * Die Seite beginnt jetzt wie jede andere Agentenseite mit dem Chat-Einstieg:
* Verbindungszusammenfassung sind entfallen. Die Seite führt stattdessen die * Porträt, Name, Funktion und Eingabefeld. Beim Herunterscrollen wandert er
* drei bisherigen Unterseiten Personalverwaltung, Bearbeitungsverlauf und * über denselben `IntersectionObserver` wie bei Ferdi, Bruno, Livia, Nora und
* Kanäle & Systeme unverändert als Reiter (Runde 4, §2.1). * Sina in den obersten Balken — es ist buchstäblich dieselbe Komponente
* (`AgentWorkspaceHero`) und dieselbe `AgentChatStickyBar`, keine
* Thomas-Sonderlösung.
*
* Damit der Chatkopf überhaupt wegscrollen kann, scrollt der äussere Bereich
* als Ganzes. Die drei Abschnitte behalten darunter ihre eigene Höhe und ihren
* eigenen inneren Scroll — deshalb steht die Mindesthöhe am Wrapper und nicht
* in den Abschnitten selbst.
* *
* Es wird immer nur der gewählte Bereich gerendert — drei lange Abschnitte * Es wird immer nur der gewählte Bereich gerendert — drei lange Abschnitte
* untereinander wären nicht dasselbe in kürzer, sondern etwas anderes. Die * untereinander wären nicht dasselbe in kürzer, sondern etwas anderes. Die
@@ -45,13 +55,25 @@ export default function MeineAgenten() {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<TeamPageHeader title={MY_AGENTS_LABEL} /> <Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<AgentWorkspaceHero
agentId={AGENT_SUPERVISOR.id}
name={AGENT_SUPERVISOR.name}
role={AGENT_SUPERVISOR.role}
/>
<AgentWorkspaceTabs value={section} onChange={handleChange} /> <TeamPageHeader
title={MY_AGENTS_LABEL}
description={THOMAS_INTRO}
tabs={<AgentWorkspaceTabs value={section} onChange={handleChange} />}
/>
{section === AGENT_SECTIONS.PERSONNEL && <PersonnelSection />} <Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '70vh' }}>
{section === AGENT_SECTIONS.HISTORY && <HistorySection />} {section === AGENT_SECTIONS.PERSONNEL && <PersonnelSection />}
{section === AGENT_SECTIONS.CHANNELS && <ChannelsSection />} {section === AGENT_SECTIONS.HISTORY && <HistorySection />}
{section === AGENT_SECTIONS.CHANNELS && <ChannelsSection />}
</Box>
</Box>
</Box> </Box>
) )
} }
+32 -75
View File
@@ -1,78 +1,45 @@
import { useCallback, useMemo, useRef, useState } from 'react' import { useCallback, useRef } from 'react'
import { useSearchParams } from 'react-router' import { useSearchParams } from 'react-router'
import { Box, Tab, Tabs } from '@mui/material' import { Box, Tab, Tabs } from '@mui/material'
import { AgentWorkspaceHero } from '../../components/team' import { AgentWorkspaceHero } from '../../components/team'
import { LeadTable } from '../../components/expose' import { ResearchWorkspace } from '../../components/research'
import { LoadingPage, ErrorState } from '../../components/ui' import { ExposeLeadSection } from '../../components/expose'
import { useExposeLeads } from '../../hooks/useExposeLeads' import {
import { useProperties } from '../../hooks/useProperties' LIVIA_SECTIONS,
import { ExposeLeadStatus } from '../../domain/exposeLead' LIVIA_SECTION_LABELS,
import { EXPOSE_LEAD_TABS, EXPOSE_LEAD_TAB_LABELS } from '../../lib/constants' LIVIA_SECTION_ORDER,
import type { ExposeLeadTab } from '../../lib/constants' LIVIA_SECTION_PARAM,
} from '../../lib/constants'
import type { LiviaSection } from '../../lib/constants'
import { agentWorkspaceById } from '../../lib/agentWorkspaces' import { agentWorkspaceById } from '../../lib/agentWorkspaces'
const LIVIA = agentWorkspaceById('livia')! const LIVIA = agentWorkspaceById('livia')!
const PARAM_TAB = 'tab' function resolveSection(raw: string | null): LiviaSection {
return LIVIA_SECTION_ORDER.find(s => s === raw) ?? LIVIA_SECTIONS.RESEARCH
function resolveTab(raw: string | null): ExposeLeadTab {
return raw === EXPOSE_LEAD_TABS.ARCHIVED ? EXPOSE_LEAD_TABS.ARCHIVED : EXPOSE_LEAD_TABS.ACTIVE
} }
/** /**
* Livia — Exposé Master (Runde 4, §8). * Livia — Research & Market Intelligence (Runde 8, §1).
* *
* Die frühere Inserate-Seite ist vollständig ersetzt: Titel, Beschreibung, der * Die Seite führt zwei Bereiche in der Reihenfolge ihrer Bedeutung: zuerst die
* Knopf «Neues Inserat» und die Inserate-Tabelle sind entfallen. Ziel der Seite * Markt-Recherche, danach die nachgeordneten Exposés. Die Reihenfolge ist die
* ist die schnelle Verarbeitung von Leads, die Nora erkannt und ein * Aussage — Livia ist neu die Agentin, die nach draussen schaut; das Exposé ist
* Bewirtschafter zur Exposé-Erstellung weitergeleitet hat. * geblieben, aber nicht mehr das, wofür sie eingestellt ist.
*
* Es wird immer nur der gewählte Bereich gerendert, und die Wahl steht in
* `?bereich=`, damit der Zurück-Knopf des Browsers sie kennt — dasselbe Muster
* wie bei «Meine Agenten».
*/ */
export default function MyListings() { export default function MyListings() {
const [params, setParams] = useSearchParams() const [params, setParams] = useSearchParams()
const tab = resolveTab(params.get(PARAM_TAB)) const section = resolveSection(params.get(LIVIA_SECTION_PARAM))
const [selectedId, setSelectedId] = useState<string | null>(null)
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const { data: leads = [], isLoading, isError, refetch } = useExposeLeads() const handleChange = useCallback(
const { data: properties = [] } = useProperties() (next: LiviaSection) => setParams({ [LIVIA_SECTION_PARAM]: next }),
[setParams],
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])
const handleTabChange = useCallback((next: ExposeLeadTab) => {
setSelectedId(null)
setParams({ [PARAM_TAB]: next })
}, [setParams])
if (isLoading) return <LoadingPage />
if (isError) return <ErrorState message="Die Leads konnten nicht geladen werden." onRetry={() => refetch()} />
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
@@ -81,27 +48,17 @@ export default function MyListings() {
<Box sx={{ px: 3, pb: 5 }}> <Box sx={{ px: 3, pb: 5 }}>
<Tabs <Tabs
value={tab} value={section}
onChange={(_, v: ExposeLeadTab) => handleTabChange(v)} onChange={(_, v: LiviaSection) => handleChange(v)}
sx={{ mb: 2, '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9rem' } }} sx={{ mb: 2, '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9rem' } }}
> >
{Object.values(EXPOSE_LEAD_TABS).map(value => ( {LIVIA_SECTION_ORDER.map(value => (
<Tab key={value} value={value} label={EXPOSE_LEAD_TAB_LABELS[value]} /> <Tab key={value} value={value} label={LIVIA_SECTION_LABELS[value]} />
))} ))}
</Tabs> </Tabs>
<LeadTable {section === LIVIA_SECTIONS.RESEARCH && <ResearchWorkspace />}
leads={visible} {section === LIVIA_SECTIONS.EXPOSE && <ExposeLeadSection scrollRef={scrollRef} />}
properties={properties}
selectedId={selectedId}
onSelect={handleSelect}
withWorkspace={tab === EXPOSE_LEAD_TABS.ACTIVE}
emptyText={
tab === EXPOSE_LEAD_TABS.ACTIVE
? 'Keine aktiven Leads. Sobald Nora ein Signal erkennt und es weitergeleitet wird, erscheint es hier.'
: 'Noch keine archivierten Leads.'
}
/>
{/* Weissraum unter der Liste, damit auch die letzte Zeile an den {/* Weissraum unter der Liste, damit auch die letzte Zeile an den
oberen Rand gleiten kann. */} oberen Rand gleiten kann. */}
@@ -13,7 +13,7 @@ import { computeAgentWorkload } from '../agentWorkloadService'
import type { AgentWorkloadInput } from '../agentWorkloadService' import type { AgentWorkloadInput } from '../agentWorkloadService'
import { WorkloadTone } from '../../domain/agentWorkload' import { WorkloadTone } from '../../domain/agentWorkload'
import { ReminderStatus } from '../../domain/reminder' import { ReminderStatus } from '../../domain/reminder'
import { ExposeLeadStatus } from '../../domain/exposeLead' import { LeadChannel } from '../../domain/unifiedLead'
const NOW = Date.parse('2026-08-06T12:00:00.000Z') const NOW = Date.parse('2026-08-06T12:00:00.000Z')
const DAY = 24 * 60 * 60 * 1000 const DAY = 24 * 60 * 60 * 1000
@@ -23,7 +23,7 @@ function iso(offsetDays: number): string {
} }
const EMPTY: AgentWorkloadInput = { const EMPTY: AgentWorkloadInput = {
reminders: [], visits: [], exposeLeads: [], leads: [], properties: [], reminders: [], visits: [], leads: [], properties: [],
} }
function input(partial: Partial<AgentWorkloadInput>): AgentWorkloadInput { function input(partial: Partial<AgentWorkloadInput>): AgentWorkloadInput {
@@ -37,6 +37,11 @@ const visit = (days: number) =>
({ scheduledAt: iso(days) }) as AgentWorkloadInput['visits'][number] ({ scheduledAt: iso(days) }) as AgentWorkloadInput['visits'][number]
const property = (score: number) => const property = (score: number) =>
({ dataQuality: { score } }) as AgentWorkloadInput['properties'][number] ({ dataQuality: { score } }) as AgentWorkloadInput['properties'][number]
/** Recherche-Lead von Livia (Runde 8, §1). */
const kiLead = (days: number) =>
({ channel: LeadChannel.KI_SIGNAL, receivedAt: iso(days) }) as AgentWorkloadInput['leads'][number]
const netzwerkLead = (days: number) =>
({ channel: LeadChannel.NETZWERK, receivedAt: iso(days) }) as AgentWorkloadInput['leads'][number]
describe('Auslastung je Agent', () => { describe('Auslastung je Agent', () => {
it('meldet bei Ferdi Überfälliges vor den Fristen der Woche', () => { it('meldet bei Ferdi Überfälliges vor den Fristen der Woche', () => {
@@ -104,11 +109,27 @@ describe('Auslastung je Agent', () => {
it('formuliert die Einzahl korrekt', () => { it('formuliert die Einzahl korrekt', () => {
const w = computeAgentWorkload( const w = computeAgentWorkload(
input({ exposeLeads: [{ status: ExposeLeadStatus.ACTIVE }] as AgentWorkloadInput['exposeLeads'] }), input({ leads: [kiLead(-1)] }),
NOW, NOW,
).livia ).livia
expect(w.label).toBe('1 Exposé offen') expect(w.label).toBe('1 neue Marktmeldung')
})
/**
* Runde 8, §1: Livia zählt ihre eigene Recherche, nicht jeden Lead. Ein
* Netzwerk-Hinweis oder ein CRM-Lead ist fremde Arbeit — stünde er auf ihrem
* Schild, behauptete die Zahl eine Leistung, die sie nicht erbracht hat.
*/
it('zählt bei Livia nur die selbst recherchierten Leads', () => {
const w = computeAgentWorkload(
input({ leads: [kiLead(-1), kiLead(-2), netzwerkLead(-1)] }),
NOW,
)
expect(w.livia.count).toBe(2)
// Bei Nora landen alle drei — sie gleicht jeden Kanal gegen den Bestand ab.
expect(w.nora.count).toBe(3)
}) })
it('meldet bei leerer Datenlage überall Ruhe statt Lücken', () => { it('meldet bei leerer Datenlage überall Ruhe statt Lücken', () => {
+27 -17
View File
@@ -1,8 +1,7 @@
import { ReminderStatus } from '../domain/reminder' import { ReminderStatus } from '../domain/reminder'
import type { Reminder } from '../domain/reminder' import type { Reminder } from '../domain/reminder'
import { ExposeLeadStatus } from '../domain/exposeLead'
import type { ExposeLead } from '../domain/exposeLead'
import type { VisitAssignment } from '../domain/visitAssignment' import type { VisitAssignment } from '../domain/visitAssignment'
import { LeadChannel } from '../domain/unifiedLead'
import type { UnifiedLead } from '../domain/unifiedLead' import type { UnifiedLead } from '../domain/unifiedLead'
import type { Property } from '../domain/property' import type { Property } from '../domain/property'
import { WorkloadTone } from '../domain/agentWorkload' import { WorkloadTone } from '../domain/agentWorkload'
@@ -33,7 +32,6 @@ const DAY_MS = 24 * 60 * 60 * 1000
const URGENT_FROM = { const URGENT_FROM = {
reminders: 5, reminders: 5,
visits: 4, visits: 4,
expose: 5,
dataGaps: 10, dataGaps: 10,
} as const } as const
@@ -48,7 +46,6 @@ const WINDOW_DAYS = {
export interface AgentWorkloadInput { export interface AgentWorkloadInput {
reminders: Reminder[] reminders: Reminder[]
visits: VisitAssignment[] visits: VisitAssignment[]
exposeLeads: ExposeLead[]
leads: UnifiedLead[] leads: UnifiedLead[]
properties: Property[] properties: Property[]
} }
@@ -114,29 +111,42 @@ export function computeAgentWorkload(
: `${upcomingVisits} ${plural(upcomingVisits, 'Besichtigung steht', 'Besichtigungen stehen')} an. Die Briefings sind vorbereitet.`, : `${upcomingVisits} ${plural(upcomingVisits, 'Besichtigung steht', 'Besichtigungen stehen')} an. Die Briefings sind vorbereitet.`,
} }
// ── Livia: weitergeleitete Leads ──────────────────────────────────────────── // ── Livia: recherchierte Leads (Runde 8, §1) ────────────────────────────────
const openExpose = input.exposeLeads.filter(l => l.status === ExposeLeadStatus.ACTIVE).length //
// Bis Runde 7 zählte hier die Anzahl offener Exposés. Seit Livia nach draussen
// schaut, ist das nicht mehr ihre Kennzahl — eine Plakette trägt einen Satz,
// und der muss die Hauptaufgabe treffen. Gezählt wird, was sie in den letzten
// zwei Wochen aus externen Quellen verdichtet hat.
const freshResearch = input.leads.filter(
l => l.channel === LeadChannel.KI_SIGNAL && within(l.receivedAt, leadsFrom, now),
).length
const livia: AgentWorkload = { const livia: AgentWorkload = {
agentId: 'livia', agentId: 'livia',
count: openExpose, count: freshResearch,
label: `${openExpose} ${plural(openExpose, 'Exposé offen', 'Exposés offen')}`, label: `${freshResearch} neue ${plural(freshResearch, 'Marktmeldung', 'Marktmeldungen')}`,
tone: tone(openExpose, URGENT_FROM.expose), // Recherche ist nie «dringend» — Livia entscheidet nichts, sie legt vor.
voice: openExpose === 0 tone: freshResearch > 0 ? WorkloadTone.ATTENTION : WorkloadTone.CALM,
? 'Kein Exposé offen. Leite mir einen Lead weiter, dann lege ich los.' voice: freshResearch === 0
: `${openExpose} ${plural(openExpose, 'Lead wartet', 'Leads warten')} auf ein Exposé von mir.`, ? 'Aus den verknüpften Quellen kam nichts Relevantes. Ich lese weiter.'
: `Ich habe ${freshResearch} ${plural(freshResearch, 'Meldung', 'Meldungen')} zu einem Lead verdichtet. Nora gleicht sie mit dem Bestand ab.`,
} }
// ── Nora: Marktchancen ────────────────────────────────────────────────────── // ── Nora: Abgleich mit dem eigenen Bestand (Runde 8, §2) ────────────────────
//
// Gezählt werden alle frischen Leads jedes Kanals — das ist Noras Eingang.
// Dass Livias Recherche-Leads darin enthalten sind, ist kein Doppelzählen:
// Livia hat sie gefunden, Nora muss sie abgleichen. Zwei Arbeitsschritte am
// selben Lead sind zwei Zahlen, sonst wäre eine der beiden Plaketten leer.
const freshLeads = input.leads.filter(l => within(l.receivedAt, leadsFrom, now)).length const freshLeads = input.leads.filter(l => within(l.receivedAt, leadsFrom, now)).length
const nora: AgentWorkload = { const nora: AgentWorkload = {
agentId: 'nora', agentId: 'nora',
count: freshLeads, count: freshLeads,
label: `${freshLeads} neue ${plural(freshLeads, 'Marktchance', 'Marktchancen')}`, label: `${freshLeads} ${plural(freshLeads, 'Lead', 'Leads')} zum Abgleich`,
// Neue Chancen sind nie «dringend» — Nora entscheidet nichts, sie schlägt vor. // Der Abgleich ist nie «dringend» — Nora entscheidet nichts, sie schlägt vor.
tone: freshLeads > 0 ? WorkloadTone.ATTENTION : WorkloadTone.CALM, tone: freshLeads > 0 ? WorkloadTone.ATTENTION : WorkloadTone.CALM,
voice: freshLeads === 0 voice: freshLeads === 0
? 'Zurzeit keine neue Chance. Ich beobachte den Markt weiter.' ? 'Zurzeit nichts abzugleichen. Ich halte den Bestand bereit.'
: `Ich habe ${freshLeads} neue ${plural(freshLeads, 'Chance', 'Chancen')} gefunden. Entscheiden musst du.`, : `Ich gleiche ${freshLeads} ${plural(freshLeads, 'Lead', 'Leads')} mit unseren Objekten und Mietverhältnissen ab. Entscheiden musst du.`,
} }
// ── Sina: Datenpflege ─────────────────────────────────────────────────────── // ── Sina: Datenpflege ───────────────────────────────────────────────────────