diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 88ff2ae85..aa2886d0b 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -127,12 +127,17 @@ }, "spoolSelect": { "title": "Select Spools", - "description": "Select spools to print labels for.", + "description": "Search for and select spool labels to print:", + "searchPlaceholder": "Search by spool ID, filament, material, location, or lot number", "showArchived": "Show Archived", "noSpoolsSelected": "You have not selected any spools.", "selectAll": "Select/Unselect All", "selectedTotal_one": "{{count}} spool selected", "selectedTotal_other": "{{count}} spools selected" + }, + "filamentSelect": { + "description": "Search for and select filament labels to print:", + "searchPlaceholder": "Search by filament ID, vendor, name, material, or color" } }, "scanner": { diff --git a/client/src/pages/printing/index.tsx b/client/src/pages/printing/index.tsx index f6ddc4246..709fbc288 100644 --- a/client/src/pages/printing/index.tsx +++ b/client/src/pages/printing/index.tsx @@ -50,11 +50,14 @@ export const Printing = () => { {step === 0 && ( { + searchPlaceholder={t("printing.spoolSelect.searchPlaceholder")} + onPrint={(selectedIds) => { setSearchParams((prev) => { const newParams = new URLSearchParams(prev); newParams.delete("spools"); - spools.forEach((spool) => newParams.append("spools", spool.id.toString())); + selectedIds.forEach((id) => newParams.append("spools", id.toString())); + // Keep the selector route as the explicit return target so back/cancel + // returns to the selection step instead of the originating show page. newParams.set("return", "/spool/print"); return newParams; }); diff --git a/client/src/pages/printing/spoolSelectModal.tsx b/client/src/pages/printing/spoolSelectModal.tsx index 91d8fdb36..954360878 100644 --- a/client/src/pages/printing/spoolSelectModal.tsx +++ b/client/src/pages/printing/spoolSelectModal.tsx @@ -1,18 +1,25 @@ -import { RightOutlined } from "@ant-design/icons"; import { useTable } from "@refinedev/antd"; -import { Button, Checkbox, Col, message, Row, Space, Table } from "antd"; +import { Button, Checkbox, Col, Input, message, Pagination, Row, Space, Table } from "antd"; import { t } from "i18next"; -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router"; import { FilteredQueryColumn, SortedColumn, SpoolIconColumn } from "../../components/column"; -import { useSpoolmanFilamentFilter, useSpoolmanMaterials } from "../../components/otherModels"; +import { + useSpoolmanFilamentFilter, + useSpoolmanLocations, + useSpoolmanLotNumbers, + useSpoolmanMaterials, +} from "../../components/otherModels"; import { removeUndefined } from "../../utils/filtering"; import { TableState } from "../../utils/saveload"; import { ISpool } from "../spools/model"; interface Props { description?: string; - onContinue: (selectedSpools: ISpool[]) => void; + initialSelectedIds?: number[]; + onExport?: (selectedIds: number[]) => void; + onPrint?: (selectedIds: number[]) => void; + searchPlaceholder?: string; } interface ISpoolCollapsed extends ISpool { @@ -36,73 +43,163 @@ function collapseSpool(element: ISpool): ISpoolCollapsed { }; } -const SpoolSelectModal = ({ description, onContinue }: Props) => { - const [selectedItems, setSelectedItems] = useState([]); +const SpoolSelectModal = ({ description, initialSelectedIds, onExport, onPrint, searchPlaceholder }: Props) => { + const MIN_TABLE_SCROLL_Y = 180; + const TABLE_BOTTOM_GAP = 16; + const [selectedItems, setSelectedItems] = useState(initialSelectedIds ?? []); const [showArchived, setShowArchived] = useState(false); const [messageApi, contextHolder] = message.useMessage(); const navigate = useNavigate(); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [selectedArchivedMap, setSelectedArchivedMap] = useState>({}); + const [tableScrollY, setTableScrollY] = useState(300); + const rootRef = useRef(null); + const tableContainerRef = useRef(null); - const { tableProps, sorters, filters, currentPage, pageSize } = useTable({ - resource: "spool", - meta: { - queryParams: { - ["allow_archived"]: showArchived, + const { tableProps, sorters, filters, setFilters, currentPage, pageSize, setCurrentPage, setPageSize } = + useTable({ + resource: "spool", + meta: { + queryParams: { + ["allow_archived"]: showArchived, + ...(debouncedSearch.length > 0 ? { search: debouncedSearch } : {}), + }, }, - }, - syncWithLocation: false, - pagination: { - mode: "off", - currentPage: 1, - pageSize: 10, - }, - sorters: { - mode: "server", - }, - filters: { - mode: "server", - }, - queryOptions: { - select(data) { - return { - total: data.total, - data: data.data.map(collapseSpool), - }; + syncWithLocation: false, + pagination: { + mode: "server", + currentPage: 1, + pageSize: 50, }, - }, - }); + sorters: { + mode: "server", + }, + filters: { + mode: "server", + }, + queryOptions: { + select(data) { + return { + total: data.total, + data: data.data.map(collapseSpool), + }; + }, + }, + }); - // Store state in local storage const tableState: TableState = { sorters, filters, pagination: { currentPage: currentPage, pageSize }, }; - // Collapse the dataSource to a mutable list and add a filament_name field const dataSource: ISpoolCollapsed[] = useMemo( () => (tableProps.dataSource || []).map((record) => ({ ...record })), [tableProps.dataSource], ); + const selectedSet = useMemo(() => new Set(selectedItems), [selectedItems]); - // Function to add/remove all filtered items from selected items - const selectUnselectFiltered = (select: boolean) => { - setSelectedItems((prevSelected) => { - const filtered = dataSource.map((spool) => spool.id).filter((spool) => !prevSelected.includes(spool)); - return select ? [...prevSelected, ...filtered] : filtered; + useEffect(() => { + if (dataSource.length === 0) { + return; + } + setSelectedArchivedMap((prev) => { + const next = { ...prev }; + dataSource.forEach((spool) => { + next[spool.id] = spool.archived === true; + }); + return next; }); - }; + }, [dataSource]); - // Handler for selecting/unselecting individual items - const handleSelectItem = (item: number) => { + useEffect(() => { + const computeScrollHeight = () => { + if (!tableContainerRef.current) { + return; + } + const viewportHeight = window.visualViewport?.height ?? window.innerHeight; + const tableTop = tableContainerRef.current.getBoundingClientRect().top; + const availableHeight = Math.floor(viewportHeight - tableTop - TABLE_BOTTOM_GAP); + setTableScrollY(Math.max(MIN_TABLE_SCROLL_Y, availableHeight)); + }; + + computeScrollHeight(); + + const onViewportResize = () => computeScrollHeight(); + window.addEventListener("resize", onViewportResize); + window.addEventListener("orientationchange", onViewportResize); + window.visualViewport?.addEventListener("resize", onViewportResize); + window.visualViewport?.addEventListener("scroll", onViewportResize); + + const resizeObserver = + typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => computeScrollHeight()) : undefined; + if (resizeObserver && rootRef.current) { + resizeObserver.observe(rootRef.current); + } + + return () => { + window.removeEventListener("resize", onViewportResize); + window.removeEventListener("orientationchange", onViewportResize); + window.visualViewport?.removeEventListener("resize", onViewportResize); + window.visualViewport?.removeEventListener("scroll", onViewportResize); + resizeObserver?.disconnect(); + }; + }, []); + + // Debounce search input to avoid excessive API calls while typing + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(searchTerm.trim()); + setCurrentPage(1); + }, 300); + return () => clearTimeout(timer); + }, [searchTerm, setCurrentPage]); + + const paginationTotal = tableProps.pagination ? (tableProps.pagination.total ?? 0) : 0; + const handlePageChange = useCallback( + (page: number, nextPageSize?: number) => { + if (typeof nextPageSize === "number" && nextPageSize !== pageSize) { + setPageSize(nextPageSize); + } + setCurrentPage(page); + }, + [pageSize, setPageSize, setCurrentPage], + ); + const handlePageSizeChange = useCallback( + (_current: number, size: number) => { + setPageSize(size); + setCurrentPage(1); + }, + [setPageSize, setCurrentPage], + ); + + const selectUnselectFiltered = useCallback( + (select: boolean) => { + setSelectedItems((prevSelected) => { + const nextSelected = new Set(prevSelected); + dataSource.forEach((spool) => { + if (select) { + nextSelected.add(spool.id); + } else { + nextSelected.delete(spool.id); + } + }); + return Array.from(nextSelected); + }); + }, + [dataSource], + ); + + const handleSelectItem = useCallback((item: number) => { setSelectedItems((prevSelected) => prevSelected.includes(item) ? prevSelected.filter((selected) => selected !== item) : [...prevSelected, item], ); - }; + }, []); - // State for the select/unselect all checkbox - const isAllFilteredSelected = dataSource.every((spool) => selectedItems.includes(spool.id)); + const isAllFilteredSelected = dataSource.length > 0 && dataSource.every((spool) => selectedSet.has(spool.id)); const isSomeButNotAllFilteredSelected = - dataSource.some((spool) => selectedItems.includes(spool.id)) && !isAllFilteredSelected; + dataSource.some((spool) => selectedSet.has(spool.id)) && !isAllFilteredSelected; const commonProps = { t, @@ -114,106 +211,207 @@ const SpoolSelectModal = ({ description, onContinue }: Props) => { tableState, sorter: true, }; + const resolvedDescription = + description ?? + t("printing.spoolSelect.description", { + defaultValue: "Search for and select spool labels to print:", + }); + const resolvedSearchPlaceholder = + searchPlaceholder ?? + t("printing.spoolSelect.searchPlaceholder", { + defaultValue: "Search by spool ID, filament, material, location, or lot number", + }); return ( <> {contextHolder} - - {description &&
{description}
} - ( - handleSelectItem(item.id)} /> - ), - }, - SortedColumn({ - ...commonProps, - id: "id", - i18ncat: "spool", - width: 80, - }), - SpoolIconColumn({ - ...commonProps, - id: "filament.combined_name", - dataId: "filament.combined_name", - i18nkey: "spool.fields.filament_name", - color: (record: ISpoolCollapsed) => record.filament.color_hex, - filterValueQuery: useSpoolmanFilamentFilter(), - }), - FilteredQueryColumn({ - ...commonProps, - id: "filament.material", - i18nkey: "spool.fields.material", - filterValueQuery: useSpoolmanMaterials(), - }), - ])} - /> - - - { - selectUnselectFiltered(e.target.checked); +
+ {(resolvedDescription || tableProps.pagination) && ( + +
{resolvedDescription &&
{resolvedDescription}
} + {tableProps.pagination && ( + + + + )} + + )} + + + { + setSearchTerm(event.target.value); }} - > - {t("printing.spoolSelect.selectAll")} - - - -
- {t("printing.spoolSelect.selectedTotal", { - count: selectedItems.length, - })} -
- - - { - setShowArchived(e.target.checked); - if (!e.target.checked) { - // Remove archived spools from selected items - setSelectedItems((prevSelected) => - prevSelected.filter( - (selected) => dataSource.find((spool) => spool.id === selected)?.archived !== true, - ), - ); - } + onSearch={(value) => { + setSearchTerm(value); }} - > - {t("printing.spoolSelect.showArchived")} - + /> - + + + + +
+ { + selectUnselectFiltered(e.target.checked); + }} + > + {t("printing.spoolSelect.selectAll")} + + { + setShowArchived(e.target.checked); + if (!e.target.checked) { + setSelectedItems((prevSelected) => + prevSelected.filter((selected) => selectedArchivedMap[selected] !== true), + ); + } + }} + > + {t("printing.spoolSelect.showArchived")} + +
+ {t("printing.spoolSelect.selectedTotal", { + count: selectedItems.length, + })} +
+ + {onPrint && ( + + )} + {onExport && ( + + )} + +
+ - +
+
( + handleSelectItem(item.id)} /> + ), + }, + SortedColumn({ + ...commonProps, + id: "id", + i18ncat: "spool", + width: 70, + }), + SpoolIconColumn({ + ...commonProps, + id: "filament.combined_name", + dataId: "filament.combined_name", + i18nkey: "spool.fields.filament_name", + width: 360, + 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, + filterValueQuery: useSpoolmanFilamentFilter(), + }), + FilteredQueryColumn({ + ...commonProps, + id: "filament.material", + i18nkey: "spool.fields.material", + filterValueQuery: useSpoolmanMaterials(), + width: 140, + }), + FilteredQueryColumn({ + ...commonProps, + id: "location", + i18ncat: "spool", + filterValueQuery: useSpoolmanLocations(), + width: 160, + }), + FilteredQueryColumn({ + ...commonProps, + id: "lot_nr", + i18ncat: "spool", + filterValueQuery: useSpoolmanLotNumbers(), + width: 160, + }), + ])} + /> + + ); }; diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 8f667e3da..90efa6387 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -128,6 +128,17 @@ class SpoolMeasureParameters(BaseModel): async def find( *, db: Annotated[AsyncSession, Depends(get_db_session)], + search: Annotated[ + str | None, + Query( + title="Search", + description=( + "Partial case-insensitive search term applied across spool ID, filament vendor name, filament name, " + "material, location, and lot number. Separate multiple terms with a comma. Surround a term with " + "quotes to search for the exact term." + ), + ), + ] = None, filament_name_old: Annotated[ str | None, Query(alias="filament_name", title="Filament Name", description="See filament.name.", deprecated=True), @@ -287,6 +298,7 @@ async def find( db_items, total_count = await spool.find( db=db, + search=search, 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, diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 5c190ce65..16f39d437 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -111,9 +111,10 @@ async def get_by_id(db: AsyncSession, spool_id: int) -> models.Spool: return spool -async def find( # noqa: C901, PLR0912 +async def find( # noqa: C901, PLR0912, PLR0915 *, db: AsyncSession, + search: str | None = None, filament_name: str | None = None, filament_id: int | Sequence[int] | None = None, filament_material: str | None = None, @@ -147,6 +148,40 @@ async def find( # noqa: C901, PLR0912 stmt = add_where_clause_str_opt(stmt, models.Filament.material, filament_material) stmt = add_where_clause_str_opt(stmt, models.Spool.location, location) stmt = add_where_clause_str_opt(stmt, models.Spool.lot_nr, lot_nr) + if search is not None: + search_conditions = [] + for value_part in search.split(","): + if len(value_part) == 0: + continue + + if value_part[0] == '"' and value_part[-1] == '"': + exact_value = value_part[1:-1] + search_conditions.extend( + [ + models.Vendor.name == exact_value, + models.Filament.name == exact_value, + models.Filament.material == exact_value, + models.Spool.location == exact_value, + models.Spool.lot_nr == exact_value, + ], + ) + if exact_value.lstrip("-").isdigit(): + search_conditions.append(models.Spool.id == int(exact_value)) + else: + fuzzy_value = f"%{value_part}%" + search_conditions.extend( + [ + models.Vendor.name.ilike(fuzzy_value), + models.Filament.name.ilike(fuzzy_value), + models.Filament.material.ilike(fuzzy_value), + models.Spool.location.ilike(fuzzy_value), + models.Spool.lot_nr.ilike(fuzzy_value), + sqlalchemy.cast(models.Spool.id, sqlalchemy.String).ilike(fuzzy_value), + ], + ) + + if search_conditions: + stmt = stmt.where(sqlalchemy.or_(*search_conditions)) if not allow_archived: # Since the archived field is nullable, and default is false, we need to check for both false or null