Compare commits
12 Commits
02da984b6e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| beb1ad2bfa | |||
| 23ef41270c | |||
| c321d916b5 | |||
| 67eaed32d5 | |||
| f38baa985e | |||
| ad1df035e9 | |||
| 801d3da02a | |||
| 5f66302e73 | |||
| 8681f7aee7 | |||
| 3aa633df0a | |||
| 18cafc4361 | |||
| 0556be9e3b |
252
CLAUDE.md
252
CLAUDE.md
@@ -134,13 +134,42 @@ This is a fork with UX improvements. Issues are tracked on the Gitea instance ab
|
||||
| 6 | Spool Adjustment History | ✅ DONE |
|
||||
| 7 | 3dfilamentprofiles Integration | ❌ REMOVED |
|
||||
| 8 | (duplicate of #7) | ✅ DONE |
|
||||
| 9 | Hierarchical Locations (Room/Bin) | **Open** |
|
||||
| 9 | Hierarchical Locations (Room/Bin) | ✅ DONE |
|
||||
| 10 | Database Import/Export | ✅ DONE |
|
||||
| 11 | Slicer Integration / Print Jobs | ✅ DONE |
|
||||
| 12 | Redesign Home Page Dashboard | **Open** |
|
||||
| 13 | Batch Operations (Multi-Select Edit) | **Open** |
|
||||
| 14 | Extra Weight Field (DryPods, etc.) | **Open** |
|
||||
| 15 | Spool Count per Filament in List | **Open** |
|
||||
| 12 | Redesign Home Page Dashboard | ✅ DONE |
|
||||
| 13 | Batch Operations (Multi-Select Edit) | ✅ DONE |
|
||||
| 14 | Extra Weight Field (DryPods, etc.) | ✅ DONE |
|
||||
| 15 | Spool Count per Filament in List | ✅ DONE |
|
||||
| 16 | Filter Spools by Color | ✅ DONE |
|
||||
| 17 | Image Attachments for Filaments | **Open** |
|
||||
| 18 | QR Code Labels for Filaments | **Open** |
|
||||
| 19 | Integration with filamentcolors.xyz | ✅ DONE |
|
||||
| 20 | Gallery View for Spools (Color Grid) | ✅ DONE |
|
||||
| 21 | QR Code Version/Complexity Config | **Open** |
|
||||
| 23 | Reporting & Data Export | **Open** |
|
||||
| 24 | NFC/RFID Tag Support | **Open** |
|
||||
| 25 | Sort Spools by Custom Fields | ✅ DONE |
|
||||
| 26 | Label Template Enhancements | **Open** |
|
||||
| 27 | QR Scanner Camera Device Selector | **Open** |
|
||||
| 28 | Mobile-Friendly UI / Responsive | **Open** |
|
||||
| 29 | Table Column Customization | **Open** |
|
||||
| 30 | Printer Management & Parts Inventory | **Open** |
|
||||
| 31 | Special Filament Properties | **Open** |
|
||||
| 32 | SpoolType Model (Cardboard/Plastic/Metal) | **Open** |
|
||||
| 34 | Color Swatch Border for Dark Mode | ✅ DONE |
|
||||
| 35 | Global Search Box | ✅ DONE |
|
||||
| 36 | Spool-Level Overrides (Color, Price) | ✅ DONE |
|
||||
| 37 | Plugin System for Extensions | **Open** |
|
||||
| 39 | Usage Statistics & Cost Tracking | ✅ DONE |
|
||||
| 40 | Storage vs Active Spool Status | **Open** |
|
||||
| 41 | Load Cell / Scale Integration | **Open** |
|
||||
| 42 | Allow Spools Heavier Than Max | ✅ DONE |
|
||||
| 43 | Show Filament Fields on Spool View | ✅ DONE |
|
||||
| 44 | HomeAssistant Add-On Packaging | **Open** |
|
||||
| 45 | Filament Swatch Card Generator | **Open** |
|
||||
| 46 | Project-Based Spool Grouping | **Open** |
|
||||
| 47 | G-code Filament Usage Estimation | **Open** |
|
||||
|
||||
### Other Features (not tracked in Gitea)
|
||||
- ✅ Theme Colors (5 options)
|
||||
@@ -148,6 +177,9 @@ This is a fork with UX improvements. Issues are tracked on the Gitea instance ab
|
||||
- ✅ QR Code Size Setting
|
||||
- ✅ Parse Temps from Comments
|
||||
- ✅ Prev/Next Navigation on Edit Pages
|
||||
- ✅ Price/Cost Tracking (remaining value, inventory value)
|
||||
- ✅ Print History on Spool Detail
|
||||
- ✅ Usage Analytics Dashboard
|
||||
- ✅ Color Column in Spool List (with filter)
|
||||
|
||||
### Issue #1 Complete - Inline Creation Modals
|
||||
@@ -355,6 +387,123 @@ Added optional color column to the spool list with filtering capability.
|
||||
- Multi-select filtering (can filter by multiple colors)
|
||||
- Column hidden by default (enable via column selector)
|
||||
|
||||
### Issue #9 Complete - Hierarchical Locations
|
||||
|
||||
Storage locations can now be organized in a tree structure (Room > Shelf > Bin).
|
||||
|
||||
**Backend:**
|
||||
- `spoolman/database/models.py` - Added `Location` model with self-referential parent
|
||||
- `spoolman/database/location.py` - CRUD operations, tree building, full path resolution
|
||||
- `spoolman/api/v1/location.py` - REST API for locations
|
||||
- `migrations/versions/2025_01_21_0100-*.py` - Migration for location table
|
||||
|
||||
**Frontend:**
|
||||
- `client/src/components/LocationTreeSelect.tsx` - Tree-based location picker
|
||||
- `client/src/components/LocationCreateModal.tsx` - Inline location creation
|
||||
|
||||
**Features:**
|
||||
- Hierarchical structure with parent/child relationships
|
||||
- Location types: room, shelf, bin, drawer, box, other
|
||||
- Legacy flat location string preserved for backward compatibility
|
||||
- API endpoints: GET /location, GET /location/tree, POST/PATCH/DELETE
|
||||
|
||||
### Issue #12 Complete - Dashboard Redesign
|
||||
|
||||
Redesigned home page with better visualizations and quick stats.
|
||||
|
||||
**Backend:** No changes needed - uses existing endpoints.
|
||||
|
||||
**Frontend:**
|
||||
- `client/src/pages/home/index.tsx` - Refactored layout
|
||||
- `client/src/pages/home/components/AlertCards.tsx` - Low stock, pending jobs, needs weighing alerts
|
||||
- `client/src/pages/home/components/MaterialDistributionChart.tsx` - Pie chart by material (recharts)
|
||||
- `client/src/pages/home/components/QuickStats.tsx` - Total spools, filaments, vendors, weight
|
||||
|
||||
**Dependencies:**
|
||||
- Added `recharts` package for charts
|
||||
|
||||
### Issue #13 Complete - Batch Operations
|
||||
|
||||
Multi-select spools with bulk archive/delete/move actions.
|
||||
|
||||
**Backend:**
|
||||
- `spoolman/api/v1/spool.py` - Added bulk endpoints:
|
||||
- POST /spool/bulk/archive
|
||||
- POST /spool/bulk/delete
|
||||
- POST /spool/bulk/update
|
||||
|
||||
**Frontend:**
|
||||
- `client/src/pages/spools/list.tsx` - Added row selection
|
||||
- `client/src/pages/spools/components/BatchActionBar.tsx` - Floating action bar
|
||||
- `client/src/pages/spools/functions.tsx` - Bulk helper functions
|
||||
|
||||
**Features:**
|
||||
- Checkbox selection with select-all
|
||||
- Floating action bar shows when items selected
|
||||
- Archive, unarchive, delete, move to location
|
||||
- Confirmation modals for all destructive actions
|
||||
|
||||
### Issue #14 Complete - Extra Weight Field
|
||||
|
||||
Track extra weight from DryPods, custom spool holders, or other accessories.
|
||||
|
||||
**Backend:**
|
||||
- `spoolman/database/models.py` - Added `extra_weight` column to Spool model
|
||||
- `spoolman/database/spool.py` - Updated `measure()` to account for extra weight
|
||||
- `spoolman/api/v1/spool.py` - Added `extra_weight` to create/update parameters
|
||||
- `migrations/versions/2025_01_21_0200-*.py` - Migration for extra_weight column
|
||||
|
||||
**Frontend:**
|
||||
- `client/src/pages/spools/create.tsx` - Added extra_weight form field
|
||||
- `client/src/pages/spools/edit.tsx` - Added extra_weight form field
|
||||
- Weight calculations updated to include extra_weight in gross weight
|
||||
|
||||
**Features:**
|
||||
- Optional field for tracking additional weight on spool
|
||||
- Automatically subtracted when measuring spool weight
|
||||
- Useful for DryPods, custom spool holders, reel adapters
|
||||
|
||||
### Issue #15 Complete - Spool Count per Filament
|
||||
|
||||
Already implemented - filament list shows `spool_count` and `total_remaining_weight` columns.
|
||||
|
||||
### Price & Cost Tracking
|
||||
|
||||
Track spool purchase price and calculate remaining value.
|
||||
|
||||
**Backend:**
|
||||
- `spoolman/api/v1/models.py` - Added `remaining_value` computed field
|
||||
- Formula: `remaining_value = (remaining_weight / initial_weight) * price`
|
||||
- Price falls back to filament price if not set on spool
|
||||
|
||||
**Frontend:**
|
||||
- `client/src/pages/spools/list.tsx` - Added remaining_value column (hidden by default)
|
||||
- `client/src/pages/home/components/QuickStats.tsx` - Added "Inventory Value" stat
|
||||
|
||||
### Print History on Spool Detail
|
||||
|
||||
Show print job history on the spool detail page.
|
||||
|
||||
**Frontend:**
|
||||
- `client/src/pages/spools/show.tsx` - Added collapsible print history table
|
||||
- Fetches print jobs filtered by spool_id
|
||||
- Shows: Created date, Filename, Filament Used, Status, Finished date
|
||||
|
||||
### Usage Analytics Dashboard
|
||||
|
||||
Time-series charts showing filament consumption trends.
|
||||
|
||||
**Backend:**
|
||||
- `spoolman/api/v1/other.py` - New analytics endpoints:
|
||||
- `GET /analytics/usage?days=N` - Daily usage from SpoolAdjustments
|
||||
- `GET /analytics/by-material?days=N` - Usage breakdown by material
|
||||
|
||||
**Frontend:**
|
||||
- `client/src/pages/home/components/UsageAnalytics.tsx` - New component
|
||||
- Area chart showing daily consumption (recharts)
|
||||
- Period selector: 7, 30, or 90 days
|
||||
- Material breakdown cards showing usage by filament type
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
@@ -374,16 +523,91 @@ ssh root@192.168.0.5 "/mnt/user/appdata/spoolman2-build/update.sh"
|
||||
|
||||
---
|
||||
|
||||
### Issue #42 Complete - Allow Spools Heavier Than Max
|
||||
|
||||
Removed weight validation cap that prevented spools from exceeding their theoretical initial weight.
|
||||
|
||||
**Backend:**
|
||||
- `spoolman/database/spool.py` - Removed `max(..., 0)` clamps from create/update; when remaining > initial, adjusts initial upward
|
||||
- `spoolman/api/v1/models.py` - Removed `max(..., 0)` from remaining_weight computation
|
||||
|
||||
### Issue #43 Complete - Show Filament Fields on Spool View
|
||||
|
||||
Display filament custom fields on the spool detail page.
|
||||
|
||||
**Frontend:**
|
||||
- `client/src/pages/spools/show.tsx` - Added `filamentExtraFields` section after spool extra fields
|
||||
|
||||
### Issue #25 Complete - Sort by Custom Fields & Remaining Weight
|
||||
|
||||
**Backend:**
|
||||
- `spoolman/database/spool.py` - Added `extra.{key}` sort support via outerjoin on SpoolField table
|
||||
|
||||
**Frontend:**
|
||||
- `client/src/pages/spools/list.tsx` - Added `sorter: true` to weight/length NumberColumns
|
||||
- `client/src/components/column.tsx` - Changed CustomFieldColumn to sortable with `dataId: extra.{key}`
|
||||
|
||||
### Issue #35 Complete - Global Search Box
|
||||
|
||||
**Backend:**
|
||||
- `spoolman/api/v1/spool.py` - Added `q` parameter to find endpoint
|
||||
- `spoolman/database/spool.py` - OR-based search across filament name, material, vendor name, location, lot_nr, comment
|
||||
|
||||
**Frontend:**
|
||||
- `client/src/pages/spools/list.tsx` - Added `Input.Search` component, passes `q` via meta.queryParams
|
||||
|
||||
### Issue #20 Complete - Gallery View for Spools
|
||||
|
||||
Color grid view for spools with progress bars and material labels.
|
||||
|
||||
**Frontend:**
|
||||
- `client/src/pages/spools/components/SpoolGallery.tsx` - Grid of color cards with progress bars
|
||||
- `client/src/pages/spools/list.tsx` - View mode toggle (table/gallery)
|
||||
|
||||
### Issue #36 Complete - Spool-Level Overrides
|
||||
|
||||
Per-spool color override with ColorPicker (price and initial_weight already existed).
|
||||
|
||||
**Backend:**
|
||||
- `spoolman/database/models.py` - Added `color_hex` field to Spool model
|
||||
- `spoolman/api/v1/spool.py` - Added `color_hex` to create/update params
|
||||
- `migrations/versions/2025_01_22_0100-g7h8i9j0k1l2_add_spool_color_hex.py`
|
||||
|
||||
**Frontend:**
|
||||
- `client/src/pages/spools/create.tsx`, `edit.tsx` - ColorPicker form field
|
||||
- All display locations updated to prefer `spool.color_hex` over `filament.color_hex`
|
||||
|
||||
### Issue #39 Complete - Usage Statistics & Cost Tracking
|
||||
|
||||
Added cost analytics endpoint and total spent / avg cost per kg stats.
|
||||
|
||||
**Backend:**
|
||||
- `spoolman/api/v1/other.py` - Added `GET /analytics/cost` endpoint returning total_spent, avg_cost_per_kg, cost_by_material
|
||||
|
||||
**Frontend:**
|
||||
- `client/src/pages/home/components/QuickStats.tsx` - Added "Total Spent" and "Avg Cost/kg" stat cards
|
||||
- `client/src/pages/home/components/UsageAnalytics.tsx` - Added cost per material in breakdown cards
|
||||
|
||||
### Issue #19 Complete - filamentcolors.xyz Integration
|
||||
|
||||
Color lookup from filamentcolors.xyz database (2134 swatches, 317 manufacturers).
|
||||
|
||||
**Backend:**
|
||||
- `spoolman/api/v1/other.py` - Proxy endpoints:
|
||||
- `GET /filamentcolors/manufacturers` - List all manufacturers
|
||||
- `GET /filamentcolors/search` - Search swatches by manufacturer, color, filament type
|
||||
|
||||
**Frontend:**
|
||||
- `client/src/components/filamentColorLookup.tsx` - Modal with manufacturer filter + color grid
|
||||
- Added search button next to ColorPicker on filament create and edit forms
|
||||
- Click a swatch to apply its measured hex color
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Filament Select Refresh**: Fixed race condition where form value was set before options re-rendered. Uses `pendingFilamentId` state + useEffect.
|
||||
- **Parse Temps from Comments**: Added regex-based temperature parser button on filament edit page.
|
||||
|
||||
## Known Issues / TODO
|
||||
|
||||
### Parse Temperature from Comments
|
||||
Some filaments have temp info in comment field (e.g., "extrude 190-230 / bed 50-70") but not in the min/max fields. Could add a migration or button to parse and populate.
|
||||
|
||||
### Filament Select Not Refreshing After Inline Creation
|
||||
When creating a filament via the inline modal on spool create page, the select dropdown doesn't immediately show the new filament with its name (shows ID instead).
|
||||
|
||||
### Issue #7 - 3dfilamentprofiles Integration REMOVED
|
||||
The 3dfilamentprofiles.com website has Vercel bot protection that blocks automated requests. Their GitHub repo (jklewa/filament-profiles-data) only provides sample data (~3 filaments). Since SpoolmanDB already has 6,957+ filaments from major brands, the 3dfp integration was removed rather than maintaining dead code.
|
||||
|
||||
### Future: Issue #9 - Hierarchical Locations
|
||||
Add room/bin/shelf support for locations. See Gitea issue #9 for details.
|
||||
|
||||
@@ -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",
|
||||
@@ -191,11 +194,14 @@
|
||||
"weight_to_use": "Weight",
|
||||
"used_weight": "Used Weight",
|
||||
"remaining_weight": "Remaining Weight",
|
||||
"remaining_value": "Remaining Value",
|
||||
"measured_weight": "Measured Weight",
|
||||
"used_length": "Used Length",
|
||||
"remaining_length": "Remaining Length",
|
||||
"initial_weight": "Initial Weight",
|
||||
"spool_weight": "Empty Weight",
|
||||
"extra_weight": "Extra Weight",
|
||||
"color_hex": "Color Override",
|
||||
"location": "Location",
|
||||
"lot_nr": "Lot Nr",
|
||||
"first_used": "First Used",
|
||||
@@ -203,8 +209,11 @@
|
||||
"registered": "Registered",
|
||||
"comment": "Comment",
|
||||
"archived": "Archived",
|
||||
"filament_fields": "Filament Custom Fields",
|
||||
"adjustment_history": "Adjustment History",
|
||||
"show_history": "Show History",
|
||||
"print_history": "Print History",
|
||||
"show_print_history": "Show Print History",
|
||||
"adjustment_timestamp": "Date/Time",
|
||||
"adjustment_type": "Type",
|
||||
"adjustment_type_weight": "Weight",
|
||||
@@ -220,6 +229,8 @@
|
||||
"measured_weight": "How much the filament and spool weigh.",
|
||||
"initial_weight": "The initial weight of filament on the spool (net weight). Will use the weight from the filament object if not set.",
|
||||
"spool_weight": "The weight of the spool when it is empty. Leave empty to use the value from the filament or the manufacturer.",
|
||||
"extra_weight": "Additional weight to account for when measuring, such as DryPods, custom spool holders, or other accessories.",
|
||||
"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."
|
||||
@@ -344,10 +355,21 @@
|
||||
"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"
|
||||
},
|
||||
"color_lookup": {
|
||||
"button": "Find color from filamentcolors.xyz",
|
||||
"title": "Find Color - filamentcolors.xyz",
|
||||
"manufacturer": "Manufacturer",
|
||||
"color_search": "Search color name...",
|
||||
"results": "results",
|
||||
"showing_page": "page {{page}}"
|
||||
}
|
||||
},
|
||||
"vendor": {
|
||||
@@ -377,6 +399,20 @@
|
||||
"name_required": "Name is required"
|
||||
}
|
||||
},
|
||||
"print_job": {
|
||||
"fields": {
|
||||
"created": "Created",
|
||||
"filename": "Filename",
|
||||
"filament_used": "Filament Used",
|
||||
"status": "Status",
|
||||
"finished": "Finished"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"completed": "Completed",
|
||||
"cancelled": "Cancelled"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"home": "Home",
|
||||
"welcome": "Welcome to your Spoolman instance!",
|
||||
@@ -396,7 +432,20 @@
|
||||
"total_spools": "Total Spools",
|
||||
"total_filaments": "Filament Types",
|
||||
"total_vendors": "Vendors",
|
||||
"total_weight": "Total Remaining"
|
||||
"total_weight": "Total Remaining",
|
||||
"total_value": "Inventory Value",
|
||||
"total_spent": "Total Spent",
|
||||
"avg_cost_per_kg": "Avg Cost/kg"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Usage Analytics",
|
||||
"7_days": "7 Days",
|
||||
"30_days": "30 Days",
|
||||
"90_days": "90 Days",
|
||||
"total_used": "Total used: {{weight}} kg",
|
||||
"used": "Used",
|
||||
"spent": "Spent",
|
||||
"by_material": "Usage by Material"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
166
client/src/components/filamentColorLookup.tsx
Normal file
166
client/src/components/filamentColorLookup.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Input, Modal, Select, Space, Spin, Tooltip } from "antd";
|
||||
import { useState } from "react";
|
||||
import { getAPIURL } from "../utils/url";
|
||||
|
||||
interface Swatch {
|
||||
id: number;
|
||||
color_name: string;
|
||||
hex_color: string | null;
|
||||
manufacturer_name: string;
|
||||
filament_type: string;
|
||||
image_front: string | null;
|
||||
}
|
||||
|
||||
interface Manufacturer {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface FilamentColorLookupProps {
|
||||
onSelect: (hexColor: string) => void;
|
||||
}
|
||||
|
||||
export function FilamentColorLookup({ onSelect }: FilamentColorLookupProps) {
|
||||
const t = useTranslate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [manufacturer, setManufacturer] = useState<string | undefined>();
|
||||
const [colorSearch, setColorSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data: manufacturers, isLoading: mfrsLoading } = useQuery<Manufacturer[]>({
|
||||
queryKey: ["filamentcolors", "manufacturers"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${getAPIURL()}/filamentcolors/manufacturers`);
|
||||
return res.json();
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const { data: searchResult, isLoading: searchLoading } = useQuery<{ count: number; swatches: Swatch[] }>({
|
||||
queryKey: ["filamentcolors", "search", manufacturer, colorSearch, page],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (manufacturer) params.set("manufacturer", manufacturer);
|
||||
if (colorSearch) params.set("color", colorSearch);
|
||||
params.set("limit", "50");
|
||||
params.set("page", String(page));
|
||||
const res = await fetch(`${getAPIURL()}/filamentcolors/search?${params}`);
|
||||
return res.json();
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const handleSelect = (hex: string) => {
|
||||
onSelect(hex);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={t("filament.color_lookup.button")}>
|
||||
<Button icon={<SearchOutlined />} onClick={() => setOpen(true)} size="small" />
|
||||
</Tooltip>
|
||||
<Modal
|
||||
title={t("filament.color_lookup.title")}
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={null}
|
||||
width={700}
|
||||
>
|
||||
<Space.Compact style={{ width: "100%", marginBottom: 12 }}>
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder={t("filament.color_lookup.manufacturer")}
|
||||
style={{ width: "50%" }}
|
||||
loading={mfrsLoading}
|
||||
value={manufacturer}
|
||||
onChange={(v) => { setManufacturer(v); setPage(1); }}
|
||||
options={manufacturers?.map((m) => ({ label: m.name, value: m.name }))}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("filament.color_lookup.color_search")}
|
||||
value={colorSearch}
|
||||
onChange={(e) => { setColorSearch(e.target.value); setPage(1); }}
|
||||
allowClear
|
||||
style={{ width: "50%" }}
|
||||
/>
|
||||
</Space.Compact>
|
||||
|
||||
{searchLoading ? (
|
||||
<div style={{ textAlign: "center", padding: 40 }}><Spin /></div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginBottom: 8, fontSize: 12, color: "#888" }}>
|
||||
{searchResult?.count ?? 0} {t("filament.color_lookup.results")}
|
||||
{searchResult && searchResult.count > 50 && ` (${t("filament.color_lookup.showing_page", { page })})`}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(100px, 1fr))",
|
||||
gap: 8,
|
||||
maxHeight: 400,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{searchResult?.swatches.map((s) => (
|
||||
<Tooltip
|
||||
key={s.id}
|
||||
title={`${s.manufacturer_name} - ${s.color_name} (${s.filament_type}) #${s.hex_color ?? "?"}`}
|
||||
>
|
||||
<div
|
||||
onClick={() => s.hex_color && handleSelect(s.hex_color)}
|
||||
style={{
|
||||
cursor: s.hex_color ? "pointer" : "default",
|
||||
border: "1px solid #d9d9d9",
|
||||
borderRadius: 6,
|
||||
padding: 6,
|
||||
textAlign: "center",
|
||||
opacity: s.hex_color ? 1 : 0.4,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: "1",
|
||||
borderRadius: 4,
|
||||
backgroundColor: s.hex_color ? `#${s.hex_color}` : "#ccc",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 10, lineHeight: 1.2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{s.color_name}
|
||||
</div>
|
||||
<div style={{ fontSize: 9, color: "#888", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{s.filament_type}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
{searchResult && searchResult.count > 50 && (
|
||||
<div style={{ marginTop: 12, textAlign: "center" }}>
|
||||
<Button size="small" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{t("buttons.prev")}
|
||||
</Button>
|
||||
<span style={{ margin: "0 12px", fontSize: 12 }}>
|
||||
{page} / {Math.ceil(searchResult.count / 50)}
|
||||
</span>
|
||||
<Button size="small" disabled={page * 50 >= searchResult.count} onClick={() => setPage((p) => p + 1)}>
|
||||
{t("buttons.next")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../compo
|
||||
import { FilamentImportModal } from "../../components/filamentImportModal";
|
||||
import { VendorCreateModal } from "../../components/vendorCreateModal";
|
||||
import { MultiColorPicker } from "../../components/multiColorPicker";
|
||||
import { FilamentColorLookup } from "../../components/filamentColorLookup";
|
||||
import {
|
||||
DEFAULT_DENSITY,
|
||||
DEFAULT_DIAMETER,
|
||||
@@ -248,19 +249,22 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{colorType == "single" && (
|
||||
<Form.Item
|
||||
name={"color_hex"}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
},
|
||||
]}
|
||||
getValueFromEvent={(e) => {
|
||||
return e?.toHex();
|
||||
}}
|
||||
>
|
||||
<ColorPicker format="hex" />
|
||||
</Form.Item>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
|
||||
<Form.Item
|
||||
name={"color_hex"}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
},
|
||||
]}
|
||||
getValueFromEvent={(e) => {
|
||||
return e?.toHex();
|
||||
}}
|
||||
>
|
||||
<ColorPicker format="hex" />
|
||||
</Form.Item>
|
||||
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
|
||||
</div>
|
||||
)}
|
||||
{colorType == "multi" && (
|
||||
<Form.Item
|
||||
|
||||
@@ -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";
|
||||
@@ -8,12 +8,61 @@ import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
||||
import { MultiColorPicker } from "../../components/multiColorPicker";
|
||||
import { FilamentColorLookup } from "../../components/filamentColorLookup";
|
||||
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
|
||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||
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 +86,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
|
||||
@@ -200,19 +249,22 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{colorType == "single" && (
|
||||
<Form.Item
|
||||
name={"color_hex"}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
},
|
||||
]}
|
||||
getValueFromEvent={(e) => {
|
||||
return e?.toHex();
|
||||
}}
|
||||
>
|
||||
<ColorPicker />
|
||||
</Form.Item>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
|
||||
<Form.Item
|
||||
name={"color_hex"}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
},
|
||||
]}
|
||||
getValueFromEvent={(e) => {
|
||||
return e?.toHex();
|
||||
}}
|
||||
>
|
||||
<ColorPicker />
|
||||
</Form.Item>
|
||||
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
|
||||
</div>
|
||||
)}
|
||||
{colorType == "multi" && (
|
||||
<Form.Item
|
||||
@@ -330,6 +382,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
|
||||
|
||||
@@ -39,7 +39,11 @@ function AlertCard({ icon, label, count, to, color, loading }: AlertCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertCards() {
|
||||
interface AlertCardsProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function AlertCards({ compact }: AlertCardsProps) {
|
||||
const t = useTranslate();
|
||||
const { token } = useToken();
|
||||
|
||||
@@ -106,7 +110,7 @@ export function AlertCards() {
|
||||
if (visibleAlerts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Row gutter={[compact ? 8 : 12, compact ? 8 : 12]} style={{ marginTop: compact ? 8 : 16 }}>
|
||||
{visibleAlerts.map((alert, i) => (
|
||||
<AlertCard key={i} {...alert} />
|
||||
))}
|
||||
|
||||
@@ -20,7 +20,11 @@ const COLORS = [
|
||||
"#2f54eb", // geekblue
|
||||
];
|
||||
|
||||
export function MaterialDistributionChart() {
|
||||
interface MaterialDistributionChartProps {
|
||||
square?: boolean;
|
||||
}
|
||||
|
||||
export function MaterialDistributionChart({ square }: MaterialDistributionChartProps) {
|
||||
const t = useTranslate();
|
||||
const { token } = useToken();
|
||||
|
||||
@@ -59,20 +63,21 @@ export function MaterialDistributionChart() {
|
||||
<Card
|
||||
title={t("home.charts.material_distribution")}
|
||||
size="small"
|
||||
style={{ marginBottom: 16 }}
|
||||
style={square ? { aspectRatio: "1", display: "flex", flexDirection: "column" } : { marginBottom: 16 }}
|
||||
styles={square ? { body: { flex: 1, padding: "8px 12px", display: "flex", flexDirection: "column" } } : undefined}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<ResponsiveContainer width="100%" height={square ? "100%" : 250}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
innerRadius={square ? 40 : 50}
|
||||
outerRadius={square ? 70 : 80}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
|
||||
labelLine={{ stroke: token.colorTextSecondary }}
|
||||
label={square ? undefined : ({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
|
||||
labelLine={square ? false : { stroke: token.colorTextSecondary }}
|
||||
>
|
||||
{chartData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
@@ -86,6 +91,13 @@ export function MaterialDistributionChart() {
|
||||
borderRadius: token.borderRadius,
|
||||
}}
|
||||
/>
|
||||
{square && (
|
||||
<Legend
|
||||
layout="horizontal"
|
||||
verticalAlign="bottom"
|
||||
wrapperStyle={{ fontSize: 11 }}
|
||||
/>
|
||||
)}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { 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";
|
||||
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;
|
||||
|
||||
export function QuickStats() {
|
||||
interface QuickStatsProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function QuickStats({ compact }: QuickStatsProps) {
|
||||
const t = useTranslate();
|
||||
const { token } = useToken();
|
||||
const currencyFormatter = useCurrencyFormatter();
|
||||
|
||||
const { result: filamentsResult, query: filamentsQuery } = useList<IFilament>({
|
||||
resource: "filament",
|
||||
@@ -40,6 +48,25 @@ export function QuickStats() {
|
||||
return data.reduce((sum, spool) => sum + (spool.remaining_weight ?? 0), 0);
|
||||
}, [spoolsResult?.data]);
|
||||
|
||||
// Calculate total inventory value
|
||||
const totalValue = useMemo(() => {
|
||||
const data = spoolsResult?.data || [];
|
||||
return data.reduce((sum, spool) => sum + (spool.remaining_value ?? 0), 0);
|
||||
}, [spoolsResult?.data]);
|
||||
|
||||
// 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`;
|
||||
@@ -47,9 +74,70 @@ export function QuickStats() {
|
||||
return `${Math.round(grams)} g`;
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: t("home.quick_stats.total_filaments"),
|
||||
value: filamentsResult?.total ?? 0,
|
||||
loading: filamentsQuery.isLoading,
|
||||
prefix: <HighlightOutlined />,
|
||||
to: "/filament",
|
||||
},
|
||||
{
|
||||
title: t("home.quick_stats.total_vendors"),
|
||||
value: vendorsResult?.total ?? 0,
|
||||
loading: vendorsQuery.isLoading,
|
||||
prefix: <UserOutlined />,
|
||||
to: "/vendor",
|
||||
},
|
||||
{
|
||||
title: t("home.quick_stats.total_weight"),
|
||||
value: formatWeight(totalWeight),
|
||||
loading: spoolsQuery.isLoading,
|
||||
prefix: <DatabaseOutlined />,
|
||||
},
|
||||
{
|
||||
title: t("home.quick_stats.total_value"),
|
||||
value: currencyFormatter.format(totalValue),
|
||||
loading: spoolsQuery.isLoading,
|
||||
prefix: <DollarOutlined />,
|
||||
},
|
||||
{
|
||||
title: t("home.quick_stats.total_spent"),
|
||||
value: currencyFormatter.format(costStats?.total_spent ?? 0),
|
||||
loading: costLoading,
|
||||
prefix: <DollarOutlined />,
|
||||
},
|
||||
{
|
||||
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 />,
|
||||
},
|
||||
];
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Row gutter={[8, 8]}>
|
||||
{stats.map((stat, i) => (
|
||||
<Col xs={12} key={i}>
|
||||
<Card size="small" styles={{ body: { padding: "8px 12px" } }}>
|
||||
<Statistic
|
||||
title={stat.title}
|
||||
value={stat.value}
|
||||
loading={stat.loading}
|
||||
prefix={stat.prefix}
|
||||
valueStyle={{ fontSize: 16 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Row gutter={[12, 12]} style={{ marginTop: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Col xs={12} md={8} lg={4}>
|
||||
<Link to="/spool">
|
||||
<Card hoverable size="small">
|
||||
<Statistic
|
||||
@@ -61,40 +149,21 @@ export function QuickStats() {
|
||||
</Card>
|
||||
</Link>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Link to="/filament">
|
||||
<Card hoverable size="small">
|
||||
<Statistic
|
||||
title={t("home.quick_stats.total_filaments")}
|
||||
value={filamentsResult?.total ?? 0}
|
||||
loading={filamentsQuery.isLoading}
|
||||
prefix={<HighlightOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Link>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Link to="/vendor">
|
||||
<Card hoverable size="small">
|
||||
<Statistic
|
||||
title={t("home.quick_stats.total_vendors")}
|
||||
value={vendorsResult?.total ?? 0}
|
||||
loading={vendorsQuery.isLoading}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Link>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={t("home.quick_stats.total_weight")}
|
||||
value={formatWeight(totalWeight)}
|
||||
loading={spoolsQuery.isLoading}
|
||||
prefix={<DatabaseOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
{stats.map((stat, i) => (
|
||||
<Col xs={12} md={8} lg={4} key={i}>
|
||||
{stat.to ? (
|
||||
<Link to={stat.to}>
|
||||
<Card hoverable size="small">
|
||||
<Statistic title={stat.title} value={stat.value} loading={stat.loading} prefix={stat.prefix} />
|
||||
</Card>
|
||||
</Link>
|
||||
) : (
|
||||
<Card size="small">
|
||||
<Statistic title={stat.title} value={stat.value} loading={stat.loading} prefix={stat.prefix} />
|
||||
</Card>
|
||||
)}
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
153
client/src/pages/home/components/UsageAnalytics.tsx
Normal file
153
client/src/pages/home/components/UsageAnalytics.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, Segmented, Spin, Typography } from "antd";
|
||||
import { useState } from "react";
|
||||
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { getAPIURL } from "../../../utils/url";
|
||||
import { useCurrencyFormatter } from "../../../utils/settings";
|
||||
|
||||
interface UsageDataPoint {
|
||||
date: string;
|
||||
weight_used: number;
|
||||
}
|
||||
|
||||
interface MaterialUsage {
|
||||
material: string;
|
||||
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[]>({
|
||||
queryKey: ["analytics", "usage", days],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${getAPIURL()}/analytics/usage?days=${days}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const { data: materialData, isLoading: materialLoading } = useQuery<MaterialUsage[]>({
|
||||
queryKey: ["analytics", "by-material", days],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${getAPIURL()}/analytics/by-material?days=${days}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
// Calculate total usage
|
||||
const totalUsage = usageData?.reduce((sum, d) => sum + d.weight_used, 0) ?? 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t("home.analytics.title")}
|
||||
extra={
|
||||
<Segmented
|
||||
value={days}
|
||||
onChange={(v) => setDays(v as number)}
|
||||
options={[
|
||||
{ label: t("home.analytics.7_days"), value: 7 },
|
||||
{ label: t("home.analytics.30_days"), value: 30 },
|
||||
{ label: t("home.analytics.90_days"), value: 90 },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
{usageLoading ? (
|
||||
<div style={{ textAlign: "center", padding: 40 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Typography.Text type="secondary">
|
||||
{t("home.analytics.total_used", { weight: (totalUsage / 1000).toFixed(2) })}
|
||||
</Typography.Text>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={usageData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorUsage" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#1890ff" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#1890ff" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDate}
|
||||
tick={{ fontSize: 11 }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `${v}g`} width={50} />
|
||||
<Tooltip
|
||||
formatter={(value) => [`${(value as number).toFixed(1)} g`, t("home.analytics.used")]}
|
||||
labelFormatter={(label) => new Date(label).toLocaleDateString()}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="weight_used"
|
||||
stroke="#1890ff"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUsage)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{materialData && materialData.length > 0 && (
|
||||
<>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
{t("home.analytics.by_material")}
|
||||
</Typography.Title>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
{materialData.map((m) => {
|
||||
const costInfo = costData?.cost_by_material?.find((c) => c.material === m.material);
|
||||
// Calculate cost of material used based on unit cost (cost per gram)
|
||||
const costPerGram = costInfo && costInfo.total_weight > 0 ? costInfo.total_cost / costInfo.total_weight : null;
|
||||
const usageCost = costPerGram != null ? costPerGram * m.weight_used : null;
|
||||
return (
|
||||
<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>
|
||||
{usageCost != null && (
|
||||
<>
|
||||
<br />
|
||||
<Typography.Text type="secondary">
|
||||
{currencyFormatter.format(usageCost)}
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { ISpool } from "../spools/model";
|
||||
import { AlertCards } from "./components/AlertCards";
|
||||
import { MaterialDistributionChart } from "./components/MaterialDistributionChart";
|
||||
import { QuickStats } from "./components/QuickStats";
|
||||
import { UsageAnalytics } from "./components/UsageAnalytics";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
@@ -48,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;
|
||||
},
|
||||
},
|
||||
@@ -109,34 +112,38 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
|
||||
fontFamily: token.fontFamily,
|
||||
}}
|
||||
>
|
||||
{/* Header with spool count and add button */}
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Statistic
|
||||
title={t("spool.spool")}
|
||||
value={spoolsResult?.total ?? 0}
|
||||
loading={spoolsQuery.isLoading}
|
||||
valueStyle={{ fontSize: 36, fontWeight: 600 }}
|
||||
/>
|
||||
{/* Top section: Pie chart + stats/alerts side by side */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} md={10}>
|
||||
<MaterialDistributionChart square />
|
||||
</Col>
|
||||
<Col>
|
||||
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => navigate("/spool/create")}>
|
||||
{t("spool.titles.create")}
|
||||
</Button>
|
||||
<Col xs={24} md={14}>
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
||||
<Col>
|
||||
<Statistic
|
||||
title={t("spool.spool")}
|
||||
value={spoolsResult?.total ?? 0}
|
||||
loading={spoolsQuery.isLoading}
|
||||
valueStyle={{ fontSize: 36, fontWeight: 600 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => navigate("/spool/create")}>
|
||||
{t("spool.titles.create")}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<QuickStats compact />
|
||||
<AlertCards compact />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Alert badges */}
|
||||
<AlertCards />
|
||||
|
||||
{/* Material distribution chart */}
|
||||
<MaterialDistributionChart />
|
||||
|
||||
{/* Spool list */}
|
||||
<Card
|
||||
title={t("home.all_spools")}
|
||||
extra={<Link to="/spool">{t("buttons.viewAll")}</Link>}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Table<ISpool>
|
||||
dataSource={spoolsResult?.data as ISpool[]}
|
||||
@@ -162,8 +169,11 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick stats */}
|
||||
<QuickStats />
|
||||
{/* Usage analytics & insights */}
|
||||
<UsageAnalytics />
|
||||
|
||||
{/* Alerts */}
|
||||
<AlertCards />
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
|
||||
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 { 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";
|
||||
@@ -51,6 +51,7 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
|
||||
|
||||
const initialWeightValue = Form.useWatch("initial_weight", form);
|
||||
const spoolWeightValue = Form.useWatch("spool_weight", form);
|
||||
const extraWeightValue = Form.useWatch("extra_weight", form);
|
||||
|
||||
if (props.mode === "clone") {
|
||||
// Clear out the values that we don't want to clone
|
||||
@@ -85,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.
|
||||
@@ -208,10 +218,15 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
|
||||
return initialWeightValue ?? selectedFilament?.weight ?? 0;
|
||||
};
|
||||
|
||||
const getExtraWeight = (): number => {
|
||||
return extraWeightValue ?? 0;
|
||||
};
|
||||
|
||||
const getGrossWeight = (): number => {
|
||||
const net_weight = getFilamentWeight();
|
||||
const spool_weight = getSpoolWeight();
|
||||
return net_weight + spool_weight;
|
||||
const extra_weight = getExtraWeight();
|
||||
return net_weight + spool_weight + extra_weight;
|
||||
};
|
||||
|
||||
const getMeasuredWeight = (): number => {
|
||||
@@ -402,6 +417,30 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
|
||||
<InputNumber addonAfter="g" precision={1} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("spool.fields.extra_weight")}
|
||||
help={t("spool.fields_help.extra_weight")}
|
||||
name={["extra_weight"]}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
type: "number",
|
||||
min: 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber addonAfter="g" precision={1} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
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>
|
||||
|
||||
@@ -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";
|
||||
@@ -67,6 +67,7 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
|
||||
const initialWeightValue = Form.useWatch("initial_weight", form);
|
||||
const spoolWeightValue = Form.useWatch("spool_weight", form);
|
||||
const extraWeightValue = Form.useWatch("extra_weight", form);
|
||||
|
||||
// Calculate prev/next IDs for navigation
|
||||
const { prevId, nextId } = useMemo(() => {
|
||||
@@ -189,10 +190,15 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
return initialWeightValue ?? selectedFilament?.weight ?? 0;
|
||||
};
|
||||
|
||||
const getExtraWeight = (): number => {
|
||||
return extraWeightValue ?? 0;
|
||||
};
|
||||
|
||||
const getGrossWeight = (): number => {
|
||||
const net_weight = getFilamentWeight();
|
||||
const spool_weight = getSpoolWeight();
|
||||
return net_weight + spool_weight;
|
||||
const extra_weight = getExtraWeight();
|
||||
return net_weight + spool_weight + extra_weight;
|
||||
};
|
||||
|
||||
const getMeasuredWeight = (): number => {
|
||||
@@ -394,6 +400,30 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
<InputNumber addonAfter="g" precision={1} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("spool.fields.extra_weight")}
|
||||
help={t("spool.fields_help.extra_weight")}
|
||||
name={["extra_weight"]}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
type: "number",
|
||||
min: 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber addonAfter="g" precision={1} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,6 +92,7 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [
|
||||
"filament.color_hex",
|
||||
"filament.material",
|
||||
"price",
|
||||
"remaining_value",
|
||||
"used_weight",
|
||||
"remaining_weight",
|
||||
"used_length",
|
||||
@@ -101,7 +105,8 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [
|
||||
"comment",
|
||||
];
|
||||
const defaultColumns = allColumns.filter(
|
||||
(column_id) => ["registered", "used_length", "remaining_length", "lot_nr", "filament.color_hex"].indexOf(column_id) === -1
|
||||
(column_id) =>
|
||||
["registered", "used_length", "remaining_length", "lot_nr", "filament.color_hex", "remaining_value"].indexOf(column_id) === -1
|
||||
);
|
||||
|
||||
export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
@@ -120,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[]>([]);
|
||||
|
||||
@@ -132,6 +141,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
meta: {
|
||||
queryParams: {
|
||||
["allow_archived"]: showArchived,
|
||||
...(searchQuery ? { q: searchQuery } : {}),
|
||||
},
|
||||
},
|
||||
syncWithLocation: false,
|
||||
@@ -351,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={{
|
||||
@@ -388,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
|
||||
@@ -425,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(),
|
||||
}),
|
||||
@@ -439,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({
|
||||
@@ -462,6 +492,19 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
return currencyFormatter.format(obj.price);
|
||||
},
|
||||
}),
|
||||
SortedColumn({
|
||||
...commonProps,
|
||||
id: "remaining_value",
|
||||
i18ncat: "spool",
|
||||
align: "right",
|
||||
width: 100,
|
||||
render: (_, obj: ISpoolCollapsed) => {
|
||||
if (obj.remaining_value === undefined) {
|
||||
return "";
|
||||
}
|
||||
return currencyFormatter.format(obj.remaining_value);
|
||||
},
|
||||
}),
|
||||
NumberColumn({
|
||||
...commonProps,
|
||||
id: "used_weight",
|
||||
@@ -470,6 +513,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
unit: "g",
|
||||
maxDecimals: 0,
|
||||
width: 110,
|
||||
sorter: true,
|
||||
}),
|
||||
NumberColumn({
|
||||
...commonProps,
|
||||
@@ -479,6 +523,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
maxDecimals: 0,
|
||||
defaultText: t("unknown"),
|
||||
width: 110,
|
||||
sorter: true,
|
||||
}),
|
||||
NumberColumn({
|
||||
...commonProps,
|
||||
@@ -487,6 +532,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
unit: "mm",
|
||||
maxDecimals: 0,
|
||||
width: 120,
|
||||
sorter: true,
|
||||
}),
|
||||
NumberColumn({
|
||||
...commonProps,
|
||||
@@ -496,6 +542,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
maxDecimals: 0,
|
||||
defaultText: t("unknown"),
|
||||
width: 120,
|
||||
sorter: true,
|
||||
}),
|
||||
FilteredQueryColumn({
|
||||
...commonProps,
|
||||
@@ -541,6 +588,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
ActionsColumn(t("table.actions"), actions),
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
<BatchActionBar
|
||||
selectedIds={selectedRowKeys as number[]}
|
||||
onArchive={handleBatchArchive}
|
||||
|
||||
@@ -15,9 +15,12 @@ export interface ISpool {
|
||||
price?: number;
|
||||
initial_weight?: number;
|
||||
spool_weight?: number;
|
||||
extra_weight?: number;
|
||||
color_hex?: string;
|
||||
remaining_weight?: number;
|
||||
used_weight: number;
|
||||
remaining_length?: number;
|
||||
remaining_value?: number;
|
||||
used_length: number;
|
||||
location?: string;
|
||||
lot_nr?: string;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useCurrencyFormatter } from "../../utils/settings";
|
||||
import { getAPIURL, getBasePath } from "../../utils/url";
|
||||
import { IFilament } from "../filaments/model";
|
||||
import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
|
||||
import { ISpool, ISpoolAdjustment } from "./model";
|
||||
import { IPrintJob, ISpool, ISpoolAdjustment } from "./model";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -45,6 +46,16 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
||||
enabled: !!record?.id,
|
||||
});
|
||||
|
||||
// Fetch print job history
|
||||
const { data: printJobsData, isLoading: printJobsLoading } = useQuery({
|
||||
queryKey: ["spool-print-jobs", record?.id],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${getAPIURL()}/print-job?spool_id=${record?.id}`);
|
||||
return (await res.json()) as IPrintJob[];
|
||||
},
|
||||
enabled: !!record?.id,
|
||||
});
|
||||
|
||||
const spoolPrice = (item?: ISpool) => {
|
||||
const price = item?.price ?? item?.filament.price;
|
||||
if (price === undefined) {
|
||||
@@ -116,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
|
||||
@@ -235,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>
|
||||
@@ -288,6 +309,69 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Title level={4} style={{ marginTop: 24 }}>
|
||||
{t("spool.fields.print_history")}
|
||||
</Title>
|
||||
<Collapse
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: t("spool.fields.show_print_history"),
|
||||
children: (
|
||||
<Table<IPrintJob>
|
||||
dataSource={printJobsData}
|
||||
loading={printJobsLoading}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 10 }}
|
||||
columns={[
|
||||
{
|
||||
title: t("print_job.fields.created"),
|
||||
dataIndex: "created",
|
||||
key: "created",
|
||||
render: (value: string) => (
|
||||
<span title={dayjs.utc(value).local().format()}>
|
||||
{dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("print_job.fields.filename"),
|
||||
dataIndex: "filename",
|
||||
key: "filename",
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t("print_job.fields.filament_used"),
|
||||
dataIndex: "filament_used_g",
|
||||
key: "filament_used_g",
|
||||
render: (value: number) => `${value.toFixed(1)} g`,
|
||||
},
|
||||
{
|
||||
title: t("print_job.fields.status"),
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
render: (value: string) => t(`print_job.status.${value}`),
|
||||
},
|
||||
{
|
||||
title: t("print_job.fields.finished"),
|
||||
dataIndex: "finished",
|
||||
key: "finished",
|
||||
render: (value: string | null) =>
|
||||
value ? (
|
||||
<span title={dayjs.utc(value).local().format()}>
|
||||
{dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")}
|
||||
</span>
|
||||
) : (
|
||||
"-"
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,26 +35,17 @@ def upgrade() -> None:
|
||||
op.create_index(op.f("ix_location_id"), "location", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_location_parent_id"), "location", ["parent_id"], unique=False)
|
||||
|
||||
# Add location_id column to spool table
|
||||
# Add location_id column to spool table (SQLite compatible - no FK constraint)
|
||||
op.add_column(
|
||||
"spool",
|
||||
sa.Column("location_id", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_spool_location_id",
|
||||
"spool",
|
||||
"location",
|
||||
["location_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
op.create_index(op.f("ix_spool_location_id"), "spool", ["location_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop location table and remove location_id from spool."""
|
||||
op.drop_index(op.f("ix_spool_location_id"), table_name="spool")
|
||||
op.drop_constraint("fk_spool_location_id", "spool", type_="foreignkey")
|
||||
op.drop_column("spool", "location_id")
|
||||
op.drop_index(op.f("ix_location_parent_id"), table_name="location")
|
||||
op.drop_index(op.f("ix_location_id"), table_name="location")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Add extra_weight field to spool for DryPods, custom holders, etc.
|
||||
|
||||
Revision ID: f6g7h8i9j0k1
|
||||
Revises: e5f6g7h8i9j0
|
||||
Create Date: 2025-01-21 22:00:00.000000
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f6g7h8i9j0k1"
|
||||
down_revision = "e5f6g7h8i9j0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"spool",
|
||||
sa.Column(
|
||||
"extra_weight",
|
||||
sa.Float(),
|
||||
nullable=True,
|
||||
comment="Extra weight to account for (DryPods, custom holders, etc.).",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("spool", "extra_weight")
|
||||
@@ -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")
|
||||
@@ -311,6 +311,18 @@ class Spool(BaseModel):
|
||||
description=("Weight of an empty spool (tare weight)."),
|
||||
examples=[246],
|
||||
)
|
||||
extra_weight: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
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.",
|
||||
@@ -325,6 +337,12 @@ class Spool(BaseModel):
|
||||
),
|
||||
examples=[5612.4],
|
||||
)
|
||||
remaining_value: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
description="Estimated remaining value of filament on the spool based on remaining weight and price.",
|
||||
examples=[12.50],
|
||||
)
|
||||
used_length: float = Field(
|
||||
ge=0,
|
||||
description="Consumed length of filament from the spool in millimeters.",
|
||||
@@ -369,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,
|
||||
@@ -389,6 +407,13 @@ class Spool(BaseModel):
|
||||
diameter=filament.diameter,
|
||||
)
|
||||
|
||||
# Calculate remaining value based on remaining weight and price
|
||||
remaining_value: Optional[float] = None
|
||||
price = item.price if item.price is not None else filament.price
|
||||
initial_weight = item.initial_weight if item.initial_weight is not None else filament.weight
|
||||
if remaining_weight is not None and price is not None and initial_weight is not None and initial_weight > 0:
|
||||
remaining_value = round((remaining_weight / initial_weight) * price, 2)
|
||||
|
||||
return Spool(
|
||||
id=item.id,
|
||||
registered=item.registered,
|
||||
@@ -398,10 +423,12 @@ class Spool(BaseModel):
|
||||
price=item.price,
|
||||
initial_weight=item.initial_weight,
|
||||
spool_weight=item.spool_weight,
|
||||
extra_weight=item.extra_weight,
|
||||
used_weight=item.used_weight,
|
||||
used_length=used_length,
|
||||
remaining_weight=remaining_weight,
|
||||
remaining_length=remaining_length,
|
||||
remaining_value=remaining_value,
|
||||
location=item.location,
|
||||
lot_nr=item.lot_nr,
|
||||
comment=item.comment,
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""Filament related endpoints."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
import httpx
|
||||
import sqlalchemy
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.database import filament, spool
|
||||
from spoolman.database import filament, models, spool
|
||||
from spoolman.database.database import get_db_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -173,3 +177,299 @@ async def rename_location(
|
||||
logger.info("Renaming location %s to %s", location, body.name)
|
||||
await spool.rename_location(db=db, current_name=location, new_name=body.name)
|
||||
return body.name
|
||||
|
||||
|
||||
class UsageDataPoint(BaseModel):
|
||||
"""A single data point for usage analytics."""
|
||||
|
||||
date: str = Field(description="Date in YYYY-MM-DD format.")
|
||||
weight_used: float = Field(description="Total weight used in grams (positive value).")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/analytics/usage",
|
||||
name="Get usage analytics",
|
||||
description="Get filament usage data aggregated by day for the specified period.",
|
||||
response_model=list[UsageDataPoint],
|
||||
)
|
||||
async def get_usage_analytics(
|
||||
*,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
days: Annotated[
|
||||
int,
|
||||
Query(description="Number of days to look back.", ge=1, le=365),
|
||||
] = 30,
|
||||
) -> list[UsageDataPoint]:
|
||||
"""Get usage analytics aggregated by day."""
|
||||
# Calculate the start date
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# Query adjustments grouped by date
|
||||
# Only include negative values (filament used, not added)
|
||||
stmt = (
|
||||
sqlalchemy.select(
|
||||
func.date(models.SpoolAdjustment.timestamp).label("date"),
|
||||
func.sum(
|
||||
sqlalchemy.case(
|
||||
(models.SpoolAdjustment.value < 0, -models.SpoolAdjustment.value),
|
||||
else_=0,
|
||||
)
|
||||
).label("weight_used"),
|
||||
)
|
||||
.where(models.SpoolAdjustment.timestamp >= start_date)
|
||||
.where(models.SpoolAdjustment.adjustment_type == "weight")
|
||||
.group_by(func.date(models.SpoolAdjustment.timestamp))
|
||||
.order_by(func.date(models.SpoolAdjustment.timestamp))
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
# Fill in missing dates with 0
|
||||
data_by_date = {str(row.date): row.weight_used for row in rows}
|
||||
all_dates = []
|
||||
current_date = start_date.date()
|
||||
end_date = datetime.utcnow().date()
|
||||
|
||||
while current_date <= end_date:
|
||||
date_str = current_date.strftime("%Y-%m-%d")
|
||||
all_dates.append(
|
||||
UsageDataPoint(
|
||||
date=date_str,
|
||||
weight_used=data_by_date.get(date_str, 0),
|
||||
)
|
||||
)
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
return all_dates
|
||||
|
||||
|
||||
class MaterialUsage(BaseModel):
|
||||
"""Usage breakdown by material."""
|
||||
|
||||
material: str = Field(description="Material name.")
|
||||
weight_used: float = Field(description="Total weight used in grams.")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/analytics/by-material",
|
||||
name="Get usage by material",
|
||||
description="Get filament usage breakdown by material type.",
|
||||
response_model=list[MaterialUsage],
|
||||
)
|
||||
async def get_usage_by_material(
|
||||
*,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
days: Annotated[
|
||||
Optional[int],
|
||||
Query(description="Number of days to look back. If not set, returns all-time usage.", ge=1, le=365),
|
||||
] = None,
|
||||
) -> list[MaterialUsage]:
|
||||
"""Get usage breakdown by material."""
|
||||
# Join adjustments with spools and filaments to get material
|
||||
stmt = (
|
||||
sqlalchemy.select(
|
||||
func.coalesce(models.Filament.material, "Unknown").label("material"),
|
||||
func.sum(
|
||||
sqlalchemy.case(
|
||||
(models.SpoolAdjustment.value < 0, -models.SpoolAdjustment.value),
|
||||
else_=0,
|
||||
)
|
||||
).label("weight_used"),
|
||||
)
|
||||
.join(models.Spool, models.SpoolAdjustment.spool_id == models.Spool.id)
|
||||
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
|
||||
.where(models.SpoolAdjustment.adjustment_type == "weight")
|
||||
)
|
||||
|
||||
if days is not None:
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
stmt = stmt.where(models.SpoolAdjustment.timestamp >= start_date)
|
||||
|
||||
stmt = stmt.group_by(models.Filament.material).order_by(func.sum(-models.SpoolAdjustment.value).desc())
|
||||
|
||||
result = await db.execute(stmt)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# --- filamentcolors.xyz integration ---
|
||||
|
||||
FILAMENTCOLORS_API = "https://filamentcolors.xyz/api"
|
||||
|
||||
|
||||
class FilamentColorSwatch(BaseModel):
|
||||
"""A color swatch from filamentcolors.xyz."""
|
||||
|
||||
id: int
|
||||
color_name: str
|
||||
hex_color: Optional[str] = None
|
||||
manufacturer_name: str
|
||||
filament_type: str
|
||||
image_front: Optional[str] = None
|
||||
|
||||
|
||||
class FilamentColorManufacturer(BaseModel):
|
||||
"""A manufacturer from filamentcolors.xyz."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class FilamentColorSearchResult(BaseModel):
|
||||
"""Search results from filamentcolors.xyz."""
|
||||
|
||||
count: int
|
||||
swatches: list[FilamentColorSwatch]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/filamentcolors/manufacturers",
|
||||
name="Get filamentcolors.xyz manufacturers",
|
||||
description="List all manufacturers from filamentcolors.xyz.",
|
||||
response_model=list[FilamentColorManufacturer],
|
||||
)
|
||||
async def get_filamentcolors_manufacturers() -> list[FilamentColorManufacturer]:
|
||||
"""Proxy manufacturer list from filamentcolors.xyz."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(f"{FILAMENTCOLORS_API}/manufacturer/", params={"format": "json", "limit": 500})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
return [
|
||||
FilamentColorManufacturer(id=m["id"], name=m["name"])
|
||||
for m in data.get("results", [])
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/filamentcolors/search",
|
||||
name="Search filamentcolors.xyz swatches",
|
||||
description="Search color swatches from filamentcolors.xyz by manufacturer and/or color name.",
|
||||
response_model=FilamentColorSearchResult,
|
||||
)
|
||||
async def search_filamentcolors(
|
||||
*,
|
||||
manufacturer: Annotated[
|
||||
Optional[str],
|
||||
Query(description="Manufacturer name to filter by."),
|
||||
] = None,
|
||||
color: Annotated[
|
||||
Optional[str],
|
||||
Query(description="Color name to search for."),
|
||||
] = None,
|
||||
filament_type: Annotated[
|
||||
Optional[str],
|
||||
Query(description="Filament type to filter by (e.g., PLA, PETG)."),
|
||||
] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||
page: Annotated[int, Query(ge=1)] = 1,
|
||||
) -> FilamentColorSearchResult:
|
||||
"""Proxy swatch search from filamentcolors.xyz."""
|
||||
params: dict = {"format": "json", "limit": limit, "page": page}
|
||||
if manufacturer:
|
||||
params["manufacturer__name"] = manufacturer
|
||||
if color:
|
||||
params["color_name__icontains"] = color
|
||||
if filament_type:
|
||||
params["filament_type__name__icontains"] = filament_type
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(f"{FILAMENTCOLORS_API}/swatch/", params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
swatches = [
|
||||
FilamentColorSwatch(
|
||||
id=s["id"],
|
||||
color_name=s.get("color_name", ""),
|
||||
hex_color=s.get("hex_color"),
|
||||
manufacturer_name=s.get("manufacturer", {}).get("name", ""),
|
||||
filament_type=s.get("filament_type", {}).get("name", ""),
|
||||
image_front=s.get("image_front"),
|
||||
)
|
||||
for s in data.get("results", [])
|
||||
]
|
||||
|
||||
return FilamentColorSearchResult(count=data.get("count", 0), swatches=swatches)
|
||||
|
||||
@@ -51,6 +51,18 @@ class SpoolParameters(BaseModel):
|
||||
description="The weight of an empty spool, in grams. (tare weight)",
|
||||
examples=[200],
|
||||
)
|
||||
extra_weight: Optional[float] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
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,
|
||||
@@ -293,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(
|
||||
@@ -329,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,
|
||||
@@ -449,6 +471,8 @@ async def create( # noqa: ANN201
|
||||
price=body.price,
|
||||
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,
|
||||
|
||||
@@ -100,6 +100,15 @@ class Spool(Base):
|
||||
initial_weight: Mapped[Optional[float]] = mapped_column()
|
||||
spool_weight: Mapped[Optional[float]] = mapped_column()
|
||||
used_weight: Mapped[float] = mapped_column()
|
||||
extra_weight: Mapped[Optional[float]] = mapped_column(
|
||||
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"),
|
||||
|
||||
@@ -41,6 +41,8 @@ async def create(
|
||||
remaining_weight: Optional[float] = None,
|
||||
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,
|
||||
@@ -69,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
|
||||
|
||||
@@ -84,6 +89,8 @@ async def create(
|
||||
registered=datetime.utcnow().replace(microsecond=0),
|
||||
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,
|
||||
@@ -115,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,
|
||||
@@ -153,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):
|
||||
@@ -221,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))
|
||||
|
||||
@@ -258,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":
|
||||
@@ -440,7 +477,12 @@ async def measure(
|
||||
|
||||
"""
|
||||
spool_result = await db.execute(
|
||||
sqlalchemy.select(models.Spool.initial_weight, models.Spool.used_weight, models.Spool.spool_weight).where(
|
||||
sqlalchemy.select(
|
||||
models.Spool.initial_weight,
|
||||
models.Spool.used_weight,
|
||||
models.Spool.spool_weight,
|
||||
models.Spool.extra_weight,
|
||||
).where(
|
||||
models.Spool.id == spool_id,
|
||||
),
|
||||
)
|
||||
@@ -452,6 +494,7 @@ async def measure(
|
||||
|
||||
initial_weight = spool_info[0]
|
||||
spool_weight = spool_info[2]
|
||||
extra_weight = spool_info[3] or 0 # Default to 0 if not set
|
||||
if initial_weight is None or initial_weight == 0 or spool_weight is None or spool_weight == 0:
|
||||
# Get filament weight and spool_weight
|
||||
result = await db.execute(
|
||||
@@ -473,7 +516,7 @@ async def measure(
|
||||
if initial_weight is None or initial_weight == 0:
|
||||
raise SpoolMeasureError("Initial weight is not set.")
|
||||
|
||||
initial_gross_weight = initial_weight + spool_weight
|
||||
initial_gross_weight = initial_weight + spool_weight + extra_weight
|
||||
|
||||
# if the measurement is greater than the initial weight, set the initial weight to the measurement
|
||||
if weight > initial_gross_weight:
|
||||
@@ -485,9 +528,10 @@ async def measure(
|
||||
# Calculate the weight used since last measure
|
||||
weight_to_use = current_use - weight
|
||||
|
||||
# If the measured weight is less than the empty weight, use the rest of the spool
|
||||
if (initial_gross_weight - weight_to_use) < spool_weight:
|
||||
weight_to_use = current_use - spool_weight
|
||||
# If the measured weight is less than the empty weight (+ extra), use the rest of the spool
|
||||
empty_gross_weight = spool_weight + extra_weight
|
||||
if (initial_gross_weight - weight_to_use) < empty_gross_weight:
|
||||
weight_to_use = current_use - empty_gross_weight
|
||||
|
||||
return await use_weight(db, spool_id, weight_to_use, comment=comment)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user