diff --git a/CLAUDE.md b/CLAUDE.md index 45cf9ce..47af507 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ Environment variables (see `.env.example`): This is a fork with UX improvements. Issues are tracked on the Gitea instance above. -## UX Improvement Issues +## UX Improvement Issues (Gitea) | # | Title | Status | |---|-------|--------| @@ -132,16 +132,23 @@ This is a fork with UX improvements. Issues are tracked on the Gitea instance ab | 4 | Layout Max-Width | ✅ DONE | | 5 | Temperature Ranges | ✅ DONE | | 6 | Spool Adjustment History | ✅ DONE | -| 7 | 3dfilamentprofiles Integration | ❌ REMOVED (bot protection) | -| 8 | (see Gitea #8) | ✅ DONE | -| 9 | Hierarchical Locations (Room/Bin) | Open | +| 7 | 3dfilamentprofiles Integration | ❌ REMOVED | +| 8 | (duplicate of #7) | ✅ DONE | +| 9 | Hierarchical Locations (Room/Bin) | **Open** | | 10 | Database Import/Export | ✅ DONE | -| 11 | Theme Colors | ✅ DONE | -| 12 | Clickable Dashboard Cards | ✅ DONE | -| 13 | QR Code Size Setting | ✅ DONE | -| 14 | Parse Temps from Comments | ✅ DONE | -| 15 | Print Job Tracking / Slicer Integration | ✅ DONE | -| 16 | Prev/Next Navigation on Edit Pages | ✅ 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** | + +### Other Features (not tracked in Gitea) +- ✅ Theme Colors (5 options) +- ✅ Clickable Dashboard Cards +- ✅ QR Code Size Setting +- ✅ Parse Temps from Comments +- ✅ Prev/Next Navigation on Edit Pages +- ✅ Color Column in Spool List (with filter) ### Issue #1 Complete - Inline Creation Modals @@ -326,6 +333,28 @@ Track pending print jobs from slicer and manage filament deduction after prints - Configure via env vars: `SPOOLMAN_URL`, `SPOOLMAN_SPOOL_ID` - Or config file: `~/.config/spoolman/slicer.conf` +### Color Column in Spool List + +Added optional color column to the spool list with filtering capability. + +**Backend:** +- `spoolman/database/filament.py` - `find_colors()` function to get distinct color hex codes +- `spoolman/api/v1/other.py` - `GET /api/v1/color` endpoint to list all colors +- `spoolman/api/v1/spool.py` - Added `filament.color_hex` query parameter for filtering +- `spoolman/database/spool.py` - Filter implementation (supports comma-separated values) + +**Frontend:** +- `client/src/components/column.tsx` - `ColorFilterColumn` component renders color swatches +- `client/src/components/otherModels.tsx` - `useSpoolmanColors()` hook to fetch colors +- `client/src/pages/spools/list.tsx` - Added color column (hidden by default) +- `client/public/locales/en/common.json` - Added "Color" translation key + +**Features:** +- Color displayed as small swatch with hex tooltip +- Filter dropdown shows swatches with hex codes +- Multi-select filtering (can filter by multiple colors) +- Column hidden by default (enable via column selector) + --- ## Deployment diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 260fb1c..9021828 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -173,6 +173,7 @@ "filament_internal": "Internal", "filament_external": "External", "price": "Price", + "color": "Color", "material": "Material", "weight_to_use": "Weight", "used_weight": "Used Weight", diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index 1acf1cb..f45d385 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -220,6 +220,52 @@ export function FilteredQueryColumn(props: FilteredQueryColu return Column({ ...props, filters, filteredValue, onFilterDropdownOpen, loadingFilters: query.isLoading }); } +interface ColorFilterColumnProps extends BaseColumnProps { + filterValueQuery: UseQueryResult; + colorGetter: (record: Obj) => string | undefined; +} + +export function ColorFilterColumn(props: ColorFilterColumnProps) { + const query = props.filterValueQuery; + + let filters: ColumnFilterItem[] = []; + if (query.data) { + filters = query.data; + } + + const typedFilters = typeFilters(props.tableState.filters); + const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj)); + + const onFilterDropdownOpen = () => { + query.refetch(); + }; + + return Column({ + ...props, + filters, + filteredValue, + onFilterDropdownOpen, + loadingFilters: query.isLoading, + allowMultipleFilters: true, + render: (_: unknown, record: Obj) => { + const colorHex = props.colorGetter(record); + if (!colorHex) return null; + return ( +
+ ); + }, + }); +} + interface NumberColumnProps extends BaseColumnProps { unit: string; maxDecimals?: number; diff --git a/client/src/components/otherModels.tsx b/client/src/components/otherModels.tsx index e83d462..9a374b5 100644 --- a/client/src/components/otherModels.tsx +++ b/client/src/components/otherModels.tsx @@ -203,3 +203,36 @@ export function useSpoolmanLocations(enabled: boolean = false) { }, }); } + +export function useSpoolmanColors(enabled: boolean = false) { + return useQuery({ + enabled: enabled, + queryKey: ["colors"], + queryFn: async () => { + const response = await fetch(getAPIURL() + "/color"); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }, + select: (data) => { + return data.map((colorHex) => ({ + text: ( +
+
+ #{colorHex} +
+ ), + value: colorHex, + })); + }, + }); +} diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx index cfd6414..c0b8dbc 100644 --- a/client/src/pages/spools/list.tsx +++ b/client/src/pages/spools/list.tsx @@ -18,6 +18,7 @@ import { useNavigate } from "react-router"; import { Action, ActionsColumn, + ColorFilterColumn, CustomFieldColumn, DateColumn, FilteredQueryColumn, @@ -28,6 +29,7 @@ import { } from "../../components/column"; import { useLiveify } from "../../components/liveify"; import { + useSpoolmanColors, useSpoolmanFilamentFilter, useSpoolmanLocations, useSpoolmanLotNumbers, @@ -48,6 +50,7 @@ interface ISpoolCollapsed extends ISpool { "filament.combined_name": string; // Eg. "Prusa - PLA Red" "filament.id": number; "filament.material"?: string; + "filament.color_hex"?: string; } function collapseSpool(element: ISpool): ISpoolCollapsed { @@ -65,6 +68,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, }; } @@ -72,6 +76,7 @@ function translateColumnI18nKey(columnName: string): string { columnName = columnName.replace(".", "_"); if (columnName === "filament_combined_name") columnName = "filament_name"; else if (columnName === "filament_material") columnName = "material"; + else if (columnName === "filament_color_hex") columnName = "color"; return `spool.fields.${columnName}`; } @@ -80,6 +85,7 @@ const namespace = "spoolList-v2"; const allColumns: (keyof ISpoolCollapsed & string)[] = [ "id", "filament.combined_name", + "filament.color_hex", "filament.material", "price", "used_weight", @@ -94,7 +100,7 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [ "comment", ]; const defaultColumns = allColumns.filter( - (column_id) => ["registered", "used_length", "remaining_length", "lot_nr"].indexOf(column_id) === -1 + (column_id) => ["registered", "used_length", "remaining_length", "lot_nr", "filament.color_hex"].indexOf(column_id) === -1 ); export const SpoolList: React.FC = () => { @@ -367,6 +373,14 @@ export const SpoolList: React.FC = () => { dataId: "filament.combined_name", filterValueQuery: useSpoolmanFilamentFilter(), }), + ColorFilterColumn({ + ...commonProps, + id: "filament.color_hex", + i18nkey: "spool.fields.color", + filterValueQuery: useSpoolmanColors(), + colorGetter: (record: ISpoolCollapsed) => record.filament.color_hex, + width: 60, + }), FilteredQueryColumn({ ...commonProps, id: "filament.material", diff --git a/spoolman/api/v1/other.py b/spoolman/api/v1/other.py index 14f7128..750da77 100644 --- a/spoolman/api/v1/other.py +++ b/spoolman/api/v1/other.py @@ -47,6 +47,33 @@ async def find_materials( return await filament.find_materials(db=db) +@router.get( + "/color", + name="Find colors", + description="Get a list of all filament colors (hex codes).", + response_model_exclude_none=True, + responses={ + 200: { + "description": "A list of all filament color hex codes.", + "content": { + "application/json": { + "example": [ + "FF0000", + "00FF00", + "0000FF", + ], + }, + }, + }, + }, +) +async def find_colors( + *, + db: Annotated[AsyncSession, Depends(get_db_session)], +) -> list[str]: + return await filament.find_colors(db=db) + + @router.get( "/article-number", name="Find article numbers", diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index cae8bba..7b335e4 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -212,6 +212,17 @@ async def find( ), ), ] = None, + filament_color_hex: Annotated[ + Optional[str], + Query( + alias="filament.color_hex", + title="Filament Color", + description=( + "Filter by exact filament color hex code (without #). Separate multiple colors with a comma." + ), + examples=["FF0000", "FF0000,00FF00,0000FF"], + ), + ] = None, filament_vendor_name: Annotated[ Optional[str], Query( @@ -321,6 +332,7 @@ async def find( 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, + filament_color_hex=filament_color_hex, vendor_name=filament_vendor_name if filament_vendor_name is not None else vendor_name_old, vendor_id=filament_vendor_ids, location=location, diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index a63266a..12ca215 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -241,6 +241,16 @@ async def find_materials( return [row[0] for row in rows.all() if row[0] is not None] +async def find_colors( + *, + db: AsyncSession, +) -> list[str]: + """Find a list of unique filament colors (hex codes) from the filament table.""" + stmt = select(models.Filament.color_hex).distinct() + rows = await db.execute(stmt) + return sorted([row[0] for row in rows.all() if row[0] is not None]) + + async def find_article_numbers( *, db: AsyncSession, diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index d975ca4..9d949fe 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -118,6 +118,7 @@ async def find( # noqa: C901, PLR0912 filament_name: Optional[str] = None, filament_id: Optional[Union[int, Sequence[int]]] = None, filament_material: Optional[str] = None, + filament_color_hex: Optional[Union[str, Sequence[str]]] = None, vendor_name: Optional[str] = None, vendor_id: Optional[Union[int, Sequence[int]]] = None, location: Optional[str] = None, @@ -152,6 +153,15 @@ 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) + # Filter by color_hex (exact match, supports multiple comma-separated values) + if filament_color_hex is not None: + if isinstance(filament_color_hex, str): + colors = [c.strip() for c in filament_color_hex.split(",") if c.strip()] + else: + colors = list(filament_color_hex) + if colors: + stmt = stmt.where(models.Filament.color_hex.in_(colors)) + if not allow_archived: # Since the archived field is nullable, and default is false, we need to check for both false or null stmt = stmt.where(