2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 19:46:46 +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:
Oliver 2024-10-11 23:39:07 +11:00 committed by GitHub
parent 48bb5fd579
commit f77c8a5b5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 136 additions and 78 deletions

View File

@ -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)

View File

@ -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):

View File

@ -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',
) )

View File

@ -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:

View File

@ -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;

View File

@ -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}>

View File

@ -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,6 +139,7 @@ function stockItemTableColumns(): TableColumn[] {
); );
} }
if (record.in_stock) {
if (allocated > 0) { if (allocated > 0) {
if (allocated >= quantity) { if (allocated >= quantity) {
color = 'orange'; color = 'orange';
@ -197,8 +185,9 @@ function stockItemTableColumns(): TableColumn[] {
>{t`This stock item has been depleted`}</Text> >{t`This stock item has been depleted`}</Text>
); );
} }
}
if (!in_stock) { if (!record.in_stock) {
color = 'red'; color = 'red';
} }