2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-08 08:01:38 +00:00
Files
InvenTree/src/frontend/src/forms/StockForms.tsx
T
Oliver 2fe0eefa8f Adds "active" field for Company model (#7024)
* Add "active" field to Company model

* Expose 'active' parameter to API

* Fix default value

* Add 'active' column to PUI

* Update PUI table

* Update company detail pages

* Update API filters for SupplierPart and ManufacturerPart

* Bump API version

* Update order forms

* Add edit action to SalesOrderDetail page

* Enable editing of ReturnOrder

* Typo fix

* Adds explicit "active" field to SupplierPart model

* More updates

- Add "inactive" badge to SupplierPart page
- Update SupplierPartTable
- Update backend API fields

* Update ReturnOrderTable

- Also some refactoring

* Impove usePurchaseOrderLineItemFields hook

* Cleanup

* Implement duplicate action for SupplierPart

* Fix for ApiForm

- Only override initialValues for specified fields

* Allow edit and duplicate of StockItem

* Fix for ApiForm

- Default values were overriding initial data

* Add duplicate part option

* Cleanup ApiForm

- Cache props.fields

* Fix unused import

* More fixes

* Add unit tests

* Allow ordering company by 'active' status

* Update docs

* Merge migrations

* Fix for serializers.py

* Force new form value

* Remove debug call

* Further unit test fixes

* Update default CSRF_TRUSTED_ORIGINS values

* Reduce debug output
2024-04-20 23:18:25 +10:00

800 lines
18 KiB
TypeScript

import { t } from '@lingui/macro';
import { Flex, NumberInput, Skeleton, Text } from '@mantine/core';
import { modals } from '@mantine/modals';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { Suspense, useCallback, useMemo, useState } from 'react';
import { api } from '../App';
import { ActionButton } from '../components/buttons/ActionButton';
import {
ApiFormAdjustFilterType,
ApiFormFieldSet
} from '../components/forms/fields/ApiFormField';
import { Thumbnail } from '../components/images/Thumbnail';
import { StylishText } from '../components/items/StylishText';
import { StatusRenderer } from '../components/render/StatusRenderer';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { InvenTreeIcon } from '../functions/icons';
import {
ApiFormModalProps,
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../hooks/UseForm';
import { apiUrl } from '../states/ApiState';
/**
* Construct a set of fields for creating / editing a StockItem instance
*/
export function useStockFields({
create = false
}: {
create: boolean;
}): ApiFormFieldSet {
const [part, setPart] = useState<number | null>(null);
const [supplierPart, setSupplierPart] = useState<number | null>(null);
return useMemo(() => {
const fields: ApiFormFieldSet = {
part: {
value: part,
disabled: !create,
onValueChange: (change) => {
setPart(change);
// TODO: implement remaining functionality from old stock.py
// Clear the 'supplier_part' field if the part is changed
setSupplierPart(null);
}
},
supplier_part: {
// TODO: icon
value: supplierPart,
onValueChange: setSupplierPart,
filters: {
part_detail: true,
supplier_detail: true,
...(part ? { part } : {})
},
adjustFilters: (adjust: ApiFormAdjustFilterType) => {
if (adjust.data.part) {
adjust.filters['part'] = adjust.data.part;
}
return adjust.filters;
}
},
use_pack_size: {
hidden: !create,
description: t`Add given quantity as packs instead of individual items`
},
location: {
hidden: !create,
filters: {
structural: false
}
// TODO: icon
},
quantity: {
hidden: !create,
description: t`Enter initial quantity for this stock item`
},
serial_numbers: {
// TODO: icon
field_type: 'string',
label: t`Serial Numbers`,
description: t`Enter serial numbers for new stock (or leave blank)`,
required: false,
hidden: !create
},
serial: {
hidden: create
// TODO: icon
},
batch: {
// TODO: icon
},
status: {},
expiry_date: {
// TODO: icon
},
purchase_price: {
// TODO: icon
},
purchase_price_currency: {
// TODO: icon
},
packaging: {
// TODO: icon,
},
link: {
// TODO: icon
},
owner: {
// TODO: icon
},
delete_on_deplete: {}
};
// TODO: Handle custom field management based on provided options
// TODO: refer to stock.py in original codebase
return fields;
}, [part, supplierPart]);
}
/**
* Launch a form to create a new StockItem instance
*/
export function useCreateStockItem() {
const fields = useStockFields({ create: true });
return useCreateApiFormModal({
url: ApiEndpoints.stock_item_list,
fields: fields,
title: t`Add Stock Item`
});
}
function StockItemDefaultMove({
stockItem,
value
}: {
stockItem: any;
value: any;
}) {
console.log('item', stockItem);
const { data } = useSuspenseQuery({
queryKey: [
'location',
stockItem.part_detail.default_location ??
stockItem.part_detail.category_default_location
],
queryFn: async () => {
const url = apiUrl(
ApiEndpoints.stock_location_list,
stockItem.part_detail.default_location ??
stockItem.part_detail.category_default_location
);
return api
.get(url)
.then((response) => {
switch (response.status) {
case 200:
return response.data;
default:
return null;
}
})
.catch(() => {
return null;
});
}
});
return (
<Flex gap="sm" justify="space-evenly" align="center">
<Flex gap="sm" direction="column" align="center">
<Text>
{value} x {stockItem.part_detail.name}
</Text>
<Thumbnail
src={stockItem.part_detail.thumbnail}
size={80}
align="center"
/>
</Flex>
<Flex direction="column" gap="sm" align="center">
<Text>{stockItem.location_detail.pathstring}</Text>
<InvenTreeIcon icon="arrow_down" />
<Suspense fallback={<Skeleton width="150px" />}>
<Text>{data?.pathstring}</Text>
</Suspense>
</Flex>
</Flex>
);
}
function moveToDefault(
stockItem: any,
value: StockItemQuantity,
refresh: () => void
) {
modals.openConfirmModal({
title: <StylishText>Confirm Stock Transfer</StylishText>,
children: <StockItemDefaultMove stockItem={stockItem} value={value} />,
onConfirm: () => {
if (
stockItem.location === stockItem.part_detail.default_location ||
stockItem.location === stockItem.part_detail.category_default_location
) {
return;
}
api
.post(apiUrl(ApiEndpoints.stock_transfer), {
items: [
{
pk: stockItem.pk,
quantity: value,
batch: stockItem.batch,
status: stockItem.status
}
],
location:
stockItem.part_detail.default_location ??
stockItem.part_detail.category_default_location
})
.then((response) => {
refresh();
return response.data;
})
.catch(() => {
return null;
});
}
});
}
type StockAdjustmentItemWithRecord = {
obj: any;
} & StockAdjustmentItem;
type TableFieldRefreshFn = (idx: number) => void;
type TableFieldChangeFn = (idx: number, key: string, value: any) => void;
type StockRow = {
item: StockAdjustmentItemWithRecord;
idx: number;
changeFn: TableFieldChangeFn;
removeFn: TableFieldRefreshFn;
};
function StockOperationsRow({
input,
transfer = false,
add = false,
setMax = false,
merge = false,
record
}: {
input: StockRow;
transfer?: boolean;
add?: boolean;
setMax?: boolean;
merge?: boolean;
record?: any;
}) {
const item = input.item;
console.log('rec', record);
const [value, setValue] = useState<StockItemQuantity>(
add ? 0 : item.quantity ?? 0
);
const onChange = useCallback(
(value: any) => {
setValue(value);
input.changeFn(input.idx, 'quantity', value);
},
[item]
);
const removeAndRefresh = () => {
input.removeFn(input.idx);
};
return (
<tr>
<td>
<Flex gap="sm" align="center">
<Thumbnail
size={40}
src={record.part_detail.thumbnail}
align="center"
/>
<div>{record.part_detail.name}</div>
</Flex>
</td>
<td>{record.location ? record.location_detail.pathstring : '-'}</td>
<td>
<Flex align="center" gap="xs">
<Text>{record.quantity}</Text>
<StatusRenderer status={record.status} type={ModelType.stockitem} />
</Flex>
</td>
{!merge && (
<td>
<NumberInput
value={value}
onChange={onChange}
max={setMax ? record.quantity : undefined}
min={0}
style={{ maxWidth: '100px' }}
/>
</td>
)}
<td>
<Flex gap="3px">
{transfer && (
<ActionButton
onClick={() => moveToDefault(record, value, removeAndRefresh)}
icon={<InvenTreeIcon icon="default_location" />}
tooltip={t`Move to default location`}
tooltipAlignment="top"
disabled={
!record.part_detail.default_location &&
!record.part_detail.category_default_location
}
/>
)}
<ActionButton
onClick={() => input.removeFn(input.idx)}
icon={<InvenTreeIcon icon="square_x" />}
tooltip={t`Remove item from list`}
tooltipAlignment="top"
color="red"
/>
</Flex>
</td>
</tr>
);
}
type StockItemQuantity = number | '' | undefined;
type StockAdjustmentItem = {
pk: number;
quantity: StockItemQuantity;
batch?: string;
status?: number | '' | null;
packaging?: string;
};
function mapAdjustmentItems(items: any[]) {
const mappedItems: StockAdjustmentItemWithRecord[] = items.map((elem) => {
return {
pk: elem.pk,
quantity: elem.quantity,
batch: elem.batch,
status: elem.status,
packaging: elem.packaging,
obj: elem
};
});
return mappedItems;
}
function stockTransferFields(items: any[]): ApiFormFieldSet {
if (!items) {
return {};
}
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
const fields: ApiFormFieldSet = {
items: {
field_type: 'table',
value: mapAdjustmentItems(items),
modelRenderer: (val) => {
return (
<StockOperationsRow
input={val}
transfer
setMax
key={val.item.pk}
record={records[val.item.pk]}
/>
);
},
headers: [t`Part`, t`Location`, t`In Stock`, t`Move`, t`Actions`]
},
location: {
filters: {
structural: false
}
// TODO: icon
},
notes: {}
};
return fields;
}
function stockRemoveFields(items: any[]): ApiFormFieldSet {
if (!items) {
return {};
}
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
const fields: ApiFormFieldSet = {
items: {
field_type: 'table',
value: mapAdjustmentItems(items),
modelRenderer: (val) => {
return (
<StockOperationsRow
input={val}
setMax
key={val.item.pk}
record={records[val.item.pk]}
/>
);
},
headers: [t`Part`, t`Location`, t`In Stock`, t`Remove`, t`Actions`]
},
notes: {}
};
return fields;
}
function stockAddFields(items: any[]): ApiFormFieldSet {
if (!items) {
return {};
}
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
const fields: ApiFormFieldSet = {
items: {
field_type: 'table',
value: mapAdjustmentItems(items),
modelRenderer: (val) => {
return (
<StockOperationsRow
input={val}
add
key={val.item.pk}
record={records[val.item.pk]}
/>
);
},
headers: [t`Part`, t`Location`, t`In Stock`, t`Add`, t`Actions`]
},
notes: {}
};
return fields;
}
function stockCountFields(items: any[]): ApiFormFieldSet {
if (!items) {
return {};
}
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
const fields: ApiFormFieldSet = {
items: {
field_type: 'table',
value: mapAdjustmentItems(items),
modelRenderer: (val) => {
return (
<StockOperationsRow
input={val}
key={val.item.pk}
record={records[val.item.pk]}
/>
);
},
headers: [t`Part`, t`Location`, t`In Stock`, t`Count`, t`Actions`]
},
notes: {}
};
return fields;
}
function stockChangeStatusFields(items: any[]): ApiFormFieldSet {
if (!items) {
return {};
}
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
const fields: ApiFormFieldSet = {
items: {
field_type: 'table',
value: items.map((elem) => {
return elem.pk;
}),
modelRenderer: (val) => {
return (
<StockOperationsRow
input={val}
key={val.item}
merge
record={records[val.item]}
/>
);
},
headers: [t`Part`, t`Location`, t`In Stock`, t`Actions`]
},
status: {},
note: {}
};
return fields;
}
function stockMergeFields(items: any[]): ApiFormFieldSet {
if (!items) {
return {};
}
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
const fields: ApiFormFieldSet = {
items: {
field_type: 'table',
value: items.map((elem) => {
return {
item: elem.pk,
obj: elem
};
}),
modelRenderer: (val) => {
return (
<StockOperationsRow
input={val}
key={val.item.item}
merge
record={records[val.item.item]}
/>
);
},
headers: [t`Part`, t`Location`, t`In Stock`, t`Actions`]
},
location: {
default: items[0]?.part_detail.default_location,
filters: {
structural: false
}
},
notes: {},
allow_mismatched_suppliers: {},
allow_mismatched_status: {}
};
return fields;
}
function stockAssignFields(items: any[]): ApiFormFieldSet {
if (!items) {
return {};
}
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
const fields: ApiFormFieldSet = {
items: {
field_type: 'table',
value: items.map((elem) => {
return {
item: elem.pk,
obj: elem
};
}),
modelRenderer: (val) => {
return (
<StockOperationsRow
input={val}
key={val.item.item}
merge
record={records[val.item.item]}
/>
);
},
headers: [t`Part`, t`Location`, t`In Stock`, t`Actions`]
},
customer: {
filters: {
is_customer: true
}
},
notes: {}
};
return fields;
}
function stockDeleteFields(items: any[]): ApiFormFieldSet {
if (!items) {
return {};
}
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
const fields: ApiFormFieldSet = {
items: {
field_type: 'table',
value: items.map((elem) => {
return elem.pk;
}),
modelRenderer: (val) => {
return (
<StockOperationsRow
input={val}
key={val.item}
merge
record={records[val.item]}
/>
);
},
headers: [t`Part`, t`Location`, t`In Stock`, t`Actions`]
}
};
return fields;
}
type apiModalFunc = (props: ApiFormModalProps) => {
open: () => void;
close: () => void;
toggle: () => void;
modal: JSX.Element;
};
function stockOperationModal({
items,
pk,
model,
refresh,
fieldGenerator,
endpoint,
title,
modalFunc = useCreateApiFormModal
}: {
items?: object;
pk?: number;
model: ModelType | string;
refresh: () => void;
fieldGenerator: (items: any[]) => ApiFormFieldSet;
endpoint: ApiEndpoints;
title: string;
modalFunc?: apiModalFunc;
}) {
const params: any = {
part_detail: true,
location_detail: true,
cascade: false
};
// A Stock item can have location=null, but not part=null
params[model] = pk === undefined && model === 'location' ? 'null' : pk;
const { data } = useQuery({
queryKey: ['stockitems', model, pk, items],
queryFn: async () => {
if (items) {
return Array.isArray(items) ? items : [items];
}
const url = apiUrl(ApiEndpoints.stock_item_list);
return api
.get(url, {
params: params
})
.then((response) => {
if (response.status === 200) {
return response.data;
}
})
.catch(() => {
return null;
});
}
});
const fields = useMemo(() => {
return fieldGenerator(data);
}, [data]);
return modalFunc({
url: endpoint,
fields: fields,
title: title,
onFormSuccess: () => refresh()
});
}
export type StockOperationProps = {
items?: object;
pk?: number;
model: ModelType.stockitem | 'location' | ModelType.part;
refresh: () => void;
};
export function useAddStockItem(props: StockOperationProps) {
return stockOperationModal({
...props,
fieldGenerator: stockAddFields,
endpoint: ApiEndpoints.stock_add,
title: t`Add Stock`
});
}
export function useRemoveStockItem(props: StockOperationProps) {
return stockOperationModal({
...props,
fieldGenerator: stockRemoveFields,
endpoint: ApiEndpoints.stock_remove,
title: t`Remove Stock`
});
}
export function useTransferStockItem(props: StockOperationProps) {
return stockOperationModal({
...props,
fieldGenerator: stockTransferFields,
endpoint: ApiEndpoints.stock_transfer,
title: t`Transfer Stock`
});
}
export function useCountStockItem(props: StockOperationProps) {
return stockOperationModal({
...props,
fieldGenerator: stockCountFields,
endpoint: ApiEndpoints.stock_count,
title: t`Count Stock`
});
}
export function useChangeStockStatus(props: StockOperationProps) {
return stockOperationModal({
...props,
fieldGenerator: stockChangeStatusFields,
endpoint: ApiEndpoints.stock_change_status,
title: t`Change Stock Status`
});
}
export function useMergeStockItem(props: StockOperationProps) {
return stockOperationModal({
...props,
fieldGenerator: stockMergeFields,
endpoint: ApiEndpoints.stock_merge,
title: t`Merge Stock`
});
}
export function useAssignStockItem(props: StockOperationProps) {
return stockOperationModal({
...props,
fieldGenerator: stockAssignFields,
endpoint: ApiEndpoints.stock_assign,
title: `Assign Stock to Customer`
});
}
export function useDeleteStockItem(props: StockOperationProps) {
return stockOperationModal({
...props,
fieldGenerator: stockDeleteFields,
endpoint: ApiEndpoints.stock_item_list,
modalFunc: useDeleteApiFormModal,
title: t`Delete Stock Items`
});
}
export function stockLocationFields({}: {}): ApiFormFieldSet {
let fields: ApiFormFieldSet = {
parent: {
description: t`Parent stock location`,
required: false
},
name: {},
description: {},
structural: {},
external: {},
location_type: {}
};
return fields;
}