feat: Add quick wins batch (#42, #43, #25, #35, #20, #36, #39)
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

- #42: Allow spools heavier than theoretical max (remove weight clamps)
- #43: Show filament custom fields on spool detail page
- #25: Sort spools by custom fields and remaining weight
- #35: Global search box for spool list
- #20: Gallery view for spools (color grid with progress bars)
- #36: Spool-level color override with ColorPicker
- #39: Cost analytics endpoint, total spent & avg cost/kg stats
- Fix: Filament select refresh after inline creation
- Fix: Parse temperatures from filament comments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-22 23:09:42 -06:00
parent 5f66302e73
commit 801d3da02a
18 changed files with 524 additions and 45 deletions

View File

@@ -34,6 +34,8 @@
"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",
"galleryView": "Gallery",
"tableView": "Table",
"viewAll": "View All" "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.",
@@ -179,6 +181,7 @@
}, },
"spool": { "spool": {
"spool": "Spools", "spool": "Spools",
"search_placeholder": "Search by name, material, vendor, location...",
"fields": { "fields": {
"id": "ID", "id": "ID",
"filament_name": "Filament", "filament_name": "Filament",
@@ -198,6 +201,7 @@
"initial_weight": "Initial Weight", "initial_weight": "Initial Weight",
"spool_weight": "Empty Weight", "spool_weight": "Empty Weight",
"extra_weight": "Extra Weight", "extra_weight": "Extra Weight",
"color_hex": "Color Override",
"location": "Location", "location": "Location",
"lot_nr": "Lot Nr", "lot_nr": "Lot Nr",
"first_used": "First Used", "first_used": "First Used",
@@ -205,6 +209,7 @@
"registered": "Registered", "registered": "Registered",
"comment": "Comment", "comment": "Comment",
"archived": "Archived", "archived": "Archived",
"filament_fields": "Filament Custom Fields",
"adjustment_history": "Adjustment History", "adjustment_history": "Adjustment History",
"show_history": "Show History", "show_history": "Show History",
"print_history": "Print History", "print_history": "Print History",
@@ -225,6 +230,7 @@
"initial_weight": "The initial weight of filament on the spool (net weight). Will use the weight from the filament object if not set.", "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.", "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.", "extra_weight": "Additional weight to account for when measuring, such as DryPods, custom spool holders, or other accessories.",
"color_hex": "Override the filament color for this specific spool.",
"location": "Where the spool is located if you have multiple locations where you store your spools.", "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.", "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." "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."
@@ -349,7 +355,10 @@
"created_success": "Created filament \"{{name}}\"", "created_success": "Created filament \"{{name}}\"",
"vendor_placeholder": "Select a manufacturer", "vendor_placeholder": "Select a manufacturer",
"name_placeholder": "E.g. Matte Black", "name_placeholder": "E.g. Matte Black",
"material_placeholder": "PLA, PETG, ABS, etc." "material_placeholder": "PLA, PETG, ABS, etc.",
"parse_temps": "Parse temps from comment",
"temps_parsed": "Temperature fields updated from comment",
"no_temps_found": "No temperature patterns found in comment"
}, },
"buttons": { "buttons": {
"add_spool": "Add Spool" "add_spool": "Add Spool"
@@ -416,7 +425,9 @@
"total_filaments": "Filament Types", "total_filaments": "Filament Types",
"total_vendors": "Vendors", "total_vendors": "Vendors",
"total_weight": "Total Remaining", "total_weight": "Total Remaining",
"total_value": "Inventory Value" "total_value": "Inventory Value",
"total_spent": "Total Spent",
"avg_cost_per_kg": "Avg Cost/kg"
}, },
"analytics": { "analytics": {
"title": "Usage Analytics", "title": "Usage Analytics",
@@ -425,6 +436,7 @@
"90_days": "90 Days", "90_days": "90 Days",
"total_used": "Total used: {{weight}} kg", "total_used": "Total used: {{weight}} kg",
"used": "Used", "used": "Used",
"spent": "Spent",
"by_material": "Usage by Material" "by_material": "Usage by Material"
} }
}, },

View File

