mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +00:00
[PUI] Build allocation sub tables (#8380)
* Add helpers methods for table row expansion * Render a simplified "line item sub table" - Akin to CUI implementation - But like, better... * Edit / delete individual stock allocations * Improvements for BuildLineTable and BuildOutputTable * Improvements for table fields * Refactoring * Refactor BuildLineTable - Calculate and cache filtered allocation values * Code cleanup * Further fixes and features * Revert new serializer field - Turns out not to be needed * Add playwright tests * Bug fix for CUI tables - Ensure allocations are correctly filtered by output ID * Adjust CUI table
This commit is contained in:
parent
178f939e42
commit
40f456fbc9
@ -7,6 +7,7 @@ from django.db import models, transaction
|
||||
from django.db.models import (
|
||||
BooleanField,
|
||||
Case,
|
||||
Count,
|
||||
ExpressionWrapper,
|
||||
F,
|
||||
FloatField,
|
||||
@ -1362,6 +1363,8 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
part_detail = part_serializers.PartBriefSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
|
||||
|
||||
# Annotated (calculated) fields
|
||||
|
||||
# Total quantity of allocated stock
|
||||
allocated = serializers.FloatField(
|
||||
label=_('Allocated Stock'),
|
||||
read_only=True
|
||||
@ -1476,7 +1479,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
allocated=Coalesce(
|
||||
Sum('allocations__quantity'), 0,
|
||||
output_field=models.DecimalField()
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
ref = 'bom_item__sub_part__'
|
||||
|
@ -2505,6 +2505,7 @@ function renderBuildLineAllocationTable(element, build_line, options={}) {
|
||||
url: '{% url "api-build-item-list" %}',
|
||||
queryParams: {
|
||||
build_line: build_line.pk,
|
||||
output: options.output ?? undefined,
|
||||
},
|
||||
showHeader: false,
|
||||
columns: [
|
||||
@ -2609,9 +2610,10 @@ function renderBuildLineAllocationTable(element, build_line, options={}) {
|
||||
*/
|
||||
function loadBuildLineTable(table, build_id, options={}) {
|
||||
|
||||
const params = options.params || {};
|
||||
const output = options.output;
|
||||
|
||||
let name = 'build-lines';
|
||||
let params = options.params || {};
|
||||
let output = options.output;
|
||||
|
||||
params.build = build_id;
|
||||
|
||||
@ -2647,6 +2649,7 @@ function loadBuildLineTable(table, build_id, options={}) {
|
||||
detailFormatter: function(_index, row, element) {
|
||||
renderBuildLineAllocationTable(element, row, {
|
||||
parent_table: table,
|
||||
output: output,
|
||||
});
|
||||
},
|
||||
formatNoMatches: function() {
|
||||
@ -2730,6 +2733,15 @@ function loadBuildLineTable(table, build_id, options={}) {
|
||||
return yesNoLabel(row.bom_item_detail.inherited);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'trackable',
|
||||
title: '{% trans "Trackable" %}',
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
formatter: function(value, row) {
|
||||
return yesNoLabel(row.part_detail.trackable);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'unit_quantity',
|
||||
sortable: true,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Alert, Container, Group, Table } from '@mantine/core';
|
||||
import { Alert, Container, Group, Stack, Table, Text } from '@mantine/core';
|
||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { ReactNode, useCallback, useEffect, useMemo } from 'react';
|
||||
import { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
|
||||
import { identifierString } from '../../../functions/conversion';
|
||||
@ -18,6 +18,69 @@ export interface TableFieldRowProps {
|
||||
removeFn: (idx: number) => void;
|
||||
}
|
||||
|
||||
function TableFieldRow({
|
||||
item,
|
||||
idx,
|
||||
errors,
|
||||
definition,
|
||||
control,
|
||||
changeFn,
|
||||
removeFn
|
||||
}: {
|
||||
item: any;
|
||||
idx: number;
|
||||
errors: any;
|
||||
definition: ApiFormFieldType;
|
||||
control: UseControllerReturn<FieldValues, any>;
|
||||
changeFn: (idx: number, key: string, value: any) => void;
|
||||
removeFn: (idx: number) => void;
|
||||
}) {
|
||||
// Table fields require render function
|
||||
if (!definition.modelRenderer) {
|
||||
return (
|
||||
<Table.Tr key="table-row-no-renderer">
|
||||
<Table.Td colSpan={definition.headers?.length}>
|
||||
<Alert color="red" title={t`Error`} icon={<IconExclamationCircle />}>
|
||||
{`modelRenderer entry required for tables`}
|
||||
</Alert>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
return definition.modelRenderer({
|
||||
item: item,
|
||||
idx: idx,
|
||||
rowErrors: errors,
|
||||
control: control,
|
||||
changeFn: changeFn,
|
||||
removeFn: removeFn
|
||||
});
|
||||
}
|
||||
|
||||
export function TableFieldErrorWrapper({
|
||||
props,
|
||||
errorKey,
|
||||
children
|
||||
}: {
|
||||
props: TableFieldRowProps;
|
||||
errorKey: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const msg = props?.rowErrors && props.rowErrors[errorKey];
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{children}
|
||||
{msg && (
|
||||
<Text size="xs" c="red">
|
||||
{msg.message}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableField({
|
||||
definition,
|
||||
fieldName,
|
||||
@ -47,7 +110,7 @@ export function TableField({
|
||||
};
|
||||
|
||||
// Extract errors associated with the current row
|
||||
const rowErrors = useCallback(
|
||||
const rowErrors: any = useCallback(
|
||||
(idx: number) => {
|
||||
if (Array.isArray(error)) {
|
||||
return error[idx];
|
||||
@ -74,31 +137,18 @@ export function TableField({
|
||||
<Table.Tbody>
|
||||
{value.length > 0 ? (
|
||||
value.map((item: any, idx: number) => {
|
||||
// Table fields require render function
|
||||
if (!definition.modelRenderer) {
|
||||
return (
|
||||
<Table.Tr key="table-row-no-renderer">
|
||||
<Table.Td colSpan={definition.headers?.length}>
|
||||
<Alert
|
||||
color="red"
|
||||
title={t`Error`}
|
||||
icon={<IconExclamationCircle />}
|
||||
>
|
||||
{`modelRenderer entry required for tables`}
|
||||
</Alert>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
return definition.modelRenderer({
|
||||
item: item,
|
||||
idx: idx,
|
||||
rowErrors: rowErrors(idx),
|
||||
control: control,
|
||||
changeFn: onRowFieldChange,
|
||||
removeFn: removeRow
|
||||
});
|
||||
return (
|
||||
<TableFieldRow
|
||||
key={`table-row-${idx}`}
|
||||
item={item}
|
||||
idx={idx}
|
||||
errors={rowErrors(idx)}
|
||||
control={control}
|
||||
definition={definition}
|
||||
changeFn={onRowFieldChange}
|
||||
removeFn={removeRow}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Table.Tr key="table-row-no-entries">
|
||||
|
@ -17,7 +17,10 @@ import {
|
||||
ApiFormFieldSet,
|
||||
ApiFormFieldType
|
||||
} from '../components/forms/fields/ApiFormField';
|
||||
import { TableFieldRowProps } from '../components/forms/fields/TableField';
|
||||
import {
|
||||
TableFieldErrorWrapper,
|
||||
TableFieldRowProps
|
||||
} from '../components/forms/fields/TableField';
|
||||
import { ProgressBar } from '../components/items/ProgressBar';
|
||||
import { StatusRenderer } from '../components/render/StatusRenderer';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
@ -210,7 +213,11 @@ function BuildOutputFormRow({
|
||||
<Table.Td>
|
||||
<PartColumn part={record.part_detail} />
|
||||
</Table.Td>
|
||||
<Table.Td>{serial}</Table.Td>
|
||||
<Table.Td>
|
||||
<TableFieldErrorWrapper props={props} errorKey="output">
|
||||
{serial}
|
||||
</TableFieldErrorWrapper>
|
||||
</Table.Td>
|
||||
<Table.Td>{record.batch}</Table.Td>
|
||||
<Table.Td>
|
||||
<StatusRenderer status={record.status} type={ModelType.stockitem} />{' '}
|
||||
@ -259,7 +266,7 @@ export function useCompleteBuildOutputsForm({
|
||||
<BuildOutputFormRow props={row} record={record} key={record.pk} />
|
||||
);
|
||||
},
|
||||
headers: [t`Part`, t`Stock Item`, t`Batch`, t`Status`]
|
||||
headers: [t`Part`, t`Build Output`, t`Batch`, t`Status`]
|
||||
},
|
||||
status_custom_key: {},
|
||||
location: {
|
||||
@ -454,8 +461,8 @@ function BuildAllocateLineRow({
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ProgressBar
|
||||
value={record.allocated}
|
||||
maximum={record.quantity}
|
||||
value={record.allocatedQuantity}
|
||||
maximum={record.requiredQuantity}
|
||||
progressLabel
|
||||
/>
|
||||
</Table.Td>
|
||||
@ -512,6 +519,7 @@ export function useAllocateStockToBuildForm({
|
||||
lineItems.find((item) => item.pk == row.item.build_line) ?? {};
|
||||
return (
|
||||
<BuildAllocateLineRow
|
||||
key={row.idx}
|
||||
props={row}
|
||||
record={record}
|
||||
sourceLocation={sourceLocation}
|
||||
@ -565,8 +573,8 @@ export function useAllocateStockToBuildForm({
|
||||
return {
|
||||
build_line: item.pk,
|
||||
stock_item: undefined,
|
||||
quantity: Math.max(0, item.quantity - item.allocated),
|
||||
output: null
|
||||
quantity: Math.max(0, item.requiredQuantity - item.allocatedQuantity),
|
||||
output: outputId
|
||||
};
|
||||
})
|
||||
},
|
||||
|
@ -23,6 +23,7 @@ export type TableState = {
|
||||
clearActiveFilters: () => void;
|
||||
expandedRecords: any[];
|
||||
setExpandedRecords: (records: any[]) => void;
|
||||
isRowExpanded: (pk: number) => boolean;
|
||||
selectedRecords: any[];
|
||||
selectedIds: number[];
|
||||
hasSelectedRecords: boolean;
|
||||
@ -79,6 +80,14 @@ export function useTable(tableName: string): TableState {
|
||||
// Array of expanded records
|
||||
const [expandedRecords, setExpandedRecords] = useState<any[]>([]);
|
||||
|
||||
// Function to determine if a record is expanded
|
||||
const isRowExpanded = useCallback(
|
||||
(pk: number) => {
|
||||
return expandedRecords.includes(pk);
|
||||
},
|
||||
[expandedRecords]
|
||||
);
|
||||
|
||||
// Array of selected records
|
||||
const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
|
||||
|
||||
@ -148,6 +157,7 @@ export function useTable(tableName: string): TableState {
|
||||
clearActiveFilters,
|
||||
expandedRecords,
|
||||
setExpandedRecords,
|
||||
isRowExpanded,
|
||||
selectedRecords,
|
||||
selectedIds,
|
||||
setSelectedRecords,
|
||||
|
@ -251,11 +251,7 @@ export default function BuildDetail() {
|
||||
name: 'line-items',
|
||||
label: t`Line Items`,
|
||||
icon: <IconListNumbers />,
|
||||
content: build?.pk ? (
|
||||
<BuildLineTable build={build} buildId={build.pk} />
|
||||
) : (
|
||||
<Skeleton />
|
||||
)
|
||||
content: build?.pk ? <BuildLineTable build={build} /> : <Skeleton />
|
||||
},
|
||||
{
|
||||
name: 'incomplete-outputs',
|
||||
|
@ -20,6 +20,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
DataTable,
|
||||
DataTableCellClickHandler,
|
||||
DataTableRowExpansionProps,
|
||||
DataTableSortStatus
|
||||
} from 'mantine-datatable';
|
||||
import React, {
|
||||
@ -103,7 +104,7 @@ export type InvenTreeTableProps<T = any> = {
|
||||
barcodeActions?: React.ReactNode[];
|
||||
tableFilters?: TableFilter[];
|
||||
tableActions?: React.ReactNode[];
|
||||
rowExpansion?: any;
|
||||
rowExpansion?: DataTableRowExpansionProps<T>;
|
||||
idAccessor?: string;
|
||||
dataFormatter?: (data: any) => any;
|
||||
rowActions?: (record: T) => RowAction[];
|
||||
@ -633,6 +634,33 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
tableState.refreshTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoize row expansion options:
|
||||
* - If rowExpansion is not provided, return undefined
|
||||
* - Otherwise, return the rowExpansion object
|
||||
* - Utilize the useTable hook to track expanded rows
|
||||
*/
|
||||
const rowExpansion: DataTableRowExpansionProps<T> | undefined =
|
||||
useMemo(() => {
|
||||
if (!props.rowExpansion) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...props.rowExpansion,
|
||||
expanded: {
|
||||
recordIds: tableState.expandedRecords,
|
||||
onRecordIdsChange: (ids: any[]) => {
|
||||
tableState.setExpandedRecords(ids);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [
|
||||
tableState.expandedRecords,
|
||||
tableState.setExpandedRecords,
|
||||
props.rowExpansion
|
||||
]);
|
||||
|
||||
const optionalParams = useMemo(() => {
|
||||
let optionalParamsa: Record<string, any> = {};
|
||||
if (tableProps.enablePagination) {
|
||||
@ -779,7 +807,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
onSelectedRecordsChange={
|
||||
enableSelection ? onSelectedRecordsChange : undefined
|
||||
}
|
||||
rowExpansion={tableProps.rowExpansion}
|
||||
rowExpansion={rowExpansion}
|
||||
rowStyle={tableProps.rowStyle}
|
||||
fetching={isFetching}
|
||||
noRecordsText={missingRecordsText}
|
||||
|
@ -161,8 +161,11 @@ export default function BuildAllocatedStockTable({
|
||||
const editItem = useEditApiFormModal({
|
||||
pk: selectedItem,
|
||||
url: ApiEndpoints.build_item_list,
|
||||
title: t`Edit Build Item`,
|
||||
title: t`Edit Stock Allocation`,
|
||||
fields: {
|
||||
stock_item: {
|
||||
disabled: true
|
||||
},
|
||||
quantity: {}
|
||||
},
|
||||
table: table
|
||||
@ -171,7 +174,7 @@ export default function BuildAllocatedStockTable({
|
||||
const deleteItem = useDeleteApiFormModal({
|
||||
pk: selectedItem,
|
||||
url: ApiEndpoints.build_item_list,
|
||||
title: t`Delete Build Item`,
|
||||
title: t`Delete Stock Allocation`,
|
||||
table: table
|
||||
});
|
||||
|
||||
|
@ -1,12 +1,15 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Alert, Group, Text } from '@mantine/core';
|
||||
import { ActionIcon, Alert, Group, Paper, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconCircleMinus,
|
||||
IconShoppingCart,
|
||||
IconTool,
|
||||
IconWand
|
||||
} from '@tabler/icons-react';
|
||||
import { DataTable, DataTableRowExpansionProps } from 'mantine-datatable';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
@ -22,34 +25,140 @@ import {
|
||||
import { navigateToLink } from '../../functions/navigation';
|
||||
import { notYetImplemented } from '../../functions/notifications';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { TableColumn } from '../Column';
|
||||
import { BooleanColumn, PartColumn } from '../ColumnRenderers';
|
||||
import { BooleanColumn, LocationColumn, PartColumn } from '../ColumnRenderers';
|
||||
import { TableFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { RowAction } from '../RowActions';
|
||||
import {
|
||||
RowAction,
|
||||
RowActions,
|
||||
RowDeleteAction,
|
||||
RowEditAction
|
||||
} from '../RowActions';
|
||||
import { TableHoverCard } from '../TableHoverCard';
|
||||
|
||||
/**
|
||||
* Render a sub-table of allocated stock against a particular build line.
|
||||
*
|
||||
* - Renders a simplified table of stock allocated against the build line
|
||||
* - Provides "edit" and "delete" actions for each allocation
|
||||
*
|
||||
* Note: We expect that the "lineItem" object contains an allocations[] list
|
||||
*/
|
||||
function BuildLineSubTable({
|
||||
lineItem,
|
||||
onEditAllocation,
|
||||
onDeleteAllocation
|
||||
}: {
|
||||
lineItem: any;
|
||||
onEditAllocation: (pk: number) => void;
|
||||
onDeleteAllocation: (pk: number) => void;
|
||||
}) {
|
||||
const user = useUserState();
|
||||
|
||||
const tableColumns: any[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'part',
|
||||
title: t`Part`,
|
||||
render: (record: any) => {
|
||||
return <PartColumn part={record.part_detail} />;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'quantity',
|
||||
title: t`Quantity`,
|
||||
render: (record: any) => {
|
||||
if (!!record.stock_item_detail?.serial) {
|
||||
return `# ${record.stock_item_detail.serial}`;
|
||||
}
|
||||
return record.quantity;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'stock_item_detail.batch',
|
||||
title: t`Batch`
|
||||
},
|
||||
LocationColumn({
|
||||
accessor: 'location_detail'
|
||||
}),
|
||||
{
|
||||
accessor: '---actions---',
|
||||
title: ' ',
|
||||
width: 50,
|
||||
render: (record: any) => {
|
||||
return (
|
||||
<RowActions
|
||||
title={t`Actions`}
|
||||
index={record.pk}
|
||||
actions={[
|
||||
RowEditAction({
|
||||
hidden: !user.hasChangeRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
onEditAllocation(record.pk);
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden: !user.hasDeleteRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
onDeleteAllocation(record.pk);
|
||||
}
|
||||
})
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}, [user, onEditAllocation, onDeleteAllocation]);
|
||||
|
||||
return (
|
||||
<Paper p="md">
|
||||
<Stack gap="xs">
|
||||
<DataTable
|
||||
minHeight={50}
|
||||
withTableBorder
|
||||
withColumnBorders
|
||||
striped
|
||||
pinLastColumn
|
||||
idAccessor="pk"
|
||||
columns={tableColumns}
|
||||
records={lineItem.filteredAllocations}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a table of build lines for a particular build.
|
||||
*/
|
||||
export default function BuildLineTable({
|
||||
buildId,
|
||||
build,
|
||||
outputId,
|
||||
output,
|
||||
params = {}
|
||||
}: Readonly<{
|
||||
buildId: number;
|
||||
build: any;
|
||||
outputId?: number;
|
||||
output?: any;
|
||||
params?: any;
|
||||
}>) {
|
||||
const table = useTable('buildline');
|
||||
const user = useUserState();
|
||||
const navigate = useNavigate();
|
||||
const buildStatus = useStatusCodes({ modelType: ModelType.build });
|
||||
|
||||
const hasOutput: boolean = useMemo(() => !!output?.pk, [output]);
|
||||
|
||||
const table = useTable(hasOutput ? 'buildline-output' : 'buildline');
|
||||
|
||||
const isActive: boolean = useMemo(() => {
|
||||
return (
|
||||
build?.status == buildStatus.PRODUCTION ||
|
||||
@ -184,10 +293,30 @@ export default function BuildLineTable({
|
||||
return [
|
||||
{
|
||||
accessor: 'bom_item',
|
||||
title: t`Component`,
|
||||
ordering: 'part',
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
render: (record: any) => PartColumn({ part: record.part_detail })
|
||||
render: (record: any) => {
|
||||
const hasAllocatedItems = record.allocatedQuantity > 0;
|
||||
|
||||
return (
|
||||
<Group wrap="nowrap">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
disabled={!hasAllocatedItems}
|
||||
>
|
||||
{table.isRowExpanded(record.pk) ? (
|
||||
<IconChevronDown />
|
||||
) : (
|
||||
<IconChevronRight />
|
||||
)}
|
||||
</ActionIcon>
|
||||
<PartColumn part={record.part_detail} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'part_detail.IPN',
|
||||
@ -207,24 +336,29 @@ export default function BuildLineTable({
|
||||
},
|
||||
BooleanColumn({
|
||||
accessor: 'bom_item_detail.optional',
|
||||
ordering: 'optional'
|
||||
ordering: 'optional',
|
||||
hidden: hasOutput
|
||||
}),
|
||||
BooleanColumn({
|
||||
accessor: 'bom_item_detail.consumable',
|
||||
ordering: 'consumable'
|
||||
ordering: 'consumable',
|
||||
hidden: hasOutput
|
||||
}),
|
||||
BooleanColumn({
|
||||
accessor: 'bom_item_detail.allow_variants',
|
||||
ordering: 'allow_variants'
|
||||
ordering: 'allow_variants',
|
||||
hidden: hasOutput
|
||||
}),
|
||||
BooleanColumn({
|
||||
accessor: 'bom_item_detail.inherited',
|
||||
ordering: 'inherited',
|
||||
title: t`Gets Inherited`
|
||||
title: t`Gets Inherited`,
|
||||
hidden: hasOutput
|
||||
}),
|
||||
BooleanColumn({
|
||||
accessor: 'part_detail.trackable',
|
||||
ordering: 'trackable'
|
||||
ordering: 'trackable',
|
||||
hidden: hasOutput
|
||||
}),
|
||||
{
|
||||
accessor: 'bom_item_detail.quantity',
|
||||
@ -244,12 +378,14 @@ export default function BuildLineTable({
|
||||
},
|
||||
{
|
||||
accessor: 'quantity',
|
||||
title: t`Required Quantity`,
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
render: (record: any) => {
|
||||
// If a build output is specified, use the provided quantity
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text>{record.quantity}</Text>
|
||||
<Text>{record.requiredQuantity}</Text>
|
||||
{record?.part_detail?.units && (
|
||||
<Text size="xs">[{record.part_detail.units}]</Text>
|
||||
)}
|
||||
@ -266,6 +402,7 @@ export default function BuildLineTable({
|
||||
{
|
||||
accessor: 'allocated',
|
||||
switchable: false,
|
||||
sortable: true,
|
||||
hidden: !isActive,
|
||||
render: (record: any) => {
|
||||
return record?.bom_item_detail?.consumable ? (
|
||||
@ -273,14 +410,14 @@ export default function BuildLineTable({
|
||||
) : (
|
||||
<ProgressBar
|
||||
progressLabel={true}
|
||||
value={record.allocated}
|
||||
maximum={record.quantity}
|
||||
value={record.allocatedQuantity}
|
||||
maximum={record.requiredQuantity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}, [isActive]);
|
||||
}, [hasOutput, isActive, table, output]);
|
||||
|
||||
const buildOrderFields = useBuildOrderFields({ create: true });
|
||||
|
||||
@ -331,7 +468,7 @@ export default function BuildLineTable({
|
||||
|
||||
const allocateStock = useAllocateStockToBuildForm({
|
||||
build: build,
|
||||
outputId: null,
|
||||
outputId: output?.pk ?? null,
|
||||
buildId: build.pk,
|
||||
lineItems: selectedRows,
|
||||
onFormSuccess: () => {
|
||||
@ -348,12 +485,12 @@ export default function BuildLineTable({
|
||||
hidden: true
|
||||
},
|
||||
output: {
|
||||
hidden: true,
|
||||
value: null
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
initialData: {
|
||||
build_line: selectedLine
|
||||
build_line: selectedLine,
|
||||
output: output?.pk ?? null
|
||||
},
|
||||
preFormContent: (
|
||||
<Alert color="red" title={t`Deallocate Stock`}>
|
||||
@ -368,13 +505,35 @@ export default function BuildLineTable({
|
||||
table: table
|
||||
});
|
||||
|
||||
const [selectedAllocation, setSelectedAllocation] = useState<number>(0);
|
||||
|
||||
const editAllocation = useEditApiFormModal({
|
||||
url: ApiEndpoints.build_item_list,
|
||||
pk: selectedAllocation,
|
||||
title: t`Edit Stock Allocation`,
|
||||
fields: {
|
||||
stock_item: {
|
||||
disabled: true
|
||||
},
|
||||
quantity: {}
|
||||
},
|
||||
table: table
|
||||
});
|
||||
|
||||
const deleteAllocation = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.build_item_list,
|
||||
pk: selectedAllocation,
|
||||
title: t`Delete Stock Allocation`,
|
||||
table: table
|
||||
});
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
let part = record.part_detail ?? {};
|
||||
const in_production = build.status == buildStatus.PRODUCTION;
|
||||
const consumable = record.bom_item_detail?.consumable ?? false;
|
||||
|
||||
const hasOutput = !!outputId;
|
||||
const hasOutput = !!output?.pk;
|
||||
|
||||
// Can allocate
|
||||
let canAllocate =
|
||||
@ -440,7 +599,7 @@ export default function BuildLineTable({
|
||||
onClick: () => {
|
||||
setInitialData({
|
||||
part: record.part,
|
||||
parent: buildId,
|
||||
parent: build.pk,
|
||||
quantity: record.quantity - record.allocated
|
||||
});
|
||||
newBuildOrder.open();
|
||||
@ -459,7 +618,7 @@ export default function BuildLineTable({
|
||||
}
|
||||
];
|
||||
},
|
||||
[user, outputId, build, buildStatus]
|
||||
[user, output, build, buildStatus]
|
||||
);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
@ -471,7 +630,7 @@ export default function BuildLineTable({
|
||||
key="auto-allocate"
|
||||
icon={<IconWand />}
|
||||
tooltip={t`Auto Allocate Stock`}
|
||||
hidden={!visible}
|
||||
hidden={!visible || hasOutput}
|
||||
color="blue"
|
||||
onClick={() => {
|
||||
autoAllocateStock.open();
|
||||
@ -485,14 +644,17 @@ export default function BuildLineTable({
|
||||
disabled={!table.hasSelectedRecords}
|
||||
color="green"
|
||||
onClick={() => {
|
||||
setSelectedRows(
|
||||
table.selectedRecords.filter(
|
||||
(r) =>
|
||||
r.allocated < r.quantity &&
|
||||
!r.trackable &&
|
||||
!r.bom_item_detail.consumable
|
||||
)
|
||||
);
|
||||
let rows = table.selectedRecords
|
||||
.filter((r) => r.allocatedQuantity < r.requiredQuantity)
|
||||
.filter((r) => !r.bom_item_detail?.consumable);
|
||||
|
||||
if (hasOutput) {
|
||||
rows = rows.filter((r) => r.trackable);
|
||||
} else {
|
||||
rows = rows.filter((r) => !r.trackable);
|
||||
}
|
||||
|
||||
setSelectedRows(rows);
|
||||
allocateStock.open();
|
||||
}}
|
||||
/>,
|
||||
@ -500,7 +662,7 @@ export default function BuildLineTable({
|
||||
key="deallocate-stock"
|
||||
icon={<IconCircleMinus />}
|
||||
tooltip={t`Deallocate Stock`}
|
||||
hidden={!visible}
|
||||
hidden={!visible || hasOutput}
|
||||
disabled={table.hasSelectedRecords}
|
||||
color="red"
|
||||
onClick={() => {
|
||||
@ -513,16 +675,85 @@ export default function BuildLineTable({
|
||||
user,
|
||||
build,
|
||||
buildStatus,
|
||||
hasOutput,
|
||||
table.hasSelectedRecords,
|
||||
table.selectedRecords
|
||||
]);
|
||||
|
||||
/**
|
||||
* Format the records for the table, before rendering
|
||||
*
|
||||
* - Filter the "allocations" field to only show allocations for the selected output
|
||||
* - Pre-calculate the "requiredQuantity" and "allocatedQuantity" fields
|
||||
*/
|
||||
const formatRecords = useCallback(
|
||||
(records: any[]): any[] => {
|
||||
return records.map((record) => {
|
||||
let allocations = [...record.allocations];
|
||||
|
||||
// If an output is specified, filter the allocations to only show those for the selected output
|
||||
if (output?.pk) {
|
||||
allocations = allocations.filter((a) => a.install_into == output.pk);
|
||||
}
|
||||
|
||||
let allocatedQuantity = 0;
|
||||
let requiredQuantity = record.quantity;
|
||||
|
||||
// Calculate the total allocated quantity
|
||||
allocations.forEach((a) => {
|
||||
allocatedQuantity += a.quantity;
|
||||
});
|
||||
|
||||
// Calculate the required quantity (based on the build output)
|
||||
if (output?.quantity && record.bom_item_detail) {
|
||||
requiredQuantity = output.quantity * record.bom_item_detail.quantity;
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
filteredAllocations: allocations,
|
||||
requiredQuantity: requiredQuantity,
|
||||
allocatedQuantity: allocatedQuantity
|
||||
};
|
||||
});
|
||||
},
|
||||
[output]
|
||||
);
|
||||
|
||||
// Control row expansion
|
||||
const rowExpansion: DataTableRowExpansionProps<any> = useMemo(() => {
|
||||
return {
|
||||
allowMultiple: true,
|
||||
expandable: ({ record }: { record: any }) => {
|
||||
// Only items with allocated stock can be expanded
|
||||
return table.isRowExpanded(record.pk) || record.allocatedQuantity > 0;
|
||||
},
|
||||
content: ({ record }: { record: any }) => {
|
||||
return (
|
||||
<BuildLineSubTable
|
||||
lineItem={record}
|
||||
onEditAllocation={(pk: number) => {
|
||||
setSelectedAllocation(pk);
|
||||
editAllocation.open();
|
||||
}}
|
||||
onDeleteAllocation={(pk: number) => {
|
||||
setSelectedAllocation(pk);
|
||||
deleteAllocation.open();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [table.isRowExpanded, output]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{autoAllocateStock.modal}
|
||||
{newBuildOrder.modal}
|
||||
{allocateStock.modal}
|
||||
{deallocateStock.modal}
|
||||
{editAllocation.modal}
|
||||
{deleteAllocation.modal}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.build_line_list)}
|
||||
tableState={table}
|
||||
@ -530,14 +761,16 @@ export default function BuildLineTable({
|
||||
props={{
|
||||
params: {
|
||||
...params,
|
||||
build: buildId,
|
||||
build: build.pk,
|
||||
part_detail: true
|
||||
},
|
||||
tableActions: tableActions,
|
||||
tableFilters: tableFilters,
|
||||
rowActions: rowActions,
|
||||
dataFormatter: formatRecords,
|
||||
enableDownload: true,
|
||||
enableSelection: true
|
||||
enableSelection: true,
|
||||
rowExpansion: rowExpansion
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -1,6 +1,19 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Group, Text } from '@mantine/core';
|
||||
import { IconCircleCheck, IconCircleX } from '@tabler/icons-react';
|
||||
import {
|
||||
Alert,
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
Paper,
|
||||
Space,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconExclamationCircle
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@ -8,6 +21,7 @@ import { api } from '../../App';
|
||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { ProgressBar } from '../../components/items/ProgressBar';
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -19,7 +33,6 @@ import {
|
||||
} from '../../forms/BuildForms';
|
||||
import { useStockFields } from '../../forms/StockForms';
|
||||
import { InvenTreeIcon } from '../../functions/icons';
|
||||
import { notYetImplemented } from '../../functions/notifications';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useEditApiFormModal
|
||||
@ -32,12 +45,79 @@ import { LocationColumn, PartColumn, StatusColumn } from '../ColumnRenderers';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { RowAction, RowEditAction } from '../RowActions';
|
||||
import { TableHoverCard } from '../TableHoverCard';
|
||||
import BuildLineTable from './BuildLineTable';
|
||||
|
||||
type TestResultOverview = {
|
||||
name: string;
|
||||
result: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detail drawer view for allocating stock against a specific build output
|
||||
*/
|
||||
function OutputAllocationDrawer({
|
||||
build,
|
||||
output,
|
||||
opened,
|
||||
close
|
||||
}: {
|
||||
build: any;
|
||||
output: any;
|
||||
opened: boolean;
|
||||
close: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
position="bottom"
|
||||
size="lg"
|
||||
title={
|
||||
<Group p="md" wrap="nowrap" justify="space-apart">
|
||||
<StylishText size="lg">{t`Build Output Stock Allocation`}</StylishText>
|
||||
<Space h="lg" />
|
||||
<PartColumn part={build.part_detail} />
|
||||
{output?.serial && (
|
||||
<Text size="sm">
|
||||
{t`Serial Number`}: {output.serial}
|
||||
</Text>
|
||||
)}
|
||||
{output?.batch && (
|
||||
<Text size="sm">
|
||||
{t`Batch Code`}: {output.batch}
|
||||
</Text>
|
||||
)}
|
||||
<Space h="lg" />
|
||||
</Group>
|
||||
}
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
withCloseButton
|
||||
closeOnEscape
|
||||
closeOnClickOutside
|
||||
styles={{
|
||||
header: {
|
||||
width: '100%'
|
||||
},
|
||||
title: {
|
||||
width: '100%'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Divider />
|
||||
<Paper p="md">
|
||||
<BuildLineTable
|
||||
build={build}
|
||||
output={output}
|
||||
params={{
|
||||
tracked: true
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BuildOutputTable({
|
||||
build,
|
||||
refreshBuild
|
||||
@ -85,7 +165,7 @@ export default function BuildOutputTable({
|
||||
}, [partId, testTemplates]);
|
||||
|
||||
// Fetch the "tracked" BOM items associated with the partId
|
||||
const { data: trackedItems } = useQuery({
|
||||
const { data: trackedItems, refetch: refetchTrackedItems } = useQuery({
|
||||
queryKey: ['trackeditems', buildId],
|
||||
queryFn: async () => {
|
||||
if (!buildId || buildId < 0) {
|
||||
@ -111,7 +191,7 @@ export default function BuildOutputTable({
|
||||
// Ensure base table data is updated correctly
|
||||
useEffect(() => {
|
||||
table.refreshTable();
|
||||
}, [hasTrackedItems, hasRequiredTests]);
|
||||
}, [testTemplates, trackedItems, hasTrackedItems, hasRequiredTests]);
|
||||
|
||||
// Format table records
|
||||
const formatRecords = useCallback(
|
||||
@ -152,13 +232,11 @@ export default function BuildOutputTable({
|
||||
let allocated = 0;
|
||||
|
||||
// Find all allocations which match the build output
|
||||
let allocations = item.allocations.filter(
|
||||
(allocation: any) => allocation.install_into == record.pk
|
||||
);
|
||||
|
||||
allocations.forEach((allocation: any) => {
|
||||
allocated += allocation.quantity;
|
||||
});
|
||||
item.allocations
|
||||
?.filter((allocation: any) => allocation.install_into == record.pk)
|
||||
?.forEach((allocation: any) => {
|
||||
allocated += allocation.quantity;
|
||||
});
|
||||
|
||||
if (allocated >= item.bom_item_detail.quantity) {
|
||||
fullyAllocatedCount += 1;
|
||||
@ -230,6 +308,32 @@ export default function BuildOutputTable({
|
||||
table: table
|
||||
});
|
||||
|
||||
const deallocateBuildOutput = useCreateApiFormModal({
|
||||
url: ApiEndpoints.build_order_deallocate,
|
||||
pk: build.pk,
|
||||
title: t`Deallocate Stock`,
|
||||
preFormContent: (
|
||||
<Alert
|
||||
color="yellow"
|
||||
icon={<IconExclamationCircle />}
|
||||
title={t`Deallocate Stock`}
|
||||
>
|
||||
{t`This action will deallocate all stock from the selected build output`}
|
||||
</Alert>
|
||||
),
|
||||
fields: {
|
||||
output: {
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
initialData: {
|
||||
output: selectedOutputs[0]?.pk
|
||||
},
|
||||
onFormSuccess: () => {
|
||||
refetchTrackedItems();
|
||||
}
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<ActionButton
|
||||
@ -281,15 +385,23 @@ export default function BuildOutputTable({
|
||||
title: t`Allocate`,
|
||||
tooltip: t`Allocate stock to build output`,
|
||||
color: 'blue',
|
||||
hidden: !hasTrackedItems || !user.hasChangeRole(UserRoles.build),
|
||||
icon: <InvenTreeIcon icon="plus" />,
|
||||
onClick: notYetImplemented
|
||||
onClick: () => {
|
||||
setSelectedOutputs([record]);
|
||||
openDrawer();
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t`Deallocate`,
|
||||
tooltip: t`Deallocate stock from build output`,
|
||||
color: 'red',
|
||||
hidden: !hasTrackedItems || !user.hasChangeRole(UserRoles.build),
|
||||
icon: <InvenTreeIcon icon="minus" />,
|
||||
onClick: notYetImplemented
|
||||
onClick: () => {
|
||||
setSelectedOutputs([record]);
|
||||
deallocateBuildOutput.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t`Complete`,
|
||||
@ -330,7 +442,7 @@ export default function BuildOutputTable({
|
||||
}
|
||||
];
|
||||
},
|
||||
[user, partId]
|
||||
[user, partId, hasTrackedItems]
|
||||
);
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
@ -432,13 +544,23 @@ export default function BuildOutputTable({
|
||||
trackedItems
|
||||
]);
|
||||
|
||||
const [drawerOpen, { open: openDrawer, close: closeDrawer }] =
|
||||
useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{addBuildOutput.modal}
|
||||
{completeBuildOutputsForm.modal}
|
||||
{scrapBuildOutputsForm.modal}
|
||||
{editBuildOutput.modal}
|
||||
{deallocateBuildOutput.modal}
|
||||
{cancelBuildOutputsForm.modal}
|
||||
<OutputAllocationDrawer
|
||||
build={build}
|
||||
output={selectedOutputs[0]}
|
||||
opened={drawerOpen}
|
||||
close={closeDrawer}
|
||||
/>
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
url={apiUrl(ApiEndpoints.stock_item_list)}
|
||||
@ -452,11 +574,16 @@ export default function BuildOutputTable({
|
||||
},
|
||||
enableLabels: true,
|
||||
enableReports: true,
|
||||
modelType: ModelType.stockitem,
|
||||
dataFormatter: formatRecords,
|
||||
tableActions: tableActions,
|
||||
rowActions: rowActions,
|
||||
enableSelection: true
|
||||
enableSelection: true,
|
||||
onRowClick: (record: any) => {
|
||||
if (hasTrackedItems && !!record.serial) {
|
||||
setSelectedOutputs([record]);
|
||||
openDrawer();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -157,3 +157,103 @@ test('Pages - Build Order - Build Outputs', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Build outputs have been completed').waitFor();
|
||||
});
|
||||
|
||||
test('Pages - Build Order - Allocation', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/manufacturing/build-order/1/line-items`);
|
||||
|
||||
// Expand the R_10K_0805 line item
|
||||
await page.getByText('R_10K_0805_1%').first().click();
|
||||
await page.getByText('Reel Storage').waitFor();
|
||||
await page.getByText('R_10K_0805_1%').first().click();
|
||||
|
||||
// The capacitor stock should be fully allocated
|
||||
const cell = await page.getByRole('cell', { name: /C_1uF_0805/ });
|
||||
const row = await cell.locator('xpath=ancestor::tr').first();
|
||||
|
||||
await row.getByText(/150 \/ 150/).waitFor();
|
||||
|
||||
// Expand this row
|
||||
await cell.click();
|
||||
await page.getByRole('cell', { name: '2022-4-27', exact: true }).waitFor();
|
||||
await page.getByRole('cell', { name: 'Reel Storage', exact: true }).waitFor();
|
||||
|
||||
// Navigate to the "Incomplete Outputs" tab
|
||||
await page.getByRole('tab', { name: 'Incomplete Outputs' }).click();
|
||||
|
||||
// Find output #7
|
||||
const output7 = await page
|
||||
.getByRole('cell', { name: '# 7' })
|
||||
.locator('xpath=ancestor::tr')
|
||||
.first();
|
||||
|
||||
// Expecting 3/4 allocated outputs
|
||||
await output7.getByText('3 / 4').waitFor();
|
||||
|
||||
// Expecting 0/3 completed tests
|
||||
await output7.getByText('0 / 3').waitFor();
|
||||
|
||||
// Expand the output
|
||||
await output7.click();
|
||||
|
||||
await page.getByText('Build Output Stock Allocation').waitFor();
|
||||
await page.getByText('Serial Number: 7').waitFor();
|
||||
|
||||
// Data of expected rows
|
||||
const data = [
|
||||
{
|
||||
name: 'Red Widget',
|
||||
ipn: 'widget.red',
|
||||
available: '123',
|
||||
required: '3',
|
||||
allocated: '3'
|
||||
},
|
||||
{
|
||||
name: 'Blue Widget',
|
||||
ipn: 'widget.blue',
|
||||
available: '45',
|
||||
required: '5',
|
||||
allocated: '5'
|
||||
},
|
||||
{
|
||||
name: 'Pink Widget',
|
||||
ipn: 'widget.pink',
|
||||
available: '4',
|
||||
required: '4',
|
||||
allocated: '0'
|
||||
},
|
||||
{
|
||||
name: 'Green Widget',
|
||||
ipn: 'widget.green',
|
||||
available: '245',
|
||||
required: '6',
|
||||
allocated: '6'
|
||||
}
|
||||
];
|
||||
|
||||
// Check for expected rows
|
||||
for (let idx = 0; idx < data.length; idx++) {
|
||||
let item = data[idx];
|
||||
|
||||
let cell = await page.getByRole('cell', { name: item.name });
|
||||
let row = await cell.locator('xpath=ancestor::tr').first();
|
||||
let progress = `${item.allocated} / ${item.required}`;
|
||||
|
||||
await row.getByRole('cell', { name: item.ipn }).first().waitFor();
|
||||
await row.getByRole('cell', { name: item.available }).first().waitFor();
|
||||
await row.getByRole('cell', { name: progress }).first().waitFor();
|
||||
}
|
||||
|
||||
// Check for expected buttons on Red Widget
|
||||
let redWidget = await page.getByRole('cell', { name: 'Red Widget' });
|
||||
let redRow = await redWidget.locator('xpath=ancestor::tr').first();
|
||||
|
||||
await redRow.getByLabel(/row-action-menu-/i).click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'Allocate Stock', exact: true })
|
||||
.waitFor();
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'Deallocate Stock', exact: true })
|
||||
.waitFor();
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user