feat(agenten): Runde 4 — «Meine Agenten» mit fünf digitalen Mitarbeitenden

Setzt das Umsetzungsbriefing Runde 4 im bestehenden Frontend um.

Informationsarchitektur
- «Teamübersicht» heisst «Meine Agenten»; ihr bisheriger Inhalt (Kreisgrafik,
  Auswertung, Verbindungszusammenfassung) ist entfallen.
- Personalverwaltung, Bearbeitungsverlauf und Kanäle & Systeme sind jetzt
  Reiter derselben Seite (?section=), gestaltet wie die Auswahl im
  Bearbeitungsverlauf. Es wird nur der gewählte Bereich gerendert.
- Die alten Unterseitenpfade leiten um, damit Lesezeichen nicht brechen.

Agentenbestand
- Reto und Lea vollständig entfernt — aus Navigation, Dossiers, Protokoll,
  Verbindungen, Vorgängen und Porträtbestand.
- Retos Aufgaben liegen bei Bruno: Nachbereitung, WhatsApp-Anruf,
  Protokoll und Kundennotiz, Ablage im CRM.
- Livia ist «Exposé Master»: Lageberichte, Inserate, Angebotsbroschüren.
- Sidebar führt Ferdi, Bruno, Livia, Nora, Sina mit Porträt und Funktion.

Agentenseiten
- Ein gemeinsamer AgentWorkspaceHero auf allen fünf Seiten.
- Ferdi: Auswertungskarten, Priorität und Typfarben entfallen; Fälligkeit nur
  bei fünf Tagen oder weniger rot; Objektlinks nach «Meine Objekte»; neu die
  Terminplanung im verbundenen Kalender mit typgerechtem PDF-Ausschnitt.
- Sina: reduzierte, filter- und sortierbare Objektübersicht; Detailansicht
  direkt editierbar, leere Pflichtfelder rot umrandet.
- Nora: Signale ohne Prozentsätze und Konfidenzstufen, nur belegbare Angaben;
  Mehrfachauswahl leitet Objekte an Livia weiter.
- Livia: aktive und archivierte Leads, Arbeitsbereich gleitet an den oberen
  Rand; dreistufiger Exposé-Prozess Hochladen → Exposé → Export.
- Bruno: neue Seite mit Auftragsliste und Vor-/Nachbereitungs-Drawer;
  Glocke warnt bei Besichtigung unter 24 Stunden ohne Bericht.

Datenschicht
- Neu: Kalender, Exposé-Leads, Exposé-Entwürfe, Besichtigungsaufträge —
  je Domain, Provider, Service und Hook.
- IAIService um generateExposeText erweitert; der Entwurf nutzt ausschliesslich
  erfasste Objektdaten und meldet Lücken, statt sie zu füllen.
- Alle Objektverweise zeigen auf reale Einträge aus «Meine Objekte»; neue
  Detailroute /supply/properties/:propertyId.

Offen: Chat, Kalender, CRM und DMS sind Frontend-Simulation ohne Anbindung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin SutterandClaude Opus 5 committed 2026-08-05 14:43:16 +02:00
1 parent cc5582b7fd
commit d200d4e930
352 files changed
+8069 -8512

No files matched your search

