diff --git a/CHANGELOG.md b/CHANGELOG.md index b9861c4e8f..6988a362c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. - [#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 diff --git a/docs/docs/assets/images/build/bom_compare.png b/docs/docs/assets/images/build/bom_compare.png new file mode 100644 index 0000000000..d67f3c416e Binary files /dev/null and b/docs/docs/assets/images/build/bom_compare.png differ diff --git a/docs/docs/assets/images/build/bom_compare_icon.png b/docs/docs/assets/images/build/bom_compare_icon.png new file mode 100644 index 0000000000..e22b4c1b07 Binary files /dev/null and b/docs/docs/assets/images/build/bom_compare_icon.png differ diff --git a/docs/docs/manufacturing/bom.md b/docs/docs/manufacturing/bom.md index bca4bddbab..87093f704e 100644 --- a/docs/docs/manufacturing/bom.md +++ b/docs/docs/manufacturing/bom.md @@ -4,7 +4,7 @@ title: 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*. @@ -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") }} -## 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") }} -``` -Required Quantity = Base Quantity * Number of Assemblies -``` +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: -### 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: -``` -Required Quantity = Required Quantity * (1 + Attrition Percentage) -``` +| View Mode | Description | +| --- | --- | +| *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" - 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. +In each case, any differences between the BOM line items are highlighted in red. diff --git a/docs/docs/manufacturing/required.md b/docs/docs/manufacturing/required.md new file mode 100644 index 0000000000..949277b9ab --- /dev/null +++ b/docs/docs/manufacturing/required.md @@ -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. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index abacabf74d..82bb9cd772 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -150,6 +150,7 @@ nav: - Bill of Materials: manufacturing/bom.md - Build Orders: manufacturing/build.md - Build Outputs: manufacturing/output.md + - Required Quantity: manufacturing/required.md - Allocating Stock: manufacturing/allocate.md - External Manufacturing: manufacturing/external.md - Example Build Order: manufacturing/example.md diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 84dcb6b9b5..8e4a0312df 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/frontend/src/components/images/ApiImage.tsx b/src/frontend/src/components/images/ApiImage.tsx index fcce1c22ec..05cfcc7047 100644 --- a/src/frontend/src/components/images/ApiImage.tsx +++ b/src/frontend/src/components/images/ApiImage.tsx @@ -71,6 +71,7 @@ export function ApiImage(props: Readonly) { src={imageUrl} fit='contain' style={{ + ...props.style, opacity: isLoaded ? 1 : 0, transition: 'opacity 0.2s ease' }} diff --git a/src/frontend/src/components/images/Thumbnail.tsx b/src/frontend/src/components/images/Thumbnail.tsx index 22c86249d8..c24e7671a0 100644 --- a/src/frontend/src/components/images/Thumbnail.tsx +++ b/src/frontend/src/components/images/Thumbnail.tsx @@ -56,7 +56,7 @@ export function Thumbnail({ w={size} fit='contain' radius='xs' - style={{ maxHeight: size }} + style={{ maxHeight: size, height: size }} /> {inner} diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 645b305a52..4c56646f50 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -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(''); - - 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: ( - } title={t`Validate BOM`}> - {t`Do you want to validate the bill of materials for this assembly?`} - - ), - 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 ; - } - - let icon: ReactNode; - let color: MantineColor; - let title = ''; - let description = ''; - - if (bomInformation.instance?.bom_validated) { - color = 'green'; - icon = ; - 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 = ; - 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 = ; - title = t`BOM Not Validated`; - description = t`The Bill of Materials for this part has not yet been validated`; - } - - return ( - <> - {validateBom.modal} - - {!bomInformation.instance?.bom_validated && - user.hasChangeRole(UserRoles.bom) && ( - } - color='green' - tooltip={t`Validate BOM`} - onClick={validateBom.open} - /> - )} - - - - {icon} - - - - - - {description} - {bomInformation.instance?.bom_checked_date && ( - - {t`Validated On`}:{' '} - {bomInformation.instance.bom_checked_date} - - )} - {bomInformation.instance?.bom_checked_by_detail && ( - - {t`Validated By`}: - - - )} - - - - - - - ); -} - /** * 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: ( - + ), icon: , hidden: !part.assembly || !user.hasViewRole(UserRoles.bom), diff --git a/src/frontend/src/pages/part/bom/BomActions.tsx b/src/frontend/src/pages/part/bom/BomActions.tsx new file mode 100644 index 0000000000..764db9f1b8 --- /dev/null +++ b/src/frontend/src/pages/part/bom/BomActions.tsx @@ -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(false); + + const [bomCompareId, setBomCompareId] = useState(''); + + 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(''); + + 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: ( + } title={t`Validate BOM`}> + {t`Do you want to validate the bill of materials for this assembly?`} + + ), + 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 ; + } + + let icon: ReactNode; + let color: MantineColor; + let title = ''; + let description = ''; + + if (bomInformation.instance?.bom_validated) { + color = 'green'; + icon = ; + 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 = ; + 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 = ; + title = t`BOM Not Validated`; + description = t`The Bill of Materials for this part has not yet been validated`; + } + + return ( + <> + {validateBom.modal} + + } + color='blue' + tooltip={t`Compare Bill of Materials`} + onClick={() => setBomCompareOpen(true)} + /> + {!bomInformation.instance?.bom_validated && + user.hasChangeRole(UserRoles.bom) && ( + } + color='green' + tooltip={t`Validate BOM`} + onClick={validateBom.open} + /> + )} + + + + {icon} + + + + + + {description} + {bomInformation.instance?.bom_checked_date && ( + + {t`Validated On`}:{' '} + {bomInformation.instance.bom_checked_date} + + )} + {bomInformation.instance?.bom_checked_by_detail && ( + + {t`Validated By`}: + + + )} + + + + + + { + setBomCompareId(''); + setBomCompareOpen(false); + setSearchParams((params: URLSearchParams) => { + params.delete('compare'); + return params; + }); + }} + /> + + ); +} diff --git a/src/frontend/src/pages/part/bom/BomCompare.tsx b/src/frontend/src/pages/part/bom/BomCompare.tsx new file mode 100644 index 0000000000..be69f0968b --- /dev/null +++ b/src/frontend/src/pages/part/bom/BomCompare.tsx @@ -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 ( + + + + ); + } else { + return ( + + + + ); + } + } else if ( + !!item.deltas?.length || + item.primary?.quantity != item.secondary?.quantity + ) { + // Part exists in both BOMs but has differences + return ( + + + + ); + } else { + return ( + + + + ); + } + }, [item]); + + return ( + + + + {rowIcon} + + + + + + + {item.primary?.quantity ?? item.secondary?.quantity ?? '-'} + + {item.part_detail?.units && ( + [{item.part_detail.units}] + )} + + + + + {rowIcon} + {partMatch && deltas.length > 0 ? ( + + {deltas.map((delta, index) => ( + + {delta.label} + {delta.primaryValue ?? '-'} + + + + {delta.secondaryValue ?? '-'} + + ))} + + ) : ( + + {partMatch + ? t`No changes` + : !!item.primary + ? t`Part removed from BOM` + : t`Part added to BOM`} + + )} + + + + ); +} + +function BomTable({ + items +}: Readonly<{ + items: BomCompareRow[]; +}>) { + return ( + + + + + {t`Part`} + {t`Quantity`} + {t`Changes`} + + + + {items.map((item: any, index) => ( + + ))} + +
+
+ ); +} + +export function BomCompareDrawer({ + opened, + onClosed, + compareId, + partInstance +}: { + opened: boolean; + onClosed: () => void; + compareId?: string; + partInstance: any; +}) { + const [displayMode, setDisplayMode] = useState('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( + 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 ( + {t`Compare Bill of Materials`} + } + > + + + + + + {t`Primary Assembly`} + {t`Primary assembly for comparison`} + + + + { + setSecondaryPartId(value); + if (opened) { + setSearchParams( + { + compare: value + }, + { replace: true } + ); + } + } + }} + /> + +