2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

[PUI] Order Parts Wizard (#8602)

* Add generic <WizardDrawer />

* 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 <PartDetail>

* Fix static hook issue

* Fix infinite useEffect

* Playwright tests
This commit is contained in:
Oliver 2024-12-16 23:07:33 +11:00 committed by GitHub
parent 34b70d0a39
commit 169f4f8350
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1025 additions and 151 deletions

View File

@ -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>;
}

View File

@ -70,7 +70,9 @@ export function RenderSupplierPart(
{...props} {...props}
primary={supplier?.name} primary={supplier?.name}
secondary={instance.SKU} secondary={instance.SKU}
image={part?.thumbnail ?? part?.image} image={
part?.thumbnail ?? part?.image ?? supplier?.thumbnail ?? supplier?.image
}
suffix={ suffix={
part.full_name ? <Text size='sm'>{part.full_name}</Text> : undefined part.full_name ? <Text size='sm'>{part.full_name}</Text> : undefined
} }

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -18,11 +18,18 @@ import type {
/** /**
* Field set for SupplierPart instance * Field set for SupplierPart instance
*/ */
export function useSupplierPartFields() { export function useSupplierPartFields({
partId
}: {
partId?: number;
}) {
return useMemo(() => { return useMemo(() => {
const fields: ApiFormFieldSet = { const fields: ApiFormFieldSet = {
part: { part: {
value: partId,
disabled: !!partId,
filters: { filters: {
part: partId,
purchaseable: true, purchaseable: true,
active: true active: true
} }
@ -63,7 +70,7 @@ export function useSupplierPartFields() {
}; };
return fields; return fields;
}, []); }, [partId]);
} }
export function useManufacturerPartFields() { export function useManufacturerPartFields() {

View File

@ -139,8 +139,10 @@ export function usePurchaseOrderLineItemFields({
* Construct a set of fields for creating / editing a PurchaseOrder instance * Construct a set of fields for creating / editing a PurchaseOrder instance
*/ */
export function usePurchaseOrderFields({ export function usePurchaseOrderFields({
supplierId,
duplicateOrderId duplicateOrderId
}: { }: {
supplierId?: number;
duplicateOrderId?: number; duplicateOrderId?: number;
}): ApiFormFieldSet { }): ApiFormFieldSet {
return useMemo(() => { return useMemo(() => {
@ -150,7 +152,8 @@ export function usePurchaseOrderFields({
}, },
description: {}, description: {},
supplier: { supplier: {
disabled: duplicateOrderId !== undefined, value: supplierId,
disabled: !!duplicateOrderId || !!supplierId,
filters: { filters: {
is_supplier: true, is_supplier: true,
active: true active: true
@ -213,7 +216,7 @@ export function usePurchaseOrderFields({
} }
return fields; return fields;
}, [duplicateOrderId]); }, [duplicateOrderId, supplierId]);
} }
/** /**

View File

@ -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>
)
};
}

View File

@ -318,7 +318,7 @@ export default function SupplierPartDetail() {
]; ];
}, [user, supplierPart]); }, [user, supplierPart]);
const supplierPartFields = useSupplierPartFields(); const supplierPartFields = useSupplierPartFields({});
const editSupplierPart = useEditApiFormModal({ const editSupplierPart = useEditApiFormModal({
url: ApiEndpoints.supplier_part_list, url: ApiEndpoints.supplier_part_list,

View File

@ -29,7 +29,7 @@ import {
IconTruckReturn, IconTruckReturn,
IconVersions IconVersions
} from '@tabler/icons-react'; } 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 { type ReactNode, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import Select from 'react-select'; import Select from 'react-select';
@ -62,6 +62,7 @@ import NotesPanel from '../../components/panels/NotesPanel';
import type { PanelType } from '../../components/panels/Panel'; import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup'; import { PanelGroup } from '../../components/panels/PanelGroup';
import { RenderPart } from '../../components/render/Part'; import { RenderPart } from '../../components/render/Part';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { formatPriceRange } from '../../defaults/formatters'; import { formatPriceRange } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
@ -416,41 +417,13 @@ export default function PartDetail() {
]; ];
// Add in price range data // Add in price range data
if (id) { if (part.pricing_min || part.pricing_max) {
br.push({ br.push({
type: 'string', type: 'string',
name: 'pricing', name: 'pricing',
label: t`Price Range`, label: t`Price Range`,
value_formatter: () => { value_formatter: () => {
const { data } = useSuspenseQuery({ return formatPriceRange(part.pricing_min, part.pricing_max);
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}`
}`
);
} }
}); });
} }
@ -463,79 +436,6 @@ export default function PartDetail() {
icon: 'serial' 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 ? ( return part ? (
<ItemDetailsGrid> <ItemDetailsGrid>
<Grid> <Grid>
@ -565,7 +465,14 @@ export default function PartDetail() {
) : ( ) : (
<Skeleton /> <Skeleton />
); );
}, [globalSettings, part, serials, instanceQuery]); }, [
globalSettings,
part,
id,
serials,
instanceQuery.isFetching,
instanceQuery.data
]);
// Part data panels (recalculate when part data changes) // Part data panels (recalculate when part data changes)
const partPanels: PanelType[] = useMemo(() => { const partPanels: PanelType[] = useMemo(() => {
@ -735,7 +642,7 @@ export default function PartDetail() {
model_id: part?.pk model_id: part?.pk
}) })
]; ];
}, [id, part, user, globalSettings, userSettings]); }, [id, part, user, globalSettings, userSettings, detailsPanel]);
// Fetch information on part revision // Fetch information on part revision
const partRevisionQuery = useQuery({ const partRevisionQuery = useQuery({
@ -820,19 +727,18 @@ export default function PartDetail() {
}); });
}, [part, partRevisionQuery.isFetching, partRevisionQuery.data]); }, [part, partRevisionQuery.isFetching, partRevisionQuery.data]);
const breadcrumbs = useMemo( const breadcrumbs = useMemo(() => {
() => [ return [
{ name: t`Parts`, url: '/part' }, { name: t`Parts`, url: '/part' },
...(part.category_path ?? []).map((c: any) => ({ ...(part.category_path ?? []).map((c: any) => ({
name: c.name, name: c.name,
url: getDetailUrl(ModelType.partcategory, c.pk) url: getDetailUrl(ModelType.partcategory, c.pk)
})) }))
], ];
[part] }, [part]);
);
const badges: ReactNode[] = useMemo(() => { const badges: ReactNode[] = useMemo(() => {
if (instanceQuery.isLoading || instanceQuery.isFetching) { if (instanceQuery.isFetching) {
return []; return [];
} }
@ -883,7 +789,7 @@ export default function PartDetail() {
key='inactive' key='inactive'
/> />
]; ];
}, [part, instanceQuery]); }, [part, instanceQuery.isFetching]);
const partFields = usePartFields({ create: false }); const partFields = usePartFields({ create: false });
@ -970,6 +876,10 @@ export default function PartDetail() {
const countStockItems = useCountStockItem(stockActionProps); const countStockItems = useCountStockItem(stockActionProps);
const transferStockItems = useTransferStockItem(stockActionProps); const transferStockItems = useTransferStockItem(stockActionProps);
const orderPartsWizard = OrderPartsWizard({
parts: [part]
});
const partActions = useMemo(() => { const partActions = useMemo(() => {
return [ return [
<AdminButton model={ModelType.part} id={part.pk} />, <AdminButton model={ModelType.part} id={part.pk} />,
@ -1011,6 +921,18 @@ export default function PartDetail() {
onClick: () => { onClick: () => {
part.pk && transferStockItems.open(); 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} {duplicatePart.modal}
{editPart.modal} {editPart.modal}
{deletePart.modal} {deletePart.modal}
{orderPartsWizard.wizard}
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}> <InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap='xs'> <Stack gap='xs'>
<NavigationTree <NavigationTree

View File

@ -7,6 +7,7 @@ import {
IconHistory, IconHistory,
IconInfoCircle, IconInfoCircle,
IconPackages, IconPackages,
IconShoppingCart,
IconSitemap IconSitemap
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@ -41,6 +42,7 @@ import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup'; import { PanelGroup } from '../../components/panels/PanelGroup';
import LocateItemButton from '../../components/plugins/LocateItemButton'; import LocateItemButton from '../../components/plugins/LocateItemButton';
import { StatusRenderer } from '../../components/render/StatusRenderer'; import { StatusRenderer } from '../../components/render/StatusRenderer';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { formatCurrency } from '../../defaults/formatters'; import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
@ -354,7 +356,7 @@ export default function StockDetail() {
<DetailsTable fields={br} item={data} /> <DetailsTable fields={br} item={data} />
</ItemDetailsGrid> </ItemDetailsGrid>
); );
}, [stockitem, instanceQuery, enableExpiry]); }, [stockitem, instanceQuery.isFetching, enableExpiry]);
const showBuildAllocations: boolean = useMemo(() => { const showBuildAllocations: boolean = useMemo(() => {
// Determine if "build allocations" should be shown for this stock item // 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 stockActions = useMemo(() => {
const inStock = const inStock =
user.hasChangeRole(UserRoles.stock) && user.hasChangeRole(UserRoles.stock) &&
@ -717,6 +723,17 @@ export default function StockDetail() {
stockitem.pk && removeStockItem.open(); 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`, name: t`Serialize`,
tooltip: t`Serialize stock`, tooltip: t`Serialize stock`,
@ -730,14 +747,15 @@ export default function StockDetail() {
} }
}, },
{ {
name: t`Transfer`, name: t`Order`,
tooltip: t`Transfer Stock`, tooltip: t`Order Stock`,
hidden: !inStock, hidden:
icon: ( !user.hasAddRole(UserRoles.purchase_order) ||
<InvenTreeIcon icon='transfer' iconProps={{ color: 'blue' }} /> !stockitem.part_detail?.active ||
), !stockitem.part_detail?.purchaseable,
icon: <IconShoppingCart color='blue' />,
onClick: () => { onClick: () => {
stockitem.pk && transferStockItem.open(); orderPartsWizard.openWizard();
} }
}, },
{ {
@ -898,6 +916,7 @@ export default function StockDetail() {
{serializeStockItem.modal} {serializeStockItem.modal}
{returnStockItem.modal} {returnStockItem.modal}
{assignToCustomer.modal} {assignToCustomer.modal}
{orderPartsWizard.wizard}
</Stack> </Stack>
</InstanceDetail> </InstanceDetail>
); );

View File

@ -13,6 +13,7 @@ import { useNavigate } from 'react-router-dom';
import { ActionButton } from '../../components/buttons/ActionButton'; import { ActionButton } from '../../components/buttons/ActionButton';
import { ProgressBar } from '../../components/items/ProgressBar'; import { ProgressBar } from '../../components/items/ProgressBar';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
@ -20,7 +21,6 @@ import {
useAllocateStockToBuildForm, useAllocateStockToBuildForm,
useBuildOrderFields useBuildOrderFields
} from '../../forms/BuildForms'; } from '../../forms/BuildForms';
import { notYetImplemented } from '../../functions/notifications';
import { import {
useCreateApiFormModal, useCreateApiFormModal,
useDeleteApiFormModal, useDeleteApiFormModal,
@ -527,6 +527,12 @@ export default function BuildLineTable({
table: table table: table
}); });
const [partsToOrder, setPartsToOrder] = useState<any[]>([]);
const orderPartsWizard = OrderPartsWizard({
parts: partsToOrder
});
const rowActions = useCallback( const rowActions = useCallback(
(record: any): RowAction[] => { (record: any): RowAction[] => {
const part = record.part_detail ?? {}; const part = record.part_detail ?? {};
@ -552,7 +558,6 @@ export default function BuildLineTable({
record.trackable == hasOutput; record.trackable == hasOutput;
const canOrder = const canOrder =
in_production &&
!consumable && !consumable &&
user.hasAddRole(UserRoles.purchase_order) && user.hasAddRole(UserRoles.purchase_order) &&
part.purchaseable; part.purchaseable;
@ -588,8 +593,12 @@ export default function BuildLineTable({
icon: <IconShoppingCart />, icon: <IconShoppingCart />,
title: t`Order Stock`, title: t`Order Stock`,
hidden: !canOrder, hidden: !canOrder,
disabled: !table.hasSelectedRecords,
color: 'blue', color: 'blue',
onClick: notYetImplemented onClick: () => {
setPartsToOrder([record.part_detail]);
orderPartsWizard.openWizard();
}
}, },
{ {
icon: <IconTool />, icon: <IconTool />,
@ -631,6 +640,24 @@ export default function BuildLineTable({
autoAllocateStock.open(); 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 <ActionButton
key='allocate-stock' key='allocate-stock'
icon={<IconArrowRight />} icon={<IconArrowRight />}
@ -749,6 +776,7 @@ export default function BuildLineTable({
{deallocateStock.modal} {deallocateStock.modal}
{editAllocation.modal} {editAllocation.modal}
{deleteAllocation.modal} {deleteAllocation.modal}
{orderPartsWizard.wizard}
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.build_line_list)} url={apiUrl(ApiEndpoints.build_line_list)}
tableState={table} tableState={table}

View File

@ -2,12 +2,16 @@ import { t } from '@lingui/macro';
import { Group, Text } from '@mantine/core'; import { Group, Text } from '@mantine/core';
import { type ReactNode, useMemo } from 'react'; import { type ReactNode, useMemo } from 'react';
import { IconShoppingCart } from '@tabler/icons-react';
import { AddItemButton } from '../../components/buttons/AddItemButton'; import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ActionDropdown } from '../../components/items/ActionDropdown';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { formatPriceRange } from '../../defaults/formatters'; import { formatPriceRange } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
import { usePartFields } from '../../forms/PartForms'; import { usePartFields } from '../../forms/PartForms';
import { InvenTreeIcon } from '../../functions/icons';
import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable'; import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
@ -333,8 +337,25 @@ export function PartListTable({
modelType: ModelType.part modelType: ModelType.part
}); });
const orderPartsWizard = OrderPartsWizard({ parts: table.selectedRecords });
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
return [ 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 <AddItemButton
key='add-part' key='add-part'
hidden={!user.hasAddRole(UserRoles.part)} hidden={!user.hasAddRole(UserRoles.part)}
@ -342,11 +363,12 @@ export function PartListTable({
onClick={() => newPart.open()} onClick={() => newPart.open()}
/> />
]; ];
}, [user]); }, [user, table.hasSelectedRecords]);
return ( return (
<> <>
{newPart.modal} {newPart.modal}
{orderPartsWizard.wizard}
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.part_list)} url={apiUrl(ApiEndpoints.part_list)}
tableState={table} tableState={table}

View File

@ -158,7 +158,9 @@ export function SupplierPartTable({
]; ];
}, [params]); }, [params]);
const supplierPartFields = useSupplierPartFields(); const supplierPartFields = useSupplierPartFields({
partId: params?.part
});
const addSupplierPart = useCreateApiFormModal({ const addSupplierPart = useCreateApiFormModal({
url: ApiEndpoints.supplier_part_list, url: ApiEndpoints.supplier_part_list,
@ -208,7 +210,7 @@ export function SupplierPartTable({
]; ];
}, []); }, []);
const editSupplierPartFields = useSupplierPartFields(); const editSupplierPartFields = useSupplierPartFields({});
const [selectedSupplierPart, setSelectedSupplierPart] = useState<number>(0); const [selectedSupplierPart, setSelectedSupplierPart] = useState<number>(0);

View File

@ -14,6 +14,7 @@ import { useNavigate } from 'react-router-dom';
import { ActionButton } from '../../components/buttons/ActionButton'; import { ActionButton } from '../../components/buttons/ActionButton';
import { AddItemButton } from '../../components/buttons/AddItemButton'; import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ProgressBar } from '../../components/items/ProgressBar'; import { ProgressBar } from '../../components/items/ProgressBar';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { formatCurrency } from '../../defaults/formatters'; import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
@ -24,7 +25,6 @@ import {
useSalesOrderAllocateSerialsFields, useSalesOrderAllocateSerialsFields,
useSalesOrderLineItemFields useSalesOrderLineItemFields
} from '../../forms/SalesOrderForms'; } from '../../forms/SalesOrderForms';
import { notYetImplemented } from '../../functions/notifications';
import { import {
useCreateApiFormModal, useCreateApiFormModal,
useDeleteApiFormModal, useDeleteApiFormModal,
@ -285,6 +285,12 @@ export default function SalesOrderLineItemTable({
} }
}); });
const [partsToOrder, setPartsToOrder] = useState<any[]>([]);
const orderPartsWizard = OrderPartsWizard({
parts: partsToOrder
});
const tableFilters: TableFilter[] = useMemo(() => { const tableFilters: TableFilter[] = useMemo(() => {
return [ return [
{ {
@ -313,6 +319,18 @@ export default function SalesOrderLineItemTable({
}} }}
hidden={!editable || !user.hasAddRole(UserRoles.sales_order)} 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 <ActionButton
key='allocate-stock' key='allocate-stock'
tooltip={t`Allocate Stock`} tooltip={t`Allocate Stock`}
@ -396,7 +414,10 @@ export default function SalesOrderLineItemTable({
title: t`Order stock`, title: t`Order stock`,
icon: <IconShoppingCart />, icon: <IconShoppingCart />,
color: 'blue', color: 'blue',
onClick: notYetImplemented onClick: () => {
setPartsToOrder([record.part_detail]);
orderPartsWizard.openWizard();
}
}, },
RowEditAction({ RowEditAction({
hidden: !editable || !user.hasChangeRole(UserRoles.sales_order), hidden: !editable || !user.hasChangeRole(UserRoles.sales_order),
@ -455,6 +476,7 @@ export default function SalesOrderLineItemTable({
{newBuildOrder.modal} {newBuildOrder.modal}
{allocateBySerials.modal} {allocateBySerials.modal}
{allocateStock.modal} {allocateStock.modal}
{orderPartsWizard.wizard}
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.sales_order_line_list)} url={apiUrl(ApiEndpoints.sales_order_line_list)}
tableState={table} tableState={table}

View File

@ -1,9 +1,10 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Group, Text } from '@mantine/core'; 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 { AddItemButton } from '../../components/buttons/AddItemButton';
import { ActionDropdown } from '../../components/items/ActionDropdown'; import { ActionDropdown } from '../../components/items/ActionDropdown';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { formatCurrency, formatPriceRange } from '../../defaults/formatters'; import { formatCurrency, formatPriceRange } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
@ -21,7 +22,6 @@ import {
useTransferStockItem useTransferStockItem
} from '../../forms/StockForms'; } from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons'; import { InvenTreeIcon } from '../../functions/icons';
import { notYetImplemented } from '../../functions/notifications';
import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable'; import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
@ -540,6 +540,12 @@ export function StockItemTable({
modelType: ModelType.stockitem modelType: ModelType.stockitem
}); });
const [partsToOrder, setPartsToOrder] = useState<any[]>([]);
const orderPartsWizard = OrderPartsWizard({
parts: partsToOrder
});
const transferStock = useTransferStockItem(tableActionParams); const transferStock = useTransferStockItem(tableActionParams);
const addStock = useAddStockItem(tableActionParams); const addStock = useAddStockItem(tableActionParams);
const removeStock = useRemoveStockItem(tableActionParams); const removeStock = useRemoveStockItem(tableActionParams);
@ -562,6 +568,17 @@ export function StockItemTable({
icon={<InvenTreeIcon icon='stock' />} icon={<InvenTreeIcon icon='stock' />}
disabled={table.selectedRecords.length === 0} disabled={table.selectedRecords.length === 0}
actions={[ 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`, name: t`Add Stock`,
icon: <InvenTreeIcon icon='add' iconProps={{ color: 'green' }} />, icon: <InvenTreeIcon icon='add' iconProps={{ color: 'green' }} />,
@ -580,17 +597,6 @@ export function StockItemTable({
removeStock.open(); 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`, name: t`Transfer Stock`,
icon: ( icon: (
@ -624,8 +630,14 @@ export function StockItemTable({
name: t`Order stock`, name: t`Order stock`,
icon: <InvenTreeIcon icon='buy' />, icon: <InvenTreeIcon icon='buy' />,
tooltip: t`Order new stock`, tooltip: t`Order new stock`,
disabled: !can_add_order || !can_change_order, hidden: !user.hasAddRole(UserRoles.purchase_order),
onClick: notYetImplemented disabled: !table.hasSelectedRecords,
onClick: () => {
setPartsToOrder(
table.selectedRecords.map((record) => record.part_detail)
);
orderPartsWizard.openWizard();
}
}, },
{ {
name: t`Assign to customer`, name: t`Assign to customer`,
@ -654,7 +666,7 @@ export function StockItemTable({
onClick={() => newStockItem.open()} onClick={() => newStockItem.open()}
/> />
]; ];
}, [user, table, allowAdd]); }, [user, allowAdd, table.hasSelectedRecords, table.selectedRecords]);
return ( return (
<> <>
@ -667,6 +679,7 @@ export function StockItemTable({
{mergeStock.modal} {mergeStock.modal}
{assignStock.modal} {assignStock.modal}
{deleteStock.modal} {deleteStock.modal}
{orderPartsWizard.wizard}
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.stock_item_list)} url={apiUrl(ApiEndpoints.stock_item_list)}
tableState={table} tableState={table}

View File

@ -1,4 +1,5 @@
import { test } from '../baseFixtures.ts'; import { test } from '../baseFixtures.ts';
import { baseUrl } from '../defaults.ts';
import { clickButtonIfVisible, openFilterDrawer } from '../helpers.ts'; import { clickButtonIfVisible, openFilterDrawer } from '../helpers.ts';
import { doQuickLogin } from '../login.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(); 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 * Tests for receiving items against a purchase order
*/ */