From eec53ffd82fca4218150ce42838f8a0b5262feae Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 24 Aug 2024 15:17:05 +1000 Subject: [PATCH] [PUI] Build actions (#7945) * add table buttons to build line table * Add deallocate row action * Restrict row actions * Add functionality to 'deallocate' stock from build order * Implement 'auto-allocate' * Table column cleanup * Refactor code into new hook: - Helper function to update a set of selected rows - Callback function to remove row * Refactor existing forms to use new hook * Fix for RelatedModelField - Handle callback for null value * Memoize each field instance * Cleanup dead code * Define interfac for TableField row properties * Handle processing of nested errors * Pass form controller through to table field rows * Pass row errors through to individual table rows * Allow Standalone field to render errors * Allow allocation against build lines * Adjust quantity value when stock item is changed * Fix issue related to field name * Add "available" filter * Add "remove row" button * Add field for selecting source location * Filter out consumable items * Adjust form success message --- src/backend/InvenTree/build/serializers.py | 4 +- .../components/buttons/RemoveRowButton.tsx | 22 ++ src/frontend/src/components/forms/ApiForm.tsx | 15 +- .../src/components/forms/StandaloneField.tsx | 26 +- .../components/forms/fields/ApiFormField.tsx | 26 +- .../forms/fields/RelatedModelField.tsx | 2 +- .../components/forms/fields/TableField.tsx | 28 +- .../src/components/items/ProgressBar.tsx | 7 +- src/frontend/src/enums/ApiEndpoints.tsx | 3 + src/frontend/src/forms/BuildForms.tsx | 326 ++++++++++++++---- src/frontend/src/forms/PurchaseOrderForms.tsx | 9 +- src/frontend/src/forms/StockForms.tsx | 16 +- src/frontend/src/hooks/UseSelectedRows.tsx | 37 ++ src/frontend/src/pages/build/BuildDetail.tsx | 2 +- .../src/tables/build/BuildLineTable.tsx | 184 +++++++++- 15 files changed, 595 insertions(+), 112 deletions(-) create mode 100644 src/frontend/src/components/buttons/RemoveRowButton.tsx create mode 100644 src/frontend/src/hooks/UseSelectedRows.tsx diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 473c4b423d..4471fe0297 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -891,8 +891,8 @@ class BuildUnallocationSerializer(serializers.Serializer): data = self.validated_data build.deallocate_stock( - build_line=data['build_line'], - output=data['output'] + build_line=data.get('build_line', None), + output=data.get('output', None), ) diff --git a/src/frontend/src/components/buttons/RemoveRowButton.tsx b/src/frontend/src/components/buttons/RemoveRowButton.tsx new file mode 100644 index 0000000000..f46a928cfc --- /dev/null +++ b/src/frontend/src/components/buttons/RemoveRowButton.tsx @@ -0,0 +1,22 @@ +import { t } from '@lingui/macro'; + +import { InvenTreeIcon } from '../../functions/icons'; +import { ActionButton } from './ActionButton'; + +export default function RemoveRowButton({ + onClick, + tooltip = t`Remove this row` +}: { + onClick: () => void; + tooltip?: string; +}) { + return ( + } + tooltip={tooltip} + tooltipAlignment="top" + color="red" + /> + ); +} diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index bfff18714e..a7390f62a1 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -502,7 +502,20 @@ export function ApiForm({ } if (typeof v === 'object' && Array.isArray(v)) { - form.setError(path, { message: v.join(', ') }); + if (field?.field_type == 'table') { + // Special handling for "table" fields - they have nested errors + v.forEach((item: any, idx: number) => { + for (const [key, value] of Object.entries(item)) { + const path: string = `${k}.${idx}.${key}`; + if (Array.isArray(value)) { + form.setError(path, { message: value.join(', ') }); + } + } + }); + } else { + // Standard error handling for other fields + form.setError(path, { message: v.join(', ') }); + } } else { processErrors(v, path); } diff --git a/src/frontend/src/components/forms/StandaloneField.tsx b/src/frontend/src/components/forms/StandaloneField.tsx index ea1c7c751e..b9f7345a56 100644 --- a/src/frontend/src/components/forms/StandaloneField.tsx +++ b/src/frontend/src/components/forms/StandaloneField.tsx @@ -1,37 +1,53 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { ApiFormField, ApiFormFieldType } from './fields/ApiFormField'; export function StandaloneField({ fieldDefinition, + fieldName = 'field', defaultValue, - hideLabels + hideLabels, + error }: { fieldDefinition: ApiFormFieldType; + fieldName?: string; defaultValue?: any; hideLabels?: boolean; + error?: string; }) { + // Field must have a defined name + const name = useMemo(() => fieldName ?? 'field', [fieldName]); + const defaultValues = useMemo(() => { if (defaultValue) return { - field: defaultValue + [name]: defaultValue }; return {}; }, [defaultValue]); - const form = useForm<{}>({ + const form = useForm({ criteriaMode: 'all', defaultValues }); + useEffect(() => { + form.clearErrors(); + + if (!!error) { + form.setError(name, { message: error }); + } + }, [form, error]); + return ( ); diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 3e0147bd45..86539ce64c 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -204,8 +204,8 @@ export function ApiFormField({ }, [value]); // Construct the individual field - function buildField() { - switch (definition.field_type) { + const fieldInstance = useMemo(() => { + switch (fieldDefinition.field_type) { case 'related field': return ( ); } - } + }, [ + booleanValue, + control, + controller, + field, + fieldId, + fieldName, + fieldDefinition, + numericalValue, + onChange, + reducedDefinition, + ref, + setFields, + value + ]); - if (definition.hidden) { + if (fieldDefinition.hidden) { return null; } return ( {definition.preFieldContent} - {buildField()} + {fieldInstance} {definition.postFieldContent} ); diff --git a/src/frontend/src/components/forms/fields/RelatedModelField.tsx b/src/frontend/src/components/forms/fields/RelatedModelField.tsx index 728a32f35e..9a74cc4032 100644 --- a/src/frontend/src/components/forms/fields/RelatedModelField.tsx +++ b/src/frontend/src/components/forms/fields/RelatedModelField.tsx @@ -207,7 +207,7 @@ export function RelatedModelField({ setPk(_pk); // Run custom callback for this field (if provided) - definition.onValueChange?.(_pk, value.data ?? {}); + definition.onValueChange?.(_pk, value?.data ?? {}); }, [field.onChange, definition] ); diff --git a/src/frontend/src/components/forms/fields/TableField.tsx b/src/frontend/src/components/forms/fields/TableField.tsx index c4eadc915b..7fb6e3fa39 100644 --- a/src/frontend/src/components/forms/fields/TableField.tsx +++ b/src/frontend/src/components/forms/fields/TableField.tsx @@ -1,12 +1,21 @@ import { Trans, t } from '@lingui/macro'; import { Container, Group, Table } from '@mantine/core'; -import { useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { FieldValues, UseControllerReturn } from 'react-hook-form'; import { InvenTreeIcon } from '../../../functions/icons'; import { StandaloneField } from '../StandaloneField'; import { ApiFormFieldType } from './ApiFormField'; +export interface TableFieldRowProps { + item: any; + idx: number; + rowErrors: any; + control: UseControllerReturn; + changeFn: (idx: number, key: string, value: any) => void; + removeFn: (idx: number) => void; +} + export function TableField({ definition, fieldName, @@ -34,6 +43,16 @@ export function TableField({ field.onChange(val); }; + // Extract errors associated with the current row + const rowErrors = useCallback( + (idx: number) => { + if (Array.isArray(error)) { + return error[idx]; + } + }, + [error] + ); + return ( @@ -49,18 +68,21 @@ export function TableField({ // Table fields require render function if (!definition.modelRenderer) { return ( - {t`modelRenderer entry required for tables`} + {t`modelRenderer entry required for tables`} ); } + return definition.modelRenderer({ item: item, idx: idx, + rowErrors: rowErrors(idx), + control: control, changeFn: onRowFieldChange, removeFn: removeRow }); }) ) : ( - + ) { let maximum = props.maximum ?? 100; let value = Math.max(props.value, 0); - // Calculate progress as a percentage of the maximum value - return Math.min(100, (value / maximum) * 100); + if (maximum == 0) { + return 0; + } + + return (value / maximum) * 100; }, [props]); return ( diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 1908a59577..6ce43aa910 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -74,6 +74,9 @@ export enum ApiEndpoints { build_output_create = 'build/:id/create-output/', build_output_scrap = 'build/:id/scrap-outputs/', build_output_delete = 'build/:id/delete-outputs/', + build_order_auto_allocate = 'build/:id/auto-allocate/', + build_order_allocate = 'build/:id/allocate/', + build_order_deallocate = 'build/:id/unallocate/', build_line_list = 'build/line/', build_item_list = 'build/item/', diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index 1b6a730191..fd86e6b4b8 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -1,5 +1,5 @@ import { t } from '@lingui/macro'; -import { Alert, Stack, Text } from '@mantine/core'; +import { Alert, Stack, Table, Text } from '@mantine/core'; import { IconCalendar, IconLink, @@ -10,16 +10,26 @@ import { IconUsersGroup } from '@tabler/icons-react'; import { DataTable } from 'mantine-datatable'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { api } from '../App'; import { ActionButton } from '../components/buttons/ActionButton'; -import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; +import RemoveRowButton from '../components/buttons/RemoveRowButton'; +import { StandaloneField } from '../components/forms/StandaloneField'; +import { + ApiFormFieldSet, + ApiFormFieldType +} from '../components/forms/fields/ApiFormField'; +import { TableFieldRowProps } from '../components/forms/fields/TableField'; +import { ProgressBar } from '../components/items/ProgressBar'; import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ModelType } from '../enums/ModelType'; +import { resolveItem } from '../functions/conversion'; import { InvenTreeIcon } from '../functions/icons'; import { useCreateApiFormModal } from '../hooks/UseForm'; import { useBatchCodeGenerator } from '../hooks/UseGenerator'; +import { useSelectedRows } from '../hooks/UseSelectedRows'; import { apiUrl } from '../states/ApiState'; import { useGlobalSettingsState } from '../states/SettingsState'; import { PartColumn, StatusColumn } from '../tables/ColumnRenderers'; @@ -240,7 +250,7 @@ function buildOutputFormTable(outputs: any[], onRemove: (output: any) => void) { tooltip={t`Remove output`} icon={} color="red" - onClick={() => onRemove(record)} + onClick={() => onRemove(record.pk)} disabled={outputs.length <= 1} /> ) @@ -259,13 +269,11 @@ export function useCompleteBuildOutputsForm({ outputs: any[]; onFormSuccess: (response: any) => void; }) { - const [selectedOutputs, setSelectedOutputs] = useState([]); - const [location, setLocation] = useState(null); - useEffect(() => { - setSelectedOutputs(outputs); - }, [outputs]); + const { selectedRows, removeRow } = useSelectedRows({ + rows: outputs + }); useEffect(() => { if (location) { @@ -277,25 +285,15 @@ export function useCompleteBuildOutputsForm({ ); }, [location, build.destination, build.part_detail]); - // Remove a selected output from the list - const removeOutput = useCallback( - (output: any) => { - setSelectedOutputs( - selectedOutputs.filter((item) => item.pk != output.pk) - ); - }, - [selectedOutputs] - ); - const preFormContent = useMemo(() => { - return buildOutputFormTable(selectedOutputs, removeOutput); - }, [selectedOutputs, removeOutput]); + return buildOutputFormTable(selectedRows, removeRow); + }, [selectedRows, removeRow]); const buildOutputCompleteFields: ApiFormFieldSet = useMemo(() => { return { outputs: { hidden: true, - value: selectedOutputs.map((output) => { + value: selectedRows.map((output: any) => { return { output: output.pk }; @@ -314,7 +312,7 @@ export function useCompleteBuildOutputsForm({ notes: {}, accept_incomplete_allocation: {} }; - }, [selectedOutputs, location]); + }, [selectedRows, location]); return useCreateApiFormModal({ url: apiUrl(ApiEndpoints.build_output_complete, build.pk), @@ -327,6 +325,9 @@ export function useCompleteBuildOutputsForm({ }); } +/* + * Dynamic form for scraping multiple build outputs + */ export function useScrapBuildOutputsForm({ build, outputs, @@ -337,21 +338,10 @@ export function useScrapBuildOutputsForm({ onFormSuccess: (response: any) => void; }) { const [location, setLocation] = useState(null); - const [selectedOutputs, setSelectedOutputs] = useState([]); - useEffect(() => { - setSelectedOutputs(outputs); - }, [outputs]); - - // Remove a selected output from the list - const removeOutput = useCallback( - (output: any) => { - setSelectedOutputs( - selectedOutputs.filter((item) => item.pk != output.pk) - ); - }, - [selectedOutputs] - ); + const { selectedRows, removeRow } = useSelectedRows({ + rows: outputs + }); useEffect(() => { if (location) { @@ -364,14 +354,14 @@ export function useScrapBuildOutputsForm({ }, [location, build.destination, build.part_detail]); const preFormContent = useMemo(() => { - return buildOutputFormTable(selectedOutputs, removeOutput); - }, [selectedOutputs, removeOutput]); + return buildOutputFormTable(selectedRows, removeRow); + }, [selectedRows, removeRow]); const buildOutputScrapFields: ApiFormFieldSet = useMemo(() => { return { outputs: { hidden: true, - value: selectedOutputs.map((output) => { + value: selectedRows.map((output: any) => { return { output: output.pk, quantity: output.quantity @@ -387,7 +377,7 @@ export function useScrapBuildOutputsForm({ notes: {}, discard_allocations: {} }; - }, [location, selectedOutputs]); + }, [location, selectedRows]); return useCreateApiFormModal({ url: apiUrl(ApiEndpoints.build_output_scrap, build.pk), @@ -409,21 +399,9 @@ export function useCancelBuildOutputsForm({ outputs: any[]; onFormSuccess: (response: any) => void; }) { - const [selectedOutputs, setSelectedOutputs] = useState([]); - - useEffect(() => { - setSelectedOutputs(outputs); - }, [outputs]); - - // Remove a selected output from the list - const removeOutput = useCallback( - (output: any) => { - setSelectedOutputs( - selectedOutputs.filter((item) => item.pk != output.pk) - ); - }, - [selectedOutputs] - ); + const { selectedRows, removeRow } = useSelectedRows({ + rows: outputs + }); const preFormContent = useMemo(() => { return ( @@ -431,23 +409,23 @@ export function useCancelBuildOutputsForm({ {t`Selected build outputs will be deleted`} - {buildOutputFormTable(selectedOutputs, removeOutput)} + {buildOutputFormTable(selectedRows, removeRow)} ); - }, [selectedOutputs, removeOutput]); + }, [selectedRows, removeRow]); const buildOutputCancelFields: ApiFormFieldSet = useMemo(() => { return { outputs: { hidden: true, - value: selectedOutputs.map((output) => { + value: selectedRows.map((output: any) => { return { output: output.pk }; }) } }; - }, [selectedOutputs]); + }, [selectedRows]); return useCreateApiFormModal({ url: apiUrl(ApiEndpoints.build_output_delete, build.pk), @@ -459,3 +437,231 @@ export function useCancelBuildOutputsForm({ successMessage: t`Build outputs have been cancelled` }); } + +function buildAllocationFormTable( + outputs: any[], + onRemove: (output: any) => void +) { + return ( + PartColumn(record.part_detail) + }, + { + accessor: 'allocated', + title: t`Allocated`, + render: (record: any) => ( + + ) + }, + { + accessor: 'actions', + title: '', + render: (record: any) => ( + } + color="red" + onClick={() => onRemove(record.pk)} + disabled={outputs.length <= 1} + /> + ) + } + ]} + /> + ); +} + +// Construct a single row in the 'allocate stock to build' table +function BuildAllocateLineRow({ + props, + record, + sourceLocation +}: { + props: TableFieldRowProps; + record: any; + sourceLocation: number | undefined; +}) { + const stockField: ApiFormFieldType = useMemo(() => { + return { + field_type: 'related field', + api_url: apiUrl(ApiEndpoints.stock_item_list), + model: ModelType.stockitem, + filters: { + available: true, + part_detail: true, + location_detail: true, + bom_item: record.bom_item, + location: sourceLocation, + cascade: sourceLocation ? true : undefined + }, + value: props.item.stock_item, + name: 'stock_item', + onValueChange: (value: any, instance: any) => { + props.changeFn(props.idx, 'stock_item', value); + + // Update the allocated quantity based on the selected stock item + if (instance) { + let available = instance.quantity - instance.allocated; + + props.changeFn( + props.idx, + 'quantity', + Math.min(props.item.quantity, available) + ); + } + } + }; + }, [props]); + + const quantityField: ApiFormFieldType = useMemo(() => { + return { + field_type: 'number', + name: 'quantity', + required: true, + value: props.item.quantity, + onValueChange: (value: any) => { + props.changeFn(props.idx, 'quantity', value); + } + }; + }, [props]); + + const partDetail = useMemo( + () => PartColumn(record.part_detail), + [record.part_detail] + ); + + return ( + <> + + {partDetail} + + + + + + + + + + + props.removeFn(props.idx)} /> + + + + ); +} + +/* + * Dynamic form for allocating stock against multiple build order line items + */ +export function useAllocateStockToBuildForm({ + buildId, + outputId, + build, + lineItems, + onFormSuccess +}: { + buildId: number; + outputId?: number | null; + build: any; + lineItems: any[]; + onFormSuccess: (response: any) => void; +}) { + const [sourceLocation, setSourceLocation] = useState( + undefined + ); + + const buildAllocateFields: ApiFormFieldSet = useMemo(() => { + const fields: ApiFormFieldSet = { + items: { + field_type: 'table', + value: [], + headers: [t`Part`, t`Allocated`, t`Stock Item`, t`Quantity`], + modelRenderer: (row: TableFieldRowProps) => { + // Find the matching record from the passed 'lineItems' + const record = + lineItems.find((item) => item.pk == row.item.build_line) ?? {}; + return ( + + ); + } + } + }; + + return fields; + }, [lineItems, sourceLocation]); + + useEffect(() => { + setSourceLocation(build.take_from); + }, [build.take_from]); + + const sourceLocationField: ApiFormFieldType = useMemo(() => { + return { + field_type: 'related field', + api_url: apiUrl(ApiEndpoints.stock_location_list), + model: ModelType.stocklocation, + required: false, + label: t`Source Location`, + description: t`Select the source location for the stock allocation`, + name: 'source_location', + value: build.take_from, + onValueChange: (value: any) => { + setSourceLocation(value); + } + }; + }, [build?.take_from]); + + const preFormContent = useMemo(() => { + return ( + + + + ); + }, [sourceLocationField]); + + return useCreateApiFormModal({ + url: ApiEndpoints.build_order_allocate, + pk: buildId, + title: t`Allocate Stock`, + fields: buildAllocateFields, + preFormContent: preFormContent, + successMessage: t`Stock items allocated`, + onFormSuccess: onFormSuccess, + initialData: { + items: lineItems.map((item) => { + return { + build_line: item.pk, + stock_item: undefined, + quantity: Math.max(0, item.quantity - item.allocated), + output: null + }; + }) + }, + size: '80%' + }); +} diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index 28feda1a41..a6579a91b9 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -28,6 +28,7 @@ import { useEffect, useMemo, useState } from 'react'; import { api } from '../App'; import { ActionButton } from '../components/buttons/ActionButton'; +import RemoveRowButton from '../components/buttons/RemoveRowButton'; import { StandaloneField } from '../components/forms/StandaloneField'; import { ApiFormAdjustFilterType, @@ -438,13 +439,7 @@ function LineItemFormRow({ onClick={() => open()} /> )} - input.removeFn(input.idx)} - icon={} - tooltip={t`Remove item from list`} - tooltipAlignment="top" - color="red" - /> + input.removeFn(input.idx)} /> diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 7ac947826b..18de04eb91 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -7,6 +7,7 @@ import { Suspense, useCallback, useMemo, useState } from 'react'; import { api } from '../App'; import { ActionButton } from '../components/buttons/ActionButton'; +import RemoveRowButton from '../components/buttons/RemoveRowButton'; import { ApiFormAdjustFilterType, ApiFormFieldSet @@ -322,13 +323,6 @@ function StockOperationsRow({ [item] ); - const changeSubItem = useCallback( - (key: string, value: any) => { - input.changeFn(input.idx, key, value); - }, - [input] - ); - const removeAndRefresh = () => { input.removeFn(input.idx); }; @@ -422,13 +416,7 @@ function StockOperationsRow({ variant={packagingOpen ? 'filled' : 'transparent'} /> )} - input.removeFn(input.idx)} - icon={} - tooltip={t`Remove item from list`} - tooltipAlignment="top" - color="red" - /> + input.removeFn(input.idx)} /> diff --git a/src/frontend/src/hooks/UseSelectedRows.tsx b/src/frontend/src/hooks/UseSelectedRows.tsx new file mode 100644 index 0000000000..8a089eec12 --- /dev/null +++ b/src/frontend/src/hooks/UseSelectedRows.tsx @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useState } from 'react'; + +/** + * Hook to manage multiple selected rows in a multi-action modal. + * + * - The hook is initially provided with a list of rows + * - A callback is provided to remove a row, based on the provided ID value + */ +export function useSelectedRows({ + rows, + pkField = 'pk' +}: { + rows: T[]; + pkField?: string; +}) { + const [selectedRows, setSelectedRows] = useState(rows); + + // Update selection whenever input rows are updated + useEffect(() => { + setSelectedRows(rows); + }, [rows]); + + // Callback to remove the selected row + const removeRow = useCallback( + (pk: any) => { + setSelectedRows((rows) => + rows.filter((row: any) => row[pkField ?? 'pk'] !== pk) + ); + }, + [pkField] + ); + + return { + selectedRows, + removeRow + }; +} diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index bc9a02981f..9e52db71ca 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -253,7 +253,7 @@ export default function BuildDetail() { label: t`Line Items`, icon: , content: build?.pk ? ( - + ) : ( ) diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index 83c87d5804..0ab49f02e6 100644 --- a/src/frontend/src/tables/build/BuildLineTable.tsx +++ b/src/frontend/src/tables/build/BuildLineTable.tsx @@ -1,19 +1,27 @@ import { t } from '@lingui/macro'; -import { Group, Text } from '@mantine/core'; +import { Alert, Group, Text } from '@mantine/core'; import { IconArrowRight, + IconCircleMinus, IconShoppingCart, - IconTool + IconTool, + IconTransferIn, + IconWand } from '@tabler/icons-react'; import { useCallback, useMemo, useState } from 'react'; +import { ActionButton } from '../../components/buttons/ActionButton'; import { ProgressBar } from '../../components/items/ProgressBar'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; -import { useBuildOrderFields } from '../../forms/BuildForms'; +import { + useAllocateStockToBuildForm, + useBuildOrderFields +} from '../../forms/BuildForms'; import { notYetImplemented } from '../../functions/notifications'; import { useCreateApiFormModal } from '../../hooks/UseForm'; +import useStatusCodes from '../../hooks/UseStatusCodes'; import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; @@ -26,15 +34,18 @@ import { TableHoverCard } from '../TableHoverCard'; export default function BuildLineTable({ buildId, + build, outputId, params = {} }: { buildId: number; + build: any; outputId?: number; params?: any; }) { const table = useTable('buildline'); const user = useUserState(); + const buildStatus = useStatusCodes({ modelType: ModelType.build }); const tableFilters: TableFilter[] = useMemo(() => { return [ @@ -211,7 +222,7 @@ export default function BuildLineTable({ ordering: 'unit_quantity', render: (record: any) => { return ( - + {record.bom_item_detail?.quantity} {record?.part_detail?.units && ( [{record.part_detail.units}] @@ -223,9 +234,10 @@ export default function BuildLineTable({ { accessor: 'quantity', sortable: true, + switchable: false, render: (record: any) => { return ( - + {record.quantity} {record?.part_detail?.units && ( [{record.part_detail.units}] @@ -262,6 +274,10 @@ export default function BuildLineTable({ const [initialData, setInitialData] = useState({}); + const [selectedLine, setSelectedLine] = useState(null); + + const [selectedRows, setSelectedRows] = useState([]); + const newBuildOrder = useCreateApiFormModal({ url: ApiEndpoints.build_order_list, title: t`Create Build Order`, @@ -271,6 +287,75 @@ export default function BuildLineTable({ modelType: ModelType.build }); + const autoAllocateStock = useCreateApiFormModal({ + url: ApiEndpoints.build_order_auto_allocate, + pk: build.pk, + title: t`Allocate Stock`, + fields: { + location: { + filters: { + structural: false + } + }, + exclude_location: {}, + interchangeable: {}, + substitutes: {}, + optional_items: {} + }, + initialData: { + location: build.take_from, + interchangeable: true, + substitutes: true, + optional_items: false + }, + successMessage: t`Auto allocation in progress`, + table: table, + preFormContent: ( + + {t`Automatically allocate stock to this build according to the selected options`} + + ) + }); + + const allowcateStock = useAllocateStockToBuildForm({ + build: build, + outputId: null, + buildId: build.pk, + lineItems: selectedRows, + onFormSuccess: () => { + table.refreshTable(); + } + }); + + const deallocateStock = useCreateApiFormModal({ + url: ApiEndpoints.build_order_deallocate, + pk: build.pk, + title: t`Deallocate Stock`, + fields: { + build_line: { + hidden: true + }, + output: { + hidden: true, + value: null + } + }, + initialData: { + build_line: selectedLine + }, + preFormContent: ( + + {selectedLine == undefined ? ( + {t`Deallocate all untracked stock for this build order`} + ) : ( + {t`Deallocate stock from the selected line item`} + )} + + ), + successMessage: t`Stock has been deallocated`, + table: table + }); + const rowActions = useCallback( (record: any): RowAction[] => { let part = record.part_detail ?? {}; @@ -280,6 +365,11 @@ export default function BuildLineTable({ return []; } + // Only allow actions when build is in production + if (!build?.status || build.status != buildStatus.PRODUCTION) { + return []; + } + const hasOutput = !!outputId; // Can allocate @@ -288,6 +378,12 @@ export default function BuildLineTable({ record.allocated < record.quantity && record.trackable == hasOutput; + // Can de-allocate + let canDeallocate = + user.hasChangeRole(UserRoles.build) && + record.allocated > 0 && + record.trackable == hasOutput; + let canOrder = user.hasAddRole(UserRoles.purchase_order) && part.purchaseable; let canBuild = user.hasAddRole(UserRoles.build) && part.assembly; @@ -298,7 +394,20 @@ export default function BuildLineTable({ title: t`Allocate Stock`, hidden: !canAllocate, color: 'green', - onClick: notYetImplemented + onClick: () => { + setSelectedRows([record]); + allowcateStock.open(); + } + }, + { + icon: , + title: t`Deallocate Stock`, + hidden: !canDeallocate, + color: 'red', + onClick: () => { + setSelectedLine(record.pk); + deallocateStock.open(); + } }, { icon: , @@ -323,12 +432,67 @@ export default function BuildLineTable({ } ]; }, - [user, outputId] + [user, outputId, build, buildStatus] ); + const tableActions = useMemo(() => { + const production = build.status == buildStatus.PRODUCTION; + const canEdit = user.hasChangeRole(UserRoles.build); + const visible = production && canEdit; + return [ + } + tooltip={t`Auto Allocate Stock`} + hidden={!visible} + color="blue" + onClick={() => { + autoAllocateStock.open(); + }} + />, + } + tooltip={t`Allocate Stock`} + hidden={!visible} + disabled={!table.hasSelectedRecords} + color="green" + onClick={() => { + setSelectedRows( + table.selectedRecords.filter( + (r) => + r.allocated < r.quantity && + !r.trackable && + !r.bom_item_detail.consumable + ) + ); + allowcateStock.open(); + }} + />, + } + tooltip={t`Deallocate Stock`} + hidden={!visible} + disabled={table.hasSelectedRecords} + color="red" + onClick={() => { + setSelectedLine(null); + deallocateStock.open(); + }} + /> + ]; + }, [ + user, + build, + buildStatus, + table.hasSelectedRecords, + table.selectedRecords + ]); + return ( <> + {autoAllocateStock.modal} {newBuildOrder.modal} + {allowcateStock.modal} + {deallocateStock.modal}