Files
property-match/src/components/markt-hinweise/MarktHinweisPanels.tsx
T
Benjamin SutterandClaude Opus 5 9ed21e33a7 feat(runde-5): Startseite mit Agenten-Meetingraum, sticky Chat und Noras Kanäle
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>
2026-08-06 12:44:22 +02:00

232 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }