From 169f4f83508a01d7cc73284f40659450161afc29 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 16 Dec 2024 23:07:33 +1100 Subject: [PATCH] [PUI] Order Parts Wizard (#8602) * Add generic * Add a wizard hook - Similar to existing modal form hook * Slight refactor * Simple placeholder table * Only purchaseable parts * Add callback to remove selected part * Add step enum * Improve wizard * Render supplier image * Add validation checks for wizard * Further wizard improvements * add error support * Improvements * Refactoring * Add error checking * Order from part detail page * Implement from SalesOrder view * Implement from build line table * Implement from StockItem table * Add to StockDetail page * Cleanup PartTable * Refactor to use DataTable interface * Simplify wizard into single step * Refactoring * Allow creation of new supplier part * Cleanup * Refactor * Fix static hook issue * Fix infinite useEffect * Playwright tests --- src/frontend/src/components/items/Expand.tsx | 14 + .../src/components/render/Company.tsx | 4 +- .../components/wizards/OrderPartsWizard.tsx | 420 ++++++++++++++++++ .../src/components/wizards/WizardDrawer.tsx | 188 ++++++++ src/frontend/src/forms/CompanyForms.tsx | 11 +- src/frontend/src/forms/PurchaseOrderForms.tsx | 7 +- src/frontend/src/hooks/UseWizard.tsx | 133 ++++++ .../src/pages/company/SupplierPartDetail.tsx | 2 +- src/frontend/src/pages/part/PartDetail.tsx | 149 ++----- src/frontend/src/pages/stock/StockDetail.tsx | 35 +- .../src/tables/build/BuildLineTable.tsx | 34 +- src/frontend/src/tables/part/PartTable.tsx | 24 +- .../tables/purchasing/SupplierPartTable.tsx | 6 +- .../tables/sales/SalesOrderLineItemTable.tsx | 26 +- .../src/tables/stock/StockItemTable.tsx | 45 +- .../tests/pages/pui_purchase_order.spec.ts | 78 ++++ 16 files changed, 1025 insertions(+), 151 deletions(-) create mode 100644 src/frontend/src/components/items/Expand.tsx create mode 100644 src/frontend/src/components/wizards/OrderPartsWizard.tsx create mode 100644 src/frontend/src/components/wizards/WizardDrawer.tsx create mode 100644 src/frontend/src/hooks/UseWizard.tsx diff --git a/src/frontend/src/components/items/Expand.tsx b/src/frontend/src/components/items/Expand.tsx new file mode 100644 index 000000000000..ae12cacd0652 --- /dev/null +++ b/src/frontend/src/components/items/Expand.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; + +/** + * A component that expands to fill the available space + */ +export default function Expand({ + children, + flex +}: { + children: ReactNode; + flex?: number; +}) { + return
{children}
; +} diff --git a/src/frontend/src/components/render/Company.tsx b/src/frontend/src/components/render/Company.tsx index d0d1c6c47ad2..75c713e4c9e6 100644 --- a/src/frontend/src/components/render/Company.tsx +++ b/src/frontend/src/components/render/Company.tsx @@ -70,7 +70,9 @@ export function RenderSupplierPart( {...props} primary={supplier?.name} secondary={instance.SKU} - image={part?.thumbnail ?? part?.image} + image={ + part?.thumbnail ?? part?.image ?? supplier?.thumbnail ?? supplier?.image + } suffix={ part.full_name ? {part.full_name} : undefined } diff --git a/src/frontend/src/components/wizards/OrderPartsWizard.tsx b/src/frontend/src/components/wizards/OrderPartsWizard.tsx new file mode 100644 index 000000000000..1076eb6df8ab --- /dev/null +++ b/src/frontend/src/components/wizards/OrderPartsWizard.tsx @@ -0,0 +1,420 @@ +import { t } from '@lingui/macro'; +import { Alert, Group, Paper, Tooltip } from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { IconShoppingCart } from '@tabler/icons-react'; +import { DataTable } from 'mantine-datatable'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; +import { useSupplierPartFields } from '../../forms/CompanyForms'; +import { usePurchaseOrderFields } from '../../forms/PurchaseOrderForms'; +import { useCreateApiFormModal } from '../../hooks/UseForm'; +import useWizard from '../../hooks/UseWizard'; +import { apiUrl } from '../../states/ApiState'; +import { PartColumn } from '../../tables/ColumnRenderers'; +import { ActionButton } from '../buttons/ActionButton'; +import { AddItemButton } from '../buttons/AddItemButton'; +import RemoveRowButton from '../buttons/RemoveRowButton'; +import { StandaloneField } from '../forms/StandaloneField'; +import type { ApiFormFieldSet } from '../forms/fields/ApiFormField'; +import Expand from '../items/Expand'; + +/** + * Attributes for each selected part + * - part: The part instance + * - supplier_part: The selected supplier part instance + * - purchase_order: The selected purchase order instance + * - quantity: The quantity of the part to order + * - errors: Error messages for each attribute + */ +interface PartOrderRecord { + part: any; + supplier_part: any; + purchase_order: any; + quantity: number; + errors: any; +} + +function SelectPartsStep({ + records, + onRemovePart, + onSelectSupplierPart, + onSelectPurchaseOrder +}: { + records: PartOrderRecord[]; + onRemovePart: (part: any) => void; + onSelectSupplierPart: (partId: number, supplierPart: any) => void; + onSelectPurchaseOrder: (partId: number, purchaseOrder: any) => void; +}) { + const [selectedRecord, setSelectedRecord] = useState( + null + ); + + const purchaseOrderFields = usePurchaseOrderFields({ + supplierId: selectedRecord?.supplier_part?.supplier + }); + + const newPurchaseOrder = useCreateApiFormModal({ + url: apiUrl(ApiEndpoints.purchase_order_list), + title: t`New Purchase Order`, + fields: purchaseOrderFields, + successMessage: t`Purchase order created`, + onFormSuccess: (response: any) => { + onSelectPurchaseOrder(selectedRecord?.part.pk, response); + } + }); + + const supplierPartFields = useSupplierPartFields({ + partId: selectedRecord?.part.pk + }); + + const newSupplierPart = useCreateApiFormModal({ + url: apiUrl(ApiEndpoints.supplier_part_list), + title: t`New Supplier Part`, + fields: supplierPartFields, + successMessage: t`Supplier part created`, + onFormSuccess: (response: any) => { + onSelectSupplierPart(selectedRecord?.part.pk, response); + } + }); + + const addToOrderFields: ApiFormFieldSet = useMemo(() => { + return { + order: { + value: selectedRecord?.purchase_order?.pk, + disabled: true + }, + part: { + value: selectedRecord?.supplier_part?.pk, + disabled: true + }, + reference: {}, + quantity: { + // TODO: Auto-fill with the desired quantity + }, + merge_items: {} + }; + }, [selectedRecord]); + + const addToOrder = useCreateApiFormModal({ + url: apiUrl(ApiEndpoints.purchase_order_line_list), + title: t`Add to Purchase Order`, + fields: addToOrderFields, + focus: 'quantity', + initialData: { + order: selectedRecord?.purchase_order?.pk, + part: selectedRecord?.supplier_part?.pk, + quantity: selectedRecord?.quantity + }, + onFormSuccess: (response: any) => { + // Remove the row from the list + onRemovePart(selectedRecord?.part); + }, + successMessage: t`Part added to purchase order` + }); + + const columns: any[] = useMemo(() => { + return [ + { + accessor: 'left_actions', + title: ' ', + width: '1%', + render: (record: PartOrderRecord) => ( + + onRemovePart(record.part)} /> + + ) + }, + { + accessor: 'part', + title: t`Part`, + render: (record: PartOrderRecord) => ( + + + + + + ) + }, + { + accessor: 'supplier_part', + title: t`Supplier Part`, + width: '40%', + render: (record: PartOrderRecord) => ( + + + { + onSelectSupplierPart(record.part.pk, instance); + }, + filters: { + part: record.part.pk, + active: true, + supplier_detail: true + } + }} + /> + + { + setSelectedRecord(record); + newSupplierPart.open(); + }} + /> + + ) + }, + { + accessor: 'purchase_order', + title: t`Purchase Order`, + width: '40%', + render: (record: PartOrderRecord) => ( + + + { + onSelectPurchaseOrder(record.part.pk, instance); + } + }} + /> + + { + setSelectedRecord(record); + newPurchaseOrder.open(); + }} + /> + + ) + }, + { + accessor: 'right_actions', + title: ' ', + width: '1%', + render: (record: PartOrderRecord) => ( + + { + setSelectedRecord(record); + addToOrder.open(); + }} + disabled={ + !record.supplier_part?.pk || + !record.quantity || + !record.purchase_order?.pk + } + icon={} + tooltip={t`Add to selected purchase order`} + tooltipAlignment='top' + color='blue' + /> + + ) + } + ]; + }, [onRemovePart]); + + if (records.length === 0) { + return ( + + {t`No purchaseable parts selected`} + + ); + } + + return ( + <> + + {newPurchaseOrder.modal} + {newSupplierPart.modal} + {addToOrder.modal} + + ); +} + +export default function OrderPartsWizard({ + parts +}: { + parts: any[]; +}) { + // Track a list of selected parts + const [selectedParts, setSelectedParts] = useState([]); + + // Remove a part from the selected parts list + const removePart = useCallback( + (part: any) => { + const records = selectedParts.filter( + (record: PartOrderRecord) => record.part?.pk !== part.pk + ); + + setSelectedParts(records); + + // If no parts remain, close the wizard + if (records.length === 0) { + wizard.closeWizard(); + showNotification({ + title: t`Parts Added`, + message: t`All selected parts added to a purchase order`, + color: 'green' + }); + } + }, + [selectedParts] + ); + + // Select a supplier part for a part + const selectSupplierPart = useCallback( + (partId: number, supplierPart: any) => { + const records = [...selectedParts]; + + records.forEach((record: PartOrderRecord, index: number) => { + if (record.part.pk === partId) { + records[index].supplier_part = supplierPart; + } + }); + + setSelectedParts(records); + }, + [selectedParts] + ); + + // Select purchase order for a part + const selectPurchaseOrder = useCallback( + (partId: number, purchaseOrder: any) => { + const records = [...selectedParts]; + + records.forEach((record: PartOrderRecord, index: number) => { + if (record.part.pk === partId) { + records[index].purchase_order = purchaseOrder; + } + }); + + setSelectedParts(records); + }, + [selectedParts] + ); + + // Render the select wizard step + const renderStep = useCallback( + (step: number) => { + return ( + + ); + }, + [selectedParts] + ); + + const canStepForward = useCallback( + (step: number): boolean => { + if (!selectedParts?.length) { + wizard.setError(t`No parts selected`); + wizard.setErrorDetail(t`You must select at least one part to order`); + return false; + } + + let result = true; + const records = [...selectedParts]; + + // Check for errors in each part + selectedParts.forEach((record: PartOrderRecord, index: number) => { + records[index].errors = { + supplier_part: !record.supplier_part + ? t`Supplier part is required` + : null, + quantity: + !record.quantity || record.quantity <= 0 + ? t`Quantity is required` + : null + }; + + // If any errors are found, set the result to false + if (Object.values(records[index].errors).some((error) => error)) { + result = false; + } + }); + + setSelectedParts(records); + + if (!result) { + wizard.setError(t`Invalid part selection`); + wizard.setErrorDetail( + t`Please correct the errors in the selected parts` + ); + } + + return result; + }, + [selectedParts] + ); + + // Create the wizard manager + const wizard = useWizard({ + title: t`Order Parts`, + steps: [], + renderStep: renderStep, + canStepForward: canStepForward + }); + + // Reset the wizard to a known state when opened + useEffect(() => { + const records: PartOrderRecord[] = []; + + if (wizard.opened) { + parts + .filter((part) => part.purchaseable && part.active) + .forEach((part) => { + // Prevent duplicate entries based on pk + if ( + !records.find( + (record: PartOrderRecord) => record.part?.pk === part.pk + ) + ) { + records.push({ + part: part, + supplier_part: undefined, + purchase_order: undefined, + quantity: 1, + errors: {} + }); + } + }); + + setSelectedParts(records); + } else { + setSelectedParts([]); + } + }, [wizard.opened]); + + return wizard; +} diff --git a/src/frontend/src/components/wizards/WizardDrawer.tsx b/src/frontend/src/components/wizards/WizardDrawer.tsx new file mode 100644 index 000000000000..beec14c864c2 --- /dev/null +++ b/src/frontend/src/components/wizards/WizardDrawer.tsx @@ -0,0 +1,188 @@ +import { t } from '@lingui/macro'; +import { + ActionIcon, + Card, + Divider, + Drawer, + Group, + Paper, + Space, + Stack, + Stepper, + Tooltip +} from '@mantine/core'; +import { + IconArrowLeft, + IconArrowRight, + IconCircleCheck +} from '@tabler/icons-react'; +import { type ReactNode, useCallback, useMemo } from 'react'; +import { Boundary } from '../Boundary'; +import { StylishText } from '../items/StylishText'; + +/** + * Progress stepper displayed at the top of the wizard drawer. + */ +function WizardProgressStepper({ + currentStep, + steps, + onSelectStep +}: { + currentStep: number; + steps: string[]; + onSelectStep: (step: number) => void; +}) { + if (!steps || steps.length == 0) { + return null; + } + + // Determine if the user can select a particular step + const canSelectStep = useCallback( + (step: number) => { + if (!steps || steps.length <= 1) { + return false; + } + + // Only allow single-step progression + return Math.abs(step - currentStep) == 1; + }, + [currentStep, steps] + ); + + const canStepBackward = currentStep > 0; + const canStepForward = currentStep < steps.length - 1; + + return ( + + + + onSelectStep(currentStep - 1)} + disabled={!canStepBackward} + > + + + + onSelectStep(stepIndex)} + iconSize={20} + size='xs' + > + {steps.map((step: string, idx: number) => ( + + ))} + + {canStepForward ? ( + + onSelectStep(currentStep + 1)} + disabled={!canStepForward} + > + + + + ) : ( + + + + + + )} + + + ); +} + +/** + * A generic "wizard" drawer, for handling multi-step processes. + */ +export default function WizardDrawer({ + title, + currentStep, + steps, + children, + opened, + onClose, + onNextStep, + onPreviousStep +}: { + title: string; + currentStep: number; + steps: string[]; + children: ReactNode; + opened: boolean; + onClose: () => void; + onNextStep?: () => void; + onPreviousStep?: () => void; +}) { + const titleBlock: ReactNode = useMemo(() => { + return ( + + + {title} + { + if (step < currentStep) { + onPreviousStep?.(); + } else { + onNextStep?.(); + } + }} + /> + + + + + ); + }, [title, currentStep, steps]); + + return ( + + + {} + {children} + + + ); +} diff --git a/src/frontend/src/forms/CompanyForms.tsx b/src/frontend/src/forms/CompanyForms.tsx index 85138ec7f324..ad9a3d63c9cf 100644 --- a/src/frontend/src/forms/CompanyForms.tsx +++ b/src/frontend/src/forms/CompanyForms.tsx @@ -18,11 +18,18 @@ import type { /** * Field set for SupplierPart instance */ -export function useSupplierPartFields() { +export function useSupplierPartFields({ + partId +}: { + partId?: number; +}) { return useMemo(() => { const fields: ApiFormFieldSet = { part: { + value: partId, + disabled: !!partId, filters: { + part: partId, purchaseable: true, active: true } @@ -63,7 +70,7 @@ export function useSupplierPartFields() { }; return fields; - }, []); + }, [partId]); } export function useManufacturerPartFields() { diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index b36145dcb21d..8523a3a28fe8 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -139,8 +139,10 @@ export function usePurchaseOrderLineItemFields({ * Construct a set of fields for creating / editing a PurchaseOrder instance */ export function usePurchaseOrderFields({ + supplierId, duplicateOrderId }: { + supplierId?: number; duplicateOrderId?: number; }): ApiFormFieldSet { return useMemo(() => { @@ -150,7 +152,8 @@ export function usePurchaseOrderFields({ }, description: {}, supplier: { - disabled: duplicateOrderId !== undefined, + value: supplierId, + disabled: !!duplicateOrderId || !!supplierId, filters: { is_supplier: true, active: true @@ -213,7 +216,7 @@ export function usePurchaseOrderFields({ } return fields; - }, [duplicateOrderId]); + }, [duplicateOrderId, supplierId]); } /** diff --git a/src/frontend/src/hooks/UseWizard.tsx b/src/frontend/src/hooks/UseWizard.tsx new file mode 100644 index 000000000000..e8f06f9953fd --- /dev/null +++ b/src/frontend/src/hooks/UseWizard.tsx @@ -0,0 +1,133 @@ +import { Alert, Stack } from '@mantine/core'; +import { IconExclamationCircle } from '@tabler/icons-react'; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useState +} from 'react'; +import WizardDrawer from '../components/wizards/WizardDrawer'; + +export interface WizardProps { + title: string; + steps: string[]; + renderStep: (step: number) => ReactNode; + canStepForward?: (step: number) => boolean; + canStepBackward?: (step: number) => boolean; +} + +export interface WizardState { + opened: boolean; + currentStep: number; + clearError: () => void; + error: string | null; + setError: (error: string | null) => void; + errorDetail: string | null; + setErrorDetail: (errorDetail: string | null) => void; + openWizard: () => void; + closeWizard: () => void; + nextStep: () => void; + previousStep: () => void; + wizard: ReactNode; +} + +/** + * Hook for managing a wizard-style multi-step process. + * - Manage the current step of the wizard + * - Allows opening and closing the wizard + * - Handles progression between steps with optional validation + */ +export default function useWizard(props: WizardProps): WizardState { + const [currentStep, setCurrentStep] = useState(0); + const [opened, setOpened] = useState(false); + + const [error, setError] = useState(null); + const [errorDetail, setErrorDetail] = useState(null); + + const clearError = useCallback(() => { + setError(null); + setErrorDetail(null); + }, []); + + // Reset the wizard to an initial state when opened + useEffect(() => { + if (opened) { + setCurrentStep(0); + clearError(); + } + }, [opened]); + + // Open the wizard + const openWizard = useCallback(() => { + setOpened(true); + }, []); + + // Close the wizard + const closeWizard = useCallback(() => { + setOpened(false); + }, []); + + // Progress the wizard to the next step + const nextStep = useCallback(() => { + if (props.canStepForward && !props.canStepForward(currentStep)) { + return; + } + + if (props.steps && currentStep < props.steps.length - 1) { + setCurrentStep(currentStep + 1); + clearError(); + } + }, [currentStep, props.canStepForward]); + + // Go back to the previous step + const previousStep = useCallback(() => { + if (props.canStepBackward && !props.canStepBackward(currentStep)) { + return; + } + + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + clearError(); + } + }, [currentStep, props.canStepBackward]); + + // Render the wizard contents for the current step + const contents = useMemo(() => { + return props.renderStep(currentStep); + }, [opened, currentStep, props.renderStep]); + + return { + currentStep, + opened, + clearError, + error, + setError, + errorDetail, + setErrorDetail, + openWizard, + closeWizard, + nextStep, + previousStep, + wizard: ( + + + {error && ( + }> + {errorDetail} + + )} + {contents} + + + ) + }; +} diff --git a/src/frontend/src/pages/company/SupplierPartDetail.tsx b/src/frontend/src/pages/company/SupplierPartDetail.tsx index 231520f2be3a..2ccc9763dd1b 100644 --- a/src/frontend/src/pages/company/SupplierPartDetail.tsx +++ b/src/frontend/src/pages/company/SupplierPartDetail.tsx @@ -318,7 +318,7 @@ export default function SupplierPartDetail() { ]; }, [user, supplierPart]); - const supplierPartFields = useSupplierPartFields(); + const supplierPartFields = useSupplierPartFields({}); const editSupplierPart = useEditApiFormModal({ url: ApiEndpoints.supplier_part_list, diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index cf12fb768cff..d47191501f3c 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -29,7 +29,7 @@ import { IconTruckReturn, IconVersions } from '@tabler/icons-react'; -import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { type ReactNode, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import Select from 'react-select'; @@ -62,6 +62,7 @@ import NotesPanel from '../../components/panels/NotesPanel'; import type { PanelType } from '../../components/panels/Panel'; import { PanelGroup } from '../../components/panels/PanelGroup'; import { RenderPart } from '../../components/render/Part'; +import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; import { formatPriceRange } from '../../defaults/formatters'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; @@ -416,41 +417,13 @@ export default function PartDetail() { ]; // Add in price range data - if (id) { + if (part.pricing_min || part.pricing_max) { br.push({ type: 'string', name: 'pricing', label: t`Price Range`, value_formatter: () => { - const { data } = useSuspenseQuery({ - queryKey: ['pricing', id], - queryFn: async () => { - const url = apiUrl(ApiEndpoints.part_pricing, null, { - id: id - }); - - return api - .get(url) - .then((response) => { - switch (response.status) { - case 200: - return response.data; - default: - return {}; - } - }) - .catch(() => { - return {}; - }); - } - }); - - return ( - data.overall_min && - `${formatPriceRange(data.overall_min, data.overall_max)}${ - part.units && ` / ${part.units}` - }` - ); + return formatPriceRange(part.pricing_min, part.pricing_max); } }); } @@ -463,79 +436,6 @@ export default function PartDetail() { icon: 'serial' }); - // Add in stocktake information - if (id && part.last_stocktake) { - br.push({ - type: 'string', - name: 'stocktake', - label: t`Last Stocktake`, - unit: true, - value_formatter: () => { - const { data } = useSuspenseQuery({ - queryKey: ['stocktake', id], - queryFn: async () => { - const url = apiUrl(ApiEndpoints.part_stocktake_list); - - return api - .get(url, { params: { part: id, ordering: 'date' } }) - .then((response) => { - switch (response.status) { - case 200: - if (response.data.length > 0) { - return response.data[response.data.length - 1]; - } else { - return {}; - } - default: - return {}; - } - }) - .catch(() => { - return {}; - }); - } - }); - - if (data?.quantity) { - return `${data.quantity} (${data.date})`; - } else { - return '-'; - } - } - }); - - br.push({ - type: 'string', - name: 'stocktake_user', - label: t`Stocktake By`, - badge: 'user', - icon: 'user', - value_formatter: () => { - const { data } = useSuspenseQuery({ - queryKey: ['stocktake', id], - queryFn: async () => { - const url = apiUrl(ApiEndpoints.part_stocktake_list); - - return api - .get(url, { params: { part: id, ordering: 'date' } }) - .then((response) => { - switch (response.status) { - case 200: - return response.data[response.data.length - 1]; - default: - return {}; - } - }) - .catch(() => { - return {}; - }); - } - }); - return data?.user; - } - }); - } - return part ? ( @@ -565,7 +465,14 @@ export default function PartDetail() { ) : ( ); - }, [globalSettings, part, serials, instanceQuery]); + }, [ + globalSettings, + part, + id, + serials, + instanceQuery.isFetching, + instanceQuery.data + ]); // Part data panels (recalculate when part data changes) const partPanels: PanelType[] = useMemo(() => { @@ -735,7 +642,7 @@ export default function PartDetail() { model_id: part?.pk }) ]; - }, [id, part, user, globalSettings, userSettings]); + }, [id, part, user, globalSettings, userSettings, detailsPanel]); // Fetch information on part revision const partRevisionQuery = useQuery({ @@ -820,19 +727,18 @@ export default function PartDetail() { }); }, [part, partRevisionQuery.isFetching, partRevisionQuery.data]); - const breadcrumbs = useMemo( - () => [ + const breadcrumbs = useMemo(() => { + return [ { name: t`Parts`, url: '/part' }, ...(part.category_path ?? []).map((c: any) => ({ name: c.name, url: getDetailUrl(ModelType.partcategory, c.pk) })) - ], - [part] - ); + ]; + }, [part]); const badges: ReactNode[] = useMemo(() => { - if (instanceQuery.isLoading || instanceQuery.isFetching) { + if (instanceQuery.isFetching) { return []; } @@ -883,7 +789,7 @@ export default function PartDetail() { key='inactive' /> ]; - }, [part, instanceQuery]); + }, [part, instanceQuery.isFetching]); const partFields = usePartFields({ create: false }); @@ -970,6 +876,10 @@ export default function PartDetail() { const countStockItems = useCountStockItem(stockActionProps); const transferStockItems = useTransferStockItem(stockActionProps); + const orderPartsWizard = OrderPartsWizard({ + parts: [part] + }); + const partActions = useMemo(() => { return [ , @@ -1011,6 +921,18 @@ export default function PartDetail() { onClick: () => { part.pk && transferStockItems.open(); } + }, + { + name: t`Order`, + tooltip: t`Order Stock`, + hidden: + !user.hasAddRole(UserRoles.purchase_order) || + !part?.active || + !part?.purchaseable, + icon: , + onClick: () => { + orderPartsWizard.openWizard(); + } } ]} />, @@ -1047,6 +969,7 @@ export default function PartDetail() { {duplicatePart.modal} {editPart.modal} {deletePart.modal} + {orderPartsWizard.wizard} ); - }, [stockitem, instanceQuery, enableExpiry]); + }, [stockitem, instanceQuery.isFetching, enableExpiry]); const showBuildAllocations: boolean = useMemo(() => { // Determine if "build allocations" should be shown for this stock item @@ -652,6 +654,10 @@ export default function StockDetail() { } }); + const orderPartsWizard = OrderPartsWizard({ + parts: stockitem.part_detail ? [stockitem.part_detail] : [] + }); + const stockActions = useMemo(() => { const inStock = user.hasChangeRole(UserRoles.stock) && @@ -717,6 +723,17 @@ export default function StockDetail() { stockitem.pk && removeStockItem.open(); } }, + { + name: t`Transfer`, + tooltip: t`Transfer Stock`, + hidden: !inStock, + icon: ( + + ), + onClick: () => { + stockitem.pk && transferStockItem.open(); + } + }, { name: t`Serialize`, tooltip: t`Serialize stock`, @@ -730,14 +747,15 @@ export default function StockDetail() { } }, { - name: t`Transfer`, - tooltip: t`Transfer Stock`, - hidden: !inStock, - icon: ( - - ), + name: t`Order`, + tooltip: t`Order Stock`, + hidden: + !user.hasAddRole(UserRoles.purchase_order) || + !stockitem.part_detail?.active || + !stockitem.part_detail?.purchaseable, + icon: , onClick: () => { - stockitem.pk && transferStockItem.open(); + orderPartsWizard.openWizard(); } }, { @@ -898,6 +916,7 @@ export default function StockDetail() { {serializeStockItem.modal} {returnStockItem.modal} {assignToCustomer.modal} + {orderPartsWizard.wizard} ); diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index d7266dbc4a97..76bc6b860e8f 100644 --- a/src/frontend/src/tables/build/BuildLineTable.tsx +++ b/src/frontend/src/tables/build/BuildLineTable.tsx @@ -13,6 +13,7 @@ import { useNavigate } from 'react-router-dom'; import { ActionButton } from '../../components/buttons/ActionButton'; import { ProgressBar } from '../../components/items/ProgressBar'; +import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; @@ -20,7 +21,6 @@ import { useAllocateStockToBuildForm, useBuildOrderFields } from '../../forms/BuildForms'; -import { notYetImplemented } from '../../functions/notifications'; import { useCreateApiFormModal, useDeleteApiFormModal, @@ -527,6 +527,12 @@ export default function BuildLineTable({ table: table }); + const [partsToOrder, setPartsToOrder] = useState([]); + + const orderPartsWizard = OrderPartsWizard({ + parts: partsToOrder + }); + const rowActions = useCallback( (record: any): RowAction[] => { const part = record.part_detail ?? {}; @@ -552,7 +558,6 @@ export default function BuildLineTable({ record.trackable == hasOutput; const canOrder = - in_production && !consumable && user.hasAddRole(UserRoles.purchase_order) && part.purchaseable; @@ -588,8 +593,12 @@ export default function BuildLineTable({ icon: , title: t`Order Stock`, hidden: !canOrder, + disabled: !table.hasSelectedRecords, color: 'blue', - onClick: notYetImplemented + onClick: () => { + setPartsToOrder([record.part_detail]); + orderPartsWizard.openWizard(); + } }, { icon: , @@ -631,6 +640,24 @@ export default function BuildLineTable({ autoAllocateStock.open(); }} />, +