feat(home): redesign dashboard with spool list and low stock alerts
Replaces stat card layout with functional dashboard: - Prominent spool count with "Add Spool" button - Paginated list of all spools sorted by last used - Low stock alert badge (< 100g threshold) - Color swatch, name, material, and remaining weight columns - Clickable rows navigate to spool detail - Secondary stats for filaments and vendors Closes #12 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,8 @@
|
|||||||
"no": "No",
|
"no": "No",
|
||||||
"notAccessTitle": "You don't have permission to access",
|
"notAccessTitle": "You don't have permission to access",
|
||||||
"hideColumns": "Hide Columns",
|
"hideColumns": "Hide Columns",
|
||||||
"clearFilters": "Clear Filters"
|
"clearFilters": "Clear Filters",
|
||||||
|
"viewAll": "View All"
|
||||||
},
|
},
|
||||||
"warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.",
|
"warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.",
|
||||||
"notifications": {
|
"notifications": {
|
||||||
@@ -324,7 +325,10 @@
|
|||||||
"home": {
|
"home": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"welcome": "Welcome to your Spoolman instance!",
|
"welcome": "Welcome to your Spoolman instance!",
|
||||||
"description": "It looks like you haven't added any spools yet. See the <helpPageLink>Help page</helpPageLink> for help getting started."
|
"description": "It looks like you haven't added any spools yet. See the <helpPageLink>Help page</helpPageLink> for help getting started.",
|
||||||
|
"all_spools": "All Spools",
|
||||||
|
"low_stock_alert": "{{count}} spools below {{threshold}}g",
|
||||||
|
"low_stock_warning": "Low stock"
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
|
|||||||
@@ -1,129 +1,247 @@
|
|||||||
import { FileOutlined, HighlightOutlined, PlusOutlined, UnorderedListOutlined, UserOutlined } from "@ant-design/icons";
|
import {
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
HighlightOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
import { IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
|
import { IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
|
||||||
import { Card, Col, Row, Statistic, theme } from "antd";
|
import { Badge, Button, Card, Col, Pagination, Row, Statistic, Table, theme, Tooltip } from "antd";
|
||||||
import { Content } from "antd/es/layout/layout";
|
import { Content } from "antd/es/layout/layout";
|
||||||
import Title from "antd/es/typography/Title";
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import React, { ReactNode } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { Trans } from "react-i18next";
|
import { Link, useNavigate } from "react-router";
|
||||||
import { Link } from "react-router";
|
import SpoolIcon from "../../components/spoolIcon";
|
||||||
import Logo from "../../icon.svg?react";
|
import { IFilament } from "../filaments/model";
|
||||||
import { ISpool } from "../spools/model";
|
import { ISpool } from "../spools/model";
|
||||||
|
import { IVendor } from "../vendors/model";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
const { useToken } = theme;
|
const { useToken } = theme;
|
||||||
|
|
||||||
|
const LOW_STOCK_THRESHOLD = 100; // grams
|
||||||
|
|
||||||
export const Home: React.FC<IResourceComponentsProps> = () => {
|
export const Home: React.FC<IResourceComponentsProps> = () => {
|
||||||
const { token } = useToken();
|
const { token } = useToken();
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const pageSize = 15;
|
||||||
|
|
||||||
|
// Fetch all spools (not archived) for the main list
|
||||||
const spools = useList<ISpool>({
|
const spools = useList<ISpool>({
|
||||||
resource: "spool",
|
resource: "spool",
|
||||||
pagination: { pageSize: 1 },
|
pagination: { current: currentPage, pageSize },
|
||||||
|
sorters: [{ field: "last_used", order: "desc" }],
|
||||||
|
meta: {
|
||||||
|
queryParams: {
|
||||||
|
allow_archived: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const filaments = useList<ISpool>({
|
|
||||||
|
// Fetch counts for stats
|
||||||
|
const filaments = useList<IFilament>({
|
||||||
resource: "filament",
|
resource: "filament",
|
||||||
pagination: { pageSize: 1 },
|
pagination: { pageSize: 1 },
|
||||||
});
|
});
|
||||||
const vendors = useList<ISpool>({
|
const vendors = useList<IVendor>({
|
||||||
resource: "vendor",
|
resource: "vendor",
|
||||||
pagination: { pageSize: 1 },
|
pagination: { pageSize: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasSpools = !spools.result || spools.result.data.length > 0;
|
// Calculate low stock spools
|
||||||
|
const lowStockCount = useMemo(() => {
|
||||||
|
if (!spools.data?.data) return 0;
|
||||||
|
return spools.data.data.filter(
|
||||||
|
(spool) => spool.remaining_weight !== undefined && spool.remaining_weight < LOW_STOCK_THRESHOLD
|
||||||
|
).length;
|
||||||
|
}, [spools.data?.data]);
|
||||||
|
|
||||||
const ResourceStatsCard = (props: { loading: boolean; value: number; resource: string; icon: ReactNode }) => (
|
// For accurate low stock count, we need all spools
|
||||||
<Col xs={12} md={6}>
|
const allSpoolsForLowStock = useList<ISpool>({
|
||||||
<Card
|
resource: "spool",
|
||||||
loading={props.loading}
|
pagination: { pageSize: 1000 },
|
||||||
hoverable
|
filters: [
|
||||||
style={{ cursor: "pointer" }}
|
{
|
||||||
styles={{ body: { padding: 0 } }}
|
field: "remaining_weight",
|
||||||
actions={[
|
operator: "lt",
|
||||||
<Link to={`/${props.resource}`} onClick={(e) => e.stopPropagation()}>
|
value: LOW_STOCK_THRESHOLD,
|
||||||
<UnorderedListOutlined />
|
},
|
||||||
</Link>,
|
],
|
||||||
<Link to={`/${props.resource}/create`} onClick={(e) => e.stopPropagation()}>
|
meta: {
|
||||||
<PlusOutlined />
|
queryParams: {
|
||||||
</Link>,
|
allow_archived: false,
|
||||||
]}
|
},
|
||||||
>
|
},
|
||||||
<Link to={`/${props.resource}`} style={{ display: "block", padding: "24px", color: "inherit" }}>
|
});
|
||||||
<Statistic title={t(`${props.resource}.${props.resource}`)} value={props.value} prefix={props.icon} />
|
|
||||||
</Link>
|
const totalLowStock = allSpoolsForLowStock.data?.total ?? lowStockCount;
|
||||||
</Card>
|
|
||||||
</Col>
|
const spoolColumns = [
|
||||||
);
|
{
|
||||||
|
title: "",
|
||||||
|
dataIndex: "color",
|
||||||
|
key: "color",
|
||||||
|
width: 50,
|
||||||
|
render: (_: unknown, record: ISpool) => {
|
||||||
|
const colorObj = record.filament.multi_color_hexes
|
||||||
|
? {
|
||||||
|
colors: record.filament.multi_color_hexes.split(","),
|
||||||
|
vertical: record.filament.multi_color_direction === "longitudinal",
|
||||||
|
}
|
||||||
|
: record.filament.color_hex;
|
||||||
|
return colorObj ? <SpoolIcon color={colorObj} /> : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("spool.fields.filament_name"),
|
||||||
|
dataIndex: "filament",
|
||||||
|
key: "filament",
|
||||||
|
render: (_: unknown, record: ISpool) => {
|
||||||
|
const vendorName = record.filament.vendor?.name;
|
||||||
|
const filamentName = record.filament.name ?? `Filament #${record.filament.id}`;
|
||||||
|
return vendorName ? `${vendorName} - ${filamentName}` : filamentName;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("spool.fields.material"),
|
||||||
|
dataIndex: ["filament", "material"],
|
||||||
|
key: "material",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("spool.fields.remaining_weight"),
|
||||||
|
dataIndex: "remaining_weight",
|
||||||
|
key: "remaining_weight",
|
||||||
|
width: 120,
|
||||||
|
align: "right" as const,
|
||||||
|
render: (weight: number | undefined, record: ISpool) => {
|
||||||
|
if (weight === undefined) return t("unknown");
|
||||||
|
const isLowStock = weight < LOW_STOCK_THRESHOLD;
|
||||||
|
return (
|
||||||
|
<span style={{ color: isLowStock ? token.colorError : undefined, fontWeight: isLowStock ? 600 : undefined }}>
|
||||||
|
{Math.round(weight)} g
|
||||||
|
{isLowStock && (
|
||||||
|
<Tooltip title={t("home.low_stock_warning")}>
|
||||||
|
<ExclamationCircleOutlined style={{ marginLeft: 6 }} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Content
|
<Content
|
||||||
style={{
|
style={{
|
||||||
padding: "2em 20px",
|
padding: "1.5em 20px",
|
||||||
minHeight: 280,
|
minHeight: 280,
|
||||||
maxWidth: 800,
|
maxWidth: 900,
|
||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
backgroundColor: token.colorBgContainer,
|
backgroundColor: token.colorBgContainer,
|
||||||
borderRadius: token.borderRadiusLG,
|
borderRadius: token.borderRadiusLG,
|
||||||
color: token.colorText,
|
color: token.colorText,
|
||||||
fontFamily: token.fontFamily,
|
fontFamily: token.fontFamily,
|
||||||
fontSize: token.fontSizeLG,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Title
|
{/* Header with spool count and add button */}
|
||||||
style={{
|
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||||
display: "flex",
|
<Col>
|
||||||
alignItems: "center",
|
<Statistic
|
||||||
justifyContent: "center",
|
title={t("spool.spool")}
|
||||||
fontSize: token.fontSizeHeading1,
|
value={spools.data?.total ?? 0}
|
||||||
}}
|
loading={spools.isLoading}
|
||||||
>
|
valueStyle={{ fontSize: 36, fontWeight: 600 }}
|
||||||
<div
|
/>
|
||||||
style={{
|
</Col>
|
||||||
display: "inline-block",
|
<Col>
|
||||||
height: "1.5em",
|
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => navigate("/spool/create")}>
|
||||||
marginRight: "0.5em",
|
{t("spool.titles.create")}
|
||||||
}}
|
</Button>
|
||||||
>
|
</Col>
|
||||||
<Logo />
|
|
||||||
</div>
|
|
||||||
Spoolman
|
|
||||||
</Title>
|
|
||||||
<Row justify="center" gutter={[16, 16]} style={{ marginTop: "3em" }}>
|
|
||||||
<ResourceStatsCard
|
|
||||||
resource="spool"
|
|
||||||
value={spools.result?.total || 0}
|
|
||||||
loading={spools.query.isLoading}
|
|
||||||
icon={<FileOutlined />}
|
|
||||||
/>
|
|
||||||
<ResourceStatsCard
|
|
||||||
resource="filament"
|
|
||||||
value={filaments.result?.total || 0}
|
|
||||||
loading={filaments.query.isLoading}
|
|
||||||
icon={<HighlightOutlined />}
|
|
||||||
/>
|
|
||||||
<ResourceStatsCard
|
|
||||||
resource="vendor"
|
|
||||||
value={vendors.result?.total || 0}
|
|
||||||
loading={vendors.query.isLoading}
|
|
||||||
icon={<UserOutlined />}
|
|
||||||
/>
|
|
||||||
</Row>
|
</Row>
|
||||||
{!hasSpools && (
|
|
||||||
<>
|
{/* Alert badges */}
|
||||||
<p style={{ marginTop: 32 }}>{t("home.welcome")}</p>
|
{totalLowStock > 0 && (
|
||||||
<p>
|
<Row style={{ marginBottom: 16 }}>
|
||||||
<Trans
|
<Col>
|
||||||
i18nKey="home.description"
|
<Link to="/spool?remaining_weight_lt=100">
|
||||||
components={{
|
<Badge
|
||||||
helpPageLink: <Link to="/help" />,
|
count={totalLowStock}
|
||||||
}}
|
style={{ backgroundColor: token.colorError }}
|
||||||
/>
|
overflowCount={99}
|
||||||
</p>
|
>
|
||||||
</>
|
<Card size="small" hoverable style={{ cursor: "pointer" }}>
|
||||||
|
<ExclamationCircleOutlined style={{ color: token.colorError, marginRight: 8 }} />
|
||||||
|
{t("home.low_stock_alert", { count: totalLowStock, threshold: LOW_STOCK_THRESHOLD })}
|
||||||
|
</Card>
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Spool list */}
|
||||||
|
<Card
|
||||||
|
title={t("home.all_spools")}
|
||||||
|
extra={<Link to="/spool">{t("buttons.viewAll")}</Link>}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
dataSource={spools.data?.data}
|
||||||
|
columns={spoolColumns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={spools.isLoading}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => navigate(`/spool/show/${record.id}`),
|
||||||
|
style: { cursor: "pointer" },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: "12px 16px", borderTop: `1px solid ${token.colorBorderSecondary}` }}>
|
||||||
|
<Pagination
|
||||||
|
current={currentPage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={spools.data?.total ?? 0}
|
||||||
|
onChange={setCurrentPage}
|
||||||
|
showSizeChanger={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Secondary stats */}
|
||||||
|
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||||
|
<Col xs={12}>
|
||||||
|
<Link to="/filament">
|
||||||
|
<Card hoverable size="small">
|
||||||
|
<Statistic
|
||||||
|
title={t("filament.filament")}
|
||||||
|
value={filaments.data?.total ?? 0}
|
||||||
|
loading={filaments.isLoading}
|
||||||
|
prefix={<HighlightOutlined />}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12}>
|
||||||
|
<Link to="/vendor">
|
||||||
|
<Card hoverable size="small">
|
||||||
|
<Statistic
|
||||||
|
title={t("vendor.vendor")}
|
||||||
|
value={vendors.data?.total ?? 0}
|
||||||
|
loading={vendors.isLoading}
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
</Content>
|
</Content>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user