Files
spoolman2/client/src/pages/spools/list.tsx
2024-08-06 14:28:09 +02:00

476 lines
14 KiB
TypeScript

import { useCallback, useMemo, useState } from "react";
import { IResourceComponentsProps, useInvalidate, useNavigation, useTranslate } from "@refinedev/core";
import { useTable, List } from "@refinedev/antd";
import { Table, Button, Dropdown, Modal } from "antd";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { ISpool } from "./model";
import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload";
import {
EditOutlined,
EyeOutlined,
FilterOutlined,
InboxOutlined,
PlusSquareOutlined,
PrinterOutlined,
ToolOutlined,
ToTopOutlined,
} from "@ant-design/icons";
import {
DateColumn,
FilteredQueryColumn,
NumberColumn,
RichColumn,
SortedColumn,
SpoolIconColumn,
Action,
ActionsColumn,
CustomFieldColumn,
} from "../../components/column";
import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
import {
useSpoolmanFilamentFilter,
useSpoolmanLocations,
useSpoolmanLotNumbers,
useSpoolmanMaterials,
} from "../../components/otherModels";
import { useLiveify } from "../../components/liveify";
import { removeUndefined } from "../../utils/filtering";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { useNavigate } from "react-router-dom";
import { useCurrency } from "../../utils/settings";
dayjs.extend(utc);
const { confirm } = Modal;
interface ISpoolCollapsed extends ISpool {
"filament.combined_name": string; // Eg. "Prusa - PLA Red"
"filament.id": number;
"filament.material"?: 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,
};
}
function translateColumnI18nKey(columnName: string): string {
columnName = columnName.replace(".", "_");
if (columnName === "filament_combined_name") columnName = "filament_name";
else if (columnName === "filament_material") columnName = "material";
return `spool.fields.${columnName}`;
}
const namespace = "spoolList-v2";
const allColumns: (keyof ISpoolCollapsed & string)[] = [
"id",
"filament.combined_name",
"filament.material",
"price",
"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"].indexOf(column_id) === -1
);
export const SpoolList: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate();
const invalidate = useInvalidate();
const navigate = useNavigate();
const extraFields = useGetFields(EntityType.spool);
const currency = useCurrency();
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);
// 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, current, pageSize, setCurrent } =
useTable<ISpoolCollapsed>({
meta: {
queryParams: {
["allow_archived"]: showArchived,
},
},
syncWithLocation: false,
pagination: {
mode: "server",
current: initialState.pagination.current,
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<string[]>(initialState.showColumns ?? defaultColumns);
// Store state in local storage
const tableState: TableState = {
sorters,
filters,
pagination: { current, 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 && 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);
},
});
}
};
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: <EyeOutlined />, link: showUrl("spool", record.id) },
{ name: t("buttons.edit"), icon: <EditOutlined />, link: editUrl("spool", record.id) },
{ name: t("buttons.clone"), icon: <PlusSquareOutlined />, link: cloneUrl("spool", record.id) },
{ name: t("spool.titles.adjust"), icon: <ToolOutlined />, onClick: () => openSpoolAdjustModal(record) },
];
if (record.archived) {
actions.push({
name: t("buttons.unArchive"),
icon: <ToTopOutlined />,
onClick: () => archiveSpool(record, false),
});
} else {
actions.push({ name: t("buttons.archive"), icon: <InboxOutlined />, 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 (
<List
headerButtons={({ defaultButtons }) => (
<>
<Button
type="primary"
icon={<PrinterOutlined />}
onClick={() => {
navigate("print");
}}
>
{t("printing.qrcode.button")}
</Button>
<Button
type="primary"
icon={<InboxOutlined />}
onClick={() => {
setShowArchived(!showArchived);
}}
>
{showArchived ? t("buttons.hideArchived") : t("buttons.showArchived")}
</Button>
<Button
type="primary"
icon={<FilterOutlined />}
onClick={() => {
setFilters([], "replace");
setSorters([{ field: "id", order: "asc" }]);
setCurrent(1);
}}
>
{t("buttons.clearFilters")}
</Button>
<Dropdown
trigger={["click"]}
menu={{
items: allColumnsWithExtraFields.map((column_id) => {
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);
},
}}
>
<Button type="primary" icon={<EditOutlined />}>
{t("buttons.hideColumns")}
</Button>
</Dropdown>
{defaultButtons}
</>
)}
>
{spoolAdjustModal}
<Table
{...tableProps}
sticky
tableLayout="auto"
scroll={{ x: "max-content" }}
dataSource={dataSource}
rowKey="id"
// 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.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(),
}),
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) => {
return obj.price?.toLocaleString(undefined, {
style: "currency",
currencyDisplay: "narrowSymbol",
currency: currency,
notation: "compact",
});
},
}),
NumberColumn({
...commonProps,
id: "used_weight",
i18ncat: "spool",
align: "right",
unit: "g",
maxDecimals: 0,
width: 110,
}),
NumberColumn({
...commonProps,
id: "remaining_weight",
i18ncat: "spool",
unit: "g",
maxDecimals: 0,
defaultText: t("unknown"),
width: 110,
}),
NumberColumn({
...commonProps,
id: "used_length",
i18ncat: "spool",
unit: "mm",
maxDecimals: 0,
width: 120,
}),
NumberColumn({
...commonProps,
id: "remaining_length",
i18ncat: "spool",
unit: "mm",
maxDecimals: 0,
defaultText: t("unknown"),
width: 120,
}),
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(actions),
])}
/>
</List>
);
};
export default SpoolList;