mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +00:00
[PUI] Stock Detail (#8274)
* Add "in_stock" attribute to StockItem API * Add "unavailable" badge to StockDetail page * Hide stock actions menu for "unavailable" stock * Fix renderer for StockItemTable * refactor stock table display * Add 'date' type details field * Disable "expiry" information on StockDetailPage * Icon fix * Bump API version
This commit is contained in:
parent
48bb5fd579
commit
f77c8a5b5b
@ -1,13 +1,16 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 267
|
INVENTREE_API_VERSION = 268
|
||||||
|
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
|
||||||
|
268 - 2024-10-11 : https://github.com/inventree/InvenTree/pull/8274
|
||||||
|
- Adds "in_stock" attribute to the StockItem serializer
|
||||||
|
|
||||||
267 - 2024-10-8 : https://github.com/inventree/InvenTree/pull/8250
|
267 - 2024-10-8 : https://github.com/inventree/InvenTree/pull/8250
|
||||||
- Remove "allocations" field from the SalesOrderShipment API endpoint(s)
|
- Remove "allocations" field from the SalesOrderShipment API endpoint(s)
|
||||||
- Add "allocated_items" field to the SalesOrderShipment API endpoint(s)
|
- Add "allocated_items" field to the SalesOrderShipment API endpoint(s)
|
||||||
|
@ -441,6 +441,7 @@ class StockItem(
|
|||||||
tags = TaggableManager(blank=True)
|
tags = TaggableManager(blank=True)
|
||||||
|
|
||||||
# A Query filter which will be re-used in multiple places to determine if a StockItem is actually "in stock"
|
# A Query filter which will be re-used in multiple places to determine if a StockItem is actually "in stock"
|
||||||
|
# See also: StockItem.in_stock() method
|
||||||
IN_STOCK_FILTER = Q(
|
IN_STOCK_FILTER = Q(
|
||||||
quantity__gt=0,
|
quantity__gt=0,
|
||||||
sales_order=None,
|
sales_order=None,
|
||||||
@ -1404,16 +1405,20 @@ class StockItem(
|
|||||||
return self.children.count()
|
return self.children.count()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def in_stock(self):
|
def in_stock(self) -> bool:
|
||||||
"""Returns True if this item is in stock.
|
"""Returns True if this item is in stock.
|
||||||
|
|
||||||
See also: IN_STOCK_FILTER
|
See also: StockItem.IN_STOCK_FILTER for the db optimized version of this check.
|
||||||
"""
|
"""
|
||||||
query = StockItem.objects.filter(pk=self.pk)
|
return all([
|
||||||
|
self.quantity > 0, # Quantity must be greater than zero
|
||||||
query = query.filter(StockItem.IN_STOCK_FILTER)
|
self.sales_order is None, # Not assigned to a SalesOrder
|
||||||
|
self.belongs_to is None, # Not installed inside another StockItem
|
||||||
return query.exists()
|
self.customer is None, # Not assigned to a customer
|
||||||
|
self.consumed_by is None, # Not consumed by a build
|
||||||
|
not self.is_building, # Not part of an active build
|
||||||
|
self.status in StockStatusGroups.AVAILABLE_CODES, # Status is "available"
|
||||||
|
])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def can_adjust_location(self):
|
def can_adjust_location(self):
|
||||||
|
@ -358,6 +358,7 @@ class StockItemSerializer(
|
|||||||
'customer',
|
'customer',
|
||||||
'delete_on_deplete',
|
'delete_on_deplete',
|
||||||
'expiry_date',
|
'expiry_date',
|
||||||
|
'in_stock',
|
||||||
'is_building',
|
'is_building',
|
||||||
'link',
|
'link',
|
||||||
'location',
|
'location',
|
||||||
@ -468,6 +469,8 @@ class StockItemSerializer(
|
|||||||
child=serializers.DictField(), source='location.get_path', read_only=True
|
child=serializers.DictField(), source='location.get_path', read_only=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
in_stock = serializers.BooleanField(read_only=True, label=_('In Stock'))
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Field used when creating a stock item
|
Field used when creating a stock item
|
||||||
"""
|
"""
|
||||||
@ -519,6 +522,10 @@ class StockItemSerializer(
|
|||||||
'supplier_part__manufacturer_part__manufacturer',
|
'supplier_part__manufacturer_part__manufacturer',
|
||||||
'supplier_part__tags',
|
'supplier_part__tags',
|
||||||
'test_results',
|
'test_results',
|
||||||
|
'customer',
|
||||||
|
'belongs_to',
|
||||||
|
'sales_order',
|
||||||
|
'consumed_by',
|
||||||
'tags',
|
'tags',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ import { ReactNode, useCallback, useMemo } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { api } from '../../App';
|
import { api } from '../../App';
|
||||||
|
import { formatDate } from '../../defaults/formatters';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import { ModelType } from '../../enums/ModelType';
|
import { ModelType } from '../../enums/ModelType';
|
||||||
import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons';
|
import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons';
|
||||||
@ -50,7 +51,7 @@ type BadgeType = 'owner' | 'user' | 'group';
|
|||||||
type ValueFormatterReturn = string | number | null | React.ReactNode;
|
type ValueFormatterReturn = string | number | null | React.ReactNode;
|
||||||
|
|
||||||
type StringDetailField = {
|
type StringDetailField = {
|
||||||
type: 'string' | 'text';
|
type: 'string' | 'text' | 'date';
|
||||||
unit?: boolean;
|
unit?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -174,6 +175,10 @@ function NameBadge({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DateValue(props: Readonly<FieldProps>) {
|
||||||
|
return <Text size="sm">{formatDate(props.field_value?.toString())}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the value of a 'string' or 'text' field.
|
* Renders the value of a 'string' or 'text' field.
|
||||||
* If owner is defined, only renders a badge
|
* If owner is defined, only renders a badge
|
||||||
@ -346,6 +351,8 @@ export function DetailsTableField({
|
|||||||
return ProgressBarValue;
|
return ProgressBarValue;
|
||||||
case 'status':
|
case 'status':
|
||||||
return StatusValue;
|
return StatusValue;
|
||||||
|
case 'date':
|
||||||
|
return DateValue;
|
||||||
case 'text':
|
case 'text':
|
||||||
case 'string':
|
case 'string':
|
||||||
default:
|
default:
|
||||||
|
@ -18,6 +18,7 @@ export interface StatusCodeListInterface {
|
|||||||
|
|
||||||
interface RenderStatusLabelOptionsInterface {
|
interface RenderStatusLabelOptionsInterface {
|
||||||
size?: MantineSize;
|
size?: MantineSize;
|
||||||
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -121,6 +122,10 @@ export const StatusRenderer = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const statusCodes = getStatusCodes(type);
|
const statusCodes = getStatusCodes(type);
|
||||||
|
|
||||||
|
if (options?.hidden) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (statusCodes === undefined || statusCodes === null) {
|
if (statusCodes === undefined || statusCodes === null) {
|
||||||
console.warn('StatusRenderer: statusCodes is undefined');
|
console.warn('StatusRenderer: statusCodes is undefined');
|
||||||
return null;
|
return null;
|
||||||
|
@ -59,6 +59,7 @@ import {
|
|||||||
} from '../../hooks/UseForm';
|
} from '../../hooks/UseForm';
|
||||||
import { useInstance } from '../../hooks/UseInstance';
|
import { useInstance } from '../../hooks/UseInstance';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
|
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
|
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
|
||||||
import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable';
|
import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable';
|
||||||
@ -72,6 +73,13 @@ export default function StockDetail() {
|
|||||||
|
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
|
|
||||||
|
const globalSettings = useGlobalSettingsState();
|
||||||
|
|
||||||
|
const enableExpiry = useMemo(
|
||||||
|
() => globalSettings.isSet('STOCK_ENABLE_EXPIRY'),
|
||||||
|
[globalSettings]
|
||||||
|
);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [treeOpen, setTreeOpen] = useState(false);
|
const [treeOpen, setTreeOpen] = useState(false);
|
||||||
@ -214,6 +222,7 @@ export default function StockDetail() {
|
|||||||
{
|
{
|
||||||
type: 'link',
|
type: 'link',
|
||||||
name: 'parent',
|
name: 'parent',
|
||||||
|
icon: 'sitemap',
|
||||||
label: t`Parent Item`,
|
label: t`Parent Item`,
|
||||||
model: ModelType.stockitem,
|
model: ModelType.stockitem,
|
||||||
hidden: !stockitem.parent,
|
hidden: !stockitem.parent,
|
||||||
@ -258,7 +267,14 @@ export default function StockDetail() {
|
|||||||
|
|
||||||
// Bottom right - any other information
|
// Bottom right - any other information
|
||||||
let br: DetailsField[] = [
|
let br: DetailsField[] = [
|
||||||
// TODO: Expiry date
|
// Expiry date
|
||||||
|
{
|
||||||
|
type: 'date',
|
||||||
|
name: 'expiry_date',
|
||||||
|
label: t`Expiry Date`,
|
||||||
|
hidden: !enableExpiry || !stockitem.expiry_date,
|
||||||
|
icon: 'calendar'
|
||||||
|
},
|
||||||
// TODO: Ownership
|
// TODO: Ownership
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@ -320,7 +336,7 @@ export default function StockDetail() {
|
|||||||
<DetailsTable fields={br} item={data} />
|
<DetailsTable fields={br} item={data} />
|
||||||
</ItemDetailsGrid>
|
</ItemDetailsGrid>
|
||||||
);
|
);
|
||||||
}, [stockitem, instanceQuery]);
|
}, [stockitem, instanceQuery, enableExpiry]);
|
||||||
|
|
||||||
const showBuildAllocations: boolean = useMemo(() => {
|
const showBuildAllocations: boolean = useMemo(() => {
|
||||||
// Determine if "build allocations" should be shown for this stock item
|
// Determine if "build allocations" should be shown for this stock item
|
||||||
@ -636,6 +652,7 @@ export default function StockDetail() {
|
|||||||
/>,
|
/>,
|
||||||
<ActionDropdown
|
<ActionDropdown
|
||||||
tooltip={t`Stock Operations`}
|
tooltip={t`Stock Operations`}
|
||||||
|
hidden={!stockitem.in_stock}
|
||||||
icon={<IconPackages />}
|
icon={<IconPackages />}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
@ -749,7 +766,11 @@ export default function StockDetail() {
|
|||||||
<DetailsBadge
|
<DetailsBadge
|
||||||
color="yellow"
|
color="yellow"
|
||||||
label={t`Available` + `: ${available}`}
|
label={t`Available` + `: ${available}`}
|
||||||
visible={!stockitem.serial && available != stockitem.quantity}
|
visible={
|
||||||
|
stockitem.in_stock &&
|
||||||
|
!stockitem.serial &&
|
||||||
|
available != stockitem.quantity
|
||||||
|
}
|
||||||
key="available"
|
key="available"
|
||||||
/>,
|
/>,
|
||||||
<DetailsBadge
|
<DetailsBadge
|
||||||
@ -761,11 +782,32 @@ export default function StockDetail() {
|
|||||||
<StatusRenderer
|
<StatusRenderer
|
||||||
status={stockitem.status_custom_key}
|
status={stockitem.status_custom_key}
|
||||||
type={ModelType.stockitem}
|
type={ModelType.stockitem}
|
||||||
options={{ size: 'lg' }}
|
options={{
|
||||||
|
size: 'lg',
|
||||||
|
hidden: !!stockitem.status_custom_key
|
||||||
|
}}
|
||||||
key="status"
|
key="status"
|
||||||
|
/>,
|
||||||
|
<DetailsBadge
|
||||||
|
color="yellow"
|
||||||
|
label={t`Stale`}
|
||||||
|
visible={enableExpiry && stockitem.stale && !stockitem.expired}
|
||||||
|
key="stale"
|
||||||
|
/>,
|
||||||
|
<DetailsBadge
|
||||||
|
color="orange"
|
||||||
|
label={t`Expired`}
|
||||||
|
visible={enableExpiry && stockitem.expired}
|
||||||
|
key="expired"
|
||||||
|
/>,
|
||||||
|
<DetailsBadge
|
||||||
|
color="red"
|
||||||
|
label={t`Unavailable`}
|
||||||
|
visible={stockitem.in_stock == false}
|
||||||
|
key="unavailable"
|
||||||
/>
|
/>
|
||||||
];
|
];
|
||||||
}, [stockitem, instanceQuery]);
|
}, [stockitem, instanceQuery, enableExpiry]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
|
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
|
||||||
|
@ -77,18 +77,6 @@ function stockItemTableColumns(): TableColumn[] {
|
|||||||
let extra: ReactNode[] = [];
|
let extra: ReactNode[] = [];
|
||||||
let color = undefined;
|
let color = undefined;
|
||||||
|
|
||||||
// Determine if a stock item is "in stock"
|
|
||||||
// TODO: Refactor this out into a function
|
|
||||||
let in_stock =
|
|
||||||
!record?.belongs_to &&
|
|
||||||
!record?.consumed_by &&
|
|
||||||
!record?.customer &&
|
|
||||||
!record?.is_building &&
|
|
||||||
!record?.sales_order &&
|
|
||||||
!record?.expired &&
|
|
||||||
record?.quantity &&
|
|
||||||
record?.quantity > 0;
|
|
||||||
|
|
||||||
if (record.serial && quantity == 1) {
|
if (record.serial && quantity == 1) {
|
||||||
text = `# ${record.serial}`;
|
text = `# ${record.serial}`;
|
||||||
}
|
}
|
||||||
@ -101,42 +89,41 @@ function stockItemTableColumns(): TableColumn[] {
|
|||||||
size="sm"
|
size="sm"
|
||||||
>{t`This stock item is in production`}</Text>
|
>{t`This stock item is in production`}</Text>
|
||||||
);
|
);
|
||||||
}
|
} else if (record.sales_order) {
|
||||||
|
|
||||||
if (record.sales_order) {
|
|
||||||
extra.push(
|
extra.push(
|
||||||
<Text
|
<Text
|
||||||
key="sales-order"
|
key="sales-order"
|
||||||
size="sm"
|
size="sm"
|
||||||
>{t`This stock item has been assigned to a sales order`}</Text>
|
>{t`This stock item has been assigned to a sales order`}</Text>
|
||||||
);
|
);
|
||||||
}
|
} else if (record.customer) {
|
||||||
|
|
||||||
if (record.customer) {
|
|
||||||
extra.push(
|
extra.push(
|
||||||
<Text
|
<Text
|
||||||
key="customer"
|
key="customer"
|
||||||
size="sm"
|
size="sm"
|
||||||
>{t`This stock item has been assigned to a customer`}</Text>
|
>{t`This stock item has been assigned to a customer`}</Text>
|
||||||
);
|
);
|
||||||
}
|
} else if (record.belongs_to) {
|
||||||
|
|
||||||
if (record.belongs_to) {
|
|
||||||
extra.push(
|
extra.push(
|
||||||
<Text
|
<Text
|
||||||
key="belongs-to"
|
key="belongs-to"
|
||||||
size="sm"
|
size="sm"
|
||||||
>{t`This stock item is installed in another stock item`}</Text>
|
>{t`This stock item is installed in another stock item`}</Text>
|
||||||
);
|
);
|
||||||
}
|
} else if (record.consumed_by) {
|
||||||
|
|
||||||
if (record.consumed_by) {
|
|
||||||
extra.push(
|
extra.push(
|
||||||
<Text
|
<Text
|
||||||
key="consumed-by"
|
key="consumed-by"
|
||||||
size="sm"
|
size="sm"
|
||||||
>{t`This stock item has been consumed by a build order`}</Text>
|
>{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) {
|
if (record.expired) {
|
||||||
@ -152,53 +139,55 @@ function stockItemTableColumns(): TableColumn[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allocated > 0) {
|
if (record.in_stock) {
|
||||||
if (allocated >= quantity) {
|
if (allocated > 0) {
|
||||||
color = 'orange';
|
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(
|
extra.push(
|
||||||
<Text
|
<Text
|
||||||
key="fully-allocated"
|
key="depleted"
|
||||||
size="sm"
|
size="sm"
|
||||||
>{t`This stock item is fully allocated`}</Text>
|
>{t`This stock item has been depleted`}</Text>
|
||||||
);
|
|
||||||
} else {
|
|
||||||
extra.push(
|
|
||||||
<Text
|
|
||||||
key="partially-allocated"
|
|
||||||
size="sm"
|
|
||||||
>{t`This stock item is partially allocated`}</Text>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (available != quantity) {
|
if (!record.in_stock) {
|
||||||
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 (!in_stock) {
|
|
||||||
color = 'red';
|
color = 'red';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user