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