refactor: replace hardcoded hex colors with design tokens across priority components

- Add DS_COLORS.heat, DS_COLORS.futureCard, INQUIRY_STATUS_META tokens to ds.ts
- Eliminate 4 duplicate RESULT_TYPE_META / TYPE_META constants (now all use ds.ts)
- Eliminate 3 duplicate scoreColor / SCORE_COLOR functions (now all use matchScoreHex)
- HeatBadge: use DS_COLORS.heat.VERY_HOT / HOT tokens
- FutureAvailabilityCard: use DS_COLORS.futureCard.controlled / signal tokens
- IntelligenceMatchCard: import RESULT_TYPE_META from ds.ts, remove primary override
- MatchScoreDisplay: replace local scoreColor() with matchScoreHex()
- CompareColumnHeader: remove local TYPE_META + SCORE_COLOR, use ds.ts + utils.ts
- CompareTableBody: import RESULT_TYPE_META + matchScoreHex, fix #7c3aed → token
- compareUtils: remove TYPE_META + SCORE_COLOR, use dataQualityHex in scoreBar
- Anfragen + AnfragenInquiryItem: deduplicate STATUS_CONFIG → INQUIRY_STATUS_META
- Anfragen: #1e3a5f → 'primary.main', KI alert → futureCard.controlled tokens
- AnfragenMessageBubble: own-message bubble → 'primary.main', AI bubble → tokens
- ScoreBreakdownPanel: #1e3a5f → 'primary.main', #6d28d9 → futureCard.controlled
- ESLint: add warn rule against new hex colors in component sx props

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 13:02:01 +02:00
parent c972392b78
commit 7f090a48ff
13 changed files with 130 additions and 112 deletions
+19
View File
@@ -19,4 +19,23 @@ export default defineConfig([
globals: globals.browser,
},
},
// Discourage new hardcoded hex colors in components and pages.
// Use DS_COLORS, matchScoreHex(), RESULT_TYPE_META, or MUI theme tokens ("primary.main") instead.
// See src/lib/ds.ts and src/lib/utils.ts for available tokens.
{
files: ['src/components/**/*.{ts,tsx}', 'src/pages/**/*.{ts,tsx}'],
rules: {
'no-restricted-syntax': [
'warn',
{
selector: [
'Property[key.name=/^(bgcolor|color|borderColor|background|fill)$/]',
' > Literal[value=/^#[0-9A-Fa-f]{3,8}$/]',
].join(''),
message:
'Avoid hardcoded hex colors in sx props. Use DS_COLORS, matchScoreHex(), RESULT_TYPE_META, or MUI theme tokens ("primary.main") from src/lib/ds.ts and src/lib/utils.ts.',
},
],
},
},
])
+5 -12
View File
@@ -3,17 +3,10 @@ import { X, AlertTriangle, BookmarkCheck, ExternalLink, Kanban } from 'lucide-re
import { useNavigate } from 'react-router'
import { usePipelineItems } from '../../hooks/usePipeline'
import { usePipelineStore } from '../../stores/pipelineStore'
import { RESULT_TYPE_META } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
const TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
}
const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b'
interface Props {
item: UnifiedMatchResult
onRemove: () => void
@@ -24,7 +17,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
const { data: pipelineItems = [] } = usePipelineItems()
const { openSavedDialog } = usePipelineStore()
const meta = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
const meta = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? (item as any).property : null
const sig = item.resultType === 'FUTURE_AVAILABILITY' ? (item as any).signal : null
@@ -86,7 +79,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
{/* Row 3: score */}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 1 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: SCORE_COLOR(item.matchScore), lineHeight: 1 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: matchScoreHex(item.matchScore), lineHeight: 1 }}>
{item.matchScore}
</Typography>
<Typography variant="caption" color="text.secondary">/100</Typography>
@@ -152,7 +145,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
variant="contained"
startIcon={<Kanban size={12} />}
onClick={handleSave}
sx={{ flex: 1, textTransform: 'none', fontSize: '0.72rem', py: 0.5, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ flex: 1, textTransform: 'none', fontSize: '0.72rem', py: 0.5 }}
>
Merken
</Button>
+8 -8
View File
@@ -10,8 +10,6 @@ import {
import { AlertOctagon, AlertTriangle, CheckCircle2, Trophy, XCircle, Zap } from 'lucide-react'
import {
HARD_CRITERIA,
SCORE_COLOR,
TYPE_META,
RISK_LEVEL_ORDER,
getProp,
getSig,
@@ -20,6 +18,8 @@ import {
scoreBar,
} from './compareUtils'
import { CompareCell, MissingDataCell } from './index'
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
// ── Local helper ──────────────────────────────────────────────────────────────
@@ -61,7 +61,7 @@ export function CompareTableBody({
{/* 1. Result Type */}
{row('1. Result-Typ', compareItems.map(item => {
const m = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
const m = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
return <Chip label={m.label} size="small" sx={{ bgcolor: m.color, color: 'white', fontWeight: 600, fontSize: 11 }} />
}))}
@@ -83,7 +83,7 @@ export function CompareTableBody({
iconTooltip="Höchster Match Score"
>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
<Typography variant="h5" sx={{ fontWeight: 800, color: SCORE_COLOR(item.matchScore), lineHeight: 1 }}>
<Typography variant="h5" sx={{ fontWeight: 800, color: matchScoreHex(item.matchScore), lineHeight: 1 }}>
{item.matchScore}
</Typography>
<Typography variant="caption" color="text.secondary">/100</Typography>
@@ -172,9 +172,9 @@ export function CompareTableBody({
const sig = getSig(item)
if (prop) return <Typography variant="body2">{prop.availabilityDate}</Typography>
if (sig) return (
<CompareCell highlight="future" icon={<Zap size={14} color="#7c3aed" />} iconTooltip="Probabilistisches Signal — keine bestätigte Verfügbarkeit">
<CompareCell highlight="future" icon={<Zap size={14} color={DS_COLORS.futureCard.controlled.accent} />} iconTooltip="Probabilistisches Signal — keine bestätigte Verfügbarkeit">
<Typography variant="body2">~{sig.timeHorizonMonths} Monate</Typography>
<Typography variant="caption" sx={{ color: '#7c3aed' }}>
<Typography variant="caption" sx={{ color: DS_COLORS.futureCard.controlled.accent }}>
{Math.round(sig.probability * 100)}% Wahrscheinlichkeit
</Typography>
</CompareCell>
@@ -305,9 +305,9 @@ export function CompareTableBody({
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>Nicht anwendbar</Typography>
)
return (
<CompareCell highlight="future" icon={<Zap size={14} color="#7c3aed" />} iconTooltip="Probabilistisches Zukunftssignal">
<CompareCell highlight="future" icon={<Zap size={14} color={DS_COLORS.futureCard.controlled.accent} />} iconTooltip="Probabilistisches Zukunftssignal">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#7c3aed' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_COLORS.futureCard.controlled.accent }}>
{Math.round(sig.probability * 100)}% Wahrscheinlichkeit
</Typography>
<Typography variant="caption" color="text.secondary">
+2 -9
View File
@@ -2,11 +2,11 @@ import type { ReactNode } from 'react'
import { Box, LinearProgress, Typography } from '@mui/material'
import type { WeightingKey } from '../../domain/needBuilder'
import type { UnifiedMatchResult, VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult'
import { dataQualityHex } from '../../lib/utils'
// ── Helpers ───────────────────────────────────────────────────────────────────
export const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
export const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b'
export function getProp(item: UnifiedMatchResult) {
return item.resultType !== 'FUTURE_AVAILABILITY'
@@ -20,13 +20,6 @@ export function getSig(item: UnifiedMatchResult) {
: null
}
export const TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
}
export const RISK_LEVEL_ORDER: Record<string, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
export const CRITERION_ALIASES: Record<WeightingKey, string[]> = {
@@ -78,7 +71,7 @@ export const DATA_SX = {
// ── Score bar helper ──────────────────────────────────────────────────────────
export function scoreBar(value: number, label?: string): ReactNode {
const color = value >= 0.8 ? '#1a7a4a' : value >= 0.6 ? '#d97706' : '#c0392b'
const color = dataQualityHex(value)
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 70 }}>
+15 -15
View File
@@ -1,14 +1,8 @@
import { Box, Chip, Typography } from '@mui/material'
import { Kanban } from 'lucide-react'
import { INQUIRY_STATUS_META, DS_COLORS } from '../../lib/ds'
import type { Inquiry } from '../../domain/inquiry'
const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: string }> = {
new: { label: 'Neu', color: '#dc2626', bgColor: '#fef2f2' },
in_progress: { label: 'Aktiv', color: '#d97706', bgColor: '#fffbeb' },
answered: { label: 'Beantwortet', color: '#1a7a4a', bgColor: '#f0fdf4' },
archived: { label: 'Archiviert', color: '#64748b', bgColor: '#f8fafc' },
}
interface AnfragenInquiryItemProps {
inq: Inquiry
isSelected: boolean
@@ -17,7 +11,7 @@ interface AnfragenInquiryItemProps {
}
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }: AnfragenInquiryItemProps) {
const cfg = STATUS_CONFIG[inq.status ?? 'new']
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]
@@ -26,21 +20,22 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
px: 2, py: 1.5,
borderBottom: '1px solid #f1f5f9',
cursor: 'pointer',
bgcolor: isSelected ? '#eff6ff' : !inq.isRead ? 'rgba(220,38,38,0.03)' : 'transparent',
borderLeft: isSelected ? '3px solid #1e3a5f' : '3px solid transparent',
'&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' },
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' },
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: '#dc2626', flexShrink: 0 }} />}
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: cfg?.fg ?? '#dc2626', 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: '#dc2626', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box sx={{ width: 18, height: 18, borderRadius: '50%', bgcolor: cfg?.fg ?? '#dc2626', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography sx={{ color: 'white', fontSize: '0.6rem', fontWeight: 700 }}>{inq.unreadCount}</Typography>
</Box>
)}
@@ -63,7 +58,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label={cfg?.label ?? inq.status} size="small"
sx={{ height: 16, fontSize: '0.6rem', bgcolor: cfg?.bgColor, color: cfg?.color, fontWeight: 600 }} />
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' }}>
{inq.matchScore}%
@@ -74,7 +69,12 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
icon={<Kanban size={9} />}
label="Pipeline"
size="small"
sx={{ height: 16, fontSize: '0.6rem', bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600, '& .MuiChip-icon': { color: '#1e3a5f' } }}
sx={{
height: 16, fontSize: '0.6rem', fontWeight: 600,
bgcolor: DS_COLORS.futureCard.signal.headerBg,
color: 'primary.main',
'& .MuiChip-icon': { color: 'primary.main' },
}}
/>
)}
</Box>
@@ -1,5 +1,6 @@
import { Box, Typography } from '@mui/material'
import { Bot, Building2, FileText } from 'lucide-react'
import { DS_COLORS } from '../../lib/ds'
import type { InquiryMessage } from '../../domain/inquiry'
export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
@@ -22,15 +23,15 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
maxWidth: { xs: '88%', md: '72%' },
px: 2, py: 1.25,
borderRadius: isOwnMessage ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
bgcolor: isOwnMessage ? '#1e3a5f' : isAI ? '#f5f3ff' : 'white',
border: isOwnMessage ? 'none' : isAI ? '1px solid #ddd6fe' : '1px solid #e2e8f0',
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',
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 ? '#5b21b6' : '#1e293b',
color: isOwnMessage ? 'white' : isAI ? DS_COLORS.futureCard.controlled.badgeText : '#1e293b',
whiteSpace: 'pre-wrap', lineHeight: 1.65, fontSize: '0.875rem',
}}
>
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { floorLabel, SOURCE_META, ASSET_TYPE_LABELS, getOpportunityHeadline } from './futureAvailabilityCardHelpers'
import { SignalQualityDots } from './SignalQualityDots'
import { DS_COLORS } from '../../lib/ds'
import type { MatchCardViewModel } from './MatchCardViewModel'
// ── Future Availability Card ──────────────────────────────────────────────────
@@ -12,13 +13,12 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
const isControlled = vm.signalIsControlled ?? false
const navigate = useNavigate()
// PRE-MARKET VERIFIED: soft purple / institutional premium
// MARKET SIGNAL: slate blue / analytical
const accentColor = isControlled ? '#7c3aed' : '#1d4ed8'
const headerBg = isControlled ? '#faf5ff' : '#eff6ff'
const borderColor = isControlled ? '#e9d5ff' : '#bfdbfe'
const badgeBg = isControlled ? '#ede9fe' : '#dbeafe'
const badgeColor = isControlled ? '#5b21b6' : '#1e40af'
const cardColors = isControlled ? DS_COLORS.futureCard.controlled : DS_COLORS.futureCard.signal
const accentColor = cardColors.accent
const headerBg = cardColors.headerBg
const borderColor = cardColors.border
const badgeBg = cardColors.badgeBg
const badgeColor = cardColors.badgeText
const assetLabel = vm.assetType ? (ASSET_TYPE_LABELS[vm.assetType] ?? vm.assetType) : 'Fläche'
const headline = getOpportunityHeadline(vm.signalType, assetLabel)
@@ -168,7 +168,7 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35 }}>
{(vm.signalMarketIndicators ?? []).slice(0, 3).map((ind, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.6 }}>
<Box sx={{ width: 4, height: 4, borderRadius: '50%', bgcolor: '#1d4ed8', mt: '5px', flexShrink: 0 }} />
<Box sx={{ width: 4, height: 4, borderRadius: '50%', bgcolor: DS_COLORS.futureCard.signal.indicator, mt: '5px', flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.72rem', color: '#475569', lineHeight: 1.3 }}>{ind}</Typography>
</Box>
))}
@@ -210,7 +210,7 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
...(a.variant === 'primary' && {
bgcolor: accentColor,
'&:hover': { bgcolor: isControlled ? '#6d28d9' : '#1e40af' },
'&:hover': { bgcolor: cardColors.accentHover },
}),
}}
>
@@ -224,7 +224,9 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
onClick={() => navigate(`/demand/property/${vm.propertyId}${vm.unitId ? `?unit=${vm.unitId}` : ''}`)}
sx={{
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
bgcolor: '#7c3aed', '&:hover': { bgcolor: '#6d28d9' }, ml: 'auto',
bgcolor: DS_COLORS.futureCard.controlled.accent,
'&:hover': { bgcolor: DS_COLORS.futureCard.controlled.accentHover },
ml: 'auto',
}}
>
Zur Einheit
@@ -7,18 +7,10 @@ import { useSessionStore } from '../../stores/sessionStore'
import { LocationPreview } from '../shared/LocationPreview'
import { HeatBadge } from '../shared/HeatBadge'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { RESULT_TYPE_META } from '../../lib/ds'
import type { MatchCardViewModel } from './MatchCardViewModel'
import { FutureAvailabilityCard } from './FutureAvailabilityCard'
// ── Constants ─────────────────────────────────────────────────────────────────
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Future Availability', color: '#7c3aed' },
}
// ── Main component ────────────────────────────────────────────────────────────
interface Props {
@@ -163,10 +155,6 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
disabled={a.disabled}
sx={{
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
...(a.variant === 'primary' && {
bgcolor: '#1e3a5f',
'&:hover': { bgcolor: '#162d4a' },
}),
}}
>
{a.label}
@@ -1,22 +1,17 @@
import { Box, Typography } from '@mui/material'
import { matchScoreHex } from '../../lib/utils'
interface Props {
score: number
size?: 'sm' | 'md' | 'lg'
}
function scoreColor(score: number): string {
if (score >= 78) return '#1a7a4a'
if (score >= 52) return '#d97706'
return '#c0392b'
}
const FONT_SIZES: Record<string, string> = { sm: '1.25rem', md: '1.75rem', lg: '2.5rem' }
export function MatchScoreDisplay({ score, size = 'md' }: Props) {
return (
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.25, flexShrink: 0 }}>
<Typography sx={{ fontWeight: 800, fontSize: FONT_SIZES[size], color: scoreColor(score), lineHeight: 1 }}>
<Typography sx={{ fontWeight: 800, fontSize: FONT_SIZES[size], color: matchScoreHex(score), lineHeight: 1 }}>
{score}
</Typography>
<Typography variant="caption" color="text.secondary">/100</Typography>
@@ -6,6 +6,8 @@ import type { FutureSignal } from '../../domain/futureSignal'
import { CREDIBILITY_LABELS, HARD_KEYS, factorLabel } from './scoreBreakdownConstants'
import { CriterionRow } from './CriterionRow'
import { criterionScoreColor, criterionScoreTextColor } from '../../lib/utils'
import { DS_COLORS } from '../../lib/ds'
import { SCORE_STRONG, SCORE_MODERATE } from '../../lib/constants'
// ── Standard breakdown (VERIFIED_PORTFOLIO / EXTERNAL / MAISON) ──────────────
@@ -38,17 +40,17 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps)
return (
<>
{/* Hard criteria */}
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 1.25 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'primary.main', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 1.25 }}>
{hasAllFactors ? 'Hart-Kriterien' : 'Bewertungsfaktoren'}
</Typography>
{hardFactors.map((f, i) => <CriterionRow key={i} factor={f} maxWeight={maxHardWeight} />)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#eef2ff', px: 1.25, py: 0.75, borderRadius: 1, mt: 0.25, mb: hasAllFactors && softFactors.length > 0 ? 2 : 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'primary.main' }}>
Hart-Kriterien {sb.hardMatchScore}/100 × 60%
</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1e3a5f' }}>
<Typography variant="caption" sx={{ fontWeight: 800, color: 'primary.main' }}>
{hardContrib} Pkt
</Typography>
</Box>
@@ -120,7 +122,7 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps)
{/* Total */}
<Box sx={{
display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
bgcolor: baseSum >= 78 ? '#f0fdf4' : baseSum >= 52 ? '#fffbeb' : '#fef2f2',
bgcolor: baseSum >= SCORE_STRONG ? '#f0fdf4' : baseSum >= SCORE_MODERATE ? '#fffbeb' : '#fef2f2',
p: 1.5, borderRadius: 1,
}}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
@@ -161,7 +163,7 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
<>
{/* Schritt 1 — Kriterien-Match */}
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#6d28d9', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_COLORS.futureCard.controlled.accent, textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 0.75 }}>
Schritt 1 Kriterien-Match
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.25, lineHeight: 1.4 }}>
@@ -205,7 +207,7 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
{/* Schritt 2 — Verlässlichkeit */}
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#6d28d9', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_COLORS.futureCard.controlled.accent, textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 0.75 }}>
Schritt 2 Verlässlichkeit des Signals
</Typography>
+8 -10
View File
@@ -1,6 +1,7 @@
import { Box, Tooltip, Typography } from '@mui/material'
import { Flame } from 'lucide-react'
import { getHeatLevel, heatTooltip } from '../../lib/propertyHeat'
import { DS_COLORS } from '../../lib/ds'
interface Props {
propertyId: string | undefined
@@ -13,27 +14,24 @@ export function HeatBadge({ propertyId, size = 'md' }: Props) {
const isVeryHot = level === 'VERY_HOT'
const tooltip = heatTooltip(propertyId)
const colors = isVeryHot ? DS_COLORS.heat.VERY_HOT : DS_COLORS.heat.HOT
const iconSize = size === 'sm' ? 10 : 12
const fontSize = size === 'sm' ? '0.6rem' : '0.65rem'
const label = isVeryHot ? 'Sehr gefragt' : 'Gefragt'
const bgColor = isVeryHot ? '#fef3c7' : '#fff7ed'
const border = isVeryHot ? '1px solid #fcd34d' : '1px solid #fed7aa'
const iconColor = isVeryHot ? '#d97706' : '#ea580c'
const textColor = isVeryHot ? '#92400e' : '#c2410c'
const iconSize = size === 'sm' ? 10 : 12
const fontSize = size === 'sm' ? '0.6rem' : '0.65rem'
const label = isVeryHot ? 'Sehr gefragt' : 'Gefragt'
return (
<Tooltip title={tooltip} placement="top" arrow>
<Box
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.35,
bgcolor: bgColor, border, borderRadius: 1,
bgcolor: colors.bg, border: `1px solid ${colors.border}`, borderRadius: 1,
px: size === 'sm' ? 0.6 : 0.75, py: 0.15,
cursor: 'default',
}}
>
<Flame size={iconSize} color={iconColor} fill={isVeryHot ? iconColor : 'none'} />
<Typography sx={{ fontSize, fontWeight: 700, color: textColor, lineHeight: 1.2 }}>
<Flame size={iconSize} color={colors.icon} fill={isVeryHot ? colors.icon : 'none'} />
<Typography sx={{ fontSize, fontWeight: 700, color: colors.text, lineHeight: 1.2 }}>
{label}
</Typography>
</Box>
+32
View File
@@ -42,8 +42,40 @@ export const DS_COLORS = {
LOW: { bg: 'rgba(192,57,43,0.10)', fg: '#c0392b' },
INCOMPLETE: { bg: 'rgba(127,0,0,0.12)', fg: '#7f1d1d' },
},
heat: {
VERY_HOT: { bg: '#fef3c7', border: '#fcd34d', icon: '#d97706', text: '#92400e' },
HOT: { bg: '#fff7ed', border: '#fed7aa', icon: '#ea580c', text: '#c2410c' },
},
futureCard: {
controlled: {
accent: '#7c3aed',
accentHover: '#6d28d9',
headerBg: '#faf5ff',
border: '#e9d5ff',
badgeBg: '#ede9fe',
badgeText: '#5b21b6',
},
signal: {
accent: '#1d4ed8',
accentHover: '#1e40af',
headerBg: '#eff6ff',
border: '#bfdbfe',
badgeBg: '#dbeafe',
badgeText: '#1e40af',
indicator: '#1d4ed8',
},
},
} as const
// ── Inquiry status metadata ───────────────────────────────────────────────────
export const INQUIRY_STATUS_META: Record<string, { label: string; fg: string; bg: string }> = {
new: { label: 'Neu', fg: '#dc2626', bg: '#fef2f2' },
in_progress: { label: 'Aktiv', fg: '#d97706', bg: '#fffbeb' },
answered: { label: 'Beantwortet', fg: '#1a7a4a', bg: '#f0fdf4' },
archived: { label: 'Archiviert', fg: '#64748b', bg: '#f8fafc' },
}
// ── Score → qualitative level helpers ────────────────────────────────────────
export function scoreToConfidenceLevel(score: number): ConfidenceLevel {
+14 -19
View File
@@ -12,16 +12,10 @@ import type { InquiryMessage } from '../../domain/inquiry'
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
import { INQUIRY_STATUS_META, DS_COLORS } from '../../lib/ds'
// ── Config ────────────────────────────────────────────────────────────────────
const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: string }> = {
new: { label: 'Neu', color: '#dc2626', bgColor: '#fef2f2' },
in_progress:{ label: 'Aktiv', color: '#d97706', bgColor: '#fffbeb' },
answered: { label: 'Beantwortet', color: '#1a7a4a', bgColor: '#f0fdf4' },
archived: { label: 'Archiviert', color: '#64748b', bgColor: '#f8fafc' },
}
const FILTER_TABS = [
{ key: 'all', label: 'Alle' },
{ key: 'new', label: 'Neu' },
@@ -147,7 +141,7 @@ export default function Anfragen() {
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1rem' }}>Anfragen</Typography>
{totalUnread > 0 && (
<Chip label={`${totalUnread} neu`} size="small"
sx={{ bgcolor: '#dc2626', color: 'white', fontWeight: 700, height: 20, fontSize: '0.7rem' }} />
sx={{ bgcolor: INQUIRY_STATUS_META.new.fg, color: 'white', fontWeight: 700, height: 20, fontSize: '0.7rem' }} />
)}
</Box>
<TextField
@@ -161,10 +155,10 @@ export default function Anfragen() {
<Chip key={tab.key} label={tab.label} size="small" onClick={() => setStatusFilter(tab.key)}
sx={{
height: 22, fontSize: '0.7rem', cursor: 'pointer',
bgcolor: statusFilter === tab.key ? '#1e3a5f' : '#f1f5f9',
bgcolor: statusFilter === tab.key ? 'primary.main' : '#f1f5f9',
color: statusFilter === tab.key ? 'white' : '#475569',
fontWeight: statusFilter === tab.key ? 700 : 400,
'&:hover': { bgcolor: statusFilter === tab.key ? '#1a3050' : '#e2e8f0' },
'&:hover': { bgcolor: statusFilter === tab.key ? 'primary.dark' : '#e2e8f0' },
}}
/>
))}
@@ -210,7 +204,7 @@ export default function Anfragen() {
<IconButton size="small" sx={{ display: { md: 'none' }, mr: -0.5 }} onClick={() => setMobileShowChat(false)}>
<ArrowLeft size={16} />
</IconButton>
<Avatar sx={{ width: 36, height: 36, bgcolor: '#1e3a5f', fontSize: '0.8rem', flexShrink: 0 }}>
<Avatar sx={{ width: 36, height: 36, bgcolor: 'primary.main', fontSize: '0.8rem', flexShrink: 0 }}>
{selected.tenantName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}>
@@ -232,9 +226,9 @@ export default function Anfragen() {
}} />
)}
<Chip
label={STATUS_CONFIG[selected.status ?? 'new']?.label ?? selected.status}
label={INQUIRY_STATUS_META[selected.status ?? 'new']?.label ?? selected.status}
size="small"
sx={{ bgcolor: STATUS_CONFIG[selected.status ?? 'new']?.bgColor, color: STATUS_CONFIG[selected.status ?? 'new']?.color, fontWeight: 600, height: 22, fontSize: '0.75rem' }}
sx={{ bgcolor: INQUIRY_STATUS_META[selected.status ?? 'new']?.bg, color: INQUIRY_STATUS_META[selected.status ?? 'new']?.fg, fontWeight: 600, height: 22, fontSize: '0.75rem' }}
/>
{/* Pipeline link */}
{linkedPipelineItem && (
@@ -245,10 +239,11 @@ export default function Anfragen() {
onClick={() => navigate('/demand/pipeline')}
sx={{
height: 22, fontSize: '0.75rem', cursor: 'pointer',
bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600,
border: '1px solid #bfdbfe',
'& .MuiChip-icon': { color: '#1e3a5f' },
'&:hover': { bgcolor: '#dbeafe' },
bgcolor: DS_COLORS.futureCard.signal.headerBg, fontWeight: 600,
color: 'primary.main',
border: `1px solid ${DS_COLORS.futureCard.signal.border}`,
'& .MuiChip-icon': { color: 'primary.main' },
'&:hover': { bgcolor: DS_COLORS.futureCard.signal.badgeBg },
}}
/>
)}
@@ -273,7 +268,7 @@ export default function Anfragen() {
severity="info"
icon={<Bot size={16} />}
onClose={() => setKiAlert(null)}
sx={{ py: 0.5, bgcolor: '#faf5ff', color: '#4c1d95', border: '1px solid #ddd6fe', '& .MuiAlert-icon': { color: '#7c3aed' } }}
sx={{ py: 0.5, bgcolor: DS_COLORS.futureCard.controlled.headerBg, color: DS_COLORS.futureCard.controlled.badgeText, border: `1px solid ${DS_COLORS.futureCard.controlled.border}`, '& .MuiAlert-icon': { color: DS_COLORS.futureCard.controlled.accent } }}
>
<strong>KI erkannt:</strong> {kiAlert.title}" wurde in der Pipeline auf <strong>{kiAlert.stage}</strong> verschoben.{' '}
<Box component="span" sx={{ cursor: 'pointer', textDecoration: 'underline' }} onClick={() => navigate('/demand/pipeline')}>
@@ -304,7 +299,7 @@ export default function Anfragen() {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<IconButton size="small" sx={{ color: '#94a3b8' }}><Paperclip size={16} /></IconButton>
<IconButton size="small" onClick={handleSend} disabled={!replyText.trim()}
sx={{ bgcolor: '#1e3a5f', color: 'white', '&:hover': { bgcolor: '#1a3050' }, '&:disabled': { bgcolor: '#e2e8f0', color: '#94a3b8' } }}>
sx={{ bgcolor: 'primary.main', color: 'white', '&:hover': { bgcolor: 'primary.dark' }, '&:disabled': { bgcolor: '#e2e8f0', color: '#94a3b8' } }}>
<Send size={16} />
</IconButton>
</Box>