feat(design-system): F003 – complete design system foundation

Design tokens:
- lib/ds.ts: DS_COLORS (resultType/confidence/risk/availability/freshness/dataQuality), scoreToConfidenceLevel(), scoreToDataQualityLevel()
- lib/constants.ts: added CONFIDENCE_LABELS, DATA_QUALITY_LABELS

Badge system (src/components/badges/):
- ResultTypeBadge, ConfidenceBadge, RiskBadge, AvailabilityBadge, FreshnessBadge, DataQualityBadge
- All use DS_COLORS, MUI Chip size=small, lucide icons, German labels from constants

Score components (src/components/scores/):
- ConfidenceScore: numeric % with color from utils.confidenceHex
- DataQualityScore: LinearProgress + qualitative label
- ScoreBar: horizontal bar with label/value/weight
- ScoreBreakdownMini: ScoreFactor[] mapped to ScoreBars with Tooltip explanations

Card system (src/components/cards/):
- DecisionCard: header+badges+score+body+actions, selected/loading/restricted states
- CompactCard: single-row 48px card with leading/trailing slots
- MetricCard: KPI card with label/value/delta/icon (extracted pattern from SupplyDashboard)

Panel system (src/components/panels/):
- InfoPanel: outlined Paper with icon+title header
- DetailPanel: Accordion sections in bordered Paper
- WarningPanel: MUI Alert with warning/critical severity

Table components (src/components/tables/):
- DataTableShell: Paper+TableContainer+Table with loading/empty/toolbar slots
- TableToolbar: flex row with title/count/filters/actions
- FilterBar: deletable Chips with clear-all
- EmptyTableState: centered empty row spanning all cols
- LoadingRows: Skeleton cell rows

Form components (src/components/forms/):
- TextInput: MUI TextField wrapper with onChange(string) signature
- SelectField: generic SelectField<T extends string> with MUI Select
- PriorityChipGroup: togglable Chip set (single/multi select)
- FieldWithConfidence: label+value+ConfidenceBadge for AI-parsed fields

