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
|
data = self.validated_data
|
||||||
|
|
||||||
build.deallocate_stock(
|
build.deallocate_stock(
|
||||||
build_line=data['build_line'],
|
build_line=data.get('build_line', None),
|
||||||
output=data['output']
|
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 (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(', ') });
|
form.setError(path, { message: v.join(', ') });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
processErrors(v, path);
|
processErrors(v, path);
|
||||||
}
|
}
|
||||||
|
@ -1,37 +1,53 @@
|
|||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { ApiFormField, ApiFormFieldType } from './fields/ApiFormField';
|
import { ApiFormField, ApiFormFieldType } from './fields/ApiFormField';
|
||||||
|
|
||||||
export function StandaloneField({
|
export function StandaloneField({
|
||||||
fieldDefinition,
|
fieldDefinition,
|
||||||
|
fieldName = 'field',
|
||||||
defaultValue,
|
defaultValue,
|
||||||
hideLabels
|
hideLabels,
|
||||||
|
error
|
||||||
}: {
|
}: {
|
||||||
fieldDefinition: ApiFormFieldType;
|
fieldDefinition: ApiFormFieldType;
|
||||||
|
fieldName?: string;
|
||||||
defaultValue?: any;
|
defaultValue?: any;
|
||||||
hideLabels?: boolean;
|
hideLabels?: boolean;
|
||||||
|
error?: string;
|
||||||
}) {
|
}) {
|
||||||
|
// Field must have a defined name
|
||||||
|
const name = useMemo(() => fieldName ?? 'field', [fieldName]);
|
||||||
|
|
||||||
const defaultValues = useMemo(() => {
|
const defaultValues = useMemo(() => {
|
||||||
if (defaultValue)
|
if (defaultValue)
|
||||||
return {
|
return {
|
||||||
field: defaultValue
|
[name]: defaultValue
|
||||||
};
|
};
|
||||||
return {};
|
return {};
|
||||||
}, [defaultValue]);
|
}, [defaultValue]);
|
||||||
|
|
||||||
const form = useForm<{}>({
|
const form = useForm({
|
||||||
criteriaMode: 'all',
|
criteriaMode: 'all',
|
||||||
defaultValues
|
defaultValues
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.clearErrors();
|
||||||
|
|
||||||
|
if (!!error) {
|
||||||
|
form.setError(name, { message: error });
|
||||||
|
}
|
||||||
|
}, [form, error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<ApiFormField
|
<ApiFormField
|
||||||
fieldName="field"
|
fieldName={name}
|
||||||
definition={fieldDefinition}
|
definition={fieldDefinition}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
hideLabels={hideLabels}
|
hideLabels={hideLabels}
|
||||||
|
setFields={undefined}
|
||||||
/>
|
/>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
);
|
);
|
||||||
|
@ -204,8 +204,8 @@ export function ApiFormField({
|
|||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
// Construct the individual field
|
// Construct the individual field
|
||||||
function buildField() {
|
const fieldInstance = useMemo(() => {
|
||||||
switch (definition.field_type) {
|
switch (fieldDefinition.field_type) {
|
||||||
case 'related field':
|
case 'related field':
|
||||||
return (
|
return (
|
||||||
<RelatedModelField
|
<RelatedModelField
|
||||||
@ -236,7 +236,7 @@ export function ApiFormField({
|
|||||||
checked={booleanValue}
|
checked={booleanValue}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={fieldId}
|
id={fieldId}
|
||||||
aria-label={`boolean-field-${field.name}`}
|
aria-label={`boolean-field-${fieldName}`}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
size="sm"
|
size="sm"
|
||||||
error={error?.message}
|
error={error?.message}
|
||||||
@ -322,16 +322,30 @@ export function ApiFormField({
|
|||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}, [
|
||||||
|
booleanValue,
|
||||||
|
control,
|
||||||
|
controller,
|
||||||
|
field,
|
||||||
|
fieldId,
|
||||||
|
fieldName,
|
||||||
|
fieldDefinition,
|
||||||
|
numericalValue,
|
||||||
|
onChange,
|
||||||
|
reducedDefinition,
|
||||||
|
ref,
|
||||||
|
setFields,
|
||||||
|
value
|
||||||
|
]);
|
||||||
|
|
||||||
if (definition.hidden) {
|
if (fieldDefinition.hidden) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
{definition.preFieldContent}
|
{definition.preFieldContent}
|
||||||
{buildField()}
|
{fieldInstance}
|
||||||
{definition.postFieldContent}
|
{definition.postFieldContent}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
@ -207,7 +207,7 @@ export function RelatedModelField({
|
|||||||
setPk(_pk);
|
setPk(_pk);
|
||||||
|
|
||||||
// Run custom callback for this field (if provided)
|
// Run custom callback for this field (if provided)
|
||||||
definition.onValueChange?.(_pk, value.data ?? {});
|
definition.onValueChange?.(_pk, value?.data ?? {});
|
||||||
},
|
},
|
||||||
[field.onChange, definition]
|
[field.onChange, definition]
|
||||||
);
|
);
|
||||||
|
@ -1,12 +1,21 @@
|
|||||||
import { Trans, t } from '@lingui/macro';
|
import { Trans, t } from '@lingui/macro';
|
||||||
import { Container, Group, Table } from '@mantine/core';
|
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 { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||||
|
|
||||||
import { InvenTreeIcon } from '../../../functions/icons';
|
import { InvenTreeIcon } from '../../../functions/icons';
|
||||||
import { StandaloneField } from '../StandaloneField';
|
import { StandaloneField } from '../StandaloneField';
|
||||||
import { ApiFormFieldType } from './ApiFormField';
|
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({
|
export function TableField({
|
||||||
definition,
|
definition,
|
||||||
fieldName,
|
fieldName,
|
||||||
@ -34,6 +43,16 @@ export function TableField({
|
|||||||
field.onChange(val);
|
field.onChange(val);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Extract errors associated with the current row
|
||||||
|
const rowErrors = useCallback(
|
||||||
|
(idx: number) => {
|
||||||
|
if (Array.isArray(error)) {
|
||||||
|
return error[idx];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[error]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table highlightOnHover striped aria-label={`table-field-${field.name}`}>
|
<Table highlightOnHover striped aria-label={`table-field-${field.name}`}>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
@ -49,18 +68,21 @@ export function TableField({
|
|||||||
// Table fields require render function
|
// Table fields require render function
|
||||||
if (!definition.modelRenderer) {
|
if (!definition.modelRenderer) {
|
||||||
return (
|
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({
|
return definition.modelRenderer({
|
||||||
item: item,
|
item: item,
|
||||||
idx: idx,
|
idx: idx,
|
||||||
|
rowErrors: rowErrors(idx),
|
||||||
|
control: control,
|
||||||
changeFn: onRowFieldChange,
|
changeFn: onRowFieldChange,
|
||||||
removeFn: removeRow
|
removeFn: removeRow
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<Table.Tr>
|
<Table.Tr key="table-row-no-entries">
|
||||||
<Table.Td
|
<Table.Td
|
||||||
style={{ textAlign: 'center' }}
|
style={{ textAlign: 'center' }}
|
||||||
colSpan={definition.headers?.length}
|
colSpan={definition.headers?.length}
|
||||||
|
@ -18,8 +18,11 @@ export function ProgressBar(props: Readonly<ProgressBarProps>) {
|
|||||||
let maximum = props.maximum ?? 100;
|
let maximum = props.maximum ?? 100;
|
||||||
let value = Math.max(props.value, 0);
|
let value = Math.max(props.value, 0);
|
||||||
|
|
||||||
// Calculate progress as a percentage of the maximum value
|
if (maximum == 0) {
|
||||||
return Math.min(100, (value / maximum) * 100);
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (value / maximum) * 100;
|
||||||
}, [props]);
|
}, [props]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -74,6 +74,9 @@ export enum ApiEndpoints {
|
|||||||
build_output_create = 'build/:id/create-output/',
|
build_output_create = 'build/:id/create-output/',
|
||||||
build_output_scrap = 'build/:id/scrap-outputs/',
|
build_output_scrap = 'build/:id/scrap-outputs/',
|
||||||
build_output_delete = 'build/:id/delete-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_line_list = 'build/line/',
|
||||||
build_item_list = 'build/item/',
|
build_item_list = 'build/item/',
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Alert, Stack, Text } from '@mantine/core';
|
import { Alert, Stack, Table, Text } from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconLink,
|
IconLink,
|
||||||
@ -10,16 +10,26 @@ import {
|
|||||||
IconUsersGroup
|
IconUsersGroup
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { DataTable } from 'mantine-datatable';
|
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 { api } from '../App';
|
||||||
import { ActionButton } from '../components/buttons/ActionButton';
|
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 { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||||
import { ModelType } from '../enums/ModelType';
|
import { ModelType } from '../enums/ModelType';
|
||||||
|
import { resolveItem } from '../functions/conversion';
|
||||||
import { InvenTreeIcon } from '../functions/icons';
|
import { InvenTreeIcon } from '../functions/icons';
|
||||||
import { useCreateApiFormModal } from '../hooks/UseForm';
|
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||||
import { useBatchCodeGenerator } from '../hooks/UseGenerator';
|
import { useBatchCodeGenerator } from '../hooks/UseGenerator';
|
||||||
|
import { useSelectedRows } from '../hooks/UseSelectedRows';
|
||||||
import { apiUrl } from '../states/ApiState';
|
import { apiUrl } from '../states/ApiState';
|
||||||
import { useGlobalSettingsState } from '../states/SettingsState';
|
import { useGlobalSettingsState } from '../states/SettingsState';
|
||||||
import { PartColumn, StatusColumn } from '../tables/ColumnRenderers';
|
import { PartColumn, StatusColumn } from '../tables/ColumnRenderers';
|
||||||
@ -240,7 +250,7 @@ function buildOutputFormTable(outputs: any[], onRemove: (output: any) => void) {
|
|||||||
tooltip={t`Remove output`}
|
tooltip={t`Remove output`}
|
||||||
icon={<InvenTreeIcon icon="cancel" />}
|
icon={<InvenTreeIcon icon="cancel" />}
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => onRemove(record)}
|
onClick={() => onRemove(record.pk)}
|
||||||
disabled={outputs.length <= 1}
|
disabled={outputs.length <= 1}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -259,13 +269,11 @@ export function useCompleteBuildOutputsForm({
|
|||||||
outputs: any[];
|
outputs: any[];
|
||||||
onFormSuccess: (response: any) => void;
|
onFormSuccess: (response: any) => void;
|
||||||
}) {
|
}) {
|
||||||
const [selectedOutputs, setSelectedOutputs] = useState<any[]>([]);
|
|
||||||
|
|
||||||
const [location, setLocation] = useState<number | null>(null);
|
const [location, setLocation] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const { selectedRows, removeRow } = useSelectedRows({
|
||||||
setSelectedOutputs(outputs);
|
rows: outputs
|
||||||
}, [outputs]);
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location) {
|
if (location) {
|
||||||
@ -277,25 +285,15 @@ export function useCompleteBuildOutputsForm({
|
|||||||
);
|
);
|
||||||
}, [location, build.destination, build.part_detail]);
|
}, [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(() => {
|
const preFormContent = useMemo(() => {
|
||||||
return buildOutputFormTable(selectedOutputs, removeOutput);
|
return buildOutputFormTable(selectedRows, removeRow);
|
||||||
}, [selectedOutputs, removeOutput]);
|
}, [selectedRows, removeRow]);
|
||||||
|
|
||||||
const buildOutputCompleteFields: ApiFormFieldSet = useMemo(() => {
|
const buildOutputCompleteFields: ApiFormFieldSet = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
outputs: {
|
outputs: {
|
||||||
hidden: true,
|
hidden: true,
|
||||||
value: selectedOutputs.map((output) => {
|
value: selectedRows.map((output: any) => {
|
||||||
return {
|
return {
|
||||||
output: output.pk
|
output: output.pk
|
||||||
};
|
};
|
||||||
@ -314,7 +312,7 @@ export function useCompleteBuildOutputsForm({
|
|||||||
notes: {},
|
notes: {},
|
||||||
accept_incomplete_allocation: {}
|
accept_incomplete_allocation: {}
|
||||||
};
|
};
|
||||||
}, [selectedOutputs, location]);
|
}, [selectedRows, location]);
|
||||||
|
|
||||||
return useCreateApiFormModal({
|
return useCreateApiFormModal({
|
||||||
url: apiUrl(ApiEndpoints.build_output_complete, build.pk),
|
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({
|
export function useScrapBuildOutputsForm({
|
||||||
build,
|
build,
|
||||||
outputs,
|
outputs,
|
||||||
@ -337,21 +338,10 @@ export function useScrapBuildOutputsForm({
|
|||||||
onFormSuccess: (response: any) => void;
|
onFormSuccess: (response: any) => void;
|
||||||
}) {
|
}) {
|
||||||
const [location, setLocation] = useState<number | null>(null);
|
const [location, setLocation] = useState<number | null>(null);
|
||||||
const [selectedOutputs, setSelectedOutputs] = useState<any[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { selectedRows, removeRow } = useSelectedRows({
|
||||||
setSelectedOutputs(outputs);
|
rows: outputs
|
||||||
}, [outputs]);
|
});
|
||||||
|
|
||||||
// Remove a selected output from the list
|
|
||||||
const removeOutput = useCallback(
|
|
||||||
(output: any) => {
|
|
||||||
setSelectedOutputs(
|
|
||||||
selectedOutputs.filter((item) => item.pk != output.pk)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[selectedOutputs]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location) {
|
if (location) {
|
||||||
@ -364,14 +354,14 @@ export function useScrapBuildOutputsForm({
|
|||||||
}, [location, build.destination, build.part_detail]);
|
}, [location, build.destination, build.part_detail]);
|
||||||
|
|
||||||
const preFormContent = useMemo(() => {
|
const preFormContent = useMemo(() => {
|
||||||
return buildOutputFormTable(selectedOutputs, removeOutput);
|
return buildOutputFormTable(selectedRows, removeRow);
|
||||||
}, [selectedOutputs, removeOutput]);
|
}, [selectedRows, removeRow]);
|
||||||
|
|
||||||
const buildOutputScrapFields: ApiFormFieldSet = useMemo(() => {
|
const buildOutputScrapFields: ApiFormFieldSet = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
outputs: {
|
outputs: {
|
||||||
hidden: true,
|
hidden: true,
|
||||||
value: selectedOutputs.map((output) => {
|
value: selectedRows.map((output: any) => {
|
||||||
return {
|
return {
|
||||||
output: output.pk,
|
output: output.pk,
|
||||||
quantity: output.quantity
|
quantity: output.quantity
|
||||||
@ -387,7 +377,7 @@ export function useScrapBuildOutputsForm({
|
|||||||
notes: {},
|
notes: {},
|
||||||
discard_allocations: {}
|
discard_allocations: {}
|
||||||
};
|
};
|
||||||
}, [location, selectedOutputs]);
|
}, [location, selectedRows]);
|
||||||
|
|
||||||
return useCreateApiFormModal({
|
return useCreateApiFormModal({
|
||||||
url: apiUrl(ApiEndpoints.build_output_scrap, build.pk),
|
url: apiUrl(ApiEndpoints.build_output_scrap, build.pk),
|
||||||
@ -409,21 +399,9 @@ export function useCancelBuildOutputsForm({
|
|||||||
outputs: any[];
|
outputs: any[];
|
||||||
onFormSuccess: (response: any) => void;
|
onFormSuccess: (response: any) => void;
|
||||||
}) {
|
}) {
|
||||||
const [selectedOutputs, setSelectedOutputs] = useState<any[]>([]);
|
const { selectedRows, removeRow } = useSelectedRows({
|
||||||
|
rows: outputs
|
||||||
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 preFormContent = useMemo(() => {
|
const preFormContent = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
@ -431,23 +409,23 @@ export function useCancelBuildOutputsForm({
|
|||||||
<Alert color="red" title={t`Cancel Build Outputs`}>
|
<Alert color="red" title={t`Cancel Build Outputs`}>
|
||||||
<Text>{t`Selected build outputs will be deleted`}</Text>
|
<Text>{t`Selected build outputs will be deleted`}</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
{buildOutputFormTable(selectedOutputs, removeOutput)}
|
{buildOutputFormTable(selectedRows, removeRow)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}, [selectedOutputs, removeOutput]);
|
}, [selectedRows, removeRow]);
|
||||||
|
|
||||||
const buildOutputCancelFields: ApiFormFieldSet = useMemo(() => {
|
const buildOutputCancelFields: ApiFormFieldSet = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
outputs: {
|
outputs: {
|
||||||
hidden: true,
|
hidden: true,
|
||||||
value: selectedOutputs.map((output) => {
|
value: selectedRows.map((output: any) => {
|
||||||
return {
|
return {
|
||||||
output: output.pk
|
output: output.pk
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [selectedOutputs]);
|
}, [selectedRows]);
|
||||||
|
|
||||||
return useCreateApiFormModal({
|
return useCreateApiFormModal({
|
||||||
url: apiUrl(ApiEndpoints.build_output_delete, build.pk),
|
url: apiUrl(ApiEndpoints.build_output_delete, build.pk),
|
||||||
@ -459,3 +437,231 @@ export function useCancelBuildOutputsForm({
|
|||||||
successMessage: t`Build outputs have been cancelled`
|
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 { api } from '../App';
|
||||||
import { ActionButton } from '../components/buttons/ActionButton';
|
import { ActionButton } from '../components/buttons/ActionButton';
|
||||||
|
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||||
import { StandaloneField } from '../components/forms/StandaloneField';
|
import { StandaloneField } from '../components/forms/StandaloneField';
|
||||||
import {
|
import {
|
||||||
ApiFormAdjustFilterType,
|
ApiFormAdjustFilterType,
|
||||||
@ -438,13 +439,7 @@ function LineItemFormRow({
|
|||||||
onClick={() => open()}
|
onClick={() => open()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ActionButton
|
<RemoveRowButton onClick={() => input.removeFn(input.idx)} />
|
||||||
onClick={() => input.removeFn(input.idx)}
|
|
||||||
icon={<InvenTreeIcon icon="square_x" />}
|
|
||||||
tooltip={t`Remove item from list`}
|
|
||||||
tooltipAlignment="top"
|
|
||||||
color="red"
|
|
||||||
/>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
@ -7,6 +7,7 @@ import { Suspense, useCallback, useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { api } from '../App';
|
import { api } from '../App';
|
||||||
import { ActionButton } from '../components/buttons/ActionButton';
|
import { ActionButton } from '../components/buttons/ActionButton';
|
||||||
|
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||||
import {
|
import {
|
||||||
ApiFormAdjustFilterType,
|
ApiFormAdjustFilterType,
|
||||||
ApiFormFieldSet
|
ApiFormFieldSet
|
||||||
@ -322,13 +323,6 @@ function StockOperationsRow({
|
|||||||
[item]
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const changeSubItem = useCallback(
|
|
||||||
(key: string, value: any) => {
|
|
||||||
input.changeFn(input.idx, key, value);
|
|
||||||
},
|
|
||||||
[input]
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeAndRefresh = () => {
|
const removeAndRefresh = () => {
|
||||||
input.removeFn(input.idx);
|
input.removeFn(input.idx);
|
||||||
};
|
};
|
||||||
@ -422,13 +416,7 @@ function StockOperationsRow({
|
|||||||
variant={packagingOpen ? 'filled' : 'transparent'}
|
variant={packagingOpen ? 'filled' : 'transparent'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ActionButton
|
<RemoveRowButton onClick={() => input.removeFn(input.idx)} />
|
||||||
onClick={() => input.removeFn(input.idx)}
|
|
||||||
icon={<InvenTreeIcon icon="square_x" />}
|
|
||||||
tooltip={t`Remove item from list`}
|
|
||||||
tooltipAlignment="top"
|
|
||||||
color="red"
|
|
||||||
/>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</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`,
|
label: t`Line Items`,
|
||||||
icon: <IconListNumbers />,
|
icon: <IconListNumbers />,
|
||||||
content: build?.pk ? (
|
content: build?.pk ? (
|
||||||
<BuildLineTable buildId={build.pk} />
|
<BuildLineTable build={build} buildId={build.pk} />
|
||||||
) : (
|
) : (
|
||||||
<Skeleton />
|
<Skeleton />
|
||||||
)
|
)
|
||||||
|
@ -1,19 +1,27 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Group, Text } from '@mantine/core';
|
import { Alert, Group, Text } from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
|
IconCircleMinus,
|
||||||
IconShoppingCart,
|
IconShoppingCart,
|
||||||
IconTool
|
IconTool,
|
||||||
|
IconTransferIn,
|
||||||
|
IconWand
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||||
import { ProgressBar } from '../../components/items/ProgressBar';
|
import { ProgressBar } from '../../components/items/ProgressBar';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import { ModelType } from '../../enums/ModelType';
|
import { ModelType } from '../../enums/ModelType';
|
||||||
import { UserRoles } from '../../enums/Roles';
|
import { UserRoles } from '../../enums/Roles';
|
||||||
import { useBuildOrderFields } from '../../forms/BuildForms';
|
import {
|
||||||
|
useAllocateStockToBuildForm,
|
||||||
|
useBuildOrderFields
|
||||||
|
} from '../../forms/BuildForms';
|
||||||
import { notYetImplemented } from '../../functions/notifications';
|
import { notYetImplemented } from '../../functions/notifications';
|
||||||
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
||||||
|
import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||||
import { useTable } from '../../hooks/UseTable';
|
import { useTable } from '../../hooks/UseTable';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
@ -26,15 +34,18 @@ import { TableHoverCard } from '../TableHoverCard';
|
|||||||
|
|
||||||
export default function BuildLineTable({
|
export default function BuildLineTable({
|
||||||
buildId,
|
buildId,
|
||||||
|
build,
|
||||||
outputId,
|
outputId,
|
||||||
params = {}
|
params = {}
|
||||||
}: {
|
}: {
|
||||||
buildId: number;
|
buildId: number;
|
||||||
|
build: any;
|
||||||
outputId?: number;
|
outputId?: number;
|
||||||
params?: any;
|
params?: any;
|
||||||
}) {
|
}) {
|
||||||
const table = useTable('buildline');
|
const table = useTable('buildline');
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
|
const buildStatus = useStatusCodes({ modelType: ModelType.build });
|
||||||
|
|
||||||
const tableFilters: TableFilter[] = useMemo(() => {
|
const tableFilters: TableFilter[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@ -211,7 +222,7 @@ export default function BuildLineTable({
|
|||||||
ordering: 'unit_quantity',
|
ordering: 'unit_quantity',
|
||||||
render: (record: any) => {
|
render: (record: any) => {
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Text>{record.bom_item_detail?.quantity}</Text>
|
<Text>{record.bom_item_detail?.quantity}</Text>
|
||||||
{record?.part_detail?.units && (
|
{record?.part_detail?.units && (
|
||||||
<Text size="xs">[{record.part_detail.units}]</Text>
|
<Text size="xs">[{record.part_detail.units}]</Text>
|
||||||
@ -223,9 +234,10 @@ export default function BuildLineTable({
|
|||||||
{
|
{
|
||||||
accessor: 'quantity',
|
accessor: 'quantity',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
switchable: false,
|
||||||
render: (record: any) => {
|
render: (record: any) => {
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Text>{record.quantity}</Text>
|
<Text>{record.quantity}</Text>
|
||||||
{record?.part_detail?.units && (
|
{record?.part_detail?.units && (
|
||||||
<Text size="xs">[{record.part_detail.units}]</Text>
|
<Text size="xs">[{record.part_detail.units}]</Text>
|
||||||
@ -262,6 +274,10 @@ export default function BuildLineTable({
|
|||||||
|
|
||||||
const [initialData, setInitialData] = useState<any>({});
|
const [initialData, setInitialData] = useState<any>({});
|
||||||
|
|
||||||
|
const [selectedLine, setSelectedLine] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
||||||
|
|
||||||
const newBuildOrder = useCreateApiFormModal({
|
const newBuildOrder = useCreateApiFormModal({
|
||||||
url: ApiEndpoints.build_order_list,
|
url: ApiEndpoints.build_order_list,
|
||||||
title: t`Create Build Order`,
|
title: t`Create Build Order`,
|
||||||
@ -271,6 +287,75 @@ export default function BuildLineTable({
|
|||||||
modelType: ModelType.build
|
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(
|
const rowActions = useCallback(
|
||||||
(record: any): RowAction[] => {
|
(record: any): RowAction[] => {
|
||||||
let part = record.part_detail ?? {};
|
let part = record.part_detail ?? {};
|
||||||
@ -280,6 +365,11 @@ export default function BuildLineTable({
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only allow actions when build is in production
|
||||||
|
if (!build?.status || build.status != buildStatus.PRODUCTION) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const hasOutput = !!outputId;
|
const hasOutput = !!outputId;
|
||||||
|
|
||||||
// Can allocate
|
// Can allocate
|
||||||
@ -288,6 +378,12 @@ export default function BuildLineTable({
|
|||||||
record.allocated < record.quantity &&
|
record.allocated < record.quantity &&
|
||||||
record.trackable == hasOutput;
|
record.trackable == hasOutput;
|
||||||
|
|
||||||
|
// Can de-allocate
|
||||||
|
let canDeallocate =
|
||||||
|
user.hasChangeRole(UserRoles.build) &&
|
||||||
|
record.allocated > 0 &&
|
||||||
|
record.trackable == hasOutput;
|
||||||
|
|
||||||
let canOrder =
|
let canOrder =
|
||||||
user.hasAddRole(UserRoles.purchase_order) && part.purchaseable;
|
user.hasAddRole(UserRoles.purchase_order) && part.purchaseable;
|
||||||
let canBuild = user.hasAddRole(UserRoles.build) && part.assembly;
|
let canBuild = user.hasAddRole(UserRoles.build) && part.assembly;
|
||||||
@ -298,7 +394,20 @@ export default function BuildLineTable({
|
|||||||
title: t`Allocate Stock`,
|
title: t`Allocate Stock`,
|
||||||
hidden: !canAllocate,
|
hidden: !canAllocate,
|
||||||
color: 'green',
|
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 />,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{autoAllocateStock.modal}
|
||||||
{newBuildOrder.modal}
|
{newBuildOrder.modal}
|
||||||
|
{allowcateStock.modal}
|
||||||
|
{deallocateStock.modal}
|
||||||
<InvenTreeTable
|
<InvenTreeTable
|
||||||
url={apiUrl(ApiEndpoints.build_line_list)}
|
url={apiUrl(ApiEndpoints.build_line_list)}
|
||||||
tableState={table}
|
tableState={table}
|
||||||
@ -339,11 +503,11 @@ export default function BuildLineTable({
|
|||||||
build: buildId,
|
build: buildId,
|
||||||
part_detail: true
|
part_detail: true
|
||||||
},
|
},
|
||||||
|
tableActions: tableActions,
|
||||||
tableFilters: tableFilters,
|
tableFilters: tableFilters,
|
||||||
rowActions: rowActions,
|
rowActions: rowActions,
|
||||||
modelType: ModelType.part,
|
enableDownload: true,
|
||||||
modelField: 'part_detail.pk',
|
enableSelection: true
|
||||||
enableDownload: true
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
Loading…
Reference in New Issue
Block a user