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, Input, Modal, Table } from "antd"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router"; import { Action, ActionsColumn, ColorFilterColumn, CustomFieldColumn, DateColumn, FilteredQueryColumn, NumberColumn, RichColumn, SortedColumn, SpoolIconColumn, } from "../../components/column"; import { useLiveify } from "../../components/liveify"; import { useSpoolmanColors, useSpoolmanFilamentFilter, useSpoolmanLocations, useSpoolmanLotNumbers, useSpoolmanMaterials, } from "../../components/otherModels"; import { removeUndefined } from "../../utils/filtering"; import { EntityType, useGetFields } from "../../utils/queryFields"; import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload"; 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); const { confirm } = Modal; 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 { let filament_name: string; if (element.filament.vendor && "name" in element.filament.vendor) { filament_name = `${element.filament.vendor.name} - ${element.filament.name}`; } else { filament_name = element.filament.name ?? element.filament.id.toString(); } if (element.price === undefined) { element.price = element.filament.price; } return { ...element, "filament.combined_name": filament_name, "filament.id": element.filament.id, "filament.material": element.filament.material, "filament.color_hex": element.color_hex ?? element.filament.color_hex, }; } 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}`; } const namespace = "spoolList-v2"; const allColumns: (keyof ISpoolCollapsed & string)[] = [ "id", "filament.combined_name", "filament.color_hex", "filament.material", "price", "remaining_value", "used_weight", "remaining_weight", "used_length", "remaining_length", "location", "lot_nr", "first_used", "last_used", "registered", "comment", ]; const defaultColumns = allColumns.filter( (column_id) => ["registered", "used_length", "remaining_length", "lot_nr", "filament.color_hex", "remaining_value"].indexOf(column_id) === -1 ); export const SpoolList: React.FC = () => { const t = useTranslate(); const invalidate = useInvalidate(); const navigate = useNavigate(); const extraFields = useGetFields(EntityType.spool); const currencyFormatter = useCurrencyFormatter(); const { openSpoolAdjustModal, spoolAdjustModal } = useSpoolAdjustModal(); const allColumnsWithExtraFields = [...allColumns, ...(extraFields.data?.map((field) => "extra." + field.key) ?? [])]; // Load initial state const initialState = useInitialTableState(namespace); // 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([]); // Fetch data from the API // To provide the live updates, we use a custom solution (useLiveify) instead of the built-in refine "liveMode" feature. // This is because the built-in feature does not call the liveProvider subscriber with a list of IDs, but instead // calls it with a list of filters, sorters, etc. This means the server-side has to support this, which is quite hard. const { tableProps, sorters, setSorters, filters, setFilters, currentPage, pageSize, setCurrentPage } = useTable({ meta: { queryParams: { ["allow_archived"]: showArchived, ...(searchQuery ? { q: searchQuery } : {}), }, }, syncWithLocation: false, pagination: { mode: "server", currentPage: initialState.pagination.currentPage, pageSize: initialState.pagination.pageSize, }, sorters: { mode: "server", initial: initialState.sorters, }, filters: { mode: "server", initial: initialState.filters, }, liveMode: "manual", onLiveEvent(event) { if (event.type === "created" || event.type === "deleted") { // updated is handled by the liveify invalidate({ resource: "spool", invalidates: ["list"], }); } }, queryOptions: { select(data) { return { total: data.total, data: data.data.map(collapseSpool), }; }, }, }); // Create state for the columns to show const [showColumns, setShowColumns] = useState(initialState.showColumns ?? defaultColumns); // Store state in local storage const tableState: TableState = { sorters, filters, pagination: { currentPage: currentPage, pageSize }, showColumns, }; useStoreInitialState(namespace, tableState); // Collapse the dataSource to a mutable list const queryDataSource: ISpoolCollapsed[] = useMemo( () => (tableProps.dataSource || []).map((record) => ({ ...record })), [tableProps.dataSource] ); const dataSource = useLiveify("spool", queryDataSource, collapseSpool); // Function for opening an ant design modal that asks for confirmation for archiving a spool const archiveSpool = async (spool: ISpoolCollapsed, archive: boolean) => { await setSpoolArchived(spool, archive); invalidate({ resource: "spool", id: spool.id, invalidates: ["list", "detail"], }); }; const archiveSpoolPopup = async (spool: ISpoolCollapsed) => { // If the spool has no remaining weight, archive it immediately since it's likely not a mistake if (spool.remaining_weight != undefined && spool.remaining_weight <= 0) { await archiveSpool(spool, true); } else { confirm({ title: t("spool.titles.archive"), content: t("spool.messages.archive"), okText: t("buttons.archive"), okType: "primary", cancelText: t("buttons.cancel"), onOk() { return archiveSpool(spool, true); }, }); } }; // Batch action handlers const handleBatchArchive = () => { confirm({ title: t("batch.archive_confirm"), content: t("batch.archive_confirm_content", { count: selectedRowKeys.length }), okText: t("buttons.archive"), okType: "primary", cancelText: t("buttons.cancel"), async onOk() { await bulkArchiveSpools(selectedRowKeys as number[], true); invalidate({ resource: "spool", invalidates: ["list"] }); setSelectedRowKeys([]); }, }); }; const handleBatchUnarchive = async () => { confirm({ title: t("batch.unarchive_confirm"), content: t("batch.unarchive_confirm_content", { count: selectedRowKeys.length }), okText: t("buttons.unArchive"), okType: "primary", cancelText: t("buttons.cancel"), async onOk() { await bulkArchiveSpools(selectedRowKeys as number[], false); invalidate({ resource: "spool", invalidates: ["list"] }); setSelectedRowKeys([]); }, }); }; const handleBatchDelete = () => { confirm({ title: t("batch.delete_confirm"), content: t("batch.delete_confirm_content", { count: selectedRowKeys.length }), okText: t("buttons.delete"), okType: "danger", cancelText: t("buttons.cancel"), async onOk() { await bulkDeleteSpools(selectedRowKeys as number[]); invalidate({ resource: "spool", invalidates: ["list"] }); setSelectedRowKeys([]); }, }); }; const handleBatchMove = async (location: string) => { await bulkUpdateSpools(selectedRowKeys as number[], { location }); invalidate({ resource: "spool", invalidates: ["list"] }); setSelectedRowKeys([]); }; if (tableProps.pagination) { tableProps.pagination.showSizeChanger = true; } const { editUrl, showUrl, cloneUrl } = useNavigation(); const actions = useCallback( (record: ISpoolCollapsed) => { const actions: Action[] = [ { name: t("buttons.show"), icon: , link: showUrl("spool", record.id) }, { name: t("buttons.edit"), icon: , link: editUrl("spool", record.id) }, { name: t("buttons.clone"), icon: , link: cloneUrl("spool", record.id) }, { name: t("spool.titles.adjust"), icon: , onClick: () => openSpoolAdjustModal(record) }, ]; if (record.archived) { actions.push({ name: t("buttons.unArchive"), icon: , onClick: () => archiveSpool(record, false), }); } else { actions.push({ name: t("buttons.archive"), icon: , onClick: () => archiveSpoolPopup(record) }); } return actions; }, [t, editUrl, showUrl, cloneUrl, openSpoolAdjustModal, archiveSpool, archiveSpoolPopup] ); const originalOnChange = tableProps.onChange; tableProps.onChange = (pagination, filters, sorter, extra) => { // Rename any key called "filament.combined_name" in filters to "filament.id" // This is because we want to use combined_name for sorting, but id for filtering, // and Ant Design and Refine only supports specifying a single field for both. Object.keys(filters).forEach((key) => { if (key === "filament.combined_name") { filters["filament.id"] = filters[key]; delete filters[key]; } }); originalOnChange?.(pagination, filters, sorter, extra); }; const commonProps = { t, navigate, actions, dataSource, tableState, sorter: true, }; return ( ( <> { if (column_id.indexOf("extra.") === 0) { const extraField = extraFields.data?.find((field) => "extra." + field.key === column_id); return { key: column_id, label: extraField?.name ?? column_id, }; } return { key: column_id, label: t(translateColumnI18nKey(column_id)), }; }), selectedKeys: showColumns, selectable: true, multiple: true, onDeselect: (keys) => { setShowColumns(keys.selectedKeys); }, onSelect: (keys) => { setShowColumns(keys.selectedKeys); }, }} > {defaultButtons} )} > {spoolAdjustModal} { setSearchQuery(value); setCurrentPage(1); }} style={{ marginBottom: 16, maxWidth: 400 }} /> {viewMode === "gallery" ? ( ) : ( setSelectedRowKeys(newSelectedRowKeys), preserveSelectedRowKeys: true, }} // Make archived rows greyed out onRow={(record) => { if (record.archived) { return { style: { fontStyle: "italic", color: "#999", }, }; } else { return {}; } }} columns={removeUndefined([ SortedColumn({ ...commonProps, id: "id", i18ncat: "spool", width: 70, }), SpoolIconColumn({ ...commonProps, id: "filament.combined_name", i18nkey: "spool.fields.filament_name", color: (record: ISpoolCollapsed) => 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(), }), ColorFilterColumn({ ...commonProps, id: "filament.color_hex", i18nkey: "spool.fields.color", filterValueQuery: useSpoolmanColors(), colorGetter: (record: ISpoolCollapsed) => record.color_hex ?? record.filament.color_hex, width: 60, }), FilteredQueryColumn({ ...commonProps, id: "filament.material", i18nkey: "spool.fields.material", filterValueQuery: useSpoolmanMaterials(), width: 120, }), SortedColumn({ ...commonProps, id: "price", i18ncat: "spool", align: "right", width: 80, render: (_, obj: ISpoolCollapsed) => { if (obj.price === undefined) { return ""; } 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", i18ncat: "spool", align: "right", unit: "g", maxDecimals: 0, width: 110, sorter: true, }), NumberColumn({ ...commonProps, id: "remaining_weight", i18ncat: "spool", unit: "g", maxDecimals: 0, defaultText: t("unknown"), width: 110, sorter: true, }), NumberColumn({ ...commonProps, id: "used_length", i18ncat: "spool", unit: "mm", maxDecimals: 0, width: 120, sorter: true, }), NumberColumn({ ...commonProps, id: "remaining_length", i18ncat: "spool", unit: "mm", maxDecimals: 0, defaultText: t("unknown"), width: 120, sorter: true, }), FilteredQueryColumn({ ...commonProps, id: "location", i18ncat: "spool", filterValueQuery: useSpoolmanLocations(), width: 120, }), FilteredQueryColumn({ ...commonProps, id: "lot_nr", i18ncat: "spool", filterValueQuery: useSpoolmanLotNumbers(), width: 120, }), DateColumn({ ...commonProps, id: "first_used", i18ncat: "spool", }), DateColumn({ ...commonProps, id: "last_used", i18ncat: "spool", }), DateColumn({ ...commonProps, id: "registered", i18ncat: "spool", }), ...(extraFields.data?.map((field) => { return CustomFieldColumn({ ...commonProps, field, }); }) ?? []), RichColumn({ ...commonProps, id: "comment", i18ncat: "spool", width: 150, }), ActionsColumn(t("table.actions"), actions), ])} /> )} setSelectedRowKeys([])} /> ); }; export default SpoolList;