7934da7669
Token infrastructure (src/lib/ds.ts): - DS_TEXT: 16 semantic text color tokens (primary/secondary/muted/success/ warning/error/info/brand/signal + dark variants successDark/warningDark/ signalDark/infoDark for text on tinted surfaces) - DS_BG: page/surface/subtle/muted background tokens - DS_BORDER: default/muted/strong border tokens - DS_SURFACE: 10 bg+border surface pairs (success/warning/error/info/indigo/ purple/orange/blue/neutral/slate) - DS_MATCH_TIER: score-tier surface aliases keyed by strong/moderate/weak - DS_PRE_MARKET / DS_MARKET_SIGNAL: named aliases for futureCard tokens - DS_SHADOW: card/panel/dialog elevation tokens - BADGE_COLORS: 14 semantic presets for GenericBadge (confidenceHigh, riskMedium, preMarket, marketSignal, verified, gold, silver, bronze…) GenericBadge (src/components/shared/GenericBadge.tsx): - New semanticVariant prop (keyof BADGE_COLORS) — preferred over raw hex - color prop becomes optional (fallback), type-documented as escape hatch Priority file migrations — 237 hex literals replaced across 10 files: ScoreBreakdownPanel.tsx −22 PipelineDetailPanel.tsx −22 FutureAvailabilityCard.tsx −26 AICompareSummary.tsx −27 LocationIntelligencePanel −39 AssistantPromptSuggestions −18 UnitStructurePanel.tsx −25 BerichtDialog.tsx −24 PreMarketPanel.tsx −24 PropertyActivityLogPanel −10 ESLint (eslint.config.js): - Updated rule message to reference new tokens (DS_TEXT, DS_SURFACE, etc.) - Added 'stroke' to monitored property names - Remains 'warn' for gradual migration; use check:tokens for CI gate CI script (scripts/check-tokens.js + npm run check:tokens): - Counts hex patterns in components/ + pages/ - Fails if count > THRESHOLD (ratchet: 1958 baseline, lower per sprint) - Reports top 15 offenders for prioritizing next migration batch Results: ESLint targeted sx-prop violations: 1752 → 1030 (−41%) 0 TypeScript errors, 154 tests green Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
215 lines
10 KiB
TypeScript
215 lines
10 KiB
TypeScript
import { useState } from 'react'
|
|
import { useNavigate } from 'react-router'
|
|
import {
|
|
Box, Button, Chip, Divider, IconButton, TextField, Tooltip, Typography,
|
|
} from '@mui/material'
|
|
import {
|
|
AlertTriangle, ChevronRight, CheckCircle, ExternalLink, FileText,
|
|
MapPin, MessageSquare, Sparkles, StickyNote, X,
|
|
} from 'lucide-react'
|
|
import { useMoveStage, useUpdateNotes, useLoseItem } from '../../hooks/usePipeline'
|
|
import type { PipelineItem } from '../../domain/pipeline'
|
|
import { STAGES, NEXT_STAGE, MOCK_DOCS } from './pipelineConstants'
|
|
import { scoreColor, detailPath, getKiInsight } from './pipelineUtils'
|
|
import { DS_TEXT, DS_SURFACE, DS_BORDER, DS_PRE_MARKET, DS_COLORS } from '../../lib/ds'
|
|
|
|
// ── DetailPanel ───────────────────────────────────────────────────────────────
|
|
|
|
export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () => void }) {
|
|
const navigate = useNavigate()
|
|
const { mutate: moveStage } = useMoveStage()
|
|
const { mutate: updateNotes } = useUpdateNotes()
|
|
const { mutate: loseItem } = useLoseItem()
|
|
const path = detailPath(item)
|
|
const [notes, setNotes] = useState(item.notes ?? '')
|
|
const stageConfig = STAGES.find(s => s.key === item.stage)!
|
|
const stageIndex = STAGES.findIndex(s => s.key === item.stage)
|
|
const nextStage = NEXT_STAGE[item.stage]
|
|
const ki = getKiInsight(item)
|
|
const docs = MOCK_DOCS[item.id] ?? []
|
|
const isClosed = item.stage === 'CLOSED_WON' || item.stage === 'CLOSED_LOST'
|
|
const progressIdx = Math.min(stageIndex, 4)
|
|
|
|
return (
|
|
<Box sx={{
|
|
width: { xs: '100%', md: 340 }, flexShrink: 0,
|
|
display: 'flex', flexDirection: 'column',
|
|
bgcolor: 'white', borderLeft: `1px solid ${DS_BORDER.default}`, overflow: 'hidden',
|
|
}}>
|
|
{/* Header */}
|
|
<Box sx={{ px: 2, py: 2, borderBottom: `1px solid ${DS_BORDER.default}` }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
<Typography variant="body1" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.25 }}>{item.title}</Typography>
|
|
<Typography variant="caption" color="text.secondary">{item.location}</Typography>
|
|
</Box>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, flexShrink: 0 }}>
|
|
<Typography sx={{ fontWeight: 900, fontSize: '1.1rem', color: scoreColor(item.matchScore) }}>
|
|
{item.matchScore}%
|
|
</Typography>
|
|
{path && (
|
|
<Tooltip title="Vollständige Detailansicht öffnen">
|
|
<IconButton size="small" onClick={() => navigate(path)} sx={{ color: DS_TEXT.muted, '&:hover': { color: DS_TEXT.brand } }}>
|
|
<ExternalLink size={15} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
<IconButton size="small" onClick={onClose} sx={{ color: DS_TEXT.disabled }}>
|
|
<X size={16} />
|
|
</IconButton>
|
|
</Box>
|
|
</Box>
|
|
<Box sx={{ mt: 2 }}>
|
|
<Box sx={{ display: 'flex', gap: 0.25, mb: 1 }}>
|
|
{STAGES.slice(0, 5).map((s, idx) => (
|
|
<Box key={s.key} sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: idx <= progressIdx ? stageConfig.color : DS_BORDER.default }} />
|
|
))}
|
|
</Box>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Chip label={stageConfig.label} size="small" sx={{ bgcolor: stageConfig.bgColor, color: stageConfig.color, fontWeight: 700, height: 22, fontSize: '0.75rem' }} />
|
|
{item.inquiryId && (
|
|
<Chip
|
|
icon={<MessageSquare size={11} />}
|
|
label="Chat"
|
|
size="small"
|
|
onClick={() => navigate(`/demand/anfragen?inquiry=${item.inquiryId}`)}
|
|
sx={{
|
|
height: 22, fontSize: '0.75rem', cursor: 'pointer',
|
|
bgcolor: DS_SURFACE.blue.bg, color: DS_TEXT.brand, fontWeight: 600,
|
|
border: `1px solid ${DS_SURFACE.blue.border}`,
|
|
'& .MuiChip-icon': { color: DS_TEXT.brand },
|
|
'&:hover': { bgcolor: DS_COLORS.futureCard.signal.badgeBg },
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Property / unit address */}
|
|
{item.propertyAddress && (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 1.25 }}>
|
|
<MapPin size={12} color={DS_TEXT.muted} />
|
|
<Typography variant="caption" color="text.secondary">{item.propertyAddress}</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
|
{/* KI */}
|
|
<Box sx={{ px: 2, pt: 2, pb: 1.5 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
|
<Sparkles size={13} color={DS_PRE_MARKET.accent} />
|
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_PRE_MARKET.accent, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.7rem' }}>
|
|
KI Einschätzung
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ bgcolor: DS_SURFACE.purple.bg, border: `1px solid ${DS_PRE_MARKET.border}`, borderRadius: 2, p: 1.5, mb: 1 }}>
|
|
<Typography variant="caption" sx={{ color: DS_TEXT.signalDark, lineHeight: 1.6 }}>{ki.summary}</Typography>
|
|
</Box>
|
|
{ki.positives.map((p, i) => (
|
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, mb: 0.375 }}>
|
|
<CheckCircle size={12} color={DS_TEXT.success} style={{ marginTop: 2, flexShrink: 0 }} />
|
|
<Typography variant="caption" sx={{ color: DS_TEXT.successDark, lineHeight: 1.4 }}>{p}</Typography>
|
|
</Box>
|
|
))}
|
|
{ki.risks.map((r, i) => (
|
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, mb: 0.375 }}>
|
|
<AlertTriangle size={12} color={DS_TEXT.warning} style={{ marginTop: 2, flexShrink: 0 }} />
|
|
<Typography variant="caption" sx={{ color: DS_TEXT.warningDark, lineHeight: 1.4 }}>{r}</Typography>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
|
|
<Divider />
|
|
|
|
{/* Stage actions */}
|
|
{!isClosed && (
|
|
<Box sx={{ px: 2, py: 1.5 }}>
|
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.7rem', display: 'block', mb: 1 }}>
|
|
Nächste Aktion
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
|
{nextStage && (
|
|
<Button size="small" variant="contained" endIcon={<ChevronRight size={14} />}
|
|
onClick={() => moveStage({ id: item.id, stage: nextStage.key })}
|
|
sx={{ bgcolor: stageConfig.color, '&:hover': { filter: 'brightness(0.9)' }, fontSize: '0.75rem', py: 0.5 }}
|
|
>
|
|
{nextStage.label}
|
|
</Button>
|
|
)}
|
|
<Button size="small" variant="outlined" onClick={() => loseItem(item.id)}
|
|
sx={{ color: DS_TEXT.error, borderColor: DS_TEXT.error, fontSize: '0.75rem', py: 0.5 }}>
|
|
Ablehnen
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
<Divider />
|
|
|
|
{/* Notes */}
|
|
<Box sx={{ px: 2, py: 1.5 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
|
<StickyNote size={13} color={DS_TEXT.secondary} />
|
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.7rem' }}>
|
|
Notizen
|
|
</Typography>
|
|
</Box>
|
|
<TextField
|
|
size="small" multiline minRows={3} fullWidth
|
|
placeholder="Notiz hinzufügen…"
|
|
value={notes}
|
|
onChange={e => setNotes(e.target.value)}
|
|
onBlur={() => updateNotes({ id: item.id, notes })}
|
|
sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.8rem', borderRadius: 1.5 } }}
|
|
/>
|
|
</Box>
|
|
|
|
<Divider />
|
|
|
|
{/* Documents */}
|
|
<Box sx={{ px: 2, py: 1.5 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
|
<FileText size={13} color={DS_TEXT.secondary} />
|
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.7rem' }}>
|
|
Dokumente
|
|
</Typography>
|
|
</Box>
|
|
{docs.length === 0 ? (
|
|
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>Noch keine Dokumente.</Typography>
|
|
) : (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
{docs.map((doc, i) => (
|
|
<Box key={i} sx={{
|
|
display: 'flex', alignItems: 'center', gap: 1,
|
|
px: 1.5, py: 0.75, bgcolor: DS_SURFACE.neutral.bg,
|
|
borderRadius: 1.5, border: `1px solid ${DS_BORDER.default}`,
|
|
cursor: 'pointer', '&:hover': { bgcolor: '#f1f5f9' },
|
|
}}>
|
|
<FileText size={13} color={DS_TEXT.secondary} />
|
|
<Typography variant="caption" sx={{ flex: 1, color: DS_TEXT.primary }} noWrap>{doc.name}</Typography>
|
|
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>{doc.date}</Typography>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
)}
|
|
<Button size="small" variant="text" sx={{ mt: 0.75, color: DS_TEXT.brand, fontSize: '0.75rem', p: 0 }}>
|
|
+ Dokument hochladen
|
|
</Button>
|
|
</Box>
|
|
|
|
<Box sx={{ px: 2, pb: 2 }}>
|
|
<Divider sx={{ mb: 1.5 }} />
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
{item.availabilityLabel && <Typography variant="caption" color="text.secondary"><strong>Verfügbar:</strong> {item.availabilityLabel}</Typography>}
|
|
{item.assignedTo && <Typography variant="caption" color="text.secondary"><strong>Verantwortlich:</strong> {item.assignedTo}</Typography>}
|
|
<Typography variant="caption" color="text.secondary">
|
|
<strong>Hinzugefügt:</strong> {new Date(item.addedAt).toLocaleDateString('de-CH')}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|