feat(livia)!: Exposé-Funktion vollständig entfernt

Runde 8 hat Livia zur Research- & Market-Intelligence-Agentin gemacht. Die
Exposé-Erstellung war der Kern ihrer alten Rolle und fällt mit ihr weg — sie
wird nicht nachgeordnet weitergeführt. Ein zweiter Reiter hätte die Funktion
sichtbar gelassen und die neue Positionierung damit halbiert.

Entfernt: 23 Dateien (Arbeitsbereich, dreistufiger Assistent, Dossier mit elf
Bereichen, Provider, Services, Hooks, Feldkatalog, Mockdaten, deterministischer
Textbaustein) sowie `generateExposeText` aus `IAIService` und beiden
Implementierungen.

Livias Arbeitsplatz zeigt jetzt nur noch die Recherche-Leads — ohne
Reiterbalken, weil eine Leiste mit einem Eintrag eine Alternative behauptet,
die es nicht gibt. Ihr Personalblatt, die Bearbeitungsverlaufs-Einträge und die
Teamdokumentation sind auf die neue Rolle nachgezogen; die beiden
Beispielvorgänge zeigen jetzt eine Leaderzeugung und ein Review mit
widersprüchlichen Quellen statt Lagebeschrieb und Exposétext.

Bei Nora entfällt die Mehrfachauswahl in der Objektliste: sie diente allein der
Weiterleitung an die Exposé-Erstellung. Eine Auswahl ohne Ziel wäre ein
Bedienelement, das nichts auslöst. Die Matching-Darstellung selbst bleibt
unverändert.

Der Wiederaufbau ist in `archive/livia/` beschrieben; das Manifest hält jetzt
fest, welche seiner Annahmen eingetreten sind und welche nicht — Livia bleibt
im Team, nur die Funktion ist weg.

Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen, 423/423 Tests grün,
Produktionsbuild erfolgreich, Smoke-Test über sieben Seiten ohne
Laufzeitfehler, 12/12 Abnahmekriterien erfüllt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin SutterandClaude Opus 5 committed 2026-08-23 22:44:08 +02:00
1 parent f5451f649f
commit a219d3f2ec
33 files changed
+178 -2474

No files matched your search

