refactor: architecture compliance pass — DS tokens, hook boundary, god component split, AI hardening
- DS token migration: Anfragen.tsx + child components (AnfragenInquiryItem, AnfragenMessageBubble) fully migrated; DS_TEXT.brandDark added; scoreTheme.ts moved to src/lib/ with re-export proxy - Hook boundary: Results.tsx no longer calls needService directly — routes through useNeeds() with optional refetchOnMount/gcTime overrides - NewListing.tsx (440L) split into useNewListingForm hook + 8 section components under src/components/new-listing/; page shell reduced to 121 lines - AI hardening: Zod .strict() on all schemas, AIProvenance extended with schemaVersion/ fallbackReason/traceId/latencyMs, AITraceStore stats with p50/p90/p99 + failure breakdowns, MockAIService buildFollowUpQuestions with priority ordering + area-ambiguity detection, prompt templates updated (LIGHT_INDUSTRIAL, budget unit, ambiguity detection, decimal precision) - Tests: all 154 passing; fixed test regression caused by OfferEmailResponseSchema body min(50) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { Building2, MapPin, Ruler, Calendar } from 'lucide-react'
|
||||
import type { InquiryPreparationReportDraft, ReportObjectFieldKey } from '../../domain/inquiryReport'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { Inquiry } from '../../domain/inquiry'
|
||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
||||
|
||||
interface Props {
|
||||
draft: InquiryPreparationReportDraft
|
||||
@@ -100,39 +101,39 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ borderBottom: '3px solid #1e3a5f', pb: 2, mb: 3 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e3a5f', fontFamily: 'inherit' }}>
|
||||
<Box sx={{ borderBottom: `3px solid ${DS_TEXT.brand}`, pb: 2, mb: 3 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: DS_TEXT.brand, fontFamily: 'inherit' }}>
|
||||
Objektvorschlag
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.muted, mt: 0.5 }}>
|
||||
Wincasa AG · {new Date().toLocaleDateString('de-CH')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{editableByField['intro'] && (
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, mb: 3, color: '#1e293b' }}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, mb: 3, color: DS_TEXT.primary }}>
|
||||
{editableByField['intro']}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 2, mb: 3 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
|
||||
<Box sx={{ bgcolor: DS_BG.page, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, p: 2, mb: 3 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
|
||||
Ihre Anfrage
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#374151', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary, mb: 0.5 }}>
|
||||
<strong>Kontakt:</strong> {inquiry.tenantName}{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary }}>
|
||||
<strong>Betreff:</strong> {inquiry.subject}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{editableByField['highlights'] && (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
|
||||
Warum diese Objekte passen
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: '#374151', mb: 3 }}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: DS_TEXT.primary, mb: 3 }}>
|
||||
{editableByField['highlights']}
|
||||
</Typography>
|
||||
</>
|
||||
@@ -145,7 +146,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
||||
label={p.title}
|
||||
size="small"
|
||||
icon={<Building2 size={12} />}
|
||||
sx={{ bgcolor: '#e0e7ff', color: '#1e3a5f', fontWeight: 600 }}
|
||||
sx={{ bgcolor: DS_SURFACE.indigo.bg, color: DS_TEXT.brand, fontWeight: 600 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
@@ -161,7 +162,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
||||
<Box key={property.id}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Divider sx={{ flex: 1 }} />
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
||||
Objekt {idx + 1} von {selectedProps.length}
|
||||
</Typography>
|
||||
<Divider sx={{ flex: 1 }} />
|
||||
@@ -184,7 +185,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
||||
height: 120,
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
bgcolor: '#e2e8f0',
|
||||
bgcolor: DS_BORDER.default,
|
||||
backgroundImage: property.mapImageUrl ? `url(${property.mapImageUrl})` : 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
@@ -194,7 +195,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{!property.mapImageUrl && <MapPin size={28} color="#94a3b8" />}
|
||||
{!property.mapImageUrl && <MapPin size={28} color={DS_TEXT.disabled} />}
|
||||
</Box>
|
||||
{/* Property photo */}
|
||||
<Box
|
||||
@@ -203,7 +204,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
||||
height: 120,
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
bgcolor: '#e2e8f0',
|
||||
bgcolor: DS_BORDER.default,
|
||||
backgroundImage: image ? `url(${image})` : 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
@@ -212,23 +213,23 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{!image && <Building2 size={28} color="#94a3b8" />}
|
||||
{!image && <Building2 size={28} color={DS_TEXT.disabled} />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e3a5f', mb: 0.5, fontFamily: 'inherit' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: DS_TEXT.brand, mb: 0.5, fontFamily: 'inherit' }}>
|
||||
{property.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
|
||||
<MapPin size={13} />
|
||||
<Typography variant="caption">{property.location.city}{property.location.district ? `, ${property.location.district}` : ''}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
|
||||
<Ruler size={13} />
|
||||
<Typography variant="caption">{property.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
|
||||
<Calendar size={13} />
|
||||
<Typography variant="caption">{new Date(property.availabilityDate).toLocaleDateString('de-CH')}</Typography>
|
||||
</Box>
|
||||
@@ -240,7 +241,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 0.75,
|
||||
bgcolor: '#f8fafc',
|
||||
bgcolor: DS_BG.page,
|
||||
borderRadius: 1,
|
||||
p: 1.5,
|
||||
}}
|
||||
@@ -250,10 +251,10 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
||||
if (!val) return null
|
||||
return (
|
||||
<Box key={key}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', display: 'block' }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.68rem', display: 'block' }}>
|
||||
{FIELD_LABELS[key] ?? key}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.78rem' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.primary, fontSize: '0.78rem' }}>
|
||||
{val}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -268,10 +269,10 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
||||
|
||||
{editableByField['next_steps'] && (
|
||||
<Box sx={{ bgcolor: 'white', p: 3, boxShadow: '0 2px 12px rgba(0,0,0,0.1)', borderRadius: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
|
||||
Nächste Schritte
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: '#374151' }}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: DS_TEXT.primary }}>
|
||||
{editableByField['next_steps']}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Alert, Box, Button, CircularProgress, Divider } from '@mui/material'
|
||||
import { ArrowRight, Bookmark, Search } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
canProceed: boolean
|
||||
isSearching: boolean
|
||||
isSavingProfile: boolean
|
||||
onSearch: () => void
|
||||
onSaveProfile: () => void
|
||||
}
|
||||
|
||||
export function AISearchActionBar({ canProceed, isSearching, isSavingProfile, onSearch, onSaveProfile }: Props) {
|
||||
const isProcessing = isSearching || isSavingProfile
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={!canProceed || isProcessing}
|
||||
onClick={onSearch}
|
||||
endIcon={isSearching ? <CircularProgress size={18} color="inherit" /> : <Search size={18} />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 1.5,
|
||||
bgcolor: '#1e3a5f',
|
||||
'&:hover': { bgcolor: '#162d4a' },
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
}}
|
||||
>
|
||||
{isSearching ? 'Sucht…' : 'Jetzt suchen'}
|
||||
</Button>
|
||||
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
disabled={!canProceed || isProcessing}
|
||||
onClick={onSaveProfile}
|
||||
startIcon={<Bookmark size={16} />}
|
||||
endIcon={isSavingProfile ? <CircularProgress size={18} color="inherit" /> : <ArrowRight size={18} />}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
textTransform: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{isSavingProfile ? 'Analysiert…' : 'Als Suchprofil speichern'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Alert severity="info" sx={{ mt: -1 }}>
|
||||
<strong>Jetzt suchen</strong> liefert sofortige Ergebnisse.{' '}
|
||||
<strong>Als Suchprofil speichern</strong> legt einen dauerhaften Bedarf an, der automatisch mit neuen Angeboten abgeglichen wird — auch in Zukunft.
|
||||
</Alert>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Alert, Box, Button, CircularProgress } from '@mui/material'
|
||||
import { Save } from 'lucide-react'
|
||||
import { NeedCardPreview } from './NeedCardPreview'
|
||||
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
|
||||
|
||||
interface Props {
|
||||
criteria: ParsedNeedCriteria
|
||||
weights: Record<WeightingKey, number>
|
||||
parseResult: ParseNeedResult
|
||||
needTitle: string
|
||||
overallConfidence: number
|
||||
isSaving: boolean
|
||||
onNeedTitleChange: (t: string) => void
|
||||
onBack: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
export function AISearchSavePreview({
|
||||
criteria,
|
||||
weights,
|
||||
parseResult,
|
||||
needTitle,
|
||||
overallConfidence,
|
||||
isSaving,
|
||||
onNeedTitleChange,
|
||||
onBack,
|
||||
onSave,
|
||||
}: Props) {
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
Dieses Suchprofil wird als aktiver Bedarf gespeichert und erscheint automatisch im Match Center der Verwaltung.
|
||||
</Alert>
|
||||
<NeedCardPreview
|
||||
criteria={criteria}
|
||||
weights={weights}
|
||||
confidenceByField={parseResult.confidenceByField}
|
||||
missingFields={parseResult.missingFields}
|
||||
needTitle={needTitle}
|
||||
onNeedTitleChange={onNeedTitleChange}
|
||||
/>
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
|
||||
<Button variant="outlined" onClick={onBack} disabled={isSaving}>
|
||||
← Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
endIcon={isSaving ? <CircularProgress size={16} color="inherit" /> : <Save size={16} />}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
|
||||
>
|
||||
{isSaving
|
||||
? 'Wird gespeichert…'
|
||||
: overallConfidence < 0.6
|
||||
? 'Als Entwurf speichern'
|
||||
: 'Suchprofil speichern & Matching starten'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Chip, Typography } from '@mui/material'
|
||||
import { Kanban } from 'lucide-react'
|
||||
import { INQUIRY_STATUS_META, DS_COLORS } from '../../lib/ds'
|
||||
import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
|
||||
import type { Inquiry } from '../../domain/inquiry'
|
||||
|
||||
interface AnfragenInquiryItemProps {
|
||||
@@ -14,28 +14,29 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
|
||||
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
|
||||
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
|
||||
const lastMsg = inq.thread[inq.thread.length - 1]
|
||||
const unreadColor = cfg?.fg ?? DS_TEXT.danger
|
||||
|
||||
return (
|
||||
<Box onClick={() => onSelect(inq.id)} sx={{
|
||||
px: 2, py: 1.5,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
borderBottom: `1px solid ${DS_BORDER.muted}`,
|
||||
cursor: 'pointer',
|
||||
bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : !inq.isRead ? 'rgba(220,38,38,0.03)' : 'transparent',
|
||||
borderLeft: isSelected ? '3px solid' : '3px solid transparent',
|
||||
borderLeftColor: isSelected ? 'primary.main' : 'transparent',
|
||||
'&:hover': { bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : '#f8fafc' },
|
||||
'&:hover': { bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : DS_BG.page },
|
||||
transition: 'background-color 0.1s ease',
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.375 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
||||
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: cfg?.fg ?? '#dc2626', flexShrink: 0 }} />}
|
||||
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />}
|
||||
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
|
||||
{inq.tenantName}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
||||
{inq.unreadCount > 0 && (
|
||||
<Box sx={{ width: 18, height: 18, borderRadius: '50%', bgcolor: cfg?.fg ?? '#dc2626', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Box sx={{ width: 18, height: 18, borderRadius: '50%', bgcolor: unreadColor, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography sx={{ color: 'white', fontSize: '0.6rem', fontWeight: 700 }}>{inq.unreadCount}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -47,7 +48,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
|
||||
{inq.tenantCompany}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: '#475569', display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
|
||||
{inq.subject}
|
||||
</Typography>
|
||||
{lastMsg && (
|
||||
@@ -60,7 +61,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
|
||||
<Chip label={cfg?.label ?? inq.status} size="small"
|
||||
sx={{ height: 16, fontSize: '0.6rem', bgcolor: cfg?.bg, color: cfg?.fg, fontWeight: 600 }} />
|
||||
{inq.matchScore && (
|
||||
<Typography variant="caption" sx={{ color: inq.matchScore >= 80 ? '#1a7a4a' : '#d97706', fontWeight: 700, fontSize: '0.7rem' }}>
|
||||
<Typography variant="caption" sx={{ color: inq.matchScore >= 80 ? DS_TEXT.success : DS_TEXT.warning, fontWeight: 700, fontSize: '0.7rem' }}>
|
||||
{inq.matchScore}%
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { Bot, Building2, FileText } from 'lucide-react'
|
||||
import { DS_COLORS } from '../../lib/ds'
|
||||
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
|
||||
import type { InquiryMessage } from '../../domain/inquiry'
|
||||
|
||||
export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
||||
@@ -12,8 +12,8 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: isOwnMessage ? 'flex-end' : 'flex-start', mb: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.375 }}>
|
||||
{!isOwnMessage && isAI && <Bot size={11} color="#7c3aed" />}
|
||||
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color="#64748b" />}
|
||||
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
|
||||
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||
{msg.senderName} · {date} {time}
|
||||
</Typography>
|
||||
@@ -24,14 +24,14 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
||||
px: 2, py: 1.25,
|
||||
borderRadius: isOwnMessage ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
|
||||
bgcolor: isOwnMessage ? 'primary.main' : isAI ? DS_COLORS.futureCard.controlled.headerBg : 'white',
|
||||
border: isOwnMessage ? 'none' : isAI ? `1px solid ${DS_COLORS.futureCard.controlled.border}` : '1px solid #e2e8f0',
|
||||
border: isOwnMessage ? 'none' : isAI ? `1px solid ${DS_COLORS.futureCard.controlled.border}` : `1px solid ${DS_BORDER.default}`,
|
||||
boxShadow: isOwnMessage ? '0 2px 6px rgba(30,58,95,0.2)' : '0 1px 2px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: isOwnMessage ? 'white' : isAI ? DS_COLORS.futureCard.controlled.badgeText : '#1e293b',
|
||||
color: isOwnMessage ? 'white' : isAI ? DS_COLORS.futureCard.controlled.badgeText : DS_TEXT.primary,
|
||||
whiteSpace: 'pre-wrap', lineHeight: 1.65, fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
@@ -45,17 +45,17 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
px: 1.5, py: 0.75,
|
||||
bgcolor: isOwnMessage ? 'rgba(255,255,255,0.12)' : '#f1f5f9',
|
||||
bgcolor: isOwnMessage ? 'rgba(255,255,255,0.12)' : DS_BG.subtle,
|
||||
borderRadius: 1.5, cursor: 'pointer',
|
||||
'&:hover': { bgcolor: isOwnMessage ? 'rgba(255,255,255,0.2)' : '#e2e8f0' },
|
||||
'&:hover': { bgcolor: isOwnMessage ? 'rgba(255,255,255,0.2)' : DS_BG.muted },
|
||||
}}
|
||||
>
|
||||
<FileText size={13} color={isOwnMessage ? 'rgba(255,255,255,0.75)' : '#64748b'} />
|
||||
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.9)' : '#475569', flex: 1 }} noWrap>
|
||||
<FileText size={13} color={isOwnMessage ? 'rgba(255,255,255,0.75)' : DS_TEXT.muted} />
|
||||
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.9)' : DS_TEXT.secondary, flex: 1 }} noWrap>
|
||||
{att.fileName}
|
||||
</Typography>
|
||||
{att.fileSize && (
|
||||
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.5)' : '#94a3b8', flexShrink: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.5)' : DS_TEXT.disabled, flexShrink: 0 }}>
|
||||
{att.fileSize < 1024 * 1024
|
||||
? `${Math.round(att.fileSize / 1024)} KB`
|
||||
: `${(att.fileSize / 1024 / 1024).toFixed(1)} MB`}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||
|
||||
interface Props {
|
||||
criteria: ParsedNeedCriteria
|
||||
onChange: (c: ParsedNeedCriteria) => void
|
||||
}
|
||||
|
||||
export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props) {
|
||||
return (
|
||||
<Accordion disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', borderRadius: 1, mt: 1, '&:before': { display: 'none' } }}>
|
||||
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 36, px: 1.5, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b' }}>Erweiterte Anforderungen</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 1.5, pt: 0, pb: 1.5 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.5, mb: 1.5 }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox size="small" checked={c.requireGroundFloor ?? false} onChange={e => set({ ...c, requireGroundFloor: e.target.checked || undefined })} />}
|
||||
label={<Typography variant="caption">Erdgeschoss erforderlich</Typography>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox size="small" checked={c.requireAirConditioning ?? false} onChange={e => set({ ...c, requireAirConditioning: e.target.checked || undefined })} />}
|
||||
label={<Typography variant="caption">Klimaanlage erforderlich</Typography>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox size="small" checked={c.requireLoadingDock ?? false} onChange={e => set({ ...c, requireLoadingDock: e.target.checked || undefined })} />}
|
||||
label={<Typography variant="caption">Laderampe erforderlich</Typography>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox size="small" checked={c.requireBarrierFree ?? false} onChange={e => set({ ...c, requireBarrierFree: e.target.checked || undefined })} />}
|
||||
label={<Typography variant="caption">Barrierefrei erforderlich</Typography>}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Parkplätze</Typography>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth placeholder="0"
|
||||
value={c.requiredParkingMin ?? ''}
|
||||
onChange={e => set({ ...c, requiredParkingMin: parseInt(e.target.value) || undefined })}
|
||||
slotProps={{ htmlInput: { min: 0, max: 100 } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Ausbaustandard</Typography>
|
||||
<TextField
|
||||
select size="small" fullWidth
|
||||
value={c.requiredFitOut ?? ''}
|
||||
onChange={e => set({ ...c, requiredFitOut: (e.target.value as 'BASIC' | 'FULL' | 'PREMIUM') || undefined })}
|
||||
>
|
||||
<MenuItem value="">Kein Mindeststandard</MenuItem>
|
||||
<MenuItem value="BASIC">Basisausbau</MenuItem>
|
||||
<MenuItem value="FULL">Vollausbau</MenuItem>
|
||||
<MenuItem value="PREMIUM">Premiumausbau</MenuItem>
|
||||
</TextField>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Deckenhöhe (m)</Typography>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth placeholder="z.B. 6"
|
||||
value={c.minCeilingHeightM ?? ''}
|
||||
onChange={e => set({ ...c, minCeilingHeightM: parseFloat(e.target.value) || undefined })}
|
||||
slotProps={{ htmlInput: { min: 2, max: 20, step: 0.5 } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Vertragslaufzeit (Monate)</Typography>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth placeholder="z.B. 36"
|
||||
value={c.minContractDurationMonths ?? ''}
|
||||
onChange={e => set({ ...c, minContractDurationMonths: parseInt(e.target.value) || undefined })}
|
||||
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { Accordion, AccordionDetails, AccordionSummary, Box, Card, Checkbox, Chip, FormControlLabel, MenuItem, Stack, TextField, Typography } from '@mui/material'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
|
||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||
import { AssetType } from '../../domain/enums'
|
||||
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
|
||||
|
||||
interface Props {
|
||||
criteria: ParsedNeedCriteria
|
||||
@@ -220,77 +220,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Extended requirements */}
|
||||
<Accordion disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', borderRadius: 1, mt: 1, '&:before': { display: 'none' } }}>
|
||||
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 36, px: 1.5, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b' }}>Erweiterte Anforderungen</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 1.5, pt: 0, pb: 1.5 }}>
|
||||
{/* Boolean requirement checkboxes — 2×2 grid */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.5, mb: 1.5 }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox size="small" checked={c.requireGroundFloor ?? false} onChange={e => set({ ...c, requireGroundFloor: e.target.checked || undefined })} />}
|
||||
label={<Typography variant="caption">Erdgeschoss erforderlich</Typography>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox size="small" checked={c.requireAirConditioning ?? false} onChange={e => set({ ...c, requireAirConditioning: e.target.checked || undefined })} />}
|
||||
label={<Typography variant="caption">Klimaanlage erforderlich</Typography>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox size="small" checked={c.requireLoadingDock ?? false} onChange={e => set({ ...c, requireLoadingDock: e.target.checked || undefined })} />}
|
||||
label={<Typography variant="caption">Laderampe erforderlich</Typography>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox size="small" checked={c.requireBarrierFree ?? false} onChange={e => set({ ...c, requireBarrierFree: e.target.checked || undefined })} />}
|
||||
label={<Typography variant="caption">Barrierefrei erforderlich</Typography>}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Numeric / select fields — 2×2 grid */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Parkplätze</Typography>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth placeholder="0"
|
||||
value={c.requiredParkingMin ?? ''}
|
||||
onChange={e => set({ ...c, requiredParkingMin: parseInt(e.target.value) || undefined })}
|
||||
slotProps={{ htmlInput: { min: 0, max: 100 } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Ausbaustandard</Typography>
|
||||
<TextField
|
||||
select size="small" fullWidth
|
||||
value={c.requiredFitOut ?? ''}
|
||||
onChange={e => set({ ...c, requiredFitOut: (e.target.value as 'BASIC' | 'FULL' | 'PREMIUM') || undefined })}
|
||||
>
|
||||
<MenuItem value="">Kein Mindeststandard</MenuItem>
|
||||
<MenuItem value="BASIC">Basisausbau</MenuItem>
|
||||
<MenuItem value="FULL">Vollausbau</MenuItem>
|
||||
<MenuItem value="PREMIUM">Premiumausbau</MenuItem>
|
||||
</TextField>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Deckenhöhe (m)</Typography>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth placeholder="z.B. 6"
|
||||
value={c.minCeilingHeightM ?? ''}
|
||||
onChange={e => set({ ...c, minCeilingHeightM: parseFloat(e.target.value) || undefined })}
|
||||
slotProps={{ htmlInput: { min: 2, max: 20, step: 0.5 } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Vertragslaufzeit (Monate)</Typography>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth placeholder="z.B. 36"
|
||||
value={c.minContractDurationMonths ?? ''}
|
||||
onChange={e => set({ ...c, minContractDurationMonths: parseInt(e.target.value) || undefined })}
|
||||
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<NeedExtendedRequirements criteria={c} onChange={set} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { FutureSignal } from '../../domain/futureSignal'
|
||||
import { SIGNAL_TYPE_LABELS, SIGNAL_ACTION, SOURCE_META } from './futureAvailabilityConstants'
|
||||
import { CREDIBILITY_META, SENSITIVITY_META, RISK_META, ageLabel } from './futureAvailabilityContextUtils'
|
||||
import { SignalSourcesSection } from './SignalSourcesSection'
|
||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_PRE_MARKET, DS_MARKET_SIGNAL, DS_BORDER } from '../../lib/ds'
|
||||
|
||||
interface Props {
|
||||
match: Match
|
||||
@@ -35,81 +36,85 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
||||
...(signal.evidence?.sourceUrls ?? []),
|
||||
]
|
||||
|
||||
const probBarColor = probPct >= 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b'
|
||||
const probBarColor = probPct >= 70 ? DS_TEXT.success : probPct >= 50 ? DS_TEXT.warning : DS_TEXT.error
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
|
||||
{/* ── Header ──────────────────────────────────────────────────────────── */}
|
||||
{/* ── Header ── */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75, flexWrap: 'wrap' }}>
|
||||
<Zap size={16} color="#7c3aed" />
|
||||
<Zap size={16} color={DS_PRE_MARKET.accent} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Future Availability Signal</Typography>
|
||||
{signal.isVerified && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: '#f0fdf4', border: '1px solid #86efac', borderRadius: 1, px: 0.75, py: 0.2 }}>
|
||||
<ShieldCheck size={11} color="#1a7a4a" />
|
||||
<Typography sx={{ fontSize: '0.68rem', color: '#1a7a4a', fontWeight: 700 }}>Verifiziert</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`, borderRadius: 1, px: 0.75, py: 0.2 }}>
|
||||
<ShieldCheck size={11} color={DS_TEXT.success} />
|
||||
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.success, fontWeight: 700 }}>Verifiziert</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 0.5, color: '#94a3b8' }}>
|
||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.disabled }}>
|
||||
<Clock size={12} />
|
||||
<Typography variant="caption" color="text.secondary">{ageLabel(signal.createdAt)}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{signal.title && (
|
||||
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: '#1e293b', mb: 2, lineHeight: 1.4 }}>
|
||||
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: DS_TEXT.primary, mb: 2, lineHeight: 1.4 }}>
|
||||
{signal.title}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* ── 1. KI-Zusammenfassung ─────────────────────────────────────────── */}
|
||||
{/* ── 1. KI-Zusammenfassung ── */}
|
||||
{signal.aiSummary && (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}>
|
||||
<Sparkles size={14} color="#7c3aed" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#4c1d95', fontSize: '0.8125rem' }}>KI-Zusammenfassung</Typography>
|
||||
<Sparkles size={14} color={DS_PRE_MARKET.accent} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DS_TEXT.signalDark, fontSize: '0.8125rem' }}>KI-Zusammenfassung</Typography>
|
||||
</Box>
|
||||
<Box sx={{ bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1.5, p: 1.75 }}>
|
||||
<Typography variant="body2" sx={{ color: '#1e1b4b', lineHeight: 1.7 }}>
|
||||
<Box sx={{ bgcolor: DS_SURFACE.purple.bg, border: `1px solid ${DS_SURFACE.purple.border}`, borderRadius: 1.5, p: 1.75 }}>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.7 }}>
|
||||
{signal.aiSummary}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── 2. Strategische Einschätzung ─────────────────────────────────── */}
|
||||
{/* ── 2. Strategische Einschätzung ── */}
|
||||
{signal.strategicInterpretation && (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}>
|
||||
<TrendingUp size={14} color="#0369a1" />
|
||||
<TrendingUp size={14} color={DS_TEXT.info} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Strategische Einschätzung</Typography>
|
||||
</Box>
|
||||
<Box sx={{ bgcolor: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 1.5, p: 1.75 }}>
|
||||
<Typography variant="body2" sx={{ color: '#0c4a6e', lineHeight: 1.7 }}>
|
||||
<Box sx={{ bgcolor: DS_SURFACE.info.bg, border: `1px solid ${DS_SURFACE.info.border}`, borderRadius: 1.5, p: 1.75 }}>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.infoDark, lineHeight: 1.7 }}>
|
||||
{signal.strategicInterpretation}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── 3. Handlungsempfehlung ───────────────────────────────────────── */}
|
||||
{/* ── 3. Handlungsempfehlung ── */}
|
||||
{action && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, bgcolor: action.urgency === 'high' ? '#fff7ed' : '#f8fafc', border: `1px solid ${action.urgency === 'high' ? '#fed7aa' : '#e2e8f0'}`, borderRadius: 1.5, px: 1.5, py: 1.25, mb: 2.5 }}>
|
||||
<Zap size={14} color={action.urgency === 'high' ? '#c2410c' : '#64748b'} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 1,
|
||||
bgcolor: action.urgency === 'high' ? DS_SURFACE.orange.bg : DS_BG.page,
|
||||
border: `1px solid ${action.urgency === 'high' ? DS_SURFACE.orange.border : DS_BORDER.default}`,
|
||||
borderRadius: 1.5, px: 1.5, py: 1.25, mb: 2.5,
|
||||
}}>
|
||||
<Zap size={14} color={action.urgency === 'high' ? DS_TEXT.error : DS_TEXT.muted} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: action.urgency === 'high' ? '#9a3412' : '#475569', mb: 0.25 }}>Empfohlene Aktion</Typography>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: action.urgency === 'high' ? '#7c2d12' : '#334155', lineHeight: 1.5 }}>{action.label}</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: action.urgency === 'high' ? DS_TEXT.warningDark : DS_TEXT.secondary, mb: 0.25 }}>Empfohlene Aktion</Typography>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: action.urgency === 'high' ? DS_TEXT.error : DS_TEXT.primary, lineHeight: 1.5 }}>{action.label}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* ── 4. Signal-Kenndaten ──────────────────────────────────────────── */}
|
||||
{/* ── 4. Signal-Kenndaten ── */}
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.25, fontSize: '0.8125rem' }}>Signal-Kenndaten</Typography>
|
||||
|
||||
{/* Probability bar */}
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.4 }}>
|
||||
<Typography variant="caption" color="text.secondary">Eintretenswahrscheinlichkeit</Typography>
|
||||
@@ -119,7 +124,7 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
||||
variant="determinate"
|
||||
value={probPct}
|
||||
sx={{
|
||||
height: 6, borderRadius: 3, bgcolor: '#f1f5f9',
|
||||
height: 6, borderRadius: 3, bgcolor: DS_BG.subtle,
|
||||
'& .MuiLinearProgress-bar': { bgcolor: probBarColor, borderRadius: 3 },
|
||||
}}
|
||||
/>
|
||||
@@ -129,13 +134,13 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
||||
{signal.signalType && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Signaltyp</Typography>
|
||||
<Chip label={SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType} size="small" sx={{ bgcolor: '#ede9fe', color: '#6d28d9', fontWeight: 600 }} />
|
||||
<Chip label={SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType} size="small" sx={{ bgcolor: DS_PRE_MARKET.badgeBg, color: DS_PRE_MARKET.accentHover, fontWeight: 600 }} />
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Zeithorizont</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Calendar size={13} color="#64748b" />
|
||||
<Calendar size={13} color={DS_TEXT.muted} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -156,22 +161,22 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* ── 5. Erkannte Marktindikatoren ──────────────────────────────────── */}
|
||||
{/* ── 5. Erkannte Marktindikatoren ── */}
|
||||
{signal.marketIndicators && signal.marketIndicators.length > 0 && (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Erkannte Marktindikatoren</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{signal.marketIndicators.map((indicator, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.875 }}>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#1d4ed8', mt: '6px', flexShrink: 0 }} />
|
||||
<Typography variant="body2" sx={{ color: '#334155', lineHeight: 1.55 }}>{indicator}</Typography>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: DS_MARKET_SIGNAL.accent, mt: '6px', flexShrink: 0 }} />
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.55 }}>{indicator}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── 6. Transparenz (Bestätigt / Nicht bestätigt) ─────────────────── */}
|
||||
{/* ── 6. Transparenz ── */}
|
||||
{((signal.confirmedFacts && signal.confirmedFacts.length > 0) ||
|
||||
(signal.unconfirmedFacts && signal.unconfirmedFacts.length > 0)) && (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
@@ -179,14 +184,14 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
|
||||
{(signal.confirmedFacts ?? []).map((fact, i) => (
|
||||
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||
<CheckCircle2 size={14} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 1 }} />
|
||||
<Typography variant="body2" sx={{ color: '#1a7a4a', fontWeight: 500, lineHeight: 1.45 }}>{fact}</Typography>
|
||||
<CheckCircle2 size={14} color={DS_TEXT.success} style={{ flexShrink: 0, marginTop: 1 }} />
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.success, fontWeight: 500, lineHeight: 1.45 }}>{fact}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{(signal.unconfirmedFacts ?? []).map((fact, i) => (
|
||||
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||
<AlertTriangle size={14} color="#d97706" style={{ flexShrink: 0, marginTop: 1 }} />
|
||||
<Typography variant="body2" sx={{ color: '#92400e', lineHeight: 1.45 }}>{fact}</Typography>
|
||||
<AlertTriangle size={14} color={DS_TEXT.warning} style={{ flexShrink: 0, marginTop: 1 }} />
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.warningDark, lineHeight: 1.45 }}>{fact}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
@@ -195,7 +200,7 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* ── 7. Quellen & Belege ───────────────────────────────────────────── */}
|
||||
{/* ── 7. Quellen & Belege ── */}
|
||||
<SignalSourcesSection
|
||||
sourceMeta={sourceMeta}
|
||||
credMeta={credMeta}
|
||||
@@ -204,7 +209,7 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
||||
signal={signal}
|
||||
/>
|
||||
|
||||
{/* ── Footer ──────────────────────────────────────────────────────────── */}
|
||||
{/* ── Footer ── */}
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25, flexWrap: 'wrap' }}>
|
||||
@@ -213,12 +218,12 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
||||
{signal.verifiedBy && (
|
||||
<>
|
||||
<Typography variant="caption" color="text.secondary">Verifiziert von:</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a' }}>{signal.verifiedBy}</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.success }}>{signal.verifiedBy}</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.55, bgcolor: '#f8fafc', borderRadius: 1, px: 1.25, py: 1, fontStyle: 'italic' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.55, bgcolor: DS_BG.page, borderRadius: 1, px: 1.25, py: 1, fontStyle: 'italic' }}>
|
||||
{signal.disclaimer}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp }
|
||||
import { useMatchDetail } from '../../hooks/useMatches'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
|
||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds'
|
||||
|
||||
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
||||
|
||||
@@ -30,7 +31,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
||||
{/* Preis */}
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Tag size={15} color="#374151" />
|
||||
<Tag size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography>
|
||||
</Box>
|
||||
<KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.–`} />
|
||||
@@ -44,7 +45,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
||||
{/* Hauptangaben */}
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Info size={15} color="#374151" />
|
||||
<Info size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography>
|
||||
</Box>
|
||||
<KeyFactRow label="Verfügbarkeit" value={property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Auf Anfrage'} />
|
||||
@@ -66,27 +67,27 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
||||
{property.softFactors && (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<TrendingUp size={15} color="#374151" />
|
||||
<TrendingUp size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{property.softFactors.publicTransportMinutes != null && (
|
||||
<Chip size="small" icon={<Train size={11} />} label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }} />
|
||||
<Chip size="small" icon={<Train size={11} />} label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} sx={{ bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, border: `1px solid ${DS_SURFACE.blue.border}`, fontWeight: 500 }} />
|
||||
)}
|
||||
{property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
|
||||
<Chip size="small" label={`${property.softFactors.parkingSpots} Parkplätze`} sx={{ bgcolor: '#f8fafc', color: '#374151', border: '1px solid #e2e8f0', fontWeight: 500 }} />
|
||||
<Chip size="small" label={`${property.softFactors.parkingSpots} Parkplätze`} sx={{ bgcolor: DS_BG.page, color: DS_TEXT.primary, border: `1px solid ${DS_BORDER.default}`, fontWeight: 500 }} />
|
||||
)}
|
||||
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
|
||||
<Chip size="small" label="Prestigestandort" sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }} />
|
||||
<Chip size="small" label="Prestigestandort" sx={{ bgcolor: DS_SURFACE.warning.bg, color: DS_TEXT.warningDark, border: `1px solid ${DS_SURFACE.warning.border}`, fontWeight: 500 }} />
|
||||
)}
|
||||
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
|
||||
<Chip size="small" label="Hohe Sichtbarkeit" sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }} />
|
||||
<Chip size="small" label="Hohe Sichtbarkeit" sx={{ bgcolor: DS_SURFACE.warning.bg, color: DS_TEXT.warningDark, border: `1px solid ${DS_SURFACE.warning.border}`, fontWeight: 500 }} />
|
||||
)}
|
||||
{property.softFactors.passerbyFrequency && (
|
||||
<Chip size="small" label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`} sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontWeight: 500 }} />
|
||||
<Chip size="small" label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`} sx={{ bgcolor: DS_SURFACE.success.bg, color: DS_TEXT.success, border: `1px solid ${DS_SURFACE.success.border}`, fontWeight: 500 }} />
|
||||
)}
|
||||
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
|
||||
<Chip size="small" label={`Talentindex: ${property.softFactors.talentAccess}`} sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }} />
|
||||
<Chip size="small" label={`Talentindex: ${property.softFactors.talentAccess}`} sx={{ bgcolor: DS_SURFACE.purple.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.purple.border}`, fontWeight: 500 }} />
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
@@ -96,12 +97,12 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
||||
{property.softFactors?.publicTransportMinutes != null && (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Clock size={15} color="#374151" />
|
||||
<Clock size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}>
|
||||
<Train size={18} color="#1d4ed8" />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: DS_SURFACE.blue.bg, border: `1px solid ${DS_SURFACE.blue.border}`, flexShrink: 0 }}>
|
||||
<Train size={18} color={DS_MARKET_SIGNAL.accent} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{property.softFactors.publicTransportMinutes} Min. zu Fuss</Typography>
|
||||
@@ -118,7 +119,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
||||
{units.length > 0 && (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Layers size={15} color="#374151" />
|
||||
<Layers size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
|
||||
@@ -135,10 +136,10 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
||||
{property.description && (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Building2 size={15} color="#374151" />
|
||||
<Building2 size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.75, color: '#374151', whiteSpace: 'pre-wrap' }}>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.75, color: DS_TEXT.primary, whiteSpace: 'pre-wrap' }}>
|
||||
{property.description}
|
||||
</Typography>
|
||||
</Paper>
|
||||
@@ -147,7 +148,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
||||
{/* Quelle & Referenz */}
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Info size={15} color="#374151" />
|
||||
<Info size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
|
||||
</Box>
|
||||
<KeyFactRow label="Datenquelle" value={sourceLabel} />
|
||||
@@ -158,7 +159,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
||||
)}
|
||||
{property.sourceUrl && (
|
||||
<Box sx={{ mt: 1.25 }}>
|
||||
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }}>
|
||||
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
|
||||
Zum Originalinserat
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
SOURCE_LABELS,
|
||||
UnitRow,
|
||||
} from './MatchDetailPropertyDetails'
|
||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
||||
|
||||
interface PropertyDetailPublicSectionsProps {
|
||||
property: Property
|
||||
@@ -57,7 +58,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
||||
{/* ── Preis ── */}
|
||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Tag size={15} color="#374151" />
|
||||
<Tag size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography>
|
||||
</Box>
|
||||
<KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.–`} />
|
||||
@@ -74,7 +75,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
||||
{/* ── Hauptangaben ── */}
|
||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Info size={15} color="#374151" />
|
||||
<Info size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography>
|
||||
</Box>
|
||||
<KeyFactRow
|
||||
@@ -127,7 +128,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
||||
{property.softFactors && (
|
||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<TrendingUp size={15} color="#374151" />
|
||||
<TrendingUp size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
@@ -136,49 +137,49 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
||||
size="small"
|
||||
icon={<Train size={11} />}
|
||||
label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`}
|
||||
sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }}
|
||||
sx={{ bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, border: `1px solid ${DS_SURFACE.blue.border}`, fontWeight: 500 }}
|
||||
/>
|
||||
)}
|
||||
{property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${property.softFactors.parkingSpots} Parkplätze`}
|
||||
sx={{ bgcolor: '#f8fafc', color: '#374151', border: '1px solid #e2e8f0', fontWeight: 500 }}
|
||||
sx={{ bgcolor: DS_BG.page, color: DS_TEXT.primary, border: `1px solid ${DS_BORDER.default}`, fontWeight: 500 }}
|
||||
/>
|
||||
)}
|
||||
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Prestigestandort"
|
||||
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }}
|
||||
sx={{ bgcolor: DS_SURFACE.warning.bg, color: DS_TEXT.warningDark, border: `1px solid ${DS_SURFACE.warning.border}`, fontWeight: 500 }}
|
||||
/>
|
||||
)}
|
||||
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Hohe Sichtbarkeit"
|
||||
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }}
|
||||
sx={{ bgcolor: DS_SURFACE.warning.bg, color: DS_TEXT.warningDark, border: `1px solid ${DS_SURFACE.warning.border}`, fontWeight: 500 }}
|
||||
/>
|
||||
)}
|
||||
{property.softFactors.passerbyFrequency && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`}
|
||||
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontWeight: 500 }}
|
||||
sx={{ bgcolor: DS_SURFACE.success.bg, color: DS_TEXT.success, border: `1px solid ${DS_SURFACE.success.border}`, fontWeight: 500 }}
|
||||
/>
|
||||
)}
|
||||
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Hoher Talentzugang"
|
||||
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }}
|
||||
sx={{ bgcolor: DS_SURFACE.purple.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.purple.border}`, fontWeight: 500 }}
|
||||
/>
|
||||
)}
|
||||
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Talentindex: ${property.softFactors.talentAccess}`}
|
||||
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }}
|
||||
sx={{ bgcolor: DS_SURFACE.purple.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.purple.border}`, fontWeight: 500 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -189,12 +190,12 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
||||
{property.softFactors?.publicTransportMinutes != null && (
|
||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Clock size={15} color="#374151" />
|
||||
<Clock size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}>
|
||||
<Train size={18} color="#1d4ed8" />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: DS_SURFACE.blue.bg, border: `1px solid ${DS_SURFACE.blue.border}`, flexShrink: 0 }}>
|
||||
<Train size={18} color={DS_MARKET_SIGNAL.accent} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
@@ -220,7 +221,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
||||
{(property.units ?? []).length > 0 && (
|
||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Layers size={15} color="#374151" />
|
||||
<Layers size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
|
||||
@@ -241,10 +242,10 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
||||
{property.description && (
|
||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Building2 size={15} color="#374151" />
|
||||
<Building2 size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.75, color: '#374151', whiteSpace: 'pre-wrap' }}>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.75, color: DS_TEXT.primary, whiteSpace: 'pre-wrap' }}>
|
||||
{property.description}
|
||||
</Typography>
|
||||
</Paper>
|
||||
@@ -253,7 +254,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
||||
{/* ── Quelle & Referenz ── */}
|
||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Info size={15} color="#374151" />
|
||||
<Info size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
|
||||
</Box>
|
||||
<KeyFactRow label="Datenquelle" value={sourceLabel} />
|
||||
@@ -278,7 +279,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
||||
href={property.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }}
|
||||
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}
|
||||
>
|
||||
Zum Originalinserat
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Box, Card, TextField, Typography } from '@mui/material'
|
||||
|
||||
interface Props {
|
||||
street: string
|
||||
onStreetChange: (v: string) => void
|
||||
houseNumber: string
|
||||
onHouseNumberChange: (v: string) => void
|
||||
postalCode: string
|
||||
onPostalCodeChange: (v: string) => void
|
||||
city: string
|
||||
onCityChange: (v: string) => void
|
||||
}
|
||||
|
||||
export function AddressSection({
|
||||
street, onStreetChange,
|
||||
houseNumber, onHouseNumberChange,
|
||||
postalCode, onPostalCodeChange,
|
||||
city, onCityChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Adresse</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '3fr 1fr', gap: 2, mb: 2 }}>
|
||||
<TextField
|
||||
label="Strasse" value={street}
|
||||
onChange={e => onStreetChange(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Nr." value={houseNumber}
|
||||
onChange={e => onHouseNumberChange(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
|
||||
<TextField
|
||||
label="PLZ" value={postalCode}
|
||||
onChange={e => onPostalCodeChange(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Ort" value={city}
|
||||
onChange={e => onCityChange(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Alert, Box, Button, Card, CircularProgress, TextField, Typography } from '@mui/material'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
onTextChange: (v: string) => void
|
||||
onParse: () => void
|
||||
parsing: boolean
|
||||
applied: boolean
|
||||
}
|
||||
|
||||
export function AiAssistCard({ text, onTextChange, onParse, parsing, applied }: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3, border: `1px solid ${DS_SURFACE.indigo.border}`, bgcolor: DS_SURFACE.indigo.bg }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Sparkles size={16} color={DS_TEXT.brand} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DS_TEXT.brand }}>
|
||||
KI-Hilfe — Formular automatisch ausfüllen
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
|
||||
Beschreiben Sie die Fläche in eigenen Worten — die KI füllt die Felder automatisch aus.
|
||||
</Typography>
|
||||
<TextField
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="z.B.: Bürofläche 450 m² im Zentrum Zürich, 3. OG, CHF 280/m²/Jahr, sehr gute ÖV-Anbindung, 4 Parkplätze, Vollausbau, verfügbar ab Juli 2025"
|
||||
value={text}
|
||||
onChange={e => onTextChange(e.target.value)}
|
||||
sx={{ mb: 1.5, bgcolor: 'white' }}
|
||||
/>
|
||||
{applied && (
|
||||
<Alert severity="success" sx={{ mb: 1.5, py: 0.5 }}>
|
||||
Felder wurden automatisch ausgefüllt — bitte überprüfen und ergänzen.
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={parsing ? <CircularProgress size={13} color="inherit" /> : <Sparkles size={13} />}
|
||||
onClick={onParse}
|
||||
disabled={!text.trim() || parsing}
|
||||
sx={{ bgcolor: DS_TEXT.brand, '&:hover': { bgcolor: DS_BORDER.strong }, textTransform: 'none' }}
|
||||
>
|
||||
{parsing ? 'Analysiert…' : 'KI analysieren'}
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { ASSET_TYPE_LABELS } from '../../pages/supply/newListingConstants'
|
||||
|
||||
interface Props {
|
||||
assetType: string
|
||||
onAssetTypeChange: (v: string) => void
|
||||
areaSqm: string
|
||||
onAreaSqmChange: (v: string) => void
|
||||
rentPerSqm: string
|
||||
onRentPerSqmChange: (v: string) => void
|
||||
availableFrom: string
|
||||
onAvailableFromChange: (v: string) => void
|
||||
description: string
|
||||
onDescriptionChange: (v: string) => void
|
||||
}
|
||||
|
||||
export function AreaDetailsSection({
|
||||
assetType, onAssetTypeChange,
|
||||
areaSqm, onAreaSqmChange,
|
||||
rentPerSqm, onRentPerSqmChange,
|
||||
availableFrom, onAvailableFromChange,
|
||||
description, onDescriptionChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Flächendetails</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||
<TextField
|
||||
select label="Flächentyp" value={assetType}
|
||||
onChange={e => onAssetTypeChange(e.target.value)} size="small" fullWidth
|
||||
>
|
||||
{Object.entries(ASSET_TYPE_LABELS).map(([v, l]) => (
|
||||
<MenuItem key={v} value={v}>{l}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
label="Fläche (m²)" value={areaSqm}
|
||||
onChange={e => onAreaSqmChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
|
||||
onChange={e => onRentPerSqmChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Verfügbar ab" value={availableFrom}
|
||||
onChange={e => onAvailableFromChange(e.target.value)}
|
||||
size="small" type="date"
|
||||
slotProps={{ inputLabel: { shrink: true } }} fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Beschreibung (optional)" value={description}
|
||||
onChange={e => onDescriptionChange(e.target.value)}
|
||||
size="small" multiline rows={3} fullWidth sx={{ mt: 2 }}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Box, Card, TextField, Typography } from '@mui/material'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
onNameChange: (v: string) => void
|
||||
email: string
|
||||
onEmailChange: (v: string) => void
|
||||
phone: string
|
||||
onPhoneChange: (v: string) => void
|
||||
}
|
||||
|
||||
export function ContactSection({ name, onNameChange, email, onEmailChange, phone, onPhoneChange }: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Kontakt (optional)</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||
<TextField
|
||||
label="Name" value={name}
|
||||
onChange={e => onNameChange(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Telefon" value={phone}
|
||||
onChange={e => onPhoneChange(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="E-Mail" value={email}
|
||||
onChange={e => onEmailChange(e.target.value)}
|
||||
size="small" type="email" fullWidth sx={{ gridColumn: '1 / -1' }}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Box, Button, Typography } from '@mui/material'
|
||||
import { CheckCircle } from 'lucide-react'
|
||||
import { DS_TEXT } from '../../lib/ds'
|
||||
|
||||
interface Props {
|
||||
onViewListings: () => void
|
||||
onCreateAnother: () => void
|
||||
}
|
||||
|
||||
export function CreatedScreen({ onViewListings, onCreateAnother }: Props) {
|
||||
return (
|
||||
<Box sx={{ maxWidth: 520, mx: 'auto', mt: 8, px: 3, textAlign: 'center' }}>
|
||||
<CheckCircle size={48} color={DS_TEXT.success} style={{ marginBottom: 16 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>Inserat erstellt</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||||
Das Inserat wurde veröffentlicht und ist für passende Suchanfragen sichtbar.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
|
||||
<Button variant="outlined" onClick={onViewListings}>
|
||||
Meine Inserate
|
||||
</Button>
|
||||
<Button variant="contained" sx={{ bgcolor: DS_TEXT.brand, '&:hover': { bgcolor: DS_TEXT.brandDark } }} onClick={onCreateAnother}>
|
||||
Weiteres Inserat
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Box, Button, Card, Chip, TextField, Typography } from '@mui/material'
|
||||
import { ImagePlus } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
images: string[]
|
||||
imageInput: string
|
||||
onImageInputChange: (v: string) => void
|
||||
onAdd: () => void
|
||||
onRemove: (index: number) => void
|
||||
}
|
||||
|
||||
export function ImageUrlSection({ images, imageInput, onImageInputChange, onAdd, onRemove }: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Bilder (optional)</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 1.5 }}>
|
||||
<TextField
|
||||
label="Bild-URL eingeben"
|
||||
value={imageInput}
|
||||
onChange={e => onImageInputChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); onAdd() } }}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="https://…"
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<ImagePlus size={15} />}
|
||||
onClick={onAdd}
|
||||
disabled={!imageInput.trim()}
|
||||
sx={{ whiteSpace: 'nowrap', textTransform: 'none' }}
|
||||
>
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</Box>
|
||||
{images.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{images.map((url, i) => (
|
||||
<Chip
|
||||
key={i}
|
||||
label={url.length > 40 ? url.slice(0, 40) + '…' : url}
|
||||
size="small"
|
||||
onDelete={() => onRemove(i)}
|
||||
sx={{ maxWidth: 300 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { SOFT_FACTORS, LEVEL_OPTIONS } from '../../pages/supply/newListingConstants'
|
||||
|
||||
interface Props {
|
||||
softLevels: Record<string, string>
|
||||
onChange: (key: string, value: string) => void
|
||||
}
|
||||
|
||||
export function SoftFactorsSection({ softLevels, onChange }: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Lage & Ausstrahlung</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Diese Angaben verbessern die Match-Qualität erheblich.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 2 }}>
|
||||
{SOFT_FACTORS.map(({ key, label }) => (
|
||||
<TextField
|
||||
key={key}
|
||||
select
|
||||
label={label}
|
||||
value={softLevels[key] ?? ''}
|
||||
onChange={e => onChange(key, e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
>
|
||||
{LEVEL_OPTIONS.map(o => (
|
||||
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
|
||||
|
||||
interface Props {
|
||||
floor: string
|
||||
onFloorChange: (v: string) => void
|
||||
fitOut: string
|
||||
onFitOutChange: (v: string) => void
|
||||
parking: string
|
||||
onParkingChange: (v: string) => void
|
||||
ceilingHeight: string
|
||||
onCeilingHeightChange: (v: string) => void
|
||||
}
|
||||
|
||||
export function TechnicalDetailsSection({
|
||||
floor, onFloorChange,
|
||||
fitOut, onFitOutChange,
|
||||
parking, onParkingChange,
|
||||
ceilingHeight, onCeilingHeightChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Technische Details (optional)</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 2 }}>
|
||||
<TextField
|
||||
label="Stockwerk" value={floor}
|
||||
onChange={e => onFloorChange(e.target.value)}
|
||||
size="small" type="number" fullWidth placeholder="0 = EG"
|
||||
/>
|
||||
<TextField
|
||||
select label="Ausbaustandard" value={fitOut}
|
||||
onChange={e => onFitOutChange(e.target.value)} size="small" fullWidth
|
||||
>
|
||||
{FIT_OUT_OPTIONS.map(o => (
|
||||
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Parkplätze" value={parking}
|
||||
onChange={e => onParkingChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 0 }} fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Deckenhöhe (m)" value={ceilingHeight}
|
||||
onChange={e => onCeilingHeightChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ step: 0.1, min: 2 }} fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { AiAssistCard } from './AiAssistCard'
|
||||
export { AreaDetailsSection } from './AreaDetailsSection'
|
||||
export { AddressSection } from './AddressSection'
|
||||
export { SoftFactorsSection } from './SoftFactorsSection'
|
||||
export { TechnicalDetailsSection } from './TechnicalDetailsSection'
|
||||
export { ImageUrlSection } from './ImageUrlSection'
|
||||
export { ContactSection } from './ContactSection'
|
||||
export { CreatedScreen } from './CreatedScreen'
|
||||
@@ -1,37 +1,2 @@
|
||||
export type ScoreTier = 'gold' | 'silver' | 'bronze'
|
||||
|
||||
export function getScoreTier(score: number): ScoreTier {
|
||||
if (score >= 90) return 'gold'
|
||||
if (score >= 80) return 'silver'
|
||||
return 'bronze'
|
||||
}
|
||||
|
||||
export const SCORE_THEME = {
|
||||
gold: {
|
||||
gradient: 'linear-gradient(135deg,#f8e642 0%,#d4920e 100%)',
|
||||
border: '#c9900c',
|
||||
glow: 'rgba(212,146,14,0.35)',
|
||||
text: '#7a4f00',
|
||||
label: 'Top Match',
|
||||
cardBorder: '#fbbf24',
|
||||
cardBg: 'linear-gradient(160deg,#fffbeb,#fef3c7)',
|
||||
},
|
||||
silver: {
|
||||
gradient: 'linear-gradient(135deg,#f1f5f9 0%,#cbd5e1 100%)',
|
||||
border: '#94a3b8',
|
||||
glow: 'rgba(148,163,184,0.30)',
|
||||
text: '#334155',
|
||||
label: 'Starkes Match',
|
||||
cardBorder: '#cbd5e1',
|
||||
cardBg: 'linear-gradient(160deg,#f8fafc,#f1f5f9)',
|
||||
},
|
||||
bronze: {
|
||||
gradient: 'linear-gradient(135deg,#fde8c8 0%,#d4956a 100%)',
|
||||
border: '#c07a46',
|
||||
glow: 'rgba(192,122,70,0.25)',
|
||||
text: '#7c3d0c',
|
||||
label: 'Gutes Match',
|
||||
cardBorder: '#f5d0a9',
|
||||
cardBg: 'linear-gradient(160deg,#fdf6f0,#fef3e8)',
|
||||
},
|
||||
} as const
|
||||
export { getScoreTier, SCORE_THEME } from '../../lib/scoreTheme'
|
||||
export type { ScoreTier } from '../../lib/scoreTheme'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Card, Chip, Stack, Typography } from '@mui/material'
|
||||
import { MapPin, Ruler, Wallet, Calendar, CheckCircle } from 'lucide-react'
|
||||
import type { PropertyNeedMatch } from '../../domain/match'
|
||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
||||
|
||||
interface Props {
|
||||
match: PropertyNeedMatch
|
||||
@@ -9,9 +10,9 @@ interface Props {
|
||||
|
||||
function ScoreBadge({ score }: { score: number }) {
|
||||
const isGold = score >= 90
|
||||
const bg = isGold ? '#fef3c7' : '#f1f5f9'
|
||||
const color = isGold ? '#d97706' : '#64748b'
|
||||
const borderColor = isGold ? '#fde68a' : '#e2e8f0'
|
||||
const bg = isGold ? DS_SURFACE.warning.bg : DS_BG.subtle
|
||||
const color = isGold ? DS_TEXT.warning : DS_TEXT.muted
|
||||
const borderColor = isGold ? DS_SURFACE.warning.border : DS_BORDER.default
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -37,9 +38,9 @@ function ScoreBadge({ score }: { score: number }) {
|
||||
function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Box sx={{ color: '#94a3b8', display: 'flex', alignItems: 'center' }}>{icon}</Box>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', minWidth: 70 }}>{label}</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
|
||||
<Box sx={{ color: DS_TEXT.disabled, display: 'flex', alignItems: 'center' }}>{icon}</Box>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, minWidth: 70 }}>{label}</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.primary, fontWeight: 500 }}>{value}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -52,10 +53,10 @@ export function NeedMatchCard({ match, onClick }: Props) {
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 1.5,
|
||||
border: '1px solid #e2e8f0',
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
borderRadius: 1.5,
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
'&:hover': onClick ? { borderColor: '#1e3a5f', bgcolor: '#f8fafc', boxShadow: '0 2px 8px rgba(30,58,95,0.08)' } : { borderColor: '#cbd5e1', bgcolor: '#fafafa' },
|
||||
'&:hover': onClick ? { borderColor: DS_TEXT.brand, bgcolor: DS_BG.page, boxShadow: '0 2px 8px rgba(30,58,95,0.08)' } : { borderColor: DS_BORDER.strong, bgcolor: DS_BG.page },
|
||||
transition: 'border-color 0.15s, background-color 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
>
|
||||
@@ -64,7 +65,7 @@ export function NeedMatchCard({ match, onClick }: Props) {
|
||||
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary }}>
|
||||
{match.company}
|
||||
</Typography>
|
||||
<Chip
|
||||
@@ -73,10 +74,10 @@ export function NeedMatchCard({ match, onClick }: Props) {
|
||||
sx={{
|
||||
fontSize: '0.65rem',
|
||||
height: 18,
|
||||
bgcolor: match.score >= 90 ? '#fef3c7' : '#f1f5f9',
|
||||
color: match.score >= 90 ? '#92400e' : '#475569',
|
||||
bgcolor: match.score >= 90 ? DS_SURFACE.warning.bg : DS_BG.subtle,
|
||||
color: match.score >= 90 ? DS_TEXT.warningDark : DS_TEXT.secondary,
|
||||
border: '1px solid',
|
||||
borderColor: match.score >= 90 ? '#fde68a' : '#e2e8f0',
|
||||
borderColor: match.score >= 90 ? DS_SURFACE.warning.border : DS_BORDER.default,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -96,8 +97,8 @@ export function NeedMatchCard({ match, onClick }: Props) {
|
||||
<Box sx={{ mb: 1 }}>
|
||||
{match.matchHighlights.slice(0, 2).map((h, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mb: 0.25 }}>
|
||||
<CheckCircle size={11} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.4 }}>{h}</Typography>
|
||||
<CheckCircle size={11} color={DS_TEXT.success} style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.primary, lineHeight: 1.4 }}>{h}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
@@ -110,11 +111,11 @@ export function NeedMatchCard({ match, onClick }: Props) {
|
||||
key={mh}
|
||||
label={mh}
|
||||
size="small"
|
||||
sx={{ fontSize: '0.62rem', height: 18, bgcolor: '#f0fdf4', color: '#166534', border: '1px solid #bbf7d0' }}
|
||||
sx={{ fontSize: '0.62rem', height: 18, bgcolor: DS_SURFACE.success.bg, color: DS_TEXT.successDark, border: `1px solid ${DS_SURFACE.success.border}` }}
|
||||
/>
|
||||
))}
|
||||
{match.mustHaves.length > 3 && (
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', alignSelf: 'center' }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, alignSelf: 'center' }}>
|
||||
+{match.mustHaves.length - 3}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useNeeds } from '../../hooks/useNeeds'
|
||||
import { getCityIntelligence, getMarketRent } from '../../lib/locationIntelligence'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { generateSellingArguments, generateWeaknesses } from './negotiationInsightsUtils'
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
||||
|
||||
interface Props {
|
||||
property: Property
|
||||
@@ -20,7 +19,6 @@ export function NegotiationInsightsPanel({ property }: Props) {
|
||||
const marketRent = getMarketRent(property.location.city, property.assetType)
|
||||
const priceDiff = marketRent ? ((property.rentPricePerSqm - marketRent) / marketRent) * 100 : null
|
||||
|
||||
// Comparable properties for price positioning
|
||||
const comparables = allProperties
|
||||
.filter(p => p.id !== property.id && p.assetType === property.assetType && p.location.city === property.location.city)
|
||||
|
||||
@@ -28,7 +26,6 @@ export function NegotiationInsightsPanel({ property }: Props) {
|
||||
? comparables.reduce((s, p) => s + p.rentPricePerSqm, 0) / comparables.length
|
||||
: null
|
||||
|
||||
// Active needs matching this property type/location
|
||||
const matchingNeeds = needs.filter(n =>
|
||||
n.assetType === property.assetType &&
|
||||
(n.preferredLocations?.some(loc => loc.toLowerCase().includes(property.location.city.toLowerCase())) ?? false)
|
||||
@@ -39,6 +36,10 @@ export function NegotiationInsightsPanel({ property }: Props) {
|
||||
const strongArgs = sellingArgs.filter(a => a.strength === 'strong')
|
||||
const mediumArgs = sellingArgs.filter(a => a.strength === 'medium')
|
||||
|
||||
const priceDiffBg = priceDiff !== null
|
||||
? (Math.abs(priceDiff) < 10 ? DS_SURFACE.success.bg : priceDiff > 0 ? DS_SURFACE.orange.bg : DS_SURFACE.success.bg)
|
||||
: DS_BG.page
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
|
||||
@@ -47,24 +48,24 @@ export function NegotiationInsightsPanel({ property }: Props) {
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5 }}>Preispositionierung</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 1.5 }}>
|
||||
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 120 }}>
|
||||
<Box sx={{ flex: 1, p: 1.5, bgcolor: DS_BG.page, borderRadius: 1.5, border: `1px solid ${DS_BORDER.default}`, minWidth: 120 }}>
|
||||
<Typography variant="caption" color="text.secondary">Ihr Preis</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#0f1923' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: DS_TEXT.primary }}>
|
||||
CHF {property.rentPricePerSqm}/m²/Jahr
|
||||
</Typography>
|
||||
</Box>
|
||||
{marketRent && (
|
||||
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 120 }}>
|
||||
<Box sx={{ flex: 1, p: 1.5, bgcolor: DS_BG.page, borderRadius: 1.5, border: `1px solid ${DS_BORDER.default}`, minWidth: 120 }}>
|
||||
<Typography variant="caption" color="text.secondary">Marktmedian {property.location.city}</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#475569' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: DS_TEXT.secondary }}>
|
||||
CHF {marketRent}/m²
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{avgComparableRent && (
|
||||
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 120 }}>
|
||||
<Box sx={{ flex: 1, p: 1.5, bgcolor: DS_BG.page, borderRadius: 1.5, border: `1px solid ${DS_BORDER.default}`, minWidth: 120 }}>
|
||||
<Typography variant="caption" color="text.secondary">Vergleichsangebote Ø</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#475569' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: DS_TEXT.secondary }}>
|
||||
CHF {Math.round(avgComparableRent)}/m²
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -72,9 +73,9 @@ export function NegotiationInsightsPanel({ property }: Props) {
|
||||
</Box>
|
||||
|
||||
{priceDiff !== null && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1.25, bgcolor: Math.abs(priceDiff) < 10 ? '#f0fdf4' : priceDiff > 0 ? '#fff8f0' : '#f0fdf4', borderRadius: 1.5 }}>
|
||||
{priceDiff > 0 ? <TrendingUp size={15} color="#d97706" /> : <TrendingDown size={15} color="#1a7a4a" />}
|
||||
<Typography variant="body2" sx={{ color: priceDiff > 15 ? '#92400e' : priceDiff > 0 ? '#d97706' : '#1a7a4a', fontWeight: 500 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1.25, bgcolor: priceDiffBg, borderRadius: 1.5 }}>
|
||||
{priceDiff > 0 ? <TrendingUp size={15} color={DS_TEXT.warning} /> : <TrendingDown size={15} color={DS_TEXT.success} />}
|
||||
<Typography variant="body2" sx={{ color: priceDiff > 15 ? DS_TEXT.warningDark : priceDiff > 0 ? DS_TEXT.warning : DS_TEXT.success, fontWeight: 500 }}>
|
||||
{priceDiff > 15
|
||||
? `Ihr Preis liegt ${Math.round(priceDiff)}% über dem Marktmedian — starke USPs nötig zur Rechtfertigung.`
|
||||
: priceDiff > 5
|
||||
@@ -86,7 +87,6 @@ export function NegotiationInsightsPanel({ property }: Props) {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Price bar vs market */}
|
||||
{marketRent && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||
@@ -95,7 +95,7 @@ export function NegotiationInsightsPanel({ property }: Props) {
|
||||
CHF {Math.round(marketRent * 0.7)}–{Math.round(marketRent * 1.4)}/m²
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ position: 'relative', height: 8, bgcolor: '#e2e8f0', borderRadius: 4 }}>
|
||||
<Box sx={{ position: 'relative', height: 8, bgcolor: DS_BORDER.default, borderRadius: 4 }}>
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
left: `${Math.min(90, Math.max(5, ((property.rentPricePerSqm - marketRent * 0.7) / (marketRent * 0.7)) * 100))}%`,
|
||||
@@ -103,7 +103,7 @@ export function NegotiationInsightsPanel({ property }: Props) {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
bgcolor: '#1e3a5f',
|
||||
bgcolor: DS_TEXT.brand,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
@@ -121,11 +121,11 @@ export function NegotiationInsightsPanel({ property }: Props) {
|
||||
{matchingNeeds.length > 0 ? (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800, color: '#1e3a5f' }}>{matchingNeeds.length}</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800, color: DS_TEXT.brand }}>{matchingNeeds.length}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">aktive Suchprofile für diesen Typ & Standort</Typography>
|
||||
</Box>
|
||||
{matchingNeeds.slice(0, 4).map(n => (
|
||||
<Box key={n.id} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: '1px solid #f1f5f9' }}>
|
||||
<Box key={n.id} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: `1px solid ${DS_BORDER.muted}` }}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500 }}>{n.companyName}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
@@ -137,8 +137,8 @@ export function NegotiationInsightsPanel({ property }: Props) {
|
||||
label={n.status === 'ACTIVE' ? 'Aktiv' : n.status === 'DRAFT' ? 'Entwurf' : n.status}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: n.status === 'ACTIVE' ? '#f0fdf4' : '#f1f5f9',
|
||||
color: n.status === 'ACTIVE' ? '#1a7a4a' : '#64748b',
|
||||
bgcolor: n.status === 'ACTIVE' ? DS_SURFACE.success.bg : DS_BG.subtle,
|
||||
color: n.status === 'ACTIVE' ? DS_TEXT.success : DS_TEXT.muted,
|
||||
fontSize: 10, height: 18,
|
||||
}}
|
||||
/>
|
||||
@@ -156,10 +156,10 @@ export function NegotiationInsightsPanel({ property }: Props) {
|
||||
</Typography>
|
||||
)}
|
||||
{intel && (
|
||||
<Box sx={{ mt: 1.5, p: 1.25, bgcolor: '#f8fafc', borderRadius: 1.5 }}>
|
||||
<Box sx={{ mt: 1.5, p: 1.25, bgcolor: DS_BG.page, borderRadius: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Ø Vermietungsdauer vergleichbarer Objekte in {property.location.city}:{' '}
|
||||
<strong style={{ color: '#1e3a5f' }}>{intel.avgDaysOnMarket} Tage</strong>
|
||||
<strong style={{ color: DS_TEXT.brand }}>{intel.avgDaysOnMarket} Tage</strong>
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -175,12 +175,12 @@ export function NegotiationInsightsPanel({ property }: Props) {
|
||||
|
||||
{strongArgs.length > 0 && (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a', display: 'block', mb: 0.75 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.success, display: 'block', mb: 0.75 }}>
|
||||
Starke Argumente
|
||||
</Typography>
|
||||
{strongArgs.map((arg, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
||||
<CheckCircle size={15} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<CheckCircle size={15} color={DS_TEXT.success} style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{arg.title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{arg.detail}</Typography>
|
||||
@@ -192,12 +192,12 @@ export function NegotiationInsightsPanel({ property }: Props) {
|
||||
|
||||
{mediumArgs.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#d97706', display: 'block', mb: 0.75 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.warning, display: 'block', mb: 0.75 }}>
|
||||
Weitere Vorteile
|
||||
</Typography>
|
||||
{mediumArgs.map((arg, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 0.75 }}>
|
||||
<CheckCircle size={14} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<CheckCircle size={14} color={DS_TEXT.warning} style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>{arg.title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{arg.detail}</Typography>
|
||||
@@ -218,10 +218,10 @@ export function NegotiationInsightsPanel({ property }: Props) {
|
||||
</Typography>
|
||||
{weaknesses.map((w, i) => (
|
||||
<Box key={i} sx={{ mb: 1.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#c0392b', mb: 0.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.error, mb: 0.25 }}>
|
||||
⚠ {w.issue}
|
||||
</Typography>
|
||||
<Box sx={{ pl: 1.5, borderLeft: '2px solid #e2e8f0' }}>
|
||||
<Box sx={{ pl: 1.5, borderLeft: `2px solid ${DS_BORDER.default}` }}>
|
||||
<Typography variant="caption" color="text.secondary">→ {w.mitigation}</Typography>
|
||||
</Box>
|
||||
{i < weaknesses.length - 1 && <Divider sx={{ mt: 1.25 }} />}
|
||||
@@ -240,14 +240,14 @@ export function NegotiationInsightsPanel({ property }: Props) {
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
|
||||
{intel.dominantIndustryClusters.map(c => (
|
||||
<Chip key={c} label={c} size="small"
|
||||
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 24, fontWeight: 500 }}
|
||||
sx={{ bgcolor: DS_SURFACE.blue.bg, color: DS_TEXT.brand, fontSize: 11, height: 24, fontWeight: 500 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={intel.demandStrength === 'VERY_HIGH' ? 95 : intel.demandStrength === 'HIGH' ? 75 : intel.demandStrength === 'MEDIUM' ? 50 : 25}
|
||||
sx={{ mt: 1.5, height: 6, borderRadius: 3, bgcolor: '#f1f5f9', '& .MuiLinearProgress-bar': { bgcolor: '#1e3a5f' } }}
|
||||
sx={{ mt: 1.5, height: 6, borderRadius: 3, bgcolor: DS_BG.subtle, '& .MuiLinearProgress-bar': { bgcolor: DS_TEXT.brand } }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
Nachfragestärke: {' '}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { Target, TrendingUp, Users } from 'lucide-react'
|
||||
import { DS_PRE_MARKET } from '../../lib/ds'
|
||||
|
||||
interface Props {
|
||||
demandProfiles: number
|
||||
highQualityLeads: number
|
||||
}
|
||||
|
||||
export function PreMarketDemandIntelligence({ demandProfiles, highQualityLeads }: Props) {
|
||||
return (
|
||||
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}>
|
||||
Matching Demand Intelligence
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Users size={11} color={DS_PRE_MARKET.accent} style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.3 }}>
|
||||
<strong>{demandProfiles} aktive Suchprofile</strong> im System erkannt
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Target size={11} color={DS_PRE_MARKET.accent} style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.3 }}>
|
||||
<strong>{highQualityLeads} hochwertige Suchanfragen</strong> mit passendem Flächenbedarf
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<TrendingUp size={11} color={DS_PRE_MARKET.accent} style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.3 }}>
|
||||
Frühzeitige Matchgelegenheit — vor offizieller Vermarktung exklusiv verfügbar
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, Chip, CircularProgress, Divider, Switch, TextField, Typography } from '@mui/material'
|
||||
import { Clock, Layers, ShieldCheck, Target, TrendingUp, Users, Zap } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Box, Chip, CircularProgress, Divider, Switch, Typography } from '@mui/material'
|
||||
import { Clock, ShieldCheck, Zap } from 'lucide-react'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { MockupUnitProvider } from '../../provider/MockupUnitProvider'
|
||||
import { useUpdateProperty } from '../../hooks/useProperties'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||
import { floorLabel, SectionTitle } from './PropertyDetailHelpers'
|
||||
import { SectionTitle } from './PropertyDetailHelpers'
|
||||
import { PreMarketDemandIntelligence } from './PreMarketDemandIntelligence'
|
||||
import { PreMarketUnitGrid } from './PreMarketUnitGrid'
|
||||
|
||||
export const MOCK_TODAY = new Date('2026-05-20')
|
||||
|
||||
@@ -24,6 +27,7 @@ export function PreMarketPanel({ p }: { p: Property }) {
|
||||
return init
|
||||
})
|
||||
const [unitSaving, setUnitSaving] = useState<Record<string, boolean>>({})
|
||||
const queryClient = useQueryClient()
|
||||
const updateProperty = useUpdateProperty()
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
const saving = updateProperty.isPending
|
||||
@@ -48,7 +52,6 @@ export function PreMarketPanel({ p }: { p: Property }) {
|
||||
? Math.max(0, Math.round((targetDate.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24 * 30)))
|
||||
: null
|
||||
|
||||
// Mock demand intelligence (derived deterministically from property characteristics)
|
||||
const demandProfiles = Math.min(14, (p.areaSqm >= 1000 ? 5 : p.areaSqm >= 500 ? 8 : 4) +
|
||||
(['Zürich', 'Basel', 'Bern', 'Zug'].some(c => (p.location?.city ?? '').includes(c)) ? 4 : 1))
|
||||
const highQualityLeads = Math.max(1, Math.floor(demandProfiles * 0.38))
|
||||
@@ -133,7 +136,7 @@ export function PreMarketPanel({ p }: { p: Property }) {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Active: lead time + status + demand intelligence */}
|
||||
{/* Active: lead time + status + unit grid + demand intelligence */}
|
||||
{enabled && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
{/* Lead time selector */}
|
||||
@@ -182,94 +185,20 @@ export function PreMarketPanel({ p }: { p: Property }) {
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Unit-level release controls */}
|
||||
{(p.units?.length ?? 0) > 0 && (
|
||||
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}>
|
||||
Einheiten freigeben
|
||||
</Typography>
|
||||
{p.units!.map(u => {
|
||||
const us = unitStates[u.id] ?? { enabled: false, availableFrom: '' }
|
||||
return (
|
||||
<Box
|
||||
key={u.id}
|
||||
sx={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 140px auto',
|
||||
gap: 1, alignItems: 'center', py: 0.75,
|
||||
borderBottom: '1px solid #f3e8ff',
|
||||
'&:last-child': { borderBottom: 'none' },
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: DS_TEXT.primary }}>
|
||||
{floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>
|
||||
{u.areaSqm.toLocaleString('de-CH')} m²
|
||||
{u.currentTenant ? ` · ${u.currentTenant}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
value={us.availableFrom}
|
||||
disabled={!us.enabled}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }}
|
||||
onChange={e => {
|
||||
const next = { ...us, availableFrom: e.target.value }
|
||||
setUnitStates(prev => ({ ...prev, [u.id]: next }))
|
||||
if (us.enabled) saveUnit(u.id, true, e.target.value)
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: DS_PRE_MARKET.accent }} />}
|
||||
<Switch
|
||||
size="small"
|
||||
checked={us.enabled}
|
||||
onChange={(_, checked) => {
|
||||
const next = { ...us, enabled: checked }
|
||||
setUnitStates(prev => ({ ...prev, [u.id]: next }))
|
||||
saveUnit(u.id, checked, us.availableFrom)
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiSwitch-switchBase.Mui-checked': { color: DS_PRE_MARKET.accent },
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<PreMarketUnitGrid
|
||||
units={p.units!}
|
||||
unitStates={unitStates}
|
||||
unitSaving={unitSaving}
|
||||
setUnitStates={setUnitStates}
|
||||
saveUnit={saveUnit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Demand Intelligence */}
|
||||
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}>
|
||||
Matching Demand Intelligence
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Users size={11} color={DS_PRE_MARKET.accent} style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.3 }}>
|
||||
<strong>{demandProfiles} aktive Suchprofile</strong> im System erkannt
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Target size={11} color={DS_PRE_MARKET.accent} style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.3 }}>
|
||||
<strong>{highQualityLeads} hochwertige Suchanfragen</strong> mit passendem Flächenbedarf
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<TrendingUp size={11} color={DS_PRE_MARKET.accent} style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.3 }}>
|
||||
Frühzeitige Matchgelegenheit — vor offizieller Vermarktung exklusiv verfügbar
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<PreMarketDemandIntelligence
|
||||
demandProfiles={demandProfiles}
|
||||
highQualityLeads={highQualityLeads}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Box, CircularProgress, Switch, TextField, Typography } from '@mui/material'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { DS_PRE_MARKET, DS_TEXT } from '../../lib/ds'
|
||||
import { floorLabel } from './PropertyDetailHelpers'
|
||||
|
||||
type PropertyUnit = NonNullable<Property['units']>[number]
|
||||
|
||||
interface Props {
|
||||
units: PropertyUnit[]
|
||||
unitStates: Record<string, { enabled: boolean; availableFrom: string }>
|
||||
unitSaving: Record<string, boolean>
|
||||
setUnitStates: React.Dispatch<React.SetStateAction<Record<string, { enabled: boolean; availableFrom: string }>>>
|
||||
saveUnit: (unitId: string, enabled: boolean, availableFrom: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) {
|
||||
return (
|
||||
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}>
|
||||
Einheiten freigeben
|
||||
</Typography>
|
||||
{units.map(u => {
|
||||
const us = unitStates[u.id] ?? { enabled: false, availableFrom: '' }
|
||||
return (
|
||||
<Box
|
||||
key={u.id}
|
||||
sx={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 140px auto',
|
||||
gap: 1, alignItems: 'center', py: 0.75,
|
||||
borderBottom: '1px solid #f3e8ff',
|
||||
'&:last-child': { borderBottom: 'none' },
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: DS_TEXT.primary }}>
|
||||
{floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>
|
||||
{u.areaSqm.toLocaleString('de-CH')} m²
|
||||
{u.currentTenant ? ` · ${u.currentTenant}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
value={us.availableFrom}
|
||||
disabled={!us.enabled}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }}
|
||||
onChange={e => {
|
||||
const next = { ...us, availableFrom: e.target.value }
|
||||
setUnitStates(prev => ({ ...prev, [u.id]: next }))
|
||||
if (us.enabled) saveUnit(u.id, true, e.target.value)
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: DS_PRE_MARKET.accent }} />}
|
||||
<Switch
|
||||
size="small"
|
||||
checked={us.enabled}
|
||||
onChange={(_, checked) => {
|
||||
const next = { ...us, enabled: checked }
|
||||
setUnitStates(prev => ({ ...prev, [u.id]: next }))
|
||||
saveUnit(u.id, checked, us.availableFrom)
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiSwitch-switchBase.Mui-checked': { color: DS_PRE_MARKET.accent },
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user