mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
[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:
parent
ebb01c5e5b
commit
eec53ffd82
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
22
src/frontend/src/components/buttons/RemoveRowButton.tsx
Normal file
22
src/frontend/src/components/buttons/RemoveRowButton.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
@ -502,7 +502,20 @@ export function ApiForm({
|
||||
}
|
||||
|
||||
if (typeof v === 'object' && Array.isArray(v)) {
|
||||
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);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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]
|
||||
);
|
||||
|
@ -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}
|
||||
|
@ -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 (
|
||||
|
@ -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/',
|
||||
|
||||
|
@ -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%'
|
||||
});
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
37
src/frontend/src/hooks/UseSelectedRows.tsx
Normal file
37
src/frontend/src/hooks/UseSelectedRows.tsx
Normal 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
|
||||
};
|
||||
}
|
@ -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 />
|
||||
)
|
||||
|
@ -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
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
Loading…
Reference in New Issue
Block a user