diff --git a/src/frontend/src/components/items/Expand.tsx b/src/frontend/src/components/items/Expand.tsx new file mode 100644 index 0000000000..ae12cacd06 --- /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 <div style={{ flexGrow: flex ?? 1 }}>{children}</div>; +} diff --git a/src/frontend/src/components/render/Company.tsx b/src/frontend/src/components/render/Company.tsx index d0d1c6c47a..75c713e4c9 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 ? <Text size='sm'>{part.full_name}</Text> : undefined } diff --git a/src/frontend/src/components/wizards/OrderPartsWizard.tsx b/src/frontend/src/components/wizards/OrderPartsWizard.tsx new file mode 100644 index 0000000000..1076eb6df8 --- /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<PartOrderRecord | null>( + 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) => ( + <Group gap='xs' wrap='nowrap' justify='left'> + <RemoveRowButton onClick={() => onRemovePart(record.part)} /> + </Group> + ) + }, + { + accessor: 'part', + title: t`Part`, + render: (record: PartOrderRecord) => ( + <Tooltip label={record.part?.description}> + <Paper p='xs'> + <PartColumn part={record.part} /> + </Paper> + </Tooltip> + ) + }, + { + accessor: 'supplier_part', + title: t`Supplier Part`, + width: '40%', + render: (record: PartOrderRecord) => ( + <Group gap='xs' wrap='nowrap' justify='left'> + <Expand> + <StandaloneField + fieldName='supplier_part' + hideLabels={true} + error={record.errors?.supplier_part} + fieldDefinition={{ + field_type: 'related field', + api_url: apiUrl(ApiEndpoints.supplier_part_list), + model: ModelType.supplierpart, + required: true, + value: record.supplier_part?.pk, + onValueChange: (value, instance) => { + onSelectSupplierPart(record.part.pk, instance); + }, + filters: { + part: record.part.pk, + active: true, + supplier_detail: true + } + }} + /> + </Expand> + <AddItemButton + tooltip={t`New supplier part`} + tooltipAlignment='top' + onClick={() => { + setSelectedRecord(record); + newSupplierPart.open(); + }} + /> + </Group> + ) + }, + { + accessor: 'purchase_order', + title: t`Purchase Order`, + width: '40%', + render: (record: PartOrderRecord) => ( + <Group gap='xs' wrap='nowrap' justify='left'> + <Expand> + <StandaloneField + fieldName='purchase_order' + hideLabels={true} + fieldDefinition={{ + field_type: 'related field', + api_url: apiUrl(ApiEndpoints.purchase_order_list), + model: ModelType.purchaseorder, + disabled: !record.supplier_part?.supplier, + value: record.purchase_order?.pk, + filters: { + supplier: record.supplier_part?.supplier, + outstanding: true + }, + onValueChange: (value, instance) => { + onSelectPurchaseOrder(record.part.pk, instance); + } + }} + /> + </Expand> + <AddItemButton + tooltip={t`New purchase order`} + tooltipAlignment='top' + disabled={!record.supplier_part?.pk} + onClick={() => { + setSelectedRecord(record); + newPurchaseOrder.open(); + }} + /> + </Group> + ) + }, + { + accessor: 'right_actions', + title: ' ', + width: '1%', + render: (record: PartOrderRecord) => ( + <Group grow gap='xs' wrap='nowrap' justify='right'> + <ActionButton + onClick={() => { + setSelectedRecord(record); + addToOrder.open(); + }} + disabled={ + !record.supplier_part?.pk || + !record.quantity || + !record.purchase_order?.pk + } + icon={<IconShoppingCart />} + tooltip={t`Add to selected purchase order`} + tooltipAlignment='top' + color='blue' + /> + </Group> + ) + } + ]; + }, [onRemovePart]); + + if (records.length === 0) { + return ( + <Alert color='red' title={t`No parts selected`}> + {t`No purchaseable parts selected`} + </Alert> + ); + } + + return ( + <> + <DataTable idAccessor='part.pk' columns={columns} records={records} /> + {newPurchaseOrder.modal} + {newSupplierPart.modal} + {addToOrder.modal} + </> + ); +} + +export default function OrderPartsWizard({ + parts +}: { + parts: any[]; +}) { + // Track a list of selected parts + const [selectedParts, setSelectedParts] = useState<PartOrderRecord[]>([]); + + // 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 ( + <SelectPartsStep + records={selectedParts} + onRemovePart={removePart} + onSelectSupplierPart={selectSupplierPart} + onSelectPurchaseOrder={selectPurchaseOrder} + /> + ); + }, + [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 0000000000..beec14c864 --- /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 ( + <Card p='xs' withBorder> + <Group justify='space-between' gap='xs' wrap='nowrap'> + <Tooltip + label={steps[currentStep - 1]} + position='top' + disabled={!canStepBackward} + > + <ActionIcon + variant='transparent' + onClick={() => onSelectStep(currentStep - 1)} + disabled={!canStepBackward} + > + <IconArrowLeft /> + </ActionIcon> + </Tooltip> + <Stepper + active={currentStep} + onStepClick={(stepIndex: number) => onSelectStep(stepIndex)} + iconSize={20} + size='xs' + > + {steps.map((step: string, idx: number) => ( + <Stepper.Step + label={step} + key={step} + aria-label={`wizard-step-${idx}`} + allowStepSelect={canSelectStep(idx)} + /> + ))} + </Stepper> + {canStepForward ? ( + <Tooltip + label={steps[currentStep + 1]} + position='top' + disabled={!canStepForward} + > + <ActionIcon + variant='transparent' + onClick={() => onSelectStep(currentStep + 1)} + disabled={!canStepForward} + > + <IconArrowRight /> + </ActionIcon> + </Tooltip> + ) : ( + <Tooltip label={t`Complete`} position='top'> + <ActionIcon color='green' variant='transparent'> + <IconCircleCheck /> + </ActionIcon> + </Tooltip> + )} + </Group> + </Card> + ); +} + +/** + * 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 ( + <Stack gap='xs' style={{ width: '100%' }}> + <Group + gap='xs' + wrap='nowrap' + justify='space-between' + grow + preventGrowOverflow={false} + > + <StylishText size='xl'>{title}</StylishText> + <WizardProgressStepper + currentStep={currentStep} + steps={steps} + onSelectStep={(step: number) => { + if (step < currentStep) { + onPreviousStep?.(); + } else { + onNextStep?.(); + } + }} + /> + <Space /> + </Group> + <Divider /> + </Stack> + ); + }, [title, currentStep, steps]); + + return ( + <Drawer + position='bottom' + size={'75%'} + title={titleBlock} + withCloseButton={true} + closeOnEscape={false} + closeOnClickOutside={false} + styles={{ + header: { + width: '100%' + }, + title: { + width: '100%' + } + }} + opened={opened} + onClose={onClose} + > + <Boundary label='wizard-drawer'> + <Paper p='md'>{}</Paper> + {children} + </Boundary> + </Drawer> + ); +} diff --git a/src/frontend/src/forms/CompanyForms.tsx b/src/frontend/src/forms/CompanyForms.tsx index 85138ec7f3..ad9a3d63c9 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 b36145dcb2..8523a3a28f 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 0000000000..e8f06f9953 --- /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<string | null>(null); + const [errorDetail, setErrorDetail] = useState<string | null>(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: ( + <WizardDrawer + title={props.title} + currentStep={currentStep} + steps={props.steps} + opened={opened} + onClose={closeWizard} + onNextStep={nextStep} + onPreviousStep={previousStep} + > + <Stack gap='xs'> + {error && ( + <Alert color='red' title={error} icon={<IconExclamationCircle />}> + {errorDetail} + </Alert> + )} + {contents} + </Stack> + </WizardDrawer> + ) + }; +} diff --git a/src/frontend/src/pages/company/SupplierPartDetail.tsx b/src/frontend/src/pages/company/SupplierPartDetail.tsx index 231520f2be..2ccc9763dd 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 cf12fb768c..d47191501f 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 ? ( <ItemDetailsGrid> <Grid> @@ -565,7 +465,14 @@ export default function PartDetail() { ) : ( <Skeleton /> ); - }, [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 [ <AdminButton model={ModelType.part} id={part.pk} />, @@ -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: <IconShoppingCart color='blue' />, + onClick: () => { + orderPartsWizard.openWizard(); + } } ]} />, @@ -1047,6 +969,7 @@ export default function PartDetail() { {duplicatePart.modal} {editPart.modal} {deletePart.modal} + {orderPartsWizard.wizard} <InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}> <Stack gap='xs'> <NavigationTree diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index b9aedbcfa7..c362501458 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -7,6 +7,7 @@ import { IconHistory, IconInfoCircle, IconPackages, + IconShoppingCart, IconSitemap } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; @@ -41,6 +42,7 @@ import type { PanelType } from '../../components/panels/Panel'; import { PanelGroup } from '../../components/panels/PanelGroup'; import LocateItemButton from '../../components/plugins/LocateItemButton'; import { StatusRenderer } from '../../components/render/StatusRenderer'; +import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; import { formatCurrency } from '../../defaults/formatters'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; @@ -354,7 +356,7 @@ export default function StockDetail() { <DetailsTable fields={br} item={data} /> </ItemDetailsGrid> ); - }, [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: ( + <InvenTreeIcon icon='transfer' iconProps={{ color: 'blue' }} /> + ), + 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: ( - <InvenTreeIcon icon='transfer' iconProps={{ color: 'blue' }} /> - ), + name: t`Order`, + tooltip: t`Order Stock`, + hidden: + !user.hasAddRole(UserRoles.purchase_order) || + !stockitem.part_detail?.active || + !stockitem.part_detail?.purchaseable, + icon: <IconShoppingCart color='blue' />, onClick: () => { - stockitem.pk && transferStockItem.open(); + orderPartsWizard.openWizard(); } }, { @@ -898,6 +916,7 @@ export default function StockDetail() { {serializeStockItem.modal} {returnStockItem.modal} {assignToCustomer.modal} + {orderPartsWizard.wizard} </Stack> </InstanceDetail> ); diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index d7266dbc4a..76bc6b860e 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<any[]>([]); + + 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: <IconShoppingCart />, title: t`Order Stock`, hidden: !canOrder, + disabled: !table.hasSelectedRecords, color: 'blue', - onClick: notYetImplemented + onClick: () => { + setPartsToOrder([record.part_detail]); + orderPartsWizard.openWizard(); + } }, { icon: <IconTool />, @@ -631,6 +640,24 @@ export default function BuildLineTable({ autoAllocateStock.open(); }} />, + <ActionButton + key='order-parts' + hidden={!user.hasAddRole(UserRoles.purchase_order)} + disabled={!table.hasSelectedRecords} + icon={<IconShoppingCart />} + color='blue' + tooltip={t`Order Parts`} + onClick={() => { + setPartsToOrder( + table.selectedRecords + .filter( + (r) => r.part_detail?.purchaseable && r.part_detail?.active + ) + .map((r) => r.part_detail) + ); + orderPartsWizard.openWizard(); + }} + />, <ActionButton key='allocate-stock' icon={<IconArrowRight />} @@ -749,6 +776,7 @@ export default function BuildLineTable({ {deallocateStock.modal} {editAllocation.modal} {deleteAllocation.modal} + {orderPartsWizard.wizard} <InvenTreeTable url={apiUrl(ApiEndpoints.build_line_list)} tableState={table} diff --git a/src/frontend/src/tables/part/PartTable.tsx b/src/frontend/src/tables/part/PartTable.tsx index 51583ce1f3..f3c83e5d01 100644 --- a/src/frontend/src/tables/part/PartTable.tsx +++ b/src/frontend/src/tables/part/PartTable.tsx @@ -2,12 +2,16 @@ import { t } from '@lingui/macro'; import { Group, Text } from '@mantine/core'; import { type ReactNode, useMemo } from 'react'; +import { IconShoppingCart } from '@tabler/icons-react'; import { AddItemButton } from '../../components/buttons/AddItemButton'; +import { ActionDropdown } from '../../components/items/ActionDropdown'; +import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; import { formatPriceRange } from '../../defaults/formatters'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; import { usePartFields } from '../../forms/PartForms'; +import { InvenTreeIcon } from '../../functions/icons'; import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; @@ -333,8 +337,25 @@ export function PartListTable({ modelType: ModelType.part }); + const orderPartsWizard = OrderPartsWizard({ parts: table.selectedRecords }); + const tableActions = useMemo(() => { return [ + <ActionDropdown + tooltip={t`Part Actions`} + icon={<InvenTreeIcon icon='part' />} + disabled={!table.hasSelectedRecords} + actions={[ + { + name: t`Order Parts`, + icon: <IconShoppingCart color='blue' />, + tooltip: t`Order selected parts`, + onClick: () => { + orderPartsWizard.openWizard(); + } + } + ]} + />, <AddItemButton key='add-part' hidden={!user.hasAddRole(UserRoles.part)} @@ -342,11 +363,12 @@ export function PartListTable({ onClick={() => newPart.open()} /> ]; - }, [user]); + }, [user, table.hasSelectedRecords]); return ( <> {newPart.modal} + {orderPartsWizard.wizard} <InvenTreeTable url={apiUrl(ApiEndpoints.part_list)} tableState={table} diff --git a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx index f8219cdf8f..5d4c65c022 100644 --- a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx +++ b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx @@ -158,7 +158,9 @@ export function SupplierPartTable({ ]; }, [params]); - const supplierPartFields = useSupplierPartFields(); + const supplierPartFields = useSupplierPartFields({ + partId: params?.part + }); const addSupplierPart = useCreateApiFormModal({ url: ApiEndpoints.supplier_part_list, @@ -208,7 +210,7 @@ export function SupplierPartTable({ ]; }, []); - const editSupplierPartFields = useSupplierPartFields(); + const editSupplierPartFields = useSupplierPartFields({}); const [selectedSupplierPart, setSelectedSupplierPart] = useState<number>(0); diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx index 2fb3cda56b..77841f556d 100644 --- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx @@ -14,6 +14,7 @@ import { useNavigate } from 'react-router-dom'; import { ActionButton } from '../../components/buttons/ActionButton'; import { AddItemButton } from '../../components/buttons/AddItemButton'; import { ProgressBar } from '../../components/items/ProgressBar'; +import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; import { formatCurrency } from '../../defaults/formatters'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; @@ -24,7 +25,6 @@ import { useSalesOrderAllocateSerialsFields, useSalesOrderLineItemFields } from '../../forms/SalesOrderForms'; -import { notYetImplemented } from '../../functions/notifications'; import { useCreateApiFormModal, useDeleteApiFormModal, @@ -285,6 +285,12 @@ export default function SalesOrderLineItemTable({ } }); + const [partsToOrder, setPartsToOrder] = useState<any[]>([]); + + const orderPartsWizard = OrderPartsWizard({ + parts: partsToOrder + }); + const tableFilters: TableFilter[] = useMemo(() => { return [ { @@ -313,6 +319,18 @@ export default function SalesOrderLineItemTable({ }} hidden={!editable || !user.hasAddRole(UserRoles.sales_order)} />, + <ActionButton + key='order-parts' + hidden={!user.hasAddRole(UserRoles.purchase_order)} + disabled={!table.hasSelectedRecords} + tooltip={t`Order Parts`} + icon={<IconShoppingCart />} + color='blue' + onClick={() => { + setPartsToOrder(table.selectedRecords.map((r) => r.part_detail)); + orderPartsWizard.openWizard(); + }} + />, <ActionButton key='allocate-stock' tooltip={t`Allocate Stock`} @@ -396,7 +414,10 @@ export default function SalesOrderLineItemTable({ title: t`Order stock`, icon: <IconShoppingCart />, color: 'blue', - onClick: notYetImplemented + onClick: () => { + setPartsToOrder([record.part_detail]); + orderPartsWizard.openWizard(); + } }, RowEditAction({ hidden: !editable || !user.hasChangeRole(UserRoles.sales_order), @@ -455,6 +476,7 @@ export default function SalesOrderLineItemTable({ {newBuildOrder.modal} {allocateBySerials.modal} {allocateStock.modal} + {orderPartsWizard.wizard} <InvenTreeTable url={apiUrl(ApiEndpoints.sales_order_line_list)} tableState={table} diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index d9dfc20cf4..19ccc52c3e 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -1,9 +1,10 @@ import { t } from '@lingui/macro'; import { Group, Text } from '@mantine/core'; -import { type ReactNode, useMemo } from 'react'; +import { type ReactNode, useMemo, useState } from 'react'; import { AddItemButton } from '../../components/buttons/AddItemButton'; import { ActionDropdown } from '../../components/items/ActionDropdown'; +import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; import { formatCurrency, formatPriceRange } from '../../defaults/formatters'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; @@ -21,7 +22,6 @@ import { useTransferStockItem } from '../../forms/StockForms'; import { InvenTreeIcon } from '../../functions/icons'; -import { notYetImplemented } from '../../functions/notifications'; import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; @@ -540,6 +540,12 @@ export function StockItemTable({ modelType: ModelType.stockitem }); + const [partsToOrder, setPartsToOrder] = useState<any[]>([]); + + const orderPartsWizard = OrderPartsWizard({ + parts: partsToOrder + }); + const transferStock = useTransferStockItem(tableActionParams); const addStock = useAddStockItem(tableActionParams); const removeStock = useRemoveStockItem(tableActionParams); @@ -562,6 +568,17 @@ export function StockItemTable({ icon={<InvenTreeIcon icon='stock' />} disabled={table.selectedRecords.length === 0} actions={[ + { + name: t`Count Stock`, + icon: ( + <InvenTreeIcon icon='stocktake' iconProps={{ color: 'blue' }} /> + ), + tooltip: t`Count Stock`, + disabled: !can_add_stocktake, + onClick: () => { + countStock.open(); + } + }, { name: t`Add Stock`, icon: <InvenTreeIcon icon='add' iconProps={{ color: 'green' }} />, @@ -580,17 +597,6 @@ export function StockItemTable({ removeStock.open(); } }, - { - name: t`Count Stock`, - icon: ( - <InvenTreeIcon icon='stocktake' iconProps={{ color: 'blue' }} /> - ), - tooltip: t`Count Stock`, - disabled: !can_add_stocktake, - onClick: () => { - countStock.open(); - } - }, { name: t`Transfer Stock`, icon: ( @@ -624,8 +630,14 @@ export function StockItemTable({ name: t`Order stock`, icon: <InvenTreeIcon icon='buy' />, tooltip: t`Order new stock`, - disabled: !can_add_order || !can_change_order, - onClick: notYetImplemented + hidden: !user.hasAddRole(UserRoles.purchase_order), + disabled: !table.hasSelectedRecords, + onClick: () => { + setPartsToOrder( + table.selectedRecords.map((record) => record.part_detail) + ); + orderPartsWizard.openWizard(); + } }, { name: t`Assign to customer`, @@ -654,7 +666,7 @@ export function StockItemTable({ onClick={() => newStockItem.open()} /> ]; - }, [user, table, allowAdd]); + }, [user, allowAdd, table.hasSelectedRecords, table.selectedRecords]); return ( <> @@ -667,6 +679,7 @@ export function StockItemTable({ {mergeStock.modal} {assignStock.modal} {deleteStock.modal} + {orderPartsWizard.wizard} <InvenTreeTable url={apiUrl(ApiEndpoints.stock_item_list)} tableState={table} diff --git a/src/frontend/tests/pages/pui_purchase_order.spec.ts b/src/frontend/tests/pages/pui_purchase_order.spec.ts index ff8207346d..90d753c7dc 100644 --- a/src/frontend/tests/pages/pui_purchase_order.spec.ts +++ b/src/frontend/tests/pages/pui_purchase_order.spec.ts @@ -1,4 +1,5 @@ import { test } from '../baseFixtures.ts'; +import { baseUrl } from '../defaults.ts'; import { clickButtonIfVisible, openFilterDrawer } from '../helpers.ts'; import { doQuickLogin } from '../login.ts'; @@ -76,6 +77,83 @@ test('Purchase Orders - Filters', async ({ page }) => { await page.getByRole('option', { name: 'Target Date After' }).waitFor(); }); +test('Purchase Orders - Order Parts', async ({ page }) => { + await doQuickLogin(page); + + // Open "Order Parts" wizard from the "parts" table + await page.getByRole('tab', { name: 'Parts' }).click(); + await page + .getByLabel('panel-tabs-partcategory') + .getByRole('tab', { name: 'Parts' }) + .click(); + + // Select multiple parts + for (let ii = 1; ii < 5; ii++) { + await page.getByLabel(`Select record ${ii}`, { exact: true }).click(); + } + + await page.getByLabel('action-menu-part-actions').click(); + await page.getByLabel('action-menu-part-actions-order-parts').click(); + await page + .getByRole('heading', { name: 'Order Parts' }) + .locator('div') + .first() + .waitFor(); + await page.getByRole('banner').getByRole('button').click(); + + // Open "Order Parts" wizard from the "Stock Items" table + await page.getByRole('tab', { name: 'Stock' }).click(); + await page.getByRole('tab', { name: 'Stock Items' }).click(); + + // Select multiple stock items + for (let ii = 2; ii < 7; ii += 2) { + await page.getByLabel(`Select record ${ii}`, { exact: true }).click(); + } + + await page + .getByLabel('Stock Items') + .getByLabel('action-menu-stock-actions') + .click(); + await page.getByLabel('action-menu-stock-actions-order-stock').click(); + await page.getByRole('banner').getByRole('button').click(); + + // Order from the part detail page + await page.goto(`${baseUrl}/part/69/`); + await page.waitForURL('**/part/69/**'); + + await page.getByLabel('action-menu-stock-actions').click(); + await page.getByLabel('action-menu-stock-actions-order').click(); + + // Select supplier part + await page.getByLabel('related-field-supplier_part').click(); + await page.getByText('WM1731-ND').click(); + + // Option to create a new supplier part + await page.getByLabel('action-button-new-supplier-part').click(); + await page.getByLabel('related-field-supplier', { exact: true }).click(); + await page.getByText('Future').click(); + await page.getByRole('button', { name: 'Cancel' }).click(); + + // Select purchase order + await page.getByLabel('related-field-purchase_order').click(); + await page.getByText('PO0001').click(); + + // Option to create a new purchase order + await page.getByLabel('action-button-new-purchase-order').click(); + await page.getByLabel('related-field-project_code').click(); + await page.getByText('PRJ-PHO').click(); + await page.getByRole('button', { name: 'Cancel' }).click(); + + // Add the part to the purchase order + await page.getByLabel('action-button-add-to-selected').click(); + await page.getByLabel('number-field-quantity').fill('100'); + await page.waitForTimeout(250); + await page.getByRole('button', { name: 'Submit' }).click(); + await page + .getByText('All selected parts added to a purchase order') + .waitFor(); +}); + /** * Tests for receiving items against a purchase order */