InvenTree/src/frontend/src/tables/stock/StockItemTable.tsx

667 lines
18 KiB
TypeScript

import { t } from '@lingui/macro';
import { Group, Text } from '@mantine/core';
import { type ReactNode, useMemo } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ActionDropdown } from '../../components/items/ActionDropdown';
import { formatCurrency, formatPriceRange } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import {
type StockOperationProps,
useAddStockItem,
useAssignStockItem,
useChangeStockStatus,
useCountStockItem,
useDeleteStockItem,
useMergeStockItem,
useRemoveStockItem,
useStockFields,
useTransferStockItem
} from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
import { notYetImplemented } from '../../functions/notifications';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
import type { TableColumn } from '../Column';
import {
DateColumn,
DescriptionColumn,
LocationColumn,
PartColumn,
StatusColumn
} from '../ColumnRenderers';
import { StatusFilterOptions, type TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard';
/**
* Construct a list of columns for the stock item table
*/
function stockItemTableColumns(): TableColumn[] {
return [
{
accessor: 'part',
sortable: true,
render: (record: any) => PartColumn({ part: record?.part_detail })
},
{
accessor: 'part_detail.IPN',
title: t`IPN`,
sortable: true
},
{
accessor: 'part_detail.revision',
title: t`Revision`,
sortable: true
},
DescriptionColumn({
accessor: 'part_detail.description'
}),
{
accessor: 'quantity',
ordering: 'stock',
sortable: true,
title: t`Stock`,
render: (record: any) => {
// TODO: Push this out into a custom renderer
const quantity = record?.quantity ?? 0;
const allocated = record?.allocated ?? 0;
const available = quantity - allocated;
let text = quantity;
const part = record?.part_detail ?? {};
const extra: ReactNode[] = [];
let color = undefined;
if (record.serial && quantity == 1) {
text = `# ${record.serial}`;
}
if (record.is_building) {
color = 'blue';
extra.push(
<Text
key='production'
size='sm'
>{t`This stock item is in production`}</Text>
);
} else if (record.sales_order) {
extra.push(
<Text
key='sales-order'
size='sm'
>{t`This stock item has been assigned to a sales order`}</Text>
);
} else if (record.customer) {
extra.push(
<Text
key='customer'
size='sm'
>{t`This stock item has been assigned to a customer`}</Text>
);
} else if (record.belongs_to) {
extra.push(
<Text
key='belongs-to'
size='sm'
>{t`This stock item is installed in another stock item`}</Text>
);
} else if (record.consumed_by) {
extra.push(
<Text
key='consumed-by'
size='sm'
>{t`This stock item has been consumed by a build order`}</Text>
);
} else if (!record.in_stock) {
extra.push(
<Text
key='unavailable'
size='sm'
>{t`This stock item is unavailable`}</Text>
);
}
if (record.expired) {
extra.push(
<Text
key='expired'
size='sm'
>{t`This stock item has expired`}</Text>
);
} else if (record.stale) {
extra.push(
<Text key='stale' size='sm'>{t`This stock item is stale`}</Text>
);
}
if (record.in_stock) {
if (allocated > 0) {
if (allocated >= quantity) {
color = 'orange';
extra.push(
<Text
key='fully-allocated'
size='sm'
>{t`This stock item is fully allocated`}</Text>
);
} else {
extra.push(
<Text
key='partially-allocated'
size='sm'
>{t`This stock item is partially allocated`}</Text>
);
}
}
if (available != quantity) {
if (available > 0) {
extra.push(
<Text key='available' size='sm' c='orange'>
{`${t`Available`}: ${available}`}
</Text>
);
} else {
extra.push(
<Text
key='no-stock'
size='sm'
c='red'
>{t`No stock available`}</Text>
);
}
}
if (quantity <= 0) {
extra.push(
<Text
key='depleted'
size='sm'
>{t`This stock item has been depleted`}</Text>
);
}
}
if (!record.in_stock) {
color = 'red';
}
return (
<TableHoverCard
value={
<Group gap='xs' justify='left' wrap='nowrap'>
<Text c={color}>{text}</Text>
{part.units && (
<Text size='xs' c={color}>
[{part.units}]
</Text>
)}
</Group>
}
title={t`Stock Information`}
extra={extra}
/>
);
}
},
StatusColumn({ model: ModelType.stockitem }),
{
accessor: 'batch',
sortable: true
},
LocationColumn({
accessor: 'location_detail'
}),
{
accessor: 'purchase_order',
title: t`Purchase Order`,
render: (record: any) => {
return record.purchase_order_reference;
}
},
{
accessor: 'SKU',
title: t`Supplier Part`,
sortable: true
},
{
accessor: 'MPN',
title: t`Manufacturer Part`,
sortable: true
},
{
accessor: 'purchase_price',
title: t`Unit Price`,
sortable: true,
switchable: true,
render: (record: any) =>
formatCurrency(record.purchase_price, {
currency: record.purchase_price_currency
})
},
{
accessor: 'stock_value',
title: t`Stock Value`,
sortable: false,
render: (record: any) => {
const min_price =
record.purchase_price || record.part_detail?.pricing_min;
const max_price =
record.purchase_price || record.part_detail?.pricing_max;
const currency = record.purchase_price_currency || undefined;
return formatPriceRange(min_price, max_price, {
currency: currency,
multiplier: record.quantity
});
}
},
{
accessor: 'packaging',
sortable: true
},
DateColumn({
title: t`Expiry Date`,
accessor: 'expiry_date',
hidden: !useGlobalSettingsState.getState().isSet('STOCK_ENABLE_EXPIRY')
}),
DateColumn({
title: t`Last Updated`,
accessor: 'updated'
}),
DateColumn({
accessor: 'stocktake_date',
title: t`Stocktake Date`,
sortable: true
})
];
}
/**
* Construct a list of available filters for the stock item table
*/
function stockItemTableFilters({
enableExpiry
}: {
enableExpiry: boolean;
}): TableFilter[] {
return [
{
name: 'active',
label: t`Active`,
description: t`Show stock for active parts`
},
{
name: 'status',
label: t`Status`,
description: t`Filter by stock status`,
choiceFunction: StatusFilterOptions(ModelType.stockitem)
},
{
name: 'assembly',
label: t`Assembly`,
description: t`Show stock for assembled parts`
},
{
name: 'allocated',
label: t`Allocated`,
description: t`Show items which have been allocated`
},
{
name: 'available',
label: t`Available`,
description: t`Show items which are available`
},
{
name: 'cascade',
label: t`Include Sublocations`,
description: t`Include stock in sublocations`
},
{
name: 'depleted',
label: t`Depleted`,
description: t`Show depleted stock items`
},
{
name: 'in_stock',
label: t`In Stock`,
description: t`Show items which are in stock`
},
{
name: 'is_building',
label: t`In Production`,
description: t`Show items which are in production`
},
{
name: 'include_variants',
label: t`Include Variants`,
description: t`Include stock items for variant parts`
},
{
name: 'installed',
label: t`Installed`,
description: t`Show stock items which are installed in other items`
},
{
name: 'sent_to_customer',
label: t`Sent to Customer`,
description: t`Show items which have been sent to a customer`
},
{
name: 'serialized',
label: t`Is Serialized`,
description: t`Show items which have a serial number`
},
{
name: 'batch',
label: t`Batch Code`,
description: t`Filter items by batch code`,
type: 'text'
},
{
name: 'serial',
label: t`Serial Number`,
description: t`Filter items by serial number`,
type: 'text'
},
{
name: 'serial_lte',
label: t`Serial Number LTE`,
description: t`Show items with serial numbers less than or equal to a given value`,
type: 'text'
},
{
name: 'serial_gte',
label: t`Serial Number GTE`,
description: t`Show items with serial numbers greater than or equal to a given value`,
type: 'text'
},
{
name: 'has_batch',
label: t`Has Batch Code`,
description: t`Show items which have a batch code`
},
{
name: 'tracked',
label: t`Tracked`,
description: t`Show tracked items`
},
{
name: 'has_purchase_price',
label: t`Has Purchase Price`,
description: t`Show items which have a purchase price`
},
{
name: 'expired',
label: t`Expired`,
description: t`Show items which have expired`,
active: enableExpiry
},
{
name: 'stale',
label: t`Stale`,
description: t`Show items which are stale`,
active: enableExpiry
},
{
name: 'expiry_before',
label: t`Expired Before`,
description: t`Show items which expired before this date`,
type: 'date',
active: enableExpiry
},
{
name: 'expiry_after',
label: t`Expired After`,
description: t`Show items which expired after this date`,
type: 'date',
active: enableExpiry
},
{
name: 'updated_before',
label: t`Updated Before`,
description: t`Show items updated before this date`,
type: 'date'
},
{
name: 'updated_after',
label: t`Updated After`,
description: t`Show items updated after this date`,
type: 'date'
},
{
name: 'stocktake_before',
label: t`Stocktake Before`,
description: t`Show items counted before this date`,
type: 'date'
},
{
name: 'stocktake_after',
label: t`Stocktake After`,
description: t`Show items counted after this date`,
type: 'date'
},
{
name: 'external',
label: t`External Location`,
description: t`Show items in an external location`
}
];
}
/*
* Load a table of stock items
*/
export function StockItemTable({
params = {},
allowAdd = false,
tableName = 'stockitems'
}: Readonly<{
params?: any;
allowAdd?: boolean;
tableName: string;
}>) {
const table = useTable(tableName);
const user = useUserState();
const settings = useGlobalSettingsState();
const stockExpiryEnabled = useMemo(
() => settings.isSet('STOCK_ENABLE_EXPIRY'),
[settings]
);
const tableColumns = useMemo(() => stockItemTableColumns(), []);
const tableFilters = useMemo(
() =>
stockItemTableFilters({
enableExpiry: stockExpiryEnabled
}),
[stockExpiryEnabled]
);
const tableActionParams: StockOperationProps = useMemo(() => {
return {
items: table.selectedRecords,
model: ModelType.stockitem,
refresh: table.refreshTable,
filters: {
in_stock: true
}
};
}, [table]);
const newStockItemFields = useStockFields({
create: true,
partId: params.part
});
const newStockItem = useCreateApiFormModal({
url: ApiEndpoints.stock_item_list,
title: t`Add Stock Item`,
fields: newStockItemFields,
initialData: {
part: params.part,
location: params.location
},
follow: true,
modelType: ModelType.stockitem
});
const transferStock = useTransferStockItem(tableActionParams);
const addStock = useAddStockItem(tableActionParams);
const removeStock = useRemoveStockItem(tableActionParams);
const countStock = useCountStockItem(tableActionParams);
const changeStockStatus = useChangeStockStatus(tableActionParams);
const mergeStock = useMergeStockItem(tableActionParams);
const assignStock = useAssignStockItem(tableActionParams);
const deleteStock = useDeleteStockItem(tableActionParams);
const tableActions = useMemo(() => {
const can_delete_stock = user.hasDeleteRole(UserRoles.stock);
const can_add_stock = user.hasAddRole(UserRoles.stock);
const can_add_stocktake = user.hasAddRole(UserRoles.stocktake);
const can_add_order = user.hasAddRole(UserRoles.purchase_order);
const can_change_order = user.hasChangeRole(UserRoles.purchase_order);
return [
<ActionDropdown
key='stock-actions'
tooltip={t`Stock Actions`}
icon={<InvenTreeIcon icon='stock' />}
disabled={table.selectedRecords.length === 0}
actions={[
{
name: t`Add Stock`,
icon: <InvenTreeIcon icon='add' iconProps={{ color: 'green' }} />,
tooltip: t`Add a new stock item`,
disabled: !can_add_stock,
onClick: () => {
addStock.open();
}
},
{
name: t`Remove Stock`,
icon: <InvenTreeIcon icon='remove' iconProps={{ color: 'red' }} />,
tooltip: t`Remove some quantity from a stock item`,
disabled: !can_add_stock,
onClick: () => {
removeStock.open();
}
},
{
name: t`Count Stock`,
icon: (
<InvenTreeIcon icon='stocktake' iconProps={{ color: 'blue' }} />
),
tooltip: t`Count Stock`,
disabled: !can_add_stocktake,
onClick: () => {
countStock.open();
}
},
{
name: t`Transfer Stock`,
icon: (
<InvenTreeIcon icon='transfer' iconProps={{ color: 'blue' }} />
),
tooltip: t`Move Stock items to new locations`,
disabled: !can_add_stock,
onClick: () => {
transferStock.open();
}
},
{
name: t`Change stock status`,
icon: <InvenTreeIcon icon='info' iconProps={{ color: 'blue' }} />,
tooltip: t`Change the status of stock items`,
disabled: !can_add_stock,
onClick: () => {
changeStockStatus.open();
}
},
{
name: t`Merge stock`,
icon: <InvenTreeIcon icon='merge' />,
tooltip: t`Merge stock items`,
disabled: !can_add_stock,
onClick: () => {
mergeStock.open();
}
},
{
name: t`Order stock`,
icon: <InvenTreeIcon icon='buy' />,
tooltip: t`Order new stock`,
disabled: !can_add_order || !can_change_order,
onClick: notYetImplemented
},
{
name: t`Assign to customer`,
icon: <InvenTreeIcon icon='customer' />,
tooltip: t`Assign items to a customer`,
disabled: !can_add_stock,
onClick: () => {
assignStock.open();
}
},
{
name: t`Delete stock`,
icon: <InvenTreeIcon icon='delete' iconProps={{ color: 'red' }} />,
tooltip: t`Delete Stock Items`,
disabled: !can_delete_stock,
onClick: () => {
deleteStock.open();
}
}
]}
/>,
<AddItemButton
key='add-stock-item'
hidden={!allowAdd || !user.hasAddRole(UserRoles.stock)}
tooltip={t`Add Stock Item`}
onClick={() => newStockItem.open()}
/>
];
}, [user, table, allowAdd]);
return (
<>
{newStockItem.modal}
{transferStock.modal}
{removeStock.modal}
{addStock.modal}
{countStock.modal}
{changeStockStatus.modal}
{mergeStock.modal}
{assignStock.modal}
{deleteStock.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.stock_item_list)}
tableState={table}
columns={tableColumns}
props={{
enableDownload: true,
enableSelection: true,
enableLabels: true,
enableReports: true,
tableFilters: tableFilters,
tableActions: tableActions,
modelType: ModelType.stockitem,
params: {
...params,
part_detail: true,
location_detail: true,
supplier_part_detail: true
}
}}
/>
</>
);
}