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
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:
@@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
<Card size="small" key={m.material} style={{ minWidth: 120 }}>
|
const costInfo = costData?.cost_by_material?.find((c) => c.material === m.material);
|
||||||
<Typography.Text strong>{m.material}</Typography.Text>
|
return (
|
||||||
<br />
|
<Card size="small" key={m.material} style={{ minWidth: 120 }}>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text strong>{m.material}</Typography.Text>
|
||||||
{m.weight_used >= 1000 ? `${(m.weight_used / 1000).toFixed(2)} kg` : `${m.weight_used.toFixed(0)} g`}
|
<br />
|
||||||
</Typography.Text>
|
<Typography.Text type="secondary">
|
||||||
</Card>
|
{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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -49,12 +49,14 @@ 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
|
||||||
colors: record.filament.multi_color_hexes.split(","),
|
: record.filament.multi_color_hexes
|
||||||
vertical: record.filament.multi_color_direction === "longitudinal",
|
? {
|
||||||
}
|
colors: record.filament.multi_color_hexes.split(","),
|
||||||
: record.filament.color_hex;
|
vertical: record.filament.multi_color_direction === "longitudinal",
|
||||||
|
}
|
||||||
|
: record.filament.color_hex;
|
||||||
return colorObj ? <SpoolIcon color={colorObj} /> : null;
|
return colorObj ? <SpoolIcon color={colorObj} /> : null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
91
client/src/pages/spools/components/SpoolGallery.tsx
Normal file
91
client/src/pages/spools/components/SpoolGallery.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,12 +453,14 @@ 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
|
||||||
colors: record.filament.multi_color_hexes.split(","),
|
: record.filament.multi_color_hexes
|
||||||
vertical: record.filament.multi_color_direction === "longitudinal",
|
? {
|
||||||
}
|
colors: record.filament.multi_color_hexes.split(","),
|
||||||
: record.filament.color_hex,
|
vertical: record.filament.multi_color_direction === "longitudinal",
|
||||||
|
}
|
||||||
|
: record.filament.color_hex,
|
||||||
dataId: "filament.combined_name",
|
dataId: "filament.combined_name",
|
||||||
filterValueQuery: useSpoolmanFilamentFilter(),
|
filterValueQuery: useSpoolmanFilamentFilter(),
|
||||||
}),
|
}),
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,12 +127,14 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorObj = record?.filament.multi_color_hexes
|
const colorObj = record?.color_hex
|
||||||
? {
|
? record.color_hex
|
||||||
colors: record.filament.multi_color_hexes.split(","),
|
: record?.filament.multi_color_hexes
|
||||||
vertical: record.filament.multi_color_direction === "longitudinal",
|
? {
|
||||||
}
|
colors: record.filament.multi_color_hexes.split(","),
|
||||||
: record?.filament.color_hex;
|
vertical: record.filament.multi_color_direction === "longitudinal",
|
||||||
|
}
|
||||||
|
: record?.filament.color_hex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user