2
0
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:
Oliver 2024-10-29 11:36:32 +11:00 committed by GitHub
parent 178f939e42
commit 40f456fbc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 672 additions and 102 deletions

View File

@ -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__'

View File

@ -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,

View File

@ -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">

View File

@ -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
};
})
},

View File

@ -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,

View File

@ -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',

View File

@ -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}

View File

@ -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
});

View File

@ -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
}}
/>
</>

View File

@ -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();
}
}
}}
/>
</>

View File

@ -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();
});