diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index e86323a9c2..f5664f4533 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -820,8 +820,9 @@ class PartFilter(rest_filters.FilterSet): def filter_has_units(self, queryset, name, value): """Filter by whether the Part has units or not""" if str2bool(value): - return queryset.exclude(units='') - return queryset.filter(units='') + return queryset.exclude(Q(units=None) | Q(units='')) + + return queryset.filter(Q(units=None) | Q(units='')).distinct() # Filter by parts which have (or not) an IPN value has_ipn = rest_filters.BooleanFilter(label='Has IPN', method='filter_has_ipn') diff --git a/src/frontend/src/components/tables/Filter.tsx b/src/frontend/src/components/tables/Filter.tsx index 15e9ac0cc0..23e06bf41a 100644 --- a/src/frontend/src/components/tables/Filter.tsx +++ b/src/frontend/src/components/tables/Filter.tsx @@ -1,3 +1,8 @@ +import { t } from '@lingui/macro'; + +import { ModelType } from '../../enums/ModelType'; +import { useServerApiState } from '../../states/ApiState'; + /** * Interface for the table filter choice */ @@ -7,15 +12,96 @@ export type TableFilterChoice = { }; /** - * Interface for the table filter, + * Interface for the table filter type. Provides a number of options for selecting filter value: + * + * choices: A list of TableFilterChoice objects + * choiceFunction: A function which returns a list of TableFilterChoice objects + * statusType: A ModelType which is used to generate a list of status codes */ export type TableFilter = { name: string; label: string; description?: string; - type: string; + type?: string; choices?: TableFilterChoice[]; choiceFunction?: () => TableFilterChoice[]; defaultValue?: any; value?: any; + displayValue?: any; }; + +/** + * Return list of available filter options for a given filter + * @param filter - TableFilter object + * @returns - A list of TableFilterChoice objects + */ +export function getTableFilterOptions( + filter: TableFilter +): TableFilterChoice[] { + if (filter.choices) { + return filter.choices; + } + + if (filter.choiceFunction) { + return filter.choiceFunction(); + } + + // Default fallback is a boolean filter + return [ + { value: 'true', label: t`Yes` }, + { value: 'false', label: t`No` } + ]; +} + +/* + * Construct a table filter which allows filtering by status code + */ +export function StatusFilterOptions( + model: ModelType +): () => TableFilterChoice[] { + return () => { + const statusCodeList = useServerApiState.getState().status; + + if (!statusCodeList) { + return []; + } + + const codes = statusCodeList[model]; + + if (codes) { + return Object.keys(codes).map((key) => { + const entry = codes[key]; + return { + value: entry.key, + label: entry.label ?? entry.key + }; + }); + } + + return []; + }; +} + +export function AssignedToMeFilter(): TableFilter { + return { + name: 'assigned_to_me', + label: t`Assigned to me`, + description: t`Show orders assigned to me` + }; +} + +export function OutstandingFilter(): TableFilter { + return { + name: 'outstanding', + label: t`Outstanding`, + description: t`Show outstanding orders` + }; +} + +export function OverdueFilter(): TableFilter { + return { + name: 'overdue', + label: t`Overdue`, + description: t`Show overdue orders` + }; +} diff --git a/src/frontend/src/components/tables/FilterBadge.tsx b/src/frontend/src/components/tables/FilterBadge.tsx deleted file mode 100644 index 6af65ca1ac..0000000000 --- a/src/frontend/src/components/tables/FilterBadge.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { t } from '@lingui/macro'; -import { Badge, CloseButton } from '@mantine/core'; -import { Text, Tooltip } from '@mantine/core'; -import { Group } from '@mantine/core'; - -import { TableFilter } from './Filter'; - -export function FilterBadge({ - filter, - onFilterRemove -}: { - filter: TableFilter; - onFilterRemove: () => void; -}) { - /** - * Construct text to display for the given badge ID - */ - function filterDescription() { - let text = filter.label || filter.name; - - text += ' = '; - text += filter.value; - - return text; - } - - return ( - ({ - root: { - paddingRight: '4px' - }, - inner: { - textTransform: 'none' - } - })} - > - - {filterDescription()} - - onFilterRemove()} /> - - - - ); -} diff --git a/src/frontend/src/components/tables/FilterGroup.tsx b/src/frontend/src/components/tables/FilterGroup.tsx deleted file mode 100644 index 1a7998ae8d..0000000000 --- a/src/frontend/src/components/tables/FilterGroup.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { t } from '@lingui/macro'; -import { ActionIcon, Group, Text, Tooltip } from '@mantine/core'; -import { IconFilterMinus } from '@tabler/icons-react'; -import { IconFilterPlus } from '@tabler/icons-react'; - -import { TableFilter } from './Filter'; -import { FilterBadge } from './FilterBadge'; - -/** - * Return a table filter group component: - * - Displays a list of active filters for the table - * - Allows the user to add/remove filters - * - Allows the user to clear all filters - */ -export function FilterGroup({ - activeFilters, - onFilterAdd, - onFilterRemove, - onFilterClearAll -}: { - activeFilters: TableFilter[]; - onFilterAdd: () => void; - onFilterRemove: (filterName: string) => void; - onFilterClearAll: () => void; -}) { - return ( - - {activeFilters.length == 0 && ( - {t`Add table filter`} - )} - {activeFilters.map((f) => ( - onFilterRemove(f.name)} - /> - ))} - {activeFilters.length && ( - onFilterClearAll()} - > - - - - - )} - { - onFilterAdd()}> - - - - - } - - ); -} diff --git a/src/frontend/src/components/tables/FilterSelectDrawer.tsx b/src/frontend/src/components/tables/FilterSelectDrawer.tsx new file mode 100644 index 0000000000..212490807b --- /dev/null +++ b/src/frontend/src/components/tables/FilterSelectDrawer.tsx @@ -0,0 +1,234 @@ +import { t } from '@lingui/macro'; +import { + Badge, + Button, + CloseButton, + Divider, + Drawer, + Group, + Paper, + Select, + Stack, + Text, + Tooltip +} from '@mantine/core'; +import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; + +import { TableState } from '../../hooks/UseTable'; +import { StylishText } from '../items/StylishText'; +import { + TableFilter, + TableFilterChoice, + getTableFilterOptions +} from './Filter'; + +/* + * Render a single table filter item + */ +function FilterItem({ + flt, + tableState +}: { + flt: TableFilter; + tableState: TableState; +}) { + const removeFilter = useCallback(() => { + let newFilters = tableState.activeFilters.filter( + (f) => f.name !== flt.name + ); + tableState.setActiveFilters(newFilters); + }, [flt]); + + return ( + + + + {flt.label} + {flt.description} + + + {flt.displayValue ?? flt.value} + + + + + + + ); +} + +interface FilterProps extends React.ComponentPropsWithoutRef<'div'> { + name: string; + label: string; + description?: string; +} + +/* + * Custom component for the filter select + */ +const FilterSelectItem = forwardRef( + ({ label, description, ...others }, ref) => ( +
+ {label} + {description} +
+ ) +); + +function FilterAddGroup({ + tableState, + availableFilters +}: { + tableState: TableState; + availableFilters: TableFilter[]; +}) { + const filterOptions = useMemo(() => { + let activeFilterNames = tableState.activeFilters.map((flt) => flt.name); + + return availableFilters + .filter((flt) => !activeFilterNames.includes(flt.name)) + .map((flt) => ({ + value: flt.name, + label: flt.label, + description: flt.description + })); + }, [tableState.activeFilters, availableFilters]); + + const [selectedFilter, setSelectedFilter] = useState(null); + + const valueOptions: TableFilterChoice[] = useMemo(() => { + // Find the matching filter + let filter: TableFilter | undefined = availableFilters.find( + (flt) => flt.name === selectedFilter + ); + + if (!filter) { + return []; + } + + return getTableFilterOptions(filter); + }, [selectedFilter]); + + const setSelectedValue = useCallback( + (value: string | null) => { + // Find the matching filter + let filter: TableFilter | undefined = availableFilters.find( + (flt) => flt.name === selectedFilter + ); + + if (!filter) { + return; + } + + let filters = tableState.activeFilters.filter( + (flt) => flt.name !== selectedFilter + ); + + let newFilter: TableFilter = { + ...filter, + value: value, + displayValue: valueOptions.find((v) => v.value === value)?.label + }; + + tableState.setActiveFilters([...filters, newFilter]); + }, + [selectedFilter] + ); + + return ( + + + setSelectedValue(value)} + maxDropdownHeight={800} + /> + )} + + ); +} + +export function FilterSelectDrawer({ + availableFilters, + tableState, + opened, + onClose +}: { + availableFilters: TableFilter[]; + tableState: TableState; + opened: boolean; + onClose: () => void; +}) { + const [addFilter, setAddFilter] = useState(false); + + // Hide the "add filter" selection whenever the selected filters change + useEffect(() => { + setAddFilter(false); + }, [tableState.activeFilters]); + + return ( + {t`Table Filters`}} + > + + {tableState.activeFilters.map((f) => ( + + ))} + {tableState.activeFilters.length > 0 && } + {addFilter && ( + + + + )} + {addFilter && ( + + )} + {!addFilter && + tableState.activeFilters.length < availableFilters.length && ( + + )} + {!addFilter && tableState.activeFilters.length > 0 && ( + + )} + + + ); +} diff --git a/src/frontend/src/components/tables/FilterSelectModal.tsx b/src/frontend/src/components/tables/FilterSelectModal.tsx deleted file mode 100644 index b09d942515..0000000000 --- a/src/frontend/src/components/tables/FilterSelectModal.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { t } from '@lingui/macro'; -import { Modal } from '@mantine/core'; -import { Select } from '@mantine/core'; -import { Stack } from '@mantine/core'; -import { Button, Group, Text } from '@mantine/core'; -import { forwardRef, useMemo, useState } from 'react'; - -import { TableFilter, TableFilterChoice } from './Filter'; - -/** - * Construct the selection of filters - */ -function constructAvailableFilters( - activeFilters: TableFilter[], - availableFilters: TableFilter[] -) { - // Collect a list of active filters - let activeFilterNames = activeFilters.map((flt) => flt.name); - - let options = availableFilters - .filter((flt) => !activeFilterNames.includes(flt.name)) - .map((flt) => ({ - value: flt.name, - label: flt.label, - description: flt.description - })); - - return options; -} - -/** - * Construct the selection of available values for the selected filter - */ -function constructValueOptions( - availableFilters: TableFilter[], - selectedFilter: string | null -) { - // No options if no filter is selected - if (!selectedFilter) { - return []; - } - - let filter = availableFilters.find((flt) => flt.name === selectedFilter); - - if (!filter) { - console.error(`Could not find filter ${selectedFilter}`); - return []; - } - - let options: TableFilterChoice[] = []; - - switch (filter.type) { - case 'boolean': - // Boolean filter values True / False - options = [ - { value: 'true', label: t`True` }, - { value: 'false', label: t`False` } - ]; - break; - default: - // Choices are supplied by the filter definition - if (filter.choices) { - options = filter.choices; - } else if (filter.choiceFunction) { - options = filter.choiceFunction(); - } else { - console.error(`Filter choices not supplied for filter ${filter.name}`); - } - break; - } - - return options; -} - -interface FilterProps extends React.ComponentPropsWithoutRef<'div'> { - name: string; - label: string; - description?: string; -} - -/* - * Custom component for the filter select - */ -const FilterSelectItem = forwardRef( - ({ name, label, description, ...others }, ref) => ( -
- {label} - {description} -
- ) -); - -/** - * Modal dialog to add a} new filter for a particular table - * @param opened : boolean - Whether the modal is opened or not - * @param onClose : () => void - Function called when the modal is closed - * @returns - */ -export function FilterSelectModal({ - availableFilters, - activeFilters, - opened, - onCreateFilter, - onClose -}: { - availableFilters: TableFilter[]; - activeFilters: TableFilter[]; - opened: boolean; - onCreateFilter: (name: string, value: string) => void; - onClose: () => void; -}) { - let filterOptions = useMemo( - () => constructAvailableFilters(activeFilters, availableFilters), - [activeFilters, availableFilters] - ); - - // Internal state variable for the selected filter - let [selectedFilter, setSelectedFilter] = useState(null); - - // Internal state variable for the selected filter value - let [value, setValue] = useState(null); - - let valueOptions = useMemo( - () => constructValueOptions(availableFilters, selectedFilter), - [availableFilters, activeFilters, selectedFilter] - ); - - // Callback when the modal is closed. Ensure that the internal state is reset - function closeModal() { - setSelectedFilter(null); - setValue(null); - onClose(); - } - - function createFilter() { - if (selectedFilter && value) { - onCreateFilter(selectedFilter, value); - } - closeModal(); - } - - return ( - - - {t`Select from the available filters`} - setValue(value)} - withinPortal={true} - maxDropdownHeight={400} - /> - - - - - - - ); -} diff --git a/src/frontend/src/components/tables/InvenTreeTable.tsx b/src/frontend/src/components/tables/InvenTreeTable.tsx index 5232ce5d3e..ca17406fef 100644 --- a/src/frontend/src/components/tables/InvenTreeTable.tsx +++ b/src/frontend/src/components/tables/InvenTreeTable.tsx @@ -15,8 +15,7 @@ import { TableColumn } from './Column'; import { TableColumnSelect } from './ColumnSelect'; import { DownloadAction } from './DownloadAction'; import { TableFilter } from './Filter'; -import { FilterGroup } from './FilterGroup'; -import { FilterSelectModal } from './FilterSelectModal'; +import { FilterSelectDrawer } from './FilterSelectDrawer'; import { RowAction, RowActions } from './RowActions'; import { TableSearchInput } from './Search'; @@ -126,13 +125,6 @@ export function InvenTreeTable({ defaultValue: [] }); - // Active filters (saved to local storage) - const [activeFilters, setActiveFilters] = useLocalStorage({ - key: `inventree-active-table-filters-${tableName}`, - defaultValue: [], - getInitialValueInEffect: false - }); - // Data selection const [selectedRecords, setSelectedRecords] = useState([]); @@ -198,50 +190,12 @@ export function InvenTreeTable({ ); } - // Filter selection open state - const [filterSelectOpen, setFilterSelectOpen] = useState(false); - // Pagination const [page, setPage] = useState(1); // Filter list visibility const [filtersVisible, setFiltersVisible] = useState(false); - /* - * Callback for the "add filter" button. - * Launches a modal dialog to add a new filter - */ - function onFilterAdd(name: string, value: string) { - let filters = [...activeFilters]; - - let newFilter = tableProps.customFilters?.find((flt) => flt.name == name); - - if (newFilter) { - filters.push({ - ...newFilter, - value: value - }); - - setActiveFilters(filters); - } - } - - /* - * Callback function when a specified filter is removed from the table - */ - function onFilterRemove(filterName: string) { - let filters = activeFilters.filter((flt) => flt.name != filterName); - - setActiveFilters(filters); - } - - /* - * Callback function when all custom filters are removed from the table - */ - function onFilterClearAll() { - setActiveFilters([]); - } - // Search term const [searchTerm, setSearchTerm] = useState(''); @@ -259,7 +213,9 @@ export function InvenTreeTable({ }; // Add custom filters - activeFilters.forEach((flt) => (queryParams[flt.name] = flt.value)); + tableState.activeFilters.forEach( + (flt) => (queryParams[flt.name] = flt.value) + ); // Add custom search term if (searchTerm) { @@ -398,11 +354,12 @@ export function InvenTreeTable({ const { data, isError, isFetching, isLoading, refetch } = useQuery({ queryKey: [ - `table-${tableName}`, + tableState.tableKey, + props.params, sortStatus.columnAccessor, sortStatus.direction, page, - activeFilters, + tableState.activeFilters, searchTerm ], queryFn: fetchTableData, @@ -412,23 +369,17 @@ export function InvenTreeTable({ const [recordCount, setRecordCount] = useState(0); - /* - * Reload the table whenever the tableKey changes - * this allows us to programmatically refresh the table - */ - useEffect(() => { - refetch(); - }, [tableState?.tableKey, props.params]); - return ( <> - setFilterSelectOpen(false)} - /> + {tableProps.enableFilters && + (tableProps.customFilters?.length ?? 0) > 0 && ( + setFiltersVisible(false)} + /> + )} @@ -478,8 +429,8 @@ export function InvenTreeTable({ (tableProps.customFilters?.length ?? 0 > 0) && ( @@ -498,14 +449,6 @@ export function InvenTreeTable({ )} - {filtersVisible && ( - setFilterSelectOpen(true)} - onFilterRemove={onFilterRemove} - onFilterClearAll={onFilterClearAll} - /> - )} { return [ + { + name: 'sub_part_trackable', + label: t`Trackable Part`, + description: t`Show trackable items` + }, + { + name: 'sub_part_assembly', + label: t`Assembled Part`, + description: t`Show asssmbled items` + }, + { + name: 'available_stock', + label: t`Has Available Stock`, + description: t`Show items with available stock` + }, + { + name: 'on_order', + label: t`On Order`, + description: t`Show items on order` + }, + { + name: 'validated', + label: t`Validated`, + description: t`Show validated items` + }, + { + name: 'inherited', + label: t`Gets Inherited`, + description: t`Show inherited items` + }, + { + name: 'optional', + label: t`Optional`, + description: t`Show optional items` + }, { name: 'consumable', label: t`Consumable`, - type: 'boolean' + description: t`Show consumable items` + }, + { + name: 'has_pricing', + label: t`Has Pricing`, + description: t`Show items with pricing` } - // TODO: More BOM table filters here ]; }, [partId, params]); diff --git a/src/frontend/src/components/tables/bom/UsedInTable.tsx b/src/frontend/src/components/tables/bom/UsedInTable.tsx index 407f51df7d..439af13954 100644 --- a/src/frontend/src/components/tables/bom/UsedInTable.tsx +++ b/src/frontend/src/components/tables/bom/UsedInTable.tsx @@ -80,7 +80,28 @@ export function UsedInTable({ }, [partId]); const tableFilters: TableFilter[] = useMemo(() => { - return []; + return [ + { + name: 'inherited', + label: t`Gets Inherited`, + description: t`Show inherited items` + }, + { + name: 'optional', + label: t`Optional`, + description: t`Show optional items` + }, + { + name: 'part_active', + label: t`Active`, + description: t`Show active assemblies` + }, + { + name: 'part_trackable', + label: t`Trackable`, + description: t`Show trackable assemblies` + } + ]; }, [partId]); return ( diff --git a/src/frontend/src/components/tables/build/BuildOrderTable.tsx b/src/frontend/src/components/tables/build/BuildOrderTable.tsx index 7526421875..b10ebc685e 100644 --- a/src/frontend/src/components/tables/build/BuildOrderTable.tsx +++ b/src/frontend/src/components/tables/build/BuildOrderTable.tsx @@ -18,6 +18,7 @@ import { StatusColumn, TargetDateColumn } from '../ColumnRenderers'; +import { StatusFilterOptions, TableFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; /** @@ -101,26 +102,39 @@ function buildOrderTableColumns(): TableColumn[] { export function BuildOrderTable({ params = {} }: { params?: any }) { const tableColumns = useMemo(() => buildOrderTableColumns(), []); - const tableFilters = useMemo(() => { + const tableFilters: TableFilter[] = useMemo(() => { return [ { - // TODO: Filter by status code name: 'active', type: 'boolean', - label: t`Active` + label: t`Active`, + description: t`Show active orders` + }, + { + name: 'status', + label: t`Status`, + description: t`Filter by order status`, + choiceFunction: StatusFilterOptions(ModelType.build) }, { name: 'overdue', type: 'boolean', - label: t`Overdue` + label: t`Overdue`, + description: t`Show overdue status` }, { name: 'assigned_to_me', type: 'boolean', - label: t`Assigned to me` + label: t`Assigned to me`, + description: t`Show orders assigned to me` } // TODO: 'assigned to' filter // TODO: 'issued by' filter + // { + // name: 'has_project_code', + // title: t`Has Project Code`, + // description: t`Show orders with project code`, + // } // TODO: 'has project code' filter (see table_filters.js) // TODO: 'project code' filter (see table_filters.js) ]; diff --git a/src/frontend/src/components/tables/part/PartCategoryTable.tsx b/src/frontend/src/components/tables/part/PartCategoryTable.tsx index 3c8e277e52..8ec22999c0 100644 --- a/src/frontend/src/components/tables/part/PartCategoryTable.tsx +++ b/src/frontend/src/components/tables/part/PartCategoryTable.tsx @@ -5,8 +5,10 @@ import { useNavigate } from 'react-router-dom'; import { ApiPaths } from '../../../enums/ApiEndpoints'; import { useTable } from '../../../hooks/UseTable'; import { apiUrl } from '../../../states/ApiState'; +import { YesNoButton } from '../../items/YesNoButton'; import { TableColumn } from '../Column'; import { DescriptionColumn } from '../ColumnRenderers'; +import { TableFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; /** @@ -31,6 +33,14 @@ export function PartCategoryTable({ params = {} }: { params?: any }) { title: t`Path`, sortable: false }, + { + accessor: 'structural', + title: t`Structural`, + sortable: true, + render: (record: any) => { + return ; + } + }, { accessor: 'part_count', title: t`Parts`, @@ -39,6 +49,21 @@ export function PartCategoryTable({ params = {} }: { params?: any }) { ]; }, []); + const tableFilters: TableFilter[] = useMemo(() => { + return [ + { + name: 'cascade', + label: t`Include Subcategories`, + description: t`Include subcategories in results` + }, + { + name: 'structural', + label: t`Structural`, + description: t`Show structural categories` + } + ]; + }, []); + return ( { navigate(`/part/category/${record.pk}`); } diff --git a/src/frontend/src/components/tables/part/PartParameterTemplateTable.tsx b/src/frontend/src/components/tables/part/PartParameterTemplateTable.tsx index 11dd0aaed2..dc028c0db3 100644 --- a/src/frontend/src/components/tables/part/PartParameterTemplateTable.tsx +++ b/src/frontend/src/components/tables/part/PartParameterTemplateTable.tsx @@ -14,6 +14,7 @@ import { apiUrl } from '../../../states/ApiState'; import { useUserState } from '../../../states/UserState'; import { AddItemButton } from '../../buttons/AddItemButton'; import { TableColumn } from '../Column'; +import { TableFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; import { RowDeleteAction, RowEditAction } from '../RowActions'; @@ -22,6 +23,26 @@ export default function PartParameterTemplateTable() { const user = useUserState(); + const tableFilters: TableFilter[] = useMemo(() => { + return [ + { + name: 'checkbox', + label: t`Checkbox`, + description: t`Show checkbox templates` + }, + { + name: 'has_choices', + label: t`Has choices`, + description: t`Show templates with choices` + }, + { + name: 'has_units', + label: t`Has Units`, + description: t`Show templates with units` + } + ]; + }, []); + const tableColumns: TableColumn[] = useMemo(() => { return [ { @@ -113,6 +134,7 @@ export default function PartParameterTemplateTable() { columns={tableColumns} props={{ rowActions: rowActions, + customFilters: tableFilters, customActionGroups: tableActions }} /> diff --git a/src/frontend/src/components/tables/part/PartVariantTable.tsx b/src/frontend/src/components/tables/part/PartVariantTable.tsx index 9f768a0d9d..7477b6bc2e 100644 --- a/src/frontend/src/components/tables/part/PartVariantTable.tsx +++ b/src/frontend/src/components/tables/part/PartVariantTable.tsx @@ -1,14 +1,43 @@ +import { t } from '@lingui/macro'; +import { useMemo } from 'react'; + +import { TableFilter } from '../Filter'; import { PartListTable } from './PartTable'; /** * Display variant parts for the specified parent part */ export function PartVariantTable({ partId }: { partId: string }) { + const tableFilters: TableFilter[] = useMemo(() => { + return [ + { + name: 'active', + label: t`Active`, + description: t`Show active variants` + }, + { + name: 'template', + label: t`Template`, + description: t`Show template variants` + }, + { + name: 'virtual', + label: t`Virtual`, + description: t`Show virtual variants` + }, + { + name: 'trackable', + label: t`Trackable`, + description: t`Show trackable variants` + } + ]; + }, []); + return ( { + return [ + { + name: 'status', + label: t`Status`, + description: t`Filter by order status`, + choiceFunction: StatusFilterOptions(ModelType.purchaseorder) + }, + OutstandingFilter(), + OverdueFilter(), + AssignedToMeFilter() + // TODO: has_project_code + // TODO: project_code + ]; + }, []); // TODO: Row actions @@ -83,6 +104,7 @@ export function PurchaseOrderTable({ params }: { params?: any }) { ...params, supplier_detail: true }, + customFilters: tableFilters, onRowClick: (row: any) => { if (row.pk) { navigate(`/purchasing/purchase-order/${row.pk}`); diff --git a/src/frontend/src/components/tables/sales/ReturnOrderTable.tsx b/src/frontend/src/components/tables/sales/ReturnOrderTable.tsx index 4e18ec8ce9..2b5b05dbe3 100644 --- a/src/frontend/src/components/tables/sales/ReturnOrderTable.tsx +++ b/src/frontend/src/components/tables/sales/ReturnOrderTable.tsx @@ -16,6 +16,13 @@ import { StatusColumn, TargetDateColumn } from '../ColumnRenderers'; +import { + AssignedToMeFilter, + OutstandingFilter, + OverdueFilter, + StatusFilterOptions, + TableFilter +} from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; export function ReturnOrderTable({ params }: { params?: any }) { @@ -23,7 +30,19 @@ export function ReturnOrderTable({ params }: { params?: any }) { const navigate = useNavigate(); - // TODO: Custom filters + const tableFilters: TableFilter[] = useMemo(() => { + return [ + { + name: 'status', + label: t`Status`, + description: t`Filter by order status`, + choiceFunction: StatusFilterOptions(ModelType.returnorder) + }, + OutstandingFilter(), + OverdueFilter(), + AssignedToMeFilter() + ]; + }, []); // TODO: Row actions @@ -81,6 +100,7 @@ export function ReturnOrderTable({ params }: { params?: any }) { ...params, customer_detail: true }, + customFilters: tableFilters, onRowClick: (row: any) => { if (row.pk) { navigate(`/sales/return-order/${row.pk}/`); diff --git a/src/frontend/src/components/tables/sales/SalesOrderTable.tsx b/src/frontend/src/components/tables/sales/SalesOrderTable.tsx index ff0e489e0d..b6bd435d17 100644 --- a/src/frontend/src/components/tables/sales/SalesOrderTable.tsx +++ b/src/frontend/src/components/tables/sales/SalesOrderTable.tsx @@ -17,6 +17,13 @@ import { TargetDateColumn, TotalPriceColumn } from '../ColumnRenderers'; +import { + AssignedToMeFilter, + OutstandingFilter, + OverdueFilter, + StatusFilterOptions, + TableFilter +} from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; export function SalesOrderTable({ params }: { params?: any }) { @@ -24,7 +31,21 @@ export function SalesOrderTable({ params }: { params?: any }) { const navigate = useNavigate(); - // TODO: Custom filters + const tableFilters: TableFilter[] = useMemo(() => { + return [ + { + name: 'status', + label: t`Status`, + description: t`Filter by order status`, + choiceFunction: StatusFilterOptions(ModelType.salesorder) + }, + OutstandingFilter(), + OverdueFilter(), + AssignedToMeFilter() + // TODO: has_project_code + // TODO: project_code + ]; + }, []); // TODO: Row actions @@ -80,6 +101,7 @@ export function SalesOrderTable({ params }: { params?: any }) { ...params, customer_detail: true }, + customFilters: tableFilters, onRowClick: (row: any) => { if (row.pk) { navigate(`/sales/sales-order/${row.pk}/`); diff --git a/src/frontend/src/components/tables/stock/StockItemTable.tsx b/src/frontend/src/components/tables/stock/StockItemTable.tsx index 4b97df238d..da2fc6d1a2 100644 --- a/src/frontend/src/components/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/components/tables/stock/StockItemTable.tsx @@ -11,7 +11,7 @@ import { apiUrl } from '../../../states/ApiState'; import { Thumbnail } from '../../images/Thumbnail'; import { TableColumn } from '../Column'; import { StatusColumn } from '../ColumnRenderers'; -import { TableFilter } from '../Filter'; +import { StatusFilterOptions, TableFilter } from '../Filter'; import { TableHoverCard } from '../TableHoverCard'; import { InvenTreeTable } from './../InvenTreeTable'; @@ -243,15 +243,98 @@ function stockItemTableColumns(): TableColumn[] { function stockItemTableFilters(): TableFilter[] { return [ { - name: 'test_filter', - label: t`Test Filter`, - description: t`This is a test filter`, - type: 'choice', - choiceFunction: () => [ - { value: '1', label: 'One' }, - { value: '2', label: 'Two' }, - { value: '3', label: 'Three' } - ] + name: 'active', + label: t`Active`, + description: t`Show stock for active parts` + }, + { + name: 'status', + label: t`Status`, + description: t`Filter by stock status`, + choiceFunction: StatusFilterOptions(ModelType.stockitem) + }, + { + name: 'assembly', + label: t`Assembly`, + description: t`Show stock for assmebled parts` + }, + { + name: 'allocated', + label: t`Allocated`, + description: t`Show items which have been allocated` + }, + { + name: 'available', + label: t`Available`, + description: t`Show items which are available` + }, + { + name: 'cascade', + label: t`Include Sublocations`, + description: t`Include stock in sublocations` + }, + { + name: 'depleted', + label: t`Depleted`, + description: t`Show depleted stock items` + }, + { + name: 'in_stock', + label: t`In Stock`, + description: t`Show items which are in stock` + }, + { + name: 'is_building', + label: t`In Production`, + description: t`Show items which are in production` + }, + { + name: 'include_variants', + label: t`Include Variants`, + description: t`Include stock items for variant parts` + }, + { + name: 'installed', + label: t`Installed`, + description: t`Show stock items which are installed in other items` + }, + { + name: 'sent_to_customer', + label: t`Sent to Customer`, + description: t`Show items which have been sent to a customer` + }, + { + name: 'serialized', + label: t`Is Serialized`, + description: t`Show items which have a serial number` + }, + // TODO: serial + // TODO: serial_gte + // TODO: serial_lte + { + name: 'has_batch', + label: t`Has Batch Code`, + description: t`Show items which have a batch code` + }, + // TODO: batch + { + name: 'tracked', + label: t`Tracked`, + description: t`Show tracked items` + }, + { + name: 'has_purchase_price', + label: t`Has Purchase Price`, + description: t`Show items which have a purchase price` + }, + // TODO: Expired + // TODO: stale + // TODO: expiry_date_lte + // TODO: expiry_date_gte + { + name: 'external', + label: t`External Location`, + description: t`Show items in an external location` } ]; } diff --git a/src/frontend/src/components/tables/stock/StockLocationTable.tsx b/src/frontend/src/components/tables/stock/StockLocationTable.tsx index 5c1e969e7d..66513cbcbc 100644 --- a/src/frontend/src/components/tables/stock/StockLocationTable.tsx +++ b/src/frontend/src/components/tables/stock/StockLocationTable.tsx @@ -8,6 +8,7 @@ import { apiUrl } from '../../../states/ApiState'; import { YesNoButton } from '../../items/YesNoButton'; import { TableColumn } from '../Column'; import { DescriptionColumn } from '../ColumnRenderers'; +import { TableFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; /** @@ -18,6 +19,31 @@ export function StockLocationTable({ params = {} }: { params?: any }) { const navigate = useNavigate(); + const tableFilters: TableFilter[] = useMemo(() => { + return [ + { + name: 'cascade', + label: t`Include Sublocations`, + description: t`Include sublocations in results` + }, + { + name: 'structural', + label: t`Structural`, + description: t`Show structural locations` + }, + { + name: 'external', + label: t`External`, + description: t`Show external locations` + }, + { + name: 'has_location_type', + label: t`Has location type` + } + // TODO: location_type + ]; + }, []); + const tableColumns: TableColumn[] = useMemo(() => { return [ { @@ -69,6 +95,7 @@ export function StockLocationTable({ params = {} }: { params?: any }) { props={{ enableDownload: true, params: params, + customFilters: tableFilters, onRowClick: (record) => { navigate(`/stock/location/${record.pk}`); } diff --git a/src/frontend/src/hooks/UseTable.tsx b/src/frontend/src/hooks/UseTable.tsx index 686fe74749..b4fb340ef6 100644 --- a/src/frontend/src/hooks/UseTable.tsx +++ b/src/frontend/src/hooks/UseTable.tsx @@ -1,17 +1,27 @@ -import { randomId } from '@mantine/hooks'; +import { randomId, useLocalStorage } from '@mantine/hooks'; import { useCallback, useState } from 'react'; +import { TableFilter } from '../components/tables/Filter'; + +/* + * Type definition for representing the state of a table: + * + * tableKey: A unique key for the table. When this key changes, the table will be refreshed. + * refreshTable: A callback function to externally refresh the table. + * activeFilters: An array of active filters (saved to local storage) + */ export type TableState = { tableKey: string; + activeFilters: TableFilter[]; + setActiveFilters: (filters: TableFilter[]) => void; + clearActiveFilters: () => void; refreshTable: () => void; }; /** * A custom hook for managing the state of an component. * - * tableKey: A unique key for the table. When this key changes, the table will be refreshed. - * refreshTable: A callback function to externally refresh the table. - * + * Refer to the TableState type definition for more information. */ export function useTable(tableName: string): TableState { @@ -27,8 +37,23 @@ export function useTable(tableName: string): TableState { setTableKey(generateTableName()); }, []); + // Array of active filters (saved to local storage) + const [activeFilters, setActiveFilters] = useLocalStorage({ + key: `inventree-table-filters-${tableName}`, + defaultValue: [], + getInitialValueInEffect: false + }); + + // Callback to clear all active filters from the table + const clearActiveFilters = useCallback(() => { + setActiveFilters([]); + }, []); + return { tableKey, + activeFilters, + setActiveFilters, + clearActiveFilters, refreshTable }; }