Merge commit '27ca0836e7c7e46ebb41ea98d507972ae7a6b018' into block-notes

This commit is contained in:
Oliver Walters
2026-05-23 12:41:47 +00:00
105 changed files with 7424 additions and 335 deletions
+16 -4
View File
@@ -12,12 +12,13 @@ export enum ApiEndpoints {
// User API endpoints
user_list = 'user/',
user_set_password = 'user/:id/set-password/',
user_me = 'user/me/',
user_profile = 'user/profile/',
user_roles = 'user/roles/',
user_token = 'user/token/',
user_tokens = 'user/tokens/',
user_simple_login = 'email/generate/',
// Individual user endpoints
user_me_profile = 'user/me/profile/',
user_me_roles = 'user/me/roles/',
user_me_token = 'user/me/token/',
user_me = 'user/me/',
// User auth endpoints
auth_base = '/auth/',
@@ -198,6 +199,17 @@ export enum ApiEndpoints {
return_order_line_list = 'order/ro-line/',
return_order_extra_line_list = 'order/ro-extra-line/',
transfer_order_list = 'order/transfer-order/',
transfer_order_issue = 'order/transfer-order/:id/issue/',
transfer_order_hold = 'order/transfer-order/:id/hold/',
transfer_order_cancel = 'order/transfer-order/:id/cancel/',
transfer_order_complete = 'order/transfer-order/:id/complete/',
transfer_order_allocate = 'order/transfer-order/:id/allocate/',
transfer_order_allocate_serials = 'order/transfer-order/:id/allocate-serials/',
transfer_order_line_list = 'order/transfer-order-line/',
transfer_order_allocation_list = 'order/transfer-order-allocation/',
// Template API endpoints
label_list = 'label/template/',
label_print = 'label/print/',
@@ -207,6 +207,22 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.return_order_line_list,
icon: 'return_orders'
},
transferorder: {
label: () => t`Transfer Order`,
label_multiple: () => t`Transfer Orders`,
url_overview: '/stock/location/index/transfer-orders',
url_detail: '/stock/transfer-order/:pk/',
api_endpoint: ApiEndpoints.transfer_order_list,
admin_url: '/order/transferorder/',
supports_barcode: true,
icon: 'transfer_orders'
},
transferorderlineitem: {
label: () => t`Transfer Order Line Item`,
label_multiple: () => t`Transfer Order Line Items`,
api_endpoint: ApiEndpoints.transfer_order_line_list,
icon: 'transfer-orders'
},
address: {
label: () => t`Address`,
label_multiple: () => t`Addresses`,
+2
View File
@@ -24,6 +24,8 @@ export enum ModelType {
salesordershipment = 'salesordershipment',
returnorder = 'returnorder',
returnorderlineitem = 'returnorderlineitem',
transferorder = 'transferorder',
transferorderlineitem = 'transferorderlineitem',
importsession = 'importsession',
address = 'address',
contact = 'contact',
+3
View File
@@ -11,6 +11,7 @@ export enum UserRoles {
part_category = 'part_category',
purchase_order = 'purchase_order',
return_order = 'return_order',
transfer_order = 'transfer_order',
sales_order = 'sales_order',
stock = 'stock',
stock_location = 'stock_location'
@@ -40,6 +41,8 @@ export function userRoleLabel(role: UserRoles): string {
return t`Purchase Orders`;
case UserRoles.return_order:
return t`Return Orders`;
case UserRoles.transfer_order:
return t`Transfer Orders`;
case UserRoles.sales_order:
return t`Sales Orders`;
case UserRoles.stock:
@@ -12,7 +12,8 @@ export default function PrimaryActionButton({
icon,
color,
hidden,
onClick
onClick,
leftSection
}: Readonly<{
title: string;
tooltip?: string;
@@ -20,6 +21,7 @@ export default function PrimaryActionButton({
color?: string;
hidden?: boolean;
onClick: () => void;
leftSection?: React.ReactNode;
}>) {
if (hidden) {
return null;
@@ -28,7 +30,7 @@ export default function PrimaryActionButton({
return (
<Tooltip label={tooltip ?? title} position='bottom' hidden={!tooltip}>
<Button
leftSection={icon && <InvenTreeIcon icon={icon} />}
leftSection={leftSection ?? (icon && <InvenTreeIcon icon={icon} />)}
color={color}
radius='sm'
p='xs'
@@ -1,4 +1,8 @@
import type { CalendarOptions, DatesSetArg } from '@fullcalendar/core';
import type {
CalendarOptions,
DatesSetArg,
EventContentArg
} from '@fullcalendar/core';
import allLocales from '@fullcalendar/core/locales-all';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
@@ -15,6 +19,7 @@ import {
Box,
Button,
Group,
HoverCard,
Indicator,
LoadingOverlay,
Popover,
@@ -29,7 +34,13 @@ import {
IconDownload,
IconFilter
} from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState
} from 'react';
import { useShallow } from 'zustand/react/shallow';
import {
defaultLocale,
@@ -44,6 +55,7 @@ export interface InvenTreeCalendarProps extends CalendarOptions {
enableDownload?: boolean;
enableFilters?: boolean;
enableSearch?: boolean;
eventTooltipContent?: (event: EventContentArg) => ReactNode;
filters?: TableFilter[];
isLoading?: boolean;
state: CalendarState;
@@ -53,6 +65,7 @@ export default function Calendar({
enableDownload,
enableFilters = false,
enableSearch,
eventTooltipContent,
isLoading,
filters,
state,
@@ -112,6 +125,36 @@ export default function Calendar({
[calendarProps.datesSet, state.ref, state.setMonthName]
);
const wrappedEventContent = useCallback(
(arg: EventContentArg) => {
const inner =
typeof calendarProps.eventContent === 'function'
? calendarProps.eventContent(arg, null)
: (calendarProps.eventContent ?? null);
if (!eventTooltipContent) return inner;
const tooltip = eventTooltipContent(arg);
if (!tooltip) return inner;
return (
<HoverCard
openDelay={300}
closeDelay={100}
shadow='md'
position='top-start'
>
<HoverCard.Target>
<div style={{ width: '100%', overflow: 'hidden' }}>{inner}</div>
</HoverCard.Target>
<HoverCard.Dropdown>{tooltip}</HoverCard.Dropdown>
</HoverCard>
);
},
[calendarProps.eventContent, eventTooltipContent]
);
return (
<>
{state.exportModal.modal}
@@ -217,6 +260,7 @@ export default function Calendar({
footerToolbar={false}
{...calendarProps}
datesSet={datesSet}
eventContent={wrappedEventContent}
/>
</Box>
</Stack>
@@ -46,12 +46,14 @@ export default function OrderCalendar({
model,
role,
params,
filters
filters,
tooltip
}: {
model: ModelType;
role: UserRoles;
params: Record<string, any>;
filters?: TableFilter[];
tooltip?: (event: EventContentArg) => React.ReactNode;
}) {
const navigate = useNavigate();
const user = useUserState();
@@ -106,6 +108,7 @@ export default function OrderCalendar({
const end: string = order.target_date || start;
return {
order: order,
id: order.pk,
title: order.reference,
description: order.description,
@@ -209,6 +212,7 @@ export default function OrderCalendar({
state={calendarState}
filters={calendarFilters}
editable={true}
eventTooltipContent={tooltip}
eventContent={renderOrder}
eventClick={onClickOrder}
eventChange={onEditOrder}
@@ -0,0 +1,61 @@
import type { EventContentArg } from '@fullcalendar/core';
import type { ModelType } from '@lib/enums/ModelType';
import { resolveItem } from '@lib/functions/Conversion';
import { t } from '@lingui/core/macro';
import { Badge, Divider, Group, Stack, Text } from '@mantine/core';
import { formatDate } from '../../defaults/formatters';
import { RenderInstance } from '../render/Instance';
import { RenderOwner } from '../render/User';
export default function OrderCalendarToolTip({
event,
instanceLookup,
modelType
}: {
event: EventContentArg;
instanceLookup: string;
modelType: ModelType;
}) {
// Extract the order instance from the event
const order = event?.event?._def?.extendedProps?.order;
const instance = resolveItem(order, instanceLookup);
if (!order) return null;
return (
<Stack gap='xs'>
<RenderInstance model={modelType} instance={instance} />
<Divider />
<Group gap='xs'>
<Text size='sm' fw='bold'>
{order.reference}
</Text>
<Text size='xs'>{order.description || order.title}</Text>
</Group>
{order.start_date && (
<Group gap='xs'>
<Text size='sm' fw='bold'>{t`Start Date`}</Text>
<Text size='xs'>{formatDate(order.start_date)}</Text>
</Group>
)}
{order.target_date && (
<Group gap='xs'>
<Text size='sm' fw='bold'>{t`Target Date`}</Text>
<Text size='xs'>{formatDate(order.target_date)}</Text>
{order.overdue && (
<Badge size='sm' color='red'>
{t`Overdue`}
</Badge>
)}
</Group>
)}
{order.responsible && (
<Group gap='xs'>
<Text size='sm' fw='bold'>{t`Responsible`}</Text>
<RenderOwner instance={order.responsible_detail} />
</Group>
)}
</Stack>
);
}
@@ -12,6 +12,7 @@ import {
} from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react';
import { generateUrl } from '../../functions/urls';
import { Thumbnail } from '../images/Thumbnail';
/**
* Return an icon based on the provided filename
@@ -59,9 +60,11 @@ export function attachmentIcon(attachment: string): ReactNode {
*/
export function AttachmentLink({
attachment,
thumbnail,
external
}: Readonly<{
attachment: string;
thumbnail?: string;
external?: boolean;
}>): ReactNode {
const url = useMemo(() => {
@@ -82,7 +85,13 @@ export function AttachmentLink({
return (
<Group justify='left' gap='sm' wrap='nowrap'>
{external ? <IconLink /> : attachmentIcon(attachment)}
{thumbnail ? (
<Thumbnail src={thumbnail} hover size={16} />
) : external ? (
<IconLink />
) : (
attachmentIcon(attachment)
)}
{!!attachment ? (
<Anchor href={url} target='_blank' rel='noopener noreferrer'>
{text}
+42 -2
View File
@@ -4,8 +4,13 @@ import { useHotkeys } from '@mantine/hooks';
import { StylishText } from '@lib/components/StylishText';
import { shortenString } from '@lib/functions/String';
import { Fragment, type ReactNode, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { usePluginUIFeature } from '../../hooks/UsePluginUIFeature';
import { useUserSettingsState } from '../../states/SettingsStates';
import PrimaryActionButton from '../buttons/PrimaryActionButton';
import { ApiImage } from '../images/ApiImage';
import { ApiIcon } from '../items/ApiIcon';
import type { PrimaryActionUIFeature } from '../plugins/PluginUIFeatureTypes';
import { type Breadcrumb, BreadcrumbList } from './BreadcrumbList';
import PageTitle from './PageTitle';
@@ -45,6 +50,8 @@ export function PageDetail({
editEnabled
}: Readonly<PageDetailInterface>) {
const userSettings = useUserSettingsState();
const navigate = useNavigate();
const location = useLocation();
useHotkeys([
[
'mod+E',
@@ -86,6 +93,39 @@ export function PageDetail({
}
}, [breadcrumbs, last_crumb, userSettings]);
const extraActions = usePluginUIFeature<PrimaryActionUIFeature>({
featureType: 'primary_action',
context: { location: location.pathname }
});
// action caching
const computedActions = useMemo(() => {
const extraActionArray: ReactNode[] = extraActions.map((action) => {
const { options: opts, func } = action;
const { title, icon, context, options } = opts;
const click = () => {
const url = options?.url;
if (url) {
navigate(url);
} else if (func) {
func(context);
}
};
return (
<PrimaryActionButton
title={title}
leftSection={<ApiIcon name={icon as string} />}
color={options?.color}
onClick={click}
key={title}
/>
);
});
return [...(extraActionArray ?? []), ...(actions ?? [])];
}, [extraActions, actions]);
return (
<>
<PageTitle title={pageTitleString} />
@@ -140,9 +180,9 @@ export function PageDetail({
</Group>
)}
</Group>
{actions && (
{computedActions && (
<Group gap={5} justify='right' wrap='nowrap' align='flex-start'>
{actions.map((action, idx) => (
{computedActions.map((action, idx) => (
<Fragment key={idx}>{action}</Fragment>
))}
</Group>
@@ -30,7 +30,8 @@ export enum PluginUIFeatureType {
panel = 'panel',
template_editor = 'template_editor',
template_preview = 'template_preview',
navigation = 'navigation'
navigation = 'navigation',
primary_action = 'primary_action'
}
/**
@@ -84,3 +84,11 @@ export type NavigationUIFeature = {
featureContext: {};
featureReturnType: undefined;
};
export type PrimaryActionUIFeature = {
featureType: 'primary_action';
requestContext: {};
responseOptions: PluginUIFeature;
featureContext: {};
featureReturnType: undefined;
};
@@ -50,11 +50,11 @@ export function RenderCompany(
return (
<RenderInlineModel
{...props}
image={instance.thumbnail || instance.image}
primary={instance.name}
suffix={instance.description}
image={instance?.thumbnail || instance?.image}
primary={instance?.name}
suffix={instance?.description}
url={
props.link ? getDetailUrl(ModelType.company, instance.pk) : undefined
props.link ? getDetailUrl(ModelType.company, instance?.pk) : undefined
}
/>
);
@@ -51,7 +51,9 @@ import {
RenderReturnOrder,
RenderReturnOrderLineItem,
RenderSalesOrder,
RenderSalesOrderShipment
RenderSalesOrderShipment,
RenderTransferOrder,
RenderTransferOrderLineItem
} from './Order';
import { RenderPart, RenderPartCategory, RenderPartTestTemplate } from './Part';
import { RenderPlugin } from './Plugin';
@@ -87,6 +89,8 @@ export const RendererLookup: ModelRendererDict = {
[ModelType.returnorderlineitem]: RenderReturnOrderLineItem,
[ModelType.salesorder]: RenderSalesOrder,
[ModelType.salesordershipment]: RenderSalesOrderShipment,
[ModelType.transferorder]: RenderTransferOrder,
[ModelType.transferorderlineitem]: RenderTransferOrderLineItem,
[ModelType.stocklocation]: RenderStockLocation,
[ModelType.stocklocationtype]: RenderStockLocationType,
[ModelType.stockitem]: RenderStockItem,
@@ -123,3 +123,46 @@ export function RenderSalesOrderShipment({
/>
);
}
/**
* Inline rendering of a single TransferOrder instance
*/
export function RenderTransferOrder(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
return (
<RenderInlineModel
{...props}
primary={instance.reference}
secondary={instance.description}
suffix={StatusRenderer({
status: instance.status_custom_key,
type: ModelType.transferorder
})}
url={
props.link
? getDetailUrl(ModelType.transferorder, instance.pk)
: undefined
}
/>
);
}
export function RenderTransferOrderLineItem(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
return (
<RenderInlineModel
{...props}
primary={instance.reference}
suffix={StatusRenderer({
status: instance.outcome,
type: ModelType.transferorderlineitem
})}
/>
);
}
@@ -11,6 +11,8 @@ export const statusCodeList: Record<string, ModelType> = {
PurchaseOrderStatus: ModelType.purchaseorder,
ReturnOrderStatus: ModelType.returnorder,
ReturnOrderLineStatus: ModelType.returnorderlineitem,
TransferOrderStatus: ModelType.transferorder,
TransferOrderLineStatus: ModelType.transferorderlineitem,
SalesOrderStatus: ModelType.salesorder,
StockHistoryCode: ModelType.stockhistory,
StockStatus: ModelType.stockitem,
@@ -0,0 +1,308 @@
import { ApiEndpoints, ModelType, ProgressBar, apiUrl } from '@lib/index';
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import { Table } from '@mantine/core';
import { IconCalendar, IconUsers } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField';
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
import { useCreateApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { RenderPartColumn } from '../tables/ColumnRenderers';
export function useTransferOrderFields({
duplicateOrderId
}: {
duplicateOrderId?: number;
}): ApiFormFieldSet {
const globalSettings = useGlobalSettingsState();
return useMemo(() => {
const fields: ApiFormFieldSet = {
reference: {},
description: {},
project_code: {},
start_date: {
icon: <IconCalendar />
},
target_date: {
icon: <IconCalendar />
},
take_from: {},
destination: {
filters: {
structural: false
}
},
consume: {},
link: {},
responsible: {
filters: {
is_active: true
},
icon: <IconUsers />
}
};
// Order duplication fields
if (!!duplicateOrderId) {
fields.duplicate = {
children: {
order_id: {
hidden: true,
value: duplicateOrderId
},
copy_lines: {},
// Transfer Orders don't have extra lines for now...
copy_extra_lines: { hidden: true, value: false }
}
};
}
if (!globalSettings.isSet('PROJECT_CODES_ENABLED', true)) {
delete fields.project_code;
}
return fields;
}, [duplicateOrderId, globalSettings]);
}
export function useTransferOrderLineItemFields({
orderId,
create
}: {
orderId?: number;
create?: boolean;
}): ApiFormFieldSet {
return useMemo(() => {
const fields: ApiFormFieldSet = {
order: {
filters: {},
disabled: true
},
part: {
filters: {
active: true,
virtual: false
}
},
reference: {},
quantity: {},
project_code: {
description: t`Select project code for this line item`
},
target_date: {},
notes: {},
link: {}
};
return fields;
}, [orderId, create]);
}
function TransferOrderAllocateLineRow({
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 useAllocateToTransferOrderForm({
orderId,
sourceLocationId,
lineItems,
onFormSuccess
}: {
orderId: number;
sourceLocationId?: number;
lineItems: any[];
onFormSuccess: (response: any) => void;
}) {
const [sourceLocation, setSourceLocation] = useState<number | null>(
sourceLocationId || null
);
const fields: ApiFormFieldSet = useMemo(() => {
return {
// Non-submitted field to select the source location
source_location: {
exclude: true,
required: false,
value: sourceLocationId,
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 (
<TransferOrderAllocateLineRow
key={`table-row-${row.idx}-${record.pk}`}
props={row}
record={record}
sourceLocation={sourceLocation}
/>
);
}
}
};
}, [orderId, lineItems, sourceLocation]);
return useCreateApiFormModal({
title: t`Allocate Stock`,
url: ApiEndpoints.transfer_order_allocate,
pk: orderId,
fields: fields,
onFormSuccess: onFormSuccess,
successMessage: t`Stock items allocated`,
size: '80%',
initialData: {
items: lineItems.map((item) => {
return {
line_item: item.pk,
quantity: 0,
stock_item: null
};
})
}
});
}
export function useTransferOrderAllocationFields({
orderId
}: {
orderId?: number;
}): ApiFormFieldSet {
return useMemo(() => {
return {
item: {
// Cannot change item, but display for reference
disabled: true
},
quantity: {}
};
}, [orderId]);
}
export function useTransferOrderAllocateSerialsFields({
itemId,
orderId
}: {
itemId: number;
orderId: number;
}): ApiFormFieldSet {
return useMemo(() => {
return {
line_item: {
value: itemId,
hidden: true
},
quantity: {},
serial_numbers: {}
};
}, [itemId, orderId]);
}
+3
View File
@@ -21,6 +21,7 @@ import {
IconCancel,
IconCheck,
IconCircleCheck,
IconCircleDashedCheck,
IconCircleMinus,
IconCirclePlus,
IconCircleX,
@@ -148,11 +149,13 @@ const icons: InvenTreeIconType = {
build_order: IconTools,
builds: IconTools,
used_in: IconStack2,
consume: IconCircleDashedCheck,
manufacturers: IconBuildingFactory2,
suppliers: IconBuilding,
customers: IconBuildingStore,
purchase_orders: IconShoppingCart,
return_orders: IconTruckReturn,
transfer_orders: IconTransfer,
sales_orders: IconTruckDelivery,
scheduling: IconCalendarStats,
scrap: IconCircleX,
@@ -53,7 +53,7 @@ export function AccountDetailPanel() {
const editProfile = useEditApiFormModal({
title: t`Edit Profile Information`,
url: ApiEndpoints.user_profile,
url: ApiEndpoints.user_me_profile,
onFormSuccess: fetchUserState,
fields: profileFields,
successMessage: t`Profile details updated`
@@ -70,10 +70,12 @@ function PluginSettingGroupItem({
export default function PluginSettingsGroup({
mixin,
message,
includeBaseSettings,
global
}: {
global: boolean;
message?: string;
includeBaseSettings?: boolean;
mixin?: string;
}) {
const mixins: string = useMemo(() => {
@@ -99,16 +101,18 @@ export default function PluginSettingsGroup({
return (
<Stack gap='xs'>
<Accordion defaultValue='plugin-settings'>
<Accordion.Item value='plugin-settings'>
<Accordion.Control>
<StylishText size='lg'>{t`Plugin Settings`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<GlobalSettingList keys={PLUGIN_SETTINGS_KEYS} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
{includeBaseSettings == true && (
<Accordion defaultValue='plugin-settings'>
<Accordion.Item value='plugin-settings'>
<Accordion.Control>
<StylishText size='lg'>{t`Plugin Settings`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<GlobalSettingList keys={PLUGIN_SETTINGS_KEYS} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
<Alert color='blue' icon={<IconInfoCircle />}>
<Text>
{message ??
@@ -13,9 +13,10 @@ import {
IconQrcode,
IconServerCog,
IconShoppingCart,
IconTransfer,
IconTruckDelivery
} from '@tabler/icons-react';
import { lazy, useMemo } from 'react';
import { useMemo } from 'react';
import { PluginPanelKey } from '@lib/enums/ModelType';
import type { PanelType } from '@lib/types/Panel';
@@ -25,13 +26,9 @@ import PageTitle from '../../../components/nav/PageTitle';
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
import { PanelGroup } from '../../../components/panels/PanelGroup';
import { GlobalSettingList } from '../../../components/settings/SettingList';
import { Loadable } from '../../../functions/loading';
import { useServerApiState } from '../../../states/ServerApiState';
import { useUserState } from '../../../states/UserState';
const PluginSettingsGroup = Loadable(
lazy(() => import('./PluginSettingsGroup'))
);
import PluginSettingsGroup from './PluginSettingsGroup';
/**
* System settings page
@@ -60,6 +57,7 @@ export default function SystemSettings() {
'DISPLAY_FULL_NAMES',
'DISPLAY_PROFILE_INFO',
'WEEK_STARTS_ON',
'INVENTREE_UPLOAD_MAX_SIZE',
'INVENTREE_STRICT_URLS'
]}
/>
@@ -367,11 +365,27 @@ export default function SystemSettings() {
</Stack>
)
},
{
name: 'transferorders',
label: t`Transfer Orders`,
icon: <IconTransfer />,
content: (
<GlobalSettingList
keys={[
'TRANSFERORDER_ENABLED',
'TRANSFERORDER_REFERENCE_PATTERN',
'TRANSFERORDER_REQUIRE_RESPONSIBLE'
]}
/>
)
},
{
name: 'plugins',
label: t`Plugins`,
icon: <IconPlugConnected />,
content: <PluginSettingsGroup global={true} />
content: (
<PluginSettingsGroup global={true} includeBaseSettings={true} />
)
}
];
}, []);
@@ -9,7 +9,7 @@ import {
IconSearch,
IconUserCircle
} from '@tabler/icons-react';
import { lazy, useMemo } from 'react';
import { useMemo } from 'react';
import { PluginPanelKey } from '@lib/enums/ModelType';
import type { PanelType } from '@lib/types/Panel';
@@ -18,14 +18,10 @@ import PageTitle from '../../../components/nav/PageTitle';
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
import { PanelGroup } from '../../../components/panels/PanelGroup';
import { UserSettingList } from '../../../components/settings/SettingList';
import { Loadable } from '../../../functions/loading';
import { useUserState } from '../../../states/UserState';
import { SecurityContent } from './AccountSettings/SecurityContent';
import { AccountContent } from './AccountSettings/UserPanel';
const PluginSettingsGroup = Loadable(
lazy(() => import('./PluginSettingsGroup'))
);
import PluginSettingsGroup from './PluginSettingsGroup';
/**
* User settings page
@@ -130,7 +126,9 @@ export default function UserSettings() {
name: 'plugins',
label: t`Plugin Settings`,
icon: <IconPlugConnected />,
content: <PluginSettingsGroup global={false} />
content: (
<PluginSettingsGroup global={false} includeBaseSettings={true} />
)
}
];
}, []);
+13 -2
View File
@@ -6,14 +6,16 @@ import {
IconTable,
IconTools
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import type { EventContentArg } from '@fullcalendar/core';
import { ModelType, PluginPanelKey } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import type { TableFilter } from '@lib/types/Filters';
import type { PanelType } from '@lib/types/Panel';
import { useLocalStorage } from '@mantine/hooks';
import OrderCalendar from '../../components/calendar/OrderCalendar';
import OrderCalendarToolTip from '../../components/calendar/OrderCalendarToolTip';
import PermissionDenied from '../../components/errors/PermissionDenied';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup } from '../../components/panels/PanelGroup';
@@ -34,12 +36,21 @@ function BuildOrderCalendar() {
});
}, [globalSettings]);
const renderTooltip = useCallback((event: EventContentArg) => {
return OrderCalendarToolTip({
event: event,
modelType: ModelType.part,
instanceLookup: 'part_detail'
});
}, []);
return (
<OrderCalendar
model={ModelType.build}
role={UserRoles.build}
params={{ outstanding: true }}
params={{ outstanding: true, part_detail: true }}
filters={calendarFilters}
tooltip={renderTooltip}
/>
);
}
+31 -3
View File
@@ -23,12 +23,14 @@ import {
IconListDetails,
IconListTree,
IconLock,
IconLockOpen,
IconPackages,
IconSearch,
IconShoppingCart,
IconStack2,
IconTestPipe,
IconTools,
IconTransfer,
IconTruckDelivery,
IconTruckReturn,
IconVersions
@@ -100,6 +102,7 @@ import { RelatedPartTable } from '../../tables/part/RelatedPartTable';
import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable';
import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
import PartAllocationPanel from './PartAllocationPanel';
import PartPricingPanel from './PartPricingPanel';
import PartStockHistoryDetail from './PartStockHistoryDetail';
@@ -770,6 +773,20 @@ export default function PartDetail() {
hidden: !part.assembly || !user.hasViewRole(UserRoles.build),
content: part.pk ? <BuildOrderTable partId={part.pk} /> : <Skeleton />
},
{
name: 'transfer_orders',
label: t`Transfer Orders`,
icon: <IconTransfer />,
hidden:
part.virtual ||
!globalSettings.isSet('TRANSFERORDER_ENABLED') ||
!user.hasViewRole(UserRoles.transfer_order),
content: part.pk ? (
<TransferOrderTable partId={part.pk} />
) : (
<Skeleton />
)
},
{
name: 'stocktake',
label: t`Stock History`,
@@ -1132,9 +1149,20 @@ export default function PartDetail() {
<PageDetail
title={`${t`Part`}: ${part.full_name}`}
icon={
part?.locked ? (
<IconLock aria-label='part-lock-icon' />
) : undefined
<ActionIcon
aria-label='part-lock-icon'
variant='transparent'
disabled={!user.hasChangeRole(UserRoles.part)}
onClick={() => {
api
.patch(apiUrl(ApiEndpoints.part_list, part.pk), {
locked: !part.locked
})
.then(refreshInstance);
}}
>
{part?.locked ? <IconLock /> : <IconLockOpen />}
</ActionIcon>
}
subtitle={part.description}
imageUrl={part.image}
@@ -10,13 +10,15 @@ import {
IconShoppingCart,
IconTable
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import type { EventContentArg } from '@fullcalendar/core';
import { ModelType, PluginPanelKey } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import type { TableFilter } from '@lib/index';
import { useLocalStorage } from '@mantine/hooks';
import OrderCalendar from '../../components/calendar/OrderCalendar';
import OrderCalendarToolTip from '../../components/calendar/OrderCalendarToolTip';
import PermissionDenied from '../../components/errors/PermissionDenied';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup } from '../../components/panels/PanelGroup';
@@ -37,12 +39,21 @@ function PurchaseOrderCalendar() {
return PurchaseOrderFilters({ includeDateFilters: false });
}, []);
const renderTooltip = useCallback((event: EventContentArg) => {
return OrderCalendarToolTip({
event: event,
modelType: ModelType.company,
instanceLookup: 'supplier_detail'
});
}, []);
return (
<OrderCalendar
model={ModelType.purchaseorder}
role={UserRoles.purchase_order}
params={{ outstanding: true }}
params={{ outstanding: true, supplier_detail: true }}
filters={calendarFilters}
tooltip={renderTooltip}
/>
);
}
+23 -3
View File
@@ -9,13 +9,15 @@ import {
IconTruckDelivery,
IconTruckReturn
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import type { EventContentArg } from '@fullcalendar/core';
import { ModelType, PluginPanelKey } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import type { TableFilter } from '@lib/index';
import { useLocalStorage } from '@mantine/hooks';
import OrderCalendar from '../../components/calendar/OrderCalendar';
import OrderCalendarToolTip from '../../components/calendar/OrderCalendarToolTip';
import PermissionDenied from '../../components/errors/PermissionDenied';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup } from '../../components/panels/PanelGroup';
@@ -35,12 +37,21 @@ function SalesOrderCalendar() {
return SalesOrderFilters({ includeDateFilters: false });
}, []);
const renderTooltip = useCallback((event: EventContentArg) => {
return OrderCalendarToolTip({
event: event,
modelType: ModelType.company,
instanceLookup: 'customer_detail'
});
}, []);
return (
<OrderCalendar
model={ModelType.salesorder}
role={UserRoles.sales_order}
params={{ outstanding: true }}
params={{ outstanding: true, customer_detail: true }}
filters={calendarFilters}
tooltip={renderTooltip}
/>
);
}
@@ -50,12 +61,21 @@ const ReturnOrderCalendar = () => {
return SalesOrderFilters({ includeDateFilters: false });
}, []);
const renderTooltip = useCallback((event: EventContentArg) => {
return OrderCalendarToolTip({
event: event,
modelType: ModelType.company,
instanceLookup: 'customer_detail'
});
}, []);
return (
<OrderCalendar
model={ModelType.returnorder}
role={UserRoles.return_order}
params={{ outstanding: true }}
params={{ outstanding: true, customer_detail: true }}
filters={calendarFilters}
tooltip={renderTooltip}
/>
);
};
@@ -8,11 +8,13 @@ import type { PanelType } from '@lib/types/Panel';
import { t } from '@lingui/core/macro';
import { Group, Skeleton, Stack } from '@mantine/core';
import {
IconCalendar,
IconInfoCircle,
IconListDetails,
IconPackages,
IconSitemap,
IconTable
IconTable,
IconTransfer
} from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
@@ -20,6 +22,7 @@ import { api } from '../../App';
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import OrderCalendar from '../../components/calendar/OrderCalendar';
import {
type DetailsField,
DetailsTable
@@ -48,11 +51,14 @@ import {
import { useInstance } from '../../hooks/UseInstance';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { useUserSettingsState } from '../../states/SettingsStates';
import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import { PartListTable } from '../../tables/part/PartTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import StockLocationParametricTable from '../../tables/stock/StockLocationParametricTable';
import { StockLocationTable } from '../../tables/stock/StockLocationTable';
import TransferOrderParametricTable from '../../tables/stock/TransferOrderParametricTable';
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
export default function Stock() {
const { id: _id } = useParams();
@@ -65,6 +71,7 @@ export default function Stock() {
const navigate = useNavigate();
const user = useUserState();
const settings = useUserSettingsState();
const globalSettings = useGlobalSettingsState();
const [treeOpen, setTreeOpen] = useState(false);
@@ -169,6 +176,7 @@ export default function Stock() {
}, [location, instanceQuery]);
const [sublocationView, setSublocationView] = useState<string>('table');
const [transferOrderView, setTransferOrderView] = useState<string>('table');
const locationPanels: PanelType[] = useMemo(() => {
return [
@@ -219,6 +227,42 @@ export default function Stock() {
/>
)
},
SegmentedControlPanel({
name: 'transfer-orders',
label: t`Transfer Orders`,
icon: <IconTransfer />,
hidden:
!user.hasViewRole(UserRoles.transfer_order) ||
!globalSettings.isSet('TRANSFERORDER_ENABLED'),
selection: transferOrderView,
onChange: setTransferOrderView,
options: [
{
value: 'table',
label: t`Table View`,
icon: <IconTable />,
content: <TransferOrderTable />
},
{
value: 'calendar',
label: t`Calendar View`,
icon: <IconCalendar />,
content: (
<OrderCalendar
model={ModelType.transferorder}
role={UserRoles.transfer_order}
params={{ outstanding: true }}
/>
)
},
{
value: 'parametric',
label: t`Parametric View`,
icon: <IconListDetails />,
content: <TransferOrderParametricTable />
}
]
}),
{
name: 'default_parts',
label: t`Default Parts`,
@@ -240,7 +284,7 @@ export default function Stock() {
hidden: !location.pk
})
];
}, [sublocationView, location, id]);
}, [sublocationView, transferOrderView, location, id]);
const editLocation = useEditApiFormModal({
url: ApiEndpoints.stock_location_list,
+34 -2
View File
@@ -88,6 +88,7 @@ import InstalledItemsTable from '../../tables/stock/InstalledItemsTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable';
import { StockTrackingTable } from '../../tables/stock/StockTrackingTable';
import TransferOrderAllocationTable from '../../tables/stock/TransferOrderAllocationTable';
export default function StockDetail() {
const { id } = useParams();
@@ -476,6 +477,13 @@ export default function StockDetail() {
return stockitem?.part_detail?.salable;
}, [stockitem]);
const showTransferAllocations: boolean = useMemo(() => {
return (
!stockitem?.part_detail?.virtual &&
globalSettings.isSet('TRANSFERORDER_ENABLED')
);
}, [stockitem]);
// API query to determine if this stock item has trackable BOM items
const trackedBomItemQuery = useQuery({
queryKey: ['tracked-bom-item', stockitem.pk, stockitem.part],
@@ -544,11 +552,17 @@ export default function StockDetail() {
icon: <IconBookmark />,
hidden:
!stockitem.in_stock ||
(!showSalesAllocations && !showBuildAllocations),
(!showSalesAllocations &&
!showBuildAllocations &&
!showTransferAllocations),
content: (
<Accordion
multiple={true}
defaultValue={['buildAllocations', 'salesAllocations']}
defaultValue={[
'buildAllocations',
'salesAllocations',
'transferAllocations'
]}
>
{showBuildAllocations && (
<Accordion.Item value='buildAllocations' key='buildAllocations'>
@@ -580,6 +594,24 @@ export default function StockDetail() {
</Accordion.Panel>
</Accordion.Item>
)}
{showTransferAllocations && (
<Accordion.Item
value='transferAllocations'
key='transferAllocations'
>
<Accordion.Control>
<StylishText size='lg'>{t`Transfer Order Allocations`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<TransferOrderAllocationTable
stockId={stockitem.pk}
modelField='order'
modelTarget={ModelType.transferorder}
showOrderInfo
/>
</Accordion.Panel>
</Accordion.Item>
)}
</Accordion>
)
},
@@ -0,0 +1,552 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { type PanelType, apiUrl } from '@lib/index';
import {
IconBookmark,
IconInfoCircle,
IconList,
IconListCheck
} from '@tabler/icons-react';
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
CancelItemAction,
DuplicateItemAction,
EditItemAction,
HoldItemAction,
OptionsActionDropdown
} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import ParametersPanel from '../../components/panels/ParametersPanel';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { useTransferOrderFields } from '../../forms/TransferOrderForms';
import {
useCreateApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import TransferOrderAllocationTable from '../../tables/stock/TransferOrderAllocationTable';
import TransferOrderLineItemTable from '../../tables/stock/TransferOrderLineItemTable';
export default function TransferOrderDetail() {
const { id } = useParams();
const user = useUserState();
const globalSettings = useGlobalSettingsState();
const {
instance: order,
instanceQuery,
refreshInstance
} = useInstance({
endpoint: ApiEndpoints.transfer_order_list,
pk: id,
params: {}
});
const toStatus = useStatusCodes({ modelType: ModelType.transferorder });
const lineItemsEditable: boolean = useMemo(() => {
const orderOpen: boolean =
order.status != toStatus.COMPLETE && order.status != toStatus.CANCELLED;
return orderOpen;
// TODO: does this setting make any sense for Transfer Orders???
// if (orderOpen) {
// return true;
// } else {
// return globalSettings.isSet('TRANSFERORDER_EDIT_COMPLETED_ORDERS');
// }
}, [globalSettings, order.status, toStatus]);
// for now, only permit editing allocations when line items can be edited
const allocationsEditable = lineItemsEditable;
const orderOpen = useMemo(() => {
return (
order.status == toStatus.PENDING ||
order.status == toStatus.ISSUED ||
order.status == toStatus.ON_HOLD
);
}, [order, toStatus]);
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'link',
name: 'take_from',
icon: 'location',
label: t`Source Location`,
model: ModelType.stocklocation
},
{
type: 'link',
name: 'destination',
icon: 'location',
label: t`Destination Location`,
model: ModelType.stocklocation
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.transferorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.transferorder,
icon: 'status',
hidden:
!order.status_custom_key || order.status_custom_key == order.status
}
];
const tr: DetailsField[] = [
{
type: 'boolean',
name: 'consume',
icon: 'consume',
label: t`Consume Stock`
},
{
type: 'text',
name: 'line_items',
label: t`Line Items`,
icon: 'list'
},
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: order.line_items,
progress: order.completed_lines
}
];
const bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !order.link
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !order.project_code
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !order.responsible
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Creation Date`,
icon: 'calendar',
copy: true,
hidden: !order.creation_date
},
{
type: 'date',
name: 'issue_date',
label: t`Issue Date`,
icon: 'calendar',
copy: true,
hidden: !order.issue_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
copy: true,
hidden: !order.start_date
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
copy: true,
hidden: !order.target_date
},
{
type: 'date',
name: 'complete_date',
icon: 'calendar_check',
label: t`Completion Date`,
copy: true,
hidden: !order.complete_date
}
];
return (
<ItemDetailsGrid>
<Grid grow>
{/* TODO: what image do we show for a Transfer Order? */}
{/* <DetailsImage
appRole={UserRoles.transfer_order}
apiPath={ApiEndpoints.transfer_order_list}
src="/static/img/blank_image.png"
pk={order.pk}
/> */}
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
</ItemDetailsGrid>
);
}, [order, instanceQuery]);
const orderPanels: PanelType[] = useMemo(() => {
return [
{
name: 'detail',
label: t`Order Details`,
icon: <IconInfoCircle />,
content: detailsPanel
},
{
name: 'line-items',
label: t`Line Items`,
icon: <IconList />,
content: (
<TransferOrderLineItemTable
orderId={order.pk}
sourceLocationId={order.take_from}
orderDetailRefresh={refreshInstance}
editable={lineItemsEditable}
/>
// TODO: add back the accordion if we need extra lines
// <Accordion
// multiple={true}
// defaultValue={[
// 'line-items',
// // 'extra-items'
// ]}
// >
// <Accordion.Item value='line-items' key='lineitems'>
// <Accordion.Control>
// <StylishText size='lg'>{t`Line Items`}</StylishText>
// </Accordion.Control>
// <Accordion.Panel>
// <TransferOrderLineItemTable
// orderId={order.pk}
// orderDetailRefresh={refreshInstance}
// editable={lineItemsEditable}
// />
// </Accordion.Panel>
// </Accordion.Item>
// {/* <Accordion.Item value='extra-items' key='extraitems'>
// <Accordion.Control>
// <StylishText size='lg'>{t`Extra Line Items`}</StylishText>
// </Accordion.Control>
// <Accordion.Panel>
// <ExtraLineItemTable
// endpoint={ApiEndpoints.sales_order_extra_line_list}
// orderId={order.pk}
// editable={lineItemsEditable}
// orderDetailRefresh={refreshInstance}
// currency={orderCurrency}
// role={UserRoles.sales_order}
// />
// </Accordion.Panel>
// </Accordion.Item> */}
// </Accordion>
)
},
{
name: 'allocations',
label:
order.status != toStatus.COMPLETE
? t`Allocated Stock`
: t`Transferred Stock`,
icon:
order.status != toStatus.COMPLETE ? (
<IconBookmark />
) : (
<IconListCheck />
),
content: (
<TransferOrderAllocationTable
orderId={order.pk}
showPartInfo
allowEdit={allocationsEditable}
modelField='item'
modelTarget={ModelType.stockitem}
/>
)
},
ParametersPanel({
model_type: ModelType.transferorder,
model_id: order.pk
}),
AttachmentPanel({
model_type: ModelType.transferorder,
model_id: order.pk
}),
NotesPanel({
model_type: ModelType.transferorder,
model_id: order.pk
})
];
}, [order, id, user]);
const orderBadges: ReactNode[] = useMemo(() => {
return instanceQuery.isLoading
? []
: [
<StatusRenderer
status={order.status_custom_key}
type={ModelType.transferorder}
options={{ size: 'lg' }}
/>
];
}, [order, instanceQuery]);
const transferOrderFields = useTransferOrderFields({});
const duplicateTransferOrderFields = useTransferOrderFields({
duplicateOrderId: order.pk
});
const editTransferOrder = useEditApiFormModal({
url: ApiEndpoints.transfer_order_list,
pk: order.pk,
title: t`Edit Transfer Order`,
fields: transferOrderFields,
onFormSuccess: () => {
refreshInstance();
}
});
const duplicateTransferOrderInitialData = useMemo(() => {
const data = { ...order };
// if we set the reference to null/undefined, it will be left blank in the form
// if we omit the reference altogether, it will be auto-generated via reference pattern
// from the OPTIONS response
delete data.reference;
return data;
}, [order]);
const duplicateTransferOrder = useCreateApiFormModal({
url: ApiEndpoints.transfer_order_list,
title: t`Add Transfer Order`,
fields: duplicateTransferOrderFields,
initialData: duplicateTransferOrderInitialData,
modelType: ModelType.transferorder,
follow: true
});
const issueOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.transfer_order_issue, order.pk),
title: t`Issue Transfer Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Issue this order`,
successMessage: t`Order issued`
});
const cancelOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.transfer_order_cancel, order.pk),
title: t`Cancel Transfer Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Cancel this order`,
successMessage: t`Order cancelled`
});
const holdOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.transfer_order_hold, order.pk),
title: t`Hold Transfer Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Place this order on hold`,
successMessage: t`Order placed on hold`
});
const completeOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.transfer_order_complete, order.pk),
title: t`Complete Transfer Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Mark this order as complete`,
successMessage: t`Order completed`,
fields: {
accept_incomplete_allocation: {}
}
});
const orderActions = useMemo(() => {
const canEdit: boolean = user.hasChangeRole(UserRoles.transfer_order);
const canIssue: boolean =
canEdit &&
(order.status == toStatus.PENDING || order.status == toStatus.ON_HOLD);
const canHold: boolean =
canEdit &&
(order.status == toStatus.PENDING || order.status == toStatus.ISSUED);
const canCancel: boolean =
canEdit &&
(order.status == toStatus.PENDING || order.status == toStatus.ON_HOLD);
const canComplete: boolean = canEdit && order.status == toStatus.ISSUED;
return [
<PrimaryActionButton
title={t`Issue Order`}
icon='issue'
hidden={!canIssue}
color='blue'
onClick={() => issueOrder.open()}
/>,
<PrimaryActionButton
title={t`Complete Order`}
icon='complete'
hidden={!canComplete}
color='green'
onClick={() => completeOrder.open()}
/>,
<AdminButton model={ModelType.transferorder} id={order.pk} />,
<BarcodeActionDropdown
model={ModelType.transferorder}
pk={order.pk}
hash={order?.barcode_hash}
/>,
<PrintingActions
modelType={ModelType.transferorder}
items={[order.pk]}
enableReports
enableLabels
/>,
<OptionsActionDropdown
tooltip={t`Order Actions`}
actions={[
EditItemAction({
hidden: !user.hasChangeRole(UserRoles.transfer_order),
tooltip: t`Edit order`,
onClick: () => {
editTransferOrder.open();
}
}),
DuplicateItemAction({
tooltip: t`Duplicate order`,
hidden: !user.hasChangeRole(UserRoles.transfer_order),
onClick: () => duplicateTransferOrder.open()
}),
HoldItemAction({
tooltip: t`Hold order`,
hidden: !canHold,
onClick: () => holdOrder.open()
}),
CancelItemAction({
tooltip: t`Cancel order`,
hidden: !canCancel,
onClick: () => cancelOrder.open()
})
]}
/>
];
}, [user, order, orderOpen, toStatus]);
const subtitle: string = useMemo(() => {
const t = order.take_from_detail?.pathstring || '';
const d = order.destination_detail?.pathstring || '';
return `${t}${d}`;
}, [order]);
return (
<>
{editTransferOrder.modal}
{issueOrder.modal}
{cancelOrder.modal}
{holdOrder.modal}
{completeOrder.modal}
{duplicateTransferOrder.modal}
<InstanceDetail
query={instanceQuery}
requiredRole={UserRoles.transfer_order}
>
<Stack gap='xs'>
<PageDetail
title={`${t`Transfer Order`}: ${order.reference}`}
subtitle={subtitle}
// What should be the Transfer Order image?
// imageUrl={order.customer_detail?.image}
badges={orderBadges}
actions={orderActions}
breadcrumbs={[{ name: t`Stock`, url: '/stock/' }]}
lastCrumb={[
{
name: order.reference,
url: `/stock/transfer-order/${order.pk}`
}
]}
editAction={editTransferOrder.open}
editEnabled={user.hasChangePermission(ModelType.transferorder)}
/>
<PanelGroup
pageKey='transferorder'
panels={orderPanels}
model={ModelType.transferorder}
reloadInstance={instanceQuery.refetch}
instance={order}
id={order.pk}
/>
</Stack>
</InstanceDetail>
</>
);
}
+5
View File
@@ -88,6 +88,10 @@ export const ReturnOrderDetail = Loadable(
lazy(() => import('./pages/sales/ReturnOrderDetail'))
);
export const TransferOrderDetail = Loadable(
lazy(() => import('./pages/stock/TransferOrderDetail'))
);
export const Scan = Loadable(lazy(() => import('./pages/Index/Scan')));
export const ErrorPage = Loadable(lazy(() => import('./pages/ErrorPage')));
@@ -169,6 +173,7 @@ export const routes = (
<Route index element={<Navigate to='location/index/' />} />
<Route path='location/:id?/*' element={<LocationDetail />} />
<Route path='item/:id/*' element={<StockDetail />} />
<Route path='transfer-order/:id/*' element={<TransferOrderDetail />} />
</Route>
<Route path='manufacturing/'>
<Route index element={<Navigate to='index/' />} />
+1 -1
View File
@@ -154,7 +154,7 @@ pushes changes in user profile to backend
export function patchUser(key: 'language' | 'theme' | 'widgets', val: any) {
const uid = useUserState.getState().userId();
if (uid) {
api.patch(apiUrl(ApiEndpoints.user_profile), { [key]: val });
api.patch(apiUrl(ApiEndpoints.user_me_profile), { [key]: val });
} else {
console.log('user not logged in, not patching');
}
+1 -1
View File
@@ -104,7 +104,7 @@ export const useUserState = create<UserStateProps>((set, get) => ({
// Fetch role data
await api
.get(apiUrl(ApiEndpoints.user_roles))
.get(apiUrl(ApiEndpoints.user_me_roles))
.then((response) => {
if (response.status == 200) {
const user: UserProps = get().user as UserProps;
@@ -276,6 +276,7 @@ export default function InvenTreeTableHeader({
<Divider />
{tableState.filterSet.activeFilters?.map((filter) => (
<FilterPreview
key={filter.name}
filter={filter}
filters={tableProps.tableFilters}
/>
@@ -536,7 +536,10 @@ export default function BuildLineTable({
minWidth: 125,
render: (record: any) => {
return record?.bom_item_detail?.consumable ? (
<Text style={{ fontStyle: 'italic' }}>{t`Consumable item`}</Text>
<Text
size='sm'
style={{ fontStyle: 'italic' }}
>{t`Consumable item`}</Text>
) : (
<ProgressBar
progressLabel={true}
@@ -49,7 +49,12 @@ function attachmentTableColumns(): TableColumn[] {
noWrap: true,
render: (record: any) => {
if (record.attachment) {
return <AttachmentLink attachment={record.attachment} />;
return (
<AttachmentLink
thumbnail={record.thumbnail}
attachment={record.attachment}
/>
);
} else if (record.link) {
return <AttachmentLink attachment={record.link} external />;
} else {
@@ -300,6 +305,11 @@ export function AttachmentTable({
name: 'is_file',
label: t`Is File`,
description: t`Show file attachments`
},
{
name: 'is_image',
label: t`Is Image`,
description: t`Show image attachments`
}
];
}, []);
@@ -26,7 +26,7 @@ export function ApiTokenTable({
const [opened, { open, close }] = useDisclosure(false);
const generateToken = useCreateApiFormModal({
url: ApiEndpoints.user_token,
url: ApiEndpoints.user_me_token,
method: 'GET',
title: t`Generate Token`,
fields: { name: {} },
@@ -15,7 +15,8 @@ import { RenderCompany } from '../../components/render/Company';
import {
RenderPurchaseOrder,
RenderReturnOrder,
RenderSalesOrder
RenderSalesOrder,
RenderTransferOrder
} from '../../components/render/Order';
import { RenderPart } from '../../components/render/Part';
import { StatusRenderer } from '../../components/render/StatusRenderer';
@@ -181,6 +182,17 @@ export function StockTrackingTable({
navigate: navigate
})
},
{
label: t`Transfer Order`,
key: 'transferorder',
details:
deltas.transferorder_detail &&
RenderTransferOrder({
instance: deltas.transferorder_detail,
link: true,
navigate: navigate
})
},
{
label: t`Customer`,
key: 'customer',
@@ -0,0 +1,285 @@
import { type RowAction, RowEditAction } from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import useTable from '@lib/hooks/UseTable';
import type { TableFilter } from '@lib/types/Filters';
import type { StockOperationProps } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { Alert } from '@mantine/core';
import { IconCircleX } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { useTransferOrderAllocationFields } from '../../forms/TransferOrderForms';
import {
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { useUserState } from '../../states/UserState';
import {
DescriptionColumn,
LocationColumn,
PartColumn,
ReferenceColumn,
StatusColumn
} from '../ColumnRenderers';
import { IncludeVariantsFilter, StockLocationFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
export default function TransferOrderAllocationTable({
partId,
stockId,
orderId,
lineItemId,
showPartInfo,
showOrderInfo,
allowEdit,
isSubTable,
modelTarget,
modelField
}: Readonly<{
partId?: number;
stockId?: number;
orderId?: number;
lineItemId?: number;
showPartInfo?: boolean;
showOrderInfo?: boolean;
allowEdit?: boolean;
isSubTable?: boolean;
modelTarget?: ModelType;
modelField?: string;
}>) {
const user = useUserState();
const tableId = useMemo(() => {
let id = 'transferorderallocations';
if (!!partId) {
id += '-part';
}
if (isSubTable) {
id += '-sub';
}
return id;
}, [partId, isSubTable]);
const table = useTable(tableId);
const [selectedAllocation, setSelectedAllocation] = useState<number>(0);
const tableFilters: TableFilter[] = useMemo(() => {
const filters: TableFilter[] = [
{
name: 'outstanding',
label: t`Outstanding`,
description: t`Show outstanding allocations`
},
StockLocationFilter()
];
if (!!partId) {
filters.push(IncludeVariantsFilter());
}
return filters;
}, [partId]);
const tableColumns: TableColumn[] = useMemo(() => {
return [
ReferenceColumn({
accessor: 'order_detail.reference',
title: t`Transfer Order`,
switchable: false,
sortable: true,
hidden: showOrderInfo != true
}),
DescriptionColumn({
accessor: 'order_detail.description',
hidden: showOrderInfo != true
}),
StatusColumn({
accessor: 'order_detail.status',
model: ModelType.transferorder,
title: t`Order Status`,
hidden: showOrderInfo != true
}),
PartColumn({
hidden: showPartInfo != true,
part: 'part_detail'
}),
DescriptionColumn({
accessor: 'part_detail.description',
hidden: showPartInfo != true
}),
{
accessor: 'part_detail.IPN',
title: t`IPN`,
hidden: showPartInfo != true,
sortable: true,
ordering: 'IPN'
},
{
accessor: 'serial',
title: t`Serial Number`,
sortable: true,
switchable: true,
render: (record: any) => record?.item_detail?.serial
},
{
accessor: 'batch',
title: t`Batch Code`,
sortable: true,
switchable: true,
render: (record: any) => record?.item_detail?.batch
},
{
accessor: 'available',
title: t`Available Quantity`,
sortable: false,
hidden: isSubTable,
render: (record: any) => record?.item_detail?.quantity
},
{
accessor: 'quantity',
title: t`Allocated Quantity`,
sortable: true
},
LocationColumn({
accessor: 'location_detail',
switchable: true,
sortable: true
})
];
}, [showOrderInfo, showPartInfo, isSubTable]);
const editAllocationFields = useTransferOrderAllocationFields({
orderId: orderId
});
const editAllocation = useEditApiFormModal({
url: ApiEndpoints.transfer_order_allocation_list,
pk: selectedAllocation,
fields: editAllocationFields,
title: t`Edit Allocation`,
onFormSuccess: () => table.refreshTable()
});
const deleteAllocation = useDeleteApiFormModal({
url: ApiEndpoints.transfer_order_allocation_list,
pk: selectedAllocation,
title: t`Remove Allocated Stock`,
preFormContent: (
<Alert color='red' title={t`Confirm Removal`}>
{t`Are you sure you want to remove this allocated stock from the order?`}
</Alert>
),
submitText: t`Remove`,
onFormSuccess: () => table.refreshTable()
});
const rowActions = useCallback(
(record: any): RowAction[] => {
return [
RowEditAction({
tooltip: t`Edit Allocation`,
hidden: !allowEdit || !user.hasChangeRole(UserRoles.transfer_order),
onClick: () => {
setSelectedAllocation(record.pk);
editAllocation.open();
}
}),
{
title: t`Remove`,
tooltip: t`Remove allocated stock`,
icon: <IconCircleX />,
color: 'red',
hidden: !allowEdit || !user.hasDeleteRole(UserRoles.transfer_order),
onClick: () => {
setSelectedAllocation(record.pk);
deleteAllocation.open();
}
}
];
},
[allowEdit, user]
);
const stockOperationProps: StockOperationProps = useMemo(() => {
// Extract stock items from the selected records
// Note that the table is actually a list of TransferOrderAllocation instances,
// so we need to reconstruct the stock item details
const stockItems: any[] = table.selectedRecords
.filter((item: any) => !!item.item_detail)
.map((item: any) => {
return {
...item.item_detail,
part_detail: item.part_detail,
location_detail: item.location_detail
};
});
return {
items: stockItems,
model: ModelType.stockitem,
refresh: table.refreshTable
};
}, [table.selectedRecords, table.refreshTable]);
const stockAdjustActions = useStockAdjustActions({
formProps: stockOperationProps,
merge: false,
assign: false,
delete: false,
add: false,
count: false,
remove: false
});
const tableActions = useMemo(() => {
return [stockAdjustActions.dropdown];
}, [allowEdit, orderId, user, stockAdjustActions.dropdown]);
return (
<>
{editAllocation.modal}
{deleteAllocation.modal}
{!isSubTable && stockAdjustActions.modals.map((modal) => modal.modal)}
<InvenTreeTable
url={apiUrl(ApiEndpoints.transfer_order_allocation_list)}
tableState={table}
columns={tableColumns}
props={{
params: {
part_detail: showPartInfo ?? false,
order_detail: showOrderInfo ?? false,
item_detail: true,
location_detail: true,
line: lineItemId,
part: partId,
order: orderId,
item: stockId
},
enableSearch: !isSubTable,
enableRefresh: !isSubTable,
enableColumnSwitching: !isSubTable,
enableFilters: !isSubTable,
enableDownload: !isSubTable,
enableSelection: !isSubTable,
minHeight: isSubTable ? 100 : undefined,
rowActions: rowActions,
tableActions: isSubTable ? undefined : tableActions,
tableFilters: tableFilters,
modelField: modelField ?? 'order',
enableReports: !isSubTable,
enableLabels: !isSubTable,
printingAccessor: 'item',
modelType: modelTarget ?? ModelType.transferorder
}}
/>
</>
);
}
@@ -0,0 +1,529 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import useTable from '@lib/hooks/UseTable';
import {
ActionButton,
AddItemButton,
ModelType,
ProgressBar,
RowDeleteAction,
RowDuplicateAction,
RowEditAction,
RowViewAction,
UserRoles,
formatDecimal
} from '@lib/index';
import type { TableFilter } from '@lib/types/Filters';
import type { RowAction, TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { Group, Paper, Text } from '@mantine/core';
import {
IconArrowRight,
IconHash,
IconShoppingCart,
IconSquareArrowRight,
IconTools
} from '@tabler/icons-react';
import type { DataTableRowExpansionProps } from 'mantine-datatable';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { RenderPart } from '../../components/render/Part';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { useBuildOrderFields } from '../../forms/BuildForms';
import {
useAllocateToTransferOrderForm,
useTransferOrderAllocateSerialsFields,
useTransferOrderLineItemFields
} from '../../forms/TransferOrderForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useUserState } from '../../states/UserState';
import {
DateColumn,
DecimalColumn,
DescriptionColumn,
LinkColumn,
ProjectCodeColumn,
RenderPartColumn
} from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
import RowExpansionIcon from '../RowExpansionIcon';
import { TableHoverCard } from '../TableHoverCard';
import TransferOrderAllocationTable from './TransferOrderAllocationTable';
export default function TransferOrderLineItemTable({
orderId,
sourceLocationId,
orderDetailRefresh,
editable
}: Readonly<{
orderId: number;
sourceLocationId?: number;
orderDetailRefresh: () => void;
editable: boolean;
}>) {
const navigate = useNavigate();
const user = useUserState();
const table = useTable('transfer-order-line-item');
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'part',
sortable: true,
switchable: false,
minWidth: 175,
render: (record: any) => {
return (
<Group wrap='nowrap'>
{record.part_detail?.virtual || (
<RowExpansionIcon
enabled={record.allocated}
expanded={table.isRowExpanded(record.pk)}
/>
)}
<RenderPartColumn part={record.part_detail} />
</Group>
);
}
},
{
accessor: 'part_detail.IPN',
title: t`IPN`,
switchable: true
},
DescriptionColumn({
accessor: 'part_detail.description'
}),
{
accessor: 'reference',
sortable: false,
switchable: true
},
ProjectCodeColumn({}),
DecimalColumn({
accessor: 'quantity',
sortable: true
}),
DateColumn({
accessor: 'target_date',
sortable: true,
title: t`Target Date`
}),
{
accessor: 'stock',
title: t`Available Stock`,
render: (record: any) => {
if (record.part_detail?.virtual) {
return <Text size='sm' fs='italic'>{t`Virtual part`}</Text>;
}
const part_stock = record?.available_stock ?? 0;
const variant_stock = record?.available_variant_stock ?? 0;
const available = part_stock + variant_stock;
const required = Math.max(
record.quantity - record.allocated - record.shipped,
0
);
let color: string | undefined;
let text = `${formatDecimal(available)}`;
const extra: ReactNode[] = [];
if (available <= 0) {
color = 'red';
text = t`No stock available`;
} else if (available < required) {
color = 'orange';
}
if (variant_stock > 0) {
extra.push(<Text size='sm'>{t`Includes variant stock`}</Text>);
}
if (record.building > 0) {
extra.push(
<Text size='sm'>
{t`In production`}: {formatDecimal(record.building)}
</Text>
);
}
if (record.on_order > 0) {
extra.push(
<Text size='sm'>
{t`On order`}: {formatDecimal(record.on_order)}
</Text>
);
}
return (
<TableHoverCard
value={<Text c={color}>{text}</Text>}
extra={extra}
title={t`Stock Information`}
/>
);
}
},
{
accessor: 'allocated',
sortable: true,
render: (record: any) => {
if (record.part_detail?.virtual) {
return <Text size='sm' fs='italic'>{t`Virtual part`}</Text>;
}
return (
<ProgressBar
progressLabel={true}
value={record.allocated}
maximum={record.quantity}
/>
);
}
},
{
accessor: 'transferred',
title: t`Transferred`,
sortable: true,
render: (record: any) => {
if (record.part_detail?.virtual) {
return <Text size='sm' fs='italic'>{t`Virtual part`}</Text>;
}
return (
<ProgressBar
progressLabel={true}
value={record.transferred}
maximum={record.quantity}
/>
);
}
},
{
accessor: 'notes'
},
LinkColumn({
accessor: 'link'
})
];
}, [table.isRowExpanded]);
const [initialData, setInitialData] = useState({});
const [selectedItems, setSelectedItems] = useState<any[]>([]);
const [selectedLineId, setSelectedLineId] = useState<number>(0);
const [selectedPart, setSelectedPart] = useState<any>(null);
const [partsToOrder, setPartsToOrder] = useState<any[]>([]);
const allocateStock = useAllocateToTransferOrderForm({
orderId: orderId,
lineItems: selectedItems.filter(
(item) => item.part_detail?.virtual !== true
),
sourceLocationId: sourceLocationId,
onFormSuccess: () => {
table.refreshTable();
table.clearSelectedRecords();
}
});
const orderPartsWizard = OrderPartsWizard({
parts: partsToOrder
});
const buildOrderFields = useBuildOrderFields({
create: true,
modalId: 'build-order-create-from-transfer-order'
});
const newBuildOrder = useCreateApiFormModal({
url: ApiEndpoints.build_order_list,
title: t`Create Build Order`,
modalId: 'build-order-create-from-transfer-order',
fields: buildOrderFields,
initialData: initialData,
follow: true,
modelType: ModelType.build
});
const createLineFields = useTransferOrderLineItemFields({
orderId: orderId,
create: true
});
const newLine = useCreateApiFormModal({
url: ApiEndpoints.transfer_order_line_list,
title: t`Add Line Item`,
fields: createLineFields,
initialData: {
...initialData
},
onFormSuccess: orderDetailRefresh,
table: table
});
const editLineFields = useTransferOrderLineItemFields({
orderId: orderId,
create: false
});
const editLine = useEditApiFormModal({
url: ApiEndpoints.transfer_order_line_list,
pk: selectedLineId,
title: t`Edit Line Item`,
fields: editLineFields,
onFormSuccess: orderDetailRefresh,
table: table
});
const deleteLine = useDeleteApiFormModal({
url: ApiEndpoints.transfer_order_line_list,
pk: selectedLineId,
title: t`Delete Line Item`,
onFormSuccess: orderDetailRefresh,
table: table
});
const allocateSerialFields = useTransferOrderAllocateSerialsFields({
itemId: selectedLineId,
orderId: orderId
});
const allocateBySerials = useCreateApiFormModal({
url: ApiEndpoints.transfer_order_allocate_serials,
pk: orderId,
title: t`Allocate Serial Numbers`,
preFormContent: selectedPart ? (
<Paper withBorder p='sm'>
<RenderPart instance={selectedPart} />
</Paper>
) : undefined,
initialData: initialData,
fields: allocateSerialFields,
table: table
});
const tableActions = useMemo(() => {
return [
<AddItemButton
key='add-transfer-order-line-item'
tooltip={t`Add Line Item`}
onClick={() => {
setInitialData({
order: orderId
});
newLine.open();
}}
hidden={!editable || !user.hasAddRole(UserRoles.transfer_order)}
/>,
<ActionButton
key='order-parts'
hidden={!user.hasAddRole(UserRoles.purchase_order)}
disabled={!table.hasSelectedRecords}
tooltip={t`Order Parts`}
icon={<IconShoppingCart />}
color='blue'
onClick={() => {
setPartsToOrder(table.selectedRecords.map((r) => r.part_detail));
orderPartsWizard.openWizard();
}}
/>,
<ActionButton
key='allocate-stock'
tooltip={t`Allocate Stock`}
icon={<IconArrowRight />}
disabled={!table.hasSelectedRecords}
color='green'
onClick={() => {
setSelectedItems(
table.selectedRecords.filter((r: any) => r.allocated < r.quantity)
);
allocateStock.open();
}}
/>
];
}, [user, orderId, table.hasSelectedRecords, table.selectedRecords]);
const rowActions = useCallback(
(record: any): RowAction[] => {
const allocated = (record?.allocated ?? 0) > (record?.quantity ?? 0);
const virtual = record?.part_detail?.virtual ?? false;
return [
{
hidden:
allocated ||
virtual ||
!editable ||
!user.hasChangeRole(UserRoles.transfer_order),
title: t`Allocate Stock`,
icon: <IconSquareArrowRight />,
color: 'green',
onClick: () => {
setSelectedItems([record]);
allocateStock.open();
}
},
{
hidden:
!record?.part_detail?.trackable ||
allocated ||
virtual ||
!editable ||
!user.hasChangeRole(UserRoles.transfer_order),
title: t`Allocate serials`,
icon: <IconHash />,
color: 'green',
onClick: () => {
setSelectedLineId(record.pk);
setSelectedPart(record?.part_detail ?? null);
setInitialData({
quantity: record.quantity - record.allocated
});
allocateBySerials.open();
}
},
{
hidden:
allocated ||
virtual ||
!user.hasAddRole(UserRoles.build) ||
!record?.part_detail?.assembly,
title: t`Build stock`,
icon: <IconTools />,
color: 'blue',
onClick: () => {
setInitialData({
part: record.part,
quantity: (record?.quantity ?? 1) - (record?.allocated ?? 0),
transfer_order: orderId
});
newBuildOrder.open();
}
},
{
hidden:
allocated ||
virtual ||
!user.hasAddRole(UserRoles.purchase_order) ||
!record?.part_detail?.purchaseable,
title: t`Order stock`,
icon: <IconShoppingCart />,
color: 'blue',
onClick: () => {
setPartsToOrder([record.part_detail]);
orderPartsWizard.openWizard();
}
},
RowEditAction({
hidden: !editable || !user.hasChangeRole(UserRoles.transfer_order),
onClick: () => {
setSelectedLineId(record.pk);
editLine.open();
}
}),
RowDuplicateAction({
hidden: !editable || !user.hasAddRole(UserRoles.transfer_order),
onClick: () => {
setInitialData(record);
newLine.open();
}
}),
RowDeleteAction({
hidden: !editable || !user.hasDeleteRole(UserRoles.transfer_order),
onClick: () => {
setSelectedLineId(record.pk);
deleteLine.open();
}
}),
RowViewAction({
title: t`View Part`,
modelType: ModelType.part,
modelId: record.part,
navigate: navigate,
hidden: !user.hasViewRole(UserRoles.part)
})
];
},
[navigate, user, editable]
);
// Control row expansion
const rowExpansion: DataTableRowExpansionProps<any> = useMemo(() => {
return {
allowMultiple: true,
expandable: ({ record }: { record: any }) => {
if (record?.part_detail?.virtual) {
return false;
}
return table.isRowExpanded(record.pk) || record.allocated > 0;
},
content: ({ record }: { record: any }) => {
return (
<TransferOrderAllocationTable
showOrderInfo={false}
showPartInfo={false}
orderId={orderId}
lineItemId={record.pk}
partId={record.part}
allowEdit={editable}
modelTarget={ModelType.stockitem}
modelField={'item'}
isSubTable
/>
);
}
};
}, [orderId, table.isRowExpanded]);
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'allocated',
label: t`Allocated`,
description: t`Show lines which are fully allocated`
},
{
name: 'completed',
label: t`Completed`,
description: t`Show lines which are completed`
}
];
}, []);
return (
<>
{editLine.modal}
{deleteLine.modal}
{newLine.modal}
{newBuildOrder.modal}
{allocateBySerials.modal}
{allocateStock.modal}
{orderPartsWizard.wizard}
<InvenTreeTable
url={apiUrl(ApiEndpoints.transfer_order_line_list)}
tableState={table}
columns={tableColumns}
props={{
enableSelection: true,
enableDownload: true,
params: {
order: orderId,
part_detail: true
},
rowActions: rowActions,
tableActions: tableActions,
tableFilters: tableFilters,
rowExpansion: rowExpansion
}}
/>
</>
);
}
@@ -0,0 +1,48 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables';
import { type ReactNode, useMemo } from 'react';
import { DescriptionColumn, ReferenceColumn } from '../ColumnRenderers';
import {
AssignedToMeFilter,
OrderStatusFilter,
OutstandingFilter,
OverdueFilter,
ProjectCodeFilter,
ResponsibleFilter
} from '../Filter';
import ParametricDataTable from '../general/ParametricDataTable';
export default function TransferOrderParametricTable({
queryParams
}: {
queryParams?: Record<string, any>;
}): ReactNode {
const customColumns: TableColumn[] = useMemo(() => {
return [ReferenceColumn({ switchable: false }), DescriptionColumn({})];
}, []);
const customFilters: TableFilter[] = useMemo(() => {
return [
OrderStatusFilter({ model: ModelType.transferorder }),
OutstandingFilter(),
OverdueFilter(),
AssignedToMeFilter(),
ProjectCodeFilter(),
ResponsibleFilter()
];
}, []);
return (
<ParametricDataTable
modelType={ModelType.transferorder}
endpoint={ApiEndpoints.transfer_order_list}
customColumns={customColumns}
customFilters={customFilters}
queryParams={{
...queryParams
}}
/>
);
}
@@ -0,0 +1,185 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import { AddItemButton, UserRoles, useTable } from '@lib/index';
import type { TableFilter } from '@lib/types/Filters';
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { useTransferOrderFields } from '../../forms/TransferOrderForms';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useUserState } from '../../states/UserState';
import {
BooleanColumn,
CompletionDateColumn,
CreatedByColumn,
CreationDateColumn,
DescriptionColumn,
LocationColumn,
ProjectCodeColumn,
ReferenceColumn,
ResponsibleColumn,
StartDateColumn,
StatusColumn,
TargetDateColumn
} from '../ColumnRenderers';
import {
AssignedToMeFilter,
CompletedAfterFilter,
CompletedBeforeFilter,
CreatedAfterFilter,
CreatedBeforeFilter,
CreatedByFilter,
HasProjectCodeFilter,
IncludeVariantsFilter,
MaxDateFilter,
MinDateFilter,
OrderStatusFilter,
OutstandingFilter,
OverdueFilter,
ProjectCodeFilter,
ResponsibleFilter,
StartDateAfterFilter,
StartDateBeforeFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter
} from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
export function TransferOrderTable({
partId
}: Readonly<{
partId?: number;
}>) {
const table = useTable(
!!partId ? 'transferorders-part' : 'transferorders-index'
);
const user = useUserState();
const tableFilters: TableFilter[] = useMemo(() => {
const filters: TableFilter[] = [
OrderStatusFilter({ model: ModelType.transferorder }),
OutstandingFilter(),
OverdueFilter(),
AssignedToMeFilter(),
MinDateFilter(),
MaxDateFilter(),
CreatedBeforeFilter(),
CreatedAfterFilter(),
TargetDateBeforeFilter(),
TargetDateAfterFilter(),
StartDateBeforeFilter(),
StartDateAfterFilter(),
{
name: 'has_target_date',
type: 'boolean',
label: t`Has Target Date`,
description: t`Show orders with a target date`
},
{
name: 'has_start_date',
type: 'boolean',
label: t`Has Start Date`,
description: t`Show orders with a start date`
},
CompletedBeforeFilter(),
CompletedAfterFilter(),
HasProjectCodeFilter(),
ProjectCodeFilter(),
ResponsibleFilter(),
CreatedByFilter()
];
if (!!partId) {
filters.push(IncludeVariantsFilter());
}
return filters;
}, [partId]);
const tableColumns = useMemo(() => {
return [
ReferenceColumn({}),
DescriptionColumn({}),
LocationColumn({
accessor: 'take_from_detail',
title: t`Source Location`
}),
LocationColumn({
accessor: 'destination_detail',
title: t`Destination Location`
}),
BooleanColumn({
accessor: 'consume',
title: t`Consume Stock`,
sortable: true,
switchable: true
}),
// LineItemsProgressColumn({}),
StatusColumn({ model: ModelType.transferorder }),
ProjectCodeColumn({
defaultVisible: false
}),
CreationDateColumn({
defaultVisible: false
}),
CreatedByColumn({
defaultVisible: false
}),
StartDateColumn({
defaultVisible: false
}),
TargetDateColumn({}),
CompletionDateColumn({
accessor: 'complete_date'
}),
ResponsibleColumn({})
];
}, []);
const transferOrderFields = useTransferOrderFields({});
const newTransferOrder = useCreateApiFormModal({
url: ApiEndpoints.transfer_order_list,
title: t`Add Transfer Order`,
fields: transferOrderFields,
initialData: {},
follow: true,
modelType: ModelType.transferorder
});
const tableActions = useMemo(() => {
return [
<AddItemButton
key='add-transfer-order'
tooltip={t`Add Transfer Order`}
onClick={() => newTransferOrder.open()}
hidden={!user.hasAddRole(UserRoles.transfer_order)}
/>
];
}, [user]);
return (
<>
{newTransferOrder.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.transfer_order_list)}
tableState={table}
columns={tableColumns}
props={{
params: {
part: partId
// customer: customerId,
// customer_detail: true
},
tableFilters: tableFilters,
tableActions: tableActions,
modelType: ModelType.transferorder,
enableSelection: true,
enableDownload: true,
enableReports: true,
enableLabels: true
}}
/>
</>
);
}
+1 -1
View File
@@ -70,7 +70,7 @@ export const test = baseTest.extend({
!msg.text().includes('/this/does/not/exist.js') &&
!url.includes('/this/does/not/exist.js') &&
!url.includes('/api/user/me/') &&
!url.includes('/api/user/token/') &&
!url.includes('/api/user/me/token/') &&
!url.includes('/api/auth/v1/auth/login') &&
!url.includes('/api/auth/v1/auth/session') &&
!url.includes('/api/auth/v1/account/authenticators/totp') &&
@@ -142,7 +142,7 @@ test('Dashboard - Preserve widget sizes', async ({ browser }) => {
password: user.testcred
});
(await api).patch('user/profile/', {
(await api).patch('user/me/profile/', {
data: {
widgets: {
widgets: ['ovr-so'],
+210 -5
View File
@@ -1,8 +1,10 @@
import { expect, test } from '../baseFixtures.js';
import { stevenuser } from '../defaults.js';
import {
activateCalendarView,
clearTableFilters,
clickButtonIfVisible,
clickOnRowMenu,
loadTab,
navigate,
openFilterDrawer,
@@ -100,11 +102,8 @@ test('Stock - Location Delete', async ({ browser }) => {
// Delete this location, and all child locations
await page
.locator('div')
.filter({
hasText: new RegExp(`^Stock>PCB Assembler>${loc_1}Stock Location$`)
})
.getByLabel('action-menu-location-actions')
.getByRole('button', { name: 'action-menu-location-actions' })
.first()
.click();
await page
.getByRole('menuitem', { name: 'action-menu-location-actions-delete' })
@@ -551,3 +550,209 @@ test('Stock - Location', async ({ browser }) => {
await page.getByRole('button', { name: 'Scan', exact: true }).click();
await page.getByText('No match found for barcode data').waitFor();
});
test('Transfer Orders - General', async ({ browser }) => {
const page = await doCachedLogin(browser);
await page.getByRole('tab', { name: 'Stock' }).click();
await page.waitForURL('**/stock/location/index/**');
await loadTab(page, 'Transfer Orders');
await clearTableFilters(page);
// We have now loaded the "Transfer Orders" table. Check for some expected texts
await page.getByText('Complete').first().waitFor();
await page.getByText('Issued').first().waitFor();
await page.getByText('Cancelled').first().waitFor();
// Load a particular Transfer Order
await page.getByRole('cell', { name: 'TO-0002' }).click();
await page.waitForTimeout(200);
// This transfer order should be "issued"
await page.getByText('Issued').first().waitFor();
// Edit the transfer order (via keyboard shortcut)
await page.keyboard.press('Control+E');
await page.getByLabel('text-field-reference', { exact: true }).waitFor();
await page.getByLabel('related-field-project_code').waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('button', { name: 'Complete Order' }).click();
await page.getByRole('button', { name: 'Cancel' }).click();
// Check for other expected actions
await page.getByRole('button', { name: 'action-menu-order-actions' }).click();
await page.getByLabel('action-menu-order-actions-edit').waitFor();
await page.getByLabel('action-menu-order-actions-duplicate').waitFor();
await page.getByLabel('action-menu-order-actions-hold').waitFor();
// Click on some tabs
await loadTab(page, 'Line Items');
await loadTab(page, 'Allocated Stock');
await loadTab(page, 'Parameters');
await loadTab(page, 'Attachments');
await loadTab(page, 'Notes');
});
test('Transfer Order - Reference', async ({ browser }) => {
const page = await doCachedLogin(browser);
// go to transfer orders
await page.getByRole('tab', { name: 'Stock' }).click();
await page.waitForURL('**/stock/location/index/**');
await loadTab(page, 'Transfer Orders');
// click add button
await page
.getByRole('button', { name: 'action-button-add-transfer-' })
.click();
// Ensure a new reference is suggested
await expect(
page.getByLabel('text-field-reference', { exact: true })
).not.toBeEmpty();
// Grab the Transfer Order reference
const reference: string = await page
.getByRole('textbox', { name: 'text-field-reference' })
.inputValue();
expect(reference).toMatch(/TO-\d+/);
await page.getByRole('textbox', { name: 'text-field-description' }).click();
await page
.getByRole('textbox', { name: 'text-field-description' })
.fill('creating from playwrigh!');
// create the transfer order
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Item Created').waitFor();
// go back to stock page
await page.getByRole('link', { name: 'Stock', exact: true }).click();
await page
.getByRole('button', { name: 'action-button-add-transfer-' })
.click();
const nextReference: string = await page
.getByRole('textbox', { name: 'text-field-reference' })
.inputValue();
expect(nextReference).toMatch(/TO-\d+/);
// Ensure that the reference has incremented
const refNumber = Number(reference.replace('TO-', ''));
const nextRefNumber = Number(nextReference.replace('TO-', ''));
expect(nextRefNumber).toBe(refNumber + 1);
});
test('Transfer Order - Calendar', async ({ browser }) => {
const page = await doCachedLogin(browser);
await navigate(page, 'stock/location/index/transfer-orders');
await activateCalendarView(page);
// Export calendar data
await page.getByLabel('calendar-export-data').click();
await page.getByRole('button', { name: 'Export', exact: true }).click();
await page.getByText('Process completed successfully').waitFor();
// Required because we downloaded a file
await page.context().close();
});
test('Transfer Order - Edit', async ({ browser }) => {
const page = await doCachedLogin(browser);
await navigate(page, 'stock/transfer-order/2/');
// Check for expected text items
await page.getByText('Consume some paint').first().waitFor();
await page.getByText('2026-04-20').waitFor(); // Created date
await page.getByText('2026-04-23').waitFor(); // Issue date
await page.getByText('PRJ-HEL').waitFor(); // Project Code
await page.keyboard.press('Control+E');
// Edit start date
await page.getByLabel('date-field-start_date').fill('2026-04-28');
// Submit the form
await page.getByRole('button', { name: 'Submit' }).click();
// Expect error
await page.getByText('Errors exist for one or more form fields').waitFor();
await page.getByText('Target date must be after start date').waitFor();
// Cancel the form
await page.getByRole('button', { name: 'Cancel' }).click();
});
test('Transfer Order - Allocate and Transfer', async ({ browser }) => {
const page = await doCachedLogin(browser);
await navigate(page, 'stock/transfer-order/6/');
// Duplicate this transfer order, to ensure a fresh run each time
await page.getByLabel('action-menu-order-actions').click();
await page.getByLabel('action-menu-order-actions-duplicate').click();
// Submit the duplicate request and ensure it completes
await page.getByRole('button', { name: 'Submit' }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Item Created').waitFor();
// Issue the order
await page.getByRole('button', { name: 'Issue Order' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Order issued').waitFor();
await loadTab(page, 'Line Items');
// Allocate line item 1
const cell1 = await page.getByText('C_100pF_0402', { exact: true });
await clickOnRowMenu(cell1);
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
await page.getByText('C_100pF_0402Location:Offsite').waitFor();
await page.waitForTimeout(200);
await page.getByRole('button', { name: 'Submit' }).click();
// Allocate line item 1
const cell2 = await page.getByText('R_2.2K_0603_1%', { exact: true });
await clickOnRowMenu(cell2);
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
await page.getByText('R_2.2K_0603_1%Location:').waitFor();
await page.waitForTimeout(200);
await page.getByRole('button', { name: 'Submit' }).click();
// Complete the order
await page.getByRole('button', { name: 'Complete Order' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Complete', { exact: true }).first().waitFor();
// Tab should have changed to Transferred Stock
await loadTab(page, 'Transferred Stock');
await page.getByText('C_100pF_0402').waitFor();
await page.getByText('2.2K resistor in 0603 SMD').waitFor();
});
test('Transfer Orders - Duplicate', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'stock/transfer-order/1/detail'
});
await page.getByLabel('action-menu-order-actions').click();
await page.getByLabel('action-menu-order-actions-duplicate').click();
// Ensure a new reference is suggested
await expect(
page.getByLabel('text-field-reference', { exact: true })
).not.toBeEmpty();
// Submit the duplicate request and ensure it completes
await page.getByRole('button', { name: 'Submit' }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('tab', { name: 'Order Details' }).waitFor();
await page.getByRole('tab', { name: 'Order Details' }).click();
await page.getByText('Pending').first().waitFor();
});