mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-06 09:43:38 +00:00
[UI] BOM compare (#11853)
* Refactor existing components * Select assembly for comparison * Rough BOM comparison table * Select display mode * Layout tweaks * Reset secondary part when drawer is closed * Responsive grids * Documentation * Update CHANGELOG.md * Add playwright tests * Update wording * Allow specification of secondary part with URL search params * Update URL params when value changes * Clearer display using icons * Improve diff layout * Adjust playwright tests
This commit is contained in:
@@ -13,7 +13,7 @@ v480 -> 2026-04-27 : https://github.com/inventree/InvenTree/pull/11816
|
||||
- The "issued_by" field on the Build API endpoint is now read-only, and is automatically set to the current user when a build is created
|
||||
|
||||
v479 -> 2026-04-11 : https://github.com/inventree/InvenTree/pull/11723
|
||||
- POST /api//notifications/readall/ now requires a POST action
|
||||
- POST /api/notifications/readall/ now requires a POST action
|
||||
- POST /api/admin/email/test/ - now returns a 200 on. a successful test
|
||||
- GET /api/notifications/ - now uses user-centric permissions, not a general read
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export function ApiImage(props: Readonly<ApiImageProps>) {
|
||||
src={imageUrl}
|
||||
fit='contain'
|
||||
style={{
|
||||
...props.style,
|
||||
opacity: isLoaded ? 1 : 0,
|
||||
transition: 'opacity 0.2s ease'
|
||||
}}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function Thumbnail({
|
||||
w={size}
|
||||
fit='contain'
|
||||
radius='xs'
|
||||
style={{ maxHeight: size }}
|
||||
style={{ maxHeight: size, height: size }}
|
||||
/>
|
||||
{inner}
|
||||
</Group>
|
||||
|
||||
@@ -5,9 +5,7 @@ import {
|
||||
Center,
|
||||
Grid,
|
||||
Group,
|
||||
HoverCard,
|
||||
Loader,
|
||||
type MantineColor,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
@@ -17,13 +15,11 @@ import {
|
||||
IconBookmarks,
|
||||
IconBuilding,
|
||||
IconChecklist,
|
||||
IconCircleCheck,
|
||||
IconClipboardList,
|
||||
IconCurrencyDollar,
|
||||
IconExclamationCircle,
|
||||
IconInfoCircle,
|
||||
IconLayersLinked,
|
||||
IconListCheck,
|
||||
IconListDetails,
|
||||
IconListTree,
|
||||
IconLock,
|
||||
@@ -47,7 +43,6 @@ 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 { ActionButton } from '@lib/index';
|
||||
import type { StockOperationProps } from '@lib/types/Forms';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
@@ -76,20 +71,17 @@ import NotesPanel from '../../components/panels/NotesPanel';
|
||||
import type { PanelType } from '../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { RenderPart } from '../../components/render/Part';
|
||||
import { RenderUser } from '../../components/render/User';
|
||||
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
||||
import { useApi } from '../../contexts/ApiContext';
|
||||
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
|
||||
import { usePartFields } from '../../forms/PartForms';
|
||||
import { useFindSerialNumberForm } from '../../forms/StockForms';
|
||||
import useBackgroundTask from '../../hooks/UseBackgroundTask';
|
||||
import {
|
||||
useApiFormModal,
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { type UseInstanceResult, useInstance } from '../../hooks/UseInstance';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
|
||||
import {
|
||||
useGlobalSettingsState,
|
||||
@@ -112,6 +104,7 @@ import PartAllocationPanel from './PartAllocationPanel';
|
||||
import PartPricingPanel from './PartPricingPanel';
|
||||
import PartStockHistoryDetail from './PartStockHistoryDetail';
|
||||
import PartSupplierDetail from './PartSupplierDetail';
|
||||
import { BomActions } from './bom/BomActions';
|
||||
|
||||
/**
|
||||
* Render a part revision selector component
|
||||
@@ -154,132 +147,6 @@ function RevisionSelector({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A hover-over component which displays information about the BOM validation for a given part
|
||||
*/
|
||||
function BomValidationInformation({
|
||||
bomInformation,
|
||||
partId
|
||||
}: {
|
||||
bomInformation: UseInstanceResult;
|
||||
partId: number;
|
||||
}) {
|
||||
const user = useUserState();
|
||||
|
||||
const [taskId, setTaskId] = useState<string>('');
|
||||
|
||||
useBackgroundTask({
|
||||
taskId: taskId,
|
||||
message: t`Validating BOM`,
|
||||
successMessage: t`BOM validated`,
|
||||
onComplete: () => {
|
||||
bomInformation.instanceQuery.refetch();
|
||||
}
|
||||
});
|
||||
|
||||
const validateBom = useApiFormModal({
|
||||
url: ApiEndpoints.bom_validate,
|
||||
method: 'PUT',
|
||||
fields: {
|
||||
valid: {
|
||||
hidden: true,
|
||||
value: true
|
||||
}
|
||||
},
|
||||
title: t`Validate BOM`,
|
||||
pk: partId,
|
||||
preFormContent: (
|
||||
<Alert color='green' icon={<IconCircleCheck />} title={t`Validate BOM`}>
|
||||
<Text>{t`Do you want to validate the bill of materials for this assembly?`}</Text>
|
||||
</Alert>
|
||||
),
|
||||
successMessage: null,
|
||||
onFormSuccess: (response: any) => {
|
||||
// If the process has been offloaded to a background task
|
||||
if (response.task_id) {
|
||||
setTaskId(response.task_id);
|
||||
} else {
|
||||
bomInformation.instanceQuery.refetch();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (bomInformation.instanceQuery.isFetching) {
|
||||
return <Loader size='sm' />;
|
||||
}
|
||||
|
||||
let icon: ReactNode;
|
||||
let color: MantineColor;
|
||||
let title = '';
|
||||
let description = '';
|
||||
|
||||
if (bomInformation.instance?.bom_validated) {
|
||||
color = 'green';
|
||||
icon = <IconListCheck />;
|
||||
title = t`BOM Validated`;
|
||||
description = t`The Bill of Materials for this part has been validated`;
|
||||
} else if (bomInformation.instance?.bom_checked_date) {
|
||||
color = 'yellow';
|
||||
icon = <IconExclamationCircle />;
|
||||
title = t`BOM Not Validated`;
|
||||
description = t`The Bill of Materials for this part has previously been checked, but requires revalidation`;
|
||||
} else {
|
||||
color = 'red';
|
||||
icon = <IconExclamationCircle />;
|
||||
title = t`BOM Not Validated`;
|
||||
description = t`The Bill of Materials for this part has not yet been validated`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{validateBom.modal}
|
||||
<Group gap='xs' justify='flex-end'>
|
||||
{!bomInformation.instance?.bom_validated &&
|
||||
user.hasChangeRole(UserRoles.bom) && (
|
||||
<ActionButton
|
||||
icon={<IconCircleCheck />}
|
||||
color='green'
|
||||
tooltip={t`Validate BOM`}
|
||||
onClick={validateBom.open}
|
||||
/>
|
||||
)}
|
||||
<HoverCard position='bottom-end'>
|
||||
<HoverCard.Target>
|
||||
<ActionIcon
|
||||
color={color}
|
||||
variant='transparent'
|
||||
aria-label='bom-validation-info'
|
||||
>
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Alert color={color} icon={icon} title={title}>
|
||||
<Stack gap='xs'>
|
||||
<Text size='sm'>{description}</Text>
|
||||
{bomInformation.instance?.bom_checked_date && (
|
||||
<Text size='sm'>
|
||||
{t`Validated On`}:{' '}
|
||||
{bomInformation.instance.bom_checked_date}
|
||||
</Text>
|
||||
)}
|
||||
{bomInformation.instance?.bom_checked_by_detail && (
|
||||
<Group gap='xs'>
|
||||
<Text size='sm'>{t`Validated By`}: </Text>
|
||||
<RenderUser
|
||||
instance={bomInformation.instance.bom_checked_by_detail}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail view for a single Part instance
|
||||
*/
|
||||
@@ -295,6 +162,7 @@ export default function PartDetail() {
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
const userSettings = useUserSettingsState();
|
||||
|
||||
// BOM validation information (used for hover-over info on the BOM tab)
|
||||
const bomInformation = useInstance({
|
||||
endpoint: ApiEndpoints.bom_validate,
|
||||
pk: id,
|
||||
@@ -808,10 +676,7 @@ export default function PartDetail() {
|
||||
name: 'bom',
|
||||
label: t`Bill of Materials`,
|
||||
controls: (
|
||||
<BomValidationInformation
|
||||
bomInformation={bomInformation}
|
||||
partId={part.pk ?? -1}
|
||||
/>
|
||||
<BomActions bomInformation={bomInformation} partInstance={part} />
|
||||
),
|
||||
icon: <IconListTree />,
|
||||
hidden: !part.assembly || !user.hasViewRole(UserRoles.bom),
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { ActionButton } from '@lib/components/ActionButton';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Group,
|
||||
HoverCard,
|
||||
Loader,
|
||||
type MantineColor,
|
||||
Stack,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCircleCheck,
|
||||
IconExclamationCircle,
|
||||
IconGitCompare,
|
||||
IconListCheck
|
||||
} from '@tabler/icons-react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { RenderUser } from '../../../components/render/User';
|
||||
import useBackgroundTask from '../../../hooks/UseBackgroundTask';
|
||||
import { useApiFormModal } from '../../../hooks/UseForm';
|
||||
import type { UseInstanceResult } from '../../../hooks/UseInstance';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
import { BomCompareDrawer } from './BomCompare';
|
||||
|
||||
/**
|
||||
* A hover-over component which displays information about the BOM validation for a given part
|
||||
*/
|
||||
export function BomActions({
|
||||
bomInformation,
|
||||
partInstance
|
||||
}: Readonly<{
|
||||
bomInformation: UseInstanceResult;
|
||||
partInstance: any;
|
||||
}>) {
|
||||
const user = useUserState();
|
||||
|
||||
const [bomCompareOpen, setBomCompareOpen] = useState<boolean>(false);
|
||||
|
||||
const [bomCompareId, setBomCompareId] = useState<string>('');
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Open the BOM compare drawer if the URL contains the relevant query parameter
|
||||
useEffect(() => {
|
||||
if (
|
||||
searchParams.has('compare') &&
|
||||
!!searchParams.get('compare') &&
|
||||
!bomCompareOpen
|
||||
) {
|
||||
setBomCompareId(searchParams.get('compare') as string);
|
||||
setBomCompareOpen(true);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const [taskId, setTaskId] = useState<string>('');
|
||||
|
||||
useBackgroundTask({
|
||||
taskId: taskId,
|
||||
message: t`Validating BOM`,
|
||||
successMessage: t`BOM validated`,
|
||||
onComplete: () => {
|
||||
bomInformation.instanceQuery.refetch();
|
||||
}
|
||||
});
|
||||
|
||||
const validateBom = useApiFormModal({
|
||||
url: ApiEndpoints.bom_validate,
|
||||
method: 'PUT',
|
||||
fields: {
|
||||
valid: {
|
||||
hidden: true,
|
||||
value: true
|
||||
}
|
||||
},
|
||||
title: t`Validate BOM`,
|
||||
pk: partInstance.pk,
|
||||
preFormContent: (
|
||||
<Alert color='green' icon={<IconCircleCheck />} title={t`Validate BOM`}>
|
||||
<Text>{t`Do you want to validate the bill of materials for this assembly?`}</Text>
|
||||
</Alert>
|
||||
),
|
||||
successMessage: null,
|
||||
onFormSuccess: (response: any) => {
|
||||
// If the process has been offloaded to a background task
|
||||
if (response.task_id) {
|
||||
setTaskId(response.task_id);
|
||||
} else {
|
||||
bomInformation.instanceQuery.refetch();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (bomInformation.instanceQuery.isFetching) {
|
||||
return <Loader size='sm' />;
|
||||
}
|
||||
|
||||
let icon: ReactNode;
|
||||
let color: MantineColor;
|
||||
let title = '';
|
||||
let description = '';
|
||||
|
||||
if (bomInformation.instance?.bom_validated) {
|
||||
color = 'green';
|
||||
icon = <IconListCheck />;
|
||||
title = t`BOM Validated`;
|
||||
description = t`The Bill of Materials for this part has been validated`;
|
||||
} else if (bomInformation.instance?.bom_checked_date) {
|
||||
color = 'yellow';
|
||||
icon = <IconExclamationCircle />;
|
||||
title = t`BOM Not Validated`;
|
||||
description = t`The Bill of Materials for this part has previously been checked, but requires revalidation`;
|
||||
} else {
|
||||
color = 'red';
|
||||
icon = <IconExclamationCircle />;
|
||||
title = t`BOM Not Validated`;
|
||||
description = t`The Bill of Materials for this part has not yet been validated`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{validateBom.modal}
|
||||
<Group gap='xs' justify='flex-end'>
|
||||
<ActionButton
|
||||
icon={<IconGitCompare />}
|
||||
color='blue'
|
||||
tooltip={t`Compare Bill of Materials`}
|
||||
onClick={() => setBomCompareOpen(true)}
|
||||
/>
|
||||
{!bomInformation.instance?.bom_validated &&
|
||||
user.hasChangeRole(UserRoles.bom) && (
|
||||
<ActionButton
|
||||
icon={<IconCircleCheck />}
|
||||
color='green'
|
||||
tooltip={t`Validate BOM`}
|
||||
onClick={validateBom.open}
|
||||
/>
|
||||
)}
|
||||
<HoverCard position='bottom-end'>
|
||||
<HoverCard.Target>
|
||||
<ActionIcon
|
||||
color={color}
|
||||
variant='transparent'
|
||||
aria-label='bom-validation-info'
|
||||
>
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Alert color={color} icon={icon} title={title}>
|
||||
<Stack gap='xs'>
|
||||
<Text size='sm'>{description}</Text>
|
||||
{bomInformation.instance?.bom_checked_date && (
|
||||
<Text size='sm'>
|
||||
{t`Validated On`}:{' '}
|
||||
{bomInformation.instance.bom_checked_date}
|
||||
</Text>
|
||||
)}
|
||||
{bomInformation.instance?.bom_checked_by_detail && (
|
||||
<Group gap='xs'>
|
||||
<Text size='sm'>{t`Validated By`}: </Text>
|
||||
<RenderUser
|
||||
instance={bomInformation.instance.bom_checked_by_detail}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
</Group>
|
||||
<BomCompareDrawer
|
||||
partInstance={partInstance}
|
||||
compareId={bomCompareId}
|
||||
opened={bomCompareOpen}
|
||||
onClosed={() => {
|
||||
setBomCompareId('');
|
||||
setBomCompareOpen(false);
|
||||
setSearchParams((params: URLSearchParams) => {
|
||||
params.delete('compare');
|
||||
return params;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { ApiEndpoints, ModelType, StylishText, apiUrl } from '@lib/index';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconCircleCheck,
|
||||
IconCirclePlus,
|
||||
IconCircleX,
|
||||
IconStatusChange
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../../App';
|
||||
import { StandaloneField } from '../../../components/forms/StandaloneField';
|
||||
import Expand from '../../../components/items/Expand';
|
||||
import { RenderPartColumn } from '../../../tables/ColumnRenderers';
|
||||
|
||||
// Field to check for differences when comparing BOM items
|
||||
const DELTA_FIELDS = {
|
||||
quantity: t`Quantity`,
|
||||
reference: t`Reference`,
|
||||
allow_variants: t`Allow Variants`,
|
||||
inherited: t`Inherited`,
|
||||
optional: t`Optional`,
|
||||
consumable: t`Consumable`,
|
||||
setup_quantity: t`Setup Quantity`,
|
||||
attrition: t`Attrition`,
|
||||
rounding_multiple: t`Rounding Multiple`
|
||||
};
|
||||
|
||||
type BomCompareRow = {
|
||||
part_detail: any;
|
||||
primary: any;
|
||||
secondary: any;
|
||||
match: boolean;
|
||||
deltas: string[];
|
||||
key: string;
|
||||
};
|
||||
|
||||
type BomDisplayMode = 'all' | 'different' | 'common';
|
||||
|
||||
function getBomDeltas(primary: any, secondary: any): string[] {
|
||||
const deltas: string[] = [];
|
||||
|
||||
Object.entries(DELTA_FIELDS).forEach(([field, label]) => {
|
||||
if (primary?.[field] != secondary?.[field]) {
|
||||
deltas.push(field);
|
||||
}
|
||||
});
|
||||
|
||||
return deltas;
|
||||
}
|
||||
|
||||
function BomTableRow({
|
||||
item
|
||||
}: Readonly<{
|
||||
item: BomCompareRow;
|
||||
}>) {
|
||||
const partMatch = !!item.primary && !!item.secondary;
|
||||
|
||||
const quantityMatch =
|
||||
partMatch && item.primary.quantity == item.secondary.quantity;
|
||||
|
||||
const deltas: any[] = useMemo(() => {
|
||||
const fields: any[] = [];
|
||||
|
||||
item.deltas.forEach((delta) => {
|
||||
fields.push({
|
||||
field: delta,
|
||||
label: DELTA_FIELDS[delta as keyof typeof DELTA_FIELDS],
|
||||
primaryValue: item.primary?.[delta] ?? null,
|
||||
secondaryValue: item.secondary?.[delta] ?? null
|
||||
});
|
||||
});
|
||||
|
||||
return fields;
|
||||
}, [item]);
|
||||
|
||||
// Determine the appropriate icon to display for this row
|
||||
const rowIcon: ReactNode = useMemo(() => {
|
||||
if (!!item.primary != !!item.secondary) {
|
||||
if (!!item.secondary) {
|
||||
// Part was added to the secondary BOM (exists in secondary but not primary)
|
||||
return (
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
size='sm'
|
||||
color='var(--mantine-color-yellow-5)'
|
||||
>
|
||||
<IconCirclePlus />
|
||||
</ActionIcon>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
size='sm'
|
||||
color='var(--mantine-color-red-5)'
|
||||
>
|
||||
<IconCircleX />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
!!item.deltas?.length ||
|
||||
item.primary?.quantity != item.secondary?.quantity
|
||||
) {
|
||||
// Part exists in both BOMs but has differences
|
||||
return (
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
size='sm'
|
||||
color='var(--mantine-color-blue-5)'
|
||||
>
|
||||
<IconStatusChange />
|
||||
</ActionIcon>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
size='sm'
|
||||
color='var(--mantine-color-green-5)'
|
||||
>
|
||||
<IconCircleCheck />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Group gap='xs'>
|
||||
{rowIcon}
|
||||
<RenderPartColumn part={item.part_detail} />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap='xs'>
|
||||
<Text size='sm'>
|
||||
{item.primary?.quantity ?? item.secondary?.quantity ?? '-'}
|
||||
</Text>
|
||||
{item.part_detail?.units && (
|
||||
<Text size='xs'>[{item.part_detail.units}]</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap='xs'>
|
||||
{rowIcon}
|
||||
{partMatch && deltas.length > 0 ? (
|
||||
<Stack gap='xs'>
|
||||
{deltas.map((delta, index) => (
|
||||
<Group key={delta.field} gap='xs' justify='space-between'>
|
||||
<Text size='xs'>{delta.label}</Text>
|
||||
<Text size='xs'>{delta.primaryValue ?? '-'}</Text>
|
||||
<ActionIcon size='xs' variant='transparent'>
|
||||
<IconArrowRight />
|
||||
</ActionIcon>
|
||||
<Text size='xs'>{delta.secondaryValue ?? '-'}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size='xs'>
|
||||
{partMatch
|
||||
? t`No changes`
|
||||
: !!item.primary
|
||||
? t`Part removed from BOM`
|
||||
: t`Part added to BOM`}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
function BomTable({
|
||||
items
|
||||
}: Readonly<{
|
||||
items: BomCompareRow[];
|
||||
}>) {
|
||||
return (
|
||||
<Paper p='xs' withBorder>
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t`Part`}</Table.Th>
|
||||
<Table.Th>{t`Quantity`}</Table.Th>
|
||||
<Table.Th>{t`Changes`}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item: any, index) => (
|
||||
<BomTableRow key={index} item={item} />
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function BomCompareDrawer({
|
||||
opened,
|
||||
onClosed,
|
||||
compareId,
|
||||
partInstance
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClosed: () => void;
|
||||
compareId?: string;
|
||||
partInstance: any;
|
||||
}) {
|
||||
const [displayMode, setDisplayMode] = useState<BomDisplayMode>('all');
|
||||
|
||||
const [searchParam, setSearchParams] = useSearchParams();
|
||||
|
||||
// Fetch entire BOM for the part
|
||||
const primaryBom = useQuery({
|
||||
queryKey: ['bom-compare-primary', partInstance.pk, opened],
|
||||
enabled: opened && !!partInstance.pk,
|
||||
queryFn: async () => {
|
||||
return api
|
||||
.get(apiUrl(ApiEndpoints.bom_list), {
|
||||
params: {
|
||||
part: partInstance.pk,
|
||||
sub_part_detail: true
|
||||
}
|
||||
})
|
||||
.then((response) => response.data);
|
||||
}
|
||||
});
|
||||
|
||||
// Secondary part ID
|
||||
const [secondaryPartId, setSecondaryPartId] = useState<string>(
|
||||
compareId ?? ''
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSecondaryPartId(compareId ?? '');
|
||||
}, [opened]);
|
||||
|
||||
// Fetch BOM for the secondary part
|
||||
const secondaryBom = useQuery({
|
||||
queryKey: ['bom-compare-secondary', secondaryPartId, opened],
|
||||
enabled: opened && !!secondaryPartId,
|
||||
queryFn: async () => {
|
||||
return api
|
||||
.get(apiUrl(ApiEndpoints.bom_list), {
|
||||
params: {
|
||||
part: secondaryPartId,
|
||||
sub_part_detail: true
|
||||
}
|
||||
})
|
||||
.then((response) => response.data);
|
||||
}
|
||||
});
|
||||
|
||||
// Perform comparison against
|
||||
const comparedItems: any[] = useMemo(() => {
|
||||
let rows: BomCompareRow[] = [];
|
||||
|
||||
const primaryPartIds = new Set();
|
||||
const secondaryPartIds = new Set();
|
||||
|
||||
// First, iterate through the "primary" BOM to generate the initial data
|
||||
primaryBom.data?.forEach((item: any) => {
|
||||
let subPartId = `${item.sub_part}`;
|
||||
|
||||
while (primaryPartIds.has(subPartId)) {
|
||||
subPartId += '_dup';
|
||||
}
|
||||
|
||||
primaryPartIds.add(subPartId);
|
||||
|
||||
rows.push({
|
||||
part_detail: item.sub_part_detail,
|
||||
primary: item,
|
||||
secondary: null,
|
||||
match: false,
|
||||
deltas: getBomDeltas(item, null), // Initialize deltas with all fields (since no match yet)
|
||||
key: subPartId
|
||||
});
|
||||
});
|
||||
|
||||
// Next, iterate through the "secondary" BOM to find matches and update the data
|
||||
secondaryBom.data?.forEach((item: any) => {
|
||||
let subPartId = `${item.sub_part}`;
|
||||
|
||||
while (secondaryPartIds.has(subPartId)) {
|
||||
subPartId += '_dup';
|
||||
}
|
||||
|
||||
secondaryPartIds.add(subPartId);
|
||||
|
||||
// Try to find a matching part in the primary BOM
|
||||
const match = rows.find((row) => row.key == subPartId);
|
||||
|
||||
if (match) {
|
||||
// If a match is found, update the existing row
|
||||
match.secondary = item;
|
||||
match.match = true; // Mark as a match
|
||||
match.deltas = getBomDeltas(match.primary, match.secondary); // Update deltas with actual differences
|
||||
} else {
|
||||
// If no match is found, add a new row for the secondary item
|
||||
rows.push({
|
||||
part_detail: item.sub_part_detail,
|
||||
primary: null,
|
||||
secondary: item,
|
||||
match: false,
|
||||
deltas: getBomDeltas(null, item),
|
||||
key: subPartId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
switch (displayMode) {
|
||||
case 'different':
|
||||
// Show only *different* parts
|
||||
rows = rows.filter((row) => !row.match);
|
||||
break;
|
||||
case 'common':
|
||||
// Show only *common* parts
|
||||
rows = rows.filter((row) => row.match);
|
||||
break;
|
||||
default:
|
||||
case 'all':
|
||||
break;
|
||||
}
|
||||
|
||||
// Return rows, sorted by part name
|
||||
return rows.sort((a, b) => {
|
||||
const nameA = a.part_detail?.name ?? '';
|
||||
const nameB = b.part_detail?.name ?? '';
|
||||
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
}, [displayMode, primaryBom.data, secondaryBom.data]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={onClosed}
|
||||
withCloseButton
|
||||
position='bottom'
|
||||
size='80%'
|
||||
title={
|
||||
<StylishText size='lg'>{t`Compare Bill of Materials`}</StylishText>
|
||||
}
|
||||
>
|
||||
<Stack gap='xs'>
|
||||
<Divider />
|
||||
<Paper p='xs' withBorder>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<Stack gap='xs' justify='flex-start' align='stretch'>
|
||||
<Text size='sm'>{t`Primary Assembly`}</Text>
|
||||
<Text
|
||||
size='xs'
|
||||
c='dimmed'
|
||||
>{t`Primary assembly for comparison`}</Text>
|
||||
<RenderPartColumn part={partInstance} />
|
||||
</Stack>
|
||||
<Expand>
|
||||
<StandaloneField
|
||||
fieldName='assembly'
|
||||
fieldDefinition={{
|
||||
description: t`Select assembly to compare`,
|
||||
label: t`Secondary Assembly`,
|
||||
field_type: 'related field',
|
||||
value: secondaryPartId,
|
||||
api_url: apiUrl(ApiEndpoints.part_list),
|
||||
model: ModelType.part,
|
||||
required: true,
|
||||
filters: {
|
||||
assembly: true
|
||||
},
|
||||
onValueChange: (value) => {
|
||||
setSecondaryPartId(value);
|
||||
if (opened) {
|
||||
setSearchParams(
|
||||
{
|
||||
compare: value
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Expand>
|
||||
<Select
|
||||
label={t`Display Mode`}
|
||||
aria-label='bom-compare-display-mode'
|
||||
description={t`Select display mode for BOM comparison`}
|
||||
defaultValue={'all'}
|
||||
onChange={(value) => setDisplayMode(value as any)}
|
||||
data={[
|
||||
{ value: 'all', label: t`Show all Parts` },
|
||||
{ value: 'different', label: t`Show different Parts` },
|
||||
{ value: 'common', label: t`Show common Parts` }
|
||||
]}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
{secondaryPartId ? (
|
||||
<BomTable items={comparedItems} />
|
||||
) : (
|
||||
<Alert color='yellow'>{t`Select an assembly to view Bill of Materials comparison`}</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -268,6 +268,37 @@ test('Parts - BOM Validation', async ({ browser }) => {
|
||||
await page.getByText('Validated By: allaccessAlly').waitFor();
|
||||
});
|
||||
|
||||
test('Parts - BOM Comparison', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'part/104/bom' });
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-compare-bill-of' })
|
||||
.click();
|
||||
await page.getByText('Select an assembly to view').waitFor();
|
||||
await page
|
||||
.getByRole('combobox', { name: 'related-field-assembly' })
|
||||
.fill('blue round table');
|
||||
await page.getByText('Blue Round TableA round table').click();
|
||||
|
||||
await page.getByRole('columnheader', { name: 'Quantity' }).first().waitFor();
|
||||
await page.getByRole('columnheader', { name: 'Changes' }).first().waitFor();
|
||||
|
||||
await page.getByText('No changes').first().waitFor();
|
||||
await page.getByText('Part added to BOM').first().waitFor();
|
||||
await page.getByText('Removed from BOM').first().waitFor();
|
||||
|
||||
// Change display mode
|
||||
await page.getByRole('textbox', { name: 'bom-compare-display-mode' }).click();
|
||||
await page.getByRole('option', { name: 'Show different Parts' }).click();
|
||||
|
||||
// Use URL params to compare directly
|
||||
await navigate(page, 'part/108/bom?compare=107');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByText('0.125').nth(1).waitFor();
|
||||
await page.getByText('Red Paint', { exact: true }).first().waitFor();
|
||||
await page.getByText('Blue Paint', { exact: true }).first().waitFor();
|
||||
});
|
||||
|
||||
test('Parts - Editing', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'part/104/details' });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user