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",
"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"
}
},

View File

@@ -440,8 +440,9 @@ export function CustomFieldColumn<Obj extends Entity>(props: Omit<BaseColumnProp
const commonProps = {
...props,
id: ["extra", field.key],
dataId: `extra.${field.key}` as keyof Obj & string,
title: field.name,
sorter: false,
sorter: true,
transform: (value: unknown) => {
if (value === null || value === 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 { 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<IResourceComponentsProps> = () => {
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",
onLiveEvent() {
// 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} />
</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")}>
<Space.Compact>
<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 { useQuery } from "@tanstack/react-query";
import { Card, Col, Row, Statistic, theme } from "antd";
import { useMemo } from "react";
import { Link } from "react-router";
@@ -7,6 +8,7 @@ import { IFilament } from "../../filaments/model";
import { ISpool } from "../../spools/model";
import { IVendor } from "../../vendors/model";
import { useCurrencyFormatter } from "../../../utils/settings";
import { getAPIURL } from "../../../utils/url";
const { useToken } = theme;
@@ -48,6 +50,19 @@ export function QuickStats() {
return data.reduce((sum, spool) => 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() {
/>
</Card>
</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>
);
}

View File

@@ -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<number>(30);
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
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
@@ -104,15 +120,26 @@ export function UsageAnalytics() {
{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>
))}
{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 }}>
<Typography.Text strong>{m.material}</Typography.Text>
<br />
<Typography.Text type="secondary">
{t("home.analytics.used")}: {m.weight_used >= 1000 ? `${(m.weight_used / 1000).toFixed(2)} kg` : `${m.weight_used.toFixed(0)} g`}
</Typography.Text>
{costInfo && (
<>
<br />
<Typography.Text type="secondary">
{t("home.analytics.spent")}: {currencyFormatter.format(costInfo.total_cost)}
</Typography.Text>
</>
)}
</Card>
);
})}
</div>
</>
)}

View File

@@ -49,12 +49,14 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
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 ? <SpoolIcon color={colorObj} /> : null;
},
},

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 { 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<IResourceComponentsProps & CreateOrCloneProps
refetch: refetchFilaments,
} = useGetFilamentSelectOptions();
// Track pending filament selection after inline creation
const [pendingFilamentId, setPendingFilamentId] = useState<number | null>(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<IResourceComponentsProps & CreateOrCloneProps
<InputNumber addonAfter="g" precision={1} />
</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}>
<InputNumber value={usedWeight} />
</Form.Item>

View File

@@ -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<IResourceComponentsProps> = () => {
<InputNumber addonAfter="g" precision={1} />
</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}>
<InputNumber value={usedWeight} />
</Form.Item>

View File

@@ -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<IResourceComponentsProps> = () => {
// 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<React.Key[]>([]);
@@ -134,6 +141,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
meta: {
queryParams: {
["allow_archived"]: showArchived,
...(searchQuery ? { q: searchQuery } : {}),
},
},
syncWithLocation: false,
@@ -353,6 +361,12 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
>
{t("buttons.clearFilters")}
</Button>
<Button
icon={viewMode === "table" ? <AppstoreOutlined /> : <TableOutlined />}
onClick={() => setViewMode(viewMode === "table" ? "gallery" : "table")}
>
{viewMode === "table" ? t("buttons.galleryView") : t("buttons.tableView")}
</Button>
<Dropdown
trigger={["click"]}
menu={{
@@ -390,6 +404,18 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
)}
>
{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
{...tableProps}
sticky
@@ -427,12 +453,14 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
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<IResourceComponentsProps> = () => {
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<IResourceComponentsProps> = () => {
unit: "g",
maxDecimals: 0,
width: 110,
sorter: true,
}),
NumberColumn({
...commonProps,
@@ -494,6 +523,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0,
defaultText: t("unknown"),
width: 110,
sorter: true,
}),
NumberColumn({
...commonProps,
@@ -502,6 +532,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
unit: "mm",
maxDecimals: 0,
width: 120,
sorter: true,
}),
NumberColumn({
...commonProps,
@@ -511,6 +542,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0,
defaultText: t("unknown"),
width: 120,
sorter: true,
}),
FilteredQueryColumn({
...commonProps,
@@ -556,6 +588,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
ActionsColumn(t("table.actions"), actions),
])}
/>
)}
<BatchActionBar
selectedIds={selectedRowKeys as number[]}
onArchive={handleBatchArchive}

View File

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

View File

@@ -25,6 +25,7 @@ const { confirm } = Modal;
export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
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<IResourceComponentsProps> = () => {
});
};
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 (
<Show
@@ -245,6 +248,14 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
{extraFields?.data?.map((field, index) => (
<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 }}>
{t("spool.fields.adjustment_history")}
</Title>