diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index c7f8de1835..85053dabf4 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -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__' diff --git a/src/backend/InvenTree/templates/js/translated/build.js b/src/backend/InvenTree/templates/js/translated/build.js index a16d93e4ab..4e6ec08c62 100644 --- a/src/backend/InvenTree/templates/js/translated/build.js +++ b/src/backend/InvenTree/templates/js/translated/build.js @@ -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, diff --git a/src/frontend/src/components/forms/fields/TableField.tsx b/src/frontend/src/components/forms/fields/TableField.tsx index 71de02cc5b..bb0da08540 100644 --- a/src/frontend/src/components/forms/fields/TableField.tsx +++ b/src/frontend/src/components/forms/fields/TableField.tsx @@ -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; + changeFn: (idx: number, key: string, value: any) => void; + removeFn: (idx: number) => void; +}) { + // Table fields require render function + if (!definition.modelRenderer) { + return ( + + + }> + {`modelRenderer entry required for tables`} + + + + ); + } + + 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 ( + + {children} + {msg && ( + + {msg.message} + + )} + + ); +} + 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({ {value.length > 0 ? ( value.map((item: any, idx: number) => { - // Table fields require render function - if (!definition.modelRenderer) { - return ( - - - } - > - {`modelRenderer entry required for tables`} - - - - ); - } - - return definition.modelRenderer({ - item: item, - idx: idx, - rowErrors: rowErrors(idx), - control: control, - changeFn: onRowFieldChange, - removeFn: removeRow - }); + return ( + + ); }) ) : ( diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index a3e2044933..60a24d6290 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -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({ - {serial} + + + {serial} + + {record.batch} {' '} @@ -259,7 +266,7 @@ export function useCompleteBuildOutputsForm({ ); }, - 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({ @@ -512,6 +519,7 @@ export function useAllocateStockToBuildForm({ lineItems.find((item) => item.pk == row.item.build_line) ?? {}; return ( 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([]); + // 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([]); @@ -148,6 +157,7 @@ export function useTable(tableName: string): TableState { clearActiveFilters, expandedRecords, setExpandedRecords, + isRowExpanded, selectedRecords, selectedIds, setSelectedRecords, diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index beba9a734c..c7c8a62b62 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -251,11 +251,7 @@ export default function BuildDetail() { name: 'line-items', label: t`Line Items`, icon: , - content: build?.pk ? ( - - ) : ( - - ) + content: build?.pk ? : }, { name: 'incomplete-outputs', diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index 5ffbc5fd33..1f9eb01df9 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -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 = { barcodeActions?: React.ReactNode[]; tableFilters?: TableFilter[]; tableActions?: React.ReactNode[]; - rowExpansion?: any; + rowExpansion?: DataTableRowExpansionProps; idAccessor?: string; dataFormatter?: (data: any) => any; rowActions?: (record: T) => RowAction[]; @@ -633,6 +634,33 @@ export function InvenTreeTable>({ 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 | 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 = {}; if (tableProps.enablePagination) { @@ -779,7 +807,7 @@ export function InvenTreeTable>({ onSelectedRecordsChange={ enableSelection ? onSelectedRecordsChange : undefined } - rowExpansion={tableProps.rowExpansion} + rowExpansion={rowExpansion} rowStyle={tableProps.rowStyle} fetching={isFetching} noRecordsText={missingRecordsText} diff --git a/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx b/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx index 379afd242e..eeaa2ffef2 100644 --- a/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx +++ b/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx @@ -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 }); diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index bc95ee134b..7e8335fe3f 100644 --- a/src/frontend/src/tables/build/BuildLineTable.tsx +++ b/src/frontend/src/tables/build/BuildLineTable.tsx @@ -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 ; + } + }, + { + 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 ( + { + onEditAllocation(record.pk); + } + }), + RowDeleteAction({ + hidden: !user.hasDeleteRole(UserRoles.build), + onClick: () => { + onDeleteAllocation(record.pk); + } + }) + ]} + /> + ); + } + } + ]; + }, [user, onEditAllocation, onDeleteAllocation]); + + return ( + + + + + + ); +} + +/** + * 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 ( + + + {table.isRowExpanded(record.pk) ? ( + + ) : ( + + )} + + + + ); + } }, { 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 ( - {record.quantity} + {record.requiredQuantity} {record?.part_detail?.units && ( [{record.part_detail.units}] )} @@ -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({ ) : ( ); } } ]; - }, [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: ( @@ -368,13 +505,35 @@ export default function BuildLineTable({ table: table }); + const [selectedAllocation, setSelectedAllocation] = useState(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={} 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={} 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 = 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 ( + { + 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} diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx index a377b0a741..d5587a356e 100644 --- a/src/frontend/src/tables/build/BuildOutputTable.tsx +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -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 ( + <> + + {t`Build Output Stock Allocation`} + + + {output?.serial && ( + + {t`Serial Number`}: {output.serial} + + )} + {output?.batch && ( + + {t`Batch Code`}: {output.batch} + + )} + + + } + opened={opened} + onClose={close} + withCloseButton + closeOnEscape + closeOnClickOutside + styles={{ + header: { + width: '100%' + }, + title: { + width: '100%' + } + }} + > + + + + + + + ); +} + 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: ( + } + title={t`Deallocate Stock`} + > + {t`This action will deallocate all stock from the selected build output`} + + ), + fields: { + output: { + hidden: true + } + }, + initialData: { + output: selectedOutputs[0]?.pk + }, + onFormSuccess: () => { + refetchTrackedItems(); + } + }); + const tableActions = useMemo(() => { return [ , - 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: , - 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} + { + if (hasTrackedItems && !!record.serial) { + setSelectedOutputs([record]); + openDrawer(); + } + } }} /> diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index faaea12fbc..0fb6f5937b 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -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(); +});