mirror of
https://github.com/inventree/InvenTree.git
synced 2026-06-12 03:28:37 +00:00
[feature] tags support (#12077)
* Add Tag API endpoints * Enable filtering by model type * Remove old tags filters against Part endpoint * Add generic tags filter for filtering against tagged items * Add API unit tests for the tags API endpoints * Create generic mixin class for adding tags support * Update existing tagged models * Add tags to more model types * Enable new tags API filtering for multiple models * Add support for tag filtering in part table * Update transfer table filters * Add tags filter to more places * Allow multiple values to be selected as filters * Add a new 'tags' type form field * Display tags on part page * tags support for orders * Add support for SalesOrderShipment * build order * Company support * SupplierPart and ManufacturerPart * support StockItem * Enable tag filtering for attachments * Make tagslist readonly * docs * Mark props as read only * Update API version * Update CHANGELOG * force tags to be case insensitive * Add playwright test for build order tags * more playwright testing * Fix docs link
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { ActionIcon, Badge, Group, Paper } from '@mantine/core';
|
||||
import { IconTag } from '@tabler/icons-react';
|
||||
|
||||
export default function TagsList({
|
||||
tags
|
||||
}: Readonly<{
|
||||
tags: string[];
|
||||
}>) {
|
||||
if (!tags || tags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper p='xs' shadow='xs' withBorder>
|
||||
<Group gap='xs'>
|
||||
<ActionIcon size='sm' variant='transparent'>
|
||||
<IconTag />
|
||||
</ActionIcon>
|
||||
{tags.map((tag: string) => (
|
||||
<Badge key={tag} variant='outline' size='sm'>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -259,6 +259,7 @@ export enum ApiEndpoints {
|
||||
config_list = 'admin/config/',
|
||||
parameter_list = 'parameter/',
|
||||
parameter_template_list = 'parameter/template/',
|
||||
tag_list = 'tag/',
|
||||
|
||||
// Internal system things
|
||||
system_internal_trace_end = 'system-internal/observability/end'
|
||||
|
||||
@@ -319,5 +319,11 @@ export const ModelInformationDict: ModelDict = {
|
||||
url_overview: '/settings/admin/errors',
|
||||
url_detail: '/settings/admin/errors/:pk/',
|
||||
icon: 'exclamation'
|
||||
},
|
||||
tag: {
|
||||
label: () => t`Tag`,
|
||||
label_multiple: () => t`Tags`,
|
||||
api_endpoint: ApiEndpoints.tag_list,
|
||||
icon: 'tag'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,7 +38,8 @@ export enum ModelType {
|
||||
contenttype = 'contenttype',
|
||||
selectionlist = 'selectionlist',
|
||||
selectionentry = 'selectionentry',
|
||||
error = 'error'
|
||||
error = 'error',
|
||||
tag = 'tag'
|
||||
}
|
||||
|
||||
export enum PluginPanelKey {
|
||||
|
||||
@@ -110,6 +110,7 @@ export { ProgressBar } from './components/ProgressBar';
|
||||
export { PassFailButton, YesNoButton } from './components/YesNoButton';
|
||||
export { SearchInput } from './components/SearchInput';
|
||||
export { TableColumnSelect } from './components/TableColumnSelect';
|
||||
export { default as TagsList } from './components/TagsList';
|
||||
export { default as InvenTreeTable } from './components/InvenTreeTable';
|
||||
export {
|
||||
RowViewAction,
|
||||
|
||||
@@ -41,6 +41,7 @@ export type TableFilter = {
|
||||
name: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
type?: TableFilterType;
|
||||
choices?: TableFilterChoice[];
|
||||
choiceFunction?: () => TableFilterChoice[];
|
||||
@@ -52,6 +53,8 @@ export type TableFilter = {
|
||||
apiFilter?: Record<string, any>;
|
||||
model?: ModelType;
|
||||
modelRenderer?: (instance: any) => string;
|
||||
transform?: (item: any) => TableFilterChoice;
|
||||
multi?: boolean;
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
@@ -99,7 +99,8 @@ export type ApiFormFieldType = {
|
||||
| 'file upload'
|
||||
| 'nested object'
|
||||
| 'dependent field'
|
||||
| 'table';
|
||||
| 'table'
|
||||
| 'tags';
|
||||
api_url?: string;
|
||||
pk_field?: string;
|
||||
model?: ModelType;
|
||||
|
||||
@@ -17,6 +17,7 @@ import { NestedObjectField } from './NestedObjectField';
|
||||
import NumberField from './NumberField';
|
||||
import { RelatedModelField } from './RelatedModelField';
|
||||
import { TableField } from './TableField';
|
||||
import TagsField from './TagsField';
|
||||
import TextField from './TextField';
|
||||
|
||||
/**
|
||||
@@ -249,6 +250,10 @@ export function ApiFormField({
|
||||
control={controller}
|
||||
/>
|
||||
);
|
||||
case 'tags':
|
||||
return (
|
||||
<TagsField controller={controller} definition={fieldDefinition} />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Alert color='red' title={t`Error`}>
|
||||
|
||||
@@ -34,13 +34,13 @@ export function BooleanField({
|
||||
|
||||
// Coerce the value to a (stringified) boolean value
|
||||
const booleanValue: boolean = useMemo(() => {
|
||||
return isTrue(value);
|
||||
return isTrue(value ?? definition.default ?? false);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Switch
|
||||
{...definition}
|
||||
defaultValue={definition.default ?? false}
|
||||
defaultValue={undefined}
|
||||
checked={booleanValue}
|
||||
id={fieldId}
|
||||
aria-label={`boolean-field-${fieldName}`}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { TagsInput } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { ApiFormFieldType } from '@lib/types/Forms';
|
||||
import { api } from '../../../App';
|
||||
|
||||
export default function TagsField({
|
||||
controller,
|
||||
definition
|
||||
}: Readonly<{
|
||||
controller: UseControllerReturn<FieldValues, any>;
|
||||
definition: ApiFormFieldType;
|
||||
}>) {
|
||||
const {
|
||||
field,
|
||||
fieldState: { error }
|
||||
} = controller;
|
||||
|
||||
const [tags, setTags] = useState<string[]>(field.value ?? []);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(searchValue, 250);
|
||||
|
||||
// Sync inbound value changes (e.g. when initial data is loaded)
|
||||
useEffect(() => {
|
||||
setTags(field.value ?? []);
|
||||
}, [field.value]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(value: string[]) => {
|
||||
setTags(value);
|
||||
field.onChange(value);
|
||||
definition.onValueChange?.(value);
|
||||
},
|
||||
[field, definition]
|
||||
);
|
||||
|
||||
const tagQuery = useQuery({
|
||||
queryKey: ['tags-autocomplete', debouncedSearch],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get(apiUrl(ApiEndpoints.tag_list), {
|
||||
params: { search: debouncedSearch, limit: 20 }
|
||||
})
|
||||
.then((r) => r.data)
|
||||
});
|
||||
|
||||
const suggestions: string[] = useMemo(() => {
|
||||
const results: any[] = tagQuery.data?.results ?? tagQuery.data ?? [];
|
||||
return results.map((tag: any) => String(tag.name));
|
||||
}, [tagQuery.data]);
|
||||
|
||||
const reducedDefinition: any = useMemo(() => {
|
||||
return {
|
||||
...definition,
|
||||
allow_null: undefined,
|
||||
allow_blank: undefined
|
||||
};
|
||||
}, [definition]);
|
||||
|
||||
return (
|
||||
<TagsInput
|
||||
{...reducedDefinition}
|
||||
ref={field.ref}
|
||||
placeholder={definition.placeholder}
|
||||
aria-label={`tags-field-${field.name}`}
|
||||
value={tags}
|
||||
onChange={onChange}
|
||||
data={suggestions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
error={definition.error ?? error?.message}
|
||||
radius='sm'
|
||||
splitChars={[',', '\t', '\n', ';', ':', '.', '-']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,12 @@ export function RenderError({
|
||||
return instance && <RenderInlineModel primary={instance.name} />;
|
||||
}
|
||||
|
||||
export function RenderTag({
|
||||
instance
|
||||
}: Readonly<InstanceRenderInterface>): ReactNode {
|
||||
return instance && <RenderInlineModel primary={instance.name} />;
|
||||
}
|
||||
|
||||
export function RenderImportSession({
|
||||
instance
|
||||
}: {
|
||||
|
||||
@@ -55,7 +55,8 @@ import {
|
||||
RenderParameterTemplate,
|
||||
RenderProjectCode,
|
||||
RenderSelectionEntry,
|
||||
RenderSelectionList
|
||||
RenderSelectionList,
|
||||
RenderTag
|
||||
} from './Generic';
|
||||
import {
|
||||
RenderPurchaseOrder,
|
||||
@@ -116,7 +117,8 @@ export const RendererLookup: ModelRendererDict = {
|
||||
[ModelType.contenttype]: RenderContentType,
|
||||
[ModelType.selectionlist]: RenderSelectionList,
|
||||
[ModelType.selectionentry]: RenderSelectionEntry,
|
||||
[ModelType.error]: RenderError
|
||||
[ModelType.error]: RenderError,
|
||||
[ModelType.tag]: RenderTag
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from '../hooks/UseGenerator';
|
||||
import { useGlobalSettingsState } from '../states/SettingsStates';
|
||||
import { RenderPartColumn } from '../tables/ColumnRenderers';
|
||||
import { TagsField } from './CommonFields';
|
||||
|
||||
/**
|
||||
* Field set for BuildOrder forms
|
||||
@@ -124,6 +125,7 @@ export function useBuildOrderFields({
|
||||
},
|
||||
value: destination
|
||||
},
|
||||
tags: TagsField({}),
|
||||
link: {
|
||||
icon: <IconLink />
|
||||
},
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ApiFormFieldType } from '@lib/types/Forms';
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
export function TagsField({
|
||||
label,
|
||||
description,
|
||||
placeholder
|
||||
}: Readonly<{
|
||||
label?: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
}>): ApiFormFieldType {
|
||||
return {
|
||||
field_type: 'tags',
|
||||
label: label ?? t`Tags`,
|
||||
description: description ?? t`Tags for this item`,
|
||||
placeholder: placeholder ?? t`Select tags`
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IconPhone
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { TagsField } from './CommonFields';
|
||||
|
||||
/**
|
||||
* Field set for SupplierPart instance
|
||||
@@ -82,6 +83,7 @@ export function useSupplierPartFields({
|
||||
icon: <IconHash />
|
||||
},
|
||||
description: {},
|
||||
tags: TagsField({}),
|
||||
link: {
|
||||
icon: <IconLink />
|
||||
},
|
||||
@@ -117,6 +119,7 @@ export function useManufacturerPartFields() {
|
||||
},
|
||||
MPN: {},
|
||||
description: {},
|
||||
tags: TagsField({}),
|
||||
link: {}
|
||||
};
|
||||
|
||||
@@ -143,6 +146,7 @@ export function companyFields(): ApiFormFieldSet {
|
||||
email: {
|
||||
icon: <IconAt />
|
||||
},
|
||||
tags: TagsField({}),
|
||||
tax_id: {},
|
||||
is_supplier: {},
|
||||
is_manufacturer: {},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { t } from '@lingui/core/macro';
|
||||
import { IconBuildingStore, IconCopy, IconPackages } from '@tabler/icons-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useGlobalSettingsState } from '../states/SettingsStates';
|
||||
import { TagsField } from './CommonFields';
|
||||
|
||||
/**
|
||||
* Construct a set of fields for creating / editing a Part instance
|
||||
@@ -54,6 +55,7 @@ export function usePartFields({
|
||||
}
|
||||
},
|
||||
keywords: {},
|
||||
tags: TagsField({}),
|
||||
units: {},
|
||||
link: {},
|
||||
default_location: {
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
useSerialNumberGenerator
|
||||
} from '../hooks/UseGenerator';
|
||||
import { useGlobalSettingsState } from '../states/SettingsStates';
|
||||
import { TagsField } from './CommonFields';
|
||||
/*
|
||||
* Construct a set of fields for creating / editing a PurchaseOrderLineItem instance
|
||||
*/
|
||||
@@ -287,6 +288,7 @@ export function usePurchaseOrderFields({
|
||||
structural: false
|
||||
}
|
||||
},
|
||||
tags: TagsField({}),
|
||||
link: {},
|
||||
contact: {
|
||||
icon: <IconUser />,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Thumbnail } from '../components/images/Thumbnail';
|
||||
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||
import { useGlobalSettingsState } from '../states/SettingsStates';
|
||||
import { StatusFilterOptions } from '../tables/Filter';
|
||||
import { TagsField } from './CommonFields';
|
||||
|
||||
export function useReturnOrderFields({
|
||||
duplicateOrderId
|
||||
@@ -52,6 +53,7 @@ export function useReturnOrderFields({
|
||||
icon: <IconCalendar />
|
||||
},
|
||||
link: {},
|
||||
tags: TagsField({}),
|
||||
contact: {
|
||||
icon: <IconUser />,
|
||||
adjustFilters: (value: ApiFormAdjustFilterType) => {
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm';
|
||||
import { useGlobalSettingsState } from '../states/SettingsStates';
|
||||
import { useUserState } from '../states/UserState';
|
||||
import { RenderPartColumn } from '../tables/ColumnRenderers';
|
||||
import { TagsField } from './CommonFields';
|
||||
|
||||
export function useSalesOrderFields({
|
||||
duplicateOrderId
|
||||
@@ -64,6 +65,7 @@ export function useSalesOrderFields({
|
||||
target_date: {
|
||||
icon: <IconCalendar />
|
||||
},
|
||||
tags: TagsField({}),
|
||||
link: {},
|
||||
contact: {
|
||||
icon: <IconUser />,
|
||||
@@ -537,6 +539,7 @@ export function useSalesOrderShipmentFields({
|
||||
},
|
||||
tracking_number: {},
|
||||
invoice_number: {},
|
||||
tags: TagsField({}),
|
||||
link: {}
|
||||
};
|
||||
}, [customerId, pending]);
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
import useStatusCodes from '../hooks/UseStatusCodes';
|
||||
import { useGlobalSettingsState } from '../states/SettingsStates';
|
||||
import { StatusFilterOptions } from '../tables/Filter';
|
||||
import { TagsField } from './CommonFields';
|
||||
|
||||
/**
|
||||
* Construct a set of fields for creating / editing a StockItem instance
|
||||
@@ -272,6 +273,7 @@ export function useStockFields({
|
||||
packaging: {
|
||||
icon: <IconPackage />
|
||||
},
|
||||
tags: TagsField({}),
|
||||
link: {
|
||||
icon: <IconLink />
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { TableFieldRowProps } from '../components/forms/fields/TableField';
|
||||
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||
import { useGlobalSettingsState } from '../states/SettingsStates';
|
||||
import { RenderPartColumn } from '../tables/ColumnRenderers';
|
||||
import { TagsField } from './CommonFields';
|
||||
|
||||
export function useTransferOrderFields({
|
||||
duplicateOrderId
|
||||
@@ -36,6 +37,7 @@ export function useTransferOrderFields({
|
||||
}
|
||||
},
|
||||
consume: {},
|
||||
tags: TagsField({}),
|
||||
link: {},
|
||||
responsible: {
|
||||
filters: {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||
import { TagsList } from '@lib/index';
|
||||
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
||||
import type { PanelType } from '@lib/types/Panel';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
@@ -228,7 +229,8 @@ export default function BuildDetail() {
|
||||
endpoint: ApiEndpoints.build_order_list,
|
||||
pk: id,
|
||||
params: {
|
||||
part_detail: true
|
||||
part_detail: true,
|
||||
tags: true
|
||||
},
|
||||
refetchOnMount: true
|
||||
});
|
||||
@@ -438,17 +440,20 @@ export default function BuildDetail() {
|
||||
|
||||
return (
|
||||
<ItemDetailsGrid>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.part}
|
||||
apiPath={ApiEndpoints.part_list}
|
||||
src={build.part_detail?.image ?? build.part_detail?.thumbnail}
|
||||
pk={build.part}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={data} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Stack gap='xs'>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.part}
|
||||
apiPath={ApiEndpoints.part_list}
|
||||
src={build.part_detail?.image ?? build.part_detail?.thumbnail}
|
||||
pk={build.part}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={data} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<TagsList tags={build.tags} />
|
||||
</Stack>
|
||||
<DetailsTable fields={tr} item={data} />
|
||||
<DetailsTable fields={bl} item={data} />
|
||||
<DetailsTable fields={br} item={data} />
|
||||
@@ -612,6 +617,7 @@ export default function BuildDetail() {
|
||||
title: t`Edit Build Order`,
|
||||
modalId: 'edit-build-order',
|
||||
fields: editBuildOrderFields,
|
||||
queryParams: new URLSearchParams({ tags: 'true' }),
|
||||
onFormSuccess: refreshInstance
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ 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 { TagsList } from '@lib/index';
|
||||
import type { PanelType } from '@lib/types/Panel';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
@@ -78,7 +79,9 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
|
||||
} = useInstance({
|
||||
endpoint: ApiEndpoints.company_list,
|
||||
pk: id,
|
||||
params: {},
|
||||
params: {
|
||||
tags: true
|
||||
},
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
@@ -153,23 +156,26 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
|
||||
|
||||
return (
|
||||
<ItemDetailsGrid>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.purchase_order}
|
||||
apiPath={apiUrl(ApiEndpoints.company_list, company.pk)}
|
||||
src={company.image}
|
||||
pk={company.pk}
|
||||
refresh={refreshInstance}
|
||||
imageActions={{
|
||||
uploadFile: true,
|
||||
downloadImage: true,
|
||||
deleteFile: true
|
||||
}}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable item={company} fields={tl} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Stack gap='xs'>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.purchase_order}
|
||||
apiPath={apiUrl(ApiEndpoints.company_list, company.pk)}
|
||||
src={company.image}
|
||||
pk={company.pk}
|
||||
refresh={refreshInstance}
|
||||
imageActions={{
|
||||
uploadFile: true,
|
||||
downloadImage: true,
|
||||
deleteFile: true
|
||||
}}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable item={company} fields={tl} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<TagsList tags={company.tags} />
|
||||
</Stack>
|
||||
<DetailsTable item={company} fields={tr} />
|
||||
</ItemDetailsGrid>
|
||||
);
|
||||
@@ -288,6 +294,7 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
|
||||
pk: company?.pk,
|
||||
title: t`Edit Company`,
|
||||
fields: companyFields(),
|
||||
queryParams: new URLSearchParams({ tags: 'true' }),
|
||||
onFormSuccess: refreshInstance
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import TagsList from '@lib/components/TagsList';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
@@ -59,7 +60,8 @@ export default function ManufacturerPartDetail() {
|
||||
hasPrimaryKey: true,
|
||||
params: {
|
||||
part_detail: true,
|
||||
manufacturer_detail: true
|
||||
manufacturer_detail: true,
|
||||
tags: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -133,20 +135,23 @@ export default function ManufacturerPartDetail() {
|
||||
|
||||
return (
|
||||
<ItemDetailsGrid>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.part}
|
||||
src={manufacturerPart?.part_detail?.image}
|
||||
apiPath={apiUrl(
|
||||
ApiEndpoints.part_list,
|
||||
manufacturerPart?.part_detail?.pk
|
||||
)}
|
||||
pk={manufacturerPart?.part_detail?.pk}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Stack gap='xs'>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.part}
|
||||
src={manufacturerPart?.part_detail?.image}
|
||||
apiPath={apiUrl(
|
||||
ApiEndpoints.part_list,
|
||||
manufacturerPart?.part_detail?.pk
|
||||
)}
|
||||
pk={manufacturerPart?.part_detail?.pk}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<TagsList tags={manufacturerPart.tags} />
|
||||
</Stack>
|
||||
<DetailsTable title={t`Manufacturer Details`} fields={tr} item={data} />
|
||||
</ItemDetailsGrid>
|
||||
);
|
||||
@@ -211,6 +216,7 @@ export default function ManufacturerPartDetail() {
|
||||
pk: manufacturerPart?.pk,
|
||||
title: t`Edit Manufacturer Part`,
|
||||
fields: editManufacturerPartFields,
|
||||
queryParams: new URLSearchParams({ tags: 'true' }),
|
||||
onFormSuccess: refreshInstance
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import TagsList from '@lib/components/TagsList';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
@@ -67,7 +68,8 @@ export default function SupplierPartDetail() {
|
||||
params: {
|
||||
part_detail: true,
|
||||
supplier_detail: true,
|
||||
manufacturer_detail: true
|
||||
manufacturer_detail: true,
|
||||
tags: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -221,20 +223,23 @@ export default function SupplierPartDetail() {
|
||||
|
||||
return (
|
||||
<ItemDetailsGrid>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.part}
|
||||
src={supplierPart?.part_detail?.image}
|
||||
apiPath={apiUrl(
|
||||
ApiEndpoints.part_list,
|
||||
supplierPart?.part_detail?.pk
|
||||
)}
|
||||
pk={supplierPart?.part_detail?.pk}
|
||||
/>
|
||||
<Grid.Col span={8}>
|
||||
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Stack gap='xs'>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.part}
|
||||
src={supplierPart?.part_detail?.image}
|
||||
apiPath={apiUrl(
|
||||
ApiEndpoints.part_list,
|
||||
supplierPart?.part_detail?.pk
|
||||
)}
|
||||
pk={supplierPart?.part_detail?.pk}
|
||||
/>
|
||||
<Grid.Col span={8}>
|
||||
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<TagsList tags={supplierPart.tags} />
|
||||
</Stack>
|
||||
<DetailsTable title={t`Supplier`} fields={bl} item={data} />
|
||||
<DetailsTable title={t`Packaging`} fields={br} item={data} />
|
||||
<DetailsTable title={t`Availability`} fields={tr} item={data} />
|
||||
@@ -339,6 +344,7 @@ export default function SupplierPartDetail() {
|
||||
pk: supplierPart?.pk,
|
||||
title: t`Edit Supplier Part`,
|
||||
fields: supplierPartFields,
|
||||
queryParams: new URLSearchParams({ tags: 'true' }),
|
||||
onFormSuccess: refreshInstance
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import Select from 'react-select';
|
||||
|
||||
import TagsList from '@lib/components/TagsList';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
@@ -190,7 +191,8 @@ export default function PartDetail() {
|
||||
endpoint: ApiEndpoints.part_list,
|
||||
pk: id,
|
||||
params: {
|
||||
path_detail: true
|
||||
path_detail: true,
|
||||
tags: true
|
||||
},
|
||||
refetchOnMount: true
|
||||
});
|
||||
@@ -612,6 +614,7 @@ export default function PartDetail() {
|
||||
<DetailsTable fields={tl} item={data} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<TagsList tags={part.tags} />
|
||||
{enableRevisionSelection && (
|
||||
<Paper p='sm' withBorder>
|
||||
<Stack gap='xs'>
|
||||
@@ -998,6 +1001,7 @@ export default function PartDetail() {
|
||||
pk: part.pk,
|
||||
title: t`Edit Part`,
|
||||
fields: partFields,
|
||||
queryParams: new URLSearchParams({ tags: 'true' }),
|
||||
onFormSuccess: refreshInstance
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { TagsList } from '@lib/index';
|
||||
import type { PanelType } from '@lib/types/Panel';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
|
||||
@@ -65,7 +66,8 @@ export default function PurchaseOrderDetail() {
|
||||
endpoint: ApiEndpoints.purchase_order_list,
|
||||
pk: id,
|
||||
params: {
|
||||
supplier_detail: true
|
||||
supplier_detail: true,
|
||||
tags: true
|
||||
},
|
||||
refetchOnMount: true
|
||||
});
|
||||
@@ -89,6 +91,7 @@ export default function PurchaseOrderDetail() {
|
||||
pk: id,
|
||||
title: t`Edit Purchase Order`,
|
||||
fields: purchaseOrderFields,
|
||||
queryParams: new URLSearchParams({ tags: 'true' }),
|
||||
onFormSuccess: () => {
|
||||
refreshInstance();
|
||||
}
|
||||
@@ -318,17 +321,20 @@ export default function PurchaseOrderDetail() {
|
||||
|
||||
return (
|
||||
<ItemDetailsGrid>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.purchase_order}
|
||||
apiPath={ApiEndpoints.company_list}
|
||||
src={order.supplier_detail?.image}
|
||||
pk={order.supplier}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={order} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Stack gap='xs'>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.purchase_order}
|
||||
apiPath={ApiEndpoints.company_list}
|
||||
src={order.supplier_detail?.image}
|
||||
pk={order.supplier}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={order} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<TagsList tags={order.tags} />
|
||||
</Stack>
|
||||
<DetailsTable fields={tr} item={order} />
|
||||
<DetailsTable fields={bl} item={order} />
|
||||
<DetailsTable fields={br} item={order} />
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { TagsList } from '@lib/index';
|
||||
import type { PanelType } from '@lib/types/Panel';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
|
||||
@@ -66,7 +67,8 @@ export default function ReturnOrderDetail() {
|
||||
endpoint: ApiEndpoints.return_order_list,
|
||||
pk: id,
|
||||
params: {
|
||||
customer_detail: true
|
||||
customer_detail: true,
|
||||
tags: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -296,17 +298,20 @@ export default function ReturnOrderDetail() {
|
||||
|
||||
return (
|
||||
<ItemDetailsGrid>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.purchase_order}
|
||||
apiPath={ApiEndpoints.company_list}
|
||||
src={order.customer_detail?.image}
|
||||
pk={order.customer}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={order} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Stack gap='xs'>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.purchase_order}
|
||||
apiPath={ApiEndpoints.company_list}
|
||||
src={order.customer_detail?.image}
|
||||
pk={order.customer}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={order} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<TagsList tags={order.tags} />
|
||||
</Stack>
|
||||
<DetailsTable fields={tr} item={order} />
|
||||
<DetailsTable fields={bl} item={order} />
|
||||
<DetailsTable fields={br} item={order} />
|
||||
@@ -403,6 +408,7 @@ export default function ReturnOrderDetail() {
|
||||
pk: order.pk,
|
||||
title: t`Edit Return Order`,
|
||||
fields: returnOrderFields,
|
||||
queryParams: new URLSearchParams({ tags: 'true' }),
|
||||
onFormSuccess: () => {
|
||||
refreshInstance();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { type ReactNode, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { StylishText } from '@lib/components/StylishText';
|
||||
import TagsList from '@lib/components/TagsList';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
@@ -75,7 +76,8 @@ export default function SalesOrderDetail() {
|
||||
endpoint: ApiEndpoints.sales_order_list,
|
||||
pk: id,
|
||||
params: {
|
||||
customer_detail: true
|
||||
customer_detail: true,
|
||||
tags: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -287,17 +289,20 @@ export default function SalesOrderDetail() {
|
||||
|
||||
return (
|
||||
<ItemDetailsGrid>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.purchase_order}
|
||||
apiPath={ApiEndpoints.company_list}
|
||||
src={order.customer_detail?.image}
|
||||
pk={order.customer}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={order} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Stack gap='xs'>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.purchase_order}
|
||||
apiPath={ApiEndpoints.company_list}
|
||||
src={order.customer_detail?.image}
|
||||
pk={order.customer}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={order} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<TagsList tags={order.tags} />
|
||||
</Stack>
|
||||
<DetailsTable fields={tr} item={order} />
|
||||
<DetailsTable fields={bl} item={order} />
|
||||
<DetailsTable fields={br} item={order} />
|
||||
@@ -325,6 +330,7 @@ export default function SalesOrderDetail() {
|
||||
pk: order.pk,
|
||||
title: t`Edit Sales Order`,
|
||||
fields: salesOrderFields,
|
||||
queryParams: new URLSearchParams({ tags: 'true' }),
|
||||
onFormSuccess: () => {
|
||||
refreshInstance();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,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 { TagsList } from '@lib/index';
|
||||
import type { PanelType } from '@lib/types/Panel';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
|
||||
@@ -68,7 +69,8 @@ export default function SalesOrderShipmentDetail() {
|
||||
endpoint: ApiEndpoints.sales_order_shipment_list,
|
||||
pk: id,
|
||||
params: {
|
||||
order_detail: true
|
||||
order_detail: true,
|
||||
tags: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -221,23 +223,26 @@ export default function SalesOrderShipmentDetail() {
|
||||
return (
|
||||
<>
|
||||
<ItemDetailsGrid>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.sales_order}
|
||||
apiPath={ApiEndpoints.company_list}
|
||||
src={customer?.image}
|
||||
pk={customer?.pk}
|
||||
imageActions={{
|
||||
selectExisting: false,
|
||||
downloadImage: false,
|
||||
uploadFile: false,
|
||||
deleteFile: false
|
||||
}}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={data} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Stack gap='xs'>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.sales_order}
|
||||
apiPath={ApiEndpoints.company_list}
|
||||
src={customer?.image}
|
||||
pk={customer?.pk}
|
||||
imageActions={{
|
||||
selectExisting: false,
|
||||
downloadImage: false,
|
||||
uploadFile: false,
|
||||
deleteFile: false
|
||||
}}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={data} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<TagsList tags={shipment.tags} />
|
||||
</Stack>
|
||||
<DetailsTable fields={tr} item={data} />
|
||||
<DetailsTable fields={bl} item={data} />
|
||||
<DetailsTable fields={br} item={data} />
|
||||
@@ -295,6 +300,7 @@ export default function SalesOrderShipmentDetail() {
|
||||
pk: shipment.pk,
|
||||
fields: editShipmentFields,
|
||||
title: t`Edit Shipment`,
|
||||
queryParams: new URLSearchParams({ tags: 'true' }),
|
||||
onFormSuccess: refreshShipment
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||
import type { TableFilter } from '@lib/index';
|
||||
import type { StockOperationProps } from '@lib/types/Forms';
|
||||
import type { PanelType } from '@lib/types/Panel';
|
||||
import { t } from '@lingui/core/macro';
|
||||
@@ -60,6 +61,21 @@ import { StockLocationTable } from '../../tables/stock/StockLocationTable';
|
||||
import TransferOrderParametricTable from '../../tables/stock/TransferOrderParametricTable';
|
||||
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
|
||||
|
||||
function TransferOrderCalendar() {
|
||||
const calendarFilters: TableFilter[] = useMemo(() => {
|
||||
return [];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<OrderCalendar
|
||||
model={ModelType.transferorder}
|
||||
role={UserRoles.transfer_order}
|
||||
params={{ outstanding: true }}
|
||||
filters={calendarFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Stock() {
|
||||
const { id: _id } = useParams();
|
||||
|
||||
@@ -247,13 +263,7 @@ export default function Stock() {
|
||||
value: 'calendar',
|
||||
label: t`Calendar View`,
|
||||
icon: <IconCalendar />,
|
||||
content: (
|
||||
<OrderCalendar
|
||||
model={ModelType.transferorder}
|
||||
role={UserRoles.transfer_order}
|
||||
params={{ outstanding: true }}
|
||||
/>
|
||||
)
|
||||
content: <TransferOrderCalendar />
|
||||
},
|
||||
{
|
||||
value: 'parametric',
|
||||
|
||||
@@ -35,6 +35,7 @@ import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { getDetailUrl, getOverviewUrl } from '@lib/functions/Navigation';
|
||||
import { TagsList } from '@lib/index';
|
||||
import type { ApiFormFieldSet, StockOperationProps } from '@lib/types/Forms';
|
||||
import type { PanelType } from '@lib/types/Panel';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
@@ -118,7 +119,8 @@ export default function StockDetail() {
|
||||
params: {
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
path_detail: true
|
||||
path_detail: true,
|
||||
tags: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -445,19 +447,23 @@ export default function StockDetail() {
|
||||
|
||||
return (
|
||||
<ItemDetailsGrid>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.part}
|
||||
apiPath={ApiEndpoints.part_list}
|
||||
src={
|
||||
stockitem.part_detail?.image ?? stockitem?.part_detail?.thumbnail
|
||||
}
|
||||
pk={stockitem.part}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={data} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Stack gap='xs'>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.part}
|
||||
apiPath={ApiEndpoints.part_list}
|
||||
src={
|
||||
stockitem.part_detail?.image ??
|
||||
stockitem?.part_detail?.thumbnail
|
||||
}
|
||||
pk={stockitem.part}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={data} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<TagsList tags={stockitem.tags} />
|
||||
</Stack>
|
||||
<DetailsTable fields={tr} item={data} />
|
||||
<DetailsTable fields={bl} item={data} />
|
||||
<DetailsTable fields={br} item={data} />
|
||||
@@ -702,6 +708,7 @@ export default function StockDetail() {
|
||||
title: t`Edit Stock Item`,
|
||||
modalId: 'edit-stock-item',
|
||||
fields: editStockItemFields,
|
||||
queryParams: new URLSearchParams({ tags: 'true' }),
|
||||
onFormSuccess: refreshInstance
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useParams } from 'react-router-dom';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { type PanelType, apiUrl } from '@lib/index';
|
||||
import { type PanelType, TagsList, apiUrl } from '@lib/index';
|
||||
import {
|
||||
IconBookmark,
|
||||
IconInfoCircle,
|
||||
@@ -62,7 +62,9 @@ export default function TransferOrderDetail() {
|
||||
} = useInstance({
|
||||
endpoint: ApiEndpoints.transfer_order_list,
|
||||
pk: id,
|
||||
params: {}
|
||||
params: {
|
||||
tags: true
|
||||
}
|
||||
});
|
||||
|
||||
const toStatus = useStatusCodes({ modelType: ModelType.transferorder });
|
||||
@@ -233,18 +235,21 @@ export default function TransferOrderDetail() {
|
||||
|
||||
return (
|
||||
<ItemDetailsGrid>
|
||||
<Grid grow>
|
||||
{/* TODO: what image do we show for a Transfer Order? */}
|
||||
{/* <DetailsImage
|
||||
<Stack gap='xs'>
|
||||
<Grid grow>
|
||||
{/* TODO: what image do we show for a Transfer Order? */}
|
||||
{/* <DetailsImage
|
||||
appRole={UserRoles.transfer_order}
|
||||
apiPath={ApiEndpoints.transfer_order_list}
|
||||
src="/static/img/blank_image.png"
|
||||
pk={order.pk}
|
||||
/> */}
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={order} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={order} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<TagsList tags={order.tags} />
|
||||
</Stack>
|
||||
<DetailsTable fields={tr} item={order} />
|
||||
<DetailsTable fields={bl} item={order} />
|
||||
<DetailsTable fields={br} item={order} />
|
||||
@@ -369,6 +374,7 @@ export default function TransferOrderDetail() {
|
||||
pk: order.pk,
|
||||
title: t`Edit Transfer Order`,
|
||||
fields: transferOrderFields,
|
||||
queryParams: new URLSearchParams({ tags: 'true' }),
|
||||
onFormSuccess: () => {
|
||||
refreshInstance();
|
||||
}
|
||||
|
||||
@@ -399,6 +399,29 @@ export function ResponsibleFilter(): TableFilter {
|
||||
});
|
||||
}
|
||||
|
||||
export function TagsFilter({
|
||||
modelType
|
||||
}: {
|
||||
modelType?: ModelType;
|
||||
}): TableFilter {
|
||||
return {
|
||||
name: 'tags',
|
||||
label: t`Tags`,
|
||||
description: t`Filter by tags`,
|
||||
placeholder: t`Select tags`,
|
||||
type: 'api',
|
||||
multi: true,
|
||||
apiUrl: apiUrl(ApiEndpoints.tag_list),
|
||||
model: ModelType.tag,
|
||||
modelRenderer: (instance: any) => instance.name,
|
||||
apiFilter: modelType ? { model_type: modelType } : undefined,
|
||||
transform: (item: any) => ({
|
||||
value: item.name.toString(),
|
||||
label: item.name.toString()
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
export function UserFilter({
|
||||
name,
|
||||
label,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Paper,
|
||||
Select,
|
||||
Space,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import { DateInput, type DateValue } from '@mantine/dates';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -27,6 +29,7 @@ import type {
|
||||
TableFilterType
|
||||
} from '@lib/types/Filters';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import { api } from '../App';
|
||||
import { StandaloneField } from '../components/forms/StandaloneField';
|
||||
import {
|
||||
filterDisplayLabel,
|
||||
@@ -106,6 +109,74 @@ function FilterItem({
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Multi-select element for 'api' filters with multi=true.
|
||||
* Fetches all options from the API then renders a searchable MultiSelect.
|
||||
* The user picks multiple values and confirms with an Apply button.
|
||||
*/
|
||||
function MultiApiFilterElement({
|
||||
filterProps,
|
||||
onValueChange
|
||||
}: Readonly<{
|
||||
filterProps: TableFilter;
|
||||
onValueChange: (value: string | null, displayValue?: any) => void;
|
||||
}>) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['filter-options', filterProps.apiUrl, filterProps.apiFilter],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get(filterProps.apiUrl ?? '', { params: filterProps.apiFilter })
|
||||
.then((r) => r.data),
|
||||
enabled: !!filterProps.apiUrl
|
||||
});
|
||||
|
||||
const options: TableFilterChoice[] = useMemo(() => {
|
||||
const results: any[] = query.data?.results ?? query.data ?? [];
|
||||
if (filterProps.transform) {
|
||||
return results.map(filterProps.transform);
|
||||
}
|
||||
return results.map((item: any) => ({
|
||||
value: String(item.pk ?? item.id ?? item.slug ?? item.value),
|
||||
label: filterProps.modelRenderer?.(item) ?? String(item.name ?? item.pk)
|
||||
}));
|
||||
}, [query.data, filterProps.transform, filterProps.modelRenderer]);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
if (!selected.length) return;
|
||||
const labels = selected.map(
|
||||
(v) => options.find((o) => o.value === v)?.label ?? v
|
||||
);
|
||||
onValueChange(selected.join(','), labels.join(', '));
|
||||
}, [selected, options, onValueChange]);
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
data={options}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
searchable
|
||||
label={t`Value`}
|
||||
placeholder={filterProps.placeholder ?? t`Select one or more values`}
|
||||
maxDropdownHeight={400}
|
||||
rightSectionPointerEvents='all'
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
aria-label='apply-tags-filter'
|
||||
variant='transparent'
|
||||
color='green'
|
||||
size='md'
|
||||
disabled={!selected.length}
|
||||
onClick={apply}
|
||||
>
|
||||
<IconCheck />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterElement({
|
||||
filterName,
|
||||
filterProps,
|
||||
@@ -133,6 +204,14 @@ function FilterElement({
|
||||
|
||||
switch (filterProps.type) {
|
||||
case 'api':
|
||||
if (filterProps.multi) {
|
||||
return (
|
||||
<MultiApiFilterElement
|
||||
filterProps={filterProps}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StandaloneField
|
||||
fieldName={`filter-${filterName}`}
|
||||
@@ -144,7 +223,15 @@ function FilterElement({
|
||||
model: filterProps.model,
|
||||
label: t`Select filter value`,
|
||||
onValueChange: (value: any, instance: any) => {
|
||||
onValueChange(value, filterProps.modelRenderer?.(instance));
|
||||
if (filterProps.transform) {
|
||||
const choice = filterProps.transform(instance);
|
||||
onValueChange(choice.value, choice.label);
|
||||
} else {
|
||||
onValueChange(
|
||||
value,
|
||||
filterProps.modelRenderer?.(instance) ?? value
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ResponsibleFilter,
|
||||
StartDateAfterFilter,
|
||||
StartDateBeforeFilter,
|
||||
TagsFilter,
|
||||
TargetDateAfterFilter,
|
||||
TargetDateBeforeFilter
|
||||
} from '../Filter';
|
||||
@@ -49,7 +50,8 @@ export default function BuildOrderFilters({
|
||||
HasProjectCodeFilter(),
|
||||
IssuedByFilter(),
|
||||
ResponsibleFilter(),
|
||||
PartCategoryFilter()
|
||||
PartCategoryFilter(),
|
||||
TagsFilter({ modelType: ModelType.build })
|
||||
];
|
||||
|
||||
const dateFilters: TableFilter[] = [
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
CompanyColumn,
|
||||
DescriptionColumn
|
||||
} from '../ColumnRenderers';
|
||||
import { TagsFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
/**
|
||||
@@ -115,7 +116,8 @@ export function CompanyTable({
|
||||
name: 'is_customer',
|
||||
label: t`Customer`,
|
||||
description: t`Show companies which are customers`
|
||||
}
|
||||
},
|
||||
TagsFilter({ modelType: ModelType.company })
|
||||
];
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { TagsFilter } from '../Filter';
|
||||
|
||||
/**
|
||||
* Construct a set of filters for the part table
|
||||
@@ -141,6 +143,9 @@ export function PartTableFilters(): TableFilter[] {
|
||||
label: t`Subscribed`,
|
||||
description: t`Filter by parts to which the user is subscribed`,
|
||||
type: 'boolean'
|
||||
}
|
||||
},
|
||||
TagsFilter({
|
||||
modelType: ModelType.part
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
LinkColumn,
|
||||
PartColumn
|
||||
} from '../ColumnRenderers';
|
||||
import { TagsFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
/*
|
||||
@@ -161,7 +162,8 @@ export function ManufacturerPartTable({
|
||||
active: !manufacturerId,
|
||||
description: t`Show manufacturer parts for active manufacturers.`,
|
||||
type: 'boolean'
|
||||
}
|
||||
},
|
||||
TagsFilter({ modelType: ModelType.manufacturerpart })
|
||||
];
|
||||
}, [manufacturerId]);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ResponsibleFilter,
|
||||
StartDateAfterFilter,
|
||||
StartDateBeforeFilter,
|
||||
TagsFilter,
|
||||
TargetDateAfterFilter,
|
||||
TargetDateBeforeFilter,
|
||||
UpdatedAfterFilter,
|
||||
@@ -38,7 +39,8 @@ export default function PurchaseOrderFilters({
|
||||
ProjectCodeFilter(),
|
||||
HasProjectCodeFilter(),
|
||||
ResponsibleFilter(),
|
||||
CreatedByFilter()
|
||||
CreatedByFilter(),
|
||||
TagsFilter({ modelType: ModelType.purchaseorder })
|
||||
];
|
||||
|
||||
const dateFilters: TableFilter[] = [
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
NoteColumn,
|
||||
PartColumn
|
||||
} from '../ColumnRenderers';
|
||||
import { TagsFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { TableHoverCard } from '../TableHoverCard';
|
||||
|
||||
@@ -60,7 +61,8 @@ export function SupplierPartTable({
|
||||
{
|
||||
name: 'active',
|
||||
value: 'true'
|
||||
}
|
||||
},
|
||||
TagsFilter({ modelType: ModelType.supplierpart })
|
||||
];
|
||||
|
||||
if (!supplierId) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ResponsibleFilter,
|
||||
StartDateAfterFilter,
|
||||
StartDateBeforeFilter,
|
||||
TagsFilter,
|
||||
TargetDateAfterFilter,
|
||||
TargetDateBeforeFilter,
|
||||
UpdatedAfterFilter,
|
||||
@@ -41,7 +42,8 @@ export default function ReturnOrderFilters({
|
||||
HasProjectCodeFilter(),
|
||||
ProjectCodeFilter(),
|
||||
ResponsibleFilter(),
|
||||
CreatedByFilter()
|
||||
CreatedByFilter(),
|
||||
TagsFilter({ modelType: ModelType.returnorder })
|
||||
];
|
||||
|
||||
const dateFilters: TableFilter[] = [
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ResponsibleFilter,
|
||||
StartDateAfterFilter,
|
||||
StartDateBeforeFilter,
|
||||
TagsFilter,
|
||||
TargetDateAfterFilter,
|
||||
TargetDateBeforeFilter,
|
||||
UpdatedAfterFilter,
|
||||
@@ -41,7 +42,8 @@ export default function SalesOrderFilters({
|
||||
HasProjectCodeFilter(),
|
||||
ProjectCodeFilter(),
|
||||
ResponsibleFilter(),
|
||||
CreatedByFilter()
|
||||
CreatedByFilter(),
|
||||
TagsFilter({ modelType: ModelType.salesorder })
|
||||
];
|
||||
|
||||
const dateFilters: TableFilter[] = [
|
||||
|
||||
@@ -25,7 +25,6 @@ import type { TableColumn } from '@lib/types/Tables';
|
||||
import {
|
||||
useCheckShipmentForm,
|
||||
useCompleteShipmentForm,
|
||||
useSalesOrderShipmentCompleteFields,
|
||||
useSalesOrderShipmentFields,
|
||||
useUncheckShipmentForm
|
||||
} from '../../forms/SalesOrderForms';
|
||||
@@ -41,6 +40,7 @@ import {
|
||||
LinkColumn,
|
||||
StatusColumn
|
||||
} from '../ColumnRenderers';
|
||||
import { TagsFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
export default function SalesOrderShipmentTable({
|
||||
@@ -71,8 +71,6 @@ export default function SalesOrderShipmentTable({
|
||||
pending: !selectedShipment.shipment_date
|
||||
});
|
||||
|
||||
const completeShipmentFields = useSalesOrderShipmentCompleteFields({});
|
||||
|
||||
const newShipment = useCreateApiFormModal({
|
||||
url: ApiEndpoints.sales_order_shipment_list,
|
||||
fields: newShipmentFields,
|
||||
@@ -303,7 +301,8 @@ export default function SalesOrderShipmentTable({
|
||||
name: 'delivered',
|
||||
label: t`Delivered`,
|
||||
description: t`Show shipments which have been delivered`
|
||||
}
|
||||
},
|
||||
TagsFilter({ modelType: ModelType.salesordershipment })
|
||||
];
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
SerialLTEFilter,
|
||||
StatusFilterOptions,
|
||||
SupplierFilter,
|
||||
TagsFilter,
|
||||
UpdatedAfterFilter,
|
||||
UpdatedBeforeFilter
|
||||
} from '../Filter';
|
||||
@@ -306,7 +307,8 @@ function stockItemTableFilters({
|
||||
name: 'external',
|
||||
label: t`External Location`,
|
||||
description: t`Show items in an external location`
|
||||
}
|
||||
},
|
||||
TagsFilter({ modelType: ModelType.stockitem })
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ModelType, type TableFilter } from '@lib/index';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
AssignedToMeFilter,
|
||||
CompletedAfterFilter,
|
||||
CompletedBeforeFilter,
|
||||
CreatedAfterFilter,
|
||||
CreatedBeforeFilter,
|
||||
CreatedByFilter,
|
||||
HasProjectCodeFilter,
|
||||
IncludeVariantsFilter,
|
||||
MaxDateFilter,
|
||||
MinDateFilter,
|
||||
OrderStatusFilter,
|
||||
OutstandingFilter,
|
||||
OverdueFilter,
|
||||
ProjectCodeFilter,
|
||||
ResponsibleFilter,
|
||||
StartDateAfterFilter,
|
||||
StartDateBeforeFilter,
|
||||
TagsFilter,
|
||||
TargetDateAfterFilter,
|
||||
TargetDateBeforeFilter
|
||||
} from '../Filter';
|
||||
|
||||
export default function TransferOrderFilters({
|
||||
partId,
|
||||
includeDateFilters = true
|
||||
}: {
|
||||
partId?: number;
|
||||
includeDateFilters?: boolean;
|
||||
}): TableFilter[] {
|
||||
const filters: TableFilter[] = [
|
||||
OrderStatusFilter({ model: ModelType.transferorder }),
|
||||
OutstandingFilter(),
|
||||
OverdueFilter(),
|
||||
AssignedToMeFilter(),
|
||||
HasProjectCodeFilter(),
|
||||
ProjectCodeFilter(),
|
||||
ResponsibleFilter(),
|
||||
CreatedByFilter(),
|
||||
TagsFilter({ modelType: ModelType.transferorder })
|
||||
];
|
||||
|
||||
const dateFilters: TableFilter[] = [
|
||||
MinDateFilter(),
|
||||
MaxDateFilter(),
|
||||
CreatedBeforeFilter(),
|
||||
CreatedAfterFilter(),
|
||||
TargetDateBeforeFilter(),
|
||||
TargetDateAfterFilter(),
|
||||
StartDateBeforeFilter(),
|
||||
StartDateAfterFilter(),
|
||||
{
|
||||
name: 'has_target_date',
|
||||
type: 'boolean',
|
||||
label: t`Has Target Date`,
|
||||
description: t`Show orders with a target date`
|
||||
},
|
||||
{
|
||||
name: 'has_start_date',
|
||||
type: 'boolean',
|
||||
label: t`Has Start Date`,
|
||||
description: t`Show orders with a start date`
|
||||
},
|
||||
CompletedBeforeFilter(),
|
||||
CompletedAfterFilter()
|
||||
];
|
||||
|
||||
if (includeDateFilters) {
|
||||
filters.push(...dateFilters);
|
||||
}
|
||||
|
||||
if (!!partId) {
|
||||
filters.push(IncludeVariantsFilter());
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
@@ -22,28 +22,8 @@ import {
|
||||
StatusColumn,
|
||||
TargetDateColumn
|
||||
} from '../ColumnRenderers';
|
||||
import {
|
||||
AssignedToMeFilter,
|
||||
CompletedAfterFilter,
|
||||
CompletedBeforeFilter,
|
||||
CreatedAfterFilter,
|
||||
CreatedBeforeFilter,
|
||||
CreatedByFilter,
|
||||
HasProjectCodeFilter,
|
||||
IncludeVariantsFilter,
|
||||
MaxDateFilter,
|
||||
MinDateFilter,
|
||||
OrderStatusFilter,
|
||||
OutstandingFilter,
|
||||
OverdueFilter,
|
||||
ProjectCodeFilter,
|
||||
ResponsibleFilter,
|
||||
StartDateAfterFilter,
|
||||
StartDateBeforeFilter,
|
||||
TargetDateAfterFilter,
|
||||
TargetDateBeforeFilter
|
||||
} from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import TransferOrderFilters from './TransferOrderFilters';
|
||||
|
||||
export function TransferOrderTable({
|
||||
partId
|
||||
@@ -56,45 +36,8 @@ export function TransferOrderTable({
|
||||
const user = useUserState();
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
const filters: TableFilter[] = [
|
||||
OrderStatusFilter({ model: ModelType.transferorder }),
|
||||
OutstandingFilter(),
|
||||
OverdueFilter(),
|
||||
AssignedToMeFilter(),
|
||||
MinDateFilter(),
|
||||
MaxDateFilter(),
|
||||
CreatedBeforeFilter(),
|
||||
CreatedAfterFilter(),
|
||||
TargetDateBeforeFilter(),
|
||||
TargetDateAfterFilter(),
|
||||
StartDateBeforeFilter(),
|
||||
StartDateAfterFilter(),
|
||||
{
|
||||
name: 'has_target_date',
|
||||
type: 'boolean',
|
||||
label: t`Has Target Date`,
|
||||
description: t`Show orders with a target date`
|
||||
},
|
||||
{
|
||||
name: 'has_start_date',
|
||||
type: 'boolean',
|
||||
label: t`Has Start Date`,
|
||||
description: t`Show orders with a start date`
|
||||
},
|
||||
CompletedBeforeFilter(),
|
||||
CompletedAfterFilter(),
|
||||
HasProjectCodeFilter(),
|
||||
ProjectCodeFilter(),
|
||||
ResponsibleFilter(),
|
||||
CreatedByFilter()
|
||||
];
|
||||
|
||||
if (!!partId) {
|
||||
filters.push(IncludeVariantsFilter());
|
||||
}
|
||||
|
||||
return filters;
|
||||
}, [partId]);
|
||||
return TransferOrderFilters({ includeDateFilters: true });
|
||||
}, []);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
return [
|
||||
|
||||
@@ -120,6 +120,51 @@ test('Build Order - Basic Tests', async ({ browser }) => {
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
// Test tags filtering against Build Orders
|
||||
test('Build Order - Tags', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'manufacturing/index/buildorders'
|
||||
});
|
||||
|
||||
// Filter by tag
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
||||
.click();
|
||||
await clearTableFilters(page);
|
||||
await page.getByRole('button', { name: 'table-select-filters' }).click();
|
||||
await page.getByRole('button', { name: 'Add Filter' }).click();
|
||||
await page.getByRole('combobox', { name: 'Filter' }).fill('tag');
|
||||
await page.getByRole('option', { name: 'Tags' }).click();
|
||||
await page.getByRole('combobox', { name: 'Value' }).click();
|
||||
|
||||
// Check for expected tags
|
||||
await page.getByRole('option', { name: 'Furniture' }).waitFor();
|
||||
await page.getByRole('option', { name: 'Electronics' }).click();
|
||||
await page.getByRole('option', { name: 'PCB Assembly' }).click();
|
||||
|
||||
// Apply the "Furniture" tag filter
|
||||
await page.getByRole('button', { name: 'apply-tags-filter' }).click();
|
||||
await page.getByRole('button', { name: 'filter-drawer-close' }).click();
|
||||
|
||||
// Check for expected results
|
||||
await page.getByRole('cell', { name: 'BO0026' }).click();
|
||||
await page.getByText('100 x 002.01-PCBA | Widget').waitFor();
|
||||
|
||||
// Check for tags displayed on BuildOrder detail page
|
||||
await page.getByText('Electronics', { exact: true }).first().waitFor();
|
||||
await page.getByText('PCB Assembly', { exact: true }).first().waitFor();
|
||||
|
||||
// Edit the build order
|
||||
await page.keyboard.press('Control+E');
|
||||
|
||||
const tagsField = await page.getByRole('combobox', {
|
||||
name: 'tags-field-tags'
|
||||
});
|
||||
|
||||
await expect(tagsField).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
// Test that the build order reference field increments correctly
|
||||
test('Build Order - Reference', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
|
||||
@@ -276,6 +276,29 @@ test('Sales Orders - Shipments', async ({ browser }) => {
|
||||
.click();
|
||||
});
|
||||
|
||||
// Filter Shipments by tag
|
||||
test('Sales Orders - Shipments - Tags', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'sales/index/shipments' });
|
||||
|
||||
// Filter by tag
|
||||
await clearTableFilters(page);
|
||||
await page.getByRole('button', { name: 'table-select-filters' }).click();
|
||||
await page.getByRole('button', { name: 'Add Filter' }).click();
|
||||
await page.getByRole('combobox', { name: 'Filter' }).fill('tag');
|
||||
await page.getByRole('option', { name: 'Tags' }).click();
|
||||
await page.getByRole('combobox', { name: 'Value' }).click();
|
||||
|
||||
// Apply the "Requires Payment" tag filter
|
||||
await page.getByRole('option', { name: 'Requires Payment' }).click();
|
||||
await page.getByRole('button', { name: 'apply-tags-filter' }).click();
|
||||
await page.getByRole('button', { name: 'filter-drawer-close' }).click();
|
||||
|
||||
// Click through to one of the selected shipments
|
||||
await page.getByRole('cell', { name: 'SO0007' }).click();
|
||||
await page.getByText('Sales Order: SO0007').first().waitFor();
|
||||
await page.getByText('Requires Payment', { exact: true }).first().waitFor();
|
||||
});
|
||||
|
||||
// Complete a shipment against a sales order
|
||||
test('Sales Orders - Complete Shipment', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
|
||||
Reference in New Issue
Block a user