2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-05 19:10:54 +00:00
Files
InvenTree/src/frontend/src/forms/SalesOrderForms.tsx
Oliver 79aa824c14 Keep allocation forms open (#11621)
- Following https://github.com/inventree/InvenTree/pull/11074
- Option to keep allocation forms open
2026-03-29 17:49:46 +11:00

581 lines
15 KiB
TypeScript

import { t } from '@lingui/core/macro';
import { Alert, Table, Text } from '@mantine/core';
import {
IconAddressBook,
IconCalendar,
IconCircleCheck,
IconCircleX,
IconCoins,
IconUser,
IconUsers
} from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField';
import { ProgressBar } from '@lib/components/ProgressBar';
import { apiUrl } from '@lib/functions/Api';
import { toNumber } from '@lib/functions/Conversion';
import type {
ApiFormAdjustFilterType,
ApiFormFieldSet,
ApiFormFieldType
} from '@lib/types/Forms';
import dayjs from 'dayjs';
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
import useBackgroundTask from '../hooks/UseBackgroundTask';
import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { useUserState } from '../states/UserState';
import { RenderPartColumn } from '../tables/ColumnRenderers';
export function useSalesOrderFields({
duplicateOrderId
}: {
duplicateOrderId?: number;
}): ApiFormFieldSet {
const globalSettings = useGlobalSettingsState();
return useMemo(() => {
const fields: ApiFormFieldSet = {
reference: {},
description: {},
customer: {
disabled: duplicateOrderId != undefined,
filters: {
is_customer: true,
active: true
}
},
customer_reference: {},
project_code: {},
order_currency: {},
start_date: {
icon: <IconCalendar />
},
target_date: {
icon: <IconCalendar />
},
link: {},
contact: {
icon: <IconUser />,
adjustFilters: (value: ApiFormAdjustFilterType) => {
return {
...value.filters,
company: value.data.customer
};
}
},
address: {
icon: <IconAddressBook />,
adjustFilters: (value: ApiFormAdjustFilterType) => {
return {
...value.filters,
company: value.data.customer
};
}
},
responsible: {
icon: <IconUsers />
}
};
// Order duplication fields
if (!!duplicateOrderId) {
fields.duplicate = {
children: {
order_id: {
hidden: true,
value: duplicateOrderId
},
copy_lines: {},
copy_extra_lines: {},
copy_parameters: {}
}
};
}
if (!globalSettings.isSet('PROJECT_CODES_ENABLED', true)) {
delete fields.project_code;
}
return fields;
}, [duplicateOrderId, globalSettings]);
}
export function useSalesOrderLineItemFields({
customerId,
orderId,
create,
currency
}: {
customerId?: number;
orderId?: number;
create?: boolean;
currency?: string;
}): ApiFormFieldSet {
const [salePrice, setSalePrice] = useState<string | undefined>(undefined);
const [partCurrency, setPartCurrency] = useState<string>(currency ?? '');
const [part, setPart] = useState<any>({});
const [quantity, setQuantity] = useState<string>('1');
// Update suggested sale price when part, quantity, or part currency changes
useEffect(() => {
// Only attempt to set sale price for new line items
if (!create) return;
const qty = toNumber(quantity, null);
if (qty == null || qty <= 0) {
setSalePrice(undefined);
return;
}
if (!part || !part.price_breaks || part.price_breaks.length === 0) {
setSalePrice(undefined);
return;
}
const applicablePriceBreaks = part?.price_breaks
?.filter(
(pb: any) => pb.price_currency == partCurrency && qty >= pb.quantity
)
.sort((a: any, b: any) => b.quantity - a.quantity);
if (applicablePriceBreaks.length) {
setSalePrice(applicablePriceBreaks[0].price);
} else {
setSalePrice(undefined);
}
}, [part, quantity, partCurrency, create]);
return useMemo(() => {
const fields: ApiFormFieldSet = {
order: {
filters: {
customer_detail: true
},
disabled: true,
value: create ? orderId : undefined
},
part: {
filters: {
active: true,
salable: true,
price_breaks: true
},
onValueChange: (_: any, record?: any) => setPart(record)
},
reference: {},
quantity: {
onValueChange: (value) => {
setQuantity(value);
}
},
sale_price: {
placeholder: salePrice,
placeholderAutofill: true,
placeholderWarningCompare: salePrice,
placeholderWarning: t`Price based on part and quantity differs${salePrice ? `; suggested: (${salePrice})` : '.'}`
},
sale_price_currency: {
icon: <IconCoins />,
value: partCurrency,
onValueChange: setPartCurrency
},
project_code: {
description: t`Select project code for this line item`
},
target_date: {},
notes: {},
link: {}
};
return fields;
}, [salePrice, partCurrency, orderId, create]);
}
export function useCheckShipmentForm({
shipmentId,
onSuccess
}: {
shipmentId: number;
onSuccess: (response: any) => void;
}) {
const user = useUserState();
return useEditApiFormModal({
url: ApiEndpoints.sales_order_shipment_list,
pk: shipmentId,
title: t`Check Shipment`,
preFormContent: (
<Alert color='green' icon={<IconCircleCheck />} title={t`Check Shipment`}>
<Text>{t`Marking the shipment as checked indicates that you have verified that all items included in this shipment are correct`}</Text>
</Alert>
),
fetchInitialData: false,
fields: {
checked_by: {
hidden: true,
value: user.getUser()?.pk
}
},
successMessage: t`Shipment marked as checked`,
onFormSuccess: onSuccess
});
}
export function useUncheckShipmentForm({
shipmentId,
onSuccess
}: {
shipmentId: number;
onSuccess: (response: any) => void;
}) {
return useEditApiFormModal({
url: ApiEndpoints.sales_order_shipment_list,
pk: shipmentId,
title: t`Uncheck Shipment`,
preFormContent: (
<Alert color='red' icon={<IconCircleX />} title={t`Uncheck Shipment`}>
<Text>{t`Marking the shipment as unchecked indicates that the shipment requires further verification`}</Text>
</Alert>
),
fetchInitialData: false,
fields: {
checked_by: {
hidden: true,
value: null
}
},
successMessage: t`Shipment marked as unchecked`,
onFormSuccess: onSuccess
});
}
export function useCompleteShipmentForm({
shipment,
onSuccess
}: {
shipment: any;
onSuccess: () => void;
}) {
const [taskId, setTaskId] = useState<string>('');
const completeShipmentFields = useSalesOrderShipmentCompleteFields({});
useBackgroundTask({
taskId: taskId,
message: t`Completing shipment`,
successMessage: t`Shipment completed successfully`,
onSuccess: onSuccess
});
return useCreateApiFormModal({
url: ApiEndpoints.sales_order_shipment_complete,
pk: shipment.pk,
title: t`Complete Shipment`,
fields: completeShipmentFields,
focus: 'tracking_number',
initialData: {
...shipment,
shipment_date: dayjs().format('YYYY-MM-DD')
},
successMessage: null,
onFormSuccess: (response: any) => {
if (response.task_id) {
setTaskId(response.task_id);
} else {
onSuccess();
}
}
});
}
function SalesOrderAllocateLineRow({
props,
record,
sourceLocation
}: Readonly<{
props: TableFieldRowProps;
record: any;
sourceLocation?: number | null;
}>) {
// Statically defined field for selecting the stock item
const stockItemField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'related field',
api_url: apiUrl(ApiEndpoints.stock_item_list),
model: ModelType.stockitem,
autoFill: true,
filters: {
available: true,
part_detail: true,
location_detail: true,
location: sourceLocation,
cascade: sourceLocation ? true : undefined,
part: record.part
},
value: props.item.stock_item,
name: 'stock_item',
onValueChange: (value: any, instance: any) => {
props.changeFn(props.idx, 'stock_item', value);
// Update the allocated quantity based on the selected stock item
if (instance) {
const available = instance.quantity - instance.allocated;
const required = record.quantity - record.allocated;
let quantity = props.item?.quantity ?? 0;
quantity = Math.max(quantity, required);
quantity = Math.min(quantity, available);
if (quantity != props.item.quantity) {
props.changeFn(props.idx, 'quantity', quantity);
}
}
}
};
}, [sourceLocation, record, props]);
// Statically defined field for selecting the allocation quantity
const quantityField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'number',
name: 'quantity',
required: true,
value: props.item.quantity,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'quantity', value);
}
};
}, [props]);
return (
<Table.Tr key={`table-row-${props.idx}-${record.pk}`}>
<Table.Td>
<RenderPartColumn part={record.part_detail} />
</Table.Td>
<Table.Td>
<ProgressBar
value={record.allocated}
maximum={record.quantity}
progressLabel
/>
</Table.Td>
<Table.Td>
<StandaloneField
fieldName='stock_item'
fieldDefinition={stockItemField}
error={props.rowErrors?.stock_item?.message}
/>
</Table.Td>
<Table.Td>
<StandaloneField
fieldName='quantity'
fieldDefinition={quantityField}
error={props.rowErrors?.quantity?.message}
/>
</Table.Td>
<Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
</Table.Td>
</Table.Tr>
);
}
export function useAllocateToSalesOrderForm({
orderId,
shipmentId,
lineItems,
onFormSuccess
}: {
orderId: number;
shipmentId?: number;
lineItems: any[];
onFormSuccess: (response: any) => void;
}) {
const [sourceLocation, setSourceLocation] = useState<number | null>(null);
// Reset source location to known state
useEffect(() => {
setSourceLocation(null);
}, [orderId, shipmentId, lineItems]);
const fields: ApiFormFieldSet = useMemo(() => {
return {
// Non-submitted field to select the source location
source_location: {
exclude: true,
required: false,
field_type: 'related field',
api_url: apiUrl(ApiEndpoints.stock_location_list),
model: ModelType.stocklocation,
label: t`Source Location`,
description: t`Select the source location for the stock allocation`,
onValueChange: (value: any) => {
setSourceLocation(value);
}
},
items: {
field_type: 'table',
value: [],
headers: [
{ title: t`Part`, style: { minWidth: '200px' } },
{ title: t`Allocated`, style: { minWidth: '200px' } },
{ title: t`Stock Item`, style: { width: '100%' } },
{ title: t`Quantity`, style: { minWidth: '200px' } },
{ title: '', style: { width: '50px' } }
],
modelRenderer: (row: TableFieldRowProps) => {
const record =
lineItems.find((item) => item.pk == row.item.line_item) ?? {};
return (
<SalesOrderAllocateLineRow
key={`table-row-${row.idx}-${record.pk}`}
props={row}
record={record}
sourceLocation={sourceLocation}
/>
);
}
},
shipment: {
autoFill: true,
filters: {
shipped: false,
order_detail: true,
order: orderId
}
}
};
}, [orderId, shipmentId, lineItems, sourceLocation]);
return useCreateApiFormModal({
title: t`Allocate Stock`,
url: ApiEndpoints.sales_order_allocate,
pk: orderId,
fields: fields,
onFormSuccess: onFormSuccess,
successMessage: t`Stock items allocated`,
size: '80%',
keepOpenOption: true,
initialData: {
items: lineItems.map((item) => {
return {
line_item: item.pk,
quantity: 0,
stock_item: null
};
})
}
});
}
export function useSalesOrderAllocateSerialsFields({
itemId,
orderId
}: {
itemId: number;
orderId: number;
}): ApiFormFieldSet {
return useMemo(() => {
return {
line_item: {
value: itemId,
hidden: true
},
quantity: {},
serial_numbers: {},
shipment: {
filters: {
order_detail: true,
order: orderId,
shipped: false
}
}
};
}, [itemId, orderId]);
}
export function useSalesOrderShipmentFields({
customerId,
pending
}: {
customerId?: number;
pending?: boolean;
}): ApiFormFieldSet {
return useMemo(() => {
return {
order: {
disabled: true
},
reference: {},
shipment_date: {
hidden: pending ?? true
},
delivery_date: {
hidden: pending ?? true
},
shipment_address: {
placeholder: t`Leave blank to use the order address`,
filters: {
company: customerId,
ordering: '-primary'
}
},
tracking_number: {},
invoice_number: {},
link: {}
};
}, [customerId, pending]);
}
export function useSalesOrderShipmentCompleteFields({
shipmentId
}: {
shipmentId?: number;
}): ApiFormFieldSet {
return useMemo(() => {
return {
shipment_date: {},
tracking_number: {},
invoice_number: {},
link: {}
};
}, [shipmentId]);
}
export function useSalesOrderAllocationFields({
orderId,
shipment
}: {
orderId?: number;
shipment: any;
}): ApiFormFieldSet {
return useMemo(() => {
return {
item: {
// Cannot change item, but display for reference
disabled: true
},
quantity: {},
shipment: {
// Cannot change shipment once it has been shipped
disabled: !!shipment?.shipment_date,
// Order ID is required for this field to be accessed
hidden: !orderId,
filters: {
order: orderId,
shipped: false
}
}
};
}, [orderId, shipment]);
}