diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json
index 992a65f..510edff 100644
--- a/client/public/locales/en/common.json
+++ b/client/public/locales/en/common.json
@@ -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 Help page for help getting started."
+ "description": "It looks like you haven't added any spools yet. See the Help page for help getting started.",
+ "all_spools": "All Spools",
+ "low_stock_alert": "{{count}} spools below {{threshold}}g",
+ "low_stock_warning": "Low stock"
},
"help": {
"help": "Help",
diff --git a/client/src/pages/home/index.tsx b/client/src/pages/home/index.tsx
index 525e219..3b8cee4 100644
--- a/client/src/pages/home/index.tsx
+++ b/client/src/pages/home/index.tsx
@@ -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 = () => {
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({
resource: "spool",
- pagination: { pageSize: 1 },
+ pagination: { current: currentPage, pageSize },
+ sorters: [{ field: "last_used", order: "desc" }],
+ meta: {
+ queryParams: {
+ allow_archived: false,
+ },
+ },
});
- const filaments = useList({
+
+ // Fetch counts for stats
+ const filaments = useList({
resource: "filament",
pagination: { pageSize: 1 },
});
- const vendors = useList({
+ const vendors = useList({
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 }) => (
-
- e.stopPropagation()}>
-
- ,
- e.stopPropagation()}>
-
- ,
- ]}
- >
-
-
-
-
-
- );
+ // For accurate low stock count, we need all spools
+ const allSpoolsForLowStock = useList({
+ 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 ? : 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 (
+
+ {Math.round(weight)} g
+ {isLowStock && (
+
+
+
+ )}
+
+ );
+ },
+ },
+ ];
return (
-
-
-
-
- Spoolman
-
-
- }
- />
- }
- />
- }
- />
+ {/* Header with spool count and add button */}
+
+
+
+
+
+ } onClick={() => navigate("/spool/create")}>
+ {t("spool.titles.create")}
+
+
- {!hasSpools && (
- <>
- {t("home.welcome")}
-
- ,
- }}
- />
-
- >
+
+ {/* Alert badges */}
+ {totalLowStock > 0 && (
+
+
+
+
+
+
+ {t("home.low_stock_alert", { count: totalLowStock, threshold: LOW_STOCK_THRESHOLD })}
+
+
+
+
+
)}
+
+ {/* Spool list */}
+ {t("buttons.viewAll")}}
+ styles={{ body: { padding: 0 } }}
+ >
+ ({
+ onClick: () => navigate(`/spool/show/${record.id}`),
+ style: { cursor: "pointer" },
+ })}
+ />
+
+
+
+ {/* Secondary stats */}
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+ }
+ />
+
+
+
+
);
};