diff --git a/LIVIA_PROFIL_UND_PROMPT.md b/LIVIA_PROFIL_UND_PROMPT.md index a6e91f0..7a8de1b 100644 --- a/LIVIA_PROFIL_UND_PROMPT.md +++ b/LIVIA_PROFIL_UND_PROMPT.md @@ -1,4 +1,4 @@ -# Livia — Gesamtprofil und Prompt +# Livia als Exposé Master — Gesamtprofil und Prompt (Stand vor Runde 8) > **Zweck:** Vollständige, wiederverwendbare Fallakte der digitalen Mitarbeiterin **Livia** > («Exposé Master», Property On / Repository `property-match`), Stand **21. August 2026**. @@ -366,10 +366,18 @@ vor — er zeigt, dass Livia auch im autonomen Betrieb Zweifel sichtbar macht, s --- -## A17. Wiederherstellungspaket (Rückbau 21.08.2026) +## A17. Stand seit Runde 8 — was aus dieser Fassung geworden ist -Livias Funktionalitäten werden aus der Anwendung entfernt. Damit sie später vollständig -nachgebaut werden können, liegt in [`archive/livia/`](./archive/livia/) ein eigenständiges +**Dieses Dokument beschreibt Livia als Exposé Master, also den Stand vor dem 23.08.2026.** +Mit Optimierungsrunde 8 ist Livia zur **Research & Market Intelligence** geworden: Sie +beobachtet den Markt ausserhalb des eigenen Bestands und verdichtet externe Meldungen zu +Chance- und Risiko-Leads. Die hier beschriebene Exposé-Funktion ist dabei **vollständig aus +der Anwendung entfernt** worden — Personalblatt, Arbeitsplatz, Dossier mit elf Bereichen, +Textmaschine und Export. + +Es gibt damit zwei Livias mit demselben Namen. Wer diesen Text liest, liest die frühere. + +Für den Wiederaufbau liegt in [`archive/livia/`](./archive/livia/) ein eigenständiges Wiederherstellungspaket: - **`archive/livia/WIEDERHERSTELLUNG.md`** — Rückbau- und Wiedereinbau-Manifest: alle 23 diff --git a/archive/livia/WIEDERHERSTELLUNG.md b/archive/livia/WIEDERHERSTELLUNG.md index 2e24df4..2fbeae7 100644 --- a/archive/livia/WIEDERHERSTELLUNG.md +++ b/archive/livia/WIEDERHERSTELLUNG.md @@ -8,6 +8,24 @@ > **Angelegt am 21.08.2026, Stand Commit `5527448`** (2026-08-18, «fix(startseite): Porträts > scharf statt weich, besonders die hinteren»), Branch `agents6`. > +> **Vollzogen am 23.08.2026.** Mit Runde 8 wurde Livia zur Research- & Market-Intelligence- +> Agentin, und die Exposé-Funktion ist vollständig aus der Anwendung entfernt worden — nicht +> bloss nachgeordnet. Alle 23 Dateien aus Teil A sind gelöscht, die Eingriffe aus Teil B +> ausgeführt. Dieses Manifest ist damit von der Vorsorge zur Wiedereinbau-Anleitung geworden. +> +> **Abweichungen gegenüber der ursprünglichen Annahme** — beim Wiedereinbau beachten: +> - Livia ist **nicht** aus den Registern entfernt worden. Sie bleibt im Team, nur mit anderer +> Rolle. Abschnitt 2.2 beschreibt einen Rückbau, der so nicht stattgefunden hat: die Einträge +> in `agentWorkspaces.ts`, `teamAgents.ts`, `agentPhotos.ts` und `agentDirectory.ts` stehen +> unverändert, dort wurde nur die Funktionsbezeichnung geändert. Das Team hat weiterhin fünf +> Mitglieder; die Sitzordnung im Meetingraum ist unberührt. +> - Die Weiterleitung bei Nora (Abschnitt 2.5) ist entfallen, weil sie ohne Exposé kein Ziel +> mehr hatte. Mit Runde 8 fliessen Leads in die Gegenrichtung: Livia recherchiert, Nora +> gleicht ab. +> - Brunos Abschnitt «Lagebericht (von Livia)» steht unverändert — er hing an der Lage-, nicht +> an der Exposé-Funktion. +> - Zusätzlich entfernt, weil erst mit Runde 8 entstanden: `ExposeLeadSection.tsx`. +> > Fachliche Beschreibung und System-Prompt liegen separat in > [`../../LIVIA_PROFIL_UND_PROMPT.md`](../../LIVIA_PROFIL_UND_PROMPT.md). diff --git a/src/components/expose/ExposeFieldGroup.tsx b/src/components/expose/ExposeFieldGroup.tsx deleted file mode 100644 index 1c292ea..0000000 --- a/src/components/expose/ExposeFieldGroup.tsx +++ /dev/null @@ -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 - 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 ( - - - {label} - - - {(field.options ?? []).map(o => { - const selected = isSelected(value, o) - return ( - onChange(toggleChip(value, o))} - sx={{ - cursor: 'pointer', - bgcolor: selected ? '#152642' : 'transparent', - color: selected ? 'white' : DS_TEXT.secondary, - borderColor: DS_SLATE[200], - }} - /> - ) - })} - - - ) - } - - return ( - onChange(e.target.value)} - sx={{ - '& .MuiOutlinedInput-notchedOutline': isMissing - ? { borderColor: DS_TEXT.error, borderWidth: 2 } - : {}, - }} - > - {field.type === 'select' && - (field.options ?? []).map(o => {o})} - - ) -} - -/** - * 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 ( - - - {section.title} - - {section.description && ( - - {section.description} - - )} - - - {section.fields.map(field => ( - - onChange(field.key, v)} - /> - - ))} - - - {footer && {footer}} - - ) -} diff --git a/src/components/expose/ExposeLeadSection.tsx b/src/components/expose/ExposeLeadSection.tsx deleted file mode 100644 index 8492025..0000000 --- a/src/components/expose/ExposeLeadSection.tsx +++ /dev/null @@ -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 -} - -/** - * 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(EXPOSE_LEAD_TABS.ACTIVE) - const [selectedId, setSelectedId] = useState(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 - if (isError) return refetch()} /> - - return ( - - - Nachgeordnete Aufgabe. Livias Schwerpunkt liegt seit Runde 8 auf der Marktrecherche — - Exposés entstehen weiterhin hier, wenn ein Objekt vermarktet werden soll. - - - handleTabChange(v)} - sx={{ mb: 2, '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9rem' } }} - > - {Object.values(EXPOSE_LEAD_TABS).map(value => ( - - ))} - - - - - ) -} diff --git a/src/components/expose/ExposeMediaManager.tsx b/src/components/expose/ExposeMediaManager.tsx deleted file mode 100644 index 31cf3f3..0000000 --- a/src/components/expose/ExposeMediaManager.tsx +++ /dev/null @@ -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(null) - - const patch = useCallback( - (id: string, changes: Partial) => { - 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 ( - - {/* Uploadfläche */} - 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] }, - }} - > - - - Zusätzliche Bilder hierher ziehen oder auswählen - - - Nur für Bilder, die noch nicht in «Meine Objekte» hinterlegt sind — JPG oder PNG - - handleFiles(e.target.files)} - /> - - - - {importedCount} Bild(er) automatisch aus «Meine Objekte» importiert · {images.length} insgesamt - - - {/* Medien-Kuration */} - - {images.map((img, index) => ( - - {/* Reihenfolge */} - - move(index, -1)} disabled={index === 0} aria-label="Nach oben"> - - - move(index, 1)} disabled={index === images.length - 1} aria-label="Nach unten"> - - - - - - - - - - {img.fileName} - - - {img.imported ? 'aus «Meine Objekte»' : 'hochgeladen'} - - - setCover(img.id)} - aria-label="Als Titelbild" - sx={{ ml: 'auto', color: img.isCover ? '#b8975a' : '#cbd5e1' }} - > - - - - - - - patch(img.id, { category: e.target.value as ExposeImage['category'] })} - sx={{ width: 170, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }} - > - {Object.values(ExposeImageCategory).map(c => ( - {EXPOSE_IMAGE_CATEGORY_LABELS[c]} - ))} - - patch(img.id, { caption: e.target.value })} - sx={{ flex: 1, minWidth: 180, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }} - /> - patch(img.id, { visible: e.target.checked })} - /> - } - label={Im Exposé sichtbar} - /> - - - - ))} - - - {images.length === 0 && ( - - )} - - ) -} diff --git a/src/components/expose/ExposeWizard.tsx b/src/components/expose/ExposeWizard.tsx deleted file mode 100644 index cc180e1..0000000 --- a/src/components/expose/ExposeWizard.tsx +++ /dev/null @@ -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 - return -} - -/** - * 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(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 ( - - {/* Schrittleiste */} - - - {EXPOSE_STEPS.map((label, index) => ( - - setStep(index)} sx={{ textTransform: 'none' }}> - {label} - - - ))} - - - - - {/* ── Schritt 1: Hochladen ── */} - {step === 0 && ( - setDraft({ ...draft, images })} - /> - )} - - {/* ── Schritt 2: Exposé ── */} - {step === 1 && ( - - - setDraft({ ...draft, tonality: e.target.value as ExposeTonality })} - sx={{ width: 210 }} - > - {Object.entries(EXPOSE_TONALITY_LABELS).map(([value, label]) => ( - {label} - ))} - - - {missing.size > 0 && ( - - {missing.size} Pflichtangabe(n) fehlen - - )} - - - {EXPOSE_SECTIONS.map(section => ( - - {TEXT_FIELD_SECTIONS.map(t => ( - - ))} - - ) : undefined - } - /> - ))} - - {/* Medien-Kuration bleibt aus Schritt 1 erhalten und wird hier nur - verlinkt — dieselbe Liste zweimal zu rendern wäre Duplikat. */} - - - Kontakte & CI - - - setDraft({ ...draft, contactPerson: e.target.value })} - /> - setDraft({ ...draft, branding: e.target.value as ExposeBranding })} - > - Aus Maklerprofil übernehmen - Manuell setzen - - {draft.branding === ExposeBranding.MANUAL && ( - setDraft({ ...draft, brandColor: e.target.value })} - /> - )} - - - )} - - {/* ── Schritt 3: Export ── */} - {step === 2 && ( - - {!isGenerated && ( - - Das Exposé ist noch nicht erstellt. Vorschau und Download zeigen den aktuellen Stand. - - )} - {missing.size > 0 && ( - - Es fehlen noch {missing.size} Pflichtangabe(n) — im Schritt «Exposé» rot umrandet. - - )} - - - - - - - - - Der PDF-Export nutzt den Druckdialog des Browsers («Als PDF speichern»); CI, Bilder, - Texte und Anhänge folgen der Exposé-Konfiguration. - - - )} - - - {/* Speichern und Erstellen sind klar getrennt (§8.5.2). */} - - - {isGenerated - ? `Erstellt am ${new Date(draft.generatedAt!).toLocaleString('de-CH')}` - : 'Noch kein Exposé erstellt.'} - - - - - - ) -} diff --git a/src/components/expose/LeadTable.tsx b/src/components/expose/LeadTable.tsx deleted file mode 100644 index fce792e..0000000 --- a/src/components/expose/LeadTable.tsx +++ /dev/null @@ -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(null) - - const recommended = lead.propertyIds - .map(id => properties.find(p => p.id === id)) - .filter((p): p is Property => Boolean(p)) - - return ( - 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', - }} - > - - {new Date(lead.receivedAt).toLocaleDateString('de-CH')} - - - - {lead.prospect} - - - - {lead.contacts.length === 0 ? ( - – - ) : ( - lead.contacts.map(c => ( - - {c} - - )) - )} - - - - {lead.locationHint} - - - {/* Mehrere Objekte je Lead — jedes verlinkt nach «Meine Objekte». */} - e.stopPropagation()}> - {recommended.length === 0 ? ( - – - ) : ( - recommended.map(p => ( - - )) - )} - - - ) -}) - -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 ( - - {emptyText} - - ) - } - - return ( - - - {COLUMN_LABELS.map(h => ( - - {h} - - ))} - - - {leads.map(lead => ( - - - {withWorkspace && lead.id === selectedId && } - - ))} - - ) -} diff --git a/src/components/expose/LeadWorkspace.tsx b/src/components/expose/LeadWorkspace.tsx deleted file mode 100644 index 9d97672..0000000 --- a/src/components/expose/LeadWorkspace.tsx +++ /dev/null @@ -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(lead.propertyIds[0] ?? '') - - if (isLoading) return - - // 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 => Boolean(p)) - - if (recommended.length === 0) { - return ( - - - Zu diesem Lead ist kein Objekt aus «Meine Objekte» hinterlegt — ohne Objekt lässt sich kein Exposé erstellen. - - - ) - } - - const active = recommended.find(p => p.id === propertyId) ?? recommended[0] - - return ( - - {recommended.length > 1 && ( - setPropertyId(v)} - variant="scrollable" - scrollButtons="auto" - sx={{ mb: 2, '& .MuiTab-root': { textTransform: 'none', fontSize: '0.8125rem' } }} - > - {recommended.map(p => ( - - ))} - - )} - - - - ) -} diff --git a/src/components/expose/index.ts b/src/components/expose/index.ts deleted file mode 100644 index e4419ee..0000000 --- a/src/components/expose/index.ts +++ /dev/null @@ -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' diff --git a/src/components/market-leads/LeadDetail.tsx b/src/components/market-leads/LeadDetail.tsx index be59ff6..1a795e8 100644 --- a/src/components/market-leads/LeadDetail.tsx +++ b/src/components/market-leads/LeadDetail.tsx @@ -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 @@ -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([]) - // 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 ( {/* 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 ) : ( - <> - {relevant.map(({ p, fit }) => ( - toggle(p.id)} - /> - ))} - - + relevant.map(({ p, fit }) => ( + + )) )} diff --git a/src/components/market-leads/MarketLeadPanels.tsx b/src/components/market-leads/MarketLeadPanels.tsx index 19c12d7..d04b0d8 100644 --- a/src/components/market-leads/MarketLeadPanels.tsx +++ b/src/components/market-leads/MarketLeadPanels.tsx @@ -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 ( - diff --git a/src/domain/expose.ts b/src/domain/expose.ts deleted file mode 100644 index ce52e96..0000000 --- a/src/domain/expose.ts +++ /dev/null @@ -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 - -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 -} diff --git a/src/domain/exposeLead.ts b/src/domain/exposeLead.ts deleted file mode 100644 index 9d15107..0000000 --- a/src/domain/exposeLead.ts +++ /dev/null @@ -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 & { - receivedAt?: string - status?: ExposeLeadStatus -} diff --git a/src/hooks/useExpose.ts b/src/hooks/useExpose.ts deleted file mode 100644 index 0ff78c9..0000000 --- a/src/hooks/useExpose.ts +++ /dev/null @@ -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') - }, - }) -} diff --git a/src/hooks/useExposeLeads.ts b/src/hooks/useExposeLeads.ts deleted file mode 100644 index 9ecfb75..0000000 --- a/src/hooks/useExposeLeads.ts +++ /dev/null @@ -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') - }, - }) -} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 601d1ad..3f16c00 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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 = { 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 = { - [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 = { - [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 = { - EXTERIOR: 'Aussenansicht', - INTERIOR: 'Innenansicht', - FLOORPLAN: 'Grundriss', - SURROUNDINGS: 'Umgebung', - OTHER: 'Sonstiges', -} - -export const EXPOSE_DOCUMENT_TYPE_LABELS: Record = { - FLOORPLAN: 'Grundriss', - LEASE_CONTRACT: 'Mietvertrag', - ENERGY_CERTIFICATE: 'Energieausweis (GEAK)', - SITE_PLAN: 'Situationsplan', - OTHER: 'Sonstiges', -} - -export const EXPOSE_TONALITY_LABELS: Record = { - SACHLICH: 'Sachlich', - HOCHWERTIG: 'Hochwertig', - EINLADEND: 'Einladend', -} // ── Bruno — Besichtigungsaufträge (Runde 4, §9) ────────────────────────────── diff --git a/src/lib/exposeFields.ts b/src/lib/exposeFields.ts deleted file mode 100644 index 59ec24e..0000000 --- a/src/lib/exposeFields.ts +++ /dev/null @@ -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[] { - return EXPOSE_REQUIRED_KEYS.filter(k => !values[k] || values[k].trim() === '') -} diff --git a/src/mock-data/agentWorkItems.ts b/src/mock-data/agentWorkItems.ts index 2da21ef..b0fd359 100644 --- a/src/mock-data/agentWorkItems.ts +++ b/src/mock-data/agentWorkItems.ts @@ -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.', }, ], }, diff --git a/src/mock-data/agents/livia.ts b/src/mock-data/agents/livia.ts index c10f895..9d34b85 100644 --- a/src/mock-data/agents/livia.ts +++ b/src/mock-data/agents/livia.ts @@ -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', diff --git a/src/mock-data/exposeLeads.ts b/src/mock-data/exposeLeads.ts deleted file mode 100644 index 4d2faf8..0000000 --- a/src/mock-data/exposeLeads.ts +++ /dev/null @@ -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', - }, -] diff --git a/src/mock-data/teamAgents.ts b/src/mock-data/teamAgents.ts index d99b2f6..155a791 100644 --- a/src/mock-data/teamAgents.ts +++ b/src/mock-data/teamAgents.ts @@ -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' diff --git a/src/pages/supply/MyListings.tsx b/src/pages/supply/MyListings.tsx index 477be4a..0263216 100644 --- a/src/pages/supply/MyListings.tsx +++ b/src/pages/supply/MyListings.tsx @@ -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(null) - - const handleChange = useCallback( - (next: LiviaSection) => setParams({ [LIVIA_SECTION_PARAM]: next }), - [setParams], - ) - return ( - + - handleChange(v)} - sx={{ mb: 2, '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9rem' } }} - > - {LIVIA_SECTION_ORDER.map(value => ( - - ))} - - - {section === LIVIA_SECTIONS.RESEARCH && } - {section === LIVIA_SECTIONS.EXPOSE && } - - {/* Weissraum unter der Liste, damit auch die letzte Zeile an den - oberen Rand gleiten kann. */} - + diff --git a/src/provider/IExposeLeadProvider.ts b/src/provider/IExposeLeadProvider.ts deleted file mode 100644 index 32573eb..0000000 --- a/src/provider/IExposeLeadProvider.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { CreateExposeLeadInput, ExposeLead } from '../domain/exposeLead' - -export interface IExposeLeadProvider { - getAll(): Promise - getById(id: string): Promise - create(input: CreateExposeLeadInput): Promise - archive(id: string): Promise -} diff --git a/src/provider/IExposeProvider.ts b/src/provider/IExposeProvider.ts deleted file mode 100644 index a1e7c61..0000000 --- a/src/provider/IExposeProvider.ts +++ /dev/null @@ -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 - save(draft: ExposeDraft): Promise - /** Setzt den Erstellungszeitpunkt — trennt «gespeichert» von «erstellt». */ - markGenerated(draftId: string): Promise -} diff --git a/src/provider/MockupExposeLeadProvider.ts b/src/provider/MockupExposeLeadProvider.ts deleted file mode 100644 index 038adda..0000000 --- a/src/provider/MockupExposeLeadProvider.ts +++ /dev/null @@ -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] - }, -} diff --git a/src/provider/MockupExposeProvider.ts b/src/provider/MockupExposeProvider.ts deleted file mode 100644 index dd209ef..0000000 --- a/src/provider/MockupExposeProvider.ts +++ /dev/null @@ -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() - -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`) - }, -} diff --git a/src/services/ai/IAIService.ts b/src/services/ai/IAIService.ts index 97a06fd..2fef478 100644 --- a/src/services/ai/IAIService.ts +++ b/src/services/ai/IAIService.ts @@ -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 -} - -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> - // Exposé-Texte (Livia) — pro Abschnitt auslösbar - generateExposeText(input: ExposeTextInput): Promise> - // Legacy methods extractCriteria(input: string): Promise> generateFollowUp(partialNeed: Partial): Promise> diff --git a/src/services/ai/backend/BackendAIService.ts b/src/services/ai/backend/BackendAIService.ts index f3569bd..6146ff9 100644 --- a/src/services/ai/backend/BackendAIService.ts +++ b/src/services/ai/backend/BackendAIService.ts @@ -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> { - return MockAIService.generateExposeText(input) - }, - // ── Legacy: generateFollowUp ──────────────────────────────────────────────── generateFollowUp(partialNeed: Partial): Promise> { return withFallback('generateFollowUp', async () => { diff --git a/src/services/ai/mock/MockAIService.ts b/src/services/ai/mock/MockAIService.ts index 0b69607..3a5ab5a 100644 --- a/src/services/ai/mock/MockAIService.ts +++ b/src/services/ai/mock/MockAIService.ts @@ -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('generateExposeText', async () => { - await delay(SIMULATED_DELAY.medium) - return { data: buildExposeText(input), provenance: mockProvenance() } - }), - generateFitOutAdvice: (input: FitOutAdviceInput) => traceMock('generateFitOutAdvice', async () => { await delay(SIMULATED_DELAY.medium) diff --git a/src/services/ai/mock/exposeTextBuilder.ts b/src/services/ai/mock/exposeTextBuilder.ts deleted file mode 100644 index d3fc1e5..0000000 --- a/src/services/ai/mock/exposeTextBuilder.ts +++ /dev/null @@ -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 = { - 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 = { - [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: [] } -} diff --git a/src/services/exposeExport.ts b/src/services/exposeExport.ts deleted file mode 100644 index fdbcd15..0000000 --- a/src/services/exposeExport.ts +++ /dev/null @@ -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, '&') - .replace(//g, '>') - .replace(/"/g, '"') -} - -function paragraphs(text: string): string { - return text - .split('\n') - .filter(line => line.trim() !== '') - .map(line => `

