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",
|
||||
"notAccessTitle": "You don't have permission to access",
|
||||
"hideColumns": "Hide Columns",
|
||||
"clearFilters": "Clear Filters"
|
||||
"clearFilters": "Clear Filters",
|
||||
"viewAll": "View All"
|
||||
},
|
||||
"warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.",
|
||||
"notifications": {
|
||||
@@ -324,7 +325,10 @@
|
||||
"home": {
|
||||
"home": "Home",
|
||||
"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",
|
||||
|
||||
@@ -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 { 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 Title from "antd/es/typography/Title";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React, { ReactNode } from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import Logo from "../../icon.svg?react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import SpoolIcon from "../../components/spoolIcon";
|
||||
import { IFilament } from "../filaments/model";
|
||||
import { ISpool } from "../spools/model";
|
||||
import { IVendor } from "../vendors/model";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
const { useToken } = theme;
|
||||
|
||||
const LOW_STOCK_THRESHOLD = 100; // grams
|
||||
|
||||
export const Home: React.FC<IResourceComponentsProps> = () => {
|
||||
const { token } = useToken();
|
||||
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>({
|
||||
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",
|
||||
pagination: { pageSize: 1 },
|
||||
});
|
||||
const vendors = useList<ISpool>({
|
||||
const vendors = useList<IVendor>({
|
||||
resource: "vendor",
|
||||
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 }) => (
|
||||
<Col xs={12} md={6}>
|
||||
<Card
|
||||
loading={props.loading}
|
||||
hoverable
|
||||
style={{ cursor: "pointer" }}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
actions={[
|
||||
<Link to={`/${props.resource}`} onClick={(e) => e.stopPropagation()}>
|
||||
<UnorderedListOutlined />
|
||||
</Link>,
|
||||
<Link to={`/${props.resource}/create`} onClick={(e) => e.stopPropagation()}>
|
||||
<PlusOutlined />
|
||||
</Link>,
|
||||
]}
|
||||
>
|
||||
<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>
|
||||
</Card>
|
||||
</Col>
|
||||
// For accurate low stock count, we need all spools
|
||||
const allSpoolsForLowStock = useList<ISpool>({
|
||||
resource: "spool",
|
||||
pagination: { pageSize: 1000 },
|
||||
filters: [
|
||||
{
|
||||
field: "remaining_weight",
|
||||
operator: "lt",
|
||||
value: LOW_STOCK_THRESHOLD,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
queryParams: {
|
||||
allow_archived: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const totalLowStock = allSpoolsForLowStock.data?.total ?? lowStockCount;
|
||||
|
||||
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 (
|
||||
<Content
|
||||
style={{
|
||||
padding: "2em 20px",
|
||||
padding: "1.5em 20px",
|
||||
minHeight: 280,
|
||||
maxWidth: 800,
|
||||
maxWidth: 900,
|
||||
margin: "0 auto",
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
color: token.colorText,
|
||||
fontFamily: token.fontFamily,
|
||||
fontSize: token.fontSizeLG,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<Title
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: token.fontSizeHeading1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
height: "1.5em",
|
||||
marginRight: "0.5em",
|
||||
}}
|
||||
>
|
||||
<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 />}
|
||||
{/* Header with spool count and add button */}
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Statistic
|
||||
title={t("spool.spool")}
|
||||
value={spools.data?.total ?? 0}
|
||||
loading={spools.isLoading}
|
||||
valueStyle={{ fontSize: 36, fontWeight: 600 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => navigate("/spool/create")}>
|
||||
{t("spool.titles.create")}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Alert badges */}
|
||||
{totalLowStock > 0 && (
|
||||
<Row style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Link to="/spool?remaining_weight_lt=100">
|
||||
<Badge
|
||||
count={totalLowStock}
|
||||
style={{ backgroundColor: token.colorError }}
|
||||
overflowCount={99}
|
||||
>
|
||||
<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>
|
||||
{!hasSpools && (
|
||||
<>
|
||||
<p style={{ marginTop: 32 }}>{t("home.welcome")}</p>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="home.description"
|
||||
components={{
|
||||
helpPageLink: <Link to="/help" />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user