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 (
|
from django.db.models import (
|
||||||
BooleanField,
|
BooleanField,
|
||||||
Case,
|
Case,
|
||||||
|
Count,
|
||||||
ExpressionWrapper,
|
ExpressionWrapper,
|
||||||
F,
|
F,
|
||||||
FloatField,
|
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)
|
part_detail = part_serializers.PartBriefSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
|
||||||
|
|
||||||
# Annotated (calculated) fields
|
# Annotated (calculated) fields
|
||||||
|
|
||||||
|
# Total quantity of allocated stock
|
||||||
allocated = serializers.FloatField(
|
allocated = serializers.FloatField(
|
||||||
label=_('Allocated Stock'),
|
label=_('Allocated Stock'),
|
||||||
read_only=True
|
read_only=True
|
||||||
@ -1476,7 +1479,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
|||||||
allocated=Coalesce(
|
allocated=Coalesce(
|
||||||
Sum('allocations__quantity'), 0,
|
Sum('allocations__quantity'), 0,
|
||||||
output_field=models.DecimalField()
|
output_field=models.DecimalField()
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
ref = 'bom_item__sub_part__'
|
ref = 'bom_item__sub_part__'
|
||||||
|
@ -2505,6 +2505,7 @@ function renderBuildLineAllocationTable(element, build_line, options={}) {
|
|||||||
url: '{% url "api-build-item-list" %}',
|
url: '{% url "api-build-item-list" %}',
|
||||||
queryParams: {
|
queryParams: {
|
||||||
build_line: build_line.pk,
|
build_line: build_line.pk,
|
||||||
|
output: options.output ?? undefined,
|
||||||
},
|
},
|
||||||
showHeader: false,
|
showHeader: false,
|
||||||
columns: [
|
columns: [
|
||||||
@ -2609,9 +2610,10 @@ function renderBuildLineAllocationTable(element, build_line, options={}) {
|
|||||||
*/
|
*/
|
||||||
function loadBuildLineTable(table, build_id, options={}) {
|
function loadBuildLineTable(table, build_id, options={}) {
|
||||||
|
|
||||||
|
const params = options.params || {};
|
||||||
|
const output = options.output;
|
||||||
|
|
||||||
let name = 'build-lines';
|
let name = 'build-lines';
|
||||||
let params = options.params || {};
|
|
||||||
let output = options.output;
|
|
||||||
|
|
||||||
params.build = build_id;
|
params.build = build_id;
|
||||||
|
|
||||||
@ -2647,6 +2649,7 @@ function loadBuildLineTable(table, build_id, options={}) {
|
|||||||
detailFormatter: function(_index, row, element) {
|
detailFormatter: function(_index, row, element) {
|
||||||
renderBuildLineAllocationTable(element, row, {
|
renderBuildLineAllocationTable(element, row, {
|
||||||
parent_table: table,
|
parent_table: table,
|
||||||
|
output: output,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
formatNoMatches: function() {
|
formatNoMatches: function() {
|
||||||
@ -2730,6 +2733,15 @@ function loadBuildLineTable(table, build_id, options={}) {
|
|||||||
return yesNoLabel(row.bom_item_detail.inherited);
|
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',
|
field: 'unit_quantity',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Trans, t } from '@lingui/macro';
|
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 { 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 { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||||
|
|
||||||
import { identifierString } from '../../../functions/conversion';
|
import { identifierString } from '../../../functions/conversion';
|
||||||
@ -18,6 +18,69 @@ export interface TableFieldRowProps {
|
|||||||
removeFn: (idx: number) => void;
|
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({
|
export function TableField({
|
||||||
definition,
|
definition,
|
||||||
fieldName,
|
fieldName,
|
||||||
@ -47,7 +110,7 @@ export function TableField({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Extract errors associated with the current row
|
// Extract errors associated with the current row
|
||||||
const rowErrors = useCallback(
|
const rowErrors: any = useCallback(
|
||||||
(idx: number) => {
|
(idx: number) => {
|
||||||
if (Array.isArray(error)) {
|
if (Array.isArray(error)) {
|
||||||
return error[idx];
|
return error[idx];
|
||||||
@ -74,31 +137,18 @@ export function TableField({
|
|||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{value.length > 0 ? (
|
{value.length > 0 ? (
|
||||||
value.map((item: any, idx: number) => {
|
value.map((item: any, idx: number) => {
|
||||||
// Table fields require render function
|
return (
|
||||||
if (!definition.modelRenderer) {
|
<TableFieldRow
|
||||||
return (
|
key={`table-row-${idx}`}
|
||||||
<Table.Tr key="table-row-no-renderer">
|
item={item}
|
||||||
<Table.Td colSpan={definition.headers?.length}>
|
idx={idx}
|
||||||
<Alert
|
errors={rowErrors(idx)}
|
||||||
color="red"
|
control={control}
|
||||||
title={t`Error`}
|
definition={definition}
|
||||||
icon={<IconExclamationCircle />}
|
changeFn={onRowFieldChange}
|
||||||
>
|
removeFn={removeRow}
|
||||||
{`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
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<Table.Tr key="table-row-no-entries">
|
<Table.Tr key="table-row-no-entries">
|
||||||
|
@ -17,7 +17,10 @@ import {
|
|||||||
ApiFormFieldSet,
|
ApiFormFieldSet,
|
||||||
ApiFormFieldType
|
ApiFormFieldType
|
||||||
} from '../components/forms/fields/ApiFormField';
|
} 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 { ProgressBar } from '../components/items/ProgressBar';
|
||||||
import { StatusRenderer } from '../components/render/StatusRenderer';
|
import { StatusRenderer } from '../components/render/StatusRenderer';
|
||||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||||
@ -210,7 +213,11 @@ function BuildOutputFormRow({
|
|||||||
<Table.Td>
|
<Table.Td>
|
||||||
<PartColumn part={record.part_detail} />
|
<PartColumn part={record.part_detail} />
|
||||||
</Table.Td>
|
</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>{record.batch}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<StatusRenderer status={record.status} type={ModelType.stockitem} />{' '}
|
<StatusRenderer status={record.status} type={ModelType.stockitem} />{' '}
|
||||||
@ -259,7 +266,7 @@ export function useCompleteBuildOutputsForm({
|
|||||||
<BuildOutputFormRow props={row} record={record} key={record.pk} />
|
<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: {},
|
status_custom_key: {},
|
||||||
location: {
|
location: {
|
||||||
@ -454,8 +461,8 @@ function BuildAllocateLineRow({
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
value={record.allocated}
|
value={record.allocatedQuantity}
|
||||||
maximum={record.quantity}
|
maximum={record.requiredQuantity}
|
||||||
progressLabel
|
progressLabel
|
||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@ -512,6 +519,7 @@ export function useAllocateStockToBuildForm({
|
|||||||
lineItems.find((item) => item.pk == row.item.build_line) ?? {};
|
lineItems.find((item) => item.pk == row.item.build_line) ?? {};
|
||||||
return (
|
return (
|
||||||
<BuildAllocateLineRow
|
<BuildAllocateLineRow
|
||||||
|
key={row.idx}
|
||||||
props={row}
|
props={row}
|
||||||
record={record}
|
record={record}
|
||||||
sourceLocation={sourceLocation}
|
sourceLocation={sourceLocation}
|
||||||
@ -565,8 +573,8 @@ export function useAllocateStockToBuildForm({
|
|||||||
return {
|
return {
|
||||||
build_line: item.pk,
|
build_line: item.pk,
|
||||||
stock_item: undefined,
|
stock_item: undefined,
|
||||||
quantity: Math.max(0, item.quantity - item.allocated),
|
quantity: Math.max(0, item.requiredQuantity - item.allocatedQuantity),
|
||||||
output: null
|
output: outputId
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
@ -23,6 +23,7 @@ export type TableState = {
|
|||||||
clearActiveFilters: () => void;
|
clearActiveFilters: () => void;
|
||||||
expandedRecords: any[];
|
expandedRecords: any[];
|
||||||
setExpandedRecords: (records: any[]) => void;
|
setExpandedRecords: (records: any[]) => void;
|
||||||
|
isRowExpanded: (pk: number) => boolean;
|
||||||
selectedRecords: any[];
|
selectedRecords: any[];
|
||||||
selectedIds: number[];
|
selectedIds: number[];
|
||||||
hasSelectedRecords: boolean;
|
hasSelectedRecords: boolean;
|
||||||
@ -79,6 +80,14 @@ export function useTable(tableName: string): TableState {
|
|||||||
// Array of expanded records
|
// Array of expanded records
|
||||||
const [expandedRecords, setExpandedRecords] = useState<any[]>([]);
|
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
|
// Array of selected records
|
||||||
const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
|
const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
|
||||||
|
|
||||||
@ -148,6 +157,7 @@ export function useTable(tableName: string): TableState {
|
|||||||
clearActiveFilters,
|
clearActiveFilters,
|
||||||
expandedRecords,
|
expandedRecords,
|
||||||
setExpandedRecords,
|
setExpandedRecords,
|
||||||
|
isRowExpanded,
|
||||||
selectedRecords,
|
selectedRecords,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
setSelectedRecords,
|
setSelectedRecords,
|
||||||
|
@ -251,11 +251,7 @@ export default function BuildDetail() {
|
|||||||
name: 'line-items',
|
name: 'line-items',
|
||||||
label: t`Line Items`,
|
label: t`Line Items`,
|
||||||
icon: <IconListNumbers />,
|
icon: <IconListNumbers />,
|
||||||
content: build?.pk ? (
|
content: build?.pk ? <BuildLineTable build={build} /> : <Skeleton />
|
||||||
<BuildLineTable build={build} buildId={build.pk} />
|
|
||||||
) : (
|
|
||||||
<Skeleton />
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'incomplete-outputs',
|
name: 'incomplete-outputs',
|
||||||
|
@ -20,6 +20,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import {
|
import {
|
||||||
DataTable,
|
DataTable,
|
||||||
DataTableCellClickHandler,
|
DataTableCellClickHandler,
|
||||||
|
DataTableRowExpansionProps,
|
||||||
DataTableSortStatus
|
DataTableSortStatus
|
||||||
} from 'mantine-datatable';
|
} from 'mantine-datatable';
|
||||||
import React, {
|
import React, {
|
||||||
@ -103,7 +104,7 @@ export type InvenTreeTableProps<T = any> = {
|
|||||||
barcodeActions?: React.ReactNode[];
|
barcodeActions?: React.ReactNode[];
|
||||||
tableFilters?: TableFilter[];
|
tableFilters?: TableFilter[];
|
||||||
tableActions?: React.ReactNode[];
|
tableActions?: React.ReactNode[];
|
||||||
rowExpansion?: any;
|
rowExpansion?: DataTableRowExpansionProps<T>;
|
||||||
idAccessor?: string;
|
idAccessor?: string;
|
||||||
dataFormatter?: (data: any) => any;
|
dataFormatter?: (data: any) => any;
|
||||||
rowActions?: (record: T) => RowAction[];
|
rowActions?: (record: T) => RowAction[];
|
||||||
@ -633,6 +634,33 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
|||||||
tableState.refreshTable();
|
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(() => {
|
const optionalParams = useMemo(() => {
|
||||||
let optionalParamsa: Record<string, any> = {};
|
let optionalParamsa: Record<string, any> = {};
|
||||||
if (tableProps.enablePagination) {
|
if (tableProps.enablePagination) {
|
||||||
@ -779,7 +807,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
|||||||
onSelectedRecordsChange={
|
onSelectedRecordsChange={
|
||||||
enableSelection ? onSelectedRecordsChange : undefined
|
enableSelection ? onSelectedRecordsChange : undefined
|
||||||
}
|
}
|
||||||
rowExpansion={tableProps.rowExpansion}
|
rowExpansion={rowExpansion}
|
||||||
rowStyle={tableProps.rowStyle}
|
rowStyle={tableProps.rowStyle}
|
||||||
fetching={isFetching}
|
fetching={isFetching}
|
||||||
noRecordsText={missingRecordsText}
|
noRecordsText={missingRecordsText}
|
||||||
|
@ -161,8 +161,11 @@ export default function BuildAllocatedStockTable({
|
|||||||
const editItem = useEditApiFormModal({
|
const editItem = useEditApiFormModal({
|
||||||
pk: selectedItem,
|
pk: selectedItem,
|
||||||
url: ApiEndpoints.build_item_list,
|
url: ApiEndpoints.build_item_list,
|
||||||
title: t`Edit Build Item`,
|
title: t`Edit Stock Allocation`,
|
||||||
fields: {
|
fields: {
|
||||||
|
stock_item: {
|
||||||
|
disabled: true
|
||||||
|
},
|
||||||
quantity: {}
|
quantity: {}
|
||||||
},
|
},
|
||||||
table: table
|
table: table
|
||||||
@ -171,7 +174,7 @@ export default function BuildAllocatedStockTable({
|
|||||||
const deleteItem = useDeleteApiFormModal({
|
const deleteItem = useDeleteApiFormModal({
|
||||||
pk: selectedItem,
|
pk: selectedItem,
|
||||||
url: ApiEndpoints.build_item_list,
|
url: ApiEndpoints.build_item_list,
|
||||||
title: t`Delete Build Item`,
|
title: t`Delete Stock Allocation`,
|
||||||
table: table
|
table: table
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,12 +1,15 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Alert, Group, Text } from '@mantine/core';
|
import { ActionIcon, Alert, Group, Paper, Stack, Text } from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
|
IconChevronDown,
|
||||||
|
IconChevronRight,
|
||||||
IconCircleMinus,
|
IconCircleMinus,
|
||||||
IconShoppingCart,
|
IconShoppingCart,
|
||||||
IconTool,
|
IconTool,
|
||||||
IconWand
|
IconWand
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import { DataTable, DataTableRowExpansionProps } from 'mantine-datatable';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
@ -22,34 +25,140 @@ import {
|
|||||||
import { navigateToLink } from '../../functions/navigation';
|
import { navigateToLink } from '../../functions/navigation';
|
||||||
import { notYetImplemented } from '../../functions/notifications';
|
import { notYetImplemented } from '../../functions/notifications';
|
||||||
import { getDetailUrl } from '../../functions/urls';
|
import { getDetailUrl } from '../../functions/urls';
|
||||||
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
import {
|
||||||
|
useCreateApiFormModal,
|
||||||
|
useDeleteApiFormModal,
|
||||||
|
useEditApiFormModal
|
||||||
|
} from '../../hooks/UseForm';
|
||||||
import useStatusCodes from '../../hooks/UseStatusCodes';
|
import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||||
import { useTable } from '../../hooks/UseTable';
|
import { useTable } from '../../hooks/UseTable';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
import { TableColumn } from '../Column';
|
import { TableColumn } from '../Column';
|
||||||
import { BooleanColumn, PartColumn } from '../ColumnRenderers';
|
import { BooleanColumn, LocationColumn, PartColumn } from '../ColumnRenderers';
|
||||||
import { TableFilter } from '../Filter';
|
import { TableFilter } from '../Filter';
|
||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
import { RowAction } from '../RowActions';
|
import {
|
||||||
|
RowAction,
|
||||||
|
RowActions,
|
||||||
|
RowDeleteAction,
|
||||||
|
RowEditAction
|
||||||
|
} from '../RowActions';
|
||||||
import { TableHoverCard } from '../TableHoverCard';
|
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({
|
export default function BuildLineTable({
|
||||||
buildId,
|
|
||||||
build,
|
build,
|
||||||
outputId,
|
output,
|
||||||
params = {}
|
params = {}
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
buildId: number;
|
|
||||||
build: any;
|
build: any;
|
||||||
outputId?: number;
|
output?: any;
|
||||||
params?: any;
|
params?: any;
|
||||||
}>) {
|
}>) {
|
||||||
const table = useTable('buildline');
|
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const buildStatus = useStatusCodes({ modelType: ModelType.build });
|
const buildStatus = useStatusCodes({ modelType: ModelType.build });
|
||||||
|
|
||||||
|
const hasOutput: boolean = useMemo(() => !!output?.pk, [output]);
|
||||||
|
|
||||||
|
const table = useTable(hasOutput ? 'buildline-output' : 'buildline');
|
||||||
|
|
||||||
const isActive: boolean = useMemo(() => {
|
const isActive: boolean = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
build?.status == buildStatus.PRODUCTION ||
|
build?.status == buildStatus.PRODUCTION ||
|
||||||
@ -184,10 +293,30 @@ export default function BuildLineTable({
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessor: 'bom_item',
|
accessor: 'bom_item',
|
||||||
|
title: t`Component`,
|
||||||
ordering: 'part',
|
ordering: 'part',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
switchable: false,
|
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',
|
accessor: 'part_detail.IPN',
|
||||||
@ -207,24 +336,29 @@ export default function BuildLineTable({
|
|||||||
},
|
},
|
||||||
BooleanColumn({
|
BooleanColumn({
|
||||||
accessor: 'bom_item_detail.optional',
|
accessor: 'bom_item_detail.optional',
|
||||||
ordering: 'optional'
|
ordering: 'optional',
|
||||||
|
hidden: hasOutput
|
||||||
}),
|
}),
|
||||||
BooleanColumn({
|
BooleanColumn({
|
||||||
accessor: 'bom_item_detail.consumable',
|
accessor: 'bom_item_detail.consumable',
|
||||||
ordering: 'consumable'
|
ordering: 'consumable',
|
||||||
|
hidden: hasOutput
|
||||||
}),
|
}),
|
||||||
BooleanColumn({
|
BooleanColumn({
|
||||||
accessor: 'bom_item_detail.allow_variants',
|
accessor: 'bom_item_detail.allow_variants',
|
||||||
ordering: 'allow_variants'
|
ordering: 'allow_variants',
|
||||||
|
hidden: hasOutput
|
||||||
}),
|
}),
|
||||||
BooleanColumn({
|
BooleanColumn({
|
||||||
accessor: 'bom_item_detail.inherited',
|
accessor: 'bom_item_detail.inherited',
|
||||||
ordering: 'inherited',
|
ordering: 'inherited',
|
||||||
title: t`Gets Inherited`
|
title: t`Gets Inherited`,
|
||||||
|
hidden: hasOutput
|
||||||
}),
|
}),
|
||||||
BooleanColumn({
|
BooleanColumn({
|
||||||
accessor: 'part_detail.trackable',
|
accessor: 'part_detail.trackable',
|
||||||
ordering: 'trackable'
|
ordering: 'trackable',
|
||||||
|
hidden: hasOutput
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
accessor: 'bom_item_detail.quantity',
|
accessor: 'bom_item_detail.quantity',
|
||||||
@ -244,12 +378,14 @@ export default function BuildLineTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessor: 'quantity',
|
accessor: 'quantity',
|
||||||
|
title: t`Required Quantity`,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
switchable: false,
|
switchable: false,
|
||||||
render: (record: any) => {
|
render: (record: any) => {
|
||||||
|
// If a build output is specified, use the provided quantity
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Text>{record.quantity}</Text>
|
<Text>{record.requiredQuantity}</Text>
|
||||||
{record?.part_detail?.units && (
|
{record?.part_detail?.units && (
|
||||||
<Text size="xs">[{record.part_detail.units}]</Text>
|
<Text size="xs">[{record.part_detail.units}]</Text>
|
||||||
)}
|
)}
|
||||||
@ -266,6 +402,7 @@ export default function BuildLineTable({
|
|||||||
{
|
{
|
||||||
accessor: 'allocated',
|
accessor: 'allocated',
|
||||||
switchable: false,
|
switchable: false,
|
||||||
|
sortable: true,
|
||||||
hidden: !isActive,
|
hidden: !isActive,
|
||||||
render: (record: any) => {
|
render: (record: any) => {
|
||||||
return record?.bom_item_detail?.consumable ? (
|
return record?.bom_item_detail?.consumable ? (
|
||||||
@ -273,14 +410,14 @@ export default function BuildLineTable({
|
|||||||
) : (
|
) : (
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
progressLabel={true}
|
progressLabel={true}
|
||||||
value={record.allocated}
|
value={record.allocatedQuantity}
|
||||||
maximum={record.quantity}
|
maximum={record.requiredQuantity}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, [isActive]);
|
}, [hasOutput, isActive, table, output]);
|
||||||
|
|
||||||
const buildOrderFields = useBuildOrderFields({ create: true });
|
const buildOrderFields = useBuildOrderFields({ create: true });
|
||||||
|
|
||||||
@ -331,7 +468,7 @@ export default function BuildLineTable({
|
|||||||
|
|
||||||
const allocateStock = useAllocateStockToBuildForm({
|
const allocateStock = useAllocateStockToBuildForm({
|
||||||
build: build,
|
build: build,
|
||||||
outputId: null,
|
outputId: output?.pk ?? null,
|
||||||
buildId: build.pk,
|
buildId: build.pk,
|
||||||
lineItems: selectedRows,
|
lineItems: selectedRows,
|
||||||
onFormSuccess: () => {
|
onFormSuccess: () => {
|
||||||
@ -348,12 +485,12 @@ export default function BuildLineTable({
|
|||||||
hidden: true
|
hidden: true
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
hidden: true,
|
hidden: true
|
||||||
value: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
initialData: {
|
initialData: {
|
||||||
build_line: selectedLine
|
build_line: selectedLine,
|
||||||
|
output: output?.pk ?? null
|
||||||
},
|
},
|
||||||
preFormContent: (
|
preFormContent: (
|
||||||
<Alert color="red" title={t`Deallocate Stock`}>
|
<Alert color="red" title={t`Deallocate Stock`}>
|
||||||
@ -368,13 +505,35 @@ export default function BuildLineTable({
|
|||||||
table: table
|
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(
|
const rowActions = useCallback(
|
||||||
(record: any): RowAction[] => {
|
(record: any): RowAction[] => {
|
||||||
let part = record.part_detail ?? {};
|
let part = record.part_detail ?? {};
|
||||||
const in_production = build.status == buildStatus.PRODUCTION;
|
const in_production = build.status == buildStatus.PRODUCTION;
|
||||||
const consumable = record.bom_item_detail?.consumable ?? false;
|
const consumable = record.bom_item_detail?.consumable ?? false;
|
||||||
|
|
||||||
const hasOutput = !!outputId;
|
const hasOutput = !!output?.pk;
|
||||||
|
|
||||||
// Can allocate
|
// Can allocate
|
||||||
let canAllocate =
|
let canAllocate =
|
||||||
@ -440,7 +599,7 @@ export default function BuildLineTable({
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
setInitialData({
|
setInitialData({
|
||||||
part: record.part,
|
part: record.part,
|
||||||
parent: buildId,
|
parent: build.pk,
|
||||||
quantity: record.quantity - record.allocated
|
quantity: record.quantity - record.allocated
|
||||||
});
|
});
|
||||||
newBuildOrder.open();
|
newBuildOrder.open();
|
||||||
@ -459,7 +618,7 @@ export default function BuildLineTable({
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
[user, outputId, build, buildStatus]
|
[user, output, build, buildStatus]
|
||||||
);
|
);
|
||||||
|
|
||||||
const tableActions = useMemo(() => {
|
const tableActions = useMemo(() => {
|
||||||
@ -471,7 +630,7 @@ export default function BuildLineTable({
|
|||||||
key="auto-allocate"
|
key="auto-allocate"
|
||||||
icon={<IconWand />}
|
icon={<IconWand />}
|
||||||
tooltip={t`Auto Allocate Stock`}
|
tooltip={t`Auto Allocate Stock`}
|
||||||
hidden={!visible}
|
hidden={!visible || hasOutput}
|
||||||
color="blue"
|
color="blue"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
autoAllocateStock.open();
|
autoAllocateStock.open();
|
||||||
@ -485,14 +644,17 @@ export default function BuildLineTable({
|
|||||||
disabled={!table.hasSelectedRecords}
|
disabled={!table.hasSelectedRecords}
|
||||||
color="green"
|
color="green"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedRows(
|
let rows = table.selectedRecords
|
||||||
table.selectedRecords.filter(
|
.filter((r) => r.allocatedQuantity < r.requiredQuantity)
|
||||||
(r) =>
|
.filter((r) => !r.bom_item_detail?.consumable);
|
||||||
r.allocated < r.quantity &&
|
|
||||||
!r.trackable &&
|
if (hasOutput) {
|
||||||
!r.bom_item_detail.consumable
|
rows = rows.filter((r) => r.trackable);
|
||||||
)
|
} else {
|
||||||
);
|
rows = rows.filter((r) => !r.trackable);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedRows(rows);
|
||||||
allocateStock.open();
|
allocateStock.open();
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
@ -500,7 +662,7 @@ export default function BuildLineTable({
|
|||||||
key="deallocate-stock"
|
key="deallocate-stock"
|
||||||
icon={<IconCircleMinus />}
|
icon={<IconCircleMinus />}
|
||||||
tooltip={t`Deallocate Stock`}
|
tooltip={t`Deallocate Stock`}
|
||||||
hidden={!visible}
|
hidden={!visible || hasOutput}
|
||||||
disabled={table.hasSelectedRecords}
|
disabled={table.hasSelectedRecords}
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -513,16 +675,85 @@ export default function BuildLineTable({
|
|||||||
user,
|
user,
|
||||||
build,
|
build,
|
||||||
buildStatus,
|
buildStatus,
|
||||||
|
hasOutput,
|
||||||
table.hasSelectedRecords,
|
table.hasSelectedRecords,
|
||||||
table.selectedRecords
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{autoAllocateStock.modal}
|
{autoAllocateStock.modal}
|
||||||
{newBuildOrder.modal}
|
{newBuildOrder.modal}
|
||||||
{allocateStock.modal}
|
{allocateStock.modal}
|
||||||
{deallocateStock.modal}
|
{deallocateStock.modal}
|
||||||
|
{editAllocation.modal}
|
||||||
|
{deleteAllocation.modal}
|
||||||
<InvenTreeTable
|
<InvenTreeTable
|
||||||
url={apiUrl(ApiEndpoints.build_line_list)}
|
url={apiUrl(ApiEndpoints.build_line_list)}
|
||||||
tableState={table}
|
tableState={table}
|
||||||
@ -530,14 +761,16 @@ export default function BuildLineTable({
|
|||||||
props={{
|
props={{
|
||||||
params: {
|
params: {
|
||||||
...params,
|
...params,
|
||||||
build: buildId,
|
build: build.pk,
|
||||||
part_detail: true
|
part_detail: true
|
||||||
},
|
},
|
||||||
tableActions: tableActions,
|
tableActions: tableActions,
|
||||||
tableFilters: tableFilters,
|
tableFilters: tableFilters,
|
||||||
rowActions: rowActions,
|
rowActions: rowActions,
|
||||||
|
dataFormatter: formatRecords,
|
||||||
enableDownload: true,
|
enableDownload: true,
|
||||||
enableSelection: true
|
enableSelection: true,
|
||||||
|
rowExpansion: rowExpansion
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -1,6 +1,19 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Group, Text } from '@mantine/core';
|
import {
|
||||||
import { IconCircleCheck, IconCircleX } from '@tabler/icons-react';
|
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 { useQuery } from '@tanstack/react-query';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
@ -8,6 +21,7 @@ import { api } from '../../App';
|
|||||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||||
import { ProgressBar } from '../../components/items/ProgressBar';
|
import { ProgressBar } from '../../components/items/ProgressBar';
|
||||||
|
import { StylishText } from '../../components/items/StylishText';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import { ModelType } from '../../enums/ModelType';
|
import { ModelType } from '../../enums/ModelType';
|
||||||
import { UserRoles } from '../../enums/Roles';
|
import { UserRoles } from '../../enums/Roles';
|
||||||
@ -19,7 +33,6 @@ import {
|
|||||||
} from '../../forms/BuildForms';
|
} from '../../forms/BuildForms';
|
||||||
import { useStockFields } from '../../forms/StockForms';
|
import { useStockFields } from '../../forms/StockForms';
|
||||||
import { InvenTreeIcon } from '../../functions/icons';
|
import { InvenTreeIcon } from '../../functions/icons';
|
||||||
import { notYetImplemented } from '../../functions/notifications';
|
|
||||||
import {
|
import {
|
||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
useEditApiFormModal
|
useEditApiFormModal
|
||||||
@ -32,12 +45,79 @@ import { LocationColumn, PartColumn, StatusColumn } from '../ColumnRenderers';
|
|||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
import { RowAction, RowEditAction } from '../RowActions';
|
import { RowAction, RowEditAction } from '../RowActions';
|
||||||
import { TableHoverCard } from '../TableHoverCard';
|
import { TableHoverCard } from '../TableHoverCard';
|
||||||
|
import BuildLineTable from './BuildLineTable';
|
||||||
|
|
||||||
type TestResultOverview = {
|
type TestResultOverview = {
|
||||||
name: string;
|
name: string;
|
||||||
result: boolean;
|
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({
|
export default function BuildOutputTable({
|
||||||
build,
|
build,
|
||||||
refreshBuild
|
refreshBuild
|
||||||
@ -85,7 +165,7 @@ export default function BuildOutputTable({
|
|||||||
}, [partId, testTemplates]);
|
}, [partId, testTemplates]);
|
||||||
|
|
||||||
// Fetch the "tracked" BOM items associated with the partId
|
// Fetch the "tracked" BOM items associated with the partId
|
||||||
const { data: trackedItems } = useQuery({
|
const { data: trackedItems, refetch: refetchTrackedItems } = useQuery({
|
||||||
queryKey: ['trackeditems', buildId],
|
queryKey: ['trackeditems', buildId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!buildId || buildId < 0) {
|
if (!buildId || buildId < 0) {
|
||||||
@ -111,7 +191,7 @@ export default function BuildOutputTable({
|
|||||||
// Ensure base table data is updated correctly
|
// Ensure base table data is updated correctly
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
table.refreshTable();
|
table.refreshTable();
|
||||||
}, [hasTrackedItems, hasRequiredTests]);
|
}, [testTemplates, trackedItems, hasTrackedItems, hasRequiredTests]);
|
||||||
|
|
||||||
// Format table records
|
// Format table records
|
||||||
const formatRecords = useCallback(
|
const formatRecords = useCallback(
|
||||||
@ -152,13 +232,11 @@ export default function BuildOutputTable({
|
|||||||
let allocated = 0;
|
let allocated = 0;
|
||||||
|
|
||||||
// Find all allocations which match the build output
|
// Find all allocations which match the build output
|
||||||
let allocations = item.allocations.filter(
|
item.allocations
|
||||||
(allocation: any) => allocation.install_into == record.pk
|
?.filter((allocation: any) => allocation.install_into == record.pk)
|
||||||
);
|
?.forEach((allocation: any) => {
|
||||||
|
allocated += allocation.quantity;
|
||||||
allocations.forEach((allocation: any) => {
|
});
|
||||||
allocated += allocation.quantity;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (allocated >= item.bom_item_detail.quantity) {
|
if (allocated >= item.bom_item_detail.quantity) {
|
||||||
fullyAllocatedCount += 1;
|
fullyAllocatedCount += 1;
|
||||||
@ -230,6 +308,32 @@ export default function BuildOutputTable({
|
|||||||
table: table
|
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(() => {
|
const tableActions = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@ -281,15 +385,23 @@ export default function BuildOutputTable({
|
|||||||
title: t`Allocate`,
|
title: t`Allocate`,
|
||||||
tooltip: t`Allocate stock to build output`,
|
tooltip: t`Allocate stock to build output`,
|
||||||
color: 'blue',
|
color: 'blue',
|
||||||
|
hidden: !hasTrackedItems || !user.hasChangeRole(UserRoles.build),
|
||||||
icon: <InvenTreeIcon icon="plus" />,
|
icon: <InvenTreeIcon icon="plus" />,
|
||||||
onClick: notYetImplemented
|
onClick: () => {
|
||||||
|
setSelectedOutputs([record]);
|
||||||
|
openDrawer();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t`Deallocate`,
|
title: t`Deallocate`,
|
||||||
tooltip: t`Deallocate stock from build output`,
|
tooltip: t`Deallocate stock from build output`,
|
||||||
color: 'red',
|
color: 'red',
|
||||||
|
hidden: !hasTrackedItems || !user.hasChangeRole(UserRoles.build),
|
||||||
icon: <InvenTreeIcon icon="minus" />,
|
icon: <InvenTreeIcon icon="minus" />,
|
||||||
onClick: notYetImplemented
|
onClick: () => {
|
||||||
|
setSelectedOutputs([record]);
|
||||||
|
deallocateBuildOutput.open();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t`Complete`,
|
title: t`Complete`,
|
||||||
@ -330,7 +442,7 @@ export default function BuildOutputTable({
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
[user, partId]
|
[user, partId, hasTrackedItems]
|
||||||
);
|
);
|
||||||
|
|
||||||
const tableColumns: TableColumn[] = useMemo(() => {
|
const tableColumns: TableColumn[] = useMemo(() => {
|
||||||
@ -432,13 +544,23 @@ export default function BuildOutputTable({
|
|||||||
trackedItems
|
trackedItems
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const [drawerOpen, { open: openDrawer, close: closeDrawer }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{addBuildOutput.modal}
|
{addBuildOutput.modal}
|
||||||
{completeBuildOutputsForm.modal}
|
{completeBuildOutputsForm.modal}
|
||||||
{scrapBuildOutputsForm.modal}
|
{scrapBuildOutputsForm.modal}
|
||||||
{editBuildOutput.modal}
|
{editBuildOutput.modal}
|
||||||
|
{deallocateBuildOutput.modal}
|
||||||
{cancelBuildOutputsForm.modal}
|
{cancelBuildOutputsForm.modal}
|
||||||
|
<OutputAllocationDrawer
|
||||||
|
build={build}
|
||||||
|
output={selectedOutputs[0]}
|
||||||
|
opened={drawerOpen}
|
||||||
|
close={closeDrawer}
|
||||||
|
/>
|
||||||
<InvenTreeTable
|
<InvenTreeTable
|
||||||
tableState={table}
|
tableState={table}
|
||||||
url={apiUrl(ApiEndpoints.stock_item_list)}
|
url={apiUrl(ApiEndpoints.stock_item_list)}
|
||||||
@ -452,11 +574,16 @@ export default function BuildOutputTable({
|
|||||||
},
|
},
|
||||||
enableLabels: true,
|
enableLabels: true,
|
||||||
enableReports: true,
|
enableReports: true,
|
||||||
modelType: ModelType.stockitem,
|
|
||||||
dataFormatter: formatRecords,
|
dataFormatter: formatRecords,
|
||||||
tableActions: tableActions,
|
tableActions: tableActions,
|
||||||
rowActions: rowActions,
|
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.getByRole('button', { name: 'Submit' }).click();
|
||||||
await page.getByText('Build outputs have been completed').waitFor();
|
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