2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-12-16 17:28:11 +00:00

[WIP] Generic parameters (#10699)

* Add ParameterTemplate model

- Data structure duplicated from PartParameterTemplate

* Apply data migration for templates

* Admin integration

* API endpoints for ParameterTemplate

* Scaffolding

* Add validator for ParameterTemplate model type

- Update migrations
- Make Parameter class abstract (for now)
- Validators

* API updates

- Fix options for model_type
- Add API filters

* Add definition for Parameter model

* Add django admin site integration

* Update InvenTreeParameterMixin class

- Fetch queryset of all linked Parameter instances
- Ensure deletion of linked instances

* API endpoints for Parameter instances

* Refactor UI table for parameter templates

* Add comment for later

* Add "enabled" field to ParameterTemplate model

* Add new field to serializer

* Rough-in new table

* Implement generic "parameter" table

* Enable parameters for Company model

* Change migration for part parameter

- Make it "universal"

* Remove code for ManufacturerPartParameter

* Fix for filters

* Add data import for parameter table

* Add verbose name to ParameterTemplate model

* Removed dead API code

* Update global setting

* Fix typos

* Check global setting for unit validation

* Use GenericForeignKey

* Add generic relationship to allow reverse lookups

* Fixes for table structure

* Add custom serializer field for ContentType with choices

* Adds ContentTypeField

- Handles representation of content type
- Provides human-readable options

* Refactor API filtering for endpoints

- Specify ContentType by ID, model or app label

* Revert change to parameters property

* Define GenericRelationship for linking model

* Refactoring some code

* Add a generic way to back-annotate and prefetch parameters for any model type

* Change panel position

* Directly annotate parameters against different model serializers

* remove defunct admin classes

* Run plugin validation against parameter

* Fix prefetching for PartSerializer

* Implement generic "filtering" against queryset

* Implement generic "ordering" by parameter

* Make parametric table generic

* Refactor segmented panels

* Consolidate part table views

* Fix for parametric part table

- Only display parameters for which we know there is a value

* Add parametric tables for company views

* Fix typo in file name

* Prefetch to reduce hits

* Add generic API mixin for filtering and ordering by parameter

* Fix hook for rebuilding template parameters

* Remove serializer

* Remove old models

* Fix code for copying parameters from category

* Implement more parametric tables:

- ManufacturerPart
- SupplierPart
- Fixes and enhancements

* Add parameter support for orders

* Add UI support for parameters against orders

* Update API version

* Update CHANGELOG.md

* Add parameter support for build orders

* Tweak frontend

* Add renderer

* Remove defunct endpoints

* Add migration requirement

* Require contenttypes to be updated

* Update migration

* Try using ID val

* Adjust migration dependencies

* fix params fixture

* fix schema export

* fix modelset

* Fixes for data migration

* tweak table

* Fix for Category Parameters

* Use branch of demo dataset for testing

* Add parameteric build order table

* disable broken imports

* remove old model from ruleset

* correct test

* Table tweaks

* fix test

* Remove old model type

* fix test

* fix test

* Refactor mixin to avoid specifying model type manually

* fix test

* fix resolve name

* remove unneeded import

* Tweak unit testing

* Fix unit test

* Enable bulk-create

* More fixes

* More unit test tweaks

* Enhancements

* Unit test fixes

* Add some migration tests

* Fix admin tests

* Fix part tests

* adapt expectation

* fix remaining typecheck

* Docs updates

* Rearrange models

* fix paramater caching

* fix doc links

* adjust assumption

* Adjust data migration unit tests

* docs fixes

* Fix docs link

* Fixes

* Tweak formatting

* Add doc for setting

* Add metadata view for parameters

* Add metadata view for ParamterTemplate

* Update CHANGELOG file

* Deconflict model_type fields

* Invert key:value

* Revert "Invert key:value"

This reverts commit d555658db2.

* fix assert

* Update API rev notes

* Initial unit tests for API

* Test parameter create / edit / delete via the API

* Add some more unit tests for the API

* Validate queryset annotation

- Add unit test with large dataset
- Ensure number of queries is fixed
- Fix for prefetching check

* Add breaking change info to CHANGELOG.md

* Ensure that parameters are removed when deleting the linked object

* Enhance type hinting

* Refactor part parameter exporter plugin

- Any model which supports parameters can use this now
- Update documentation

* Improve serializer field

* Adjust unit test

* Reimplement checks for locked parts

* Fix unit test for data migration

* Fix for unit test

* Allow disable edit for ParameterTable

* Fix supplier part import wizard

* Add unit tests for template API filtering

* Add playwright tests for purchasing index

* Add tests for manufacturing index page

* ui tests for sales index

* Add data migration tests for ManufacturerPartParameter

* Pull specific branch for python binding tests

* Specify target migration

* Remove debug statement

* Tweak migration unit tests

* Add options for spectacular

* Add explicit choice options

* Ensure empty string values are converted to None

* Don't use custom branch for python checks

* Fix for migration test

* Fix migration test

* Fix reference target

* Remove duplicate enum in spectactular.py

* Add null choice to custom serializer class

* [UI] Edit shipment details

- Pass "pending" status through to the form

* New migration strategy:

part.0144:
- Add new "enabled" field to PartParameterTemplate model
- Add new ContentType fields to the "PartParameterTemplate" and "PartParameter" models
- Data migration for existing "PartParameter" records

part.0145:
- Set NOT NULL constraints on new fields
- Remove the obsolete "part" field from the "PartParameter" model

* More migration updates:

- Create new "models" (without moving the existing tables)
- Data migration for PartCataegoryParameterTemplate model
- Remove PartParameterTemplate and PartParameter models

* Overhaul of migration strategy

- New models simply point to the old database tables
- Perform schema and data migrations on the old models first (in the part app)
- Swap model references in correct order

* Improve checks for data migrations

* Bug fix for data migration

* Add migration unit test to ensure that primary keys are maintained

* Add playwright test for company parameters

* Rename underlying database tables

* Fixes for migration unit tests

* Revert "Rename underlying database tables"

This reverts commit 477c692076.

* Fix for migration sequencing

* Simplify new playwright test

* Remove spectacular collision

* Monkey patch the drf-spectacular warn function

* Do not use custom branch for playwright testing

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver
2025-12-04 20:41:36 +11:00
committed by GitHub
parent c443b4e9b8
commit fa0d892a62
135 changed files with 5873 additions and 3307 deletions

View File

@@ -111,8 +111,6 @@ export enum ApiEndpoints {
// Part API endpoints
part_list = 'part/',
part_parameter_list = 'part/parameter/',
part_parameter_template_list = 'part/parameter/template/',
part_thumbs_list = 'part/thumbs/',
part_pricing = 'part/:id/pricing/',
part_requirements = 'part/:id/requirements/',
@@ -134,7 +132,6 @@ export enum ApiEndpoints {
supplier_part_list = 'company/part/',
supplier_part_pricing_list = 'company/price-break/',
manufacturer_part_list = 'company/part/manufacturer/',
manufacturer_part_parameter_list = 'company/part/manufacturer/parameter/',
// Stock location endpoints
stock_location_list = 'stock/location/',
@@ -243,5 +240,7 @@ export enum ApiEndpoints {
notes_image_upload = 'notes-image-upload/',
email_list = 'admin/email/',
email_test = 'admin/email/test/',
config_list = 'admin/config/'
config_list = 'admin/config/',
parameter_list = 'parameter/',
parameter_template_list = 'parameter/template/'
}

View File

@@ -33,13 +33,18 @@ export const ModelInformationDict: ModelDict = {
admin_url: '/part/part/',
icon: 'part'
},
partparametertemplate: {
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'
parameter: {
label: () => t`Parameter`,
label_multiple: () => t`Parameters`,
api_endpoint: ApiEndpoints.parameter_list,
icon: 'list_details'
},
parametertemplate: {
label: () => t`Parameter Template`,
label_multiple: () => t`Parameter Templates`,
api_endpoint: ApiEndpoints.parameter_template_list,
admin_url: '/common/parametertemplate/',
icon: 'list'
},
parttesttemplate: {
label: () => t`Part Test Template`,

View File

@@ -6,7 +6,6 @@ export enum ModelType {
supplierpart = 'supplierpart',
manufacturerpart = 'manufacturerpart',
partcategory = 'partcategory',
partparametertemplate = 'partparametertemplate',
parttesttemplate = 'parttesttemplate',
projectcode = 'projectcode',
stockitem = 'stockitem',
@@ -17,6 +16,8 @@ export enum ModelType {
buildline = 'buildline',
builditem = 'builditem',
company = 'company',
parameter = 'parameter',
parametertemplate = 'parametertemplate',
purchaseorder = 'purchaseorder',
purchaseorderlineitem = 'purchaseorderlineitem',
salesorder = 'salesorder',

View File

@@ -33,7 +33,7 @@ export default function SegmentedIconControl({
data={data.map((item) => ({
value: item.value,
label: (
<Tooltip label={item.label}>
<Tooltip label={item.label} position='top-end'>
<ActionIcon
variant='transparent'
color={color}

View File

@@ -8,7 +8,7 @@ export type PanelType = {
label: string;
controls?: ReactNode;
icon?: ReactNode;
content: ReactNode;
content?: ReactNode;
hidden?: boolean;
disabled?: boolean;
showHeadline?: boolean;

View File

@@ -0,0 +1,32 @@
import type { ModelType } from '@lib/enums/ModelType';
import { t } from '@lingui/core/macro';
import { Skeleton } from '@mantine/core';
import { IconListDetails } from '@tabler/icons-react';
import { ParameterTable } from '../../tables/general/ParameterTable';
import type { PanelType } from './Panel';
export default function ParametersPanel({
model_type,
model_id,
allowEdit = true
}: {
model_type: ModelType;
model_id: number | undefined;
allowEdit?: boolean;
}): PanelType {
return {
name: 'parameters',
label: t`Parameters`,
icon: <IconListDetails />,
content:
model_type && model_id ? (
<ParameterTable
allowEdit={allowEdit}
modelType={model_type}
modelId={model_id}
/>
) : (
<Skeleton />
)
};
}

View File

@@ -0,0 +1,53 @@
import SegmentedIconControl from '../buttons/SegmentedIconControl';
import type { PanelType } from './Panel';
export type SegmentedControlPanelSelection = {
value: string;
label: string;
icon: React.ReactNode;
content: React.ReactNode;
};
interface SegmentedPanelType extends PanelType {
options: SegmentedControlPanelSelection[];
selection: string;
onChange: (value: string) => void;
}
/**
* Display a panel which can be used to display multiple options,
* based on a built-in segmented control.
*/
export default function SegmentedControlPanel(
props: SegmentedPanelType
): PanelType {
// Extract the content based on the selection
let content = null;
for (const option of props.options) {
if (option.value === props.selection) {
content = option.content;
break;
}
}
if (content === null && props.options.length > 0) {
content = props.options[0].content;
}
return {
...props,
content: content,
controls: (
<SegmentedIconControl
value={props.selection}
onChange={props.onChange}
data={props.options.map((option: any) => ({
value: option.value,
label: option.label,
icon: option.icon
}))}
/>
)
};
}

View File

@@ -2,6 +2,30 @@ import type { ReactNode } from 'react';
import { type InstanceRenderInterface, RenderInlineModel } from './Instance';
export function RenderParameterTemplate({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
return (
<RenderInlineModel
primary={instance.name}
secondary={instance.description}
suffix={instance.units}
/>
);
}
export function RenderParameter({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
return (
<RenderInlineModel
primary={instance.template?.name || ''}
secondary={instance.description}
suffix={instance.data || instance.data_numeric || ''}
/>
);
}
export function RenderProjectCode({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {

View File

@@ -36,6 +36,8 @@ import {
RenderContentType,
RenderError,
RenderImportSession,
RenderParameter,
RenderParameterTemplate,
RenderProjectCode,
RenderSelectionList
} from './Generic';
@@ -46,12 +48,7 @@ import {
RenderSalesOrder,
RenderSalesOrderShipment
} from './Order';
import {
RenderPart,
RenderPartCategory,
RenderPartParameterTemplate,
RenderPartTestTemplate
} from './Part';
import { RenderPart, RenderPartCategory, RenderPartTestTemplate } from './Part';
import { RenderPlugin } from './Plugin';
import { RenderLabelTemplate, RenderReportTemplate } from './Report';
import {
@@ -71,11 +68,12 @@ export const RendererLookup: ModelRendererDict = {
[ModelType.builditem]: RenderBuildItem,
[ModelType.company]: RenderCompany,
[ModelType.contact]: RenderContact,
[ModelType.parameter]: RenderParameter,
[ModelType.parametertemplate]: RenderParameterTemplate,
[ModelType.manufacturerpart]: RenderManufacturerPart,
[ModelType.owner]: RenderOwner,
[ModelType.part]: RenderPart,
[ModelType.partcategory]: RenderPartCategory,
[ModelType.partparametertemplate]: RenderPartParameterTemplate,
[ModelType.parttesttemplate]: RenderPartTestTemplate,
[ModelType.projectcode]: RenderProjectCode,
[ModelType.purchaseorder]: RenderPurchaseOrder,

View File

@@ -141,23 +141,6 @@ export function RenderPartCategory(
);
}
/**
* Inline rendering of a PartParameterTemplate instance
*/
export function RenderPartParameterTemplate({
instance
}: Readonly<{
instance: any;
}>): ReactNode {
return (
<RenderInlineModel
primary={instance.name}
secondary={instance.description}
suffix={instance.units}
/>
);
}
export function RenderPartTestTemplate({
instance
}: Readonly<{

View File

@@ -419,8 +419,8 @@ const ParametersStep = ({
hideLabels
fieldDefinition={{
field_type: 'related field',
model: ModelType.partparametertemplate,
api_url: apiUrl(ApiEndpoints.part_parameter_template_list),
model: ModelType.parametertemplate,
api_url: apiUrl(ApiEndpoints.parameter_template_list),
disabled: p.on_category,
value: p.parameter_template,
onValueChange: (v) => {
@@ -677,13 +677,14 @@ export default function ImportPartWizard({
{} as Record<number, number>
);
const createParameters = useParameters.map((p) => ({
part: importResult!.part_id,
model_type: 'part',
model_id: importResult!.part_id,
template: p.parameter_template,
data: p.value
}));
try {
await api.post(
apiUrl(ApiEndpoints.part_parameter_list),
apiUrl(ApiEndpoints.parameter_list),
createParameters
);
showNotification({

View File

@@ -1,12 +1,16 @@
import { IconUsers } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import type {
StatusCodeInterface,
StatusCodeListInterface
} from '../components/render/StatusRenderer';
import { useApi } from '../contexts/ApiContext';
import { useGlobalStatusState } from '../states/GlobalStatusState';
export function projectCodeFields(): ApiFormFieldSet {
@@ -91,3 +95,117 @@ export function extraLineItemFields(): ApiFormFieldSet {
link: {}
};
}
export function useParameterFields({
modelType,
modelId
}: {
modelType: ModelType;
modelId: number;
}): 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'
);
const [data, setData] = useState<string>('');
// Reset the field type and choices when the model changes
useEffect(() => {
setFieldType('string');
setChoices([]);
setData('');
}, [modelType, modelId]);
return useMemo(() => {
return {
model_type: {
hidden: true,
value: modelType
},
model_id: {
hidden: true,
value: modelId
},
template: {
filters: {
for_model: modelType,
enabled: true
},
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: {
value: data,
onValueChange: (value: any) => {
setData(value);
},
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: {}
};
}, [data, modelType, fieldType, choices, modelId]);
}

View File

@@ -97,21 +97,6 @@ export function useManufacturerPartFields() {
}, []);
}
export function useManufacturerPartParameterFields() {
return useMemo(() => {
const fields: ApiFormFieldSet = {
manufacturer_part: {
disabled: true
},
name: {},
value: {},
units: {}
};
return fields;
}, []);
}
/**
* Field set for editing a company instance
*/

View File

@@ -1,11 +1,7 @@
import type { ApiFormFieldSet } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import { IconBuildingStore, IconCopy, IconPackages } from '@tabler/icons-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';
/**
@@ -224,97 +220,6 @@ export function partCategoryFields({
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 {
return {
part: {

View File

@@ -71,7 +71,7 @@ const MachineManagementPanel = Loadable(
lazy(() => import('./MachineManagementPanel'))
);
const PartParameterPanel = Loadable(lazy(() => import('./PartParameterPanel')));
const ParameterPanel = Loadable(lazy(() => import('./ParameterPanel')));
const ErrorReportTable = Loadable(
lazy(() => import('../../../../tables/settings/ErrorTable'))
@@ -191,10 +191,10 @@ export default function AdminCenter() {
content: <UnitManagementPanel />
},
{
name: 'part-parameters',
label: t`Part Parameters`,
name: 'parameters',
label: t`Parameters`,
icon: <IconList />,
content: <PartParameterPanel />,
content: <ParameterPanel />,
hidden: !user.hasViewRole(UserRoles.part)
},
{
@@ -274,7 +274,7 @@ export default function AdminCenter() {
id: 'plm',
label: t`PLM`,
panelIDs: [
'part-parameters',
'parameters',
'category-parameters',
'location-types',
'stocktake'

View File

@@ -2,21 +2,21 @@ import { t } from '@lingui/core/macro';
import { Accordion } from '@mantine/core';
import { StylishText } from '../../../../components/items/StylishText';
import PartParameterTemplateTable from '../../../../tables/part/PartParameterTemplateTable';
import ParameterTemplateTable from '../../../../tables/general/ParameterTemplateTable';
import SelectionListTable from '../../../../tables/part/SelectionListTable';
export default function PartParameterPanel() {
return (
<Accordion defaultValue='parametertemplate'>
<Accordion.Item value='parametertemplate' key='parametertemplate'>
<Accordion multiple defaultValue={['parameter-templates']}>
<Accordion.Item value='parameter-templates' key='parameter-templates'>
<Accordion.Control>
<StylishText size='lg'>{t`Part Parameter Template`}</StylishText>
<StylishText size='lg'>{t`Parameter Templates`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<PartParameterTemplateTable />
<ParameterTemplateTable />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='selectionlist' key='selectionlist'>
<Accordion.Item value='selection-lists' key='selection-lists'>
<Accordion.Control>
<StylishText size='lg'>{t`Selection Lists`}</StylishText>
</Accordion.Control>

View File

@@ -7,6 +7,7 @@ import {
IconCurrencyDollar,
IconFileAnalytics,
IconFingerprint,
IconList,
IconPackages,
IconPlugConnected,
IconQrcode,
@@ -185,6 +186,12 @@ export default function SystemSettings() {
/>
)
},
{
name: 'parameters',
label: t`Parameters`,
icon: <IconList />,
content: <GlobalSettingList keys={['PARAMETER_ENFORCE_UNITS']} />
},
{
name: 'parts',
label: t`Parts`,
@@ -213,8 +220,7 @@ export default function SystemSettings() {
'PART_COPY_PARAMETERS',
'PART_COPY_TESTS',
'PART_CATEGORY_PARAMETERS',
'PART_CATEGORY_DEFAULT_ICON',
'PART_PARAMETER_ENFORCE_UNITS'
'PART_CATEGORY_DEFAULT_ICON'
]}
/>
)

View File

@@ -45,6 +45,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import ParametersPanel from '../../components/panels/ParametersPanel';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { RenderStockLocation } from '../../components/render/Stock';
import { useBuildOrderFields } from '../../forms/BuildForms';
@@ -519,6 +520,10 @@ export default function BuildDetail() {
<Skeleton />
)
},
ParametersPanel({
model_type: ModelType.build,
model_id: build.pk
}),
AttachmentPanel({
model_type: ModelType.build,
model_id: build.pk

View File

@@ -1,21 +1,27 @@
import { t } from '@lingui/core/macro';
import { Stack } from '@mantine/core';
import { IconCalendar, IconTable, IconTools } from '@tabler/icons-react';
import {
IconCalendar,
IconListDetails,
IconTable,
IconTools
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import type { TableFilter } from '@lib/types/Filters';
import { useLocalStorage } from '@mantine/hooks';
import SegmentedIconControl from '../../components/buttons/SegmentedIconControl';
import OrderCalendar from '../../components/calendar/OrderCalendar';
import PermissionDenied from '../../components/errors/PermissionDenied';
import { PageDetail } from '../../components/nav/PageDetail';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import SegmentedControlPanel from '../../components/panels/SegmentedControlPanel';
import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import { PartCategoryFilter } from '../../tables/Filter';
import BuildOrderParametricTable from '../../tables/build/BuildOrderParametricTable';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
function BuildOrderCalendar() {
@@ -43,20 +49,6 @@ function BuildOrderCalendar() {
);
}
function BuildOverview({
view
}: {
view: string;
}) {
switch (view) {
case 'calendar':
return <BuildOrderCalendar />;
case 'table':
default:
return <BuildOrderTable />;
}
}
/**
* Build Order index page
*/
@@ -64,34 +56,41 @@ export default function BuildIndex() {
const user = useUserState();
const [buildOrderView, setBuildOrderView] = useLocalStorage<string>({
key: 'buildOrderView',
key: 'build-order-view',
defaultValue: 'table'
});
const panels: PanelType[] = useMemo(() => {
return [
{
name: 'buildorders',
SegmentedControlPanel({
name: 'buildorder',
label: t`Build Orders`,
content: <BuildOverview view={buildOrderView} />,
icon: <IconTools />,
controls: (
<SegmentedIconControl
value={buildOrderView}
onChange={setBuildOrderView}
data={[
{ value: 'table', label: t`Table View`, icon: <IconTable /> },
{
value: 'calendar',
label: t`Calendar View`,
icon: <IconCalendar />
}
]}
/>
)
}
selection: buildOrderView,
onChange: setBuildOrderView,
options: [
{
value: 'table',
label: t`Table View`,
icon: <IconTable />,
content: <BuildOrderTable />
},
{
value: 'calendar',
label: t`Calendar View`,
icon: <IconCalendar />,
content: <BuildOrderCalendar />
},
{
value: 'parametric',
label: t`Parametric View`,
icon: <IconListDetails />,
content: <BuildOrderParametricTable />
}
]
})
];
}, [buildOrderView, setBuildOrderView]);
}, [user, buildOrderView]);
if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.build)) {
return <PermissionDenied />;

View File

@@ -39,6 +39,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import ParametersPanel from '../../components/panels/ParametersPanel';
import { companyFields } from '../../forms/CompanyForms';
import {
useDeleteApiFormModal,
@@ -265,6 +266,10 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
icon: <IconMap2 />,
content: company?.pk && <AddressTable companyId={company.pk} />
},
ParametersPanel({
model_type: ModelType.company,
model_id: company?.pk
}),
AttachmentPanel({
model_type: ModelType.company,
model_id: company.pk

View File

@@ -3,7 +3,6 @@ import { Grid, Skeleton, Stack } from '@mantine/core';
import {
IconBuildingWarehouse,
IconInfoCircle,
IconList,
IconPackages
} from '@tabler/icons-react';
import { useMemo } from 'react';
@@ -33,6 +32,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import ParametersPanel from '../../components/panels/ParametersPanel';
import { useManufacturerPartFields } from '../../forms/CompanyForms';
import {
useCreateApiFormModal,
@@ -41,7 +41,6 @@ import {
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useUserState } from '../../states/UserState';
import ManufacturerPartParameterTable from '../../tables/purchasing/ManufacturerPartParameterTable';
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
@@ -161,18 +160,6 @@ export default function ManufacturerPartDetail() {
icon: <IconInfoCircle />,
content: detailsPanel
},
{
name: 'parameters',
label: t`Parameters`,
icon: <IconList />,
content: manufacturerPart?.pk ? (
<ManufacturerPartParameterTable
params={{ manufacturer_part: manufacturerPart.pk }}
/>
) : (
<Skeleton />
)
},
{
name: 'stock',
label: t`Received Stock`,
@@ -201,6 +188,10 @@ export default function ManufacturerPartDetail() {
<Skeleton />
)
},
ParametersPanel({
model_type: ModelType.manufacturerpart,
model_id: manufacturerPart?.pk
}),
AttachmentPanel({
model_type: ModelType.manufacturerpart,
model_id: manufacturerPart?.pk

View File

@@ -36,6 +36,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import ParametersPanel from '../../components/panels/ParametersPanel';
import { useSupplierPartFields } from '../../forms/CompanyForms';
import {
useCreateApiFormModal,
@@ -284,6 +285,10 @@ export default function SupplierPartDetail() {
<Skeleton />
)
},
ParametersPanel({
model_type: ModelType.supplierpart,
model_id: supplierPart?.pk
}),
AttachmentPanel({
model_type: ModelType.supplierpart,
model_id: supplierPart?.pk

View File

@@ -6,7 +6,8 @@ import {
IconListCheck,
IconListDetails,
IconPackages,
IconSitemap
IconSitemap,
IconTable
} from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
@@ -15,6 +16,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { getDetailUrl } from '@lib/functions/Navigation';
import { useLocalStorage } from '@mantine/hooks';
import AdminButton from '../../components/buttons/AdminButton';
import StarredToggleButton from '../../components/buttons/StarredToggleButton';
import {
@@ -33,6 +35,7 @@ import NavigationTree from '../../components/nav/NavigationTree';
import { PageDetail } from '../../components/nav/PageDetail';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import SegmentedControlPanel from '../../components/panels/SegmentedControlPanel';
import { partCategoryFields } from '../../forms/PartForms';
import {
useDeleteApiFormModal,
@@ -258,6 +261,11 @@ export default function CategoryDetail() {
];
}, [id, user, category.pk, category.starred]);
const [partsView, setPartsView] = useLocalStorage<string>({
key: 'category-parts-view',
defaultValue: 'table'
});
const panels: PanelType[] = useMemo(
() => [
{
@@ -272,20 +280,35 @@ export default function CategoryDetail() {
icon: <IconSitemap />,
content: <PartCategoryTable parentId={id} />
},
{
SegmentedControlPanel({
name: 'parts',
label: t`Parts`,
icon: <IconCategory />,
content: (
<PartListTable
props={{
params: {
category: id
}
}}
/>
)
},
selection: partsView,
onChange: setPartsView,
options: [
{
value: 'table',
label: t`Table View`,
icon: <IconTable />,
content: (
<PartListTable
props={{
params: {
category: id
}
}}
/>
)
},
{
value: 'parametric',
label: t`Parametric View`,
icon: <IconListDetails />,
content: <ParametricPartTable categoryId={id} />
}
]
}),
{
name: 'stockitem',
label: t`Stock Items`,
@@ -307,15 +330,9 @@ export default function CategoryDetail() {
icon: <IconListCheck />,
hidden: !id || !category.pk,
content: <PartCategoryTemplateTable categoryId={category?.pk} />
},
{
name: 'parameters',
label: t`Part Parameters`,
icon: <IconListDetails />,
content: <ParametricPartTable categoryId={id} />
}
],
[category, id]
[category, id, partsView]
);
const breadcrumbs = useMemo(

View File

@@ -22,8 +22,8 @@ import {
IconExclamationCircle,
IconInfoCircle,
IconLayersLinked,
IconList,
IconListCheck,
IconListDetails,
IconListTree,
IconLock,
IconPackages,
@@ -97,7 +97,7 @@ import { useUserState } from '../../states/UserState';
import { BomTable } from '../../tables/bom/BomTable';
import { UsedInTable } from '../../tables/bom/UsedInTable';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import { PartParameterTable } from '../../tables/part/PartParameterTable';
import { ParameterTable } from '../../tables/general/ParameterTable';
import PartPurchaseOrdersTable from '../../tables/part/PartPurchaseOrdersTable';
import PartTestResultTable from '../../tables/part/PartTestResultTable';
import PartTestTemplateTable from '../../tables/part/PartTestTemplateTable';
@@ -788,17 +788,6 @@ export default function PartDetail() {
icon: <IconInfoCircle />,
content: detailsPanel
},
{
name: 'parameters',
label: t`Parameters`,
icon: <IconList />,
content: (
<PartParameterTable
partId={id ?? -1}
partLocked={part?.locked == true}
/>
)
},
{
name: 'stock',
label: t`Stock`,
@@ -949,6 +938,30 @@ export default function PartDetail() {
icon: <IconLayersLinked />,
content: <RelatedPartTable partId={part.pk} />
},
{
name: 'parameters',
label: t`Parameters`,
icon: <IconListDetails />,
content: (
<>
{part.locked && (
<Alert
title={t`Part is Locked`}
color='orange'
icon={<IconLock />}
p='xs'
>
<Text>{t`Part parameters cannot be edited, as the part is locked`}</Text>
</Alert>
)}
<ParameterTable
modelType={ModelType.part}
modelId={part?.pk}
allowEdit={part?.locked != true}
/>
</>
)
},
AttachmentPanel({
model_type: ModelType.part,
model_id: part?.pk

View File

@@ -32,6 +32,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import ParametersPanel from '../../components/panels/ParametersPanel';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { formatCurrency } from '../../defaults/formatters';
import { usePurchaseOrderFields } from '../../forms/PurchaseOrderForms';
@@ -389,6 +390,10 @@ export default function PurchaseOrderDetail() {
/>
)
},
ParametersPanel({
model_type: ModelType.purchaseorder,
model_id: order.pk
}),
AttachmentPanel({
model_type: ModelType.purchaseorder,
model_id: order.pk

View File

@@ -5,6 +5,7 @@ import {
IconBuildingStore,
IconBuildingWarehouse,
IconCalendar,
IconListDetails,
IconPackageExport,
IconShoppingCart,
IconTable
@@ -14,104 +15,193 @@ import { useMemo } from 'react';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { useLocalStorage } from '@mantine/hooks';
import SegmentedIconControl from '../../components/buttons/SegmentedIconControl';
import OrderCalendar from '../../components/calendar/OrderCalendar';
import PermissionDenied from '../../components/errors/PermissionDenied';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup } from '../../components/panels/PanelGroup';
import SegmentedControlPanel from '../../components/panels/SegmentedControlPanel';
import { useUserState } from '../../states/UserState';
import { CompanyTable } from '../../tables/company/CompanyTable';
import ParametricCompanyTable from '../../tables/company/ParametricCompanyTable';
import ManufacturerPartParametricTable from '../../tables/purchasing/ManufacturerPartParametricTable';
import { ManufacturerPartTable } from '../../tables/purchasing/ManufacturerPartTable';
import PurchaseOrderParametricTable from '../../tables/purchasing/PurchaseOrderParametricTable';
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
import SupplierPartParametricTable from '../../tables/purchasing/SupplierPartParametricTable';
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
function PurchaseOrderOverview({
view
}: {
view: string;
}) {
switch (view) {
case 'calendar':
return (
<OrderCalendar
model={ModelType.purchaseorder}
role={UserRoles.purchase_order}
params={{ outstanding: true }}
/>
);
case 'table':
default:
return <PurchaseOrderTable />;
}
}
export default function PurchasingIndex() {
const user = useUserState();
const [purchaseOrderView, setpurchaseOrderView] = useLocalStorage<string>({
key: 'purchaseOrderView',
const [purchaseOrderView, setPurchaseOrderView] = useLocalStorage<string>({
key: 'purchase-order-view',
defaultValue: 'table'
});
const [supplierView, setSupplierView] = useLocalStorage<string>({
key: 'supplier-view',
defaultValue: 'table'
});
const [manufacturerView, setManufacturerView] = useLocalStorage<string>({
key: 'manufacturer-view',
defaultValue: 'table'
});
const [manufacturerPartsView, setManufacturerPartsView] =
useLocalStorage<string>({
key: 'manufacturer-parts-view',
defaultValue: 'table'
});
const [supplierPartsView, setSupplierPartsView] = useLocalStorage<string>({
key: 'supplier-parts-view',
defaultValue: 'table'
});
const panels = useMemo(() => {
return [
{
SegmentedControlPanel({
name: 'purchaseorders',
label: t`Purchase Orders`,
icon: <IconShoppingCart />,
hidden: !user.hasViewRole(UserRoles.purchase_order),
content: <PurchaseOrderOverview view={purchaseOrderView} />,
controls: (
<SegmentedIconControl
value={purchaseOrderView}
onChange={setpurchaseOrderView}
data={[
{ value: 'table', label: t`Table View`, icon: <IconTable /> },
{
value: 'calendar',
label: t`Calendar View`,
icon: <IconCalendar />
}
]}
/>
)
},
{
selection: purchaseOrderView,
onChange: setPurchaseOrderView,
options: [
{
value: 'table',
label: t`Table View`,
icon: <IconTable />,
content: <PurchaseOrderTable />
},
{
value: 'calendar',
label: t`Calendar View`,
icon: <IconCalendar />,
content: (
<OrderCalendar
model={ModelType.purchaseorder}
role={UserRoles.purchase_order}
params={{ outstanding: true }}
/>
)
},
{
value: 'parametric',
label: t`Parametric View`,
icon: <IconListDetails />,
content: <PurchaseOrderParametricTable />
}
]
}),
SegmentedControlPanel({
name: 'suppliers',
label: t`Suppliers`,
icon: <IconBuildingStore />,
content: (
<CompanyTable
path='purchasing/supplier'
params={{ is_supplier: true }}
/>
)
},
{
selection: supplierView,
onChange: setSupplierView,
options: [
{
value: 'table',
label: t`Table View`,
icon: <IconTable />,
content: (
<CompanyTable
path='purchasing/supplier'
params={{ is_supplier: true }}
/>
)
},
{
value: 'parametric',
label: t`Parametric View`,
icon: <IconListDetails />,
content: (
<ParametricCompanyTable queryParams={{ is_supplier: true }} />
)
}
]
}),
SegmentedControlPanel({
name: 'supplier-parts',
label: t`Supplier Parts`,
icon: <IconPackageExport />,
content: <SupplierPartTable />
},
{
selection: supplierPartsView,
onChange: setSupplierPartsView,
options: [
{
value: 'table',
label: t`Table View`,
icon: <IconTable />,
content: <SupplierPartTable />
},
{
value: 'parametric',
label: t`Parametric View`,
icon: <IconListDetails />,
content: <SupplierPartParametricTable />
}
]
}),
SegmentedControlPanel({
name: 'manufacturer',
label: t`Manufacturers`,
icon: <IconBuildingFactory2 />,
content: (
<CompanyTable
path='purchasing/manufacturer'
params={{ is_manufacturer: true }}
/>
)
},
{
selection: manufacturerView,
onChange: setManufacturerView,
options: [
{
value: 'table',
label: t`Table View`,
icon: <IconTable />,
content: (
<CompanyTable
path='purchasing/manufacturer'
params={{ is_manufacturer: true }}
/>
)
},
{
value: 'parametric',
label: t`Parametric View`,
icon: <IconListDetails />,
content: (
<ParametricCompanyTable queryParams={{ is_manufacturer: true }} />
)
}
]
}),
SegmentedControlPanel({
name: 'manufacturer-parts',
label: t`Manufacturer Parts`,
icon: <IconBuildingWarehouse />,
content: <ManufacturerPartTable />
}
selection: manufacturerPartsView,
onChange: setManufacturerPartsView,
options: [
{
value: 'table',
label: t`Table View`,
icon: <IconTable />,
content: <ManufacturerPartTable />
},
{
value: 'parametric',
label: t`Parametric View`,
icon: <IconListDetails />,
content: <ManufacturerPartParametricTable />
}
]
})
];
}, [user, purchaseOrderView]);
}, [
user,
manufacturerPartsView,
manufacturerView,
purchaseOrderView,
supplierPartsView,
supplierView
]);
if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.purchase_order)) {
return <PermissionDenied />;

View File

@@ -32,6 +32,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import ParametersPanel from '../../components/panels/ParametersPanel';
import { RenderAddress } from '../../components/render/Company';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { formatCurrency } from '../../defaults/formatters';
@@ -354,6 +355,10 @@ export default function ReturnOrderDetail() {
</Accordion>
)
},
ParametersPanel({
model_type: ModelType.returnorder,
model_id: order.pk
}),
AttachmentPanel({
model_type: ModelType.returnorder,
model_id: order.pk

View File

@@ -4,6 +4,7 @@ import {
IconBuildingStore,
IconCalendar,
IconCubeSend,
IconListDetails,
IconTable,
IconTruckDelivery,
IconTruckReturn
@@ -13,93 +14,74 @@ import { useMemo } from 'react';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { useLocalStorage } from '@mantine/hooks';
import SegmentedIconControl from '../../components/buttons/SegmentedIconControl';
import OrderCalendar from '../../components/calendar/OrderCalendar';
import PermissionDenied from '../../components/errors/PermissionDenied';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup } from '../../components/panels/PanelGroup';
import SegmentedControlPanel from '../../components/panels/SegmentedControlPanel';
import { useUserState } from '../../states/UserState';
import { CompanyTable } from '../../tables/company/CompanyTable';
import ParametricCompanyTable from '../../tables/company/ParametricCompanyTable';
import ReturnOrderParametricTable from '../../tables/sales/ReturnOrderParametricTable';
import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable';
import SalesOrderParametricTable from '../../tables/sales/SalesOrderParametricTable';
import SalesOrderShipmentTable from '../../tables/sales/SalesOrderShipmentTable';
import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
function SalesOrderOverview({
view
}: {
view: string;
}) {
switch (view) {
case 'calendar':
return (
<OrderCalendar
model={ModelType.salesorder}
role={UserRoles.sales_order}
params={{ outstanding: true }}
/>
);
case 'table':
default:
return <SalesOrderTable />;
}
}
function ReturnOrderOverview({
view
}: {
view: string;
}) {
switch (view) {
case 'calendar':
return (
<OrderCalendar
model={ModelType.returnorder}
role={UserRoles.return_order}
params={{ outstanding: true }}
/>
);
case 'table':
default:
return <ReturnOrderTable />;
}
}
export default function SalesIndex() {
const user = useUserState();
const [customersView, setCustomersView] = useLocalStorage<string>({
key: 'customer-view',
defaultValue: 'table'
});
const [salesOrderView, setSalesOrderView] = useLocalStorage<string>({
key: 'salesOrderView',
key: 'sales-order-view',
defaultValue: 'table'
});
const [returnOrderView, setReturnOrderView] = useLocalStorage<string>({
key: 'returnOrderView',
key: 'return-order-view',
defaultValue: 'table'
});
const panels = useMemo(() => {
return [
{
SegmentedControlPanel({
name: 'salesorders',
label: t`Sales Orders`,
icon: <IconTruckDelivery />,
content: <SalesOrderOverview view={salesOrderView} />,
controls: (
<SegmentedIconControl
value={salesOrderView}
onChange={setSalesOrderView}
data={[
{ value: 'table', label: t`Table View`, icon: <IconTable /> },
{
value: 'calendar',
label: t`Calendar View`,
icon: <IconCalendar />
}
]}
/>
),
hidden: !user.hasViewRole(UserRoles.sales_order)
},
hidden: !user.hasViewRole(UserRoles.sales_order),
selection: salesOrderView,
onChange: setSalesOrderView,
options: [
{
value: 'table',
label: t`Table View`,
icon: <IconTable />,
content: <SalesOrderTable />
},
{
value: 'calendar',
label: t`Calendar View`,
icon: <IconCalendar />,
content: (
<OrderCalendar
model={ModelType.returnorder}
role={UserRoles.return_order}
params={{ outstanding: true }}
/>
)
},
{
value: 'parametric',
label: t`Parametric View`,
icon: <IconListDetails />,
content: <SalesOrderParametricTable />
}
]
}),
{
name: 'shipments',
label: t`Pending Shipments`,
@@ -112,37 +94,70 @@ export default function SalesIndex() {
/>
)
},
{
SegmentedControlPanel({
name: 'returnorders',
label: t`Return Orders`,
icon: <IconTruckReturn />,
content: <ReturnOrderOverview view={returnOrderView} />,
controls: (
<SegmentedIconControl
value={returnOrderView}
onChange={setReturnOrderView}
data={[
{ value: 'table', label: t`Table View`, icon: <IconTable /> },
{
value: 'calendar',
label: t`Calendar View`,
icon: <IconCalendar />
}
]}
/>
),
hidden: !user.hasViewRole(UserRoles.return_order)
},
{
hidden: !user.hasViewRole(UserRoles.return_order),
selection: returnOrderView,
onChange: setReturnOrderView,
options: [
{
value: 'table',
label: t`Table View`,
icon: <IconTable />,
content: <ReturnOrderTable />
},
{
value: 'calendar',
label: t`Calendar View`,
icon: <IconCalendar />,
content: (
<OrderCalendar
model={ModelType.returnorder}
role={UserRoles.return_order}
params={{ outstanding: true }}
/>
)
},
{
value: 'parametric',
label: t`Parametric View`,
icon: <IconListDetails />,
content: <ReturnOrderParametricTable />
}
]
}),
SegmentedControlPanel({
name: 'customers',
label: t`Customers`,
icon: <IconBuildingStore />,
content: (
<CompanyTable path='sales/customer' params={{ is_customer: true }} />
)
}
selection: customersView,
onChange: setCustomersView,
options: [
{
value: 'table',
label: t`Table View`,
icon: <IconTable />,
content: (
<CompanyTable
path='sales/customer'
params={{ is_customer: true }}
/>
)
},
{
value: 'parametric',
label: t`Parametric View`,
icon: <IconListDetails />,
content: (
<ParametricCompanyTable queryParams={{ is_customer: true }} />
)
}
]
})
];
}, [user, salesOrderView, returnOrderView]);
}, [user, customersView, salesOrderView, returnOrderView]);
if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.sales_order)) {
return <PermissionDenied />;

View File

@@ -38,6 +38,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import ParametersPanel from '../../components/panels/ParametersPanel';
import { RenderAddress } from '../../components/render/Company';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { formatCurrency } from '../../defaults/formatters';
@@ -427,6 +428,10 @@ export default function SalesOrderDetail() {
<Skeleton />
)
},
ParametersPanel({
model_type: ModelType.salesorder,
model_id: order.pk
}),
AttachmentPanel({
model_type: ModelType.salesorder,
model_id: order.pk

View File

@@ -0,0 +1,40 @@
import { ApiEndpoints, ModelType } from '@lib/index';
import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables';
import { type ReactNode, useMemo } from 'react';
import { DescriptionColumn, ReferenceColumn } from '../ColumnRenderers';
import { OrderStatusFilter, OutstandingFilter } from '../Filter';
import ParametricDataTable from '../general/ParametricDataTable';
export default function BuildOrderParametricTable({
queryParams
}: {
queryParams?: Record<string, any>;
}): ReactNode {
const customColumns: TableColumn[] = useMemo(() => {
return [
ReferenceColumn({
switchable: false
}),
DescriptionColumn({
accessor: 'title'
})
];
}, []);
const customFilters: TableFilter[] = useMemo(() => {
return [OutstandingFilter(), OrderStatusFilter({ model: ModelType.build })];
}, []);
return (
<ParametricDataTable
modelType={ModelType.build}
endpoint={ApiEndpoints.build_order_list}
customColumns={customColumns}
customFilters={customFilters}
queryParams={{
...queryParams
}}
/>
);
}

View File

@@ -0,0 +1,39 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import type { TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { CompanyColumn, DescriptionColumn } from '../ColumnRenderers';
import ParametricDataTable from '../general/ParametricDataTable';
export default function ParametricCompanyTable({
queryParams
}: {
queryParams?: any;
}) {
const customColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'name',
title: t`Company`,
sortable: true,
switchable: false,
render: (record: any) => {
return <CompanyColumn company={record} />;
}
},
DescriptionColumn({})
];
}, []);
return (
<ParametricDataTable
modelType={ModelType.company}
endpoint={ApiEndpoints.company_list}
customColumns={customColumns}
queryParams={{
...queryParams
}}
/>
);
}

View File

@@ -0,0 +1,276 @@
import {
ApiEndpoints,
ModelType,
RowDeleteAction,
RowEditAction,
YesNoButton,
apiUrl,
formatDecimal
} from '@lib/index';
import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { IconFileUpload, IconPlus } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import ImporterDrawer from '../../components/importer/ImporterDrawer';
import { ActionDropdown } from '../../components/items/ActionDropdown';
import { useParameterFields } from '../../forms/CommonForms';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
import {
DateColumn,
DescriptionColumn,
NoteColumn,
UserColumn
} from '../ColumnRenderers';
import { UserFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard';
/**
* Construct a table listing parameters
*/
export function ParameterTable({
modelType,
modelId,
allowEdit = true
}: {
modelType: ModelType;
modelId: number;
allowEdit?: boolean;
}) {
const table = useTable('parameters');
const user = useUserState();
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'template_detail.name',
switchable: false,
sortable: true,
ordering: 'name'
},
DescriptionColumn({
accessor: 'template_detail.description'
}),
{
accessor: 'data',
switchable: false,
sortable: true,
render: (record) => {
const template = record.template_detail;
if (template?.checkbox) {
return <YesNoButton value={record.data} />;
}
const extra: any[] = [];
if (
template.units &&
record.data_numeric &&
record.data_numeric != record.data
) {
const numeric = formatDecimal(record.data_numeric, { digits: 15 });
extra.push(`${numeric} [${template.units}]`);
}
return (
<TableHoverCard
value={record.data}
extra={extra}
title={t`Internal Units`}
/>
);
}
},
{
accessor: 'template_detail.units',
ordering: 'units',
sortable: true
},
NoteColumn({}),
DateColumn({
accessor: 'updated',
title: t`Last Updated`,
sortable: true,
switchable: true
}),
UserColumn({
accessor: 'updated_by_detail',
ordering: 'updated_by',
title: t`Updated By`
})
];
}, [user]);
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'enabled',
label: 'Enabled',
description: t`Show parameters for enabled templates`,
type: 'boolean'
},
UserFilter({
name: 'updated_by',
label: t`Updated By`,
description: t`Filter by user who last updated the parameter`
})
];
}, []);
const [selectedParameter, setSelectedParameter] = useState<any | undefined>(
undefined
);
const [importOpened, setImportOpened] = useState<boolean>(false);
const [selectedSession, setSelectedSession] = useState<number | undefined>(
undefined
);
const importSessionFields = useMemo(() => {
const fields = dataImporterSessionFields({
modelType: ModelType.parameter
});
fields.field_overrides.value = {
model_type: modelType,
model_id: modelId
};
return fields;
}, [modelType, modelId]);
const importParameters = useCreateApiFormModal({
url: ApiEndpoints.import_session_list,
title: t`Import Parameters`,
fields: importSessionFields,
onFormSuccess: (response: any) => {
setSelectedSession(response.pk);
setImportOpened(true);
}
});
const newParameter = useCreateApiFormModal({
url: ApiEndpoints.parameter_list,
title: t`Add Parameter`,
fields: useParameterFields({ modelType, modelId }),
initialData: {
data: ''
},
table: table
});
const editParameter = useEditApiFormModal({
url: ApiEndpoints.parameter_list,
pk: selectedParameter?.pk,
title: t`Edit Parameter`,
fields: useParameterFields({ modelType, modelId }),
table: table
});
const deleteParameter = useDeleteApiFormModal({
url: ApiEndpoints.parameter_list,
pk: selectedParameter?.pk,
title: t`Delete Parameter`,
table: table
});
const tableActions = useMemo(() => {
return [
<ActionDropdown
key='add-parameter-actions'
tooltip={t`Add Parameters`}
position='bottom-start'
icon={<IconPlus />}
hidden={!user.hasAddPermission(modelType)}
actions={[
{
name: t`Create Parameter`,
icon: <IconPlus />,
tooltip: t`Create a new parameter`,
onClick: () => {
setSelectedParameter(undefined);
newParameter.open();
}
},
{
name: t`Import from File`,
icon: <IconFileUpload />,
tooltip: t`Import parameters from a file`,
onClick: () => {
importParameters.open();
}
}
]}
/>
];
}, [allowEdit, user]);
const rowActions = useCallback(
(record: any) => {
return [
RowEditAction({
tooltip: t`Edit Parameter`,
onClick: () => {
setSelectedParameter(record);
editParameter.open();
},
hidden: !user.hasChangePermission(modelType)
}),
RowDeleteAction({
tooltip: t`Delete Parameter`,
onClick: () => {
setSelectedParameter(record);
deleteParameter.open();
},
hidden: !user.hasDeletePermission(modelType)
})
];
},
[user]
);
return (
<>
{newParameter.modal}
{editParameter.modal}
{deleteParameter.modal}
{importParameters.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.parameter_list)}
tableState={table}
columns={tableColumns}
props={{
enableDownload: true,
enableBulkDelete: allowEdit != false,
enableSelection: allowEdit != false,
rowActions: allowEdit == false ? undefined : rowActions,
tableActions: allowEdit == false ? undefined : tableActions,
tableFilters: tableFilters,
params: {
model_type: modelType,
model_id: modelId
}
}}
/>
<ImporterDrawer
sessionId={selectedSession ?? -1}
opened={selectedSession !== undefined && importOpened}
onClose={() => {
setSelectedSession(undefined);
setImportOpened(false);
table.refreshTable();
}}
/>
</>
);
}

View File

@@ -1,19 +1,18 @@
import { t } from '@lingui/core/macro';
import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '@lib/components/AddItemButton';
import {
type RowAction,
AddItemButton,
ApiEndpoints,
type ApiFormFieldSet,
RowDeleteAction,
RowDuplicateAction,
RowEditAction
} from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
RowEditAction,
UserRoles,
apiUrl
} from '@lib/index';
import type { TableFilter } from '@lib/types/Filters';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables';
import type { RowAction, TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { useCallback, useMemo, useState } from 'react';
import { useFilters } from '../../hooks/UseFilter';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
@@ -24,11 +23,114 @@ import { useUserState } from '../../states/UserState';
import { BooleanColumn, DescriptionColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
export default function PartParameterTemplateTable() {
const table = useTable('part-parameter-templates');
/**
* Render a table of ParameterTemplate objects
*/
export default function ParameterTemplateTable() {
const table = useTable('parameter-templates');
const user = useUserState();
const parameterTemplateFields: ApiFormFieldSet = useMemo(() => {
return {
name: {},
description: {},
units: {},
model_type: {},
choices: {},
checkbox: {},
selectionlist: {},
enabled: {}
};
}, []);
const newTemplate = useCreateApiFormModal({
url: ApiEndpoints.parameter_template_list,
title: t`Add Parameter Template`,
table: table,
fields: useMemo(
() => ({
...parameterTemplateFields
}),
[parameterTemplateFields]
)
});
const [selectedTemplate, setSelectedTemplate] = useState<any | undefined>(
undefined
);
const duplicateTemplate = useCreateApiFormModal({
url: ApiEndpoints.parameter_template_list,
title: t`Duplicate Parameter Template`,
table: table,
fields: useMemo(
() => ({
...parameterTemplateFields
}),
[parameterTemplateFields]
),
initialData: selectedTemplate
});
const deleteTemplate = useDeleteApiFormModal({
url: ApiEndpoints.parameter_template_list,
pk: selectedTemplate?.pk,
title: t`Delete Parameter Template`,
table: table
});
const editTemplate = useEditApiFormModal({
url: ApiEndpoints.parameter_template_list,
pk: selectedTemplate?.pk,
title: t`Edit Parameter Template`,
table: table,
fields: useMemo(
() => ({
...parameterTemplateFields
}),
[parameterTemplateFields]
)
});
// Callback for row actions
const rowActions = useCallback(
(record: any): RowAction[] => {
return [
RowEditAction({
onClick: () => {
setSelectedTemplate(record);
editTemplate.open();
}
}),
RowDuplicateAction({
onClick: () => {
setSelectedTemplate(record);
duplicateTemplate.open();
}
}),
RowDeleteAction({
onClick: () => {
setSelectedTemplate(record);
deleteTemplate.open();
}
})
];
},
[user]
);
const modelTypeFilters = useFilters({
url: apiUrl(ApiEndpoints.parameter_template_list),
method: 'OPTIONS',
accessor: 'data.actions.POST.model_type.choices',
transform: (item: any) => {
return {
value: item.value,
label: item.display_name
};
}
});
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
@@ -45,9 +147,20 @@ export default function PartParameterTemplateTable() {
name: 'has_units',
label: t`Has Units`,
description: t`Show templates with units`
},
{
name: 'enabled',
label: t`Enabled`,
description: t`Show enabled templates`
},
{
name: 'model_type',
label: t`Model Type`,
description: t`Filter by model type`,
choices: modelTypeFilters.choices
}
];
}, []);
}, [modelTypeFilters.choices]);
const tableColumns: TableColumn[] = useMemo(() => {
return [
@@ -56,109 +169,27 @@ export default function PartParameterTemplateTable() {
sortable: true,
switchable: false
},
{
accessor: 'parts',
sortable: true,
switchable: true
},
DescriptionColumn({}),
{
accessor: 'units',
sortable: true
},
DescriptionColumn({}),
{
accessor: 'model_type'
},
BooleanColumn({
accessor: 'checkbox'
}),
{
accessor: 'choices'
}
},
BooleanColumn({
accessor: 'enabled',
title: t`Enabled`
})
];
}, []);
const partParameterTemplateFields: ApiFormFieldSet = useMemo(() => {
return {
name: {},
description: {},
units: {},
choices: {},
checkbox: {},
selectionlist: {}
};
}, []);
const newTemplate = useCreateApiFormModal({
url: ApiEndpoints.part_parameter_template_list,
title: t`Add Parameter Template`,
table: table,
fields: useMemo(
() => ({ ...partParameterTemplateFields }),
[partParameterTemplateFields]
)
});
const [selectedTemplate, setSelectedTemplate] = useState<any | undefined>(
undefined
);
const duplicateTemplate = useCreateApiFormModal({
url: ApiEndpoints.part_parameter_template_list,
title: t`Duplicate Parameter Template`,
table: table,
fields: useMemo(
() => ({ ...partParameterTemplateFields }),
[partParameterTemplateFields]
),
initialData: selectedTemplate
});
const editTemplate = useEditApiFormModal({
url: ApiEndpoints.part_parameter_template_list,
pk: selectedTemplate?.pk,
title: t`Edit Parameter Template`,
table: table,
fields: useMemo(
() => ({ ...partParameterTemplateFields }),
[partParameterTemplateFields]
)
});
const deleteTemplate = useDeleteApiFormModal({
url: ApiEndpoints.part_parameter_template_list,
pk: selectedTemplate?.pk,
title: t`Delete Parameter Template`,
table: table
});
// Callback for row actions
const rowActions = useCallback(
(record: any): RowAction[] => {
return [
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.part),
onClick: () => {
setSelectedTemplate(record);
editTemplate.open();
}
}),
RowDuplicateAction({
hidden: !user.hasAddRole(UserRoles.part),
onClick: () => {
setSelectedTemplate(record);
duplicateTemplate.open();
}
}),
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.part),
onClick: () => {
setSelectedTemplate(record);
deleteTemplate.open();
}
})
];
},
[user]
);
const tableActions = useMemo(() => {
return [
<AddItemButton
@@ -177,13 +208,13 @@ export default function PartParameterTemplateTable() {
{duplicateTemplate.modal}
{deleteTemplate.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.part_parameter_template_list)}
url={apiUrl(ApiEndpoints.parameter_template_list)}
tableState={table}
columns={tableColumns}
props={{
rowActions: rowActions,
tableFilters: tableFilters,
tableActions: tableActions,
tableFilters: tableFilters,
enableDownload: true
}}
/>

View File

@@ -0,0 +1,442 @@
import { cancelEvent } from '@lib/functions/Events';
import {
ApiEndpoints,
type ApiFormFieldSet,
type 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 { IconCirclePlus } from '@tabler/icons-react';
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,
exists_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 | null>(null);
const [selectedParameter, setSelectedParameter] = useState<number>(-1);
const parameterFields: ApiFormFieldSet = useParameterFields({
modelType: modelType,
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);
// Ensure that the parameter template is included in the table
const template = parameterTemplates.data.find(
(t: any) => t.pk == parameter.template
);
if (!template) {
// Reload the parameter templates
parameterTemplates.refetch();
}
},
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]);
const rowActions = useCallback(
(record: any) => {
return [
{
title: t`Add Parameter`,
icon: <IconCirclePlus />,
color: 'green',
hidden: !user.hasAddPermission(modelType),
onClick: () => {
setSelectedInstance(record.pk);
setSelectedTemplate(null);
addParameter.open();
}
}
];
},
[modelType, user]
);
return (
<>
{addParameter.modal}
{editParameter.modal}
<InvenTreeTable
url={apiUrl(endpoint)}
tableState={table}
columns={tableColumns}
props={{
enableDownload: true,
rowActions: rowActions,
tableFilters: tableFilters,
params: {
...queryParams,
parameters: true,
...parametricQueryFilters
},
modelType: modelType,
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);
}
}
}}
/>
</>
);
}

View File

@@ -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 { 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 { ApiFormFieldSet } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables';
import { useApi } from '../../contexts/ApiContext';
import { formatDecimal } from '../../defaults/formatters';
import { usePartParameterFields } from '../../forms/PartForms';
import {
useCreateApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { DescriptionColumn, PartColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
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>
);
}
import ParametricDataTable from '../general/ParametricDataTable';
export default function ParametricPartTable({
categoryId
}: Readonly<{
categoryId?: any;
}>) {
const api = useApi();
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(() => {
const customFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'active',
@@ -367,8 +32,8 @@ export default function ParametricPartTable({
];
}, []);
const tableColumns: TableColumn[] = useMemo(() => {
const partColumns: TableColumn[] = [
const customColumns: TableColumn[] = useMemo(() => {
return [
PartColumn({
part: '',
switchable: false
@@ -386,43 +51,19 @@ export default function ParametricPartTable({
sortable: true
}
];
return [...partColumns, ...parameterColumns];
}, [parameterColumns]);
}, []);
return (
<>
{addParameter.modal}
{editParameter.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.part_list)}
tableState={table}
columns={tableColumns}
props={{
enableDownload: true,
tableFilters: tableFilters,
params: {
category: categoryId,
cascade: 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);
}
}
}}
/>
</>
<ParametricDataTable
modelType={ModelType.part}
endpoint={ApiEndpoints.part_list}
customColumns={customColumns}
customFilters={customFilters}
queryParams={{
category: categoryId,
cascade: true,
category_detail: true
}}
/>
);
}

View File

@@ -37,7 +37,7 @@ export default function PartCategoryTemplateTable({
value: categoryId,
disabled: categoryId !== undefined
},
parameter_template: {},
template: {},
default_value: {}
};
}, [categoryId]);
@@ -83,7 +83,7 @@ export default function PartCategoryTemplateTable({
accessor: 'category_detail.pathstring'
},
{
accessor: 'parameter_template_detail.name',
accessor: 'template_detail.name',
title: t`Parameter Template`,
sortable: true,
switchable: false
@@ -99,8 +99,8 @@ export default function PartCategoryTemplateTable({
let units = '';
if (record?.parameter_template_detail?.units) {
units = `[${record.parameter_template_detail.units}]`;
if (record?.template_detail?.units) {
units = `[${record.template_detail.units}]`;
}
return (
@@ -162,7 +162,9 @@ export default function PartCategoryTemplateTable({
tableActions: tableActions,
enableDownload: true,
params: {
category: categoryId
category: categoryId,
template_detail: true,
category_detail: true
}
}}
/>

View File

@@ -1,254 +0,0 @@
import { t } from '@lingui/core/macro';
import { Alert, Stack, Text } from '@mantine/core';
import { IconLock } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '@lib/components/AddItemButton';
import {
type RowAction,
RowDeleteAction,
RowEditAction
} from '@lib/components/RowActions';
import { YesNoButton } from '@lib/components/YesNoButton';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import type { TableFilter } from '@lib/types/Filters';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables';
import { formatDecimal } from '../../defaults/formatters';
import { usePartParameterFields } from '../../forms/PartForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
import {
DateColumn,
DescriptionColumn,
NoteColumn,
PartColumn,
UserColumn
} from '../ColumnRenderers';
import { IncludeVariantsFilter, UserFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard';
/**
* Construct a table listing parameters for a given part
*/
export function PartParameterTable({
partId,
partLocked
}: Readonly<{
partId: any;
partLocked?: boolean;
}>) {
const table = useTable('part-parameters');
const user = useUserState();
const tableColumns: TableColumn[] = useMemo(() => {
return [
PartColumn({
part: 'part_detail'
}),
{
accessor: 'part_detail.IPN',
sortable: false,
switchable: true,
defaultVisible: false
},
{
accessor: 'template_detail.name',
switchable: false,
sortable: true,
ordering: 'name',
render: (record) => {
const variant = String(partId) != String(record.part);
return (
<Text style={{ fontStyle: variant ? 'italic' : 'inherit' }}>
{record.template_detail?.name}
</Text>
);
}
},
DescriptionColumn({
accessor: 'template_detail.description'
}),
{
accessor: 'data',
switchable: false,
sortable: true,
render: (record) => {
const template = record.template_detail;
if (template?.checkbox) {
return <YesNoButton value={record.data} />;
}
const extra: any[] = [];
if (
template.units &&
record.data_numeric &&
record.data_numeric != record.data
) {
const numeric = formatDecimal(record.data_numeric, { digits: 15 });
extra.push(`${numeric} [${template.units}]`);
}
return (
<TableHoverCard
value={record.data}
extra={extra}
title={t`Internal Units`}
/>
);
}
},
{
accessor: 'template_detail.units',
ordering: 'units',
sortable: true
},
NoteColumn({}),
DateColumn({
accessor: 'updated',
title: t`Last Updated`,
sortable: true,
switchable: true
}),
UserColumn({
accessor: 'updated_by_detail',
ordering: 'updated_by',
title: t`Updated By`
})
];
}, [partId]);
const tableFilters: TableFilter[] = useMemo(() => {
return [
IncludeVariantsFilter(),
UserFilter({
name: 'updated_by',
label: t`Updated By`,
description: t`Filter by user who last updated the parameter`
})
];
}, []);
const partParameterFields: ApiFormFieldSet = usePartParameterFields({});
const newParameter = useCreateApiFormModal({
url: ApiEndpoints.part_parameter_list,
title: t`New Part Parameter`,
fields: useMemo(() => ({ ...partParameterFields }), [partParameterFields]),
focus: 'template',
initialData: {
part: partId
},
table: table
});
const [selectedParameter, setSelectedParameter] = useState<
number | undefined
>(undefined);
const editParameter = useEditApiFormModal({
url: ApiEndpoints.part_parameter_list,
pk: selectedParameter,
title: t`Edit Part Parameter`,
focus: 'data',
fields: useMemo(() => ({ ...partParameterFields }), [partParameterFields]),
table: table
});
const deleteParameter = useDeleteApiFormModal({
url: ApiEndpoints.part_parameter_list,
pk: selectedParameter,
title: t`Delete Part Parameter`,
table: table
});
// Callback for row actions
const rowActions = useCallback(
(record: any): RowAction[] => {
// Actions not allowed for "variant" rows
if (String(partId) != String(record.part)) {
return [];
}
return [
RowEditAction({
tooltip: t`Edit Part Parameter`,
hidden: partLocked || !user.hasChangeRole(UserRoles.part),
onClick: () => {
setSelectedParameter(record.pk);
editParameter.open();
}
}),
RowDeleteAction({
tooltip: t`Delete Part Parameter`,
hidden: partLocked || !user.hasDeleteRole(UserRoles.part),
onClick: () => {
setSelectedParameter(record.pk);
deleteParameter.open();
}
})
];
},
[partId, partLocked, user]
);
// Custom table actions
const tableActions = useMemo(() => {
return [
<AddItemButton
key='add-parameter'
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
tooltip={t`Add parameter`}
onClick={() => newParameter.open()}
/>
];
}, [partLocked, user]);
return (
<>
{newParameter.modal}
{editParameter.modal}
{deleteParameter.modal}
<Stack gap='xs'>
{partLocked && (
<Alert
title={t`Part is Locked`}
color='orange'
icon={<IconLock />}
p='xs'
>
<Text>{t`Part parameters cannot be edited, as the part is locked`}</Text>
</Alert>
)}
<InvenTreeTable
url={apiUrl(ApiEndpoints.part_parameter_list)}
tableState={table}
columns={tableColumns}
props={{
rowActions: rowActions,
enableDownload: true,
tableActions: tableActions,
tableFilters: tableFilters,
params: {
part: partId,
template_detail: true,
part_detail: true
}
}}
/>
</Stack>
</>
);
}

View File

@@ -1,140 +0,0 @@
import { t } from '@lingui/core/macro';
import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '@lib/components/AddItemButton';
import {
type RowAction,
RowDeleteAction,
RowEditAction
} from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import type { TableColumn } from '@lib/types/Tables';
import { useManufacturerPartParameterFields } from '../../forms/CompanyForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
import { InvenTreeTable } from '../InvenTreeTable';
export default function ManufacturerPartParameterTable({
params
}: Readonly<{
params: any;
}>) {
const table = useTable('manufacturer-part-parameter');
const user = useUserState();
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'name',
title: t`Name`,
sortable: true,
switchable: false
},
{
accessor: 'value',
title: t`Value`,
sortable: true,
switchable: false
},
{
accessor: 'units',
title: t`Units`,
sortable: false,
switchable: true
}
];
}, []);
const fields = useManufacturerPartParameterFields();
const [selectedParameter, setSelectedParameter] = useState<
number | undefined
>(undefined);
const createParameter = useCreateApiFormModal({
url: ApiEndpoints.manufacturer_part_parameter_list,
title: t`Add Parameter`,
fields: fields,
table: table,
initialData: {
manufacturer_part: params.manufacturer_part
}
});
const editParameter = useEditApiFormModal({
url: ApiEndpoints.manufacturer_part_parameter_list,
pk: selectedParameter,
title: t`Edit Parameter`,
fields: fields,
table: table
});
const deleteParameter = useDeleteApiFormModal({
url: ApiEndpoints.manufacturer_part_parameter_list,
pk: selectedParameter,
title: t`Delete Parameter`,
table: table
});
const rowActions = useCallback(
(record: any): RowAction[] => {
return [
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.purchase_order),
onClick: () => {
setSelectedParameter(record.pk);
editParameter.open();
}
}),
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.purchase_order),
onClick: () => {
setSelectedParameter(record.pk);
deleteParameter.open();
}
})
];
},
[user]
);
const tableActions = useMemo(() => {
return [
<AddItemButton
key='add-parameter'
tooltip={t`Add Parameter`}
onClick={() => {
createParameter.open();
}}
hidden={!user.hasAddRole(UserRoles.purchase_order)}
/>
];
}, [user]);
return (
<>
{createParameter.modal}
{editParameter.modal}
{deleteParameter.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.manufacturer_part_parameter_list)}
tableState={table}
columns={tableColumns}
props={{
params: {
...params
},
rowActions: rowActions,
tableActions: tableActions
}}
/>
</>
);
}

View File

@@ -0,0 +1,70 @@
import { ApiEndpoints, ModelType } from '@lib/index';
import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { type ReactNode, useMemo } from 'react';
import { CompanyColumn, PartColumn } from '../ColumnRenderers';
import ParametricDataTable from '../general/ParametricDataTable';
export default function ManufacturerPartParametricTable({
queryParams
}: {
queryParams?: Record<string, any>;
}): ReactNode {
const customColumns: TableColumn[] = useMemo(() => {
return [
PartColumn({
switchable: false
}),
{
accessor: 'part_detail.IPN',
title: t`IPN`,
sortable: false,
switchable: true
},
{
accessor: 'manufacturer',
sortable: true,
render: (record: any) => (
<CompanyColumn company={record?.manufacturer_detail} />
)
},
{
accessor: 'MPN',
title: t`MPN`,
sortable: true
}
];
}, []);
const customFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'part_active',
label: t`Active Part`,
description: t`Show manufacturer parts for active internal parts.`,
type: 'boolean'
},
{
name: 'manufacturer_active',
label: t`Active Manufacturer`,
description: t`Show manufacturer parts for active manufacturers.`,
type: 'boolean'
}
];
}, []);
return (
<ParametricDataTable
modelType={ModelType.manufacturerpart}
endpoint={ApiEndpoints.manufacturer_part_list}
customColumns={customColumns}
customFilters={customFilters}
queryParams={{
...queryParams,
part_detail: true,
manufacturer_detail: true
}}
/>
);
}

View File

@@ -0,0 +1,67 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { type ReactNode, useMemo } from 'react';
import {
CompanyColumn,
DescriptionColumn,
ReferenceColumn
} from '../ColumnRenderers';
import {
AssignedToMeFilter,
OrderStatusFilter,
OutstandingFilter,
OverdueFilter,
ProjectCodeFilter,
ResponsibleFilter
} from '../Filter';
import ParametricDataTable from '../general/ParametricDataTable';
export default function PurchaseOrderParametricTable({
queryParams
}: {
queryParams?: Record<string, any>;
}): ReactNode {
const customColumns: TableColumn[] = useMemo(() => {
return [
ReferenceColumn({
switchable: false
}),
{
accessor: 'supplier__name',
title: t`Supplier`,
sortable: true,
render: (record: any) => (
<CompanyColumn company={record.supplier_detail} />
)
},
DescriptionColumn({})
];
}, []);
const customFilters: TableFilter[] = useMemo(() => {
return [
OrderStatusFilter({ model: ModelType.purchaseorder }),
OutstandingFilter(),
OverdueFilter(),
AssignedToMeFilter(),
ProjectCodeFilter(),
ResponsibleFilter()
];
}, []);
return (
<ParametricDataTable
modelType={ModelType.purchaseorder}
endpoint={ApiEndpoints.purchase_order_list}
customColumns={customColumns}
customFilters={customFilters}
queryParams={{
...queryParams,
supplier_detail: true
}}
/>
);
}

View File

@@ -0,0 +1,52 @@
import { ApiEndpoints, ModelType } from '@lib/index';
import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { type ReactNode, useMemo } from 'react';
import { CompanyColumn, PartColumn } from '../ColumnRenderers';
import ParametricDataTable from '../general/ParametricDataTable';
export default function SupplierPartParametricTable({
queryParams
}: {
queryParams?: Record<string, any>;
}): ReactNode {
const customColumns: TableColumn[] = useMemo(() => {
return [
PartColumn({
switchable: false,
part: 'part_detail'
}),
{
accessor: 'supplier',
sortable: true,
render: (record: any) => (
<CompanyColumn company={record?.supplier_detail} />
)
},
{
accessor: 'SKU',
title: t`Supplier Part`,
sortable: true
}
];
}, []);
const customFilters: TableFilter[] = useMemo(() => {
return [];
}, []);
return (
<ParametricDataTable
modelType={ModelType.supplierpart}
endpoint={ApiEndpoints.supplier_part_list}
customColumns={customColumns}
customFilters={customFilters}
queryParams={{
...queryParams,
part_detail: true,
supplier_detail: true
}}
/>
);
}

View File

@@ -0,0 +1,65 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { type ReactNode, useMemo } from 'react';
import {
CompanyColumn,
DescriptionColumn,
ReferenceColumn
} from '../ColumnRenderers';
import {
AssignedToMeFilter,
OrderStatusFilter,
OutstandingFilter,
OverdueFilter,
ProjectCodeFilter,
ResponsibleFilter
} from '../Filter';
import ParametricDataTable from '../general/ParametricDataTable';
export default function ReturnOrderParametricTable({
queryParams
}: {
queryParams?: Record<string, any>;
}): ReactNode {
const customColumns: TableColumn[] = useMemo(() => {
return [
ReferenceColumn({ switchable: false }),
{
accessor: 'customer__name',
title: t`Customer`,
sortable: true,
render: (record: any) => (
<CompanyColumn company={record.customer_detail} />
)
},
DescriptionColumn({})
];
}, []);
const customFilters: TableFilter[] = useMemo(() => {
return [
OrderStatusFilter({ model: ModelType.returnorder }),
OutstandingFilter(),
OverdueFilter(),
AssignedToMeFilter(),
ProjectCodeFilter(),
ResponsibleFilter()
];
}, []);
return (
<ParametricDataTable
modelType={ModelType.returnorder}
endpoint={ApiEndpoints.return_order_list}
customColumns={customColumns}
customFilters={customFilters}
queryParams={{
...queryParams,
customer_detail: true
}}
/>
);
}

View File

@@ -0,0 +1,65 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { type ReactNode, useMemo } from 'react';
import {
CompanyColumn,
DescriptionColumn,
ReferenceColumn
} from '../ColumnRenderers';
import {
AssignedToMeFilter,
OrderStatusFilter,
OutstandingFilter,
OverdueFilter,
ProjectCodeFilter,
ResponsibleFilter
} from '../Filter';
import ParametricDataTable from '../general/ParametricDataTable';
export default function SalesOrderParametricTable({
queryParams
}: {
queryParams?: Record<string, any>;
}): ReactNode {
const customColumns: TableColumn[] = useMemo(() => {
return [
ReferenceColumn({ switchable: false }),
{
accessor: 'customer__name',
title: t`Customer`,
sortable: true,
render: (record: any) => (
<CompanyColumn company={record.customer_detail} />
)
},
DescriptionColumn({})
];
}, []);
const customFilters: TableFilter[] = useMemo(() => {
return [
OrderStatusFilter({ model: ModelType.salesorder }),
OutstandingFilter(),
OverdueFilter(),
AssignedToMeFilter(),
ProjectCodeFilter(),
ResponsibleFilter()
];
}, []);
return (
<ParametricDataTable
modelType={ModelType.salesorder}
endpoint={ApiEndpoints.sales_order_list}
customColumns={customColumns}
customFilters={customFilters}
queryParams={{
...queryParams,
customer_detail: true
}}
/>
);
}

View File

@@ -1,11 +1,20 @@
import { expect } from '@playwright/test';
import { type Page, expect } from '@playwright/test';
import { createApi } from './api';
export const clickOnParamFilter = async (page: Page, name: string) => {
const button = await page
.getByRole('button', { name: `${name} Not sorted` })
.getByRole('button')
.first();
await button.scrollIntoViewIfNeeded();
await button.click();
};
/**
* Open the filter drawer for the currently visible table
* @param page - The page object
*/
export const openFilterDrawer = async (page) => {
export const openFilterDrawer = async (page: Page) => {
await page.getByLabel('table-select-filters').click();
};
@@ -13,7 +22,7 @@ export const openFilterDrawer = async (page) => {
* Close the filter drawer for the currently visible table
* @param page - The page object
*/
export const closeFilterDrawer = async (page) => {
export const closeFilterDrawer = async (page: Page) => {
await page.getByLabel('filter-drawer-close').click();
};
@@ -22,7 +31,11 @@ export const closeFilterDrawer = async (page) => {
* @param page - The page object
* @param name - The name of the button to click
*/
export const clickButtonIfVisible = async (page, name, timeout = 500) => {
export const clickButtonIfVisible = async (
page: Page,
name: string,
timeout = 500
) => {
await page.waitForTimeout(timeout);
if (await page.getByRole('button', { name }).isVisible()) {
@@ -34,14 +47,14 @@ export const clickButtonIfVisible = async (page, name, timeout = 500) => {
* Clear all filters from the currently visible table
* @param page - The page object
*/
export const clearTableFilters = async (page) => {
export const clearTableFilters = async (page: Page) => {
await openFilterDrawer(page);
await clickButtonIfVisible(page, 'Clear Filters', 250);
await closeFilterDrawer(page);
await page.waitForLoadState('networkidle');
};
export const setTableChoiceFilter = async (page, filter, value) => {
export const setTableChoiceFilter = async (page: Page, filter, value) => {
await openFilterDrawer(page);
await page.getByRole('button', { name: 'Add Filter' }).click();
@@ -103,7 +116,7 @@ export const navigate = async (
/**
* CLick on the 'tab' element with the provided name
*/
export const loadTab = async (page, tabName, exact?) => {
export const loadTab = async (page: Page, tabName, exact?) => {
await page
.getByLabel(/panel-tabs-/)
.getByRole('tab', { name: tabName, exact: exact ?? false })
@@ -113,13 +126,13 @@ export const loadTab = async (page, tabName, exact?) => {
};
// Activate "table" view in certain contexts
export const activateTableView = async (page) => {
export const activateTableView = async (page: Page) => {
await page.getByLabel('segmented-icon-control-table').click();
await page.waitForLoadState('networkidle');
};
// Activate "calendar" view in certain contexts
export const activateCalendarView = async (page) => {
export const activateCalendarView = async (page: Page) => {
await page.getByLabel('segmented-icon-control-calendar').click();
await page.waitForLoadState('networkidle');
};
@@ -127,7 +140,7 @@ export const activateCalendarView = async (page) => {
/**
* Perform a 'global search' on the provided page, for the provided query text
*/
export const globalSearch = async (page, query) => {
export const globalSearch = async (page: Page, query) => {
await page.getByLabel('open-search').click();
await page.getByLabel('global-search-input').clear();
await page.getByPlaceholder('Enter search text').fill(query);

View File

@@ -11,6 +11,26 @@ import {
} from '../helpers.ts';
import { doCachedLogin } from '../login.ts';
test('Build - Index', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'manufacturing/index/' });
await loadTab(page, 'Build Orders');
// Ensure all data views are available
await page
.getByRole('button', { name: 'segmented-icon-control-parametric' })
.click();
await page
.getByRole('button', { name: 'segmented-icon-control-calendar' })
.click();
await page.getByRole('button', { name: 'action-button-next-month' }).click();
await page
.getByRole('button', { name: 'segmented-icon-control-table' })
.click();
});
test('Build Order - Basic Tests', async ({ browser }) => {
const page = await doCachedLogin(browser);

View File

@@ -1,5 +1,5 @@
import { test } from '../baseFixtures.js';
import { loadTab, navigate } from '../helpers.js';
import { clickOnParamFilter, loadTab, navigate } from '../helpers.js';
import { doCachedLogin } from '../login.js';
test('Company', async ({ browser }) => {
@@ -40,3 +40,23 @@ test('Company', async ({ browser }) => {
await page.getByText('Enter a valid URL.').waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
});
test('Company - Parameters', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'steven',
password: 'wizardstaff',
url: 'purchasing/index/suppliers'
});
// Show parametric view
await page
.getByRole('button', { name: 'segmented-icon-control-parametric' })
.click();
// Filter by "payment terms" parameter value
await clickOnParamFilter(page, 'Payment Terms');
await page.getByRole('option', { name: 'NET-30' }).click();
await page.getByRole('cell', { name: 'Arrow Electronics' }).waitFor();
await page.getByRole('cell', { name: 'PCB assembly house' }).waitFor();
});

View File

@@ -1,6 +1,7 @@
import { test } from '../baseFixtures';
import {
clearTableFilters,
clickOnParamFilter,
clickOnRowMenu,
deletePart,
getRowFromCell,
@@ -182,7 +183,14 @@ test('Parts - Locking', async ({ browser }) => {
.waitFor();
await loadTab(page, 'Parameters');
await page.getByLabel('action-button-add-parameter').waitFor();
await page
.getByRole('button', { name: 'action-menu-add-parameters' })
.click();
await page
.getByRole('menuitem', {
name: 'action-menu-add-parameters-create-parameter'
})
.click();
// Navigate to a known assembly which *is* locked
await navigate(page, 'part/100/bom');
@@ -495,7 +503,14 @@ test('Parts - Parameters', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/69/parameters' });
// Create a new template
await page.getByLabel('action-button-add-parameter').click();
await page
.getByRole('button', { name: 'action-menu-add-parameters' })
.click();
await page
.getByRole('menuitem', {
name: 'action-menu-add-parameters-create-parameter'
})
.click();
// Select the "Color" parameter template (should create a "choice" field)
await page.getByLabel('related-field-template').fill('Color');
@@ -509,7 +524,7 @@ test('Parts - Parameters', async ({ browser }) => {
// Select the "polarized" parameter template (should create a "checkbox" field)
await page.getByLabel('related-field-template').fill('Polarized');
await page.getByText('Is this part polarized?').click();
await page.getByRole('option', { name: 'Polarized Is this part' }).click();
// Submit with "false" value
await page.getByRole('button', { name: 'Submit' }).click();
@@ -538,38 +553,33 @@ test('Parts - Parameters', async ({ browser }) => {
// Finally, delete the parameter
await row.getByLabel(/row-action-menu-/i).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete', exact: true }).click();
await page.getByText('No records found').first().waitFor();
});
test('Parts - Parameter Filtering', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/' });
await loadTab(page, 'Part Parameters');
await loadTab(page, 'Parts', true);
await page
.getByRole('button', { name: 'segmented-icon-control-parametric' })
.click();
await clearTableFilters(page);
// All parts should be available (no filters applied)
await page.getByText(/\/ 42\d/).waitFor();
const clickOnParamFilter = async (name: string) => {
const button = await page
.getByRole('button', { name: `${name} Not sorted` })
.getByRole('button')
.first();
await button.scrollIntoViewIfNeeded();
await button.click();
};
const clearParamFilter = async (name: string) => {
await clickOnParamFilter(name);
await clickOnParamFilter(page, name);
await page.getByLabel(`clear-filter-${name}`).waitFor();
await page.getByLabel(`clear-filter-${name}`).click();
// await page.getByLabel(`clear-filter-${name}`).click();
};
// Let's filter by color
await clickOnParamFilter('Color');
await clickOnParamFilter(page, 'Color');
await page.getByRole('option', { name: 'Red' }).click();
// Only 10 parts available

View File

@@ -13,6 +13,117 @@ import {
} from '../helpers.ts';
import { doCachedLogin } from '../login.ts';
test('Purchasing - Index', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'purchasing/index/' });
// Purchase Orders tab
await loadTab(page, 'Purchase Orders');
await page
.getByRole('button', { name: 'segmented-icon-control-parametric' })
.click();
await page
.getByRole('button', { name: 'segmented-icon-control-calendar' })
.click();
await page.getByRole('button', { name: 'calendar-select-month' }).waitFor();
await page
.getByRole('button', { name: 'segmented-icon-control-table' })
.click();
// Suppliers tab
await loadTab(page, 'Suppliers');
await page
.getByRole('button', { name: 'segmented-icon-control-parametric' })
.click();
await page
.getByRole('button', { name: 'segmented-icon-control-table' })
.click();
// Supplier parts tab
await loadTab(page, 'Supplier Parts');
await page
.getByRole('button', { name: 'segmented-icon-control-parametric' })
.click();
await page
.getByRole('button', { name: 'segmented-icon-control-table' })
.click();
// Manufacturers tab
await loadTab(page, 'Manufacturers');
await page
.getByRole('button', { name: 'segmented-icon-control-parametric' })
.click();
await page
.getByRole('button', { name: 'segmented-icon-control-table' })
.click();
// Manufacturer parts tab
await loadTab(page, 'Manufacturer Parts');
await page
.getByRole('button', { name: 'segmented-icon-control-parametric' })
.click();
await page
.getByRole('button', { name: 'segmented-icon-control-table' })
.click();
});
test('Purchase Orders - General', async ({ browser }) => {
const page = await doCachedLogin(browser);
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.waitForURL('**/purchasing/index/**');
await page.getByRole('cell', { name: 'PO0012' }).click();
await page.waitForTimeout(200);
await loadTab(page, 'Line Items');
await loadTab(page, 'Received Stock');
await loadTab(page, 'Parameters');
await loadTab(page, 'Attachments');
await page.getByRole('tab', { name: 'Purchasing' }).click();
await loadTab(page, 'Suppliers');
await page.getByText('Arrow', { exact: true }).click();
await page.waitForTimeout(200);
await loadTab(page, 'Supplied Parts');
await loadTab(page, 'Purchase Orders');
await loadTab(page, 'Stock Items');
await loadTab(page, 'Contacts');
await loadTab(page, 'Addresses');
await loadTab(page, 'Attachments');
await page.getByRole('tab', { name: 'Purchasing' }).click();
await loadTab(page, 'Manufacturers');
await page.getByText('AVX Corporation').click();
await page.waitForTimeout(200);
await loadTab(page, 'Addresses');
await page.getByRole('cell', { name: 'West Branch' }).click();
await page.locator('.mantine-ScrollArea-root').click();
await page
.getByRole('row', { name: 'West Branch Yes Surf Avenue 9' })
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('text-field-title', { exact: true }).waitFor();
await page.getByLabel('text-field-line2', { exact: true }).waitFor();
// Read the current value of the cell, to ensure we always *change* it!
const value = await page
.getByLabel('text-field-line2', { exact: true })
.inputValue();
await page
.getByLabel('text-field-line2', { exact: true })
.fill(value == 'old' ? 'new' : 'old');
await page.getByRole('button', { name: 'Submit' }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('tab', { name: 'Details' }).waitFor();
});
test('Purchase Orders - Table', async ({ browser }) => {
const page = await doCachedLogin(browser);
@@ -130,62 +241,6 @@ test('Purchase Orders - Barcodes', async ({ browser }) => {
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
});
test('Purchase Orders - General', async ({ browser }) => {
const page = await doCachedLogin(browser);
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.waitForURL('**/purchasing/index/**');
await page.getByRole('cell', { name: 'PO0012' }).click();
await page.waitForTimeout(200);
await loadTab(page, 'Line Items');
await loadTab(page, 'Received Stock');
await loadTab(page, 'Attachments');
await page.getByRole('tab', { name: 'Purchasing' }).click();
await loadTab(page, 'Suppliers');
await page.getByText('Arrow', { exact: true }).click();
await page.waitForTimeout(200);
await loadTab(page, 'Supplied Parts');
await loadTab(page, 'Purchase Orders');
await loadTab(page, 'Stock Items');
await loadTab(page, 'Contacts');
await loadTab(page, 'Addresses');
await loadTab(page, 'Attachments');
await page.getByRole('tab', { name: 'Purchasing' }).click();
await loadTab(page, 'Manufacturers');
await page.getByText('AVX Corporation').click();
await page.waitForTimeout(200);
await loadTab(page, 'Addresses');
await page.getByRole('cell', { name: 'West Branch' }).click();
await page.locator('.mantine-ScrollArea-root').click();
await page
.getByRole('row', { name: 'West Branch Yes Surf Avenue 9' })
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('text-field-title', { exact: true }).waitFor();
await page.getByLabel('text-field-line2', { exact: true }).waitFor();
// Read the current value of the cell, to ensure we always *change* it!
const value = await page
.getByLabel('text-field-line2', { exact: true })
.inputValue();
await page
.getByLabel('text-field-line2', { exact: true })
.fill(value == 'old' ? 'new' : 'old');
await page.getByRole('button', { name: 'Submit' }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('tab', { name: 'Details' }).waitFor();
});
test('Purchase Orders - Filters', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'reader',

View File

@@ -18,6 +18,16 @@ test('Sales Orders - Tabs', async ({ browser }) => {
await loadTab(page, 'Sales Orders');
await page.waitForURL('**/web/sales/index/salesorders');
await page
.getByRole('button', { name: 'segmented-icon-control-parametric' })
.click();
await page
.getByRole('button', { name: 'segmented-icon-control-calendar' })
.click();
await page
.getByRole('button', { name: 'segmented-icon-control-table' })
.click();
// Pending Shipments panel
await loadTab(page, 'Pending Shipments');
await page.getByRole('cell', { name: 'SO0007' }).waitFor();
@@ -27,8 +37,26 @@ test('Sales Orders - Tabs', async ({ browser }) => {
await loadTab(page, 'Return Orders');
await page.getByRole('cell', { name: 'NOISE-COMPLAINT' }).waitFor();
await page
.getByRole('button', { name: 'segmented-icon-control-parametric' })
.click();
await page
.getByRole('button', { name: 'segmented-icon-control-calendar' })
.click();
await page
.getByRole('button', { name: 'segmented-icon-control-table' })
.click();
// Customers
await loadTab(page, 'Customers');
await page
.getByRole('button', { name: 'segmented-icon-control-parametric' })
.click();
await page
.getByRole('button', { name: 'segmented-icon-control-table' })
.click();
await page.getByText('Customer A').click();
await loadTab(page, 'Notes');
await loadTab(page, 'Attachments');

View File

@@ -254,7 +254,7 @@ test('Settings - Admin', async ({ browser }) => {
await loadTab(page, 'Currencies');
await loadTab(page, 'Project Codes');
await loadTab(page, 'Custom Units');
await loadTab(page, 'Part Parameters');
await loadTab(page, 'Parameters', true);
await loadTab(page, 'Category Parameters');
await loadTab(page, 'Label Templates');
await loadTab(page, 'Report Templates');
@@ -373,6 +373,115 @@ test('Settings - Admin - Barcode History', async ({ browser }) => {
});
});
test('Settings - Admin - Parameter', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree'
});
await page.getByRole('button', { name: 'admin' }).click();
await page.getByRole('menuitem', { name: 'Admin Center' }).click();
await loadTab(page, 'Parameters', true);
await page.waitForTimeout(1000);
await page.waitForLoadState('networkidle');
// Clean old template data if exists
await page
.getByRole('cell', { name: 'my custom parameter' })
.waitFor({ timeout: 500 })
.then(async (cell) => {
await page
.getByRole('cell', { name: 'my custom parameter' })
.locator('..')
.getByLabel('row-action-menu-')
.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
})
.catch(() => {});
await page.getByRole('button', { name: 'Selection Lists' }).click();
// Allow time for the table to load
await page.waitForTimeout(1000);
await page.waitForLoadState('networkidle');
// Clean old list data if exists
await page
.getByRole('cell', { name: 'some list' })
.waitFor({ timeout: 500 })
.then(async (cell) => {
await page
.getByRole('cell', { name: 'some list' })
.locator('..')
.getByLabel('row-action-menu-')
.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
})
.catch(() => {});
// Add selection list
await page.getByLabel('action-button-add-selection-').waitFor();
await page.getByLabel('action-button-add-selection-').click();
await page.getByLabel('text-field-name').fill('some list');
await page.getByLabel('text-field-description').fill('Listdescription');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('cell', { name: 'some list' }).waitFor();
await page.getByLabel('action-button-add-parameter').waitFor();
await page.getByLabel('action-button-add-parameter').click();
await page.getByLabel('text-field-name').fill('my custom parameter');
await page.getByLabel('text-field-description').fill('description');
await page
.locator('div')
.filter({ hasText: /^Search\.\.\.$/ })
.nth(2)
.click();
await page
.getByRole('option', { name: 'some list' })
.locator('div')
.first()
.click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('cell', { name: 'my custom parameter' }).click();
// Fill parameter
await navigate(page, 'part/104/parameters/');
await page.getByLabel('Parameters').getByText('Parameters').waitFor();
await page.waitForLoadState('networkidle');
await page
.getByRole('button', { name: 'action-menu-add-parameters' })
.click();
await page
.getByRole('menuitem', {
name: 'action-menu-add-parameters-create-parameter'
})
.click();
await page.waitForTimeout(500);
await page.getByText('Add Parameter').waitFor();
await page
.getByText('Template *Parameter')
.locator('div')
.filter({ hasText: /^Search\.\.\.$/ })
.first()
.click();
await page
.getByText('Template *Parameter')
.locator('div')
.filter({ hasText: /^Search\.\.\.$/ })
.locator('input')
.fill('my custom parameter');
await page.getByRole('option', { name: 'my custom parameter' }).click();
await page.getByLabel('choice-field-data').fill('2');
await page.getByRole('button', { name: 'Submit' }).click();
await page.waitForTimeout(2500);
});
test('Settings - Admin - Unauthorized', async ({ browser }) => {
// Try to access "admin" page with a non-staff user
const page = await doCachedLogin(browser, {

View File

@@ -1,103 +0,0 @@
import { test } from '../baseFixtures';
import { navigate } from '../helpers';
import { doCachedLogin } from '../login';
test('PUI - Admin - Parameter', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree'
});
await page.getByRole('button', { name: 'admin' }).click();
await page.getByRole('menuitem', { name: 'Admin Center' }).click();
await page.getByRole('tab', { name: 'Part Parameters' }).click();
await page.getByRole('button', { name: 'Selection Lists' }).click();
await page.waitForLoadState('networkidle');
// clean old data if exists
await page
.getByRole('cell', { name: 'some list' })
.waitFor({ timeout: 200 })
.then(async (cell) => {
await page
.getByRole('cell', { name: 'some list' })
.locator('..')
.getByLabel('row-action-menu-')
.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
})
.catch(() => {});
// clean old data if exists
await page.getByRole('button', { name: 'Part Parameter Template' }).click();
await page.waitForLoadState('networkidle');
await page
.getByRole('cell', { name: 'my custom parameter' })
.waitFor({ timeout: 200 })
.then(async (cell) => {
await page
.getByRole('cell', { name: 'my custom parameter' })
.locator('..')
.getByLabel('row-action-menu-')
.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
})
.catch(() => {});
// Add selection list
await page.getByRole('button', { name: 'Selection Lists' }).click();
await page.waitForLoadState('networkidle');
await page.getByLabel('action-button-add-selection-').waitFor();
await page.getByLabel('action-button-add-selection-').click();
await page.getByLabel('text-field-name').fill('some list');
await page.getByLabel('text-field-description').fill('Listdescription');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('cell', { name: 'some list' }).waitFor();
await page.waitForTimeout(200);
// Add parameter
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'Part Parameter Template' }).click();
await page.getByLabel('action-button-add-parameter').waitFor();
await page.getByLabel('action-button-add-parameter').click();
await page.getByLabel('text-field-name').fill('my custom parameter');
await page.getByLabel('text-field-description').fill('description');
await page
.locator('div')
.filter({ hasText: /^Search\.\.\.$/ })
.nth(2)
.click();
await page
.getByRole('option', { name: 'some list' })
.locator('div')
.first()
.click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('cell', { name: 'my custom parameter' }).click();
// Fill parameter
await navigate(page, 'part/104/parameters/');
await page.getByLabel('Parameters').getByText('Parameters').waitFor();
await page.waitForLoadState('networkidle');
await page.getByLabel('action-button-add-parameter').waitFor();
await page.getByLabel('action-button-add-parameter').click();
await page.waitForTimeout(200);
await page.getByText('New Part Parameter').waitFor();
await page
.getByText('Template *Parameter')
.locator('div')
.filter({ hasText: /^Search\.\.\.$/ })
.first()
.click();
await page
.getByText('Template *Parameter')
.locator('div')
.filter({ hasText: /^Search\.\.\.$/ })
.locator('input')
.fill('my custom parameter');
await page.getByRole('option', { name: 'my custom parameter' }).click();
await page.getByLabel('choice-field-data').fill('2');
await page.getByRole('button', { name: 'Submit' }).click();
});