mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +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:
		
							
								
								
									
										1
									
								
								.github/workflows/qc_checks.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/qc_checks.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -152,6 +152,7 @@ jobs: | |||||||
|           apt-dependency: gettext poppler-utils |           apt-dependency: gettext poppler-utils | ||||||
|           dev-install: true |           dev-install: true | ||||||
|           update: true |           update: true | ||||||
|  |           npm: true | ||||||
|       - name: Download Python Code For `${{ env.wrapper_name }}` |       - name: Download Python Code For `${{ env.wrapper_name }}` | ||||||
|         run: git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} |         run: git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} | ||||||
|           ./${{ 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": { |   "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": "^8.41.0", | ||||||
|     "eslint-config-google": "^0.14.0", |     "eslint-config-google": "^0.14.0" | ||||||
|     "mantine-datatable": "^2.8.5" |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -30,6 +30,7 @@ | |||||||
|         "axios": "^1.4.0", |         "axios": "^1.4.0", | ||||||
|         "dayjs": "^1.11.9", |         "dayjs": "^1.11.9", | ||||||
|         "html5-qrcode": "^2.3.8", |         "html5-qrcode": "^2.3.8", | ||||||
|  |         "mantine-datatable": "^2.9.0", | ||||||
|         "react": "^18.2.0", |         "react": "^18.2.0", | ||||||
|         "react-dom": "^18.2.0", |         "react-dom": "^18.2.0", | ||||||
|         "react-router-dom": "^6.14.2", |         "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 = [ | export const navTabs = [ | ||||||
|   { text: <Trans>Home</Trans>, name: 'home' }, |   { 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 = { | 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')) |   lazy(() => import('./components/nav/Layout')) | ||||||
| ); | ); | ||||||
| export const Home = Loadable(lazy(() => import('./pages/Index/Home'))); | 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( | export const Dashboard = Loadable( | ||||||
|   lazy(() => import('./pages/Index/Dashboard')) |   lazy(() => import('./pages/Index/Dashboard')) | ||||||
| ); | ); | ||||||
| @@ -48,6 +52,18 @@ export const router = createBrowserRouter( | |||||||
|           path: 'dashboard/', |           path: 'dashboard/', | ||||||
|           element: <Dashboard /> |           element: <Dashboard /> | ||||||
|         }, |         }, | ||||||
|  |         { | ||||||
|  |           path: 'parts/', | ||||||
|  |           element: <Parts /> | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           path: 'stock/', | ||||||
|  |           element: <Stock /> | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           path: 'build/', | ||||||
|  |           element: <Build /> | ||||||
|  |         }, | ||||||
|         { |         { | ||||||
|           path: '/profile/:tabValue', |           path: '/profile/:tabValue', | ||||||
|           element: <Profile /> |           element: <Profile /> | ||||||
|   | |||||||
| @@ -14,5 +14,10 @@ export default defineConfig({ | |||||||
|   build: { |   build: { | ||||||
|     manifest: true, |     manifest: true, | ||||||
|     outDir: '../../InvenTree/web/static/web' |     outDir: '../../InvenTree/web/static/web' | ||||||
|  |   }, | ||||||
|  |   server: { | ||||||
|  |     watch: { | ||||||
|  |       usePolling: true | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
		Reference in New Issue
	
	Block a user