Hauptnavigation und Startseite (§2) - Hauptseite «Übersicht» samt Route, Menüeintrag, Hook, Service und den nur dort verwendeten Widgets entfernt; /supply/dashboard leitet auf die Startseite um, damit Lesezeichen nicht ins Leere laufen. - «Meine Objekte» heisst im Menü «Startseite»; die frühere Überschrift «Objektverwaltung» heisst jetzt «Meine Objekte». - Die Startseite scrollt als Ganzes. Die Höhe des Meetingraums rechnet gegen die Viewport-Höhe, damit Titel und Balken «Filter & Übersicht» beim Einstieg auf jeder Monitorgrösse sichtbar bleiben (geprüft 1280–1920 px). Agenten-Meetingraum (§3) - Zentralperspektive aus clip-path-Flächen, ohne 3D-Engine und ohne Animationsbibliothek: rechts geschlossene Wand, links und gegenüber boden- bis deckenhohe Glasfronten mit Blick auf Rhein, Basler Münster, Altstadt und Wettsteinbrücke. - Sitzordnung, Zielrouten, Grössen und Plakettenversatz liegen in einer zentralen Konfiguration (meetingRoomSeats.ts). - Neuer Agent Giorgio, Personalverwalter, sitzt vis-à-vis und führt in «Meine Agenten». Er steht bewusst nicht in AGENT_WORKSPACES: er hat keine Arbeitsseite, sondern ist die Adminfläche selbst. - Auf schmalen Schirmen tritt eine vereinfachte Kachelanordnung an die Stelle der Perspektive; ausgeblendet wird kein Agent. Sticky Chat bei allen Agenten (§5, §9–§11) - Scrollt der Chatkopf unter den globalen Balken, übernimmt dort eine kompakte Fassung mit Porträt, Name, Frage und Eingabefeld. - Ein gemeinsamer Zustand (agentChatStore) statt zweier Chat-Instanzen: Eingabetext und Fokus überstehen den Wechsel in beide Richtungen. - Noras Seite scrollte bisher gar nicht — Chatkopf ausserhalb des Scrollbereichs, zwei getrennt scrollende Spalten darunter. Behoben über den Seitenaufbau, nicht über eine Nora-Sonderlösung. Nora: CRM-Kanal und gemeinsame Lead-Liste (§12, §13) - Dritter Kanal «CRM» mit eigenem Domänentyp, Mockdaten, Provider, Service und Hook nach dem bestehenden Schichtenmuster. - Die Reiter «KI-Signale»/«Netzwerk» sind einer gemeinsamen Liste gewichen. Jede Zeile trägt ihre Kanalherkunft als Badge; die Herkunft steht im Datenmodell (LeadChannel) und wird nicht aus der Darstellung geraten. - Der frühere Reiterbalken ist der Filter «Kanaltyp» mit Alle/KI Signal/ Netzwerk/CRM, Standard «Alle», mit Empty State und Rücksetzung. Produkttour und Profilmenü (§8) - Tour auf die neue Struktur umgeschrieben: Startseite, Meetingraum, Agenten, sticky Chat, Leadkanäle. «Übersicht» kommt nicht mehr vor, «Meine Objekte» bezeichnet nur noch den Objektbereich. - Bereich «Demo-Modus» mit «Verwaltung» und «Bürosuche» entfernt. Geprüft: 413 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925). Im Browser: Navigation, Agentenklicks aller sechs Plätze, Kanalfilter und Detailschubladen, Sticky Chat bei vier Agenten, 1280–1920 px sowie Tablet- und Mobilbreite, keine 404 für Bilder oder Routen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
232 lines
10 KiB
TypeScript
232 lines
10 KiB
TypeScript
import { memo, useCallback, useMemo, useState } from 'react'
|
||
import { Alert, Box, Button, Chip, Divider, TextField, Typography } from '@mui/material'
|
||
import { Building2, CheckCircle2, Copy, MapPin, Ruler } 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: 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, HinweisDetail }
|