mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	React UI improvements (#5756)
* Add CompanyDetail page * Add "edit company" button (and associated form) * Implement dropdown actions for company page * Update actions for PartDetail * Adds overlay while modal is loading - Lets the user know that something is at least happening in the background * Update panels * Implement separate pages for different company types - Mostly for better breadcrumbs * Placeholder actions for build detail * Add stock table to company page * typescript linting * Fix unused variables * remove dodgy improt
This commit is contained in:
		| @@ -1,11 +1,5 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { | ||||
|   Alert, | ||||
|   Divider, | ||||
|   LoadingOverlay, | ||||
|   ScrollArea, | ||||
|   Text | ||||
| } from '@mantine/core'; | ||||
| import { Alert, Divider, LoadingOverlay, Text } from '@mantine/core'; | ||||
| import { Button, Group, Stack } from '@mantine/core'; | ||||
| import { useForm } from '@mantine/form'; | ||||
| import { modals } from '@mantine/modals'; | ||||
| @@ -277,24 +271,22 @@ export function ApiForm({ | ||||
|           </Alert> | ||||
|         )} | ||||
|         {preFormElement} | ||||
|         <ScrollArea> | ||||
|           <Stack spacing="xs"> | ||||
|             {Object.entries(props.fields ?? {}).map( | ||||
|               ([fieldName, field]) => | ||||
|                 !field.hidden && ( | ||||
|                   <ApiFormField | ||||
|                     key={fieldName} | ||||
|                     field={field} | ||||
|                     fieldName={fieldName} | ||||
|                     formProps={props} | ||||
|                     form={form} | ||||
|                     error={form.errors[fieldName] ?? null} | ||||
|                     definitions={fieldDefinitions} | ||||
|                   /> | ||||
|                 ) | ||||
|             )} | ||||
|           </Stack> | ||||
|         </ScrollArea> | ||||
|         <Stack spacing="xs"> | ||||
|           {Object.entries(props.fields ?? {}).map( | ||||
|             ([fieldName, field]) => | ||||
|               !field.hidden && ( | ||||
|                 <ApiFormField | ||||
|                   key={fieldName} | ||||
|                   field={field} | ||||
|                   fieldName={fieldName} | ||||
|                   formProps={props} | ||||
|                   form={form} | ||||
|                   error={form.errors[fieldName] ?? null} | ||||
|                   definitions={fieldDefinitions} | ||||
|                 /> | ||||
|               ) | ||||
|           )} | ||||
|         </Stack> | ||||
|         {postFormElement} | ||||
|       </Stack> | ||||
|       <Divider /> | ||||
|   | ||||
| @@ -19,7 +19,7 @@ export function Thumbnail({ | ||||
|  | ||||
|   return ( | ||||
|     <ApiImage | ||||
|       src={src} | ||||
|       src={src || '/static/img/blank_image.png'} | ||||
|       alt={alt} | ||||
|       width={size} | ||||
|       fit="contain" | ||||
|   | ||||
							
								
								
									
										65
									
								
								src/frontend/src/components/items/ActionDropdown.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/frontend/src/components/items/ActionDropdown.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| import { ActionIcon, Menu, Tooltip } from '@mantine/core'; | ||||
| import { ReactNode, useMemo } from 'react'; | ||||
|  | ||||
| import { notYetImplemented } from '../../functions/notifications'; | ||||
|  | ||||
| export type ActionDropdownItem = { | ||||
|   icon: ReactNode; | ||||
|   name: string; | ||||
|   tooltip?: string; | ||||
|   disabled?: boolean; | ||||
|   onClick?: () => void; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * A simple Menu component which renders a set of actions. | ||||
|  * | ||||
|  * If no "active" actions are provided, the menu will not be rendered | ||||
|  */ | ||||
| export function ActionDropdown({ | ||||
|   icon, | ||||
|   tooltip, | ||||
|   actions | ||||
| }: { | ||||
|   icon: ReactNode; | ||||
|   tooltip?: string; | ||||
|   actions: ActionDropdownItem[]; | ||||
| }) { | ||||
|   const hasActions = useMemo(() => { | ||||
|     return actions.some((action) => !action.disabled); | ||||
|   }, [actions]); | ||||
|  | ||||
|   return hasActions ? ( | ||||
|     <Menu position="bottom-end"> | ||||
|       <Menu.Target> | ||||
|         <Tooltip label={tooltip}> | ||||
|           <ActionIcon size="lg" radius="sm" variant="outline"> | ||||
|             {icon} | ||||
|           </ActionIcon> | ||||
|         </Tooltip> | ||||
|       </Menu.Target> | ||||
|       <Menu.Dropdown> | ||||
|         {actions.map((action, index) => | ||||
|           action.disabled ? null : ( | ||||
|             <Tooltip label={action.tooltip}> | ||||
|               <Menu.Item | ||||
|                 icon={action.icon} | ||||
|                 key={index} | ||||
|                 onClick={() => { | ||||
|                   if (action.onClick != undefined) { | ||||
|                     action.onClick(); | ||||
|                   } else { | ||||
|                     notYetImplemented(); | ||||
|                   } | ||||
|                 }} | ||||
|                 disabled={action.disabled} | ||||
|               > | ||||
|                 {action.name} | ||||
|               </Menu.Item> | ||||
|             </Tooltip> | ||||
|           ) | ||||
|         )} | ||||
|       </Menu.Dropdown> | ||||
|     </Menu> | ||||
|   ) : null; | ||||
| } | ||||
| @@ -1,7 +1,8 @@ | ||||
| import { Container, Flex, Space } from '@mantine/core'; | ||||
| import { Container, Flex, LoadingOverlay, Space } from '@mantine/core'; | ||||
| import { Navigate, Outlet } from 'react-router-dom'; | ||||
|  | ||||
| import { InvenTreeStyle } from '../../globalStyle'; | ||||
| import { useModalState } from '../../states/ModalState'; | ||||
| import { useSessionState } from '../../states/SessionState'; | ||||
| import { Footer } from './Footer'; | ||||
| import { Header } from './Header'; | ||||
| @@ -19,9 +20,12 @@ export const ProtectedRoute = ({ children }: { children: JSX.Element }) => { | ||||
| export default function LayoutComponent() { | ||||
|   const { classes } = InvenTreeStyle(); | ||||
|  | ||||
|   const modalState = useModalState(); | ||||
|  | ||||
|   return ( | ||||
|     <ProtectedRoute> | ||||
|       <Flex direction="column" mih="100vh"> | ||||
|         <LoadingOverlay visible={modalState.loading} /> | ||||
|         <Header /> | ||||
|         <Container className={classes.layoutContent} size="100%"> | ||||
|           <Outlet /> | ||||
|   | ||||
| @@ -41,7 +41,11 @@ export function PageDetail({ | ||||
|               </Stack> | ||||
|             </Group> | ||||
|             <Space /> | ||||
|             {actions && <Group position="right">{actions}</Group>} | ||||
|             {actions && ( | ||||
|               <Group spacing={5} position="right"> | ||||
|                 {actions} | ||||
|               </Group> | ||||
|             )} | ||||
|           </Group> | ||||
|         </Stack> | ||||
|       </Paper> | ||||
|   | ||||
| @@ -224,20 +224,22 @@ export function AttachmentTable({ | ||||
|  | ||||
|   return ( | ||||
|     <Stack spacing="xs"> | ||||
|       <InvenTreeTable | ||||
|         url={url} | ||||
|         tableKey={tableKey} | ||||
|         columns={tableColumns} | ||||
|         props={{ | ||||
|           noRecordsText: t`No attachments found`, | ||||
|           enableSelection: true, | ||||
|           customActionGroups: customActionGroups, | ||||
|           rowActions: allowEdit && allowDelete ? rowActions : undefined, | ||||
|           params: { | ||||
|             [model]: pk | ||||
|           } | ||||
|         }} | ||||
|       /> | ||||
|       {pk && pk > 0 && ( | ||||
|         <InvenTreeTable | ||||
|           url={url} | ||||
|           tableKey={tableKey} | ||||
|           columns={tableColumns} | ||||
|           props={{ | ||||
|             noRecordsText: t`No attachments found`, | ||||
|             enableSelection: true, | ||||
|             customActionGroups: customActionGroups, | ||||
|             rowActions: allowEdit && allowDelete ? rowActions : undefined, | ||||
|             params: { | ||||
|               [model]: pk | ||||
|             } | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|       {allowEdit && validPk && ( | ||||
|         <Dropzone onDrop={uploadFiles}> | ||||
|           <Dropzone.Idle> | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { Group, Text } from '@mantine/core'; | ||||
| import { useMemo } from 'react'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
|  | ||||
| import { useTableRefresh } from '../../../hooks/TableRefresh'; | ||||
| import { ApiPaths, apiUrl } from '../../../states/ApiState'; | ||||
| @@ -11,9 +12,17 @@ import { InvenTreeTable } from '../InvenTreeTable'; | ||||
|  * A table which displays a list of company records, | ||||
|  * based on the provided filter parameters | ||||
|  */ | ||||
| export function CompanyTable({ params }: { params?: any }) { | ||||
| export function CompanyTable({ | ||||
|   params, | ||||
|   path | ||||
| }: { | ||||
|   params?: any; | ||||
|   path?: string; | ||||
| }) { | ||||
|   const { tableKey } = useTableRefresh('company'); | ||||
|  | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   const columns = useMemo(() => { | ||||
|     return [ | ||||
|       { | ||||
| @@ -24,7 +33,7 @@ export function CompanyTable({ params }: { params?: any }) { | ||||
|           return ( | ||||
|             <Group spacing="xs" noWrap={true}> | ||||
|               <Thumbnail | ||||
|                 src={record.thumbnail ?? record.image} | ||||
|                 src={record.thumbnail ?? record.image ?? ''} | ||||
|                 alt={record.name} | ||||
|                 size={24} | ||||
|               /> | ||||
| @@ -56,6 +65,10 @@ export function CompanyTable({ params }: { params?: any }) { | ||||
|       props={{ | ||||
|         params: { | ||||
|           ...params | ||||
|         }, | ||||
|         onRowClick: (row: any) => { | ||||
|           let base = path ?? 'company'; | ||||
|           navigate(`/${base}/${row.pk}`); | ||||
|         } | ||||
|       }} | ||||
|     /> | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import { api } from '../App'; | ||||
| import { ApiForm, ApiFormProps } from '../components/forms/ApiForm'; | ||||
| import { ApiFormFieldType } from '../components/forms/fields/ApiFormField'; | ||||
| import { apiUrl } from '../states/ApiState'; | ||||
| import { useModalState } from '../states/ModalState'; | ||||
| import { invalidResponse, permissionDenied } from './notifications'; | ||||
| import { generateUniqueId } from './uid'; | ||||
|  | ||||
| @@ -97,6 +98,10 @@ export function openModalApiForm(props: ApiFormProps) { | ||||
|  | ||||
|   let url = constructFormUrl(props); | ||||
|  | ||||
|   // let modalState = useModalState(); | ||||
|  | ||||
|   useModalState.getState().lock(); | ||||
|  | ||||
|   // Make OPTIONS request first | ||||
|   api | ||||
|     .options(url) | ||||
| @@ -119,6 +124,7 @@ export function openModalApiForm(props: ApiFormProps) { | ||||
|       modals.open({ | ||||
|         title: props.title, | ||||
|         modalId: modalId, | ||||
|         size: 'xl', | ||||
|         onClose: () => { | ||||
|           props.onClose ? props.onClose() : null; | ||||
|         }, | ||||
| @@ -126,8 +132,12 @@ export function openModalApiForm(props: ApiFormProps) { | ||||
|           <ApiForm modalId={modalId} props={props} fieldDefinitions={fields} /> | ||||
|         ) | ||||
|       }); | ||||
|  | ||||
|       useModalState.getState().unlock(); | ||||
|     }) | ||||
|     .catch((error) => { | ||||
|       useModalState.getState().unlock(); | ||||
|  | ||||
|       console.log('Error:', error); | ||||
|       if (error.response) { | ||||
|         invalidResponse(error.response.status); | ||||
|   | ||||
							
								
								
									
										57
									
								
								src/frontend/src/functions/forms/CompanyForms.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/frontend/src/functions/forms/CompanyForms.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { | ||||
|   IconAt, | ||||
|   IconCurrencyDollar, | ||||
|   IconGlobe, | ||||
|   IconPhone | ||||
| } from '@tabler/icons-react'; | ||||
|  | ||||
| import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField'; | ||||
| import { ApiPaths } from '../../states/ApiState'; | ||||
| import { openEditApiForm } from '../forms'; | ||||
|  | ||||
| /** | ||||
|  * Field set for editing a company instance | ||||
|  */ | ||||
| export function companyFields(): ApiFormFieldSet { | ||||
|   return { | ||||
|     name: {}, | ||||
|     description: {}, | ||||
|     website: { | ||||
|       icon: <IconGlobe /> | ||||
|     }, | ||||
|     currency: { | ||||
|       icon: <IconCurrencyDollar /> | ||||
|     }, | ||||
|     phone: { | ||||
|       icon: <IconPhone /> | ||||
|     }, | ||||
|     email: { | ||||
|       icon: <IconAt /> | ||||
|     }, | ||||
|     is_supplier: {}, | ||||
|     is_manufacturer: {}, | ||||
|     is_customer: {} | ||||
|   }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Edit a company instance | ||||
|  */ | ||||
| export function editCompany({ | ||||
|   pk, | ||||
|   callback | ||||
| }: { | ||||
|   pk: number; | ||||
|   callback?: () => void; | ||||
| }) { | ||||
|   openEditApiForm({ | ||||
|     name: 'company-edit', | ||||
|     title: t`Edit Company`, | ||||
|     url: ApiPaths.company_list, | ||||
|     pk: pk, | ||||
|     fields: companyFields(), | ||||
|     successMessage: t`Company updated`, | ||||
|     onFormSuccess: callback | ||||
|   }); | ||||
| } | ||||
| @@ -3,16 +3,26 @@ import { Alert, LoadingOverlay, Stack, Text } from '@mantine/core'; | ||||
| import { | ||||
|   IconClipboardCheck, | ||||
|   IconClipboardList, | ||||
|   IconCopy, | ||||
|   IconDots, | ||||
|   IconEdit, | ||||
|   IconFileTypePdf, | ||||
|   IconInfoCircle, | ||||
|   IconLink, | ||||
|   IconList, | ||||
|   IconListCheck, | ||||
|   IconNotes, | ||||
|   IconPaperclip, | ||||
|   IconSitemap | ||||
|   IconPrinter, | ||||
|   IconQrcode, | ||||
|   IconSitemap, | ||||
|   IconTrash, | ||||
|   IconUnlink | ||||
| } from '@tabler/icons-react'; | ||||
| import { useMemo } from 'react'; | ||||
| import { useParams } from 'react-router-dom'; | ||||
|  | ||||
| import { ActionDropdown } from '../../components/items/ActionDropdown'; | ||||
| import { | ||||
|   PlaceholderPanel, | ||||
|   PlaceholderPill | ||||
| @@ -25,6 +35,7 @@ import { StockItemTable } from '../../components/tables/stock/StockItemTable'; | ||||
| import { NotesEditor } from '../../components/widgets/MarkdownEditor'; | ||||
| import { useInstance } from '../../hooks/UseInstance'; | ||||
| import { ApiPaths, apiUrl } from '../../states/ApiState'; | ||||
| import { useUserState } from '../../states/UserState'; | ||||
|  | ||||
| /** | ||||
|  * Detail page for a single Build Order | ||||
| @@ -44,6 +55,8 @@ export default function BuildDetail() { | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   const user = useUserState(); | ||||
|  | ||||
|   const buildPanels: PanelType[] = useMemo(() => { | ||||
|     return [ | ||||
|       { | ||||
| @@ -130,22 +143,78 @@ export default function BuildDetail() { | ||||
|     ]; | ||||
|   }, [build]); | ||||
|  | ||||
|   const buildActions = useMemo(() => { | ||||
|     // TODO: Disable certain actions based on user permissions | ||||
|     return [ | ||||
|       <ActionDropdown | ||||
|         tooltip={t`Barcode Actions`} | ||||
|         icon={<IconQrcode />} | ||||
|         actions={[ | ||||
|           { | ||||
|             icon: <IconQrcode />, | ||||
|             name: t`View`, | ||||
|             tooltip: t`View part barcode` | ||||
|           }, | ||||
|           { | ||||
|             icon: <IconLink />, | ||||
|             name: t`Link Barcode`, | ||||
|             tooltip: t`Link custom barcode to part`, | ||||
|             disabled: build?.barcode_hash | ||||
|           }, | ||||
|           { | ||||
|             icon: <IconUnlink />, | ||||
|             name: t`Unlink Barcode`, | ||||
|             tooltip: t`Unlink custom barcode from part`, | ||||
|             disabled: !build?.barcode_hash | ||||
|           } | ||||
|         ]} | ||||
|       />, | ||||
|       <ActionDropdown | ||||
|         tooltip={t`Reporting Actions`} | ||||
|         icon={<IconPrinter />} | ||||
|         actions={[ | ||||
|           { | ||||
|             icon: <IconFileTypePdf />, | ||||
|             name: t`Report`, | ||||
|             tooltip: t`Print build report` | ||||
|           } | ||||
|         ]} | ||||
|       />, | ||||
|       <ActionDropdown | ||||
|         tooltip={t`Build Order Actions`} | ||||
|         icon={<IconDots />} | ||||
|         actions={[ | ||||
|           { | ||||
|             icon: <IconEdit color="blue" />, | ||||
|             name: t`Edit`, | ||||
|             tooltip: t`Edit build order` | ||||
|           }, | ||||
|           { | ||||
|             icon: <IconCopy color="green" />, | ||||
|             name: t`Duplicate`, | ||||
|             tooltip: t`Duplicate build order` | ||||
|           }, | ||||
|           { | ||||
|             icon: <IconTrash color="red" />, | ||||
|             name: t`Delete`, | ||||
|             tooltip: t`Delete build order` | ||||
|           } | ||||
|         ]} | ||||
|       /> | ||||
|     ]; | ||||
|   }, [id, build, user]); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Stack spacing="xs"> | ||||
|         <PageDetail | ||||
|           title={t`Build Order`} | ||||
|           subtitle={build.reference} | ||||
|           detail={ | ||||
|             <Alert color="teal" title="Build order detail goes here"> | ||||
|               <Text>TODO: Build details</Text> | ||||
|             </Alert> | ||||
|           } | ||||
|           breadcrumbs={[ | ||||
|             { name: t`Build Orders`, url: '/build' }, | ||||
|             { name: build.reference, url: `/build/${build.pk}` } | ||||
|           ]} | ||||
|           actions={[<PlaceholderPill key="1" />]} | ||||
|           actions={buildActions} | ||||
|         /> | ||||
|         <LoadingOverlay visible={instanceQuery.isFetching} /> | ||||
|         <PanelGroup pageKey="build" panels={buildPanels} /> | ||||
|   | ||||
							
								
								
									
										226
									
								
								src/frontend/src/pages/company/CompanyDetail.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										226
									
								
								src/frontend/src/pages/company/CompanyDetail.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,226 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { Group, LoadingOverlay, Stack, Text } from '@mantine/core'; | ||||
| import { | ||||
|   IconBuildingFactory2, | ||||
|   IconBuildingWarehouse, | ||||
|   IconDots, | ||||
|   IconEdit, | ||||
|   IconInfoCircle, | ||||
|   IconMap2, | ||||
|   IconNotes, | ||||
|   IconPackageExport, | ||||
|   IconPackages, | ||||
|   IconPaperclip, | ||||
|   IconShoppingCart, | ||||
|   IconTrash, | ||||
|   IconTruckDelivery, | ||||
|   IconTruckReturn, | ||||
|   IconUsersGroup | ||||
| } from '@tabler/icons-react'; | ||||
| import { useMemo } from 'react'; | ||||
| import { useParams } from 'react-router-dom'; | ||||
|  | ||||
| import { Thumbnail } from '../../components/images/Thumbnail'; | ||||
| import { ActionDropdown } from '../../components/items/ActionDropdown'; | ||||
| import { Breadcrumb } from '../../components/nav/BreadcrumbList'; | ||||
| import { PageDetail } from '../../components/nav/PageDetail'; | ||||
| import { PanelGroup } from '../../components/nav/PanelGroup'; | ||||
| import { PanelType } from '../../components/nav/PanelGroup'; | ||||
| import { AttachmentTable } from '../../components/tables/general/AttachmentTable'; | ||||
| import { PurchaseOrderTable } from '../../components/tables/purchasing/PurchaseOrderTable'; | ||||
| import { ReturnOrderTable } from '../../components/tables/sales/ReturnOrderTable'; | ||||
| import { SalesOrderTable } from '../../components/tables/sales/SalesOrderTable'; | ||||
| import { StockItemTable } from '../../components/tables/stock/StockItemTable'; | ||||
| import { NotesEditor } from '../../components/widgets/MarkdownEditor'; | ||||
| import { editCompany } from '../../functions/forms/CompanyForms'; | ||||
| import { useInstance } from '../../hooks/UseInstance'; | ||||
| import { ApiPaths, apiUrl } from '../../states/ApiState'; | ||||
| import { useUserState } from '../../states/UserState'; | ||||
|  | ||||
| export type CompanyDetailProps = { | ||||
|   title: string; | ||||
|   breadcrumbs: Breadcrumb[]; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Detail view for a single company instance | ||||
|  */ | ||||
| export default function CompanyDetail(props: CompanyDetailProps) { | ||||
|   const { id } = useParams(); | ||||
|  | ||||
|   const user = useUserState(); | ||||
|  | ||||
|   const { | ||||
|     instance: company, | ||||
|     refreshInstance, | ||||
|     instanceQuery | ||||
|   } = useInstance({ | ||||
|     endpoint: ApiPaths.company_list, | ||||
|     pk: id, | ||||
|     params: {}, | ||||
|     refetchOnMount: true | ||||
|   }); | ||||
|  | ||||
|   const companyPanels: PanelType[] = useMemo(() => { | ||||
|     return [ | ||||
|       { | ||||
|         name: 'details', | ||||
|         label: t`Details`, | ||||
|         icon: <IconInfoCircle /> | ||||
|       }, | ||||
|       { | ||||
|         name: 'manufactured-parts', | ||||
|         label: t`Manufactured Parts`, | ||||
|         icon: <IconBuildingFactory2 />, | ||||
|         hidden: !company?.is_manufacturer | ||||
|       }, | ||||
|       { | ||||
|         name: 'supplied-parts', | ||||
|         label: t`Supplied Parts`, | ||||
|         icon: <IconBuildingWarehouse />, | ||||
|         hidden: !company?.is_supplier | ||||
|       }, | ||||
|       { | ||||
|         name: 'purchase-orders', | ||||
|         label: t`Purchase Orders`, | ||||
|         icon: <IconShoppingCart />, | ||||
|         hidden: !company?.is_supplier, | ||||
|         content: company?.pk && ( | ||||
|           <PurchaseOrderTable params={{ supplier: company.pk }} /> | ||||
|         ) | ||||
|       }, | ||||
|       { | ||||
|         name: 'stock-items', | ||||
|         label: t`Stock Items`, | ||||
|         icon: <IconPackages />, | ||||
|         hidden: !company?.is_manufacturer && !company?.is_supplier, | ||||
|         content: company?.pk && ( | ||||
|           <StockItemTable params={{ company: company.pk }} /> | ||||
|         ) | ||||
|       }, | ||||
|       { | ||||
|         name: 'sales-orders', | ||||
|         label: t`Sales Orders`, | ||||
|         icon: <IconTruckDelivery />, | ||||
|         hidden: !company?.is_customer, | ||||
|         content: company?.pk && ( | ||||
|           <SalesOrderTable params={{ customer: company.pk }} /> | ||||
|         ) | ||||
|       }, | ||||
|       { | ||||
|         name: 'return-orders', | ||||
|         label: t`Return Orders`, | ||||
|         icon: <IconTruckReturn />, | ||||
|         hidden: !company?.is_customer, | ||||
|         content: company.pk && ( | ||||
|           <ReturnOrderTable params={{ customer: company.pk }} /> | ||||
|         ) | ||||
|       }, | ||||
|       { | ||||
|         name: 'assigned-stock', | ||||
|         label: t`Assigned Stock`, | ||||
|         icon: <IconPackageExport />, | ||||
|         hidden: !company?.is_customer | ||||
|       }, | ||||
|       { | ||||
|         name: 'contacts', | ||||
|         label: t`Contacts`, | ||||
|         icon: <IconUsersGroup /> | ||||
|       }, | ||||
|       { | ||||
|         name: 'addresses', | ||||
|         label: t`Addresses`, | ||||
|         icon: <IconMap2 /> | ||||
|       }, | ||||
|       { | ||||
|         name: 'attachments', | ||||
|         label: t`Attachments`, | ||||
|         icon: <IconPaperclip />, | ||||
|         content: ( | ||||
|           <AttachmentTable | ||||
|             endpoint={ApiPaths.company_attachment_list} | ||||
|             model="company" | ||||
|             pk={company.pk ?? -1} | ||||
|           /> | ||||
|         ) | ||||
|       }, | ||||
|       { | ||||
|         name: 'notes', | ||||
|         label: t`Notes`, | ||||
|         icon: <IconNotes />, | ||||
|         content: ( | ||||
|           <NotesEditor | ||||
|             url={apiUrl(ApiPaths.company_list, company.pk)} | ||||
|             data={company?.notes ?? ''} | ||||
|             allowEdit={true} | ||||
|           /> | ||||
|         ) | ||||
|       } | ||||
|     ]; | ||||
|   }, [id, company]); | ||||
|  | ||||
|   const companyDetail = useMemo(() => { | ||||
|     return ( | ||||
|       <Group spacing="xs" noWrap={true}> | ||||
|         <Thumbnail | ||||
|           src={String(company.image || '')} | ||||
|           size={128} | ||||
|           alt={company?.name} | ||||
|         /> | ||||
|         <Stack spacing="xs"> | ||||
|           <Text size="lg" weight={500}> | ||||
|             {company.name} | ||||
|           </Text> | ||||
|           <Text size="sm">{company.description}</Text> | ||||
|         </Stack> | ||||
|       </Group> | ||||
|     ); | ||||
|   }, [id, company]); | ||||
|  | ||||
|   const companyActions = useMemo(() => { | ||||
|     // TODO: Finer fidelity on these permissions, perhaps? | ||||
|     let canEdit = user.checkUserRole('purchase_order', 'change'); | ||||
|     let canDelete = user.checkUserRole('purchase_order', 'delete'); | ||||
|  | ||||
|     return [ | ||||
|       <ActionDropdown | ||||
|         tooltip={t`Company Actions`} | ||||
|         icon={<IconDots />} | ||||
|         actions={[ | ||||
|           { | ||||
|             icon: <IconEdit color="blue" />, | ||||
|             name: t`Edit`, | ||||
|             tooltip: t`Edit company`, | ||||
|             disabled: !canEdit, | ||||
|             onClick: () => { | ||||
|               if (company?.pk) { | ||||
|                 editCompany({ | ||||
|                   pk: company?.pk, | ||||
|                   callback: refreshInstance | ||||
|                 }); | ||||
|               } | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             icon: <IconTrash color="red" />, | ||||
|             name: t`Delete`, | ||||
|             tooltip: t`Delete company`, | ||||
|             disabled: !canDelete | ||||
|           } | ||||
|         ]} | ||||
|       /> | ||||
|     ]; | ||||
|   }, [id, company, user]); | ||||
|  | ||||
|   return ( | ||||
|     <Stack spacing="xs"> | ||||
|       <LoadingOverlay visible={instanceQuery.isFetching} /> | ||||
|       <PageDetail | ||||
|         detail={companyDetail} | ||||
|         actions={companyActions} | ||||
|         breadcrumbs={props.breadcrumbs} | ||||
|       /> | ||||
|       <PanelGroup pageKey="company" panels={companyPanels} /> | ||||
|     </Stack> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										12
									
								
								src/frontend/src/pages/company/CustomerDetail.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/frontend/src/pages/company/CustomerDetail.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
|  | ||||
| import CompanyDetail from './CompanyDetail'; | ||||
|  | ||||
| export default function CustomerDetail() { | ||||
|   return ( | ||||
|     <CompanyDetail | ||||
|       title={t`Customer`} | ||||
|       breadcrumbs={[{ name: t`Sales`, url: '/sales/' }]} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										12
									
								
								src/frontend/src/pages/company/ManufacturerDetail.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/frontend/src/pages/company/ManufacturerDetail.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
|  | ||||
| import CompanyDetail from './CompanyDetail'; | ||||
|  | ||||
| export default function ManufacturerDetail() { | ||||
|   return ( | ||||
|     <CompanyDetail | ||||
|       title={t`Manufacturer`} | ||||
|       breadcrumbs={[{ name: t`Purchasing`, url: '/purchasing/' }]} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										12
									
								
								src/frontend/src/pages/company/SupplierDetail.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/frontend/src/pages/company/SupplierDetail.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
|  | ||||
| import CompanyDetail from './CompanyDetail'; | ||||
|  | ||||
| export default function SupplierDetail() { | ||||
|   return ( | ||||
|     <CompanyDetail | ||||
|       title={t`Supplier`} | ||||
|       breadcrumbs={[{ name: t`Purchasing`, url: '/purchasing/' }]} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
| @@ -1,34 +1,37 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { | ||||
|   Alert, | ||||
|   Button, | ||||
|   Group, | ||||
|   LoadingOverlay, | ||||
|   Stack, | ||||
|   Text | ||||
| } from '@mantine/core'; | ||||
| import { Group, LoadingOverlay, Stack, Text } from '@mantine/core'; | ||||
| import { | ||||
|   IconBuilding, | ||||
|   IconCalendarStats, | ||||
|   IconClipboardList, | ||||
|   IconCopy, | ||||
|   IconCurrencyDollar, | ||||
|   IconDots, | ||||
|   IconEdit, | ||||
|   IconInfoCircle, | ||||
|   IconLayersLinked, | ||||
|   IconLink, | ||||
|   IconList, | ||||
|   IconListTree, | ||||
|   IconNotes, | ||||
|   IconPackages, | ||||
|   IconPaperclip, | ||||
|   IconQrcode, | ||||
|   IconShoppingCart, | ||||
|   IconStack2, | ||||
|   IconTestPipe, | ||||
|   IconTools, | ||||
|   IconTransfer, | ||||
|   IconTrash, | ||||
|   IconTruckDelivery, | ||||
|   IconUnlink, | ||||
|   IconVersions | ||||
| } from '@tabler/icons-react'; | ||||
| import { useMemo } from 'react'; | ||||
| import { useParams } from 'react-router-dom'; | ||||
|  | ||||
| import { ApiImage } from '../../components/images/ApiImage'; | ||||
| import { PlaceholderPanel } from '../../components/items/Placeholder'; | ||||
| import { ActionDropdown } from '../../components/items/ActionDropdown'; | ||||
| import { PageDetail } from '../../components/nav/PageDetail'; | ||||
| import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; | ||||
| import { AttachmentTable } from '../../components/tables/general/AttachmentTable'; | ||||
| @@ -40,6 +43,7 @@ import { NotesEditor } from '../../components/widgets/MarkdownEditor'; | ||||
| import { editPart } from '../../functions/forms/PartForms'; | ||||
| import { useInstance } from '../../hooks/UseInstance'; | ||||
| import { ApiPaths, apiUrl } from '../../states/ApiState'; | ||||
| import { useUserState } from '../../states/UserState'; | ||||
|  | ||||
| /** | ||||
|  * Detail view for a single Part instance | ||||
| @@ -47,6 +51,8 @@ import { ApiPaths, apiUrl } from '../../states/ApiState'; | ||||
| export default function PartDetail() { | ||||
|   const { id } = useParams(); | ||||
|  | ||||
|   const user = useUserState(); | ||||
|  | ||||
|   const { | ||||
|     instance: part, | ||||
|     refreshInstance, | ||||
| @@ -66,8 +72,7 @@ export default function PartDetail() { | ||||
|       { | ||||
|         name: 'details', | ||||
|         label: t`Details`, | ||||
|         icon: <IconInfoCircle />, | ||||
|         content: <PlaceholderPanel /> | ||||
|         icon: <IconInfoCircle /> | ||||
|       }, | ||||
|       { | ||||
|         name: 'parameters', | ||||
| @@ -98,55 +103,57 @@ export default function PartDetail() { | ||||
|         name: 'bom', | ||||
|         label: t`Bill of Materials`, | ||||
|         icon: <IconListTree />, | ||||
|         hidden: !part.assembly, | ||||
|         content: <PlaceholderPanel /> | ||||
|         hidden: !part.assembly | ||||
|       }, | ||||
|       { | ||||
|         name: 'builds', | ||||
|         label: t`Build Orders`, | ||||
|         icon: <IconTools />, | ||||
|         hidden: !part.assembly && !part.component, | ||||
|         content: <PlaceholderPanel /> | ||||
|         hidden: !part.assembly && !part.component | ||||
|       }, | ||||
|       { | ||||
|         name: 'used_in', | ||||
|         label: t`Used In`, | ||||
|         icon: <IconStack2 />, | ||||
|         hidden: !part.component, | ||||
|         content: <PlaceholderPanel /> | ||||
|         hidden: !part.component | ||||
|       }, | ||||
|       { | ||||
|         name: 'pricing', | ||||
|         label: t`Pricing`, | ||||
|         icon: <IconCurrencyDollar />, | ||||
|         content: <PlaceholderPanel /> | ||||
|         icon: <IconCurrencyDollar /> | ||||
|       }, | ||||
|       { | ||||
|         name: 'suppliers', | ||||
|         label: t`Suppliers`, | ||||
|         icon: <IconBuilding />, | ||||
|         hidden: !part.purchaseable, | ||||
|         content: <PlaceholderPanel /> | ||||
|         hidden: !part.purchaseable | ||||
|       }, | ||||
|       { | ||||
|         name: 'purchase_orders', | ||||
|         label: t`Purchase Orders`, | ||||
|         icon: <IconShoppingCart />, | ||||
|         content: <PlaceholderPanel />, | ||||
|         hidden: !part.purchaseable | ||||
|       }, | ||||
|       { | ||||
|         name: 'sales_orders', | ||||
|         label: t`Sales Orders`, | ||||
|         icon: <IconTruckDelivery />, | ||||
|         content: <PlaceholderPanel />, | ||||
|         hidden: !part.salable | ||||
|       }, | ||||
|       { | ||||
|         name: 'scheduling', | ||||
|         label: t`Scheduling`, | ||||
|         icon: <IconCalendarStats /> | ||||
|       }, | ||||
|       { | ||||
|         name: 'stocktake', | ||||
|         label: t`Stocktake`, | ||||
|         icon: <IconClipboardList /> | ||||
|       }, | ||||
|       { | ||||
|         name: 'test_templates', | ||||
|         label: t`Test Templates`, | ||||
|         icon: <IconTestPipe />, | ||||
|         content: <PlaceholderPanel />, | ||||
|         hidden: !part.trackable | ||||
|       }, | ||||
|       { | ||||
| @@ -212,6 +219,79 @@ export default function PartDetail() { | ||||
|     ); | ||||
|   }, [part, id]); | ||||
|  | ||||
|   const partActions = useMemo(() => { | ||||
|     // TODO: Disable actions based on user permissions | ||||
|     return [ | ||||
|       <ActionDropdown | ||||
|         tooltip={t`Barcode Actions`} | ||||
|         icon={<IconQrcode />} | ||||
|         actions={[ | ||||
|           { | ||||
|             icon: <IconQrcode />, | ||||
|             name: t`View`, | ||||
|             tooltip: t`View part barcode` | ||||
|           }, | ||||
|           { | ||||
|             icon: <IconLink />, | ||||
|             name: t`Link Barcode`, | ||||
|             tooltip: t`Link custom barcode to part`, | ||||
|             disabled: part?.barcode_hash | ||||
|           }, | ||||
|           { | ||||
|             icon: <IconUnlink />, | ||||
|             name: t`Unlink Barcode`, | ||||
|             tooltip: t`Unlink custom barcode from part`, | ||||
|             disabled: !part?.barcode_hash | ||||
|           } | ||||
|         ]} | ||||
|       />, | ||||
|       <ActionDropdown | ||||
|         tooltip={t`Stock Actions`} | ||||
|         icon={<IconPackages />} | ||||
|         actions={[ | ||||
|           { | ||||
|             icon: <IconClipboardList color="blue" />, | ||||
|             name: t`Count Stock`, | ||||
|             tooltip: t`Count part stock` | ||||
|           }, | ||||
|           { | ||||
|             icon: <IconTransfer color="blue" />, | ||||
|             name: t`Transfer Stock`, | ||||
|             tooltip: t`Transfer part stock` | ||||
|           } | ||||
|         ]} | ||||
|       />, | ||||
|       <ActionDropdown | ||||
|         tooltip={t`Part Actions`} | ||||
|         icon={<IconDots />} | ||||
|         actions={[ | ||||
|           { | ||||
|             icon: <IconEdit color="blue" />, | ||||
|             name: t`Edit`, | ||||
|             tooltip: t`Edit part`, | ||||
|             onClick: () => { | ||||
|               part.pk && | ||||
|                 editPart({ | ||||
|                   part_id: part.pk, | ||||
|                   callback: refreshInstance | ||||
|                 }); | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             icon: <IconCopy color="green" />, | ||||
|             name: t`Duplicate`, | ||||
|             tooltip: t`Duplicate part` | ||||
|           }, | ||||
|           { | ||||
|             icon: <IconTrash color="red" />, | ||||
|             name: t`Delete`, | ||||
|             tooltip: t`Delete part` | ||||
|           } | ||||
|         ]} | ||||
|       /> | ||||
|     ]; | ||||
|   }, [id, part, user]); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Stack spacing="xs"> | ||||
| @@ -219,21 +299,7 @@ export default function PartDetail() { | ||||
|         <PageDetail | ||||
|           detail={partDetail} | ||||
|           breadcrumbs={breadcrumbs} | ||||
|           actions={[ | ||||
|             <Button | ||||
|               variant="outline" | ||||
|               color="blue" | ||||
|               onClick={() => | ||||
|                 part.pk && | ||||
|                 editPart({ | ||||
|                   part_id: part.pk, | ||||
|                   callback: refreshInstance | ||||
|                 }) | ||||
|               } | ||||
|             > | ||||
|               Edit Part | ||||
|             </Button> | ||||
|           ]} | ||||
|           actions={partActions} | ||||
|         /> | ||||
|         <PanelGroup pageKey="part" panels={partPanels} /> | ||||
|       </Stack> | ||||
|   | ||||
| @@ -26,13 +26,23 @@ export default function PurchasingIndex() { | ||||
|         name: 'suppliers', | ||||
|         label: t`Suppliers`, | ||||
|         icon: <IconBuildingStore />, | ||||
|         content: <CompanyTable params={{ is_supplier: true }} /> | ||||
|         content: ( | ||||
|           <CompanyTable | ||||
|             path="purchasing/supplier" | ||||
|             params={{ is_supplier: true }} | ||||
|           /> | ||||
|         ) | ||||
|       }, | ||||
|       { | ||||
|         name: 'manufacturer', | ||||
|         label: t`Manufacturers`, | ||||
|         icon: <IconBuildingFactory2 />, | ||||
|         content: <CompanyTable params={{ is_manufacturer: true }} /> | ||||
|         content: ( | ||||
|           <CompanyTable | ||||
|             path="purchasing/manufacturer" | ||||
|             params={{ is_manufacturer: true }} | ||||
|           /> | ||||
|         ) | ||||
|       } | ||||
|     ]; | ||||
|   }, []); | ||||
|   | ||||
| @@ -32,7 +32,9 @@ export default function PurchasingIndex() { | ||||
|         name: 'suppliers', | ||||
|         label: t`Customers`, | ||||
|         icon: <IconBuildingStore />, | ||||
|         content: <CompanyTable params={{ is_customer: true }} /> | ||||
|         content: ( | ||||
|           <CompanyTable path="sales/customer" params={{ is_customer: true }} /> | ||||
|         ) | ||||
|       } | ||||
|     ]; | ||||
|   }, []); | ||||
|   | ||||
| @@ -12,6 +12,22 @@ export const Playground = Loadable( | ||||
|   lazy(() => import('./pages/Index/Playground')) | ||||
| ); | ||||
|  | ||||
| export const CompanyDetail = Loadable( | ||||
|   lazy(() => import('./pages/company/CompanyDetail')) | ||||
| ); | ||||
|  | ||||
| export const CustomerDetail = Loadable( | ||||
|   lazy(() => import('./pages/company/CustomerDetail')) | ||||
| ); | ||||
|  | ||||
| export const SupplierDetail = Loadable( | ||||
|   lazy(() => import('./pages/company/SupplierDetail')) | ||||
| ); | ||||
|  | ||||
| export const ManufacturerDetail = Loadable( | ||||
|   lazy(() => import('./pages/company/ManufacturerDetail')) | ||||
| ); | ||||
|  | ||||
| export const CategoryDetail = Loadable( | ||||
|   lazy(() => import('./pages/part/CategoryDetail')) | ||||
| ); | ||||
| @@ -109,9 +125,13 @@ export const routes = ( | ||||
|       </Route> | ||||
|       <Route path="purchasing/"> | ||||
|         <Route index element={<PurchasingIndex />} /> | ||||
|         <Route path="supplier/:id/" element={<SupplierDetail />} /> | ||||
|         <Route path="manufacturer/:id/" element={<ManufacturerDetail />} /> | ||||
|       </Route> | ||||
|       <Route path="company/:id/" element={<CompanyDetail />} /> | ||||
|       <Route path="sales/"> | ||||
|         <Route index element={<SalesIndex />} /> | ||||
|         <Route path="customer/:id/" element={<CustomerDetail />} /> | ||||
|       </Route> | ||||
|       <Route path="/profile/:tabValue" element={<Profile />} /> | ||||
|     </Route> | ||||
|   | ||||
| @@ -55,6 +55,7 @@ export enum ApiPaths { | ||||
|  | ||||
|   // Company URLs | ||||
|   company_list = 'api-company-list', | ||||
|   company_attachment_list = 'api-company-attachment-list', | ||||
|   supplier_part_list = 'api-supplier-part-list', | ||||
|  | ||||
|   // Stock Item URLs | ||||
| @@ -137,6 +138,8 @@ export function apiEndpoint(path: ApiPaths): string { | ||||
|       return 'part/attachment/'; | ||||
|     case ApiPaths.company_list: | ||||
|       return 'company/'; | ||||
|     case ApiPaths.company_attachment_list: | ||||
|       return 'company/attachment/'; | ||||
|     case ApiPaths.supplier_part_list: | ||||
|       return 'company/part/'; | ||||
|     case ApiPaths.stock_item_list: | ||||
|   | ||||
							
								
								
									
										16
									
								
								src/frontend/src/states/ModalState.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/frontend/src/states/ModalState.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| import { create } from 'zustand'; | ||||
|  | ||||
| interface ModalStateProps { | ||||
|   loading: boolean; | ||||
|   lock: () => void; | ||||
|   unlock: () => void; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Global state manager for modal forms. | ||||
|  */ | ||||
| export const useModalState = create<ModalStateProps>((set) => ({ | ||||
|   loading: false, | ||||
|   lock: () => set(() => ({ loading: true })), | ||||
|   unlock: () => set(() => ({ loading: false })) | ||||
| })); | ||||
| @@ -10,6 +10,7 @@ interface UserStateProps { | ||||
|   username: () => string; | ||||
|   setUser: (newUser: UserProps) => void; | ||||
|   fetchUserState: () => void; | ||||
|   checkUserRole: (role: string, permission: string) => boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|   | ||||
		Reference in New Issue
	
	Block a user