mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 03:26:45 +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:
parent
34b70d0a39
commit
169f4f8350
14
src/frontend/src/components/items/Expand.tsx
Normal file
14
src/frontend/src/components/items/Expand.tsx
Normal 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>;
|
||||
}
|
@ -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
|
||||
}
|
||||
|
420
src/frontend/src/components/wizards/OrderPartsWizard.tsx
Normal file
420
src/frontend/src/components/wizards/OrderPartsWizard.tsx
Normal 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;
|
||||
}
|
188
src/frontend/src/components/wizards/WizardDrawer.tsx
Normal file
188
src/frontend/src/components/wizards/WizardDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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() {
|
||||
|
@ -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]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
133
src/frontend/src/hooks/UseWizard.tsx
Normal file
133
src/frontend/src/hooks/UseWizard.tsx
Normal 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>
|
||||
)
|
||||
};
|
||||
}
|
@ -318,7 +318,7 @@ export default function SupplierPartDetail() {
|
||||
];
|
||||
}, [user, supplierPart]);
|
||||
|
||||
const supplierPartFields = useSupplierPartFields();
|
||||
const supplierPartFields = useSupplierPartFields({});
|
||||
|
||||
const editSupplierPart = useEditApiFormModal({
|
||||
url: ApiEndpoints.supplier_part_list,
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
*/
|
||||
|
Loading…
x
Reference in New Issue
Block a user