mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-16 17:28:11 +00:00
Make parametric table generic
This commit is contained in:
@@ -111,8 +111,6 @@ export enum ApiEndpoints {
|
|||||||
|
|
||||||
// Part API endpoints
|
// Part API endpoints
|
||||||
part_list = 'part/',
|
part_list = 'part/',
|
||||||
part_parameter_list = 'part/parameter/',
|
|
||||||
part_parameter_template_list = 'part/parameter/template/',
|
|
||||||
part_thumbs_list = 'part/thumbs/',
|
part_thumbs_list = 'part/thumbs/',
|
||||||
part_pricing = 'part/:id/pricing/',
|
part_pricing = 'part/:id/pricing/',
|
||||||
part_requirements = 'part/:id/requirements/',
|
part_requirements = 'part/:id/requirements/',
|
||||||
|
|||||||
@@ -33,18 +33,18 @@ export const ModelInformationDict: ModelDict = {
|
|||||||
admin_url: '/part/part/',
|
admin_url: '/part/part/',
|
||||||
icon: 'part'
|
icon: 'part'
|
||||||
},
|
},
|
||||||
|
parameter: {
|
||||||
|
label: () => t`Parameter`,
|
||||||
|
label_multiple: () => t`Parameters`,
|
||||||
|
api_endpoint: ApiEndpoints.parameter_list,
|
||||||
|
icon: 'list_details'
|
||||||
|
},
|
||||||
parametertemplate: {
|
parametertemplate: {
|
||||||
label: () => t`Parameter Template`,
|
label: () => t`Parameter Template`,
|
||||||
label_multiple: () => t`Parameter Templates`,
|
label_multiple: () => t`Parameter Templates`,
|
||||||
api_endpoint: ApiEndpoints.parameter_template_list
|
api_endpoint: ApiEndpoints.parameter_template_list,
|
||||||
},
|
admin_url: '/common/parametertemplate/',
|
||||||
partparametertemplate: {
|
icon: 'list'
|
||||||
label: () => t`Part Parameter Template`,
|
|
||||||
label_multiple: () => t`Part Parameter Templates`,
|
|
||||||
url_overview: '/settings/admin/part-parameters',
|
|
||||||
url_detail: '/partparametertemplate/:pk/',
|
|
||||||
api_endpoint: ApiEndpoints.part_parameter_template_list,
|
|
||||||
icon: 'test_templates'
|
|
||||||
},
|
},
|
||||||
parttesttemplate: {
|
parttesttemplate: {
|
||||||
label: () => t`Part Test Template`,
|
label: () => t`Part Test Template`,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export enum ModelType {
|
|||||||
supplierpart = 'supplierpart',
|
supplierpart = 'supplierpart',
|
||||||
manufacturerpart = 'manufacturerpart',
|
manufacturerpart = 'manufacturerpart',
|
||||||
partcategory = 'partcategory',
|
partcategory = 'partcategory',
|
||||||
partparametertemplate = 'partparametertemplate',
|
|
||||||
parttesttemplate = 'parttesttemplate',
|
parttesttemplate = 'parttesttemplate',
|
||||||
projectcode = 'projectcode',
|
projectcode = 'projectcode',
|
||||||
stockitem = 'stockitem',
|
stockitem = 'stockitem',
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
|
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { IconBuildingStore, IconCopy, IconPackages } from '@tabler/icons-react';
|
import { IconBuildingStore, IconCopy, IconPackages } from '@tabler/icons-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
|
||||||
import { apiUrl } from '@lib/functions/Api';
|
|
||||||
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
|
||||||
import { useApi } from '../contexts/ApiContext';
|
|
||||||
import { useGlobalSettingsState } from '../states/SettingsStates';
|
import { useGlobalSettingsState } from '../states/SettingsStates';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,97 +220,6 @@ export function partCategoryFields({
|
|||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePartParameterFields({
|
|
||||||
editTemplate
|
|
||||||
}: {
|
|
||||||
editTemplate?: boolean;
|
|
||||||
}): ApiFormFieldSet {
|
|
||||||
const api = useApi();
|
|
||||||
|
|
||||||
// Valid field choices
|
|
||||||
const [choices, setChoices] = useState<any[]>([]);
|
|
||||||
|
|
||||||
// Field type for "data" input
|
|
||||||
const [fieldType, setFieldType] = useState<'string' | 'boolean' | 'choice'>(
|
|
||||||
'string'
|
|
||||||
);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
return {
|
|
||||||
part: {
|
|
||||||
disabled: true
|
|
||||||
},
|
|
||||||
template: {
|
|
||||||
disabled: editTemplate == false,
|
|
||||||
onValueChange: (value: any, record: any) => {
|
|
||||||
// Adjust the type of the "data" field based on the selected template
|
|
||||||
if (record?.checkbox) {
|
|
||||||
// This is a "checkbox" field
|
|
||||||
setChoices([]);
|
|
||||||
setFieldType('boolean');
|
|
||||||
} else if (record?.choices) {
|
|
||||||
const _choices: string[] = record.choices.split(',');
|
|
||||||
|
|
||||||
if (_choices.length > 0) {
|
|
||||||
setChoices(
|
|
||||||
_choices.map((choice) => {
|
|
||||||
return {
|
|
||||||
display_name: choice.trim(),
|
|
||||||
value: choice.trim()
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setFieldType('choice');
|
|
||||||
} else {
|
|
||||||
setChoices([]);
|
|
||||||
setFieldType('string');
|
|
||||||
}
|
|
||||||
} else if (record?.selectionlist) {
|
|
||||||
api
|
|
||||||
.get(
|
|
||||||
apiUrl(ApiEndpoints.selectionlist_detail, record.selectionlist)
|
|
||||||
)
|
|
||||||
.then((res) => {
|
|
||||||
setChoices(
|
|
||||||
res.data.choices.map((item: any) => {
|
|
||||||
return {
|
|
||||||
value: item.value,
|
|
||||||
display_name: item.label
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setFieldType('choice');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setChoices([]);
|
|
||||||
setFieldType('string');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
type: fieldType,
|
|
||||||
field_type: fieldType,
|
|
||||||
choices: fieldType === 'choice' ? choices : undefined,
|
|
||||||
default: fieldType === 'boolean' ? false : undefined,
|
|
||||||
adjustValue: (value: any) => {
|
|
||||||
// Coerce boolean value into a string (required by backend)
|
|
||||||
|
|
||||||
let v: string = value.toString().trim();
|
|
||||||
|
|
||||||
if (fieldType === 'boolean') {
|
|
||||||
if (v.toLowerCase() !== 'true') {
|
|
||||||
v = 'false';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
note: {}
|
|
||||||
};
|
|
||||||
}, [editTemplate, fieldType, choices]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function partStocktakeFields(): ApiFormFieldSet {
|
export function partStocktakeFields(): ApiFormFieldSet {
|
||||||
return {
|
return {
|
||||||
part: {
|
part: {
|
||||||
|
|||||||
@@ -312,7 +312,11 @@ export default function CategoryDetail() {
|
|||||||
name: 'parameters',
|
name: 'parameters',
|
||||||
label: t`Part Parameters`,
|
label: t`Part Parameters`,
|
||||||
icon: <IconListDetails />,
|
icon: <IconListDetails />,
|
||||||
content: <ParametricPartTable categoryId={id} />
|
content: (
|
||||||
|
<>
|
||||||
|
<ParametricPartTable categoryId={id} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[category, id]
|
[category, id]
|
||||||
|
|||||||
409
src/frontend/src/tables/general/ParametricDataTable.tsx
Normal file
409
src/frontend/src/tables/general/ParametricDataTable.tsx
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
import { cancelEvent } from '@lib/functions/Events';
|
||||||
|
import {
|
||||||
|
ApiEndpoints,
|
||||||
|
type ApiFormFieldSet,
|
||||||
|
ModelType,
|
||||||
|
UserRoles,
|
||||||
|
YesNoButton,
|
||||||
|
apiUrl,
|
||||||
|
formatDecimal,
|
||||||
|
getDetailUrl,
|
||||||
|
navigateToLink
|
||||||
|
} from '@lib/index';
|
||||||
|
import type { TableFilter } from '@lib/types/Filters';
|
||||||
|
import type { TableColumn } from '@lib/types/Tables';
|
||||||
|
import { t } from '@lingui/core/macro';
|
||||||
|
import { Group } from '@mantine/core';
|
||||||
|
import { useHover } from '@mantine/hooks';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useApi } from '../../contexts/ApiContext';
|
||||||
|
import { useParameterFields } from '../../forms/CommonForms';
|
||||||
|
import {
|
||||||
|
useCreateApiFormModal,
|
||||||
|
useEditApiFormModal
|
||||||
|
} from '../../hooks/UseForm';
|
||||||
|
import { useTable } from '../../hooks/UseTable';
|
||||||
|
import { useUserState } from '../../states/UserState';
|
||||||
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
|
import { TableHoverCard } from '../TableHoverCard';
|
||||||
|
import {
|
||||||
|
PARAMETER_FILTER_OPERATORS,
|
||||||
|
ParameterFilter
|
||||||
|
} from './ParametricDataTableFilters';
|
||||||
|
|
||||||
|
// Render an individual parameter cell
|
||||||
|
function ParameterCell({
|
||||||
|
record,
|
||||||
|
template,
|
||||||
|
canEdit
|
||||||
|
}: Readonly<{
|
||||||
|
record: any;
|
||||||
|
template: any;
|
||||||
|
canEdit: boolean;
|
||||||
|
}>) {
|
||||||
|
const { hovered, ref } = useHover();
|
||||||
|
|
||||||
|
// Find matching template parameter
|
||||||
|
const parameter = useMemo(() => {
|
||||||
|
return record.parameters?.find((p: any) => p.template == template.pk);
|
||||||
|
}, [record, template]);
|
||||||
|
|
||||||
|
const extra: any[] = [];
|
||||||
|
|
||||||
|
// Format the value for display
|
||||||
|
const value: ReactNode = useMemo(() => {
|
||||||
|
let v: any = parameter?.data;
|
||||||
|
|
||||||
|
// Handle boolean values
|
||||||
|
if (template?.checkbox && v != undefined) {
|
||||||
|
v = <YesNoButton value={parameter.data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return v;
|
||||||
|
}, [parameter, template]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
template.units &&
|
||||||
|
parameter &&
|
||||||
|
parameter.data_numeric &&
|
||||||
|
parameter.data_numeric != parameter.data
|
||||||
|
) {
|
||||||
|
const numeric = formatDecimal(parameter.data_numeric, { digits: 15 });
|
||||||
|
extra.push(`${numeric} [${template.units}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hovered && canEdit) {
|
||||||
|
extra.push(t`Click to edit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Group grow ref={ref} justify='space-between'>
|
||||||
|
<Group grow>
|
||||||
|
<TableHoverCard
|
||||||
|
value={value ?? '-'}
|
||||||
|
extra={extra}
|
||||||
|
icon={hovered && canEdit ? 'edit' : 'info'}
|
||||||
|
title={template.name}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A table which displays parametric data for generic model types.
|
||||||
|
* The table can be extended by passing in additional column, filters, and actions.
|
||||||
|
*/
|
||||||
|
export default function ParametricDataTable({
|
||||||
|
modelType,
|
||||||
|
endpoint,
|
||||||
|
queryParams,
|
||||||
|
customFilters,
|
||||||
|
customColumns
|
||||||
|
}: {
|
||||||
|
modelType: ModelType;
|
||||||
|
endpoint: ApiEndpoints | string;
|
||||||
|
queryParams?: Record<string, any>;
|
||||||
|
customFilters?: TableFilter[];
|
||||||
|
customColumns?: TableColumn[];
|
||||||
|
}) {
|
||||||
|
const api = useApi();
|
||||||
|
const table = useTable(`parametric-data-${modelType}`);
|
||||||
|
const user = useUserState();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Fetch all active parameter templates for the given model type
|
||||||
|
const parameterTemplates = useQuery({
|
||||||
|
queryKey: ['parameter-templates', modelType],
|
||||||
|
queryFn: async () => {
|
||||||
|
return api
|
||||||
|
.get(apiUrl(ApiEndpoints.parameter_template_list), {
|
||||||
|
params: {
|
||||||
|
active: true,
|
||||||
|
for_model: modelType
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((response) => response.data);
|
||||||
|
},
|
||||||
|
refetchOnMount: true
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Store filters against selected part parameters.
|
||||||
|
* These are stored in the format:
|
||||||
|
* {
|
||||||
|
* parameter_1: {
|
||||||
|
* '=': 'value1',
|
||||||
|
* '<': 'value2',
|
||||||
|
* ...
|
||||||
|
* },
|
||||||
|
* parameter_2: {
|
||||||
|
* '=': 'value3',
|
||||||
|
* },
|
||||||
|
* ...
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Which allows multiple filters to be applied against each parameter template.
|
||||||
|
*/
|
||||||
|
const [parameterFilters, setParameterFilters] = useState<any>({});
|
||||||
|
|
||||||
|
/* Remove filters for a specific parameter template
|
||||||
|
* - If no operator is specified, remove all filters for this template
|
||||||
|
* - If an operator is specified, remove filters for that operator only
|
||||||
|
*/
|
||||||
|
const clearParameterFilter = useCallback(
|
||||||
|
(templateId: number, operator?: string) => {
|
||||||
|
const filterName = `parameter_${templateId}`;
|
||||||
|
|
||||||
|
if (!operator) {
|
||||||
|
// If no operator is specified, remove all filters for this template
|
||||||
|
setParameterFilters((prev: any) => {
|
||||||
|
const newFilters = { ...prev };
|
||||||
|
// Remove any filters that match the template ID
|
||||||
|
Object.keys(newFilters).forEach((key: string) => {
|
||||||
|
if (key == filterName) {
|
||||||
|
delete newFilters[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return newFilters;
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An operator is specified, so we remove filters for that operator only
|
||||||
|
setParameterFilters((prev: any) => {
|
||||||
|
const filters = { ...prev };
|
||||||
|
|
||||||
|
const paramFilters = filters[filterName] || {};
|
||||||
|
|
||||||
|
if (paramFilters[operator] !== undefined) {
|
||||||
|
// Remove the specific operator filter
|
||||||
|
delete paramFilters[operator];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...filters,
|
||||||
|
[filterName]: paramFilters
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
table.refreshTable();
|
||||||
|
},
|
||||||
|
[setParameterFilters, table.refreshTable]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add (or update) a filter for a specific parameter template.
|
||||||
|
* @param templateId - The ID of the parameter template to filter on.
|
||||||
|
* @param value - The value to filter by.
|
||||||
|
* @param operator - The operator to use for filtering (e.g., '=', '<', '>', etc.).
|
||||||
|
*/
|
||||||
|
const addParameterFilter = useCallback(
|
||||||
|
(templateId: number, value: string, operator: string) => {
|
||||||
|
const filterName = `parameter_${templateId}`;
|
||||||
|
|
||||||
|
const filterValue = value?.toString().trim() ?? '';
|
||||||
|
|
||||||
|
if (filterValue.length > 0) {
|
||||||
|
setParameterFilters((prev: any) => {
|
||||||
|
const filters = { ...prev };
|
||||||
|
const paramFilters = filters[filterName] || {};
|
||||||
|
|
||||||
|
paramFilters[operator] = filterValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...filters,
|
||||||
|
[filterName]: paramFilters
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
table.refreshTable();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setParameterFilters, clearParameterFilter, table.refreshTable]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Construct the query filters for the table based on the parameter filters
|
||||||
|
const parametricQueryFilters = useMemo(() => {
|
||||||
|
const filters: Record<string, string> = {};
|
||||||
|
|
||||||
|
Object.keys(parameterFilters).forEach((key: string) => {
|
||||||
|
const paramFilters: any = parameterFilters[key];
|
||||||
|
|
||||||
|
Object.keys(paramFilters).forEach((operator: string) => {
|
||||||
|
const name = `${key}${PARAMETER_FILTER_OPERATORS[operator] || ''}`;
|
||||||
|
const value = paramFilters[operator];
|
||||||
|
|
||||||
|
filters[name] = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return filters;
|
||||||
|
}, [parameterFilters]);
|
||||||
|
|
||||||
|
const [selectedInstance, setSelectedInstance] = useState<number>(-1);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<number>(-1);
|
||||||
|
const [selectedParameter, setSelectedParameter] = useState<number>(-1);
|
||||||
|
|
||||||
|
const parameterFields: ApiFormFieldSet = useParameterFields({
|
||||||
|
modelType: ModelType.part,
|
||||||
|
modelId: selectedInstance
|
||||||
|
});
|
||||||
|
|
||||||
|
const addParameter = useCreateApiFormModal({
|
||||||
|
url: ApiEndpoints.parameter_list,
|
||||||
|
title: t`Add Parameter`,
|
||||||
|
fields: useMemo(() => ({ ...parameterFields }), [parameterFields]),
|
||||||
|
focus: 'data',
|
||||||
|
onFormSuccess: (parameter: any) => {
|
||||||
|
updateParameterRecord(selectedInstance, parameter);
|
||||||
|
},
|
||||||
|
initialData: {
|
||||||
|
part: selectedInstance,
|
||||||
|
template: selectedTemplate
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const editParameter = useEditApiFormModal({
|
||||||
|
url: ApiEndpoints.parameter_list,
|
||||||
|
title: t`Edit Parameter`,
|
||||||
|
pk: selectedParameter,
|
||||||
|
fields: useMemo(() => ({ ...parameterFields }), [parameterFields]),
|
||||||
|
focus: 'data',
|
||||||
|
onFormSuccess: (parameter: any) => {
|
||||||
|
updateParameterRecord(selectedInstance, parameter);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update a single parameter record in the table
|
||||||
|
const updateParameterRecord = useCallback(
|
||||||
|
(part: number, parameter: any) => {
|
||||||
|
const records = table.records;
|
||||||
|
const recordIndex = records.findIndex((record: any) => record.pk == part);
|
||||||
|
|
||||||
|
if (recordIndex < 0) {
|
||||||
|
// No matching part: reload the entire table
|
||||||
|
table.refreshTable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameterIndex = records[recordIndex].parameters.findIndex(
|
||||||
|
(p: any) => p.pk == parameter.pk
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parameterIndex < 0) {
|
||||||
|
// No matching parameter - append new parameter
|
||||||
|
records[recordIndex].parameters.push(parameter);
|
||||||
|
} else {
|
||||||
|
records[recordIndex].parameters[parameterIndex] = parameter;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.updateRecord(records[recordIndex]);
|
||||||
|
},
|
||||||
|
[table.records, table.updateRecord]
|
||||||
|
);
|
||||||
|
|
||||||
|
const parameterColumns: TableColumn[] = useMemo(() => {
|
||||||
|
const data = parameterTemplates?.data || [];
|
||||||
|
|
||||||
|
return data.map((template: any) => {
|
||||||
|
let title = template.name;
|
||||||
|
|
||||||
|
if (template.units) {
|
||||||
|
title += ` [${template.units}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = parameterFilters[`parameter_${template.pk}`] || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessor: `parameter_${template.pk}`,
|
||||||
|
title: title,
|
||||||
|
sortable: true,
|
||||||
|
extra: {
|
||||||
|
template: template.pk
|
||||||
|
},
|
||||||
|
render: (record: any) => (
|
||||||
|
<ParameterCell
|
||||||
|
record={record}
|
||||||
|
template={template}
|
||||||
|
canEdit={user.hasChangeRole(UserRoles.part)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
filtering: Object.keys(filters).length > 0,
|
||||||
|
filter: ({ close }: { close: () => void }) => {
|
||||||
|
return (
|
||||||
|
<ParameterFilter
|
||||||
|
template={template}
|
||||||
|
filters={parameterFilters[`parameter_${template.pk}`] || {}}
|
||||||
|
setFilter={addParameterFilter}
|
||||||
|
clearFilter={clearParameterFilter}
|
||||||
|
closeFilter={close}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [user, parameterTemplates.data, parameterFilters]);
|
||||||
|
|
||||||
|
// Callback function when a parameter cell is clicked
|
||||||
|
const onParameterClick = useCallback((template: number, instance: any) => {
|
||||||
|
setSelectedTemplate(template);
|
||||||
|
setSelectedInstance(instance.pk);
|
||||||
|
const parameter = instance.parameters?.find(
|
||||||
|
(p: any) => p.template == template
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parameter) {
|
||||||
|
setSelectedParameter(parameter.pk);
|
||||||
|
editParameter.open();
|
||||||
|
} else {
|
||||||
|
addParameter.open();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tableFilters: TableFilter[] = useMemo(() => {
|
||||||
|
return [...(customFilters || [])];
|
||||||
|
}, [customFilters]);
|
||||||
|
|
||||||
|
const tableColumns: TableColumn[] = useMemo(() => {
|
||||||
|
return [...(customColumns || []), ...parameterColumns];
|
||||||
|
}, [customColumns, parameterColumns]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{addParameter.modal}
|
||||||
|
{editParameter.modal}
|
||||||
|
<InvenTreeTable
|
||||||
|
url={apiUrl(endpoint)}
|
||||||
|
tableState={table}
|
||||||
|
columns={tableColumns}
|
||||||
|
props={{
|
||||||
|
enableDownload: true,
|
||||||
|
tableFilters: tableFilters,
|
||||||
|
params: {
|
||||||
|
...queryParams,
|
||||||
|
parameters: true,
|
||||||
|
...parametricQueryFilters
|
||||||
|
},
|
||||||
|
onCellClick: ({ event, record, index, column, columnIndex }) => {
|
||||||
|
cancelEvent(event);
|
||||||
|
|
||||||
|
// Is this a "parameter" cell?
|
||||||
|
if (column?.accessor?.toString()?.startsWith('parameter_')) {
|
||||||
|
const col = column as any;
|
||||||
|
onParameterClick(col.extra.template, record);
|
||||||
|
} else if (record?.pk) {
|
||||||
|
// Navigate through to the detail page
|
||||||
|
const url = getDetailUrl(modelType, record.pk);
|
||||||
|
navigateToLink(url, navigate, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,353 +1,18 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
|
||||||
import { Group } from '@mantine/core';
|
|
||||||
import { useHover } from '@mantine/hooks';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { YesNoButton } from '@lib/components/YesNoButton';
|
|
||||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||||
import { ModelType } from '@lib/enums/ModelType';
|
import { ModelType } from '@lib/enums/ModelType';
|
||||||
import { UserRoles } from '@lib/enums/Roles';
|
|
||||||
import { apiUrl } from '@lib/functions/Api';
|
|
||||||
import { cancelEvent } from '@lib/functions/Events';
|
|
||||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
|
||||||
import { navigateToLink } from '@lib/functions/Navigation';
|
|
||||||
import type { TableFilter } from '@lib/types/Filters';
|
import type { TableFilter } from '@lib/types/Filters';
|
||||||
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
|
||||||
import type { TableColumn } from '@lib/types/Tables';
|
import type { TableColumn } from '@lib/types/Tables';
|
||||||
import { useApi } from '../../contexts/ApiContext';
|
import { t } from '@lingui/core/macro';
|
||||||
import { formatDecimal } from '../../defaults/formatters';
|
import { useMemo } from 'react';
|
||||||
import { usePartParameterFields } from '../../forms/PartForms';
|
|
||||||
import {
|
|
||||||
useCreateApiFormModal,
|
|
||||||
useEditApiFormModal
|
|
||||||
} from '../../hooks/UseForm';
|
|
||||||
import { useTable } from '../../hooks/UseTable';
|
|
||||||
import { useUserState } from '../../states/UserState';
|
|
||||||
import { DescriptionColumn, PartColumn } from '../ColumnRenderers';
|
import { DescriptionColumn, PartColumn } from '../ColumnRenderers';
|
||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
import ParametricDataTable from '../general/ParametricDataTable';
|
||||||
import { TableHoverCard } from '../TableHoverCard';
|
|
||||||
import {
|
|
||||||
PARAMETER_FILTER_OPERATORS,
|
|
||||||
ParameterFilter
|
|
||||||
} from './ParametricPartTableFilters';
|
|
||||||
|
|
||||||
// Render an individual parameter cell
|
|
||||||
function ParameterCell({
|
|
||||||
record,
|
|
||||||
template,
|
|
||||||
canEdit
|
|
||||||
}: Readonly<{
|
|
||||||
record: any;
|
|
||||||
template: any;
|
|
||||||
canEdit: boolean;
|
|
||||||
}>) {
|
|
||||||
const { hovered, ref } = useHover();
|
|
||||||
|
|
||||||
// Find matching template parameter
|
|
||||||
const parameter = useMemo(() => {
|
|
||||||
return record.parameters?.find((p: any) => p.template == template.pk);
|
|
||||||
}, [record, template]);
|
|
||||||
|
|
||||||
const extra: any[] = [];
|
|
||||||
|
|
||||||
// Format the value for display
|
|
||||||
const value: ReactNode = useMemo(() => {
|
|
||||||
let v: any = parameter?.data;
|
|
||||||
|
|
||||||
// Handle boolean values
|
|
||||||
if (template?.checkbox && v != undefined) {
|
|
||||||
v = <YesNoButton value={parameter.data} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return v;
|
|
||||||
}, [parameter, template]);
|
|
||||||
|
|
||||||
if (
|
|
||||||
template.units &&
|
|
||||||
parameter &&
|
|
||||||
parameter.data_numeric &&
|
|
||||||
parameter.data_numeric != parameter.data
|
|
||||||
) {
|
|
||||||
const numeric = formatDecimal(parameter.data_numeric, { digits: 15 });
|
|
||||||
extra.push(`${numeric} [${template.units}]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hovered && canEdit) {
|
|
||||||
extra.push(t`Click to edit`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Group grow ref={ref} justify='space-between'>
|
|
||||||
<Group grow>
|
|
||||||
<TableHoverCard
|
|
||||||
value={value ?? '-'}
|
|
||||||
extra={extra}
|
|
||||||
icon={hovered && canEdit ? 'edit' : 'info'}
|
|
||||||
title={template.name}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ParametricPartTable({
|
export default function ParametricPartTable({
|
||||||
categoryId
|
categoryId
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
categoryId?: any;
|
categoryId?: any;
|
||||||
}>) {
|
}>) {
|
||||||
const api = useApi();
|
const customFilters: TableFilter[] = useMemo(() => {
|
||||||
const table = useTable('parametric-parts');
|
|
||||||
const user = useUserState();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const categoryParameters = useQuery({
|
|
||||||
queryKey: ['category-parameters', categoryId],
|
|
||||||
queryFn: async () => {
|
|
||||||
return api
|
|
||||||
.get(apiUrl(ApiEndpoints.part_parameter_template_list), {
|
|
||||||
params: {
|
|
||||||
category: categoryId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((response) => response.data);
|
|
||||||
},
|
|
||||||
refetchOnMount: true
|
|
||||||
});
|
|
||||||
|
|
||||||
/* Store filters against selected part parameters.
|
|
||||||
* These are stored in the format:
|
|
||||||
* {
|
|
||||||
* parameter_1: {
|
|
||||||
* '=': 'value1',
|
|
||||||
* '<': 'value2',
|
|
||||||
* ...
|
|
||||||
* },
|
|
||||||
* parameter_2: {
|
|
||||||
* '=': 'value3',
|
|
||||||
* },
|
|
||||||
* ...
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* Which allows multiple filters to be applied against each parameter template.
|
|
||||||
*/
|
|
||||||
const [parameterFilters, setParameterFilters] = useState<any>({});
|
|
||||||
|
|
||||||
/* Remove filters for a specific parameter template
|
|
||||||
* - If no operator is specified, remove all filters for this template
|
|
||||||
* - If an operator is specified, remove filters for that operator only
|
|
||||||
*/
|
|
||||||
const clearParameterFilter = useCallback(
|
|
||||||
(templateId: number, operator?: string) => {
|
|
||||||
const filterName = `parameter_${templateId}`;
|
|
||||||
|
|
||||||
if (!operator) {
|
|
||||||
// If no operator is specified, remove all filters for this template
|
|
||||||
setParameterFilters((prev: any) => {
|
|
||||||
const newFilters = { ...prev };
|
|
||||||
// Remove any filters that match the template ID
|
|
||||||
Object.keys(newFilters).forEach((key: string) => {
|
|
||||||
if (key == filterName) {
|
|
||||||
delete newFilters[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return newFilters;
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// An operator is specified, so we remove filters for that operator only
|
|
||||||
setParameterFilters((prev: any) => {
|
|
||||||
const filters = { ...prev };
|
|
||||||
|
|
||||||
const paramFilters = filters[filterName] || {};
|
|
||||||
|
|
||||||
if (paramFilters[operator] !== undefined) {
|
|
||||||
// Remove the specific operator filter
|
|
||||||
delete paramFilters[operator];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...filters,
|
|
||||||
[filterName]: paramFilters
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
table.refreshTable();
|
|
||||||
},
|
|
||||||
[setParameterFilters, table.refreshTable]
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add (or update) a filter for a specific parameter template.
|
|
||||||
* @param templateId - The ID of the parameter template to filter on.
|
|
||||||
* @param value - The value to filter by.
|
|
||||||
* @param operator - The operator to use for filtering (e.g., '=', '<', '>', etc.).
|
|
||||||
*/
|
|
||||||
const addParameterFilter = useCallback(
|
|
||||||
(templateId: number, value: string, operator: string) => {
|
|
||||||
const filterName = `parameter_${templateId}`;
|
|
||||||
|
|
||||||
const filterValue = value?.toString().trim() ?? '';
|
|
||||||
|
|
||||||
if (filterValue.length > 0) {
|
|
||||||
setParameterFilters((prev: any) => {
|
|
||||||
const filters = { ...prev };
|
|
||||||
const paramFilters = filters[filterName] || {};
|
|
||||||
|
|
||||||
paramFilters[operator] = filterValue;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...filters,
|
|
||||||
[filterName]: paramFilters
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
table.refreshTable();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setParameterFilters, clearParameterFilter, table.refreshTable]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Construct the query filters for the table based on the parameter filters
|
|
||||||
const parametricQueryFilters = useMemo(() => {
|
|
||||||
const filters: Record<string, string> = {};
|
|
||||||
|
|
||||||
Object.keys(parameterFilters).forEach((key: string) => {
|
|
||||||
const paramFilters: any = parameterFilters[key];
|
|
||||||
|
|
||||||
Object.keys(paramFilters).forEach((operator: string) => {
|
|
||||||
const name = `${key}${PARAMETER_FILTER_OPERATORS[operator] || ''}`;
|
|
||||||
const value = paramFilters[operator];
|
|
||||||
|
|
||||||
filters[name] = value;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return filters;
|
|
||||||
}, [parameterFilters]);
|
|
||||||
|
|
||||||
const [selectedPart, setSelectedPart] = useState<number>(0);
|
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<number>(0);
|
|
||||||
const [selectedParameter, setSelectedParameter] = useState<number>(0);
|
|
||||||
|
|
||||||
const partParameterFields: ApiFormFieldSet = usePartParameterFields({
|
|
||||||
editTemplate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const addParameter = useCreateApiFormModal({
|
|
||||||
url: ApiEndpoints.part_parameter_list,
|
|
||||||
title: t`Add Part Parameter`,
|
|
||||||
fields: useMemo(() => ({ ...partParameterFields }), [partParameterFields]),
|
|
||||||
focus: 'data',
|
|
||||||
onFormSuccess: (parameter: any) => {
|
|
||||||
updateParameterRecord(selectedPart, parameter);
|
|
||||||
},
|
|
||||||
initialData: {
|
|
||||||
part: selectedPart,
|
|
||||||
template: selectedTemplate
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const editParameter = useEditApiFormModal({
|
|
||||||
url: ApiEndpoints.part_parameter_list,
|
|
||||||
title: t`Edit Part Parameter`,
|
|
||||||
pk: selectedParameter,
|
|
||||||
fields: useMemo(() => ({ ...partParameterFields }), [partParameterFields]),
|
|
||||||
focus: 'data',
|
|
||||||
onFormSuccess: (parameter: any) => {
|
|
||||||
updateParameterRecord(selectedPart, parameter);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update a single parameter record in the table
|
|
||||||
const updateParameterRecord = useCallback(
|
|
||||||
(part: number, parameter: any) => {
|
|
||||||
const records = table.records;
|
|
||||||
const partIndex = records.findIndex((record: any) => record.pk == part);
|
|
||||||
|
|
||||||
if (partIndex < 0) {
|
|
||||||
// No matching part: reload the entire table
|
|
||||||
table.refreshTable();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parameterIndex = records[partIndex].parameters.findIndex(
|
|
||||||
(p: any) => p.pk == parameter.pk
|
|
||||||
);
|
|
||||||
|
|
||||||
if (parameterIndex < 0) {
|
|
||||||
// No matching parameter - append new parameter
|
|
||||||
records[partIndex].parameters.push(parameter);
|
|
||||||
} else {
|
|
||||||
records[partIndex].parameters[parameterIndex] = parameter;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.updateRecord(records[partIndex]);
|
|
||||||
},
|
|
||||||
[table.records, table.updateRecord]
|
|
||||||
);
|
|
||||||
|
|
||||||
const parameterColumns: TableColumn[] = useMemo(() => {
|
|
||||||
const data = categoryParameters?.data || [];
|
|
||||||
|
|
||||||
return data.map((template: any) => {
|
|
||||||
let title = template.name;
|
|
||||||
|
|
||||||
if (template.units) {
|
|
||||||
title += ` [${template.units}]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters = parameterFilters[`parameter_${template.pk}`] || {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessor: `parameter_${template.pk}`,
|
|
||||||
title: title,
|
|
||||||
sortable: true,
|
|
||||||
extra: {
|
|
||||||
template: template.pk
|
|
||||||
},
|
|
||||||
render: (record: any) => (
|
|
||||||
<ParameterCell
|
|
||||||
record={record}
|
|
||||||
template={template}
|
|
||||||
canEdit={user.hasChangeRole(UserRoles.part)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
filtering: Object.keys(filters).length > 0,
|
|
||||||
filter: ({ close }: { close: () => void }) => {
|
|
||||||
return (
|
|
||||||
<ParameterFilter
|
|
||||||
template={template}
|
|
||||||
filters={parameterFilters[`parameter_${template.pk}`] || {}}
|
|
||||||
setFilter={addParameterFilter}
|
|
||||||
clearFilter={clearParameterFilter}
|
|
||||||
closeFilter={close}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [user, categoryParameters.data, parameterFilters]);
|
|
||||||
|
|
||||||
const onParameterClick = useCallback((template: number, part: any) => {
|
|
||||||
setSelectedTemplate(template);
|
|
||||||
setSelectedPart(part.pk);
|
|
||||||
const parameter = part.parameters?.find((p: any) => p.template == template);
|
|
||||||
|
|
||||||
if (parameter) {
|
|
||||||
setSelectedParameter(parameter.pk);
|
|
||||||
editParameter.open();
|
|
||||||
} else {
|
|
||||||
addParameter.open();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const tableFilters: TableFilter[] = useMemo(() => {
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: 'active',
|
name: 'active',
|
||||||
@@ -367,8 +32,8 @@ export default function ParametricPartTable({
|
|||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const tableColumns: TableColumn[] = useMemo(() => {
|
const customColumns: TableColumn[] = useMemo(() => {
|
||||||
const partColumns: TableColumn[] = [
|
return [
|
||||||
PartColumn({
|
PartColumn({
|
||||||
part: '',
|
part: '',
|
||||||
switchable: false
|
switchable: false
|
||||||
@@ -386,43 +51,19 @@ export default function ParametricPartTable({
|
|||||||
sortable: true
|
sortable: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
}, []);
|
||||||
return [...partColumns, ...parameterColumns];
|
|
||||||
}, [parameterColumns]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ParametricDataTable
|
||||||
{addParameter.modal}
|
modelType={ModelType.part}
|
||||||
{editParameter.modal}
|
endpoint={ApiEndpoints.part_list}
|
||||||
<InvenTreeTable
|
customColumns={customColumns}
|
||||||
url={apiUrl(ApiEndpoints.part_list)}
|
customFilters={customFilters}
|
||||||
tableState={table}
|
queryParams={{
|
||||||
columns={tableColumns}
|
|
||||||
props={{
|
|
||||||
enableDownload: true,
|
|
||||||
tableFilters: tableFilters,
|
|
||||||
params: {
|
|
||||||
category: categoryId,
|
category: categoryId,
|
||||||
cascade: true,
|
cascade: true,
|
||||||
category_detail: true,
|
category_detail: true
|
||||||
parameters: true,
|
|
||||||
...parametricQueryFilters
|
|
||||||
},
|
|
||||||
onCellClick: ({ event, record, index, column, columnIndex }) => {
|
|
||||||
cancelEvent(event);
|
|
||||||
|
|
||||||
// Is this a "parameter" cell?
|
|
||||||
if (column?.accessor?.toString()?.startsWith('parameter_')) {
|
|
||||||
const col = column as any;
|
|
||||||
onParameterClick(col.extra.template, record);
|
|
||||||
} else if (record?.pk) {
|
|
||||||
// Navigate through to the part detail page
|
|
||||||
const url = getDetailUrl(ModelType.part, record.pk);
|
|
||||||
navigateToLink(url, navigate, event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user