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,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline"; import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
@@ -17,9 +18,41 @@ import {
DataTable, DataTable,
DateField, DateField,
type DataColumn, type DataColumn,
PageHeader,
EmptyState,
LoadingState,
FilterBar,
StatCard,
} from "../ui"; } from "../ui";
import { useTheme } from "../../context/ThemeContext"; import { useTheme } from "../../context/ThemeContext";
const BoxIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
</svg>
);
const InboxIcon = (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</svg>
);
interface DemoRow { interface DemoRow {
id: number; id: number;
name: string; name: string;
@@ -53,6 +86,7 @@ export default function UiKit() {
const [date, setDate] = useState("2026-06-06"); const [date, setDate] = useState("2026-06-06");
const [sortBy, setSortBy] = useState("name"); const [sortBy, setSortBy] = useState("name");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [lastClicked, setLastClicked] = useState<string | null>(null);
const onSort = (key: string) => { const onSort = (key: string) => {
if (sortBy === key) setSortDir((d) => (d === "asc" ? "desc" : "asc")); if (sortBy === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
@@ -185,6 +219,146 @@ export default function UiKit() {
<Alert severity="info">Informace.</Alert> <Alert severity="info">Informace.</Alert>
</Stack> </Stack>
</Card> </Card>
{/* ── Phase 6 primitives ── */}
<Card>
<Typography variant="h6">Kit Phase 6 Primitives</Typography>
<SectionLabel>PageHeader title only</SectionLabel>
<PageHeader title="Přehled projektů" />
<SectionLabel>PageHeader with subtitle + actions</SectionLabel>
<PageHeader
title="Sklady"
subtitle="Správa skladových zásob"
actions={
<>
<Button variant="outlined" color="inherit" size="small">
Export
</Button>
<Button size="small">Nová položka</Button>
</>
}
/>
<SectionLabel>EmptyState icon + action</SectionLabel>
<EmptyState
icon={InboxIcon}
title="Žádné záznamy"
description="Zatím nebyly přidány žádné položky."
action={<Button size="small">Přidat první položku</Button>}
/>
<SectionLabel>EmptyState title only</SectionLabel>
<EmptyState title="Žádné výsledky" />
<SectionLabel>LoadingState</SectionLabel>
<LoadingState label="Načítám data…" />
<SectionLabel>FilterBar</SectionLabel>
<FilterBar>
<Box sx={{ width: 200 }}>
<TextField placeholder="Hledat…" />
</Box>
<Box sx={{ width: 160 }}>
<Select
value={selectVal}
onChange={setSelectVal}
options={[
{ value: "a", label: "Všechny stavy" },
{ value: "b", label: "Aktivní" },
{ value: "c", label: "Uzavřené" },
]}
/>
</Box>
<Button size="small" variant="outlined" color="inherit">
Resetovat
</Button>
</FilterBar>
<SectionLabel>StatCard grid all colors</SectionLabel>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
gap: 1.5,
}}
>
<StatCard label="Celkem" value="1 240" icon={BoxIcon} />
<StatCard
label="Aktivní"
value="38"
icon={BoxIcon}
color="primary"
/>
<StatCard
label="Dokončené"
value="112"
icon={BoxIcon}
color="success"
footer="↑ 12 oproti minulému měsíci"
/>
<StatCard
label="Varování"
value="5"
icon={BoxIcon}
color="warning"
/>
<StatCard label="Chyby" value="2" icon={BoxIcon} color="error" />
<StatCard
label="Informace"
value="99"
icon={BoxIcon}
color="info"
/>
</Box>
<SectionLabel>
DataTable onRowClick + rowDanger (row 2 = danger)
</SectionLabel>
{lastClicked && (
<Typography
variant="caption"
color="text.secondary"
mb={1}
display="block"
>
Naposledy kliknuto: {lastClicked}
</Typography>
)}
<DataTable<DemoRow>
columns={columns}
rows={DEMO_ROWS}
rowKey={(r) => r.id}
onRowClick={(r) => setLastClicked(r.name)}
rowDanger={(r) => r.id === 2}
rowInactive={(r) => r.id === 3}
/>
<SectionLabel>Button as RouterLink</SectionLabel>
<Stack direction="row" spacing={1}>
<Button
component={RouterLink}
to="/ui-kit"
variant="outlined"
color="inherit"
size="small"
>
RouterLink button (current page)
</Button>
<Button
component="a"
href="https://mui.com"
target="_blank"
rel="noopener"
size="small"
variant="outlined"
color="inherit"
>
Anchor button
</Button>
</Stack>
</Card>
</Stack> </Stack>
</Box> </Box>
</ScopedCssBaseline> </ScopedCssBaseline>

View File

@@ -1,6 +1,12 @@
import MuiButton, { type ButtonProps } from "@mui/material/Button"; import MuiButton, { type ButtonProps } from "@mui/material/Button";
import type { ElementType } from "react";
/** App Button: defaults to the brand primary contained style. */ /** App Button: defaults to the brand primary contained style.
export default function Button(props: ButtonProps) { * 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} />; return <MuiButton variant="contained" color="primary" {...props} />;
} }

View File

@@ -32,6 +32,10 @@ export interface DataTableProps<T> {
rows: T[]; rows: T[];
rowKey: (row: T) => string | number; rowKey: (row: T) => string | number;
rowInactive?: (row: T) => boolean; 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; empty?: ReactNode;
/** Server-sort wiring: active key (matches a column's sortKey), direction, handler. */ /** Server-sort wiring: active key (matches a column's sortKey), direction, handler. */
sortBy?: string; sortBy?: string;
@@ -45,6 +49,8 @@ export default function DataTable<T>({
rows, rows,
rowKey, rowKey,
rowInactive, rowInactive,
onRowClick,
rowDanger,
empty, empty,
sortBy, sortBy,
sortDir, sortDir,
@@ -115,7 +121,21 @@ export default function DataTable<T>({
<TableRow <TableRow
key={rowKey(row)} key={rowKey(row)}
hover 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) => ( {columns.map((c) => (
<TableCell <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 type { TabDef } from "./Tabs";
export { default as Pagination } from "./Pagination"; export { default as Pagination } from "./Pagination";
export { default as DateField } from "./DateField"; 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";