2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-18 04:55:44 +00:00

Merge branch 'master' into matmair/issue6281

This commit is contained in:
Matthias Mair
2025-01-02 09:25:47 +01:00
committed by GitHub
147 changed files with 59274 additions and 53019 deletions

View File

@ -1,7 +1,7 @@
import { t } from '@lingui/macro';
import { Box, Divider, Modal } from '@mantine/core';
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { type NavigateFunction, useNavigate } from 'react-router-dom';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import type { ModelType } from '../../enums/ModelType';
@ -17,17 +17,34 @@ export default function BarcodeScanDialog({
title,
opened,
onClose
}: {
}: Readonly<{
title?: string;
opened: boolean;
onClose: () => void;
}) {
}>) {
const navigate = useNavigate();
const user = useUserState();
return (
<Modal
size='lg'
opened={opened}
onClose={onClose}
title={<StylishText size='xl'>{title ?? t`Scan Barcode`}</StylishText>}
>
<Divider />
<Box>
<ScanInputHandler navigate={navigate} onClose={onClose} />
</Box>
</Modal>
);
}
export function ScanInputHandler({
onClose,
navigate
}: Readonly<{ onClose: () => void; navigate: NavigateFunction }>) {
const [error, setError] = useState<string>('');
const [processing, setProcessing] = useState<boolean>(false);
const user = useUserState();
const onScan = useCallback((barcode: string) => {
if (!barcode || barcode.length === 0) {
@ -80,19 +97,5 @@ export default function BarcodeScanDialog({
});
}, []);
return (
<>
<Modal
size='lg'
opened={opened}
onClose={onClose}
title={<StylishText size='xl'>{title ?? t`Scan Barcode`}</StylishText>}
>
<Divider />
<Box>
<BarcodeInput onScan={onScan} error={error} processing={processing} />
</Box>
</Modal>
</>
);
return <BarcodeInput onScan={onScan} error={error} processing={processing} />;
}

View File

@ -0,0 +1,21 @@
import {} from '@mantine/core';
import type { ContextModalProps } from '@mantine/modals';
import type { NavigateFunction } from 'react-router-dom';
import { ScanInputHandler } from '../barcodes/BarcodeScanDialog';
export function QrModal({
context,
id,
innerProps
}: Readonly<
ContextModalProps<{ modalBody: string; navigate: NavigateFunction }>
>) {
function close() {
context.closeModal(id);
}
function navigate() {
context.closeModal(id);
}
return <ScanInputHandler navigate={innerProps.navigate} onClose={close} />;
}

View File

@ -1,17 +1,24 @@
import { LoadingOverlay } from '@mantine/core';
import type { ModelType } from '../../enums/ModelType';
import type { UserRoles } from '../../enums/Roles';
import { useUserState } from '../../states/UserState';
import ClientError from '../errors/ClientError';
import PermissionDenied from '../errors/PermissionDenied';
import ServerError from '../errors/ServerError';
export default function InstanceDetail({
status,
loading,
children
children,
requiredRole,
requiredPermission
}: Readonly<{
status: number;
loading: boolean;
children: React.ReactNode;
requiredRole?: UserRoles;
requiredPermission?: ModelType;
}>) {
const user = useUserState();
@ -27,5 +34,13 @@ export default function InstanceDetail({
return <ClientError status={status} />;
}
if (requiredRole && !user.hasViewRole(requiredRole)) {
return <PermissionDenied />;
}
if (requiredPermission && !user.hasViewPermission(requiredPermission)) {
return <PermissionDenied />;
}
return <>{children}</>;
}

View File

@ -1,19 +1,22 @@
import { Badge, Center, type MantineSize } from '@mantine/core';
import { colorMap } from '../../defaults/backendMappings';
import { statusColorMap } from '../../defaults/backendMappings';
import type { ModelType } from '../../enums/ModelType';
import { resolveItem } from '../../functions/conversion';
import { useGlobalStatusState } from '../../states/StatusState';
interface StatusCodeInterface {
key: string;
export interface StatusCodeInterface {
key: number;
label: string;
name: string;
color: string;
}
export interface StatusCodeListInterface {
[key: string]: StatusCodeInterface;
status_class: string;
values: {
[key: string]: StatusCodeInterface;
};
}
interface RenderStatusLabelOptionsInterface {
@ -33,10 +36,10 @@ function renderStatusLabel(
let color = null;
// Find the entry which matches the provided key
for (const name in codes) {
const entry = codes[name];
for (const name in codes.values) {
const entry: StatusCodeInterface = codes.values[name];
if (entry.key == key) {
if (entry?.key == key) {
text = entry.label;
color = entry.color;
break;
@ -51,7 +54,7 @@ function renderStatusLabel(
// Fallbacks
if (color == null) color = 'default';
color = colorMap[color] || colorMap['default'];
color = statusColorMap[color] || statusColorMap['default'];
const size = options.size || 'xs';
if (!text) {
@ -65,7 +68,9 @@ function renderStatusLabel(
);
}
export function getStatusCodes(type: ModelType | string) {
export function getStatusCodes(
type: ModelType | string
): StatusCodeListInterface | null {
const statusCodeList = useGlobalStatusState.getState().status;
if (statusCodeList === undefined) {
@ -97,7 +102,7 @@ export function getStatusCodeName(
}
for (const name in statusCodes) {
const entry = statusCodes[name];
const entry: StatusCodeInterface = statusCodes.values[name];
if (entry.key == key) {
return entry.name;

View File

@ -6,6 +6,7 @@ import { ContextMenuProvider } from 'mantine-contextmenu';
import { AboutInvenTreeModal } from '../components/modals/AboutInvenTreeModal';
import { LicenseModal } from '../components/modals/LicenseModal';
import { QrModal } from '../components/modals/QrModal';
import { ServerInfoModal } from '../components/modals/ServerInfoModal';
import { useLocalState } from '../states/LocalState';
import { LanguageContext } from './LanguageContext';
@ -47,7 +48,8 @@ export function ThemeContext({
modals={{
info: ServerInfoModal,
about: AboutInvenTreeModal,
license: LicenseModal
license: LicenseModal,
qr: QrModal
}}
>
<Notifications />

View File

@ -1,12 +1,20 @@
import { t } from '@lingui/macro';
import type { SpotlightActionData } from '@mantine/spotlight';
import { IconLink, IconPointer } from '@tabler/icons-react';
import { IconBarcode, IconLink, IconPointer } from '@tabler/icons-react';
import type { NavigateFunction } from 'react-router-dom';
import { openContextModal } from '@mantine/modals';
import { useLocalState } from '../states/LocalState';
import { useUserState } from '../states/UserState';
import { aboutInvenTree, docLinks, licenseInfo, serverInfo } from './links';
export function openQrModal(navigate: NavigateFunction) {
return openContextModal({
modal: 'qr',
innerProps: { navigate: navigate }
});
}
export function getActions(navigate: NavigateFunction) {
const setNavigationOpen = useLocalState((state) => state.setNavigationOpen);
const { user } = useUserState();
@ -55,6 +63,13 @@ export function getActions(navigate: NavigateFunction) {
description: t`Open the main navigation menu`,
onClick: () => setNavigationOpen(true),
leftSection: <IconPointer size='1.2rem' />
},
{
id: 'scan',
label: t`Scan`,
description: t`Scan a barcode or QR code`,
onClick: () => openQrModal(navigate),
leftSection: <IconBarcode size='1.2rem' />
}
];

View File

@ -20,7 +20,7 @@ export const statusCodeList: Record<string, ModelType> = {
/*
* Map the colors used in the backend to the colors used in the frontend
*/
export const colorMap: { [key: string]: string } = {
export const statusColorMap: { [key: string]: string } = {
dark: 'dark',
warning: 'yellow',
success: 'green',

View File

@ -1,6 +1,12 @@
import { IconUsers } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import type { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
import type {
StatusCodeInterface,
StatusCodeListInterface
} from '../components/render/StatusRenderer';
import { useGlobalStatusState } from '../states/StatusState';
export function projectCodeFields(): ApiFormFieldSet {
return {
@ -12,16 +18,51 @@ export function projectCodeFields(): ApiFormFieldSet {
};
}
export function customStateFields(): ApiFormFieldSet {
return {
key: {},
name: {},
label: {},
color: {},
logical_key: {},
model: {},
reference_status: {}
};
export function useCustomStateFields(): ApiFormFieldSet {
// Status codes
const statusCodes = useGlobalStatusState();
// Selected base status class
const [statusClass, setStatusClass] = useState<string>('');
// Construct a list of status options based on the selected status class
const statusOptions: any[] = useMemo(() => {
const options: any[] = [];
const valuesList = Object.values(statusCodes.status ?? {}).find(
(value: StatusCodeListInterface) => value.status_class === statusClass
);
Object.values(valuesList?.values ?? {}).forEach(
(value: StatusCodeInterface) => {
options.push({
value: value.key,
display_name: value.label
});
}
);
return options;
}, [statusCodes, statusClass]);
return useMemo(() => {
return {
reference_status: {
onValueChange(value) {
setStatusClass(value);
}
},
logical_key: {
field_type: 'choice',
choices: statusOptions
},
key: {},
name: {},
label: {},
color: {},
model: {}
};
}, [statusOptions]);
}
export function customUnitsFields(): ApiFormFieldSet {

View File

@ -482,7 +482,7 @@ function StockOperationsRow({
const [statusOpen, statusHandlers] = useDisclosure(false, {
onOpen: () => {
setStatus(record?.status || undefined);
setStatus(record?.status_custom_key || record?.status || undefined);
props.changeFn(props.idx, 'status', record?.status || undefined);
},
onClose: () => {

View File

@ -31,14 +31,18 @@ export default function useStatusCodes({
const statusCodeList = useGlobalStatusState.getState().status;
const codes = useMemo(() => {
const statusCodes = getStatusCodes(modelType) || {};
const statusCodes = getStatusCodes(modelType) || null;
const codesMap: Record<any, any> = {};
for (const name in statusCodes) {
codesMap[name] = statusCodes[name].key;
if (!statusCodes) {
return codesMap;
}
Object.keys(statusCodes.values).forEach((name) => {
codesMap[name] = statusCodes.values[name].key;
});
return codesMap;
}, [modelType, statusCodeList]);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -107,6 +107,15 @@ export default function BuildDetail() {
label: t`Status`,
model: ModelType.build
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.build,
icon: 'status',
hidden:
!build.status_custom_key || build.status_custom_key == build.status
},
{
type: 'text',
name: 'reference',
@ -510,7 +519,11 @@ export default function BuildDetail() {
{holdOrder.modal}
{issueOrder.modal}
{completeOrder.modal}
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<InstanceDetail
status={requestStatus}
loading={instanceQuery.isFetching}
requiredRole={UserRoles.build}
>
<Stack gap='xs'>
<PageDetail
title={`${t`Build Order`}: ${build.reference}`}

View File

@ -398,7 +398,11 @@ export default function SupplierPartDetail() {
{deleteSupplierPart.modal}
{duplicateSupplierPart.modal}
{editSupplierPart.modal}
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<InstanceDetail
status={requestStatus}
loading={instanceQuery.isFetching}
requiredRole={UserRoles.purchase_order}
>
<Stack gap='xs'>
<PageDetail
title={t`Supplier Part`}

View File

@ -318,6 +318,7 @@ export default function CategoryDetail() {
<InstanceDetail
status={requestStatus}
loading={id ? instanceQuery.isFetching : false}
requiredRole={UserRoles.part_category}
>
<Stack gap='xs'>
<LoadingOverlay visible={instanceQuery.isFetching} />

View File

@ -970,18 +970,24 @@ export default function PartDetail() {
{editPart.modal}
{deletePart.modal}
{orderPartsWizard.wizard}
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<InstanceDetail
status={requestStatus}
loading={instanceQuery.isFetching}
requiredRole={UserRoles.part}
>
<Stack gap='xs'>
<NavigationTree
title={t`Part Categories`}
modelType={ModelType.partcategory}
endpoint={ApiEndpoints.category_tree}
opened={treeOpen}
onClose={() => {
setTreeOpen(false);
}}
selectedId={part?.category}
/>
{user.hasViewRole(UserRoles.part_category) && (
<NavigationTree
title={t`Part Categories`}
modelType={ModelType.partcategory}
endpoint={ApiEndpoints.category_tree}
opened={treeOpen}
onClose={() => {
setTreeOpen(false);
}}
selectedId={part?.category}
/>
)}
<PageDetail
title={`${t`Part`}: ${part.full_name}`}
icon={
@ -992,9 +998,13 @@ export default function PartDetail() {
subtitle={part.description}
imageUrl={part.image}
badges={badges}
breadcrumbs={breadcrumbs}
breadcrumbs={
user.hasViewRole(UserRoles.part_category)
? breadcrumbs
: undefined
}
breadcrumbAction={() => {
setTreeOpen(true); // Open the category tree
setTreeOpen(true);
}}
editAction={editPart.open}
editEnabled={user.hasChangeRole(UserRoles.part)}

View File

@ -144,6 +144,15 @@ export default function PurchaseOrderDetail() {
name: 'status',
label: t`Status`,
model: ModelType.purchaseorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.purchaseorder,
icon: 'status',
hidden:
!order.status_custom_key || order.status_custom_key == order.status
}
];
@ -474,7 +483,11 @@ export default function PurchaseOrderDetail() {
{completeOrder.modal}
{editPurchaseOrder.modal}
{duplicatePurchaseOrder.modal}
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<InstanceDetail
status={requestStatus}
loading={instanceQuery.isFetching}
requiredRole={UserRoles.purchase_order}
>
<Stack gap='xs'>
<PageDetail
title={`${t`Purchase Order`}: ${order.reference}`}

View File

@ -24,7 +24,8 @@ export default function PurchasingIndex() {
name: 'purchaseorders',
label: t`Purchase Orders`,
icon: <IconShoppingCart />,
content: <PurchaseOrderTable />
content: <PurchaseOrderTable />,
hidden: !user.hasViewRole(UserRoles.purchase_order)
},
{
name: 'suppliers',
@ -49,7 +50,7 @@ export default function PurchasingIndex() {
)
}
];
}, []);
}, [user]);
if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.purchase_order)) {
return <PermissionDenied />;

View File

@ -115,6 +115,15 @@ export default function ReturnOrderDetail() {
name: 'status',
label: t`Status`,
model: ModelType.returnorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.returnorder,
icon: 'status',
hidden:
!order.status_custom_key || order.status_custom_key == order.status
}
];
@ -461,7 +470,11 @@ export default function ReturnOrderDetail() {
{holdOrder.modal}
{completeOrder.modal}
{duplicateReturnOrder.modal}
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<InstanceDetail
status={requestStatus}
loading={instanceQuery.isFetching}
requiredRole={UserRoles.return_order}
>
<Stack gap='xs'>
<PageDetail
title={`${t`Return Order`}: ${order.reference}`}

View File

@ -25,13 +25,15 @@ export default function PurchasingIndex() {
name: 'salesorders',
label: t`Sales Orders`,
icon: <IconTruckDelivery />,
content: <SalesOrderTable />
content: <SalesOrderTable />,
hidden: !user.hasViewRole(UserRoles.sales_order)
},
{
name: 'returnorders',
label: t`Return Orders`,
icon: <IconTruckReturn />,
content: <ReturnOrderTable />
content: <ReturnOrderTable />,
hidden: !user.hasViewRole(UserRoles.return_order)
},
{
name: 'suppliers',
@ -42,7 +44,7 @@ export default function PurchasingIndex() {
)
}
];
}, []);
}, [user]);
if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.sales_order)) {
return <PermissionDenied />;

View File

@ -124,6 +124,15 @@ export default function SalesOrderDetail() {
name: 'status',
label: t`Status`,
model: ModelType.salesorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.salesorder,
icon: 'status',
hidden:
!order.status_custom_key || order.status_custom_key == order.status
}
];
@ -525,7 +534,11 @@ export default function SalesOrderDetail() {
{completeOrder.modal}
{editSalesOrder.modal}
{duplicateSalesOrder.modal}
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<InstanceDetail
status={requestStatus}
loading={instanceQuery.isFetching}
requiredRole={UserRoles.sales_order}
>
<Stack gap='xs'>
<PageDetail
title={`${t`Sales Order`}: ${order.reference}`}

View File

@ -352,6 +352,7 @@ export default function SalesOrderShipmentDetail() {
<InstanceDetail
status={shipmentStatus}
loading={shipmentQuery.isFetching || customerQuery.isFetching}
requiredRole={UserRoles.sales_order}
>
<Stack gap='xs'>
<PageDetail

View File

@ -280,6 +280,8 @@ export default function Stock() {
<BarcodeActionDropdown
model={ModelType.stocklocation}
pk={location.pk}
hash={location?.barcode_hash}
perm={user.hasChangeRole(UserRoles.stock_location)}
actions={[
{
name: 'Scan in stock items',
@ -363,6 +365,7 @@ export default function Stock() {
<InstanceDetail
status={requestStatus}
loading={id ? instanceQuery.isFetching : false}
requiredRole={UserRoles.stock_location}
>
<Stack>
<NavigationTree

View File

@ -133,11 +133,21 @@ export default function StockDetail() {
icon: 'part',
hidden: !part.IPN
},
{
name: 'status',
type: 'status',
label: t`Status`,
model: ModelType.stockitem
},
{
name: 'status_custom_key',
type: 'status',
label: t`Stock Status`,
model: ModelType.stockitem
label: t`Custom Status`,
model: ModelType.stockitem,
icon: 'status',
hidden:
!stockitem.status_custom_key ||
stockitem.status_custom_key == stockitem.status
},
{
type: 'text',
@ -845,11 +855,10 @@ export default function StockDetail() {
key='batch'
/>,
<StatusRenderer
status={stockitem.status_custom_key}
status={stockitem.status_custom_key || stockitem.status}
type={ModelType.stockitem}
options={{
size: 'lg',
hidden: !!stockitem.status_custom_key
size: 'lg'
}}
key='status'
/>,
@ -875,16 +884,22 @@ export default function StockDetail() {
}, [stockitem, instanceQuery, enableExpiry]);
return (
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<InstanceDetail
requiredRole={UserRoles.stock}
status={requestStatus}
loading={instanceQuery.isFetching}
>
<Stack>
<NavigationTree
title={t`Stock Locations`}
modelType={ModelType.stocklocation}
endpoint={ApiEndpoints.stock_location_tree}
opened={treeOpen}
onClose={() => setTreeOpen(false)}
selectedId={stockitem?.location}
/>
{user.hasViewRole(UserRoles.stock_location) && (
<NavigationTree
title={t`Stock Locations`}
modelType={ModelType.stocklocation}
endpoint={ApiEndpoints.stock_location_tree}
opened={treeOpen}
onClose={() => setTreeOpen(false)}
selectedId={stockitem?.location}
/>
)}
<PageDetail
title={t`Stock Item`}
subtitle={stockitem.part_detail?.full_name}
@ -892,7 +907,9 @@ export default function StockDetail() {
editAction={editStockItem.open}
editEnabled={user.hasChangePermission(ModelType.stockitem)}
badges={stockBadges}
breadcrumbs={breadcrumbs}
breadcrumbs={
user.hasViewRole(UserRoles.stock_location) ? breadcrumbs : undefined
}
breadcrumbAction={() => {
setTreeOpen(true);
}}

View File

@ -9,7 +9,7 @@ import type { ModelType } from '../enums/ModelType';
import { apiUrl } from './ApiState';
import { useUserState } from './UserState';
type StatusLookup = Record<ModelType | string, StatusCodeListInterface>;
export type StatusLookup = Record<ModelType | string, StatusCodeListInterface>;
interface ServerStateProps {
status?: StatusLookup;
@ -35,8 +35,10 @@ export const useGlobalStatusState = create<ServerStateProps>()(
.then((response) => {
const newStatusLookup: StatusLookup = {} as StatusLookup;
for (const key in response.data) {
newStatusLookup[statusCodeList[key] || key] =
response.data[key].values;
newStatusLookup[statusCodeList[key] || key] = {
status_class: key,
values: response.data[key].values
};
}
set({ status: newStatusLookup });
})

View File

@ -1,7 +1,11 @@
import { t } from '@lingui/macro';
import type {
StatusCodeInterface,
StatusCodeListInterface
} from '../components/render/StatusRenderer';
import type { ModelType } from '../enums/ModelType';
import { useGlobalStatusState } from '../states/StatusState';
import { type StatusLookup, useGlobalStatusState } from '../states/StatusState';
/**
* Interface for the table filter choice
@ -71,17 +75,18 @@ export function StatusFilterOptions(
model: ModelType
): () => TableFilterChoice[] {
return () => {
const statusCodeList = useGlobalStatusState.getState().status;
const statusCodeList: StatusLookup | undefined =
useGlobalStatusState.getState().status;
if (!statusCodeList) {
return [];
}
const codes = statusCodeList[model];
const codes: StatusCodeListInterface | undefined = statusCodeList[model];
if (codes) {
return Object.keys(codes).map((key) => {
const entry = codes[key];
return Object.keys(codes.values).map((key) => {
const entry: StatusCodeInterface = codes.values[key];
return {
value: entry.key.toString(),
label: entry.label?.toString() ?? entry.key.toString()

View File

@ -1,10 +1,16 @@
import { t } from '@lingui/macro';
import { Badge } from '@mantine/core';
import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import type {
StatusCodeInterface,
StatusCodeListInterface
} from '../../components/render/StatusRenderer';
import { statusColorMap } from '../../defaults/backendMappings';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { UserRoles } from '../../enums/Roles';
import { customStateFields } from '../../forms/CommonForms';
import { useCustomStateFields } from '../../forms/CommonForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
@ -12,10 +18,17 @@ import {
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useGlobalStatusState } from '../../states/StatusState';
import { useUserState } from '../../states/UserState';
import type { TableColumn } from '../Column';
import type { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { type RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
import {
type RowAction,
RowDeleteAction,
RowDuplicateAction,
RowEditAction
} from '../RowActions';
/**
* Table for displaying list of custom states
@ -23,12 +36,64 @@ import { type RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
export default function CustomStateTable() {
const table = useTable('customstates');
const statusCodes = useGlobalStatusState();
// Find the associated logical state key
const getLogicalState = useCallback(
(group: string, key: number) => {
const valuesList = Object.values(statusCodes.status ?? {}).find(
(value: StatusCodeListInterface) => value.status_class === group
);
const value = Object.values(valuesList?.values ?? {}).find(
(value: StatusCodeInterface) => value.key === key
);
return value?.label ?? value?.name ?? '';
},
[statusCodes]
);
const user = useUserState();
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'reference_status',
label: t`Status Group`,
field_type: 'choice',
choices: Object.values(statusCodes.status ?? {}).map(
(value: StatusCodeListInterface) => ({
label: value.status_class,
value: value.status_class
})
)
}
];
}, [statusCodes]);
const columns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'reference_status',
title: t`Status`,
sortable: true
},
{
accessor: 'logical_key',
title: t`Logical State`,
sortable: true,
render: (record: any) => {
const stateText = getLogicalState(
record.reference_status,
record.logical_key
);
return stateText ? stateText : record.logical_key;
}
},
{
accessor: 'name',
title: t`Identifier`,
sortable: true
},
{
@ -36,34 +101,45 @@ export default function CustomStateTable() {
title: t`Display Name`,
sortable: true
},
{
accessor: 'color'
},
{
accessor: 'key',
sortable: true
},
{
accessor: 'logical_key',
sortable: true
},
{
accessor: 'model_name',
title: t`Model`,
sortable: true
},
{
accessor: 'reference_status',
title: t`Status`,
sortable: true
accessor: 'color',
render: (record: any) => {
return (
<Badge
color={statusColorMap[record.color] || statusColorMap['default']}
variant='filled'
size='xs'
>
{record.color}
</Badge>
);
}
}
];
}, []);
}, [getLogicalState]);
const newCustomStateFields = useCustomStateFields();
const duplicateCustomStateFields = useCustomStateFields();
const editCustomStateFields = useCustomStateFields();
const [initialStateData, setInitialStateData] = useState<any>({});
const newCustomState = useCreateApiFormModal({
url: ApiEndpoints.custom_state_list,
title: t`Add State`,
fields: customStateFields(),
fields: newCustomStateFields,
table: table
});
const duplicateCustomState = useCreateApiFormModal({
url: ApiEndpoints.custom_state_list,
title: t`Add State`,
fields: duplicateCustomStateFields,
initialData: initialStateData,
table: table
});
@ -75,7 +151,7 @@ export default function CustomStateTable() {
url: ApiEndpoints.custom_state_list,
pk: selectedCustomState,
title: t`Edit State`,
fields: customStateFields(),
fields: editCustomStateFields,
table: table
});
@ -96,6 +172,13 @@ export default function CustomStateTable() {
editCustomState.open();
}
}),
RowDuplicateAction({
hidden: !user.hasAddRole(UserRoles.admin),
onClick: () => {
setInitialStateData(record);
duplicateCustomState.open();
}
}),
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.admin),
onClick: () => {
@ -112,7 +195,10 @@ export default function CustomStateTable() {
return [
<AddItemButton
key={'add'}
onClick={() => newCustomState.open()}
onClick={() => {
setInitialStateData({});
newCustomState.open();
}}
tooltip={t`Add State`}
/>
];
@ -122,6 +208,7 @@ export default function CustomStateTable() {
<>
{newCustomState.modal}
{editCustomState.modal}
{duplicateCustomState.modal}
{deleteCustomState.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.custom_state_list)}
@ -130,6 +217,7 @@ export default function CustomStateTable() {
props={{
rowActions: rowActions,
tableActions: tableActions,
tableFilters: tableFilters,
enableDownload: true
}}
/>

View File

@ -34,7 +34,7 @@ export const clickButtonIfVisible = async (page, name, timeout = 500) => {
export const clearTableFilters = async (page) => {
await openFilterDrawer(page);
await clickButtonIfVisible(page, 'Clear Filters');
await page.getByLabel('filter-drawer-close').click();
await closeFilterDrawer(page);
};
export const setTableChoiceFilter = async (page, filter, value) => {
@ -42,7 +42,9 @@ export const setTableChoiceFilter = async (page, filter, value) => {
await page.getByRole('button', { name: 'Add Filter' }).click();
await page.getByPlaceholder('Select filter').fill(filter);
await page.getByRole('option', { name: 'Status' }).click();
await page.getByPlaceholder('Select filter').click();
await page.getByRole('option', { name: filter }).click();
await page.getByPlaceholder('Select filter value').click();
await page.getByRole('option', { name: value }).click();

View File

@ -1,9 +1,9 @@
import { test } from '../baseFixtures.ts';
import { baseUrl } from '../defaults.ts';
import {
clickButtonIfVisible,
clearTableFilters,
getRowFromCell,
openFilterDrawer
setTableChoiceFilter
} from '../helpers.ts';
import { doQuickLogin } from '../login.ts';
@ -266,6 +266,24 @@ test('Build Order - Filters', async ({ page }) => {
await page.goto(`${baseUrl}/manufacturing/index/buildorders`);
await openFilterDrawer(page);
await clickButtonIfVisible(page, 'Clear Filters');
await clearTableFilters(page);
await page.getByText('1 - 24 / 24').waitFor();
// Toggle 'Outstanding' filter
await setTableChoiceFilter(page, 'Outstanding', 'Yes');
await page.getByText('1 - 18 / 18').waitFor();
await clearTableFilters(page);
await setTableChoiceFilter(page, 'Outstanding', 'No');
await page.getByText('1 - 6 / 6').waitFor();
await clearTableFilters(page);
// Filter by custom status code
await setTableChoiceFilter(page, 'Status', 'Pending Approval');
// Single result - navigate through to the build order
await page.getByText('1 - 1 / 1').waitFor();
await page.getByRole('cell', { name: 'BO0023' }).click();
await page.getByText('On Hold').first().waitFor();
await page.getByText('Pending Approval').first().waitFor();
});

View File

@ -1,6 +1,11 @@
import { test } from '../baseFixtures.js';
import { baseUrl } from '../defaults.js';
import { clickButtonIfVisible, openFilterDrawer } from '../helpers.js';
import {
clearTableFilters,
clickButtonIfVisible,
openFilterDrawer,
setTableChoiceFilter
} from '../helpers.js';
import { doQuickLogin } from '../login.js';
test('Stock - Basic Tests', async ({ page }) => {
@ -84,9 +89,15 @@ test('Stock - Filters', async ({ page }) => {
.getByRole('cell', { name: 'A round table - with blue paint' })
.waitFor();
// Clear filters (ready for next set of tests)
await openFilterDrawer(page);
await clickButtonIfVisible(page, 'Clear Filters');
// Filter by custom status code
await clearTableFilters(page);
await setTableChoiceFilter(page, 'Status', 'Incoming goods inspection');
await page.getByText('1 - 8 / 8').waitFor();
await page.getByRole('cell', { name: '1551AGY' }).first().waitFor();
await page.getByRole('cell', { name: 'widget.blue' }).first().waitFor();
await page.getByRole('cell', { name: '002.01-PCBA' }).first().waitFor();
await clearTableFilters(page);
});
test('Stock - Serial Numbers', async ({ page }) => {
@ -158,47 +169,58 @@ test('Stock - Serial Numbers', async ({ page }) => {
test('Stock - Stock Actions', async ({ page }) => {
await doQuickLogin(page);
// Find an in-stock, untracked item
await page.goto(
`${baseUrl}/stock/location/index/stock-items?in_stock=1&serialized=0`
);
await page.getByText('530470210').first().click();
await page.goto(`${baseUrl}/stock/item/1225/details`);
// Helper function to launch a stock action
const launchStockAction = async (action: string) => {
await page.getByLabel('action-menu-stock-operations').click();
await page.getByLabel(`action-menu-stock-operations-${action}`).click();
};
const setStockStatus = async (status: string) => {
await page.getByLabel('action-button-change-status').click();
await page.getByLabel('choice-field-status').click();
await page.getByRole('option', { name: status }).click();
};
// Check for required values
await page.getByText('Status', { exact: true }).waitFor();
await page.getByText('Custom Status', { exact: true }).waitFor();
await page.getByText('Attention needed').waitFor();
await page
.locator('div')
.filter({ hasText: /^Quantity: 270$/ })
.first()
.getByLabel('Stock Details')
.getByText('Incoming goods inspection')
.waitFor();
await page.getByText('123').first().waitFor();
// Check for expected action sections
await page.getByLabel('action-menu-barcode-actions').click();
await page.getByLabel('action-menu-barcode-actions-link-barcode').click();
await page.getByRole('banner').getByRole('button').click();
await page.getByLabel('action-menu-printing-actions').click();
await page.getByLabel('action-menu-printing-actions-print-labels').click();
await page.getByRole('button', { name: 'Cancel' }).click();
await page.getByLabel('action-menu-stock-operations').click();
await page.getByLabel('action-menu-stock-operations-count').waitFor();
await page.getByLabel('action-menu-stock-operations-add').waitFor();
await page.getByLabel('action-menu-stock-operations-remove').waitFor();
await page.getByLabel('action-menu-stock-operations-transfer').click();
await page.getByLabel('text-field-notes').fill('test notes');
// Add stock, and change status
await launchStockAction('add');
await page.getByLabel('number-field-quantity').fill('12');
await setStockStatus('Lost');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('This field is required.').first().waitFor();
// Set the status field
await page.getByLabel('action-button-change-status').click();
await page.getByLabel('choice-field-status').click();
await page.getByText('Attention needed').click();
await page.getByText('Lost').first().waitFor();
await page.getByText('Unavailable').first().waitFor();
await page.getByText('135').first().waitFor();
// Set the packaging field
await page.getByLabel('action-button-adjust-packaging').click();
await page.getByLabel('text-field-packaging').fill('test packaging');
// Remove stock, and change status
await launchStockAction('remove');
await page.getByLabel('number-field-quantity').fill('99');
await setStockStatus('Damaged');
await page.getByRole('button', { name: 'Submit' }).click();
// Close the dialog
await page.getByRole('button', { name: 'Cancel' }).click();
await page.getByText('36').first().waitFor();
await page.getByText('Damaged').first().waitFor();
// Count stock and change status (reverting to original value)
await launchStockAction('count');
await page.getByLabel('number-field-quantity').fill('123');
await setStockStatus('Incoming goods inspection');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('123').first().waitFor();
await page.getByText('Custom Status').first().waitFor();
await page.getByText('Incoming goods inspection').first().waitFor();
// Find an item which has been sent to a customer
await page.goto(`${baseUrl}/stock/item/1014/details`);
@ -220,7 +242,4 @@ test('Stock - Tracking', async ({ page }) => {
await page.getByText('- - Factory/Office Block/Room').first().waitFor();
await page.getByRole('link', { name: 'Widget Assembly' }).waitFor();
await page.getByRole('cell', { name: 'Installed into assembly' }).waitFor();
await page.waitForTimeout(1500);
return;
});

View File

@ -153,7 +153,7 @@ test('Settings - Admin - Barcode History', async ({ page, request }) => {
await page.getByRole('menuitem', { name: 'Admin Center' }).click();
await page.getByRole('tab', { name: 'Barcode Scans' }).click();
await page.waitForTimeout(2000);
await page.waitForTimeout(500);
// Barcode history is displayed in table
barcodes.forEach(async (barcode) => {