2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 12:05:53 +00:00

[PUI/Feature] Integrate Part "Default Location" into UX (#5972)

* Add default parts to location page

* Fix name strings

* Add Stock Transfer modal

* Add ApiForm Table field

* temp

* Add stock transfer form to part, stock item and location

* All stock operations for Item, Part, and Location added (except order new)

* Add default_location category traversal, and initial PO Line Item Receive form

* .

* Remove debug values

* Added PO line receive form

* Add functionality to PO receive extra fields

* .

* Forgot to bump API version

* Add Category Default to details panel

* Fix stockItem query count

* Fix reviewed issues

* .

* .

* .

* Prevent root category from checking parent for default location
This commit is contained in:
Lavissa
2024-03-15 02:06:18 +01:00
committed by GitHub
parent 6abd33f060
commit 0196dd2f60
22 changed files with 1785 additions and 57 deletions

View File

@ -443,7 +443,7 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
))}
<Button
onClick={form.handleSubmit(submitForm, onFormError)}
variant="outline"
variant="filled"
radius="sm"
color={props.submitColor ?? 'green'}
disabled={isLoading || (props.fetchInitialData && !isDirty)}

View File

@ -19,6 +19,7 @@ import { ChoiceField } from './ChoiceField';
import DateField from './DateField';
import { NestedObjectField } from './NestedObjectField';
import { RelatedModelField } from './RelatedModelField';
import { TableField } from './TableField';
export type ApiFormData = UseFormReturnType<Record<string, unknown>>;
@ -69,7 +70,8 @@ export type ApiFormFieldType = {
| 'number'
| 'choice'
| 'file upload'
| 'nested object';
| 'nested object'
| 'table';
api_url?: string;
model?: ModelType;
modelRenderer?: (instance: any) => ReactNode;
@ -86,6 +88,7 @@ export type ApiFormFieldType = {
postFieldContent?: JSX.Element;
onValueChange?: (value: any) => void;
adjustFilters?: (value: ApiFormAdjustFilterType) => any;
headers?: string[];
};
/**
@ -266,6 +269,14 @@ export function ApiFormField({
control={control}
/>
);
case 'table':
return (
<TableField
definition={definition}
fieldName={fieldName}
control={controller}
/>
);
default:
return (
<Alert color="red" title={t`Error`}>

View File

@ -30,7 +30,6 @@ export function RelatedModelField({
limit?: number;
}) {
const fieldId = useId();
const {
field,
fieldState: { error }
@ -60,7 +59,6 @@ export function RelatedModelField({
field.value !== ''
) {
const url = `${definition.api_url}${field.value}/`;
api.get(url).then((response) => {
if (response.data && response.data.pk) {
const value = {

View File

@ -0,0 +1,80 @@
import { Trans, t } from '@lingui/macro';
import { Table } from '@mantine/core';
import { FieldValues, UseControllerReturn } from 'react-hook-form';
import { InvenTreeIcon } from '../../../functions/icons';
import { ApiFormFieldType } from './ApiFormField';
export function TableField({
definition,
fieldName,
control
}: {
definition: ApiFormFieldType;
fieldName: string;
control: UseControllerReturn<FieldValues, any>;
}) {
const {
field,
fieldState: { error }
} = control;
const { value, ref } = field;
const onRowFieldChange = (idx: number, key: string, value: any) => {
const val = field.value;
val[idx][key] = value;
field.onChange(val);
};
const removeRow = (idx: number) => {
const val = field.value;
val.splice(idx, 1);
field.onChange(val);
};
return (
<Table highlightOnHover striped>
<thead>
<tr>
{definition.headers?.map((header) => {
return <th key={header}>{header}</th>;
})}
</tr>
</thead>
<tbody>
{value.length > 0 ? (
value.map((item: any, idx: number) => {
// Table fields require render function
if (!definition.modelRenderer) {
return <tr>{t`modelRenderer entry required for tables`}</tr>;
}
return definition.modelRenderer({
item: item,
idx: idx,
changeFn: onRowFieldChange,
removeFn: removeRow
});
})
) : (
<tr>
<td
style={{ textAlign: 'center' }}
colSpan={definition.headers?.length}
>
<span
style={{
display: 'flex',
justifyContent: 'center',
gap: '5px'
}}
>
<InvenTreeIcon icon="info" />
<Trans>No entries available</Trans>
</span>
</td>
</tr>
)}
</tbody>
</Table>
);
}

View File

@ -36,11 +36,13 @@ export type ActionDropdownItem = {
export function ActionDropdown({
icon,
tooltip,
actions
actions,
disabled = false
}: {
icon: ReactNode;
tooltip?: string;
actions: ActionDropdownItem[];
disabled?: boolean;
}) {
const hasActions = useMemo(() => {
return actions.some((action) => !action.hidden);
@ -54,7 +56,12 @@ export function ActionDropdown({
<Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}>
<Menu.Target>
<Tooltip label={tooltip} hidden={!tooltip}>
<ActionIcon size="lg" radius="sm" variant="outline">
<ActionIcon
size="lg"
radius="sm"
variant="outline"
disabled={disabled}
>
{icon}
</ActionIcon>
</Tooltip>

View File

@ -84,11 +84,20 @@ export enum ApiEndpoints {
stock_location_tree = 'stock/location/tree/',
stock_attachment_list = 'stock/attachment/',
stock_test_result_list = 'stock/test/',
stock_transfer = 'stock/transfer/',
stock_remove = 'stock/remove/',
stock_add = 'stock/add/',
stock_count = 'stock/count/',
stock_change_status = 'stock/change_status/',
stock_merge = 'stock/merge/',
stock_assign = 'stock/assign/',
stock_status = 'stock/status/',
// Order API endpoints
purchase_order_list = 'order/po/',
purchase_order_line_list = 'order/po-line/',
purchase_order_attachment_list = 'order/po/attachment/',
purchase_order_receive = 'order/po/:id/receive/',
sales_order_list = 'order/so/',
sales_order_attachment_list = 'order/so/attachment/',
sales_order_shipment_list = 'order/so/shipment/',

View File

@ -1,3 +1,6 @@
import { t } from '@lingui/macro';
import { Flex, FocusTrap, Modal, NumberInput, TextInput } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconAddressBook,
IconCalendar,
@ -11,12 +14,24 @@ import {
IconUser,
IconUsers
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { api } from '../App';
import { ActionButton } from '../components/buttons/ActionButton';
import { StandaloneField } from '../components/forms/StandaloneField';
import {
ApiFormAdjustFilterType,
ApiFormFieldSet
} from '../components/forms/fields/ApiFormField';
import { Thumbnail } from '../components/images/Thumbnail';
import { ProgressBar } from '../components/items/ProgressBar';
import { StylishText } from '../components/items/StylishText';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { InvenTreeIcon } from '../functions/icons';
import { useCreateApiFormModal } from '../hooks/UseForm';
import { apiUrl } from '../states/ApiState';
/*
* Construct a set of fields for creating / editing a PurchaseOrderLineItem instance
@ -143,3 +158,497 @@ export function purchaseOrderFields(): ApiFormFieldSet {
}
};
}
/**
* Render a table row for a single TableField entry
*/
function LineItemFormRow({
input,
record,
statuses
}: {
input: any;
record: any;
statuses: any;
}) {
// Barcode Modal state
const [opened, { open, close }] = useDisclosure(false);
// Location value
const [location, setLocation] = useState(
input.item.location ??
record.part_detail.default_location ??
record.part_detail.category_default_location
);
const [locationOpen, locationHandlers] = useDisclosure(
location ? true : false,
{
onClose: () => input.changeFn(input.idx, 'location', null),
onOpen: () => input.changeFn(input.idx, 'location', location)
}
);
// Change form value when state is altered
useEffect(() => {
input.changeFn(input.idx, 'location', location);
}, [location]);
// State for serializing
const [batchCode, setBatchCode] = useState<string>('');
const [serials, setSerials] = useState<string>('');
const [batchOpen, batchHandlers] = useDisclosure(false, {
onClose: () => {
input.changeFn(input.idx, 'batch_code', '');
input.changeFn(input.idx, 'serial_numbers', '');
}
});
// Change form value when state is altered
useEffect(() => {
input.changeFn(input.idx, 'batch_code', batchCode);
}, [batchCode]);
// Change form value when state is altered
useEffect(() => {
input.changeFn(input.idx, 'serial_numbers', serials);
}, [serials]);
// Status value
const [statusOpen, statusHandlers] = useDisclosure(false, {
onClose: () => input.changeFn(input.idx, 'status', 10)
});
// Barcode value
const [barcodeInput, setBarcodeInput] = useState<any>('');
const [barcode, setBarcode] = useState(null);
// Change form value when state is altered
useEffect(() => {
input.changeFn(input.idx, 'barcode', barcode);
}, [barcode]);
// Update location field description on state change
useEffect(() => {
if (!opened) {
return;
}
const timeoutId = setTimeout(() => {
setBarcode(barcodeInput.length ? barcodeInput : null);
close();
setBarcodeInput('');
}, 500);
return () => clearTimeout(timeoutId);
}, [barcodeInput]);
// Info string with details about certain selected locations
const locationDescription = useMemo(() => {
let text = t`Choose Location`;
if (location === null) {
return text;
}
// Selected location is order line destination
if (location === record.destination) {
return t`Item Destination selected`;
}
// Selected location is base part's category default location
if (
!record.destination &&
!record.destination_detail &&
location === record.part_detail.category_default_location
) {
return t`Part category default location selected`;
}
// Selected location is identical to already received stock for this line
if (
!record.destination &&
record.destination_detail &&
location === record.destination_detail.pk &&
record.received > 0
) {
return t`Received stock location selected`;
}
// Selected location is base part's default location
if (location === record.part_detail.default_location) {
return t`Default location selected`;
}
return text;
}, [location]);
return (
<>
<Modal
opened={opened}
onClose={close}
title={<StylishText children={t`Scan Barcode`} />}
>
<FocusTrap>
<TextInput
label="Barcode data"
data-autofocus
value={barcodeInput}
onChange={(e) => setBarcodeInput(e.target.value)}
/>
</FocusTrap>
</Modal>
<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.supplier_part_detail.SKU}</td>
<td>
<ProgressBar
value={record.received}
maximum={record.quantity}
progressLabel
/>
</td>
<td style={{ width: '1%', whiteSpace: 'nowrap' }}>
<NumberInput
value={input.item.quantity}
style={{ width: '100px' }}
max={input.item.quantity}
min={0}
onChange={(value) => input.changeFn(input.idx, 'quantity', value)}
/>
</td>
<td style={{ width: '1%', whiteSpace: 'nowrap' }}>
<Flex gap="1px">
<ActionButton
onClick={() => locationHandlers.toggle()}
icon={<InvenTreeIcon icon="location" />}
tooltip={t`Set Location`}
tooltipAlignment="top"
variant={locationOpen ? 'filled' : 'outline'}
/>
<ActionButton
onClick={() => batchHandlers.toggle()}
icon={<InvenTreeIcon icon="batch_code" />}
tooltip={t`Assign Batch Code${
record.trackable && ' and Serial Numbers'
}`}
tooltipAlignment="top"
variant={batchOpen ? 'filled' : 'outline'}
/>
<ActionButton
onClick={() => statusHandlers.toggle()}
icon={<InvenTreeIcon icon="status" />}
tooltip={t`Change Status`}
tooltipAlignment="top"
variant={statusOpen ? 'filled' : 'outline'}
/>
{barcode ? (
<ActionButton
icon={<InvenTreeIcon icon="unlink" />}
tooltip={t`Unlink Barcode`}
tooltipAlignment="top"
variant="filled"
color="red"
onClick={() => setBarcode(null)}
/>
) : (
<ActionButton
icon={<InvenTreeIcon icon="barcode" />}
tooltip={t`Scan Barcode`}
tooltipAlignment="top"
variant="outline"
onClick={() => open()}
/>
)}
<ActionButton
onClick={() => input.removeFn(input.idx)}
icon={<InvenTreeIcon icon="square_x" />}
tooltip={t`Remove item from list`}
tooltipAlignment="top"
color="red"
/>
</Flex>
</td>
</tr>
{locationOpen && (
<tr>
<td colSpan={4}>
<Flex align="end" gap={5}>
<div style={{ flexGrow: '1' }}>
<StandaloneField
fieldDefinition={{
field_type: 'related field',
model: ModelType.stocklocation,
api_url: apiUrl(ApiEndpoints.stock_location_list),
filters: {
structural: false
},
onValueChange: (value) => {
setLocation(value);
},
description: locationDescription,
value: location,
label: t`Location`,
icon: <InvenTreeIcon icon="location" />
}}
defaultValue={
record.destination ??
(record.destination_detail
? record.destination_detail.pk
: null)
}
/>
</div>
<Flex style={{ marginBottom: '7px' }}>
{(record.part_detail.default_location ||
record.part_detail.category_default_location) && (
<ActionButton
icon={<InvenTreeIcon icon="default_location" />}
tooltip={t`Store at default location`}
onClick={() =>
setLocation(
record.part_detail.default_location ??
record.part_detail.category_default_location
)
}
tooltipAlignment="top"
/>
)}
{record.destination && (
<ActionButton
icon={<InvenTreeIcon icon="destination" />}
tooltip={t`Store at line item destination `}
onClick={() => setLocation(record.destination)}
tooltipAlignment="top"
/>
)}
{!record.destination &&
record.destination_detail &&
record.received > 0 && (
<ActionButton
icon={<InvenTreeIcon icon="repeat_destination" />}
tooltip={t`Store with already received stock`}
onClick={() => setLocation(record.destination_detail.pk)}
tooltipAlignment="top"
/>
)}
</Flex>
</Flex>
</td>
<td>
<div
style={{
height: '100%',
display: 'grid',
gridTemplateColumns: 'repeat(6, 1fr)',
gridTemplateRows: 'auto',
alignItems: 'end'
}}
>
<InvenTreeIcon icon="downleft" />
</div>
</td>
</tr>
)}
{batchOpen && (
<>
<tr>
<td colSpan={4}>
<Flex align="end" gap={5}>
<div style={{ flexGrow: '1' }}>
<StandaloneField
fieldDefinition={{
field_type: 'string',
onValueChange: (value) => setBatchCode(value),
label: 'Batch Code',
value: batchCode
}}
/>
</div>
</Flex>
</td>
<td>
<div
style={{
height: '100%',
display: 'grid',
gridTemplateColumns: 'repeat(6, 1fr)',
gridTemplateRows: 'auto',
alignItems: 'end'
}}
>
<span></span>
<InvenTreeIcon icon="downleft" />
</div>
</td>
</tr>
{record.trackable && (
<tr>
<td colSpan={4}>
<Flex align="end" gap={5}>
<div style={{ flexGrow: '1' }}>
<StandaloneField
fieldDefinition={{
field_type: 'string',
onValueChange: (value) => setSerials(value),
label: 'Serial numbers',
value: serials
}}
/>
</div>
</Flex>
</td>
<td>
<div
style={{
height: '100%',
display: 'grid',
gridTemplateColumns: 'repeat(6, 1fr)',
gridTemplateRows: 'auto',
alignItems: 'end'
}}
>
<span></span>
<InvenTreeIcon icon="downleft" />
</div>
</td>
</tr>
)}
</>
)}
{statusOpen && (
<tr>
<td colSpan={4}>
<StandaloneField
fieldDefinition={{
field_type: 'choice',
api_url: apiUrl(ApiEndpoints.stock_status),
choices: statuses,
label: 'Status',
onValueChange: (value) =>
input.changeFn(input.idx, 'status', value)
}}
defaultValue={10}
/>
</td>
<td>
<div
style={{
height: '100%',
display: 'grid',
gridTemplateColumns: 'repeat(6, 1fr)',
gridTemplateRows: 'auto',
alignItems: 'end'
}}
>
<span></span>
<span></span>
<InvenTreeIcon icon="downleft" />
</div>
</td>
</tr>
)}
</>
);
}
type LineFormHandlers = {
onOpen?: () => void;
onClose?: () => void;
};
type LineItemsForm = {
items: any[];
orderPk: number;
formProps?: LineFormHandlers;
};
export function useReceiveLineItems(props: LineItemsForm) {
const { data } = useQuery({
queryKey: ['stock', 'status'],
queryFn: async () => {
return api.get(apiUrl(ApiEndpoints.stock_status)).then((response) => {
if (response.status === 200) {
const entries = Object.values(response.data.values);
const mapped = entries.map((item: any) => {
return {
value: item.key,
display_name: item.label
};
});
return mapped;
}
});
}
});
const records = Object.fromEntries(
props.items.map((item) => [item.pk, item])
);
const filteredItems = props.items.filter(
(elem) => elem.quantity !== elem.received
);
const fields: ApiFormFieldSet = {
id: {
value: props.orderPk,
hidden: true
},
items: {
field_type: 'table',
value: filteredItems.map((elem, idx) => {
return {
line_item: elem.pk,
location: elem.destination ?? elem.destination_detail?.pk ?? null,
quantity: elem.quantity - elem.received,
batch_code: '',
serial_numbers: '',
status: 10,
barcode: null
};
}),
modelRenderer: (instance) => {
const record = records[instance.item.line_item];
return (
<LineItemFormRow
input={instance}
record={record}
statuses={data}
key={record.pk}
/>
);
},
headers: ['Part', 'SKU', 'Received', 'Quantity to receive', 'Actions']
},
location: {
filters: {
structural: false
}
}
};
const url = apiUrl(ApiEndpoints.purchase_order_receive, null, {
id: props.orderPk
});
return useCreateApiFormModal({
...props.formProps,
url: url,
title: t`Receive line items`,
fields: fields,
initialData: {
location: null
},
size: 'max(60%,800px)'
});
}

