mirror of
https://github.com/inventree/InvenTree.git
synced 2025-10-30 04:35:42 +00:00
* Cleaner handling of inputs * Fix for frontend form: - Fix typo in field - Better option defaults * Tweak part category delete form * Add frontend tests
454 lines
12 KiB
TypeScript
454 lines
12 KiB
TypeScript
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 { StockOperationProps } from '@lib/types/Forms';
|
|
import { t } from '@lingui/core/macro';
|
|
import { Group, Skeleton, Stack, Text } from '@mantine/core';
|
|
import { IconInfoCircle, IconPackages, IconSitemap } from '@tabler/icons-react';
|
|
import { useMemo, useState } from 'react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import { api } from '../../App';
|
|
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 { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
|
import {
|
|
BarcodeActionDropdown,
|
|
DeleteItemAction,
|
|
EditItemAction,
|
|
OptionsActionDropdown
|
|
} from '../../components/items/ActionDropdown';
|
|
import { ApiIcon } from '../../components/items/ApiIcon';
|
|
import InstanceDetail from '../../components/nav/InstanceDetail';
|
|
import NavigationTree from '../../components/nav/NavigationTree';
|
|
import { PageDetail } from '../../components/nav/PageDetail';
|
|
import type { PanelType } from '../../components/panels/Panel';
|
|
import { PanelGroup } from '../../components/panels/PanelGroup';
|
|
import LocateItemButton from '../../components/plugins/LocateItemButton';
|
|
import { stockLocationFields } from '../../forms/StockForms';
|
|
import { InvenTreeIcon } from '../../functions/icons';
|
|
import {
|
|
useDeleteApiFormModal,
|
|
useEditApiFormModal
|
|
} from '../../hooks/UseForm';
|
|
import { useInstance } from '../../hooks/UseInstance';
|
|
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
|
|
import { useUserState } from '../../states/UserState';
|
|
import { PartListTable } from '../../tables/part/PartTable';
|
|
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
|
import { StockLocationTable } from '../../tables/stock/StockLocationTable';
|
|
|
|
export default function Stock() {
|
|
const { id: _id } = useParams();
|
|
|
|
const id = useMemo(
|
|
() => (!Number.isNaN(Number.parseInt(_id || '')) ? _id : undefined),
|
|
[_id]
|
|
);
|
|
|
|
const navigate = useNavigate();
|
|
const user = useUserState();
|
|
|
|
const [treeOpen, setTreeOpen] = useState(false);
|
|
|
|
const {
|
|
instance: location,
|
|
refreshInstance,
|
|
instanceQuery
|
|
} = useInstance({
|
|
endpoint: ApiEndpoints.stock_location_list,
|
|
hasPrimaryKey: true,
|
|
pk: id,
|
|
params: {
|
|
path_detail: true
|
|
}
|
|
});
|
|
|
|
const detailsPanel = useMemo(() => {
|
|
if (id && instanceQuery.isFetching) {
|
|
return <Skeleton />;
|
|
}
|
|
|
|
const left: DetailsField[] = [
|
|
{
|
|
type: 'text',
|
|
name: 'name',
|
|
label: t`Name`,
|
|
copy: true,
|
|
value_formatter: () => (
|
|
<Group gap='xs'>
|
|
{location.icon && <ApiIcon name={location.icon} />}
|
|
{location.name}
|
|
</Group>
|
|
)
|
|
},
|
|
{
|
|
type: 'text',
|
|
name: 'pathstring',
|
|
label: t`Path`,
|
|
icon: 'sitemap',
|
|
copy: true,
|
|
hidden: !id
|
|
},
|
|
{
|
|
type: 'text',
|
|
name: 'description',
|
|
label: t`Description`,
|
|
copy: true
|
|
},
|
|
{
|
|
type: 'link',
|
|
name: 'parent',
|
|
model_field: 'name',
|
|
icon: 'location',
|
|
label: t`Parent Location`,
|
|
model: ModelType.stocklocation,
|
|
hidden: !location?.parent
|
|
}
|
|
];
|
|
|
|
const right: DetailsField[] = [
|
|
{
|
|
type: 'text',
|
|
name: 'items',
|
|
icon: 'stock',
|
|
label: t`Stock Items`,
|
|
value_formatter: () => location?.items || '0'
|
|
},
|
|
{
|
|
type: 'text',
|
|
name: 'sublocations',
|
|
icon: 'location',
|
|
label: t`Sublocations`,
|
|
hidden: !location?.sublocations
|
|
},
|
|
{
|
|
type: 'boolean',
|
|
name: 'structural',
|
|
label: t`Structural`,
|
|
icon: 'sitemap'
|
|
},
|
|
{
|
|
type: 'boolean',
|
|
name: 'external',
|
|
label: t`External`
|
|
},
|
|
{
|
|
type: 'string',
|
|
// TODO: render location type icon here (ref: #7237)
|
|
name: 'location_type_detail.name',
|
|
label: t`Location Type`,
|
|
hidden: !location?.location_type,
|
|
icon: 'packages'
|
|
}
|
|
];
|
|
|
|
return (
|
|
<ItemDetailsGrid>
|
|
{id && location?.pk ? (
|
|
<DetailsTable item={location} fields={left} />
|
|
) : (
|
|
<Text>{t`Top level stock location`}</Text>
|
|
)}
|
|
{id && location?.pk && <DetailsTable item={location} fields={right} />}
|
|
</ItemDetailsGrid>
|
|
);
|
|
}, [location, instanceQuery]);
|
|
|
|
const locationPanels: PanelType[] = useMemo(() => {
|
|
return [
|
|
{
|
|
name: 'details',
|
|
label: t`Location Details`,
|
|
icon: <IconInfoCircle />,
|
|
content: detailsPanel
|
|
},
|
|
{
|
|
name: 'sublocations',
|
|
label: id ? t`Sublocations` : t`Stock Locations`,
|
|
icon: <IconSitemap />,
|
|
content: <StockLocationTable parentId={id} />
|
|
},
|
|
{
|
|
name: 'stock-items',
|
|
label: t`Stock Items`,
|
|
icon: <IconPackages />,
|
|
content: (
|
|
<StockItemTable
|
|
tableName='location-stock'
|
|
allowAdd
|
|
params={{
|
|
location: id
|
|
}}
|
|
/>
|
|
)
|
|
},
|
|
{
|
|
name: 'default_parts',
|
|
label: t`Default Parts`,
|
|
icon: <IconPackages />,
|
|
hidden: !location.pk,
|
|
content: (
|
|
<PartListTable
|
|
props={{
|
|
params: {
|
|
default_location: location.pk
|
|
}
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
];
|
|
}, [location, id]);
|
|
|
|
const editLocation = useEditApiFormModal({
|
|
url: ApiEndpoints.stock_location_list,
|
|
pk: id,
|
|
title: t`Edit Stock Location`,
|
|
fields: stockLocationFields(),
|
|
onFormSuccess: refreshInstance
|
|
});
|
|
|
|
const deleteOptions = useMemo(() => {
|
|
return [
|
|
{
|
|
value: 'false',
|
|
display_name: t`Move items to parent location`
|
|
},
|
|
{
|
|
value: 'true',
|
|
display_name: t`Delete items`
|
|
}
|
|
];
|
|
}, []);
|
|
|
|
const deleteLocation = useDeleteApiFormModal({
|
|
url: ApiEndpoints.stock_location_list,
|
|
pk: id,
|
|
title: t`Delete Stock Location`,
|
|
fields: {
|
|
delete_stock_items: {
|
|
label: t`Items Action`,
|
|
required: true,
|
|
description: t`Action for stock items in this location`,
|
|
field_type: 'choice',
|
|
choices: deleteOptions
|
|
},
|
|
delete_sub_locations: {
|
|
label: t`Locations Action`,
|
|
required: true,
|
|
description: t`Action for child locations in this location`,
|
|
field_type: 'choice',
|
|
choices: deleteOptions
|
|
}
|
|
},
|
|
onFormSuccess: () => {
|
|
if (location.parent) {
|
|
navigate(getDetailUrl(ModelType.stocklocation, location.parent));
|
|
} else {
|
|
navigate('/stock/');
|
|
}
|
|
}
|
|
});
|
|
|
|
const stockOperationProps: StockOperationProps = useMemo(() => {
|
|
return {
|
|
pk: location.pk,
|
|
model: 'location',
|
|
refresh: refreshInstance,
|
|
filters: {
|
|
in_stock: true
|
|
}
|
|
};
|
|
}, [location]);
|
|
|
|
const stockAdjustActions = useStockAdjustActions({
|
|
formProps: stockOperationProps,
|
|
enabled: true,
|
|
delete: false,
|
|
merge: false,
|
|
assign: false
|
|
});
|
|
|
|
const scanInStockItem = useBarcodeScanDialog({
|
|
title: t`Scan Stock Item`,
|
|
modelType: ModelType.stockitem,
|
|
callback: async (barcode, response) => {
|
|
const item = response.stockitem.instance;
|
|
|
|
// Scan the stock item into the current location
|
|
return api
|
|
.post(apiUrl(ApiEndpoints.stock_transfer), {
|
|
location: location.pk,
|
|
items: [
|
|
{
|
|
pk: item.pk,
|
|
quantity: item.quantity
|
|
}
|
|
]
|
|
})
|
|
.then(() => {
|
|
return {
|
|
success: t`Scanned stock item into location`
|
|
};
|
|
})
|
|
.catch((error) => {
|
|
console.error('Error scanning stock item:', error);
|
|
return {
|
|
error: t`Error scanning stock item`
|
|
};
|
|
});
|
|
}
|
|
});
|
|
|
|
const scanInStockLocation = useBarcodeScanDialog({
|
|
title: t`Scan Stock Location`,
|
|
modelType: ModelType.stocklocation,
|
|
callback: async (barcode, response) => {
|
|
const pk = response.stocklocation.pk;
|
|
|
|
// Set the parent location
|
|
return api
|
|
.patch(apiUrl(ApiEndpoints.stock_location_list, pk), {
|
|
parent: location.pk
|
|
})
|
|
.then(() => {
|
|
return {
|
|
success: t`Scanned stock location into location`
|
|
};
|
|
})
|
|
.catch((error) => {
|
|
console.error('Error scanning stock location:', error);
|
|
return {
|
|
error: t`Error scanning stock location`
|
|
};
|
|
});
|
|
}
|
|
});
|
|
|
|
const locationActions = useMemo(
|
|
() => [
|
|
<AdminButton model={ModelType.stocklocation} id={location.pk} />,
|
|
<LocateItemButton locationId={location.pk} />,
|
|
location.pk ? (
|
|
<BarcodeActionDropdown
|
|
model={ModelType.stocklocation}
|
|
pk={location.pk}
|
|
hash={location?.barcode_hash}
|
|
perm={user.hasChangeRole(UserRoles.stock_location)}
|
|
actions={[
|
|
{
|
|
name: 'Scan in stock items',
|
|
icon: <InvenTreeIcon icon='stock' />,
|
|
tooltip: 'Scan item into this location',
|
|
onClick: scanInStockItem.open
|
|
},
|
|
{
|
|
name: 'Scan in container',
|
|
icon: <InvenTreeIcon icon='unallocated_stock' />,
|
|
tooltip: 'Scan container into this location',
|
|
onClick: scanInStockLocation.open
|
|
}
|
|
]}
|
|
/>
|
|
) : null,
|
|
<PrintingActions
|
|
modelType={ModelType.stocklocation}
|
|
items={[location.pk ?? 0]}
|
|
hidden={!location?.pk}
|
|
enableLabels
|
|
enableReports
|
|
/>,
|
|
stockAdjustActions.dropdown,
|
|
<OptionsActionDropdown
|
|
tooltip={t`Location Actions`}
|
|
actions={[
|
|
EditItemAction({
|
|
hidden: !id || !user.hasChangeRole(UserRoles.stock_location),
|
|
tooltip: t`Edit Stock Location`,
|
|
onClick: () => editLocation.open()
|
|
}),
|
|
DeleteItemAction({
|
|
hidden: !id || !user.hasDeleteRole(UserRoles.stock_location),
|
|
tooltip: t`Delete Stock Location`,
|
|
onClick: () => deleteLocation.open()
|
|
})
|
|
]}
|
|
/>
|
|
],
|
|
[location, id, user, stockAdjustActions.dropdown]
|
|
);
|
|
|
|
const breadcrumbs = useMemo(
|
|
() => [
|
|
{ name: t`Stock`, url: '/stock' },
|
|
...(location.path ?? []).map((l: any) => ({
|
|
name: l.name,
|
|
url: getDetailUrl(ModelType.stocklocation, l.pk),
|
|
icon: l.icon ? <ApiIcon name={l.icon} /> : undefined
|
|
}))
|
|
],
|
|
[location]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{editLocation.modal}
|
|
{deleteLocation.modal}
|
|
{scanInStockItem.dialog}
|
|
{scanInStockLocation.dialog}
|
|
<InstanceDetail
|
|
query={instanceQuery}
|
|
requiredRole={UserRoles.stock_location}
|
|
>
|
|
<Stack>
|
|
<NavigationTree
|
|
title={t`Stock Locations`}
|
|
modelType={ModelType.stocklocation}
|
|
endpoint={ApiEndpoints.stock_location_tree}
|
|
opened={treeOpen}
|
|
onClose={() => setTreeOpen(false)}
|
|
selectedId={location?.pk}
|
|
/>
|
|
<PageDetail
|
|
title={(location?.name ?? id) ? t`Stock Location` : t`Stock`}
|
|
subtitle={location?.description}
|
|
icon={location?.icon && <ApiIcon name={location?.icon} />}
|
|
actions={locationActions}
|
|
editAction={editLocation.open}
|
|
editEnabled={
|
|
!!location?.pk &&
|
|
user.hasChangePermission(ModelType.stocklocation)
|
|
}
|
|
breadcrumbs={breadcrumbs}
|
|
lastCrumb={[
|
|
{
|
|
name: location.name,
|
|
url: `/stock/location/${location.pk}/`
|
|
}
|
|
]}
|
|
breadcrumbAction={() => {
|
|
setTreeOpen(true);
|
|
}}
|
|
/>
|
|
<PanelGroup
|
|
pageKey='stocklocation'
|
|
panels={locationPanels}
|
|
model={ModelType.stocklocation}
|
|
reloadInstance={refreshInstance}
|
|
id={location?.pk}
|
|
instance={location}
|
|
/>
|
|
</Stack>
|
|
{stockAdjustActions.modals.map((modal) => modal.modal)}
|
|
</InstanceDetail>
|
|
</>
|
|
);
|
|
}
|