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