Apply same to StockItem

This commit is contained in:
Oliver Walters 2026-06-30 13:30:12 +00:00
parent bd0455c940
commit 85e2907f60
4 changed files with 455 additions and 382 deletions

View File

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

View File

@ -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: <StockDetailsPanel instance={instance} />
};
}

View File

@ -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 <Skeleton />;
}
// 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: () => (
<Group gap='xs' justify='space-apart'>
<Text>{stockitem.serial}</Text>
<Space flex={10} />
<Group gap={2} justify='right'>
{serialNumbers.previous?.pk && (
<Tooltip label={t`Previous serial number`} position='top'>
<Button
p={3}
aria-label='previous-serial-number'
leftSection={<IconArrowLeft />}
variant='transparent'
size='sm'
onClick={() => {
navigate(
getDetailUrl(
ModelType.stockitem,
serialNumbers.previous.pk
)
);
}}
>
{serialNumbers.previous.serial}
</Button>
</Tooltip>
)}
<ActionButton
icon={<IconSearch size={18} />}
tooltip={t`Find serial number`}
tooltipAlignment='top'
variant='transparent'
onClick={findBySerialNumber.open}
/>
{serialNumbers.next?.pk && (
<Tooltip label={t`Next serial number`} position='top'>
<Button
p={3}
aria-label='next-serial-number'
rightSection={<IconArrowRight />}
variant='transparent'
size='sm'
onClick={() => {
navigate(
getDetailUrl(ModelType.stockitem, serialNumbers.next.pk)
);
}}
>
{serialNumbers.next.serial}
</Button>
</Tooltip>
)}
</Group>
</Group>
)
},
{
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 (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
apiPath={ApiEndpoints.part_list}
src={
stockitem.part_detail?.image ??
stockitem?.part_detail?.thumbnail
}
pk={stockitem.part}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={stockitem.tags} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
</ItemDetailsGrid>
);
}, [
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: <IconInfoCircle />,
content: detailsPanel
content: (
<StockDetailsPanel
instance={stockitem}
allowImageEdit
showSerialNav
refreshInstance={refreshInstance}
/>
)
},
{
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}
<InstanceDetail
query={instanceQuery}

View File

@ -0,0 +1,410 @@
import { t } from '@lingui/core/macro';
import {
Button,
Grid,
Group,
Skeleton,
Space,
Stack,
Text,
Tooltip
} from '@mantine/core';
import { IconArrowLeft, IconArrowRight, IconSearch } from '@tabler/icons-react';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { ActionButton } from '@lib/components/ActionButton';
import TagsList from '@lib/components/TagsList';
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 } from '@lib/functions/Navigation';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { formatCurrency } from '../../defaults/formatters';
import { useFindSerialNumberForm } from '../../forms/StockForms';
import { useInstance } from '../../hooks/UseInstance';
import { useGlobalSettingsState } from '../../states/SettingsStates';
export function StockDetailsPanel({
instance,
allowImageEdit = false,
showSerialNav = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
showSerialNav?: boolean;
refreshInstance?: () => 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: () => (
<Group gap='xs' justify='space-apart'>
<Text>{instance?.serial}</Text>
<Space flex={10} />
<Group gap={2} justify='right'>
{serialNumbers?.previous?.pk && (
<Tooltip label={t`Previous serial number`} position='top'>
<Button
p={3}
aria-label='previous-serial-number'
leftSection={<IconArrowLeft />}
variant='transparent'
size='sm'
onClick={() => {
navigate(
getDetailUrl(
ModelType.stockitem,
serialNumbers.previous.pk
)
);
}}
>
{serialNumbers.previous.serial}
</Button>
</Tooltip>
)}
<ActionButton
icon={<IconSearch size={18} />}
tooltip={t`Find serial number`}
tooltipAlignment='top'
variant='transparent'
onClick={findBySerialNumber.open}
/>
{serialNumbers?.next?.pk && (
<Tooltip label={t`Next serial number`} position='top'>
<Button
p={3}
aria-label='next-serial-number'
rightSection={<IconArrowRight />}
variant='transparent'
size='sm'
onClick={() => {
navigate(
getDetailUrl(ModelType.stockitem, serialNumbers.next.pk)
);
}}
>
{serialNumbers.next.serial}
</Button>
</Tooltip>
)}
</Group>
</Group>
)
})
},
{
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 <Skeleton />;
return (
<>
{showSerialNav && findBySerialNumber.modal}
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.part : undefined}
imageActions={
allowImageEdit
? {
uploadFile: true,
deleteFile: true
}
: {}
}
apiPath={apiUrl(ApiEndpoints.part_list, instance?.part)}
src={part?.image ?? part?.thumbnail}
pk={instance?.part}
refresh={refreshInstance}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
</ItemDetailsGrid>
</>
);
}