View File

@ -1,12 +1,28 @@
import { t } from '@lingui/macro';
import { useMemo, useState } from 'react';
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 { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm';
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
@ -144,6 +160,651 @@ export function useEditStockItem({
});
}
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: {

View File

@ -1,5 +1,6 @@
import {
Icon123,
IconArrowMerge,
IconBinaryTree2,
IconBookmarks,
IconBox,
@ -10,13 +11,19 @@ import {
IconCalendarStats,
IconCategory,
IconCheck,
IconCircleMinus,
IconCirclePlus,
IconClipboardList,
IconClipboardText,
IconCopy,
IconCornerDownLeft,
IconCornerUpRightDouble,
IconCurrencyDollar,
IconDots,
IconDotsCircleHorizontal,
IconExternalLink,
IconFileUpload,
IconFlagShare,
IconGitBranch,
IconGridDots,
IconHash,
@ -27,6 +34,7 @@ import {
IconMail,
IconMapPin,
IconMapPinHeart,
IconMinusVertical,
IconNotes,
IconNumbers,
IconPackage,
@ -35,7 +43,9 @@ import {
IconPaperclip,
IconPhone,
IconPhoto,
IconPrinter,
IconProgressCheck,
IconQrcode,
IconQuestionMark,
IconRulerMeasure,
IconShoppingCart,
@ -47,9 +57,11 @@ import {
IconTestPipe,
IconTool,
IconTools,
IconTransfer,
IconTrash,
IconTruck,
IconTruckDelivery,
IconUnlink,
IconUser,
IconUserStar,
IconUsersGroup,
@ -59,6 +71,9 @@ import {
IconX
} from '@tabler/icons-react';
import { IconFlag } from '@tabler/icons-react';
import { IconSquareXFilled } from '@tabler/icons-react';
import { IconShoppingCartPlus } from '@tabler/icons-react';
import { IconArrowBigDownLineFilled } from '@tabler/icons-react';
import { IconTruckReturn } from '@tabler/icons-react';
import { IconInfoCircle } from '@tabler/icons-react';
import { IconCalendarTime } from '@tabler/icons-react';
@ -127,6 +142,8 @@ const icons = {
creation_date: IconCalendarTime,
location: IconMapPin,
default_location: IconMapPinHeart,
category_default_location: IconMapPinHeart,
parent_default_location: IconMapPinHeart,
default_supplier: IconShoppingCartHeart,
link: IconLink,
responsible: IconUserStar,
@ -137,13 +154,30 @@ const icons = {
group: IconUsersGroup,
check: IconCheck,
copy: IconCopy,
square_x: IconSquareXFilled,
arrow_down: IconArrowBigDownLineFilled,
transfer: IconTransfer,
actions: IconDots,
reports: IconPrinter,
buy: IconShoppingCartPlus,
add: IconCirclePlus,
remove: IconCircleMinus,
merge: IconArrowMerge,
customer: IconUser,
quantity: IconNumbers,
progress: IconProgressCheck,
reference: IconHash,
website: IconWorld,
email: IconMail,
phone: IconPhone,
sitemap: IconSitemap
sitemap: IconSitemap,
downleft: IconCornerDownLeft,
barcode: IconQrcode,
barLine: IconMinusVertical,
batch_code: IconClipboardText,
destination: IconFlag,
repeat_destination: IconFlagShare,
unlink: IconUnlink
};
export type InvenTreeIconType = keyof typeof icons;
@ -167,6 +201,9 @@ export function InvenTreeIcon(props: IconProps) {
if (props.icon in icons) {
Icon = GetIcon(props.icon);
} else {
console.warn(
`Icon name '${props.icon}' is not registered with the Icon manager`
);
Icon = IconQuestionMark;
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Alert, Divider, Stack } from '@mantine/core';
import { Alert, Divider, MantineNumberSize, Stack } from '@mantine/core';
import { useId } from '@mantine/hooks';
import { useEffect, useMemo, useRef } from 'react';
@ -20,6 +20,7 @@ export interface ApiFormModalProps extends ApiFormProps {
onClose?: () => void;
onOpen?: () => void;
closeOnClickOutside?: boolean;
size?: MantineNumberSize;
}
/**
@ -59,7 +60,7 @@ export function useApiFormModal(props: ApiFormModalProps) {
onOpen: formProps.onOpen,
onClose: formProps.onClose,
closeOnClickOutside: formProps.closeOnClickOutside,
size: 'xl',
size: props.size ?? 'xl',
children: (
<Stack spacing={'xs'}>
<Divider />
@ -125,7 +126,7 @@ export function useDeleteApiFormModal(props: ApiFormModalProps) {
color={'red'}
>{t`Are you sure you want to delete this item?`}</Alert>
),
fields: {}
fields: props.fields ?? {}
}),
[props]
);

View File

@ -115,6 +115,20 @@ export default function CategoryDetail({}: {}) {
name: 'structural',
label: t`Structural`,
icon: 'sitemap'
},
{
type: 'link',
name: 'parent_default_location',
label: t`Parent default location`,
model: ModelType.stocklocation,
hidden: !category.parent_default_location || category.default_location
},
{
type: 'link',
name: 'default_location',
label: t`Default location`,
model: ModelType.stocklocation,
hidden: !category.default_location
}
];

View File

@ -26,7 +26,6 @@ import {
IconStack2,
IconTestPipe,
IconTools,
IconTransfer,
IconTruckDelivery,
IconVersions
} from '@tabler/icons-react';
@ -58,6 +57,12 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { usePartFields } from '../../forms/PartForms';
import {
StockOperationProps,
useCountStockItem,
useTransferStockItem
} from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
import { useEditApiFormModal } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
@ -131,6 +136,13 @@ export default function PartDetail() {
model: ModelType.stocklocation,
hidden: !part.default_location
},
{
type: 'link',
name: 'category_default_location',
label: t`Category Default Location`,
model: ModelType.stocklocation,
hidden: part.default_location || !part.category_default_location
},
{
type: 'string',
name: 'IPN',
@ -460,10 +472,10 @@ export default function PartDetail() {
name: 'stock',
label: t`Stock`,
icon: <IconPackages />,
content: (
content: part.pk && (
<StockItemTable
params={{
part: part.pk ?? -1
part: part.pk
}}
/>
)
@ -631,6 +643,17 @@ export default function PartDetail() {
onFormSuccess: refreshInstance
});
const stockActionProps: StockOperationProps = useMemo(() => {
return {
pk: part.pk,
model: ModelType.part,
refresh: refreshInstance
};
}, [part]);
const countStockItems = useCountStockItem(stockActionProps);
const transferStockItems = useTransferStockItem(stockActionProps);
const partActions = useMemo(() => {
// TODO: Disable actions based on user permissions
return [
@ -651,14 +674,24 @@ export default function PartDetail() {
icon={<IconPackages />}
actions={[
{
icon: <IconClipboardList color="blue" />,
icon: (
<InvenTreeIcon icon="stocktake" iconProps={{ color: 'blue' }} />
),
name: t`Count Stock`,
tooltip: t`Count part stock`
tooltip: t`Count part stock`,
onClick: () => {
part.pk && countStockItems.open();
}
},
{
icon: <IconTransfer color="blue" />,
icon: (
<InvenTreeIcon icon="transfer" iconProps={{ color: 'blue' }} />
),
name: t`Transfer Stock`,
tooltip: t`Transfer part stock`
tooltip: t`Transfer part stock`,
onClick: () => {
part.pk && transferStockItems.open();
}
}
]}
/>,
@ -704,6 +737,8 @@ export default function PartDetail() {
actions={partActions}
/>
<PanelGroup pageKey="part" panels={partPanels} />
{transferStockItems.modal}
{countStockItems.modal}
</Stack>
</>
);

View File

@ -9,11 +9,17 @@ import {
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ActionButton } from '../../components/buttons/ActionButton';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
ActionDropdown,
EditItemAction
BarcodeActionDropdown,
DeleteItemAction,
EditItemAction,
LinkBarcodeAction,
UnlinkBarcodeAction,
ViewBarcodeAction
} from '../../components/items/ActionDropdown';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
@ -21,10 +27,17 @@ import { StockLocationTree } from '../../components/nav/StockLocationTree';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { stockLocationFields } from '../../forms/StockForms';
import {
StockOperationProps,
stockLocationFields,
useCountStockItem,
useTransferStockItem
} from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
import { useEditApiFormModal } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useUserState } from '../../states/UserState';
import { PartListTable } from '../../tables/part/PartTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { StockLocationTable } from '../../tables/stock/StockLocationTable';
@ -154,6 +167,21 @@ export default function Stock() {
label: t`Stock Locations`,
icon: <IconSitemap />,
content: <StockLocationTable parentId={id} />
},
{
name: 'default_parts',
label: t`Default Parts`,
icon: <IconPackages />,
hidden: !location.pk,
content: (
<PartListTable
props={{
params: {
default_location: location.pk
}
}}
/>
)
}
];
}, [location, id]);
@ -166,8 +194,79 @@ export default function Stock() {
onFormSuccess: refreshInstance
});
const locationActions = useMemo(() => {
return [
const stockItemActionProps: StockOperationProps = useMemo(() => {
return {
pk: location.pk,
model: 'location',
refresh: refreshInstance
};
}, [location]);
const transferStockItems = useTransferStockItem(stockItemActionProps);
const countStockItems = useCountStockItem(stockItemActionProps);
const locationActions = useMemo(
() => [
<ActionButton
icon={<InvenTreeIcon icon="stocktake" />}
variant="outline"
size="lg"
/>,
<BarcodeActionDropdown
actions={[
ViewBarcodeAction({}),
LinkBarcodeAction({}),
UnlinkBarcodeAction({}),
{
name: 'Scan in stock items',
icon: <InvenTreeIcon icon="stock" />,
tooltip: 'Scan items'
},
{
name: 'Scan in container',
icon: <InvenTreeIcon icon="unallocated_stock" />,
tooltip: 'Scan container'
}
]}
/>,
<ActionDropdown
key="reports"
icon={<InvenTreeIcon icon="reports" />}
actions={[
{
name: 'Print Label',
icon: '',
tooltip: 'Print label'
},
{
name: 'Print Location Report',
icon: '',
tooltip: 'Print Report'
}
]}
/>,
<ActionDropdown
key="operations"
icon={<InvenTreeIcon icon="stock" />}
actions={[
{
name: 'Count Stock',
icon: (
<InvenTreeIcon icon="stocktake" iconProps={{ color: 'blue' }} />
),
tooltip: 'Count Stock',
onClick: () => countStockItems.open()
},
{
name: 'Transfer Stock',
icon: (
<InvenTreeIcon icon="transfer" iconProps={{ color: 'blue' }} />
),
tooltip: 'Transfer Stock',
onClick: () => transferStockItems.open()
}
]}
/>,
<ActionDropdown
key="location"
tooltip={t`Location Actions`}
@ -180,8 +279,9 @@ export default function Stock() {
})
]}
/>
];
}, [id, user]);
],
[location, id, user]
);
const breadcrumbs = useMemo(
() => [
@ -214,6 +314,8 @@ export default function Stock() {
}}
/>
<PanelGroup pageKey="stocklocation" panels={locationPanels} />
{transferStockItems.modal}
{countStockItems.modal}
</Stack>
</>
);

View File

@ -11,9 +11,6 @@ import {
IconBookmark,
IconBoxPadding,
IconChecklist,
IconCircleCheck,
IconCircleMinus,
IconCirclePlus,
IconCopy,
IconDots,
IconHistory,
@ -21,8 +18,7 @@ import {
IconNotes,
IconPackages,
IconPaperclip,
IconSitemap,
IconTransfer
IconSitemap
} from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
@ -46,7 +42,15 @@ import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useEditStockItem } from '../../forms/StockForms';
import {
StockOperationProps,
useAddStockItem,
useCountStockItem,
useEditStockItem,
useRemoveStockItem,
useTransferStockItem
} from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
@ -300,7 +304,7 @@ export default function StockDetail() {
{ name: t`Stock`, url: '/stock' },
...(stockitem.location_path ?? []).map((l: any) => ({
name: l.name,
url: `/stock/location/${l.pk}`
url: apiUrl(ApiEndpoints.stock_location_list, l.pk)
}))
],
[stockitem]
@ -311,6 +315,19 @@ export default function StockDetail() {
callback: () => refreshInstance()
});
const stockActionProps: StockOperationProps = useMemo(() => {
return {
items: stockitem,
model: ModelType.stockitem,
refresh: refreshInstance
};
}, [stockitem]);
const countStockItem = useCountStockItem(stockActionProps);
const addStockItem = useAddStockItem(stockActionProps);
const removeStockItem = useRemoveStockItem(stockActionProps);
const transferStockItem = useTransferStockItem(stockActionProps);
const stockActions = useMemo(
() => /* TODO: Disable actions based on user permissions*/ [
<BarcodeActionDropdown
@ -332,22 +349,38 @@ export default function StockDetail() {
{
name: t`Count`,
tooltip: t`Count stock`,
icon: <IconCircleCheck color="green" />
icon: (
<InvenTreeIcon icon="stocktake" iconProps={{ color: 'blue' }} />
),
onClick: () => {
stockitem.pk && countStockItem.open();
}
},
{
name: t`Add`,
tooltip: t`Add stock`,
icon: <IconCirclePlus color="green" />
icon: <InvenTreeIcon icon="add" iconProps={{ color: 'green' }} />,
onClick: () => {
stockitem.pk && addStockItem.open();
}
},
{
name: t`Remove`,
tooltip: t`Remove stock`,
icon: <IconCircleMinus color="red" />
icon: <InvenTreeIcon icon="remove" iconProps={{ color: 'red' }} />,
onClick: () => {
stockitem.pk && removeStockItem.open();
}
},
{
name: t`Transfer`,
tooltip: t`Transfer stock`,
icon: <IconTransfer color="blue" />
icon: (
<InvenTreeIcon icon="transfer" iconProps={{ color: 'blue' }} />
),
onClick: () => {
stockitem.pk && transferStockItem.open();
}
}
]}
/>,
@ -361,11 +394,7 @@ export default function StockDetail() {
tooltip: t`Duplicate stock item`,
icon: <IconCopy />
},
EditItemAction({
onClick: () => {
stockitem.pk && editStockItem.open();
}
}),
EditItemAction({}),
DeleteItemAction({})
]}
/>
@ -398,6 +427,10 @@ export default function StockDetail() {
/>
<PanelGroup pageKey="stockitem" panels={stockPanels} />
{editStockItem.modal}
{countStockItem.modal}
{addStockItem.modal}
{removeStockItem.modal}
{transferStockItem.modal}
</Stack>
);
}

View File

@ -12,7 +12,10 @@ import { RenderStockLocation } from '../../components/render/Stock';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { usePurchaseOrderLineItemFields } from '../../forms/PurchaseOrderForms';
import {
usePurchaseOrderLineItemFields,
useReceiveLineItems
} from '../../forms/PurchaseOrderForms';
import { getDetailUrl } from '../../functions/urls';
import {
useCreateApiFormModal,
@ -52,6 +55,16 @@ export function PurchaseOrderLineItemTable({
const navigate = useNavigate();
const user = useUserState();
const [singleRecord, setSingeRecord] = useState(null);
const receiveLineItems = useReceiveLineItems({
items: singleRecord ? [singleRecord] : table.selectedRecords,
orderPk: orderId,
formProps: {
// Timeout is a small hack to prevent function being called before re-render
onClose: () => setTimeout(() => setSingeRecord(null), 500)
}
});
const tableColumns = useMemo(() => {
return [
{
@ -213,7 +226,11 @@ export function PurchaseOrderLineItemTable({
hidden: received,
title: t`Receive line item`,
icon: <IconSquareArrowRight />,
color: 'green'
color: 'green',
onClick: () => {
setSingeRecord(record);
receiveLineItems.open();
}
},
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.purchase_order),
@ -241,21 +258,22 @@ export function PurchaseOrderLineItemTable({
const tableActions = useMemo(() => {
return [
<AddItemButton
key="add-line-item"
tooltip={t`Add line item`}
onClick={() => newLine.open()}
hidden={!user?.hasAddRole(UserRoles.purchase_order)}
/>,
<ActionButton
key="receive-items"
text={t`Receive items`}
icon={<IconSquareArrowRight />}
onClick={() => receiveLineItems.open()}
disabled={table.selectedRecords.length === 0}
/>
];
}, [orderId, user]);
}, [orderId, user, table]);
return (
<>
{receiveLineItems.modal}
{newLine.modal}
{editLine.modal}
{deleteLine.modal}

View File

@ -4,11 +4,24 @@ import { ReactNode, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ActionDropdown } from '../../components/items/ActionDropdown';
import { formatCurrency, renderDate } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useStockFields } from '../../forms/StockForms';
import {
StockOperationProps,
useAddStockItem,
useAssignStockItem,
useChangeStockStatus,
useCountStockItem,
useDeleteStockItem,
useMergeStockItem,
useRemoveStockItem,
useStockFields,
useTransferStockItem
} from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
import { getDetailUrl } from '../../functions/urls';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
@ -335,8 +348,17 @@ export function StockItemTable({ params = {} }: { params?: any }) {
const table = useTable('stockitems');
const user = useUserState();
const navigate = useNavigate();
const tableActionParams: StockOperationProps = useMemo(() => {
return {
items: table.selectedRecords,
model: ModelType.stockitem,
refresh: table.refreshTable
};
}, [table]);
const stockItemFields = useStockFields({ create: true });
const newStockItem = useCreateApiFormModal({
@ -354,26 +376,137 @@ export function StockItemTable({ params = {} }: { params?: any }) {
}
});
const transferStock = useTransferStockItem(tableActionParams);
const addStock = useAddStockItem(tableActionParams);
const removeStock = useRemoveStockItem(tableActionParams);
const countStock = useCountStockItem(tableActionParams);
const changeStockStatus = useChangeStockStatus(tableActionParams);
const mergeStock = useMergeStockItem(tableActionParams);
const assignStock = useAssignStockItem(tableActionParams);
const deleteStock = useDeleteStockItem(tableActionParams);
const tableActions = useMemo(() => {
let can_delete_stock = user.hasDeleteRole(UserRoles.stock);
let can_add_stock = user.hasAddRole(UserRoles.stock);
let can_add_stocktake = user.hasAddRole(UserRoles.stocktake);
let can_add_order = user.hasAddRole(UserRoles.purchase_order);
let can_change_order = user.hasChangeRole(UserRoles.purchase_order);
return [
<ActionDropdown
key="stockoperations"
icon={<InvenTreeIcon icon="stock" />}
disabled={table.selectedRecords.length === 0}
actions={[
{
name: t`Add stock`,
icon: <InvenTreeIcon icon="add" iconProps={{ color: 'green' }} />,
tooltip: t`Add a new stock item`,
disabled: !can_add_stock,
onClick: () => {
addStock.open();
}
},
{
name: t`Remove stock`,
icon: <InvenTreeIcon icon="remove" iconProps={{ color: 'red' }} />,
tooltip: t`Remove some quantity from a stock item`,
disabled: !can_add_stock,
onClick: () => {
removeStock.open();
}
},
{
name: 'Count Stock',
icon: (
<InvenTreeIcon icon="stocktake" iconProps={{ color: 'blue' }} />
),
tooltip: 'Count Stock',
disabled: !can_add_stocktake,
onClick: () => {
countStock.open();
}
},
{
name: t`Transfer stock`,
icon: (
<InvenTreeIcon icon="transfer" iconProps={{ color: 'blue' }} />
),
tooltip: t`Move Stock items to new locations`,
disabled: !can_add_stock,
onClick: () => {
transferStock.open();
}
},
{
name: t`Change stock status`,
icon: <InvenTreeIcon icon="info" iconProps={{ color: 'blue' }} />,
tooltip: t`Change the status of stock items`,
disabled: !can_add_stock,
onClick: () => {
changeStockStatus.open();
}
},
{
name: t`Merge stock`,
icon: <InvenTreeIcon icon="merge" />,
tooltip: t`Merge stock items`,
disabled: !can_add_stock,
onClick: () => {
mergeStock.open();
}
},
{
name: t`Order stock`,
icon: <InvenTreeIcon icon="buy" />,
tooltip: t`Order new stock`,
disabled: !can_add_order || !can_change_order
},
{
name: t`Assign to customer`,
icon: <InvenTreeIcon icon="customer" />,
tooltip: t`Order new stock`,
disabled: !can_add_stock,
onClick: () => {
assignStock.open();
}
},
{
name: t`Delete stock`,
icon: <InvenTreeIcon icon="delete" iconProps={{ color: 'red' }} />,
tooltip: t`Delete stock items`,
disabled: !can_delete_stock,
onClick: () => {
deleteStock.open();
}
}
]}
/>,
<AddItemButton
hidden={!user.hasAddRole(UserRoles.stock)}
tooltip={t`Add Stock Item`}
onClick={() => newStockItem.open()}
/>
];
}, [user]);
}, [user, table]);
return (
<>
{newStockItem.modal}
{transferStock.modal}
{removeStock.modal}
{addStock.modal}
{countStock.modal}
{changeStockStatus.modal}
{mergeStock.modal}
{assignStock.modal}
{deleteStock.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.stock_item_list)}
tableState={table}
columns={tableColumns}
props={{
enableDownload: true,
enableSelection: false,
enableSelection: true,
tableFilters: tableFilters,
tableActions: tableActions,
onRowClick: (record) =>