feat(mui): kit primitives — PageHeader, EmptyState, LoadingState, FilterBar, StatCard + DataTable onRowClick/rowDanger

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-06 23:18:39 +02:00
parent 26f12b01a0
commit ba18cb07f0
9 changed files with 450 additions and 3 deletions

View File

@@ -1,6 +1,12 @@
import MuiButton, { type ButtonProps } from "@mui/material/Button";
import type { ElementType } from "react";
/** App Button: defaults to the brand primary contained style. */
export default function Button(props: ButtonProps) {
/** App Button: defaults to the brand primary contained style.
* Accepts `component` + any extra props the component requires
* (e.g. `component={RouterLink} to="/foo"` or `component="a" href="…"`).
*/
export default function Button<C extends ElementType = "button">(
props: ButtonProps<C, { component?: C }>,
) {
return <MuiButton variant="contained" color="primary" {...props} />;
}

View File

@@ -32,6 +32,10 @@ export interface DataTableProps<T> {
rows: T[];
rowKey: (row: T) => string | number;
rowInactive?: (row: T) => boolean;
/** When set, each row is clickable and shows a pointer cursor. */
onRowClick?: (row: T) => void;
/** When it returns true, the row is tinted with the error palette (danger highlight). */
rowDanger?: (row: T) => boolean;
empty?: ReactNode;
/** Server-sort wiring: active key (matches a column's sortKey), direction, handler. */
sortBy?: string;
@@ -45,6 +49,8 @@ export default function DataTable<T>({
rows,
rowKey,
rowInactive,
onRowClick,
rowDanger,
empty,
sortBy,
sortDir,
@@ -115,7 +121,21 @@ export default function DataTable<T>({
<TableRow
key={rowKey(row)}
hover
sx={rowInactive?.(row) ? { opacity: 0.55 } : undefined}
onClick={onRowClick ? () => onRowClick(row) : undefined}
sx={[
onRowClick ? { cursor: "pointer" } : {},
rowDanger?.(row)
? {
bgcolor: "error.light",
opacity: 0.88,
"&:hover": {
bgcolor: "error.light",
filter: "brightness(0.97)",
},
}
: {},
rowInactive?.(row) ? { opacity: 0.55 } : {},
]}
>
{columns.map((c) => (
<TableCell

View File

@@ -0,0 +1,52 @@
import type { ReactNode } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
export interface EmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
}
/** Centered empty-state placeholder: icon, title, description, optional CTA. */
export default function EmptyState({
icon,
title,
description,
action,
}: EmptyStateProps) {
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
py: 6,
color: "text.secondary",
gap: 1,
}}
>
{icon && (
<Box
sx={{ color: "text.disabled", mb: 0.5, fontSize: 48, lineHeight: 1 }}
>
{icon}
</Box>
)}
<Typography
variant="subtitle1"
sx={{ fontWeight: 500, color: "text.secondary" }}
>
{title}
</Typography>
{description && (
<Typography variant="body2" color="text.secondary">
{description}
</Typography>
)}
{action && <Box sx={{ mt: 1.5 }}>{action}</Box>}
</Box>
);
}

View File

@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import Box from "@mui/material/Box";
export interface FilterBarProps {
children: ReactNode;
}
/**
* Flex row wrapper for filter controls — replaces admin-search-bar/admin-form-row-N.
* Children size themselves; callers wrap individual controls in a Box with width.
*/
export default function FilterBar({ children }: FilterBarProps) {
return (
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 1.5,
alignItems: "flex-end",
mb: 2,
}}
>
{children}
</Box>
);
}

View File

@@ -0,0 +1,30 @@
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import Typography from "@mui/material/Typography";
export interface LoadingStateProps {
label?: string;
}
/** Centered loading indicator — replaces admin-loading/admin-spinner CSS patterns. */
export default function LoadingState({ label }: LoadingStateProps) {
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
py: 8,
gap: 2,
}}
>
<CircularProgress size={36} />
{label && (
<Typography variant="body2" color="text.secondary">
{label}
</Typography>
)}
</Box>
);
}

View File

@@ -0,0 +1,48 @@
import type { ReactNode } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
export interface PageHeaderProps {
title: string;
subtitle?: string;
actions?: ReactNode;
}
/**
* Standard page header: title (+ optional subtitle) on the left,
* action controls on the right. No framer-motion — pages add their own entrance.
*/
export default function PageHeader({
title,
subtitle,
actions,
}: PageHeaderProps) {
return (
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
<Box>
<Typography variant="h4">{title}</Typography>
{subtitle && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{subtitle}
</Typography>
)}
</Box>
{actions && (
<Box
sx={{ display: "flex", alignItems: "center", gap: 1, flexShrink: 0 }}
>
{actions}
</Box>
)}
</Box>
);
}

85
src/admin/ui/StatCard.tsx Normal file
View File

@@ -0,0 +1,85 @@
import type { ReactNode } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Card from "./Card";
export type StatCardColor =
| "default"
| "primary"
| "success"
| "warning"
| "error"
| "info";
export interface StatCardProps {
label: string;
value: ReactNode;
icon?: ReactNode;
color?: StatCardColor;
footer?: ReactNode;
}
/**
* KPI / metric tile. Dense Soft-SaaS look: tinted icon square, big value,
* small uppercase label, optional footer caption.
*/
export default function StatCard({
label,
value,
icon,
color = "default",
footer,
}: StatCardProps) {
const iconBg = color === "default" ? "action.hover" : `${color}.light`;
const iconColor = color === "default" ? "text.secondary" : `${color}.main`;
return (
<Card>
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5 }}>
{icon && (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: 2,
bgcolor: iconBg,
color: iconColor,
flexShrink: 0,
}}
>
{icon}
</Box>
)}
<Box sx={{ minWidth: 0 }}>
<Typography
variant="h5"
sx={{ fontFamily: "'DM Mono', Menlo, monospace", lineHeight: 1.2 }}
>
{value}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{
textTransform: "uppercase",
letterSpacing: ".06em",
fontWeight: 600,
}}
>
{label}
</Typography>
</Box>
</Box>
{footer && (
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: 1, borderColor: "divider" }}>
<Typography variant="caption" color="text.secondary">
{footer}
</Typography>
</Box>
)}
</Card>
);
}

View File

@@ -16,3 +16,9 @@ export { Tabs, TabPanel } from "./Tabs";
export type { TabDef } from "./Tabs";
export { default as Pagination } from "./Pagination";
export { default as DateField } from "./DateField";
export { default as PageHeader } from "./PageHeader";
export { default as EmptyState } from "./EmptyState";
export { default as LoadingState } from "./LoadingState";
export { default as FilterBar } from "./FilterBar";
export { default as StatCard } from "./StatCard";
export type { StatCardColor } from "./StatCard";