mirror of
https://github.com/inventree/InvenTree.git
synced 2026-06-12 03:28:37 +00:00
Transfer Order (#11281)
* initial skel commit for transfer orders * initial transfer order backend model * add some serializers, rename PLACED to ISSUED for TransferOrders * adding from admin console works * simple table list almost working, but we need to add order line items.... * add other cols to table * add Transfer Order from table view * moving towards a detail view * wip: adding detail view * add take from and destination serializer details * add other detail grid items * edit/duplicate transfer order * more action buttons * first crack at adding line items * add to line item * add filters * starting work on row actions * more action buttons for line items * fix copy lines in duplicate * basic allocation works * allocations table actions * allocate serials * allocated serial row expansion * add transferred qty to serializers * move items on complete, show in tracking * change panel to transferred stock upon complete * allow incomplete line items * disable edit allocations when completed * add ref pattern and to settings * add admin to line item inline * add calendar and parametric view * basic transfer order report * add transfer order ruleset * starting allocation buisness logic throughout for TOs * disable accept incomplete logic, which was incorrect, until I fix * fix incomplete allocation option * add transferred col to default report * add transfer order to calendar ics view * chain condition for readability * add transfer order allocations table to stockitem view * don't account TO allocations in availability * add transfer orders table for a part * 'consume' option by doing take_stock * squash migrations * starting to test transfer order * more transfer order tests * add transfer order consume test * wip, more tests * more transfer order tests * had to refresh_from_db * switch "to" to "transfer-order" in url paths * only select non-virtual parts from transfer order * add transfer order docs * deconflict migrations * fix frontend build error * fix validation on transfer order reference pattern * add oath2 scope for transfer order * fix state test to include transfer order state * add barcode_model_type_code for transfer order * bump api version * check view role for transfer order, remove debug/commented out lines * add serialized allocation test * Fix migrations * Frontend fixes * Implement required 'company' attribute * transfer order report context * attempt to fix tests * delete transfer order allocations on cancel * add a few playwright tests, more incoming * more playwright * add source and destination locations to table * deconflict migrations * Fix build issue * attempt to fix flaky transfer order test * duplicate transfer order before running tests * Adjust playwright tests * Fix migration dependency order --------- Co-authored-by: Oliver <oliver.henry.walters@gmail.com> Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
@@ -199,6 +199,17 @@ export enum ApiEndpoints {
|
||||
return_order_line_list = 'order/ro-line/',
|
||||
return_order_extra_line_list = 'order/ro-extra-line/',
|
||||
|
||||
transfer_order_list = 'order/transfer-order/',
|
||||
transfer_order_issue = 'order/transfer-order/:id/issue/',
|
||||
transfer_order_hold = 'order/transfer-order/:id/hold/',
|
||||
transfer_order_cancel = 'order/transfer-order/:id/cancel/',
|
||||
transfer_order_complete = 'order/transfer-order/:id/complete/',
|
||||
transfer_order_allocate = 'order/transfer-order/:id/allocate/',
|
||||
transfer_order_allocate_serials = 'order/transfer-order/:id/allocate-serials/',
|
||||
|
||||
transfer_order_line_list = 'order/transfer-order-line/',
|
||||
transfer_order_allocation_list = 'order/transfer-order-allocation/',
|
||||
|
||||
// Template API endpoints
|
||||
label_list = 'label/template/',
|
||||
label_print = 'label/print/',
|
||||
|
||||
@@ -207,6 +207,22 @@ export const ModelInformationDict: ModelDict = {
|
||||
api_endpoint: ApiEndpoints.return_order_line_list,
|
||||
icon: 'return_orders'
|
||||
},
|
||||
transferorder: {
|
||||
label: () => t`Transfer Order`,
|
||||
label_multiple: () => t`Transfer Orders`,
|
||||
url_overview: '/stock/location/index/transfer-orders',
|
||||
url_detail: '/stock/transfer-order/:pk/',
|
||||
api_endpoint: ApiEndpoints.transfer_order_list,
|
||||
admin_url: '/order/transferorder/',
|
||||
supports_barcode: true,
|
||||
icon: 'transfer_orders'
|
||||
},
|
||||
transferorderlineitem: {
|
||||
label: () => t`Transfer Order Line Item`,
|
||||
label_multiple: () => t`Transfer Order Line Items`,
|
||||
api_endpoint: ApiEndpoints.transfer_order_line_list,
|
||||
icon: 'transfer-orders'
|
||||
},
|
||||
address: {
|
||||
label: () => t`Address`,
|
||||
label_multiple: () => t`Addresses`,
|
||||
|
||||
@@ -24,6 +24,8 @@ export enum ModelType {
|
||||
salesordershipment = 'salesordershipment',
|
||||
returnorder = 'returnorder',
|
||||
returnorderlineitem = 'returnorderlineitem',
|
||||
transferorder = 'transferorder',
|
||||
transferorderlineitem = 'transferorderlineitem',
|
||||
importsession = 'importsession',
|
||||
address = 'address',
|
||||
contact = 'contact',
|
||||
|
||||
@@ -11,6 +11,7 @@ export enum UserRoles {
|
||||
part_category = 'part_category',
|
||||
purchase_order = 'purchase_order',
|
||||
return_order = 'return_order',
|
||||
transfer_order = 'transfer_order',
|
||||
sales_order = 'sales_order',
|
||||
stock = 'stock',
|
||||
stock_location = 'stock_location'
|
||||
@@ -40,6 +41,8 @@ export function userRoleLabel(role: UserRoles): string {
|
||||
return t`Purchase Orders`;
|
||||
case UserRoles.return_order:
|
||||
return t`Return Orders`;
|
||||
case UserRoles.transfer_order:
|
||||
return t`Transfer Orders`;
|
||||
case UserRoles.sales_order:
|
||||
return t`Sales Orders`;
|
||||
case UserRoles.stock:
|
||||
|
||||
@@ -51,7 +51,9 @@ import {
|
||||
RenderReturnOrder,
|
||||
RenderReturnOrderLineItem,
|
||||
RenderSalesOrder,
|
||||
RenderSalesOrderShipment
|
||||
RenderSalesOrderShipment,
|
||||
RenderTransferOrder,
|
||||
RenderTransferOrderLineItem
|
||||
} from './Order';
|
||||
import { RenderPart, RenderPartCategory, RenderPartTestTemplate } from './Part';
|
||||
import { RenderPlugin } from './Plugin';
|
||||
@@ -87,6 +89,8 @@ export const RendererLookup: ModelRendererDict = {
|
||||
[ModelType.returnorderlineitem]: RenderReturnOrderLineItem,
|
||||
[ModelType.salesorder]: RenderSalesOrder,
|
||||
[ModelType.salesordershipment]: RenderSalesOrderShipment,
|
||||
[ModelType.transferorder]: RenderTransferOrder,
|
||||
[ModelType.transferorderlineitem]: RenderTransferOrderLineItem,
|
||||
[ModelType.stocklocation]: RenderStockLocation,
|
||||
[ModelType.stocklocationtype]: RenderStockLocationType,
|
||||
[ModelType.stockitem]: RenderStockItem,
|
||||
|
||||
@@ -123,3 +123,46 @@ export function RenderSalesOrderShipment({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline rendering of a single TransferOrder instance
|
||||
*/
|
||||
export function RenderTransferOrder(
|
||||
props: Readonly<InstanceRenderInterface>
|
||||
): ReactNode {
|
||||
const { instance } = props;
|
||||
|
||||
return (
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
primary={instance.reference}
|
||||
secondary={instance.description}
|
||||
suffix={StatusRenderer({
|
||||
status: instance.status_custom_key,
|
||||
type: ModelType.transferorder
|
||||
})}
|
||||
url={
|
||||
props.link
|
||||
? getDetailUrl(ModelType.transferorder, instance.pk)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderTransferOrderLineItem(
|
||||
props: Readonly<InstanceRenderInterface>
|
||||
): ReactNode {
|
||||
const { instance } = props;
|
||||
|
||||
return (
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
primary={instance.reference}
|
||||
suffix={StatusRenderer({
|
||||
status: instance.outcome,
|
||||
type: ModelType.transferorderlineitem
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export const statusCodeList: Record<string, ModelType> = {
|
||||
PurchaseOrderStatus: ModelType.purchaseorder,
|
||||
ReturnOrderStatus: ModelType.returnorder,
|
||||
ReturnOrderLineStatus: ModelType.returnorderlineitem,
|
||||
TransferOrderStatus: ModelType.transferorder,
|
||||
TransferOrderLineStatus: ModelType.transferorderlineitem,
|
||||
SalesOrderStatus: ModelType.salesorder,
|
||||
StockHistoryCode: ModelType.stockhistory,
|
||||
StockStatus: ModelType.stockitem,
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { ApiEndpoints, ModelType, ProgressBar, apiUrl } from '@lib/index';
|
||||
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Table } from '@mantine/core';
|
||||
import { IconCalendar, IconUsers } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||
import { StandaloneField } from '../components/forms/StandaloneField';
|
||||
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
|
||||
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||
import { useGlobalSettingsState } from '../states/SettingsStates';
|
||||
import { RenderPartColumn } from '../tables/ColumnRenderers';
|
||||
|
||||
export function useTransferOrderFields({
|
||||
duplicateOrderId
|
||||
}: {
|
||||
duplicateOrderId?: number;
|
||||
}): ApiFormFieldSet {
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
return useMemo(() => {
|
||||
const fields: ApiFormFieldSet = {
|
||||
reference: {},
|
||||
description: {},
|
||||
project_code: {},
|
||||
start_date: {
|
||||
icon: <IconCalendar />
|
||||
},
|
||||
target_date: {
|
||||
icon: <IconCalendar />
|
||||
},
|
||||
take_from: {},
|
||||
destination: {
|
||||
filters: {
|
||||
structural: false
|
||||
}
|
||||
},
|
||||
consume: {},
|
||||
link: {},
|
||||
responsible: {
|
||||
filters: {
|
||||
is_active: true
|
||||
},
|
||||
icon: <IconUsers />
|
||||
}
|
||||
};
|
||||
|
||||
// Order duplication fields
|
||||
if (!!duplicateOrderId) {
|
||||
fields.duplicate = {
|
||||
children: {
|
||||
order_id: {
|
||||
hidden: true,
|
||||
value: duplicateOrderId
|
||||
},
|
||||
copy_lines: {},
|
||||
// Transfer Orders don't have extra lines for now...
|
||||
copy_extra_lines: { hidden: true, value: false }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalSettings.isSet('PROJECT_CODES_ENABLED', true)) {
|
||||
delete fields.project_code;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [duplicateOrderId, globalSettings]);
|
||||
}
|
||||
|
||||
export function useTransferOrderLineItemFields({
|
||||
orderId,
|
||||
create
|
||||
}: {
|
||||
orderId?: number;
|
||||
create?: boolean;
|
||||
}): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
const fields: ApiFormFieldSet = {
|
||||
order: {
|
||||
filters: {},
|
||||
disabled: true
|
||||
},
|
||||
part: {
|
||||
filters: {
|
||||
active: true,
|
||||
virtual: false
|
||||
}
|
||||
},
|
||||
reference: {},
|
||||
quantity: {},
|
||||
project_code: {
|
||||
description: t`Select project code for this line item`
|
||||
},
|
||||
target_date: {},
|
||||
notes: {},
|
||||
link: {}
|
||||
};
|
||||
|
||||
return fields;
|
||||
}, [orderId, create]);
|
||||
}
|
||||
|
||||
function TransferOrderAllocateLineRow({
|
||||
props,
|
||||
record,
|
||||
sourceLocation
|
||||
}: Readonly<{
|
||||
props: TableFieldRowProps;
|
||||
record: any;
|
||||
sourceLocation?: number | null;
|
||||
}>) {
|
||||
// Statically defined field for selecting the stock item
|
||||
const stockItemField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
field_type: 'related field',
|
||||
api_url: apiUrl(ApiEndpoints.stock_item_list),
|
||||
model: ModelType.stockitem,
|
||||
autoFill: true,
|
||||
filters: {
|
||||
available: true,
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
location: sourceLocation,
|
||||
cascade: sourceLocation ? true : undefined,
|
||||
part: record.part
|
||||
},
|
||||
value: props.item.stock_item,
|
||||
name: 'stock_item',
|
||||
onValueChange: (value: any, instance: any) => {
|
||||
props.changeFn(props.idx, 'stock_item', value);
|
||||
|
||||
// Update the allocated quantity based on the selected stock item
|
||||
if (instance) {
|
||||
const available = instance.quantity - instance.allocated;
|
||||
const required = record.quantity - record.allocated;
|
||||
|
||||
let quantity = props.item?.quantity ?? 0;
|
||||
|
||||
quantity = Math.max(quantity, required);
|
||||
quantity = Math.min(quantity, available);
|
||||
|
||||
if (quantity != props.item.quantity) {
|
||||
props.changeFn(props.idx, 'quantity', quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [sourceLocation, record, props]);
|
||||
|
||||
// Statically defined field for selecting the allocation quantity
|
||||
const quantityField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
field_type: 'number',
|
||||
name: 'quantity',
|
||||
required: true,
|
||||
value: props.item.quantity,
|
||||
onValueChange: (value: any) => {
|
||||
props.changeFn(props.idx, 'quantity', value);
|
||||
}
|
||||
};
|
||||
}, [props]);
|
||||
|
||||
return (
|
||||
<Table.Tr key={`table-row-${props.idx}-${record.pk}`}>
|
||||
<Table.Td>
|
||||
<RenderPartColumn part={record.part_detail} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ProgressBar
|
||||
value={record.allocated}
|
||||
maximum={record.quantity}
|
||||
progressLabel
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldName='stock_item'
|
||||
fieldDefinition={stockItemField}
|
||||
error={props.rowErrors?.stock_item?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldName='quantity'
|
||||
fieldDefinition={quantityField}
|
||||
error={props.rowErrors?.quantity?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAllocateToTransferOrderForm({
|
||||
orderId,
|
||||
sourceLocationId,
|
||||
lineItems,
|
||||
onFormSuccess
|
||||
}: {
|
||||
orderId: number;
|
||||
sourceLocationId?: number;
|
||||
lineItems: any[];
|
||||
onFormSuccess: (response: any) => void;
|
||||
}) {
|
||||
const [sourceLocation, setSourceLocation] = useState<number | null>(
|
||||
sourceLocationId || null
|
||||
);
|
||||
|
||||
const fields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
// Non-submitted field to select the source location
|
||||
source_location: {
|
||||
exclude: true,
|
||||
required: false,
|
||||
value: sourceLocationId,
|
||||
field_type: 'related field',
|
||||
api_url: apiUrl(ApiEndpoints.stock_location_list),
|
||||
model: ModelType.stocklocation,
|
||||
label: t`Source Location`,
|
||||
description: t`Select the source location for the stock allocation`,
|
||||
onValueChange: (value: any) => {
|
||||
setSourceLocation(value);
|
||||
}
|
||||
},
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: [],
|
||||
headers: [
|
||||
{ title: t`Part`, style: { minWidth: '200px' } },
|
||||
{ title: t`Allocated`, style: { minWidth: '200px' } },
|
||||
{ title: t`Stock Item`, style: { width: '100%' } },
|
||||
{ title: t`Quantity`, style: { minWidth: '200px' } },
|
||||
{ title: '', style: { width: '50px' } }
|
||||
],
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record =
|
||||
lineItems.find((item) => item.pk == row.item.line_item) ?? {};
|
||||
|
||||
return (
|
||||
<TransferOrderAllocateLineRow
|
||||
key={`table-row-${row.idx}-${record.pk}`}
|
||||
props={row}
|
||||
record={record}
|
||||
sourceLocation={sourceLocation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [orderId, lineItems, sourceLocation]);
|
||||
|
||||
return useCreateApiFormModal({
|
||||
title: t`Allocate Stock`,
|
||||
url: ApiEndpoints.transfer_order_allocate,
|
||||
pk: orderId,
|
||||
fields: fields,
|
||||
onFormSuccess: onFormSuccess,
|
||||
successMessage: t`Stock items allocated`,
|
||||
size: '80%',
|
||||
initialData: {
|
||||
items: lineItems.map((item) => {
|
||||
return {
|
||||
line_item: item.pk,
|
||||
quantity: 0,
|
||||
stock_item: null
|
||||
};
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useTransferOrderAllocationFields({
|
||||
orderId
|
||||
}: {
|
||||
orderId?: number;
|
||||
}): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
item: {
|
||||
// Cannot change item, but display for reference
|
||||
disabled: true
|
||||
},
|
||||
quantity: {}
|
||||
};
|
||||
}, [orderId]);
|
||||
}
|
||||
|
||||
export function useTransferOrderAllocateSerialsFields({
|
||||
itemId,
|
||||
orderId
|
||||
}: {
|
||||
itemId: number;
|
||||
orderId: number;
|
||||
}): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
line_item: {
|
||||
value: itemId,
|
||||
hidden: true
|
||||
},
|
||||
quantity: {},
|
||||
serial_numbers: {}
|
||||
};
|
||||
}, [itemId, orderId]);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
IconCancel,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconCircleDashedCheck,
|
||||
IconCircleMinus,
|
||||
IconCirclePlus,
|
||||
IconCircleX,
|
||||
@@ -148,11 +149,13 @@ const icons: InvenTreeIconType = {
|
||||
build_order: IconTools,
|
||||
builds: IconTools,
|
||||
used_in: IconStack2,
|
||||
consume: IconCircleDashedCheck,
|
||||
manufacturers: IconBuildingFactory2,
|
||||
suppliers: IconBuilding,
|
||||
customers: IconBuildingStore,
|
||||
purchase_orders: IconShoppingCart,
|
||||
return_orders: IconTruckReturn,
|
||||
transfer_orders: IconTransfer,
|
||||
sales_orders: IconTruckDelivery,
|
||||
scheduling: IconCalendarStats,
|
||||
scrap: IconCircleX,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IconQrcode,
|
||||
IconServerCog,
|
||||
IconShoppingCart,
|
||||
IconTransfer,
|
||||
IconTruckDelivery
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
@@ -363,6 +364,20 @@ export default function SystemSettings() {
|
||||
</Stack>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'transferorders',
|
||||
label: t`Transfer Orders`,
|
||||
icon: <IconTransfer />,
|
||||
content: (
|
||||
<GlobalSettingList
|
||||
keys={[
|
||||
'TRANSFERORDER_ENABLED',
|
||||
'TRANSFERORDER_REFERENCE_PATTERN',
|
||||
'TRANSFERORDER_REQUIRE_RESPONSIBLE'
|
||||
]}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'plugins',
|
||||
label: t`Plugins`,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
IconStack2,
|
||||
IconTestPipe,
|
||||
IconTools,
|
||||
IconTransfer,
|
||||
IconTruckDelivery,
|
||||
IconTruckReturn,
|
||||
IconVersions
|
||||
@@ -101,6 +102,7 @@ import { RelatedPartTable } from '../../tables/part/RelatedPartTable';
|
||||
import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable';
|
||||
import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
|
||||
import PartAllocationPanel from './PartAllocationPanel';
|
||||
import PartPricingPanel from './PartPricingPanel';
|
||||
import PartStockHistoryDetail from './PartStockHistoryDetail';
|
||||
@@ -771,6 +773,20 @@ export default function PartDetail() {
|
||||
hidden: !part.assembly || !user.hasViewRole(UserRoles.build),
|
||||
content: part.pk ? <BuildOrderTable partId={part.pk} /> : <Skeleton />
|
||||
},
|
||||
{
|
||||
name: 'transfer_orders',
|
||||
label: t`Transfer Orders`,
|
||||
icon: <IconTransfer />,
|
||||
hidden:
|
||||
part.virtual ||
|
||||
!globalSettings.isSet('TRANSFERORDER_ENABLED') ||
|
||||
!user.hasViewRole(UserRoles.transfer_order),
|
||||
content: part.pk ? (
|
||||
<TransferOrderTable partId={part.pk} />
|
||||
) : (
|
||||
<Skeleton />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'stocktake',
|
||||
label: t`Stock History`,
|
||||
|
||||
@@ -8,11 +8,13 @@ import type { PanelType } from '@lib/types/Panel';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Group, Skeleton, Stack } from '@mantine/core';
|
||||
import {
|
||||
IconCalendar,
|
||||
IconInfoCircle,
|
||||
IconListDetails,
|
||||
IconPackages,
|
||||
IconSitemap,
|
||||
IconTable
|
||||
IconTable,
|
||||
IconTransfer
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
@@ -20,6 +22,7 @@ import { api } from '../../App';
|
||||
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import OrderCalendar from '../../components/calendar/OrderCalendar';
|
||||
import {
|
||||
type DetailsField,
|
||||
DetailsTable
|
||||
@@ -48,11 +51,14 @@ import {
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
|
||||
import { useUserSettingsState } from '../../states/SettingsStates';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { PartListTable } from '../../tables/part/PartTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
import StockLocationParametricTable from '../../tables/stock/StockLocationParametricTable';
|
||||
import { StockLocationTable } from '../../tables/stock/StockLocationTable';
|
||||
import TransferOrderParametricTable from '../../tables/stock/TransferOrderParametricTable';
|
||||
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
|
||||
|
||||
export default function Stock() {
|
||||
const { id: _id } = useParams();
|
||||
@@ -65,6 +71,7 @@ export default function Stock() {
|
||||
const navigate = useNavigate();
|
||||
const user = useUserState();
|
||||
const settings = useUserSettingsState();
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const [treeOpen, setTreeOpen] = useState(false);
|
||||
|
||||
@@ -169,6 +176,7 @@ export default function Stock() {
|
||||
}, [location, instanceQuery]);
|
||||
|
||||
const [sublocationView, setSublocationView] = useState<string>('table');
|
||||
const [transferOrderView, setTransferOrderView] = useState<string>('table');
|
||||
|
||||
const locationPanels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
@@ -219,6 +227,42 @@ export default function Stock() {
|
||||
/>
|
||||
)
|
||||
},
|
||||
SegmentedControlPanel({
|
||||
name: 'transfer-orders',
|
||||
label: t`Transfer Orders`,
|
||||
icon: <IconTransfer />,
|
||||
hidden:
|
||||
!user.hasViewRole(UserRoles.transfer_order) ||
|
||||
!globalSettings.isSet('TRANSFERORDER_ENABLED'),
|
||||
selection: transferOrderView,
|
||||
onChange: setTransferOrderView,
|
||||
options: [
|
||||
{
|
||||
value: 'table',
|
||||
label: t`Table View`,
|
||||
icon: <IconTable />,
|
||||
content: <TransferOrderTable />
|
||||
},
|
||||
{
|
||||
value: 'calendar',
|
||||
label: t`Calendar View`,
|
||||
icon: <IconCalendar />,
|
||||
content: (
|
||||
<OrderCalendar
|
||||
model={ModelType.transferorder}
|
||||
role={UserRoles.transfer_order}
|
||||
params={{ outstanding: true }}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'parametric',
|
||||
label: t`Parametric View`,
|
||||
icon: <IconListDetails />,
|
||||
content: <TransferOrderParametricTable />
|
||||
}
|
||||
]
|
||||
}),
|
||||
{
|
||||
name: 'default_parts',
|
||||
label: t`Default Parts`,
|
||||
@@ -240,7 +284,7 @@ export default function Stock() {
|
||||
hidden: !location.pk
|
||||
})
|
||||
];
|
||||
}, [sublocationView, location, id]);
|
||||
}, [sublocationView, transferOrderView, location, id]);
|
||||
|
||||
const editLocation = useEditApiFormModal({
|
||||
url: ApiEndpoints.stock_location_list,
|
||||
|
||||
@@ -88,6 +88,7 @@ import InstalledItemsTable from '../../tables/stock/InstalledItemsTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable';
|
||||
import { StockTrackingTable } from '../../tables/stock/StockTrackingTable';
|
||||
import TransferOrderAllocationTable from '../../tables/stock/TransferOrderAllocationTable';
|
||||
|
||||
export default function StockDetail() {
|
||||
const { id } = useParams();
|
||||
@@ -476,6 +477,13 @@ export default function StockDetail() {
|
||||
return stockitem?.part_detail?.salable;
|
||||
}, [stockitem]);
|
||||
|
||||
const showTransferAllocations: boolean = useMemo(() => {
|
||||
return (
|
||||
!stockitem?.part_detail?.virtual &&
|
||||
globalSettings.isSet('TRANSFERORDER_ENABLED')
|
||||
);
|
||||
}, [stockitem]);
|
||||
|
||||
// API query to determine if this stock item has trackable BOM items
|
||||
const trackedBomItemQuery = useQuery({
|
||||
queryKey: ['tracked-bom-item', stockitem.pk, stockitem.part],
|
||||
@@ -544,11 +552,17 @@ export default function StockDetail() {
|
||||
icon: <IconBookmark />,
|
||||
hidden:
|
||||
!stockitem.in_stock ||
|
||||
(!showSalesAllocations && !showBuildAllocations),
|
||||
(!showSalesAllocations &&
|
||||
!showBuildAllocations &&
|
||||
!showTransferAllocations),
|
||||
content: (
|
||||
<Accordion
|
||||
multiple={true}
|
||||
defaultValue={['buildAllocations', 'salesAllocations']}
|
||||
defaultValue={[
|
||||
'buildAllocations',
|
||||
'salesAllocations',
|
||||
'transferAllocations'
|
||||
]}
|
||||
>
|
||||
{showBuildAllocations && (
|
||||
<Accordion.Item value='buildAllocations' key='buildAllocations'>
|
||||
@@ -580,6 +594,24 @@ export default function StockDetail() {
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
{showTransferAllocations && (
|
||||
<Accordion.Item
|
||||
value='transferAllocations'
|
||||
key='transferAllocations'
|
||||
>
|
||||
<Accordion.Control>
|
||||
<StylishText size='lg'>{t`Transfer Order Allocations`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<TransferOrderAllocationTable
|
||||
stockId={stockitem.pk}
|
||||
modelField='order'
|
||||
modelTarget={ModelType.transferorder}
|
||||
showOrderInfo
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</Accordion>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Grid, Skeleton, Stack } from '@mantine/core';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { type PanelType, apiUrl } from '@lib/index';
|
||||
import {
|
||||
IconBookmark,
|
||||
IconInfoCircle,
|
||||
IconList,
|
||||
IconListCheck
|
||||
} from '@tabler/icons-react';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
|
||||
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import {
|
||||
type DetailsField,
|
||||
DetailsTable
|
||||
} from '../../components/details/Details';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import {
|
||||
BarcodeActionDropdown,
|
||||
CancelItemAction,
|
||||
DuplicateItemAction,
|
||||
EditItemAction,
|
||||
HoldItemAction,
|
||||
OptionsActionDropdown
|
||||
} from '../../components/items/ActionDropdown';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import AttachmentPanel from '../../components/panels/AttachmentPanel';
|
||||
import NotesPanel from '../../components/panels/NotesPanel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import ParametersPanel from '../../components/panels/ParametersPanel';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { useTransferOrderFields } from '../../forms/TransferOrderForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import TransferOrderAllocationTable from '../../tables/stock/TransferOrderAllocationTable';
|
||||
import TransferOrderLineItemTable from '../../tables/stock/TransferOrderLineItemTable';
|
||||
|
||||
export default function TransferOrderDetail() {
|
||||
const { id } = useParams();
|
||||
|
||||
const user = useUserState();
|
||||
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const {
|
||||
instance: order,
|
||||
instanceQuery,
|
||||
refreshInstance
|
||||
} = useInstance({
|
||||
endpoint: ApiEndpoints.transfer_order_list,
|
||||
pk: id,
|
||||
params: {}
|
||||
});
|
||||
|
||||
const toStatus = useStatusCodes({ modelType: ModelType.transferorder });
|
||||
|
||||
const lineItemsEditable: boolean = useMemo(() => {
|
||||
const orderOpen: boolean =
|
||||
order.status != toStatus.COMPLETE && order.status != toStatus.CANCELLED;
|
||||
|
||||
return orderOpen;
|
||||
// TODO: does this setting make any sense for Transfer Orders???
|
||||
// if (orderOpen) {
|
||||
// return true;
|
||||
// } else {
|
||||
// return globalSettings.isSet('TRANSFERORDER_EDIT_COMPLETED_ORDERS');
|
||||
// }
|
||||
}, [globalSettings, order.status, toStatus]);
|
||||
|
||||
// for now, only permit editing allocations when line items can be edited
|
||||
const allocationsEditable = lineItemsEditable;
|
||||
|
||||
const orderOpen = useMemo(() => {
|
||||
return (
|
||||
order.status == toStatus.PENDING ||
|
||||
order.status == toStatus.ISSUED ||
|
||||
order.status == toStatus.ON_HOLD
|
||||
);
|
||||
}, [order, toStatus]);
|
||||
|
||||
const detailsPanel = useMemo(() => {
|
||||
if (instanceQuery.isFetching) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
const tl: DetailsField[] = [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'reference',
|
||||
label: t`Reference`,
|
||||
copy: true
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
name: 'take_from',
|
||||
icon: 'location',
|
||||
label: t`Source Location`,
|
||||
model: ModelType.stocklocation
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
name: 'destination',
|
||||
icon: 'location',
|
||||
label: t`Destination Location`,
|
||||
model: ModelType.stocklocation
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'description',
|
||||
label: t`Description`,
|
||||
copy: true
|
||||
},
|
||||
{
|
||||
type: 'status',
|
||||
name: 'status',
|
||||
label: t`Status`,
|
||||
model: ModelType.transferorder
|
||||
},
|
||||
{
|
||||
type: 'status',
|
||||
name: 'status_custom_key',
|
||||
label: t`Custom Status`,
|
||||
model: ModelType.transferorder,
|
||||
icon: 'status',
|
||||
hidden:
|
||||
!order.status_custom_key || order.status_custom_key == order.status
|
||||
}
|
||||
];
|
||||
|
||||
const tr: DetailsField[] = [
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'consume',
|
||||
icon: 'consume',
|
||||
label: t`Consume Stock`
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'line_items',
|
||||
label: t`Line Items`,
|
||||
icon: 'list'
|
||||
},
|
||||
{
|
||||
type: 'progressbar',
|
||||
name: 'completed',
|
||||
icon: 'progress',
|
||||
label: t`Completed Line Items`,
|
||||
total: order.line_items,
|
||||
progress: order.completed_lines
|
||||
}
|
||||
];
|
||||
|
||||
const bl: DetailsField[] = [
|
||||
{
|
||||
type: 'link',
|
||||
external: true,
|
||||
name: 'link',
|
||||
label: t`Link`,
|
||||
copy: true,
|
||||
hidden: !order.link
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'project_code_label',
|
||||
label: t`Project Code`,
|
||||
icon: 'reference',
|
||||
copy: true,
|
||||
hidden: !order.project_code
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'responsible',
|
||||
label: t`Responsible`,
|
||||
badge: 'owner',
|
||||
hidden: !order.responsible
|
||||
}
|
||||
];
|
||||
|
||||
const br: DetailsField[] = [
|
||||
{
|
||||
type: 'date',
|
||||
name: 'creation_date',
|
||||
label: t`Creation Date`,
|
||||
icon: 'calendar',
|
||||
copy: true,
|
||||
hidden: !order.creation_date
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
name: 'issue_date',
|
||||
label: t`Issue Date`,
|
||||
icon: 'calendar',
|
||||
copy: true,
|
||||
hidden: !order.issue_date
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
name: 'start_date',
|
||||
label: t`Start Date`,
|
||||
icon: 'calendar',
|
||||
copy: true,
|
||||
hidden: !order.start_date
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
name: 'target_date',
|
||||
label: t`Target Date`,
|
||||
copy: true,
|
||||
hidden: !order.target_date
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
name: 'complete_date',
|
||||
icon: 'calendar_check',
|
||||
label: t`Completion Date`,
|
||||
copy: true,
|
||||
hidden: !order.complete_date
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ItemDetailsGrid>
|
||||
<Grid grow>
|
||||
{/* TODO: what image do we show for a Transfer Order? */}
|
||||
{/* <DetailsImage
|
||||
appRole={UserRoles.transfer_order}
|
||||
apiPath={ApiEndpoints.transfer_order_list}
|
||||
src="/static/img/blank_image.png"
|
||||
pk={order.pk}
|
||||
/> */}
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={order} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<DetailsTable fields={tr} item={order} />
|
||||
<DetailsTable fields={bl} item={order} />
|
||||
<DetailsTable fields={br} item={order} />
|
||||
</ItemDetailsGrid>
|
||||
);
|
||||
}, [order, instanceQuery]);
|
||||
|
||||
const orderPanels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'detail',
|
||||
label: t`Order Details`,
|
||||
icon: <IconInfoCircle />,
|
||||
content: detailsPanel
|
||||
},
|
||||
{
|
||||
name: 'line-items',
|
||||
label: t`Line Items`,
|
||||
icon: <IconList />,
|
||||
content: (
|
||||
<TransferOrderLineItemTable
|
||||
orderId={order.pk}
|
||||
sourceLocationId={order.take_from}
|
||||
orderDetailRefresh={refreshInstance}
|
||||
editable={lineItemsEditable}
|
||||
/>
|
||||
// TODO: add back the accordion if we need extra lines
|
||||
// <Accordion
|
||||
// multiple={true}
|
||||
// defaultValue={[
|
||||
// 'line-items',
|
||||
// // 'extra-items'
|
||||
// ]}
|
||||
// >
|
||||
// <Accordion.Item value='line-items' key='lineitems'>
|
||||
// <Accordion.Control>
|
||||
// <StylishText size='lg'>{t`Line Items`}</StylishText>
|
||||
// </Accordion.Control>
|
||||
// <Accordion.Panel>
|
||||
// <TransferOrderLineItemTable
|
||||
// orderId={order.pk}
|
||||
// orderDetailRefresh={refreshInstance}
|
||||
// editable={lineItemsEditable}
|
||||
// />
|
||||
// </Accordion.Panel>
|
||||
// </Accordion.Item>
|
||||
// {/* <Accordion.Item value='extra-items' key='extraitems'>
|
||||
// <Accordion.Control>
|
||||
// <StylishText size='lg'>{t`Extra Line Items`}</StylishText>
|
||||
// </Accordion.Control>
|
||||
// <Accordion.Panel>
|
||||
// <ExtraLineItemTable
|
||||
// endpoint={ApiEndpoints.sales_order_extra_line_list}
|
||||
// orderId={order.pk}
|
||||
// editable={lineItemsEditable}
|
||||
// orderDetailRefresh={refreshInstance}
|
||||
// currency={orderCurrency}
|
||||
// role={UserRoles.sales_order}
|
||||
// />
|
||||
// </Accordion.Panel>
|
||||
// </Accordion.Item> */}
|
||||
// </Accordion>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'allocations',
|
||||
label:
|
||||
order.status != toStatus.COMPLETE
|
||||
? t`Allocated Stock`
|
||||
: t`Transferred Stock`,
|
||||
icon:
|
||||
order.status != toStatus.COMPLETE ? (
|
||||
<IconBookmark />
|
||||
) : (
|
||||
<IconListCheck />
|
||||
),
|
||||
content: (
|
||||
<TransferOrderAllocationTable
|
||||
orderId={order.pk}
|
||||
showPartInfo
|
||||
allowEdit={allocationsEditable}
|
||||
modelField='item'
|
||||
modelTarget={ModelType.stockitem}
|
||||
/>
|
||||
)
|
||||
},
|
||||
ParametersPanel({
|
||||
model_type: ModelType.transferorder,
|
||||
model_id: order.pk
|
||||
}),
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.transferorder,
|
||||
model_id: order.pk
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.transferorder,
|
||||
model_id: order.pk
|
||||
})
|
||||
];
|
||||
}, [order, id, user]);
|
||||
|
||||
const orderBadges: ReactNode[] = useMemo(() => {
|
||||
return instanceQuery.isLoading
|
||||
? []
|
||||
: [
|
||||
<StatusRenderer
|
||||
status={order.status_custom_key}
|
||||
type={ModelType.transferorder}
|
||||
options={{ size: 'lg' }}
|
||||
/>
|
||||
];
|
||||
}, [order, instanceQuery]);
|
||||
|
||||
const transferOrderFields = useTransferOrderFields({});
|
||||
|
||||
const duplicateTransferOrderFields = useTransferOrderFields({
|
||||
duplicateOrderId: order.pk
|
||||
});
|
||||
|
||||
const editTransferOrder = useEditApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_list,
|
||||
pk: order.pk,
|
||||
title: t`Edit Transfer Order`,
|
||||
fields: transferOrderFields,
|
||||
onFormSuccess: () => {
|
||||
refreshInstance();
|
||||
}
|
||||
});
|
||||
|
||||
const duplicateTransferOrderInitialData = useMemo(() => {
|
||||
const data = { ...order };
|
||||
// if we set the reference to null/undefined, it will be left blank in the form
|
||||
// if we omit the reference altogether, it will be auto-generated via reference pattern
|
||||
// from the OPTIONS response
|
||||
delete data.reference;
|
||||
return data;
|
||||
}, [order]);
|
||||
|
||||
const duplicateTransferOrder = useCreateApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_list,
|
||||
title: t`Add Transfer Order`,
|
||||
fields: duplicateTransferOrderFields,
|
||||
initialData: duplicateTransferOrderInitialData,
|
||||
modelType: ModelType.transferorder,
|
||||
follow: true
|
||||
});
|
||||
|
||||
const issueOrder = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.transfer_order_issue, order.pk),
|
||||
title: t`Issue Transfer Order`,
|
||||
onFormSuccess: refreshInstance,
|
||||
preFormWarning: t`Issue this order`,
|
||||
successMessage: t`Order issued`
|
||||
});
|
||||
|
||||
const cancelOrder = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.transfer_order_cancel, order.pk),
|
||||
title: t`Cancel Transfer Order`,
|
||||
onFormSuccess: refreshInstance,
|
||||
preFormWarning: t`Cancel this order`,
|
||||
successMessage: t`Order cancelled`
|
||||
});
|
||||
|
||||
const holdOrder = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.transfer_order_hold, order.pk),
|
||||
title: t`Hold Transfer Order`,
|
||||
onFormSuccess: refreshInstance,
|
||||
preFormWarning: t`Place this order on hold`,
|
||||
successMessage: t`Order placed on hold`
|
||||
});
|
||||
|
||||
const completeOrder = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.transfer_order_complete, order.pk),
|
||||
title: t`Complete Transfer Order`,
|
||||
onFormSuccess: refreshInstance,
|
||||
preFormWarning: t`Mark this order as complete`,
|
||||
successMessage: t`Order completed`,
|
||||
fields: {
|
||||
accept_incomplete_allocation: {}
|
||||
}
|
||||
});
|
||||
|
||||
const orderActions = useMemo(() => {
|
||||
const canEdit: boolean = user.hasChangeRole(UserRoles.transfer_order);
|
||||
|
||||
const canIssue: boolean =
|
||||
canEdit &&
|
||||
(order.status == toStatus.PENDING || order.status == toStatus.ON_HOLD);
|
||||
|
||||
const canHold: boolean =
|
||||
canEdit &&
|
||||
(order.status == toStatus.PENDING || order.status == toStatus.ISSUED);
|
||||
|
||||
const canCancel: boolean =
|
||||
canEdit &&
|
||||
(order.status == toStatus.PENDING || order.status == toStatus.ON_HOLD);
|
||||
|
||||
const canComplete: boolean = canEdit && order.status == toStatus.ISSUED;
|
||||
|
||||
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.transferorder} id={order.pk} />,
|
||||
<BarcodeActionDropdown
|
||||
model={ModelType.transferorder}
|
||||
pk={order.pk}
|
||||
hash={order?.barcode_hash}
|
||||
/>,
|
||||
<PrintingActions
|
||||
modelType={ModelType.transferorder}
|
||||
items={[order.pk]}
|
||||
enableReports
|
||||
enableLabels
|
||||
/>,
|
||||
<OptionsActionDropdown
|
||||
tooltip={t`Order Actions`}
|
||||
actions={[
|
||||
EditItemAction({
|
||||
hidden: !user.hasChangeRole(UserRoles.transfer_order),
|
||||
tooltip: t`Edit order`,
|
||||
onClick: () => {
|
||||
editTransferOrder.open();
|
||||
}
|
||||
}),
|
||||
DuplicateItemAction({
|
||||
tooltip: t`Duplicate order`,
|
||||
hidden: !user.hasChangeRole(UserRoles.transfer_order),
|
||||
onClick: () => duplicateTransferOrder.open()
|
||||
}),
|
||||
HoldItemAction({
|
||||
tooltip: t`Hold order`,
|
||||
hidden: !canHold,
|
||||
onClick: () => holdOrder.open()
|
||||
}),
|
||||
CancelItemAction({
|
||||
tooltip: t`Cancel order`,
|
||||
hidden: !canCancel,
|
||||
onClick: () => cancelOrder.open()
|
||||
})
|
||||
]}
|
||||
/>
|
||||
];
|
||||
}, [user, order, orderOpen, toStatus]);
|
||||
|
||||
const subtitle: string = useMemo(() => {
|
||||
const t = order.take_from_detail?.pathstring || '';
|
||||
const d = order.destination_detail?.pathstring || '';
|
||||
return `${t} → ${d}`;
|
||||
}, [order]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editTransferOrder.modal}
|
||||
{issueOrder.modal}
|
||||
{cancelOrder.modal}
|
||||
{holdOrder.modal}
|
||||
{completeOrder.modal}
|
||||
{duplicateTransferOrder.modal}
|
||||
<InstanceDetail
|
||||
query={instanceQuery}
|
||||
requiredRole={UserRoles.transfer_order}
|
||||
>
|
||||
<Stack gap='xs'>
|
||||
<PageDetail
|
||||
title={`${t`Transfer Order`}: ${order.reference}`}
|
||||
subtitle={subtitle}
|
||||
// What should be the Transfer Order image?
|
||||
// imageUrl={order.customer_detail?.image}
|
||||
badges={orderBadges}
|
||||
actions={orderActions}
|
||||
breadcrumbs={[{ name: t`Stock`, url: '/stock/' }]}
|
||||
lastCrumb={[
|
||||
{
|
||||
name: order.reference,
|
||||
url: `/stock/transfer-order/${order.pk}`
|
||||
}
|
||||
]}
|
||||
editAction={editTransferOrder.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.transferorder)}
|
||||
/>
|
||||
<PanelGroup
|
||||
pageKey='transferorder'
|
||||
panels={orderPanels}
|
||||
model={ModelType.transferorder}
|
||||
reloadInstance={instanceQuery.refetch}
|
||||
instance={order}
|
||||
id={order.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -88,6 +88,10 @@ export const ReturnOrderDetail = Loadable(
|
||||
lazy(() => import('./pages/sales/ReturnOrderDetail'))
|
||||
);
|
||||
|
||||
export const TransferOrderDetail = Loadable(
|
||||
lazy(() => import('./pages/stock/TransferOrderDetail'))
|
||||
);
|
||||
|
||||
export const Scan = Loadable(lazy(() => import('./pages/Index/Scan')));
|
||||
|
||||
export const ErrorPage = Loadable(lazy(() => import('./pages/ErrorPage')));
|
||||
@@ -169,6 +173,7 @@ export const routes = (
|
||||
<Route index element={<Navigate to='location/index/' />} />
|
||||
<Route path='location/:id?/*' element={<LocationDetail />} />
|
||||
<Route path='item/:id/*' element={<StockDetail />} />
|
||||
<Route path='transfer-order/:id/*' element={<TransferOrderDetail />} />
|
||||
</Route>
|
||||
<Route path='manufacturing/'>
|
||||
<Route index element={<Navigate to='index/' />} />
|
||||
|
||||
@@ -15,7 +15,8 @@ import { RenderCompany } from '../../components/render/Company';
|
||||
import {
|
||||
RenderPurchaseOrder,
|
||||
RenderReturnOrder,
|
||||
RenderSalesOrder
|
||||
RenderSalesOrder,
|
||||
RenderTransferOrder
|
||||
} from '../../components/render/Order';
|
||||
import { RenderPart } from '../../components/render/Part';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
@@ -181,6 +182,17 @@ export function StockTrackingTable({
|
||||
navigate: navigate
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t`Transfer Order`,
|
||||
key: 'transferorder',
|
||||
details:
|
||||
deltas.transferorder_detail &&
|
||||
RenderTransferOrder({
|
||||
instance: deltas.transferorder_detail,
|
||||
link: true,
|
||||
navigate: navigate
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t`Customer`,
|
||||
key: 'customer',
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { type RowAction, RowEditAction } from '@lib/components/RowActions';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import useTable from '@lib/hooks/UseTable';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { StockOperationProps } from '@lib/types/Forms';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Alert } from '@mantine/core';
|
||||
import { IconCircleX } from '@tabler/icons-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTransferOrderAllocationFields } from '../../forms/TransferOrderForms';
|
||||
import {
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
DescriptionColumn,
|
||||
LocationColumn,
|
||||
PartColumn,
|
||||
ReferenceColumn,
|
||||
StatusColumn
|
||||
} from '../ColumnRenderers';
|
||||
import { IncludeVariantsFilter, StockLocationFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
export default function TransferOrderAllocationTable({
|
||||
partId,
|
||||
stockId,
|
||||
orderId,
|
||||
lineItemId,
|
||||
showPartInfo,
|
||||
showOrderInfo,
|
||||
allowEdit,
|
||||
isSubTable,
|
||||
modelTarget,
|
||||
modelField
|
||||
}: Readonly<{
|
||||
partId?: number;
|
||||
stockId?: number;
|
||||
orderId?: number;
|
||||
lineItemId?: number;
|
||||
showPartInfo?: boolean;
|
||||
showOrderInfo?: boolean;
|
||||
allowEdit?: boolean;
|
||||
isSubTable?: boolean;
|
||||
modelTarget?: ModelType;
|
||||
modelField?: string;
|
||||
}>) {
|
||||
const user = useUserState();
|
||||
|
||||
const tableId = useMemo(() => {
|
||||
let id = 'transferorderallocations';
|
||||
|
||||
if (!!partId) {
|
||||
id += '-part';
|
||||
}
|
||||
|
||||
if (isSubTable) {
|
||||
id += '-sub';
|
||||
}
|
||||
|
||||
return id;
|
||||
}, [partId, isSubTable]);
|
||||
|
||||
const table = useTable(tableId);
|
||||
|
||||
const [selectedAllocation, setSelectedAllocation] = useState<number>(0);
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
const filters: TableFilter[] = [
|
||||
{
|
||||
name: 'outstanding',
|
||||
label: t`Outstanding`,
|
||||
description: t`Show outstanding allocations`
|
||||
},
|
||||
StockLocationFilter()
|
||||
];
|
||||
|
||||
if (!!partId) {
|
||||
filters.push(IncludeVariantsFilter());
|
||||
}
|
||||
|
||||
return filters;
|
||||
}, [partId]);
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
ReferenceColumn({
|
||||
accessor: 'order_detail.reference',
|
||||
title: t`Transfer Order`,
|
||||
switchable: false,
|
||||
sortable: true,
|
||||
hidden: showOrderInfo != true
|
||||
}),
|
||||
DescriptionColumn({
|
||||
accessor: 'order_detail.description',
|
||||
hidden: showOrderInfo != true
|
||||
}),
|
||||
StatusColumn({
|
||||
accessor: 'order_detail.status',
|
||||
model: ModelType.transferorder,
|
||||
title: t`Order Status`,
|
||||
hidden: showOrderInfo != true
|
||||
}),
|
||||
PartColumn({
|
||||
hidden: showPartInfo != true,
|
||||
part: 'part_detail'
|
||||
}),
|
||||
DescriptionColumn({
|
||||
accessor: 'part_detail.description',
|
||||
hidden: showPartInfo != true
|
||||
}),
|
||||
{
|
||||
accessor: 'part_detail.IPN',
|
||||
title: t`IPN`,
|
||||
hidden: showPartInfo != true,
|
||||
sortable: true,
|
||||
ordering: 'IPN'
|
||||
},
|
||||
{
|
||||
accessor: 'serial',
|
||||
title: t`Serial Number`,
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
render: (record: any) => record?.item_detail?.serial
|
||||
},
|
||||
{
|
||||
accessor: 'batch',
|
||||
title: t`Batch Code`,
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
render: (record: any) => record?.item_detail?.batch
|
||||
},
|
||||
{
|
||||
accessor: 'available',
|
||||
title: t`Available Quantity`,
|
||||
sortable: false,
|
||||
hidden: isSubTable,
|
||||
render: (record: any) => record?.item_detail?.quantity
|
||||
},
|
||||
{
|
||||
accessor: 'quantity',
|
||||
title: t`Allocated Quantity`,
|
||||
sortable: true
|
||||
},
|
||||
LocationColumn({
|
||||
accessor: 'location_detail',
|
||||
switchable: true,
|
||||
sortable: true
|
||||
})
|
||||
];
|
||||
}, [showOrderInfo, showPartInfo, isSubTable]);
|
||||
|
||||
const editAllocationFields = useTransferOrderAllocationFields({
|
||||
orderId: orderId
|
||||
});
|
||||
|
||||
const editAllocation = useEditApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_allocation_list,
|
||||
pk: selectedAllocation,
|
||||
fields: editAllocationFields,
|
||||
title: t`Edit Allocation`,
|
||||
onFormSuccess: () => table.refreshTable()
|
||||
});
|
||||
|
||||
const deleteAllocation = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_allocation_list,
|
||||
pk: selectedAllocation,
|
||||
title: t`Remove Allocated Stock`,
|
||||
preFormContent: (
|
||||
<Alert color='red' title={t`Confirm Removal`}>
|
||||
{t`Are you sure you want to remove this allocated stock from the order?`}
|
||||
</Alert>
|
||||
),
|
||||
submitText: t`Remove`,
|
||||
onFormSuccess: () => table.refreshTable()
|
||||
});
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
return [
|
||||
RowEditAction({
|
||||
tooltip: t`Edit Allocation`,
|
||||
hidden: !allowEdit || !user.hasChangeRole(UserRoles.transfer_order),
|
||||
onClick: () => {
|
||||
setSelectedAllocation(record.pk);
|
||||
editAllocation.open();
|
||||
}
|
||||
}),
|
||||
{
|
||||
title: t`Remove`,
|
||||
tooltip: t`Remove allocated stock`,
|
||||
icon: <IconCircleX />,
|
||||
color: 'red',
|
||||
hidden: !allowEdit || !user.hasDeleteRole(UserRoles.transfer_order),
|
||||
onClick: () => {
|
||||
setSelectedAllocation(record.pk);
|
||||
deleteAllocation.open();
|
||||
}
|
||||
}
|
||||
];
|
||||
},
|
||||
[allowEdit, user]
|
||||
);
|
||||
|
||||
const stockOperationProps: StockOperationProps = useMemo(() => {
|
||||
// Extract stock items from the selected records
|
||||
// Note that the table is actually a list of TransferOrderAllocation instances,
|
||||
// so we need to reconstruct the stock item details
|
||||
const stockItems: any[] = table.selectedRecords
|
||||
.filter((item: any) => !!item.item_detail)
|
||||
.map((item: any) => {
|
||||
return {
|
||||
...item.item_detail,
|
||||
part_detail: item.part_detail,
|
||||
location_detail: item.location_detail
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items: stockItems,
|
||||
model: ModelType.stockitem,
|
||||
refresh: table.refreshTable
|
||||
};
|
||||
}, [table.selectedRecords, table.refreshTable]);
|
||||
|
||||
const stockAdjustActions = useStockAdjustActions({
|
||||
formProps: stockOperationProps,
|
||||
merge: false,
|
||||
assign: false,
|
||||
delete: false,
|
||||
add: false,
|
||||
count: false,
|
||||
remove: false
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [stockAdjustActions.dropdown];
|
||||
}, [allowEdit, orderId, user, stockAdjustActions.dropdown]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editAllocation.modal}
|
||||
{deleteAllocation.modal}
|
||||
{!isSubTable && stockAdjustActions.modals.map((modal) => modal.modal)}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.transfer_order_allocation_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
params: {
|
||||
part_detail: showPartInfo ?? false,
|
||||
order_detail: showOrderInfo ?? false,
|
||||
item_detail: true,
|
||||
location_detail: true,
|
||||
line: lineItemId,
|
||||
part: partId,
|
||||
order: orderId,
|
||||
item: stockId
|
||||
},
|
||||
enableSearch: !isSubTable,
|
||||
enableRefresh: !isSubTable,
|
||||
enableColumnSwitching: !isSubTable,
|
||||
enableFilters: !isSubTable,
|
||||
enableDownload: !isSubTable,
|
||||
enableSelection: !isSubTable,
|
||||
minHeight: isSubTable ? 100 : undefined,
|
||||
rowActions: rowActions,
|
||||
tableActions: isSubTable ? undefined : tableActions,
|
||||
tableFilters: tableFilters,
|
||||
modelField: modelField ?? 'order',
|
||||
enableReports: !isSubTable,
|
||||
enableLabels: !isSubTable,
|
||||
printingAccessor: 'item',
|
||||
modelType: modelTarget ?? ModelType.transferorder
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import useTable from '@lib/hooks/UseTable';
|
||||
import {
|
||||
ActionButton,
|
||||
AddItemButton,
|
||||
ModelType,
|
||||
ProgressBar,
|
||||
RowDeleteAction,
|
||||
RowDuplicateAction,
|
||||
RowEditAction,
|
||||
RowViewAction,
|
||||
UserRoles,
|
||||
formatDecimal
|
||||
} from '@lib/index';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { RowAction, TableColumn } from '@lib/types/Tables';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Group, Paper, Text } from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconHash,
|
||||
IconShoppingCart,
|
||||
IconSquareArrowRight,
|
||||
IconTools
|
||||
} from '@tabler/icons-react';
|
||||
import type { DataTableRowExpansionProps } from 'mantine-datatable';
|
||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { RenderPart } from '../../components/render/Part';
|
||||
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
||||
import { useBuildOrderFields } from '../../forms/BuildForms';
|
||||
import {
|
||||
useAllocateToTransferOrderForm,
|
||||
useTransferOrderAllocateSerialsFields,
|
||||
useTransferOrderLineItemFields
|
||||
} from '../../forms/TransferOrderForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
DateColumn,
|
||||
DecimalColumn,
|
||||
DescriptionColumn,
|
||||
LinkColumn,
|
||||
ProjectCodeColumn,
|
||||
RenderPartColumn
|
||||
} from '../ColumnRenderers';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import RowExpansionIcon from '../RowExpansionIcon';
|
||||
import { TableHoverCard } from '../TableHoverCard';
|
||||
import TransferOrderAllocationTable from './TransferOrderAllocationTable';
|
||||
|
||||
export default function TransferOrderLineItemTable({
|
||||
orderId,
|
||||
sourceLocationId,
|
||||
orderDetailRefresh,
|
||||
editable
|
||||
}: Readonly<{
|
||||
orderId: number;
|
||||
sourceLocationId?: number;
|
||||
orderDetailRefresh: () => void;
|
||||
editable: boolean;
|
||||
}>) {
|
||||
const navigate = useNavigate();
|
||||
const user = useUserState();
|
||||
const table = useTable('transfer-order-line-item');
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'part',
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
minWidth: 175,
|
||||
render: (record: any) => {
|
||||
return (
|
||||
<Group wrap='nowrap'>
|
||||
{record.part_detail?.virtual || (
|
||||
<RowExpansionIcon
|
||||
enabled={record.allocated}
|
||||
expanded={table.isRowExpanded(record.pk)}
|
||||
/>
|
||||
)}
|
||||
<RenderPartColumn part={record.part_detail} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'part_detail.IPN',
|
||||
title: t`IPN`,
|
||||
switchable: true
|
||||
},
|
||||
DescriptionColumn({
|
||||
accessor: 'part_detail.description'
|
||||
}),
|
||||
{
|
||||
accessor: 'reference',
|
||||
sortable: false,
|
||||
switchable: true
|
||||
},
|
||||
ProjectCodeColumn({}),
|
||||
DecimalColumn({
|
||||
accessor: 'quantity',
|
||||
sortable: true
|
||||
}),
|
||||
DateColumn({
|
||||
accessor: 'target_date',
|
||||
sortable: true,
|
||||
title: t`Target Date`
|
||||
}),
|
||||
{
|
||||
accessor: 'stock',
|
||||
title: t`Available Stock`,
|
||||
render: (record: any) => {
|
||||
if (record.part_detail?.virtual) {
|
||||
return <Text size='sm' fs='italic'>{t`Virtual part`}</Text>;
|
||||
}
|
||||
|
||||
const part_stock = record?.available_stock ?? 0;
|
||||
const variant_stock = record?.available_variant_stock ?? 0;
|
||||
const available = part_stock + variant_stock;
|
||||
|
||||
const required = Math.max(
|
||||
record.quantity - record.allocated - record.shipped,
|
||||
0
|
||||
);
|
||||
|
||||
let color: string | undefined;
|
||||
let text = `${formatDecimal(available)}`;
|
||||
|
||||
const extra: ReactNode[] = [];
|
||||
|
||||
if (available <= 0) {
|
||||
color = 'red';
|
||||
text = t`No stock available`;
|
||||
} else if (available < required) {
|
||||
color = 'orange';
|
||||
}
|
||||
|
||||
if (variant_stock > 0) {
|
||||
extra.push(<Text size='sm'>{t`Includes variant stock`}</Text>);
|
||||
}
|
||||
|
||||
if (record.building > 0) {
|
||||
extra.push(
|
||||
<Text size='sm'>
|
||||
{t`In production`}: {formatDecimal(record.building)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (record.on_order > 0) {
|
||||
extra.push(
|
||||
<Text size='sm'>
|
||||
{t`On order`}: {formatDecimal(record.on_order)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableHoverCard
|
||||
value={<Text c={color}>{text}</Text>}
|
||||
extra={extra}
|
||||
title={t`Stock Information`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
accessor: 'allocated',
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
if (record.part_detail?.virtual) {
|
||||
return <Text size='sm' fs='italic'>{t`Virtual part`}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProgressBar
|
||||
progressLabel={true}
|
||||
value={record.allocated}
|
||||
maximum={record.quantity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'transferred',
|
||||
title: t`Transferred`,
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
if (record.part_detail?.virtual) {
|
||||
return <Text size='sm' fs='italic'>{t`Virtual part`}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProgressBar
|
||||
progressLabel={true}
|
||||
value={record.transferred}
|
||||
maximum={record.quantity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'notes'
|
||||
},
|
||||
LinkColumn({
|
||||
accessor: 'link'
|
||||
})
|
||||
];
|
||||
}, [table.isRowExpanded]);
|
||||
|
||||
const [initialData, setInitialData] = useState({});
|
||||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
||||
const [selectedLineId, setSelectedLineId] = useState<number>(0);
|
||||
const [selectedPart, setSelectedPart] = useState<any>(null);
|
||||
const [partsToOrder, setPartsToOrder] = useState<any[]>([]);
|
||||
|
||||
const allocateStock = useAllocateToTransferOrderForm({
|
||||
orderId: orderId,
|
||||
lineItems: selectedItems.filter(
|
||||
(item) => item.part_detail?.virtual !== true
|
||||
),
|
||||
sourceLocationId: sourceLocationId,
|
||||
onFormSuccess: () => {
|
||||
table.refreshTable();
|
||||
table.clearSelectedRecords();
|
||||
}
|
||||
});
|
||||
|
||||
const orderPartsWizard = OrderPartsWizard({
|
||||
parts: partsToOrder
|
||||
});
|
||||
|
||||
const buildOrderFields = useBuildOrderFields({
|
||||
create: true,
|
||||
modalId: 'build-order-create-from-transfer-order'
|
||||
});
|
||||
|
||||
const newBuildOrder = useCreateApiFormModal({
|
||||
url: ApiEndpoints.build_order_list,
|
||||
title: t`Create Build Order`,
|
||||
modalId: 'build-order-create-from-transfer-order',
|
||||
fields: buildOrderFields,
|
||||
initialData: initialData,
|
||||
follow: true,
|
||||
modelType: ModelType.build
|
||||
});
|
||||
|
||||
const createLineFields = useTransferOrderLineItemFields({
|
||||
orderId: orderId,
|
||||
create: true
|
||||
});
|
||||
|
||||
const newLine = useCreateApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_line_list,
|
||||
title: t`Add Line Item`,
|
||||
fields: createLineFields,
|
||||
initialData: {
|
||||
...initialData
|
||||
},
|
||||
onFormSuccess: orderDetailRefresh,
|
||||
table: table
|
||||
});
|
||||
|
||||
const editLineFields = useTransferOrderLineItemFields({
|
||||
orderId: orderId,
|
||||
create: false
|
||||
});
|
||||
|
||||
const editLine = useEditApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_line_list,
|
||||
pk: selectedLineId,
|
||||
title: t`Edit Line Item`,
|
||||
fields: editLineFields,
|
||||
onFormSuccess: orderDetailRefresh,
|
||||
table: table
|
||||
});
|
||||
|
||||
const deleteLine = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_line_list,
|
||||
pk: selectedLineId,
|
||||
title: t`Delete Line Item`,
|
||||
onFormSuccess: orderDetailRefresh,
|
||||
table: table
|
||||
});
|
||||
|
||||
const allocateSerialFields = useTransferOrderAllocateSerialsFields({
|
||||
itemId: selectedLineId,
|
||||
orderId: orderId
|
||||
});
|
||||
|
||||
const allocateBySerials = useCreateApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_allocate_serials,
|
||||
pk: orderId,
|
||||
title: t`Allocate Serial Numbers`,
|
||||
preFormContent: selectedPart ? (
|
||||
<Paper withBorder p='sm'>
|
||||
<RenderPart instance={selectedPart} />
|
||||
</Paper>
|
||||
) : undefined,
|
||||
initialData: initialData,
|
||||
fields: allocateSerialFields,
|
||||
table: table
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<AddItemButton
|
||||
key='add-transfer-order-line-item'
|
||||
tooltip={t`Add Line Item`}
|
||||
onClick={() => {
|
||||
setInitialData({
|
||||
order: orderId
|
||||
});
|
||||
newLine.open();
|
||||
}}
|
||||
hidden={!editable || !user.hasAddRole(UserRoles.transfer_order)}
|
||||
/>,
|
||||
<ActionButton
|
||||
key='order-parts'
|
||||
hidden={!user.hasAddRole(UserRoles.purchase_order)}
|
||||
disabled={!table.hasSelectedRecords}
|
||||
tooltip={t`Order Parts`}
|
||||
icon={<IconShoppingCart />}
|
||||
color='blue'
|
||||
onClick={() => {
|
||||
setPartsToOrder(table.selectedRecords.map((r) => r.part_detail));
|
||||
orderPartsWizard.openWizard();
|
||||
}}
|
||||
/>,
|
||||
<ActionButton
|
||||
key='allocate-stock'
|
||||
tooltip={t`Allocate Stock`}
|
||||
icon={<IconArrowRight />}
|
||||
disabled={!table.hasSelectedRecords}
|
||||
color='green'
|
||||
onClick={() => {
|
||||
setSelectedItems(
|
||||
table.selectedRecords.filter((r: any) => r.allocated < r.quantity)
|
||||
);
|
||||
allocateStock.open();
|
||||
}}
|
||||
/>
|
||||
];
|
||||
}, [user, orderId, table.hasSelectedRecords, table.selectedRecords]);
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
const allocated = (record?.allocated ?? 0) > (record?.quantity ?? 0);
|
||||
const virtual = record?.part_detail?.virtual ?? false;
|
||||
|
||||
return [
|
||||
{
|
||||
hidden:
|
||||
allocated ||
|
||||
virtual ||
|
||||
!editable ||
|
||||
!user.hasChangeRole(UserRoles.transfer_order),
|
||||
title: t`Allocate Stock`,
|
||||
icon: <IconSquareArrowRight />,
|
||||
color: 'green',
|
||||
onClick: () => {
|
||||
setSelectedItems([record]);
|
||||
allocateStock.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
hidden:
|
||||
!record?.part_detail?.trackable ||
|
||||
allocated ||
|
||||
virtual ||
|
||||
!editable ||
|
||||
!user.hasChangeRole(UserRoles.transfer_order),
|
||||
title: t`Allocate serials`,
|
||||
icon: <IconHash />,
|
||||
color: 'green',
|
||||
onClick: () => {
|
||||
setSelectedLineId(record.pk);
|
||||
setSelectedPart(record?.part_detail ?? null);
|
||||
setInitialData({
|
||||
quantity: record.quantity - record.allocated
|
||||
});
|
||||
allocateBySerials.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
hidden:
|
||||
allocated ||
|
||||
virtual ||
|
||||
!user.hasAddRole(UserRoles.build) ||
|
||||
!record?.part_detail?.assembly,
|
||||
title: t`Build stock`,
|
||||
icon: <IconTools />,
|
||||
color: 'blue',
|
||||
onClick: () => {
|
||||
setInitialData({
|
||||
part: record.part,
|
||||
quantity: (record?.quantity ?? 1) - (record?.allocated ?? 0),
|
||||
transfer_order: orderId
|
||||
});
|
||||
newBuildOrder.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
hidden:
|
||||
allocated ||
|
||||
virtual ||
|
||||
!user.hasAddRole(UserRoles.purchase_order) ||
|
||||
!record?.part_detail?.purchaseable,
|
||||
title: t`Order stock`,
|
||||
icon: <IconShoppingCart />,
|
||||
color: 'blue',
|
||||
onClick: () => {
|
||||
setPartsToOrder([record.part_detail]);
|
||||
orderPartsWizard.openWizard();
|
||||
}
|
||||
},
|
||||
RowEditAction({
|
||||
hidden: !editable || !user.hasChangeRole(UserRoles.transfer_order),
|
||||
onClick: () => {
|
||||
setSelectedLineId(record.pk);
|
||||
editLine.open();
|
||||
}
|
||||
}),
|
||||
RowDuplicateAction({
|
||||
hidden: !editable || !user.hasAddRole(UserRoles.transfer_order),
|
||||
onClick: () => {
|
||||
setInitialData(record);
|
||||
newLine.open();
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden: !editable || !user.hasDeleteRole(UserRoles.transfer_order),
|
||||
onClick: () => {
|
||||
setSelectedLineId(record.pk);
|
||||
deleteLine.open();
|
||||
}
|
||||
}),
|
||||
RowViewAction({
|
||||
title: t`View Part`,
|
||||
modelType: ModelType.part,
|
||||
modelId: record.part,
|
||||
navigate: navigate,
|
||||
hidden: !user.hasViewRole(UserRoles.part)
|
||||
})
|
||||
];
|
||||
},
|
||||
[navigate, user, editable]
|
||||
);
|
||||
|
||||
// Control row expansion
|
||||
const rowExpansion: DataTableRowExpansionProps<any> = useMemo(() => {
|
||||
return {
|
||||
allowMultiple: true,
|
||||
expandable: ({ record }: { record: any }) => {
|
||||
if (record?.part_detail?.virtual) {
|
||||
return false;
|
||||
}
|
||||
return table.isRowExpanded(record.pk) || record.allocated > 0;
|
||||
},
|
||||
content: ({ record }: { record: any }) => {
|
||||
return (
|
||||
<TransferOrderAllocationTable
|
||||
showOrderInfo={false}
|
||||
showPartInfo={false}
|
||||
orderId={orderId}
|
||||
lineItemId={record.pk}
|
||||
partId={record.part}
|
||||
allowEdit={editable}
|
||||
modelTarget={ModelType.stockitem}
|
||||
modelField={'item'}
|
||||
isSubTable
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [orderId, table.isRowExpanded]);
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'allocated',
|
||||
label: t`Allocated`,
|
||||
description: t`Show lines which are fully allocated`
|
||||
},
|
||||
{
|
||||
name: 'completed',
|
||||
label: t`Completed`,
|
||||
description: t`Show lines which are completed`
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editLine.modal}
|
||||
{deleteLine.modal}
|
||||
{newLine.modal}
|
||||
{newBuildOrder.modal}
|
||||
{allocateBySerials.modal}
|
||||
{allocateStock.modal}
|
||||
{orderPartsWizard.wizard}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.transfer_order_line_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
enableSelection: true,
|
||||
enableDownload: true,
|
||||
params: {
|
||||
order: orderId,
|
||||
part_detail: true
|
||||
},
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions,
|
||||
tableFilters: tableFilters,
|
||||
rowExpansion: rowExpansion
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { DescriptionColumn, ReferenceColumn } from '../ColumnRenderers';
|
||||
import {
|
||||
AssignedToMeFilter,
|
||||
OrderStatusFilter,
|
||||
OutstandingFilter,
|
||||
OverdueFilter,
|
||||
ProjectCodeFilter,
|
||||
ResponsibleFilter
|
||||
} from '../Filter';
|
||||
import ParametricDataTable from '../general/ParametricDataTable';
|
||||
|
||||
export default function TransferOrderParametricTable({
|
||||
queryParams
|
||||
}: {
|
||||
queryParams?: Record<string, any>;
|
||||
}): ReactNode {
|
||||
const customColumns: TableColumn[] = useMemo(() => {
|
||||
return [ReferenceColumn({ switchable: false }), DescriptionColumn({})];
|
||||
}, []);
|
||||
|
||||
const customFilters: TableFilter[] = useMemo(() => {
|
||||
return [
|
||||
OrderStatusFilter({ model: ModelType.transferorder }),
|
||||
OutstandingFilter(),
|
||||
OverdueFilter(),
|
||||
AssignedToMeFilter(),
|
||||
ProjectCodeFilter(),
|
||||
ResponsibleFilter()
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ParametricDataTable
|
||||
modelType={ModelType.transferorder}
|
||||
endpoint={ApiEndpoints.transfer_order_list}
|
||||
customColumns={customColumns}
|
||||
customFilters={customFilters}
|
||||
queryParams={{
|
||||
...queryParams
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { AddItemButton, UserRoles, useTable } from '@lib/index';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useMemo } from 'react';
|
||||
import { useTransferOrderFields } from '../../forms/TransferOrderForms';
|
||||
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
BooleanColumn,
|
||||
CompletionDateColumn,
|
||||
CreatedByColumn,
|
||||
CreationDateColumn,
|
||||
DescriptionColumn,
|
||||
LocationColumn,
|
||||
ProjectCodeColumn,
|
||||
ReferenceColumn,
|
||||
ResponsibleColumn,
|
||||
StartDateColumn,
|
||||
StatusColumn,
|
||||
TargetDateColumn
|
||||
} from '../ColumnRenderers';
|
||||
import {
|
||||
AssignedToMeFilter,
|
||||
CompletedAfterFilter,
|
||||
CompletedBeforeFilter,
|
||||
CreatedAfterFilter,
|
||||
CreatedBeforeFilter,
|
||||
CreatedByFilter,
|
||||
HasProjectCodeFilter,
|
||||
IncludeVariantsFilter,
|
||||
MaxDateFilter,
|
||||
MinDateFilter,
|
||||
OrderStatusFilter,
|
||||
OutstandingFilter,
|
||||
OverdueFilter,
|
||||
ProjectCodeFilter,
|
||||
ResponsibleFilter,
|
||||
StartDateAfterFilter,
|
||||
StartDateBeforeFilter,
|
||||
TargetDateAfterFilter,
|
||||
TargetDateBeforeFilter
|
||||
} from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
export function TransferOrderTable({
|
||||
partId
|
||||
}: Readonly<{
|
||||
partId?: number;
|
||||
}>) {
|
||||
const table = useTable(
|
||||
!!partId ? 'transferorders-part' : 'transferorders-index'
|
||||
);
|
||||
const user = useUserState();
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
const filters: TableFilter[] = [
|
||||
OrderStatusFilter({ model: ModelType.transferorder }),
|
||||
OutstandingFilter(),
|
||||
OverdueFilter(),
|
||||
AssignedToMeFilter(),
|
||||
MinDateFilter(),
|
||||
MaxDateFilter(),
|
||||
CreatedBeforeFilter(),
|
||||
CreatedAfterFilter(),
|
||||
TargetDateBeforeFilter(),
|
||||
TargetDateAfterFilter(),
|
||||
StartDateBeforeFilter(),
|
||||
StartDateAfterFilter(),
|
||||
{
|
||||
name: 'has_target_date',
|
||||
type: 'boolean',
|
||||
label: t`Has Target Date`,
|
||||
description: t`Show orders with a target date`
|
||||
},
|
||||
{
|
||||
name: 'has_start_date',
|
||||
type: 'boolean',
|
||||
label: t`Has Start Date`,
|
||||
description: t`Show orders with a start date`
|
||||
},
|
||||
CompletedBeforeFilter(),
|
||||
CompletedAfterFilter(),
|
||||
HasProjectCodeFilter(),
|
||||
ProjectCodeFilter(),
|
||||
ResponsibleFilter(),
|
||||
CreatedByFilter()
|
||||
];
|
||||
|
||||
if (!!partId) {
|
||||
filters.push(IncludeVariantsFilter());
|
||||
}
|
||||
|
||||
return filters;
|
||||
}, [partId]);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
return [
|
||||
ReferenceColumn({}),
|
||||
DescriptionColumn({}),
|
||||
LocationColumn({
|
||||
accessor: 'take_from_detail',
|
||||
title: t`Source Location`
|
||||
}),
|
||||
LocationColumn({
|
||||
accessor: 'destination_detail',
|
||||
title: t`Destination Location`
|
||||
}),
|
||||
BooleanColumn({
|
||||
accessor: 'consume',
|
||||
title: t`Consume Stock`,
|
||||
sortable: true,
|
||||
switchable: true
|
||||
}),
|
||||
// LineItemsProgressColumn({}),
|
||||
StatusColumn({ model: ModelType.transferorder }),
|
||||
ProjectCodeColumn({
|
||||
defaultVisible: false
|
||||
}),
|
||||
CreationDateColumn({
|
||||
defaultVisible: false
|
||||
}),
|
||||
CreatedByColumn({
|
||||
defaultVisible: false
|
||||
}),
|
||||
StartDateColumn({
|
||||
defaultVisible: false
|
||||
}),
|
||||
TargetDateColumn({}),
|
||||
CompletionDateColumn({
|
||||
accessor: 'complete_date'
|
||||
}),
|
||||
ResponsibleColumn({})
|
||||
];
|
||||
}, []);
|
||||
|
||||
const transferOrderFields = useTransferOrderFields({});
|
||||
|
||||
const newTransferOrder = useCreateApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_list,
|
||||
title: t`Add Transfer Order`,
|
||||
fields: transferOrderFields,
|
||||
initialData: {},
|
||||
follow: true,
|
||||
modelType: ModelType.transferorder
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<AddItemButton
|
||||
key='add-transfer-order'
|
||||
tooltip={t`Add Transfer Order`}
|
||||
onClick={() => newTransferOrder.open()}
|
||||
hidden={!user.hasAddRole(UserRoles.transfer_order)}
|
||||
/>
|
||||
];
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{newTransferOrder.modal}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.transfer_order_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
params: {
|
||||
part: partId
|
||||
// customer: customerId,
|
||||
// customer_detail: true
|
||||
},
|
||||
tableFilters: tableFilters,
|
||||
tableActions: tableActions,
|
||||
modelType: ModelType.transferorder,
|
||||
enableSelection: true,
|
||||
enableDownload: true,
|
||||
enableReports: true,
|
||||
enableLabels: true
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { expect, test } from '../baseFixtures.js';
|
||||
import { stevenuser } from '../defaults.js';
|
||||
import {
|
||||
activateCalendarView,
|
||||
clearTableFilters,
|
||||
clickButtonIfVisible,
|
||||
clickOnRowMenu,
|
||||
loadTab,
|
||||
navigate,
|
||||
openFilterDrawer,
|
||||
@@ -548,3 +550,209 @@ test('Stock - Location', async ({ browser }) => {
|
||||
await page.getByRole('button', { name: 'Scan', exact: true }).click();
|
||||
await page.getByText('No match found for barcode data').waitFor();
|
||||
});
|
||||
|
||||
test('Transfer Orders - General', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await page.getByRole('tab', { name: 'Stock' }).click();
|
||||
await page.waitForURL('**/stock/location/index/**');
|
||||
|
||||
await loadTab(page, 'Transfer Orders');
|
||||
|
||||
await clearTableFilters(page);
|
||||
|
||||
// We have now loaded the "Transfer Orders" table. Check for some expected texts
|
||||
await page.getByText('Complete').first().waitFor();
|
||||
await page.getByText('Issued').first().waitFor();
|
||||
await page.getByText('Cancelled').first().waitFor();
|
||||
|
||||
// Load a particular Transfer Order
|
||||
await page.getByRole('cell', { name: 'TO-0002' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// This transfer order should be "issued"
|
||||
await page.getByText('Issued').first().waitFor();
|
||||
|
||||
// Edit the transfer order (via keyboard shortcut)
|
||||
await page.keyboard.press('Control+E');
|
||||
await page.getByLabel('text-field-reference', { exact: true }).waitFor();
|
||||
await page.getByLabel('related-field-project_code').waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete Order' }).click();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Check for other expected actions
|
||||
await page.getByRole('button', { name: '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-hold').waitFor();
|
||||
|
||||
// Click on some tabs
|
||||
await loadTab(page, 'Line Items');
|
||||
await loadTab(page, 'Allocated Stock');
|
||||
await loadTab(page, 'Parameters');
|
||||
await loadTab(page, 'Attachments');
|
||||
await loadTab(page, 'Notes');
|
||||
});
|
||||
|
||||
test('Transfer Order - Reference', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
// go to transfer orders
|
||||
await page.getByRole('tab', { name: 'Stock' }).click();
|
||||
await page.waitForURL('**/stock/location/index/**');
|
||||
await loadTab(page, 'Transfer Orders');
|
||||
|
||||
// click add button
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-transfer-' })
|
||||
.click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await expect(
|
||||
page.getByLabel('text-field-reference', { exact: true })
|
||||
).not.toBeEmpty();
|
||||
// Grab the Transfer Order reference
|
||||
const reference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(reference).toMatch(/TO-\d+/);
|
||||
|
||||
await page.getByRole('textbox', { name: 'text-field-description' }).click();
|
||||
await page
|
||||
.getByRole('textbox', { name: 'text-field-description' })
|
||||
.fill('creating from playwrigh!');
|
||||
|
||||
// create the transfer order
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// go back to stock page
|
||||
await page.getByRole('link', { name: 'Stock', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-transfer-' })
|
||||
.click();
|
||||
|
||||
const nextReference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(nextReference).toMatch(/TO-\d+/);
|
||||
|
||||
// Ensure that the reference has incremented
|
||||
const refNumber = Number(reference.replace('TO-', ''));
|
||||
const nextRefNumber = Number(nextReference.replace('TO-', ''));
|
||||
expect(nextRefNumber).toBe(refNumber + 1);
|
||||
});
|
||||
|
||||
test('Transfer Order - Calendar', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/location/index/transfer-orders');
|
||||
await activateCalendarView(page);
|
||||
|
||||
// Export calendar data
|
||||
await page.getByLabel('calendar-export-data').click();
|
||||
await page.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
await page.getByText('Process completed successfully').waitFor();
|
||||
|
||||
// Required because we downloaded a file
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('Transfer Order - Edit', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/transfer-order/2/');
|
||||
|
||||
// Check for expected text items
|
||||
await page.getByText('Consume some paint').first().waitFor();
|
||||
await page.getByText('2026-04-20').waitFor(); // Created date
|
||||
await page.getByText('2026-04-23').waitFor(); // Issue date
|
||||
await page.getByText('PRJ-HEL').waitFor(); // Project Code
|
||||
|
||||
await page.keyboard.press('Control+E');
|
||||
|
||||
// Edit start date
|
||||
await page.getByLabel('date-field-start_date').fill('2026-04-28');
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Expect error
|
||||
await page.getByText('Errors exist for one or more form fields').waitFor();
|
||||
await page.getByText('Target date must be after start date').waitFor();
|
||||
|
||||
// Cancel the form
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('Transfer Order - Allocate and Transfer', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/transfer-order/6/');
|
||||
|
||||
// Duplicate this transfer order, to ensure a fresh run each time
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// Issue the order
|
||||
await page.getByRole('button', { name: 'Issue Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Order issued').waitFor();
|
||||
|
||||
await loadTab(page, 'Line Items');
|
||||
|
||||
// Allocate line item 1
|
||||
const cell1 = await page.getByText('C_100pF_0402', { exact: true });
|
||||
await clickOnRowMenu(cell1);
|
||||
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
|
||||
await page.getByText('C_100pF_0402Location:Offsite').waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Allocate line item 1
|
||||
const cell2 = await page.getByText('R_2.2K_0603_1%', { exact: true });
|
||||
await clickOnRowMenu(cell2);
|
||||
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
|
||||
await page.getByText('R_2.2K_0603_1%Location:').waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Complete the order
|
||||
await page.getByRole('button', { name: 'Complete Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Complete', { exact: true }).first().waitFor();
|
||||
|
||||
// Tab should have changed to Transferred Stock
|
||||
await loadTab(page, 'Transferred Stock');
|
||||
await page.getByText('C_100pF_0402').waitFor();
|
||||
await page.getByText('2.2K resistor in 0603 SMD').waitFor();
|
||||
});
|
||||
|
||||
test('Transfer Orders - Duplicate', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'stock/transfer-order/1/detail'
|
||||
});
|
||||
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await expect(
|
||||
page.getByLabel('text-field-reference', { exact: true })
|
||||
).not.toBeEmpty();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).waitFor();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).click();
|
||||
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user