`LIVIA_PROFIL_UND_PROMPT.md` hält Livia als Exposé Master fest, wie sie vor Runde 8 war: Personalblatt, Aufgabenkatalog, Kanäle und Systeme, das Exposé-Dossier mit elf Bereichen und neun Pflichtfeldern, die `missingFacts`-Regel der Textmaschine — dazu ein System-Prompt, mit dem sich diese Fassung ausserhalb des Repositories rekonstruieren lässt. `archive/livia/` ergänzt die Beschreibung um die Wiederherstellung: die 23 Dateien der Exposé-Funktion als wörtliche Kopie im Original-Pfadlayout sowie ein Manifest jeder Stelle, an der Livia in geteilten Dateien verdrahtet war. Beides ist mit Runde 8 nicht überholt, sondern der Referenzstand: die Exposé-Funktion wurde nicht entfernt, sondern nachgeordnet. Das Manifest beschreibt allerdings den Rückbau eines vollständigen Entfernens und damit ein Szenario, das so nicht eingetreten ist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
114 lines
4.6 KiB
TypeScript
114 lines
4.6 KiB
TypeScript
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
import { useSearchParams } from 'react-router'
|
|
import { Box, Tab, Tabs } from '@mui/material'
|
|
import { AgentWorkspaceHero } from '../../components/team'
|
|
import { LeadTable } from '../../components/expose'
|
|
import { LoadingPage, ErrorState } from '../../components/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'
|
|
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
|
|
|
|
const LIVIA = agentWorkspaceById('livia')!
|
|
|
|
const PARAM_TAB = 'tab'
|
|
|
|
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).
|
|
*
|
|
* Die frühere Inserate-Seite ist vollständig ersetzt: Titel, Beschreibung, der
|
|
* Knopf «Neues Inserat» und die Inserate-Tabelle sind entfallen. Ziel der Seite
|
|
* ist die schnelle Verarbeitung von Leads, die Nora erkannt und ein
|
|
* Bewirtschafter zur Exposé-Erstellung weitergeleitet hat.
|
|
*/
|
|
export default function MyListings() {
|
|
const [params, setParams] = useSearchParams()
|
|
const tab = resolveTab(params.get(PARAM_TAB))
|
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
const scrollRef = useRef<HTMLDivElement>(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])
|
|
|
|
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 (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
|
<Box ref={scrollRef} sx={{ flex: 1, overflowY: 'auto' }}>
|
|
<AgentWorkspaceHero agentId={LIVIA.id} name={LIVIA.name} role={LIVIA.role} />
|
|
|
|
<Box sx={{ px: 3, pb: 5 }}>
|
|
<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 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
|
|
oberen Rand gleiten kann. */}
|
|
<Box sx={{ height: '60vh' }} />
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|