feat: Add extra weight, price tracking, print history, usage analytics
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled

- Extra Weight Field (#14): Track DryPods, custom holders in spool weight
  calculations. New extra_weight field on spool with DB migration.

- Price/Cost Tracking: Compute remaining_value based on remaining weight
  and price. Added column to spool list, inventory value on dashboard.

- Print History: Show print job history on spool detail page with
  collapsible table showing filename, filament used, status, dates.

- Usage Analytics: New dashboard component with time-series chart
  showing daily consumption, period selector (7/30/90 days), and
  material breakdown. New API endpoints for analytics data.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 22:38:14 -06:00
parent 0556be9e3b
commit 18cafc4361
15 changed files with 514 additions and 18 deletions

View File

@@ -191,11 +191,13 @@
"weight_to_use": "Weight",
"used_weight": "Used Weight",
"remaining_weight": "Remaining Weight",
"remaining_value": "Remaining Value",
"measured_weight": "Measured Weight",
"used_length": "Used Length",
"remaining_length": "Remaining Length",
"initial_weight": "Initial Weight",
"spool_weight": "Empty Weight",
"extra_weight": "Extra Weight",
"location": "Location",
"lot_nr": "Lot Nr",
"first_used": "First Used",
@@ -205,6 +207,8 @@
"archived": "Archived",
"adjustment_history": "Adjustment History",
"show_history": "Show History",
"print_history": "Print History",
"show_print_history": "Show Print History",
"adjustment_timestamp": "Date/Time",
"adjustment_type": "Type",
"adjustment_type_weight": "Weight",
@@ -220,6 +224,7 @@
"measured_weight": "How much the filament and spool weigh.",
"initial_weight": "The initial weight of filament on the spool (net weight). Will use the weight from the filament object if not set.",
"spool_weight": "The weight of the spool when it is empty. Leave empty to use the value from the filament or the manufacturer.",
"extra_weight": "Additional weight to account for when measuring, such as DryPods, custom spool holders, or other accessories.",
"location": "Where the spool is located if you have multiple locations where you store your spools.",
"lot_nr": "Manufacturer's lot number. Can be used to ensure a print has consistent color if multiple spools are used.",
"external_filament": "You have selected a filament from the external database. A filament object (and possibly a manufacturer object) will be created automatically when you create this spool. This can create duplicate filament objects if you have already created a filament object for this filament."
@@ -377,6 +382,20 @@
"name_required": "Name is required"
}
},
"print_job": {
"fields": {
"created": "Created",
"filename": "Filename",
"filament_used": "Filament Used",
"status": "Status",
"finished": "Finished"
},
"status": {
"pending": "Pending",
"completed": "Completed",
"cancelled": "Cancelled"
}
},
"home": {
"home": "Home",
"welcome": "Welcome to your Spoolman instance!",
@@ -396,7 +415,17 @@
"total_spools": "Total Spools",
"total_filaments": "Filament Types",
"total_vendors": "Vendors",
"total_weight": "Total Remaining"
"total_weight": "Total Remaining",
"total_value": "Inventory Value"
},
"analytics": {
"title": "Usage Analytics",
"7_days": "7 Days",
"30_days": "30 Days",
"90_days": "90 Days",
"total_used": "Total used: {{weight}} kg",
"used": "Used",
"by_material": "Usage by Material"
}
},
"help": {

View File

@@ -1,4 +1,4 @@
import { HighlightOutlined, InboxOutlined, DatabaseOutlined, UserOutlined } from "@ant-design/icons";
import { DollarOutlined, HighlightOutlined, InboxOutlined, DatabaseOutlined, UserOutlined } from "@ant-design/icons";
import { useList, useTranslate } from "@refinedev/core";
import { Card, Col, Row, Statistic, theme } from "antd";
import { useMemo } from "react";
@@ -6,12 +6,14 @@ import { Link } from "react-router";
import { IFilament } from "../../filaments/model";
import { ISpool } from "../../spools/model";
import { IVendor } from "../../vendors/model";
import { useCurrencyFormatter } from "../../../utils/settings";
const { useToken } = theme;
export function QuickStats() {
const t = useTranslate();
const { token } = useToken();
const currencyFormatter = useCurrencyFormatter();
const { result: filamentsResult, query: filamentsQuery } = useList<IFilament>({
resource: "filament",
@@ -40,6 +42,12 @@ export function QuickStats() {
return data.reduce((sum, spool) => sum + (spool.remaining_weight ?? 0), 0);
}, [spoolsResult?.data]);
// Calculate total inventory value
const totalValue = useMemo(() => {
const data = spoolsResult?.data || [];
return data.reduce((sum, spool) => sum + (spool.remaining_value ?? 0), 0);
}, [spoolsResult?.data]);
const formatWeight = (grams: number) => {
if (grams >= 1000) {
return `${(grams / 1000).toFixed(1)} kg`;
@@ -49,7 +57,7 @@ export function QuickStats() {
return (
<Row gutter={[12, 12]} style={{ marginTop: 16 }}>
<Col xs={12} sm={6}>
<Col xs={12} md={8} lg={4}>
<Link to="/spool">
<Card hoverable size="small">
<Statistic
@@ -61,7 +69,7 @@ export function QuickStats() {
</Card>
</Link>
</Col>
<Col xs={12} sm={6}>
<Col xs={12} md={8} lg={4}>
<Link to="/filament">
<Card hoverable size="small">
<Statistic
@@ -73,7 +81,7 @@ export function QuickStats() {
</Card>
</Link>
</Col>
<Col xs={12} sm={6}>
<Col xs={12} md={8} lg={4}>
<Link to="/vendor">
<Card hoverable size="small">
<Statistic
@@ -85,7 +93,7 @@ export function QuickStats() {
</Card>
</Link>
</Col>
<Col xs={12} sm={6}>
<Col xs={12} md={12} lg={6}>
<Card size="small">
<Statistic
title={t("home.quick_stats.total_weight")}
@@ -95,6 +103,16 @@ export function QuickStats() {
/>
</Card>
</Col>
<Col xs={12} md={12} lg={6}>
<Card size="small">
<Statistic
title={t("home.quick_stats.total_value")}
value={currencyFormatter.format(totalValue)}
loading={spoolsQuery.isLoading}
prefix={<DollarOutlined />}
/>
</Card>
</Col>
</Row>
);
}

View File

@@ -0,0 +1,123 @@
import { useTranslate } from "@refinedev/core";
import { useQuery } from "@tanstack/react-query";
import { Card, Segmented, Spin, Typography } from "antd";
import { useState } from "react";
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { getAPIURL } from "../../../utils/url";
interface UsageDataPoint {
date: string;
weight_used: number;
}
interface MaterialUsage {
material: string;
weight_used: number;
}
export function UsageAnalytics() {
const t = useTranslate();
const [days, setDays] = useState<number>(30);
const { data: usageData, isLoading: usageLoading } = useQuery<UsageDataPoint[]>({
queryKey: ["analytics", "usage", days],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/analytics/usage?days=${days}`);
return res.json();
},
});
const { data: materialData, isLoading: materialLoading } = useQuery<MaterialUsage[]>({
queryKey: ["analytics", "by-material", days],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/analytics/by-material?days=${days}`);
return res.json();
},
});
// Format date for display
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
};
// Calculate total usage
const totalUsage = usageData?.reduce((sum, d) => sum + d.weight_used, 0) ?? 0;
return (
<Card
title={t("home.analytics.title")}
extra={
<Segmented
value={days}
onChange={(v) => setDays(v as number)}
options={[
{ label: t("home.analytics.7_days"), value: 7 },
{ label: t("home.analytics.30_days"), value: 30 },
{ label: t("home.analytics.90_days"), value: 90 },
]}
/>
}
style={{ marginTop: 16 }}
>
{usageLoading ? (
<div style={{ textAlign: "center", padding: 40 }}>
<Spin />
</div>
) : (
<>
<Typography.Text type="secondary">
{t("home.analytics.total_used", { weight: (totalUsage / 1000).toFixed(2) })}
</Typography.Text>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={usageData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorUsage" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#1890ff" stopOpacity={0.8} />
<stop offset="95%" stopColor="#1890ff" stopOpacity={0.1} />
</linearGradient>
</defs>
<XAxis
dataKey="date"
tickFormatter={formatDate}
tick={{ fontSize: 11 }}
interval="preserveStartEnd"
/>
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `${v}g`} width={50} />
<Tooltip
formatter={(value) => [`${(value as number).toFixed(1)} g`, t("home.analytics.used")]}
labelFormatter={(label) => new Date(label).toLocaleDateString()}
/>
<Area
type="monotone"
dataKey="weight_used"
stroke="#1890ff"
fillOpacity={1}
fill="url(#colorUsage)"
/>
</AreaChart>
</ResponsiveContainer>
{materialData && materialData.length > 0 && (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
{t("home.analytics.by_material")}
</Typography.Title>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{materialData.map((m) => (
<Card size="small" key={m.material} style={{ minWidth: 120 }}>
<Typography.Text strong>{m.material}</Typography.Text>
<br />
<Typography.Text type="secondary">
{m.weight_used >= 1000 ? `${(m.weight_used / 1000).toFixed(2)} kg` : `${m.weight_used.toFixed(0)} g`}
</Typography.Text>
</Card>
))}
</div>
</>
)}
</>
)}
</Card>
);
}

View File

@@ -14,6 +14,7 @@ import { ISpool } from "../spools/model";
import { AlertCards } from "./components/AlertCards";
import { MaterialDistributionChart } from "./components/MaterialDistributionChart";
import { QuickStats } from "./components/QuickStats";
import { UsageAnalytics } from "./components/UsageAnalytics";
dayjs.extend(utc);
@@ -132,6 +133,9 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
{/* Material distribution chart */}
<MaterialDistributionChart />
{/* Usage analytics */}
<UsageAnalytics />
{/* Spool list */}
<Card
title={t("home.all_spools")}

View File

@@ -51,6 +51,7 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
const initialWeightValue = Form.useWatch("initial_weight", form);
const spoolWeightValue = Form.useWatch("spool_weight", form);
const extraWeightValue = Form.useWatch("extra_weight", form);
if (props.mode === "clone") {
// Clear out the values that we don't want to clone
@@ -208,10 +209,15 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
return initialWeightValue ?? selectedFilament?.weight ?? 0;
};
const getExtraWeight = (): number => {
return extraWeightValue ?? 0;
};
const getGrossWeight = (): number => {
const net_weight = getFilamentWeight();
const spool_weight = getSpoolWeight();
return net_weight + spool_weight;
const extra_weight = getExtraWeight();
return net_weight + spool_weight + extra_weight;
};
const getMeasuredWeight = (): number => {
@@ -402,6 +408,21 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.extra_weight")}
help={t("spool.fields_help.extra_weight")}
name={["extra_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} />
</Form.Item>

View File

@@ -67,6 +67,7 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
const initialWeightValue = Form.useWatch("initial_weight", form);
const spoolWeightValue = Form.useWatch("spool_weight", form);
const extraWeightValue = Form.useWatch("extra_weight", form);
// Calculate prev/next IDs for navigation
const { prevId, nextId } = useMemo(() => {
@@ -189,10 +190,15 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
return initialWeightValue ?? selectedFilament?.weight ?? 0;
};
const getExtraWeight = (): number => {
return extraWeightValue ?? 0;
};
const getGrossWeight = (): number => {
const net_weight = getFilamentWeight();
const spool_weight = getSpoolWeight();
return net_weight + spool_weight;
const extra_weight = getExtraWeight();
return net_weight + spool_weight + extra_weight;
};
const getMeasuredWeight = (): number => {
@@ -394,6 +400,21 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.extra_weight")}
help={t("spool.fields_help.extra_weight")}
name={["extra_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} />
</Form.Item>

View File

@@ -89,6 +89,7 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [
"filament.color_hex",
"filament.material",
"price",
"remaining_value",
"used_weight",
"remaining_weight",
"used_length",
@@ -101,7 +102,8 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [
"comment",
];
const defaultColumns = allColumns.filter(
(column_id) => ["registered", "used_length", "remaining_length", "lot_nr", "filament.color_hex"].indexOf(column_id) === -1
(column_id) =>
["registered", "used_length", "remaining_length", "lot_nr", "filament.color_hex", "remaining_value"].indexOf(column_id) === -1
);
export const SpoolList: React.FC<IResourceComponentsProps> = () => {
@@ -462,6 +464,19 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
return currencyFormatter.format(obj.price);
},
}),
SortedColumn({
...commonProps,
id: "remaining_value",
i18ncat: "spool",
align: "right",
width: 100,
render: (_, obj: ISpoolCollapsed) => {
if (obj.remaining_value === undefined) {
return "";
}
return currencyFormatter.format(obj.remaining_value);
},
}),
NumberColumn({
...commonProps,
id: "used_weight",

View File

@@ -15,9 +15,11 @@ export interface ISpool {
price?: number;
initial_weight?: number;
spool_weight?: number;
extra_weight?: number;
remaining_weight?: number;
used_weight: number;
remaining_length?: number;
remaining_value?: number;
used_length: number;
location?: string;
lot_nr?: string;

View File

@@ -15,7 +15,7 @@ import { useCurrencyFormatter } from "../../utils/settings";
import { getAPIURL, getBasePath } from "../../utils/url";
import { IFilament } from "../filaments/model";
import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
import { ISpool, ISpoolAdjustment } from "./model";
import { IPrintJob, ISpool, ISpoolAdjustment } from "./model";
dayjs.extend(utc);
@@ -45,6 +45,16 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
enabled: !!record?.id,
});
// Fetch print job history
const { data: printJobsData, isLoading: printJobsLoading } = useQuery({
queryKey: ["spool-print-jobs", record?.id],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/print-job?spool_id=${record?.id}`);
return (await res.json()) as IPrintJob[];
},
enabled: !!record?.id,
});
const spoolPrice = (item?: ISpool) => {
const price = item?.price ?? item?.filament.price;
if (price === undefined) {
@@ -288,6 +298,69 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
},
]}
/>
<Title level={4} style={{ marginTop: 24 }}>
{t("spool.fields.print_history")}
</Title>
<Collapse
items={[
{
key: "1",
label: t("spool.fields.show_print_history"),
children: (
<Table<IPrintJob>
dataSource={printJobsData}
loading={printJobsLoading}
rowKey="id"
size="small"
pagination={{ pageSize: 10 }}
columns={[
{
title: t("print_job.fields.created"),
dataIndex: "created",
key: "created",
render: (value: string) => (
<span title={dayjs.utc(value).local().format()}>
{dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")}
</span>
),
},
{
title: t("print_job.fields.filename"),
dataIndex: "filename",
key: "filename",
ellipsis: true,
},
{
title: t("print_job.fields.filament_used"),
dataIndex: "filament_used_g",
key: "filament_used_g",
render: (value: number) => `${value.toFixed(1)} g`,
},
{
title: t("print_job.fields.status"),
dataIndex: "status",
key: "status",
render: (value: string) => t(`print_job.status.${value}`),
},
{
title: t("print_job.fields.finished"),
dataIndex: "finished",
key: "finished",
render: (value: string | null) =>
value ? (
<span title={dayjs.utc(value).local().format()}>
{dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")}
</span>
) : (
"-"
),
},
]}
/>
),
},
]}
/>
</Show>
);
};