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:
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}
|
{...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
|
||||||
}
|
}
|
||||||
|
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
|
* 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() {
|
||||||
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
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]);
|
}, [user, supplierPart]);
|
||||||
|
|
||||||
const supplierPartFields = useSupplierPartFields();
|
const supplierPartFields = useSupplierPartFields({});
|
||||||
|
|
||||||
const editSupplierPart = useEditApiFormModal({
|
const editSupplierPart = useEditApiFormModal({
|
||||||
url: ApiEndpoints.supplier_part_list,
|
url: ApiEndpoints.supplier_part_list,
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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}
|
||||||
|
@ -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}
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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}
|
||||||
|
@ -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}
|
||||||
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
Loading…
x
Reference in New Issue
Block a user