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 { 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 { 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 { ErrorState } from './ErrorState'
|
||||||
export { PageContainer } from './PageContainer'
|
export { PageContainer } from './PageContainer'
|
||||||
export { SectionContainer } from './SectionContainer'
|
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',
|
STALE: 'Veraltet',
|
||||||
OUTDATED: 'Abgelaufen',
|
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