diff --git a/src/components/badges/AvailabilityBadge.tsx b/src/components/badges/AvailabilityBadge.tsx new file mode 100644 index 0000000..94386ed --- /dev/null +++ b/src/components/badges/AvailabilityBadge.tsx @@ -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 = { + 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 ( + } + 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 }, + }} + /> + ) +} diff --git a/src/components/badges/ConfidenceBadge.tsx b/src/components/badges/ConfidenceBadge.tsx new file mode 100644 index 0000000..5dc9d91 --- /dev/null +++ b/src/components/badges/ConfidenceBadge.tsx @@ -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 ( + } + aria-label={`Konfidenz: ${label}`} + sx={{ + bgcolor: bg, + color: fg, + border: 'none', + fontWeight: 600, + fontSize: '0.7rem', + '& .MuiChip-icon': { ml: 0.5 }, + }} + /> + ) +} diff --git a/src/components/badges/DataQualityBadge.tsx b/src/components/badges/DataQualityBadge.tsx new file mode 100644 index 0000000..452a5d9 --- /dev/null +++ b/src/components/badges/DataQualityBadge.tsx @@ -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 = { + 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 ( + } + aria-label={`Datenqualität: ${label}`} + sx={{ + bgcolor: bg, + color: fg, + border: 'none', + fontWeight: 600, + fontSize: '0.7rem', + '& .MuiChip-icon': { ml: 0.5 }, + }} + /> + ) +} diff --git a/src/components/badges/FreshnessBadge.tsx b/src/components/badges/FreshnessBadge.tsx new file mode 100644 index 0000000..b439d12 --- /dev/null +++ b/src/components/badges/FreshnessBadge.tsx @@ -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 = { + 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 ( + } + 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 }, + }} + /> + ) +} diff --git a/src/components/badges/ResultTypeBadge.tsx b/src/components/badges/ResultTypeBadge.tsx new file mode 100644 index 0000000..230d948 --- /dev/null +++ b/src/components/badges/ResultTypeBadge.tsx @@ -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 = { + 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 ( + } + aria-label={RESULT_TYPE_LABELS[type] ?? type} + sx={{ + bgcolor: bg, + color: fg, + border: 'none', + fontWeight: 600, + fontSize: '0.7rem', + '& .MuiChip-icon': { ml: 0.5 }, + }} + /> + ) +} diff --git a/src/components/badges/RiskBadge.tsx b/src/components/badges/RiskBadge.tsx new file mode 100644 index 0000000..73c0816 --- /dev/null +++ b/src/components/badges/RiskBadge.tsx @@ -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 ( + } + aria-label={`Risiko: ${RISK_LABELS[level] ?? level}`} + sx={{ + bgcolor: bg, + color: fg, + border: 'none', + fontWeight: 600, + fontSize: '0.7rem', + '& .MuiChip-icon': { ml: 0.5 }, + }} + /> + ) +} diff --git a/src/components/badges/index.ts b/src/components/badges/index.ts new file mode 100644 index 0000000..7f9e4cc --- /dev/null +++ b/src/components/badges/index.ts @@ -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' diff --git a/src/components/cards/CompactCard.tsx b/src/components/cards/CompactCard.tsx new file mode 100644 index 0000000..f2859dc --- /dev/null +++ b/src/components/cards/CompactCard.tsx @@ -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 +} + +export function CompactCard({ title, meta, leading, trailing, onClick, selected = false, sx }: CompactCardProps) { + return ( + + + {leading && {leading}} + + + {title} + + {meta && ( + + {meta} + + )} + + {trailing && {trailing}} + + + ) +} diff --git a/src/components/cards/DecisionCard.tsx b/src/components/cards/DecisionCard.tsx new file mode 100644 index 0000000..b3c05e5 --- /dev/null +++ b/src/components/cards/DecisionCard.tsx @@ -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 +} + +export function DecisionCard({ + title, + subtitle, + badges, + score, + body, + actions, + selected = false, + isLoading = false, + isRestricted = false, + onClick, + sx, +}: DecisionCardProps) { + if (isLoading) return + + return ( + + {isRestricted ? ( + + ) : ( + <> + + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + {score && {score}} + + {badges && ( + + {badges} + + )} + + + {body && ( + <> + + {body} + + )} + + {actions && ( + <> + + {actions} + + )} + + )} + + ) +} diff --git a/src/components/cards/MetricCard.tsx b/src/components/cards/MetricCard.tsx new file mode 100644 index 0000000..461657f --- /dev/null +++ b/src/components/cards/MetricCard.tsx @@ -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 +} + +export function MetricCard({ label, value, delta, icon, color = '#1e3a5f', sx }: MetricCardProps) { + return ( + + + + + {label} + + + {value} + + {delta && ( + + {delta.positive + ? + : } + + {delta.value} + + + )} + + {icon && ( + + {icon} + + )} + + + ) +} diff --git a/src/components/cards/index.ts b/src/components/cards/index.ts index 077e29e..71a92a7 100644 --- a/src/components/cards/index.ts +++ b/src/components/cards/index.ts @@ -1 +1,4 @@ export { SourceTypeBadge } from './SourceTypeBadge' +export { DecisionCard } from './DecisionCard' +export { CompactCard } from './CompactCard' +export { MetricCard } from './MetricCard' diff --git a/src/components/forms/FieldWithConfidence.tsx b/src/components/forms/FieldWithConfidence.tsx new file mode 100644 index 0000000..49729bb --- /dev/null +++ b/src/components/forms/FieldWithConfidence.tsx @@ -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 +} + +export function FieldWithConfidence({ + label, + value, + confidence, + confidenceLevel, + layout = 'column', + sx, +}: FieldWithConfidenceProps) { + const showBadge = confidence !== undefined || confidenceLevel !== undefined + + if (layout === 'row') { + return ( + + + {label} + + {value} + {showBadge && ( + + )} + + ) + } + + return ( + + + + {label} + + {showBadge && ( + + )} + + {value} + + ) +} diff --git a/src/components/forms/PriorityChipGroup.tsx b/src/components/forms/PriorityChipGroup.tsx new file mode 100644 index 0000000..adfc430 --- /dev/null +++ b/src/components/forms/PriorityChipGroup.tsx @@ -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 +} + +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 ( + + {label && ( + + {label} + + )} + + {options.map(opt => { + const selected = value.includes(opt.value) + const accent = opt.color ?? '#1e3a5f' + return ( + 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)' }, + }} + /> + ) + })} + + + ) +} diff --git a/src/components/forms/SelectField.tsx b/src/components/forms/SelectField.tsx new file mode 100644 index 0000000..96b0da2 --- /dev/null +++ b/src/components/forms/SelectField.tsx @@ -0,0 +1,55 @@ +import { FormControl, FormHelperText, InputLabel, MenuItem, Select } from '@mui/material' +import type { SxProps, Theme } from '@mui/material' + +interface SelectOption { + value: T + label: string +} + +interface SelectFieldProps { + label: string + value: T | '' + onChange: (value: T) => void + options: SelectOption[] + error?: string + helperText?: string + required?: boolean + disabled?: boolean + size?: 'small' | 'medium' + fullWidth?: boolean + sx?: SxProps +} + +export function SelectField({ + label, + value, + onChange, + options, + error, + helperText, + required, + disabled, + size = 'small', + fullWidth = true, + sx, +}: SelectFieldProps) { + const labelId = `select-${label.replace(/\s+/g, '-').toLowerCase()}` + return ( + + {label} + + {(error ?? helperText) && {error ?? helperText}} + + ) +} diff --git a/src/components/forms/TextInput.tsx b/src/components/forms/TextInput.tsx new file mode 100644 index 0000000..950c063 --- /dev/null +++ b/src/components/forms/TextInput.tsx @@ -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 +} + +export function TextInput({ + label, + value, + onChange, + error, + helperText, + placeholder, + required, + disabled, + multiline, + rows, + size = 'small', + fullWidth = true, + sx, +}: TextInputProps) { + return ( + 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} + /> + ) +} diff --git a/src/components/forms/index.ts b/src/components/forms/index.ts new file mode 100644 index 0000000..9757342 --- /dev/null +++ b/src/components/forms/index.ts @@ -0,0 +1,4 @@ +export { TextInput } from './TextInput' +export { SelectField } from './SelectField' +export { PriorityChipGroup } from './PriorityChipGroup' +export { FieldWithConfidence } from './FieldWithConfidence' diff --git a/src/components/panels/DetailPanel.tsx b/src/components/panels/DetailPanel.tsx new file mode 100644 index 0000000..40df508 --- /dev/null +++ b/src/components/panels/DetailPanel.tsx @@ -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 +} + +export function DetailPanel({ sections, sx }: DetailPanelProps) { + return ( + + {sections.map((section, i) => ( + + } + sx={{ px: 2, py: 0, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }} + > + + {section.title} + + + + {section.children} + + + ))} + + ) +} diff --git a/src/components/panels/InfoPanel.tsx b/src/components/panels/InfoPanel.tsx new file mode 100644 index 0000000..9725739 --- /dev/null +++ b/src/components/panels/InfoPanel.tsx @@ -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 +} + +export function InfoPanel({ title, icon, children, sx }: InfoPanelProps) { + return ( + + {title && ( + <> + + {icon} + + {title} + + + + + )} + {children} + + ) +} diff --git a/src/components/panels/WarningPanel.tsx b/src/components/panels/WarningPanel.tsx new file mode 100644 index 0000000..01702ef --- /dev/null +++ b/src/components/panels/WarningPanel.tsx @@ -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 ( + + + {title} + + {description && {description}} + {children && {children}} + {actions && {actions}} + + ) +} diff --git a/src/components/panels/index.ts b/src/components/panels/index.ts new file mode 100644 index 0000000..18b5183 --- /dev/null +++ b/src/components/panels/index.ts @@ -0,0 +1,3 @@ +export { InfoPanel } from './InfoPanel' +export { DetailPanel } from './DetailPanel' +export { WarningPanel } from './WarningPanel' diff --git a/src/components/scores/ConfidenceScore.tsx b/src/components/scores/ConfidenceScore.tsx new file mode 100644 index 0000000..eeb885e --- /dev/null +++ b/src/components/scores/ConfidenceScore.tsx @@ -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 ( + + {pct} + + ) + } + + return ( + + + {pct} + + {showLabel && ( + + )} + + ) +} diff --git a/src/components/scores/DataQualityScore.tsx b/src/components/scores/DataQualityScore.tsx new file mode 100644 index 0000000..68c523e --- /dev/null +++ b/src/components/scores/DataQualityScore.tsx @@ -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 ( + + {pct}% + + ) + } + + return ( + + + {label} + + {pct}% + + + + + ) +} diff --git a/src/components/scores/ScoreBar.tsx b/src/components/scores/ScoreBar.tsx new file mode 100644 index 0000000..f82523b --- /dev/null +++ b/src/components/scores/ScoreBar.tsx @@ -0,0 +1,49 @@ +import { Box, LinearProgress, Typography } from '@mui/material' +import { matchScoreColor } from '../../lib/utils' + +const COLOR_MAP: Record = { + 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 ( + + + + {label} + + + {value} + + + + {weight !== undefined && ( + + Gewicht: {Math.round(weight * 100)}% + + )} + + ) +} diff --git a/src/components/scores/ScoreBreakdownMini.tsx b/src/components/scores/ScoreBreakdownMini.tsx new file mode 100644 index 0000000..45f908b --- /dev/null +++ b/src/components/scores/ScoreBreakdownMini.tsx @@ -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 ( + + {visible.map((f, i) => ( + + + + + + ))} + {overflow > 0 && ( + + +{overflow} weitere Faktoren + + )} + + ) +} diff --git a/src/components/scores/index.ts b/src/components/scores/index.ts index 61bd9bc..5617076 100644 --- a/src/components/scores/index.ts +++ b/src/components/scores/index.ts @@ -1 +1,5 @@ export { MatchScoreRing } from './MatchScoreRing' +export { ConfidenceScore } from './ConfidenceScore' +export { DataQualityScore } from './DataQualityScore' +export { ScoreBar } from './ScoreBar' +export { ScoreBreakdownMini } from './ScoreBreakdownMini' diff --git a/src/components/tables/DataTableShell.tsx b/src/components/tables/DataTableShell.tsx new file mode 100644 index 0000000..fbb1b0a --- /dev/null +++ b/src/components/tables/DataTableShell.tsx @@ -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 +} + +export function DataTableShell({ + toolbar, + children, + loading = false, + loadingRows = 5, + loadingCols = 6, + empty = false, + emptySlot, + emptyColSpan = 6, + stickyHeader = false, + size = 'small', + sx, +}: DataTableShellProps) { + return ( + + {toolbar} + + + {!loading && children} + {loading && ( + + + + )} + {!loading && empty && ( + + {emptySlot ?? } + + )} +
+
+
+ ) +} diff --git a/src/components/tables/EmptyTableState.tsx b/src/components/tables/EmptyTableState.tsx new file mode 100644 index 0000000..908bdbd --- /dev/null +++ b/src/components/tables/EmptyTableState.tsx @@ -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 ( + + + + + + {title ?? 'Keine Einträge gefunden'} + + {description && ( + + {description} + + )} + + + + ) +} diff --git a/src/components/tables/FilterBar.tsx b/src/components/tables/FilterBar.tsx new file mode 100644 index 0000000..f5328bc --- /dev/null +++ b/src/components/tables/FilterBar.tsx @@ -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 +} + +export function FilterBar({ filters, onClearAll, sx }: FilterBarProps) { + if (filters.length === 0) return null + + return ( + + {filters.map(f => ( + + ))} + {onClearAll && filters.length > 1 && ( + + Alle löschen + + )} + + ) +} diff --git a/src/components/tables/LoadingRows.tsx b/src/components/tables/LoadingRows.tsx new file mode 100644 index 0000000..e51af68 --- /dev/null +++ b/src/components/tables/LoadingRows.tsx @@ -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) => ( + + {Array.from({ length: cols }).map((_, ci) => ( + + + + ))} + + ))} + + ) +} diff --git a/src/components/tables/TableToolbar.tsx b/src/components/tables/TableToolbar.tsx new file mode 100644 index 0000000..c7e2e7b --- /dev/null +++ b/src/components/tables/TableToolbar.tsx @@ -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 +} + +export function TableToolbar({ title, count, filterSlot, actions, sx }: TableToolbarProps) { + return ( + + + {title && ( + + {title} + + )} + {count !== undefined && ( + + )} + {filterSlot} + + {actions && ( + + {actions} + + )} + + ) +} diff --git a/src/components/tables/index.ts b/src/components/tables/index.ts new file mode 100644 index 0000000..b489583 --- /dev/null +++ b/src/components/tables/index.ts @@ -0,0 +1,5 @@ +export { DataTableShell } from './DataTableShell' +export { TableToolbar } from './TableToolbar' +export { FilterBar } from './FilterBar' +export { EmptyTableState } from './EmptyTableState' +export { LoadingRows } from './LoadingRows' diff --git a/src/components/ui/CardSkeleton.tsx b/src/components/ui/CardSkeleton.tsx new file mode 100644 index 0000000..6cfc3e2 --- /dev/null +++ b/src/components/ui/CardSkeleton.tsx @@ -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 ( + + + {hasHeader && ( + + + + + )} + + {Array.from({ length: lines }).map((_, i) => ( + + ))} + + {hasActions && ( + + + + + )} + + + ) +} diff --git a/src/components/ui/PanelLoadingState.tsx b/src/components/ui/PanelLoadingState.tsx new file mode 100644 index 0000000..e7ee3fa --- /dev/null +++ b/src/components/ui/PanelLoadingState.tsx @@ -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 ( + + {Array.from({ length: rows }).map((_, i) => ( + + ))} + + ) +} diff --git a/src/components/ui/RestrictedState.tsx b/src/components/ui/RestrictedState.tsx new file mode 100644 index 0000000..fc3782f --- /dev/null +++ b/src/components/ui/RestrictedState.tsx @@ -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 +} + +export function RestrictedState({ message, reason, sx }: RestrictedStateProps) { + return ( + + + + {message ?? 'Zugriff eingeschränkt'} + + {reason && ( + + {reason} + + )} + + ) +} diff --git a/src/components/ui/UnauthorizedState.tsx b/src/components/ui/UnauthorizedState.tsx new file mode 100644 index 0000000..d3e4624 --- /dev/null +++ b/src/components/ui/UnauthorizedState.tsx @@ -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 ( + + + + Nicht berechtigt + + {message && ( + + {message} + + )} + {onLogin && ( + + )} + + ) +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index cdcc7d2..841d800 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -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' diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 17553fd..7aa7f0b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -111,3 +111,20 @@ export const FRESHNESS_LABELS: Record = { STALE: 'Veraltet', OUTDATED: 'Abgelaufen', } + +// Confidence level display labels +export const CONFIDENCE_LABELS: Record = { + 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 = { + HIGH: 'Hoch', + MEDIUM: 'Mittel', + LOW: 'Niedrig', + INCOMPLETE: 'Unvollständig', +} diff --git a/src/lib/ds.ts b/src/lib/ds.ts new file mode 100644 index 0000000..5e4c2af --- /dev/null +++ b/src/lib/ds.ts @@ -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' +}