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:
@@ -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 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { TextInput } from './TextInput'
|
||||
export { SelectField } from './SelectField'
|
||||
export { PriorityChipGroup } from './PriorityChipGroup'
|
||||
export { FieldWithConfidence } from './FieldWithConfidence'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { InfoPanel } from './InfoPanel'
|
||||
export { DetailPanel } from './DetailPanel'
|
||||
export { WarningPanel } from './WarningPanel'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
export { MatchScoreRing } from './MatchScoreRing'
|
||||
export { ConfidenceScore } from './ConfidenceScore'
|
||||
export { DataQualityScore } from './DataQualityScore'
|
||||
export { ScoreBar } from './ScoreBar'
|
||||
export { ScoreBreakdownMini } from './ScoreBreakdownMini'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { DataTableShell } from './DataTableShell'
|
||||
export { TableToolbar } from './TableToolbar'
|
||||
export { FilterBar } from './FilterBar'
|
||||
export { EmptyTableState } from './EmptyTableState'
|
||||
export { LoadingRows } from './LoadingRows'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user