From 801d3da02a77b5ee995de2687a484ec1a6dc8570 Mon Sep 17 00:00:00 2001 From: tonym Date: Thu, 22 Jan 2026 23:09:42 -0600 Subject: [PATCH] feat: Add quick wins batch (#42, #43, #25, #35, #20, #36, #39) - #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 --- client/public/locales/en/common.json | 16 +++- client/src/components/column.tsx | 3 +- client/src/pages/filaments/edit.tsx | 75 ++++++++++++++- .../src/pages/home/components/QuickStats.tsx | 37 +++++++- .../pages/home/components/UsageAnalytics.tsx | 45 +++++++-- client/src/pages/home/index.tsx | 14 +-- .../pages/spools/components/SpoolGallery.tsx | 91 +++++++++++++++++++ client/src/pages/spools/create.tsx | 26 +++++- client/src/pages/spools/edit.tsx | 11 ++- client/src/pages/spools/list.tsx | 51 +++++++++-- client/src/pages/spools/model.tsx | 1 + client/src/pages/spools/show.tsx | 23 +++-- ...2_0100-g7h8i9j0k1l2_add_spool_color_hex.py | 25 +++++ spoolman/api/v1/models.py | 10 +- spoolman/api/v1/other.py | 80 ++++++++++++++++ spoolman/api/v1/spool.py | 17 ++++ spoolman/database/models.py | 5 + spoolman/database/spool.py | 39 +++++++- 18 files changed, 524 insertions(+), 45 deletions(-) create mode 100644 client/src/pages/spools/components/SpoolGallery.tsx create mode 100644 migrations/versions/2025_01_22_0100-g7h8i9j0k1l2_add_spool_color_hex.py diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index bb8f805..cd42931 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -34,6 +34,8 @@ "notAccessTitle": "You don't have permission to access", "hideColumns": "Hide Columns", "clearFilters": "Clear Filters", + "galleryView": "Gallery", + "tableView": "Table", "viewAll": "View All" }, "warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.", @@ -179,6 +181,7 @@ }, "spool": { "spool": "Spools", + "search_placeholder": "Search by name, material, vendor, location...", "fields": { "id": "ID", "filament_name": "Filament", @@ -198,6 +201,7 @@ "initial_weight": "Initial Weight", "spool_weight": "Empty Weight", "extra_weight": "Extra Weight", + "color_hex": "Color Override", "location": "Location", "lot_nr": "Lot Nr", "first_used": "First Used", @@ -205,6 +209,7 @@ "registered": "Registered", "comment": "Comment", "archived": "Archived", + "filament_fields": "Filament Custom Fields", "adjustment_history": "Adjustment History", "show_history": "Show 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.", "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.", + "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.", "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." @@ -349,7 +355,10 @@ "created_success": "Created filament \"{{name}}\"", "vendor_placeholder": "Select a manufacturer", "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": { "add_spool": "Add Spool" @@ -416,7 +425,9 @@ "total_filaments": "Filament Types", "total_vendors": "Vendors", "total_weight": "Total Remaining", - "total_value": "Inventory Value" + "total_value": "Inventory Value", + "total_spent": "Total Spent", + "avg_cost_per_kg": "Avg Cost/kg" }, "analytics": { "title": "Usage Analytics", @@ -425,6 +436,7 @@ "90_days": "90 Days", "total_used": "Total used: {{weight}} kg", "used": "Used", + "spent": "Spent", "by_material": "Usage by Material" } }, diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index f45d385..6632c59 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -440,8 +440,9 @@ export function CustomFieldColumn(props: Omit { if (value === null || value === undefined) { return undefined; diff --git a/client/src/pages/filaments/edit.tsx b/client/src/pages/filaments/edit.tsx index 3e7e27c..5eaa92b 100644 --- a/client/src/pages/filaments/edit.tsx +++ b/client/src/pages/filaments/edit.tsx @@ -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 { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core"; 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 { 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 in order for Ant design's form to work properly. ParsedExtras does this for us. @@ -37,7 +85,7 @@ export const FilamentEdit: React.FC = () => { sorters: [{ field: "id", order: "asc" }], }); - const { formProps, saveButtonProps, id } = useForm({ + const { formProps, saveButtonProps, id, form } = useForm({ liveMode: "manual", onLiveEvent() { // Warn the user if the filament has been updated since the form was opened @@ -330,6 +378,29 @@ export const FilamentEdit: React.FC = () => { > +
+ +
sum + (spool.remaining_value ?? 0), 0); }, [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) => { if (grams >= 1000) { return `${(grams / 1000).toFixed(1)} kg`; @@ -113,6 +128,26 @@ export function QuickStats() { /> + + + } + /> + + + + + } + /> + + ); } diff --git a/client/src/pages/home/components/UsageAnalytics.tsx b/client/src/pages/home/components/UsageAnalytics.tsx index 5ad9c84..219f2ae 100644 --- a/client/src/pages/home/components/UsageAnalytics.tsx +++ b/client/src/pages/home/components/UsageAnalytics.tsx @@ -4,6 +4,7 @@ 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"; +import { useCurrencyFormatter } from "../../../utils/settings"; interface UsageDataPoint { date: string; @@ -15,8 +16,15 @@ interface MaterialUsage { weight_used: number; } +interface CostByMaterial { + material: string; + total_cost: number; + total_weight: number; +} + export function UsageAnalytics() { const t = useTranslate(); + const currencyFormatter = useCurrencyFormatter(); const [days, setDays] = useState(30); const { data: usageData, isLoading: usageLoading } = useQuery({ @@ -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 const formatDate = (dateStr: string) => { const date = new Date(dateStr); @@ -104,15 +120,26 @@ export function UsageAnalytics() { {t("home.analytics.by_material")}
- {materialData.map((m) => ( - - {m.material} -
- - {m.weight_used >= 1000 ? `${(m.weight_used / 1000).toFixed(2)} kg` : `${m.weight_used.toFixed(0)} g`} - -
- ))} + {materialData.map((m) => { + const costInfo = costData?.cost_by_material?.find((c) => c.material === m.material); + return ( + + {m.material} +
+ + {t("home.analytics.used")}: {m.weight_used >= 1000 ? `${(m.weight_used / 1000).toFixed(2)} kg` : `${m.weight_used.toFixed(0)} g`} + + {costInfo && ( + <> +
+ + {t("home.analytics.spent")}: {currencyFormatter.format(costInfo.total_cost)} + + + )} +
+ ); + })}
)} diff --git a/client/src/pages/home/index.tsx b/client/src/pages/home/index.tsx index 67dd5b4..ccc4773 100644 --- a/client/src/pages/home/index.tsx +++ b/client/src/pages/home/index.tsx @@ -49,12 +49,14 @@ export const Home: React.FC = () => { 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; + const colorObj = record.color_hex + ? record.color_hex + : 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; }, }, diff --git a/client/src/pages/spools/components/SpoolGallery.tsx b/client/src/pages/spools/components/SpoolGallery.tsx new file mode 100644 index 0000000..899d24e --- /dev/null +++ b/client/src/pages/spools/components/SpoolGallery.tsx @@ -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 ( +
+ {Array.from({ length: 12 }).map((_, i) => ( + + ))} +
+ ); + } + + return ( +
+ {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 ( + navigate(`/spool/show/${spool.id}`)} + style={{ + opacity: spool.archived ? 0.5 : 1, + textAlign: "center", + }} + styles={{ body: { padding: "12px 8px" } }} + > +
+ {colorObj ? : } +
+ + {label} + + {spool.filament.material && ( + + {spool.filament.material} + + )} + {percent !== undefined && ( + + )} + {remaining != null && ( + + {Math.round(remaining)}g + + )} +
+ ); + })} +
+ ); +} diff --git a/client/src/pages/spools/create.tsx b/client/src/pages/spools/create.tsx index 85bbf4b..ea4ddba 100644 --- a/client/src/pages/spools/create.tsx +++ b/client/src/pages/spools/create.tsx @@ -1,7 +1,7 @@ import { MinusOutlined, PlusOutlined } from "@ant-design/icons"; import { Create, useForm } from "@refinedev/antd"; 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 dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; @@ -86,14 +86,23 @@ export const SpoolCreate: React.FC(null); + const handleFilamentCreated = async (filament: IFilament) => { - // Refetch filament list so the select options include the new filament + setPendingFilamentId(filament.id); await refetchFilaments(); - // Select the newly created filament - form.setFieldValue("filament_id", filament.id); 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 selectedFilament = useMemo(() => { // 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
+ e?.toHex()} + > + + + diff --git a/client/src/pages/spools/edit.tsx b/client/src/pages/spools/edit.tsx index 1d3353d..2f6fa02 100644 --- a/client/src/pages/spools/edit.tsx +++ b/client/src/pages/spools/edit.tsx @@ -1,7 +1,7 @@ import { LeftOutlined, RightOutlined } from "@ant-design/icons"; import { Edit, useForm } from "@refinedev/antd"; 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 { message } from "antd/lib"; import dayjs from "dayjs"; @@ -415,6 +415,15 @@ export const SpoolEdit: React.FC = () => {
+ e?.toHex()} + > + + + diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx index fe23745..344e1b3 100644 --- a/client/src/pages/spools/list.tsx +++ b/client/src/pages/spools/list.tsx @@ -1,16 +1,18 @@ import { + AppstoreOutlined, EditOutlined, EyeOutlined, FilterOutlined, InboxOutlined, PlusSquareOutlined, PrinterOutlined, + TableOutlined, ToolOutlined, ToTopOutlined, } from "@ant-design/icons"; import { List, useTable } from "@refinedev/antd"; 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 utc from "dayjs/plugin/utc"; import { useCallback, useMemo, useState } from "react"; @@ -42,6 +44,7 @@ import { useCurrencyFormatter } from "../../utils/settings"; import { bulkArchiveSpools, bulkDeleteSpools, bulkUpdateSpools, setSpoolArchived, useSpoolAdjustModal } from "./functions"; import { ISpool } from "./model"; import { BatchActionBar } from "./components/BatchActionBar"; +import { SpoolGallery } from "./components/SpoolGallery"; dayjs.extend(utc); @@ -69,7 +72,7 @@ function collapseSpool(element: ISpool): ISpoolCollapsed { "filament.combined_name": filament_name, "filament.id": element.filament.id, "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 = () => { // State for the switch to show archived spools 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 const [selectedRowKeys, setSelectedRowKeys] = useState([]); @@ -134,6 +141,7 @@ export const SpoolList: React.FC = () => { meta: { queryParams: { ["allow_archived"]: showArchived, + ...(searchQuery ? { q: searchQuery } : {}), }, }, syncWithLocation: false, @@ -353,6 +361,12 @@ export const SpoolList: React.FC = () => { > {t("buttons.clearFilters")} + = () => { )} > {spoolAdjustModal} + { + setSearchQuery(value); + setCurrentPage(1); + }} + style={{ marginBottom: 16, maxWidth: 400 }} + /> + {viewMode === "gallery" ? ( + + ) : ( = () => { id: "filament.combined_name", i18nkey: "spool.fields.filament_name", color: (record: ISpoolCollapsed) => - record.filament.multi_color_hexes - ? { - colors: record.filament.multi_color_hexes.split(","), - vertical: record.filament.multi_color_direction === "longitudinal", - } - : record.filament.color_hex, + record.color_hex + ? record.color_hex + : record.filament.multi_color_hexes + ? { + colors: record.filament.multi_color_hexes.split(","), + vertical: record.filament.multi_color_direction === "longitudinal", + } + : record.filament.color_hex, dataId: "filament.combined_name", filterValueQuery: useSpoolmanFilamentFilter(), }), @@ -441,7 +469,7 @@ export const SpoolList: React.FC = () => { id: "filament.color_hex", i18nkey: "spool.fields.color", filterValueQuery: useSpoolmanColors(), - colorGetter: (record: ISpoolCollapsed) => record.filament.color_hex, + colorGetter: (record: ISpoolCollapsed) => record.color_hex ?? record.filament.color_hex, width: 60, }), FilteredQueryColumn({ @@ -485,6 +513,7 @@ export const SpoolList: React.FC = () => { unit: "g", maxDecimals: 0, width: 110, + sorter: true, }), NumberColumn({ ...commonProps, @@ -494,6 +523,7 @@ export const SpoolList: React.FC = () => { maxDecimals: 0, defaultText: t("unknown"), width: 110, + sorter: true, }), NumberColumn({ ...commonProps, @@ -502,6 +532,7 @@ export const SpoolList: React.FC = () => { unit: "mm", maxDecimals: 0, width: 120, + sorter: true, }), NumberColumn({ ...commonProps, @@ -511,6 +542,7 @@ export const SpoolList: React.FC = () => { maxDecimals: 0, defaultText: t("unknown"), width: 120, + sorter: true, }), FilteredQueryColumn({ ...commonProps, @@ -556,6 +588,7 @@ export const SpoolList: React.FC = () => { ActionsColumn(t("table.actions"), actions), ])} /> + )} = () => { const t = useTranslate(); const extraFields = useGetFields(EntityType.spool); + const filamentExtraFields = useGetFields(EntityType.filament); const currencyFormatter = useCurrencyFormatter(); const invalidate = useInvalidate(); @@ -126,12 +127,14 @@ export const SpoolShow: React.FC = () => { }); }; - 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; + const colorObj = record?.color_hex + ? record.color_hex + : record?.filament.multi_color_hexes + ? { + colors: record.filament.multi_color_hexes.split(","), + vertical: record.filament.multi_color_direction === "longitudinal", + } + : record?.filament.color_hex; return ( = () => { {extraFields?.data?.map((field, index) => ( ))} + {filamentExtraFields?.data && filamentExtraFields.data.length > 0 && ( + <> + {t("spool.fields.filament_fields")} + {filamentExtraFields.data.map((field, index) => ( + + ))} + + )} {t("spool.fields.adjustment_history")} diff --git a/migrations/versions/2025_01_22_0100-g7h8i9j0k1l2_add_spool_color_hex.py b/migrations/versions/2025_01_22_0100-g7h8i9j0k1l2_add_spool_color_hex.py new file mode 100644 index 0000000..5def924 --- /dev/null +++ b/migrations/versions/2025_01_22_0100-g7h8i9j0k1l2_add_spool_color_hex.py @@ -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") diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py index 916de40..0ccc7a9 100644 --- a/spoolman/api/v1/models.py +++ b/spoolman/api/v1/models.py @@ -317,6 +317,12 @@ class Spool(BaseModel): description=("Extra weight to account for, such as DryPods, custom spool holders, etc."), 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( ge=0, description="Consumed weight of filament from the spool in grams.", @@ -381,14 +387,14 @@ class Spool(BaseModel): remaining_length: Optional[float] = 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( weight=remaining_weight, density=filament.density, diameter=filament.diameter, ) 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( weight=remaining_weight, density=filament.density, diff --git a/spoolman/api/v1/other.py b/spoolman/api/v1/other.py index 937267d..352db80 100644 --- a/spoolman/api/v1/other.py +++ b/spoolman/api/v1/other.py @@ -291,3 +291,83 @@ async def get_usage_by_material( rows = result.all() 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, + ) diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index d75c10e..db5be56 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -57,6 +57,12 @@ class SpoolParameters(BaseModel): description="Extra weight to account for, such as DryPods, custom spool holders, etc., in grams.", 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( None, ge=0, @@ -299,6 +305,15 @@ async def find( description="Filter by spools with remaining weight (in grams) greater than this value.", ), ] = None, + q: Annotated[ + Optional[str], + Query( + title="Search", + description=( + "Global search term. Searches across filament name, vendor name, material, and location." + ), + ), + ] = None, sort: Annotated[ Optional[str], Query( @@ -335,6 +350,7 @@ async def find( db_items, total_count = await spool.find( db=db, + q=q, filament_name=filament_name if filament_name is not None else filament_name_old, filament_id=filament_ids, 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, spool_weight=body.spool_weight, extra_weight=body.extra_weight, + color_hex=body.color_hex, remaining_weight=body.remaining_weight, used_weight=body.used_weight, first_used=body.first_used, diff --git a/spoolman/database/models.py b/spoolman/database/models.py index 7db4fbb..bebbdef 100644 --- a/spoolman/database/models.py +++ b/spoolman/database/models.py @@ -104,6 +104,11 @@ class Spool(Base): default=None, 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_id: Mapped[Optional[int]] = mapped_column( ForeignKey("location.id", ondelete="SET NULL"), diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index ef7e5ab..07e8ef8 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -42,6 +42,7 @@ async def create( initial_weight: Optional[float] = None, spool_weight: Optional[float] = None, extra_weight: Optional[float] = None, + color_hex: Optional[str] = None, used_weight: Optional[float] = None, first_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 " "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: used_weight = 0 @@ -86,6 +90,7 @@ async def create( initial_weight=initial_weight, spool_weight=spool_weight, extra_weight=extra_weight, + color_hex=color_hex, used_weight=used_weight, price=price, 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 *, db: AsyncSession, + q: Optional[str] = None, filament_name: Optional[str] = None, filament_id: Optional[Union[int, Sequence[int]]] = 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.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) if filament_color_hex is not None: if isinstance(filament_color_hex, str): @@ -223,6 +243,18 @@ async def find( # noqa: C901, PLR0912 sorts.append(models.Filament.name) elif fieldstr == "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: sorts.append(parse_nested_field(models.Spool, fieldstr)) @@ -260,7 +292,10 @@ async def update( elif k == "remaining_weight": if spool.initial_weight is None: 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): setattr(spool, k, utc_timezone_naive(v)) elif k == "extra":