952 lines
27 KiB
TypeScript
952 lines
27 KiB
TypeScript
import { t } from '@lingui/core/macro';
|
|
import {
|
|
ActionIcon,
|
|
Alert,
|
|
Container,
|
|
Flex,
|
|
FocusTrap,
|
|
Group,
|
|
HoverCard,
|
|
Modal,
|
|
Table,
|
|
TextInput
|
|
} from '@mantine/core';
|
|
import { useDisclosure } from '@mantine/hooks';
|
|
import {
|
|
IconAddressBook,
|
|
IconCalendar,
|
|
IconCoins,
|
|
IconCurrencyDollar,
|
|
IconHash,
|
|
IconInfoCircle,
|
|
IconLink,
|
|
IconNotes,
|
|
IconSitemap,
|
|
IconUser,
|
|
IconUsers
|
|
} from '@tabler/icons-react';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
|
|
import { ActionButton } from '@lib/components/ActionButton';
|
|
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
|
import { ModelType } from '@lib/enums/ModelType';
|
|
import { IconCalendarExclamation } from '@tabler/icons-react';
|
|
import dayjs from 'dayjs';
|
|
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
|
import { StandaloneField } from '../components/forms/StandaloneField';
|
|
|
|
import { ProgressBar } from '@lib/components/ProgressBar';
|
|
import { StylishText } from '@lib/components/StylishText';
|
|
import { apiUrl } from '@lib/functions/Api';
|
|
import { toNumber } from '@lib/functions/Conversion';
|
|
import type {
|
|
ApiFormAdjustFilterType,
|
|
ApiFormFieldSet
|
|
} from '@lib/types/Forms';
|
|
import {
|
|
TableFieldExtraRow,
|
|
TableFieldQuantityInput,
|
|
type TableFieldRowProps
|
|
} from '../components/forms/fields/TableField';
|
|
import { Thumbnail } from '../components/images/Thumbnail';
|
|
import { getStatusCodeOptions } from '../components/render/StatusRenderer';
|
|
import { InvenTreeIcon } from '../functions/icons';
|
|
import { useCreateApiFormModal } from '../hooks/UseForm';
|
|
import {
|
|
useBatchCodeGenerator,
|
|
useSerialNumberGenerator
|
|
} from '../hooks/UseGenerator';
|
|
import { useGlobalSettingsState } from '../states/SettingsStates';
|
|
import { ProjectCodeField, TagsField } from './CommonFields';
|
|
/*
|
|
* Construct a set of fields for creating / editing a PurchaseOrderLineItem instance
|
|
*/
|
|
export function usePurchaseOrderLineItemFields({
|
|
supplierId,
|
|
orderId,
|
|
currency,
|
|
create
|
|
}: {
|
|
supplierId?: number;
|
|
orderId?: number;
|
|
currency?: string;
|
|
create?: boolean;
|
|
}) {
|
|
const globalSettings = useGlobalSettingsState();
|
|
|
|
const [purchasePrice, setPurchasePrice] = useState<string>('');
|
|
const [purchasePriceCurrency, setPurchasePriceCurrency] = useState<string>(
|
|
currency ?? ''
|
|
);
|
|
|
|
const [autoPricing, setAutoPricing] = useState(false);
|
|
|
|
const [quantity, setQuantity] = useState<string>('1');
|
|
|
|
// Internal part information
|
|
const [part, setPart] = useState<any>({});
|
|
const [priceBreaks, setPriceBreaks] = useState<any[]>([]);
|
|
const [suggestedPurchasePrice, setSuggestedPurchasePrice] = useState<
|
|
string | undefined
|
|
>(undefined);
|
|
|
|
// Update suggested purchase price when part, quantity, or price breaks change
|
|
useEffect(() => {
|
|
// Only attempt to set purchase price for new line items
|
|
if (!create) return;
|
|
|
|
const qty = toNumber(quantity, null);
|
|
|
|
if (qty == null || qty <= 0) {
|
|
setSuggestedPurchasePrice(undefined);
|
|
return;
|
|
}
|
|
|
|
if (!part || !priceBreaks || priceBreaks.length === 0) {
|
|
setSuggestedPurchasePrice(undefined);
|
|
return;
|
|
}
|
|
|
|
const applicablePriceBreaks = priceBreaks
|
|
.filter((pb: any) => qty >= pb.quantity)
|
|
.sort((a: any, b: any) => b.quantity - a.quantity);
|
|
|
|
if (applicablePriceBreaks.length) {
|
|
setSuggestedPurchasePrice(applicablePriceBreaks[0].price);
|
|
} else {
|
|
setSuggestedPurchasePrice(undefined);
|
|
}
|
|
}, [create, part, quantity, priceBreaks]);
|
|
|
|
const fields = useMemo(() => {
|
|
const fields: ApiFormFieldSet = {
|
|
order: {
|
|
filters: {
|
|
supplier_detail: true
|
|
},
|
|
disabled: true
|
|
},
|
|
part: {
|
|
filters: {
|
|
part_detail: true,
|
|
supplier_detail: true,
|
|
active: true,
|
|
part_active: true,
|
|
price_breaks: true
|
|
},
|
|
onValueChange: (value, record) => {
|
|
setPriceBreaks(record?.price_breaks ?? []);
|
|
setPart(record?.part_detail ?? {});
|
|
},
|
|
adjustFilters: (adjust: ApiFormAdjustFilterType) => {
|
|
return {
|
|
...adjust.filters,
|
|
supplier: supplierId
|
|
};
|
|
},
|
|
addCreateFields: {
|
|
part: {
|
|
filters: {
|
|
active: true,
|
|
purchaseable: true
|
|
}
|
|
},
|
|
SKU: {},
|
|
manufacturer_part: {
|
|
filters: {
|
|
part_detail: true,
|
|
manufacturer_detail: true,
|
|
active: true
|
|
}
|
|
},
|
|
description: {},
|
|
supplier: {
|
|
hidden: true,
|
|
value: supplierId
|
|
}
|
|
}
|
|
},
|
|
line: {},
|
|
reference: {},
|
|
quantity: {
|
|
onValueChange: (value) => {
|
|
setQuantity(value);
|
|
}
|
|
},
|
|
purchase_price: {
|
|
icon: <IconCurrencyDollar />,
|
|
value: purchasePrice,
|
|
disabled: autoPricing,
|
|
placeholder: suggestedPurchasePrice,
|
|
placeholderAutofill: true,
|
|
onValueChange: setPurchasePrice
|
|
},
|
|
purchase_price_currency: {
|
|
icon: <IconCoins />,
|
|
value: purchasePriceCurrency,
|
|
onValueChange: setPurchasePriceCurrency
|
|
},
|
|
auto_pricing: {
|
|
default: create !== false,
|
|
value: autoPricing,
|
|
onValueChange: setAutoPricing
|
|
},
|
|
project_code: ProjectCodeField(),
|
|
target_date: {
|
|
icon: <IconCalendar />
|
|
},
|
|
destination: {
|
|
icon: <IconSitemap />
|
|
},
|
|
build_order: {
|
|
disabled: !part?.assembly,
|
|
filters: {
|
|
external: true,
|
|
outstanding: true,
|
|
part: part?.pk
|
|
}
|
|
},
|
|
notes: {
|
|
icon: <IconNotes />
|
|
},
|
|
link: {
|
|
icon: <IconLink />
|
|
}
|
|
};
|
|
|
|
if (!globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS', false)) {
|
|
delete fields.build_order;
|
|
}
|
|
|
|
if (create) {
|
|
fields['merge_items'] = {};
|
|
}
|
|
|
|
return fields;
|
|
}, [
|
|
create,
|
|
orderId,
|
|
part,
|
|
globalSettings,
|
|
supplierId,
|
|
autoPricing,
|
|
purchasePrice,
|
|
suggestedPurchasePrice
|
|
]);
|
|
|
|
return fields;
|
|
}
|
|
|
|
/**
|
|
* Construct a set of fields for creating / editing a PurchaseOrder instance
|
|
*/
|
|
export function usePurchaseOrderFields({
|
|
supplierId,
|
|
duplicateOrderId
|
|
}: {
|
|
supplierId?: number;
|
|
duplicateOrderId?: number;
|
|
}): ApiFormFieldSet {
|
|
const globalSettings = useGlobalSettingsState();
|
|
|
|
return useMemo(() => {
|
|
const fields: ApiFormFieldSet = {
|
|
reference: {
|
|
icon: <IconHash />
|
|
},
|
|
description: {},
|
|
supplier: {
|
|
value: supplierId,
|
|
disabled: !!duplicateOrderId || !!supplierId,
|
|
filters: {
|
|
is_supplier: true,
|
|
active: true
|
|
},
|
|
addCreateFields: {
|
|
name: {},
|
|
description: {},
|
|
is_supplier: { value: true, hidden: true }
|
|
}
|
|
},
|
|
supplier_reference: {},
|
|
project_code: ProjectCodeField(),
|
|
order_currency: {
|
|
icon: <IconCoins />
|
|
},
|
|
start_date: {
|
|
icon: <IconCalendar />
|
|
},
|
|
target_date: {
|
|
icon: <IconCalendar />
|
|
},
|
|
destination: {
|
|
filters: {
|
|
structural: false
|
|
}
|
|
},
|
|
tags: TagsField({}),
|
|
link: {},
|
|
contact: {
|
|
icon: <IconUser />,
|
|
adjustFilters: (value: ApiFormAdjustFilterType) => {
|
|
return {
|
|
...value.filters,
|
|
company: value.data.supplier
|
|
};
|
|
}
|
|
},
|
|
address: {
|
|
icon: <IconAddressBook />,
|
|
adjustFilters: (value: ApiFormAdjustFilterType) => {
|
|
return {
|
|
...value.filters,
|
|
company: value.data.supplier
|
|
};
|
|
}
|
|
},
|
|
responsible: {
|
|
filters: {
|
|
is_active: true
|
|
},
|
|
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, supplierId, globalSettings]);
|
|
}
|
|
|
|
/**
|
|
* Render a table row for a single TableField entry
|
|
*/
|
|
function LineItemFormRow({
|
|
props,
|
|
record,
|
|
statuses
|
|
}: Readonly<{
|
|
props: TableFieldRowProps;
|
|
record: any;
|
|
statuses: any;
|
|
}>) {
|
|
// Barcode Modal state
|
|
const [opened, { open, close }] = useDisclosure(false, {
|
|
onClose: () => props.changeFn(props.rowId, 'barcode', undefined)
|
|
});
|
|
|
|
const [locationOpen, locationHandlers] = useDisclosure(false, {
|
|
onClose: () => props.changeFn(props.rowId, 'location', undefined)
|
|
});
|
|
|
|
// Is this a trackable part?
|
|
const trackable: boolean = useMemo(
|
|
() => record.part_detail?.trackable ?? false,
|
|
[record]
|
|
);
|
|
|
|
const settings = useGlobalSettingsState();
|
|
|
|
useEffect(() => {
|
|
if (!!record.destination) {
|
|
props.changeFn(props.rowId, 'location', record.destination);
|
|
locationHandlers.open();
|
|
}
|
|
}, [record.destination]);
|
|
|
|
// Batch code generator
|
|
const batchCodeGenerator = useBatchCodeGenerator({
|
|
isEnabled: () => batchOpen,
|
|
onGenerate: (value: any) => {
|
|
if (value) {
|
|
props.changeFn(props.rowId, 'batch_code', value);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Serial number generator
|
|
const serialNumberGenerator = useSerialNumberGenerator({
|
|
isEnabled: () => trackable
|
|
});
|
|
|
|
const [packagingOpen, packagingHandlers] = useDisclosure(false, {
|
|
onClose: () => {
|
|
props.changeFn(props.rowId, 'packaging', undefined);
|
|
}
|
|
});
|
|
|
|
const [noteOpen, noteHandlers] = useDisclosure(false, {
|
|
onClose: () => {
|
|
props.changeFn(props.rowId, 'note', undefined);
|
|
}
|
|
});
|
|
|
|
const [batchOpen, batchHandlers] = useDisclosure(false, {
|
|
onClose: () => {
|
|
props.changeFn(props.rowId, 'batch_code', undefined);
|
|
},
|
|
onOpen: () => {
|
|
// Generate a new batch code
|
|
batchCodeGenerator.update({
|
|
part: record?.supplier_part_detail?.part,
|
|
order: record?.order
|
|
});
|
|
}
|
|
});
|
|
|
|
const [serialOpen, serialHandlers] = useDisclosure(false, {
|
|
onClose: () => {
|
|
props.changeFn(props.rowId, 'serial_numbers', undefined);
|
|
},
|
|
onOpen: () => {
|
|
// Generate new serial numbers
|
|
if (trackable) {
|
|
serialNumberGenerator.update({
|
|
part: record?.supplier_part_detail?.part,
|
|
quantity: props.item.quantity
|
|
});
|
|
} else {
|
|
props.changeFn(props.rowId, 'serial_numbers', undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
const [expiryDateOpen, expiryDateHandlers] = useDisclosure(false, {
|
|
onOpen: () => {
|
|
// check the default part expiry. Assume expiry is relative to today
|
|
const defaultExpiry = record.part_detail?.default_expiry;
|
|
if (defaultExpiry !== undefined && defaultExpiry > 0) {
|
|
props.changeFn(
|
|
props.rowId,
|
|
'expiry_date',
|
|
dayjs().add(defaultExpiry, 'day').format('YYYY-MM-DD')
|
|
);
|
|
}
|
|
},
|
|
onClose: () => {
|
|
props.changeFn(props.rowId, 'expiry_date', undefined);
|
|
}
|
|
});
|
|
|
|
// Status value
|
|
const [statusOpen, statusHandlers] = useDisclosure(false, {
|
|
onClose: () => props.changeFn(props.rowId, 'status', undefined)
|
|
});
|
|
|
|
// Barcode value
|
|
const [barcodeInput, setBarcodeInput] = useState<any>('');
|
|
const [barcode, setBarcode] = useState<string | undefined>(undefined);
|
|
|
|
// Change form value when state is altered
|
|
useEffect(() => {
|
|
props.changeFn(props.rowId, '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(() => {
|
|
const 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 &&
|
|
record.part_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 (
|
|
record.part_detail?.default_location &&
|
|
location === record.part_detail.default_location
|
|
) {
|
|
return t`Default location selected`;
|
|
}
|
|
|
|
return text;
|
|
}, [location]);
|
|
|
|
// Handle virtual parts
|
|
const virtual = useMemo(
|
|
() => record.part_detail?.virtual ?? false,
|
|
[record.part_detail]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<Modal
|
|
opened={opened}
|
|
onClose={close}
|
|
title={<StylishText>{t`Scan Barcode`}</StylishText>}
|
|
>
|
|
<FocusTrap>
|
|
<TextInput
|
|
label='Barcode data'
|
|
data-autofocus
|
|
value={barcodeInput}
|
|
onChange={(e) => setBarcodeInput(e.target.value)}
|
|
/>
|
|
</FocusTrap>
|
|
</Modal>
|
|
<Table.Tr>
|
|
<Table.Td>
|
|
<Group gap='xs' justify='space-between'>
|
|
<Group gap='xs' justify='left'>
|
|
<Thumbnail
|
|
size={40}
|
|
src={record.part_detail.thumbnail}
|
|
align='center'
|
|
/>
|
|
<div>{record.part_detail.name}</div>
|
|
</Group>
|
|
{virtual && (
|
|
<HoverCard>
|
|
<HoverCard.Target>
|
|
<ActionIcon color='blue' variant='transparent'>
|
|
<IconInfoCircle />
|
|
</ActionIcon>
|
|
</HoverCard.Target>
|
|
<HoverCard.Dropdown>
|
|
<Alert color='blue' title={t`Virtual Part`}>
|
|
{t`This part is virtual, no physical stock will be received.`}
|
|
</Alert>
|
|
</HoverCard.Dropdown>
|
|
</HoverCard>
|
|
)}
|
|
</Group>
|
|
</Table.Td>
|
|
<Table.Td>{record.supplier_part_detail.SKU}</Table.Td>
|
|
<Table.Td>
|
|
<ProgressBar
|
|
value={record.received}
|
|
maximum={record.quantity}
|
|
progressLabel
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td style={{ whiteSpace: 'nowrap' }}>
|
|
<TableFieldQuantityInput
|
|
min={0}
|
|
value={props.item.quantity ?? ''}
|
|
onChange={(value) => {
|
|
props.changeFn(props.rowId, 'quantity', value);
|
|
serialNumberGenerator.update({
|
|
quantity: value === '' ? undefined : value
|
|
});
|
|
}}
|
|
error={props.rowErrors?.quantity?.message}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td style={{ width: '1%', whiteSpace: 'nowrap' }}>
|
|
<Flex gap='1px'>
|
|
<ActionButton
|
|
size='sm'
|
|
onClick={() => locationHandlers.toggle()}
|
|
icon={<InvenTreeIcon icon='location' />}
|
|
tooltip={t`Set Location`}
|
|
tooltipAlignment='top'
|
|
variant={locationOpen ? 'outline' : 'transparent'}
|
|
disabled={virtual}
|
|
/>
|
|
<ActionButton
|
|
size='sm'
|
|
onClick={() => batchHandlers.toggle()}
|
|
icon={<InvenTreeIcon icon='batch_code' />}
|
|
tooltip={t`Assign Batch Code`}
|
|
tooltipAlignment='top'
|
|
variant={batchOpen ? 'outline' : 'transparent'}
|
|
disabled={virtual}
|
|
/>
|
|
{trackable && (
|
|
<ActionButton
|
|
size='sm'
|
|
onClick={() => serialHandlers.toggle()}
|
|
icon={<InvenTreeIcon icon='serial' />}
|
|
tooltip={t`Assign Serial Numbers`}
|
|
tooltipAlignment='top'
|
|
variant={serialOpen ? 'outline' : 'transparent'}
|
|
disabled={virtual}
|
|
/>
|
|
)}
|
|
|
|
{settings.isSet('STOCK_ENABLE_EXPIRY') && (
|
|
<ActionButton
|
|
size='sm'
|
|
onClick={() => expiryDateHandlers.toggle()}
|
|
icon={<IconCalendarExclamation />}
|
|
tooltip={t`Set Expiry Date`}
|
|
tooltipAlignment='top'
|
|
variant={expiryDateOpen ? 'outline' : 'transparent'}
|
|
disabled={virtual}
|
|
/>
|
|
)}
|
|
<ActionButton
|
|
size='sm'
|
|
icon={<InvenTreeIcon icon='packaging' />}
|
|
tooltip={t`Adjust Packaging`}
|
|
tooltipAlignment='top'
|
|
onClick={() => packagingHandlers.toggle()}
|
|
variant={packagingOpen ? 'outline' : 'transparent'}
|
|
disabled={virtual}
|
|
/>
|
|
<ActionButton
|
|
onClick={() => statusHandlers.toggle()}
|
|
icon={<InvenTreeIcon icon='status' />}
|
|
tooltip={t`Change Status`}
|
|
tooltipAlignment='top'
|
|
variant={statusOpen ? 'outline' : 'transparent'}
|
|
disabled={virtual}
|
|
/>
|
|
<ActionButton
|
|
icon={<InvenTreeIcon icon='note' />}
|
|
tooltip={t`Add Note`}
|
|
tooltipAlignment='top'
|
|
variant={noteOpen ? 'outline' : 'transparent'}
|
|
onClick={() => noteHandlers.toggle()}
|
|
disabled={virtual}
|
|
/>
|
|
{barcode ? (
|
|
<ActionButton
|
|
icon={<InvenTreeIcon icon='unlink' />}
|
|
tooltip={t`Unlink Barcode`}
|
|
tooltipAlignment='top'
|
|
variant='filled'
|
|
color='red'
|
|
onClick={() => setBarcode(undefined)}
|
|
disabled={virtual}
|
|
/>
|
|
) : (
|
|
<ActionButton
|
|
icon={<InvenTreeIcon icon='barcode' />}
|
|
tooltip={t`Scan Barcode`}
|
|
tooltipAlignment='top'
|
|
variant='transparent'
|
|
onClick={() => open()}
|
|
disabled={virtual}
|
|
/>
|
|
)}
|
|
</Flex>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
{locationOpen && (
|
|
<Table.Tr>
|
|
<Table.Td colSpan={10}>
|
|
<Group grow preventGrowOverflow={false} justify='flex-apart' p='xs'>
|
|
<Container flex={0} p='xs'>
|
|
<InvenTreeIcon icon='downright' />
|
|
</Container>
|
|
<StandaloneField
|
|
fieldDefinition={{
|
|
field_type: 'related field',
|
|
model: ModelType.stocklocation,
|
|
api_url: apiUrl(ApiEndpoints.stock_location_list),
|
|
filters: {
|
|
structural: false
|
|
},
|
|
onValueChange: (value) => {
|
|
props.changeFn(props.rowId, 'location', value);
|
|
},
|
|
description: locationDescription,
|
|
value: props.item.location,
|
|
label: t`Location`,
|
|
icon: <InvenTreeIcon icon='location' />
|
|
}}
|
|
defaultValue={
|
|
record.destination ??
|
|
(record.destination_detail
|
|
? record.destination_detail.pk
|
|
: null)
|
|
}
|
|
/>
|
|
<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={() =>
|
|
props.changeFn(
|
|
props.rowId,
|
|
'location',
|
|
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={() =>
|
|
props.changeFn(
|
|
props.rowId,
|
|
'location',
|
|
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={() =>
|
|
props.changeFn(
|
|
props.rowId,
|
|
'location',
|
|
record.destination_detail.pk
|
|
)
|
|
}
|
|
tooltipAlignment='top'
|
|
/>
|
|
)}
|
|
</Flex>
|
|
</Group>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
)}
|
|
<TableFieldExtraRow
|
|
visible={batchOpen}
|
|
onValueChange={(value) => {
|
|
props.changeFn(props.rowId, 'batch_code', value);
|
|
}}
|
|
fieldName='batch_code'
|
|
fieldDefinition={{
|
|
field_type: 'string',
|
|
label: t`Batch Code`,
|
|
description: t`Enter batch code for received items`,
|
|
value: props.item.batch_code
|
|
}}
|
|
error={props.rowErrors?.batch_code?.message}
|
|
/>
|
|
<TableFieldExtraRow
|
|
visible={serialOpen}
|
|
onValueChange={(value) =>
|
|
props.changeFn(props.rowId, 'serial_numbers', value)
|
|
}
|
|
fieldName='serial_numbers'
|
|
fieldDefinition={{
|
|
field_type: 'string',
|
|
label: t`Serial Numbers`,
|
|
description: t`Enter serial numbers for received items`,
|
|
value: props.item.serial_numbers,
|
|
placeholderAutofill: true,
|
|
placeholder:
|
|
serialNumberGenerator.result && `${serialNumberGenerator.result}`
|
|
}}
|
|
error={props.rowErrors?.serial_numbers?.message}
|
|
/>
|
|
{settings.isSet('STOCK_ENABLE_EXPIRY') && (
|
|
<TableFieldExtraRow
|
|
visible={expiryDateOpen}
|
|
onValueChange={(value) =>
|
|
props.changeFn(props.rowId, 'expiry_date', value)
|
|
}
|
|
fieldName='expiry_date'
|
|
fieldDefinition={{
|
|
field_type: 'date',
|
|
label: t`Expiry Date`,
|
|
description: t`Enter an expiry date for received items`,
|
|
value: props.item.expiry_date
|
|
}}
|
|
error={props.rowErrors?.expiry_date?.message}
|
|
/>
|
|
)}
|
|
<TableFieldExtraRow
|
|
visible={packagingOpen}
|
|
onValueChange={(value) =>
|
|
props.changeFn(props.rowId, 'packaging', value)
|
|
}
|
|
fieldName='packaging'
|
|
fieldDefinition={{
|
|
field_type: 'string',
|
|
label: t`Packaging`
|
|
}}
|
|
defaultValue={record?.supplier_part_detail?.packaging}
|
|
error={props.rowErrors?.packaging?.message}
|
|
/>
|
|
<TableFieldExtraRow
|
|
visible={statusOpen}
|
|
defaultValue={10}
|
|
fieldName='status'
|
|
onValueChange={(value) => props.changeFn(props.rowId, 'status', value)}
|
|
fieldDefinition={{
|
|
field_type: 'choice',
|
|
api_url: apiUrl(ApiEndpoints.stock_status),
|
|
choices: statuses,
|
|
label: t`Status`
|
|
}}
|
|
error={props.rowErrors?.status?.message}
|
|
/>
|
|
<TableFieldExtraRow
|
|
visible={noteOpen}
|
|
fieldName='note'
|
|
onValueChange={(value) => props.changeFn(props.rowId, 'note', value)}
|
|
fieldDefinition={{
|
|
field_type: 'string',
|
|
label: t`Note`
|
|
}}
|
|
error={props.rowErrors?.note?.message}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
type LineFormHandlers = {
|
|
onOpen?: () => void;
|
|
onClose?: () => void;
|
|
};
|
|
|
|
type LineItemsForm = {
|
|
items: any[];
|
|
orderPk: number;
|
|
destinationPk?: number;
|
|
formProps?: LineFormHandlers;
|
|
};
|
|
|
|
export function useReceiveLineItems(props: LineItemsForm) {
|
|
const stockStatusCodes = useMemo(
|
|
() => getStatusCodeOptions(ModelType.stockitem),
|
|
[]
|
|
);
|
|
|
|
const records = useMemo(() => {
|
|
return Object.fromEntries(props.items.map((item) => [item.pk, item]));
|
|
}, [props.items]);
|
|
|
|
const filteredItems = useMemo(() => {
|
|
return props.items
|
|
.filter((elem) => elem.quantity !== elem.received)
|
|
.map((elem) => {
|
|
return {
|
|
id: elem.pk,
|
|
...elem
|
|
};
|
|
});
|
|
}, [props.items]);
|
|
|
|
const fields: ApiFormFieldSet = useMemo(() => {
|
|
return {
|
|
id: {
|
|
value: props.orderPk,
|
|
hidden: true
|
|
},
|
|
items: {
|
|
field_type: 'table',
|
|
value: filteredItems.map((elem) => {
|
|
return {
|
|
id: elem.pk,
|
|
line_item: elem.pk,
|
|
location: elem.destination ?? elem.destination_detail?.pk ?? null,
|
|
quantity: elem.quantity - elem.received,
|
|
expiry_date: null,
|
|
batch_code: '',
|
|
serial_numbers: '',
|
|
status: 10,
|
|
barcode: null
|
|
};
|
|
}),
|
|
modelRenderer: (row: TableFieldRowProps) => {
|
|
const record = records[row.item.line_item];
|
|
|
|
if (!record) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<LineItemFormRow
|
|
props={row}
|
|
record={record}
|
|
statuses={stockStatusCodes}
|
|
key={row.rowId}
|
|
/>
|
|
);
|
|
},
|
|
headers: [
|
|
{ title: t`Part`, style: { minWidth: '200px' } },
|
|
{ title: t`SKU`, style: { minWidth: '200px' } },
|
|
{ title: t`Received`, style: { minWidth: '200px' } },
|
|
{ title: t`Quantity`, style: { width: '200px' } },
|
|
{ title: t`Actions` },
|
|
{ title: '', style: { width: '50px' } }
|
|
]
|
|
},
|
|
location: {
|
|
filters: {
|
|
structural: false
|
|
}
|
|
}
|
|
};
|
|
}, [filteredItems, records, props, stockStatusCodes]);
|
|
|
|
return useCreateApiFormModal({
|
|
...props.formProps,
|
|
url: apiUrl(ApiEndpoints.purchase_order_receive, props.orderPk),
|
|
title: t`Receive Line Items`,
|
|
fields: fields,
|
|
initialData: {
|
|
location: props.destinationPk
|
|
},
|
|
size: '80%',
|
|
successMessage: t`Items received`
|
|
});
|
|
}
|