574 lines
15 KiB
TypeScript
574 lines
15 KiB
TypeScript
import { t } from '@lingui/macro';
|
|
import { Alert, Group, Stack, Text } from '@mantine/core';
|
|
import { showNotification } from '@mantine/notifications';
|
|
import {
|
|
IconArrowRight,
|
|
IconCircleCheck,
|
|
IconFileArrowLeft,
|
|
IconLock,
|
|
IconSwitch3
|
|
} from '@tabler/icons-react';
|
|
import { ReactNode, useCallback, useMemo, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
import { api } from '../../App';
|
|
import { ActionButton } from '../../components/buttons/ActionButton';
|
|
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
|
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
|
import { Thumbnail } from '../../components/images/Thumbnail';
|
|
import ImporterDrawer from '../../components/importer/ImporterDrawer';
|
|
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
|
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
|
import { ModelType } from '../../enums/ModelType';
|
|
import { UserRoles } from '../../enums/Roles';
|
|
import { bomItemFields } from '../../forms/BomForms';
|
|
import { dataImporterSessionFields } from '../../forms/ImporterForms';
|
|
import {
|
|
useApiFormModal,
|
|
useCreateApiFormModal,
|
|
useDeleteApiFormModal,
|
|
useEditApiFormModal
|
|
} from '../../hooks/UseForm';
|
|
import { useTable } from '../../hooks/UseTable';
|
|
import { apiUrl } from '../../states/ApiState';
|
|
import { useUserState } from '../../states/UserState';
|
|
import { TableColumn } from '../Column';
|
|
import {
|
|
BooleanColumn,
|
|
DescriptionColumn,
|
|
NoteColumn,
|
|
ReferenceColumn
|
|
} from '../ColumnRenderers';
|
|
import { TableFilter } from '../Filter';
|
|
import { InvenTreeTable } from '../InvenTreeTable';
|
|
import { RowDeleteAction, RowEditAction } from '../RowActions';
|
|
import { TableHoverCard } from '../TableHoverCard';
|
|
|
|
// Calculate the total stock quantity available for a given BomItem
|
|
function availableStockQuantity(record: any): number {
|
|
// Base availability
|
|
let available: number = record.available_stock;
|
|
|
|
// Add in available substitute stock
|
|
available += record?.available_substitute_stock ?? 0;
|
|
|
|
// Add in variant stock
|
|
if (record.allow_variants) {
|
|
available += record?.available_variant_stock ?? 0;
|
|
}
|
|
|
|
return available;
|
|
}
|
|
|
|
export function BomTable({
|
|
partId,
|
|
partLocked,
|
|
params = {}
|
|
}: {
|
|
partId: number;
|
|
partLocked?: boolean;
|
|
params?: any;
|
|
}) {
|
|
const user = useUserState();
|
|
const table = useTable('bom');
|
|
const navigate = useNavigate();
|
|
|
|
const [importOpened, setImportOpened] = useState<boolean>(false);
|
|
|
|
const [selectedSession, setSelectedSession] = useState<number | undefined>(
|
|
undefined
|
|
);
|
|
|
|
const tableColumns: TableColumn[] = useMemo(() => {
|
|
return [
|
|
{
|
|
accessor: 'part',
|
|
switchable: false,
|
|
sortable: true,
|
|
render: (record) => {
|
|
let part = record.sub_part_detail;
|
|
let extra = [];
|
|
|
|
if (record.part != partId) {
|
|
extra.push(
|
|
<Text key="different-parent">{t`This BOM item is defined for a different parent`}</Text>
|
|
);
|
|
}
|
|
|
|
return (
|
|
part && (
|
|
<TableHoverCard
|
|
value={
|
|
<Thumbnail
|
|
src={part.thumbnail || part.image}
|
|
alt={part.description}
|
|
text={part.full_name}
|
|
/>
|
|
}
|
|
extra={extra}
|
|
title={t`Part Information`}
|
|
/>
|
|
)
|
|
);
|
|
}
|
|
},
|
|
{
|
|
accessor: 'sub_part_detail.IPN',
|
|
title: t`IPN`,
|
|
sortable: true
|
|
},
|
|
DescriptionColumn({
|
|
accessor: 'sub_part_detail.description'
|
|
}),
|
|
ReferenceColumn({
|
|
switchable: true
|
|
}),
|
|
{
|
|
accessor: 'quantity',
|
|
switchable: false,
|
|
sortable: true,
|
|
render: (record: any) => {
|
|
let quantity = formatDecimal(record.quantity);
|
|
let units = record.sub_part_detail?.units;
|
|
|
|
return (
|
|
<Group justify="space-between" grow>
|
|
<Text>{quantity}</Text>
|
|
{record.overage && <Text size="xs">+{record.overage}</Text>}
|
|
{units && <Text size="xs">{units}</Text>}
|
|
</Group>
|
|
);
|
|
}
|
|
},
|
|
{
|
|
accessor: 'substitutes',
|
|
// TODO: Show hovercard with list of substitutes
|
|
render: (row) => {
|
|
let substitutes = row.substitutes ?? [];
|
|
|
|
return substitutes.length > 0 ? (
|
|
row.length
|
|
) : (
|
|
<YesNoButton value={false} />
|
|
);
|
|
}
|
|
},
|
|
BooleanColumn({
|
|
accessor: 'optional'
|
|
}),
|
|
BooleanColumn({
|
|
accessor: 'consumable'
|
|
}),
|
|
BooleanColumn({
|
|
accessor: 'allow_variants'
|
|
}),
|
|
BooleanColumn({
|
|
accessor: 'inherited'
|
|
// TODO: Custom renderer for this column
|
|
// TODO: See bom.js for existing implementation
|
|
}),
|
|
BooleanColumn({
|
|
accessor: 'validated'
|
|
}),
|
|
{
|
|
accessor: 'price_range',
|
|
title: t`Unit Price`,
|
|
ordering: 'pricing_max',
|
|
sortable: true,
|
|
switchable: true,
|
|
render: (record: any) =>
|
|
formatPriceRange(record.pricing_min, record.pricing_max)
|
|
},
|
|
{
|
|
accessor: 'total_price',
|
|
title: t`Total Price`,
|
|
ordering: 'pricing_max_total',
|
|
sortable: true,
|
|
switchable: true,
|
|
render: (record: any) =>
|
|
formatPriceRange(record.pricing_min_total, record.pricing_max_total)
|
|
},
|
|
{
|
|
accessor: 'available_stock',
|
|
sortable: true,
|
|
render: (record) => {
|
|
let extra: ReactNode[] = [];
|
|
|
|
let available_stock: number = availableStockQuantity(record);
|
|
let on_order: number = record?.on_order ?? 0;
|
|
let building: number = record?.building ?? 0;
|
|
|
|
let text =
|
|
available_stock <= 0 ? (
|
|
<Text c="red" style={{ fontStyle: 'italic' }}>{t`No stock`}</Text>
|
|
) : (
|
|
available_stock
|
|
);
|
|
|
|
if (record.external_stock > 0) {
|
|
extra.push(
|
|
<Text key="external">
|
|
{t`External stock`}: {record.external_stock}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (record.available_substitute_stock > 0) {
|
|
extra.push(
|
|
<Text key="substitute">
|
|
{t`Includes substitute stock`}:{' '}
|
|
{record.available_substitute_stock}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (record.allow_variants && record.available_variant_stock > 0) {
|
|
extra.push(
|
|
<Text key="variant">
|
|
{t`Includes variant stock`}: {record.available_variant_stock}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (on_order > 0) {
|
|
extra.push(
|
|
<Text key="on_order">
|
|
{t`On order`}: {on_order}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (building > 0) {
|
|
extra.push(
|
|
<Text key="building">
|
|
{t`Building`}: {building}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<TableHoverCard
|
|
value={text}
|
|
extra={extra}
|
|
title={t`Stock Information`}
|
|
/>
|
|
);
|
|
}
|
|
},
|
|
{
|
|
accessor: 'can_build',
|
|
title: t`Can Build`,
|
|
sortable: true,
|
|
render: (record: any) => {
|
|
if (record.can_build === null || record.can_build === undefined) {
|
|
return '-';
|
|
}
|
|
|
|
if (!isFinite(record.can_build) || isNaN(record.can_build)) {
|
|
return '-';
|
|
}
|
|
|
|
let can_build = Math.trunc(record.can_build);
|
|
let value = (
|
|
<Text
|
|
fs={record.consumable && 'italic'}
|
|
c={can_build <= 0 && !record.consumable ? 'red' : undefined}
|
|
>
|
|
{can_build}
|
|
</Text>
|
|
);
|
|
|
|
let extra = [];
|
|
|
|
if (record.consumable) {
|
|
extra.push(<Text key="consumable">{t`Consumable item`}</Text>);
|
|
} else if (can_build <= 0) {
|
|
extra.push(
|
|
<Text key="no-build" c="red">{t`No available stock`}</Text>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<TableHoverCard value={value} extra={extra} title={t`Can Build`} />
|
|
);
|
|
}
|
|
},
|
|
NoteColumn({})
|
|
];
|
|
}, [partId, params]);
|
|
|
|
const tableFilters: TableFilter[] = useMemo(() => {
|
|
return [
|
|
{
|
|
name: 'sub_part_trackable',
|
|
label: t`Trackable Part`,
|
|
description: t`Show trackable items`
|
|
},
|
|
{
|
|
name: 'sub_part_assembly',
|
|
label: t`Assembled Part`,
|
|
description: t`Show asssmbled items`
|
|
},
|
|
{
|
|
name: 'available_stock',
|
|
label: t`Available Stock`,
|
|
description: t`Show items with available stock`
|
|
},
|
|
{
|
|
name: 'on_order',
|
|
label: t`On Order`,
|
|
description: t`Show items on order`
|
|
},
|
|
{
|
|
name: 'validated',
|
|
label: t`Validated`,
|
|
description: t`Show validated items`
|
|
},
|
|
{
|
|
name: 'inherited',
|
|
label: t`Inherited`,
|
|
description: t`Show inherited items`
|
|
},
|
|
{
|
|
name: 'allow_variants',
|
|
label: t`Allow Variants`,
|
|
description: t`Show items which allow variant substitution`
|
|
},
|
|
{
|
|
name: 'optional',
|
|
label: t`Optional`,
|
|
description: t`Show optional items`
|
|
},
|
|
{
|
|
name: 'consumable',
|
|
label: t`Consumable`,
|
|
description: t`Show consumable items`
|
|
},
|
|
{
|
|
name: 'has_pricing',
|
|
label: t`Has Pricing`,
|
|
description: t`Show items with pricing`
|
|
}
|
|
];
|
|
}, [partId, params]);
|
|
|
|
const [selectedBomItem, setSelectedBomItem] = useState<number>(0);
|
|
|
|
const importSessionFields = useMemo(() => {
|
|
let fields = dataImporterSessionFields();
|
|
|
|
fields.model_type.hidden = true;
|
|
fields.model_type.value = 'bomitem';
|
|
|
|
fields.field_overrides.value = {
|
|
part: partId
|
|
};
|
|
|
|
return fields;
|
|
}, [partId]);
|
|
|
|
const importBomItem = useCreateApiFormModal({
|
|
url: ApiEndpoints.import_session_list,
|
|
title: t`Import BOM Data`,
|
|
fields: importSessionFields,
|
|
onFormSuccess: (response: any) => {
|
|
setSelectedSession(response.pk);
|
|
setImportOpened(true);
|
|
}
|
|
});
|
|
|
|
const newBomItem = useCreateApiFormModal({
|
|
url: ApiEndpoints.bom_list,
|
|
title: t`Add BOM Item`,
|
|
fields: bomItemFields(),
|
|
initialData: {
|
|
part: partId
|
|
},
|
|
successMessage: t`BOM item created`,
|
|
table: table
|
|
});
|
|
|
|
const editBomItem = useEditApiFormModal({
|
|
url: ApiEndpoints.bom_list,
|
|
pk: selectedBomItem,
|
|
title: t`Edit BOM Item`,
|
|
fields: bomItemFields(),
|
|
successMessage: t`BOM item updated`,
|
|
table: table
|
|
});
|
|
|
|
const deleteBomItem = useDeleteApiFormModal({
|
|
url: ApiEndpoints.bom_list,
|
|
pk: selectedBomItem,
|
|
title: t`Delete BOM Item`,
|
|
successMessage: t`BOM item deleted`,
|
|
table: table
|
|
});
|
|
|
|
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: t`BOM validated`,
|
|
onFormSuccess: () => table.refreshTable()
|
|
});
|
|
|
|
const validateBomItem = useCallback((record: any) => {
|
|
const url = apiUrl(ApiEndpoints.bom_item_validate, record.pk);
|
|
|
|
api
|
|
.patch(url, { valid: true })
|
|
.then((_response) => {
|
|
showNotification({
|
|
title: t`Success`,
|
|
message: t`BOM item validated`,
|
|
color: 'green'
|
|
});
|
|
|
|
table.refreshTable();
|
|
})
|
|
.catch((_error) => {
|
|
showNotification({
|
|
title: t`Error`,
|
|
message: t`Failed to validate BOM item`,
|
|
color: 'red'
|
|
});
|
|
});
|
|
}, []);
|
|
|
|
const rowActions = useCallback(
|
|
(record: any) => {
|
|
// If this BOM item is defined for a *different* parent, then it cannot be edited
|
|
if (record.part && record.part != partId) {
|
|
return [
|
|
{
|
|
title: t`View BOM`,
|
|
onClick: () => navigate(`/part/${record.part}/`),
|
|
icon: <IconArrowRight />
|
|
}
|
|
];
|
|
}
|
|
|
|
return [
|
|
{
|
|
title: t`Validate BOM Line`,
|
|
color: 'green',
|
|
hidden:
|
|
partLocked ||
|
|
record.validated ||
|
|
!user.hasChangeRole(UserRoles.part),
|
|
icon: <IconCircleCheck />,
|
|
onClick: () => validateBomItem(record)
|
|
},
|
|
RowEditAction({
|
|
hidden: partLocked || !user.hasChangeRole(UserRoles.part),
|
|
onClick: () => {
|
|
setSelectedBomItem(record.pk);
|
|
editBomItem.open();
|
|
}
|
|
}),
|
|
{
|
|
title: t`Edit Substitutes`,
|
|
color: 'blue',
|
|
hidden: partLocked || !user.hasChangeRole(UserRoles.part),
|
|
icon: <IconSwitch3 />
|
|
},
|
|
RowDeleteAction({
|
|
hidden: partLocked || !user.hasDeleteRole(UserRoles.part),
|
|
onClick: () => {
|
|
setSelectedBomItem(record.pk);
|
|
deleteBomItem.open();
|
|
}
|
|
})
|
|
];
|
|
},
|
|
[partId, partLocked, user]
|
|
);
|
|
|
|
const tableActions = useMemo(() => {
|
|
return [
|
|
<ActionButton
|
|
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
|
|
tooltip={t`Import BOM Data`}
|
|
icon={<IconFileArrowLeft />}
|
|
onClick={() => importBomItem.open()}
|
|
/>,
|
|
<ActionButton
|
|
hidden={partLocked || !user.hasChangeRole(UserRoles.part)}
|
|
tooltip={t`Validate BOM`}
|
|
icon={<IconCircleCheck />}
|
|
onClick={() => validateBom.open()}
|
|
/>,
|
|
<AddItemButton
|
|
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
|
|
tooltip={t`Add BOM Item`}
|
|
onClick={() => newBomItem.open()}
|
|
/>
|
|
];
|
|
}, [partLocked, user]);
|
|
|
|
return (
|
|
<>
|
|
{importBomItem.modal}
|
|
{newBomItem.modal}
|
|
{editBomItem.modal}
|
|
{validateBom.modal}
|
|
{deleteBomItem.modal}
|
|
<Stack gap="xs">
|
|
{partLocked && (
|
|
<Alert
|
|
title={t`Part is Locked`}
|
|
color="orange"
|
|
icon={<IconLock />}
|
|
p="xs"
|
|
>
|
|
<Text>{t`Bill of materials cannot be edited, as the part is locked`}</Text>
|
|
</Alert>
|
|
)}
|
|
<InvenTreeTable
|
|
url={apiUrl(ApiEndpoints.bom_list)}
|
|
tableState={table}
|
|
columns={tableColumns}
|
|
props={{
|
|
params: {
|
|
...params,
|
|
part: partId,
|
|
part_detail: true,
|
|
sub_part_detail: true
|
|
},
|
|
tableActions: tableActions,
|
|
tableFilters: tableFilters,
|
|
modelType: ModelType.part,
|
|
modelField: 'sub_part',
|
|
rowActions: rowActions,
|
|
enableSelection: !partLocked,
|
|
enableBulkDelete: !partLocked,
|
|
enableDownload: true
|
|
}}
|
|
/>
|
|
</Stack>
|
|
<ImporterDrawer
|
|
sessionId={selectedSession ?? -1}
|
|
opened={selectedSession != undefined && importOpened}
|
|
onClose={() => {
|
|
setSelectedSession(undefined);
|
|
setImportOpened(false);
|
|
table.refreshTable();
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|