@@ -440,8 +440,9 @@ export function CustomFieldColumn<Obj extends Entity>(props: Omit<BaseColumnProp
const commonProps = { const commonProps = {
...props, ...props,
id: ["extra", field.key], id: ["extra", field.key],
dataId: `extra.${field.key}` as keyof Obj & string,
title: field.name, title: field.name,
sorter: false, sorter: true,
transform: (value: unknown) => { transform: (value: unknown) => {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return undefined; return undefined;

View File

@@ -1,4 +1,4 @@
import { LeftOutlined, RightOutlined } from "@ant-design/icons"; import { LeftOutlined, RightOutlined, ThunderboltOutlined } from "@ant-design/icons";
import { Edit, useForm, useSelect } from "@refinedev/antd"; import { Edit, useForm, useSelect } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core"; import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
import { Alert, Button, ColorPicker, DatePicker, Form, Input, InputNumber, message, Radio, Select, Space, Typography } from "antd"; import { Alert, Button, ColorPicker, DatePicker, Form, Input, InputNumber, message, Radio, Select, Space, Typography } from "antd";
@@ -14,6 +14,54 @@ import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { IVendor } from "../vendors/model"; import { IVendor } from "../vendors/model";
import { IFilament, IFilamentParsedExtras } from "./model"; import { IFilament, IFilamentParsedExtras } from "./model";
/**
* Parse temperature ranges from a comment string.
* Handles patterns like:
* "extrude 190-230 / bed 50-70"
* "nozzle: 200-220°C, bed: 60-70°C"
* "hotend 200 bed 60"
*/
function parseTempsFromComment(comment: string): {
extruderMin?: number;
extruderMax?: number;
bedMin?: number;
bedMax?: number;
} {
const result: { extruderMin?: number; extruderMax?: number; bedMin?: number; bedMax?: number } = {};
// Patterns for extruder temperature
const extruderPatterns = [
/(?:extrud(?:e|er)|nozzle|hotend|print\s*temp)[:\s]*(\d{2,3})\s*[-~to]+\s*(\d{2,3})/i,
/(?:extrud(?:e|er)|nozzle|hotend|print\s*temp)[:\s]*(\d{2,3})\s*°?C?/i,
];
// Patterns for bed temperature
const bedPatterns = [
/(?:bed|plate|build\s*plate)[:\s]*(\d{2,3})\s*[-~to]+\s*(\d{2,3})/i,
/(?:bed|plate|build\s*plate)[:\s]*(\d{2,3})\s*°?C?/i,
];
for (const pattern of extruderPatterns) {
const match = comment.match(pattern);
if (match) {
result.extruderMin = parseInt(match[1]);
result.extruderMax = match[2] ? parseInt(match[2]) : parseInt(match[1]);
break;
}
}
for (const pattern of bedPatterns) {
const match = comment.match(pattern);
if (match) {
result.bedMin = parseInt(match[1]);
result.bedMax = match[2] ? parseInt(match[2]) : parseInt(match[1]);
break;
}
}
return result;
}
/* /*
The API returns the extra fields as JSON values, but we need to parse them into their real types The API returns the extra fields as JSON values, but we need to parse them into their real types
in order for Ant design's form to work properly. ParsedExtras does this for us. in order for Ant design's form to work properly. ParsedExtras does this for us.
@@ -37,7 +85,7 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
sorters: [{ field: "id", order: "asc" }], sorters: [{ field: "id", order: "asc" }],
}); });
const { formProps, saveButtonProps, id } = useForm<IFilament, HttpError, IFilament, IFilament>({ const { formProps, saveButtonProps, id, form } = useForm<IFilament, HttpError, IFilament, IFilament>({
liveMode: "manual", liveMode: "manual",
onLiveEvent() { onLiveEvent() {
// Warn the user if the filament has been updated since the form was opened // Warn the user if the filament has been updated since the form was opened
@@ -330,6 +378,29 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
> >
<InputNumber addonAfter="g" precision={1} /> <InputNumber addonAfter="g" precision={1} />
</Form.Item> </Form.Item>
<div style={{ marginBottom: 16 }}>
<Button
size="small"
icon={<ThunderboltOutlined />}
onClick={() => {
const comment = form.getFieldValue("comment") || "";
const temps = parseTempsFromComment(comment);
if (!temps.extruderMin && !temps.bedMin) {
messageApi.warning(t("filament.form.no_temps_found"));
return;
}
const fields: Record<string, number | undefined> = {};
if (temps.extruderMin) fields.settings_extruder_temp_min = temps.extruderMin;
if (temps.extruderMax) fields.settings_extruder_temp_max = temps.extruderMax;
if (temps.bedMin) fields.settings_bed_temp_min = temps.bedMin;
if (temps.bedMax) fields.settings_bed_temp_max = temps.bedMax;
form.setFieldsValue(fields);
messageApi.success(t("filament.form.temps_parsed"));
}}
>
{t("filament.form.parse_temps")}
</Button>
</div>
<Form.Item label={t("filament.fields.settings_extruder_temp")}> <Form.Item label={t("filament.fields.settings_extruder_temp")}>
<Space.Compact> <Space.Compact>
<Form.Item <Form.Item

View File

@@ -1,5 +1,6 @@
import { DollarOutlined, HighlightOutlined, InboxOutlined, DatabaseOutlined, UserOutlined } from "@ant-design/icons"; import { DollarOutlined, HighlightOutlined, InboxOutlined, DatabaseOutlined, UserOutlined, RiseOutlined } from "@ant-design/icons";
import { useList, useTranslate } from "@refinedev/core"; import { useList, useTranslate } from "@refinedev/core";
import { useQuery } from "@tanstack/react-query";
import { Card, Col, Row, Statistic, theme } from "antd"; import { Card, Col, Row, Statistic, theme } from "antd";
import { useMemo } from "react"; import { useMemo } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
@@ -7,6 +8,7 @@ import { IFilament } from "../../filaments/model";
import { ISpool } from "../../spools/model"; import { ISpool } from "../../spools/model";
import { IVendor } from "../../vendors/model"; import { IVendor } from "../../vendors/model";
import { useCurrencyFormatter } from "../../../utils/settings"; import { useCurrencyFormatter } from "../../../utils/settings";
import { getAPIURL } from "../../../utils/url";
const { useToken } = theme; const { useToken } = theme;
@@ -48,6 +50,19 @@ export function QuickStats() {
return data.reduce((sum, spool) => sum + (spool.remaining_value ?? 0), 0); return data.reduce((sum, spool) => sum + (spool.remaining_value ?? 0), 0);
}, [spoolsResult?.data]); }, [spoolsResult?.data]);
// Fetch cost stats from backend
const { data: costStats, isLoading: costLoading } = useQuery<{
total_spent: number;
total_weight_purchased: number;
avg_cost_per_kg: number | null;
}>({
queryKey: ["analytics", "cost"],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/analytics/cost`);
return res.json();
},
});
const formatWeight = (grams: number) => { const formatWeight = (grams: number) => {
if (grams >= 1000) { if (grams >= 1000) {
return `${(grams / 1000).toFixed(1)} kg`; return `${(grams / 1000).toFixed(1)} kg`;
@@ -113,6 +128,26 @@ export function QuickStats() {
/> />
</Card> </Card>
</Col> </Col>
<Col xs={12} md={12} lg={6}>
<Card size="small">
<Statistic
title={t("home.quick_stats.total_spent")}
value={currencyFormatter.format(costStats?.total_spent ?? 0)}
loading={costLoading}
prefix={<DollarOutlined />}
/>
</Card>
</Col>
<Col xs={12} md={12} lg={6}>
<Card size="small">
<Statistic
title={t("home.quick_stats.avg_cost_per_kg")}
value={costStats?.avg_cost_per_kg != null ? currencyFormatter.format(costStats.avg_cost_per_kg) : "-"}
loading={costLoading}
prefix={<RiseOutlined />}
/>
</Card>
</Col>
</Row> </Row>
); );
} }

View File

@@ -4,6 +4,7 @@ import { Card, Segmented, Spin, Typography } from "antd";
import { useState } from "react"; import { useState } from "react";
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { getAPIURL } from "../../../utils/url"; import { getAPIURL } from "../../../utils/url";
import { useCurrencyFormatter } from "../../../utils/settings";
interface UsageDataPoint { interface UsageDataPoint {
date: string; date: string;
@@ -15,8 +16,15 @@ interface MaterialUsage {
weight_used: number; weight_used: number;
} }
interface CostByMaterial {
material: string;
total_cost: number;
total_weight: number;
}
export function UsageAnalytics() { export function UsageAnalytics() {
const t = useTranslate(); const t = useTranslate();
const currencyFormatter = useCurrencyFormatter();
const [days, setDays] = useState<number>(30); const [days, setDays] = useState<number>(30);
const { data: usageData, isLoading: usageLoading } = useQuery<UsageDataPoint[]>({ const { data: usageData, isLoading: usageLoading } = useQuery<UsageDataPoint[]>({
@@ -35,6 +43,14 @@ export function UsageAnalytics() {
}, },
}); });
const { data: costData } = useQuery<{ cost_by_material: CostByMaterial[] }>({
queryKey: ["analytics", "cost"],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/analytics/cost`);
return res.json();
},
});
// Format date for display // Format date for display
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
const date = new Date(dateStr); const date = new Date(dateStr);
@@ -104,15 +120,26 @@ export function UsageAnalytics() {
{t("home.analytics.by_material")} {t("home.analytics.by_material")}
</Typography.Title> </Typography.Title>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}> <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{materialData.map((m) => ( {materialData.map((m) => {
const costInfo = costData?.cost_by_material?.find((c) => c.material === m.material);
return (
<Card size="small" key={m.material} style={{ minWidth: 120 }}> <Card size="small" key={m.material} style={{ minWidth: 120 }}>
<Typography.Text strong>{m.material}</Typography.Text> <Typography.Text strong>{m.material}</Typography.Text>
<br /> <br />
<Typography.Text type="secondary"> <Typography.Text type="secondary">
{m.weight_used >= 1000 ? `${(m.weight_used / 1000).toFixed(2)} kg` : `${m.weight_used.toFixed(0)} g`} {t("home.analytics.used")}: {m.weight_used >= 1000 ? `${(m.weight_used / 1000).toFixed(2)} kg` : `${m.weight_used.toFixed(0)} g`}
</Typography.Text> </Typography.Text>
{costInfo && (
<>
<br />
<Typography.Text type="secondary">
{t("home.analytics.spent")}: {currencyFormatter.format(costInfo.total_cost)}
</Typography.Text>
</>
)}
</Card> </Card>
))} );
})}
</div> </div>
</> </>
)} )}

View File

@@ -49,7 +49,9 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
key: "color", key: "color",
width: 50, width: 50,
render: (_: unknown, record: ISpool) => { render: (_: unknown, record: ISpool) => {
const colorObj = record.filament.multi_color_hexes const colorObj = record.color_hex
? record.color_hex
: record.filament.multi_color_hexes
? { ? {
colors: record.filament.multi_color_hexes.split(","), colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal", vertical: record.filament.multi_color_direction === "longitudinal",

View File

@@ -0,0 +1,91 @@
import { useTranslate } from "@refinedev/core";
import { Card, Progress, Typography } from "antd";
import { useNavigate } from "react-router";
import SpoolIcon from "../../../components/spoolIcon";
import { ISpool } from "../model";
interface SpoolGalleryProps {
dataSource: ISpool[];
loading?: boolean;
}
export function SpoolGallery({ dataSource, loading }: SpoolGalleryProps) {
const t = useTranslate();
const navigate = useNavigate();
if (loading) {
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
{Array.from({ length: 12 }).map((_, i) => (
<Card key={i} loading size="small" style={{ height: 160 }} />
))}
</div>
);
}
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
{dataSource.map((spool) => {
const colorObj = spool.color_hex
? spool.color_hex
: spool.filament.multi_color_hexes
? {
colors: spool.filament.multi_color_hexes.split(","),
vertical: spool.filament.multi_color_direction === "longitudinal",
}
: spool.filament.color_hex;
const remaining = spool.remaining_weight;
const initial = spool.initial_weight ?? spool.filament.weight;
const percent = remaining != null && initial ? Math.round((remaining / initial) * 100) : undefined;
const vendorName = spool.filament.vendor?.name;
const filamentName = spool.filament.name ?? `#${spool.filament.id}`;
const label = vendorName ? `${vendorName} - ${filamentName}` : filamentName;
return (
<Card
key={spool.id}
size="small"
hoverable
onClick={() => navigate(`/spool/show/${spool.id}`)}
style={{
opacity: spool.archived ? 0.5 : 1,
textAlign: "center",
}}
styles={{ body: { padding: "12px 8px" } }}
>
<div style={{ marginBottom: 8 }}>
{colorObj ? <SpoolIcon color={colorObj} size="large" /> : <SpoolIcon color="#cccccc" size="large" />}
</div>
<Typography.Text
ellipsis={{ tooltip: label }}
style={{ fontSize: 12, display: "block", marginBottom: 4 }}
>
{label}
</Typography.Text>
{spool.filament.material && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{spool.filament.material}
</Typography.Text>
)}
{percent !== undefined && (
<Progress
percent={percent}
size="small"
showInfo={false}
strokeColor={percent < 10 ? "#ff4d4f" : percent < 25 ? "#faad14" : "#52c41a"}
style={{ marginTop: 6 }}
/>
)}
{remaining != null && (
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
{Math.round(remaining)}g
</Typography.Text>
)}
</Card>
);
})}
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { MinusOutlined, PlusOutlined } from "@ant-design/icons"; import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
import { Create, useForm } from "@refinedev/antd"; import { Create, useForm } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core"; import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd"; import { Alert, Button, ColorPicker, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import TextArea from "antd/es/input/TextArea"; import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
@@ -86,14 +86,23 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
refetch: refetchFilaments, refetch: refetchFilaments,
} = useGetFilamentSelectOptions(); } = useGetFilamentSelectOptions();
// Track pending filament selection after inline creation
const [pendingFilamentId, setPendingFilamentId] = useState<number | null>(null);
const handleFilamentCreated = async (filament: IFilament) => { const handleFilamentCreated = async (filament: IFilament) => {
// Refetch filament list so the select options include the new filament setPendingFilamentId(filament.id);
await refetchFilaments(); await refetchFilaments();
// Select the newly created filament
form.setFieldValue("filament_id", filament.id);
setIsFilamentModalOpen(false); setIsFilamentModalOpen(false);
}; };
// Set the filament_id once the new filament appears in the options
useEffect(() => {
if (pendingFilamentId !== null && internalSelectOptions?.some((opt) => opt.value === pendingFilamentId)) {
form.setFieldValue("filament_id", pendingFilamentId);
setPendingFilamentId(null);
}
}, [pendingFilamentId, internalSelectOptions]);
const selectedFilamentID = Form.useWatch("filament_id", form); const selectedFilamentID = Form.useWatch("filament_id", form);
const selectedFilament = useMemo(() => { const selectedFilament = useMemo(() => {
// id is a number of it's an internal filament, and a string of it's an external filament. // id is a number of it's an internal filament, and a string of it's an external filament.
@@ -423,6 +432,15 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
<InputNumber addonAfter="g" precision={1} /> <InputNumber addonAfter="g" precision={1} />
</Form.Item> </Form.Item>
<Form.Item
label={t("spool.fields.color_hex")}
help={t("spool.fields_help.color_hex")}
name={["color_hex"]}
getValueFromEvent={(e) => e?.toHex()}
>
<ColorPicker allowClear format="hex" />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}> <Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} /> <InputNumber value={usedWeight} />
</Form.Item> </Form.Item>

View File

@@ -1,7 +1,7 @@
import { LeftOutlined, RightOutlined } from "@ant-design/icons"; import { LeftOutlined, RightOutlined } from "@ant-design/icons";
import { Edit, useForm } from "@refinedev/antd"; import { Edit, useForm } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core"; import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd"; import { Alert, Button, ColorPicker, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import TextArea from "antd/es/input/TextArea"; import TextArea from "antd/es/input/TextArea";
import { message } from "antd/lib"; import { message } from "antd/lib";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -415,6 +415,15 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
<InputNumber addonAfter="g" precision={1} /> <InputNumber addonAfter="g" precision={1} />
</Form.Item> </Form.Item>
<Form.Item
label={t("spool.fields.color_hex")}
help={t("spool.fields_help.color_hex")}
name={["color_hex"]}
getValueFromEvent={(e) => e?.toHex()}
>
<ColorPicker allowClear format="hex" />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}> <Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} /> <InputNumber value={usedWeight} />
</Form.Item> </Form.Item>

View File

@@ -1,16 +1,18 @@
import { import {
AppstoreOutlined,
EditOutlined, EditOutlined,
EyeOutlined, EyeOutlined,
FilterOutlined, FilterOutlined,
InboxOutlined, InboxOutlined,
PlusSquareOutlined, PlusSquareOutlined,
PrinterOutlined, PrinterOutlined,
TableOutlined,
ToolOutlined, ToolOutlined,
ToTopOutlined, ToTopOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { List, useTable } from "@refinedev/antd"; import { List, useTable } from "@refinedev/antd";
import { IResourceComponentsProps, useInvalidate, useNavigation, useTranslate } from "@refinedev/core"; import { IResourceComponentsProps, useInvalidate, useNavigation, useTranslate } from "@refinedev/core";
import { Button, Dropdown, Modal, Table } from "antd"; import { Button, Dropdown, Input, Modal, Table } from "antd";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
@@ -42,6 +44,7 @@ import { useCurrencyFormatter } from "../../utils/settings";
import { bulkArchiveSpools, bulkDeleteSpools, bulkUpdateSpools, setSpoolArchived, useSpoolAdjustModal } from "./functions"; import { bulkArchiveSpools, bulkDeleteSpools, bulkUpdateSpools, setSpoolArchived, useSpoolAdjustModal } from "./functions";
import { ISpool } from "./model"; import { ISpool } from "./model";
import { BatchActionBar } from "./components/BatchActionBar"; import { BatchActionBar } from "./components/BatchActionBar";
import { SpoolGallery } from "./components/SpoolGallery";
dayjs.extend(utc); dayjs.extend(utc);
@@ -69,7 +72,7 @@ function collapseSpool(element: ISpool): ISpoolCollapsed {
"filament.combined_name": filament_name, "filament.combined_name": filament_name,
"filament.id": element.filament.id, "filament.id": element.filament.id,
"filament.material": element.filament.material, "filament.material": element.filament.material,
"filament.color_hex": element.filament.color_hex, "filament.color_hex": element.color_hex ?? element.filament.color_hex,
}; };
} }
@@ -122,6 +125,10 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
// State for the switch to show archived spools // State for the switch to show archived spools
const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false); const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false);
// State for search and view mode
const [searchQuery, setSearchQuery] = useState("");
const [viewMode, setViewMode] = useState<"table" | "gallery">("table");
// State for batch selection // State for batch selection
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]); const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
@@ -134,6 +141,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
meta: { meta: {
queryParams: { queryParams: {
["allow_archived"]: showArchived, ["allow_archived"]: showArchived,
...(searchQuery ? { q: searchQuery } : {}),
}, },
}, },
syncWithLocation: false, syncWithLocation: false,
@@ -353,6 +361,12 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
> >
{t("buttons.clearFilters")} {t("buttons.clearFilters")}
</Button> </Button>
<Button
icon={viewMode === "table" ? <AppstoreOutlined /> : <TableOutlined />}
onClick={() => setViewMode(viewMode === "table" ? "gallery" : "table")}
>
{viewMode === "table" ? t("buttons.galleryView") : t("buttons.tableView")}
</Button>
<Dropdown <Dropdown
trigger={["click"]} trigger={["click"]}
menu={{ menu={{
@@ -390,6 +404,18 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
)} )}
> >
{spoolAdjustModal} {spoolAdjustModal}
<Input.Search
placeholder={t("spool.search_placeholder")}
allowClear
onSearch={(value) => {
setSearchQuery(value);
setCurrentPage(1);
}}
style={{ marginBottom: 16, maxWidth: 400 }}
/>
{viewMode === "gallery" ? (
<SpoolGallery dataSource={dataSource} loading={tableProps.loading as boolean} />
) : (
<Table <Table
{...tableProps} {...tableProps}
sticky sticky
@@ -427,7 +453,9 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
id: "filament.combined_name", id: "filament.combined_name",
i18nkey: "spool.fields.filament_name", i18nkey: "spool.fields.filament_name",
color: (record: ISpoolCollapsed) => color: (record: ISpoolCollapsed) =>
record.filament.multi_color_hexes record.color_hex
? record.color_hex
: record.filament.multi_color_hexes
? { ? {
colors: record.filament.multi_color_hexes.split(","), colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal", vertical: record.filament.multi_color_direction === "longitudinal",
@@ -441,7 +469,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
id: "filament.color_hex", id: "filament.color_hex",
i18nkey: "spool.fields.color", i18nkey: "spool.fields.color",
filterValueQuery: useSpoolmanColors(), filterValueQuery: useSpoolmanColors(),
colorGetter: (record: ISpoolCollapsed) => record.filament.color_hex, colorGetter: (record: ISpoolCollapsed) => record.color_hex ?? record.filament.color_hex,
width: 60, width: 60,
}), }),
FilteredQueryColumn({ FilteredQueryColumn({
@@ -485,6 +513,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
unit: "g", unit: "g",
maxDecimals: 0, maxDecimals: 0,
width: 110, width: 110,
sorter: true,
}), }),
NumberColumn({ NumberColumn({
...commonProps, ...commonProps,
@@ -494,6 +523,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0, maxDecimals: 0,
defaultText: t("unknown"), defaultText: t("unknown"),
width: 110, width: 110,
sorter: true,
}), }),
NumberColumn({ NumberColumn({
...commonProps, ...commonProps,
@@ -502,6 +532,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
unit: "mm", unit: "mm",
maxDecimals: 0, maxDecimals: 0,
width: 120, width: 120,
sorter: true,
}), }),
NumberColumn({ NumberColumn({
...commonProps, ...commonProps,
@@ -511,6 +542,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0, maxDecimals: 0,
defaultText: t("unknown"), defaultText: t("unknown"),
width: 120, width: 120,
sorter: true,
}), }),
FilteredQueryColumn({ FilteredQueryColumn({
...commonProps, ...commonProps,
@@ -556,6 +588,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
ActionsColumn(t("table.actions"), actions), ActionsColumn(t("table.actions"), actions),
])} ])}
/> />
)}
<BatchActionBar <BatchActionBar
selectedIds={selectedRowKeys as number[]} selectedIds={selectedRowKeys as number[]}
onArchive={handleBatchArchive} onArchive={handleBatchArchive}

View File

@@ -16,6 +16,7 @@ export interface ISpool {
initial_weight?: number; initial_weight?: number;
spool_weight?: number; spool_weight?: number;
extra_weight?: number; extra_weight?: number;
color_hex?: string;
remaining_weight?: number; remaining_weight?: number;
used_weight: number; used_weight: number;
remaining_length?: number; remaining_length?: number;

View File

@@ -25,6 +25,7 @@ const { confirm } = Modal;
export const SpoolShow: React.FC<IResourceComponentsProps> = () => { export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate(); const t = useTranslate();
const extraFields = useGetFields(EntityType.spool); const extraFields = useGetFields(EntityType.spool);
const filamentExtraFields = useGetFields(EntityType.filament);
const currencyFormatter = useCurrencyFormatter(); const currencyFormatter = useCurrencyFormatter();
const invalidate = useInvalidate(); const invalidate = useInvalidate();
@@ -126,7 +127,9 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
}); });
}; };
const colorObj = record?.filament.multi_color_hexes const colorObj = record?.color_hex
? record.color_hex
: record?.filament.multi_color_hexes
? { ? {
colors: record.filament.multi_color_hexes.split(","), colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal", vertical: record.filament.multi_color_direction === "longitudinal",
@@ -245,6 +248,14 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
{extraFields?.data?.map((field, index) => ( {extraFields?.data?.map((field, index) => (
<ExtraFieldDisplay key={index} field={field} value={record?.extra[field.key]} /> <ExtraFieldDisplay key={index} field={field} value={record?.extra[field.key]} />
))} ))}
{filamentExtraFields?.data && filamentExtraFields.data.length > 0 && (
<>
<Title level={4}>{t("spool.fields.filament_fields")}</Title>
{filamentExtraFields.data.map((field, index) => (
<ExtraFieldDisplay key={index} field={field} value={record?.filament.extra[field.key]} />
))}
</>
)}
<Title level={4} style={{ marginTop: 24 }}> <Title level={4} style={{ marginTop: 24 }}>
{t("spool.fields.adjustment_history")} {t("spool.fields.adjustment_history")}
</Title> </Title>

View File

@@ -0,0 +1,25 @@
"""Add spool color_hex override.
Revision ID: g7h8i9j0k1l2
Revises: f6g7h8i9j0k1
Create Date: 2025-01-22 01:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "g7h8i9j0k1l2"
down_revision = "f6g7h8i9j0k1"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add color_hex column to spool table."""
op.add_column("spool", sa.Column("color_hex", sa.String(length=8), nullable=True))
def downgrade() -> None:
"""Remove color_hex column from spool table."""
op.drop_column("spool", "color_hex")

View File

@@ -317,6 +317,12 @@ class Spool(BaseModel):
description=("Extra weight to account for, such as DryPods, custom spool holders, etc."), description=("Extra weight to account for, such as DryPods, custom spool holders, etc."),
examples=[50], examples=[50],
) )
color_hex: Optional[str] = Field(
default=None,
max_length=8,
description="Spool-level color override (hex code without #). Overrides the filament color if set.",
examples=["FF0000"],
)
used_weight: float = Field( used_weight: float = Field(
ge=0, ge=0,
description="Consumed weight of filament from the spool in grams.", description="Consumed weight of filament from the spool in grams.",
@@ -381,14 +387,14 @@ class Spool(BaseModel):
remaining_length: Optional[float] = None remaining_length: Optional[float] = None
if item.initial_weight is not None: if item.initial_weight is not None:
remaining_weight = max(item.initial_weight - item.used_weight, 0) remaining_weight = item.initial_weight - item.used_weight
remaining_length = length_from_weight( remaining_length = length_from_weight(
weight=remaining_weight, weight=remaining_weight,
density=filament.density, density=filament.density,
diameter=filament.diameter, diameter=filament.diameter,
) )
elif filament.weight is not None: elif filament.weight is not None:
remaining_weight = max(filament.weight - item.used_weight, 0) remaining_weight = filament.weight - item.used_weight
remaining_length = length_from_weight( remaining_length = length_from_weight(
weight=remaining_weight, weight=remaining_weight,
density=filament.density, density=filament.density,

View File

@@ -291,3 +291,83 @@ async def get_usage_by_material(
rows = result.all() rows = result.all()
return [MaterialUsage(material=row.material or "Unknown", weight_used=row.weight_used) for row in rows] return [MaterialUsage(material=row.material or "Unknown", weight_used=row.weight_used) for row in rows]
class CostByMaterial(BaseModel):
"""Cost breakdown by material."""
material: str = Field(description="Material name.")
total_cost: float = Field(description="Total cost spent on this material.")
total_weight: float = Field(description="Total initial weight purchased in grams.")
class CostStats(BaseModel):
"""Overall cost statistics."""
total_spent: float = Field(description="Sum of all spool purchase prices.")
total_weight_purchased: float = Field(description="Total initial weight of all spools in grams.")
avg_cost_per_kg: Optional[float] = Field(description="Average cost per kilogram of filament.")
cost_by_material: list[CostByMaterial] = Field(description="Cost breakdown by material type.")
@router.get(
"/analytics/cost",
name="Get cost statistics",
description="Get overall cost tracking statistics across all spools.",
response_model=CostStats,
)
async def get_cost_stats(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> CostStats:
"""Get cost statistics including total spent and cost per kg."""
# Use COALESCE(spool.price, filament.price) for effective price
effective_price = func.coalesce(models.Spool.price, models.Filament.price)
effective_weight = func.coalesce(models.Spool.initial_weight, models.Filament.weight)
# Total spent and total weight across all spools (including archived)
totals_stmt = (
sqlalchemy.select(
func.sum(effective_price).label("total_spent"),
func.sum(effective_weight).label("total_weight"),
)
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
.where(effective_price.isnot(None))
)
totals_result = await db.execute(totals_stmt)
totals_row = totals_result.one()
total_spent = totals_row.total_spent or 0.0
total_weight_purchased = totals_row.total_weight or 0.0
avg_cost_per_kg = (total_spent / total_weight_purchased * 1000) if total_weight_purchased > 0 else None
# Cost by material
material_stmt = (
sqlalchemy.select(
func.coalesce(models.Filament.material, "Unknown").label("material"),
func.sum(effective_price).label("total_cost"),
func.sum(effective_weight).label("total_weight"),
)
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
.where(effective_price.isnot(None))
.group_by(models.Filament.material)
.order_by(func.sum(effective_price).desc())
)
material_result = await db.execute(material_stmt)
material_rows = material_result.all()
cost_by_material = [
CostByMaterial(
material=row.material or "Unknown",
total_cost=row.total_cost or 0.0,
total_weight=row.total_weight or 0.0,
)
for row in material_rows
]
return CostStats(
total_spent=total_spent,
total_weight_purchased=total_weight_purchased,
avg_cost_per_kg=avg_cost_per_kg,
cost_by_material=cost_by_material,
)

View File

@@ -57,6 +57,12 @@ class SpoolParameters(BaseModel):
description="Extra weight to account for, such as DryPods, custom spool holders, etc., in grams.", description="Extra weight to account for, such as DryPods, custom spool holders, etc., in grams.",
examples=[50], examples=[50],
) )
color_hex: Optional[str] = Field(
None,
max_length=8,
description="Spool-level color override (hex code without #). Overrides the filament color if set.",
examples=["FF0000"],
)
remaining_weight: Optional[float] = Field( remaining_weight: Optional[float] = Field(
None, None,
ge=0, ge=0,
@@ -299,6 +305,15 @@ async def find(
description="Filter by spools with remaining weight (in grams) greater than this value.", description="Filter by spools with remaining weight (in grams) greater than this value.",
), ),
] = None, ] = None,
q: Annotated[
Optional[str],
Query(
title="Search",
description=(
"Global search term. Searches across filament name, vendor name, material, and location."
),
),
] = None,
sort: Annotated[ sort: Annotated[
Optional[str], Optional[str],
Query( Query(
@@ -335,6 +350,7 @@ async def find(
db_items, total_count = await spool.find( db_items, total_count = await spool.find(
db=db, db=db,
q=q,
filament_name=filament_name if filament_name is not None else filament_name_old, filament_name=filament_name if filament_name is not None else filament_name_old,
filament_id=filament_ids, filament_id=filament_ids,
filament_material=filament_material if filament_material is not None else filament_material_old, filament_material=filament_material if filament_material is not None else filament_material_old,
@@ -456,6 +472,7 @@ async def create( # noqa: ANN201
initial_weight=body.initial_weight, initial_weight=body.initial_weight,
spool_weight=body.spool_weight, spool_weight=body.spool_weight,
extra_weight=body.extra_weight, extra_weight=body.extra_weight,
color_hex=body.color_hex,
remaining_weight=body.remaining_weight, remaining_weight=body.remaining_weight,
used_weight=body.used_weight, used_weight=body.used_weight,
first_used=body.first_used, first_used=body.first_used,

View File

@@ -104,6 +104,11 @@ class Spool(Base):
default=None, default=None,
comment="Extra weight to account for (DryPods, custom holders, etc.).", comment="Extra weight to account for (DryPods, custom holders, etc.).",
) )
color_hex: Mapped[Optional[str]] = mapped_column(
String(8),
default=None,
comment="Spool-level color override (hex without #).",
)
location: Mapped[Optional[str]] = mapped_column(String(64), comment="Legacy flat location string.") location: Mapped[Optional[str]] = mapped_column(String(64), comment="Legacy flat location string.")
location_id: Mapped[Optional[int]] = mapped_column( location_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("location.id", ondelete="SET NULL"), ForeignKey("location.id", ondelete="SET NULL"),

View File

@@ -42,6 +42,7 @@ async def create(
initial_weight: Optional[float] = None, initial_weight: Optional[float] = None,
spool_weight: Optional[float] = None, spool_weight: Optional[float] = None,
extra_weight: Optional[float] = None, extra_weight: Optional[float] = None,
color_hex: Optional[str] = None,
used_weight: Optional[float] = None, used_weight: Optional[float] = None,
first_used: Optional[datetime] = None, first_used: Optional[datetime] = None,
last_used: Optional[datetime] = None, last_used: Optional[datetime] = None,
@@ -70,7 +71,10 @@ async def create(
"remaining_weight can only be used if the initial_weight is " "remaining_weight can only be used if the initial_weight is "
"defined or the filament has a weight set.", "defined or the filament has a weight set.",
) )
used_weight = max(initial_weight - remaining_weight, 0) if remaining_weight > initial_weight:
# Spool is heavier than expected (manufacturing tolerance), adjust initial_weight
initial_weight = remaining_weight
used_weight = initial_weight - remaining_weight
else: else:
used_weight = 0 used_weight = 0
@@ -86,6 +90,7 @@ async def create(
initial_weight=initial_weight, initial_weight=initial_weight,
spool_weight=spool_weight, spool_weight=spool_weight,
extra_weight=extra_weight, extra_weight=extra_weight,
color_hex=color_hex,
used_weight=used_weight, used_weight=used_weight,
price=price, price=price,
first_used=first_used, first_used=first_used,
@@ -117,6 +122,7 @@ async def get_by_id(db: AsyncSession, spool_id: int) -> models.Spool:
async def find( # noqa: C901, PLR0912 async def find( # noqa: C901, PLR0912
*, *,
db: AsyncSession, db: AsyncSession,
q: Optional[str] = None,
filament_name: Optional[str] = None, filament_name: Optional[str] = None,
filament_id: Optional[Union[int, Sequence[int]]] = None, filament_id: Optional[Union[int, Sequence[int]]] = None,
filament_material: Optional[str] = None, filament_material: Optional[str] = None,
@@ -155,6 +161,20 @@ async def find( # noqa: C901, PLR0912
stmt = add_where_clause_str_opt(stmt, models.Spool.location, location) stmt = add_where_clause_str_opt(stmt, models.Spool.location, location)
stmt = add_where_clause_str_opt(stmt, models.Spool.lot_nr, lot_nr) stmt = add_where_clause_str_opt(stmt, models.Spool.lot_nr, lot_nr)
# Global search: OR across multiple text fields
if q is not None and q.strip():
search_term = f"%{q.strip()}%"
stmt = stmt.where(
sqlalchemy.or_(
models.Filament.name.ilike(search_term),
models.Filament.material.ilike(search_term),
models.Vendor.name.ilike(search_term),
models.Spool.location.ilike(search_term),
models.Spool.lot_nr.ilike(search_term),
models.Spool.comment.ilike(search_term),
)
)
# Filter by color_hex (exact match, supports multiple comma-separated values) # Filter by color_hex (exact match, supports multiple comma-separated values)
if filament_color_hex is not None: if filament_color_hex is not None:
if isinstance(filament_color_hex, str): if isinstance(filament_color_hex, str):
@@ -223,6 +243,18 @@ async def find( # noqa: C901, PLR0912
sorts.append(models.Filament.name) sorts.append(models.Filament.name)
elif fieldstr == "price": elif fieldstr == "price":
sorts.append(coalesce(models.Spool.price, models.Filament.price)) sorts.append(coalesce(models.Spool.price, models.Filament.price))
elif fieldstr.startswith("extra."):
# Sort by custom field value
extra_key = fieldstr[len("extra."):]
extra_alias = sqlalchemy.orm.aliased(models.SpoolField)
stmt = stmt.outerjoin(
extra_alias,
sqlalchemy.and_(
extra_alias.spool_id == models.Spool.id,
extra_alias.key == extra_key,
),
)
sorts.append(extra_alias.value)
else: else:
sorts.append(parse_nested_field(models.Spool, fieldstr)) sorts.append(parse_nested_field(models.Spool, fieldstr))
@@ -260,7 +292,10 @@ async def update(
elif k == "remaining_weight": elif k == "remaining_weight":
if spool.initial_weight is None: if spool.initial_weight is None:
raise ItemCreateError("remaining_weight can only be used if initial_weight is set.") raise ItemCreateError("remaining_weight can only be used if initial_weight is set.")
spool.used_weight = max(spool.initial_weight - v, 0) if v > spool.initial_weight:
# Spool is heavier than expected (manufacturing tolerance), adjust initial_weight
spool.initial_weight = v
spool.used_weight = spool.initial_weight - v
elif isinstance(v, datetime): elif isinstance(v, datetime):
setattr(spool, k, utc_timezone_naive(v)) setattr(spool, k, utc_timezone_naive(v))
elif k == "extra": elif k == "extra":