-138
View File
@@ -1,138 +0,0 @@
import { Box, Chip, MenuItem, TextField, Typography } from '@mui/material'
import type { ExposeField, ExposeSection } from '../../lib/exposeFields'
import type { ExposeValues } from '../../domain/expose'
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
interface Props {
section: ExposeSection
values: ExposeValues
missing: Set<string>
onChange: (key: string, value: string) => void
/** Zusatzinhalt am Ende des Abschnitts, z. B. der KI-Entwurfsknopf. */
footer?: React.ReactNode
}
/** Mehrfachauswahl als Wert: kommagetrennt, damit ein flaches Wörterbuch reicht. */
function toggleChip(current: string, option: string): string {
const set = new Set(current.split(',').map(s => s.trim()).filter(Boolean))
if (set.has(option)) set.delete(option)
else set.add(option)
return [...set].join(', ')
}
function isSelected(current: string, option: string): boolean {
return current.split(',').map(s => s.trim()).includes(option)
}
function FieldControl({
field, value, isMissing, onChange,
}: {
field: ExposeField
value: string
isMissing: boolean
onChange: (value: string) => void
}) {
const label = field.suffix ? `${field.label} (${field.suffix})` : field.label
if (field.type === 'chips') {
return (
<Box>
<Typography
variant="caption"
sx={{ display: 'block', mb: 0.75, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.4, fontSize: '0.65rem' }}
>
{label}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{(field.options ?? []).map(o => {
const selected = isSelected(value, o)
return (
<Chip
key={o}
label={o}
size="small"
variant={selected ? 'filled' : 'outlined'}
onClick={() => onChange(toggleChip(value, o))}
sx={{
cursor: 'pointer',
bgcolor: selected ? '#152642' : 'transparent',
color: selected ? 'white' : DS_TEXT.secondary,
borderColor: DS_SLATE[200],
}}
/>
)
})}
</Box>
</Box>
)
}
return (
<TextField
select={field.type === 'select'}
multiline={field.type === 'multiline'}
rows={field.type === 'multiline' ? 3 : undefined}
type={field.type === 'number' ? 'number' : 'text'}
label={field.required ? `${label} *` : label}
size="small"
fullWidth
value={value}
onChange={e => onChange(e.target.value)}
sx={{
'& .MuiOutlinedInput-notchedOutline': isMissing
? { borderColor: DS_TEXT.error, borderWidth: 2 }
: {},
}}
>
{field.type === 'select' &&
(field.options ?? []).map(o => <MenuItem key={o} value={o}>{o}</MenuItem>)}
</TextField>
)
}
/**
* Ein fachlicher Bereich des Exposé-Dossiers.
*
* Rendert generisch aus `lib/exposeFields.ts` — die Bereiche sind Daten, keine
* elf handgeschriebenen Formulare. Leere Pflichtfelder sind rot umrandet
* (Runde 4, §8.5.2); sie werden nicht mit plausiblen Werten gefüllt.
*/
export function ExposeFieldGroup({ section, values, missing, onChange, footer }: Props) {
return (
<Box component="section" sx={{ mb: 4 }}>
<Typography sx={{ fontWeight: 700, fontSize: '1rem', color: DS_TEXT.primary }}>
{section.title}
</Typography>
{section.description && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mb: 1.5 }}>
{section.description}
</Typography>
)}
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: 'repeat(3, minmax(0, 1fr))' },
gap: 1.5,
mt: section.description ? 0 : 1.5,
}}
>
{section.fields.map(field => (
<Box
key={field.key}
sx={{ gridColumn: field.wide || field.type === 'chips' || field.type === 'multiline' ? '1 / -1' : 'auto' }}
>
<FieldControl
field={field}
value={values[field.key] ?? ''}
isMissing={missing.has(field.key)}
onChange={v => onChange(field.key, v)}
/>
</Box>
))}
</Box>
{footer && <Box sx={{ mt: 1.5 }}>{footer}</Box>}
</Box>
)
}
-100
View File
@@ -1,100 +0,0 @@
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>
)
}
@@ -1,221 +0,0 @@
import { useCallback, useRef } from 'react'
import {
Box, Button, Checkbox, FormControlLabel, IconButton, MenuItem, TextField, Tooltip, Typography,
} from '@mui/material'
import { ArrowDown, ArrowUp, Star, Upload } from 'lucide-react'
import type { ExposeImage } from '../../domain/expose'
import { ExposeImageCategory } from '../../domain/expose'
import { EXPOSE_IMAGE_CATEGORY_LABELS } from '../../lib/constants'
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
interface Props {
images: ExposeImage[]
onChange: (next: ExposeImage[]) => void
}
/**
* Schritt 1 «Hochladen» und die Medien-Kuration in einem (Runde 4, §8.5.1).
*
* Livia importiert die Objektbilder aus «Meine Objekte» selbst; das Uploadfeld
* ist ausdrücklich nur für Bilder gedacht, die dort noch nicht liegen. Deshalb
* steht die Liste der bereits importierten Bilder gleichberechtigt neben der
* Ablagefläche und nicht darunter versteckt.
*
* Kategorie, Bildunterschrift, Sichtbarkeit, Reihenfolge und Titelbild lassen
* sich je Bild setzen. Genau ein Bild ist Titelbild — das Setzen eines neuen
* nimmt dem alten die Markierung, statt zwei Titelbilder zuzulassen.
*/
export function ExposeMediaManager({ images, onChange }: Props) {
const fileInput = useRef<HTMLInputElement>(null)
const patch = useCallback(
(id: string, changes: Partial<ExposeImage>) => {
onChange(images.map(img => (img.id === id ? { ...img, ...changes } : img)))
},
[images, onChange],
)
const setCover = useCallback(
(id: string) => onChange(images.map(img => ({ ...img, isCover: img.id === id }))),
[images, onChange],
)
const move = useCallback(
(index: number, delta: number) => {
const target = index + delta
if (target < 0 || target >= images.length) return
const next = [...images]
const [moved] = next.splice(index, 1)
next.splice(target, 0, moved)
onChange(next)
},
[images, onChange],
)
/**
* Zusätzliche Bilder. Doppelte werden über den Dateinamen abgewiesen — die
* häufigste Verwechslung ist, ein bereits importiertes Bild nochmals von der
* Festplatte zu wählen.
*/
const handleFiles = useCallback(
(files: FileList | null) => {
if (!files) return
const known = new Set(images.map(i => i.fileName))
const added: ExposeImage[] = []
for (const file of Array.from(files)) {
if (known.has(file.name)) continue
known.add(file.name)
added.push({
id: `img-upload-${file.name}-${added.length}`,
url: URL.createObjectURL(file),
fileName: file.name,
category: ExposeImageCategory.OTHER,
caption: '',
visible: true,
isCover: false,
imported: false,
})
}
if (added.length > 0) onChange([...images, ...added])
},
[images, onChange],
)
const importedCount = images.filter(i => i.imported).length
return (
<Box>
{/* Uploadfläche */}
<Box
onClick={() => fileInput.current?.click()}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); handleFiles(e.dataTransfer.files) }}
sx={{
border: '1px dashed #cbd5e1',
borderRadius: 2,
py: 5,
px: 3,
textAlign: 'center',
cursor: 'pointer',
bgcolor: DS_SLATE[50],
'&:hover': { bgcolor: DS_SLATE[100] },
}}
>
<Upload size={22} color="#64748b" />
<Typography variant="body2" sx={{ mt: 1, fontWeight: 600, color: DS_TEXT.primary }}>
Zusätzliche Bilder hierher ziehen oder auswählen
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
Nur für Bilder, die noch nicht in «Meine Objekte» hinterlegt sind — JPG oder PNG
</Typography>
<input
ref={fileInput}
type="file"
accept="image/*"
multiple
hidden
onChange={e => handleFiles(e.target.files)}
/>
</Box>
<Typography variant="caption" sx={{ display: 'block', mt: 1.5, mb: 1, color: DS_TEXT.secondary }}>
{importedCount} Bild(er) automatisch aus «Meine Objekte» importiert · {images.length} insgesamt
</Typography>
{/* Medien-Kuration */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{images.map((img, index) => (
<Box
key={img.id}
sx={{
display: 'flex',
gap: 1.5,
p: 1.25,
border: '1px solid #e2e8f0',
borderRadius: 1.5,
bgcolor: 'white',
}}
>
{/* Reihenfolge */}
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<IconButton size="small" onClick={() => move(index, -1)} disabled={index === 0} aria-label="Nach oben">
<ArrowUp size={13} />
</IconButton>
<IconButton size="small" onClick={() => move(index, 1)} disabled={index === images.length - 1} aria-label="Nach unten">
<ArrowDown size={13} />
</IconButton>
</Box>
<Box
component="img"
src={img.url}
alt={img.caption || img.fileName}
sx={{ width: 96, height: 72, objectFit: 'cover', borderRadius: 1, flexShrink: 0 }}
/>
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.primary }} noWrap>
{img.fileName}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{img.imported ? 'aus «Meine Objekte»' : 'hochgeladen'}
</Typography>
<Tooltip title="Als Titelbild">
<IconButton
size="small"
onClick={() => setCover(img.id)}
aria-label="Als Titelbild"
sx={{ ml: 'auto', color: img.isCover ? '#b8975a' : '#cbd5e1' }}
>
<Star size={14} fill={img.isCover ? '#b8975a' : 'none'} />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
select size="small" label="Kategorie"
value={img.category}
onChange={e => patch(img.id, { category: e.target.value as ExposeImage['category'] })}
sx={{ width: 170, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
>
{Object.values(ExposeImageCategory).map(c => (
<MenuItem key={c} value={c}>{EXPOSE_IMAGE_CATEGORY_LABELS[c]}</MenuItem>
))}
</TextField>
<TextField
size="small" label="Bildunterschrift"
value={img.caption}
onChange={e => patch(img.id, { caption: e.target.value })}
sx={{ flex: 1, minWidth: 180, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
/>
<FormControlLabel
control={
<Checkbox
size="small"
checked={img.visible}
onChange={e => patch(img.id, { visible: e.target.checked })}
/>
}
label={<Typography variant="caption">Im Exposé sichtbar</Typography>}
/>
</Box>
</Box>
</Box>
))}
</Box>
{images.length === 0 && (
<Button
variant="outlined"
size="small"
onClick={() => fileInput.current?.click()}
sx={{ mt: 1, textTransform: 'none' }}
>
Bilder auswählen
</Button>
)}
</Box>
)
}
-316
View File
@@ -1,316 +0,0 @@
import { useCallback, useMemo, useState } from 'react'
import {
Alert, Box, Button, Divider, MenuItem, Step, StepButton, Stepper, TextField, Typography,
} from '@mui/material'
import { Download, Eye, FileText, Sparkles } from 'lucide-react'
import type { ExposeDraft, ExposeValues } from '../../domain/expose'
import { ExposeBranding } from '../../domain/expose'
import type { Property } from '../../domain/property'
import { ExposeMediaManager } from './ExposeMediaManager'
import { ExposeFieldGroup } from './ExposeFieldGroup'
import { PanelLoadingState } from '../ui'
import { useExposeDraft, useGenerateExpose, useGenerateExposeText, useSaveExposeDraft } from '../../hooks/useExpose'
import { textFactsFrom } from '../../services/exposeService'
import { downloadExposePdf, downloadExposeWord, openExposePreview } from '../../services/exposeExport'
import { EXPOSE_SECTIONS, missingRequiredExposeKeys } from '../../lib/exposeFields'
import { EXPOSE_STEPS, EXPOSE_TONALITY_LABELS } from '../../lib/constants'
import { ExposeTextSection } from '../../services/ai/IAIService'
import type { ExposeTonality } from '../../services/ai/IAIService'
import { useToastStore } from '../../stores/toastStore'
import { DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT } from '../../lib/ds'
/** Welcher Textabschnitt gehört zu welchem Feld? Basis für «KI-Entwurf erstellen». */
const TEXT_FIELD_SECTIONS: { key: string; section: ExposeTextSection; label: string }[] = [
{ key: 'exposeTitel', section: ExposeTextSection.TITLE, label: 'Exposé-Titel' },
{ key: 'kurzbeschrieb', section: ExposeTextSection.TEASER, label: 'Kurzbeschrieb' },
{ key: 'objektbeschrieb', section: ExposeTextSection.OBJECT, label: 'Objektbeschrieb' },
{ key: 'lagebeschrieb', section: ExposeTextSection.LOCATION, label: 'Lagebeschrieb' },
{ key: 'gemeindebeschrieb', section: ExposeTextSection.MUNICIPALITY, label: 'Gemeindebeschrieb' },
{ key: 'ausstattungsbeschrieb',section: ExposeTextSection.FEATURES, label: 'Ausstattungsbeschrieb' },
{ key: 'highlights', section: ExposeTextSection.HIGHLIGHTS, label: 'Highlights' },
]
interface Props {
leadId: string
property: Property
}
/**
* Der dreistufige Exposé-Prozess: Hochladen → Exposé → Export (Runde 4, §8.5).
*
* Lädt den Entwurf und übergibt ihn als Startwert an den Arbeitsbereich. Die
* Trennung in Lader und Arbeitsbereich hat einen Grund: der lokale Entwurf soll
* genau einmal aus dem gespeicherten Stand entstehen. Über `key` löst der
* Wechsel auf ein anderes Objekt einen frischen Arbeitsbereich aus, statt einen
* Effekt zu brauchen, der den lokalen Stand nachträglich überschreibt.
*/
export function ExposeWizard({ leadId, property }: Props) {
const { data: stored, isLoading } = useExposeDraft(leadId, property)
if (isLoading || !stored) return <PanelLoadingState />
return <ExposeWizardBody key={stored.id} initialDraft={stored} />
}
/**
* Der Entwurf lebt lokal, solange gearbeitet wird, und geht erst beim
* Speichern in den Service. Das ist Absicht: bei jedem Tastendruck zu
* speichern hiesse, jeden Zwischenstand zum gültigen Stand zu erklären.
*
* «Speichern» und «Erstellen» sind getrennt — Speichern hält den Zwischenstand
* fest, Erstellen prüft die Vollständigkeit und schaltet den Export frei.
*/
function ExposeWizardBody({ initialDraft }: { initialDraft: ExposeDraft }) {
const saveDraft = useSaveExposeDraft()
const generate = useGenerateExpose()
const generateText = useGenerateExposeText()
const showToast = useToastStore(s => s.showToast)
const [step, setStep] = useState(0)
const [draft, setDraft] = useState<ExposeDraft>(initialDraft)
const missing = useMemo(
() => new Set(missingRequiredExposeKeys(draft.values)),
[draft],
)
const setValue = useCallback((key: string, value: string) => {
setDraft(prev => ({ ...prev, values: { ...prev.values, [key]: value } }))
}, [])
const setValues = useCallback((patch: ExposeValues) => {
setDraft(prev => ({ ...prev, values: { ...prev.values, ...patch } }))
}, [])
const runTextDraft = useCallback(
async (target: { key: string; section: ExposeTextSection; label: string }) => {
const result = await generateText.mutateAsync({
section: target.section,
tonality: draft.tonality,
facts: textFactsFrom(draft.values),
})
if (result.missingFacts.length > 0) {
showToast(
`${target.label}: Es fehlen belegte Angaben (${result.missingFacts.join(', ')}) — bitte zuerst erfassen.`,
'warning',
)
return
}
setValues({ [target.key]: result.text })
},
[draft, generateText, setValues, showToast],
)
const runAllTextDrafts = useCallback(async () => {
for (const target of TEXT_FIELD_SECTIONS) {
await runTextDraft(target)
}
}, [runTextDraft])
const isGenerated = Boolean(draft.generatedAt)
return (
<Box sx={{ bgcolor: DS_NEUTRAL.paper, border: '1px solid #e8e7e4', borderRadius: 2 }}>
{/* Schrittleiste */}
<Box sx={{ px: 3, pt: 2.5, pb: 1, borderBottom: '1px solid #e8e7e4' }}>
<Stepper nonLinear activeStep={step}>
{EXPOSE_STEPS.map((label, index) => (
<Step key={label} completed={index < step}>
<StepButton onClick={() => setStep(index)} sx={{ textTransform: 'none' }}>
{label}
</StepButton>
</Step>
))}
</Stepper>
</Box>
<Box sx={{ px: 3, py: 2.5, maxHeight: '65vh', overflowY: 'auto' }}>
{/* ── Schritt 1: Hochladen ── */}
{step === 0 && (
<ExposeMediaManager
images={draft.images}
onChange={images => setDraft({ ...draft, images })}
/>
)}
{/* ── Schritt 2: Exposé ── */}
{step === 1 && (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2.5, flexWrap: 'wrap' }}>
<TextField
select size="small" label="Tonalität für KI-Entwürfe"
value={draft.tonality}
onChange={e => setDraft({ ...draft, tonality: e.target.value as ExposeTonality })}
sx={{ width: 210 }}
>
{Object.entries(EXPOSE_TONALITY_LABELS).map(([value, label]) => (
<MenuItem key={value} value={value}>{label}</MenuItem>
))}
</TextField>
<Button
size="small"
variant="outlined"
startIcon={<Sparkles size={14} />}
onClick={runAllTextDrafts}
disabled={generateText.isPending}
sx={{ textTransform: 'none', borderColor: DS_SLATE[200], color: DS_TEXT.primary }}
>
Alle KI-Entwürfe erstellen
</Button>
{missing.size > 0 && (
<Typography variant="caption" sx={{ color: DS_TEXT.error, fontWeight: 600 }}>
{missing.size} Pflichtangabe(n) fehlen
</Typography>
)}
</Box>
{EXPOSE_SECTIONS.map(section => (
<ExposeFieldGroup
key={section.id}
section={section}
values={draft.values}
missing={missing}
onChange={setValue}
footer={
section.id === 'texte' ? (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{TEXT_FIELD_SECTIONS.map(t => (
<Button
key={t.key}
size="small"
startIcon={<Sparkles size={13} />}
onClick={() => runTextDraft(t)}
disabled={generateText.isPending}
sx={{ textTransform: 'none', color: DS_TEXT.secondary, fontSize: '0.75rem' }}
>
{t.label}
</Button>
))}
</Box>
) : undefined
}
/>
))}
{/* Medien-Kuration bleibt aus Schritt 1 erhalten und wird hier nur
verlinkt — dieselbe Liste zweimal zu rendern wäre Duplikat. */}
<Divider sx={{ my: 3 }} />
<Typography sx={{ fontWeight: 700, fontSize: '1rem', color: DS_TEXT.primary, mb: 0.5 }}>
Kontakte & CI
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' }, gap: 1.5, mb: 2 }}>
<TextField
size="small" label="Ansprechperson fürs Exposé"
value={draft.contactPerson}
onChange={e => setDraft({ ...draft, contactPerson: e.target.value })}
/>
<TextField
select size="small" label="Farben"
value={draft.branding}
onChange={e => setDraft({ ...draft, branding: e.target.value as ExposeBranding })}
>
<MenuItem value={ExposeBranding.FROM_PROFILE}>Aus Maklerprofil übernehmen</MenuItem>
<MenuItem value={ExposeBranding.MANUAL}>Manuell setzen</MenuItem>
</TextField>
{draft.branding === ExposeBranding.MANUAL && (
<TextField
size="small" label="Farbwert (Hex)"
placeholder="#152642"
value={draft.brandColor ?? ''}
onChange={e => setDraft({ ...draft, brandColor: e.target.value })}
/>
)}
</Box>
</Box>
)}
{/* ── Schritt 3: Export ── */}
{step === 2 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{!isGenerated && (
<Alert severity="info" sx={{ fontSize: '0.85rem' }}>
Das Exposé ist noch nicht erstellt. Vorschau und Download zeigen den aktuellen Stand.
</Alert>
)}
{missing.size > 0 && (
<Alert severity="warning" sx={{ fontSize: '0.85rem' }}>
Es fehlen noch {missing.size} Pflichtangabe(n) — im Schritt «Exposé» rot umrandet.
</Alert>
)}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
<Button
variant="contained"
startIcon={<Eye size={15} />}
onClick={() => {
if (!openExposePreview(draft)) {
showToast('Die Vorschau wurde vom Browser blockiert — bitte Pop-ups erlauben.', 'error')
}
}}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Vorschau im neuen Tab
</Button>
<Button
variant="outlined"
startIcon={<FileText size={15} />}
onClick={() => {
if (!downloadExposePdf(draft)) {
showToast('Der PDF-Export wurde vom Browser blockiert — bitte Pop-ups erlauben.', 'error')
}
}}
sx={{ textTransform: 'none', borderColor: DS_SLATE[200], color: DS_TEXT.primary }}
>
Als PDF
</Button>
<Button
variant="outlined"
startIcon={<Download size={15} />}
onClick={() => downloadExposeWord(draft)}
sx={{ textTransform: 'none', borderColor: DS_SLATE[200], color: DS_TEXT.primary }}
>
Als Word
</Button>
</Box>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
Der PDF-Export nutzt den Druckdialog des Browsers («Als PDF speichern»); CI, Bilder,
Texte und Anhänge folgen der Exposé-Konfiguration.
</Typography>
</Box>
)}
</Box>
{/* Speichern und Erstellen sind klar getrennt (§8.5.2). */}
<Box
sx={{
px: 3, py: 1.75,
borderTop: '1px solid #e8e7e4',
display: 'flex', alignItems: 'center', gap: 1,
}}
>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, flex: 1 }}>
{isGenerated
? `Erstellt am ${new Date(draft.generatedAt!).toLocaleString('de-CH')}`
: 'Noch kein Exposé erstellt.'}
</Typography>
<Button
size="small"
onClick={() => saveDraft.mutate(draft)}
disabled={saveDraft.isPending}
sx={{ textTransform: 'none', color: DS_TEXT.secondary }}
>
Speichern
</Button>
<Button
size="small"
variant="contained"
onClick={() => generate.mutate(draft, { onSuccess: () => setStep(2) })}
disabled={generate.isPending}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Speichern &amp; erstellen
</Button>
</Box>
</Box>
)
}
-149
View File
@@ -1,149 +0,0 @@
import { memo, useCallback, useRef } from 'react'
import { Box, Typography } from '@mui/material'
import type { ExposeLead } from '../../domain/exposeLead'
import type { Property } from '../../domain/property'
import { ObjectDeepLink } from '../team'
import { LeadWorkspace } from './LeadWorkspace'
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
export const LEAD_COLS = '110px 1.2fr 1.4fr 1fr 1.4fr'
const COLUMN_LABELS = ['Datum', 'Möglicher Interessent', 'Kontaktdaten', 'Raum', 'Objektempfehlung']
interface RowProps {
lead: ExposeLead
properties: Property[]
selected: boolean
onSelect: (id: string, row: HTMLElement | null) => void
}
const LeadRow = memo(function LeadRow({ lead, properties, selected, onSelect }: RowProps) {
const ref = useRef<HTMLDivElement>(null)
const recommended = lead.propertyIds
.map(id => properties.find(p => p.id === id))
.filter((p): p is Property => Boolean(p))
return (
<Box
ref={ref}
onClick={() => onSelect(lead.id, ref.current)}
sx={{
display: 'grid',
gridTemplateColumns: LEAD_COLS,
alignItems: 'flex-start',
gap: 1,
px: 2, py: 1.5,
borderBottom: '1px solid #f1f5f9',
bgcolor: selected ? '#f8fafc' : 'white',
cursor: 'pointer',
'&:hover': { bgcolor: DS_SLATE[50] },
transition: 'background-color 0.1s',
}}
>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, pt: 0.25 }}>
{new Date(lead.receivedAt).toLocaleDateString('de-CH')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>
{lead.prospect}
</Typography>
<Box sx={{ minWidth: 0 }}>
{lead.contacts.length === 0 ? (
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}>–</Typography>
) : (
lead.contacts.map(c => (
<Typography
key={c}
variant="caption"
sx={{ display: 'block', color: DS_TEXT.secondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{c}
</Typography>
))
)}
</Box>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
{lead.locationHint}
</Typography>
{/* Mehrere Objekte je Lead — jedes verlinkt nach «Meine Objekte». */}
<Box sx={{ minWidth: 0 }} onClick={e => e.stopPropagation()}>
{recommended.length === 0 ? (
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}>–</Typography>
) : (
recommended.map(p => (
<ObjectDeepLink key={p.id} propertyId={p.id} label={p.title} />
))
)}
</Box>
</Box>
)
})
interface Props {
leads: ExposeLead[]
properties: Property[]
selectedId: string | null
onSelect: (id: string, row: HTMLElement | null) => void
/** Arbeitsbereich nur bei aktiven Leads — Archiviertes wird nicht bearbeitet. */
withWorkspace: boolean
emptyText: string
}
/**
* Livias Leadliste im Grunddesign der Ferdi-Liste (Runde 4, §8.3).
*
* Der Arbeitsbereich öffnet sich zwischen der gewählten Zeile und den übrigen
* Leads — nicht in einem Drawer. Das hält den Bezug zum Lead sichtbar, während
* gearbeitet wird.
*/
export function LeadTable({ leads, properties, selectedId, onSelect, withWorkspace, emptyText }: Props) {
const handleSelect = useCallback(
(id: string, row: HTMLElement | null) => onSelect(id, row),
[onSelect],
)
if (leads.length === 0) {
return (
<Box sx={{ py: 6, textAlign: 'center' }}>
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}>{emptyText}</Typography>
</Box>
)
}
return (
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
<Box
sx={{
display: 'grid',
gridTemplateColumns: LEAD_COLS,
gap: 1,
px: 2, py: 1,
bgcolor: DS_SLATE[50],
borderBottom: '1px solid #e2e8f0',
}}
>
{COLUMN_LABELS.map(h => (
<Typography key={h} variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary, fontSize: '0.7rem' }}>
{h}
</Typography>
))}
</Box>
{leads.map(lead => (
<Box key={lead.id}>
<LeadRow
lead={lead}
properties={properties}
selected={lead.id === selectedId}
onSelect={handleSelect}
/>
{withWorkspace && lead.id === selectedId && <LeadWorkspace lead={lead} />}
</Box>
))}
</Box>
)
}
-64
View File
@@ -1,64 +0,0 @@
import { useState } from 'react'
import { Alert, Box, Tab, Tabs } from '@mui/material'
import type { ExposeLead } from '../../domain/exposeLead'
import { useProperties } from '../../hooks/useProperties'
import { ExposeWizard } from './ExposeWizard'
import { PanelLoadingState } from '../ui'
import { DS_SLATE } from '../../lib/ds'
interface Props {
lead: ExposeLead
}
/**
* Arbeitsbereich zur Exposé-Erstellung, der sich unter dem gewählten Lead öffnet.
*
* Ein Lead kann mehrere Objekte empfehlen; jedes bekommt sein eigenes Exposé.
* Deshalb steht hier eine Objektauswahl darüber und nicht eine gemeinsame
* Broschüre für alles — ein Exposé beschreibt genau ein Objekt.
*/
export function LeadWorkspace({ lead }: Props) {
const { data: properties = [], isLoading } = useProperties()
// Kein Rücksetz-Effekt beim Leadwechsel nötig: die Liste rendert je Lead eine
// eigene Zeile, ein anderer Lead ist also ein neuer Arbeitsbereich.
const [propertyId, setPropertyId] = useState<string>(lead.propertyIds[0] ?? '')
if (isLoading) return <PanelLoadingState />
// Objektempfehlungen sind auf reale Einträge aus «Meine Objekte» beschränkt.
const recommended = lead.propertyIds
.map(id => properties.find(p => p.id === id))
.filter((p): p is NonNullable<typeof p> => Boolean(p))
if (recommended.length === 0) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="warning">
Zu diesem Lead ist kein Objekt aus «Meine Objekte» hinterlegt — ohne Objekt lässt sich kein Exposé erstellen.
</Alert>
</Box>
)
}
const active = recommended.find(p => p.id === propertyId) ?? recommended[0]
return (
<Box sx={{ px: 3, py: 2.5, bgcolor: DS_SLATE[50], borderTop: '1px solid #e8e7e4', borderBottom: '1px solid #e8e7e4' }}>
{recommended.length > 1 && (
<Tabs
value={active.id}
onChange={(_, v: string) => setPropertyId(v)}
variant="scrollable"
scrollButtons="auto"
sx={{ mb: 2, '& .MuiTab-root': { textTransform: 'none', fontSize: '0.8125rem' } }}
>
{recommended.map(p => (
<Tab key={p.id} value={p.id} label={p.title} />
))}
</Tabs>
)}
<ExposeWizard leadId={lead.id} property={active} />
</Box>
)
}
-10
View File
@@ -1,10 +0,0 @@
// Property On — Livia, Exposé-Bereich (nachgeordnet seit Runde 8). Barrel-Export (CLAUDE.md §13).
export { LeadTable, LEAD_COLS } from './LeadTable'
export { LeadWorkspace } from './LeadWorkspace'
// Runde 8, §1: der Exposé-Bereich als nachgeordneter Abschnitt auf Livias Seite.
export { ExposeLeadSection } from './ExposeLeadSection'
export { ExposeWizard } from './ExposeWizard'
export { ExposeMediaManager } from './ExposeMediaManager'
export { ExposeFieldGroup } from './ExposeFieldGroup'
+9 -57
View File
@@ -1,9 +1,7 @@
import { useCallback, useMemo, useState } from 'react'
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
import { CheckCircle2, ExternalLink, Forward, MapPin, Ruler } from 'lucide-react'
import { useMemo } from 'react'
import { Alert, Box, Chip, Divider, Typography } from '@mui/material'
import { CheckCircle2, ExternalLink, MapPin, Ruler } from 'lucide-react'
import type { MarketLead } from '../../hooks/useMarketLeads'
import { useCreateExposeLead } from '../../hooks/useExposeLeads'
import { useToastStore } from '../../stores/toastStore'
import { AgentAvatar, ObjectDeepLink } from '../team'
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
import { SOURCE_TYPE_LABELS } from '../../lib/constants'
@@ -30,16 +28,15 @@ const LIVIA = agentWorkspaceById('livia')!
interface LeadDetailProps {
lead: MarketLead
/** Matching-Block und Weiterleitung anzeigen. Bei Nora ja, bei Livia nein (§1). */
/** Matching-Block anzeigen. Bei Nora ja, bei Livia nein (§1). */
showMatching?: boolean
}
/**
* Wrapper, der die Detailansicht über die Signal-ID neu aufbaut.
*
* Damit ist die Objektauswahl an das Signal gebunden, ohne sie in einem Effekt
* zurücksetzen zu müssen — ein neues Signal ist eine neue Komponente, und die
* startet ohnehin ohne Auswahl.
* Ein neues Signal ist damit eine neue Komponente und startet mit frischem
* Zustand, statt ihn in einem Effekt zurücksetzen zu müssen.
*/
function LeadDetail({ lead, showMatching = true }: LeadDetailProps) {
return <LeadDetailBody key={lead.signal.id} lead={lead} showMatching={showMatching} />
@@ -55,10 +52,6 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
const rationale = signalRationaleOf(signal)
const action = recommendedActionOf(signal)
const forwardLead = useCreateExposeLead()
const showToast = useToastStore(s => s.showToast)
const [selectedIds, setSelectedIds] = useState<string[]>([])
// Objektvorschläge unterhalb der Schwelle werden gar nicht erst gezeigt.
const relevant = useMemo(
() =>
@@ -70,29 +63,6 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
const contacts = useMemo(() => confirmableContacts(signal.extractedContacts), [signal.extractedContacts])
const toggle = useCallback((id: string) => {
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}, [])
function handleForward() {
const count = selectedIds.length
forwardLead.mutate(
{
prospect: companyName,
contacts: contacts.map(c => c.value),
locationHint: signal.locationHint,
propertyIds: selectedIds,
sourceSignalId: signal.id,
},
{
onSuccess: () => {
setSelectedIds([])
showToast(`Zur Exposé-Erstellung vorgemerkt — ${count} Objekt(e) für ${companyName}.`, 'success')
},
},
)
}
return (
<Box sx={{ height: '100%', overflowY: 'auto' }}>
{/* Header — ohne Wahrscheinlichkeitsangabe (§7.3) */}
@@ -270,27 +240,9 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
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>
</>
relevant.map(({ p, fit }) => (
<SignalPropertyRow key={p.id} p={p} fit={fit} />
))
)}
</Box>
</>
@@ -1,4 +1,4 @@
import { Box, Checkbox, Chip, Typography } from '@mui/material'
import { Box, Chip, Typography } from '@mui/material'
import { Building2, ExternalLink } from 'lucide-react'
import type { Property } from '../../domain/property'
import type { ExtractedContact } from '../../domain/futureSignal'
@@ -59,41 +59,36 @@ function ContactRow({ contact }: { contact: ExtractedContact }) {
)
}
// ── Objektzeile mit Mehrfachauswahl ──────────────────────────────────────────
// ── Objektzeile ──────────────────────────────────────────────────────────────
/**
* Ein passendes Portfolioobjekt zu einem Signal.
*
* Der Anschreiben-Entwurf und die Reminder-Aktion sind entfallen (Runde 4, §7.3):
* Nora erkennt die Nachfrage, das Dokument macht Livia. Geblieben ist die
* Auswahl — mehrere Objekte lassen sich gemeinsam für dasselbe Signal
* weiterreichen, statt für jedes einzeln denselben Weg zu gehen.
* Der Anschreiben-Entwurf und die Reminder-Aktion sind mit Runde 4 entfallen
* (§7.3), die Mehrfachauswahl mit Runde 8: sie diente allein dazu, Objekte an
* die Exposé-Erstellung weiterzureichen, und diese Funktion gibt es bei Livia
* nicht mehr. Eine Auswahl ohne Ziel wäre ein Bedienelement, das nichts
* auslöst — schlimmer als keines.
*
* Geblieben ist, was Nora für die Entscheidung braucht: welches Objekt, wo,
* wie gross, zu welchem Preis und wie gut die Fläche passt.
*/
function SignalPropertyRow({
p, fit, selected, onToggle,
p, fit,
}: {
p: Property
fit: number | null
selected: boolean
onToggle: () => void
}) {
return (
<Box
sx={{
display: 'flex', alignItems: 'center', gap: 1,
px: 1.5, py: 1.25, mb: 1,
border: `1px solid ${selected ? '#152642' : '#e2e8f0'}`,
border: '1px solid #e2e8f0',
borderRadius: 1.5,
bgcolor: selected ? '#f8fafc' : 'white',
bgcolor: 'white',
}}
>
<Checkbox
size="small"
checked={selected}
onChange={onToggle}
slotProps={{ input: { 'aria-label': `${p.title} auswählen` } }}
sx={{ p: 0.5 }}
/>
<Building2 size={14} color="#152642" style={{ flexShrink: 0 }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<ObjectDeepLink propertyId={p.id} label={p.title} fontWeight={600} />
-86
View File
@@ -1,86 +0,0 @@
/**
* Property On — Exposé-Dossier von Livia.
*
* Der Datensatz bildet die fachlichen Bereiche der Referenzvorlage ab
* (Runde 4, §8.5.2). Bewusst als flaches Feld-Wörterbuch statt als tief
* geschachtelte Struktur: die Bereiche sind reine Gliederung des Formulars,
* kein Domänenmodell. Eine Schachtelung würde jedes Feld doppelt benennen —
* einmal im Typ, einmal in der Feldliste — und beide könnten auseinanderlaufen.
*
* Was aus «Meine Objekte» belegbar ist, füllt Livia; alles andere bleibt leer
* und wird als fehlend markiert. Es werden keine plausiblen Werte erfunden.
*/
import type { ExposeTonality } from '../services/ai/IAIService'
/** Kategorien für die Medien-Kuration. */
export const ExposeImageCategory = {
EXTERIOR: 'EXTERIOR',
INTERIOR: 'INTERIOR',
FLOORPLAN: 'FLOORPLAN',
SURROUNDINGS: 'SURROUNDINGS',
OTHER: 'OTHER',
} as const
export type ExposeImageCategory = typeof ExposeImageCategory[keyof typeof ExposeImageCategory]
export interface ExposeImage {
id: string
url: string
fileName: string
category: ExposeImageCategory
caption: string
/** Im Exposé sichtbar. */
visible: boolean
isCover: boolean
/** true = aus «Meine Objekte» importiert, false = zusätzlich hochgeladen. */
imported: boolean
}
export const ExposeDocumentType = {
FLOORPLAN: 'FLOORPLAN',
LEASE_CONTRACT: 'LEASE_CONTRACT',
ENERGY_CERTIFICATE: 'ENERGY_CERTIFICATE',
SITE_PLAN: 'SITE_PLAN',
OTHER: 'OTHER',
} as const
export type ExposeDocumentType = typeof ExposeDocumentType[keyof typeof ExposeDocumentType]
export interface ExposeDocument {
id: string
type: ExposeDocumentType
title: string
fileName: string
/** Als Anhang im Exposé sichtbar. */
visibleAsAttachment: boolean
}
/** Herkunft der Firmenfarben im Exposé. */
export const ExposeBranding = {
FROM_PROFILE: 'FROM_PROFILE',
MANUAL: 'MANUAL',
} as const
export type ExposeBranding = typeof ExposeBranding[keyof typeof ExposeBranding]
/**
* Sämtliche Formularwerte. Schlüssel siehe `lib/exposeFields.ts` — dort stehen
* Beschriftung, Feldtyp, Bereich und Pflichtangabe an genau einer Stelle.
*/
export type ExposeValues = Record<string, string>
export interface ExposeDraft {
id: string
leadId: string
/** Objekt aus «Meine Objekte», aus dem die Angaben stammen. */
propertyId: string
values: ExposeValues
tonality: ExposeTonality
images: ExposeImage[]
documents: ExposeDocument[]
branding: ExposeBranding
brandColor?: string
/** Ansprechperson fürs Exposé — aus dem Maklerprofil oder manuell. */
contactPerson: string
updatedAt: string
/** Zeitpunkt der letzten Erstellung; leer, solange nur gespeichert wurde. */
generatedAt?: string
}
-38
View File
@@ -1,38 +0,0 @@
/**
* Property On — Leads, die bei Livia zur Exposé-Erstellung liegen.
*
* Ein Lead entsteht auf zwei Wegen: Nora erkennt ein Nachfragesignal und ein
* Bewirtschafter leitet es mit den passenden Objekten an Livia weiter, oder er
* erfasst ihn selbst. Der Typ bildet deshalb nur ab, was Livia zum Arbeiten
* braucht — Interessent, Kontaktdaten, Raum und Objektempfehlung.
*/
export const ExposeLeadStatus = {
ACTIVE: 'ACTIVE',
ARCHIVED: 'ARCHIVED',
} as const
export type ExposeLeadStatus = typeof ExposeLeadStatus[keyof typeof ExposeLeadStatus]
export interface ExposeLead {
id: string
/** Eingangsdatum, ISO. */
receivedAt: string
/** Möglicher Interessent — Firma oder Person, wie sie belegt ist. */
prospect: string
/** Belegbare Kontaktangaben. Unsichere Angaben stehen hier bewusst nicht. */
contacts: string[]
/** Gesuchter Raum, z. B. «Zürich, Innenstadt / Paradeplatz». */
locationHint: string
/** Objektempfehlung — ausschliesslich IDs aus «Meine Objekte». */
propertyIds: string[]
status: ExposeLeadStatus
/** Ursprungssignal bei Nora, sofern der Lead von dort kam. */
sourceSignalId?: string
/** Wer den Lead weitergeleitet hat. */
forwardedBy?: string
}
export type CreateExposeLeadInput = Omit<ExposeLead, 'id' | 'receivedAt' | 'status'> & {
receivedAt?: string
status?: ExposeLeadStatus
}
-60
View File
@@ -1,60 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { exposeService } from '../services/exposeService'
import { aiService } from '../services/aiService'
import type { ExposeDraft } from '../domain/expose'
import type { Property } from '../domain/property'
import type { ExposeTextInput } from '../services/ai/IAIService'
import { STALE_EXPOSE_LEADS } from '../lib/constants'
import { useToastStore } from '../stores/toastStore'
export function useExposeDraft(leadId: string, property: Property | undefined) {
return useQuery({
queryKey: ['expose-draft', leadId, property?.id],
queryFn: async () => (await exposeService.getOrCreateDraft(leadId, property!)).data,
enabled: !!leadId && !!property,
staleTime: STALE_EXPOSE_LEADS,
})
}
export function useSaveExposeDraft() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (draft: ExposeDraft) => (await exposeService.save(draft)).data,
onSuccess: (draft) => {
queryClient.setQueryData(['expose-draft', draft.leadId, draft.propertyId], draft)
},
onError: () => {
useToastStore.getState().showToast('Exposé konnte nicht gespeichert werden.', 'error')
},
})
}
export function useGenerateExpose() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (draft: ExposeDraft) => (await exposeService.generate(draft)).data,
onSuccess: (draft) => {
queryClient.setQueryData(['expose-draft', draft.leadId, draft.propertyId], draft)
useToastStore.getState().showToast('Exposé erstellt — bereit für den Export.', 'success')
},
onError: (err: Error) => {
useToastStore.getState().showToast(err.message || 'Exposé konnte nicht erstellt werden.', 'error')
},
})
}
/**
* KI-Textentwurf für einen Abschnitt.
*
* Fehlen belegte Angaben, liefert der Dienst keinen Text, sondern die Liste der
* Lücken — die Oberfläche sagt das dann so, statt einen Text zu zeigen, der
* Angaben behauptet, die niemand geprüft hat (§8.5.2, §9.6).
*/
export function useGenerateExposeText() {
return useMutation({
mutationFn: async (input: ExposeTextInput) => (await aiService.generateExposeText(input)).data,
onError: () => {
useToastStore.getState().showToast('Textentwurf konnte nicht erzeugt werden — bitte manuell erfassen.', 'error')
},
})
}
-39
View File
@@ -1,39 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { exposeLeadService } from '../services/exposeLeadService'
import type { CreateExposeLeadInput } from '../domain/exposeLead'
import { STALE_EXPOSE_LEADS } from '../lib/constants'
import { useToastStore } from '../stores/toastStore'
export function useExposeLeads() {
return useQuery({
queryKey: ['expose-leads'],
queryFn: async () => (await exposeLeadService.getAll()).data,
staleTime: STALE_EXPOSE_LEADS,
})
}
export function useCreateExposeLead() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (input: CreateExposeLeadInput) => (await exposeLeadService.create(input)).data,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expose-leads'] })
},
onError: (err: Error) => {
useToastStore.getState().showToast(err.message || 'Lead konnte nicht weitergeleitet werden.', 'error')
},
})
}
export function useArchiveExposeLead() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string) => (await exposeLeadService.archive(id)).data,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expose-leads'] })
},
onError: () => {
useToastStore.getState().showToast('Lead konnte nicht archiviert werden.', 'error')
},
})
}
-58
View File
@@ -59,7 +59,6 @@ export const STALE_AGENT_WORK_ITEMS = 15 * 1000 // aggressive — Freigaben änd
export const STALE_AGENT_PROTOCOL = 15 * 1000 // aggressive — jede Aktion schreibt einen Eintrag
export const STALE_AGENT_CONNECTIONS = 60 * 1000
export const STALE_CALENDAR = 60 * 1000
export const STALE_EXPOSE_LEADS = 30 * 1000 // aggressive — Weiterleitungen sollen sofort erscheinen
export const STALE_VISIT_ASSIGNMENTS = 60 * 1000
/** CRM-Leads ändern sich im Quellsystem, nicht hier — eine Minute genügt. */
export const STALE_CRM_LEADS = 60 * 1000
@@ -357,63 +356,6 @@ export const LEAD_SIGNAL_TYPE_LABELS: Record<string, string> = {
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) ───────────────────────────────────
export const EXPOSE_LEAD_TABS = {
ACTIVE: 'aktive-leads',
ARCHIVED: 'archivierte-leads',
} as const
export type ExposeLeadTab = typeof EXPOSE_LEAD_TABS[keyof typeof EXPOSE_LEAD_TABS]
export const EXPOSE_LEAD_TAB_LABELS: Record<ExposeLeadTab, string> = {
[EXPOSE_LEAD_TABS.ACTIVE]: 'Aktive Leads',
[EXPOSE_LEAD_TABS.ARCHIVED]: 'Archivierte Leads',
}
/** Die drei Schritte des Exposé-Prozesses (§8.5). */
export const EXPOSE_STEPS = ['Hochladen', 'Exposé', 'Export'] as const
export const EXPOSE_IMAGE_CATEGORY_LABELS: Record<string, string> = {
EXTERIOR: 'Aussenansicht',
INTERIOR: 'Innenansicht',
FLOORPLAN: 'Grundriss',
SURROUNDINGS: 'Umgebung',
OTHER: 'Sonstiges',
}
export const EXPOSE_DOCUMENT_TYPE_LABELS: Record<string, string> = {
FLOORPLAN: 'Grundriss',
LEASE_CONTRACT: 'Mietvertrag',
ENERGY_CERTIFICATE: 'Energieausweis (GEAK)',
SITE_PLAN: 'Situationsplan',
OTHER: 'Sonstiges',
}
export const EXPOSE_TONALITY_LABELS: Record<string, string> = {
SACHLICH: 'Sachlich',
HOCHWERTIG: 'Hochwertig',
EINLADEND: 'Einladend',
}
// ── Bruno — Besichtigungsaufträge (Runde 4, §9) ──────────────────────────────
-204
View File
@@ -1,204 +0,0 @@
/**
* Property On — die fachlichen Bereiche und Felder des Exposé-Dossiers.
*
* Eine einzige Liste für Formular, Pflichtfeldprüfung, Vorschau und Export.
* Sie ist die Umsetzung der Referenzvorlage aus Runde 4, §8.5.2 — dort stehen
* dieselben Bereiche in derselben Reihenfolge.
*
* `required: true` heisst: ohne diese Angabe ist das Exposé unvollständig und
* das Feld wird rot umrandet. Der Rest ist Kür — eine Broschüre ohne Kubatur
* ist verkaufbar, eine ohne Mietpreis nicht.
*/
export type ExposeFieldType = 'text' | 'number' | 'select' | 'multiline' | 'chips'
export interface ExposeField {
key: string
label: string
type: ExposeFieldType
required?: boolean
options?: string[]
/** Einheit oder Hinweis in der Feldbeschriftung. */
suffix?: string
/** Nimmt die ganze Zeilenbreite ein. */
wide?: boolean
}
export interface ExposeSection {
id: string
title: string
description?: string
fields: ExposeField[]
/** Abschnitt, für den sich ein KI-Textentwurf auslösen lässt. */
aiSection?: string
}
export const EXPOSE_SECTIONS: ExposeSection[] = [
{
id: 'eckdaten',
title: 'Eckdaten & Vermarktung',
description: 'Objektart und Vermarktungsart steuern die Pflichtfelder des Dossiers.',
fields: [
{ key: 'objektart', label: 'Objektart', type: 'select', required: true, options: ['Büro', 'Retail', 'Gastronomie', 'Logistik', 'Gewerbe', 'Produktion', 'Gemischt'] },
{ key: 'subtyp', label: 'Subtyp', type: 'text' },
{ key: 'vermarktungsart', label: 'Vermarktungsart', type: 'select', required: true, options: ['Miete', 'Kauf', 'Untermiete'] },
{ key: 'status', label: 'Status', type: 'select', options: ['Aktiv in Vermarktung', 'In Vorbereitung', 'Reserviert', 'Abgeschlossen'] },
{ key: 'objektreferenz', label: 'Objektreferenz', type: 'text' },
],
},
{
id: 'lage',
title: 'Lage',
description: 'Strukturierte Adresse; Distanzen erscheinen im Exposé und sind portal-tauglich.',
fields: [
{ key: 'strasse', label: 'Strasse', type: 'text', required: true },
{ key: 'hausnummer', label: 'Nr.', type: 'text' },
{ key: 'plz', label: 'PLZ', type: 'text', required: true },
{ key: 'ort', label: 'Ort', type: 'text', required: true },
{ key: 'gemeinde', label: 'Gemeinde', type: 'text' },
{ key: 'kanton', label: 'Kanton', type: 'text' },
{ key: 'land', label: 'Land', type: 'text' },
],
},
{
id: 'koordinaten',
title: 'Koordinaten & Distanzen',
description: 'Quellen: Swisstopo, ÖV-Fahrplan, OpenStreetMap — Werte bleiben überschreibbar.',
fields: [
{ key: 'breitengrad', label: 'Breitengrad', type: 'text' },
{ key: 'laengengrad', label: 'Längengrad', type: 'text' },
{ key: 'oevHaltestelle', label: 'ÖV-Haltestelle', type: 'number', suffix: 'm' },
{ key: 'einkauf', label: 'Einkauf', type: 'number', suffix: 'm' },
{ key: 'schule', label: 'Schule / Kindergarten', type: 'number', suffix: 'm' },
{ key: 'autobahn', label: 'Autobahnanschluss', type: 'number', suffix: 'm' },
],
},
{
id: 'gemeindedaten',
title: 'Gebäude- & Gemeindedaten',
description: 'Quellen: ARE (ÖV-Güteklasse), BFE Sonnendach, BFS, ESTV — überschreibbar.',
fields: [
{ key: 'oevGueteklasse', label: 'ÖV-Güteklasse', type: 'text' },
{ key: 'solareignung', label: 'Solareignung Dach', type: 'text' },
{ key: 'einwohner', label: 'Einwohner', type: 'number' },
{ key: 'bevoelkerungswachstum', label: 'Bevölkerungswachstum', type: 'number', suffix: '%/J' },
{ key: 'steuerbelastung', label: 'Steuerbelastung', type: 'number', suffix: '%' },
{ key: 'leerwohnungsziffer', label: 'Leerwohnungsziffer', type: 'number', suffix: '%' },
],
},
{
id: 'flaechen',
title: 'Flächen & Gebäude',
description: 'Flächen in m², Schweizer Konvention.',
fields: [
{ key: 'zimmer', label: 'Zimmer', type: 'number' },
{ key: 'schlafzimmer', label: 'Schlafzimmer', type: 'number' },
{ key: 'badezimmer', label: 'Badezimmer', type: 'number' },
{ key: 'sepWc', label: 'Sep. WC', type: 'number' },
{ key: 'wohnflaeche', label: 'Wohnfläche', type: 'number', suffix: 'm²' },
{ key: 'flaeche', label: 'Nutzfläche', type: 'number', suffix: 'm²', required: true },
{ key: 'grundstuecksflaeche', label: 'Grundstücksfläche', type: 'number', suffix: 'm²' },
{ key: 'balkon', label: 'Balkon / Terrasse', type: 'number', suffix: 'm²' },
{ key: 'keller', label: 'Keller', type: 'number', suffix: 'm²' },
{ key: 'raumhoehe', label: 'Raumhöhe', type: 'number', suffix: 'm' },
{ key: 'kubatur', label: 'Kubatur SIA 416', type: 'number', suffix: 'm³' },
],
},
{
id: 'verfuegbarkeit',
title: 'Gebäude & Verfügbarkeit',
fields: [
{ key: 'stockwerk', label: 'Stockwerk', type: 'text' },
{ key: 'geschosse', label: 'Geschosse im Gebäude', type: 'number' },
{ key: 'baujahr', label: 'Baujahr', type: 'number' },
{ key: 'letzteRenovation', label: 'Letzte Renovation', type: 'number' },
{ key: 'zustand', label: 'Zustand', type: 'select', options: ['Neuwertig', 'Gut', 'Renovationsbedürftig', 'Rohbau'] },
{ key: 'verfuegbarkeit', label: 'Verfügbarkeit', type: 'text', required: true },
],
},
{
id: 'baurecht',
title: 'Grundstück & Baurecht',
description: 'Quelle: geodienste/Swisstopo — Ziffern aus dem Bau-/Zonenreglement.',
fields: [
{ key: 'nutzungszone', label: 'Nutzungszone', type: 'text' },
{ key: 'ueberbauungsziffer', label: 'Überbauungsziffer', type: 'text' },
{ key: 'ausnuetzungsziffer', label: 'Ausnützungsziffer', type: 'text' },
],
},
{
id: 'preise',
title: 'Preise & Kosten',
fields: [
{ key: 'nettomiete', label: 'Nettomiete', type: 'number', suffix: 'CHF/m²/J', required: true },
{ key: 'nebenkosten', label: 'Nebenkosten', type: 'number', suffix: 'CHF/m²/J' },
{ key: 'nkArt', label: 'NK-Art', type: 'select', options: ['Akonto', 'Pauschal', 'Nach Aufwand'] },
{ key: 'kaution', label: 'Kaution', type: 'number', suffix: 'Monatsmieten' },
{ key: 'befristung', label: 'Befristung', type: 'text' },
{ key: 'parkierung', label: 'Parkierung', type: 'select', options: ['Tiefgarage', 'Aussenparkplatz', 'Besucherparkplätze', 'Keine'] },
{ key: 'parkplaetze', label: 'Anzahl Plätze', type: 'number' },
{ key: 'mietpreisParkplatz', label: 'Mietpreis Parkplatz', type: 'number', suffix: 'CHF' },
],
},
{
id: 'ausstattung',
title: 'Ausstattung & Merkmale',
description: 'Mehrfachauswahl — Merkmale erscheinen als Liste im Exposé.',
fields: [
{ key: 'aussenbereich', label: 'Aussenbereich', type: 'chips', options: ['Terrasse', 'Sitzplatz', 'Garten', 'Balkon'] },
{ key: 'energieSmart', label: 'Energie & Smart', type: 'chips', options: ['Photovoltaik-Anlage', 'Wärmepumpe', 'E-Ladestation', 'Smart Building', 'Glasfaser-Anschluss'] },
{ key: 'gebaeude', label: 'Gebäude', type: 'chips', options: ['Lift', 'Rollstuhlgängig', 'Kellerabteil', 'Waschküche', 'Minergie-Standard', 'Neubau', 'Erstbezug', 'Video-Gegensprechanlage'] },
{ key: 'innenausbau', label: 'Innenausbau', type: 'chips', options: ['Teeküche', 'Klimaanlage', 'Serverraum', 'Bodenheizung', 'Einbauschränke', 'Hohe Räume', 'Gäste-WC'] },
{ key: 'parkierungMerkmale', label: 'Parkierung', type: 'chips', options: ['Tiefgaragenplatz', 'Aussenparkplatz', 'Besucherparkplätze'] },
{ key: 'umgebung', label: 'Umgebung & Sicht', type: 'chips', options: ['Seesicht', 'Bergsicht', 'Unverbaubare Weitsicht', 'Südausrichtung', 'Ruhige Lage', 'Zentrale Lage'] },
{ key: 'merkmale', label: 'Weitere Merkmale', type: 'text', wide: true },
],
},
{
id: 'energie',
title: 'Energie & Technik',
fields: [
{ key: 'heizsystem', label: 'Heizsystem', type: 'select', options: ['Wärmepumpe', 'Fernwärme', 'Gas', 'Öl', 'Pellets', 'Elektro'] },
{ key: 'waermeverteilung', label: 'Wärmeverteilung', type: 'select', options: ['Bodenheizung', 'Radiatoren', 'Deckenstrahlung', 'Luft'] },
{ key: 'geak', label: 'GEAK', type: 'text' },
{ key: 'photovoltaik', label: 'Photovoltaik-Leistung', type: 'number', suffix: 'kWp' },
{ key: 'eLadestationen', label: 'E-Ladestationen', type: 'number' },
],
},
{
id: 'texte',
title: 'Texte & Beschriebe',
description: 'Diese Texte bilden das Herz des Exposés. Der KI-Entwurf nutzt ausschliesslich die erfassten Objektdaten.',
aiSection: 'texte',
fields: [
{ key: 'exposeTitel', label: 'Exposé-Titel', type: 'text', required: true, wide: true },
{ key: 'kurzbeschrieb', label: 'Kurzbeschrieb (Teaser)', type: 'multiline', required: true, wide: true },
{ key: 'objektbeschrieb', label: 'Objektbeschrieb', type: 'multiline', required: true, wide: true },
{ key: 'lagebeschrieb', label: 'Lagebeschrieb', type: 'multiline', wide: true },
{ key: 'gemeindebeschrieb', label: 'Gemeindebeschrieb', type: 'multiline', wide: true },
{ key: 'ausstattungsbeschrieb', label: 'Ausstattungsbeschrieb', type: 'multiline', wide: true },
{ key: 'highlights', label: 'Highlights (eine Zeile pro Punkt)', type: 'multiline', wide: true },
{ key: 'videoUrl', label: 'Video-URL', type: 'text' },
{ key: 'rundgangUrl', label: 'Virtueller Rundgang (URL)', type: 'text' },
],
},
]
/** Alle Feldschlüssel, die für ein vollständiges Exposé gesetzt sein müssen. */
export const EXPOSE_REQUIRED_KEYS: string[] = EXPOSE_SECTIONS
.flatMap(s => s.fields)
.filter(f => f.required)
.map(f => f.key)
export function exposeFieldByKey(key: string): ExposeField | undefined {
for (const section of EXPOSE_SECTIONS) {
const field = section.fields.find(f => f.key === key)
if (field) return field
}
return undefined
}
/** Welche Pflichtfelder sind noch leer? Basis für die roten Umrandungen. */
export function missingRequiredExposeKeys(values: Record<string, string>): string[] {
return EXPOSE_REQUIRED_KEYS.filter(k => !values[k] || values[k].trim() === '')
}
+105 -139
View File
@@ -1227,130 +1227,118 @@ Bruno — Recap-Agent`,
{
id: 'awi-livia-01',
agentId: AGENT_IDS.LIVIA,
kind: AgentWorkItemKind.LOCATION_MODULE,
area: AgentDomainArea.LOCATION,
title: 'Lagebeschrieb erstellt — Hardturmstrasse 118, Zürich Kreis 5',
kind: AgentWorkItemKind.MARKET_SIGNAL,
area: AgentDomainArea.MARKET,
title: 'Lead erzeugt — Alpenbäckerei Truttmann AG, Filialexpansion Zürich',
summary:
'Zum neuen Vermarktungsauftrag entstanden ein Kurztext für Inserat und Portal sowie ein Langtext für das Exposé, jeweils mit Quellenverzeichnis und Abrufdatum zu jeder Aussage.',
objectId: 'OBJ-2365',
objectLabel: 'Hardturmstrasse 118, 8005 Zürich — Bürofläche 640 m², Zürich Kreis 5',
'Aus dem Geschäftsbericht 2025, einem Medienbericht und zwei Stelleninseraten entstand ein Lead mit Suchprofil, Zeitfenster und Quellenverzeichnis. Eingeordnet als Chance und an die Lead-Liste übergeben.',
objectLabel: 'Kein eigenes Objekt betroffen — externe Nachfrage',
sourceChannel: AgentChannelType.WORKSPACE_CHAT,
targetChannel: AgentChannelType.EMAIL,
createdAt: '2026-04-09T09:15:00.000Z',
completedAt: '2026-04-09T11:40:00.000Z',
createdAt: '2026-05-04T06:10:00.000Z',
completedAt: '2026-05-04T07:35:00.000Z',
priority: AgentWorkItemPriority.LOW,
status: AgentWorkItemStatus.COMPLETED,
requiresDecision: false,
inputs: [
{ label: 'Objektadresse aus dem Vermarktungsauftrag', value: 'Hardturmstrasse 118, 8005 Zürich' },
{ label: 'Quartierzuordnung', value: 'Zürich Kreis 5 (Escher Wyss), geprüft gegen die amtlichen Adressdaten' },
{ label: 'Untersuchungsradius Mikrolage', value: '500 Meter Umkreis, Erdgeschossnutzungen und Erreichbarkeit' },
{ label: 'Auftraggebende Person', value: 'Nadine Brunner, Vermarktung Zürich' },
{ label: 'Auslösende Quelle', value: 'Geschäftsbericht 2025 der Alpenbäckerei Truttmann AG, publiziert 28.04.2026' },
{ label: 'Beobachtungsraum', value: 'Wirtschaftsraum Zürich' },
{ label: 'Beobachtete Veränderung', value: 'Retail-/Filialexpansion' },
{ label: 'Mindestrelevanz', value: '60 % — erreichter Wert 78 %' },
],
result: [
{
label: 'Kurztext für Inserat und Portal',
label: 'Welches Unternehmen',
value: 'Alpenbäckerei Truttmann AG, Bäckerei- und Gastronomiebetrieb mit 34 Filialen in der Deutschschweiz.',
},
{
label: 'Was verändert sich',
value:
'Die Fläche liegt im Kreis 5 zwischen Escher-Wyss-Platz und Hardbrücke, in einem Quartier mit hohem Anteil an Dienstleistung, Gastronomie und Kreativwirtschaft. Die Haltestelle Escher-Wyss-Platz ist in vier Gehminuten erreichbar, der Bahnhof Hardbrücke in acht Minuten.',
'Zwölf geplante Filialeröffnungen bis Ende 2027, davon drei im Raum Zürich. Im Bericht genanntes Suchprofil: Erdgeschosslagen mit hoher Fussgängerfrequenz, 150–220 m².',
},
{ label: 'Wo und wann', value: 'Zürich Kreis 4 und Kreis 5, Zeithorizont 14 Monate.' },
{
label: 'Langtext für das Exposé',
label: 'Warum daraus Flächenbedarf entstehen könnte',
value:
'Dreiteiliger Text mit Mikrolage (Nachbarschaft, Erdgeschossnutzungen, Publikumsfrequenz, Parkierung), Makrolage (Wirtschaftsstruktur Stadt Zürich, Beschäftigtenentwicklung Kreis 5, Steuerlage) und Umfeldentwicklung — insgesamt 3 940 Zeichen.',
'Das Unternehmen hat den Bedarf selbst publiziert und das Suchprofil mitgeliefert. Der CFO hat am 30.04. bestätigt, dass die Standortsuche begonnen hat; zwei Standortleiterstellen für Zürich sind ausgeschrieben.',
},
{ label: 'Einordnung', value: 'Chance — Relevanz 78 %' },
{
label: 'Erreichbarkeit',
value: [
'Tram- und Bushaltestelle Escher-Wyss-Platz: 300 Meter, 4 Gehminuten',
'Bahnhof Zürich Hardbrücke: 650 Meter, 8 Gehminuten',
'Autobahnanschluss Zürich-Hardturm: 1,4 Kilometer',
'Veloweg entlang der Hardturmstrasse, durchgehend abgetrennt',
],
},
{
label: 'Nahversorgung',
value: [
'Einkaufen: zwei Supermärkte innerhalb von 400 Metern',
'Verpflegung: 14 Gastronomiebetriebe im Umkreis von 300 Metern',
'Kinderbetreuung: zwei Kindertagesstätten innerhalb von 600 Metern',
],
},
{
label: 'Umfeldentwicklung',
label: 'Empfohlene nächste Handlung',
value:
'Arealentwicklung Hardturm-Areal in Etappen bis 2029; Zonenänderung rechtskräftig seit 2024 (Quelle: amtliche Publikation, abgerufen 09.04.2026).',
'Erdgeschossflächen zwischen 150 und 220 m² in Kreis 4 und 5 zusammenstellen und dem CFO vorlegen, bevor die Suche über einen Makler ausgeschrieben wird.',
},
{ label: 'Quellenverzeichnis', value: '11 Quellen mit Abrufdatum, je Aussage zugeordnet' },
{ label: 'Quellenverzeichnis', value: '3 Quellen mit Abrufdatum, je Aussage zugeordnet' },
],
processingSteps: [
{
id: 'awi-livia-01-step-1',
label: 'Adresse aufgenommen und dem Quartier zugeordnet',
detail: 'Schreibweise gegen die amtlichen Adressdaten geprüft, Zuordnung Zürich Kreis 5.',
label: 'Verknüpfte Quellen abgegangen',
detail: 'Täglicher Lauf 06:00 über Handelszeitung, NZZ Wirtschaft, Finanz und Wirtschaft sowie SHAB.',
},
{
id: 'awi-livia-01-step-2',
label: 'Mikrolage im Umkreis von 500 Metern abgegangen',
detail: 'Nachbarschaft, Erdgeschossnutzungen, Immissionslage, Publikumsfrequenz und Parkierung erfasst.',
label: 'Geschäftsbericht ausgewertet',
detail: 'Seite 14 nennt zwölf Filialeröffnungen bis Ende 2027 samt Suchprofil.',
},
{
id: 'awi-livia-01-step-3',
label: 'Makrolage eingeordnet',
detail: 'Wirtschaftsstruktur, Beschäftigtenentwicklung und Steuerlage der Stadt Zürich ausgewertet.',
label: 'Meldung gegengeprüft',
detail: 'Medienbericht vom 30.04. und zwei Stelleninserate bestätigen die laufende Standortsuche.',
},
{
id: 'awi-livia-01-step-4',
label: 'Amtliche Publikationen zum Umfeld gesichtet',
detail: 'Arealentwicklung und Zonenänderung als für das Objekt relevant eingestuft.',
label: 'Als Chance eingeordnet und begründet',
detail: 'Publizierter Bedarf mit bekanntem Suchprofil — unmittelbar gegen den Bestand prüfbar.',
},
{
id: 'awi-livia-01-step-5',
label: 'Kurz- und Langtext geschrieben, Quellen angehängt',
detail: 'Je Aussage Quelle und Abrufdatum hinterlegt, Ergebnis am Objekt abgelegt.',
label: 'Lead übergeben',
detail: 'An die gemeinsame Lead-Liste übergeben; kein zweiter Datensatz angelegt.',
},
],
sourceReferences: [
{
id: 'awi-livia-01-src-1',
label: 'Amtliche Publikation zur Zonenänderung Hardturm',
documentName: 'Amtliche-Publikation_Zonenaenderung-Hardturm_2024-11-08.pdf',
locator: 'Seite 3, abgerufen 09.04.2026',
label: 'Geschäftsbericht 2025 der Alpenbäckerei Truttmann AG',
documentName: 'Truttmann_Geschaeftsbericht_2025.pdf',
locator: 'Seite 14, abgerufen 04.05.2026',
},
{
id: 'awi-livia-01-src-2',
label: 'Fahrplanauskunft öffentlicher Verkehr',
documentName: 'Erreichbarkeitsauswertung_OBJ-2365_2026-04-09.pdf',
locator: 'Tabelle «Haltestellen im Umkreis», abgerufen 09.04.2026',
label: 'Medienbericht zur Expansion',
documentName: 'Handelszeitung_Truttmann-Expansion_2026-04-30.pdf',
locator: 'Absatz 3, abgerufen 04.05.2026',
},
{
id: 'awi-livia-01-src-3',
label: 'Quellenverzeichnis zum Lagebeschrieb',
documentName: 'Lagebeschrieb_OBJ-2365_Quellen_2026-04-09.pdf',
locator: 'Seiten 1–2, 11 Einträge',
label: 'Stelleninserate Standortleitung Zürich',
documentName: 'Truttmann_Stelleninserate_Zuerich_2026-04-15.pdf',
locator: 'zwei Inserate, abgerufen 04.05.2026',
},
],
availableActions: [AgentWorkItemAction.OPEN_SOURCE, AgentWorkItemAction.MARK_DONE],
history: [
{
id: 'awi-livia-01-hist-1',
at: '2026-04-09T09:15:00.000Z',
at: '2026-05-04T06:10:00.000Z',
actor: 'Livia',
action: 'Vermarktungsauftrag übernommen',
note: 'Adresse geprüft und dem Quartier Zürich Kreis 5 zugeordnet.',
action: 'Meldung im Quellenlauf erfasst',
note: 'Geschäftsbericht 2025 als relevant eingestuft.',
},
{
id: 'awi-livia-01-hist-2',
at: '2026-04-09T11:40:00.000Z',
at: '2026-05-04T07:35:00.000Z',
actor: 'Livia',
action: 'Lagebeschrieb abgelegt und zugestellt',
note: 'Kurz- und Langtext mit 11 Quellen an die Vermarktung Zürich.',
action: 'Lead erzeugt und übergeben',
note: 'Chance, Relevanz 78 %, drei Quellen mit Abrufdatum.',
},
{
id: 'awi-livia-01-hist-3',
at: '2026-04-14T08:05:00.000Z',
at: '2026-05-06T08:15:00.000Z',
actor: 'Nadine Brunner',
action: 'Text für das Inserat übernommen',
note: 'Kurztext unverändert im Portalauftritt veröffentlicht.',
action: 'Lead in Bearbeitung genommen',
note: 'Objektauswahl über Noras Abgleich angefordert.',
},
],
},
@@ -1359,140 +1347,118 @@ Bruno — Recap-Agent`,
{
id: 'awi-livia-02',
agentId: AGENT_IDS.LIVIA,
kind: AgentWorkItemKind.LOCATION_MODULE,
area: AgentDomainArea.LOCATION,
title: 'Unsichere Aussage zum Entwicklungsgebiet Wankdorf — Review erbeten',
kind: AgentWorkItemKind.MARKET_SIGNAL,
area: AgentDomainArea.MARKET,
title: 'Review nötig — Spedition Rhein GmbH, widersprüchliche Angaben zum Standort Basel',
summary:
'Für den Langtext zum Objekt in Bern liegen zwei Quellen mit unterschiedlichen Angaben zur Inbetriebnahme der neuen Haltestelle vor. Die Aussage bleibt aus dem Text ausgeklammert, bis entschieden ist, welche Angabe gilt.',
objectId: 'OBJ-2733',
objectLabel: 'Wankdorffeldstrasse 12, 3014 Bern — Bürofläche 1. bis 3. Obergeschoss, 1 150 m²',
'Zwei belegte Quellen widersprechen sich, ob der Standort Basel Kleinhüningen ganz oder teilweise aufgegeben wird. Da ein bestehendes Mietverhältnis betroffen ist, wird der Lead nicht ohne Prüfung als gesichertes Risiko geführt.',
objectId: 'prop-002',
objectLabel: 'Lagerfläche Hardstrasse 44, Basel — Mieterin: Spedition Rhein GmbH',
sourceChannel: AgentChannelType.WORKSPACE_CHAT,
targetChannel: AgentChannelType.EMAIL,
createdAt: '2026-05-07T07:45:00.000Z',
priority: AgentWorkItemPriority.MEDIUM,
targetChannel: AgentChannelType.WORKSPACE_CHAT,
createdAt: '2026-05-07T06:15:00.000Z',
priority: AgentWorkItemPriority.HIGH,
status: AgentWorkItemStatus.PENDING,
requiresDecision: true,
escalationReason:
'Review eines unsicheren Ergebnisses: Zwei belegte Quellen nennen unterschiedliche Zeitpunkte für die Inbetriebnahme der Haltestelle Wankdorffeld. Eine Aussage ohne eindeutigen Beleg geht nicht in den Exposétext.',
decisionQuestion:
'Welche Angabe soll in den Langtext übernommen werden — «ab Dezember 2027» gemäss amtlicher Publikation oder «ab Sommer 2028» gemäss Mitteilung der Standortförderung — oder soll der Absatz zur Haltestelle vorerst entfallen?',
'Soll der Lead als Risiko mit vollständiger Flächenfreisetzung geführt werden, oder wird bis zur Rückmeldung des Mieters nur die Teilaufgabe angenommen?',
inputs: [
{ label: 'Objektadresse aus dem Vermarktungsauftrag', value: 'Wankdorffeldstrasse 12, 3014 Bern' },
{ label: 'Quartierzuordnung', value: 'Bern Wankdorf, Entwicklungsgebiet Wankdorf-Nord' },
{
label: 'Widersprüchliche Quellen',
value: [
'Amtliche Publikation vom 12.03.2026 — Inbetriebnahme Dezember 2027',
'Mitteilung der Standortförderung vom 28.04.2026 — Inbetriebnahme Sommer 2028',
],
},
{ label: 'Auftraggebende Person', value: 'Nadine Brunner, Vermarktung Zürich (Mandat Bern)' },
{ label: 'Betroffenes Mietverhältnis', value: 'Spedition Rhein GmbH, Lagerfläche Hardstrasse 44, 1 900 m²' },
{ label: 'Auslösende Quelle', value: 'Konzernmitteilung vom 06.05.2026 zur Bündelung der Lagerlogistik' },
{ label: 'Gegenprüfung', value: 'Handelsregister-Mutation vom 11.05.2026, Zweckanpassung ohne eigene Lagerflächen' },
],
result: [
{ label: 'Stand', value: 'Kurztext fertig, Langtext zu 90 Prozent — ein Absatz ist ausgeklammert' },
{
label: 'Fertiger Kurztext',
value:
'Das Objekt liegt im Entwicklungsgebiet Wankdorf, unmittelbar an der Achse zwischen Bahnhof Wankdorf und Autobahnanschluss Bern-Wankdorf. Das Umfeld ist geprägt von Dienstleistung, öffentlichen Institutionen und Verkaufsflächen.',
label: 'Widerspruch',
value: [
'Konzernmitteilung: Bündelung auf Pratteln und Kaiseraugst, Basel nicht als Zentralstandort genannt.',
'Handelsregister: Zweckanpassung nennt den Betrieb eigener Lagerflächen nicht mehr — lässt aber offen, ob Basel ganz entfällt.',
],
},
{
label: 'Ausgeklammerter Absatz',
value:
'Die geplante Haltestelle Wankdorffeld liegt rund 200 Meter vom Objekt entfernt. Die Inbetriebnahme ist für [Zeitpunkt offen] vorgesehen.',
label: 'Was gesichert ist',
value: 'Die Bündelung ist publiziert, die Zweckanpassung eingetragen, und die Spedition Rhein GmbH ist Mieterin der Fläche.',
},
{
label: 'Warum die Angabe zählt',
label: 'Was offen bleibt',
value: 'Ob der Standort Basel ganz oder teilweise aufgegeben wird und ab wann.',
},
{
label: 'Empfohlene nächste Handlung',
value:
'Die Wegzeit zum öffentlichen Verkehr sinkt mit der neuen Haltestelle von 11 auf 3 Gehminuten — die Aussage ist für Interessenten mit Publikumsverkehr entscheidend.',
'Das Mietverhältnis prüfen — Laufzeit, Kündigungsfrist, Break-Optionen — und das Gespräch mit der Geschäftsleitung suchen, bevor eine Kündigung eintrifft.',
},
{ label: 'Übrige Quellen', value: '9 Quellen mit Abrufdatum, alle eindeutig und im Text belegt' },
],
processingSteps: [
{
id: 'awi-livia-02-step-1',
label: 'Adresse aufgenommen und dem Quartier zugeordnet',
detail: 'Zuordnung Bern Wankdorf, Entwicklungsgebiet Wankdorf-Nord.',
label: 'Konzernmitteilung erfasst',
detail: 'Im Quellenlauf vom 07.05. als relevant für den Beobachtungsraum Basel eingestuft.',
},
{
id: 'awi-livia-02-step-2',
label: 'Mikro- und Makrolage beschrieben',
detail: 'Nachbarschaft, Erreichbarkeit, Nahversorgung und Wirtschaftsraum Bern erfasst.',
label: 'Mieterbezug geprüft',
detail: 'Abgleich mit den Stammdaten: Spedition Rhein GmbH ist Mieterin der Hardstrasse 44.',
},
{
id: 'awi-livia-02-step-3',
label: 'Amtliche Publikationen zum Umfeld gesichtet',
detail: 'Beim Verkehrsvorhaben zwei Quellen mit unterschiedlichen Zeitangaben gefunden.',
label: 'Handelsregister gegengeprüft',
detail: 'Mutation vom 11.05. bestätigt die Richtung, nicht aber den Umfang.',
},
{
id: 'awi-livia-02-step-4',
label: 'Aussage ausgeklammert statt gemittelt',
detail: 'Der Absatz bleibt offen, bis die massgebende Quelle bestimmt ist.',
label: 'Widerspruch festgehalten statt aufgelöst',
detail: 'Der Umfang lässt sich aus keiner der beiden Quellen belegen; eine Annahme wäre eine Behauptung.',
},
{
id: 'awi-livia-02-step-5',
label: 'Review vorgelegt',
detail: 'Beide Belege mit Fundstelle und Abrufdatum zur Entscheidung beigelegt.',
detail: 'Als Risiko markiert, aber mit ausgewiesener Unsicherheit zur Entscheidung vorgelegt.',
},
],
sourceReferences: [
{
id: 'awi-livia-02-src-1',
label: 'Amtliche Publikation Verkehrsvorhaben Wankdorffeld',
documentName: 'Amtliche-Publikation_Haltestelle-Wankdorffeld_2026-03-12.pdf',
locator: 'Seite 2, Abschnitt «Terminprogramm»',
quote:
'«Die Inbetriebnahme der Haltestelle Wankdorffeld ist auf den Fahrplanwechsel im Dezember 2027 vorgesehen.»',
label: 'Konzernmitteilung zur Standortbündelung',
documentName: 'Handelszeitung_Spedition-Rhein-Restrukturierung_2026-05-06.pdf',
locator: 'Absatz 2, abgerufen 07.05.2026',
},
{
id: 'awi-livia-02-src-2',
label: 'Mitteilung der Standortförderung Bern',
documentName: 'Standortfoerderung-Bern_Mitteilung_2026-04-28.pdf',
locator: 'Seite 1, abgerufen 07.05.2026',
quote:
'«Wegen der Anpassung der Gleisgeometrie verschiebt sich die Eröffnung der Haltestelle Wankdorffeld auf Sommer 2028.»',
label: 'Handelsregister-Mutation',
documentName: 'Zefix_Spedition-Rhein-GmbH_Mutation_2026-05-11.pdf',
locator: 'Eintrag vom 11.05.2026, abgerufen 11.05.2026',
},
],
availableActions: [
AgentWorkItemAction.DECIDE,
AgentWorkItemAction.EDIT,
AgentWorkItemAction.APPROVE,
AgentWorkItemAction.OPEN_SOURCE,
],
editableFields: [
{
id: 'awi-livia-02-field-date',
label: 'Zu verwendender Zeitpunkt',
value: 'Dezember 2027',
helperText: 'Alternativ «Sommer 2028» oder leer lassen, damit der Absatz entfällt.',
},
{
id: 'awi-livia-02-field-paragraph',
label: 'Absatz zur Haltestelle',
value:
'Die geplante Haltestelle Wankdorffeld liegt rund 200 Meter vom Objekt entfernt. Die Inbetriebnahme ist auf Dezember 2027 vorgesehen; die Wegzeit zum öffentlichen Verkehr verkürzt sich damit von 11 auf 3 Gehminuten.',
multiline: true,
id: 'awi-livia-02-field-scope',
label: 'Angenommener Umfang',
value: 'Vollständige Aufgabe des Standorts',
helperText: 'Bestimmt, ob der Lead als Risiko über die ganze Fläche oder als Teilrisiko geführt wird.',
},
{
id: 'awi-livia-02-field-source',
label: 'Anzugebende Quelle',
value: 'Amtliche Publikation Verkehrsvorhaben Wankdorffeld, 12.03.2026, abgerufen 07.05.2026',
helperText: 'Erscheint im Quellenverzeichnis des Exposés.',
label: 'Massgebliche Quelle',
value: 'Konzernmitteilung vom 06.05.2026',
helperText: 'Erscheint im Quellenverzeichnis des Leads.',
},
],
availableActions: [AgentWorkItemAction.APPROVE, AgentWorkItemAction.OPEN_SOURCE],
history: [
{
id: 'awi-livia-02-hist-1',
at: '2026-05-07T07:45:00.000Z',
at: '2026-05-07T06:15:00.000Z',
actor: 'Livia',
action: 'Widerspruch erkannt und Review vorgelegt',
note: 'Zwei belegte Quellen mit unterschiedlichen Zeitangaben zur Haltestelle.',
note: 'Zwei belegte Quellen mit unterschiedlichem Umfang der Standortaufgabe.',
},
{
id: 'awi-livia-02-hist-2',
at: '2026-05-15T09:20:00.000Z',
at: '2026-05-19T06:15:00.000Z',
actor: 'Livia',
action: 'Quellen erneut geprüft',
note: 'Quartalsprüfung: keine neuere amtliche Publikation vorhanden, Widerspruch besteht weiter.',
note: 'Täglicher Lauf: keine neuere Meldung vorhanden, Widerspruch besteht weiter.',
},
],
},
+3 -20
View File
@@ -6,11 +6,6 @@
* 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).
@@ -69,7 +64,6 @@ export const liviaAgent: TeamAgent = {
'Einordnung als Chance oder Risiko, je mit empfohlener nächster Handlung',
'Quellenverzeichnis mit Abrufdatum je Aussage',
'Hinweis auf betroffene bestehende Mietverhältnisse bei negativen Signalen',
'Nachgeordnet: Exposés und Angebotsbroschüren aus den Objekten in «Meine Objekte»',
],
},
@@ -83,7 +77,6 @@ export const liviaAgent: TeamAgent = {
'Jeden Lead als Chance oder Risiko einordnen und die Einordnung begründen',
'Jede Aussage mit einer nachprüfbaren Quelle und einem Abrufdatum hinterlegen',
'Keinen Newsfeed erzeugen, sondern verdichten — nur was zu einer Handlung führen kann',
'Nachgeordnet: Exposés und Angebotsbroschüren aus den Objektdaten erstellen',
],
tasks: [
@@ -210,16 +203,6 @@ export const liviaAgent: TeamAgent = {
requiresApproval: false,
dependsOnChannel: AgentChannelType.EMAIL,
},
{
id: 'livia-task-14',
title: 'Exposé aus Objektdaten erstellen',
description:
'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,
schedule: 'auf Auslösung im Exposé-Bereich',
requiresApproval: false,
dependsOnSystem: AgentSystemType.DMS,
},
],
channels: [
@@ -300,9 +283,9 @@ export const liviaAgent: TeamAgent = {
access: AgentAccessLevel.READ_WRITE,
status: AgentConnectionStatus.CONNECTED,
usage:
'Ablage der Leads mit Quellenverzeichnis sowie der nachgeordneten Exposés und Angebotsbroschüren.',
'Ablage der Leads mit Quellenverzeichnis und der Wochenüberblicke.',
permissionNote:
'Schreibt ausschliesslich in die Ordner «Marktrecherche» und «Exposés»; bestehende Fassungen bleiben als Version erhalten.',
'Schreibt ausschliesslich in den Ordner «Marktrecherche»; bestehende Fassungen bleiben als Version erhalten.',
},
{
id: 'livia-sys-workspace',
@@ -439,7 +422,7 @@ export const liviaAgent: TeamAgent = {
{
id: 'livia-set-tonality',
label: 'Firmen-Tonalität',
description: 'Sprachstil der Leadtexte und der nachgeordneten Exposé-Texte.',
description: 'Sprachstil der Leadtexte und des Wochenüberblicks.',
kind: AgentSettingKind.SELECT,
group: AgentSettingGroup.GENERAL,
value: 'sachlich',
-68
View File
@@ -1,68 +0,0 @@
/**
* Property On — Leads im Backlog von Livia.
*
* Herkunft: Nachfragesignale, die Nora erkannt hat, plus zwei bereits
* abgeschlossene Vorgänge im Archiv. Sämtliche Objekt-IDs stammen aus
* «Meine Objekte» (`mock-data/properties.ts`) — es werden hier keine Objekte
* erfunden, sonst führte die Objektempfehlung ins Leere.
*
* Zeitanker: 20.05.2026, wie alle Property-On-Mockdaten.
*/
import type { ExposeLead } from '../domain/exposeLead'
import { ExposeLeadStatus } from '../domain/exposeLead'
export const mockExposeLeads: ExposeLead[] = [
{
id: 'lead-001',
receivedAt: '2026-05-19T14:20:00.000Z',
prospect: 'Alpbach Advisors AG',
contacts: ['zuerich@alpbach-advisors.ch', 'https://alpbach-advisors.ch', 'Dr. Markus Suter (Managing Partner)'],
locationHint: 'Zürich, Innenstadt / Paradeplatz',
propertyIds: ['prop-043', 'prop-044'],
status: ExposeLeadStatus.ACTIVE,
sourceSignalId: 'signal-001',
forwardedBy: 'Nadine Brunner',
},
{
id: 'lead-002',
receivedAt: '2026-05-18T09:05:00.000Z',
prospect: 'Novabio Pharma AG',
contacts: ['https://novabio-pharma.ch'],
locationHint: 'Basel, Allschwil',
propertyIds: ['prop-008'],
status: ExposeLeadStatus.ACTIVE,
sourceSignalId: 'signal-004',
forwardedBy: 'Nadine Brunner',
},
{
id: 'lead-003',
receivedAt: '2026-05-15T11:40:00.000Z',
prospect: 'Textilhaus Zürich AG',
contacts: ['kontakt@textilhaus-zuerich.ch'],
locationHint: 'Zürich, Kreis 4 / Langstrasse',
propertyIds: ['prop-040', 'prop-041', 'prop-046'],
status: ExposeLeadStatus.ACTIVE,
forwardedBy: 'Marc Wyss',
},
{
id: 'lead-004',
receivedAt: '2026-04-28T08:15:00.000Z',
prospect: 'Helvetia Produktion GmbH',
contacts: ['info@helvetia-produktion.ch'],
locationHint: 'Pratteln / Basel-Land',
propertyIds: ['prop-014'],
status: ExposeLeadStatus.ARCHIVED,
forwardedBy: 'Marc Wyss',
},
{
id: 'lead-005',
receivedAt: '2026-04-12T16:00:00.000Z',
prospect: 'Schweizer Grosshandel AG',
contacts: ['beschaffung@schweizer-grosshandel.ch'],
locationHint: 'Winterthur',
propertyIds: ['prop-009'],
status: ExposeLeadStatus.ARCHIVED,
forwardedBy: 'Nadine Brunner',
},
]
+5 -5
View File
@@ -2,12 +2,12 @@
* Property On — Kernteam der fünf digitalen Mitarbeitenden.
*
* Reihenfolge nach Arbeitsablauf, nicht alphabetisch: Ferdi erkennt die Frist,
* Bruno bereitet den Termin vor, Livia erstellt das Exposé, Nora findet die
* Nachfrage, Sina hält die Objektdaten sauber. Genau diese Reihenfolge führt
* auch das Agenten-Untermenü unter «Meine Agenten» (Runde 4, §2.2).
* Bruno bereitet den Termin vor, Livia recherchiert den Markt, Nora gleicht mit
* dem eigenen Bestand ab, Sina hält die Objektdaten sauber. Genau diese
* Reihenfolge führt auch das Agenten-Untermenü unter «Meine Agenten»
* (Runde 4, §2.2; Rollen von Livia und Nora geschärft in Runde 8, §1–§2).
*
* Reto und Lea sind entfallen: Retos Aufgaben liegen vollständig bei Bruno,
* Leas Angebots- und Exposé-Aufgaben bei Livia.
* Reto und Lea sind entfallen: Retos Aufgaben liegen vollständig bei Bruno.
*/
import type { TeamAgent } from '../domain/teamAgent'
+13 -48
View File
@@ -1,68 +1,33 @@
import { useCallback, useRef } from 'react'
import { useSearchParams } from 'react-router'
import { Box, Tab, Tabs } from '@mui/material'
import { Box } from '@mui/material'
import { AgentWorkspaceHero } from '../../components/team'
import { ResearchWorkspace } from '../../components/research'
import { ExposeLeadSection } from '../../components/expose'
import {
LIVIA_SECTIONS,
LIVIA_SECTION_LABELS,
LIVIA_SECTION_ORDER,
LIVIA_SECTION_PARAM,
} from '../../lib/constants'
import type { LiviaSection } from '../../lib/constants'
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
const LIVIA = agentWorkspaceById('livia')!
function resolveSection(raw: string | null): LiviaSection {
return LIVIA_SECTION_ORDER.find(s => s === raw) ?? LIVIA_SECTIONS.RESEARCH
}
/**
* Livia — Research & Market Intelligence (Runde 8, §1).
*
* Die Seite führt zwei Bereiche in der Reihenfolge ihrer Bedeutung: zuerst die
* Markt-Recherche, danach die nachgeordneten Exposés. Die Reihenfolge ist die
* Aussage — Livia ist neu die Agentin, die nach draussen schaut; das Exposé ist
* geblieben, aber nicht mehr das, wofür sie eingestellt ist.
* Die Seite führt genau eine Aufgabe: die Leads, die Livia aus den verknüpften
* externen Quellen verdichtet hat. Die frühere Exposé-Erstellung ist mit
* Runde 8 vollständig entfallen — sie war der Kern der alten Rolle und ist mit
* ihr weggefallen, nicht bloss nach hinten gerückt.
*
* 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».
* Der Aufbau ist damit derselbe wie bei den anderen Agenten: Chat-Einstieg,
* darunter die operative Liste. Kein Reiterbalken, weil es nichts zu wählen
* gibt — eine Reiterleiste mit einem Eintrag behauptet eine Alternative, die
* es nicht gibt.
*
* Der Wiederaufbau der Exposé-Funktion ist in `archive/livia/` beschrieben.
*/
export default function MyListings() {
const [params, setParams] = useSearchParams()
const section = resolveSection(params.get(LIVIA_SECTION_PARAM))
const scrollRef = useRef<HTMLDivElement>(null)
const handleChange = useCallback(
(next: LiviaSection) => setParams({ [LIVIA_SECTION_PARAM]: next }),
[setParams],
)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box ref={scrollRef} sx={{ flex: 1, overflowY: 'auto' }}>
<Box sx={{ flex: 1, overflowY: 'auto' }}>
<AgentWorkspaceHero agentId={LIVIA.id} name={LIVIA.name} role={LIVIA.role} />
<Box sx={{ px: 3, pb: 5 }}>
<Tabs
value={section}
onChange={(_, v: LiviaSection) => handleChange(v)}
sx={{ mb: 2, '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9rem' } }}
>
{LIVIA_SECTION_ORDER.map(value => (
<Tab key={value} value={value} label={LIVIA_SECTION_LABELS[value]} />
))}
</Tabs>
{section === LIVIA_SECTIONS.RESEARCH && <ResearchWorkspace />}
{section === LIVIA_SECTIONS.EXPOSE && <ExposeLeadSection scrollRef={scrollRef} />}
{/* Weissraum unter der Liste, damit auch die letzte Zeile an den
oberen Rand gleiten kann. */}
<Box sx={{ height: '60vh' }} />
<ResearchWorkspace />
</Box>
</Box>
</Box>
-8
View File
@@ -1,8 +0,0 @@
import type { CreateExposeLeadInput, ExposeLead } from '../domain/exposeLead'
export interface IExposeLeadProvider {
getAll(): Promise<ExposeLead[]>
getById(id: string): Promise<ExposeLead | null>
create(input: CreateExposeLeadInput): Promise<ExposeLead>
archive(id: string): Promise<ExposeLead>
}
-9
View File
@@ -1,9 +0,0 @@
import type { ExposeDraft } from '../domain/expose'
export interface IExposeProvider {
/** Entwurf zu einem Lead und Objekt — null, solange keiner besteht. */
getDraft(leadId: string, propertyId: string): Promise<ExposeDraft | null>
save(draft: ExposeDraft): Promise<ExposeDraft>
/** Setzt den Erstellungszeitpunkt — trennt «gespeichert» von «erstellt». */
markGenerated(draftId: string): Promise<ExposeDraft>
}
-36
View File
@@ -1,36 +0,0 @@
import { mockExposeLeads } from '../mock-data/exposeLeads'
import type { ExposeLead } from '../domain/exposeLead'
import { ExposeLeadStatus } from '../domain/exposeLead'
import type { IExposeLeadProvider } from './IExposeLeadProvider'
let store: ExposeLead[] = [...mockExposeLeads]
let sequence = store.length
export const MockupExposeLeadProvider: IExposeLeadProvider = {
async getAll() {
return [...store]
},
async getById(id) {
return store.find(l => l.id === id) ?? null
},
async create(input) {
sequence += 1
const lead: ExposeLead = {
...input,
id: `lead-${String(sequence).padStart(3, '0')}`,
receivedAt: input.receivedAt ?? new Date().toISOString(),
status: input.status ?? ExposeLeadStatus.ACTIVE,
}
store = [lead, ...store]
return lead
},
async archive(id) {
const idx = store.findIndex(l => l.id === id)
if (idx === -1) throw new Error(`Lead ${id} nicht gefunden`)
store[idx] = { ...store[idx], status: ExposeLeadStatus.ARCHIVED }
return store[idx]
},
}
-37
View File
@@ -1,37 +0,0 @@
import type { ExposeDraft } from '../domain/expose'
import type { IExposeProvider } from './IExposeProvider'
/**
* Entwurfsablage der Demo — in-memory, wie alle Mockup-Provider.
*
* Der Schlüssel ist das Paar aus Lead und Objekt: derselbe Lead kann mehrere
* Objekte empfehlen, und jedes bekommt sein eigenes Exposé.
*/
const store = new Map<string, ExposeDraft>()
function key(leadId: string, propertyId: string): string {
return `${leadId}::${propertyId}`
}
export const MockupExposeProvider: IExposeProvider = {
async getDraft(leadId, propertyId) {
return store.get(key(leadId, propertyId)) ?? null
},
async save(draft) {
const saved: ExposeDraft = { ...draft, updatedAt: new Date().toISOString() }
store.set(key(draft.leadId, draft.propertyId), saved)
return saved
},
async markGenerated(draftId) {
for (const [k, d] of store) {
if (d.id === draftId) {
const updated: ExposeDraft = { ...d, generatedAt: new Date().toISOString() }
store.set(k, updated)
return updated
}
}
throw new Error(`Exposé-Entwurf ${draftId} nicht gefunden`)
},
}
-36
View File
@@ -197,39 +197,6 @@ export interface PreMarketRentRecommendation {
confidence: 'LOW' | 'MEDIUM' | 'HIGH'
}
// ── Exposé texts (Livia) ──────────────────────────────────────────────────────
/**
* Die Textabschnitte eines Exposés. Jeder lässt sich einzeln erzeugen —
* gesamthaft heisst schlicht: alle nacheinander (Runde 4, §8.5.2).
*/
export const ExposeTextSection = {
TITLE: 'TITLE',
TEASER: 'TEASER',
OBJECT: 'OBJECT',
LOCATION: 'LOCATION',
MUNICIPALITY: 'MUNICIPALITY',
FEATURES: 'FEATURES',
HIGHLIGHTS: 'HIGHLIGHTS',
} as const
export type ExposeTextSection = typeof ExposeTextSection[keyof typeof ExposeTextSection]
export type ExposeTonality = 'SACHLICH' | 'HOCHWERTIG' | 'EINLADEND'
export interface ExposeTextInput {
section: ExposeTextSection
tonality: ExposeTonality
/** Ausschliesslich belegte Angaben aus «Meine Objekte» — nichts Erfundenes. */
facts: Record<string, string | number | undefined>
}
export interface ExposeTextResult {
section: ExposeTextSection
text: string
/** Angaben, die für einen vollständigen Text fehlen — statt sie zu erfinden. */
missingFacts: string[]
}
// ── Legacy types (kept for backward compatibility) ────────────────────────────
export interface CriteriaExtractionResult {
@@ -277,9 +244,6 @@ export interface IAIService {
// Pre-market rent recommendation (supply side) — based on regional comparables, supply & demand
recommendPreMarketRent(input: PreMarketRentInput): Promise<AIResponse<PreMarketRentRecommendation>>
// Exposé-Texte (Livia) — pro Abschnitt auslösbar
generateExposeText(input: ExposeTextInput): Promise<AIResponse<ExposeTextResult>>
// Legacy methods
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>>
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>>
@@ -59,8 +59,6 @@ import type {
FitOutAdvice,
PreMarketRentInput,
PreMarketRentRecommendation,
ExposeTextInput,
ExposeTextResult,
} from '../IAIService'
import { ServiceErrorCode } from '../../types'
import { AppError } from '../../errors'
@@ -677,17 +675,6 @@ Miettrend 12M: ${intel?.rentTrend12m ?? '?'}%
}, () => MockAIService.extractCriteria(input))
},
// ── generateExposeText ──────────────────────────────────────────────────────
//
// Bewusst ohne eigenen LLM-Aufruf: der Text darf ausschliesslich auf erfassten
// Objektdaten beruhen (Runde 4, §8.5.2). Ein freies Sprachmodell würde genau
// die fehlenden Angaben plausibel ergänzen, die hier rot markiert gehören.
// Deshalb erzeugt auch im Backend-Betrieb der deterministische Builder den
// Entwurf; er nennt Lücken, statt sie zu füllen.
generateExposeText(input: ExposeTextInput): Promise<AIResponse<ExposeTextResult>> {
return MockAIService.generateExposeText(input)
},
// ── Legacy: generateFollowUp ────────────────────────────────────────────────
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>> {
return withFallback('generateFollowUp', async () => {
-9
View File
@@ -14,15 +14,12 @@ import type {
FitOutAdvice,
PreMarketRentInput,
PreMarketRentRecommendation,
ExposeTextInput,
ExposeTextResult,
} from '../IAIService'
import { mockProvenance } from '../IAIService'
import { aiTraceStore } from '../tracing'
import { mockParseNeed } from './needParser'
import { buildComparisonSummary } from './compareBuilder'
import { buildMockDecisionBrief } from './decisionBrief'
import { buildExposeText } from './exposeTextBuilder'
import { getCityIntelligence, getMarketRent } from '../../../lib/locationIntelligence'
const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 }
@@ -302,12 +299,6 @@ export const MockAIService: IAIService = {
}
}),
generateExposeText: (input: ExposeTextInput) =>
traceMock<ExposeTextResult>('generateExposeText', async () => {
await delay(SIMULATED_DELAY.medium)
return { data: buildExposeText(input), provenance: mockProvenance() }
}),
generateFitOutAdvice: (input: FitOutAdviceInput) =>
traceMock('generateFitOutAdvice', async () => {
await delay(SIMULATED_DELAY.medium)
-105
View File
@@ -1,105 +0,0 @@
import type { ExposeTextInput, ExposeTextResult } from '../IAIService'
import { ExposeTextSection } from '../IAIService'
/**
* Deterministische Exposé-Texte für Entwicklung und Demo.
*
* Kernregel der Vorgabe: keine unbelegten Fakten erfinden (Runde 4, §8.5.2).
* Der Builder verwendet deshalb ausschliesslich die übergebenen Angaben. Was
* fehlt, wird nicht plausibel ergänzt, sondern in `missingFacts` genannt —
* die Oberfläche markiert es rot, statt einen glatten Text zu zeigen, der
* nachher nicht stimmt.
*/
const TONE_OPENER: Record<string, string> = {
SACHLICH: '',
HOCHWERTIG: 'Repräsentativ und sorgfältig ausgebaut: ',
EINLADEND: 'Willkommen in Ihrer neuen Fläche — ',
}
/** Welche Angaben braucht ein Abschnitt, um überhaupt geschrieben werden zu können? */
const REQUIRED: Record<string, string[]> = {
[ExposeTextSection.TITLE]: ['objektart', 'ort'],
[ExposeTextSection.TEASER]: ['objektart', 'flaeche', 'ort'],
[ExposeTextSection.OBJECT]: ['objektart', 'flaeche', 'ausbaustandard'],
[ExposeTextSection.LOCATION]: ['strasse', 'ort'],
[ExposeTextSection.MUNICIPALITY]: ['ort'],
[ExposeTextSection.FEATURES]: ['ausbaustandard'],
[ExposeTextSection.HIGHLIGHTS]: ['objektart', 'ort'],
}
function value(facts: ExposeTextInput['facts'], key: string): string | undefined {
const raw = facts[key]
if (raw === undefined || raw === null || raw === '') return undefined
return String(raw)
}
function compose(input: ExposeTextInput): string {
const f = input.facts
const v = (k: string) => value(f, k)
const opener = TONE_OPENER[input.tonality] ?? ''
switch (input.section) {
case ExposeTextSection.TITLE:
return [v('objektart'), v('flaeche') && `${v('flaeche')} m²`, 'in', v('ort')]
.filter(Boolean).join(' ')
case ExposeTextSection.TEASER:
return `${opener}${v('objektart')} mit ${v('flaeche')} m² in ${v('ort')}` +
(v('verfuegbarkeit') ? `, verfügbar ab ${v('verfuegbarkeit')}` : '') + '.'
case ExposeTextSection.OBJECT:
return [
`${opener}Die Fläche umfasst ${v('flaeche')} m² und ist im Ausbaustandard «${v('ausbaustandard')}» ausgeführt.`,
v('stockwerk') ? `Sie liegt im ${v('stockwerk')}. Stockwerk.` : '',
v('parkplaetze') ? `Zur Fläche gehören ${v('parkplaetze')} Parkplätze.` : '',
v('raumhoehe') ? `Die Raumhöhe beträgt ${v('raumhoehe')} m.` : '',
].filter(Boolean).join(' ')
case ExposeTextSection.LOCATION:
return [
`Das Objekt befindet sich an der ${v('strasse')} in ${v('plz') ?? ''} ${v('ort')}.`.replace(/\s+/g, ' '),
v('oevHaltestelle') ? `Die nächste ÖV-Haltestelle liegt rund ${v('oevHaltestelle')} m entfernt.` : '',
v('einkauf') ? `Einkaufsmöglichkeiten sind in etwa ${v('einkauf')} m erreichbar.` : '',
v('autobahn') ? `Der Autobahnanschluss ist rund ${v('autobahn')} m entfernt.` : '',
].filter(Boolean).join(' ')
case ExposeTextSection.MUNICIPALITY:
return [
`${v('ort')}${v('kanton') ? ` (${v('kanton')})` : ''} als Standort:`,
v('einwohner') ? `${v('einwohner')} Einwohnerinnen und Einwohner.` : '',
v('bevoelkerungswachstum') ? `Bevölkerungswachstum ${v('bevoelkerungswachstum')} % pro Jahr.` : '',
v('steuerbelastung') ? `Steuerbelastung ${v('steuerbelastung')} %.` : '',
v('leerwohnungsziffer') ? `Leerwohnungsziffer ${v('leerwohnungsziffer')} %.` : '',
].filter(Boolean).join(' ')
case ExposeTextSection.FEATURES:
return [
`Ausbaustandard «${v('ausbaustandard')}».`,
v('heizsystem') ? `Beheizt über ${v('heizsystem')}.` : '',
v('merkmale') ? `Weitere Merkmale: ${v('merkmale')}.` : '',
].filter(Boolean).join(' ')
case ExposeTextSection.HIGHLIGHTS:
return [
v('flaeche') ? `${v('flaeche')} m² ${v('objektart')}` : '',
v('ort') ? `Standort ${v('ort')}` : '',
v('parkplaetze') ? `${v('parkplaetze')} Parkplätze` : '',
v('oevHaltestelle') ? `ÖV in ${v('oevHaltestelle')} m` : '',
].filter(Boolean).join('\n')
default:
return ''
}
}
export function buildExposeText(input: ExposeTextInput): ExposeTextResult {
const missingFacts = (REQUIRED[input.section] ?? []).filter(k => value(input.facts, k) === undefined)
// Fehlt eine Grundangabe, wird kein Text behauptet — die Lücke ist die Antwort.
if (missingFacts.length > 0) {
return { section: input.section, text: '', missingFacts }
}
return { section: input.section, text: compose(input).trim(), missingFacts: [] }
}
-165
View File
@@ -1,165 +0,0 @@
/**
* Property On — Vorschau und Export der Angebotsbroschüre (Runde 4, §8.5.3).
*
* Bewusst ohne PDF-Bibliothek: der Browser kann drucken und in PDF speichern,
* und Word öffnet HTML mit `.doc`-Endung als bearbeitbares Dokument. Eine
* zusätzliche Abhängigkeit von mehreren hundert Kilobyte brächte für eine
* Broschüre aus Text und Bildern keinen Gegenwert.
*
* CI, Bilder, Texte und Anhänge folgen der Exposé-Konfiguration.
*/
import type { ExposeDraft } from '../domain/expose'
import { EXPOSE_SECTIONS } from '../lib/exposeFields'
import { EXPOSE_DOCUMENT_TYPE_LABELS } from '../lib/constants'
/** Firmenfarbe der «Beispiel Immobilien AG», sofern nicht manuell gesetzt. */
const DEFAULT_BRAND_COLOR = '#152642'
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function paragraphs(text: string): string {
return text
.split('\n')
.filter(line => line.trim() !== '')
.map(line => `<p>${escapeHtml(line)}</p>`)
.join('')
}
export function buildExposeHtml(draft: ExposeDraft): string {
const v = draft.values
const brand = draft.brandColor?.trim() || DEFAULT_BRAND_COLOR
const visibleImages = draft.images.filter(i => i.visible)
const cover = visibleImages.find(i => i.isCover) ?? visibleImages[0]
const gallery = visibleImages.filter(i => i !== cover)
const attachments = draft.documents.filter(d => d.visibleAsAttachment)
// Alle Bereiche ausser den Texten erscheinen als Faktentabelle; leere Felder
// werden weggelassen statt mit einem Strich gefüllt.
const factRows = EXPOSE_SECTIONS
.filter(s => s.id !== 'texte')
.map(section => {
const rows = section.fields
.filter(f => (v[f.key] ?? '').trim() !== '')
.map(f => `<tr><th>${escapeHtml(f.label)}</th><td>${escapeHtml(v[f.key])}${f.suffix ? ` ${escapeHtml(f.suffix)}` : ''}</td></tr>`)
.join('')
return rows ? `<h2>${escapeHtml(section.title)}</h2><table>${rows}</table>` : ''
})
.join('')
const textBlocks = [
['Kurzbeschrieb', v.kurzbeschrieb],
['Objektbeschrieb', v.objektbeschrieb],
['Lagebeschrieb', v.lagebeschrieb],
['Die Gemeinde', v.gemeindebeschrieb],
['Ausstattung', v.ausstattungsbeschrieb],
]
.filter(([, text]) => (text ?? '').trim() !== '')
.map(([title, text]) => `<h2>${escapeHtml(title!)}</h2>${paragraphs(text!)}`)
.join('')
const highlights = (v.highlights ?? '').trim()
? `<h2>Highlights</h2><ul>${v.highlights.split('\n').filter(Boolean).map(h => `<li>${escapeHtml(h)}</li>`).join('')}</ul>`
: ''
const attachmentList = attachments.length
? `<h2>Anhänge</h2><ul>${attachments.map(d => `<li>${escapeHtml(EXPOSE_DOCUMENT_TYPE_LABELS[d.type] ?? d.type)}: ${escapeHtml(d.title || d.fileName)}</li>`).join('')}</ul>`
: ''
const contact = draft.contactPerson.trim()
? `<h2>Kontakt</h2><p>${escapeHtml(draft.contactPerson)}</p>`
: ''
return `<!doctype html>
<html lang="de-CH">
<head>
<meta charset="utf-8">
<title>${escapeHtml(v.exposeTitel || 'Exposé')}</title>
<style>
body { font-family: Georgia, 'Times New Roman', serif; color: #141414; margin: 0; padding: 32px 40px; }
header { border-bottom: 4px solid ${brand}; padding-bottom: 12px; margin-bottom: 20px; }
h1 { color: ${brand}; font-size: 26px; margin: 0 0 4px; }
.sub { color: #5c5c5c; font-size: 13px; }
h2 { color: ${brand}; font-size: 15px; text-transform: uppercase; letter-spacing: .06em; margin: 24px 0 8px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; width: 38%; font-weight: 600; color: #5c5c5c; padding: 4px 8px 4px 0; vertical-align: top; }
td { padding: 4px 0; }
tr + tr th, tr + tr td { border-top: 1px solid #e8e7e4; }
p { font-size: 14px; line-height: 1.6; margin: 0 0 10px; }
ul { font-size: 14px; line-height: 1.6; }
.cover img { width: 100%; max-height: 340px; object-fit: cover; }
.gallery { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.gallery figure { margin: 0; }
.gallery img { width: 100%; height: 170px; object-fit: cover; }
figcaption { font-size: 11px; color: #5c5c5c; margin-top: 3px; }
@media print { body { padding: 0; } }
</style>
</head>
<body>
<header>
<h1>${escapeHtml(v.exposeTitel || 'Exposé')}</h1>
<div class="sub">${escapeHtml([v.strasse, v.hausnummer].filter(Boolean).join(' '))}${v.ort ? `, ${escapeHtml(v.plz ?? '')} ${escapeHtml(v.ort)}` : ''}</div>
</header>
${cover ? `<div class="cover"><img src="${escapeHtml(cover.url)}" alt="${escapeHtml(cover.caption || v.exposeTitel || '')}"></div>` : ''}
${textBlocks}
${highlights}
${factRows}
${gallery.length ? `<h2>Bilder</h2><div class="gallery">${gallery.map(i => `<figure><img src="${escapeHtml(i.url)}" alt="${escapeHtml(i.caption)}">${i.caption ? `<figcaption>${escapeHtml(i.caption)}</figcaption>` : ''}</figure>`).join('')}</div>` : ''}
${attachmentList}
${contact}
</body>
</html>`
}
function fileBaseName(draft: ExposeDraft): string {
const title = draft.values.exposeTitel || draft.values.ort || 'expose'
return title
.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'expose'
}
/** Vorschau in einem neuen Browser-Tab; von dort lässt sich als PDF drucken. */
export function openExposePreview(draft: ExposeDraft): boolean {
const win = window.open('', '_blank')
if (!win) return false
win.document.write(buildExposeHtml(draft))
win.document.close()
return true
}
function download(content: string, fileName: string, mime: string) {
const blob = new Blob([content], { type: mime })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}
/**
* PDF-Download über den Druckdialog des Browsers: dort steht «Als PDF
* speichern». Ohne Serverkomponente ist das der ehrlichste Weg — ein Knopf,
* der eine Datei nur so nennt, wäre keiner.
*/
export function downloadExposePdf(draft: ExposeDraft): boolean {
const win = window.open('', '_blank')
if (!win) return false
win.document.write(buildExposeHtml(draft))
win.document.close()
win.addEventListener('load', () => win.print())
return true
}
/** Word öffnet HTML mit `.doc`-Endung als bearbeitbares Dokument. */
export function downloadExposeWord(draft: ExposeDraft): void {
download(buildExposeHtml(draft), `${fileBaseName(draft)}.doc`, 'application/msword')
}
-56
View File
@@ -1,56 +0,0 @@
import { MockupExposeLeadProvider } from '../provider/MockupExposeLeadProvider'
import type { CreateExposeLeadInput, ExposeLead } from '../domain/exposeLead'
import type { ItemResponse, ListResponse } from './types'
import { throwServiceError } from './errors'
import { useSessionStore } from '../stores/sessionStore'
const provider = MockupExposeLeadProvider
export const exposeLeadService = {
async getAll(): Promise<ListResponse<ExposeLead>> {
try {
const data = await provider.getAll()
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
} catch (err) {
throwServiceError(err)
}
},
async getById(id: string): Promise<ItemResponse<ExposeLead | null>> {
try {
return { data: await provider.getById(id) }
} catch (err) {
throwServiceError(err)
}
},
/**
* Weiterleitung von Nora an Livia.
*
* Ohne mindestens ein Objekt hätte Livia nichts, woraus sie ein Exposé bauen
* könnte — deshalb ist die Objektempfehlung Pflicht und keine Kür.
*/
async create(input: CreateExposeLeadInput): Promise<ItemResponse<ExposeLead>> {
try {
if (input.propertyIds.length === 0) {
throw new Error('Bitte mindestens ein Objekt auswählen.')
}
const user = useSessionStore.getState().currentUser
const data = await provider.create({
...input,
forwardedBy: input.forwardedBy ?? user?.name,
})
return { data }
} catch (err) {
throwServiceError(err)
}
},
async archive(id: string): Promise<ItemResponse<ExposeLead>> {
try {
return { data: await provider.archive(id) }
} catch (err) {
throwServiceError(err)
}
},
}
-158
View File
@@ -1,158 +0,0 @@
import { MockupExposeProvider } from '../provider/MockupExposeProvider'
import type { ExposeDraft, ExposeImage, ExposeValues } from '../domain/expose'
import { ExposeBranding, ExposeImageCategory } from '../domain/expose'
import type { Property } from '../domain/property'
import type { ItemResponse } from './types'
import { throwServiceError } from './errors'
import { ASSET_TYPE_LABELS, FIT_OUT_LABELS } from '../lib/constants'
import { missingRequiredExposeKeys } from '../lib/exposeFields'
import type { ExposeTonality } from './ai/IAIService'
const provider = MockupExposeProvider
function str(value: unknown): string {
if (value === undefined || value === null) return ''
return String(value)
}
/**
* Vorbelegung aus «Meine Objekte» (Runde 4, §8.5.2).
*
* Es wird nur übernommen, was im Objektdatensatz tatsächlich steht. Felder ohne
* Entsprechung bleiben leer — sie sind der Grund, weshalb das Exposé rote
* Umrandungen zeigt, und genau das soll es. Ein vorbelegtes «ca. 3.0 m
* Raumhöhe» wäre eine Behauptung, keine Angabe.
*/
export function prefillFromProperty(p: Property): ExposeValues {
const hf = p.hardFacts ?? {}
const sf = p.softFactors ?? {}
return {
objektart: ASSET_TYPE_LABELS[p.assetType] ?? '',
vermarktungsart: 'Miete',
status: 'Aktiv in Vermarktung',
objektreferenz: str(p.propertyNumber),
strasse: str(p.address.street),
hausnummer: str(p.address.houseNumber),
plz: str(p.address.postalCode),
ort: str(p.address.city),
gemeinde: str(p.location.city),
kanton: str(p.location.canton),
land: str(p.address.country),
breitengrad: str(p.location.coordinates?.lat),
laengengrad: str(p.location.coordinates?.lng),
flaeche: str(p.areaSqm),
raumhoehe: str(hf.ceilingHeightM),
stockwerk: str(hf.floor ?? p.floorLevel),
verfuegbarkeit: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : '',
nettomiete: str(p.rentPricePerSqm),
nebenkosten: str(p.ancillaryCosts),
parkplaetze: str(hf.parking ?? sf.parkingSpots),
objektbeschrieb: str(p.description),
merkmale: hf.fitOut ? `Ausbaustandard ${FIT_OUT_LABELS[hf.fitOut] ?? hf.fitOut}` : '',
}
}
/**
* Objektbilder aus «Meine Objekte» automatisch importieren (§8.5.1).
* Doppelte Bilder werden über die URL ausgeschlossen — dieselbe Datei zweimal
* im Exposé wäre kein Fehler des Nutzers, sondern einer des Imports.
*/
export function importPropertyImages(p: Property): ExposeImage[] {
const seen = new Set<string>()
const images: ExposeImage[] = []
for (const url of p.images ?? []) {
if (seen.has(url)) continue
seen.add(url)
const fileName = url.split('/').pop() ?? `bild-${images.length + 1}.jpg`
images.push({
id: `img-${p.id}-${images.length + 1}`,
url,
fileName,
category: images.length === 0 ? ExposeImageCategory.EXTERIOR : ExposeImageCategory.INTERIOR,
caption: '',
visible: true,
isCover: images.length === 0,
imported: true,
})
}
return images
}
/** Die Angaben, aus denen der Textentwurf schöpfen darf — nichts darüber hinaus. */
export function textFactsFrom(values: ExposeValues): Record<string, string> {
const facts: Record<string, string> = {}
for (const [k, v] of Object.entries(values)) {
if (v && v.trim() !== '') facts[k] = v
}
return facts
}
function newDraft(leadId: string, property: Property, tonality: ExposeTonality): ExposeDraft {
return {
id: `expose-${leadId}-${property.id}`,
leadId,
propertyId: property.id,
values: prefillFromProperty(property),
tonality,
images: importPropertyImages(property),
documents: [],
branding: ExposeBranding.FROM_PROFILE,
contactPerson: '',
updatedAt: new Date().toISOString(),
}
}
export const exposeService = {
/**
* Bestehenden Entwurf laden oder einen neuen aus den Objektdaten aufbauen.
* Ein gespeicherter Entwurf gewinnt immer — sonst überschriebe die
* Vorbelegung bei jedem Öffnen die Arbeit des Nutzers.
*/
async getOrCreateDraft(
leadId: string,
property: Property,
tonality: ExposeTonality = 'SACHLICH',
): Promise<ItemResponse<ExposeDraft>> {
try {
const existing = await provider.getDraft(leadId, property.id)
if (existing) return { data: existing }
return { data: await provider.save(newDraft(leadId, property, tonality)) }
} catch (err) {
throwServiceError(err)
}
},
async save(draft: ExposeDraft): Promise<ItemResponse<ExposeDraft>> {
try {
return { data: await provider.save(draft) }
} catch (err) {
throwServiceError(err)
}
},
/**
* Erstellen ist mehr als Speichern: erst hier wird geprüft, ob das Dossier
* vollständig ist. Ein Exposé mit leerem Mietpreis darf nicht in den Export.
*/
async generate(draft: ExposeDraft): Promise<ItemResponse<ExposeDraft>> {
try {
const missing = missingRequiredExposeKeys(draft.values)
if (missing.length > 0) {
throw new Error(`Es fehlen noch ${missing.length} Pflichtangabe(n) — sie sind rot umrandet.`)
}
const saved = await provider.save(draft)
return { data: await provider.markGenerated(saved.id) }
} catch (err) {
throwServiceError(err)
}
},
}