diff --git a/src/frontend/src/components/previews/PreviewType.tsx b/src/frontend/src/components/previews/PreviewType.tsx
index 0cd0319822..16ad538286 100644
--- a/src/frontend/src/components/previews/PreviewType.tsx
+++ b/src/frontend/src/components/previews/PreviewType.tsx
@@ -1,6 +1,7 @@
import { ModelType } from '@lib/enums/ModelType';
import type { ReactNode } from 'react';
import { PartPreviewComponent } from './models/PartPreview';
+import { StockPreviewComponent } from './models/StockPreview';
export interface PreviewType {
preview: ReactNode;
@@ -25,6 +26,8 @@ export function getPreviewComponentForModel({
switch (modelType) {
case ModelType.part:
return PartPreviewComponent({ instance, modelId });
+ case ModelType.stockitem:
+ return StockPreviewComponent({ instance, modelId });
default:
return null;
}
diff --git a/src/frontend/src/components/previews/models/StockPreview.tsx b/src/frontend/src/components/previews/models/StockPreview.tsx
new file mode 100644
index 0000000000..788d8bf1f9
--- /dev/null
+++ b/src/frontend/src/components/previews/models/StockPreview.tsx
@@ -0,0 +1,30 @@
+import { formatDecimal } from '@lib/functions/Formatting';
+import { StockDetailsPanel } from '../../../pages/stock/StockDetailsPanel';
+import type { PreviewType } from '../PreviewType';
+
+export function StockPreviewComponent({
+ instance,
+ modelId
+}: Readonly<{
+ instance: any;
+ modelId: number;
+}>): PreviewType {
+ const part = instance?.part_detail;
+
+ let title = `Stock Item #${modelId}`;
+
+ if (part) {
+ title = part?.full_name ?? part?.name;
+
+ if (instance.serial) {
+ title += ` (# ${instance.serial})`;
+ } else {
+ title += ` (x ${formatDecimal(instance.quantity)})`;
+ }
+ }
+
+ return {
+ title: title,
+ preview:
+ };
+}
diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx
index fa4bb80fa9..3d5f32a35e 100644
--- a/src/frontend/src/pages/stock/StockDetail.tsx
+++ b/src/frontend/src/pages/stock/StockDetail.tsx
@@ -1,25 +1,12 @@
import { t } from '@lingui/core/macro';
+import { Accordion, Skeleton, Stack } from '@mantine/core';
import {
- Accordion,
- Button,
- Grid,
- Group,
- Skeleton,
- Space,
- Stack,
- Text,
- Tooltip
-} from '@mantine/core';
-import {
- IconArrowLeft,
- IconArrowRight,
IconBookmark,
IconBoxPadding,
IconChecklist,
IconHistory,
IconInfoCircle,
IconPackages,
- IconSearch,
IconShoppingCart,
IconSitemap,
IconTransform
@@ -28,27 +15,19 @@ import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
-import { ActionButton } from '@lib/components/ActionButton';
import { StylishText } from '@lib/components/StylishText';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl, getOverviewUrl } from '@lib/functions/Navigation';
-import { TagsList } from '@lib/index';
import type { ApiFormFieldSet, StockOperationProps } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel';
import { notifications } from '@mantine/notifications';
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
-import {
- type DetailsField,
- DetailsTable
-} from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
-import { DetailsImage } from '../../components/details/DetailsImage';
-import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
ActionDropdown,
BarcodeActionDropdown,
@@ -67,9 +46,8 @@ import LocateItemButton from '../../components/plugins/LocateItemButton';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { useApi } from '../../contexts/ApiContext';
-import { formatCurrency, formatDecimal } from '../../defaults/formatters';
+import { formatDecimal } from '../../defaults/formatters';
import {
- useFindSerialNumberForm,
useStockFields,
useStockItemSerializeFields
} from '../../forms/StockForms';
@@ -90,6 +68,7 @@ import { StockItemTable } from '../../tables/stock/StockItemTable';
import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable';
import { StockTrackingTable } from '../../tables/stock/StockTrackingTable';
import TransferOrderAllocationTable from '../../tables/stock/TransferOrderAllocationTable';
+import { StockDetailsPanel } from './StockDetailsPanel';
export default function StockDetail() {
const { id } = useParams();
@@ -124,366 +103,13 @@ export default function StockDetail() {
}
});
- const { instance: part, instanceQuery: partQuery } = useInstance({
+ const { instance: part } = useInstance({
endpoint: ApiEndpoints.part_list,
pk: stockitem?.part,
hasPrimaryKey: true,
defaultValue: {}
});
- const { instance: serialNumbers, instanceQuery: serialNumbersQuery } =
- useInstance({
- endpoint: ApiEndpoints.stock_serial_info,
- pk: id
- });
-
- const findBySerialNumber = useFindSerialNumberForm({
- partId: stockitem.part
- });
-
- const detailsPanel = useMemo(() => {
- const data = { ...stockitem };
- const part = stockitem?.part_detail ?? {};
-
- data.available_stock = Math.max(0, data.quantity - data.allocated);
-
- if (instanceQuery.isFetching) {
- return ;
- }
-
- // Top left - core part information
- const tl: DetailsField[] = [
- {
- name: 'part',
- label: t`Base Part`,
- type: 'link',
- model: ModelType.part
- },
- {
- name: 'part_detail.IPN',
- label: t`IPN`,
- type: 'text',
- copy: true,
- icon: 'part',
- hidden: !part.IPN
- },
- {
- name: 'part_detail.revision',
- label: t`Revision`,
- type: 'string',
- copy: true,
- icon: 'revision',
- hidden: !part.revision
- },
- {
- name: 'status',
- type: 'status',
- label: t`Status`,
- model: ModelType.stockitem
- },
- {
- name: 'status_custom_key',
- type: 'status',
- label: t`Custom Status`,
- model: ModelType.stockitem,
- icon: 'status',
- hidden:
- !stockitem.status_custom_key ||
- stockitem.status_custom_key == stockitem.status
- },
- {
- type: 'link',
- name: 'link',
- label: t`Link`,
- external: true,
- copy: true,
- hidden: !stockitem.link
- }
- ];
-
- // Top right - available stock information
- const tr: DetailsField[] = [
- {
- type: 'text',
- name: 'serial',
- label: t`Serial Number`,
- hidden: !stockitem.serial,
- value_formatter: () => (
-
- {stockitem.serial}
-
-
- {serialNumbers.previous?.pk && (
-
- }
- variant='transparent'
- size='sm'
- onClick={() => {
- navigate(
- getDetailUrl(
- ModelType.stockitem,
- serialNumbers.previous.pk
- )
- );
- }}
- >
- {serialNumbers.previous.serial}
-
-
- )}
- }
- tooltip={t`Find serial number`}
- tooltipAlignment='top'
- variant='transparent'
- onClick={findBySerialNumber.open}
- />
- {serialNumbers.next?.pk && (
-
- }
- variant='transparent'
- size='sm'
- onClick={() => {
- navigate(
- getDetailUrl(ModelType.stockitem, serialNumbers.next.pk)
- );
- }}
- >
- {serialNumbers.next.serial}
-
-
- )}
-
-
- )
- },
- {
- type: 'number',
- name: 'quantity',
- label: t`Quantity`,
- unit: part?.units,
- hidden: !!stockitem.serial && stockitem.quantity == 1
- },
- {
- type: 'number',
- name: 'available_stock',
- label: t`Available`,
- unit: part?.units,
- icon: 'stock'
- },
- {
- type: 'number',
- name: 'allocated',
- label: t`Allocated to Orders`,
- unit: part?.units,
- icon: 'tick_off',
- hidden: !stockitem.allocated
- },
- {
- type: 'text',
- name: 'batch',
- label: t`Batch Code`,
- hidden: !stockitem.batch
- }
- ];
-
- // Bottom left: location information
- const bl: DetailsField[] = [
- {
- name: 'supplier_part',
- label: t`Supplier Part`,
- type: 'link',
- model_field: 'SKU',
- model: ModelType.supplierpart,
- hidden: !stockitem.supplier_part
- },
- {
- type: 'link',
- name: 'location',
- label: t`Location`,
- model: ModelType.stocklocation,
- hidden: !stockitem.location
- },
- {
- type: 'link',
- name: 'belongs_to',
- label: t`Installed In`,
- model_filters: {
- part_detail: true
- },
- model_formatter: (model: any) => {
- let text = model?.part_detail?.full_name ?? model?.name;
- if (model.serial && model.quantity == 1) {
- text += ` # ${model.serial}`;
- }
-
- return text;
- },
- icon: 'stock',
- model: ModelType.stockitem,
- hidden: !stockitem.belongs_to
- },
- {
- type: 'link',
- name: 'parent',
- icon: 'sitemap',
- label: t`Parent Item`,
- model: ModelType.stockitem,
- hidden: !stockitem.parent,
- model_formatter: (model: any) => {
- return t`Parent stock item`;
- }
- },
- {
- type: 'link',
- name: 'consumed_by',
- label: t`Consumed By`,
- model: ModelType.build,
- hidden: !stockitem.consumed_by,
- icon: 'build',
- model_field: 'reference'
- },
- {
- type: 'link',
- name: 'build',
- label: t`Build Order`,
- model: ModelType.build,
- hidden: !stockitem.build,
- model_field: 'reference'
- },
- {
- type: 'link',
- name: 'purchase_order',
- label: t`Purchase Order`,
- model: ModelType.purchaseorder,
- hidden: !stockitem.purchase_order,
- icon: 'purchase_orders',
- model_field: 'reference'
- },
- {
- type: 'link',
- name: 'sales_order',
- label: t`Sales Order`,
- model: ModelType.salesorder,
- hidden: !stockitem.sales_order,
- icon: 'sales_orders',
- model_field: 'reference'
- },
- {
- type: 'link',
- name: 'customer',
- label: t`Customer`,
- model: ModelType.company,
- hidden: !stockitem.customer
- }
- ];
-
- // Bottom right - any other information
- const br: DetailsField[] = [
- // Expiry date
- {
- type: 'date',
- name: 'expiry_date',
- label: t`Expiry Date`,
- hidden: !enableExpiry || !stockitem.expiry_date,
- icon: 'calendar'
- },
- // TODO: Ownership
- {
- type: 'text',
- name: 'purchase_price',
- label: t`Unit Price`,
- icon: 'currency',
- hidden: !stockitem.purchase_price,
- value_formatter: () => {
- return formatCurrency(stockitem.purchase_price, {
- currency: stockitem.purchase_price_currency
- });
- }
- },
- {
- type: 'text',
- name: 'stock_value',
- label: t`Stock Value`,
- icon: 'currency',
- hidden:
- !stockitem.purchase_price ||
- stockitem.quantity == 1 ||
- stockitem.quantity == 0,
- value_formatter: () => {
- return formatCurrency(stockitem.purchase_price, {
- currency: stockitem.purchase_price_currency,
- multiplier: stockitem.quantity
- });
- }
- },
- {
- type: 'text',
- name: 'packaging',
- icon: 'part',
- label: t`Packaging`,
- hidden: !stockitem.packaging
- },
- {
- type: 'date',
- name: 'creation_date',
- icon: 'calendar',
- label: t`Created`,
- hidden: !stockitem.creation_date
- },
- {
- type: 'date',
- name: 'updated',
- icon: 'calendar',
- label: t`Last Updated`
- },
- {
- type: 'date',
- name: 'stocktake_date',
- icon: 'calendar',
- label: t`Last Stocktake`,
- hidden: !stockitem.stocktake_date
- }
- ];
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }, [
- stockitem,
- serialNumbers,
- serialNumbersQuery.isFetching,
- instanceQuery.isFetching,
- enableExpiry
- ]);
-
const showBuildAllocations: boolean = useMemo(() => {
// Determine if "build allocations" should be shown for this stock item
return (
@@ -554,7 +180,14 @@ export default function StockDetail() {
name: 'details',
label: t`Stock Details`,
icon: ,
- content: detailsPanel
+ content: (
+
+ )
},
{
name: 'tracking',
@@ -685,8 +318,6 @@ export default function StockDetail() {
showBuildAllocations,
showInstalledItems,
stockitem,
- serialNumbers,
- serialNumbersQuery,
id,
user
]);
@@ -1061,7 +692,6 @@ export default function StockDetail() {
return (
<>
- {findBySerialNumber.modal}
{scanIntoLocation.dialog}
void;
+}>) {
+ const navigate = useNavigate();
+ const globalSettings = useGlobalSettingsState();
+
+ const enableExpiry = useMemo(
+ () => globalSettings.isSet('STOCK_ENABLE_EXPIRY'),
+ [globalSettings]
+ );
+
+ const { instance: serialNumbers } = useInstance({
+ endpoint: ApiEndpoints.stock_serial_info,
+ pk: instance?.pk,
+ hasPrimaryKey: true,
+ disabled: !instance?.pk,
+ defaultValue: {}
+ });
+
+ const { instance: fetchedPart } = useInstance({
+ endpoint: ApiEndpoints.part_list,
+ pk: instance?.part,
+ hasPrimaryKey: true,
+ disabled: !instance?.part || !!instance?.part_detail,
+ defaultValue: {}
+ });
+
+ const findBySerialNumber = useFindSerialNumberForm({
+ partId: instance?.part
+ });
+
+ const part = instance?.part_detail ?? fetchedPart ?? {};
+
+ const data = useMemo(() => {
+ const d = { ...instance };
+ d.available_stock = Math.max(0, (d.quantity ?? 0) - (d.allocated ?? 0));
+ return d;
+ }, [instance]);
+
+ const tl: DetailsField[] = [
+ {
+ name: 'part',
+ label: t`Base Part`,
+ type: 'link',
+ model: ModelType.part
+ },
+ {
+ name: 'part_detail.IPN',
+ label: t`IPN`,
+ type: 'text',
+ copy: true,
+ icon: 'part',
+ hidden: !part.IPN
+ },
+ {
+ name: 'part_detail.revision',
+ label: t`Revision`,
+ type: 'string',
+ copy: true,
+ icon: 'revision',
+ hidden: !part.revision
+ },
+ {
+ name: 'status',
+ type: 'status',
+ label: t`Status`,
+ model: ModelType.stockitem
+ },
+ {
+ name: 'status_custom_key',
+ type: 'status',
+ label: t`Custom Status`,
+ model: ModelType.stockitem,
+ icon: 'status',
+ hidden:
+ !instance?.status_custom_key ||
+ instance?.status_custom_key == instance?.status
+ },
+ {
+ type: 'link',
+ name: 'link',
+ label: t`Link`,
+ external: true,
+ copy: true,
+ hidden: !instance?.link
+ }
+ ];
+
+ const tr: DetailsField[] = [
+ {
+ type: 'text',
+ name: 'serial',
+ label: t`Serial Number`,
+ hidden: !instance?.serial,
+ ...(showSerialNav && {
+ value_formatter: () => (
+
+ {instance?.serial}
+
+
+ {serialNumbers?.previous?.pk && (
+
+ }
+ variant='transparent'
+ size='sm'
+ onClick={() => {
+ navigate(
+ getDetailUrl(
+ ModelType.stockitem,
+ serialNumbers.previous.pk
+ )
+ );
+ }}
+ >
+ {serialNumbers.previous.serial}
+
+
+ )}
+ }
+ tooltip={t`Find serial number`}
+ tooltipAlignment='top'
+ variant='transparent'
+ onClick={findBySerialNumber.open}
+ />
+ {serialNumbers?.next?.pk && (
+
+ }
+ variant='transparent'
+ size='sm'
+ onClick={() => {
+ navigate(
+ getDetailUrl(ModelType.stockitem, serialNumbers.next.pk)
+ );
+ }}
+ >
+ {serialNumbers.next.serial}
+
+
+ )}
+
+
+ )
+ })
+ },
+ {
+ type: 'number',
+ name: 'quantity',
+ label: t`Quantity`,
+ unit: part?.units,
+ hidden: !!instance?.serial && instance?.quantity == 1
+ },
+ {
+ type: 'number',
+ name: 'available_stock',
+ label: t`Available`,
+ unit: part?.units,
+ icon: 'stock',
+ hidden: !!instance?.serial || instance.in_stock === false
+ },
+ {
+ type: 'number',
+ name: 'allocated',
+ label: t`Allocated to Orders`,
+ unit: part?.units,
+ icon: 'tick_off',
+ hidden: !instance?.allocated
+ },
+ {
+ type: 'text',
+ name: 'batch',
+ label: t`Batch Code`,
+ hidden: !instance?.batch
+ }
+ ];
+
+ const bl: DetailsField[] = [
+ {
+ name: 'supplier_part',
+ label: t`Supplier Part`,
+ type: 'link',
+ model_field: 'SKU',
+ model: ModelType.supplierpart,
+ hidden: !instance?.supplier_part
+ },
+ {
+ type: 'link',
+ name: 'location',
+ label: t`Location`,
+ model: ModelType.stocklocation,
+ hidden: !instance?.location
+ },
+ {
+ type: 'link',
+ name: 'belongs_to',
+ label: t`Installed In`,
+ model_filters: {
+ part_detail: true
+ },
+ model_formatter: (model: any) => {
+ let text = model?.part_detail?.full_name ?? model?.name;
+ if (model.serial && model.quantity == 1) {
+ text += ` # ${model.serial}`;
+ }
+ return text;
+ },
+ icon: 'stock',
+ model: ModelType.stockitem,
+ hidden: !instance?.belongs_to
+ },
+ {
+ type: 'link',
+ name: 'parent',
+ icon: 'sitemap',
+ label: t`Parent Item`,
+ model: ModelType.stockitem,
+ hidden: !instance?.parent,
+ model_formatter: () => t`Parent stock item`
+ },
+ {
+ type: 'link',
+ name: 'consumed_by',
+ label: t`Consumed By`,
+ model: ModelType.build,
+ hidden: !instance?.consumed_by,
+ icon: 'build',
+ model_field: 'reference'
+ },
+ {
+ type: 'link',
+ name: 'build',
+ label: t`Build Order`,
+ model: ModelType.build,
+ hidden: !instance?.build,
+ model_field: 'reference'
+ },
+ {
+ type: 'link',
+ name: 'purchase_order',
+ label: t`Purchase Order`,
+ model: ModelType.purchaseorder,
+ hidden: !instance?.purchase_order,
+ icon: 'purchase_orders',
+ model_field: 'reference'
+ },
+ {
+ type: 'link',
+ name: 'sales_order',
+ label: t`Sales Order`,
+ model: ModelType.salesorder,
+ hidden: !instance?.sales_order,
+ icon: 'sales_orders',
+ model_field: 'reference'
+ },
+ {
+ type: 'link',
+ name: 'customer',
+ label: t`Customer`,
+ model: ModelType.company,
+ hidden: !instance?.customer
+ }
+ ];
+
+ const br: DetailsField[] = [
+ {
+ type: 'date',
+ name: 'expiry_date',
+ label: t`Expiry Date`,
+ hidden: !enableExpiry || !instance?.expiry_date,
+ icon: 'calendar'
+ },
+ {
+ type: 'text',
+ name: 'purchase_price',
+ label: t`Unit Price`,
+ icon: 'currency',
+ hidden: !instance?.purchase_price,
+ value_formatter: () =>
+ formatCurrency(instance?.purchase_price, {
+ currency: instance?.purchase_price_currency
+ })
+ },
+ {
+ type: 'text',
+ name: 'stock_value',
+ label: t`Stock Value`,
+ icon: 'currency',
+ hidden:
+ !instance?.purchase_price ||
+ instance?.quantity == 1 ||
+ instance?.quantity == 0,
+ value_formatter: () =>
+ formatCurrency(instance?.purchase_price, {
+ currency: instance?.purchase_price_currency,
+ multiplier: instance?.quantity
+ })
+ },
+ {
+ type: 'text',
+ name: 'packaging',
+ icon: 'part',
+ label: t`Packaging`,
+ hidden: !instance?.packaging
+ },
+ {
+ type: 'date',
+ name: 'creation_date',
+ icon: 'calendar',
+ label: t`Created`,
+ hidden: !instance?.creation_date
+ },
+ {
+ type: 'date',
+ name: 'updated',
+ icon: 'calendar',
+ label: t`Last Updated`
+ },
+ {
+ type: 'date',
+ name: 'stocktake_date',
+ icon: 'calendar',
+ label: t`Last Stocktake`,
+ hidden: !instance?.stocktake_date
+ }
+ ];
+
+ if (!instance?.pk) return ;
+
+ return (
+ <>
+ {showSerialNav && findBySerialNumber.modal}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}