[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
This commit is contained in:
Oliver 2024-08-24 15:17:05 +10:00 committed by GitHub
parent ebb01c5e5b
commit eec53ffd82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 595 additions and 112 deletions

View File

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

View File

@ -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 (
<ActionButton
onClick={onClick}
icon={<InvenTreeIcon icon="square_x" />}
tooltip={tooltip}
tooltipAlignment="top"
color="red"
/>
);
}

View File

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

View File

@ -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 (
<FormProvider {...form}>
<ApiFormField
fieldName="field"
fieldName={name}
definition={fieldDefinition}
control={form.control}
hideLabels={hideLabels}
setFields={undefined}
/>
</FormProvider>
);

View File

@ -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 (
<RelatedModelField
@ -236,7 +236,7 @@ export function ApiFormField({
checked={booleanValue}
ref={ref}
id={fieldId}
aria-label={`boolean-field-${field.name}`}
aria-label={`boolean-field-${fieldName}`}
radius="lg"
size="sm"
error={error?.message}
@ -322,16 +322,30 @@ export function ApiFormField({
</Alert>
);
}
}
}, [
booleanValue,
control,
controller,
field,
fieldId,
fieldName,
fieldDefinition,
numericalValue,
onChange,
reducedDefinition,
ref,
setFields,
value
]);
if (definition.hidden) {
if (fieldDefinition.hidden) {
return null;
}
return (
<Stack>
{definition.preFieldContent}
{buildField()}
{fieldInstance}
{definition.postFieldContent}
</Stack>
);

View File

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

View File

@ -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<FieldValues, any>;
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 (
<Table highlightOnHover striped aria-label={`table-field-${field.name}`}>
<Table.Thead>
@ -49,18 +68,21 @@ export function TableField({
// Table fields require render function
if (!definition.modelRenderer) {
return (
<Table.Tr>{t`modelRenderer entry required for tables`}</Table.Tr>
<Table.Tr key="table-row-no-renderer">{t`modelRenderer entry required for tables`}</Table.Tr>
);
}
return definition.modelRenderer({
item: item,
idx: idx,
rowErrors: rowErrors(idx),
control: control,
changeFn: onRowFieldChange,
removeFn: removeRow
});
})
) : (
<Table.Tr>
<Table.Tr key="table-row-no-entries">
<Table.Td
style={{ textAlign: 'center' }}
colSpan={definition.headers?.length}

View File

@ -18,8 +18,11 @@ export function ProgressBar(props: Readonly<ProgressBarProps>) {
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 (

View File

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

View File

@ -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={<InvenTreeIcon icon="cancel" />}
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<any[]>([]);
const [location, setLocation] = useState<number | null>(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<number | null>(null);
const [selectedOutputs, setSelectedOutputs] = useState<any[]>([]);
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<any[]>([]);
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({
<Alert color="red" title={t`Cancel Build Outputs`}>
<Text>{t`Selected build outputs will be deleted`}</Text>
</Alert>
{buildOutputFormTable(selectedOutputs, removeOutput)}
{buildOutputFormTable(selectedRows, removeRow)}
</Stack>
);
}, [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 (
<DataTable
idAccessor="pk"
records={outputs}
columns={[
{
accessor: 'part',
title: t`Part`,
render: (record: any) => PartColumn(record.part_detail)
},
{
accessor: 'allocated',
title: t`Allocated`,
render: (record: any) => (
<ProgressBar
value={record.allocated}
maximum={record.quantity}
progressLabel
/>
)
},
{
accessor: 'actions',
title: '',
render: (record: any) => (
<ActionButton
key={`remove-line-${record.pk}`}
tooltip={t`Remove line`}
icon={<InvenTreeIcon icon="cancel" />}
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 (
<>
<Table.Tr key={`table-row-${record.pk}`}>
<Table.Td>{partDetail}</Table.Td>
<Table.Td>
<ProgressBar
value={record.allocated}
maximum={record.quantity}
progressLabel
/>
</Table.Td>
<Table.Td>
<StandaloneField
fieldDefinition={stockField}
error={props.rowErrors?.stock_item?.message}
/>
</Table.Td>
<Table.Td>
<StandaloneField
fieldDefinition={quantityField}
error={props.rowErrors?.quantity?.message}
/>
</Table.Td>
<Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
</Table.Td>
</Table.Tr>
</>
);
}
/*
* 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<number | undefined>(
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 (
<BuildAllocateLineRow
props={row}
record={record}
sourceLocation={sourceLocation}
/>
);
}
}
};
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 (
<Stack gap="xs">
<StandaloneField fieldDefinition={sourceLocationField} />
</Stack>
);
}, [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%'
});
}

View File

@ -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()}
/>
)}
<ActionButton
onClick={() => input.removeFn(input.idx)}
icon={<InvenTreeIcon icon="square_x" />}
tooltip={t`Remove item from list`}
tooltipAlignment="top"
color="red"
/>
<RemoveRowButton onClick={() => input.removeFn(input.idx)} />
</Flex>
</Table.Td>
</Table.Tr>

View File

@ -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'}
/>
)}
<ActionButton
onClick={() => input.removeFn(input.idx)}
icon={<InvenTreeIcon icon="square_x" />}
tooltip={t`Remove item from list`}
tooltipAlignment="top"
color="red"
/>
<RemoveRowButton onClick={() => input.removeFn(input.idx)} />
</Flex>
</Table.Td>
</Table.Tr>

View File

@ -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<T>({
rows,
pkField = 'pk'
}: {
rows: T[];
pkField?: string;
}) {
const [selectedRows, setSelectedRows] = useState<T[]>(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
};
}

View File

@ -253,7 +253,7 @@ export default function BuildDetail() {
label: t`Line Items`,
icon: <IconListNumbers />,
content: build?.pk ? (
<BuildLineTable buildId={build.pk} />
<BuildLineTable build={build} buildId={build.pk} />
) : (
<Skeleton />
)

View File

@ -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 (
<Group justify="space-between">
<Group justify="space-between" wrap="nowrap">
<Text>{record.bom_item_detail?.quantity}</Text>
{record?.part_detail?.units && (
<Text size="xs">[{record.part_detail.units}]</Text>
@ -223,9 +234,10 @@ export default function BuildLineTable({
{
accessor: 'quantity',
sortable: true,
switchable: false,
render: (record: any) => {
return (
<Group justify="space-between">
<Group justify="space-between" wrap="nowrap">
<Text>{record.quantity}</Text>
{record?.part_detail?.units && (
<Text size="xs">[{record.part_detail.units}]</Text>
@ -262,6 +274,10 @@ export default function BuildLineTable({
const [initialData, setInitialData] = useState<any>({});
const [selectedLine, setSelectedLine] = useState<number | null>(null);
const [selectedRows, setSelectedRows] = useState<any[]>([]);
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: (
<Alert color="green" title={t`Auto Allocate Stock`}>
<Text>{t`Automatically allocate stock to this build according to the selected options`}</Text>
</Alert>
)
});
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: (
<Alert color="red" title={t`Deallocate Stock`}>
{selectedLine == undefined ? (
<Text>{t`Deallocate all untracked stock for this build order`}</Text>
) : (
<Text>{t`Deallocate stock from the selected line item`}</Text>
)}
</Alert>
),
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: <IconCircleMinus />,
title: t`Deallocate Stock`,
hidden: !canDeallocate,
color: 'red',
onClick: () => {
setSelectedLine(record.pk);
deallocateStock.open();
}
},
{
icon: <IconShoppingCart />,
@ -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 [
<ActionButton
icon={<IconWand />}
tooltip={t`Auto Allocate Stock`}
hidden={!visible}
color="blue"
onClick={() => {
autoAllocateStock.open();
}}
/>,
<ActionButton
icon={<IconArrowRight />}
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();
}}
/>,
<ActionButton
icon={<IconCircleMinus />}
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}
<InvenTreeTable
url={apiUrl(ApiEndpoints.build_line_list)}
tableState={table}
@ -339,11 +503,11 @@ export default function BuildLineTable({
build: buildId,
part_detail: true
},
tableActions: tableActions,
tableFilters: tableFilters,
rowActions: rowActions,
modelType: ModelType.part,
modelField: 'part_detail.pk',
enableDownload: true
enableDownload: true,
enableSelection: true
}}
/>
</>