@@ -0,0 +1,259 @@
import { useState } from 'react'
import {
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
FormControl, FormControlLabel, InputLabel, MenuItem, Select as MuiSelect,
Switch, TextField, ToggleButton, ToggleButtonGroup, Typography,
} from '@mui/material'
import { Building2, Globe, Lock, Search } from 'lucide-react'
import type {
MarktHinweis, HinweisVisibility, HinweisDirection, HinweisQuelle, CreateMarktHinweisInput,
} from '../../domain/marktHinweis'
import { useCreateMarktHinweis } from '../../hooks/useMarktHinweise'
import { QUELLE_LABELS } from './hinweisHelpers'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
/**
* Erfassung eines Netzwerk-Hinweises.
*
* Eigene Datei, weil der Dialog mit über 230 Zeilen für sich steht: er teilt
* mit den Panels nur die Beschriftungstabelle, nicht den Zustand.
*/
// ── Netzwerk: HinweisErfassenDialog ─────────────────────────────────────────
function HinweisErfassenDialog({
open,
onClose,
onCreated,
}: {
open: boolean
onClose: () => void
onCreated: (id: string) => void
}) {
const mutation = useCreateMarktHinweis()
const [direction, setDirection] = useState<HinweisDirection>('SUCHE')
const [assetType, setAssetType] = useState<string>('')
const [locationHint, setLocationHint] = useState<string>('')
const [areaSqmMin, setAreaSqmMin] = useState<string>('')
const [areaSqmMax, setAreaSqmMax] = useState<string>('')
const [companyName, setCompanyName] = useState<string>('')
const [isAnonymized, setIsAnonymized] = useState<boolean>(false)
const [quelle, setQuelle] = useState<HinweisQuelle>('NETZWERKEVENT')
const [note, setNote] = useState<string>('')
const [visibility, setVisibility] = useState<HinweisVisibility>('INTERN')
const isValid = assetType.trim() !== '' && locationHint.trim() !== ''
function resetForm() {
setDirection('SUCHE')
setAssetType('')
setLocationHint('')
setAreaSqmMin('')
setAreaSqmMax('')
setCompanyName('')
setIsAnonymized(false)
setQuelle('NETZWERKEVENT')
setNote('')
setVisibility('INTERN')
}
function handleSave() {
if (!isValid) return
const input: CreateMarktHinweisInput = {
direction,
assetType: assetType as MarktHinweis['assetType'],
locationHint,
areaSqmMin: areaSqmMin ? Number(areaSqmMin) : undefined,
areaSqmMax: areaSqmMax ? Number(areaSqmMax) : undefined,
companyName: companyName.trim() || undefined,
isAnonymized,
quelle,
note: note.trim() || undefined,
visibility,
status: 'OFFEN',
}
mutation.mutate(input, {
onSuccess: (result) => {
onCreated(result.id)
resetForm()
},
})
}
function handleClose() {
resetForm()
onClose()
}
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>Hinweis erfassen</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
{/* Richtung */}
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_SLATE[600], display: 'block', mb: 0.75 }}>
Richtung
</Typography>
<ToggleButtonGroup
value={direction}
exclusive
onChange={(_, v) => { if (v) setDirection(v as HinweisDirection) }}
size="small"
sx={{ '& .MuiToggleButton-root': { textTransform: 'none', fontSize: '0.8rem' } }}
>
<ToggleButton value="SUCHE">
<Search size={14} style={{ marginRight: 6 }} /> Jemand sucht
</ToggleButton>
<ToggleButton value="VERFUEGBARKEIT">
<Building2 size={14} style={{ marginRight: 6 }} /> Wird verfügbar
</ToggleButton>
</ToggleButtonGroup>
</Box>
{/* Asset-Typ */}
<FormControl fullWidth required>
<InputLabel>Asset-Typ *</InputLabel>
<MuiSelect
value={assetType}
label="Asset-Typ *"
onChange={e => setAssetType(e.target.value)}
>
<MenuItem value="OFFICE">Büro</MenuItem>
<MenuItem value="RETAIL">Einzelhandel</MenuItem>
<MenuItem value="LIGHT_INDUSTRIAL">Gewerbe</MenuItem>
<MenuItem value="LOGISTICS">Logistik</MenuItem>
<MenuItem value="PRODUCTION">Produktion</MenuItem>
</MuiSelect>
</FormControl>
{/* Stadt / Region */}
<TextField
fullWidth
required
label="Stadt / Region"
value={locationHint}
onChange={e => setLocationHint(e.target.value)}
/>
{/* Fläche */}
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
label="Von (m²)"
type="number"
value={areaSqmMin}
onChange={e => setAreaSqmMin(e.target.value)}
sx={{ flex: 1 }}
/>
<TextField
label="Bis (m²)"
type="number"
value={areaSqmMax}
onChange={e => setAreaSqmMax(e.target.value)}
sx={{ flex: 1 }}
/>
</Box>
{/* Firma / Name */}
<TextField
fullWidth
label="Firma / Name (optional)"
value={companyName}
onChange={e => setCompanyName(e.target.value)}
/>
{/* Quelle */}
<FormControl fullWidth>
<InputLabel>Quelle</InputLabel>
<MuiSelect
value={quelle}
label="Quelle"
onChange={e => setQuelle(e.target.value as HinweisQuelle)}
>
{Object.entries(QUELLE_LABELS).map(([k, v]) => (
<MenuItem key={k} value={k}>{v}</MenuItem>
))}
</MuiSelect>
</FormControl>
{/* Notiz */}
<TextField
fullWidth
multiline
minRows={3}
label="Notiz (optional)"
value={note}
onChange={e => setNote(e.target.value)}
/>
{/* Sichtbarkeit */}
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_SLATE[600], display: 'block', mb: 0.75 }}>
Sichtbarkeit
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Box
onClick={() => setVisibility('INTERN')}
sx={{
flex: 1, borderRadius: 1.5, p: 1.5, cursor: 'pointer',
display: 'flex', flexDirection: 'column', gap: 0.5,
border: visibility === 'INTERN' ? '2px solid #4338ca' : '1px solid #e2e8f0',
bgcolor: visibility === 'INTERN' ? '#eef2ff' : 'white',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Lock size={14} color={visibility === 'INTERN' ? '#4338ca' : '#64748b'} />
<Typography variant="caption" sx={{ fontWeight: 700, color: visibility === 'INTERN' ? '#4338ca' : '#0f172a' }}>Intern</Typography>
</Box>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontSize: '0.7rem' }}>Nur für Ihr Team sichtbar</Typography>
</Box>
<Box
onClick={() => setVisibility('PLATTFORM')}
sx={{
flex: 1, borderRadius: 1.5, p: 1.5, cursor: 'pointer',
display: 'flex', flexDirection: 'column', gap: 0.5,
border: visibility === 'PLATTFORM' ? '2px solid #7c3aed' : '1px solid #e2e8f0',
bgcolor: visibility === 'PLATTFORM' ? '#faf5ff' : 'white',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Globe size={14} color={visibility === 'PLATTFORM' ? '#7c3aed' : '#64748b'} />
<Typography variant="caption" sx={{ fontWeight: 700, color: visibility === 'PLATTFORM' ? '#7c3aed' : '#0f172a' }}>Plattform</Typography>
</Box>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontSize: '0.7rem' }}>Für alle Verwaltungen auf Property Match sichtbar</Typography>
</Box>
</Box>
</Box>
{/* Anonymisieren (only for PLATTFORM) */}
{visibility === 'PLATTFORM' && (
<FormControlLabel
control={
<Switch
checked={isAnonymized}
onChange={e => setIsAnonymized(e.target.checked)}
size="small"
/>
}
label={<Typography variant="body2">Firmenname anonymisieren</Typography>}
/>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleClose} sx={{ textTransform: 'none' }}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={mutation.isPending || !isValid}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }}
>
Speichern
</Button>
</DialogActions>
</Dialog>
)
}
export { HinweisErfassenDialog }
@@ -0,0 +1,301 @@
import { memo, useCallback, useMemo, useState } from 'react'
import { Alert, Box, Button, Chip, Divider, TextField, Typography } from '@mui/material'
import { Building2, CheckCircle2, Copy, MapPin, Ruler, Search } from 'lucide-react'
import type { MarktHinweis } from '../../domain/marktHinweis'
import type { Property } from '../../domain/property'
import { useProperties } from '../../hooks/useProperties'
import { ASSET_TYPE_LABELS } from '../../lib/constants'
import { areaFitPct } from '../market-leads'
import { OWN_VERWALTUNG_ID, QUELLE_LABELS, buildAnschreiben, formatDate } from './hinweisHelpers'
import { DS_ACCENT, DS_NEUTRAL, DS_SLATE } from '../../lib/ds'
/**
* Der Netzwerk-Bereich der Nora-Seite: menschlich erfasste Marktinformationen.
*
* Ausgezogen aus `MarketIntelligence.tsx`, die mit über 1100 Zeilen zwei
* eigenständige Funktionsbereiche in einer Datei führte — bei einem harten
* Limit von 300 Zeilen je Seite.
*/
// ── Netzwerk: HinweisPropertyCard ────────────────────────────────────────────
const HinweisPropertyCard = memo(function HinweisPropertyCard({
p,
hinweis,
}: {
p: Property
hinweis: MarktHinweis
}) {
const [open, setOpen] = useState(false)
const [copied, setCopied] = useState(false)
const companyName = hinweis.isAnonymized ? 'Interessent' : (hinweis.companyName ?? 'Interessent')
const areaEstimate = hinweis.areaSqmMax ?? hinweis.areaSqmMin
const [draft, setDraft] = useState(() =>
buildAnschreiben(companyName, hinweis.locationHint, p, areaEstimate)
)
const fit = areaEstimate != null ? areaFitPct(p.areaSqm, areaEstimate) : null
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(draft)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [draft])
return (
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden', mb: 1 }}>
{/* Property header */}
<Box
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
px: 1.75, py: 1.25,
bgcolor: DS_SLATE[50],
cursor: 'default',
}}
>
<Building2 size={14} color="#152642" style={{ flexShrink: 0 }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.title}
</Typography>
<Typography variant="caption" sx={{ color: DS_SLATE[500] }}>
{p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/J
</Typography>
</Box>
{fit !== null && (
<Chip
label={`${fit}% Flächenmatch`}
size="small"
sx={{
height: 20, fontSize: '0.65rem', fontWeight: 700, flexShrink: 0,
bgcolor: fit >= 75 ? '#dcfce7' : fit >= 50 ? '#fef9c3' : '#fee2e2',
color: fit >= 75 ? '#16a34a' : fit >= 50 ? '#92400e' : '#dc2626',
}}
/>
)}
</Box>
{/* Anschreiben toggle */}
<Box sx={{ px: 1.75, py: 1, display: 'flex', alignItems: 'center', gap: 1, borderTop: '1px solid #f1f5f9' }}>
<Button
size="small"
variant={open ? 'contained' : 'outlined'}
onClick={() => setOpen(v => !v)}
sx={{
textTransform: 'none', fontSize: '0.75rem',
bgcolor: open ? '#152642' : undefined,
borderColor: open ? '#152642' : undefined,
'&:hover': { bgcolor: open ? '#162d4a' : undefined },
}}
>
{open ? 'Schliessen' : 'Anschreiben erstellen'}
</Button>
</Box>
{/* Anschreiben composer */}
{open && (
<Box sx={{ px: 1.75, pb: 1.75, pt: 0.5, borderTop: '1px solid #f1f5f9', bgcolor: DS_NEUTRAL.offWhite }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[600], textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem' }}>
Anschreiben-Entwurf
</Typography>
<Button
size="small"
startIcon={copied ? <CheckCircle2 size={13} /> : <Copy size={13} />}
onClick={handleCopy}
sx={{ textTransform: 'none', fontSize: '0.72rem', color: copied ? '#16a34a' : '#152642' }}
>
{copied ? 'Kopiert!' : 'Kopieren'}
</Button>
</Box>
<TextField
multiline
minRows={8}
fullWidth
value={draft}
onChange={e => setDraft(e.target.value)}
sx={{
'& .MuiOutlinedInput-root': { fontSize: '0.8rem', bgcolor: 'white' },
'& textarea': { lineHeight: 1.6 },
}}
/>
<Typography variant="caption" sx={{ color: DS_SLATE[400], display: 'block', mt: 0.75 }}>
Text bearbeitbar — dann kopieren und per E-Mail versenden
</Typography>
</Box>
)}
</Box>
)
})
// ── Netzwerk: HinweisListItem ────────────────────────────────────────────────
const HinweisListItem = memo(function HinweisListItem({
hinweis, selected, onClick,
}: {
hinweis: MarktHinweis
selected: boolean
onClick: () => void
}) {
const displayName = !hinweis.isAnonymized && hinweis.companyName
? hinweis.companyName
: `Anonym · ${ASSET_TYPE_LABELS[hinweis.assetType] ?? hinweis.assetType}`
const areaStr = useMemo(() => {
if (hinweis.areaSqmMin != null && hinweis.areaSqmMax != null && hinweis.areaSqmMin !== hinweis.areaSqmMax) {
return ` · ${hinweis.areaSqmMin}–${hinweis.areaSqmMax} m²`
}
if (hinweis.areaSqmMax != null) return ` · ${hinweis.areaSqmMax} m²`
if (hinweis.areaSqmMin != null) return ` · ${hinweis.areaSqmMin} m²`
return ''
}, [hinweis.areaSqmMin, hinweis.areaSqmMax])
const visibilityBadge = useMemo(() => {
if (hinweis.visibility === 'INTERN') {
return <Chip label="Intern" size="small" sx={{ bgcolor: DS_ACCENT.indigo.bg, color: DS_ACCENT.indigo.strong, height: 18, fontSize: '0.6rem' }} />
}
if (hinweis.verwaltungId === OWN_VERWALTUNG_ID) {
return <Chip label="Geteilt" size="small" sx={{ bgcolor: DS_ACCENT.violet.bg, color: DS_ACCENT.violet.main, height: 18, fontSize: '0.6rem' }} />
}
return <Chip label={hinweis.verwaltungName} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], height: 18, fontSize: '0.6rem' }} />
}, [hinweis.visibility, hinweis.verwaltungId, hinweis.verwaltungName])
return (
<Box
onClick={onClick}
sx={{
px: 2, py: 1.5,
borderBottom: '1px solid #f1f5f9',
borderLeft: `3px solid ${selected ? '#152642' : 'transparent'}`,
bgcolor: selected ? '#f0f9ff' : 'white',
cursor: 'pointer',
'&:hover': { bgcolor: selected ? '#f0f9ff' : '#f8fafc' },
transition: 'background 0.1s',
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, mb: 0.25 }}>
<Box sx={{ mt: 0.25, flexShrink: 0 }}>
{hinweis.direction === 'SUCHE'
? <Search size={14} color="#152642" />
: <Building2 size={14} color="#7c3aed" />
}
</Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], lineHeight: 1.3, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{displayName}
</Typography>
<Box sx={{ flexShrink: 0 }}>{visibilityBadge}</Box>
</Box>
<Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.65rem', display: 'block', mb: 0.5, pl: 2.75 }}>
{hinweis.locationHint}{areaStr}
</Typography>
<Box sx={{ pl: 2.75 }}>
{hinweis.direction === 'SUCHE'
? <Chip label="Suche" size="small" sx={{ bgcolor: DS_ACCENT.blue.borderSoft, color: DS_ACCENT.blue.main, height: 16, fontSize: '0.6rem' }} />
: <Chip label="Wird frei" size="small" sx={{ bgcolor: DS_ACCENT.success.bgAlt, color: DS_ACCENT.success.bright, height: 16, fontSize: '0.6rem' }} />
}
</Box>
</Box>
)
})
// ── Netzwerk: HinweisDetail ──────────────────────────────────────────────────
function HinweisDetail({ hinweis }: { hinweis: MarktHinweis }) {
const { data: properties = [] } = useProperties()
const matchingProperties = useMemo(
() => properties.filter(p => p.assetType === hinweis.assetType).slice(0, 3),
[properties, hinweis.assetType],
)
const displayName = !hinweis.isAnonymized && hinweis.companyName ? hinweis.companyName : 'Anonym'
const areaStr = useMemo(() => {
if (hinweis.areaSqmMin != null && hinweis.areaSqmMax != null && hinweis.areaSqmMin !== hinweis.areaSqmMax) {
return `${hinweis.areaSqmMin}–${hinweis.areaSqmMax} m²`
}
if (hinweis.areaSqmMax != null) return `${hinweis.areaSqmMax} m²`
if (hinweis.areaSqmMin != null) return `${hinweis.areaSqmMin} m²`
return null
}, [hinweis.areaSqmMin, hinweis.areaSqmMax])
return (
<Box sx={{ height: '100%', overflowY: 'auto' }}>
{/* Header */}
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0' }}>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 1 }}>
{displayName}
</Typography>
{/* Badge row */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.25 }}>
{hinweis.direction === 'SUCHE'
? <Chip label="Suche" size="small" sx={{ bgcolor: DS_ACCENT.blue.borderSoft, color: DS_ACCENT.blue.main, fontWeight: 600, fontSize: '0.75rem' }} />
: <Chip label="Wird verfügbar" size="small" sx={{ bgcolor: DS_ACCENT.success.bgAlt, color: DS_ACCENT.success.bright, fontWeight: 600, fontSize: '0.75rem' }} />
}
{hinweis.visibility === 'INTERN'
? <Chip label="Intern" size="small" sx={{ bgcolor: DS_ACCENT.indigo.bg, color: DS_ACCENT.indigo.strong, fontWeight: 600, fontSize: '0.75rem' }} />
: hinweis.verwaltungId === OWN_VERWALTUNG_ID
? <Chip label="Geteilt" size="small" sx={{ bgcolor: DS_ACCENT.violet.bg, color: DS_ACCENT.violet.main, fontWeight: 600, fontSize: '0.75rem' }} />
: <Chip label={hinweis.verwaltungName} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontWeight: 600, fontSize: '0.75rem' }} />
}
</Box>
{/* Stat chips */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.25 }}>
<Chip icon={<MapPin size={11} />} label={hinweis.locationHint} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />
{areaStr && <Chip icon={<Ruler size={11} />} label={areaStr} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />}
<Chip label={ASSET_TYPE_LABELS[hinweis.assetType] ?? hinweis.assetType} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />
</Box>
{/* Quelle row */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.75, px: 1.25, bgcolor: DS_SLATE[50], borderRadius: 1, border: '1px solid #e2e8f0' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500] }}>
Quelle: <strong>{QUELLE_LABELS[hinweis.quelle] ?? hinweis.quelle}</strong>
{' · '}{hinweis.createdBy}
{' · '}{formatDate(hinweis.createdAt)}
</Typography>
</Box>
</Box>
{/* Body */}
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2 }}>
{hinweis.note && (
<Box sx={{ bgcolor: DS_SLATE[50], border: '1px solid #e2e8f0', borderRadius: 1.5, p: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.5, fontSize: '0.65rem' }}>
Notiz
</Typography>
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
{hinweis.note}
</Typography>
</Box>
)}
<Divider />
{hinweis.direction === 'SUCHE' ? (
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
Passende Objekte im Portfolio ({matchingProperties.length})
</Typography>
{matchingProperties.length === 0 ? (
<Alert severity="warning" sx={{ fontSize: '0.8rem', py: 0.5 }}>
Kein passendes Portfolioobjekt gefunden
</Alert>
) : (
matchingProperties.map(p => (
<HinweisPropertyCard key={p.id} p={p} hinweis={hinweis} />
))
)}
</Box>
) : (
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
Verfügbarkeitssignal — wird auf der Plattform für andere Verwaltungen sichtbar gemacht
</Alert>
)}
</Box>
</Box>
)
}
export { HinweisPropertyCard, HinweisListItem, HinweisDetail }
@@ -0,0 +1,48 @@
import type { Property } from '../../domain/property'
/** Eigene Verwaltung — unterscheidet «geteilt» von «fremd» in der Sichtbarkeitsmarke. */
export const OWN_VERWALTUNG_ID = 'v-001'
export const QUELLE_LABELS: Record<string, string> = {
NETZWERKEVENT: 'Netzwerkevent',
TELEFONAT: 'Telefonat',
BESICHTIGUNG: 'Besichtigung',
MESSE: 'Messe',
EMAIL: 'E-Mail',
SONSTIGES: 'Sonstiges',
}
export function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de-CH')
}
/**
* Anschreiben-Entwurf zu einem Netzwerk-Hinweis.
*
* Bleibt bewusst ein Entwurf zum Kopieren: es wird nichts versendet, und der
* Text nennt nur Angaben, die im Objektdatensatz stehen.
*/
export function buildAnschreiben(
company: string,
location: string,
p: Property,
areaSqmEstimate?: number,
): string {
const areaLine = areaSqmEstimate
? `Fläche: ${p.areaSqm.toLocaleString('de-CH')} m² (Sie suchen ca. ${areaSqmEstimate.toLocaleString('de-CH')} m²)`
: `Fläche: ${p.areaSqm.toLocaleString('de-CH')} m²`
return `Sehr geehrte Damen und Herren,
wir haben erkannt, dass ${company} nach Gewerbeflächen im Raum ${location} sucht. Gerne möchten wir Ihnen eine passende Option aus unserem Portfolio vorstellen:
Objekt: ${p.title}
Lage: ${p.location.city}
${areaLine}
Mietpreis: CHF ${p.rentPricePerSqm.toLocaleString('de-CH')} / m² / Jahr
Wir würden uns freuen, Ihnen das Objekt in einer unverbindlichen Besichtigung vorzustellen und Ihre konkreten Anforderungen zu besprechen.
Mit freundlichen Grüssen
Wincasa AG
Immobilienverwaltung`
}
+5
View File
@@ -0,0 +1,5 @@
// Netzwerk-Hinweise auf der Nora-Seite. Barrel-Export (CLAUDE.md §13).
export { HinweisListItem, HinweisDetail } from './MarktHinweisPanels'
export { HinweisErfassenDialog } from './HinweisErfassenDialog'
export { OWN_VERWALTUNG_ID, QUELLE_LABELS, buildAnschreiben, formatDate } from './hinweisHelpers'