UI additions (src/components/ui/):
- CardSkeleton, PanelLoadingState, UnauthorizedState, RestrictedState

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-15 11:34:25 +02:00
parent 5ef8b9d69d
commit 70c0d79d8c
38 changed files with 1385 additions and 0 deletions
@@ -0,0 +1,39 @@
import { Chip } from '@mui/material'
import { CheckCircle2, Clock, Sparkles, XCircle, HelpCircle } from 'lucide-react'
import type { AvailabilityStatus } from '../../domain/enums'
import { DS_COLORS } from '../../lib/ds'
import { AVAILABILITY_LABELS } from '../../lib/constants'
interface AvailabilityBadgeProps {
status: AvailabilityStatus
size?: 'small' | 'medium'
}
const ICONS: Record<AvailabilityStatus, React.ElementType> = {
AVAILABLE_NOW: CheckCircle2,
AVAILABLE_SOON: Clock,
FUTURE_SIGNAL: Sparkles,
OCCUPIED: XCircle,
UNKNOWN: HelpCircle,
}
export function AvailabilityBadge({ status, size = 'small' }: AvailabilityBadgeProps) {
const { bg, fg } = DS_COLORS.availability[status]
const Icon = ICONS[status]
return (
<Chip
size={size}
label={AVAILABILITY_LABELS[status] ?? status}
icon={<Icon size={11} color={fg} />}
aria-label={`Verfügbarkeit: ${AVAILABILITY_LABELS[status] ?? status}`}
sx={{
bgcolor: bg,
color: fg,
border: 'none',
fontWeight: 600,
fontSize: '0.7rem',
'& .MuiChip-icon': { ml: 0.5 },
}}
/>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { Chip } from '@mui/material'
import { ShieldCheck } from 'lucide-react'
import type { ConfidenceLevel } from '../../domain/enums'
import { DS_COLORS, scoreToConfidenceLevel } from '../../lib/ds'
import { CONFIDENCE_LABELS } from '../../lib/constants'
interface ConfidenceBadgeProps {
level?: ConfidenceLevel
score?: number
size?: 'small' | 'medium'
}
export function ConfidenceBadge({ level, score, size = 'small' }: ConfidenceBadgeProps) {
const resolved: ConfidenceLevel =
level ?? (score !== undefined ? scoreToConfidenceLevel(score) : 'MEDIUM')
const { bg, fg } = DS_COLORS.confidence[resolved]
const scoreLabel = score !== undefined ? ` (${Math.round(score * 100)}%)` : ''
const label = `${CONFIDENCE_LABELS[resolved] ?? resolved}${scoreLabel}`
return (
<Chip
size={size}
label={label}
icon={<ShieldCheck size={11} color={fg} />}
aria-label={`Konfidenz: ${label}`}
sx={{
bgcolor: bg,
color: fg,
border: 'none',
fontWeight: 600,
fontSize: '0.7rem',
'& .MuiChip-icon': { ml: 0.5 },
}}
/>
)
}
@@ -0,0 +1,44 @@
import { Chip } from '@mui/material'
import { CheckCircle2, AlertTriangle, XCircle } from 'lucide-react'
import type { DataQualityLevel } from '../../domain/enums'
import { DS_COLORS, scoreToDataQualityLevel } from '../../lib/ds'
import { DATA_QUALITY_LABELS } from '../../lib/constants'
interface DataQualityBadgeProps {
level?: DataQualityLevel
score?: number
showScore?: boolean
size?: 'small' | 'medium'
}
const ICONS: Record<DataQualityLevel, React.ElementType> = {
HIGH: CheckCircle2,
MEDIUM: AlertTriangle,
LOW: XCircle,
INCOMPLETE: XCircle,
}
export function DataQualityBadge({ level, score, showScore = false, size = 'small' }: DataQualityBadgeProps) {
const resolved: DataQualityLevel =
level ?? (score !== undefined ? scoreToDataQualityLevel(score) : 'LOW')
const { bg, fg } = DS_COLORS.dataQuality[resolved]
const Icon = ICONS[resolved]
const scoreLabel = showScore && score !== undefined ? ` ${Math.round(score * 100)}%` : ''
const label = `${DATA_QUALITY_LABELS[resolved] ?? resolved}${scoreLabel}`
return (
<Chip
size={size}
label={label}
icon={<Icon size={11} color={fg} />}
aria-label={`Datenqualität: ${label}`}
sx={{
bgcolor: bg,
color: fg,
border: 'none',
fontWeight: 600,
fontSize: '0.7rem',
'& .MuiChip-icon': { ml: 0.5 },
}}
/>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { Chip } from '@mui/material'
import { Zap, Clock, AlertCircle } from 'lucide-react'
import type { FreshnessStatus } from '../../domain/enums'
import { DS_COLORS } from '../../lib/ds'
import { FRESHNESS_LABELS } from '../../lib/constants'
interface FreshnessBadgeProps {
status: FreshnessStatus
size?: 'small' | 'medium'
}
const ICONS: Record<FreshnessStatus, React.ElementType> = {
FRESH: Zap,
STALE: Clock,
OUTDATED: AlertCircle,
}
export function FreshnessBadge({ status, size = 'small' }: FreshnessBadgeProps) {
const { bg, fg } = DS_COLORS.freshness[status]
const Icon = ICONS[status]
return (
<Chip
size={size}
label={FRESHNESS_LABELS[status] ?? status}
icon={<Icon size={11} color={fg} />}
aria-label={`Datenaktualität: ${FRESHNESS_LABELS[status] ?? status}`}
sx={{
bgcolor: bg,
color: fg,
border: 'none',
fontWeight: 600,
fontSize: '0.7rem',
'& .MuiChip-icon': { ml: 0.5 },
}}
/>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { Chip } from '@mui/material'
import { ShieldCheck, Globe, Sparkles } from 'lucide-react'
import type { ResultType } from '../../domain/enums'
import { DS_COLORS } from '../../lib/ds'
import { RESULT_TYPE_LABELS } from '../../lib/constants'
interface ResultTypeBadgeProps {
type: ResultType
size?: 'small' | 'medium'
}
const ICONS: Record<ResultType, React.ElementType> = {
VERIFIED_PORTFOLIO: ShieldCheck,
EXTERNAL_MARKET: Globe,
FUTURE_AVAILABILITY: Sparkles,
}
export function ResultTypeBadge({ type, size = 'small' }: ResultTypeBadgeProps) {
const { bg, fg } = DS_COLORS.resultType[type]
const Icon = ICONS[type]
return (
<Chip
size={size}
label={RESULT_TYPE_LABELS[type] ?? type}
icon={<Icon size={11} color={fg} />}
aria-label={RESULT_TYPE_LABELS[type] ?? type}
sx={{
bgcolor: bg,
color: fg,
border: 'none',
fontWeight: 600,
fontSize: '0.7rem',
'& .MuiChip-icon': { ml: 0.5 },
}}
/>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { Chip } from '@mui/material'
import { AlertTriangle, Shield } from 'lucide-react'
import type { RiskLevel } from '../../domain/enums'
import { DS_COLORS } from '../../lib/ds'
import { RISK_LABELS } from '../../lib/constants'
interface RiskBadgeProps {
level: RiskLevel
size?: 'small' | 'medium'
}
export function RiskBadge({ level, size = 'small' }: RiskBadgeProps) {
const { bg, fg } = DS_COLORS.risk[level]
const Icon = level === 'LOW' || level === 'MEDIUM' ? Shield : AlertTriangle
return (
<Chip
size={size}
label={RISK_LABELS[level] ?? level}
icon={<Icon size={11} color={fg} />}
aria-label={`Risiko: ${RISK_LABELS[level] ?? level}`}
sx={{
bgcolor: bg,
color: fg,
border: 'none',
fontWeight: 600,
fontSize: '0.7rem',
'& .MuiChip-icon': { ml: 0.5 },
}}
/>
)
}
+6
View File
@@ -0,0 +1,6 @@
export { ResultTypeBadge } from './ResultTypeBadge'
export { ConfidenceBadge } from './ConfidenceBadge'
export { RiskBadge } from './RiskBadge'
export { AvailabilityBadge } from './AvailabilityBadge'
export { FreshnessBadge } from './FreshnessBadge'
export { DataQualityBadge } from './DataQualityBadge'
+53
View File
@@ -0,0 +1,53 @@
import { Box, Card, Typography } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
import type { ReactNode } from 'react'
interface CompactCardProps {
title: string
meta?: string
leading?: ReactNode
trailing?: ReactNode
onClick?: () => void
selected?: boolean
sx?: SxProps<Theme>
}
export function CompactCard({ title, meta, leading, trailing, onClick, selected = false, sx }: CompactCardProps) {
return (
<Card
onClick={onClick}
sx={{
outline: selected ? '2px solid #1e3a5f' : 'none',
outlineOffset: -1,
bgcolor: selected ? 'rgba(30,58,95,0.03)' : 'background.paper',
cursor: onClick ? 'pointer' : 'default',
'&:hover': onClick ? { bgcolor: 'rgba(0,0,0,0.015)' } : {},
...sx,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2,
py: 1,
minHeight: 48,
}}
>
{leading && <Box sx={{ flexShrink: 0 }}>{leading}</Box>}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontWeight: 500, fontSize: '0.875rem', lineHeight: 1.3 }} noWrap>
{title}
</Typography>
{meta && (
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary', lineHeight: 1.3 }} noWrap>
{meta}
</Typography>
)}
</Box>
{trailing && <Box sx={{ flexShrink: 0 }}>{trailing}</Box>}
</Box>
</Card>
)
}
+95
View File
@@ -0,0 +1,95 @@
import { Box, Card, CardActions, CardContent, Divider, Typography } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
import type { ReactNode } from 'react'
import { CardSkeleton } from '../ui/CardSkeleton'
import { RestrictedState } from '../ui/RestrictedState'
interface DecisionCardProps {
title: string
subtitle?: string
badges?: ReactNode
score?: ReactNode
body?: ReactNode
actions?: ReactNode
selected?: boolean
isLoading?: boolean
isRestricted?: boolean
onClick?: () => void
sx?: SxProps<Theme>
}
export function DecisionCard({
title,
subtitle,
badges,
score,
body,
actions,
selected = false,
isLoading = false,
isRestricted = false,
onClick,
sx,
}: DecisionCardProps) {
if (isLoading) return <CardSkeleton hasActions={!!actions} />
return (
<Card
onClick={onClick}
sx={{
outline: selected ? '2px solid #1e3a5f' : 'none',
outlineOffset: -1,
bgcolor: selected ? 'rgba(30,58,95,0.03)' : 'background.paper',
cursor: onClick ? 'pointer' : 'default',
transition: 'outline 0.1s, background-color 0.1s',
'&:hover': onClick ? { bgcolor: 'rgba(0,0,0,0.01)' } : {},
position: 'relative',
...sx,
}}
>
{isRestricted ? (
<RestrictedState sx={{ py: 5 }} />
) : (
<>
<CardContent sx={{ pb: body || actions ? 1 : 2 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
sx={{ fontWeight: 600, fontSize: '0.9375rem', lineHeight: 1.3, mb: subtitle ? 0.25 : 0 }}
noWrap
>
{title}
</Typography>
{subtitle && (
<Typography sx={{ fontSize: '0.8125rem', color: 'text.secondary', lineHeight: 1.4 }}>
{subtitle}
</Typography>
)}
</Box>
{score && <Box sx={{ flexShrink: 0 }}>{score}</Box>}
</Box>
{badges && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1 }}>
{badges}
</Box>
)}
</CardContent>
{body && (
<>
<Divider />
<CardContent sx={{ py: 1.5 }}>{body}</CardContent>
</>
)}
{actions && (
<>
<Divider />
<CardActions sx={{ px: 2, py: 1 }}>{actions}</CardActions>
</>
)}
</>
)}
</Card>
)
}
+64
View File
@@ -0,0 +1,64 @@
import { Box, Card, Typography } from '@mui/material'
import { TrendingUp, TrendingDown } from 'lucide-react'
import type { SxProps, Theme } from '@mui/material'
import type { ReactNode } from 'react'
interface MetricDelta {
value: string
positive: boolean
}
interface MetricCardProps {
label: string
value: string | number
delta?: MetricDelta
icon?: ReactNode
color?: string
sx?: SxProps<Theme>
}
export function MetricCard({ label, value, delta, icon, color = '#1e3a5f', sx }: MetricCardProps) {
return (
<Card sx={{ p: 2.5, ...sx }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<Box>
<Typography
sx={{ fontSize: '0.6875rem', fontWeight: 600, color: 'text.disabled', textTransform: 'uppercase', letterSpacing: '0.08em', mb: 0.5 }}
>
{label}
</Typography>
<Typography sx={{ fontSize: '1.75rem', fontWeight: 700, color, lineHeight: 1, fontVariantNumeric: 'tabular-nums' }}>
{value}
</Typography>
{delta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.5 }}>
{delta.positive
? <TrendingUp size={12} color="#1a7a4a" />
: <TrendingDown size={12} color="#c0392b" />}
<Typography
sx={{ fontSize: '0.75rem', color: delta.positive ? '#1a7a4a' : '#c0392b', fontWeight: 500 }}
>
{delta.value}
</Typography>
</Box>
)}
</Box>
{icon && (
<Box
sx={{
width: 40,
height: 40,
borderRadius: 1.5,
bgcolor: `${color}18`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{icon}
</Box>
)}
</Box>
</Card>
)
}
+3
View File
@@ -1 +1,4 @@
export { SourceTypeBadge } from './SourceTypeBadge'
export { DecisionCard } from './DecisionCard'
export { CompactCard } from './CompactCard'
export { MetricCard } from './MetricCard'
@@ -0,0 +1,53 @@
import { Box, Typography } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
import type { ReactNode } from 'react'
import type { ConfidenceLevel } from '../../domain/enums'
import { ConfidenceBadge } from '../badges/ConfidenceBadge'
interface FieldWithConfidenceProps {
label: string
value: ReactNode
confidence?: number
confidenceLevel?: ConfidenceLevel
layout?: 'row' | 'column'
sx?: SxProps<Theme>
}
export function FieldWithConfidence({
label,
value,
confidence,
confidenceLevel,
layout = 'column',
sx,
}: FieldWithConfidenceProps) {
const showBadge = confidence !== undefined || confidenceLevel !== undefined
if (layout === 'row') {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, ...sx }}>
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary', minWidth: 80 }}>
{label}
</Typography>
<Box sx={{ flex: 1 }}>{value}</Box>
{showBadge && (
<ConfidenceBadge level={confidenceLevel} score={confidence} />
)}
</Box>
)
}
return (
<Box sx={sx}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.25 }}>
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>
{label}
</Typography>
{showBadge && (
<ConfidenceBadge level={confidenceLevel} score={confidence} />
)}
</Box>
<Box sx={{ fontSize: '0.875rem' }}>{value}</Box>
</Box>
)
}
@@ -0,0 +1,64 @@
import { Box, Chip, Typography } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
interface ChipOption {
value: string
label: string
color?: string
}
interface PriorityChipGroupProps {
label?: string
options: ChipOption[]
value: string[]
onChange: (value: string[]) => void
exclusive?: boolean
sx?: SxProps<Theme>
}
export function PriorityChipGroup({ label, options, value, onChange, exclusive = false, sx }: PriorityChipGroupProps) {
function toggle(optValue: string) {
if (exclusive) {
onChange(value.includes(optValue) ? [] : [optValue])
} else {
onChange(
value.includes(optValue)
? value.filter(v => v !== optValue)
: [...value, optValue],
)
}
}
return (
<Box sx={sx}>
{label && (
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary', mb: 0.75, fontWeight: 500 }}>
{label}
</Typography>
)}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{options.map(opt => {
const selected = value.includes(opt.value)
const accent = opt.color ?? '#1e3a5f'
return (
<Chip
key={opt.value}
label={opt.label}
size="small"
clickable
onClick={() => toggle(opt.value)}
sx={{
fontSize: '0.75rem',
bgcolor: selected ? accent : 'transparent',
color: selected ? '#fff' : 'text.secondary',
border: '1px solid',
borderColor: selected ? accent : 'divider',
'&:hover': { bgcolor: selected ? accent : 'rgba(0,0,0,0.04)' },
}}
/>
)
})}
</Box>
</Box>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { FormControl, FormHelperText, InputLabel, MenuItem, Select } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
interface SelectOption<T extends string = string> {
value: T
label: string
}
interface SelectFieldProps<T extends string = string> {
label: string
value: T | ''
onChange: (value: T) => void
options: SelectOption<T>[]
error?: string
helperText?: string
required?: boolean
disabled?: boolean
size?: 'small' | 'medium'
fullWidth?: boolean
sx?: SxProps<Theme>
}
export function SelectField<T extends string = string>({
label,
value,
onChange,
options,
error,
helperText,
required,
disabled,
size = 'small',
fullWidth = true,
sx,
}: SelectFieldProps<T>) {
const labelId = `select-${label.replace(/\s+/g, '-').toLowerCase()}`
return (
<FormControl fullWidth={fullWidth} size={size} error={!!error} disabled={disabled} required={required} sx={sx}>
<InputLabel id={labelId}>{label}</InputLabel>
<Select
labelId={labelId}
value={value}
label={label}
onChange={e => onChange(e.target.value as T)}
>
{options.map(opt => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
{(error ?? helperText) && <FormHelperText>{error ?? helperText}</FormHelperText>}
</FormControl>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { TextField } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
interface TextInputProps {
label: string
value: string
onChange: (value: string) => void
error?: string
helperText?: string
placeholder?: string
required?: boolean
disabled?: boolean
multiline?: boolean
rows?: number
size?: 'small' | 'medium'
fullWidth?: boolean
sx?: SxProps<Theme>
}
export function TextInput({
label,
value,
onChange,
error,
helperText,
placeholder,
required,
disabled,
multiline,
rows,
size = 'small',
fullWidth = true,
sx,
}: TextInputProps) {
return (
<TextField
label={label}
value={value}
onChange={e => onChange(e.target.value)}
error={!!error}
helperText={error ?? helperText}
placeholder={placeholder}
required={required}
disabled={disabled}
multiline={multiline}
rows={rows}
size={size}
fullWidth={fullWidth}
sx={sx}
/>
)
}
+4
View File
@@ -0,0 +1,4 @@
export { TextInput } from './TextInput'
export { SelectField } from './SelectField'
export { PriorityChipGroup } from './PriorityChipGroup'
export { FieldWithConfidence } from './FieldWithConfidence'
+46
View File
@@ -0,0 +1,46 @@
import { Accordion, AccordionDetails, AccordionSummary, Paper, Typography } from '@mui/material'
import { ChevronDown } from 'lucide-react'
import type { SxProps, Theme } from '@mui/material'
import type { ReactNode } from 'react'
interface DetailSection {
title: string
defaultExpanded?: boolean
children: ReactNode
}
interface DetailPanelProps {
sections: DetailSection[]
sx?: SxProps<Theme>
}
export function DetailPanel({ sections, sx }: DetailPanelProps) {
return (
<Paper variant="outlined" sx={{ borderRadius: 1.5, overflow: 'hidden', ...sx }}>
{sections.map((section, i) => (
<Accordion
key={i}
defaultExpanded={section.defaultExpanded ?? i === 0}
disableGutters
elevation={0}
sx={{
'&:not(:last-child)': { borderBottom: '1px solid', borderColor: 'divider' },
'&::before': { display: 'none' },
}}
>
<AccordionSummary
expandIcon={<ChevronDown size={16} color="#64748b" />}
sx={{ px: 2, py: 0, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}
>
<Typography sx={{ fontWeight: 600, fontSize: '0.8125rem' }}>
{section.title}
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, py: 1.5, borderTop: '1px solid', borderColor: 'divider' }}>
{section.children}
</AccordionDetails>
</Accordion>
))}
</Paper>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { Box, Divider, Paper, Typography } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
import type { ReactNode } from 'react'
interface InfoPanelProps {
title?: string
icon?: ReactNode
children: ReactNode
sx?: SxProps<Theme>
}
export function InfoPanel({ title, icon, children, sx }: InfoPanelProps) {
return (
<Paper
variant="outlined"
sx={{ borderRadius: 1.5, overflow: 'hidden', ...sx }}
>
{title && (
<>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 2, py: 1.25 }}>
{icon}
<Typography sx={{ fontWeight: 600, fontSize: '0.8125rem', color: 'text.primary' }}>
{title}
</Typography>
</Box>
<Divider />
</>
)}
<Box sx={{ p: 2 }}>{children}</Box>
</Paper>
)
}
+26
View File
@@ -0,0 +1,26 @@
import { Alert, AlertTitle, Box } from '@mui/material'
import type { ReactNode } from 'react'
interface WarningPanelProps {
title: string
description?: string
severity: 'warning' | 'critical'
actions?: ReactNode
children?: ReactNode
}
export function WarningPanel({ title, description, severity, actions, children }: WarningPanelProps) {
return (
<Alert
severity={severity === 'critical' ? 'error' : 'warning'}
sx={{ alignItems: 'flex-start' }}
>
<AlertTitle sx={{ fontWeight: 600, mb: description || children ? 0.5 : 0 }}>
{title}
</AlertTitle>
{description && <Box sx={{ fontSize: '0.8125rem', mb: children ? 1 : 0 }}>{description}</Box>}
{children && <Box sx={{ mt: 0.5 }}>{children}</Box>}
{actions && <Box sx={{ mt: 1.5 }}>{actions}</Box>}
</Alert>
)
}
+3
View File
@@ -0,0 +1,3 @@
export { InfoPanel } from './InfoPanel'
export { DetailPanel } from './DetailPanel'
export { WarningPanel } from './WarningPanel'
+37
View File
@@ -0,0 +1,37 @@
import { Box, Typography } from '@mui/material'
import { confidenceHex } from '../../lib/utils'
import { scoreToConfidenceLevel } from '../../lib/ds'
import { ConfidenceBadge } from '../badges/ConfidenceBadge'
interface ConfidenceScoreProps {
score: number
showLabel?: boolean
compact?: boolean
}
export function ConfidenceScore({ score, showLabel = false, compact = false }: ConfidenceScoreProps) {
const color = confidenceHex(score)
const pct = `${Math.round(score * 100)}%`
if (compact) {
return (
<Typography
component="span"
sx={{ fontSize: '0.875rem', fontWeight: 700, color, fontVariantNumeric: 'tabular-nums' }}
>
{pct}
</Typography>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 0.5 }}>
<Typography sx={{ fontSize: '1.25rem', fontWeight: 700, color, lineHeight: 1, fontVariantNumeric: 'tabular-nums' }}>
{pct}
</Typography>
{showLabel && (
<ConfidenceBadge level={scoreToConfidenceLevel(score)} />
)}
</Box>
)
}
@@ -0,0 +1,48 @@
import { Box, LinearProgress, Typography } from '@mui/material'
import { dataQualityHex } from '../../lib/utils'
import { scoreToDataQualityLevel } from '../../lib/ds'
import { DATA_QUALITY_LABELS } from '../../lib/constants'
interface DataQualityScoreProps {
score: number
compact?: boolean
}
export function DataQualityScore({ score, compact = false }: DataQualityScoreProps) {
const color = dataQualityHex(score)
const pct = Math.round(score * 100)
const level = scoreToDataQualityLevel(score)
const label = DATA_QUALITY_LABELS[level] ?? level
if (compact) {
return (
<Typography
component="span"
sx={{ fontSize: '0.875rem', fontWeight: 700, color, fontVariantNumeric: 'tabular-nums' }}
>
{pct}%
</Typography>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, minWidth: 120 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>{label}</Typography>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color, fontVariantNumeric: 'tabular-nums' }}>
{pct}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={pct}
sx={{
height: 4,
borderRadius: 2,
bgcolor: 'rgba(0,0,0,0.06)',
'& .MuiLinearProgress-bar': { bgcolor: color, borderRadius: 2 },
}}
/>
</Box>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { Box, LinearProgress, Typography } from '@mui/material'
import { matchScoreColor } from '../../lib/utils'
const COLOR_MAP: Record<string, string> = {
success: '#1a7a4a',
warning: '#d97706',
error: '#c0392b',
}
interface ScoreBarProps {
label: string
value: number
weight?: number
color?: string
maxWidth?: number
}
export function ScoreBar({ label, value, weight, color, maxWidth = 200 }: ScoreBarProps) {
const resolved = color ?? COLOR_MAP[matchScoreColor(value)] ?? '#1e3a5f'
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', maxWidth }}>
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary', lineHeight: 1.4 }}>
{label}
</Typography>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: resolved, fontVariantNumeric: 'tabular-nums', ml: 1 }}>
{value}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={Math.min(value, 100)}
sx={{
height: 5,
borderRadius: 2,
maxWidth,
bgcolor: 'rgba(0,0,0,0.06)',
'& .MuiLinearProgress-bar': { bgcolor: resolved, borderRadius: 2 },
}}
/>
{weight !== undefined && (
<Typography sx={{ fontSize: '0.68rem', color: 'text.disabled', lineHeight: 1 }}>
Gewicht: {Math.round(weight * 100)}%
</Typography>
)}
</Box>
)
}
@@ -0,0 +1,35 @@
import { Box, Tooltip, Typography } from '@mui/material'
import { ScoreBar } from './ScoreBar'
import type { ScoreFactor } from '../../domain/match'
interface ScoreBreakdownMiniProps {
factors: ScoreFactor[]
maxItems?: number
showContribution?: boolean
}
export function ScoreBreakdownMini({ factors, maxItems = 5, showContribution = false }: ScoreBreakdownMiniProps) {
const visible = factors.slice(0, maxItems)
const overflow = factors.length - maxItems
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{visible.map((f, i) => (
<Tooltip key={i} title={f.explanation} placement="top-start" arrow>
<Box sx={{ cursor: 'default' }}>
<ScoreBar
label={f.criterion}
value={f.score}
weight={showContribution ? f.weight : undefined}
/>
</Box>
</Tooltip>
))}
{overflow > 0 && (
<Typography sx={{ fontSize: '0.72rem', color: 'text.disabled' }}>
+{overflow} weitere Faktoren
</Typography>
)}
</Box>
)
}
+4
View File
@@ -1 +1,5 @@
export { MatchScoreRing } from './MatchScoreRing'
export { ConfidenceScore } from './ConfidenceScore'
export { DataQualityScore } from './DataQualityScore'
export { ScoreBar } from './ScoreBar'
export { ScoreBreakdownMini } from './ScoreBreakdownMini'
+54
View File
@@ -0,0 +1,54 @@
import { Paper, Table, TableBody, TableContainer } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
import type { ReactNode } from 'react'
import { LoadingRows } from './LoadingRows'
import { EmptyTableState } from './EmptyTableState'
interface DataTableShellProps {
toolbar?: ReactNode
children: ReactNode
loading?: boolean
loadingRows?: number
loadingCols?: number
empty?: boolean
emptySlot?: ReactNode
emptyColSpan?: number
stickyHeader?: boolean
size?: 'small' | 'medium'
sx?: SxProps<Theme>
}
export function DataTableShell({
toolbar,
children,
loading = false,
loadingRows = 5,
loadingCols = 6,
empty = false,
emptySlot,
emptyColSpan = 6,
stickyHeader = false,
size = 'small',
sx,
}: DataTableShellProps) {
return (
<Paper variant="outlined" sx={{ borderRadius: 1.5, overflow: 'hidden', ...sx }}>
{toolbar}
<TableContainer>
<Table size={size} stickyHeader={stickyHeader}>
{!loading && children}
{loading && (
<TableBody>
<LoadingRows rows={loadingRows} cols={loadingCols} />
</TableBody>
)}
{!loading && empty && (
<TableBody>
{emptySlot ?? <EmptyTableState colSpan={emptyColSpan} />}
</TableBody>
)}
</Table>
</TableContainer>
</Paper>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { Box, TableCell, TableRow, Typography } from '@mui/material'
import { Inbox } from 'lucide-react'
interface EmptyTableStateProps {
colSpan: number
title?: string
description?: string
}
export function EmptyTableState({ colSpan, title, description }: EmptyTableStateProps) {
return (
<TableRow>
<TableCell colSpan={colSpan} sx={{ border: 'none' }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
py: 5,
color: 'text.disabled',
}}
>
<Inbox size={32} color="#cbd5e1" />
<Typography sx={{ fontWeight: 500, fontSize: '0.875rem', color: 'text.secondary' }}>
{title ?? 'Keine Einträge gefunden'}
</Typography>
{description && (
<Typography sx={{ fontSize: '0.8125rem', color: 'text.disabled', textAlign: 'center', maxWidth: 360 }}>
{description}
</Typography>
)}
</Box>
</TableCell>
</TableRow>
)
}
+50
View File
@@ -0,0 +1,50 @@
import { Box, Chip, Typography } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
interface ActiveFilter {
key: string
label: string
onRemove: () => void
}
interface FilterBarProps {
filters: ActiveFilter[]
onClearAll?: () => void
sx?: SxProps<Theme>
}
export function FilterBar({ filters, onClearAll, sx }: FilterBarProps) {
if (filters.length === 0) return null
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 0.5, ...sx }}>
{filters.map(f => (
<Chip
key={f.key}
label={f.label}
size="small"
onDelete={f.onRemove}
sx={{ fontSize: '0.75rem', height: 24 }}
/>
))}
{onClearAll && filters.length > 1 && (
<Typography
component="button"
onClick={onClearAll}
sx={{
fontSize: '0.75rem',
color: 'text.secondary',
cursor: 'pointer',
background: 'none',
border: 'none',
p: 0,
ml: 0.5,
'&:hover': { color: 'primary.main', textDecoration: 'underline' },
}}
>
Alle löschen
</Typography>
)}
</Box>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { Skeleton, TableCell, TableRow } from '@mui/material'
interface LoadingRowsProps {
rows?: number
cols?: number
}
export function LoadingRows({ rows = 5, cols = 6 }: LoadingRowsProps) {
return (
<>
{Array.from({ length: rows }).map((_, ri) => (
<TableRow key={ri}>
{Array.from({ length: cols }).map((_, ci) => (
<TableCell key={ci}>
<Skeleton variant="text" height={18} width={ci === 0 ? '80%' : ci === cols - 1 ? '40%' : '65%'} />
</TableCell>
))}
</TableRow>
))}
</>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { Box, Chip, Typography } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
import type { ReactNode } from 'react'
interface TableToolbarProps {
title?: string
count?: number
filterSlot?: ReactNode
actions?: ReactNode
sx?: SxProps<Theme>
}
export function TableToolbar({ title, count, filterSlot, actions, sx }: TableToolbarProps) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
py: 1.25,
borderBottom: '1px solid',
borderColor: 'divider',
gap: 1,
flexWrap: 'wrap',
...sx,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flex: 1 }}>
{title && (
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem' }}>
{title}
</Typography>
)}
{count !== undefined && (
<Chip
label={count}
size="small"
sx={{ height: 20, fontSize: '0.7rem', bgcolor: 'rgba(30,58,95,0.08)', color: '#1e3a5f' }}
/>
)}
{filterSlot}
</Box>
{actions && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
{actions}
</Box>
)}
</Box>
)
}
+5
View File
@@ -0,0 +1,5 @@
export { DataTableShell } from './DataTableShell'
export { TableToolbar } from './TableToolbar'
export { FilterBar } from './FilterBar'
export { EmptyTableState } from './EmptyTableState'
export { LoadingRows } from './LoadingRows'
+33
View File
@@ -0,0 +1,33 @@
import { Box, Card, CardContent, Skeleton } from '@mui/material'
interface CardSkeletonProps {
lines?: number
hasHeader?: boolean
hasActions?: boolean
}
export function CardSkeleton({ lines = 3, hasHeader = true, hasActions = false }: CardSkeletonProps) {
return (
<Card>
<CardContent>
{hasHeader && (
<Box sx={{ mb: 1.5 }}>
<Skeleton variant="text" width="55%" height={22} />
<Skeleton variant="text" width="35%" height={16} />
</Box>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} variant="text" width={i % 2 === 0 ? '100%' : '80%'} height={16} />
))}
</Box>
{hasActions && (
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
<Skeleton variant="rectangular" width={80} height={32} sx={{ borderRadius: 1 }} />
<Skeleton variant="rectangular" width={80} height={32} sx={{ borderRadius: 1 }} />
</Box>
)}
</CardContent>
</Card>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { Box, Skeleton } from '@mui/material'
interface PanelLoadingStateProps {
rows?: number
height?: number
}
export function PanelLoadingState({ rows = 4, height = 16 }: PanelLoadingStateProps) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, p: 1 }}>
{Array.from({ length: rows }).map((_, i) => (
<Skeleton
key={i}
variant="rectangular"
width={i % 3 === 2 ? '60%' : i % 3 === 1 ? '85%' : '100%'}
height={height}
sx={{ borderRadius: 0.5 }}
/>
))}
</Box>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { Box, Typography } from '@mui/material'
import { ShieldOff } from 'lucide-react'
import type { SxProps, Theme } from '@mui/material'
interface RestrictedStateProps {
message?: string
reason?: string
sx?: SxProps<Theme>
}
export function RestrictedState({ message, reason, sx }: RestrictedStateProps) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
py: 4,
px: 2,
textAlign: 'center',
...sx,
}}
>
<ShieldOff size={28} color="#94a3b8" />
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: 'text.secondary' }}>
{message ?? 'Zugriff eingeschränkt'}
</Typography>
{reason && (
<Typography sx={{ fontSize: '0.75rem', color: 'text.disabled', maxWidth: 280 }}>
{reason}
</Typography>
)}
</Box>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { Box, Button, Typography } from '@mui/material'
import { Lock } from 'lucide-react'
interface UnauthorizedStateProps {
message?: string
onLogin?: () => void
}
export function UnauthorizedState({ message, onLogin }: UnauthorizedStateProps) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
py: 6,
px: 3,
textAlign: 'center',
}}
>
<Lock size={36} color="#94a3b8" />
<Typography sx={{ fontWeight: 600, fontSize: '1rem', color: 'text.primary' }}>
Nicht berechtigt
</Typography>
{message && (
<Typography sx={{ fontSize: '0.875rem', color: 'text.secondary', maxWidth: 360 }}>
{message}
</Typography>
)}
{onLogin && (
<Button variant="outlined" size="small" onClick={onLogin}>
Anmelden
</Button>
)}
</Box>
)
}
+4
View File
@@ -4,3 +4,7 @@ export { EmptyState } from './EmptyState'
export { ErrorState } from './ErrorState'
export { PageContainer } from './PageContainer'
export { SectionContainer } from './SectionContainer'
export { CardSkeleton } from './CardSkeleton'
export { PanelLoadingState } from './PanelLoadingState'
export { UnauthorizedState } from './UnauthorizedState'
export { RestrictedState } from './RestrictedState'
+17
View File
@@ -111,3 +111,20 @@ export const FRESHNESS_LABELS: Record<string, string> = {
STALE: 'Veraltet',
OUTDATED: 'Abgelaufen',
}
// Confidence level display labels
export const CONFIDENCE_LABELS: Record<string, string> = {
VERY_HIGH: 'Sehr hoch',
HIGH: 'Hoch',
MEDIUM: 'Mittel',
LOW: 'Niedrig',
VERY_LOW: 'Sehr niedrig',
}
// Data quality level display labels
export const DATA_QUALITY_LABELS: Record<string, string> = {
HIGH: 'Hoch',
MEDIUM: 'Mittel',
LOW: 'Niedrig',
INCOMPLETE: 'Unvollständig',
}
+61
View File
@@ -0,0 +1,61 @@
import type { ConfidenceLevel, DataQualityLevel } from '../domain/enums'
import { CONF_HIGH, CONF_MEDIUM, DQ_HIGH, DQ_MEDIUM } from './constants'
// ── Semantic color tokens ─────────────────────────────────────────────────────
// Single source of truth for badge/score colors. All badge components read from here.
export const DS_COLORS = {
resultType: {
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' },
EXTERNAL_MARKET: { bg: 'rgba(180,83,9,0.10)', fg: '#b45309' },
FUTURE_AVAILABILITY: { bg: 'rgba(109,40,217,0.10)', fg: '#6d28d9' },
},
confidence: {
VERY_HIGH: { bg: 'rgba(26,122,74,0.12)', fg: '#1a7a4a' },
HIGH: { bg: 'rgba(26,122,74,0.09)', fg: '#1a7a4a' },
MEDIUM: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' },
LOW: { bg: 'rgba(217,119,6,0.12)', fg: '#d97706' },
VERY_LOW: { bg: 'rgba(192,57,43,0.12)', fg: '#c0392b' },
},
risk: {
LOW: { bg: 'rgba(26,122,74,0.10)', fg: '#1a7a4a' },
MEDIUM: { bg: 'rgba(217,119,6,0.10)', fg: '#d97706' },
HIGH: { bg: 'rgba(192,57,43,0.10)', fg: '#c0392b' },
CRITICAL: { bg: 'rgba(127,0,0,0.12)', fg: '#7f1d1d' },
},
availability: {
AVAILABLE_NOW: { bg: 'rgba(26,122,74,0.10)', fg: '#1a7a4a' },
AVAILABLE_SOON: { bg: 'rgba(217,119,6,0.10)', fg: '#d97706' },
FUTURE_SIGNAL: { bg: 'rgba(109,40,217,0.10)', fg: '#6d28d9' },
OCCUPIED: { bg: 'rgba(100,116,139,0.10)', fg: '#475569' },
UNKNOWN: { bg: '#f1f5f9', fg: '#94a3b8' },
},
freshness: {
FRESH: { bg: 'rgba(26,122,74,0.10)', fg: '#1a7a4a' },
STALE: { bg: 'rgba(217,119,6,0.10)', fg: '#d97706' },
OUTDATED: { bg: 'rgba(192,57,43,0.10)', fg: '#c0392b' },
},
dataQuality: {
HIGH: { bg: 'rgba(26,122,74,0.10)', fg: '#1a7a4a' },
MEDIUM: { bg: 'rgba(217,119,6,0.10)', fg: '#d97706' },
LOW: { bg: 'rgba(192,57,43,0.10)', fg: '#c0392b' },
INCOMPLETE: { bg: 'rgba(127,0,0,0.12)', fg: '#7f1d1d' },
},
} as const
// ── Score → qualitative level helpers ────────────────────────────────────────
export function scoreToConfidenceLevel(score: number): ConfidenceLevel {
if (score >= CONF_HIGH) return 'VERY_HIGH'
if (score >= 0.75) return 'HIGH'
if (score >= CONF_MEDIUM) return 'MEDIUM'
if (score >= 0.35) return 'LOW'
return 'VERY_LOW'
}
export function scoreToDataQualityLevel(score: number): DataQualityLevel {
if (score >= DQ_HIGH) return 'HIGH'
if (score >= DQ_MEDIUM) return 'MEDIUM'
if (score > 0) return 'LOW'
return 'INCOMPLETE'
}