mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-06 17:53:44 +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:
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- [#11853](https://github.com/inventree/InvenTree/pull/11853) adds BOM comparison functionality, allowing users to compare the BOM of one assembly with another assembly.
|
||||||
- [#11809](https://github.com/inventree/InvenTree/pull/11809) adds multi-level subassembly display mode to the BOM table, allowing users to view multiple levels of subassemblies in a single table view. This is an optional display mode which can be toggled on or off by the user.
|
- [#11809](https://github.com/inventree/InvenTree/pull/11809) adds multi-level subassembly display mode to the BOM table, allowing users to view multiple levels of subassemblies in a single table view. This is an optional display mode which can be toggled on or off by the user.
|
||||||
- [#11778](https://github.com/inventree/InvenTree/pull/11778) adds inline supplier part creation to po line item addition dialog.
|
- [#11778](https://github.com/inventree/InvenTree/pull/11778) adds inline supplier part creation to po line item addition dialog.
|
||||||
- [#11772](https://github.com/inventree/InvenTree/pull/11772) the UI now warns if you navigate away from a note panel with unsaved changes
|
- [#11772](https://github.com/inventree/InvenTree/pull/11772) the UI now warns if you navigate away from a note panel with unsaved changes
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
@@ -4,7 +4,7 @@ title: Bill of Materials
|
|||||||
|
|
||||||
## Bill of Materials
|
## Bill of Materials
|
||||||
|
|
||||||
A Bill of Materials (BOM) defines the list of component parts required to make an assembly, [create builds](./build.md) and allocate inventory.
|
A Bill of Materials (BOM) defines the list of component parts required to make an assembly, [create build orders](./build.md) and allocate inventory.
|
||||||
|
|
||||||
A part which can be built from other sub components is called an *Assembly*.
|
A part which can be built from other sub components is called an *Assembly*.
|
||||||
|
|
||||||
@@ -169,89 +169,26 @@ If the BOM requires revalidation, the status will be displayed as "Not Validated
|
|||||||
|
|
||||||
{{ image("build/bom_invalid.png", "BOM Not Validated") }}
|
{{ image("build/bom_invalid.png", "BOM Not Validated") }}
|
||||||
|
|
||||||
## Required Quantity Calculation
|
## BOM Comparison
|
||||||
|
|
||||||
When a new [Build Order](./build.md) is created, the required production quantity of each component part is calculated based on the BOM line items defined for the assembly being built. To calculate the required production quantity of a component part, the following considerations are made:
|
It is possible to compare the BOM of one assembly with another assembly. This comparison can highlight different component parts, quantities and other properties of the BOM line items.
|
||||||
|
|
||||||
### Base Quantity
|
To compare the BOM of one assembly with another, navigate to the "Bill of Materials" tab of the part detail page, then click on the {{ icon("git-compare", color="blue", title="Compare BOM") }} icon at the top of the BOM table:
|
||||||
|
|
||||||
The base quantity of a BOM line item is defined by the `Quantity` field of the BOM line item. This is the number of parts which are required to build one assembly. This value is multiplied by the number of assemblies which are being built to determine the total quantity of parts required.
|
{{ image("build/bom_compare_icon.png", "BOM Compare") }}
|
||||||
|
|
||||||
```
|
This will open the BOM comparison view, which allows you to select a secondary assembly to compare with the primary assembly. The BOM line items of the two assemblies will be displayed side by side, with differences highlighted:
|
||||||
Required Quantity = Base Quantity * Number of Assemblies
|
|
||||||
```
|
|
||||||
|
|
||||||
### Attrition
|
{{ image("build/bom_compare.png", "BOM Compare") }}
|
||||||
|
|
||||||
The `Attrition` field of a BOM line item is used to account for expected losses during the production process. This is expressed as a percentage of the `Base Quantity` (e.g. 2%).
|
### Display Mode
|
||||||
|
|
||||||
If a non-zero attrition percentage is specified, it is applied to the calculated `Required Quantity` value.
|
When comparing BOMs from two different assemblies, the user can select from the following view modes:
|
||||||
|
|
||||||
```
|
| View Mode | Description |
|
||||||
Required Quantity = Required Quantity * (1 + Attrition Percentage)
|
| --- | --- |
|
||||||
```
|
| *Show all parts* | Display all BOM line items from both assemblies. Differences are highlighted. |
|
||||||
|
| *Show different parts* | Display only the BOM line items which are different between the two assemblies. |
|
||||||
|
| *Show common parts* | Display only the BOM line items which are common between the two assemblies. |
|
||||||
|
|
||||||
!!! info "Optional"
|
In each case, any differences between the BOM line items are highlighted in red.
|
||||||
The attrition percentage is optional. If not specified, it defaults to 0%.
|
|
||||||
|
|
||||||
### Setup Quantity
|
|
||||||
|
|
||||||
The `Setup Quantity` field of a BOM line item is used to account for fixed losses during the production process. This is an additional quantity of the part which is required to ensure that the production run can be completed successfully. This value is added to the calculated `Required Quantity`.
|
|
||||||
|
|
||||||
```
|
|
||||||
Required Quantity = Required Quantity + Setup Quantity
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! info "Optional"
|
|
||||||
The setup quantity is optional. If not specified, it defaults to 0.
|
|
||||||
|
|
||||||
### Rounding Multiple
|
|
||||||
|
|
||||||
The `Rounding Multiple` field of a BOM line item is used to round the calculated `Required Quantity` value to the nearest multiple of the specified value. This is useful for ensuring that the required quantity is a whole number, or to meet specific packaging requirements.
|
|
||||||
|
|
||||||
```
|
|
||||||
Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multiple
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! info "Optional"
|
|
||||||
The rounding multiple is optional. If not specified, no rounding is applied to the calculated production quantity.
|
|
||||||
|
|
||||||
### Example Calculation
|
|
||||||
|
|
||||||
Consider a BOM line item with the following properties:
|
|
||||||
|
|
||||||
- Base Quantity: 3
|
|
||||||
- Attrition: 2% (0.02)
|
|
||||||
- Setup Quantity: 10
|
|
||||||
- Rounding Multiple: 25
|
|
||||||
|
|
||||||
If we are building 100 assemblies, the required quantity would be calculated as follows:
|
|
||||||
|
|
||||||
```
|
|
||||||
Required Quantity = Base Quantity * Number of Assemblies
|
|
||||||
= 3 * 100
|
|
||||||
= 300
|
|
||||||
|
|
||||||
Attrition Value = Required Quantity * Attrition Percentage
|
|
||||||
= 300 * 0.02
|
|
||||||
= 6
|
|
||||||
|
|
||||||
Required Quantity = Required Quantity + Attrition Value
|
|
||||||
= 300 + 6
|
|
||||||
= 306
|
|
||||||
|
|
||||||
Required Quantity = Required Quantity + Setup Quantity
|
|
||||||
= 306 + 10
|
|
||||||
= 316
|
|
||||||
|
|
||||||
Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multiple
|
|
||||||
= ceil(316 / 25) * 25
|
|
||||||
= 13 * 25
|
|
||||||
= 325
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
So the final required production quantity of the component part would be `325`.
|
|
||||||
|
|
||||||
!!! info "Calculation"
|
|
||||||
The required quantity calculation is performed automatically when a new [Build Order](./build.md) is created.
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
title: Required Build Quantity
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Build Quantity
|
||||||
|
|
||||||
|
When a new [Build Order](./build.md) is created, the required production quantity of each component part is calculated based on the BOM line items defined for the assembly being built. To calculate the required production quantity of a component part, the following considerations are made:
|
||||||
|
|
||||||
|
### Base Quantity
|
||||||
|
|
||||||
|
The base quantity of a BOM line item is defined by the `Quantity` field of the BOM line item. This is the number of parts which are required to build one assembly. This value is multiplied by the number of assemblies which are being built to determine the total quantity of parts required.
|
||||||
|
|
||||||
|
```
|
||||||
|
Required Quantity = Base Quantity * Number of Assemblies
|
||||||
|
```
|
||||||
|
|
||||||
|
### Attrition
|
||||||
|
|
||||||
|
The `Attrition` field of a BOM line item is used to account for expected losses during the production process. This is expressed as a percentage of the `Base Quantity` (e.g. 2%).
|
||||||
|
|
||||||
|
If a non-zero attrition percentage is specified, it is applied to the calculated `Required Quantity` value.
|
||||||
|
|
||||||
|
```
|
||||||
|
Required Quantity = Required Quantity * (1 + Attrition Percentage)
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! info "Optional"
|
||||||
|
The attrition percentage is optional. If not specified, it defaults to 0%.
|
||||||
|
|
||||||
|
### Setup Quantity
|
||||||
|
|
||||||
|
The `Setup Quantity` field of a BOM line item is used to account for fixed losses during the production process. This is an additional quantity of the part which is required to ensure that the production run can be completed successfully. This value is added to the calculated `Required Quantity`.
|
||||||
|
|
||||||
|
```
|
||||||
|
Required Quantity = Required Quantity + Setup Quantity
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! info "Optional"
|
||||||
|
The setup quantity is optional. If not specified, it defaults to 0.
|
||||||
|
|
||||||
|
### Rounding Multiple
|
||||||
|
|
||||||
|
The `Rounding Multiple` field of a BOM line item is used to round the calculated `Required Quantity` value to the nearest multiple of the specified value. This is useful for ensuring that the required quantity is a whole number, or to meet specific packaging requirements.
|
||||||
|
|
||||||
|
```
|
||||||
|
Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multiple
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! info "Optional"
|
||||||
|
The rounding multiple is optional. If not specified, no rounding is applied to the calculated production quantity.
|
||||||
|
|
||||||
|
### Example Calculation
|
||||||
|
|
||||||
|
Consider a BOM line item with the following properties:
|
||||||
|
|
||||||
|
- Base Quantity: 3
|
||||||
|
- Attrition: 2% (0.02)
|
||||||
|
- Setup Quantity: 10
|
||||||
|
- Rounding Multiple: 25
|
||||||
|
|
||||||
|
If we are building 100 assemblies, the required quantity would be calculated as follows:
|
||||||
|
|
||||||
|
```
|
||||||
|
Required Quantity = Base Quantity * Number of Assemblies
|
||||||
|
= 3 * 100
|
||||||
|
= 300
|
||||||
|
|
||||||
|
Attrition Value = Required Quantity * Attrition Percentage
|
||||||
|
= 300 * 0.02
|
||||||
|
= 6
|
||||||
|
|
||||||
|
Required Quantity = Required Quantity + Attrition Value
|
||||||
|
= 300 + 6
|
||||||
|
= 306
|
||||||
|
|
||||||
|
Required Quantity = Required Quantity + Setup Quantity
|
||||||
|
= 306 + 10
|
||||||
|
= 316
|
||||||
|
|
||||||
|
Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multiple
|
||||||
|
= ceil(316 / 25) * 25
|
||||||
|
= 13 * 25
|
||||||
|
= 325
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
So the final required production quantity of the component part would be `325`.
|
||||||
|
|
||||||
|
!!! info "Calculation"
|
||||||
|
The required quantity calculation is performed automatically when a new [Build Order](./build.md) is created.
|
||||||
@@ -150,6 +150,7 @@ nav:
|
|||||||
- Bill of Materials: manufacturing/bom.md
|
- Bill of Materials: manufacturing/bom.md
|
||||||
- Build Orders: manufacturing/build.md
|
- Build Orders: manufacturing/build.md
|
||||||
- Build Outputs: manufacturing/output.md
|
- Build Outputs: manufacturing/output.md
|
||||||
|
- Required Quantity: manufacturing/required.md
|
||||||
- Allocating Stock: manufacturing/allocate.md
|
- Allocating Stock: manufacturing/allocate.md
|
||||||
- External Manufacturing: manufacturing/external.md
|
- External Manufacturing: manufacturing/external.md
|
||||||
- Example Build Order: manufacturing/example.md
|
- Example Build Order: manufacturing/example.md
|
||||||
|
|||||||
@@ -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
|
- 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
|
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
|
- 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
|
- GET /api/notifications/ - now uses user-centric permissions, not a general read
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export function ApiImage(props: Readonly<ApiImageProps>) {
|
|||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
fit='contain'
|
fit='contain'
|
||||||
style={{
|
style={{
|
||||||
|
...props.style,
|
||||||
opacity: isLoaded ? 1 : 0,
|
opacity: isLoaded ? 1 : 0,
|
||||||
transition: 'opacity 0.2s ease'
|
transition: 'opacity 0.2s ease'
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function Thumbnail({
|
|||||||
w={size}
|
w={size}
|
||||||
fit='contain'
|
fit='contain'
|
||||||
radius='xs'
|
radius='xs'
|
||||||
style={{ maxHeight: size }}
|
style={{ maxHeight: size, height: size }}
|
||||||
/>
|
/>
|
||||||
{inner}
|
{inner}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import {
|
|||||||
Center,
|
Center,
|
||||||
Grid,
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
HoverCard,
|
|
||||||
Loader,
|
Loader,
|
||||||
type MantineColor,
|
|
||||||
Paper,
|
Paper,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -17,13 +15,11 @@ import {
|
|||||||
IconBookmarks,
|
IconBookmarks,
|
||||||
IconBuilding,
|
IconBuilding,
|
||||||
IconChecklist,
|
IconChecklist,
|
||||||
IconCircleCheck,
|
|
||||||
IconClipboardList,
|
IconClipboardList,
|
||||||
IconCurrencyDollar,
|
IconCurrencyDollar,
|
||||||
IconExclamationCircle,
|
IconExclamationCircle,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
IconLayersLinked,
|
IconLayersLinked,
|
||||||
IconListCheck,
|
|
||||||
IconListDetails,
|
IconListDetails,
|
||||||
IconListTree,
|
IconListTree,
|
||||||
IconLock,
|
IconLock,
|
||||||
@@ -47,7 +43,6 @@ import { ModelType } from '@lib/enums/ModelType';
|
|||||||
import { UserRoles } from '@lib/enums/Roles';
|
import { UserRoles } from '@lib/enums/Roles';
|
||||||
import { apiUrl } from '@lib/functions/Api';
|
import { apiUrl } from '@lib/functions/Api';
|
||||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||||
import { ActionButton } from '@lib/index';
|
|
||||||
import type { StockOperationProps } from '@lib/types/Forms';
|
import type { StockOperationProps } from '@lib/types/Forms';
|
||||||
import AdminButton from '../../components/buttons/AdminButton';
|
import AdminButton from '../../components/buttons/AdminButton';
|
||||||
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||||
@@ -76,20 +71,17 @@ import NotesPanel from '../../components/panels/NotesPanel';
|
|||||||
import type { PanelType } from '../../components/panels/Panel';
|
import type { PanelType } from '../../components/panels/Panel';
|
||||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||||
import { RenderPart } from '../../components/render/Part';
|
import { RenderPart } from '../../components/render/Part';
|
||||||
import { RenderUser } from '../../components/render/User';
|
|
||||||
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
||||||
import { useApi } from '../../contexts/ApiContext';
|
import { useApi } from '../../contexts/ApiContext';
|
||||||
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
|
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
|
||||||
import { usePartFields } from '../../forms/PartForms';
|
import { usePartFields } from '../../forms/PartForms';
|
||||||
import { useFindSerialNumberForm } from '../../forms/StockForms';
|
import { useFindSerialNumberForm } from '../../forms/StockForms';
|
||||||
import useBackgroundTask from '../../hooks/UseBackgroundTask';
|
|
||||||
import {
|
import {
|
||||||
useApiFormModal,
|
|
||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
useDeleteApiFormModal,
|
useDeleteApiFormModal,
|
||||||
useEditApiFormModal
|
useEditApiFormModal
|
||||||
} from '../../hooks/UseForm';
|
} from '../../hooks/UseForm';
|
||||||
import { type UseInstanceResult, useInstance } from '../../hooks/UseInstance';
|
import { useInstance } from '../../hooks/UseInstance';
|
||||||
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
|
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
|
||||||
import {
|
import {
|
||||||
useGlobalSettingsState,
|
useGlobalSettingsState,
|
||||||
@@ -112,6 +104,7 @@ import PartAllocationPanel from './PartAllocationPanel';
|
|||||||
import PartPricingPanel from './PartPricingPanel';
|
import PartPricingPanel from './PartPricingPanel';
|
||||||
import PartStockHistoryDetail from './PartStockHistoryDetail';
|
import PartStockHistoryDetail from './PartStockHistoryDetail';
|
||||||
import PartSupplierDetail from './PartSupplierDetail';
|
import PartSupplierDetail from './PartSupplierDetail';
|
||||||
|
import { BomActions } from './bom/BomActions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a part revision selector component
|
* 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
|
* Detail view for a single Part instance
|
||||||
*/
|
*/
|
||||||
@@ -295,6 +162,7 @@ export default function PartDetail() {
|
|||||||
const globalSettings = useGlobalSettingsState();
|
const globalSettings = useGlobalSettingsState();
|
||||||
const userSettings = useUserSettingsState();
|
const userSettings = useUserSettingsState();
|
||||||
|
|
||||||
|
// BOM validation information (used for hover-over info on the BOM tab)
|
||||||
const bomInformation = useInstance({
|
const bomInformation = useInstance({
|
||||||
endpoint: ApiEndpoints.bom_validate,
|
endpoint: ApiEndpoints.bom_validate,
|
||||||
pk: id,
|
pk: id,
|
||||||
@@ -808,10 +676,7 @@ export default function PartDetail() {
|
|||||||
name: 'bom',
|
name: 'bom',
|
||||||
label: t`Bill of Materials`,
|
label: t`Bill of Materials`,
|
||||||
controls: (
|
controls: (
|
||||||
<BomValidationInformation
|
<BomActions bomInformation={bomInformation} partInstance={part} />
|
||||||
bomInformation={bomInformation}
|
|
||||||
partId={part.pk ?? -1}
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
icon: <IconListTree />,
|
icon: <IconListTree />,
|
||||||
hidden: !part.assembly || !user.hasViewRole(UserRoles.bom),
|
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();
|
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 }) => {
|
test('Parts - Editing', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser, { url: 'part/104/details' });
|
const page = await doCachedLogin(browser, { url: 'part/104/details' });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user