feat: Marktchancen — Netzwerk-Hinweise tab (human-sourced market intel)

- MarktHinweis domain type: SUCHE/VERFUEGBARKEIT direction, INTERN/PLATTFORM
  visibility, anonymization flag, source tracking
- Full data layer: IMarktHinweisProvider, MockupMarktHinweisProvider, service, hooks
- 7 seed entries: mix of intern/shared, demand/supply, own/partner Verwaltungen
- Marktchancen gets two-tab layout: Digitale Signale (crawler) + Netzwerk (human)
- HinweisListItem: direction icon, visibility badge (Intern/Geteilt/partner name)
- HinweisDetail: note card, portfolio match (SUCHE) or availability info (VERFUEGBARKEIT)
- HinweisErfassenDialog: direction toggle, asset type, location, area range,
  company + anonymize switch, source, note, card-based visibility picker
- HinweisPropertyCard: same Anschreiben composer pattern as crawler side

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 23:47:50 +02:00
parent 63909c8caf
commit 7f6b7766e6
7 changed files with 960 additions and 53 deletions
+27
View File
@@ -0,0 +1,27 @@
import type { AssetType } from './enums'
export type HinweisVisibility = 'INTERN' | 'PLATTFORM'
export type HinweisDirection = 'SUCHE' | 'VERFUEGBARKEIT'
export type HinweisStatus = 'OFFEN' | 'IN_BEARBEITUNG' | 'ARCHIVIERT'
export type HinweisQuelle = 'NETZWERKEVENT' | 'TELEFONAT' | 'BESICHTIGUNG' | 'MESSE' | 'EMAIL' | 'SONSTIGES'
export interface MarktHinweis {
id: string
createdAt: string
createdBy: string
verwaltungId: string
verwaltungName: string
direction: HinweisDirection
assetType: AssetType
locationHint: string
areaSqmMin?: number
areaSqmMax?: number
companyName?: string
isAnonymized: boolean
quelle: HinweisQuelle
note?: string
visibility: HinweisVisibility
status: HinweisStatus
}
export type CreateMarktHinweisInput = Omit<MarktHinweis, 'id' | 'createdAt' | 'createdBy' | 'verwaltungId' | 'verwaltungName'>
+21
View File
@@ -0,0 +1,21 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { marktHinweisService } from '../services/marktHinweisService'
import type { CreateMarktHinweisInput } from '../domain/marktHinweis'
export function useMarktHinweise() {
return useQuery({
queryKey: ['marktHinweise'],
queryFn: () => marktHinweisService.getAll(),
staleTime: 60_000,
})
}
export function useCreateMarktHinweis() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateMarktHinweisInput) => marktHinweisService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['marktHinweise'] })
},
})
}
+130
View File
@@ -0,0 +1,130 @@
import { AssetType } from '../domain/enums'
import type { MarktHinweis } from '../domain/marktHinweis'
export const marktHinweise: MarktHinweis[] = [
{
id: 'mh-001',
createdAt: '2026-05-22T09:15:00Z',
createdBy: 'Thomas Müller',
verwaltungId: 'v-001',
verwaltungName: 'Wincasa AG',
direction: 'SUCHE',
assetType: AssetType.OFFICE,
locationHint: 'Zürich Innenstadt',
areaSqmMin: 200,
areaSqmMax: 400,
companyName: 'Fintech Solutions AG',
isAnonymized: false,
quelle: 'NETZWERKEVENT',
note: 'Sucht modernes Büro, Nähe HB, max CHF 350/m²/J. Kontakt: CEO Andreas Baum',
visibility: 'INTERN',
status: 'OFFEN',
},
{
id: 'mh-002',
createdAt: '2026-05-21T14:30:00Z',
createdBy: 'Sarah Keller',
verwaltungId: 'v-001',
verwaltungName: 'Wincasa AG',
direction: 'SUCHE',
assetType: AssetType.LOGISTICS,
locationHint: 'Pratteln / Muttenz',
areaSqmMin: 800,
areaSqmMax: 1500,
companyName: 'Elektro Bernhard GmbH',
isAnonymized: false,
quelle: 'TELEFONAT',
note: 'Benötigt Lagerfläche mit Rampe und Kran. Einzug Q1/2027.',
visibility: 'INTERN',
status: 'OFFEN',
},
{
id: 'mh-003',
createdAt: '2026-05-20T11:00:00Z',
createdBy: 'Thomas Müller',
verwaltungId: 'v-001',
verwaltungName: 'Wincasa AG',
direction: 'VERFUEGBARKEIT',
assetType: AssetType.OFFICE,
locationHint: 'Basel',
areaSqmMin: 350,
areaSqmMax: 350,
companyName: 'Fläche frei per Sept. 2026',
isAnonymized: false,
quelle: 'BESICHTIGUNG',
note: 'Bürofläche 350m², guter Zustand, 4. OG, Aufzug.',
visibility: 'INTERN',
status: 'OFFEN',
},
{
id: 'mh-004',
createdAt: '2026-05-19T08:45:00Z',
createdBy: 'Thomas Müller',
verwaltungId: 'v-001',
verwaltungName: 'Wincasa AG',
direction: 'SUCHE',
assetType: AssetType.RETAIL,
locationHint: 'Winterthur Altstadt',
areaSqmMin: 80,
areaSqmMax: 150,
companyName: 'Boutique-Kette',
isAnonymized: false,
quelle: 'MESSE',
note: 'Ladenfläche EG, Fussgängerzone bevorzugt.',
visibility: 'PLATTFORM',
status: 'OFFEN',
},
{
id: 'mh-005',
createdAt: '2026-05-18T16:20:00Z',
createdBy: 'Petra Züst',
verwaltungId: 'v-002',
verwaltungName: 'Livit AG',
direction: 'SUCHE',
assetType: AssetType.LIGHT_INDUSTRIAL,
locationHint: 'Raum Bern',
areaSqmMin: 400,
areaSqmMax: 700,
isAnonymized: true,
quelle: 'TELEFONAT',
note: 'Produktionsfirma sucht Gewerbehalle, Deckenhöhe mind. 5m.',
visibility: 'PLATTFORM',
status: 'OFFEN',
},
{
id: 'mh-006',
createdAt: '2026-05-17T10:30:00Z',
createdBy: 'Marco Lüthi',
verwaltungId: 'v-003',
verwaltungName: 'Zurich Invest AG',
direction: 'VERFUEGBARKEIT',
assetType: AssetType.OFFICE,
locationHint: 'Zürich West',
areaSqmMin: 550,
areaSqmMax: 550,
companyName: 'Fläche frei per April 2027',
isAnonymized: false,
quelle: 'SONSTIGES',
note: 'Repräsentatives Büro, Ausbaustandard A, direkte SBB-Anbindung.',
visibility: 'PLATTFORM',
status: 'OFFEN',
},
{
id: 'mh-007',
createdAt: '2026-05-15T09:00:00Z',
createdBy: 'Sarah Keller',
verwaltungId: 'v-001',
verwaltungName: 'Wincasa AG',
direction: 'SUCHE',
assetType: AssetType.OFFICE,
locationHint: 'Luzern',
areaSqmMin: 180,
areaSqmMax: 320,
companyName: 'Startup Hub Luzern',
isAnonymized: false,
quelle: 'NETZWERKEVENT',
note: 'Flexibler Ausbau gewünscht, Co-Working-tauglich.',
visibility: 'INTERN',
status: 'IN_BEARBEITUNG',
},
]
+689 -15
View File
@@ -1,21 +1,29 @@
import { memo, useState, useEffect, useCallback } from 'react' import { memo, useState, useEffect, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { import {
Alert, Box, Button, Chip, Divider, IconButton, Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle,
Skeleton, TextField, Tooltip, Typography, Divider, FormControl, FormControlLabel, IconButton,
InputLabel, MenuItem, Select as MuiSelect,
Skeleton, Switch, Tab, Tabs, TextField, ToggleButton, ToggleButtonGroup,
Tooltip, Typography,
} from '@mui/material' } from '@mui/material'
import { import {
AlertCircle, Bell, Building2, CheckCircle2, Copy, AlertCircle, Bell, Building2, CheckCircle2, Copy,
ExternalLink, Globe, Linkedin, Mail, MapPin, Phone, ExternalLink, Globe, Linkedin, Lock, Mail, MapPin, Phone,
Ruler, Sparkles, TrendingUp, User, Plus, Ruler, Search, Sparkles, TrendingUp, User, Users,
} from 'lucide-react' } from 'lucide-react'
import { useMarketLeads } from '../../hooks/useMarketLeads' import { useMarketLeads } from '../../hooks/useMarketLeads'
import type { MarketLead } from '../../hooks/useMarketLeads' import type { MarketLead } from '../../hooks/useMarketLeads'
import { useMarktHinweise, useCreateMarktHinweis } from '../../hooks/useMarktHinweise'
import type { MarktHinweis, HinweisVisibility, HinweisDirection, HinweisQuelle, CreateMarktHinweisInput } from '../../domain/marktHinweis'
import { useProperties } from '../../hooks/useProperties'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import type { ExtractedContact } from '../../domain/futureSignal' import type { ExtractedContact } from '../../domain/futureSignal'
// ── Constants ──────────────────────────────────────────────────────────────── // ── Constants ────────────────────────────────────────────────────────────────
const OWN_VERWALTUNG_ID = 'v-001'
const SOURCE_LABELS: Record<string, string> = { const SOURCE_LABELS: Record<string, string> = {
JOB_POSTING: 'Stelleninserate', JOB_POSTING: 'Stelleninserate',
PRESS: 'Pressebericht', PRESS: 'Pressebericht',
@@ -26,6 +34,24 @@ const SOURCE_LABELS: Record<string, string> = {
LEASE_CONTRACT: 'Mietvertrag', LEASE_CONTRACT: 'Mietvertrag',
} }
const QUELLE_LABELS: Record<string, string> = {
NETZWERKEVENT: 'Netzwerkevent',
TELEFONAT: 'Telefonat',
BESICHTIGUNG: 'Besichtigung',
MESSE: 'Messe',
EMAIL: 'E-Mail',
SONSTIGES: 'Sonstiges',
}
const ASSET_TYPE_LABELS: Record<string, string> = {
OFFICE: 'Büro',
RETAIL: 'Einzelhandel',
LIGHT_INDUSTRIAL: 'Gewerbe',
LOGISTICS: 'Logistik',
PRODUCTION: 'Produktion',
MIXED: 'Gemischt',
}
const CONTACT_ICONS: Record<string, React.ReactNode> = { const CONTACT_ICONS: Record<string, React.ReactNode> = {
EMAIL: <Mail size={13} />, EMAIL: <Mail size={13} />,
PHONE: <Phone size={13} />, PHONE: <Phone size={13} />,
@@ -56,6 +82,10 @@ function areaFitPct(propArea: number, signalArea: number): number {
return Math.max(0, Math.round(100 - (Math.abs(propArea - signalArea) / signalArea) * 100)) return Math.max(0, Math.round(100 - (Math.abs(propArea - signalArea) / signalArea) * 100))
} }
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de-CH')
}
function buildAnschreiben(company: string, location: string, p: Property, areaSqmEstimate?: number): string { function buildAnschreiben(company: string, location: string, p: Property, areaSqmEstimate?: number): string {
const areaLine = areaSqmEstimate 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² (Sie suchen ca. ${areaSqmEstimate.toLocaleString('de-CH')} m²)`
@@ -466,36 +496,589 @@ function LeadDetail({ lead }: { lead: MarketLead }) {
) )
} }
// ── 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: '#f8fafc',
cursor: 'default',
}}
>
<Building2 size={14} color="#1e3a5f" style={{ flexShrink: 0 }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.title}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{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 ? '#1e3a5f' : undefined,
borderColor: open ? '#1e3a5f' : 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: '#fafafa' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', 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' : '#1e3a5f' }}
>
{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: '#94a3b8', 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}`
}
if (hinweis.areaSqmMax != null) return ` · ${hinweis.areaSqmMax}`
if (hinweis.areaSqmMin != null) return ` · ${hinweis.areaSqmMin}`
return ''
}, [hinweis.areaSqmMin, hinweis.areaSqmMax])
const visibilityBadge = useMemo(() => {
if (hinweis.visibility === 'INTERN') {
return <Chip label="Intern" size="small" sx={{ bgcolor: '#eef2ff', color: '#4338ca', height: 18, fontSize: '0.6rem' }} />
}
if (hinweis.verwaltungId === OWN_VERWALTUNG_ID) {
return <Chip label="Geteilt" size="small" sx={{ bgcolor: '#faf5ff', color: '#7c3aed', height: 18, fontSize: '0.6rem' }} />
}
return <Chip label={hinweis.verwaltungName} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', 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 ? '#1e3a5f' : '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="#1e3a5f" />
: <Building2 size={14} color="#7c3aed" />
}
</Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', 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: '#94a3b8', 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: '#dbeafe', color: '#1d4ed8', height: 16, fontSize: '0.6rem' }} />
: <Chip label="Wird frei" size="small" sx={{ bgcolor: '#dcfce7', color: '#16a34a', 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}`
}
if (hinweis.areaSqmMax != null) return `${hinweis.areaSqmMax}`
if (hinweis.areaSqmMin != null) return `${hinweis.areaSqmMin}`
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: '#dbeafe', color: '#1d4ed8', fontWeight: 600, fontSize: '0.75rem' }} />
: <Chip label="Wird verfügbar" size="small" sx={{ bgcolor: '#dcfce7', color: '#16a34a', fontWeight: 600, fontSize: '0.75rem' }} />
}
{hinweis.visibility === 'INTERN'
? <Chip label="Intern" size="small" sx={{ bgcolor: '#eef2ff', color: '#4338ca', fontWeight: 600, fontSize: '0.75rem' }} />
: hinweis.verwaltungId === OWN_VERWALTUNG_ID
? <Chip label="Geteilt" size="small" sx={{ bgcolor: '#faf5ff', color: '#7c3aed', fontWeight: 600, fontSize: '0.75rem' }} />
: <Chip label={hinweis.verwaltungName} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', 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: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} />
{areaStr && <Chip icon={<Ruler size={11} />} label={areaStr} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} />}
<Chip label={ASSET_TYPE_LABELS[hinweis.assetType] ?? hinweis.assetType} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} />
</Box>
{/* Quelle row */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.75, px: 1.25, bgcolor: '#f8fafc', borderRadius: 1, border: '1px solid #e2e8f0' }}>
<Typography variant="caption" sx={{ color: '#64748b' }}>
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: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, p: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.5, fontSize: '0.65rem' }}>
Notiz
</Typography>
<Typography variant="body2" sx={{ color: '#1e293b', lineHeight: 1.6 }}>
{hinweis.note}
</Typography>
</Box>
)}
<Divider />
{hinweis.direction === 'SUCHE' ? (
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', 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>
)
}
// ── 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: '#475569', 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: '#475569', 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: '#64748b', 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: '#64748b', 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: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Speichern
</Button>
</DialogActions>
</Dialog>
)
}
// ── Page ───────────────────────────────────────────────────────────────────── // ── Page ─────────────────────────────────────────────────────────────────────
export default function MarketIntelligence() { export default function MarketIntelligence() {
const [selectedId, setSelectedId] = useState<string | null>(null) const [activeTab, setActiveTab] = useState(0)
const { data: leads, isLoading } = useMarketLeads() const [selectedSignalId, setSelectedSignalId] = useState<string | null>(null)
const [selectedHinweisId, setSelectedHinweisId] = useState<string | null>(null)
const [erfassenOpen, setErfassenOpen] = useState(false)
const [hinweisFilter, setHinweisFilter] = useState<'ALL' | 'INTERN' | 'PLATTFORM'>('ALL')
const { data: leads, isLoading: leadsLoading } = useMarketLeads()
const { data: hinweise = [], isLoading: hinweiseLoading } = useMarktHinweise()
// auto-select first signal
useEffect(() => { useEffect(() => {
if (!selectedId && leads.length > 0) setSelectedId(leads[0].signal.id) if (!selectedSignalId && leads.length > 0) setSelectedSignalId(leads[0].signal.id)
}, [leads, selectedId]) }, [leads, selectedSignalId])
const selectedLead = leads.find(l => l.signal.id === selectedId) ?? null // auto-select first hinweis
useEffect(() => {
if (!selectedHinweisId && hinweise.length > 0) setSelectedHinweisId(hinweise[0].id)
}, [hinweise, selectedHinweisId])
const selectedLead = leads.find(l => l.signal.id === selectedSignalId) ?? null
const filteredHinweise = useMemo(() => {
if (hinweisFilter === 'ALL') return hinweise
return hinweise.filter(h => h.visibility === hinweisFilter)
}, [hinweise, hinweisFilter])
const selectedHinweis = hinweise.find(h => h.id === selectedHinweisId) ?? null
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Page header */}
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}> <Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Marktchancen</Typography> <Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Marktchancen</Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
KI-erkannte Unternehmen mit Flächenbedarf · automatisch aus Web-Quellen · mit Portfolio-Match und Anschreiben KI-Signale aus Web-Quellen und menschliche Netzwerk-Hinweise mit Portfolio-Match
</Typography> </Typography>
</Box> </Box>
{/* Tabs */}
<Tabs
value={activeTab}
onChange={(_, v: number) => setActiveTab(v)}
sx={{
borderBottom: '1px solid #e2e8f0',
flexShrink: 0,
bgcolor: 'white',
'& .MuiTab-root': { textTransform: 'none', fontSize: '0.85rem', minHeight: 44, px: 3 },
}}
>
<Tab icon={<Sparkles size={14} />} iconPosition="start" label="Digitale Signale" />
<Tab icon={<Users size={14} />} iconPosition="start" label="Netzwerk" />
</Tabs>
{/* Tab 0: Digitale Signale */}
{activeTab === 0 && (
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}> <Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Left pane */} {/* Left pane */}
<Box sx={{ width: 360, flexShrink: 0, borderRight: '1px solid #e2e8f0', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <Box sx={{ width: 360, flexShrink: 0, borderRight: '1px solid #e2e8f0', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}> <Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>Erkannte Signale</Typography> <Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>Erkannte Signale</Typography>
<Chip label={isLoading ? '…' : leads.length} size="small" sx={{ bgcolor: '#1e3a5f', color: '#fff', fontSize: '0.7rem', height: 20 }} /> <Chip label={leadsLoading ? '…' : leads.length} size="small" sx={{ bgcolor: '#1e3a5f', color: '#fff', fontSize: '0.7rem', height: 20 }} />
</Box> </Box>
<Box sx={{ flex: 1, overflowY: 'auto' }}> <Box sx={{ flex: 1, overflowY: 'auto' }}>
{isLoading {leadsLoading
? [1, 2, 3, 4].map(i => ( ? [1, 2, 3, 4].map(i => (
<Box key={i} sx={{ px: 2, py: 1.5, borderBottom: '1px solid #f1f5f9' }}> <Box key={i} sx={{ px: 2, py: 1.5, borderBottom: '1px solid #f1f5f9' }}>
<Skeleton variant="text" width="55%" sx={{ mb: 0.25 }} /> <Skeleton variant="text" width="55%" sx={{ mb: 0.25 }} />
@@ -507,8 +1090,8 @@ export default function MarketIntelligence() {
<LeadListItem <LeadListItem
key={lead.signal.id} key={lead.signal.id}
lead={lead} lead={lead}
selected={lead.signal.id === selectedId} selected={lead.signal.id === selectedSignalId}
onClick={() => setSelectedId(lead.signal.id)} onClick={() => setSelectedSignalId(lead.signal.id)}
/> />
))} ))}
</Box> </Box>
@@ -525,6 +1108,97 @@ export default function MarketIntelligence() {
)} )}
</Box> </Box>
</Box> </Box>
)}
{/* Tab 1: Netzwerk */}
{activeTab === 1 && (
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Left pane */}
<Box sx={{ width: 360, flexShrink: 0, borderRight: '1px solid #e2e8f0', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>Netzwerk-Hinweise</Typography>
<Chip
label={hinweiseLoading ? '…' : filteredHinweise.length}
size="small"
sx={{ bgcolor: '#7c3aed', color: '#fff', fontSize: '0.7rem', height: 20 }}
/>
<Button
size="small"
variant="contained"
startIcon={<Plus size={13} />}
onClick={() => setErfassenOpen(true)}
sx={{
textTransform: 'none', fontSize: '0.72rem',
bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' },
minWidth: 'auto',
}}
>
Erfassen
</Button>
</Box>
{/* Filter chips */}
<Box sx={{ display: 'flex', gap: 0.5 }}>
{(['ALL', 'INTERN', 'PLATTFORM'] as const).map(f => (
<Chip
key={f}
label={f === 'ALL' ? 'Alle' : f === 'INTERN' ? 'Intern' : 'Plattform'}
size="small"
onClick={() => setHinweisFilter(f)}
sx={{
height: 22, fontSize: '0.68rem', cursor: 'pointer',
bgcolor: hinweisFilter === f ? '#1e3a5f' : '#f1f5f9',
color: hinweisFilter === f ? 'white' : '#475569',
}}
/>
))}
</Box>
</Box>
{/* List */}
<Box sx={{ flex: 1, overflowY: 'auto' }}>
{hinweiseLoading
? [1, 2, 3].map(i => (
<Box key={i} sx={{ px: 2, py: 1.5, borderBottom: '1px solid #f1f5f9' }}>
<Skeleton variant="text" width="60%" />
<Skeleton variant="text" width="80%" height={12} />
</Box>
))
: filteredHinweise.map(h => (
<HinweisListItem
key={h.id}
hinweis={h}
selected={h.id === selectedHinweisId}
onClick={() => setSelectedHinweisId(h.id)}
/>
))
}
</Box>
</Box>
{/* Right pane */}
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{selectedHinweis ? (
<HinweisDetail hinweis={selectedHinweis} />
) : (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Typography variant="body2" color="text.disabled">Hinweis aus der Liste auswählen</Typography>
</Box>
)}
</Box>
</Box>
)}
{/* Dialog */}
<HinweisErfassenDialog
open={erfassenOpen}
onClose={() => setErfassenOpen(false)}
onCreated={(id) => {
setSelectedHinweisId(id)
setErfassenOpen(false)
setActiveTab(1)
}}
/>
</Box> </Box>
) )
} }
+8
View File
@@ -0,0 +1,8 @@
import type { MarktHinweis, CreateMarktHinweisInput } from '../domain/marktHinweis'
export interface IMarktHinweisProvider {
getAll(): Promise<MarktHinweis[]>
getById(id: string): Promise<MarktHinweis | null>
create(data: CreateMarktHinweisInput): Promise<MarktHinweis>
update(id: string, data: Partial<MarktHinweis>): Promise<MarktHinweis>
}
@@ -0,0 +1,36 @@
import type { IMarktHinweisProvider } from './IMarktHinweisProvider'
import type { MarktHinweis, CreateMarktHinweisInput } from '../domain/marktHinweis'
import { marktHinweise as seed } from '../mock-data/marktHinweise'
const store: MarktHinweis[] = [...seed]
export const MockupMarktHinweisProvider: IMarktHinweisProvider = {
async getAll() {
return [...store]
},
async getById(id: string) {
return store.find(h => h.id === id) ?? null
},
async create(data: CreateMarktHinweisInput) {
const next: MarktHinweis = {
...data,
id: `mh-${Date.now()}`,
createdAt: new Date().toISOString(),
createdBy: 'Thomas Müller',
verwaltungId: 'v-001',
verwaltungName: 'Wincasa AG',
}
store.push(next)
return next
},
async update(id: string, data: Partial<MarktHinweis>) {
const idx = store.findIndex(h => h.id === id)
if (idx === -1) throw new Error(`MarktHinweis ${id} not found`)
const updated: MarktHinweis = { ...store[idx], ...data }
store[idx] = updated
return updated
},
}
+11
View File
@@ -0,0 +1,11 @@
import { MockupMarktHinweisProvider } from '../provider/MockupMarktHinweisProvider'
import type { MarktHinweis, CreateMarktHinweisInput } from '../domain/marktHinweis'
const provider = MockupMarktHinweisProvider
export const marktHinweisService = {
getAll: (): Promise<MarktHinweis[]> => provider.getAll(),
getById: (id: string): Promise<MarktHinweis | null> => provider.getById(id),
create: (data: CreateMarktHinweisInput): Promise<MarktHinweis> => provider.create(data),
update: (id: string, data: Partial<MarktHinweis>): Promise<MarktHinweis> => provider.update(id, data),
}