2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 12:35:46 +00:00

Adds "ON HOLD" status to order models (#7807)

* Add "ON_HOLD" status code for orders

* Add placeholder buttons for purchase order status change

* Adds hooks for introspecting status code enumerations

* Refactor status codes for import session

- Remove hard-coded values

* Refactor into <PrimaryActionButton />

* Cleanup

* more permission checks

* Add placeholder actions for SalesOrder

* Placeholder actions for ReturnOrder

* Placeholder actions for build order

* Actions for "return order"

* Update actions for return order

- Add "on hold" transition

* Implement transitions for SalesOrder

* Allow control over SalesOrderLineItemTable

* Implement PurchaseOrder actions

* Improve API query lookup efficiency

* UI cleanup

* CUI cleanup

* Build Order Updates

- Implement StateTransitionMixin for BuildOrder model
- Add BuildIssue API endpoint
- Add BuildHold API endpoint
- API query improvements
- PUI actions

* Increase timeout

* Bump API version

* Fix API version

* Fix sales order actions

* Update src/backend/InvenTree/order/serializers.py

Co-authored-by: Matthias Mair <code@mjmair.com>

* Adjust build filters

* PUI updates

* CUI refactoring for purchase orders

* Refactor CUI sales order page

* Refactor for return order

* Refactor CUI build page

* Playwright tests for build order

* Add playwright test for sales orders

* Add playwright test for purchase orders

* js linting

* Refactor return order page

* Add missing functions from previous commit

* Fix for "on order" badge on PartDetail page

* UI tweaks

* Fix unit tests

* Update version check script

* Fix typo

* Enforce integer conversion for BaseEnum class

* Unit test updates

- Includes improvement for equality comparison for enums

* Update documentation

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver
2024-08-07 20:34:54 +10:00
committed by GitHub
parent 25f162f4b2
commit 0e8c2973b2
46 changed files with 1284 additions and 222 deletions

View File

@ -0,0 +1,41 @@
import { Button, Tooltip } from '@mantine/core';
import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons';
import { notYetImplemented } from '../../functions/notifications';
/**
* A "primary action" button for display on a page detail, (for example)
*/
export default function PrimaryActionButton({
title,
tooltip,
icon,
color,
hidden,
onClick
}: {
title: string;
tooltip?: string;
icon?: InvenTreeIconType;
color?: string;
hidden?: boolean;
onClick?: () => void;
}) {
if (hidden) {
return null;
}
return (
<Tooltip label={tooltip ?? title} position="bottom" hidden={!tooltip}>
<Button
leftSection={icon && <InvenTreeIcon icon={icon} />}
color={color}
radius="sm"
p="xs"
onClick={onClick ?? notYetImplemented}
>
{title}
</Button>
</Tooltip>
);
}

View File

@ -368,6 +368,11 @@ export function ApiForm({
return;
}
// Do not auto-focus on a 'choice' field
if (field.field_type == 'choice') {
return;
}
focusField = fieldName;
});
}
@ -378,7 +383,7 @@ export function ApiForm({
form.setFocus(focusField);
setInitialFocus(focusField);
}, [props.focus, fields, form.setFocus, isLoading, initialFocus]);
}, [props.focus, form.setFocus, isLoading, initialFocus]);
const submitForm: SubmitHandler<FieldValues> = async (data) => {
setNonFieldErrors([]);

View File

@ -16,10 +16,9 @@ import {
import { IconCheck } from '@tabler/icons-react';
import { ReactNode, useMemo } from 'react';
import {
ImportSessionStatus,
useImportSession
} from '../../hooks/UseImportSession';
import { ModelType } from '../../enums/ModelType';
import { useImportSession } from '../../hooks/UseImportSession';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { StylishText } from '../items/StylishText';
import ImporterDataSelector from './ImportDataSelector';
import ImporterColumnSelector from './ImporterColumnSelector';
@ -62,19 +61,23 @@ export default function ImporterDrawer({
}) {
const session = useImportSession({ sessionId: sessionId });
const importSessionStatus = useStatusCodes({
modelType: ModelType.importsession
});
// Map from import steps to stepper steps
const currentStep = useMemo(() => {
switch (session.status) {
default:
case ImportSessionStatus.INITIAL:
case importSessionStatus.INITIAL:
return 0;
case ImportSessionStatus.MAPPING:
case importSessionStatus.MAPPING:
return 1;
case ImportSessionStatus.IMPORTING:
case importSessionStatus.IMPORTING:
return 2;
case ImportSessionStatus.PROCESSING:
case importSessionStatus.PROCESSING:
return 3;
case ImportSessionStatus.COMPLETE:
case importSessionStatus.COMPLETE:
return 4;
}
}, [session.status]);
@ -85,15 +88,15 @@ export default function ImporterDrawer({
}
switch (session.status) {
case ImportSessionStatus.INITIAL:
case importSessionStatus.INITIAL:
return <Text>Initial : TODO</Text>;
case ImportSessionStatus.MAPPING:
case importSessionStatus.MAPPING:
return <ImporterColumnSelector session={session} />;
case ImportSessionStatus.IMPORTING:
case importSessionStatus.IMPORTING:
return <ImporterImportProgress session={session} />;
case ImportSessionStatus.PROCESSING:
case importSessionStatus.PROCESSING:
return <ImporterDataSelector session={session} />;
case ImportSessionStatus.COMPLETE:
case importSessionStatus.COMPLETE:
return (
<Stack gap="xs">
<Alert

View File

@ -3,10 +3,9 @@ import { Center, Container, Loader, Stack, Text } from '@mantine/core';
import { useInterval } from '@mantine/hooks';
import { useEffect } from 'react';
import {
ImportSessionState,
ImportSessionStatus
} from '../../hooks/UseImportSession';
import { ModelType } from '../../enums/ModelType';
import { ImportSessionState } from '../../hooks/UseImportSession';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { StylishText } from '../items/StylishText';
export default function ImporterImportProgress({
@ -14,11 +13,13 @@ export default function ImporterImportProgress({
}: {
session: ImportSessionState;
}) {
const importSessionStatus = useStatusCodes({
modelType: ModelType.importsession
});
// Periodically refresh the import session data
const interval = useInterval(() => {
console.log('refreshing:', session.status);
if (session.status == ImportSessionStatus.IMPORTING) {
if (session.status == importSessionStatus.IMPORTING) {
session.refreshSession();
}
}, 1000);

View File

@ -89,7 +89,11 @@ export function ActionDropdown({
{...action.indicator}
key={action.name}
>
<Tooltip label={action.tooltip} hidden={!action.tooltip}>
<Tooltip
label={action.tooltip}
hidden={!action.tooltip}
position="left"
>
<Menu.Item
aria-label={id}
leftSection={action.icon}
@ -229,6 +233,24 @@ export function DeleteItemAction({
};
}
export function HoldItemAction({
hidden = false,
tooltip,
onClick
}: {
hidden?: boolean;
tooltip?: string;
onClick?: () => void;
}): ActionDropdownItem {
return {
icon: <InvenTreeIcon icon="hold" iconProps={{ color: 'orange' }} />,
name: t`Hold`,
tooltip: tooltip ?? t`Hold`,
onClick: onClick,
hidden: hidden
};
}
export function CancelItemAction({
hidden = false,
tooltip,

View File

@ -10,7 +10,13 @@ export function RenderOwner({
instance && (
<RenderInlineModel
primary={instance.name}
suffix={instance.label == 'group' ? <IconUsersGroup /> : <IconUser />}
suffix={
instance.label == 'group' ? (
<IconUsersGroup size={16} />
) : (
<IconUser size={16} />
)
}
/>
)
);

View File

@ -62,9 +62,12 @@ export enum ApiEndpoints {
// Build API endpoints
build_order_list = 'build/',
build_order_issue = 'build/:id/issue/',
build_order_cancel = 'build/:id/cancel/',
build_output_create = 'build/:id/create-output/',
build_order_hold = 'build/:id/hold/',
build_order_complete = 'build/:id/finish/',
build_output_complete = 'build/:id/complete/',
build_output_create = 'build/:id/create-output/',
build_output_scrap = 'build/:id/scrap-outputs/',
build_output_delete = 'build/:id/delete-outputs/',
build_line_list = 'build/line/',
@ -124,14 +127,27 @@ export enum ApiEndpoints {
// Order API endpoints
purchase_order_list = 'order/po/',
purchase_order_issue = 'order/po/:id/issue/',
purchase_order_hold = 'order/po/:id/hold/',
purchase_order_cancel = 'order/po/:id/cancel/',
purchase_order_complete = 'order/po/:id/complete/',
purchase_order_line_list = 'order/po-line/',
purchase_order_receive = 'order/po/:id/receive/',
sales_order_list = 'order/so/',
sales_order_issue = 'order/so/:id/issue/',
sales_order_hold = 'order/so/:id/hold/',
sales_order_cancel = 'order/so/:id/cancel/',
sales_order_ship = 'order/so/:id/ship/',
sales_order_complete = 'order/so/:id/complete/',
sales_order_line_list = 'order/so-line/',
sales_order_shipment_list = 'order/so/shipment/',
return_order_list = 'order/ro/',
return_order_issue = 'order/ro/:id/issue/',
return_order_hold = 'order/ro/:id/hold/',
return_order_cancel = 'order/ro/:id/cancel/',
return_order_complete = 'order/ro/:id/complete/',
return_order_line_list = 'order/ro-line/',
// Template API endpoints

View File

@ -21,6 +21,7 @@ import { InvenTreeIcon } from '../functions/icons';
import { useCreateApiFormModal } from '../hooks/UseForm';
import { useBatchCodeGenerator } from '../hooks/UseGenerator';
import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
import { PartColumn, StatusColumn } from '../tables/ColumnRenderers';
/**
@ -43,6 +44,8 @@ export function useBuildOrderFields({
}
});
const globalSettings = useGlobalSettingsState();
return useMemo(() => {
return {
reference: {},
@ -50,7 +53,13 @@ export function useBuildOrderFields({
disabled: !create,
filters: {
assembly: true,
virtual: false
virtual: false,
active: globalSettings.isSet('BUILDORDER_REQUIRE_ACTIVE_PART')
? true
: undefined,
locked: globalSettings.isSet('BUILDORDER_REQUIRE_LOCKED_PART')
? true
: undefined
},
onValueChange(value: any, record?: any) {
// Adjust the destination location for the build order
@ -107,7 +116,7 @@ export function useBuildOrderFields({
}
}
};
}, [create, destination, batchCode]);
}, [create, destination, batchCode, globalSettings]);
}
export function useBuildOrderOutputFields({

View File

@ -6,6 +6,7 @@ import {
IconBinaryTree2,
IconBookmarks,
IconBox,
IconBrandTelegram,
IconBuilding,
IconBuildingFactory2,
IconBuildingStore,
@ -32,6 +33,7 @@ import {
IconFlagShare,
IconGitBranch,
IconGridDots,
IconHandStop,
IconHash,
IconHierarchy,
IconInfoCircle,
@ -142,6 +144,10 @@ const icons = {
plus: IconCirclePlus,
minus: IconCircleMinus,
cancel: IconCircleX,
hold: IconHandStop,
issue: IconBrandTelegram,
complete: IconCircleCheck,
deliver: IconTruckDelivery,
// Part Icons
active: IconCheck,

View File

@ -1,28 +1,21 @@
import { useCallback, useMemo } from 'react';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { useInstance } from './UseInstance';
import useStatusCodes from './UseStatusCodes';
/*
* Custom hook for managing the state of a data import session
*/
// TODO: Load these values from the server?
export enum ImportSessionStatus {
INITIAL = 0,
MAPPING = 10,
IMPORTING = 20,
PROCESSING = 30,
COMPLETE = 40
}
export type ImportSessionState = {
sessionId: number;
sessionData: any;
setSessionData: (data: any) => void;
refreshSession: () => void;
sessionQuery: any;
status: ImportSessionStatus;
status: number;
availableFields: Record<string, any>;
availableColumns: string[];
mappedFields: any[];
@ -52,15 +45,17 @@ export function useImportSession({
});
const setSessionData = useCallback((data: any) => {
console.log('setting session data:');
console.log(data);
setInstance(data);
}, []);
const importSessionStatus = useStatusCodes({
modelType: ModelType.importsession
});
// Current step of the import process
const status: ImportSessionStatus = useMemo(() => {
return sessionData?.status ?? ImportSessionStatus.INITIAL;
}, [sessionData]);
const status: number = useMemo(() => {
return sessionData?.status ?? importSessionStatus.INITIAL;
}, [sessionData, importSessionStatus]);
// List of available writeable database field definitions
const availableFields: any[] = useMemo(() => {

View File

@ -0,0 +1,46 @@
import { useMemo } from 'react';
import { getStatusCodes } from '../components/render/StatusRenderer';
import { ModelType } from '../enums/ModelType';
import { useGlobalStatusState } from '../states/StatusState';
/**
* Hook to access status codes, which are enumerated by the backend.
*
* This hook is used to return a map of status codes for a given model type.
* It is a memoized wrapper around getStatusCodes,
* and returns a simplified KEY:value map of status codes.
*
* e.g. for the "PurchaseOrderStatus" enumeration, returns a map like:
*
* {
* PENDING: 10
* PLACED: 20
* ON_HOLD: 25,
* COMPLETE: 30,
* CANCELLED: 40,
* LOST: 50,
* RETURNED: 60
* }
*/
export default function useStatusCodes({
modelType
}: {
modelType: ModelType | string;
}) {
const statusCodeList = useGlobalStatusState.getState().status;
const codes = useMemo(() => {
const statusCodes = getStatusCodes(modelType) || {};
let codesMap: Record<any, any> = {};
for (let name in statusCodes) {
codesMap[name] = statusCodes[name].key;
}
return codesMap;
}, [modelType, statusCodeList]);
return codes;
}

View File

@ -19,6 +19,7 @@ import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
@ -30,6 +31,7 @@ import {
CancelItemAction,
DuplicateItemAction,
EditItemAction,
HoldItemAction,
LinkBarcodeAction,
UnlinkBarcodeAction,
ViewBarcodeAction
@ -47,6 +49,7 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
@ -364,21 +367,7 @@ export default function BuildDetail() {
pk: build.pk,
title: t`Edit Build Order`,
fields: buildOrderFields,
onFormSuccess: () => {
refreshInstance();
}
});
const cancelBuild = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.build_order_cancel, build.pk),
title: t`Cancel Build Order`,
fields: {
remove_allocated_stock: {},
remove_incomplete_outputs: {}
},
onFormSuccess: () => {
refreshInstance();
}
onFormSuccess: refreshInstance
});
const duplicateBuild = useCreateApiFormModal({
@ -393,8 +382,85 @@ export default function BuildDetail() {
modelType: ModelType.build
});
const buildStatus = useStatusCodes({ modelType: ModelType.build });
const cancelOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.build_order_cancel, build.pk),
title: t`Cancel Build Order`,
onFormSuccess: refreshInstance,
successMessage: t`Order cancelled`,
preFormWarning: t`Cancel this order`,
fields: {
remove_allocated_stock: {},
remove_incomplete_outputs: {}
}
});
const holdOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.build_order_hold, build.pk),
title: t`Hold Build Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Place this order on hold`,
successMessage: t`Order placed on hold`
});
const issueOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.build_order_issue, build.pk),
title: t`Issue Build Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Issue this order`,
successMessage: t`Order issued`
});
const completeOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.build_order_complete, build.pk),
title: t`Complete Build Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Mark this order as complete`,
successMessage: t`Order completed`,
fields: {
accept_overallocated: {},
accept_unallocated: {},
accept_incomplete: {}
}
});
const buildActions = useMemo(() => {
const canEdit = user.hasChangeRole(UserRoles.build);
const canIssue =
canEdit &&
(build.status == buildStatus.PENDING ||
build.status == buildStatus.ON_HOLD);
const canComplete = canEdit && build.status == buildStatus.PRODUCTION;
const canHold =
canEdit &&
(build.status == buildStatus.PENDING ||
build.status == buildStatus.PRODUCTION);
const canCancel =
canEdit &&
(build.status == buildStatus.PENDING ||
build.status == buildStatus.ON_HOLD ||
build.status == buildStatus.PRODUCTION);
return [
<PrimaryActionButton
title={t`Issue Order`}
icon="issue"
hidden={!canIssue}
color="blue"
onClick={issueOrder.open}
/>,
<PrimaryActionButton
title={t`Complete Order`}
icon="complete"
hidden={!canComplete}
color="green"
onClick={completeOrder.open}
/>,
<AdminButton model={ModelType.build} pk={build.pk} />,
<BarcodeActionDropdown
actions={[
@ -421,22 +487,28 @@ export default function BuildDetail() {
actions={[
EditItemAction({
onClick: () => editBuild.open(),
hidden: !user.hasChangeRole(UserRoles.build)
}),
CancelItemAction({
tooltip: t`Cancel order`,
onClick: () => cancelBuild.open(),
hidden: !user.hasChangeRole(UserRoles.build)
// TODO: Hide if build cannot be cancelled
hidden: !canEdit,
tooltip: t`Edit order`
}),
DuplicateItemAction({
onClick: () => duplicateBuild.open(),
tooltip: t`Duplicate order`,
hidden: !user.hasAddRole(UserRoles.build)
}),
HoldItemAction({
tooltip: t`Hold order`,
hidden: !canHold,
onClick: holdOrder.open
}),
CancelItemAction({
tooltip: t`Cancel order`,
onClick: cancelOrder.open,
hidden: !canCancel
})
]}
/>
];
}, [id, build, user]);
}, [id, build, user, buildStatus]);
const buildBadges = useMemo(() => {
return instanceQuery.isFetching
@ -454,7 +526,10 @@ export default function BuildDetail() {
<>
{editBuild.modal}
{duplicateBuild.modal}
{cancelBuild.modal}
{cancelOrder.modal}
{holdOrder.modal}
{issueOrder.modal}
{completeOrder.modal}
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap="xs">
<PageDetail

View File

@ -19,9 +19,13 @@ import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import {
ActionDropdown,
BarcodeActionDropdown,
DeleteItemAction,
DuplicateItemAction,
EditItemAction
EditItemAction,
LinkBarcodeAction,
UnlinkBarcodeAction,
ViewBarcodeAction
} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
@ -265,6 +269,24 @@ export default function SupplierPartDetail() {
const supplierPartActions = useMemo(() => {
return [
<AdminButton model={ModelType.supplierpart} pk={supplierPart.pk} />,
<BarcodeActionDropdown
actions={[
ViewBarcodeAction({
model: ModelType.supplierpart,
pk: supplierPart.pk
}),
LinkBarcodeAction({
hidden:
supplierPart.barcode_hash ||
!user.hasChangeRole(UserRoles.purchase_order)
}),
UnlinkBarcodeAction({
hidden:
!supplierPart.barcode_hash ||
!user.hasChangeRole(UserRoles.purchase_order)
})
]}
/>,
<ActionDropdown
tooltip={t`Supplier Part Actions`}
icon={<IconDots />}

View File

@ -130,6 +130,13 @@ export default function PartDetail() {
icon: 'part',
copy: true
},
{
type: 'string',
name: 'IPN',
label: t`IPN`,
copy: true,
hidden: !part.IPN
},
{
type: 'string',
name: 'description',
@ -177,13 +184,6 @@ export default function PartDetail() {
model: ModelType.stocklocation,
hidden: part.default_location || !part.category_default_location
},
{
type: 'string',
name: 'IPN',
label: t`IPN`,
copy: true,
hidden: !part.IPN
},
{
type: 'string',
name: 'units',
@ -799,7 +799,7 @@ export default function PartDetail() {
<DetailsBadge
label={t`On Order` + `: ${part.ordering}`}
color="blue"
visible={part.on_order > 0}
visible={part.ordering > 0}
key="on_order"
/>,
<DetailsBadge

View File

@ -12,6 +12,7 @@ import { ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
@ -23,6 +24,7 @@ import {
CancelItemAction,
DuplicateItemAction,
EditItemAction,
HoldItemAction,
LinkBarcodeAction,
UnlinkBarcodeAction,
ViewBarcodeAction
@ -41,6 +43,8 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import { PurchaseOrderLineItemTable } from '../../tables/purchasing/PurchaseOrderLineItemTable';
@ -287,8 +291,77 @@ export default function PurchaseOrderDetail() {
];
}, [order, id, user]);
const poStatus = useStatusCodes({ modelType: ModelType.purchaseorder });
const issueOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.purchase_order_issue, order.pk),
title: t`Issue Purchase Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Issue this order`,
successMessage: t`Order issued`
});
const cancelOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.purchase_order_cancel, order.pk),
title: t`Cancel Purchase Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Cancel this order`,
successMessage: t`Order cancelled`
});
const holdOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.purchase_order_hold, order.pk),
title: t`Hold Purchase Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Place this order on hold`,
successMessage: t`Order placed on hold`
});
const completeOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.purchase_order_complete, order.pk),
title: t`Complete Purchase Order`,
successMessage: t`Order completed`,
timeout: 10000,
fields: {
accept_incomplete: {}
},
onFormSuccess: refreshInstance,
preFormWarning: t`Mark this order as complete`
});
const poActions = useMemo(() => {
const canEdit: boolean = user.hasChangeRole(UserRoles.purchase_order);
const canIssue: boolean =
canEdit &&
(order.status == poStatus.PENDING || order.status == poStatus.ON_HOLD);
const canHold: boolean =
canEdit &&
(order.status == poStatus.PENDING || order.status == poStatus.PLACED);
const canComplete: boolean = canEdit && order.status == poStatus.PLACED;
const canCancel: boolean =
canEdit &&
order.status != poStatus.CANCELLED &&
order.status != poStatus.COMPLETE;
return [
<PrimaryActionButton
title={t`Issue Order`}
icon="issue"
hidden={!canIssue}
color="blue"
onClick={issueOrder.open}
/>,
<PrimaryActionButton
title={t`Complete Order`}
icon="complete"
hidden={!canComplete}
color="green"
onClick={completeOrder.open}
/>,
<AdminButton model={ModelType.purchaseorder} pk={order.pk} />,
<BarcodeActionDropdown
actions={[
@ -314,22 +387,31 @@ export default function PurchaseOrderDetail() {
icon={<IconDots />}
actions={[
EditItemAction({
hidden: !user.hasChangeRole(UserRoles.purchase_order),
hidden: !canEdit,
tooltip: t`Edit order`,
onClick: () => {
editPurchaseOrder.open();
}
}),
CancelItemAction({
tooltip: t`Cancel order`
}),
DuplicateItemAction({
hidden: !user.hasAddRole(UserRoles.purchase_order),
onClick: () => duplicatePurchaseOrder.open()
onClick: () => duplicatePurchaseOrder.open(),
tooltip: t`Duplicate order`
}),
HoldItemAction({
tooltip: t`Hold order`,
hidden: !canHold,
onClick: holdOrder.open
}),
CancelItemAction({
tooltip: t`Cancel order`,
hidden: !canCancel,
onClick: cancelOrder.open
})
]}
/>
];
}, [id, order, user]);
}, [id, order, user, poStatus]);
const orderBadges: ReactNode[] = useMemo(() => {
return instanceQuery.isLoading
@ -345,7 +427,12 @@ export default function PurchaseOrderDetail() {
return (
<>
{issueOrder.modal}
{holdOrder.modal}
{cancelOrder.modal}
{completeOrder.modal}
{editPurchaseOrder.modal}
{duplicatePurchaseOrder.modal}
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap="xs">
<PageDetail

View File

@ -11,6 +11,7 @@ import { ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
@ -22,6 +23,7 @@ import {
CancelItemAction,
DuplicateItemAction,
EditItemAction,
HoldItemAction,
LinkBarcodeAction,
UnlinkBarcodeAction,
ViewBarcodeAction
@ -40,6 +42,8 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import ReturnOrderLineItemTable from '../../tables/sales/ReturnOrderLineItemTable';
@ -101,7 +105,7 @@ export default function ReturnOrderDetail() {
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.salesorder
model: ModelType.returnorder
}
];
@ -120,15 +124,6 @@ export default function ReturnOrderDetail() {
total: order.line_items,
progress: order.completed_lines
},
{
type: 'progressbar',
name: 'shipments',
icon: 'shipment',
label: t`Completed Shipments`,
total: order.shipments,
progress: order.completed_shipments
// TODO: Fix this progress bar
},
{
type: 'text',
name: 'currency',
@ -296,8 +291,77 @@ export default function ReturnOrderDetail() {
follow: true
});
const issueOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.return_order_issue, order.pk),
title: t`Issue Return Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Issue this order`,
successMessage: t`Order issued`
});
const cancelOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.return_order_cancel, order.pk),
title: t`Cancel Return Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Cancel this order`,
successMessage: t`Order canceled`
});
const holdOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.return_order_hold, order.pk),
title: t`Hold Return Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Place this order on hold`,
successMessage: t`Order placed on hold`
});
const completeOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.return_order_complete, order.pk),
title: t`Complete Return Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Mark this order as complete`,
successMessage: t`Order completed`
});
const roStatus = useStatusCodes({ modelType: ModelType.returnorder });
const orderActions = useMemo(() => {
const canEdit: boolean = user.hasChangeRole(UserRoles.return_order);
const canIssue: boolean =
canEdit &&
(order.status == roStatus.PENDING || order.status == roStatus.ON_HOLD);
const canHold: boolean =
canEdit &&
(order.status == roStatus.PENDING ||
order.status == roStatus.PLACED ||
order.status == roStatus.IN_PROGRESS);
const canCancel: boolean =
canEdit &&
(order.status == roStatus.PENDING ||
order.status == roStatus.IN_PROGRESS ||
order.status == roStatus.ON_HOLD);
const canComplete: boolean =
canEdit && order.status == roStatus.IN_PROGRESS;
return [
<PrimaryActionButton
title={t`Issue Order`}
icon="issue"
hidden={!canIssue}
color="blue"
onClick={() => issueOrder.open()}
/>,
<PrimaryActionButton
title={t`Complete Order`}
icon="complete"
hidden={!canComplete}
color="green"
onClick={() => completeOrder.open()}
/>,
<AdminButton model={ModelType.returnorder} pk={order.pk} />,
<BarcodeActionDropdown
actions={[
@ -324,25 +388,38 @@ export default function ReturnOrderDetail() {
actions={[
EditItemAction({
hidden: !user.hasChangeRole(UserRoles.return_order),
tooltip: t`Edit order`,
onClick: () => {
editReturnOrder.open();
}
}),
CancelItemAction({
tooltip: t`Cancel order`
}),
DuplicateItemAction({
tooltip: t`Duplicate order`,
hidden: !user.hasChangeRole(UserRoles.return_order),
onClick: () => duplicateReturnOrder.open()
}),
HoldItemAction({
tooltip: t`Hold order`,
hidden: !canHold,
onClick: () => holdOrder.open()
}),
CancelItemAction({
tooltip: t`Cancel order`,
hidden: !canCancel,
onClick: () => cancelOrder.open()
})
]}
/>
];
}, [user, order]);
}, [user, order, roStatus]);
return (
<>
{editReturnOrder.modal}
{issueOrder.modal}
{cancelOrder.modal}
{holdOrder.modal}
{completeOrder.modal}
{duplicateReturnOrder.modal}
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap="xs">

View File

@ -13,6 +13,7 @@ import { ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
@ -24,6 +25,7 @@ import {
CancelItemAction,
DuplicateItemAction,
EditItemAction,
HoldItemAction,
LinkBarcodeAction,
UnlinkBarcodeAction,
ViewBarcodeAction
@ -42,6 +44,8 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
@ -213,6 +217,8 @@ export default function SalesOrderDetail() {
);
}, [order, instanceQuery]);
const soStatus = useStatusCodes({ modelType: ModelType.salesorder });
const salesOrderFields = useSalesOrderFields();
const editSalesOrder = useEditApiFormModal({
@ -253,6 +259,10 @@ export default function SalesOrderDetail() {
<SalesOrderLineItemTable
orderId={order.pk}
customerId={order.customer}
editable={
order.status != soStatus.COMPLETE &&
order.status != soStatus.CANCELLED
}
/>
)
},
@ -296,10 +306,86 @@ export default function SalesOrderDetail() {
)
}
];
}, [order, id, user]);
}, [order, id, user, soStatus]);
const issueOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.sales_order_issue, order.pk),
title: t`Issue Sales Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Issue this order`,
successMessage: t`Order issued`
});
const cancelOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.sales_order_cancel, order.pk),
title: t`Cancel Sales Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Cancel this order`,
successMessage: t`Order cancelled`
});
const holdOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.sales_order_hold, order.pk),
title: t`Hold Sales Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Place this order on hold`,
successMessage: t`Order placed on hold`
});
const completeOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.sales_order_complete, order.pk),
title: t`Complete Sales Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Mark this order as complete`,
successMessage: t`Order completed`,
fields: {
accept_incomplete: {}
}
});
const soActions = useMemo(() => {
const canEdit: boolean = user.hasChangeRole(UserRoles.sales_order);
const canIssue: boolean =
canEdit &&
(order.status == soStatus.PENDING || order.status == soStatus.ON_HOLD);
const canCancel: boolean =
canEdit &&
(order.status == soStatus.PENDING ||
order.status == soStatus.ON_HOLD ||
order.status == soStatus.IN_PROGRESS);
const canHold: boolean =
canEdit &&
(order.status == soStatus.PENDING ||
order.status == soStatus.IN_PROGRESS);
const canShip: boolean = canEdit && order.status == soStatus.IN_PROGRESS;
const canComplete: boolean = canEdit && order.status == soStatus.SHIPPED;
return [
<PrimaryActionButton
title={t`Issue Order`}
icon="issue"
hidden={!canIssue}
color="blue"
onClick={issueOrder.open}
/>,
<PrimaryActionButton
title={t`Ship Order`}
icon="deliver"
hidden={!canShip}
color="blue"
onClick={completeOrder.open}
/>,
<PrimaryActionButton
title={t`Complete Order`}
icon="complete"
hidden={!canComplete}
color="green"
onClick={completeOrder.open}
/>,
<AdminButton model={ModelType.salesorder} pk={order.pk} />,
<BarcodeActionDropdown
actions={[
@ -325,20 +411,29 @@ export default function SalesOrderDetail() {
icon={<IconDots />}
actions={[
EditItemAction({
hidden: !user.hasChangeRole(UserRoles.sales_order),
onClick: () => editSalesOrder.open()
}),
CancelItemAction({
tooltip: t`Cancel order`
hidden: !canEdit,
onClick: () => editSalesOrder.open(),
tooltip: t`Edit order`
}),
DuplicateItemAction({
hidden: !user.hasAddRole(UserRoles.sales_order),
onClick: () => duplicateSalesOrder.open()
onClick: () => duplicateSalesOrder.open(),
tooltip: t`Duplicate order`
}),
HoldItemAction({
tooltip: t`Hold order`,
hidden: !canHold,
onClick: () => holdOrder.open()
}),
CancelItemAction({
tooltip: t`Cancel order`,
hidden: !canCancel,
onClick: () => cancelOrder.open()
})
]}
/>
];
}, [user, order]);
}, [user, order, soStatus]);
const orderBadges: ReactNode[] = useMemo(() => {
return instanceQuery.isLoading
@ -355,7 +450,12 @@ export default function SalesOrderDetail() {
return (
<>
{issueOrder.modal}
{cancelOrder.modal}
{holdOrder.modal}
{completeOrder.modal}
{editSalesOrder.modal}
{duplicateSalesOrder.modal}
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap="xs">
<PageDetail

View File

@ -112,6 +112,7 @@ export function RowActions({
withinPortal={true}
label={action.tooltip ?? action.title}
key={action.title}
position="left"
>
<Menu.Item
color={action.color}

View File

@ -33,6 +33,11 @@ export function UsedInTable({
title: t`Assembly`,
render: (record: any) => PartColumn(record.part_detail)
},
{
accessor: 'part_detail.IPN',
sortable: false,
title: t`IPN`
},
{
accessor: 'sub_part',
sortable: true,

View File

@ -34,10 +34,12 @@ import { TableHoverCard } from '../TableHoverCard';
export default function SalesOrderLineItemTable({
orderId,
customerId
customerId,
editable
}: {
orderId: number;
customerId: number;
editable: boolean;
}) {
const user = useUserState();
const table = useTable('sales-order-line-item');
@ -207,7 +209,7 @@ export default function SalesOrderLineItemTable({
});
newLine.open();
}}
hidden={!user.hasAddRole(UserRoles.sales_order)}
hidden={!editable || !user.hasAddRole(UserRoles.sales_order)}
/>
];
}, [user, orderId]);
@ -218,7 +220,10 @@ export default function SalesOrderLineItemTable({
return [
{
hidden: allocated || !user.hasChangeRole(UserRoles.sales_order),
hidden:
allocated ||
!editable ||
!user.hasChangeRole(UserRoles.sales_order),
title: t`Allocate stock`,
icon: <IconSquareArrowRight />,
color: 'green'
@ -242,21 +247,21 @@ export default function SalesOrderLineItemTable({
color: 'blue'
},
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.sales_order),
hidden: !editable || !user.hasChangeRole(UserRoles.sales_order),
onClick: () => {
setSelectedLine(record.pk);
editLine.open();
}
}),
RowDuplicateAction({
hidden: !user.hasAddRole(UserRoles.sales_order),
hidden: !editable || !user.hasAddRole(UserRoles.sales_order),
onClick: () => {
setInitialData(record);
newLine.open();
}
}),
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.sales_order),
hidden: !editable || !user.hasDeleteRole(UserRoles.sales_order),
onClick: () => {
setSelectedLine(record.pk);
deleteLine.open();
@ -264,7 +269,7 @@ export default function SalesOrderLineItemTable({
})
];
},
[user]
[user, editable]
);
return (

View File

@ -19,6 +19,7 @@ import {
LineItemsProgressColumn,
ProjectCodeColumn,
ReferenceColumn,
ResponsibleColumn,
ShipmentDateColumn,
StatusColumn,
TargetDateColumn
@ -129,6 +130,7 @@ export function SalesOrderTable({
CreationDateColumn({}),
TargetDateColumn({}),
ShipmentDateColumn({}),
ResponsibleColumn({}),
{
accessor: 'total_price',
title: t`Total Price`,

View File

@ -59,6 +59,7 @@ export const test = baseTest.extend({
if (
msg.type() === 'error' &&
!msg.text().startsWith('ERR: ') &&
msg.text().indexOf('downloadable font: download failed') < 0 &&
msg
.text()
.indexOf(

View File

@ -9,8 +9,40 @@ test('PUI - Pages - Build Order', async ({ page }) => {
// Navigate to the correct build order
await page.getByRole('tab', { name: 'Build', exact: true }).click();
// We have now loaded the "Build Order" table. Check for some expected texts
await page.getByText('On Hold').waitFor();
await page.getByText('Pending').first().waitFor();
// Load a particular build order
await page.getByRole('cell', { name: 'BO0017' }).click();
// This build order should be "on hold"
await page.getByText('On Hold').first().waitFor();
await page.getByRole('button', { name: 'Issue Order' }).click();
await page.getByRole('button', { name: 'Cancel' }).click();
// Back to the build list
await page.getByLabel('breadcrumb-0-build-orders').click();
// Load a different build order
await page.getByRole('cell', { name: 'BO0011' }).click();
// This build order should be "in production"
await page.getByText('Production').first().waitFor();
await page.getByRole('button', { name: 'Complete Order' }).click();
await page.getByText('Accept Unallocated').waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
// Check for other expected actions
await page.getByLabel('action-menu-build-order-').click();
await page.getByLabel('action-menu-build-order-actions-edit').waitFor();
await page.getByLabel('action-menu-build-order-actions-duplicate').waitFor();
await page.getByLabel('action-menu-build-order-actions-hold').waitFor();
await page.getByLabel('action-menu-build-order-actions-cancel').click();
await page.getByText('Remove Incomplete Outputs').waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
// Click on some tabs
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Notes' }).click();

View File

@ -0,0 +1,67 @@
import { test } from '../baseFixtures.ts';
import { baseUrl } from '../defaults.ts';
import { doQuickLogin } from '../login.ts';
test('PUI - Sales Orders', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/home`);
await page.getByRole('tab', { name: 'Sales' }).click();
await page.getByRole('tab', { name: 'Sales Orders' }).click();
// Check for expected text in the table
await page.getByRole('tab', { name: 'Sales Orders' }).waitFor();
await page.getByText('In Progress').first().waitFor();
await page.getByText('On Hold').first().waitFor();
// Navigate to a particular sales order
await page.getByRole('cell', { name: 'SO0003' }).click();
// Order is "on hold". We will "issue" it and then place on hold again
await page.getByText('Sales Order: SO0003').waitFor();
await page.getByText('On Hold').first().waitFor();
await page.getByRole('button', { name: 'Issue Order' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
// Order should now be "in progress"
await page.getByText('In Progress').first().waitFor();
await page.getByRole('button', { name: 'Ship Order' }).waitFor();
await page.getByLabel('action-menu-order-actions').click();
await page.getByLabel('action-menu-order-actions-edit').waitFor();
await page.getByLabel('action-menu-order-actions-duplicate').waitFor();
await page.getByLabel('action-menu-order-actions-cancel').waitFor();
// Mark the order as "on hold" again
await page.getByLabel('action-menu-order-actions-hold').click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('On Hold').first().waitFor();
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
});
test('PUI - Purchase Orders', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/home`);
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
// Check for expected values
await page.getByRole('cell', { name: 'PO0014' }).waitFor();
await page.getByText('Wire-E-Coyote').waitFor();
await page.getByText('Cancelled').first().waitFor();
await page.getByText('Pending').first().waitFor();
await page.getByText('On Hold').first().waitFor();
// Click through to a particular purchase order
await page.getByRole('cell', { name: 'PO0013' }).click();
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
// Display QR code
await page.getByLabel('action-menu-barcode-actions').click();
await page.getByLabel('action-menu-barcode-actions-view').click();
await page.getByRole('img', { name: 'QR Code' }).waitFor();
});