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 && ( - - - - )} - } - tooltip={t`Find serial number`} - tooltipAlignment='top' - variant='transparent' - onClick={findBySerialNumber.open} - /> - {serialNumbers.next?.pk && ( - - - - )} - - - ) - }, - { - 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 && ( + + + + )} + } + tooltip={t`Find serial number`} + tooltipAlignment='top' + variant='transparent' + onClick={findBySerialNumber.open} + /> + {serialNumbers?.next?.pk && ( + + + + )} + + + ) + }) + }, + { + 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} + + + + + + + + + + + + + + + + ); +}