mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
[WIP] Mantine datatables (#5218)
* Create dependency-review.yml * Create scan.yml * Create sonar-project.properties * add option to use sections and refactro * translate error messages * remove unneeded vars * move function code * move data inside * add global section * add plugin section * use translated section titles * add translation strings * rename scan action * add user settings * use ordered data * fix settings url * use debounced value for strings (not choices!) * rename contex to context * move i18n provider up * move theme options into seperate context/ component * renmae statrtup vars * move translations out * reactivate sentry * move i18n provider to seperate context * move langauge state completly out of App * use theme out * move theme context * move LanguageContext * move function into state * make sentry optional for now * add key to accordion * init langauge context on top * remove unneeded css files * move errorpage to tsx * add translation for error page * Add error to title * add typecast for error * move type definition out * remove todo -> type was already added * upgrade deps * add bootstrap * remove @mantine/core * readd core * switch to bootstrap * simplify import * Add SPA views for react #2789 * split up frontend urls * Add settings for frontend url loading * add new UI scaffold * remove tracking insert * add platform app * ensure static indexes work too * add lingui * add lingui config * add mgmt tasks * add base locales * settings for frontend dev * fix typo * update deps * add pre-commit * add eslint * add testing scaffold * fix paths * remove error - tests trip correctly * merge workflow * cleanup samples * use name inline with other tests * Add real worl frontend tests * setup env * tun migrations first * optimize setup time * setup demo dataset * optimize run setup * add test for class ui * rename * fix typo * and another typo * do install * run migrations first * fix name * cleanup * use other credentials * use other credentials * fix qc * move envs to qc * remove create_site * reduce testing env * fix test * fix test call * allaccess user * add ui plattform check * add better check * remove unneeded env * enable debug * reduce wait time * also build frontend on static * add sekeleton * fix various issues * add locales * clean output before building * cleanup dir * remove bootstrap * clean up deps * fix settings panel * remove assets * move logo * split out router * split up chunks * fix zustand import syntax * bundl * update pre-render * use vendor splitting * maximes space usage * enlarge breakpoints * remove wired color changes * cleanup tabs * fix error * update auth functions * default to mail login * add placeholder marking * Add text to placeholder * readd codespell * add another test * add sort plugin * add sort plugin * sort imports * fix order * Add mega menu * run pre-commit fixes * add node min version * Docker container (#129) * Fix allocation check for completing build order (#5199) - Allocation check only applies to untracked line items * docker dev Install required node packages to docker development image * add import order settings * cleanout built ui * Add "parttable" component * Add task to serve front-end code dev * remove default arg from build * remove eslint * optimize svg * Adds generic function for rendering a table with server-side data * Implement pagination and sorting * Add more example columns * Enable selection of table data rows * add build step for plattform UI * fix install command * optional parameters * Add simple stock table * Add optional parameter for default sort * Change "no records" text based on query result * Translate * Start writing some helper functions * Add thumbnail component * Fill out more columns for stock table * Add simple skeleton for table search input * Adjust default table properties * Change loader variant * Drop-down for selecting table columns * Add search text callback * use alpine commands * do not use cache when creating image * More updates for inventree table - Fix search text entry - Add "refresh" button - Adjust variable names * Search input improvements - Add button to clear search input * Enable mantine notification system * Add "not yet implemented" notification message * Add download action button * Adds ButtonMenu component - Button which expands to show other actions - Add hooks for adding action menus to tables * Add basic build order list table * Add custom filters button for table * Allow columns to be toggled * Column visibility saved across table loads * Adds display for table filters - Define interface for table filter definition - Add component for displaying filters - Cleanup for part table * Cleanup * Define type for controlling column data * Allow custom ordering term for table column - Replaces "sortName" concept from bootstrap-table * Improve build order table - Fancy progress bars * Reimplement invoke task to serve frontend files via yarn * Update package files with mantine * Implement callback when record selection is changed * Adds generic "actionbutton" component * Remove duplicate form components * Remove tracked files in web/static * Remove a bunch of files - tracked in from the wrong original branch * More page fixes * Revert changes to reqiurements-dev.txt * Spelling fix * Component updates * Cleanup components * Cleanup * Use spread operator * Add some new dummy pages for testing * Cleanup / simplify stockitem table * Cleanup for part table * Cleanup build order table * Cleanup column toggle function * Remove hard-coded URL * Format updates * Update deps * npm required for inventree-python checks * Fix search input - Better debouncing - Cleaner code * Update package files * vite polling fixes * Implementation for download button - Dropdown menu with file format options * Implement callback for download of table data * Better state management for hidden columns * Implement state framework for active custom filters * Silence some errors * Revert change to vite config * Implement collapsible filter list group - Save active filters to local storage - Add some example filters to the part table - Add FilterBadge component * Fix page names * Simplify search input - useDebouncedValue * linting * Refactor * Remove debug msg * Simplify search state * Refactor function for constructing API query * Add tooltip * Update icons * Add modal for selecting filter options * Add more table filters for part table * render custom item for filter select * Complete implementation for selectable filters - Allow choices to be specified as attribute - Allow choices to be specified as function - Handle state management for filter choice form * Tweak badge * Cleanup top-level yarn and npm files * Less roundy --------- Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
parent
941451203a
commit
339eae53a1
1
.github/workflows/qc_checks.yaml
vendored
1
.github/workflows/qc_checks.yaml
vendored
@ -152,6 +152,7 @@ jobs:
|
||||
apt-dependency: gettext poppler-utils
|
||||
dev-install: true
|
||||
update: true
|
||||
npm: true
|
||||
- name: Download Python Code For `${{ env.wrapper_name }}`
|
||||
run: git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }}
|
||||
./${{ env.wrapper_name }}
|
||||
|
2534
package-lock.json
generated
2534
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,12 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@mantine/core": "^6.0.17",
|
||||
"@mantine/dropzone": "^6.0.17",
|
||||
"@mantine/hooks": "^6.0.17",
|
||||
"@mantine/notifications": "^6.0.17",
|
||||
"eslint": "^8.41.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"mantine-datatable": "^2.8.5"
|
||||
"eslint-config-google": "^0.14.0"
|
||||
}
|
||||
}
|
||||
|
@ -30,6 +30,7 @@
|
||||
"axios": "^1.4.0",
|
||||
"dayjs": "^1.11.9",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"mantine-datatable": "^2.9.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.14.2",
|
||||
|
35
src/frontend/src/components/items/ActionButton.tsx
Normal file
35
src/frontend/src/components/items/ActionButton.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { ActionIcon, Tooltip } from '@mantine/core';
|
||||
|
||||
/**
|
||||
* Construct a simple action button with consistent styling
|
||||
*/
|
||||
export function ActionButton({
|
||||
icon,
|
||||
color = 'black',
|
||||
tooltip = '',
|
||||
disabled = false,
|
||||
size = 18,
|
||||
onClick
|
||||
}: {
|
||||
icon: any;
|
||||
color?: string;
|
||||
tooltip?: string;
|
||||
variant?: string;
|
||||
size?: number;
|
||||
disabled?: boolean;
|
||||
onClick?: any;
|
||||
}) {
|
||||
return (
|
||||
<ActionIcon
|
||||
disabled={disabled}
|
||||
radius="xs"
|
||||
color={color}
|
||||
size={size}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Tooltip disabled={!tooltip} label={tooltip} position="left">
|
||||
{icon}
|
||||
</Tooltip>
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
36
src/frontend/src/components/items/ButtonMenu.tsx
Normal file
36
src/frontend/src/components/items/ButtonMenu.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { ActionIcon, Menu, Tooltip } from '@mantine/core';
|
||||
import { Component } from 'react';
|
||||
|
||||
/**
|
||||
* A ButtonMenu is a button that opens a menu when clicked.
|
||||
* It features a number of actions, which can be selected by the user.
|
||||
*/
|
||||
export function ButtonMenu({
|
||||
icon,
|
||||
actions,
|
||||
tooltip = '',
|
||||
label = ''
|
||||
}: {
|
||||
icon: any;
|
||||
actions: any[];
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
let idx = 0;
|
||||
|
||||
return (
|
||||
<Menu shadow="xs">
|
||||
<Menu.Target>
|
||||
<ActionIcon>
|
||||
<Tooltip label={tooltip}>{icon}</Tooltip>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{label && <Menu.Label>{label}</Menu.Label>}
|
||||
{actions.map((action) => (
|
||||
<Menu.Item key={idx++}>{action}</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
57
src/frontend/src/components/items/Thumbnail.tsx
Normal file
57
src/frontend/src/components/items/Thumbnail.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Image } from '@mantine/core';
|
||||
import { Group } from '@mantine/core';
|
||||
import { Text } from '@mantine/core';
|
||||
|
||||
export function Thumbnail({
|
||||
src,
|
||||
alt = t`Thumbnail`,
|
||||
size = 24
|
||||
}: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
// TODO: Use api to determine the correct URL
|
||||
let url = 'http://localhost:8000' + src;
|
||||
|
||||
// TODO: Use HoverCard to display a larger version of the image
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={url}
|
||||
alt={alt}
|
||||
width={size}
|
||||
fit="contain"
|
||||
radius="xs"
|
||||
withPlaceholder
|
||||
imageProps={{
|
||||
style: {
|
||||
maxHeight: size
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThumbnailHoverCard({
|
||||
src,
|
||||
text,
|
||||
link = '',
|
||||
alt = t`Thumbnail`,
|
||||
size = 24
|
||||
}: {
|
||||
src: string;
|
||||
text: string;
|
||||
link?: string;
|
||||
alt?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
// TODO: Handle link
|
||||
return (
|
||||
<Group position="left" spacing={10}>
|
||||
<Thumbnail src={src} alt={alt} size={size} />
|
||||
<Text>{text}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
15
src/frontend/src/components/tables/Column.tsx
Normal file
15
src/frontend/src/components/tables/Column.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Interface for the table column definition
|
||||
*/
|
||||
export type TableColumn = {
|
||||
accessor: string; // The key in the record to access
|
||||
ordering?: string; // The key in the record to sort by (defaults to accessor)
|
||||
title: string; // The title of the column
|
||||
sortable?: boolean; // Whether the column is sortable
|
||||
switchable?: boolean; // Whether the column is switchable
|
||||
hidden?: boolean; // Whether the column is hidden
|
||||
render?: (record: any) => any; // A custom render function
|
||||
filter?: any; // A custom filter function
|
||||
filtering?: boolean; // Whether the column is filterable
|
||||
width?: number; // The width of the column
|
||||
};
|
40
src/frontend/src/components/tables/ColumnSelect.tsx
Normal file
40
src/frontend/src/components/tables/ColumnSelect.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Checkbox, Menu, Tooltip } from '@mantine/core';
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import { IconAdjustments } from '@tabler/icons-react';
|
||||
|
||||
export function TableColumnSelect({
|
||||
columns,
|
||||
onToggleColumn
|
||||
}: {
|
||||
columns: any[];
|
||||
onToggleColumn: (columnName: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Menu shadow="xs">
|
||||
<Menu.Target>
|
||||
<ActionIcon>
|
||||
<Tooltip label={t`Select Columns`}>
|
||||
<IconAdjustments />
|
||||
</Tooltip>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t`Select Columns`}</Menu.Label>
|
||||
{columns
|
||||
.filter((col) => col.switchable)
|
||||
.map((col) => (
|
||||
<Menu.Item key={col.accessor}>
|
||||
<Checkbox
|
||||
checked={!col.hidden}
|
||||
label={col.title || col.accessor}
|
||||
onChange={(event) => onToggleColumn(col.accessor)}
|
||||
radius="sm"
|
||||
/>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
45
src/frontend/src/components/tables/DownloadAction.tsx
Normal file
45
src/frontend/src/components/tables/DownloadAction.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { ActionIcon, Divider, Group, Menu, Select } from '@mantine/core';
|
||||
import { Tooltip } from '@mantine/core';
|
||||
import { Button, Modal, Stack } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconDownload } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function DownloadAction({
|
||||
downloadCallback
|
||||
}: {
|
||||
downloadCallback: (fileFormat: string) => void;
|
||||
}) {
|
||||
const formatOptions = [
|
||||
{ value: 'csv', label: t`CSV` },
|
||||
{ value: 'tsv', label: t`TSV` },
|
||||
{ value: 'xlsx', label: t`Excel` }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon>
|
||||
<Tooltip label={t`Download selected data`}>
|
||||
<IconDownload />
|
||||
</Tooltip>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{formatOptions.map((format) => (
|
||||
<Menu.Item
|
||||
key={format.value}
|
||||
onClick={() => {
|
||||
downloadCallback(format.value);
|
||||
}}
|
||||
>
|
||||
{format.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
21
src/frontend/src/components/tables/Filter.tsx
Normal file
21
src/frontend/src/components/tables/Filter.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Interface for the table filter choice
|
||||
*/
|
||||
export type TableFilterChoice = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Interface for the table filter,
|
||||
*/
|
||||
export type TableFilter = {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
choices?: TableFilterChoice[];
|
||||
choiceFunction?: () => TableFilterChoice[];
|
||||
defaultValue?: any;
|
||||
value?: any;
|
||||
};
|
50
src/frontend/src/components/tables/FilterBadge.tsx
Normal file
50
src/frontend/src/components/tables/FilterBadge.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
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 (
|
||||
<Badge
|
||||
size="lg"
|
||||
radius="lg"
|
||||
variant="outline"
|
||||
color="gray"
|
||||
styles={(theme) => ({
|
||||
root: {
|
||||
paddingRight: '4px'
|
||||
},
|
||||
inner: {
|
||||
textTransform: 'none'
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Group spacing={1}>
|
||||
<Text>{filterDescription()}</Text>
|
||||
<Tooltip label={t`Remove filter`}>
|
||||
<CloseButton color="red" onClick={() => onFilterRemove()} />
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Badge>
|
||||
);
|
||||
}
|
58
src/frontend/src/components/tables/FilterGroup.tsx
Normal file
58
src/frontend/src/components/tables/FilterGroup.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
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 (
|
||||
<Group position="right" spacing={5}>
|
||||
{activeFilters.length == 0 && (
|
||||
<Text italic={true} size="sm">{t`Add table filter`}</Text>
|
||||
)}
|
||||
{activeFilters.map((f) => (
|
||||
<FilterBadge
|
||||
key={f.name}
|
||||
filter={f}
|
||||
onFilterRemove={() => onFilterRemove(f.name)}
|
||||
/>
|
||||
))}
|
||||
{activeFilters.length && (
|
||||
<ActionIcon
|
||||
radius="sm"
|
||||
variant="outline"
|
||||
onClick={() => onFilterClearAll()}
|
||||
>
|
||||
<Tooltip label={t`Clear all filters`}>
|
||||
<IconFilterMinus color="red" />
|
||||
</Tooltip>
|
||||
</ActionIcon>
|
||||
)}
|
||||
{
|
||||
<ActionIcon radius="sm" variant="outline" onClick={() => onFilterAdd()}>
|
||||
<Tooltip label={t`Add filter`}>
|
||||
<IconFilterPlus color="green" />
|
||||
</Tooltip>
|
||||
</ActionIcon>
|
||||
}
|
||||
</Group>
|
||||
);
|
||||
}
|
178
src/frontend/src/components/tables/FilterSelectModal.tsx
Normal file
178
src/frontend/src/components/tables/FilterSelectModal.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Modal, Space } 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<HTMLDivElement, FilterProps>(
|
||||
({ name, label, description, ...others }, ref) => (
|
||||
<div ref={ref} {...others}>
|
||||
<Text size="sm">{label}</Text>
|
||||
<Text size="xs">{description}</Text>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* 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<string | null>(null);
|
||||
|
||||
// Internal state variable for the selected filter value
|
||||
let [value, setValue] = useState<string | null>(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 (
|
||||
<Modal title={t`Add Table Filter`} opened={opened} onClose={closeModal}>
|
||||
<Stack>
|
||||
<Text>{t`Select from the available filters`}</Text>
|
||||
<Select
|
||||
data={filterOptions}
|
||||
itemComponent={FilterSelectItem}
|
||||
label={t`Filter`}
|
||||
placeholder={t`Select filter`}
|
||||
searchable={true}
|
||||
onChange={(value) => setSelectedFilter(value)}
|
||||
withinPortal={true}
|
||||
maxDropdownHeight={400}
|
||||
/>
|
||||
<Select
|
||||
data={valueOptions}
|
||||
disabled={valueOptions.length == 0}
|
||||
label={t`Value`}
|
||||
placeholder={t`Select filter value`}
|
||||
onChange={(value) => setValue(value)}
|
||||
withinPortal={true}
|
||||
maxDropdownHeight={400}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Button color="red" onClick={closeModal}>{t`Cancel`}</Button>
|
||||
<Button
|
||||
color="green"
|
||||
onClick={createFilter}
|
||||
disabled={!(selectedFilter && value)}
|
||||
>
|
||||
{t`Add Filter`}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
468
src/frontend/src/components/tables/InvenTreeTable.tsx
Normal file
468
src/frontend/src/components/tables/InvenTreeTable.tsx
Normal file
@ -0,0 +1,468 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { ActionIcon, Indicator, Space, Stack, Tooltip } from '@mantine/core';
|
||||
import { Group } from '@mantine/core';
|
||||
import { IconFilter, IconRefresh } from '@tabler/icons-react';
|
||||
import { IconBarcode, IconPrinter } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ButtonMenu } from '../items/ButtonMenu';
|
||||
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 { TableSearchInput } from './Search';
|
||||
|
||||
/*
|
||||
* Load list of hidden columns from local storage.
|
||||
* Returns a list of column names which are "hidden" for the current table
|
||||
*/
|
||||
function loadHiddenColumns(tableKey: string) {
|
||||
return JSON.parse(
|
||||
localStorage.getItem(`inventree-hidden-table-columns-${tableKey}`) || '[]'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write list of hidden columns to local storage
|
||||
* @param tableKey : string - unique key for the table
|
||||
* @param columns : string[] - list of column names
|
||||
*/
|
||||
function saveHiddenColumns(tableKey: string, columns: any[]) {
|
||||
localStorage.setItem(
|
||||
`inventree-hidden-table-columns-${tableKey}`,
|
||||
JSON.stringify(columns)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the list of active filters from local storage
|
||||
* @param tableKey : string - unique key for the table
|
||||
* @param filterList : TableFilter[] - list of available filters
|
||||
* @returns a map of active filters for the current table, {name: value}
|
||||
*/
|
||||
function loadActiveFilters(tableKey: string, filterList: TableFilter[]) {
|
||||
let active = JSON.parse(
|
||||
localStorage.getItem(`inventree-active-table-filters-${tableKey}`) || '{}'
|
||||
);
|
||||
|
||||
// We expect that the active filter list is a map of {name: value}
|
||||
// Return *only* those filters which are in the filter list
|
||||
let x = filterList
|
||||
.filter((f) => f.name in active)
|
||||
.map((f) => ({
|
||||
...f,
|
||||
value: active[f.name]
|
||||
}));
|
||||
|
||||
return x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the list of active filters to local storage
|
||||
* @param tableKey : string - unique key for the table
|
||||
* @param filters : any - map of active filters, {name: value}
|
||||
*/
|
||||
function saveActiveFilters(tableKey: string, filters: TableFilter[]) {
|
||||
let active = Object.fromEntries(filters.map((flt) => [flt.name, flt.value]));
|
||||
|
||||
localStorage.setItem(
|
||||
`inventree-active-table-filters-${tableKey}`,
|
||||
JSON.stringify(active)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Table Component which extends DataTable with custom InvenTree functionality
|
||||
*/
|
||||
export function InvenTreeTable({
|
||||
url,
|
||||
params,
|
||||
columns,
|
||||
enableDownload = false,
|
||||
enableFilters = true,
|
||||
enablePagination = true,
|
||||
enableRefresh = true,
|
||||
enableSearch = true,
|
||||
enableSelection = false,
|
||||
pageSize = 25,
|
||||
tableKey = '',
|
||||
defaultSortColumn = '',
|
||||
noRecordsText = t`No records found`,
|
||||
printingActions = [],
|
||||
barcodeActions = [],
|
||||
customActionGroups = [],
|
||||
customFilters = []
|
||||
}: {
|
||||
url: string;
|
||||
params: any;
|
||||
columns: TableColumn[];
|
||||
tableKey: string;
|
||||
defaultSortColumn?: string;
|
||||
noRecordsText?: string;
|
||||
enableDownload?: boolean;
|
||||
enableFilters?: boolean;
|
||||
enableSelection?: boolean;
|
||||
enableSearch?: boolean;
|
||||
enablePagination?: boolean;
|
||||
enableRefresh?: boolean;
|
||||
pageSize?: number;
|
||||
printingActions?: any[];
|
||||
barcodeActions?: any[];
|
||||
customActionGroups?: any[];
|
||||
customFilters?: TableFilter[];
|
||||
}) {
|
||||
// Data columns
|
||||
const [dataColumns, setDataColumns] = useState<any[]>(columns);
|
||||
|
||||
// Check if any columns are switchable (can be hidden)
|
||||
const hasSwitchableColumns = columns.some((col: any) => col.switchable);
|
||||
|
||||
// Manage state for switchable columns (initially load from local storage)
|
||||
let [hiddenColumns, setHiddenColumns] = useState(() =>
|
||||
loadHiddenColumns(tableKey)
|
||||
);
|
||||
|
||||
// Update column visibility when hiddenColumns change
|
||||
useEffect(() => {
|
||||
setDataColumns(
|
||||
dataColumns.map((col) => {
|
||||
return {
|
||||
...col,
|
||||
hidden: hiddenColumns.includes(col.accessor)
|
||||
};
|
||||
})
|
||||
);
|
||||
}, [hiddenColumns]);
|
||||
|
||||
// Callback when column visibility is toggled
|
||||
function toggleColumn(columnName: string) {
|
||||
let newColumns = [...dataColumns];
|
||||
|
||||
let colIdx = newColumns.findIndex((col) => col.accessor == columnName);
|
||||
|
||||
if (colIdx >= 0 && colIdx < newColumns.length) {
|
||||
newColumns[colIdx].hidden = !newColumns[colIdx].hidden;
|
||||
}
|
||||
|
||||
let hiddenColumnNames = newColumns
|
||||
.filter((col) => col.hidden)
|
||||
.map((col) => col.accessor);
|
||||
|
||||
// Save list of hidden columns to local storage
|
||||
saveHiddenColumns(tableKey, hiddenColumnNames);
|
||||
|
||||
// Refresh state
|
||||
setHiddenColumns(loadHiddenColumns(tableKey));
|
||||
}
|
||||
|
||||
// Check if custom filtering is enabled for this table
|
||||
const hasCustomFilters = enableFilters && customFilters.length > 0;
|
||||
|
||||
// Filter selection open state
|
||||
const [filterSelectOpen, setFilterSelectOpen] = useState<boolean>(false);
|
||||
|
||||
// Pagination
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// Filter list visibility
|
||||
const [filtersVisible, setFiltersVisible] = useState<boolean>(false);
|
||||
|
||||
// Map of currently active filters, {name: value}
|
||||
const [activeFilters, setActiveFilters] = useState(() =>
|
||||
loadActiveFilters(tableKey, customFilters)
|
||||
);
|
||||
|
||||
/*
|
||||
* 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 = customFilters.find((flt) => flt.name == name);
|
||||
|
||||
if (newFilter) {
|
||||
filters.push({
|
||||
...newFilter,
|
||||
value: value
|
||||
});
|
||||
|
||||
saveActiveFilters(tableKey, filters);
|
||||
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);
|
||||
saveActiveFilters(tableKey, filters);
|
||||
setActiveFilters(filters);
|
||||
}
|
||||
|
||||
/*
|
||||
* Callback function when all custom filters are removed from the table
|
||||
*/
|
||||
function onFilterClearAll() {
|
||||
saveActiveFilters(tableKey, []);
|
||||
setActiveFilters([]);
|
||||
}
|
||||
|
||||
// Search term
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
|
||||
/*
|
||||
* Construct query filters for the current table
|
||||
*/
|
||||
function getTableFilters(paginate: boolean = false) {
|
||||
let queryParams = { ...params };
|
||||
|
||||
// Add custom filters
|
||||
activeFilters.forEach((flt) => (queryParams[flt.name] = flt.value));
|
||||
|
||||
// Add custom search term
|
||||
if (searchTerm) {
|
||||
queryParams.search = searchTerm;
|
||||
}
|
||||
|
||||
// Pagination
|
||||
if (enablePagination && paginate) {
|
||||
queryParams.limit = pageSize;
|
||||
queryParams.offset = (page - 1) * pageSize;
|
||||
}
|
||||
|
||||
// Ordering
|
||||
let ordering = getOrderingTerm();
|
||||
|
||||
if (ordering) {
|
||||
if (sortStatus.direction == 'asc') {
|
||||
queryParams.ordering = ordering;
|
||||
} else {
|
||||
queryParams.ordering = `-${ordering}`;
|
||||
}
|
||||
}
|
||||
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
// Data download callback
|
||||
function downloadData(fileFormat: string) {
|
||||
// Download entire dataset (no pagination)
|
||||
let queryParams = getTableFilters(false);
|
||||
|
||||
// Specify file format
|
||||
queryParams.export = fileFormat;
|
||||
|
||||
let downloadUrl = api.getUri({
|
||||
url: url,
|
||||
params: queryParams
|
||||
});
|
||||
|
||||
// Download file in a new window (to force download)
|
||||
window.open(downloadUrl, '_blank');
|
||||
}
|
||||
|
||||
// Data Sorting
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||
columnAccessor: defaultSortColumn,
|
||||
direction: 'asc'
|
||||
});
|
||||
|
||||
// Return the ordering parameter
|
||||
function getOrderingTerm() {
|
||||
let key = sortStatus.columnAccessor;
|
||||
|
||||
// Sorting column not specified
|
||||
if (key == '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Find matching column:
|
||||
// If column provides custom ordering term, use that
|
||||
let column = dataColumns.find((col) => col.accessor == key);
|
||||
return column?.ordering || key;
|
||||
}
|
||||
|
||||
// Missing records text (based on server response)
|
||||
const [missingRecordsText, setMissingRecordsText] =
|
||||
useState<string>(noRecordsText);
|
||||
|
||||
// Data selection
|
||||
const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
|
||||
|
||||
function onSelectedRecordsChange(records: any[]) {
|
||||
setSelectedRecords(records);
|
||||
}
|
||||
|
||||
const handleSortStatusChange = (status: DataTableSortStatus) => {
|
||||
setPage(1);
|
||||
setSortStatus(status);
|
||||
};
|
||||
|
||||
// Function to perform API query to fetch required data
|
||||
const fetchTableData = async () => {
|
||||
let queryParams = getTableFilters(true);
|
||||
|
||||
return api
|
||||
.get(`${url}`, {
|
||||
params: queryParams,
|
||||
timeout: 30 * 1000
|
||||
})
|
||||
.then(function (response) {
|
||||
switch (response.status) {
|
||||
case 200:
|
||||
setMissingRecordsText(noRecordsText);
|
||||
return response.data;
|
||||
case 400:
|
||||
setMissingRecordsText(t`Bad request`);
|
||||
break;
|
||||
case 401:
|
||||
setMissingRecordsText(t`Unauthorized`);
|
||||
break;
|
||||
case 403:
|
||||
setMissingRecordsText(t`Forbidden`);
|
||||
break;
|
||||
case 404:
|
||||
setMissingRecordsText(t`Not found`);
|
||||
break;
|
||||
default:
|
||||
setMissingRecordsText(
|
||||
t`Unknown error` + ': ' + response.statusText
|
||||
); // TODO: Translate
|
||||
break;
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
.catch(function (error) {
|
||||
setMissingRecordsText(t`Error` + ': ' + error.message);
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
const { data, isError, isFetching, isLoading, refetch } = useQuery(
|
||||
[
|
||||
`table-${tableKey}`,
|
||||
sortStatus.columnAccessor,
|
||||
sortStatus.direction,
|
||||
page,
|
||||
activeFilters,
|
||||
searchTerm
|
||||
],
|
||||
fetchTableData,
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: 'always'
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterSelectModal
|
||||
availableFilters={customFilters}
|
||||
activeFilters={activeFilters}
|
||||
opened={filterSelectOpen}
|
||||
onCreateFilter={onFilterAdd}
|
||||
onClose={() => setFilterSelectOpen(false)}
|
||||
/>
|
||||
<Stack>
|
||||
<Group position="apart">
|
||||
<Group position="left" spacing={5}>
|
||||
{customActionGroups.map((group: any, idx: number) => group)}
|
||||
{barcodeActions.length > 0 && (
|
||||
<ButtonMenu
|
||||
icon={<IconBarcode />}
|
||||
label={t`Barcode actions`}
|
||||
tooltip={t`Barcode actions`}
|
||||
actions={barcodeActions}
|
||||
/>
|
||||
)}
|
||||
{printingActions.length > 0 && (
|
||||
<ButtonMenu
|
||||
icon={<IconPrinter />}
|
||||
label={t`Print actions`}
|
||||
tooltip={t`Print actions`}
|
||||
actions={printingActions}
|
||||
/>
|
||||
)}
|
||||
{enableDownload && (
|
||||
<DownloadAction downloadCallback={downloadData} />
|
||||
)}
|
||||
</Group>
|
||||
<Space />
|
||||
<Group position="right" spacing={5}>
|
||||
{enableSearch && (
|
||||
<TableSearchInput
|
||||
searchCallback={(term: string) => setSearchTerm(term)}
|
||||
/>
|
||||
)}
|
||||
{enableRefresh && (
|
||||
<ActionIcon>
|
||||
<Tooltip label={t`Refresh data`}>
|
||||
<IconRefresh onClick={() => refetch()} />
|
||||
</Tooltip>
|
||||
</ActionIcon>
|
||||
)}
|
||||
{hasSwitchableColumns && (
|
||||
<TableColumnSelect
|
||||
columns={dataColumns}
|
||||
onToggleColumn={toggleColumn}
|
||||
/>
|
||||
)}
|
||||
{hasCustomFilters && (
|
||||
<Indicator
|
||||
size="xs"
|
||||
label={activeFilters.length}
|
||||
disabled={activeFilters.length == 0}
|
||||
>
|
||||
<ActionIcon>
|
||||
<Tooltip label={t`Table filters`}>
|
||||
<IconFilter
|
||||
onClick={() => setFiltersVisible(!filtersVisible)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
{filtersVisible && (
|
||||
<FilterGroup
|
||||
activeFilters={activeFilters}
|
||||
onFilterAdd={() => setFilterSelectOpen(true)}
|
||||
onFilterRemove={onFilterRemove}
|
||||
onFilterClearAll={onFilterClearAll}
|
||||
/>
|
||||
)}
|
||||
<DataTable
|
||||
withBorder
|
||||
striped
|
||||
highlightOnHover
|
||||
loaderVariant="dots"
|
||||
idAccessor={'pk'}
|
||||
minHeight={200}
|
||||
totalRecords={data?.count ?? data?.length ?? 0}
|
||||
recordsPerPage={pageSize}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={handleSortStatusChange}
|
||||
selectedRecords={enableSelection ? selectedRecords : undefined}
|
||||
onSelectedRecordsChange={
|
||||
enableSelection ? onSelectedRecordsChange : undefined
|
||||
}
|
||||
fetching={isFetching}
|
||||
noRecordsText={missingRecordsText}
|
||||
records={data?.results ?? data ?? []}
|
||||
columns={dataColumns}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
31
src/frontend/src/components/tables/Search.tsx
Normal file
31
src/frontend/src/components/tables/Search.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { CloseButton, TextInput } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function TableSearchInput({
|
||||
searchCallback
|
||||
}: {
|
||||
searchCallback: (searchTerm: string) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState<string>('');
|
||||
const [searchText] = useDebouncedValue(value, 500);
|
||||
|
||||
useEffect(() => {
|
||||
searchCallback(searchText);
|
||||
}, [searchText]);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
value={value}
|
||||
icon={<IconSearch />}
|
||||
placeholder="Search"
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
rightSection={
|
||||
value.length > 0 ? (
|
||||
<CloseButton size="xs" onClick={(event) => setValue('')} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
135
src/frontend/src/components/tables/build/BuildOrderTable.tsx
Normal file
135
src/frontend/src/components/tables/build/BuildOrderTable.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Progress } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { ThumbnailHoverCard } from '../../items/Thumbnail';
|
||||
import { TableColumn } from '../Column';
|
||||
import { TableFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
/**
|
||||
* Construct a list of columns for the build order table
|
||||
*/
|
||||
function buildOrderTableColumns(): TableColumn[] {
|
||||
return [
|
||||
{
|
||||
accessor: 'reference',
|
||||
sortable: true,
|
||||
title: t`Reference`
|
||||
// TODO: Link to the build order detail page
|
||||
},
|
||||
{
|
||||
accessor: 'part',
|
||||
sortable: true,
|
||||
title: t`Part`,
|
||||
render: (record: any) => {
|
||||
let part = record.part_detail;
|
||||
return (
|
||||
part && (
|
||||
<ThumbnailHoverCard
|
||||
src={part.thumbnail || part.image}
|
||||
text={part.full_name}
|
||||
link=""
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'title',
|
||||
sortable: false,
|
||||
title: t`Description`,
|
||||
switchable: true
|
||||
},
|
||||
{
|
||||
accessor: 'project_code',
|
||||
title: t`Project Code`,
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
hidden: true
|
||||
// TODO: Hide this if project code is not enabled
|
||||
// TODO: Custom render function here
|
||||
},
|
||||
{
|
||||
accessor: 'priority',
|
||||
title: t`Priority`,
|
||||
sortable: true,
|
||||
switchable: true
|
||||
},
|
||||
{
|
||||
accessor: 'quantity',
|
||||
sortable: true,
|
||||
title: t`Quantity`,
|
||||
switchable: true
|
||||
},
|
||||
{
|
||||
accessor: 'completed',
|
||||
sortable: true,
|
||||
title: t`Completed`,
|
||||
render: (record: any) => {
|
||||
let progress =
|
||||
record.quantity <= 0 ? 0 : (100 * record.completed) / record.quantity;
|
||||
return (
|
||||
<Progress
|
||||
value={progress}
|
||||
label={record.completed}
|
||||
color={progress < 100 ? 'blue' : 'green'}
|
||||
size="xl"
|
||||
radius="xl"
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'status',
|
||||
sortable: true,
|
||||
title: t`Status`,
|
||||
switchable: true
|
||||
// TODO: Custom render function here (status label)
|
||||
},
|
||||
{
|
||||
accessor: 'creation_date',
|
||||
sortable: true,
|
||||
title: t`Created`,
|
||||
switchable: true
|
||||
}
|
||||
// TODO: issued_by
|
||||
// TODO: responsible
|
||||
// TODO: target_date
|
||||
// TODO: completion_date
|
||||
];
|
||||
}
|
||||
|
||||
function buildOrderTableFilters(): TableFilter[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildOrderTableParams(params: any): any {
|
||||
return {
|
||||
...params,
|
||||
part_detail: true
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Construct a table of build orders, according to the provided parameters
|
||||
*/
|
||||
export function BuildOrderTable({ params = {} }: { params?: any }) {
|
||||
// Add required query parameters
|
||||
let tableParams = useMemo(() => buildOrderTableParams(params), [params]);
|
||||
let tableColumns = useMemo(() => buildOrderTableColumns(), []);
|
||||
let tableFilters = useMemo(() => buildOrderTableFilters(), []);
|
||||
|
||||
tableParams.part_detail = true;
|
||||
|
||||
return (
|
||||
<InvenTreeTable
|
||||
url="build/"
|
||||
enableDownload
|
||||
tableKey="build-order-table"
|
||||
params={tableParams}
|
||||
columns={tableColumns}
|
||||
customFilters={tableFilters}
|
||||
/>
|
||||
);
|
||||
}
|
210
src/frontend/src/components/tables/part/PartTable.tsx
Normal file
210
src/frontend/src/components/tables/part/PartTable.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Text } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { notYetImplemented } from '../../../functions/notifications';
|
||||
import { shortenString } from '../../../functions/tables';
|
||||
import { ThumbnailHoverCard } from '../../items/Thumbnail';
|
||||
import { TableColumn } from '../Column';
|
||||
import { TableFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
/**
|
||||
* Construct a list of columns for the part table
|
||||
*/
|
||||
function partTableColumns(): TableColumn[] {
|
||||
return [
|
||||
{
|
||||
accessor: 'name',
|
||||
sortable: true,
|
||||
title: t`Part`,
|
||||
render: function (record: any) {
|
||||
// TODO - Link to the part detail page
|
||||
return (
|
||||
<ThumbnailHoverCard
|
||||
src={record.thumbnail || record.image}
|
||||
text={record.name}
|
||||
link=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'IPN',
|
||||
title: t`IPN`,
|
||||
sortable: true,
|
||||
switchable: true
|
||||
},
|
||||
{
|
||||
accessor: 'units',
|
||||
sortable: true,
|
||||
title: t`Units`,
|
||||
switchable: true
|
||||
},
|
||||
{
|
||||
accessor: 'description',
|
||||
title: t`Description`,
|
||||
sortable: true,
|
||||
switchable: true
|
||||
},
|
||||
{
|
||||
accessor: 'category',
|
||||
title: t`Category`,
|
||||
sortable: true,
|
||||
render: function (record: any) {
|
||||
// TODO: Link to the category detail page
|
||||
return shortenString({
|
||||
str: record.category_detail.pathstring
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'total_in_stock',
|
||||
title: t`Stock`,
|
||||
sortable: true,
|
||||
switchable: true
|
||||
},
|
||||
{
|
||||
accessor: 'price_range',
|
||||
title: t`Price Range`,
|
||||
sortable: false,
|
||||
switchable: true,
|
||||
render: function (record: any) {
|
||||
// TODO: Render price range
|
||||
return '-- price --';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'link',
|
||||
title: t`Link`,
|
||||
switchable: true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a set of filters for the part table
|
||||
*/
|
||||
function partTableFilters(): TableFilter[] {
|
||||
return [
|
||||
{
|
||||
name: 'active',
|
||||
label: t`Active`,
|
||||
description: t`Filter by part active status`,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
name: 'assembly',
|
||||
label: t`Assembly`,
|
||||
description: t`Filter by assembly attribute`,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
name: 'cascade',
|
||||
label: t`Include Subcategories`,
|
||||
description: t`Include parts in subcategories`,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
name: 'component',
|
||||
label: t`Component`,
|
||||
description: t`Filter by component attribute`,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
name: 'trackable',
|
||||
label: t`Trackable`,
|
||||
description: t`Filter by trackable attribute`,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
name: 'has_units',
|
||||
label: t`Has Units`,
|
||||
description: t`Filter by parts which have units`,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
name: 'has_ipn',
|
||||
label: t`Has IPN`,
|
||||
description: t`Filter by parts which have an internal part number`,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
name: 'has_stock',
|
||||
label: t`Has Stock`,
|
||||
description: t`Filter by parts which have stock`,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
name: 'low_stock',
|
||||
label: t`Low Stock`,
|
||||
description: t`Filter by parts which have low stock`,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
name: 'purchaseable',
|
||||
label: t`Purchaseable`,
|
||||
description: t`Filter by parts which are purchaseable`,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
name: 'salable',
|
||||
label: t`Salable`,
|
||||
description: t`Filter by parts which are salable`,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
name: 'virtual',
|
||||
label: t`Virtual`,
|
||||
description: t`Filter by parts which are virtual`,
|
||||
type: 'choice',
|
||||
choices: [
|
||||
{ value: 'true', label: t`Virtual` },
|
||||
{ value: 'false', label: t`Not Virtual` }
|
||||
]
|
||||
}
|
||||
// unallocated_stock
|
||||
// starred
|
||||
// stocktake
|
||||
// is_template
|
||||
// virtual
|
||||
// has_pricing
|
||||
// TODO: Any others from table_filters.js?
|
||||
];
|
||||
}
|
||||
|
||||
function partTableParams(params: any): any {
|
||||
return {
|
||||
...params,
|
||||
category_detail: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PartListTable - Displays a list of parts, based on the provided parameters
|
||||
* @param {Object} params - The query parameters to pass to the API
|
||||
* @returns
|
||||
*/
|
||||
export function PartListTable({ params = {} }: { params?: any }) {
|
||||
let tableParams = useMemo(() => partTableParams(params), []);
|
||||
let tableColumns = useMemo(() => partTableColumns(), []);
|
||||
let tableFilters = useMemo(() => partTableFilters(), []);
|
||||
|
||||
// Add required query parameters
|
||||
tableParams.category_detail = true;
|
||||
|
||||
return (
|
||||
<InvenTreeTable
|
||||
url="part/"
|
||||
enableDownload
|
||||
tableKey="part-table"
|
||||
printingActions={[
|
||||
<Text onClick={notYetImplemented}>Hello</Text>,
|
||||
<Text onClick={notYetImplemented}>World</Text>
|
||||
]}
|
||||
params={tableParams}
|
||||
columns={tableColumns}
|
||||
customFilters={tableFilters}
|
||||
/>
|
||||
);
|
||||
}
|
155
src/frontend/src/components/tables/stock/StockItemTable.tsx
Normal file
155
src/frontend/src/components/tables/stock/StockItemTable.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Group } from '@mantine/core';
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { notYetImplemented } from '../../../functions/notifications';
|
||||
import { ActionButton } from '../../items/ActionButton';
|
||||
import { ThumbnailHoverCard } from '../../items/Thumbnail';
|
||||
import { TableColumn } from '../Column';
|
||||
import { TableFilter } from '../Filter';
|
||||
import { InvenTreeTable } from './../InvenTreeTable';
|
||||
|
||||
/**
|
||||
* Construct a list of columns for the stock item table
|
||||
*/
|
||||
function stockItemTableColumns(): TableColumn[] {
|
||||
return [
|
||||
{
|
||||
accessor: 'part',
|
||||
sortable: true,
|
||||
title: t`Part`,
|
||||
render: function (record: any) {
|
||||
let part = record.part_detail;
|
||||
return (
|
||||
<ThumbnailHoverCard
|
||||
src={part.thumbnail || part.image}
|
||||
text={part.name}
|
||||
link=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'part_detail.description',
|
||||
sortable: false,
|
||||
switchable: true,
|
||||
title: t`Description`
|
||||
},
|
||||
{
|
||||
accessor: 'quantity',
|
||||
sortable: true,
|
||||
title: t`Stock`
|
||||
// TODO: Custom renderer for stock quantity
|
||||
},
|
||||
{
|
||||
accessor: 'status',
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
filter: true,
|
||||
title: t`Status`
|
||||
// TODO: Custom renderer for stock status label
|
||||
},
|
||||
{
|
||||
accessor: 'batch',
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
title: t`Batch`
|
||||
},
|
||||
{
|
||||
accessor: 'location',
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
title: t`Location`,
|
||||
render: function (record: any) {
|
||||
// TODO: Custom renderer for location
|
||||
return record.location;
|
||||
}
|
||||
},
|
||||
// TODO: stocktake column
|
||||
// TODO: expiry date
|
||||
// TODO: last updated
|
||||
// TODO: purchase order
|
||||
// TODO: Supplier part
|
||||
// TODO: purchase price
|
||||
// TODO: stock value
|
||||
// TODO: packaging
|
||||
// TODO: notes
|
||||
{
|
||||
accessor: 'actions',
|
||||
title: t`Actions`,
|
||||
sortable: false,
|
||||
render: function (record: any) {
|
||||
return (
|
||||
<Group position="right" spacing={5} noWrap={true}>
|
||||
{/* {EditButton(setEditing, editing)} */}
|
||||
{/* {DeleteButton()} */}
|
||||
<ActionButton
|
||||
color="green"
|
||||
icon={<IconEdit />}
|
||||
tooltip="Edit stock item"
|
||||
onClick={() => notYetImplemented()}
|
||||
/>
|
||||
<ActionButton
|
||||
color="red"
|
||||
tooltip="Delete stock item"
|
||||
icon={<IconTrash />}
|
||||
onClick={() => notYetImplemented()}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a set of parameters for the stock item table
|
||||
*/
|
||||
function stockItemTableParams(params: any): any {
|
||||
return {
|
||||
...params,
|
||||
part_detail: true,
|
||||
location_detail: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a list of available filters for the stock item table
|
||||
*/
|
||||
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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
* Load a table of stock items
|
||||
*/
|
||||
export function StockItemTable({ params = {} }: { params?: any }) {
|
||||
let tableParams = useMemo(() => stockItemTableParams(params), []);
|
||||
let tableColumns = useMemo(() => stockItemTableColumns(), []);
|
||||
let tableFilters = useMemo(() => stockItemTableFilters(), []);
|
||||
|
||||
return (
|
||||
<InvenTreeTable
|
||||
url="stock/"
|
||||
tableKey="stock-table"
|
||||
enableDownload
|
||||
enableSelection
|
||||
params={tableParams}
|
||||
columns={tableColumns}
|
||||
customFilters={tableFilters}
|
||||
/>
|
||||
);
|
||||
}
|
@ -21,7 +21,10 @@ export const footerLinks = [
|
||||
];
|
||||
export const navTabs = [
|
||||
{ text: <Trans>Home</Trans>, name: 'home' },
|
||||
{ text: <Trans>Dashboard</Trans>, name: 'dashboard' }
|
||||
{ text: <Trans>Dashboard</Trans>, name: 'dashboard' },
|
||||
{ text: <Trans>Parts</Trans>, name: 'parts' },
|
||||
{ text: <Trans>Stock</Trans>, name: 'stock' },
|
||||
{ text: <Trans>Build</Trans>, name: 'build' }
|
||||
];
|
||||
|
||||
export const docLinks = {
|
||||
|
13
src/frontend/src/functions/notifications.tsx
Normal file
13
src/frontend/src/functions/notifications.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
/**
|
||||
* Show a notification that the feature is not yet implemented
|
||||
*/
|
||||
export function notYetImplemented() {
|
||||
notifications.show({
|
||||
title: t`Not implemented`,
|
||||
message: t`This feature is not yet implemented`,
|
||||
color: 'red'
|
||||
});
|
||||
}
|
25
src/frontend/src/functions/tables.tsx
Normal file
25
src/frontend/src/functions/tables.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Reduce an input string to a given length, adding an ellipsis if necessary
|
||||
* @param str - String to shorten
|
||||
* @param len - Length to shorten to
|
||||
*/
|
||||
export function shortenString({
|
||||
str,
|
||||
len = 100
|
||||
}: {
|
||||
str: string;
|
||||
len?: number;
|
||||
}) {
|
||||
// Ensure that the string is a string
|
||||
str = str.toString();
|
||||
|
||||
// If the string is already short enough, return it
|
||||
if (str.length <= len) {
|
||||
return str;
|
||||
}
|
||||
|
||||
// Otherwise, shorten it
|
||||
let N = Math.floor(len / 2 - 1);
|
||||
|
||||
return str.slice(0, N) + '...' + str.slice(-N);
|
||||
}
|
20
src/frontend/src/pages/Index/Build.tsx
Normal file
20
src/frontend/src/pages/Index/Build.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Group } from '@mantine/core';
|
||||
|
||||
import { PlaceholderPill } from '../../components/items/Placeholder';
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import { BuildOrderTable } from '../../components/tables/build/BuildOrderTable';
|
||||
|
||||
export default function Build() {
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
<StylishText>
|
||||
<Trans>Build Orders</Trans>
|
||||
</StylishText>
|
||||
<PlaceholderPill />
|
||||
</Group>
|
||||
<BuildOrderTable />
|
||||
</>
|
||||
);
|
||||
}
|
20
src/frontend/src/pages/Index/Part.tsx
Normal file
20
src/frontend/src/pages/Index/Part.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Group } from '@mantine/core';
|
||||
|
||||
import { PlaceholderPill } from '../../components/items/Placeholder';
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import { PartListTable } from '../../components/tables/part/PartTable';
|
||||
|
||||
export default function Part() {
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
<StylishText>
|
||||
<Trans>Parts</Trans>
|
||||
</StylishText>
|
||||
<PlaceholderPill />
|
||||
</Group>
|
||||
<PartListTable />
|
||||
</>
|
||||
);
|
||||
}
|
20
src/frontend/src/pages/Index/Stock.tsx
Normal file
20
src/frontend/src/pages/Index/Stock.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Group } from '@mantine/core';
|
||||
|
||||
import { PlaceholderPill } from '../../components/items/Placeholder';
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import { StockItemTable } from '../../components/tables/stock/StockItemTable';
|
||||
|
||||
export default function Stock() {
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
<StylishText>
|
||||
<Trans>Stock Items</Trans>
|
||||
</StylishText>
|
||||
<PlaceholderPill />
|
||||
</Group>
|
||||
<StockItemTable />
|
||||
</>
|
||||
);
|
||||
}
|
@ -8,6 +8,10 @@ export const LayoutComponent = Loadable(
|
||||
lazy(() => import('./components/nav/Layout'))
|
||||
);
|
||||
export const Home = Loadable(lazy(() => import('./pages/Index/Home')));
|
||||
export const Parts = Loadable(lazy(() => import('./pages/Index/Part')));
|
||||
export const Stock = Loadable(lazy(() => import('./pages/Index/Stock')));
|
||||
export const Build = Loadable(lazy(() => import('./pages/Index/Build')));
|
||||
|
||||
export const Dashboard = Loadable(
|
||||
lazy(() => import('./pages/Index/Dashboard'))
|
||||
);
|
||||
@ -48,6 +52,18 @@ export const router = createBrowserRouter(
|
||||
path: 'dashboard/',
|
||||
element: <Dashboard />
|
||||
},
|
||||
{
|
||||
path: 'parts/',
|
||||
element: <Parts />
|
||||
},
|
||||
{
|
||||
path: 'stock/',
|
||||
element: <Stock />
|
||||
},
|
||||
{
|
||||
path: 'build/',
|
||||
element: <Build />
|
||||
},
|
||||
{
|
||||
path: '/profile/:tabValue',
|
||||
element: <Profile />
|
||||
|
@ -14,5 +14,10 @@ export default defineConfig({
|
||||
build: {
|
||||
manifest: true,
|
||||
outDir: '../../InvenTree/web/static/web'
|
||||
},
|
||||
server: {
|
||||
watch: {
|
||||
usePolling: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user