feat(frontend): implement unified design system with 17 reusable template components, skeleton loading states, and golden-ratio-based layouts
This commit is contained in:
59
frontend/src/components/templates/ConfirmDialog.tsx
Normal file
59
frontend/src/components/templates/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
} from '@mui/material';
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string | React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
confirmColor?: 'primary' | 'error' | 'warning';
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/** Standard confirmation dialog with cancel/confirm buttons. */
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Bestätigen',
|
||||
confirmColor = 'primary',
|
||||
isLoading = false,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
{typeof message === 'string' ? (
|
||||
<DialogContentText>{message}</DialogContentText>
|
||||
) : (
|
||||
message
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={isLoading}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
variant="contained"
|
||||
color={confirmColor}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? <CircularProgress size={20} /> : confirmLabel}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
238
frontend/src/components/templates/DataTable.tsx
Normal file
238
frontend/src/components/templates/DataTable.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TablePagination,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
Skeleton,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||
import InboxIcon from '@mui/icons-material/Inbox';
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
width?: number | string;
|
||||
render?: (row: T) => React.ReactNode;
|
||||
sortable?: boolean;
|
||||
searchable?: boolean;
|
||||
}
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
isLoading?: boolean;
|
||||
skeletonRows?: number;
|
||||
emptyMessage?: string;
|
||||
emptyIcon?: React.ReactNode;
|
||||
onRowClick?: (row: T) => void;
|
||||
rowKey: (row: T) => string | number;
|
||||
searchPlaceholder?: string;
|
||||
searchEnabled?: boolean;
|
||||
paginationEnabled?: boolean;
|
||||
defaultRowsPerPage?: number;
|
||||
rowsPerPageOptions?: number[];
|
||||
filters?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
title?: string;
|
||||
stickyHeader?: boolean;
|
||||
maxHeight?: number | string;
|
||||
size?: 'small' | 'medium';
|
||||
dense?: boolean;
|
||||
}
|
||||
|
||||
/** Universal data table with search, sorting, and pagination. */
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
data,
|
||||
isLoading = false,
|
||||
skeletonRows = 5,
|
||||
emptyMessage = 'Keine Einträge',
|
||||
emptyIcon,
|
||||
onRowClick,
|
||||
rowKey,
|
||||
searchPlaceholder = 'Suchen...',
|
||||
searchEnabled = true,
|
||||
paginationEnabled = true,
|
||||
defaultRowsPerPage = 10,
|
||||
rowsPerPageOptions = [5, 10, 25],
|
||||
filters,
|
||||
actions,
|
||||
title,
|
||||
stickyHeader = false,
|
||||
maxHeight,
|
||||
size = 'small',
|
||||
dense = false,
|
||||
}: DataTableProps<T>) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(defaultRowsPerPage);
|
||||
|
||||
const handleSort = useCallback((key: string) => {
|
||||
if (sortKey === key) {
|
||||
if (sortDir === 'asc') {
|
||||
setSortDir('desc');
|
||||
} else {
|
||||
setSortKey(null);
|
||||
setSortDir('asc');
|
||||
}
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
}, [sortKey, sortDir]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!search.trim()) return data;
|
||||
const term = search.toLowerCase();
|
||||
const searchableCols = columns.filter((c) => c.searchable !== false);
|
||||
return data.filter((row) =>
|
||||
searchableCols.some((col) => {
|
||||
const val = (row as Record<string, unknown>)[col.key];
|
||||
return val != null && String(val).toLowerCase().includes(term);
|
||||
})
|
||||
);
|
||||
}, [data, search, columns]);
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortKey) return filteredData;
|
||||
return [...filteredData].sort((a, b) => {
|
||||
const aVal = (a as Record<string, unknown>)[sortKey];
|
||||
const bVal = (b as Record<string, unknown>)[sortKey];
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
const cmp = String(aVal).localeCompare(String(bVal), undefined, { numeric: true });
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [filteredData, sortKey, sortDir]);
|
||||
|
||||
const paginatedData = paginationEnabled
|
||||
? sortedData.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
||||
: sortedData;
|
||||
|
||||
const showToolbar = title || actions || searchEnabled;
|
||||
|
||||
return (
|
||||
<Paper>
|
||||
{showToolbar && (
|
||||
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
|
||||
{title && <Typography variant="h6">{title}</Typography>}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{searchEnabled && (
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
sx={{ maxWidth: 300 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
{filters && <Box sx={{ px: 2, pb: 1 }}>{filters}</Box>}
|
||||
<TableContainer sx={{ maxHeight }}>
|
||||
<Table size={dense ? 'small' : size} stickyHeader={stickyHeader}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
align={col.align}
|
||||
width={col.width}
|
||||
sx={{
|
||||
cursor: col.sortable !== false ? 'pointer' : 'default',
|
||||
bgcolor: 'background.default',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onClick={() => col.sortable !== false && handleSort(col.key)}
|
||||
>
|
||||
<Box display="inline-flex" alignItems="center" gap={0.5}>
|
||||
{col.label}
|
||||
{sortKey === col.key && (
|
||||
sortDir === 'asc'
|
||||
? <ArrowUpwardIcon sx={{ fontSize: 14 }} />
|
||||
: <ArrowDownwardIcon sx={{ fontSize: 14 }} />
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: skeletonRows }, (_, i) => (
|
||||
<TableRow key={i}>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key}>
|
||||
<Skeleton animation="wave" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : paginatedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} align="center" sx={{ py: 6 }}>
|
||||
<Box display="flex" flexDirection="column" alignItems="center" color="text.secondary">
|
||||
{emptyIcon ?? <InboxIcon sx={{ fontSize: 40, mb: 1, opacity: 0.5 }} />}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{emptyMessage}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedData.map((row) => (
|
||||
<TableRow
|
||||
key={rowKey(row)}
|
||||
hover
|
||||
onClick={() => onRowClick?.(row)}
|
||||
sx={{ cursor: onRowClick ? 'pointer' : 'default' }}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key} align={col.align}>
|
||||
{col.render ? col.render(row) : (row as Record<string, unknown>)[col.key] as React.ReactNode}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{paginationEnabled && !isLoading && (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={filteredData.length}
|
||||
page={page}
|
||||
onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={(e) => { setRowsPerPage(parseInt(e.target.value, 10)); setPage(0); }}
|
||||
rowsPerPageOptions={rowsPerPageOptions}
|
||||
labelRowsPerPage="Zeilen pro Seite:"
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
74
frontend/src/components/templates/DetailLayout.tsx
Normal file
74
frontend/src/components/templates/DetailLayout.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Tab, Tabs } from '@mui/material';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { PageHeader } from './PageHeader';
|
||||
import { TabPanel } from './TabPanel';
|
||||
import type { BreadcrumbItem } from './PageHeader';
|
||||
|
||||
export interface TabDef {
|
||||
label: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
icon?: React.ReactElement;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface DetailLayoutProps {
|
||||
title: string;
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
actions?: React.ReactNode;
|
||||
tabs: TabDef[];
|
||||
backTo?: string;
|
||||
isLoading?: boolean;
|
||||
skeleton?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Detail page layout with PageHeader and tab navigation synced to URL. */
|
||||
export const DetailLayout: React.FC<DetailLayoutProps> = ({
|
||||
title,
|
||||
breadcrumbs,
|
||||
actions,
|
||||
tabs,
|
||||
backTo,
|
||||
isLoading = false,
|
||||
skeleton,
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [tab, setTab] = useState(() => {
|
||||
const t = parseInt(searchParams.get('tab') ?? '0', 10);
|
||||
return isNaN(t) || t < 0 || t >= tabs.length ? 0 : t;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (tab === 0) {
|
||||
newParams.delete('tab');
|
||||
} else {
|
||||
newParams.set('tab', String(tab));
|
||||
}
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}, [tab]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<PageHeader title={title} breadcrumbs={breadcrumbs} actions={actions} backTo={backTo} />
|
||||
{isLoading ? (
|
||||
skeleton
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} variant="scrollable" scrollButtons="auto">
|
||||
{tabs.map((t, i) => (
|
||||
<Tab key={i} label={t.label} icon={t.icon} disabled={t.disabled} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
{tabs.map((t, i) => (
|
||||
<TabPanel key={i} value={tab} index={i}>
|
||||
{t.content}
|
||||
</TabPanel>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
90
frontend/src/components/templates/FilterBar.tsx
Normal file
90
frontend/src/components/templates/FilterBar.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
TextField,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
|
||||
export interface FilterBarProps {
|
||||
searchValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
children?: React.ReactNode;
|
||||
activeFilterCount?: number;
|
||||
onClearFilters?: () => void;
|
||||
collapsible?: boolean;
|
||||
}
|
||||
|
||||
/** Search + filter controls bar. Supports collapsible accordion mode. */
|
||||
export const FilterBar: React.FC<FilterBarProps> = ({
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Suchen...',
|
||||
children,
|
||||
activeFilterCount = 0,
|
||||
onClearFilters,
|
||||
collapsible = false,
|
||||
}) => {
|
||||
const searchField = onSearchChange ? (
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue ?? ''}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
sx={{ minWidth: 200 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const clearButton = activeFilterCount > 0 && onClearFilters ? (
|
||||
<Button size="small" onClick={onClearFilters}>
|
||||
Filter zurücksetzen
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
if (collapsible) {
|
||||
return (
|
||||
<Accordion disableGutters elevation={0} sx={{ mb: 2, '&:before': { display: 'none' } }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Badge badgeContent={activeFilterCount} color="primary">
|
||||
<FilterListIcon />
|
||||
</Badge>
|
||||
<Typography variant="body2">Filter</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{searchField}
|
||||
{children}
|
||||
{clearButton}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{searchField}
|
||||
{children}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{clearButton}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
44
frontend/src/components/templates/FormCard.tsx
Normal file
44
frontend/src/components/templates/FormCard.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, CircularProgress } from '@mui/material';
|
||||
import { WidgetCard } from './WidgetCard';
|
||||
|
||||
export interface FormCardProps {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
isSubmitting?: boolean;
|
||||
onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
submitLabel?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Quick-action form widget built on WidgetCard. */
|
||||
export const FormCard: React.FC<FormCardProps> = ({
|
||||
title,
|
||||
icon,
|
||||
isSubmitting = false,
|
||||
onSubmit,
|
||||
submitLabel = 'Erstellen',
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<WidgetCard title={title} icon={icon}>
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={onSubmit}
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
||||
>
|
||||
{children}
|
||||
<Box display="flex" justifyContent="flex-end" mt={0.5}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={isSubmitting}
|
||||
startIcon={isSubmitting ? <CircularProgress size={16} color="inherit" /> : undefined}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</WidgetCard>
|
||||
);
|
||||
};
|
||||
52
frontend/src/components/templates/FormDialog.tsx
Normal file
52
frontend/src/components/templates/FormDialog.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@mui/material';
|
||||
|
||||
export interface FormDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
title: string;
|
||||
submitLabel?: string;
|
||||
isSubmitting?: boolean;
|
||||
maxWidth?: 'xs' | 'sm' | 'md';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Dialog with form content and submit/cancel buttons. */
|
||||
export const FormDialog: React.FC<FormDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
title,
|
||||
submitLabel = 'Speichern',
|
||||
isSubmitting = false,
|
||||
maxWidth = 'sm',
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth={maxWidth} fullWidth>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent sx={{ pt: '16px !important' }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{children}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={isSubmitting}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={onSubmit} variant="contained" disabled={isSubmitting}>
|
||||
{isSubmitting ? <CircularProgress size={20} /> : submitLabel}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
39
frontend/src/components/templates/FormLayout.tsx
Normal file
39
frontend/src/components/templates/FormLayout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Box, Paper, Typography } from '@mui/material';
|
||||
|
||||
export interface FormLayoutProps {
|
||||
children: React.ReactNode;
|
||||
onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
actions?: React.ReactNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** Standard form page wrapper with Paper, optional title, and action slot. */
|
||||
export const FormLayout: React.FC<FormLayoutProps> = ({
|
||||
children,
|
||||
onSubmit,
|
||||
actions,
|
||||
title,
|
||||
}) => {
|
||||
return (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
{title && (
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
component={onSubmit ? 'form' : 'div'}
|
||||
onSubmit={onSubmit}
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
{children}
|
||||
{actions && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
59
frontend/src/components/templates/InfoGrid.tsx
Normal file
59
frontend/src/components/templates/InfoGrid.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { InfoGridSkeleton } from './SkeletonPresets';
|
||||
|
||||
export interface InfoField {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export interface InfoGridProps {
|
||||
fields: InfoField[];
|
||||
columns?: 1 | 2;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/** Key-value display grid with golden-ratio label proportions. */
|
||||
export const InfoGrid: React.FC<InfoGridProps> = ({
|
||||
fields,
|
||||
columns = 1,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return <InfoGridSkeleton rows={fields.length || 4} />;
|
||||
}
|
||||
|
||||
const wrapper = columns === 2
|
||||
? { display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '0 16px' }
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Box sx={wrapper}>
|
||||
{fields.map((field, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
py: 0.75,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
...(field.fullWidth && columns === 2 ? { gridColumn: '1 / -1' } : {}),
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ minWidth: 180, flexShrink: 0 }}
|
||||
>
|
||||
{field.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div" sx={{ flex: 1 }}>
|
||||
{field.value}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
77
frontend/src/components/templates/ListCard.tsx
Normal file
77
frontend/src/components/templates/ListCard.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { Box, Divider } from '@mui/material';
|
||||
import { WidgetCard } from './WidgetCard';
|
||||
import { ItemListSkeleton } from './SkeletonPresets';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
|
||||
export interface ListCardProps<T> {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
items: T[];
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
skeletonCount?: number;
|
||||
skeletonItem?: React.ReactNode;
|
||||
emptyMessage?: string;
|
||||
maxItems?: number;
|
||||
footer?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
/** Card with a list of items, built on WidgetCard. */
|
||||
export function ListCard<T>({
|
||||
title,
|
||||
icon,
|
||||
action,
|
||||
items,
|
||||
renderItem,
|
||||
isLoading = false,
|
||||
skeletonCount = 5,
|
||||
skeletonItem,
|
||||
emptyMessage = 'Keine Einträge',
|
||||
maxItems,
|
||||
footer,
|
||||
onClick,
|
||||
sx,
|
||||
}: ListCardProps<T>) {
|
||||
const displayItems = maxItems ? items.slice(0, maxItems) : items;
|
||||
|
||||
const skeletonContent = skeletonItem ? (
|
||||
<Box>
|
||||
{Array.from({ length: skeletonCount }, (_, i) => (
|
||||
<Box key={i} sx={{ py: 1 }}>
|
||||
{skeletonItem}
|
||||
{i < skeletonCount - 1 && <Divider sx={{ mt: 1 }} />}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<ItemListSkeleton count={skeletonCount} />
|
||||
);
|
||||
|
||||
return (
|
||||
<WidgetCard
|
||||
title={title}
|
||||
icon={icon}
|
||||
action={action}
|
||||
isLoading={isLoading}
|
||||
skeleton={skeletonContent}
|
||||
isEmpty={!isLoading && displayItems.length === 0}
|
||||
emptyMessage={emptyMessage}
|
||||
footer={footer}
|
||||
onClick={onClick}
|
||||
sx={sx}
|
||||
>
|
||||
{displayItems.map((item, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Box sx={{ py: 1 }}>
|
||||
{renderItem(item, index)}
|
||||
</Box>
|
||||
{index < displayItems.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</WidgetCard>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/templates/PageContainer.tsx
Normal file
19
frontend/src/components/templates/PageContainer.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Container } from '@mui/material';
|
||||
|
||||
export interface PageContainerProps {
|
||||
children: React.ReactNode;
|
||||
maxWidth?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | false;
|
||||
}
|
||||
|
||||
/** Standard page layout wrapper with consistent padding and max width. */
|
||||
export const PageContainer: React.FC<PageContainerProps> = ({
|
||||
children,
|
||||
maxWidth = 'lg',
|
||||
}) => {
|
||||
return (
|
||||
<Container maxWidth={maxWidth} sx={{ py: 3 }}>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
79
frontend/src/components/templates/PageHeader.tsx
Normal file
79
frontend/src/components/templates/PageHeader.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
IconButton,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
actions?: React.ReactNode;
|
||||
backTo?: string;
|
||||
}
|
||||
|
||||
/** Page title bar with optional breadcrumbs, back button, and action slot. */
|
||||
export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
breadcrumbs,
|
||||
actions,
|
||||
backTo,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
<Breadcrumbs sx={{ mb: 1 }}>
|
||||
{breadcrumbs.map((item, i) =>
|
||||
item.href && i < breadcrumbs.length - 1 ? (
|
||||
<Link
|
||||
key={i}
|
||||
to={item.href}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ '&:hover': { textDecoration: 'underline' } }}>
|
||||
{item.label}
|
||||
</Typography>
|
||||
</Link>
|
||||
) : (
|
||||
<Typography key={i} variant="body2" color="text.primary">
|
||||
{item.label}
|
||||
</Typography>
|
||||
)
|
||||
)}
|
||||
</Breadcrumbs>
|
||||
)}
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={3}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{backTo && (
|
||||
<IconButton onClick={() => navigate(backTo)} size="small">
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="h5" fontWeight={700}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{actions && <Box>{actions}</Box>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
106
frontend/src/components/templates/SkeletonPresets.tsx
Normal file
106
frontend/src/components/templates/SkeletonPresets.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { Box, Paper, Skeleton } from '@mui/material';
|
||||
|
||||
/** Skeleton for a stat card: label, large value, and circular icon. */
|
||||
export const StatSkeleton: React.FC = () => (
|
||||
<Box display="flex" alignItems="center">
|
||||
<Box sx={{ flex: 1.618 }}>
|
||||
<Skeleton animation="wave" width="40%" height={14} sx={{ mb: 1 }} />
|
||||
<Skeleton animation="wave" width="60%" height={32} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
|
||||
<Skeleton animation="wave" variant="circular" width={56} height={56} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/** Skeleton for a row of chips. */
|
||||
export const ChipListSkeleton: React.FC = () => (
|
||||
<Box display="flex" gap={1} flexWrap="wrap">
|
||||
<Skeleton animation="wave" variant="rounded" width={80} height={24} />
|
||||
<Skeleton animation="wave" variant="rounded" width={100} height={24} />
|
||||
<Skeleton animation="wave" variant="rounded" width={60} height={24} />
|
||||
<Skeleton animation="wave" variant="rounded" width={90} height={24} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
export interface ItemListSkeletonProps {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** Skeleton for a list of items with avatar and two text lines. */
|
||||
export const ItemListSkeleton: React.FC<ItemListSkeletonProps> = ({ count = 5 }) => (
|
||||
<Box>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<Box key={i} display="flex" alignItems="center" gap={1.5} sx={{ py: 1 }}>
|
||||
<Skeleton animation="wave" variant="circular" width={10} height={10} />
|
||||
<Skeleton animation="wave" width="70%" height={16} />
|
||||
<Skeleton animation="wave" width="40%" height={16} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
/** Skeleton for a quick-action form. */
|
||||
export const FormSkeleton: React.FC = () => (
|
||||
<Box display="flex" flexDirection="column" gap={1.5}>
|
||||
<Skeleton animation="wave" variant="rounded" height={40} />
|
||||
<Skeleton animation="wave" variant="rounded" height={40} />
|
||||
<Skeleton animation="wave" variant="rounded" height={36} width="30%" sx={{ alignSelf: 'flex-end' }} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
export interface TableSkeletonProps {
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
/** Skeleton for a data table with header and body rows. */
|
||||
export const TableSkeleton: React.FC<TableSkeletonProps> = ({ rows = 5, columns = 4 }) => (
|
||||
<Box>
|
||||
<Box display="flex" gap={2} sx={{ mb: 1.5 }}>
|
||||
{Array.from({ length: columns }, (_, i) => (
|
||||
<Skeleton key={i} animation="wave" width={`${100 / columns}%`} height={14} />
|
||||
))}
|
||||
</Box>
|
||||
{Array.from({ length: rows }, (_, r) => (
|
||||
<Box key={r} display="flex" gap={2} sx={{ py: 0.75 }}>
|
||||
{Array.from({ length: columns }, (_, c) => (
|
||||
<Skeleton key={c} animation="wave" width={`${100 / columns}%`} height={18} />
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export interface InfoGridSkeletonProps {
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
/** Skeleton for a label-value info grid. */
|
||||
export const InfoGridSkeleton: React.FC<InfoGridSkeletonProps> = ({ rows = 4 }) => (
|
||||
<Box>
|
||||
{Array.from({ length: rows }, (_, i) => (
|
||||
<Box key={i} display="flex" gap={2} sx={{ py: 1 }}>
|
||||
<Skeleton animation="wave" width="30%" height={16} />
|
||||
<Skeleton animation="wave" width="60%" height={16} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export interface SummaryCardsSkeletonProps {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** Skeleton for a grid of summary stat cards. */
|
||||
export const SummaryCardsSkeleton: React.FC<SummaryCardsSkeletonProps> = ({ count = 4 }) => (
|
||||
<Box display="grid" gridTemplateColumns="repeat(auto-fill, minmax(140px, 1fr))" gap={2}>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<Paper key={i} variant="outlined" sx={{ p: 2 }}>
|
||||
<Skeleton animation="wave" width="50%" height={14} sx={{ mb: 1 }} />
|
||||
<Skeleton animation="wave" width="70%" height={24} />
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
81
frontend/src/components/templates/StatCard.tsx
Normal file
81
frontend/src/components/templates/StatCard.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { Box, Card, CardActionArea, CardContent, Typography } from '@mui/material';
|
||||
import { GOLDEN_RATIO } from '../../theme/theme';
|
||||
import { StatSkeleton } from './SkeletonPresets';
|
||||
|
||||
export interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ReactNode;
|
||||
color?: string;
|
||||
trend?: { value: number; label?: string };
|
||||
isLoading?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/** Stat display card with golden-ratio proportioned layout. */
|
||||
export const StatCard: React.FC<StatCardProps> = ({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
color = 'primary.main',
|
||||
trend,
|
||||
isLoading = false,
|
||||
onClick,
|
||||
}) => {
|
||||
const content = (
|
||||
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
|
||||
{isLoading ? (
|
||||
<StatSkeleton />
|
||||
) : (
|
||||
<Box display="flex" alignItems="center">
|
||||
<Box sx={{ flex: GOLDEN_RATIO }}>
|
||||
<Typography variant="caption" textTransform="uppercase" color="text.secondary">
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight={700}>
|
||||
{value}
|
||||
</Typography>
|
||||
{trend && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={trend.value >= 0 ? 'success.main' : 'error.main'}
|
||||
>
|
||||
{trend.value >= 0 ? '↑' : '↓'} {Math.abs(trend.value)}%
|
||||
{trend.label && ` ${trend.label}`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: '50%',
|
||||
bgcolor: `${color}15`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
{onClick ? (
|
||||
<CardActionArea onClick={onClick} sx={{ height: '100%' }}>
|
||||
{content}
|
||||
</CardActionArea>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
33
frontend/src/components/templates/StatusChip.tsx
Normal file
33
frontend/src/components/templates/StatusChip.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { Chip } from '@mui/material';
|
||||
|
||||
export type ChipColor = 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning';
|
||||
|
||||
export interface StatusChipProps {
|
||||
status: string;
|
||||
colorMap: Record<string, ChipColor>;
|
||||
labelMap: Record<string, string>;
|
||||
size?: 'small' | 'medium';
|
||||
variant?: 'filled' | 'outlined';
|
||||
icon?: React.ReactElement;
|
||||
}
|
||||
|
||||
/** Consistent status chip with configurable color and label maps. */
|
||||
export const StatusChip: React.FC<StatusChipProps> = ({
|
||||
status,
|
||||
colorMap,
|
||||
labelMap,
|
||||
size = 'small',
|
||||
variant = 'filled',
|
||||
icon,
|
||||
}) => {
|
||||
return (
|
||||
<Chip
|
||||
label={labelMap[status] || status}
|
||||
color={colorMap[status] || 'default'}
|
||||
size={size}
|
||||
variant={variant}
|
||||
icon={icon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
50
frontend/src/components/templates/SummaryCards.tsx
Normal file
50
frontend/src/components/templates/SummaryCards.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Box, Paper, Typography } from '@mui/material';
|
||||
import { SummaryCardsSkeleton } from './SkeletonPresets';
|
||||
|
||||
export interface SummaryStat {
|
||||
label: string;
|
||||
value: string | number;
|
||||
color?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export interface SummaryCardsProps {
|
||||
stats: SummaryStat[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/** Mini stat cards displayed in a responsive grid row. */
|
||||
export const SummaryCards: React.FC<SummaryCardsProps> = ({
|
||||
stats,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return <SummaryCardsSkeleton count={stats.length || 4} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box display="grid" gridTemplateColumns="repeat(auto-fit, minmax(160px, 1fr))" gap={2}>
|
||||
{stats.map((stat, i) => (
|
||||
<Paper
|
||||
key={i}
|
||||
variant="outlined"
|
||||
onClick={stat.onClick}
|
||||
sx={{
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
cursor: stat.onClick ? 'pointer' : 'default',
|
||||
'&:hover': stat.onClick ? { bgcolor: 'action.hover' } : {},
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" sx={{ color: stat.color || 'text.primary', fontWeight: 700 }}>
|
||||
{stat.value}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{stat.label}
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
14
frontend/src/components/templates/TabPanel.tsx
Normal file
14
frontend/src/components/templates/TabPanel.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
export interface TabPanelProps {
|
||||
children: React.ReactNode;
|
||||
value: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/** Consistent tab content wrapper. Renders only when active. */
|
||||
export const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
|
||||
if (value !== index) return null;
|
||||
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
||||
};
|
||||
144
frontend/src/components/templates/WidgetCard.tsx
Normal file
144
frontend/src/components/templates/WidgetCard.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardActionArea,
|
||||
CardContent,
|
||||
Divider,
|
||||
Skeleton,
|
||||
Typography,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import InboxIcon from '@mui/icons-material/Inbox';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
|
||||
export interface WidgetCardProps {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
errorMessage?: string;
|
||||
isEmpty?: boolean;
|
||||
emptyMessage?: string;
|
||||
emptyIcon?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
skeleton?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
noPadding?: boolean;
|
||||
footer?: React.ReactNode;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
/** Universal dashboard widget wrapper with loading/error/empty states. */
|
||||
export const WidgetCard: React.FC<WidgetCardProps> = ({
|
||||
title,
|
||||
icon,
|
||||
action,
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
errorMessage = 'Fehler beim Laden',
|
||||
isEmpty = false,
|
||||
emptyMessage = 'Keine Einträge',
|
||||
emptyIcon,
|
||||
onClick,
|
||||
skeleton,
|
||||
children,
|
||||
noPadding = false,
|
||||
footer,
|
||||
sx,
|
||||
}) => {
|
||||
const header = (
|
||||
<Box
|
||||
sx={noPadding ? { px: 2.5, pt: 2.5 } : undefined}
|
||||
>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{icon}
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
{title}
|
||||
</Typography>
|
||||
</Box>
|
||||
{action}
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return skeleton ?? (
|
||||
<Box>
|
||||
<Skeleton animation="wave" height={20} sx={{ mb: 1 }} />
|
||||
<Skeleton animation="wave" height={20} sx={{ mb: 1 }} />
|
||||
<Skeleton animation="wave" height={20} width="60%" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert severity="error" variant="outlined" sx={{ border: 'none' }}>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
py={4}
|
||||
color="text.secondary"
|
||||
>
|
||||
{emptyIcon ?? <InboxIcon sx={{ fontSize: 40, mb: 1, opacity: 0.5 }} />}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{emptyMessage}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
const cardContent = (
|
||||
<>
|
||||
{header}
|
||||
<Box sx={noPadding ? { px: 0 } : undefined}>
|
||||
{renderContent()}
|
||||
</Box>
|
||||
{footer && (
|
||||
<>
|
||||
<Divider sx={{ mt: 2 }} />
|
||||
<Box sx={{ pt: 1.5, ...(noPadding ? { px: 2.5, pb: 2.5 } : {}) }}>
|
||||
{footer}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const cardContentSx = noPadding
|
||||
? { p: 0, '&:last-child': { pb: 0 } }
|
||||
: { p: 2.5, '&:last-child': { pb: 2.5 } };
|
||||
|
||||
return (
|
||||
<Card sx={{ height: '100%', ...sx }}>
|
||||
{onClick ? (
|
||||
<CardActionArea onClick={onClick} sx={{ height: '100%' }}>
|
||||
<CardContent sx={cardContentSx}>
|
||||
{cardContent}
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
) : (
|
||||
<CardContent sx={cardContentSx}>
|
||||
{cardContent}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
33
frontend/src/components/templates/index.ts
Normal file
33
frontend/src/components/templates/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export { WidgetCard } from './WidgetCard';
|
||||
export type { WidgetCardProps } from './WidgetCard';
|
||||
export { StatCard } from './StatCard';
|
||||
export type { StatCardProps } from './StatCard';
|
||||
export { ListCard } from './ListCard';
|
||||
export type { ListCardProps } from './ListCard';
|
||||
export { FormCard } from './FormCard';
|
||||
export type { FormCardProps } from './FormCard';
|
||||
export { PageHeader } from './PageHeader';
|
||||
export type { PageHeaderProps, BreadcrumbItem } from './PageHeader';
|
||||
export { PageContainer } from './PageContainer';
|
||||
export type { PageContainerProps } from './PageContainer';
|
||||
export { FormLayout } from './FormLayout';
|
||||
export type { FormLayoutProps } from './FormLayout';
|
||||
export { DetailLayout } from './DetailLayout';
|
||||
export type { DetailLayoutProps, TabDef } from './DetailLayout';
|
||||
export { TabPanel } from './TabPanel';
|
||||
export type { TabPanelProps } from './TabPanel';
|
||||
export { DataTable } from './DataTable';
|
||||
export type { DataTableProps, Column } from './DataTable';
|
||||
export { FilterBar } from './FilterBar';
|
||||
export type { FilterBarProps } from './FilterBar';
|
||||
export { InfoGrid } from './InfoGrid';
|
||||
export type { InfoGridProps, InfoField } from './InfoGrid';
|
||||
export { SummaryCards } from './SummaryCards';
|
||||
export type { SummaryCardsProps, SummaryStat } from './SummaryCards';
|
||||
export { ConfirmDialog } from './ConfirmDialog';
|
||||
export type { ConfirmDialogProps } from './ConfirmDialog';
|
||||
export { FormDialog } from './FormDialog';
|
||||
export type { FormDialogProps } from './FormDialog';
|
||||
export { StatusChip } from './StatusChip';
|
||||
export type { StatusChipProps, ChipColor } from './StatusChip';
|
||||
export * from './SkeletonPresets';
|
||||
Reference in New Issue
Block a user