Compare commits
3 Commits
2dd7c1198c
...
267fadfc54
| Author | SHA1 | Date | |
|---|---|---|---|
| 267fadfc54 | |||
| 48bd516c0f | |||
| 0a741c9712 |
@@ -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": {
|
||||||
@@ -51,6 +52,11 @@
|
|||||||
"validationError": "Validation error: {{error}}"
|
"validationError": "Validation error: {{error}}"
|
||||||
},
|
},
|
||||||
"kofi": "Tip me on Ko-fi",
|
"kofi": "Tip me on Ko-fi",
|
||||||
|
"color": {
|
||||||
|
"click_to_copy": "Click to copy",
|
||||||
|
"copied": "Copied {{color}} to clipboard",
|
||||||
|
"copy_failed": "Failed to copy to clipboard"
|
||||||
|
},
|
||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
@@ -259,7 +265,9 @@
|
|||||||
"longitudinal": "Longitudinal",
|
"longitudinal": "Longitudinal",
|
||||||
"external_id": "External ID",
|
"external_id": "External ID",
|
||||||
"spools": "Show Spools",
|
"spools": "Show Spools",
|
||||||
"not_set": "Not Set"
|
"not_set": "Not Set",
|
||||||
|
"spool_count": "Spools",
|
||||||
|
"total_remaining_weight": "Total Remaining"
|
||||||
},
|
},
|
||||||
"fields_help": {
|
"fields_help": {
|
||||||
"name": "Filament name, to distinguish this filament type among others from the same manufacturer. Should contain the color for example.",
|
"name": "Filament name, to distinguish this filament type among others from the same manufacturer. Should contain the color for example.",
|
||||||
@@ -324,7 +332,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,17 +1,22 @@
|
|||||||
|
import { CopyOutlined } from "@ant-design/icons";
|
||||||
|
import { message, Tooltip } from "antd";
|
||||||
|
import { useTranslate } from "@refinedev/core";
|
||||||
import "./spoolIcon.css";
|
import "./spoolIcon.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
color: string | { colors: string[]; vertical: boolean };
|
color: string | { colors: string[]; vertical: boolean };
|
||||||
size?: "small" | "large";
|
size?: "small" | "large";
|
||||||
no_margin? : boolean
|
no_margin?: boolean;
|
||||||
|
showHex?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SpoolIcon(props: Readonly<Props>) {
|
export default function SpoolIcon(props: Readonly<Props>) {
|
||||||
|
const t = useTranslate();
|
||||||
let dirClass = "vertical";
|
let dirClass = "vertical";
|
||||||
let cols = [];
|
let cols: string[] = [];
|
||||||
let size = props.size ? props.size : "small";
|
const size = props.size ? props.size : "small";
|
||||||
let no_margin = props.no_margin ? "no-margin" : "";
|
const no_margin = props.no_margin ? "no-margin" : "";
|
||||||
|
|
||||||
if (typeof props.color === "string") {
|
if (typeof props.color === "string") {
|
||||||
cols = [props.color];
|
cols = [props.color];
|
||||||
} else {
|
} else {
|
||||||
@@ -19,16 +24,49 @@ export default function SpoolIcon(props: Readonly<Props>) {
|
|||||||
cols = props.color.colors;
|
cols = props.color.colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Normalize hex codes (remove # if present, uppercase)
|
||||||
|
const normalizedCols = cols.map((col) => col.replace("#", "").toUpperCase());
|
||||||
|
const hexDisplay = normalizedCols.length === 1 ? `#${normalizedCols[0]}` : normalizedCols.map((c) => `#${c}`).join(", ");
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(hexDisplay);
|
||||||
|
message.success(t("color.copied", { color: hexDisplay }));
|
||||||
|
} catch {
|
||||||
|
message.error(t("color.copy_failed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const swatchContent = (
|
||||||
<div className={"spool-icon " + dirClass + " " + size + " " + no_margin}>
|
<div className={"spool-icon " + dirClass + " " + size + " " + no_margin}>
|
||||||
{cols.map((col) => (
|
{normalizedCols.map((col) => (
|
||||||
<div
|
<div
|
||||||
key={col}
|
key={col}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#" + col.replace("#", ""),
|
backgroundColor: "#" + col,
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (props.showHex) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={`${t("color.click_to_copy")}: ${hexDisplay}`}>
|
||||||
|
<div
|
||||||
|
className="spool-icon-with-hex"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
style={{ cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
{swatchContent}
|
||||||
|
<span className="hex-code" style={{ fontFamily: "monospace", fontSize: "0.85em", opacity: 0.8 }}>
|
||||||
|
{hexDisplay}
|
||||||
|
<CopyOutlined style={{ marginLeft: 4, fontSize: "0.9em" }} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return swatchContent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ const allColumns: (keyof IFilamentCollapsed & string)[] = [
|
|||||||
"article_number",
|
"article_number",
|
||||||
"settings_extruder_temp",
|
"settings_extruder_temp",
|
||||||
"settings_bed_temp",
|
"settings_bed_temp",
|
||||||
|
"spool_count",
|
||||||
|
"total_remaining_weight",
|
||||||
"registered",
|
"registered",
|
||||||
"comment",
|
"comment",
|
||||||
];
|
];
|
||||||
@@ -324,6 +326,22 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
|
|||||||
maxDecimals: 0,
|
maxDecimals: 0,
|
||||||
width: 100,
|
width: 100,
|
||||||
}),
|
}),
|
||||||
|
NumberColumn({
|
||||||
|
...commonProps,
|
||||||
|
id: "spool_count",
|
||||||
|
i18ncat: "filament",
|
||||||
|
unit: "",
|
||||||
|
maxDecimals: 0,
|
||||||
|
width: 80,
|
||||||
|
}),
|
||||||
|
NumberColumn({
|
||||||
|
...commonProps,
|
||||||
|
id: "total_remaining_weight",
|
||||||
|
i18ncat: "filament",
|
||||||
|
unit: "g",
|
||||||
|
maxDecimals: 0,
|
||||||
|
width: 120,
|
||||||
|
}),
|
||||||
DateColumn({
|
DateColumn({
|
||||||
...commonProps,
|
...commonProps,
|
||||||
id: "registered",
|
id: "registered",
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface IFilament {
|
|||||||
multi_color_direction?: string;
|
multi_color_direction?: string;
|
||||||
external_id?: string;
|
external_id?: string;
|
||||||
extra: { [key: string]: string };
|
extra: { [key: string]: string };
|
||||||
|
spool_count?: number;
|
||||||
|
total_remaining_weight?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types
|
// IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types
|
||||||
|
|||||||
@@ -88,8 +88,7 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
|
|||||||
<Title level={5}>{t("filament.fields.name")}</Title>
|
<Title level={5}>{t("filament.fields.name")}</Title>
|
||||||
<TextField value={record?.name} />
|
<TextField value={record?.name} />
|
||||||
<Title level={5}>{t("filament.fields.color_hex")}</Title>
|
<Title level={5}>{t("filament.fields.color_hex")}</Title>
|
||||||
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin />}
|
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin showHex />}
|
||||||
{record?.color_hex && <TextField value={`#${record?.color_hex}`} />}
|
|
||||||
<Title level={5}>{t("filament.fields.material")}</Title>
|
<Title level={5}>{t("filament.fields.material")}</Title>
|
||||||
<TextField value={record?.material} />
|
<TextField value={record?.material} />
|
||||||
<Title level={5}>{t("filament.fields.price")}</Title>
|
<Title level={5}>{t("filament.fields.price")}</Title>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
|||||||
<Title level={5}>{t("spool.fields.id")}</Title>
|
<Title level={5}>{t("spool.fields.id")}</Title>
|
||||||
<NumberField value={record?.id ?? ""} />
|
<NumberField value={record?.id ?? ""} />
|
||||||
<Title level={5}>{t("spool.fields.filament")}</Title>
|
<Title level={5}>{t("spool.fields.filament")}</Title>
|
||||||
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin />}
|
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin showHex />}
|
||||||
<TextField value={record ? filamentURL(record?.filament) : ""} />
|
<TextField value={record ? filamentURL(record?.filament) : ""} />
|
||||||
<Title level={5}>{t("spool.fields.price")}</Title>
|
<Title level={5}>{t("spool.fields.price")}</Title>
|
||||||
<TextField value={spoolPrice(record)} />
|
<TextField value={spoolPrice(record)} />
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ async def find(
|
|||||||
else:
|
else:
|
||||||
filter_by_ids = None
|
filter_by_ids = None
|
||||||
|
|
||||||
db_items, total_count = await filament.find(
|
db_items, total_count, spool_stats = await filament.find(
|
||||||
db=db,
|
db=db,
|
||||||
ids=filter_by_ids,
|
ids=filter_by_ids,
|
||||||
vendor_name=vendor_name if vendor_name is not None else vendor_name_old,
|
vendor_name=vendor_name if vendor_name is not None else vendor_name_old,
|
||||||
@@ -383,7 +383,14 @@ async def find(
|
|||||||
# Set x-total-count header for pagination
|
# Set x-total-count header for pagination
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=jsonable_encoder(
|
content=jsonable_encoder(
|
||||||
(Filament.from_db(db_item) for db_item in db_items),
|
(
|
||||||
|
Filament.from_db(
|
||||||
|
db_item,
|
||||||
|
spool_count=spool_stats.get(db_item.id, (0, 0))[0],
|
||||||
|
total_remaining_weight=spool_stats.get(db_item.id, (0, 0))[1],
|
||||||
|
)
|
||||||
|
for db_item in db_items
|
||||||
|
),
|
||||||
exclude_none=True,
|
exclude_none=True,
|
||||||
),
|
),
|
||||||
headers={"x-total-count": str(total_count)},
|
headers={"x-total-count": str(total_count)},
|
||||||
|
|||||||
@@ -221,9 +221,25 @@ class Filament(BaseModel):
|
|||||||
"Query the /fields endpoint for more details about the fields."
|
"Query the /fields endpoint for more details about the fields."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
spool_count: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
ge=0,
|
||||||
|
description="Number of spools of this filament (excluding archived). Computed field.",
|
||||||
|
examples=[5],
|
||||||
|
)
|
||||||
|
total_remaining_weight: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
ge=0,
|
||||||
|
description="Total remaining weight across all spools of this filament (excluding archived), in grams. Computed field.",
|
||||||
|
examples=[2500.0],
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_db(item: models.Filament) -> "Filament":
|
def from_db(
|
||||||
|
item: models.Filament,
|
||||||
|
spool_count: Optional[int] = None,
|
||||||
|
total_remaining_weight: Optional[float] = None,
|
||||||
|
) -> "Filament":
|
||||||
"""Create a new Pydantic filament object from a database filament object."""
|
"""Create a new Pydantic filament object from a database filament object."""
|
||||||
return Filament(
|
return Filament(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
@@ -251,6 +267,8 @@ class Filament(BaseModel):
|
|||||||
),
|
),
|
||||||
external_id=item.external_id,
|
external_id=item.external_id,
|
||||||
extra={field.key: field.value for field in item.extra},
|
extra={field.key: field.value for field in item.extra},
|
||||||
|
spool_count=spool_count,
|
||||||
|
total_remaining_weight=total_remaining_weight,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -114,13 +114,16 @@ async def find(
|
|||||||
sort_by: Optional[dict[str, SortOrder]] = None,
|
sort_by: Optional[dict[str, SortOrder]] = None,
|
||||||
limit: Optional[int] = None,
|
limit: Optional[int] = None,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
) -> tuple[list[models.Filament], int]:
|
) -> tuple[list[models.Filament], int, dict[int, tuple[int, float]]]:
|
||||||
"""Find a list of filament objects by search criteria.
|
"""Find a list of filament objects by search criteria.
|
||||||
|
|
||||||
Sort by a field by passing a dict with the field name as key and the sort order as value.
|
Sort by a field by passing a dict with the field name as key and the sort order as value.
|
||||||
The field name can contain nested fields, e.g. vendor.name.
|
The field name can contain nested fields, e.g. vendor.name.
|
||||||
|
|
||||||
Returns a tuple containing the list of items and the total count of matching items.
|
Returns a tuple containing:
|
||||||
|
- list of filaments
|
||||||
|
- total count of matching items
|
||||||
|
- dict mapping filament_id to (spool_count, total_remaining_weight)
|
||||||
"""
|
"""
|
||||||
stmt = (
|
stmt = (
|
||||||
select(models.Filament)
|
select(models.Filament)
|
||||||
@@ -160,7 +163,25 @@ async def find(
|
|||||||
if total_count is None:
|
if total_count is None:
|
||||||
total_count = len(result)
|
total_count = len(result)
|
||||||
|
|
||||||
return result, total_count
|
# Fetch spool statistics for all filaments in the result
|
||||||
|
spool_stats: dict[int, tuple[int, float]] = {}
|
||||||
|
if result:
|
||||||
|
filament_ids = [f.id for f in result]
|
||||||
|
stats_stmt = (
|
||||||
|
select(
|
||||||
|
models.Spool.filament_id,
|
||||||
|
func.count(models.Spool.id).label("spool_count"),
|
||||||
|
func.coalesce(func.sum(models.Spool.initial_weight - models.Spool.used_weight), 0).label("total_remaining"),
|
||||||
|
)
|
||||||
|
.where(models.Spool.filament_id.in_(filament_ids))
|
||||||
|
.where((models.Spool.archived == False) | (models.Spool.archived == None)) # noqa: E712, E711
|
||||||
|
.group_by(models.Spool.filament_id)
|
||||||
|
)
|
||||||
|
stats_rows = await db.execute(stats_stmt)
|
||||||
|
for row in stats_rows.all():
|
||||||
|
spool_stats[row.filament_id] = (row.spool_count, float(row.total_remaining or 0))
|
||||||
|
|
||||||
|
return result, total_count, spool_stats
|
||||||
|
|
||||||
|
|
||||||
async def update(
|
async def update(
|
||||||
|
|||||||
Reference in New Issue
Block a user