From d30ab932ca418e0dae0e8cdf1aed231a78ac22e6 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 25 Apr 2024 10:11:44 +1000 Subject: [PATCH] [PUI] Implement "build outputs" table (#7115) * Update build line allocation table - Allow display of "tracked" items in main allocation table * Add resolveItem function for finding nested items * Update BuildLineTable * Allow BuildLineList to be ordered by 'trackable' field * Bump API version * Building out columns * Table tweaks * Fetch list of required test templates * Tweaks * Add placeholders for table actions * Add typing * Add placeholder buttons * Update columns * Add button to duplicate build order * Add placeholder cancel action * Refactoring * Edit company from table * Change "create" to "add" * Change more from Create to Add --- src/frontend/src/components/forms/ApiForm.tsx | 15 + .../src/components/items/ActionDropdown.tsx | 20 ++ src/frontend/src/forms/StockForms.tsx | 2 - src/frontend/src/functions/icons.tsx | 10 +- src/frontend/src/hooks/UseTable.tsx | 9 +- src/frontend/src/pages/build/BuildDetail.tsx | 35 ++- .../pages/company/ManufacturerPartDetail.tsx | 20 +- .../src/pages/company/SupplierPartDetail.tsx | 12 +- src/frontend/src/pages/part/PartDetail.tsx | 10 +- .../pages/purchasing/PurchaseOrderDetail.tsx | 33 +- .../src/pages/sales/ReturnOrderDetail.tsx | 32 +- .../src/pages/sales/SalesOrderDetail.tsx | 37 ++- src/frontend/src/pages/stock/StockDetail.tsx | 28 +- src/frontend/src/tables/ColumnRenderers.tsx | 31 +- src/frontend/src/tables/TableHoverCard.tsx | 2 +- src/frontend/src/tables/bom/BomTable.tsx | 2 +- .../src/tables/build/BuildOrderTable.tsx | 10 +- .../src/tables/build/BuildOutputTable.tsx | 284 ++++++++++++++++++ .../src/tables/company/CompanyTable.tsx | 49 ++- .../src/tables/machine/MachineListTable.tsx | 2 +- src/frontend/src/tables/part/PartTable.tsx | 7 +- .../purchasing/ManufacturerPartTable.tsx | 2 +- .../tables/purchasing/PurchaseOrderTable.tsx | 13 +- .../src/tables/sales/ReturnOrderTable.tsx | 15 +- .../src/tables/sales/SalesOrderTable.tsx | 13 +- .../src/tables/settings/TemplateTable.tsx | 2 +- .../src/tables/stock/StockItemTable.tsx | 33 +- 27 files changed, 581 insertions(+), 147 deletions(-) create mode 100644 src/frontend/src/tables/build/BuildOutputTable.tsx diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index 315121efef..6b49748fed 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -19,9 +19,11 @@ import { SubmitHandler, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; import { api, queryClient } from '../../App'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; import { NestedDict, constructField, @@ -30,6 +32,7 @@ import { mapFields } from '../../functions/forms'; import { invalidResponse } from '../../functions/notifications'; +import { getDetailUrl } from '../../functions/urls'; import { PathParams } from '../../states/ApiState'; import { ApiFormField, @@ -59,6 +62,8 @@ export interface ApiFormAction { * @param successMessage : Optional message to display on successful form submission * @param onFormSuccess : A callback function to call when the form is submitted successfully. * @param onFormError : A callback function to call when the form is submitted with errors. + * @param modelType : Define a model type for this form + * @param follow : Boolean, follow the result of the form (if possible) */ export interface ApiFormProps { url: ApiEndpoints | string; @@ -79,6 +84,8 @@ export interface ApiFormProps { successMessage?: string; onFormSuccess?: (data: any) => void; onFormError?: () => void; + modelType?: ModelType; + follow?: boolean; actions?: ApiFormAction[]; timeout?: number; } @@ -183,6 +190,8 @@ export function ApiForm({ props: ApiFormProps; optionsLoading: boolean; }) { + const navigate = useNavigate(); + const fields: ApiFormFieldSet = useMemo(() => { return props.fields ?? {}; }, [props.fields]); @@ -384,6 +393,12 @@ export function ApiForm({ props.onFormSuccess(response.data); } + if (props.follow) { + if (props.modelType && response.data?.pk) { + navigate(getDetailUrl(props.modelType, response.data?.pk)); + } + } + // Optionally show a success message if (props.successMessage) { notifications.hide('form-success'); diff --git a/src/frontend/src/components/items/ActionDropdown.tsx b/src/frontend/src/components/items/ActionDropdown.tsx index e3c2f951f1..a677bd6ee5 100644 --- a/src/frontend/src/components/items/ActionDropdown.tsx +++ b/src/frontend/src/components/items/ActionDropdown.tsx @@ -14,8 +14,10 @@ import { IconTrash, IconUnlink } from '@tabler/icons-react'; +import { color } from '@uiw/react-codemirror'; import { ReactNode, useMemo } from 'react'; +import { InvenTreeIcon } from '../../functions/icons'; import { notYetImplemented } from '../../functions/notifications'; export type ActionDropdownItem = { @@ -203,6 +205,24 @@ export function DeleteItemAction({ }; } +export function CancelItemAction({ + hidden = false, + tooltip, + onClick +}: { + hidden?: boolean; + tooltip?: string; + onClick?: () => void; +}): ActionDropdownItem { + return { + icon: , + name: t`Cancel`, + tooltip: tooltip ?? t`Cancel`, + onClick: onClick, + hidden: hidden + }; +} + // Common action button for duplicating an item export function DuplicateItemAction({ hidden = false, diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index fc023688af..ccc7276ab5 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -268,8 +268,6 @@ function StockOperationsRow({ }) { const item = input.item; - console.log('rec', record); - const [value, setValue] = useState( add ? 0 : item.quantity ?? 0 ); diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index b362d08ce5..486cb43343 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -12,8 +12,10 @@ import { IconCalendarStats, IconCategory, IconCheck, + IconCircleCheck, IconCircleMinus, IconCirclePlus, + IconCircleX, IconClipboardList, IconClipboardText, IconCopy, @@ -59,6 +61,7 @@ import { IconTool, IconTools, IconTransfer, + IconTransitionRight, IconTrash, IconTruck, IconTruckDelivery, @@ -130,6 +133,10 @@ const icons = { delete: IconTrash, packaging: IconPackage, packages: IconPackages, + install: IconTransitionRight, + plus: IconCirclePlus, + minus: IconCircleMinus, + cancel: IconCircleX, // Part Icons active: IconCheck, @@ -186,7 +193,8 @@ const icons = { batch_code: IconClipboardText, destination: IconFlag, repeat_destination: IconFlagShare, - unlink: IconUnlink + unlink: IconUnlink, + success: IconCircleCheck }; export type InvenTreeIconType = keyof typeof icons; diff --git a/src/frontend/src/hooks/UseTable.tsx b/src/frontend/src/hooks/UseTable.tsx index 010085861b..19ed88aa56 100644 --- a/src/frontend/src/hooks/UseTable.tsx +++ b/src/frontend/src/hooks/UseTable.tsx @@ -1,5 +1,5 @@ import { randomId, useLocalStorage } from '@mantine/hooks'; -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { TableFilter } from '../tables/Filter'; @@ -22,6 +22,7 @@ export type TableState = { expandedRecords: any[]; setExpandedRecords: (records: any[]) => void; selectedRecords: any[]; + hasSelectedRecords: boolean; setSelectedRecords: (records: any[]) => void; clearSelectedRecords: () => void; hiddenColumns: string[]; @@ -78,6 +79,11 @@ export function useTable(tableName: string): TableState { setSelectedRecords([]); }, []); + const hasSelectedRecords = useMemo( + () => selectedRecords.length > 0, + [selectedRecords] + ); + // Total record count const [recordCount, setRecordCount] = useState(0); @@ -126,6 +132,7 @@ export function useTable(tableName: string): TableState { selectedRecords, setSelectedRecords, clearSelectedRecords, + hasSelectedRecords, hiddenColumns, setHiddenColumns, searchTerm, diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 1f6bb03f0d..0644ad90aa 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -22,6 +22,7 @@ import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { ActionDropdown, + CancelItemAction, DuplicateItemAction, EditItemAction, LinkBarcodeAction, @@ -36,12 +37,16 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; import { useBuildOrderFields } from '../../forms/BuildForms'; -import { useEditApiFormModal } from '../../hooks/UseForm'; +import { + useCreateApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; import BuildLineTable from '../../tables/build/BuildLineTable'; import { BuildOrderTable } from '../../tables/build/BuildOrderTable'; +import BuildOutputTable from '../../tables/build/BuildOutputTable'; import { AttachmentTable } from '../../tables/general/AttachmentTable'; import { StockItemTable } from '../../tables/stock/StockItemTable'; @@ -213,7 +218,12 @@ export default function BuildDetail() { { name: 'incomplete-outputs', label: t`Incomplete Outputs`, - icon: + icon: , + content: build.pk ? ( + + ) : ( + + ) // TODO: Hide if build is complete }, { @@ -290,6 +300,18 @@ export default function BuildDetail() { } }); + const duplicateBuild = useCreateApiFormModal({ + url: ApiEndpoints.build_order_list, + title: t`Add Build Order`, + fields: buildOrderFields, + initialData: { + ...build, + reference: undefined + }, + follow: true, + modelType: ModelType.build + }); + const buildActions = useMemo(() => { // TODO: Disable certain actions based on user permissions return [ @@ -328,7 +350,13 @@ export default function BuildDetail() { onClick: () => editBuild.open(), hidden: !user.hasChangeRole(UserRoles.build) }), - DuplicateItemAction({}) + CancelItemAction({ + tooltip: t`Cancel order` + }), + DuplicateItemAction({ + onClick: () => duplicateBuild.open(), + hidden: !user.hasAddRole(UserRoles.build) + }) ]} /> ]; @@ -349,6 +377,7 @@ export default function BuildDetail() { return ( <> {editBuild.modal} + {duplicateBuild.modal} { return [ } actions={[ DuplicateItemAction({ - hidden: !user.hasAddRole(UserRoles.purchase_order) + hidden: !user.hasAddRole(UserRoles.purchase_order), + onClick: () => duplicateManufacturerPart.open() }), EditItemAction({ hidden: !user.hasChangeRole(UserRoles.purchase_order), @@ -227,6 +242,7 @@ export default function ManufacturerPartDetail() { return ( <> {editManufacturerPart.modal} + {duplicateManufacturerPart.modal} { - if (response.pk) { - navigate(getDetailUrl(ModelType.supplierpart, response.pk)); - } - } + follow: true, + modelType: ModelType.supplierpart }); const breadcrumbs = useMemo(() => { diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 09a1dfa36b..859048e5e5 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -24,7 +24,7 @@ import { } from '@tabler/icons-react'; import { useSuspenseQuery } from '@tanstack/react-query'; import { ReactNode, useMemo, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import { api } from '../../App'; import { DetailsField, DetailsTable } from '../../components/details/Details'; @@ -86,7 +86,6 @@ export default function PartDetail() { const { id } = useParams(); const user = useUserState(); - const navigate = useNavigate(); const [treeOpen, setTreeOpen] = useState(false); @@ -680,11 +679,8 @@ export default function PartDetail() { initialData: { ...part }, - onFormSuccess: (response: any) => { - if (response.pk) { - navigate(getDetailUrl(ModelType.part, response.pk)); - } - } + follow: true, + modelType: ModelType.part }); const stockActionProps: StockOperationProps = useMemo(() => { diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx index a982cf3b23..b0485ed1ec 100644 --- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx @@ -9,7 +9,7 @@ import { IconPaperclip } from '@tabler/icons-react'; import { ReactNode, useMemo } from 'react'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { DetailsField, DetailsTable } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; @@ -17,7 +17,8 @@ import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { ActionDropdown, BarcodeActionDropdown, - DeleteItemAction, + CancelItemAction, + DuplicateItemAction, EditItemAction, LinkBarcodeAction, UnlinkBarcodeAction, @@ -31,7 +32,11 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; import { usePurchaseOrderFields } from '../../forms/PurchaseOrderForms'; -import { useEditApiFormModal } from '../../hooks/UseForm'; +import { getDetailUrl } from '../../functions/urls'; +import { + useCreateApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; @@ -46,6 +51,7 @@ export default function PurchaseOrderDetail() { const { id } = useParams(); const user = useUserState(); + const navigate = useNavigate(); const { instance: order, @@ -72,6 +78,18 @@ export default function PurchaseOrderDetail() { } }); + const duplicatePurchaseOrder = useCreateApiFormModal({ + url: ApiEndpoints.purchase_order_list, + title: t`Add Purchase Order`, + fields: purchaseOrderFields, + initialData: { + ...order, + reference: undefined + }, + follow: true, + modelType: ModelType.purchaseorder + }); + const detailsPanel = useMemo(() => { if (instanceQuery.isFetching) { return ; @@ -299,8 +317,12 @@ export default function PurchaseOrderDetail() { editPurchaseOrder.open(); } }), - DeleteItemAction({ - hidden: !user.hasDeleteRole(UserRoles.purchase_order) + CancelItemAction({ + tooltip: t`Cancel order` + }), + DuplicateItemAction({ + hidden: !user.hasAddRole(UserRoles.purchase_order), + onClick: () => duplicatePurchaseOrder.open() }) ]} /> @@ -322,6 +344,7 @@ export default function PurchaseOrderDetail() { return ( <> {editPurchaseOrder.modal} + {duplicatePurchaseOrder.modal} { return [ duplicateReturnOrder.open() }) ]} /> @@ -285,6 +306,7 @@ export default function ReturnOrderDetail() { return ( <> {editReturnOrder.modal} + {duplicateReturnOrder.modal} { return [ { @@ -281,13 +300,14 @@ export default function SalesOrderDetail() { actions={[ EditItemAction({ hidden: !user.hasChangeRole(UserRoles.sales_order), - onClick: () => { - editSalesOrder.open(); - } + onClick: () => editSalesOrder.open() }), - DeleteItemAction({ - hidden: !user.hasDeleteRole(UserRoles.sales_order) - // TODO: Delete? + CancelItemAction({ + tooltip: t`Cancel order` + }), + DuplicateItemAction({ + hidden: !user.hasAddRole(UserRoles.sales_order), + onClick: () => duplicateSalesOrder.open() }) ]} /> @@ -309,6 +329,7 @@ export default function SalesOrderDetail() { return ( <> {editSalesOrder.modal} + {duplicateSalesOrder.modal} { - if (response.pk) { - navigate(getDetailUrl(ModelType.stockitem, response.pk)); - } - } + follow: true, + modelType: ModelType.stockitem }); const stockActionProps: StockOperationProps = useMemo(() => { @@ -479,6 +464,11 @@ export default function StockDetail() { return instanceQuery.isLoading ? [] : [ + , { + let location = resolveItem(record, accessor); + + if (!location) { + return {t`No location set`}; + } + + return {location.name}; + } + }; +} + export function BooleanColumn({ accessor, title, diff --git a/src/frontend/src/tables/TableHoverCard.tsx b/src/frontend/src/tables/TableHoverCard.tsx index 8486845638..93893ab834 100644 --- a/src/frontend/src/tables/TableHoverCard.tsx +++ b/src/frontend/src/tables/TableHoverCard.tsx @@ -27,7 +27,7 @@ export function TableHoverCard({ } return ( - + {value} diff --git a/src/frontend/src/tables/bom/BomTable.tsx b/src/frontend/src/tables/bom/BomTable.tsx index 06e7603347..41b358318a 100644 --- a/src/frontend/src/tables/bom/BomTable.tsx +++ b/src/frontend/src/tables/bom/BomTable.tsx @@ -298,7 +298,7 @@ export function BomTable({ const newBomItem = useCreateApiFormModal({ url: ApiEndpoints.bom_list, - title: t`Create BOM Item`, + title: t`Add BOM Item`, fields: bomItemFields(), initialData: { part: partId diff --git a/src/frontend/src/tables/build/BuildOrderTable.tsx b/src/frontend/src/tables/build/BuildOrderTable.tsx index 19f9438380..cfc0a61346 100644 --- a/src/frontend/src/tables/build/BuildOrderTable.tsx +++ b/src/frontend/src/tables/build/BuildOrderTable.tsx @@ -1,6 +1,5 @@ import { t } from '@lingui/macro'; import { useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; import { AddItemButton } from '../../components/buttons/AddItemButton'; import { PartHoverCard } from '../../components/images/Thumbnail'; @@ -11,7 +10,6 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; import { useBuildOrderFields } from '../../forms/BuildForms'; -import { getDetailUrl } from '../../functions/urls'; import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; @@ -130,7 +128,6 @@ export function BuildOrderTable({ ]; }, []); - const navigate = useNavigate(); const user = useUserState(); const table = useTable('buildorder'); @@ -146,11 +143,8 @@ export function BuildOrderTable({ sales_order: salesOrderId, parent: parentBuildId }, - onFormSuccess: (data: any) => { - if (data.pk) { - navigate(getDetailUrl(ModelType.build, data.pk)); - } - } + follow: true, + modelType: ModelType.build }); const tableActions = useMemo(() => { diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx new file mode 100644 index 0000000000..657151909d --- /dev/null +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -0,0 +1,284 @@ +import { t } from '@lingui/macro'; +import { Group, Text } from '@mantine/core'; +import { + IconCircleCheck, + IconCircleX, + IconExclamationCircle +} from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useMemo } from 'react'; + +import { api } from '../../App'; +import { ActionButton } from '../../components/buttons/ActionButton'; +import { AddItemButton } from '../../components/buttons/AddItemButton'; +import { ProgressBar } from '../../components/items/ProgressBar'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; +import { UserRoles } from '../../enums/Roles'; +import { InvenTreeIcon } from '../../functions/icons'; +import { useTable } from '../../hooks/UseTable'; +import { apiUrl } from '../../states/ApiState'; +import { useUserState } from '../../states/UserState'; +import { TableColumn } from '../Column'; +import { LocationColumn, PartColumn } from '../ColumnRenderers'; +import { InvenTreeTable } from '../InvenTreeTable'; +import { RowAction } from '../RowActions'; +import { TableHoverCard } from '../TableHoverCard'; + +type TestResultOverview = { + name: string; + result: boolean; +}; + +export default function BuildOutputTable({ + buildId, + partId +}: { + buildId: number; + partId: number; +}) { + const user = useUserState(); + const table = useTable('build-outputs'); + + // Fetch the test templates associated with the partId + const { data: testTemplates } = useQuery({ + queryKey: ['buildoutputtests', partId], + queryFn: async () => { + if (!partId) { + return []; + } + + return api + .get(apiUrl(ApiEndpoints.part_test_template_list), { + params: { + part: partId, + include_inherited: true, + enabled: true, + required: true + } + }) + .then((response) => response.data) + .catch(() => []); + } + }); + + const hasRequiredTests: boolean = useMemo(() => { + return (testTemplates?.length ?? 0) > 0; + }, [partId, testTemplates]); + + // Format table records + const formatRecords = useCallback( + (records: any[]): any[] => { + records?.forEach((record: any, index: number) => { + let results: TestResultOverview[] = []; + let passCount: number = 0; + + // Iterate through each + testTemplates?.forEach((template: any) => { + // Find the "newest" result for this template in the returned data + let result = record.tests + ?.filter((test: any) => test.template == template.pk) + .sort((a: any, b: any) => { + return a.pk < b.pk ? 1 : -1; + }) + .shift(); + + if (template?.required && result?.result) { + passCount += 1; + } + + results.push({ + name: template.test_name, + result: result?.result ?? false + }); + }); + + records[index].passCount = passCount; + records[index].results = results; + }); + + return records; + }, + [partId, testTemplates] + ); + + const tableActions = useMemo(() => { + // TODO: Button to create new build output + // TODO: Button to complete output(s) + // TODO: Button to cancel output(s) + // TODO: Button to scrap output(s) + return [ +