${escapeHtml(line)}

`) - .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 => `${escapeHtml(f.label)}${escapeHtml(v[f.key])}${f.suffix ? ` ${escapeHtml(f.suffix)}` : ''}`) - .join('') - return rows ? `

${escapeHtml(section.title)}

${rows}
` : '' - }) - .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]) => `

${escapeHtml(title!)}

${paragraphs(text!)}`) - .join('') - - const highlights = (v.highlights ?? '').trim() - ? `

Highlights

    ${v.highlights.split('\n').filter(Boolean).map(h => `
  • ${escapeHtml(h)}
  • `).join('')}
` - : '' - - const attachmentList = attachments.length - ? `

Anhänge

    ${attachments.map(d => `
  • ${escapeHtml(EXPOSE_DOCUMENT_TYPE_LABELS[d.type] ?? d.type)}: ${escapeHtml(d.title || d.fileName)}
  • `).join('')}
` - : '' - - const contact = draft.contactPerson.trim() - ? `

Kontakt

${escapeHtml(draft.contactPerson)}

` - : '' - - return ` - - - -${escapeHtml(v.exposeTitel || 'Exposé')} - - - -
-

${escapeHtml(v.exposeTitel || 'Exposé')}

-
${escapeHtml([v.strasse, v.hausnummer].filter(Boolean).join(' '))}${v.ort ? `, ${escapeHtml(v.plz ?? '')} ${escapeHtml(v.ort)}` : ''}
-
-${cover ? `
${escapeHtml(cover.caption || v.exposeTitel || '')}
` : ''} -${textBlocks} -${highlights} -${factRows} -${gallery.length ? `

Bilder

` : ''} -${attachmentList} -${contact} - -` -} - -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') -} diff --git a/src/services/exposeLeadService.ts b/src/services/exposeLeadService.ts deleted file mode 100644 index 815092c..0000000 --- a/src/services/exposeLeadService.ts +++ /dev/null @@ -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> { - 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> { - 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> { - 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> { - try { - return { data: await provider.archive(id) } - } catch (err) { - throwServiceError(err) - } - }, -} diff --git a/src/services/exposeService.ts b/src/services/exposeService.ts deleted file mode 100644 index 633d9b2..0000000 --- a/src/services/exposeService.ts +++ /dev/null @@ -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() - 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 { - const facts: Record = {} - 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> { - 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> { - 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> { - 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) - } - }, -}