mirror of
				https://github.com/optim-enterprises-bv/OptimCloud-gw-ui.git
				synced 2025-10-31 10:17:45 +00:00 
			
		
		
		
	[WIFI-12435] [WIFI-12436] Device table added functionality and styling fixes
Signed-off-by: Charles <charles.bourque96@gmail.com>
This commit is contained in:
		
							
								
								
									
										49
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										49
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -1,12 +1,12 @@ | ||||
| { | ||||
|   "name": "ucentral-client", | ||||
|   "version": "2.9.0(23)", | ||||
|   "version": "2.10.0(5)", | ||||
|   "lockfileVersion": 2, | ||||
|   "requires": true, | ||||
|   "packages": { | ||||
|     "": { | ||||
|       "name": "ucentral-client", | ||||
|       "version": "2.9.0(23)", | ||||
|       "version": "2.10.0(5)", | ||||
|       "license": "ISC", | ||||
|       "dependencies": { | ||||
|         "@chakra-ui/icons": "^2.0.11", | ||||
| @@ -20,6 +20,7 @@ | ||||
|         "@googlemaps/typescript-guards": "^2.0.3", | ||||
|         "@react-spring/web": "^9.5.5", | ||||
|         "@tanstack/react-query": "^4.12.0", | ||||
|         "@tanstack/react-table": "^8.7.9", | ||||
|         "@textea/json-viewer": "^2.10.0", | ||||
|         "axios": "^1.1.3", | ||||
|         "buffer": "^6.0.3", | ||||
| @@ -3496,6 +3497,37 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@tanstack/react-table": { | ||||
|       "version": "8.8.5", | ||||
|       "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.8.5.tgz", | ||||
|       "integrity": "sha512-g/t21E/ICHvaCOJOhsDNr5QaB/6aDQEHFbx/YliwwU/CJThMqG+dS28vnToIBV/5MBgpeXoGRi2waDJVJlZrtg==", | ||||
|       "dependencies": { | ||||
|         "@tanstack/table-core": "8.8.5" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=12" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "type": "github", | ||||
|         "url": "https://github.com/sponsors/tannerlinsley" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "react": ">=16", | ||||
|         "react-dom": ">=16" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@tanstack/table-core": { | ||||
|       "version": "8.8.5", | ||||
|       "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.8.5.tgz", | ||||
|       "integrity": "sha512-Xnwa1qxpgvSW7ozLiexmKp2PIYcLBiY/IizbdGriYCL6OOHuZ9baRhrrH51zjyz+61ly6K59rmt6AI/3RR+97Q==", | ||||
|       "engines": { | ||||
|         "node": ">=12" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "type": "github", | ||||
|         "url": "https://github.com/sponsors/tannerlinsley" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@textea/json-viewer": { | ||||
|       "version": "2.10.0", | ||||
|       "license": "MIT", | ||||
| @@ -11823,6 +11855,19 @@ | ||||
|         "use-sync-external-store": "^1.2.0" | ||||
|       } | ||||
|     }, | ||||
|     "@tanstack/react-table": { | ||||
|       "version": "8.8.5", | ||||
|       "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.8.5.tgz", | ||||
|       "integrity": "sha512-g/t21E/ICHvaCOJOhsDNr5QaB/6aDQEHFbx/YliwwU/CJThMqG+dS28vnToIBV/5MBgpeXoGRi2waDJVJlZrtg==", | ||||
|       "requires": { | ||||
|         "@tanstack/table-core": "8.8.5" | ||||
|       } | ||||
|     }, | ||||
|     "@tanstack/table-core": { | ||||
|       "version": "8.8.5", | ||||
|       "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.8.5.tgz", | ||||
|       "integrity": "sha512-Xnwa1qxpgvSW7ozLiexmKp2PIYcLBiY/IizbdGriYCL6OOHuZ9baRhrrH51zjyz+61ly6K59rmt6AI/3RR+97Q==" | ||||
|     }, | ||||
|     "@textea/json-viewer": { | ||||
|       "version": "2.10.0", | ||||
|       "requires": { | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "ucentral-client", | ||||
|   "version": "2.9.0(23)", | ||||
|   "version": "2.10.0(5)", | ||||
|   "description": "", | ||||
|   "private": true, | ||||
|   "main": "index.tsx", | ||||
| @@ -51,6 +51,7 @@ | ||||
|     "react-i18next": "^11.18.6", | ||||
|     "react-masonry-css": "^1.0.16", | ||||
|     "@tanstack/react-query": "^4.12.0", | ||||
|     "@tanstack/react-table": "^8.7.9", | ||||
|     "react-router-dom": "^6.4.2", | ||||
|     "react-table": "^7.8.0", | ||||
|     "react-virtualized-auto-sizer": "^1.0.7", | ||||
|   | ||||
							
								
								
									
										23
									
								
								src/@tanstack.react-table.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/@tanstack.react-table.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| /* eslint-disable @typescript-eslint/no-unused-vars */ | ||||
| import { BoxProps } from '@chakra-ui/react'; | ||||
| import '@tanstack/react-table'; | ||||
|  | ||||
| declare module '@tanstack/table-core' { | ||||
|   interface ColumnMeta<TData extends RowData, TValue> { | ||||
|     stopPropagation?: boolean; | ||||
|     alwaysShow?: boolean; | ||||
|     hasPopover?: boolean; | ||||
|     customMaxWidth?: string; | ||||
|     customMinWidth?: string; | ||||
|     customWidth?: string; | ||||
|     isMonospace?: boolean; | ||||
|     isCentered?: boolean; | ||||
|     columnSelectorOptions?: { | ||||
|       label?: string; | ||||
|     }; | ||||
|     headerOptions?: { | ||||
|       tooltip?: string; | ||||
|     }; | ||||
|     headerStyleProps?: BoxProps; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										62
									
								
								src/components/DataTables/DataGrid/CellRow.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								src/components/DataTables/DataGrid/CellRow.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| import * as React from 'react'; | ||||
| import { Td, Tr } from '@chakra-ui/react'; | ||||
| import { Row, flexRender } from '@tanstack/react-table'; | ||||
|  | ||||
| export type DataGridCellRowProps<TValue extends object> = { | ||||
|   row: Row<TValue>; | ||||
|   onRowClick: ((row: TValue) => (() => void) | undefined) | undefined; | ||||
|   rowStyle: { | ||||
|     hoveredRowBg: string; | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export const DataGridCellRow = <TValue extends object>({ | ||||
|   row, | ||||
|   rowStyle: { hoveredRowBg }, | ||||
|   onRowClick, | ||||
| }: DataGridCellRowProps<TValue>) => { | ||||
|   const onClick = onRowClick ? onRowClick(row.original) : undefined; | ||||
|  | ||||
|   return ( | ||||
|     <Tr | ||||
|       key={row.id} | ||||
|       _hover={{ | ||||
|         backgroundColor: hoveredRowBg, | ||||
|       }} | ||||
|       onClick={onClick} | ||||
|     > | ||||
|       {row.getVisibleCells().map((cell) => ( | ||||
|         <Td | ||||
|           px={1} | ||||
|           key={cell.id} | ||||
|           textOverflow="ellipsis" | ||||
|           overflow="hidden" | ||||
|           whiteSpace="nowrap" | ||||
|           minWidth={cell.column.columnDef.meta?.customMinWidth ?? undefined} | ||||
|           maxWidth={cell.column.columnDef.meta?.customMaxWidth ?? undefined} | ||||
|           width={cell.column.columnDef.meta?.customWidth} | ||||
|           textAlign={cell.column.columnDef.meta?.isCentered ? 'center' : undefined} | ||||
|           fontFamily={ | ||||
|             cell.column.columnDef.meta?.isMonospace | ||||
|               ? 'Inter, SFMono-Regular, Menlo, Monaco, Consolas, monospace' | ||||
|               : undefined | ||||
|           } | ||||
|           onClick={ | ||||
|             cell.column.columnDef.meta?.stopPropagation || (cell.column.id === 'actions' && onClick) | ||||
|               ? (e) => { | ||||
|                   e.stopPropagation(); | ||||
|                 } | ||||
|               : undefined | ||||
|           } | ||||
|           cursor={ | ||||
|             !cell.column.columnDef.meta?.stopPropagation && cell.column.id !== 'actions' && onClick | ||||
|               ? 'pointer' | ||||
|               : undefined | ||||
|           } | ||||
|         > | ||||
|           {flexRender(cell.column.columnDef.cell, cell.getContext())} | ||||
|         </Td> | ||||
|       ))} | ||||
|     </Tr> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										82
									
								
								src/components/DataTables/DataGrid/DataGridColumnPicker.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								src/components/DataTables/DataGrid/DataGridColumnPicker.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| import React from 'react'; | ||||
| import { Box, Checkbox, IconButton, Menu, MenuButton, MenuItem, MenuList, Tooltip } from '@chakra-ui/react'; | ||||
| import { VisibilityState } from '@tanstack/react-table'; | ||||
| import { FunnelSimple } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import { DataGridColumn } from './useDataGrid'; | ||||
| import { useAuth } from 'contexts/AuthProvider'; | ||||
|  | ||||
| export type DataGridColumnPickerProps<TValue extends object> = { | ||||
|   preference: string; | ||||
|   columns: DataGridColumn<TValue>[]; | ||||
|   columnVisibility: VisibilityState; | ||||
|   setColumnVisibility: (str: VisibilityState) => void; | ||||
| }; | ||||
|  | ||||
| export const DataGridColumnPicker = <TValue extends object>({ | ||||
|   preference, | ||||
|   columns, | ||||
|   columnVisibility, | ||||
|   setColumnVisibility, | ||||
| }: DataGridColumnPickerProps<TValue>) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const { getPref, setPref } = useAuth(); | ||||
|  | ||||
|   const handleColumnClick = React.useCallback( | ||||
|     (id: string) => { | ||||
|       const newVisibility = { ...columnVisibility }; | ||||
|       newVisibility[id] = newVisibility[id] !== undefined ? !newVisibility[id] : false; | ||||
|       const hiddenColumnsArray = Object.entries(newVisibility) | ||||
|         .filter(([, value]) => !value) | ||||
|         .map(([key]) => key); | ||||
|       setPref({ preference, value: hiddenColumnsArray.join(',') }); | ||||
|       setColumnVisibility({ ...newVisibility }); | ||||
|     }, | ||||
|     [columnVisibility], | ||||
|   ); | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     const savedPrefs = getPref(preference); | ||||
|  | ||||
|     if (savedPrefs) { | ||||
|       const savedHiddenColumns = savedPrefs.split(','); | ||||
|       setColumnVisibility(savedHiddenColumns.reduce((acc, curr) => ({ ...acc, [curr]: false }), {})); | ||||
|     } else { | ||||
|       setColumnVisibility({}); | ||||
|     } | ||||
|   }, [preference]); | ||||
|  | ||||
|   return ( | ||||
|     <Box> | ||||
|       <Menu closeOnSelect={false} isLazy> | ||||
|         <Tooltip label={t('common.columns')} hasArrow> | ||||
|           <MenuButton as={IconButton} icon={<FunnelSimple />} /> | ||||
|         </Tooltip> | ||||
|         <MenuList maxH="200px" overflowY="auto"> | ||||
|           {columns | ||||
|             .filter((col) => col.id && col.header) | ||||
|             .map((column) => { | ||||
|               const handleClick = | ||||
|                 column.id !== undefined ? () => handleColumnClick(column.id as unknown as string) : undefined; | ||||
|               const id = column.id ?? uuid(); | ||||
|               let label = column.header?.toString() ?? 'Unrecognized column'; | ||||
|               if (column.meta?.columnSelectorOptions?.label) label = column.meta.columnSelectorOptions.label; | ||||
|  | ||||
|               return ( | ||||
|                 <MenuItem | ||||
|                   key={id} | ||||
|                   as={Checkbox} | ||||
|                   isChecked={columnVisibility[id] === undefined || columnVisibility[id]} | ||||
|                   onChange={column.meta?.alwaysShow ? undefined : handleClick} | ||||
|                   isDisabled={column.meta?.alwaysShow} | ||||
|                 > | ||||
|                   {label} | ||||
|                 </MenuItem> | ||||
|               ); | ||||
|             })} | ||||
|         </MenuList> | ||||
|       </Menu> | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										43
									
								
								src/components/DataTables/DataGrid/HeaderRow.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/components/DataTables/DataGrid/HeaderRow.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| import * as React from 'react'; | ||||
| import { Box, Flex, Th, Tooltip, Tr } from '@chakra-ui/react'; | ||||
| import { HeaderGroup, flexRender } from '@tanstack/react-table'; | ||||
| import { DataGridSortIcon } from './SortIcon'; | ||||
|  | ||||
| export type DataGridHeaderRowProps<TValue extends object> = { | ||||
|   headerGroup: HeaderGroup<TValue>; | ||||
| }; | ||||
|  | ||||
| export const DataGridHeaderRow = <TValue extends object>({ headerGroup }: DataGridHeaderRowProps<TValue>) => ( | ||||
|   <Tr p={0}> | ||||
|     {headerGroup.headers.map((header) => ( | ||||
|       <Th | ||||
|         color="gray.400" | ||||
|         key={header.id} | ||||
|         colSpan={header.colSpan} | ||||
|         minWidth={header.column.columnDef.meta?.customMinWidth ?? undefined} | ||||
|         maxWidth={header.column.columnDef.meta?.customMaxWidth ?? undefined} | ||||
|         width={header.column.columnDef.meta?.customWidth} | ||||
|         fontSize="sm" | ||||
|         onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined} | ||||
|         cursor={header.column.getCanSort() ? 'pointer' : undefined} | ||||
|       > | ||||
|         <Flex display="flex" alignItems="center"> | ||||
|           {header.isPlaceholder ? null : ( | ||||
|             <Tooltip label={header.column.columnDef.meta?.headerOptions?.tooltip}> | ||||
|               <Box | ||||
|                 overflow="hidden" | ||||
|                 whiteSpace="nowrap" | ||||
|                 alignContent="center" | ||||
|                 width="100%" | ||||
|                 {...header.column.columnDef.meta?.headerStyleProps} | ||||
|               > | ||||
|                 {flexRender(header.column.columnDef.header, header.getContext())} | ||||
|               </Box> | ||||
|             </Tooltip> | ||||
|           )} | ||||
|           <DataGridSortIcon sortInfo={header.column.getIsSorted()} canSort={header.column.getCanSort()} /> | ||||
|         </Flex> | ||||
|       </Th> | ||||
|     ))} | ||||
|   </Tr> | ||||
| ); | ||||
							
								
								
									
										124
									
								
								src/components/DataTables/DataGrid/Input.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								src/components/DataTables/DataGrid/Input.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,124 @@ | ||||
| import * as React from 'react'; | ||||
| import { ArrowLeftIcon, ArrowRightIcon, ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons'; | ||||
| import { | ||||
|   Tooltip, | ||||
|   Flex, | ||||
|   IconButton, | ||||
|   Text, | ||||
|   Select, | ||||
|   NumberInput, | ||||
|   NumberInputField, | ||||
|   NumberInputStepper, | ||||
|   NumberIncrementStepper, | ||||
|   NumberDecrementStepper, | ||||
| } from '@chakra-ui/react'; | ||||
| import { Table } from '@tanstack/react-table'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import { useContainerDimensions } from 'hooks/useContainerDimensions'; | ||||
|  | ||||
| type Props<T extends object> = { | ||||
|   table: Table<T>; | ||||
|   isDisabled?: boolean; | ||||
| }; | ||||
|  | ||||
| const DataGridControls = <T extends object>({ table, isDisabled }: Props<T>) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const { ref, dimensions } = useContainerDimensions({ precision: 100 }); | ||||
|   const isCompact = dimensions.width !== 0 && dimensions.width <= 800; | ||||
|  | ||||
|   return ( | ||||
|     <Flex ref={ref} justifyContent="space-between" m={4} alignItems="center"> | ||||
|       <Flex> | ||||
|         <Tooltip label={t('table.first_page')}> | ||||
|           <IconButton | ||||
|             aria-label="Go to first page" | ||||
|             onClick={() => table.setPageIndex(0)} | ||||
|             isDisabled={isDisabled || !table.getCanPreviousPage()} | ||||
|             icon={<ArrowLeftIcon h={3} w={3} />} | ||||
|             mr={4} | ||||
|           /> | ||||
|         </Tooltip> | ||||
|         <Tooltip label={t('table.previous_page')}> | ||||
|           <IconButton | ||||
|             aria-label="Previous page" | ||||
|             onClick={() => table.previousPage()} | ||||
|             isDisabled={isDisabled || !table.getCanPreviousPage()} | ||||
|             icon={<ChevronLeftIcon h={6} w={6} />} | ||||
|           /> | ||||
|         </Tooltip> | ||||
|       </Flex> | ||||
|  | ||||
|       <Flex alignItems="center"> | ||||
|         {isCompact ? null : ( | ||||
|           <> | ||||
|             <Text flexShrink={0} mr={8}> | ||||
|               {t('table.page')}{' '} | ||||
|               <Text fontWeight="bold" as="span"> | ||||
|                 {table.getState().pagination.pageIndex + 1} | ||||
|               </Text>{' '} | ||||
|               {t('common.of')}{' '} | ||||
|               <Text fontWeight="bold" as="span"> | ||||
|                 {table.getPageCount()} | ||||
|               </Text> | ||||
|             </Text> | ||||
|             <Text flexShrink={0}>{t('table.go_to_page')}</Text>{' '} | ||||
|             <NumberInput | ||||
|               ml={2} | ||||
|               mr={8} | ||||
|               w={28} | ||||
|               min={1} | ||||
|               max={table.getPageCount()} | ||||
|               onChange={(_, numberValue) => { | ||||
|                 const newPage = numberValue ? numberValue - 1 : 0; | ||||
|                 table.setPageIndex(newPage); | ||||
|               }} | ||||
|               value={table.getState().pagination.pageIndex + 1} | ||||
|             > | ||||
|               <NumberInputField /> | ||||
|               <NumberInputStepper> | ||||
|                 <NumberIncrementStepper /> | ||||
|                 <NumberDecrementStepper /> | ||||
|               </NumberInputStepper> | ||||
|             </NumberInput> | ||||
|           </> | ||||
|         )} | ||||
|         <Select | ||||
|           w={32} | ||||
|           value={table.getState().pagination.pageSize} | ||||
|           onChange={(e) => { | ||||
|             table.setPageSize(Number(e.target.value)); | ||||
|           }} | ||||
|         > | ||||
|           {[10, 20, 30, 40, 50].map((opt) => ( | ||||
|             <option key={uuid()} value={opt}> | ||||
|               {t('common.show')} {opt} | ||||
|             </option> | ||||
|           ))} | ||||
|         </Select> | ||||
|       </Flex> | ||||
|  | ||||
|       <Flex> | ||||
|         <Tooltip label={t('table.next_page')}> | ||||
|           <IconButton | ||||
|             aria-label="Go to next page" | ||||
|             onClick={() => table.nextPage()} | ||||
|             isDisabled={isDisabled || !table.getCanNextPage()} | ||||
|             icon={<ChevronRightIcon h={6} w={6} />} | ||||
|           /> | ||||
|         </Tooltip> | ||||
|         <Tooltip label={t('table.last_page')}> | ||||
|           <IconButton | ||||
|             aria-label="Go to last page" | ||||
|             onClick={() => table.setPageIndex(table.getPageCount() - 1)} | ||||
|             isDisabled={isDisabled || !table.getCanNextPage()} | ||||
|             icon={<ArrowRightIcon h={3} w={3} />} | ||||
|             ml={4} | ||||
|           /> | ||||
|         </Tooltip> | ||||
|       </Flex> | ||||
|     </Flex> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default DataGridControls; | ||||
							
								
								
									
										23
									
								
								src/components/DataTables/DataGrid/SortIcon.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/components/DataTables/DataGrid/SortIcon.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| import React from 'react'; | ||||
| import { Icon } from '@chakra-ui/react'; | ||||
| import { SortDirection } from '@tanstack/react-table'; | ||||
| import { ArrowDown, ArrowUp, Circle } from 'phosphor-react'; | ||||
|  | ||||
| export type DataGridSortIconProps = { | ||||
|   sortInfo: false | SortDirection; | ||||
|   canSort: boolean; | ||||
| }; | ||||
|  | ||||
| export const DataGridSortIcon = ({ sortInfo, canSort }: DataGridSortIconProps) => { | ||||
|   if (canSort) { | ||||
|     if (sortInfo) { | ||||
|       return sortInfo === 'desc' ? ( | ||||
|         <Icon ml={1} boxSize={3} as={ArrowDown} /> | ||||
|       ) : ( | ||||
|         <Icon ml={1} boxSize={3} as={ArrowUp} /> | ||||
|       ); | ||||
|     } | ||||
|     return <Icon ml={1} boxSize={3} as={Circle} />; | ||||
|   } | ||||
|   return null; | ||||
| }; | ||||
							
								
								
									
										189
									
								
								src/components/DataTables/DataGrid/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								src/components/DataTables/DataGrid/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,189 @@ | ||||
| import React from 'react'; | ||||
| import { | ||||
|   Box, | ||||
|   Center, | ||||
|   Flex, | ||||
|   HStack, | ||||
|   Heading, | ||||
|   LayoutProps, | ||||
|   Spacer, | ||||
|   Spinner, | ||||
|   Table, | ||||
|   TableContainer, | ||||
|   Tbody, | ||||
|   Thead, | ||||
|   useColorModeValue, | ||||
| } from '@chakra-ui/react'; | ||||
| import { getCoreRowModel, getPaginationRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { DataGridCellRow } from './CellRow'; | ||||
| import { DataGridColumnPicker } from './DataGridColumnPicker'; | ||||
| import { DataGridHeaderRow } from './HeaderRow'; | ||||
| import DataGridControls from './Input'; | ||||
| import { DataGridColumn, UseDataGridReturn } from './useDataGrid'; | ||||
| import { RefreshButton } from 'components/Buttons/RefreshButton'; | ||||
| import { LoadingOverlay } from 'components/LoadingOverlay'; | ||||
|  | ||||
| export type ColumnOptions = { | ||||
|   isSortable?: boolean; | ||||
| }; | ||||
|  | ||||
| export type DataGridOptions<TValue extends object> = { | ||||
|   count?: number; | ||||
|   isFullScreen?: boolean; | ||||
|   isHidingControls?: boolean; | ||||
|   isManual?: boolean; | ||||
|   minimumHeight?: LayoutProps['minH']; | ||||
|   onRowClick?: (row: TValue) => (() => void) | undefined; | ||||
|   refetch?: () => void; | ||||
| }; | ||||
|  | ||||
| export type DataGridProps<TValue extends object> = { | ||||
|   controller: UseDataGridReturn; | ||||
|   columns: DataGridColumn<TValue>[]; | ||||
|   header: { | ||||
|     title: string; | ||||
|     objectListed: string; | ||||
|     leftContent?: React.ReactNode; | ||||
|     addButton?: React.ReactNode; | ||||
|     otherButtons?: React.ReactNode; | ||||
|   }; | ||||
|   data?: TValue[]; | ||||
|   isLoading?: boolean; | ||||
|   options?: DataGridOptions<TValue>; | ||||
| }; | ||||
|  | ||||
| export const DataGrid = <TValue extends object>({ | ||||
|   controller, | ||||
|   columns, | ||||
|   header, | ||||
|   data = [], | ||||
|   options = {}, | ||||
|   isLoading = false, | ||||
| }: DataGridProps<TValue>) => { | ||||
|   const { t } = useTranslation(); | ||||
|  | ||||
|   /* | ||||
|     Table Styling | ||||
|   */ | ||||
|   const textColor = useColorModeValue('gray.700', 'white'); | ||||
|   const hoveredRowBg = useColorModeValue('gray.100', 'gray.600'); | ||||
|  | ||||
|   const minimumHeight: LayoutProps['minH'] = React.useMemo(() => { | ||||
|     if (options.isFullScreen) { | ||||
|       return { base: 'calc(100vh - 360px)', md: 'calc(100vh - 288px)' }; | ||||
|     } | ||||
|     return options.minimumHeight ?? '300px'; | ||||
|   }, [options.isFullScreen, options.minimumHeight]); | ||||
|  | ||||
|   /* | ||||
|     Table Options | ||||
|   */ | ||||
|   const onRowClick = React.useMemo(() => options.onRowClick, [options.onRowClick]); | ||||
|  | ||||
|   const pagination = React.useMemo( | ||||
|     () => ({ | ||||
|       pageIndex: controller.pageInfo.pageIndex, | ||||
|       pageSize: controller.pageInfo.pageSize, | ||||
|     }), | ||||
|     [controller.pageInfo.pageIndex, controller.pageInfo.pageSize], | ||||
|   ); | ||||
|  | ||||
|   const pageCount = React.useMemo(() => { | ||||
|     if (options.isManual && options.count) { | ||||
|       return Math.ceil(options.count / pagination.pageSize); | ||||
|     } | ||||
|     return Math.ceil((data?.length ?? 0) / pagination.pageSize); | ||||
|   }, [options.count, options.isManual, data?.length, pagination.pageSize]); | ||||
|  | ||||
|   const tableOptions = React.useMemo( | ||||
|     () => ({ | ||||
|       pageCount: pageCount > 0 ? pageCount : 1, | ||||
|       initialState: { sorting: controller.sortBy, pagination }, | ||||
|       manualPagination: options.isManual, | ||||
|       manualSorting: options.isManual, | ||||
|       autoResetPageIndex: false, | ||||
|     }), | ||||
|     [options.isManual, controller.sortBy, pageCount], | ||||
|   ); | ||||
|  | ||||
|   const table = useReactTable<TValue>({ | ||||
|     // react-table base functions | ||||
|     getCoreRowModel: getCoreRowModel(), | ||||
|     getPaginationRowModel: getPaginationRowModel(), | ||||
|     getSortedRowModel: getSortedRowModel(), | ||||
|  | ||||
|     // Table State | ||||
|     data, | ||||
|     columns, | ||||
|     state: { | ||||
|       sorting: controller.sortBy, | ||||
|       columnVisibility: controller.columnVisibility, | ||||
|       pagination, | ||||
|     }, | ||||
|  | ||||
|     // Change Handlers | ||||
|     onSortingChange: controller.setSortBy, | ||||
|     onPaginationChange: controller.onPaginationChange, | ||||
|  | ||||
|     // debugTable: true, | ||||
|  | ||||
|     // Table Options | ||||
|     ...tableOptions, | ||||
|   }); | ||||
|  | ||||
|   if (isLoading && data.length === 0) { | ||||
|     return ( | ||||
|       <Center> | ||||
|         <Spinner size="xl" /> | ||||
|       </Center> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Box w="100%"> | ||||
|       <Flex mb={2}> | ||||
|         <Heading size="md" my="auto" mr={2}> | ||||
|           {header.title} | ||||
|         </Heading> | ||||
|         {header.leftContent} | ||||
|         <Spacer /> | ||||
|         <HStack spacing={2}> | ||||
|           {header.otherButtons} | ||||
|           {header.addButton} | ||||
|           <DataGridColumnPicker | ||||
|             columns={columns} | ||||
|             columnVisibility={controller.columnVisibility} | ||||
|             setColumnVisibility={controller.setColumnVisibility} | ||||
|             preference={`${controller.tableSettingsId}.hiddenColumns`} | ||||
|           /> | ||||
|           {options.refetch ? <RefreshButton onClick={options.refetch} isCompact isFetching={isLoading} /> : null} | ||||
|         </HStack> | ||||
|       </Flex> | ||||
|       <LoadingOverlay isLoading={isLoading}> | ||||
|         <TableContainer minH={minimumHeight}> | ||||
|           <Table size="small" textColor={textColor} w="100%" fontSize="14px"> | ||||
|             <Thead> | ||||
|               {table.getHeaderGroups().map((headerGroup) => ( | ||||
|                 <DataGridHeaderRow<TValue> key={headerGroup.id} headerGroup={headerGroup} /> | ||||
|               ))} | ||||
|             </Thead> | ||||
|             <Tbody> | ||||
|               {table.getRowModel().rows.map((row) => ( | ||||
|                 <DataGridCellRow<TValue> key={row.id} row={row} onRowClick={onRowClick} rowStyle={{ hoveredRowBg }} /> | ||||
|               ))} | ||||
|             </Tbody> | ||||
|           </Table> | ||||
|           {data?.length === 0 ? ( | ||||
|             <Center mt={8}> | ||||
|               <Heading size="md"> | ||||
|                 {header.objectListed ? t('common.no_obj_found', { obj: header.objectListed }) : t('common.empty_list')} | ||||
|               </Heading> | ||||
|             </Center> | ||||
|           ) : null} | ||||
|         </TableContainer> | ||||
|       </LoadingOverlay> | ||||
|       {!options.isHidingControls ? <DataGridControls table={table} isDisabled={isLoading} /> : null} | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										82
									
								
								src/components/DataTables/DataGrid/useDataGrid.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								src/components/DataTables/DataGrid/useDataGrid.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| import * as React from 'react'; | ||||
| import { | ||||
|   ColumnDef, | ||||
|   OnChangeFn, | ||||
|   PaginationState, | ||||
|   SortingColumnDef, | ||||
|   SortingState, | ||||
|   VisibilityState, | ||||
| } from '@tanstack/react-table'; | ||||
|  | ||||
| const getDefaultSettings = (settings?: string) => { | ||||
|   let limit = 10; | ||||
|   let index = 0; | ||||
|  | ||||
|   if (settings) { | ||||
|     const savedSizeSetting = localStorage.getItem(settings); | ||||
|     if (savedSizeSetting) { | ||||
|       try { | ||||
|         limit = parseInt(savedSizeSetting, 10); | ||||
|       } catch (e) { | ||||
|         limit = 10; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const savedPageSetting = localStorage.getItem(`${settings}.page`); | ||||
|     if (savedPageSetting) { | ||||
|       try { | ||||
|         index = parseInt(savedPageSetting, 10); | ||||
|       } catch (e) { | ||||
|         index = 0; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     pageSize: limit, | ||||
|     pageIndex: index, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export type DataGridColumn<T> = ColumnDef<T> & SortingColumnDef<T> & { id: string }; | ||||
|  | ||||
| export type UseDataGridReturn = { | ||||
|   tableSettingsId: string; | ||||
|   pageInfo: PaginationState; | ||||
|   columnVisibility: VisibilityState; | ||||
|   setColumnVisibility: React.Dispatch<React.SetStateAction<VisibilityState>>; | ||||
|   sortBy: SortingState; | ||||
|   setSortBy: React.Dispatch<React.SetStateAction<SortingState>>; | ||||
|   onPaginationChange: OnChangeFn<PaginationState>; | ||||
| }; | ||||
|  | ||||
| export type UseDataGridProps = { | ||||
|   tableSettingsId: string; | ||||
|   defaultSortBy?: SortingState; | ||||
| }; | ||||
|  | ||||
| export const useDataGrid = ({ tableSettingsId, defaultSortBy }: UseDataGridProps): UseDataGridReturn => { | ||||
|   const [sortBy, setSortBy] = React.useState<SortingState>(defaultSortBy ?? []); | ||||
|   const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({}); | ||||
|   const [pageInfo, setPageInfo] = React.useState<PaginationState>(getDefaultSettings(tableSettingsId)); | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     if (tableSettingsId) { | ||||
|       localStorage.setItem(`${tableSettingsId}.page`, String(pageInfo.pageIndex)); | ||||
|       if (tableSettingsId) localStorage.setItem(`${tableSettingsId}`, String(pageInfo.pageSize)); | ||||
|     } | ||||
|   }, [pageInfo.pageIndex, pageInfo.pageSize]); | ||||
|  | ||||
|   return React.useMemo( | ||||
|     () => ({ | ||||
|       tableSettingsId, | ||||
|       pageInfo, | ||||
|       sortBy, | ||||
|       setSortBy, | ||||
|       columnVisibility, | ||||
|       setColumnVisibility, | ||||
|       onPaginationChange: setPageInfo, | ||||
|     }), | ||||
|     [pageInfo, columnVisibility, sortBy], | ||||
|   ); | ||||
| }; | ||||
| @@ -1,18 +1,19 @@ | ||||
| import React from 'react'; | ||||
| import { Tooltip } from '@chakra-ui/react'; | ||||
| import { compactDate, formatDaysAgo } from 'helpers/dateFormatting'; | ||||
| import { compactDate, formatDaysAgo, formatDaysAgoCompact } from 'helpers/dateFormatting'; | ||||
|  | ||||
| type Props = { date?: number; hidePrefix?: boolean }; | ||||
| type Props = { date?: number; hidePrefix?: boolean; isCompact?: boolean }; | ||||
|  | ||||
| const getDaysAgo = ({ date, hidePrefix }: { date?: number; hidePrefix?: boolean }) => { | ||||
| const getDaysAgo = ({ date, hidePrefix, isCompact }: { date?: number; hidePrefix?: boolean; isCompact?: boolean }) => { | ||||
|   if (!date || date === 0) return '-'; | ||||
|  | ||||
|   if (isCompact) | ||||
|     return hidePrefix ? formatDaysAgoCompact(date).split(' ').slice(1).join(' ') : formatDaysAgoCompact(date); | ||||
|   return hidePrefix ? formatDaysAgo(date).split(' ').slice(1).join(' ') : formatDaysAgo(date); | ||||
| }; | ||||
|  | ||||
| const FormattedDate = ({ date, hidePrefix }: Props) => ( | ||||
| const FormattedDate = ({ date, hidePrefix, isCompact }: Props) => ( | ||||
|   <Tooltip hasArrow placement="top" label={compactDate(date ?? 0)}> | ||||
|     {getDaysAgo({ date, hidePrefix })} | ||||
|     {getDaysAgo({ date, hidePrefix, isCompact })} | ||||
|   </Tooltip> | ||||
| ); | ||||
|  | ||||
|   | ||||
| @@ -1,14 +1,16 @@ | ||||
| import React, { useMemo } from 'react'; | ||||
| import { Box, BoxProps } from '@chakra-ui/react'; | ||||
| import { bytesString } from 'helpers/stringHelper'; | ||||
|  | ||||
| const DataCell: React.FC<{ bytes?: number }> = ({ bytes }) => { | ||||
| type Props = { bytes?: number; showZerosAs?: string; boxProps?: BoxProps }; | ||||
| const DataCell = ({ bytes, showZerosAs, boxProps }: Props) => { | ||||
|   const data = useMemo(() => { | ||||
|     if (bytes === undefined) return '-'; | ||||
|  | ||||
|     if (showZerosAs && bytes === 0) return showZerosAs; | ||||
|     return bytesString(bytes); | ||||
|   }, [bytes]); | ||||
|  | ||||
|   return <div>{data}</div>; | ||||
|   return <Box {...boxProps}>{data}</Box>; | ||||
| }; | ||||
|  | ||||
| export default React.memo(DataCell); | ||||
|   | ||||
							
								
								
									
										17
									
								
								src/components/TableCells/DurationCell/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/components/TableCells/DurationCell/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| import React, { useMemo } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { secondsDuration } from 'helpers/dateFormatting'; | ||||
|  | ||||
| const DurationCell: React.FC<{ seconds?: number }> = ({ seconds }) => { | ||||
|   const { t } = useTranslation(); | ||||
|  | ||||
|   const data = useMemo(() => { | ||||
|     if (seconds === undefined) return '-'; | ||||
|  | ||||
|     return secondsDuration(seconds, t); | ||||
|   }, [seconds]); | ||||
|  | ||||
|   return <div>{data}</div>; | ||||
| }; | ||||
|  | ||||
| export default React.memo(DurationCell); | ||||
| @@ -1,13 +1,20 @@ | ||||
| import React, { useMemo } from 'react'; | ||||
| import React from 'react'; | ||||
| import { Box, BoxProps } from '@chakra-ui/react'; | ||||
|  | ||||
| const NumberCell = ({ value }: { value?: number }) => { | ||||
|   const data = useMemo(() => { | ||||
| type Props = { | ||||
|   value?: number; | ||||
|   boxProps?: BoxProps; | ||||
|   showZerosAs?: string; | ||||
| }; | ||||
|  | ||||
| const NumberCell = ({ value, boxProps, showZerosAs }: Props) => { | ||||
|   const getData = () => { | ||||
|     if (value === undefined) return '-'; | ||||
|  | ||||
|     if (value === 0 && showZerosAs) return showZerosAs; | ||||
|     return value.toLocaleString(); | ||||
|   }, [value]); | ||||
|   }; | ||||
|  | ||||
|   return <div>{data}</div>; | ||||
|   return <Box {...boxProps}>{getData()}</Box>; | ||||
| }; | ||||
|  | ||||
| export default React.memo(NumberCell); | ||||
|   | ||||
| @@ -126,3 +126,40 @@ export const dateForFilename = (dateString: number) => { | ||||
|     date.getDate(), | ||||
|   )}_${twoDigitNumber(date.getHours())}h${twoDigitNumber(date.getMinutes())}m${twoDigitNumber(date.getSeconds())}s`; | ||||
| }; | ||||
|  | ||||
| export const formatDaysAgoCompact = (d1: number, d2: number = new Date().getTime()) => { | ||||
|   try { | ||||
|     const convertedTimestamp = unixToDateString(d1); | ||||
|     const date = new Date(convertedTimestamp).getTime(); | ||||
|     const elapsed = date - d2; | ||||
|  | ||||
|     for (const key of Object.keys(UNITS)) { | ||||
|       if ( | ||||
|         Math.abs(elapsed) > UNITS[key as 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second'] || | ||||
|         key === 'second' | ||||
|       ) { | ||||
|         const result = RTF.format( | ||||
|           Math.round(elapsed / UNITS[key as 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second']), | ||||
|           key as Intl.RelativeTimeFormatUnit, | ||||
|         ); | ||||
|         return result | ||||
|           .replace(' years', 'y') | ||||
|           .replace(' year', 'y') | ||||
|           .replace(' months', 'm') | ||||
|           .replace(' month', 'm') | ||||
|           .replace(' days', 'd') | ||||
|           .replace(' day', 'd') | ||||
|           .replace(' hours', 'h') | ||||
|           .replace(' hour', 'h') | ||||
|           .replace(' minutes', 'm') | ||||
|           .replace(' minute', 'm') | ||||
|           .replace(' seconds', 's') | ||||
|           .replace(' second', 's'); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return compactDate(date); | ||||
|   } catch { | ||||
|     return '-'; | ||||
|   } | ||||
| }; | ||||
|   | ||||
| @@ -51,23 +51,30 @@ export type DeviceWithStatus = { | ||||
|   entity: string; | ||||
|   firmware: string; | ||||
|   fwUpdatePolicy: string; | ||||
|   hasGPS: boolean; | ||||
|   hasRADIUSSessions: number | boolean; | ||||
|   ipAddress: string; | ||||
|   lastConfigurationChange: number; | ||||
|   lastConfigurationDownload: number; | ||||
|   lastContact: number | string; | ||||
|   lastFWUpdate: number; | ||||
|   load: number; | ||||
|   locale: string; | ||||
|   location: string; | ||||
|   macAddress: string; | ||||
|   manufacturer: string; | ||||
|   memoryUsed: number; | ||||
|   messageCount: number; | ||||
|   modified: number; | ||||
|   notes: Note[]; | ||||
|   owner: string; | ||||
|   sanity: number; | ||||
|   started: number; | ||||
|   restrictedDevice: boolean; | ||||
|   rxBytes: number; | ||||
|   serialNumber: string; | ||||
|   subscriber: string; | ||||
|   temperature: number; | ||||
|   txBytes: number; | ||||
|   venue: string; | ||||
|   verifiedCertificate: string; | ||||
|   | ||||
| @@ -55,4 +55,5 @@ export const useGetTag = ({ serialNumber, onError }: { serialNumber?: string; on | ||||
|   useQuery(['tag', serialNumber], () => getTag(serialNumber), { | ||||
|     enabled: serialNumber !== undefined && serialNumber !== '', | ||||
|     onError, | ||||
|     staleTime: 1000 * 60 * 5, | ||||
|   }); | ||||
|   | ||||
							
								
								
									
										54
									
								
								src/hooks/useContainerDimensions.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/hooks/useContainerDimensions.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| import * as React from 'react'; | ||||
|  | ||||
| const roundToNearest = (num: number, precision: number) => { | ||||
|   const factor = 1 / precision; | ||||
|   return Math.round(num * factor) / factor; | ||||
| }; | ||||
|  | ||||
| export type UseContainerDimensionsProps = { | ||||
|   precision?: 10 | 100 | 1000; | ||||
| }; | ||||
|  | ||||
| export const useContainerDimensions = ({ precision }: UseContainerDimensionsProps) => { | ||||
|   const ref = React.useRef<HTMLDivElement>(null); | ||||
|   const [dimensions, setDimensions] = React.useState({ width: 0, height: 0 }); | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     const getDimensions = () => ({ | ||||
|       width: (ref && ref.current?.offsetWidth) || 0, | ||||
|       height: (ref && ref.current?.offsetHeight) || 0, | ||||
|     }); | ||||
|  | ||||
|     const handleResize = () => { | ||||
|       const { width, height } = getDimensions(); | ||||
|       if (!precision) { | ||||
|         if (dimensions.width !== width && dimensions.height !== height) setDimensions({ width, height }); | ||||
|       } else { | ||||
|         const newDimensions = { width, height }; | ||||
|         newDimensions.width = roundToNearest(newDimensions.width, precision); | ||||
|         newDimensions.height = roundToNearest(newDimensions.height, precision); | ||||
|         if (newDimensions.width !== dimensions.width || newDimensions.height !== dimensions.height) { | ||||
|           setDimensions(newDimensions); | ||||
|         } | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     if (ref.current) { | ||||
|       handleResize(); | ||||
|     } | ||||
|  | ||||
|     window.addEventListener('resize', handleResize); | ||||
|  | ||||
|     return () => { | ||||
|       window.removeEventListener('resize', handleResize); | ||||
|     }; | ||||
|   }, [ref, dimensions]); | ||||
|  | ||||
|   return React.useMemo( | ||||
|     () => ({ | ||||
|       dimensions, | ||||
|       ref, | ||||
|     }), | ||||
|     [dimensions.height, dimensions.width], | ||||
|   ); | ||||
| }; | ||||
| @@ -28,7 +28,6 @@ const DeviceDetails = ({ serialNumber }: Props) => { | ||||
|       ? getDevice.data?.devicePassword | ||||
|       : 'openwifi', | ||||
|   ); | ||||
|  | ||||
|   const getPassword = () => { | ||||
|     if (!getDevice.data) return '-'; | ||||
|     if (isShowingPassword) { | ||||
|   | ||||
| @@ -1,5 +1,15 @@ | ||||
| import * as React from 'react'; | ||||
| import { Box, Button, Flex, FormControl, FormLabel, useDisclosure } from '@chakra-ui/react'; | ||||
| import { | ||||
|   Box, | ||||
|   Button, | ||||
|   Flex, | ||||
|   FormControl, | ||||
|   FormLabel, | ||||
|   Icon, | ||||
|   Tooltip, | ||||
|   useColorModeValue, | ||||
|   useDisclosure, | ||||
| } from '@chakra-ui/react'; | ||||
| import { Wrapper } from '@googlemaps/react-wrapper'; | ||||
| import { Globe } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| @@ -11,13 +21,15 @@ import { useGetDeviceLastStats } from 'hooks/Network/Statistics'; | ||||
|  | ||||
| type Props = { | ||||
|   serialNumber: string; | ||||
|   isCompact?: boolean; | ||||
| }; | ||||
|  | ||||
| const LocationDisplayButton = ({ serialNumber }: Props) => { | ||||
| const LocationDisplayButton = ({ serialNumber, isCompact }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const { isOpen, onOpen, onClose } = useDisclosure(); | ||||
|   const getGoogleApiKey = useGetSystemSecret({ secret: 'google.maps.apikey' }); | ||||
|   const getLastStats = useGetDeviceLastStats({ serialNumber }); | ||||
|   const iconColor = useColorModeValue('blue.500', 'blue.200'); | ||||
|  | ||||
|   const location: google.maps.LatLngLiteral | undefined = React.useMemo(() => { | ||||
|     if (!getLastStats.data?.gps) return undefined; | ||||
| @@ -38,9 +50,15 @@ const LocationDisplayButton = ({ serialNumber }: Props) => { | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {isCompact ? ( | ||||
|         <Tooltip label={t('locations.view_gps')}> | ||||
|           <Icon as={Globe} boxSize={6} onClick={onOpen} color={iconColor} cursor="pointer" /> | ||||
|         </Tooltip> | ||||
|       ) : ( | ||||
|         <Button variant="link" onClick={onOpen} rightIcon={<Globe size={20} />} colorScheme="blue"> | ||||
|           {t('locations.view_gps')} | ||||
|         </Button> | ||||
|       )} | ||||
|       <Modal isOpen={isOpen} onClose={onClose} title={t('locations.one')}> | ||||
|         <Box w="100%" h="100%"> | ||||
|           <Flex mb={4}> | ||||
|   | ||||
							
								
								
									
										19
									
								
								src/pages/Devices/ListCard/GpsCell.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/pages/Devices/ListCard/GpsCell.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| import * as React from 'react'; | ||||
| import { Center } from '@chakra-ui/react'; | ||||
| import { DeviceWithStatus } from 'hooks/Network/Devices'; | ||||
| import LocationDisplayButton from 'pages/Device/LocationDisplayButton'; | ||||
|  | ||||
| type Props = { | ||||
|   device: DeviceWithStatus; | ||||
| }; | ||||
| const DeviceTableGpsCell = ({ device }: Props) => { | ||||
|   if (!device.hasGPS) return <Center>-</Center>; | ||||
|  | ||||
|   return ( | ||||
|     <Center> | ||||
|       <LocationDisplayButton serialNumber={device.serialNumber} isCompact /> | ||||
|     </Center> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default DeviceTableGpsCell; | ||||
							
								
								
									
										40
									
								
								src/pages/Devices/ListCard/ProvisioningStatusCell.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/pages/Devices/ListCard/ProvisioningStatusCell.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| import * as React from 'react'; | ||||
| import { Link } from '@chakra-ui/react'; | ||||
| import { DeviceWithStatus } from 'hooks/Network/Devices'; | ||||
| import { useGetProvUi } from 'hooks/Network/Endpoints'; | ||||
| import { useGetTag } from 'hooks/Network/Inventory'; | ||||
|  | ||||
| type Props = { | ||||
|   device: DeviceWithStatus; | ||||
| }; | ||||
| const ProvisioningStatusCell = ({ device }: Props) => { | ||||
|   const getProvUi = useGetProvUi(); | ||||
|   const getTag = useGetTag({ serialNumber: device.serialNumber }); | ||||
|   const goToProvUi = (dir: string) => `${getProvUi.data}/#/${dir}`; | ||||
|  | ||||
|   if (getTag.data?.extendedInfo?.entity?.name) { | ||||
|     return ( | ||||
|       <Link isExternal href={goToProvUi(`entity/${getTag.data?.entity}`)}> | ||||
|         {getTag.data?.extendedInfo?.entity?.name} | ||||
|       </Link> | ||||
|     ); | ||||
|   } | ||||
|   if (getTag.data?.extendedInfo?.venue?.name) { | ||||
|     return ( | ||||
|       <Link isExternal href={goToProvUi(`venue/${getTag.data?.venue}`)}> | ||||
|         {getTag.data?.extendedInfo?.venue?.name} | ||||
|       </Link> | ||||
|     ); | ||||
|   } | ||||
|   if (getTag.data?.extendedInfo?.subscriber?.name) { | ||||
|     return ( | ||||
|       <Link isExternal href={goToProvUi(`venue/${getTag.data?.subscriber}`)}> | ||||
|         {getTag.data?.extendedInfo?.subscriber?.name} | ||||
|       </Link> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return <span>-</span>; | ||||
| }; | ||||
|  | ||||
| export default ProvisioningStatusCell; | ||||
							
								
								
									
										17
									
								
								src/pages/Devices/ListCard/Uptime.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/pages/Devices/ListCard/Uptime.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| import * as React from 'react'; | ||||
| import DurationCell from 'components/TableCells/DurationCell'; | ||||
| import { DeviceWithStatus } from 'hooks/Network/Devices'; | ||||
|  | ||||
| type Props = { | ||||
|   device: DeviceWithStatus; | ||||
| }; | ||||
| const DeviceUptimeCell = ({ device }: Props) => { | ||||
|   if (!device.connected || device.started === 0) return <span>-</span>; | ||||
|  | ||||
|   // Get the uptime in seconds from device.started which is UNIX timestamp | ||||
|   const uptime = Math.floor(Date.now() / 1000 - device.started); | ||||
|  | ||||
|   return <DurationCell seconds={uptime} />; | ||||
| }; | ||||
|  | ||||
| export default DeviceUptimeCell; | ||||
| @@ -1,20 +1,29 @@ | ||||
| import * as React from 'react'; | ||||
| import { Box, Heading, Image, Link, Spacer, Tooltip, useDisclosure } from '@chakra-ui/react'; | ||||
| import { LockSimple } from 'phosphor-react'; | ||||
| import { Box, Center, Image, Link, Tag, TagLabel, TagRightIcon, Tooltip, useDisclosure } from '@chakra-ui/react'; | ||||
| import { | ||||
|   CheckCircle, | ||||
|   Heart, | ||||
|   HeartBreak, | ||||
|   LockSimple, | ||||
|   ThermometerCold, | ||||
|   ThermometerHot, | ||||
|   WarningCircle, | ||||
| } from 'phosphor-react'; | ||||
| import ReactCountryFlag from 'react-country-flag'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
| import Actions from './Actions'; | ||||
| import DeviceListFirmwareButton from './FirmwareButton'; | ||||
| import DeviceTableGpsCell from './GpsCell'; | ||||
| import AP from './icons/AP.png'; | ||||
| import IOT from './icons/IOT.png'; | ||||
| import MESH from './icons/MESH.png'; | ||||
| import SWITCH from './icons/SWITCH.png'; | ||||
| import { RefreshButton } from 'components/Buttons/RefreshButton'; | ||||
| import ProvisioningStatusCell from './ProvisioningStatusCell'; | ||||
| import DeviceUptimeCell from './Uptime'; | ||||
| import { CardBody } from 'components/Containers/Card/CardBody'; | ||||
| import { CardHeader } from 'components/Containers/Card/CardHeader'; | ||||
| import { ColumnPicker } from 'components/DataTables/ColumnPicker'; | ||||
| import { DataTable } from 'components/DataTables/DataTable'; | ||||
| import { DataGrid } from 'components/DataTables/DataGrid'; | ||||
| import { DataGridColumn, useDataGrid } from 'components/DataTables/DataGrid/useDataGrid'; | ||||
| import DeviceSearchBar from 'components/DeviceSearchBar'; | ||||
| import FormattedDate from 'components/InformationDisplays/FormattedDate'; | ||||
| import { ConfigureModal } from 'components/Modals/ConfigureModal'; | ||||
| @@ -30,8 +39,16 @@ import DataCell from 'components/TableCells/DataCell'; | ||||
| import NumberCell from 'components/TableCells/NumberCell'; | ||||
| import { DeviceWithStatus, useGetDeviceCount, useGetDevices } from 'hooks/Network/Devices'; | ||||
| import { FirmwareAgeResponse, useGetFirmwareAges } from 'hooks/Network/Firmware'; | ||||
| import { Column, PageInfo } from 'models/Table'; | ||||
|  | ||||
| const fourDigitNumber = (v: number) => { | ||||
|   if (v === 0) { | ||||
|     return '0.00'; | ||||
|   } | ||||
|   const str = v.toString(); | ||||
|   const fourthChar = str.charAt(3); | ||||
|   if (fourthChar === '.') return `${str.slice(0, 3)}`; | ||||
|   return `${str.slice(0, 4)}`; | ||||
| }; | ||||
| const ICON_STYLE = { width: '24px', height: '24px', borderRadius: '20px' }; | ||||
|  | ||||
| const ICONS = { | ||||
| @@ -52,8 +69,6 @@ const DeviceListCard = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const navigate = useNavigate(); | ||||
|   const [serialNumber, setSerialNumber] = React.useState<string>(''); | ||||
|   const [hiddenColumns, setHiddenColumns] = React.useState<string[]>([]); | ||||
|   const [pageInfo, setPageInfo] = React.useState<PageInfo | undefined>(undefined); | ||||
|   const scanModalProps = useDisclosure(); | ||||
|   const resetModalProps = useDisclosure(); | ||||
|   const upgradeModalProps = useDisclosure(); | ||||
| @@ -63,9 +78,13 @@ const DeviceListCard = () => { | ||||
|   const configureModalProps = useDisclosure(); | ||||
|   const rebootModalProps = useDisclosure(); | ||||
|   const scriptModal = useScriptModal(); | ||||
|   const tableController = useDataGrid({ tableSettingsId: 'gateway.devices.table' }); | ||||
|   const getCount = useGetDeviceCount({ enabled: true }); | ||||
|   const getDevices = useGetDevices({ | ||||
|     pageInfo, | ||||
|     pageInfo: { | ||||
|       limit: tableController.pageInfo.pageSize, | ||||
|       index: tableController.pageInfo.pageIndex, | ||||
|     }, | ||||
|     enabled: true, | ||||
|   }); | ||||
|   const getAges = useGetFirmwareAges({ | ||||
| @@ -171,7 +190,7 @@ const DeviceListCard = () => { | ||||
|   const dataCell = React.useCallback( | ||||
|     (v: number) => ( | ||||
|       <Box textAlign="right"> | ||||
|         <DataCell bytes={v} /> | ||||
|         <DataCell bytes={v} showZerosAs="-" /> | ||||
|       </Box> | ||||
|     ), | ||||
|     [], | ||||
| @@ -185,12 +204,27 @@ const DeviceListCard = () => { | ||||
|       ), | ||||
|     [], | ||||
|   ); | ||||
|   const compactDateCell = React.useCallback( | ||||
|     (v?: number | string, hidePrefix?: boolean) => | ||||
|       v !== undefined && typeof v === 'number' && v !== 0 ? ( | ||||
|         <FormattedDate date={v as number} hidePrefix={hidePrefix} isCompact /> | ||||
|       ) : ( | ||||
|         '-' | ||||
|       ), | ||||
|     [], | ||||
|   ); | ||||
|   const firmwareCell = React.useCallback( | ||||
|     (device: DeviceWithStatus & { age?: FirmwareAgeResponse }) => ( | ||||
|       <DeviceListFirmwareButton device={device} age={device.age} onOpenUpgrade={onOpenUpgradeModal} /> | ||||
|     ), | ||||
|     [getAges], | ||||
|   ); | ||||
|   const provCell = React.useCallback( | ||||
|     (device: DeviceWithStatus) => | ||||
|       device.subscriber || device.entity || device.venue ? <ProvisioningStatusCell device={device} /> : '-', | ||||
|     [], | ||||
|   ); | ||||
|   const uptimeCell = React.useCallback((device: DeviceWithStatus) => <DeviceUptimeCell device={device} />, []); | ||||
|   const localeCell = React.useCallback( | ||||
|     (device: DeviceWithStatus) => ( | ||||
|       <Tooltip label={`${device.locale !== '' ? `${device.locale} - ` : ''}${device.ipAddress}`} placement="top"> | ||||
| @@ -198,13 +232,25 @@ const DeviceListCard = () => { | ||||
|           {device.locale !== '' && device.ipAddress !== '' && ( | ||||
|             <ReactCountryFlag style={ICON_STYLE} countryCode={device.locale} svg /> | ||||
|           )} | ||||
|           {`  ${device.ipAddress}`} | ||||
|           {`  ${device.ipAddress.length > 0 ? device.ipAddress : '-'}`} | ||||
|         </Box> | ||||
|       </Tooltip> | ||||
|     ), | ||||
|     [], | ||||
|   ); | ||||
|   const numberCell = React.useCallback((v?: number) => <NumberCell value={v !== undefined ? v : 0} />, []); | ||||
|   const gpsCell = React.useCallback((device: DeviceWithStatus) => <DeviceTableGpsCell device={device} />, []); | ||||
|   const numberCell = React.useCallback( | ||||
|     (v?: number) => ( | ||||
|       <NumberCell | ||||
|         value={v !== undefined ? v : 0} | ||||
|         showZerosAs="-" | ||||
|         boxProps={{ | ||||
|           textAlign: 'right', | ||||
|         }} | ||||
|       /> | ||||
|     ), | ||||
|     [], | ||||
|   ); | ||||
|   const actionCell = React.useCallback( | ||||
|     (device: DeviceWithStatus) => ( | ||||
|       <Actions | ||||
| @@ -224,135 +270,358 @@ const DeviceListCard = () => { | ||||
|     [], | ||||
|   ); | ||||
|  | ||||
|   const columns: Column<DeviceWithStatus>[] = React.useMemo( | ||||
|     (): Column<DeviceWithStatus>[] => [ | ||||
|   const sanityCell = React.useCallback((device: DeviceWithStatus) => { | ||||
|     if (!device.connected) return <Center>-</Center>; | ||||
|  | ||||
|     let colorScheme = 'red'; | ||||
|     if (device.sanity >= 80) colorScheme = 'yellow'; | ||||
|     if (device.sanity === 100) colorScheme = 'green'; | ||||
|  | ||||
|     return ( | ||||
|       <Center> | ||||
|         <Tag borderRadius="full" variant="subtle" colorScheme={colorScheme}> | ||||
|           <TagLabel>{device.sanity}%</TagLabel> | ||||
|           <TagRightIcon marginStart="0.1rem" as={colorScheme === 'green' ? Heart : HeartBreak} /> | ||||
|         </Tag> | ||||
|       </Center> | ||||
|     ); | ||||
|   }, []); | ||||
|  | ||||
|   const loadCell = React.useCallback((device: DeviceWithStatus) => { | ||||
|     if (!device.connected) return <Center>-</Center>; | ||||
|  | ||||
|     let colorScheme = 'red'; | ||||
|     if (device.load <= 20) colorScheme = 'yellow'; | ||||
|     if (device.load <= 5) colorScheme = 'green'; | ||||
|  | ||||
|     return ( | ||||
|       <Center> | ||||
|         <Tag borderRadius="full" variant="subtle" colorScheme={colorScheme}> | ||||
|           <TagLabel>{fourDigitNumber(device.load)}%</TagLabel> | ||||
|           <TagRightIcon marginStart="0.1rem" as={colorScheme === 'green' ? CheckCircle : WarningCircle} /> | ||||
|         </Tag> | ||||
|       </Center> | ||||
|     ); | ||||
|   }, []); | ||||
|   const memoryCell = React.useCallback((device: DeviceWithStatus) => { | ||||
|     if (!device.connected) return <Center>-</Center>; | ||||
|  | ||||
|     let colorScheme = 'red'; | ||||
|     if (device.memoryUsed <= 85) colorScheme = 'yellow'; | ||||
|     if (device.memoryUsed <= 60) colorScheme = 'green'; | ||||
|  | ||||
|     return ( | ||||
|       <Center> | ||||
|         <Tag borderRadius="full" variant="subtle" colorScheme={colorScheme}> | ||||
|           <TagLabel>{fourDigitNumber(device.memoryUsed)}%</TagLabel> | ||||
|           <TagRightIcon marginStart="0.1rem" as={colorScheme === 'green' ? CheckCircle : WarningCircle} /> | ||||
|         </Tag> | ||||
|       </Center> | ||||
|     ); | ||||
|   }, []); | ||||
|   const temperatureCell = React.useCallback((device: DeviceWithStatus) => { | ||||
|     if (!device.connected || device.temperature === 0) return <Center>-</Center>; | ||||
|  | ||||
|     let colorScheme = 'red'; | ||||
|     if (device.temperature <= 85) colorScheme = 'yellow'; | ||||
|     if (device.temperature <= 75) colorScheme = 'green'; | ||||
|  | ||||
|     return ( | ||||
|       <Center> | ||||
|         <Tag borderRadius="full" variant="subtle" colorScheme={colorScheme}> | ||||
|           <TagLabel>{fourDigitNumber(device.temperature)}°C</TagLabel> | ||||
|           <TagRightIcon marginStart="0.1rem" as={colorScheme === 'green' ? ThermometerCold : ThermometerHot} /> | ||||
|         </Tag> | ||||
|       </Center> | ||||
|     ); | ||||
|   }, []); | ||||
|  | ||||
|   const columns: DataGridColumn<DeviceWithStatus>[] = React.useMemo( | ||||
|     (): DataGridColumn<DeviceWithStatus>[] => [ | ||||
|       { | ||||
|         id: 'badge', | ||||
|         Header: '', | ||||
|         Footer: '', | ||||
|         accessor: 'badge', | ||||
|         Cell: (v) => badgeCell(v.cell.row.original), | ||||
|         header: '', | ||||
|         footer: '', | ||||
|         accessorKey: 'badge', | ||||
|         cell: (v) => badgeCell(v.cell.row.original), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           customWidth: '35px', | ||||
|           alwaysShow: true, | ||||
|         disableSortBy: true, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: 'serialNumber', | ||||
|         Header: t('inventory.serial_number'), | ||||
|         Footer: '', | ||||
|         accessor: 'serialNumber', | ||||
|         Cell: (v) => serialCell(v.cell.row.original), | ||||
|         header: t('inventory.serial_number'), | ||||
|         footer: '', | ||||
|         accessorKey: 'serialNumber', | ||||
|         cell: (v) => serialCell(v.cell.row.original), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           alwaysShow: true, | ||||
|           customMaxWidth: '200px', | ||||
|           customWidth: '130px', | ||||
|           customMinWidth: '130px', | ||||
|         disableSortBy: true, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: 'sanity', | ||||
|         header: t('devices.sanity'), | ||||
|         footer: '', | ||||
|         cell: (v) => sanityCell(v.cell.row.original), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           headerStyleProps: { | ||||
|             textAlign: 'center', | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: 'memory', | ||||
|         header: t('analytics.memory'), | ||||
|         footer: '', | ||||
|         cell: (v) => memoryCell(v.cell.row.original), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           headerStyleProps: { | ||||
|             textAlign: 'center', | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: 'load', | ||||
|         header: 'Load', | ||||
|         footer: '', | ||||
|         cell: (v) => loadCell(v.cell.row.original), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           headerOptions: { | ||||
|             tooltip: 'CPU Load', | ||||
|           }, | ||||
|           headerStyleProps: { | ||||
|             textAlign: 'center', | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: 'temperature', | ||||
|         header: 'Temp', | ||||
|         footer: '', | ||||
|         cell: (v) => temperatureCell(v.cell.row.original), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           headerOptions: { | ||||
|             tooltip: t('analytics.temperature'), | ||||
|           }, | ||||
|           columnSelectorOptions: { | ||||
|             label: t('analytics.temperature'), | ||||
|           }, | ||||
|           headerStyleProps: { | ||||
|             textAlign: 'center', | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: 'firmware', | ||||
|         Header: t('commands.revision'), | ||||
|         Footer: '', | ||||
|         accessor: 'firmware', | ||||
|         Cell: (v) => firmwareCell(v.cell.row.original), | ||||
|         header: t('commands.revision'), | ||||
|         footer: '', | ||||
|         accessorKey: 'firmware', | ||||
|         cell: (v) => firmwareCell(v.cell.row.original), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           stopPropagation: true, | ||||
|           customWidth: '50px', | ||||
|         disableSortBy: true, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: 'compatible', | ||||
|         Header: t('common.type'), | ||||
|         Footer: '', | ||||
|         accessor: 'compatible', | ||||
|         customWidth: '50px', | ||||
|         disableSortBy: true, | ||||
|         header: t('common.type'), | ||||
|         footer: '', | ||||
|         accessorKey: 'compatible', | ||||
|         enableSorting: false, | ||||
|       }, | ||||
|       { | ||||
|         id: 'IP', | ||||
|         Header: 'IP', | ||||
|         Footer: '', | ||||
|         accessor: 'IP', | ||||
|         Cell: (v) => localeCell(v.cell.row.original), | ||||
|         disableSortBy: true, | ||||
|         header: 'IP', | ||||
|         footer: '', | ||||
|         accessorKey: 'IP', | ||||
|         cell: (v) => localeCell(v.cell.row.original), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           customMaxWidth: '140px', | ||||
|           customWidth: '130px', | ||||
|           customMinWidth: '130px', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: 'provisioning', | ||||
|         header: 'Provisioning', | ||||
|         footer: '', | ||||
|         cell: (v) => provCell(v.cell.row.original), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           stopPropagation: true, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: 'radius', | ||||
|         header: 'Rad', | ||||
|         footer: '', | ||||
|         accessorKey: 'hasRADIUSSessions', | ||||
|         cell: (v) => | ||||
|           numberCell( | ||||
|             typeof v.cell.row.original.hasRADIUSSessions === 'number' ? v.cell.row.original.hasRADIUSSessions : 0, | ||||
|           ), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           customWidth: '40px', | ||||
|           customMinWidth: '40px', | ||||
|           columnSelectorOptions: { | ||||
|             label: 'Radius Sessions', | ||||
|           }, | ||||
|           headerOptions: { | ||||
|             tooltip: 'Current active radius sessions', | ||||
|           }, | ||||
|           headerStyleProps: { | ||||
|             textAlign: 'right', | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: 'GPS', | ||||
|         header: 'GPS', | ||||
|         footer: '', | ||||
|         cell: (v) => gpsCell(v.cell.row.original), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           customWidth: '32px', | ||||
|           stopPropagation: true, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: 'uptime', | ||||
|         header: t('system.uptime'), | ||||
|         footer: '', | ||||
|         cell: (v) => uptimeCell(v.cell.row.original), | ||||
|         enableSorting: false, | ||||
|       }, | ||||
|       { | ||||
|         id: 'lastContact', | ||||
|         Header: t('analytics.last_contact'), | ||||
|         Footer: '', | ||||
|         accessor: 'lastContact', | ||||
|         Cell: (v) => dateCell(v.cell.row.original.lastContact), | ||||
|         disableSortBy: true, | ||||
|         header: t('analytics.last_contact'), | ||||
|         footer: '', | ||||
|         accessorKey: 'lastContact', | ||||
|         cell: (v) => dateCell(v.cell.row.original.lastContact), | ||||
|         enableSorting: false, | ||||
|       }, | ||||
|       { | ||||
|         id: 'lastFWUpdate', | ||||
|         Header: t('controller.devices.last_upgrade'), | ||||
|         Footer: '', | ||||
|         accessor: 'lastFWUpdate', | ||||
|         Cell: (v) => dateCell(v.cell.row.original.lastFWUpdate), | ||||
|         disableSortBy: true, | ||||
|         header: t('controller.devices.last_upgrade'), | ||||
|         footer: '', | ||||
|         accessorKey: 'lastFWUpdate', | ||||
|         cell: (v) => dateCell(v.cell.row.original.lastFWUpdate), | ||||
|         enableSorting: false, | ||||
|       }, | ||||
|       { | ||||
|         id: 'rxBytes', | ||||
|         Header: 'Rx', | ||||
|         Footer: '', | ||||
|         accessor: 'rxBytes', | ||||
|         Cell: (v) => dataCell(v.cell.row.original.rxBytes), | ||||
|         header: 'Rx', | ||||
|         footer: '', | ||||
|         accessorKey: 'rxBytes', | ||||
|         cell: (v) => dataCell(v.cell.row.original.rxBytes), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           customWidth: '50px', | ||||
|         disableSortBy: true, | ||||
|           headerStyleProps: { | ||||
|             textAlign: 'right', | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: 'txBytes', | ||||
|         Header: 'Tx', | ||||
|         Footer: '', | ||||
|         accessor: 'txBytes', | ||||
|         Cell: (v) => dataCell(v.cell.row.original.txBytes), | ||||
|         customWidth: '50px', | ||||
|         disableSortBy: true, | ||||
|         header: 'Tx', | ||||
|         footer: '', | ||||
|         accessorKey: 'txBytes', | ||||
|         cell: (v) => dataCell(v.cell.row.original.txBytes), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           customWidth: '40px', | ||||
|           customMinWidth: '40px', | ||||
|           headerStyleProps: { | ||||
|             textAlign: 'right', | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: '2G', | ||||
|         Header: '2G', | ||||
|         Footer: '', | ||||
|         accessor: 'associations_2G', | ||||
|         Cell: (v) => numberCell(v.cell.row.original.associations_2G), | ||||
|         customWidth: '50px', | ||||
|         disableSortBy: true, | ||||
|         header: '2G', | ||||
|         footer: '', | ||||
|         accessorKey: 'associations_2G', | ||||
|         cell: (v) => numberCell(v.cell.row.original.associations_2G), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           customWidth: '40px', | ||||
|           customMinWidth: '40px', | ||||
|           headerStyleProps: { | ||||
|             textAlign: 'right', | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: '5G', | ||||
|         Header: '5G', | ||||
|         Footer: '', | ||||
|         accessor: 'associations_5G', | ||||
|         Cell: (v) => numberCell(v.cell.row.original.associations_5G), | ||||
|         customWidth: '50px', | ||||
|         disableSortBy: true, | ||||
|         header: '5G', | ||||
|         footer: '', | ||||
|         accessorKey: 'associations_5G', | ||||
|         cell: (v) => numberCell(v.cell.row.original.associations_5G), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           customWidth: '40px', | ||||
|           customMinWidth: '40px', | ||||
|           headerStyleProps: { | ||||
|             textAlign: 'right', | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: '6G', | ||||
|         Header: '6G', | ||||
|         Footer: '', | ||||
|         accessor: 'associations_6G', | ||||
|         Cell: (v) => numberCell(v.cell.row.original.associations_6G), | ||||
|         customWidth: '50px', | ||||
|         disableSortBy: true, | ||||
|         header: '6G', | ||||
|         footer: '', | ||||
|         accessorKey: 'associations_6G', | ||||
|         cell: (v) => numberCell(v.cell.row.original.associations_6G), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           customWidth: '40px', | ||||
|           customMinWidth: '40px', | ||||
|           headerStyleProps: { | ||||
|             textAlign: 'right', | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: 'certificateExpiryDate', | ||||
|         Header: t('devices.certificate_expiry'), | ||||
|         Footer: '', | ||||
|         accessor: 'certificateExpiryDate', | ||||
|         Cell: (v) => dateCell(v.cell.row.original.certificateExpiryDate, true), | ||||
|         customWidth: '50px', | ||||
|         disableSortBy: true, | ||||
|         header: 'Exp', | ||||
|         footer: '', | ||||
|         accessorKey: 'certificateExpiryDate', | ||||
|         cell: (v) => compactDateCell(v.cell.row.original.certificateExpiryDate, true), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           columnSelectorOptions: { | ||||
|             label: 'Certificate Expiry', | ||||
|           }, | ||||
|           headerOptions: { | ||||
|             tooltip: 'Certificate Expiry Date', | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         id: 'actions', | ||||
|         Header: t('common.actions'), | ||||
|         Footer: '', | ||||
|         accessor: 'actions', | ||||
|         Cell: (v) => actionCell(v.cell.row.original), | ||||
|         header: t('common.actions'), | ||||
|         footer: '', | ||||
|         accessorKey: 'actions', | ||||
|         cell: (v) => actionCell(v.cell.row.original), | ||||
|         enableSorting: false, | ||||
|         meta: { | ||||
|           customWidth: '50px', | ||||
|           alwaysShow: true, | ||||
|         disableSortBy: true, | ||||
|         }, | ||||
|       }, | ||||
|     ], | ||||
|     [t, firmwareCell], | ||||
| @@ -368,50 +637,27 @@ const DeviceListCard = () => { | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <CardHeader px={4} pt={4}> | ||||
|         <Heading size="md" my="auto" mr={2}> | ||||
|           {getCount.data?.count} {t('devices.title')} | ||||
|         </Heading> | ||||
|         <DeviceSearchBar /> | ||||
|         <Spacer /> | ||||
|         <ColumnPicker | ||||
|           columns={columns as Column<unknown>[]} | ||||
|           hiddenColumns={hiddenColumns} | ||||
|           setHiddenColumns={setHiddenColumns} | ||||
|           preference="gateway.devices.table.hiddenColumns" | ||||
|         /> | ||||
|         <RefreshButton | ||||
|           onClick={() => { | ||||
|             getDevices.refetch(); | ||||
|             getCount.refetch(); | ||||
|           }} | ||||
|           isCompact | ||||
|           ml={2} | ||||
|           isFetching={getCount.isFetching || getDevices.isFetching} | ||||
|         /> | ||||
|       </CardHeader> | ||||
|       <CardBody p={4}> | ||||
|         <Box overflowX="auto" w="100%"> | ||||
|           <DataTable<DeviceWithStatus> | ||||
|             columns={ | ||||
|               columns.filter(({ id }) => !hiddenColumns.find((hidden) => hidden === id)) as { | ||||
|                 id: string; | ||||
|                 Header: string; | ||||
|                 Footer: string; | ||||
|                 accessor: string; | ||||
|               }[] | ||||
|             } | ||||
|             data={data ?? []} | ||||
|           <DataGrid<DeviceWithStatus> | ||||
|             controller={tableController} | ||||
|             header={{ | ||||
|               title: `${getCount.data?.count} ${t('devices.title')}`, | ||||
|               objectListed: t('devices.title'), | ||||
|               leftContent: <DeviceSearchBar />, | ||||
|             }} | ||||
|             columns={columns} | ||||
|             data={data} | ||||
|             isLoading={getCount.isFetching || getDevices.isFetching} | ||||
|             isManual | ||||
|             hiddenColumns={hiddenColumns} | ||||
|             obj={t('devices.title')} | ||||
|             count={getCount.data?.count || 0} | ||||
|             // @ts-ignore | ||||
|             setPageInfo={setPageInfo} | ||||
|             saveSettingsId="gateway.devices.table" | ||||
|             onRowClick={(device) => navigate(`devices/${device.serialNumber}`)} | ||||
|             isRowClickable={() => true} | ||||
|             options={{ | ||||
|               count: getCount.data?.count, | ||||
|               isManual: true, | ||||
|               onRowClick: (device) => () => navigate(`devices/${device.serialNumber}`), | ||||
|               refetch: () => { | ||||
|                 getDevices.refetch(); | ||||
|                 getCount.refetch(); | ||||
|               }, | ||||
|             }} | ||||
|           /> | ||||
|         </Box> | ||||
|       </CardBody> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Charles
					Charles