[UI] Preview Drawer (#12117)

* Add <GlobalPreviewDrawer> component

* Allow plugins to interact

* provide either pk or instance data

* open preview drawer from calendar views

* Launch preview drawer directly from tables

* Add preview types

* Fix types

* CLear data on close

* Fix transition

* Allow caller to pass preview component

* AttributeGrid

* Use a table component

* Display info items

* Navigate to detail page from drawer

* Updates

* Refactor to use part detail view

* Display parameters

* Fix image caching

* Apply same to StockItem

* Remove old component

* Refactor details grid component

* Preview panel for PurchaseOrder model

* Implement for SalesOrder

* Support ReturnOrder

* SupplierPart and ManufacturerPart

* Support preview in parameteric tables

* support Company and BuildOrder

* Fix numbervalue

* BuildOrder and SalesOrderShipment

* location and category

* add user setting

* Fix typing

* Handle link clicks within preview drawer

* fixes

* Implement onClose callback

* Handle row click when panel preview is disabled

* Support custom detail links

* update playwright tests

* UI tweaks

* Add CHANGELOG entry

* docs

* define default API params per model type

* Split out more docs pages

* Simplify state ref

* Refactoring

* Simplify PreviewDrawer component

* observe user setting in calendar view

* Enable PreviewDrawer from RowViewAction

* Push render functions onto ModelInformation dict

* Fix lib compilation

* Refactor internal uses of RowViewAction

* typescript fixes

* Simplify PreviewDrawer component structure

* remove TODO placeholders
This commit is contained in:
Oliver
2026-07-12 20:11:27 +10:00
committed by GitHub
parent b42521f205
commit c5e81a7820
92 changed files with 4685 additions and 3221 deletions
@@ -223,6 +223,12 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
('MMM DD YYYY', 'Feb 22 2022'),
],
},
'ENABLE_PREVIEW_PANEL': {
'name': _('Table Preview Panel'),
'description': _('Display a preview panel when selecting items in tables'),
'default': False,
'validator': bool,
},
'DISPLAY_STOCKTAKE_TAB': {
'name': _('Show Stock History'),
'description': _('Display stock history information in the part detail page'),
+13 -3
View File
@@ -10,7 +10,7 @@ import {
} from '@tabler/icons-react';
import { type ReactNode, useMemo, useState } from 'react';
import { cancelEvent } from '../functions/Events';
import { getDetailUrl } from '../functions/Navigation';
import { eventModified, getDetailUrl } from '../functions/Navigation';
import { navigateToLink } from '../functions/Navigation';
import type { RowAction, RowViewProps } from '../types/Tables';
@@ -23,8 +23,18 @@ export function RowViewAction(props: RowViewProps): RowAction {
color: undefined,
icon: <IconArrowRight />,
onClick: (event: any) => {
const url = getDetailUrl(props.modelType, props.modelId);
navigateToLink(url, props.navigate, event);
const showPreviewPanel = props.isPreviewEnabled?.() ?? false;
if (
!showPreviewPanel ||
eventModified(event as any) ||
!props.openPreview
) {
const url = getDetailUrl(props.modelType, props.modelId);
navigateToLink(url, props.navigate, event);
} else {
props.openPreview(props.modelType, props.modelId);
}
}
};
}
+51 -8
View File
@@ -1,5 +1,6 @@
import { t } from '@lingui/core/macro';
import type { InvenTreeIconType } from '../types/Icons';
import type { InstanceRenderInterface } from '../types/Rendering';
import { ApiEndpoints } from './ApiEndpoints';
import type { ModelType } from './ModelType';
@@ -13,6 +14,9 @@ export interface ModelInformationInterface {
pk_field?: string;
supports_barcode?: boolean;
icon: keyof InvenTreeIconType;
preview?: (props: { instance: any; modelId: number }) => any;
render?: (props: Readonly<InstanceRenderInterface>) => any;
default_query_params?: Record<string, any>;
}
export interface TranslatableModelInformationInterface
@@ -25,6 +29,33 @@ export type ModelDict = {
[key in keyof typeof ModelType]: TranslatableModelInformationInterface;
};
type ModelPreviewKey = keyof typeof ModelType;
type ModelPreviewProps = { instance: any; modelId: number };
type ModelPreview = (props: ModelPreviewProps) => any;
type ModelRender = (props: Readonly<InstanceRenderInterface>) => any;
export function registerModelPreviews(
previews: Partial<Record<ModelPreviewKey, ModelPreview>>
) {
(Object.keys(previews) as ModelPreviewKey[]).forEach((model) => {
const preview = previews[model];
if (preview) {
ModelInformationDict[model].preview = preview;
}
});
}
export function registerModelRenderers(
renderers: Partial<Record<ModelPreviewKey, ModelRender>>
) {
(Object.keys(renderers) as ModelPreviewKey[]).forEach((model) => {
const renderer = renderers[model];
if (renderer) {
ModelInformationDict[model].render = renderer;
}
});
}
export const ModelInformationDict: ModelDict = {
part: {
label: () => t`Part`,
@@ -64,7 +95,12 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.supplier_part_list,
admin_url: '/company/supplierpart/',
supports_barcode: true,
icon: 'supplier_part'
icon: 'supplier_part',
default_query_params: {
part_detail: true,
supplier_detail: true,
manufacturer_detail: true
}
},
manufacturerpart: {
label: () => t`Manufacturer Part`,
@@ -74,7 +110,8 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.manufacturer_part_list,
admin_url: '/company/manufacturerpart/',
supports_barcode: true,
icon: 'manufacturers'
icon: 'manufacturers',
default_query_params: { part_detail: true, manufacturer_detail: true }
},
partcategory: {
label: () => t`Part Category`,
@@ -93,7 +130,8 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.stock_item_list,
admin_url: '/stock/stockitem/',
supports_barcode: true,
icon: 'stock'
icon: 'stock',
default_query_params: { part_detail: true }
},
stocklocation: {
label: () => t`Stock Location`,
@@ -125,7 +163,8 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.build_order_list,
admin_url: '/build/build/',
supports_barcode: true,
icon: 'build_order'
icon: 'build_order',
default_query_params: { part_detail: true }
},
buildline: {
label: () => t`Build Line`,
@@ -164,7 +203,8 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.purchase_order_list,
admin_url: '/order/purchaseorder/',
supports_barcode: true,
icon: 'purchase_orders'
icon: 'purchase_orders',
default_query_params: { supplier_detail: true }
},
purchaseorderlineitem: {
label: () => t`Purchase Order Line`,
@@ -180,7 +220,8 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.sales_order_list,
admin_url: '/order/salesorder/',
supports_barcode: true,
icon: 'sales_orders'
icon: 'sales_orders',
default_query_params: { customer_detail: true }
},
salesordershipment: {
label: () => t`Sales Order Shipment`,
@@ -190,7 +231,8 @@ export const ModelInformationDict: ModelDict = {
admin_url: '/order/salesordershipment/',
api_endpoint: ApiEndpoints.sales_order_shipment_list,
supports_barcode: true,
icon: 'shipment'
icon: 'shipment',
default_query_params: { order_detail: true }
},
returnorder: {
label: () => t`Return Order`,
@@ -200,7 +242,8 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.return_order_list,
admin_url: '/order/returnorder/',
supports_barcode: true,
icon: 'return_orders'
icon: 'return_orders',
default_query_params: { customer_detail: true }
},
returnorderlineitem: {
label: () => t`Return Order Line Item`,
+13
View File
@@ -69,6 +69,17 @@ export type ImporterDrawerContext = {
sessionId: () => number | null;
};
export type PreviewDrawerContext = {
open: (
modelType: ModelType,
id?: number,
instance?: any,
onClose?: () => void
) => void;
close: () => void;
isOpen: () => boolean;
};
/**
* A set of properties which are passed to a plugin,
* for rendering an element in the user interface.
@@ -90,6 +101,7 @@ export type ImporterDrawerContext = {
* @param forms - A set of functions for opening various API forms (see ../components/Forms.tsx)
* @param tables - A set of functions for rendering API tables
* @param importer - A set of functions for controlling the global importer drawer (see ../components/importer/GlobalImporterDrawer.tsx)
* @param preview - A set of functions for controlling the global preview drawer (see ../components/previews/GlobalPreviewDrawer.tsx)
* @param model - The model type associated with the rendered component (if applicable)
* @param id - The ID (primary key) of the model instance for the plugin (if applicable)
* @param instance - The model instance data (if available)
@@ -125,6 +137,7 @@ export type InvenTreePluginContext = {
};
tables: InvenTreeTablesContext<any>;
importer: ImporterDrawerContext;
preview: PreviewDrawerContext;
model?: ModelType | string;
id?: string | number | null;
instance?: any;
+12
View File
@@ -0,0 +1,12 @@
import type { ReactNode } from 'react';
export interface PreviewType {
preview: ReactNode;
title: string;
}
export type PreviewComponentProps = {
instance: any;
};
export type PreviewComponent = (props: PreviewComponentProps) => PreviewType;
+6 -1
View File
@@ -154,7 +154,12 @@ type RowModelProps = {
navigate: NavigateFunction;
};
export type RowViewProps = RowAction & RowModelProps;
type RowViewBehaviorProps = {
isPreviewEnabled?: () => boolean;
openPreview?: (modelType: ModelType, modelId: number) => void;
};
export type RowViewProps = RowAction & RowModelProps & RowViewBehaviorProps;
/**
* Set of optional properties which can be passed to an InvenTreeTable component
@@ -7,7 +7,11 @@ import { ModelInformationDict } from '@lib/enums/ModelInformation';
import type { ModelType } from '@lib/enums/ModelType';
import type { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl, navigateToLink } from '@lib/functions/Navigation';
import {
eventModified,
getDetailUrl,
navigateToLink
} from '@lib/functions/Navigation';
import type { TableFilter } from '@lib/types/Filters';
import { t } from '@lingui/core/macro';
import { ActionIcon, Group, Text } from '@mantine/core';
@@ -22,6 +26,8 @@ import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../App';
import useCalendar from '../../hooks/UseCalendar';
import { openGlobalPreview } from '../../states/PreviewDrawerState';
import { useUserSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import { StatusRenderer, getStatusColor } from '../render/StatusRenderer';
import {
@@ -59,6 +65,9 @@ export default function OrderCalendar({
}) {
const navigate = useNavigate();
const user = useUserState();
const previewPanelEnabled = useUserSettingsState((state) =>
state.isSet('ENABLE_PREVIEW_PANEL')
);
// These filters apply to all order types
const orderFilters: TableFilter[] = useMemo(() => {
@@ -166,14 +175,16 @@ export default function OrderCalendar({
}
};
// Callback when PurchaseOrder is clicked
// Callback when an order is clicked - open preview drawer, or navigate on modifier click
const onClickOrder = (info: EventClickArg) => {
if (!!info.event.id) {
navigateToLink(
getDetailUrl(model, info.event.id),
navigate,
info.jsEvent
);
if (!info.event.id) return;
const detailUrl = getDetailUrl(model, info.event.id);
if (eventModified(info.jsEvent as any) || !previewPanelEnabled) {
navigateToLink(detailUrl, navigate, info.jsEvent);
} else {
openGlobalPreview(model, Number.parseInt(info.event.id));
}
};
+27 -14
View File
@@ -280,7 +280,7 @@ function NumberValue(props: Readonly<FieldProps>) {
const value = props?.field_value;
// Convert to double
const numberValue = Number.parseFloat(value.toString());
const numberValue = Number.parseFloat(value?.toString() ?? '');
if (value === null || value === undefined) {
return <Text size='sm'>'---'</Text>;
@@ -465,10 +465,12 @@ function CopyField({ value }: Readonly<{ value: string }>) {
export function DetailsTableField({
item,
field
field,
showIcons = true
}: Readonly<{
item: any;
field: DetailsField;
showIcons?: boolean;
}>) {
function getFieldType(type: string) {
switch (type) {
@@ -502,10 +504,12 @@ export function DetailsTableField({
<Table.Tr style={{ verticalAlign: 'top' }}>
<Table.Td style={{ minWidth: 75, lineBreak: 'auto', flex: 2 }}>
<Group gap='xs' wrap='nowrap'>
<InvenTreeIcon
icon={field.icon ?? (field.name as keyof InvenTreeIconType)}
/>
<Text style={{ paddingLeft: 10 }}>{field.label}</Text>
{showIcons && (
<InvenTreeIcon
icon={field.icon ?? (field.name as keyof InvenTreeIconType)}
/>
)}
<Text style={{ paddingLeft: showIcons ? 10 : 0 }}>{field.label}</Text>
</Group>
</Table.Td>
<Table.Td
@@ -525,15 +529,19 @@ export function DetailsTableField({
);
}
export function DetailsTable({
item,
fields,
title
}: Readonly<{
export interface DetailsTableProps {
item: any;
fields: DetailsField[];
title?: string;
}>) {
showIcons?: boolean;
}
export function DetailsTable({
item,
fields,
title,
showIcons = true
}: Readonly<DetailsTableProps>) {
const visibleFields = useMemo(() => {
return fields.filter((field) => !field.hidden);
}, [fields]);
@@ -552,8 +560,13 @@ export function DetailsTable({
{title && <StylishText size='lg'>{title}</StylishText>}
<Table striped verticalSpacing={5} horizontalSpacing='sm'>
<Table.Tbody>
{visibleFields.map((field: DetailsField, index: number) => (
<DetailsTableField field={field} item={item} key={index} />
{visibleFields.map((field: DetailsField) => (
<DetailsTableField
field={field}
item={item}
showIcons={showIcons}
key={field.name}
/>
))}
</Table.Tbody>
</Table>
@@ -406,6 +406,10 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {
const { hovered, ref } = useHover();
const [img, setImg] = useState<string>(props.src ?? backup_image);
useEffect(() => {
setImg(props.src ?? backup_image);
}, [props.src]);
// Sets a new image, and triggers upstream instance refresh
const setAndRefresh = (image: string) => {
setImg(image);
@@ -1,7 +1,20 @@
import { Paper, SimpleGrid } from '@mantine/core';
import type React from 'react';
import { useMemo } from 'react';
import { DetailsTable, type DetailsTableProps } from './Details';
export type { DetailsTableProps };
export function ItemDetailsGrid({
children,
tables
}: React.PropsWithChildren<{ tables?: DetailsTableProps[] }>) {
const visibleTables = useMemo(
() => tables?.filter((t) => t.fields.some((f) => !f.hidden)) ?? [],
[tables]
);
export function ItemDetailsGrid(props: React.PropsWithChildren<{}>) {
return (
<Paper p='xs'>
<SimpleGrid
@@ -10,7 +23,13 @@ export function ItemDetailsGrid(props: React.PropsWithChildren<{}>) {
spacing='xs'
verticalSpacing='xs'
>
{props.children}
{children}
{visibleTables.map((props) => (
<DetailsTable
key={props.fields.map((field) => field.name).join(',')}
{...props}
/>
))}
</SimpleGrid>
</Paper>
);
@@ -0,0 +1,54 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import { t } from '@lingui/core/macro';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useApi } from '../../contexts/ApiContext';
import type { DetailsField, DetailsTableProps } from './Details';
export function useParameterDetailsGrid({
model_type,
model_id
}: Readonly<{
model_type: ModelType;
model_id: number | string | undefined;
}>): DetailsTableProps {
const api = useApi();
const { data: parameters = [] } = useQuery({
queryKey: ['parameter-details', model_type, model_id],
enabled: !!model_id && !!model_type,
queryFn: () =>
api
.get(apiUrl(ApiEndpoints.parameter_list), {
params: { model_type, model_id, limit: 100 }
})
.then((res) => res.data?.results ?? [])
});
const { fields, item } = useMemo(() => {
const item: Record<string, string> = {};
const fields: DetailsField[] = parameters.map((param: any) => {
const key = `param_${param.pk}`;
const value =
param.data +
(param.template_detail?.units ? ` ${param.template_detail.units}` : '');
item[key] = value;
return {
type: 'string' as const,
name: key,
label: param.template_detail?.name ?? String(param.pk),
copy: true
};
});
return { fields, item };
}, [parameters]);
return {
title: t`Parameters`,
fields: fields,
item: item,
showIcons: false
};
}
@@ -29,6 +29,7 @@ import {
type PluginUIFeature,
PluginUIFeatureType
} from '../plugins/PluginUIFeature';
import GlobalPreviewDrawer from '../previews/GlobalPreviewDrawer';
import { Footer } from './Footer';
import { Header } from './Header';
@@ -148,6 +149,7 @@ export default function LayoutComponent() {
)}
</Flex>
<GlobalImporterDrawer />
<GlobalPreviewDrawer />
</>
</ProtectedRoute>
);
@@ -46,6 +46,11 @@ import {
openGlobalImporter
} from '../../states/ImporterState';
import { usePluginState } from '../../states/PluginState';
import {
closeGlobalPreview,
getGlobalPreviewState,
openGlobalPreview
} from '../../states/PreviewDrawerState';
import { useServerApiState } from '../../states/ServerApiState';
import { EditApiForm } from '../forms/ApiForm';
import { Thumbnail } from '../images/Thumbnail';
@@ -108,6 +113,12 @@ export const useInvenTreeContext = () => {
isOpen: () => getGlobalImporterState().isOpen,
sessionId: () => getGlobalImporterState().sessionId
},
preview: {
open: (modelType, id?, instance?, onClose?) =>
openGlobalPreview(modelType, id, instance, undefined, onClose),
close: () => closeGlobalPreview(),
isOpen: () => getGlobalPreviewState().isOpen
},
tables: {
renderTable: (props: InvenTreeTableRenderProps<any>) => (
<Suspense fallback={null}>
@@ -0,0 +1,28 @@
import { usePreviewDrawerState } from '../../states/PreviewDrawerState';
import { useUserSettingsState } from '../../states/SettingsStates';
import PreviewDrawer from './PreviewDrawer';
export default function GlobalPreviewDrawer() {
const enabled = useUserSettingsState((state) =>
state.isSet('ENABLE_PREVIEW_PANEL')
);
const drawer = usePreviewDrawerState((state) => state);
if (!enabled) {
return null;
}
return (
<PreviewDrawer
modelType={drawer.modelType}
id={drawer.id}
instance={drawer.instance}
filters={drawer.filters}
preview={drawer.preview}
targetUrl={drawer.targetUrl}
opened={drawer.isOpen}
onClose={drawer.closePreview}
/>
);
}
@@ -0,0 +1,30 @@
import { registerModelPreviews } from '@lib/enums/ModelInformation';
import { BuildOrderPreviewComponent } from './models/BuildOrderPreview';
import { CompanyPreviewComponent } from './models/CompanyPreview';
import { ManufacturerPartPreviewComponent } from './models/ManufacturerPartPreview';
import { PartCategoryPreviewComponent } from './models/PartCategoryPreview';
import { PartPreviewComponent } from './models/PartPreview';
import { PurchaseOrderPreviewComponent } from './models/PurchaseOrderPreview';
import { ReturnOrderPreviewComponent } from './models/ReturnOrderPreview';
import { SalesOrderPreviewComponent } from './models/SalesOrderPreview';
import { SalesOrderShipmentPreviewComponent } from './models/SalesOrderShipmentPreview';
import { StockLocationPreviewComponent } from './models/StockLocationPreview';
import { StockPreviewComponent } from './models/StockPreview';
import { SupplierPartPreviewComponent } from './models/SupplierPartPreview';
import { TransferOrderPreviewComponent } from './models/TransferOrderPreview';
registerModelPreviews({
part: PartPreviewComponent,
supplierpart: SupplierPartPreviewComponent,
manufacturerpart: ManufacturerPartPreviewComponent,
partcategory: PartCategoryPreviewComponent,
stockitem: StockPreviewComponent,
stocklocation: StockLocationPreviewComponent,
build: BuildOrderPreviewComponent,
company: CompanyPreviewComponent,
purchaseorder: PurchaseOrderPreviewComponent,
salesorder: SalesOrderPreviewComponent,
salesordershipment: SalesOrderShipmentPreviewComponent,
returnorder: ReturnOrderPreviewComponent,
transferorder: TransferOrderPreviewComponent
});
@@ -0,0 +1,195 @@
import {
ActionIcon,
Anchor,
Divider,
Drawer,
Group,
LoadingOverlay,
Stack,
Tooltip
} from '@mantine/core';
import { StylishText } from '@lib/components/StylishText';
import { cancelEvent } from '@lib/functions/Events';
import {
eventModified,
getBaseUrl,
getDetailUrl,
navigateToLink
} from '@lib/functions/Navigation';
import type { ModelType } from '@lib/index';
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { IconArrowRight } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useInstance } from '../../hooks/UseInstance';
import { getModelInfo } from '../render/ModelType';
import './ModelPreviewShim';
import { FallbackPreviewComponent } from './models/Fallback';
export default function PreviewDrawer({
modelType,
id,
instance: providedInstance,
filters,
preview: providedPreview,
targetUrl,
opened,
onClose
}: Readonly<{
modelType?: ModelType;
id?: number | string;
instance?: any;
filters?: Record<string, any>;
preview?: PreviewType;
targetUrl?: string;
opened: boolean;
onClose: () => void;
}>) {
const navigate = useNavigate();
const modelInfo = useMemo(() => {
return modelType ? getModelInfo(modelType) : null;
}, [modelType]);
const apiEndpoint = modelInfo?.api_endpoint ?? undefined;
// Combine the default query params for the model type with any filters
// explicitly provided by the caller which opened the preview (which take
// precedence over the defaults).
const queryParams = useMemo(() => {
return {
...modelInfo?.default_query_params,
...filters
};
}, [modelInfo, filters]);
const { instance: fetchedInstance, instanceQuery } = useInstance({
endpoint: apiEndpoint!,
pk: id,
hasPrimaryKey: true,
defaultValue: {},
params: queryParams,
disabled: !!providedInstance || !modelType || !id
});
const instance = useMemo(() => {
return providedInstance ?? fetchedInstance;
}, [providedInstance, fetchedInstance]);
const previewComponent: PreviewType | null = useMemo(() => {
if (providedPreview) return providedPreview;
if (!modelType || !modelInfo || id == null) return null;
const component = modelInfo?.preview?.({
instance,
modelId: typeof id === 'string' ? Number(id) : id
});
if (component == null) {
return FallbackPreviewComponent({
modelInfo,
modelType,
modelId: id,
instance
});
}
return component;
}, [providedPreview, modelType, id, instance, modelInfo]);
useEffect(() => {
if (!opened) return;
const handler = (event: MouseEvent) => {
const anchor = (event.target as HTMLElement).closest('a');
if (anchor && !eventModified(event as any)) {
if (anchor.origin === window.location.origin) {
// Same-origin: prevent browser navigation and route internally
const href = anchor.pathname + anchor.search + anchor.hash;
cancelEvent(event);
onClose();
navigateToLink(href, navigate, event as any);
} else {
// External link: let browser open it, just close the drawer
onClose();
}
}
};
document.addEventListener('click', handler, true);
return () => document.removeEventListener('click', handler, true);
}, [onClose, opened]);
const primaryUrl = useMemo(() => {
if (targetUrl) return targetUrl;
if (modelType && id) return getDetailUrl(modelType, id);
return undefined;
}, [targetUrl, modelType, id]);
const primaryHref = useMemo(() => {
if (!primaryUrl) return undefined;
const base = `/${getBaseUrl()}`;
return primaryUrl.startsWith(base) ? primaryUrl : `${base}${primaryUrl}`;
}, [primaryUrl]);
const clickTitle = useCallback(
(event: any) => {
if (!primaryUrl) return;
if (!eventModified(event as any)) {
onClose();
}
navigateToLink(primaryUrl, navigate, event as any);
},
[primaryUrl, navigate]
);
const drawerTitle = useMemo(() => {
if (!previewComponent) return null;
const titleText = (
<StylishText size='lg'>{previewComponent.title}</StylishText>
);
if (!primaryHref) return titleText;
return (
<Anchor href={primaryHref} onClick={(e) => clickTitle(e)}>
<Group aria-label={`details-${modelType}-${id}`} gap='xs'>
<Tooltip label={t`View Details`} position='left'>
<ActionIcon variant='transparent' size='lg'>
<IconArrowRight />
</ActionIcon>
</Tooltip>
{titleText}
</Group>
</Anchor>
);
}, [previewComponent, primaryHref, modelType, id, clickTitle]);
return (
<Drawer
position='right'
size='xl'
title={drawerTitle}
opened={opened}
onClose={onClose}
withCloseButton
transitionProps={{
transition: 'slide-left',
duration: 300,
timingFunction: 'ease'
}}
>
<Stack gap='xs'>
{previewComponent && (
<>
<Divider />
<LoadingOverlay visible={instanceQuery.isFetching} />
{previewComponent.preview}
</>
)}
</Stack>
</Drawer>
);
}
@@ -0,0 +1,25 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { BuildOrderDetailsPanel } from '../../../pages/build/BuildOrderDetailsPanel';
export function BuildOrderPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const part = instance?.part_detail?.full_name ?? instance?.part_detail?.name;
const ref = instance?.reference ?? `#${modelId}`;
let title = `${t`Build Order`} ${ref}`;
if (part) {
title += ` (${part})`;
}
return {
title,
preview: <BuildOrderDetailsPanel instance={instance} />
};
}
@@ -0,0 +1,18 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { CompanyDetailsPanel } from '../../../pages/company/CompanyDetailsPanel';
export function CompanyPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const name = instance?.name ?? `#${modelId}`;
return {
title: `${t`Company`} - ${name}`,
preview: <CompanyDetailsPanel instance={instance} />
};
}
@@ -0,0 +1,31 @@
import type { ModelInformationInterface } from '@lib/enums/ModelInformation';
import type { ModelType } from '@lib/enums/ModelType';
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { Alert, Text } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';
export function FallbackPreviewComponent({
modelInfo,
modelType,
modelId,
instance
}: {
modelInfo: ModelInformationInterface;
modelType: ModelType;
modelId: number | string;
instance: any;
}): PreviewType {
return {
title: `${modelInfo.label} #${modelId}`,
preview: (
<Alert
color='red'
title={t`No preview available`}
icon={<IconExclamationCircle />}
>
<Text>{t`No preview available for this model.`}</Text>
</Alert>
)
};
}
@@ -0,0 +1,26 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { ManufacturerPartDetailsPanel } from '../../../pages/company/ManufacturerPartDetailsPanel';
export function ManufacturerPartPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const manufacturer =
instance?.manufacturer_detail?.name ?? instance?.manufacturer_name;
const mpn = instance?.MPN ?? `#${modelId}`;
let title = `${t`Manufacturer Part`} ${mpn}`;
if (manufacturer) {
title += ` (${manufacturer})`;
}
return {
title,
preview: <ManufacturerPartDetailsPanel instance={instance} />
};
}
@@ -0,0 +1,18 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { PartCategoryDetailsPanel } from '../../../pages/part/PartCategoryDetailsPanel';
export function PartCategoryPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const name = `${t`Part Category`} - ${instance?.name ?? `#${modelId}`}`;
return {
title: name,
preview: <PartCategoryDetailsPanel instance={instance} />
};
}
@@ -0,0 +1,15 @@
import type { PreviewType } from '@lib/types/Preview';
import { PartDetailsPanel } from '../../../pages/part/PartDetailsPanel';
export function PartPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
return {
title: instance?.full_name || instance?.name || `Part #${modelId}`,
preview: <PartDetailsPanel instance={instance} />
};
}
@@ -0,0 +1,25 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { PurchaseOrderDetailsPanel } from '../../../pages/purchasing/PurchaseOrderDetailsPanel';
export function PurchaseOrderPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const supplier = instance?.supplier_detail?.name ?? instance?.supplier_name;
const ref = instance?.reference ?? `#${modelId}`;
let title = `${t`Purchase Order`} ${ref}`;
if (supplier) {
title += ` (${supplier})`;
}
return {
title: title,
preview: <PurchaseOrderDetailsPanel instance={instance} />
};
}
@@ -0,0 +1,25 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { ReturnOrderDetailsPanel } from '../../../pages/sales/ReturnOrderDetailsPanel';
export function ReturnOrderPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const customer = instance?.customer_detail?.name ?? instance?.customer_name;
const ref = instance?.reference ?? `#${modelId}`;
let title = `${t`Return Order`} ${ref}`;
if (customer) {
title += ` (${customer})`;
}
return {
title,
preview: <ReturnOrderDetailsPanel instance={instance} />
};
}
@@ -0,0 +1,25 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { SalesOrderDetailsPanel } from '../../../pages/sales/SalesOrderDetailsPanel';
export function SalesOrderPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const customer = instance?.customer_detail?.name ?? instance?.customer_name;
const ref = instance?.reference ?? `#${modelId}`;
let title = `${t`Sales Order`} ${ref}`;
if (customer) {
title += ` (${customer})`;
}
return {
title,
preview: <SalesOrderDetailsPanel instance={instance} />
};
}
@@ -0,0 +1,25 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { SalesOrderShipmentDetailsPanel } from '../../../pages/sales/SalesOrderShipmentDetailsPanel';
export function SalesOrderShipmentPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const order = instance?.order_detail?.reference;
const ref = instance?.reference ?? `#${modelId}`;
let title = `${t`Shipment`} ${ref}`;
if (order) {
title += ` (${order})`;
}
return {
title,
preview: <SalesOrderShipmentDetailsPanel instance={instance} />
};
}
@@ -0,0 +1,18 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { StockLocationDetailsPanel } from '../../../pages/stock/StockLocationDetailsPanel';
export function StockLocationPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const name = `${t`Stock Location`} - ${instance?.name ?? `#${modelId}`}`;
return {
title: name,
preview: <StockLocationDetailsPanel instance={instance} />
};
}
@@ -0,0 +1,30 @@
import { formatDecimal } from '@lib/functions/Formatting';
import type { PreviewType } from '@lib/types/Preview';
import { StockDetailsPanel } from '../../../pages/stock/StockDetailsPanel';
export function StockPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const part = instance?.part_detail;
let title = `Stock Item #${modelId}`;
if (part) {
title = part?.full_name ?? part?.name;
if (instance.serial) {
title += ` (# ${instance.serial})`;
} else {
title += ` (x ${formatDecimal(instance.quantity)})`;
}
}
return {
title: title,
preview: <StockDetailsPanel instance={instance} />
};
}
@@ -0,0 +1,25 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { SupplierPartDetailsPanel } from '../../../pages/company/SupplierPartDetailsPanel';
export function SupplierPartPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const supplier = instance?.supplier_detail?.name ?? instance?.supplier_name;
const sku = instance?.SKU ?? `#${modelId}`;
let title = `${t`Supplier Part`} ${sku}`;
if (supplier) {
title += ` (${supplier})`;
}
return {
title,
preview: <SupplierPartDetailsPanel instance={instance} />
};
}
@@ -0,0 +1,18 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { TransferOrderDetailsPanel } from '../../../pages/stock/TransferOrderDetailsPanel';
export function TransferOrderPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const ref = instance?.reference ?? `#${modelId}`;
return {
title: `${t`Transfer Order`} ${ref}`,
preview: <TransferOrderDetailsPanel instance={instance} />
};
}
+18 -98
View File
@@ -17,11 +17,10 @@ import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useCallback, useMemo } from 'react';
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import { ModelType } from '@lib/enums/ModelType';
import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import type {
InstanceRenderInterface,
ModelRendererDict,
RemoteInstanceProps,
RenderInlineModelProps,
RenderInstanceProps
@@ -39,107 +38,12 @@ import { useApi } from '../../contexts/ApiContext';
import { usePluginState } from '../../states/PluginState';
import { useUserSettingsState } from '../../states/SettingsStates';
import { Thumbnail } from '../images/Thumbnail';
import { RenderBuildItem, RenderBuildLine, RenderBuildOrder } from './Build';
import {
RenderAddress,
RenderCompany,
RenderContact,
RenderManufacturerPart,
RenderSupplierPart
} from './Company';
import {
RenderContentType,
RenderError,
RenderImportSession,
RenderParameter,
RenderParameterTemplate,
RenderProjectCode,
RenderSelectionEntry,
RenderSelectionList,
RenderTag
} from './Generic';
import {
RenderPurchaseOrder,
RenderReturnOrder,
RenderReturnOrderLineItem,
RenderSalesOrder,
RenderSalesOrderShipment,
RenderTransferOrder,
RenderTransferOrderLineItem
} from './Order';
import { RenderPart, RenderPartCategory, RenderPartTestTemplate } from './Part';
import { RenderPlugin } from './Plugin';
import { RenderLabelTemplate, RenderReportTemplate } from './Report';
import {
RenderStockItem,
RenderStockLocation,
RenderStockLocationType
} from './Stock';
import { RenderGroup, RenderOwner, RenderUser } from './User';
/**
* Lookup table for rendering a model instance
*/
export const RendererLookup: ModelRendererDict = {
[ModelType.address]: RenderAddress,
[ModelType.build]: RenderBuildOrder,
[ModelType.buildline]: RenderBuildLine,
[ModelType.builditem]: RenderBuildItem,
[ModelType.company]: RenderCompany,
[ModelType.contact]: RenderContact,
[ModelType.parameter]: RenderParameter,
[ModelType.parametertemplate]: RenderParameterTemplate,
[ModelType.manufacturerpart]: RenderManufacturerPart,
[ModelType.owner]: RenderOwner,
[ModelType.part]: RenderPart,
[ModelType.partcategory]: RenderPartCategory,
[ModelType.parttesttemplate]: RenderPartTestTemplate,
[ModelType.projectcode]: RenderProjectCode,
[ModelType.purchaseorder]: RenderPurchaseOrder,
[ModelType.purchaseorderlineitem]: RenderPurchaseOrder,
[ModelType.returnorder]: RenderReturnOrder,
[ModelType.returnorderlineitem]: RenderReturnOrderLineItem,
[ModelType.salesorder]: RenderSalesOrder,
[ModelType.salesordershipment]: RenderSalesOrderShipment,
[ModelType.transferorder]: RenderTransferOrder,
[ModelType.transferorderlineitem]: RenderTransferOrderLineItem,
[ModelType.stocklocation]: RenderStockLocation,
[ModelType.stocklocationtype]: RenderStockLocationType,
[ModelType.stockitem]: RenderStockItem,
[ModelType.stockhistory]: RenderStockItem,
[ModelType.supplierpart]: RenderSupplierPart,
[ModelType.user]: RenderUser,
[ModelType.group]: RenderGroup,
[ModelType.importsession]: RenderImportSession,
[ModelType.reporttemplate]: RenderReportTemplate,
[ModelType.labeltemplate]: RenderLabelTemplate,
[ModelType.pluginconfig]: RenderPlugin,
[ModelType.contenttype]: RenderContentType,
[ModelType.selectionlist]: RenderSelectionList,
[ModelType.selectionentry]: RenderSelectionEntry,
[ModelType.error]: RenderError,
[ModelType.tag]: RenderTag
};
import './ModelRenderShim';
/**
* Render an instance of a database model, depending on the provided data
*/
export function RenderInstance(props: RenderInstanceProps): ReactNode {
let RenderComponent:
| ((props: Readonly<InstanceRenderInterface>) => ReactNode)
| undefined;
// core model renderer
if (props.model !== undefined && props.custom_model === undefined) {
RenderComponent =
RendererLookup[props.model.toString().toLowerCase() as ModelType];
}
// custom model renderer (registered by a plugin) as a fallback to the core model renderer
RenderComponent ??= usePluginState().getRenderer(
props.custom_model ?? props.model ?? ''
);
const userSettings = useUserSettingsState();
// Extract model information from the defined model type
const modelInfo = useMemo(() => {
if (!props.model) {
@@ -151,6 +55,22 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode {
];
}, [props.model]);
let RenderComponent:
| ((props: Readonly<InstanceRenderInterface>) => ReactNode)
| undefined;
// core model renderer
if (props.model !== undefined && props.custom_model === undefined) {
RenderComponent = modelInfo?.render;
}
// custom model renderer (registered by a plugin) as a fallback to the core model renderer
RenderComponent ??= usePluginState().getRenderer(
props.custom_model ?? props.model ?? ''
);
const userSettings = useUserSettingsState();
const showHover: boolean = useMemo(() => {
if (!modelInfo) {
return false;
@@ -0,0 +1,80 @@
import { registerModelRenderers } from '@lib/enums/ModelInformation';
import { ModelType } from '@lib/enums/ModelType';
import { RenderBuildItem, RenderBuildLine, RenderBuildOrder } from './Build';
import {
RenderAddress,
RenderCompany,
RenderContact,
RenderManufacturerPart,
RenderSupplierPart
} from './Company';
import {
RenderContentType,
RenderError,
RenderImportSession,
RenderParameter,
RenderParameterTemplate,
RenderProjectCode,
RenderSelectionEntry,
RenderSelectionList,
RenderTag
} from './Generic';
import {
RenderPurchaseOrder,
RenderReturnOrder,
RenderReturnOrderLineItem,
RenderSalesOrder,
RenderSalesOrderShipment,
RenderTransferOrder,
RenderTransferOrderLineItem
} from './Order';
import { RenderPart, RenderPartCategory, RenderPartTestTemplate } from './Part';
import { RenderPlugin } from './Plugin';
import { RenderLabelTemplate, RenderReportTemplate } from './Report';
import {
RenderStockItem,
RenderStockLocation,
RenderStockLocationType
} from './Stock';
import { RenderGroup, RenderOwner, RenderUser } from './User';
registerModelRenderers({
[ModelType.address]: RenderAddress,
[ModelType.build]: RenderBuildOrder,
[ModelType.buildline]: RenderBuildLine,
[ModelType.builditem]: RenderBuildItem,
[ModelType.company]: RenderCompany,
[ModelType.contact]: RenderContact,
[ModelType.parameter]: RenderParameter,
[ModelType.parametertemplate]: RenderParameterTemplate,
[ModelType.manufacturerpart]: RenderManufacturerPart,
[ModelType.owner]: RenderOwner,
[ModelType.part]: RenderPart,
[ModelType.partcategory]: RenderPartCategory,
[ModelType.parttesttemplate]: RenderPartTestTemplate,
[ModelType.projectcode]: RenderProjectCode,
[ModelType.purchaseorder]: RenderPurchaseOrder,
[ModelType.purchaseorderlineitem]: RenderPurchaseOrder,
[ModelType.returnorder]: RenderReturnOrder,
[ModelType.returnorderlineitem]: RenderReturnOrderLineItem,
[ModelType.salesorder]: RenderSalesOrder,
[ModelType.salesordershipment]: RenderSalesOrderShipment,
[ModelType.transferorder]: RenderTransferOrder,
[ModelType.transferorderlineitem]: RenderTransferOrderLineItem,
[ModelType.stocklocation]: RenderStockLocation,
[ModelType.stocklocationtype]: RenderStockLocationType,
[ModelType.stockitem]: RenderStockItem,
[ModelType.stockhistory]: RenderStockItem,
[ModelType.supplierpart]: RenderSupplierPart,
[ModelType.user]: RenderUser,
[ModelType.group]: RenderGroup,
[ModelType.importsession]: RenderImportSession,
[ModelType.reporttemplate]: RenderReportTemplate,
[ModelType.labeltemplate]: RenderLabelTemplate,
[ModelType.pluginconfig]: RenderPlugin,
[ModelType.contenttype]: RenderContentType,
[ModelType.selectionlist]: RenderSelectionList,
[ModelType.selectionentry]: RenderSelectionEntry,
[ModelType.error]: RenderError,
[ModelType.tag]: RenderTag
});
@@ -0,0 +1,22 @@
import {
type RowAction,
RowViewAction,
type RowViewProps
} from '@lib/components/RowActions';
import { openGlobalPreview } from '../../states/PreviewDrawerState';
import { useUserSettingsState } from '../../states/SettingsStates';
/*
* An internal wrapper for the RowViewAction component, which adds support for the global preview panel.
*/
export function AppRowViewAction(props: RowViewProps): RowAction {
return RowViewAction({
...props,
isPreviewEnabled: () =>
useUserSettingsState.getState().isSet('ENABLE_PREVIEW_PANEL'),
openPreview: (modelType, modelId) => {
openGlobalPreview(modelType, modelId);
}
});
}
@@ -5,7 +5,7 @@ import { ModelInformationDict } from '@lib/enums/ModelInformation';
import { resolveItem } from '@lib/functions/Conversion';
import { cancelEvent } from '@lib/functions/Events';
import { mapFields } from '@lib/functions/Forms';
import { getDetailUrl } from '@lib/functions/Navigation';
import { eventModified, getDetailUrl } from '@lib/functions/Navigation';
import { navigateToLink } from '@lib/functions/Navigation';
import { useStoredTableState } from '@lib/states/StoredTableState';
import type { TableFilter } from '@lib/types/Filters';
@@ -37,6 +37,7 @@ import { useApi } from '../../contexts/ApiContext';
import { extractAvailableFields } from '../../functions/forms';
import { showApiErrorMessage } from '../../functions/notifications';
import { useLocalState } from '../../states/LocalState';
import { usePreviewDrawerState } from '../../states/PreviewDrawerState';
import { useUserSettingsState } from '../../states/SettingsStates';
import { ColumnFilterPopover } from './FilterSelectDrawer';
import InvenTreeTableHeader from './InvenTreeTableHeader';
@@ -105,6 +106,10 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
const userSettings = useUserSettingsState();
const showPreviewPanel = useMemo(() => {
return userSettings.isSet('ENABLE_PREVIEW_PANEL');
}, [userSettings]);
const stickyTableHeader = useMemo(() => {
return userSettings.isSet('STICKY_TABLE_HEADER');
}, [userSettings]);
@@ -700,6 +705,18 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
tableState.setRecords(tableData ?? apiData ?? []);
}, [tableData, apiData]);
const previewDrawer = usePreviewDrawerState();
// Callback to display "preview" view for a row (if available)
const showRowPreview = useCallback(
(pk: string | number) => {
if (tableProps.modelType && pk) {
previewDrawer.openPreview(tableProps.modelType, Number(pk));
}
},
[tableProps.modelType]
);
// Callback when a cell is clicked
const handleCellClick = useCallback(
({
@@ -732,11 +749,16 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
cancelEvent(event);
// If a model type is provided, navigate to the detail view for that model
const url = getDetailUrl(tableProps.modelType, pk);
navigateToLink(url, navigate, event);
if (!showPreviewPanel || eventModified(event as any)) {
navigateToLink(url, navigate, event);
} else {
showRowPreview(pk);
}
}
}
},
[props.onRowClick, props.onCellClick]
[props.onRowClick, props.onCellClick, showPreviewPanel]
);
const supportsContextMenu = useMemo(() => {
@@ -796,7 +818,11 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
icon: <IconArrowRight />,
onClick: (event: any) => {
cancelEvent(event);
navigateToLink(url, navigate, event);
if (eventModified(event as any)) {
navigateToLink(url, navigate, event);
} else {
showRowPreview(pk);
}
}
});
}
@@ -55,6 +55,7 @@ export default function UserSettings() {
'ICONS_IN_NAVBAR',
'STICKY_HEADER',
'STICKY_TABLE_HEADER',
'ENABLE_PREVIEW_PANEL',
'SHOW_SPOTLIGHT',
'BARCODE_IN_FORM_FIELDS',
'DATE_DISPLAY_FORMAT',
+10 -236
View File
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Alert, Grid, Skeleton, Stack, Text } from '@mantine/core';
import { Alert, Skeleton, Stack, Text } from '@mantine/core';
import {
IconChecklist,
IconCircleCheck,
@@ -21,19 +21,12 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import { TagsList } from '@lib/index';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel';
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 DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
CancelItemAction,
@@ -66,6 +59,7 @@ import BuildOutputTable from '../../tables/build/BuildOutputTable';
import PartTestResultTable from '../../tables/part/PartTestResultTable';
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { BuildOrderDetailsPanel } from './BuildOrderDetailsPanel';
function NoItems() {
return (
@@ -237,239 +231,19 @@ export default function BuildDetail() {
refetchOnMount: true
});
const { instance: partRequirements, instanceQuery: partRequirementsQuery } =
useInstance({
endpoint: ApiEndpoints.part_requirements,
pk: build?.part,
hasPrimaryKey: true,
defaultValue: {}
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const data = {
...build,
can_build: partRequirements?.can_build ?? 0
};
const tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Part`,
model: ModelType.part
},
{
type: 'text',
name: 'part_detail.IPN',
icon: 'part',
label: t`IPN`,
hidden: !build.part_detail?.IPN,
copy: true
},
{
type: 'string',
name: 'part_detail.revision',
icon: 'revision',
label: t`Revision`,
hidden: !build.part_detail?.revision,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.build
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.build,
icon: 'status',
hidden:
!build.status_custom_key || build.status_custom_key == build.status
},
{
type: 'boolean',
name: 'external',
label: t`External`,
icon: 'manufacturers',
hidden: !build.external
},
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'title',
label: t`Description`,
icon: 'description',
copy: true
},
{
type: 'link',
name: 'parent',
icon: 'builds',
label: t`Parent Build`,
model_field: 'reference',
model: ModelType.build,
hidden: !build.parent
}
];
const tr: DetailsField[] = [
{
type: 'number',
name: 'quantity',
label: t`Build Quantity`
},
{
type: 'number',
name: 'can_build',
unit: build.part_detail?.units,
label: t`Can Build`,
hidden: partRequirements?.can_build === undefined
},
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
total: build.quantity,
progress: build.completed,
label: t`Completed Outputs`
},
{
type: 'link',
name: 'sales_order',
label: t`Sales Order`,
icon: 'sales_orders',
model: ModelType.salesorder,
model_field: 'reference',
hidden: !build.sales_order
}
];
const bl: DetailsField[] = [
{
type: 'text',
name: 'issued_by',
label: t`Issued By`,
icon: 'user',
badge: 'user',
hidden: !build.issued_by
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !build.responsible
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !build.project_code
},
{
type: 'link',
name: 'take_from',
icon: 'location',
model: ModelType.stocklocation,
label: t`Source Location`,
backup_value: t`Any location`
},
{
type: 'link',
name: 'destination',
icon: 'location',
model: ModelType.stocklocation,
label: t`Destination Location`,
hidden: !build.destination
},
{
type: 'text',
name: 'batch',
label: t`Batch Code`,
hidden: !build.batch,
copy: true
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Created`,
icon: 'calendar',
copy: true,
hidden: !build.creation_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
copy: true,
hidden: !build.start_date
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
icon: 'calendar',
copy: true,
hidden: !build.target_date
},
{
type: 'date',
name: 'completion_date',
label: t`Completed`,
icon: 'calendar',
copy: true,
hidden: !build.completion_date
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
apiPath={ApiEndpoints.part_list}
src={build.part_detail?.image ?? build.part_detail?.thumbnail}
pk={build.part}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={build.tags} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
</ItemDetailsGrid>
);
}, [build, instanceQuery, partRequirements, partRequirementsQuery]);
const buildPanels: PanelType[] = useMemo(() => {
return [
{
name: 'details',
label: t`Build Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<BuildOrderDetailsPanel
instance={build}
allowImageEdit
refreshInstance={refreshInstance}
/>
)
},
{
name: 'line-items',
@@ -599,7 +373,7 @@ export default function BuildDetail() {
build,
id,
user,
partRequirements,
buildStatus,
globalSettings,
showChildBuilds,
@@ -0,0 +1,262 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { useMemo } from 'react';
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 { TagsList } from '@lib/index';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
import { useInstance } from '../../hooks/UseInstance';
export function BuildOrderDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
}>) {
const { instance: partRequirements } = useInstance({
endpoint: ApiEndpoints.part_requirements,
pk: instance?.part,
hasPrimaryKey: true,
defaultValue: {}
});
const data = useMemo(
() => ({ ...instance, can_build: partRequirements?.can_build ?? 0 }),
[instance, partRequirements]
);
const tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Part`,
model: ModelType.part
},
{
type: 'text',
name: 'part_detail.IPN',
icon: 'part',
label: t`IPN`,
hidden: !instance?.part_detail?.IPN,
copy: true
},
{
type: 'string',
name: 'part_detail.revision',
icon: 'revision',
label: t`Revision`,
hidden: !instance?.part_detail?.revision,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.build
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.build,
icon: 'status',
hidden:
!instance?.status_custom_key ||
instance?.status_custom_key == instance?.status
},
{
type: 'boolean',
name: 'external',
label: t`External`,
icon: 'manufacturers',
hidden: !instance?.external
},
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'title',
label: t`Description`,
icon: 'description',
copy: true
},
{
type: 'link',
name: 'parent',
icon: 'builds',
label: t`Parent Build`,
model_field: 'reference',
model: ModelType.build,
hidden: !instance?.parent
}
];
const tr: DetailsField[] = [
{
type: 'number',
name: 'quantity',
label: t`Build Quantity`
},
{
type: 'number',
name: 'can_build',
unit: instance?.part_detail?.units,
label: t`Can Build`,
hidden: partRequirements?.can_build === undefined
},
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
total: instance?.quantity,
progress: instance?.completed,
label: t`Completed Outputs`
},
{
type: 'link',
name: 'sales_order',
label: t`Sales Order`,
icon: 'sales_orders',
model: ModelType.salesorder,
model_field: 'reference',
hidden: !instance?.sales_order
}
];
const bl: DetailsField[] = [
{
type: 'text',
name: 'issued_by',
label: t`Issued By`,
icon: 'user',
badge: 'user',
hidden: !instance?.issued_by
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !instance?.responsible
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !instance?.project_code
},
{
type: 'link',
name: 'take_from',
icon: 'location',
model: ModelType.stocklocation,
label: t`Source Location`,
backup_value: t`Any location`
},
{
type: 'link',
name: 'destination',
icon: 'location',
model: ModelType.stocklocation,
label: t`Destination Location`,
hidden: !instance?.destination
},
{
type: 'text',
name: 'batch',
label: t`Batch Code`,
hidden: !instance?.batch,
copy: true
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Created`,
icon: 'calendar',
copy: true,
hidden: !instance?.creation_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.start_date
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.target_date
},
{
type: 'date',
name: 'completion_date',
label: t`Completed`,
icon: 'calendar',
copy: true,
hidden: !instance?.completion_date
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.build,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ fields: tr, item: data },
{ fields: bl, item: data },
{ fields: br, item: data },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.part : undefined}
apiPath={apiUrl(ApiEndpoints.part_list, instance?.part)}
src={
instance?.part_detail?.image ?? instance?.part_detail?.thumbnail
}
pk={instance?.part}
refresh={refreshInstance}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
);
}
+11 -104
View File
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { Skeleton, Stack } from '@mantine/core';
import {
IconBuildingWarehouse,
IconInfoCircle,
@@ -17,18 +17,10 @@ import { useNavigate, useParams } from 'react-router-dom';
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 { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
DeleteItemAction,
DuplicateItemAction,
@@ -58,6 +50,7 @@ import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable';
import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { CompanyDetailsPanel } from './CompanyDetailsPanel';
export type CompanyDetailProps = {
title: string;
@@ -87,101 +80,15 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
refetchOnMount: true
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const tl: DetailsField[] = [
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
name: 'website',
label: t`Website`,
external: true,
copy: true,
hidden: !company.website
},
{
type: 'text',
name: 'phone',
label: t`Phone Number`,
copy: true,
hidden: !company.phone
},
{
type: 'text',
name: 'email',
label: t`Email Address`,
copy: true,
hidden: !company.email
},
{
type: 'text',
name: 'tax_id',
label: t`Tax ID`,
copy: true,
hidden: !company.tax_id
}
];
const tr: DetailsField[] = [
{
type: 'string',
name: 'currency',
label: t`Default Currency`
},
{
type: 'boolean',
name: 'is_supplier',
label: t`Supplier`,
icon: 'suppliers'
},
{
type: 'boolean',
name: 'is_manufacturer',
label: t`Manufacturer`,
icon: 'manufacturers'
},
{
type: 'boolean',
name: 'is_customer',
label: t`Customer`,
icon: 'customers'
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={apiUrl(ApiEndpoints.company_list, company.pk)}
src={company.image}
pk={company.pk}
refresh={refreshInstance}
imageActions={{
uploadFile: true,
downloadImage: true,
deleteFile: true
}}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable item={company} fields={tl} />
</Grid.Col>
</Grid>
<TagsList tags={company.tags} />
</Stack>
<DetailsTable item={company} fields={tr} />
</ItemDetailsGrid>
);
}, [company, instanceQuery]);
const detailsPanel = instanceQuery.isFetching ? (
<Skeleton />
) : (
<CompanyDetailsPanel
instance={company}
allowImageEdit
refreshInstance={refreshInstance}
/>
);
const companyPanels: PanelType[] = useMemo(() => {
return [
@@ -0,0 +1,119 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { TagsList } from '@lib/index';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
export function CompanyDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
}>) {
const tl: DetailsField[] = [
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
name: 'website',
label: t`Website`,
external: true,
copy: true,
hidden: !instance?.website
},
{
type: 'text',
name: 'phone',
label: t`Phone Number`,
copy: true,
hidden: !instance?.phone
},
{
type: 'text',
name: 'email',
label: t`Email Address`,
copy: true,
hidden: !instance?.email
},
{
type: 'text',
name: 'tax_id',
label: t`Tax ID`,
copy: true,
hidden: !instance?.tax_id
}
];
const tr: DetailsField[] = [
{
type: 'string',
name: 'currency',
label: t`Default Currency`
},
{
type: 'boolean',
name: 'is_supplier',
label: t`Supplier`,
icon: 'suppliers'
},
{
type: 'boolean',
name: 'is_manufacturer',
label: t`Manufacturer`,
icon: 'manufacturers'
},
{
type: 'boolean',
name: 'is_customer',
label: t`Customer`,
icon: 'customers'
}
];
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid tables={[{ item: instance, fields: tr }]}>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.purchase_order : undefined}
apiPath={apiUrl(ApiEndpoints.company_list, instance.pk)}
src={instance.image}
pk={instance.pk}
refresh={refreshInstance}
imageActions={
allowImageEdit
? {
uploadFile: true,
downloadImage: true,
deleteFile: true
}
: {}
}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable item={instance} fields={tl} />
</Grid.Col>
</Grid>
<TagsList tags={instance.tags} />
</Stack>
</ItemDetailsGrid>
);
}
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { Skeleton, Stack } from '@mantine/core';
import {
IconBuildingWarehouse,
IconInfoCircle,
@@ -8,20 +8,12 @@ import {
import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import TagsList from '@lib/components/TagsList';
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 { getDetailUrl } from '@lib/functions/Navigation';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
DeleteItemAction,
DuplicateItemAction,
@@ -44,6 +36,7 @@ import { useInstance } from '../../hooks/UseInstance';
import { useUserState } from '../../states/UserState';
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { ManufacturerPartDetailsPanel } from './ManufacturerPartDetailsPanel';
export default function ManufacturerPartDetail() {
const { id } = useParams();
@@ -65,105 +58,19 @@ export default function ManufacturerPartDetail() {
}
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const data = manufacturerPart ?? {};
const tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Internal Part`,
model: ModelType.part,
hidden: !manufacturerPart.part
},
{
type: 'string',
name: 'part_detail.IPN',
label: t`IPN`,
copy: true,
icon: 'serial',
hidden: !data.part_detail?.IPN
},
{
type: 'string',
name: 'part_detail.description',
label: t`Description`,
copy: true,
icon: 'info',
hidden: !manufacturerPart.description
}
];
const tr: DetailsField[] = [
{
type: 'link',
name: 'manufacturer',
label: t`Manufacturer`,
icon: 'manufacturers',
model: ModelType.company,
hidden: !manufacturerPart.manufacturer
},
{
type: 'string',
name: 'MPN',
label: t`Manufacturer Part Number`,
copy: true,
hidden: !manufacturerPart.MPN,
icon: 'reference'
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true,
hidden: !manufacturerPart.description,
icon: 'info'
},
{
type: 'link',
external: true,
name: 'link',
label: t`External Link`,
copy: true,
hidden: !manufacturerPart.link
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
src={manufacturerPart?.part_detail?.image}
apiPath={apiUrl(
ApiEndpoints.part_list,
manufacturerPart?.part_detail?.pk
)}
pk={manufacturerPart?.part_detail?.pk}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={manufacturerPart.tags} />
</Stack>
<DetailsTable title={t`Manufacturer Details`} fields={tr} item={data} />
</ItemDetailsGrid>
);
}, [manufacturerPart, instanceQuery]);
const panels: PanelType[] = useMemo(() => {
return [
{
name: 'details',
label: t`Manufacturer Part Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<ManufacturerPartDetailsPanel
instance={manufacturerPart}
allowImageEdit
refreshInstance={refreshInstance}
/>
)
},
{
name: 'stock',
@@ -0,0 +1,119 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import TagsList from '@lib/components/TagsList';
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 {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
export function ManufacturerPartDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
}>) {
const tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Internal Part`,
model: ModelType.part,
hidden: !instance?.part
},
{
type: 'string',
name: 'part_detail.IPN',
label: t`IPN`,
copy: true,
icon: 'serial',
hidden: !instance?.part_detail?.IPN
},
{
type: 'string',
name: 'part_detail.description',
label: t`Description`,
copy: true,
icon: 'info',
hidden: !instance?.description
}
];
const tr: DetailsField[] = [
{
type: 'link',
name: 'manufacturer',
label: t`Manufacturer`,
icon: 'manufacturers',
model: ModelType.company,
hidden: !instance?.manufacturer
},
{
type: 'string',
name: 'MPN',
label: t`Manufacturer Part Number`,
copy: true,
hidden: !instance?.MPN,
icon: 'reference'
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true,
hidden: !instance?.description,
icon: 'info'
},
{
type: 'link',
external: true,
name: 'link',
label: t`External Link`,
copy: true,
hidden: !instance?.link
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.manufacturerpart,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ title: t`Manufacturer Details`, fields: tr, item: instance },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.part : undefined}
src={instance?.part_detail?.image}
apiPath={apiUrl(ApiEndpoints.part_list, instance?.part_detail?.pk)}
pk={instance?.part_detail?.pk}
refresh={refreshInstance}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable title={t`Part Details`} fields={tl} item={instance} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
);
}
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { Skeleton, Stack } from '@mantine/core';
import {
IconCurrencyDollar,
IconInfoCircle,
@@ -9,22 +9,14 @@ import {
import { type ReactNode, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import TagsList from '@lib/components/TagsList';
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 { formatDecimal } from '@lib/functions/Formatting';
import { getDetailUrl } from '@lib/functions/Navigation';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
DeleteItemAction,
@@ -49,6 +41,7 @@ import { useUserState } from '../../states/UserState';
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
import SupplierPriceBreakTable from '../../tables/purchasing/SupplierPriceBreakTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { SupplierPartDetailsPanel } from './SupplierPartDetailsPanel';
export default function SupplierPartDetail() {
const { id } = useParams();
@@ -73,187 +66,19 @@ export default function SupplierPartDetail() {
}
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const data = supplierPart ?? {};
// Access nested data
data.manufacturer =
supplierPart.manufacturer || data.manufacturer_detail?.pk;
data.MPN = supplierPart.MPN || data.manufacturer_part_detail?.MPN;
data.manufacturer_part =
supplierPart.manufacturer_part || data.manufacturer_part_detail?.pk;
const tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Internal Part`,
model: ModelType.part,
hidden: !supplierPart.part
},
{
type: 'string',
name: 'part_detail.IPN',
label: t`IPN`,
copy: true,
hidden: !data.part_detail?.IPN,
icon: 'serial'
},
{
type: 'string',
name: 'part_detail.description',
label: t`Part Description`,
copy: true,
icon: 'info',
hidden: !data.part_detail?.description
},
{
type: 'link',
external: true,
name: 'link',
label: t`External Link`,
copy: true,
hidden: !supplierPart.link
},
{
type: 'string',
name: 'note',
label: t`Note`,
copy: true,
hidden: !supplierPart.note
}
];
const bl: DetailsField[] = [
{
type: 'link',
name: 'supplier',
label: t`Supplier`,
model: ModelType.company,
icon: 'suppliers',
hidden: !supplierPart.supplier
},
{
type: 'string',
name: 'SKU',
label: t`SKU`,
copy: true,
icon: 'reference'
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true,
hidden: !data.description
},
{
type: 'link',
name: 'manufacturer',
label: t`Manufacturer`,
model: ModelType.company,
icon: 'manufacturers',
hidden: !data.manufacturer
},
{
type: 'link',
name: 'manufacturer_part',
model_field: 'MPN',
label: t`Manufacturer Part`,
model: ModelType.manufacturerpart,
icon: 'reference',
hidden: !data.manufacturer_part
}
];
const br: DetailsField[] = [
{
type: 'string',
name: 'packaging',
label: t`Packaging`,
copy: true,
hidden: !data.packaging
},
{
type: 'string',
name: 'pack_quantity',
label: t`Pack Quantity`,
copy: true,
hidden: !data.pack_quantity,
icon: 'packages'
}
];
const tr: DetailsField[] = [
{
type: 'number',
name: 'in_stock',
label: t`In Stock`,
copy: true,
icon: 'stock'
},
{
type: 'number',
name: 'on_order',
label: t`On Order`,
copy: true,
icon: 'purchase_orders'
},
{
type: 'number',
name: 'available',
label: t`Supplier Availability`,
hidden: !data.availability_updated,
copy: true,
icon: 'packages'
},
{
type: 'date',
name: 'availability_updated',
label: t`Availability Updated`,
copy: true,
hidden: !data.availability_updated,
icon: 'calendar'
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
src={supplierPart?.part_detail?.image}
apiPath={apiUrl(
ApiEndpoints.part_list,
supplierPart?.part_detail?.pk
)}
pk={supplierPart?.part_detail?.pk}
/>
<Grid.Col span={8}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={supplierPart.tags} />
</Stack>
<DetailsTable title={t`Supplier`} fields={bl} item={data} />
<DetailsTable title={t`Packaging`} fields={br} item={data} />
<DetailsTable title={t`Availability`} fields={tr} item={data} />
</ItemDetailsGrid>
);
}, [supplierPart, instanceQuery.isFetching]);
const panels: PanelType[] = useMemo(() => {
return [
{
name: 'details',
label: t`Supplier Part Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<SupplierPartDetailsPanel
instance={supplierPart}
allowImageEdit
refreshInstance={refreshInstance}
/>
)
},
{
name: 'stock',
@@ -0,0 +1,206 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { useMemo } from 'react';
import TagsList from '@lib/components/TagsList';
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 {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
export function SupplierPartDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
}>) {
const data = useMemo(() => {
if (!instance) return {};
return {
...instance,
manufacturer: instance.manufacturer || instance.manufacturer_detail?.pk,
MPN: instance.MPN || instance.manufacturer_part_detail?.MPN,
manufacturer_part:
instance.manufacturer_part || instance.manufacturer_part_detail?.pk
};
}, [instance]);
const tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Internal Part`,
model: ModelType.part,
hidden: !instance?.part
},
{
type: 'string',
name: 'part_detail.IPN',
label: t`IPN`,
copy: true,
hidden: !data.part_detail?.IPN,
icon: 'serial'
},
{
type: 'string',
name: 'part_detail.description',
label: t`Part Description`,
copy: true,
icon: 'info',
hidden: !data.part_detail?.description
},
{
type: 'link',
external: true,
name: 'link',
label: t`External Link`,
copy: true,
hidden: !instance?.link
},
{
type: 'string',
name: 'note',
label: t`Note`,
copy: true,
hidden: !instance?.note
}
];
const bl: DetailsField[] = [
{
type: 'link',
name: 'supplier',
label: t`Supplier`,
model: ModelType.company,
icon: 'suppliers',
hidden: !instance?.supplier
},
{
type: 'string',
name: 'SKU',
label: t`SKU`,
copy: true,
icon: 'reference'
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true,
hidden: !data.description
},
{
type: 'link',
name: 'manufacturer',
label: t`Manufacturer`,
model: ModelType.company,
icon: 'manufacturers',
hidden: !data.manufacturer
},
{
type: 'link',
name: 'manufacturer_part',
model_field: 'MPN',
label: t`Manufacturer Part`,
model: ModelType.manufacturerpart,
icon: 'reference',
hidden: !data.manufacturer_part
}
];
const br: DetailsField[] = [
{
type: 'string',
name: 'packaging',
label: t`Packaging`,
copy: true,
hidden: !data.packaging
},
{
type: 'string',
name: 'pack_quantity',
label: t`Pack Quantity`,
copy: true,
hidden: !data.pack_quantity,
icon: 'packages'
}
];
const tr: DetailsField[] = [
{
type: 'number',
name: 'in_stock',
label: t`In Stock`,
copy: true,
icon: 'stock'
},
{
type: 'number',
name: 'on_order',
label: t`On Order`,
copy: true,
icon: 'purchase_orders'
},
{
type: 'number',
name: 'available',
label: t`Supplier Availability`,
hidden: !data.availability_updated,
copy: true,
icon: 'packages'
},
{
type: 'date',
name: 'availability_updated',
label: t`Availability Updated`,
copy: true,
hidden: !data.availability_updated,
icon: 'calendar'
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.supplierpart,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ title: t`Supplier`, fields: bl, item: data },
{ title: t`Packaging`, fields: br, item: data },
{ title: t`Availability`, fields: tr, item: data },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.part : undefined}
src={instance?.part_detail?.image}
apiPath={apiUrl(ApiEndpoints.part_list, instance?.part_detail?.pk)}
pk={instance?.part_detail?.pk}
refresh={refreshInstance}
/>
<Grid.Col span={8}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
);
}
+4 -6
View File
@@ -7,10 +7,7 @@ import { Paper, Skeleton, Stack } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import type { DetailsField } from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {} from '../../components/items/ActionDropdown';
import { RoleTable, type RuleSet } from '../../components/items/RoleTable';
@@ -48,8 +45,9 @@ export default function GroupDetail() {
];
return (
<ItemDetailsGrid>
<DetailsTable fields={tl} item={instance} title={t`Group Details`} />
<ItemDetailsGrid
tables={[{ fields: tl, item: instance, title: t`Group Details` }]}
>
<Paper p='xs' withBorder>
<Stack gap='xs'>
<StylishText size='lg'>{t`Group Roles`}</StylishText>
+10 -11
View File
@@ -6,10 +6,7 @@ import { Badge, Group, Skeleton, Stack } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import type { DetailsField } from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
@@ -171,13 +168,15 @@ export default function UserDetail() {
instance.location;
return (
<ItemDetailsGrid>
<DetailsTable fields={tl} item={instance} title={t`User Information`} />
<DetailsTable fields={tr} item={instance} title={t`User Permissions`} />
{hasProfile && settings.isSet('DISPLAY_PROFILE_INFO') && (
<DetailsTable fields={br} item={instance} title={t`User Profile`} />
)}
</ItemDetailsGrid>
<ItemDetailsGrid
tables={[
{ fields: tl, item: instance, title: t`User Information` },
{ fields: tr, item: instance, title: t`User Permissions` },
...(hasProfile && settings.isSet('DISPLAY_PROFILE_INFO')
? [{ fields: br, item: instance, title: t`User Profile` }]
: [])
]}
/>
);
}, [instance, userGroups, instanceQuery]);
+3 -101
View File
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Group, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { LoadingOverlay, Stack } from '@mantine/core';
import {
IconCategory,
IconInfoCircle,
@@ -21,11 +21,6 @@ import type { PanelType } from '@lib/types/Panel';
import { useLocalStorage } from '@mantine/hooks';
import AdminButton from '../../components/buttons/AdminButton';
import StarredToggleButton from '../../components/buttons/StarredToggleButton';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
DeleteItemAction,
EditItemAction,
@@ -52,6 +47,7 @@ import { PartCategoryTable } from '../../tables/part/PartCategoryTable';
import PartCategoryTemplateTable from '../../tables/part/PartCategoryTemplateTable';
import { PartListTable } from '../../tables/part/PartTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { PartCategoryDetailsPanel } from './PartCategoryDetailsPanel';
/**
* Detail view for a single PartCategory instance.
@@ -106,100 +102,6 @@ export default function CategoryDetail() {
assign: false
});
const detailsPanel = useMemo(() => {
if (id && instanceQuery.isFetching) {
return <Skeleton />;
}
const left: DetailsField[] = [
{
type: 'text',
name: 'name',
label: t`Name`,
copy: true,
value_formatter: () => (
<Group gap='xs'>
{category.icon && <ApiIcon name={category.icon} />}
{category.name}
</Group>
)
},
{
type: 'text',
name: 'pathstring',
label: t`Path`,
icon: 'sitemap',
copy: true,
hidden: !id
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
name: 'parent',
model_field: 'name',
icon: 'location',
label: t`Parent Category`,
model: ModelType.partcategory,
hidden: !category?.parent
},
{
type: 'boolean',
name: 'starred',
icon: 'notification',
label: t`Subscribed`
}
];
const right: DetailsField[] = [
{
type: 'text',
name: 'part_count',
label: t`Parts`,
icon: 'part',
value_formatter: () => category?.part_count || '0'
},
{
type: 'text',
name: 'subcategories',
label: t`Subcategories`,
icon: 'sitemap',
hidden: !category?.subcategories
},
{
type: 'boolean',
name: 'structural',
label: t`Structural`,
icon: 'sitemap'
},
{
type: 'link',
name: 'parent_default_location',
label: t`Parent default location`,
model: ModelType.stocklocation,
hidden: !category.parent_default_location || category.default_location
},
{
type: 'link',
name: 'default_location',
label: t`Default location`,
model: ModelType.stocklocation,
hidden: !category.default_location
}
];
return (
<ItemDetailsGrid>
{id && category?.pk && <DetailsTable item={category} fields={left} />}
{id && category?.pk && <DetailsTable item={category} fields={right} />}
</ItemDetailsGrid>
);
}, [category, instanceQuery]);
const editCategory = useEditApiFormModal({
url: ApiEndpoints.category_list,
pk: id,
@@ -296,7 +198,7 @@ export default function CategoryDetail() {
name: 'details',
label: t`Category Details`,
icon: <IconInfoCircle />,
content: detailsPanel,
content: <PartCategoryDetailsPanel instance={category} />,
hidden: !id || !category?.pk
},
{
@@ -0,0 +1,121 @@
import { t } from '@lingui/core/macro';
import { Group, Skeleton } from '@mantine/core';
import { useMemo } from 'react';
import { ModelType } from '@lib/enums/ModelType';
import type { DetailsField } from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
import { ApiIcon } from '../../components/items/ApiIcon';
export function PartCategoryDetailsPanel({
instance
}: Readonly<{
instance: any;
}>) {
const left: DetailsField[] = useMemo(
() => [
{
type: 'text',
name: 'name',
label: t`Name`,
copy: true,
value_formatter: () => (
<Group gap='xs'>
{instance?.icon && <ApiIcon name={instance.icon} />}
{instance?.name}
</Group>
)
},
{
type: 'text',
name: 'pathstring',
label: t`Path`,
icon: 'sitemap',
copy: true,
hidden: !instance?.pathstring
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
name: 'parent',
model_field: 'name',
icon: 'location',
label: t`Parent Category`,
model: ModelType.partcategory,
hidden: !instance?.parent
},
{
type: 'boolean',
name: 'starred',
icon: 'notification',
label: t`Subscribed`
}
],
[instance]
);
const right: DetailsField[] = useMemo(
() => [
{
type: 'text',
name: 'part_count',
label: t`Parts`,
icon: 'part',
value_formatter: () => instance?.part_count || '0'
},
{
type: 'text',
name: 'subcategories',
label: t`Subcategories`,
icon: 'sitemap',
hidden: !instance?.subcategories
},
{
type: 'boolean',
name: 'structural',
label: t`Structural`,
icon: 'sitemap'
},
{
type: 'link',
name: 'parent_default_location',
label: t`Parent default location`,
model: ModelType.stocklocation,
hidden:
!instance?.parent_default_location || !!instance?.default_location
},
{
type: 'link',
name: 'default_location',
label: t`Default location`,
model: ModelType.stocklocation,
hidden: !instance?.default_location
}
],
[instance]
);
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.partcategory,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ item: instance, fields: left },
{ item: instance, fields: right },
parametersTable
]}
/>
);
}
+26 -414
View File
@@ -3,7 +3,6 @@ import {
ActionIcon,
Alert,
Center,
Grid,
Group,
Loader,
Paper,
@@ -41,7 +40,6 @@ import { type ReactNode, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Select from 'react-select';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@@ -52,13 +50,7 @@ import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import StarredToggleButton from '../../components/buttons/StarredToggleButton';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { Thumbnail } from '../../components/images/Thumbnail';
import {
ActionDropdown,
@@ -77,7 +69,7 @@ import { PanelGroup } from '../../components/panels/PanelGroup';
import { RenderPart } from '../../components/render/Part';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { useApi } from '../../contexts/ApiContext';
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
import { formatDecimal } from '../../defaults/formatters';
import { usePartFields } from '../../forms/PartForms';
import { useFindSerialNumberForm } from '../../forms/StockForms';
import {
@@ -106,6 +98,7 @@ import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
import PartAllocationPanel from './PartAllocationPanel';
import { PartDetailsPanel } from './PartDetailsPanel';
import PartPricingPanel from './PartPricingPanel';
import PartStockHistoryDetail from './PartStockHistoryDetail';
import PartSupplierDetail from './PartSupplierDetail';
@@ -175,14 +168,6 @@ export default function PartDetail() {
refetchOnMount: true
});
const { instance: serials } = useInstance({
endpoint: ApiEndpoints.part_serial_numbers,
pk: id,
hasPrimaryKey: true,
refetchOnMount: false,
defaultValue: {}
});
const {
instance: part,
refreshInstance,
@@ -275,403 +260,22 @@ export default function PartDetail() {
return partRevisionOptions.length > 0 && revisionsEnabled;
}, [partRevisionOptions, revisionsEnabled]);
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const data = { ...part };
const fetching =
partRequirementsQuery.isFetching || instanceQuery.isFetching;
// Copy part requirements data into the main part data
data.total_in_stock =
partRequirements?.total_stock ?? part?.total_in_stock ?? 0;
data.unallocated =
partRequirements?.unallocated_stock ?? part?.unallocated_stock ?? 0;
data.ordering = partRequirements?.ordering ?? part?.ordering ?? 0;
data.required =
(partRequirements?.required_for_build_orders ??
part?.required_for_build_orders ??
0) +
(partRequirements?.required_for_sales_orders ??
part?.required_for_sales_orders ??
0);
data.allocated =
(partRequirements?.allocated_to_build_orders ??
part?.allocated_to_build_orders ??
0) +
(partRequirements?.allocated_to_sales_orders ??
part?.allocated_to_sales_orders ??
0);
// Extract requirements data
data.can_build = partRequirements?.can_build ?? 0;
// Provide latest serial number info
if (!!serials.latest) {
data.latest_serial_number = serials.latest;
}
// Top left - core part information
const tl: DetailsField[] = [
{
type: 'string',
name: 'name',
label: t`Name`,
icon: 'part',
copy: true
},
{
type: 'string',
name: 'IPN',
label: t`IPN`,
copy: true,
hidden: !part.IPN
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
name: 'variant_of',
label: t`Variant of`,
model: ModelType.part,
model_field: 'full_name',
hidden: !part.variant_of
},
{
type: 'link',
name: 'revision_of',
label: t`Revision of`,
model: ModelType.part,
model_field: 'full_name',
hidden: !part.revision_of
},
{
type: 'string',
name: 'revision',
label: t`Revision`,
hidden: !part.revision,
copy: true
},
{
type: 'link',
name: 'category',
label: t`Category`,
model: ModelType.partcategory
},
{
type: 'link',
name: 'default_location',
label: t`Default Location`,
model: ModelType.stocklocation,
hidden: !part.default_location
},
{
type: 'link',
name: 'category_default_location',
label: t`Category Default Location`,
model: ModelType.stocklocation,
hidden: part.default_location || !part.category_default_location
},
{
type: 'string',
name: 'units',
label: t`Units`,
copy: true,
hidden: !part.units
},
{
type: 'string',
name: 'keywords',
label: t`Keywords`,
copy: true,
hidden: !part.keywords
},
{
type: 'link',
name: 'link',
label: t`Link`,
external: true,
copy: true,
hidden: !part.link
}
];
// Top right - stock availability information
const tr: DetailsField[] = [
{
type: 'number',
name: 'total_in_stock',
unit: part.units,
label: t`In Stock`,
hidden: part.virtual
},
{
type: 'progressbar',
name: 'unallocated_stock',
total: data.total_in_stock,
progress: data.unallocated,
label: t`Available Stock`,
hidden: part.virtual || data.total_in_stock == data.unallocated
},
{
type: 'number',
name: 'ordering',
label: t`On order`,
unit: part.units,
hidden: !part.purchaseable || part.ordering <= 0
},
{
type: 'number',
name: 'required',
label: t`Required for Orders`,
unit: part.units,
hidden: data.required <= 0,
icon: 'stocktake'
},
{
type: 'progressbar',
name: 'allocated_to_build_orders',
icon: 'manufacturers',
total: partRequirements.required_for_build_orders,
progress: partRequirements.allocated_to_build_orders,
label: t`Allocated to Build Orders`,
hidden:
fetching ||
(partRequirements.required_for_build_orders <= 0 &&
partRequirements.allocated_to_build_orders <= 0)
},
{
type: 'progressbar',
icon: 'sales_orders',
name: 'allocated_to_sales_orders',
total: partRequirements.required_for_sales_orders,
progress: partRequirements.allocated_to_sales_orders,
label: t`Allocated to Sales Orders`,
hidden:
fetching ||
(partRequirements.required_for_sales_orders <= 0 &&
partRequirements.allocated_to_sales_orders <= 0)
},
{
type: 'progressbar',
name: 'building',
label: t`In Production`,
progress: partRequirements.building,
total: partRequirements.scheduled_to_build,
hidden:
fetching ||
(!partRequirements.building && !partRequirements.scheduled_to_build)
},
{
type: 'number',
name: 'can_build',
unit: part.units,
label: t`Can Build`,
hidden: !part.assembly || fetching
},
{
type: 'number',
name: 'minimum_stock',
unit: part.units,
label: t`Minimum Stock`,
hidden: part.minimum_stock <= 0
},
{
type: 'number',
name: 'maximum_stock',
unit: part.units,
label: t`Maximum Stock`,
hidden: part.maximum_stock <= 0
}
];
// Bottom left - part attributes
const bl: DetailsField[] = [
{
type: 'boolean',
name: 'active',
label: t`Active`
},
{
type: 'boolean',
name: 'locked',
label: t`Locked`
},
{
type: 'boolean',
icon: 'template',
name: 'is_template',
label: t`Template Part`
},
{
type: 'boolean',
name: 'assembly',
label: t`Assembled Part`
},
{
type: 'boolean',
name: 'component',
label: t`Component Part`
},
{
type: 'boolean',
name: 'testable',
label: t`Testable Part`,
icon: 'test'
},
{
type: 'boolean',
name: 'trackable',
label: t`Trackable Part`
},
{
type: 'boolean',
name: 'purchaseable',
label: t`Purchaseable Part`
},
{
type: 'boolean',
name: 'salable',
icon: 'saleable',
label: t`Saleable Part`
},
{
type: 'boolean',
name: 'virtual',
label: t`Virtual Part`
},
{
type: 'boolean',
name: 'consumable',
label: t`Consumable Part`
},
{
type: 'boolean',
name: 'starred',
label: t`Subscribed`,
icon: 'bell'
}
];
// Bottom right - other part information
const br: DetailsField[] = [
{
type: 'string',
name: 'creation_date',
label: t`Creation Date`
},
{
type: 'string',
name: 'creation_user',
label: t`Created By`,
badge: 'user',
icon: 'user',
hidden: !part.creation_user
},
{
type: 'string',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !part.responsible
},
{
name: 'default_expiry',
label: t`Default Expiry`,
hidden: !part.default_expiry,
icon: 'calendar',
type: 'string',
value_formatter: () => {
return `${part.default_expiry} ${t`days`}`;
}
}
];
// Add in price range data
if (part.pricing_min || part.pricing_max) {
br.push({
type: 'string',
name: 'pricing',
label: t`Price Range`,
value_formatter: () => {
return formatPriceRange(part.pricing_min, part.pricing_max);
}
});
}
br.push({
type: 'string',
name: 'latest_serial_number',
label: t`Latest Serial Number`,
hidden: !part.trackable || !data.latest_serial_number,
icon: 'serial'
});
return part ? (
<ItemDetailsGrid>
const revisionSelector = useMemo(() => {
if (!enableRevisionSelection) return null;
return (
<Paper p='sm' withBorder>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
imageActions={{
selectExisting: true,
downloadImage: true,
uploadFile: true,
deleteFile: true
}}
src={part.image}
thumbnail={part.thumbnail}
apiPath={apiUrl(ApiEndpoints.part_list, part.pk)}
refresh={refreshInstance}
pk={part.pk}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={part.tags} />
{enableRevisionSelection && (
<Paper p='sm' withBorder>
<Stack gap='xs'>
<Group gap='xs'>
<ActionIcon variant='transparent'>
<IconVersions />
</ActionIcon>
<Text>{t`Select Part Revision`}</Text>
</Group>
<RevisionSelector part={part} options={partRevisionOptions} />
</Stack>
</Paper>
)}
<Group gap='xs'>
<ActionIcon variant='transparent'>
<IconVersions />
</ActionIcon>
<Text>{t`Select Part Revision`}</Text>
</Group>
<RevisionSelector part={part} options={partRevisionOptions} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
</ItemDetailsGrid>
) : (
<Skeleton />
</Paper>
);
}, [
globalSettings,
part,
id,
serials,
instanceQuery.isFetching,
instanceQuery.data,
enableRevisionSelection,
partRevisionOptions,
partRequirementsQuery.isFetching,
partRequirements
]);
}, [enableRevisionSelection, part, partRevisionOptions]);
// Part data panels (recalculate when part data changes)
const partPanels: PanelType[] = useMemo(() => {
@@ -680,7 +284,14 @@ export default function PartDetail() {
name: 'details',
label: t`Part Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<PartDetailsPanel
instance={part}
allowImageEdit
refreshInstance={refreshInstance}
additionalContent={revisionSelector}
/>
)
},
{
name: 'stock',
@@ -908,8 +519,9 @@ export default function PartDetail() {
user,
globalSettings,
userSettings,
detailsPanel,
bomInformation
bomInformation,
revisionSelector,
refreshInstance
]);
const breadcrumbs = useMemo(() => {
@@ -0,0 +1,370 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { type ReactNode, useMemo } from 'react';
import TagsList from '@lib/components/TagsList';
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 {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
import { formatPriceRange } from '../../defaults/formatters';
import { useInstance } from '../../hooks/UseInstance';
export function PartDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance,
additionalContent
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
additionalContent?: ReactNode;
}>) {
const { instance: requirements, instanceQuery: requirementsQuery } =
useInstance({
endpoint: ApiEndpoints.part_requirements,
pk: instance?.pk,
hasPrimaryKey: true,
disabled: !instance?.pk,
defaultValue: {}
});
const { instance: serials } = useInstance({
endpoint: ApiEndpoints.part_serial_numbers,
pk: instance?.pk,
hasPrimaryKey: true,
disabled: !instance?.pk,
defaultValue: {}
});
const data = useMemo(() => {
if (!instance) return {};
const d = { ...instance };
d.total_in_stock =
requirements?.total_stock ?? instance?.total_in_stock ?? 0;
d.unallocated =
requirements?.unallocated_stock ?? instance?.unallocated_stock ?? 0;
d.ordering = requirements?.ordering ?? instance?.ordering ?? 0;
d.required =
(requirements?.required_for_build_orders ??
instance?.required_for_build_orders ??
0) +
(requirements?.required_for_sales_orders ??
instance?.required_for_sales_orders ??
0);
d.allocated =
(requirements?.allocated_to_build_orders ??
instance?.allocated_to_build_orders ??
0) +
(requirements?.allocated_to_sales_orders ??
instance?.allocated_to_sales_orders ??
0);
d.can_build = requirements?.can_build ?? 0;
if (serials?.latest) d.latest_serial_number = serials.latest;
return d;
}, [instance, requirements, serials]);
const fetching = requirementsQuery?.isFetching ?? false;
const tl: DetailsField[] = [
{ type: 'string', name: 'name', label: t`Name`, icon: 'part', copy: true },
{
type: 'string',
name: 'IPN',
label: t`IPN`,
copy: true,
hidden: !instance?.IPN
},
{ type: 'string', name: 'description', label: t`Description`, copy: true },
{
type: 'link',
name: 'variant_of',
label: t`Variant of`,
model: ModelType.part,
model_field: 'full_name',
hidden: !instance?.variant_of
},
{
type: 'link',
name: 'revision_of',
label: t`Revision of`,
model: ModelType.part,
model_field: 'full_name',
hidden: !instance?.revision_of
},
{
type: 'string',
name: 'revision',
label: t`Revision`,
hidden: !instance?.revision,
copy: true
},
{
type: 'link',
name: 'category',
label: t`Category`,
model: ModelType.partcategory
},
{
type: 'link',
name: 'default_location',
label: t`Default Location`,
model: ModelType.stocklocation,
hidden: !instance?.default_location
},
{
type: 'link',
name: 'category_default_location',
label: t`Category Default Location`,
model: ModelType.stocklocation,
hidden: instance?.default_location || !instance?.category_default_location
},
{
type: 'string',
name: 'units',
label: t`Units`,
copy: true,
hidden: !instance?.units
},
{
type: 'string',
name: 'keywords',
label: t`Keywords`,
copy: true,
hidden: !instance?.keywords
},
{
type: 'link',
name: 'link',
label: t`Link`,
external: true,
copy: true,
hidden: !instance?.link
}
];
const tr: DetailsField[] = [
{
type: 'number',
name: 'total_in_stock',
unit: instance?.units,
label: t`In Stock`,
hidden: instance?.virtual
},
{
type: 'progressbar',
name: 'unallocated_stock',
total: data.total_in_stock,
progress: data.unallocated,
label: t`Available Stock`,
hidden: instance?.virtual || data.total_in_stock == data.unallocated
},
{
type: 'number',
name: 'ordering',
label: t`On Order`,
unit: instance?.units,
hidden: !instance?.purchaseable || instance?.ordering <= 0
},
{
type: 'number',
name: 'required',
label: t`Required for Orders`,
unit: instance?.units,
hidden: data.required <= 0,
icon: 'stocktake'
},
{
type: 'progressbar',
name: 'allocated_to_build_orders',
icon: 'manufacturers',
total: requirements?.required_for_build_orders,
progress: requirements?.allocated_to_build_orders,
label: t`Allocated to Build Orders`,
hidden:
fetching ||
(requirements?.required_for_build_orders <= 0 &&
requirements?.allocated_to_build_orders <= 0)
},
{
type: 'progressbar',
icon: 'sales_orders',
name: 'allocated_to_sales_orders',
total: requirements?.required_for_sales_orders,
progress: requirements?.allocated_to_sales_orders,
label: t`Allocated to Sales Orders`,
hidden:
fetching ||
(requirements?.required_for_sales_orders <= 0 &&
requirements?.allocated_to_sales_orders <= 0)
},
{
type: 'progressbar',
name: 'building',
label: t`In Production`,
progress: requirements?.building,
total: requirements?.scheduled_to_build,
hidden:
fetching ||
(!requirements?.building && !requirements?.scheduled_to_build)
},
{
type: 'number',
name: 'can_build',
unit: instance?.units,
label: t`Can Build`,
hidden: !instance?.assembly || fetching
},
{
type: 'number',
name: 'minimum_stock',
unit: instance?.units,
label: t`Minimum Stock`,
hidden: instance?.minimum_stock <= 0
},
{
type: 'number',
name: 'maximum_stock',
unit: instance?.units,
label: t`Maximum Stock`,
hidden: instance?.maximum_stock <= 0
}
];
const bl: DetailsField[] = [
{ type: 'boolean', name: 'active', label: t`Active` },
{ type: 'boolean', name: 'locked', label: t`Locked` },
{
type: 'boolean',
icon: 'template',
name: 'is_template',
label: t`Template Part`
},
{ type: 'boolean', name: 'assembly', label: t`Assembled Part` },
{ type: 'boolean', name: 'component', label: t`Component Part` },
{
type: 'boolean',
name: 'testable',
label: t`Testable Part`,
icon: 'test'
},
{ type: 'boolean', name: 'trackable', label: t`Trackable Part` },
{ type: 'boolean', name: 'purchaseable', label: t`Purchaseable Part` },
{
type: 'boolean',
name: 'salable',
icon: 'saleable',
label: t`Saleable Part`
},
{ type: 'boolean', name: 'virtual', label: t`Virtual Part` },
{ type: 'boolean', name: 'starred', label: t`Subscribed`, icon: 'bell' }
];
const br: DetailsField[] = useMemo(() => {
const fields: DetailsField[] = [
{
type: 'string',
name: 'creation_date',
label: t`Creation Date`
},
{
type: 'string',
name: 'creation_user',
label: t`Created By`,
badge: 'user',
icon: 'user',
hidden: !instance?.creation_user
},
{
type: 'string',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !instance?.responsible
},
{
name: 'default_expiry',
label: t`Default Expiry`,
hidden: !instance?.default_expiry,
icon: 'calendar',
type: 'string',
value_formatter: () => `${instance?.default_expiry} ${t`days`}`
}
];
if (instance?.pricing_min || instance?.pricing_max) {
fields.push({
type: 'string',
name: 'pricing',
label: t`Price Range`,
value_formatter: () =>
formatPriceRange(instance?.pricing_min, instance?.pricing_max)
});
}
fields.push({
type: 'string',
name: 'latest_serial_number',
label: t`Latest Serial Number`,
hidden: !instance?.trackable || !data.latest_serial_number,
icon: 'serial'
});
return fields;
}, [instance, data.latest_serial_number]);
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.part,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ fields: tr, item: data },
{ fields: bl, item: data },
{ fields: br, item: data },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.part : undefined}
imageActions={
allowImageEdit
? {
selectExisting: true,
downloadImage: true,
uploadFile: true,
deleteFile: true
}
: {}
}
src={instance.image}
thumbnail={instance.thumbnail}
apiPath={apiUrl(ApiEndpoints.part_list, instance.pk)}
refresh={refreshInstance}
pk={instance.pk}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={instance.tags} />
{additionalContent}
</Stack>
</ItemDetailsGrid>
);
}
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Accordion, Grid, Skeleton, Stack } from '@mantine/core';
import { Accordion, Stack } from '@mantine/core';
import { IconInfoCircle, IconList, IconPackages } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
@@ -9,17 +9,10 @@ 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 { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
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 { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
CancelItemAction,
@@ -35,7 +28,6 @@ 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 { formatCurrency } from '../../defaults/formatters';
import { usePurchaseOrderFields } from '../../forms/PurchaseOrderForms';
import {
useCreateApiFormModal,
@@ -48,6 +40,7 @@ import { useUserState } from '../../states/UserState';
import ExtraLineItemTable from '../../tables/general/ExtraLineItemTable';
import { PurchaseOrderLineItemTable } from '../../tables/purchasing/PurchaseOrderLineItemTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { PurchaseOrderDetailsPanel } from './PurchaseOrderDetailsPanel';
/**
* Detail page for a single PurchaseOrder
@@ -72,13 +65,13 @@ export default function PurchaseOrderDetail() {
refetchOnMount: true
});
const orderCurrency = useMemo(() => {
return (
const orderCurrency = useMemo(
() =>
order.order_currency ||
order.supplier_detail?.currency ||
globalSettings.getSetting('INVENTREE_DEFAULT_CURRENCY')
);
}, [order, globalSettings]);
globalSettings.getSetting('INVENTREE_DEFAULT_CURRENCY'),
[order, globalSettings]
);
const purchaseOrderFields = usePurchaseOrderFields({});
@@ -133,222 +126,19 @@ export default function PurchaseOrderDetail() {
modelType: ModelType.purchaseorder
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'supplier_reference',
label: t`Supplier Reference`,
icon: 'reference',
hidden: !order.supplier_reference,
copy: true
},
{
type: 'link',
name: 'supplier',
icon: 'suppliers',
label: t`Supplier`,
model: ModelType.company
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.purchaseorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.purchaseorder,
icon: 'status',
hidden:
!order.status_custom_key || order.status_custom_key == order.status
}
];
const tr: DetailsField[] = [
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: order.line_items,
progress: order.completed_lines
},
{
type: 'link',
model: ModelType.stocklocation,
link: true,
name: 'destination',
label: t`Destination`,
hidden: !order.destination
},
{
type: 'text',
name: 'currency',
label: t`Order Currency`,
value_formatter: () => orderCurrency
},
{
type: 'text',
name: 'total_price',
label: t`Total Cost`,
value_formatter: () => {
return formatCurrency(order?.total_price, {
currency: order?.order_currency || order?.supplier_detail?.currency
});
}
}
];
const bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !order.link
},
{
type: 'text',
name: 'contact_detail.name',
label: t`Contact`,
icon: 'user',
copy: true,
hidden: !order.contact
},
{
type: 'text',
name: 'contact_detail.email',
label: t`Contact Email`,
icon: 'email',
copy: true,
hidden: !order.contact_detail?.email
},
{
type: 'text',
name: 'contact_detail.phone',
label: t`Contact Phone`,
icon: 'phone',
copy: true,
hidden: !order.contact_detail?.phone
},
{
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`,
copy: true,
icon: 'calendar'
},
{
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`,
icon: 'calendar',
copy: true,
hidden: !order.target_date
},
{
type: 'date',
name: 'complete_date',
icon: 'calendar_check',
label: t`Completion Date`,
copy: true,
hidden: !order.complete_date
},
{
type: 'date',
name: 'updated_at',
label: t`Last Updated`,
icon: 'calendar',
copy: true,
showTime: true,
hidden: !order.updated_at
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.supplier_detail?.image}
pk={order.supplier}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
</ItemDetailsGrid>
);
}, [order, orderCurrency, instanceQuery]);
const orderPanels: PanelType[] = useMemo(() => {
return [
{
name: 'detail',
label: t`Order Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<PurchaseOrderDetailsPanel
instance={order}
allowImageEdit
refreshInstance={refreshInstance}
/>
)
},
{
name: 'line-items',
@@ -0,0 +1,258 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { useMemo } from 'react';
import TagsList from '@lib/components/TagsList';
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 {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
import { formatCurrency } from '../../defaults/formatters';
import { useGlobalSettingsState } from '../../states/SettingsStates';
export function PurchaseOrderDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
}>) {
const globalSettings = useGlobalSettingsState();
const orderCurrency = useMemo(
() =>
instance?.order_currency ||
instance?.supplier_detail?.currency ||
globalSettings.getSetting('INVENTREE_DEFAULT_CURRENCY'),
[instance, globalSettings]
);
const tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'supplier_reference',
label: t`Supplier Reference`,
icon: 'reference',
hidden: !instance?.supplier_reference,
copy: true
},
{
type: 'link',
name: 'supplier',
icon: 'suppliers',
label: t`Supplier`,
model: ModelType.company
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.purchaseorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.purchaseorder,
icon: 'status',
hidden:
!instance?.status_custom_key ||
instance?.status_custom_key == instance?.status
}
];
const tr: DetailsField[] = [
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: instance?.line_items,
progress: instance?.completed_lines
},
{
type: 'link',
model: ModelType.stocklocation,
link: true,
name: 'destination',
label: t`Destination`,
hidden: !instance?.destination
},
{
type: 'text',
name: 'currency',
label: t`Order Currency`,
value_formatter: () => orderCurrency
},
{
type: 'text',
name: 'total_price',
label: t`Total Cost`,
value_formatter: () =>
formatCurrency(instance?.total_price, {
currency:
instance?.order_currency || instance?.supplier_detail?.currency
})
}
];
const bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !instance?.link
},
{
type: 'text',
name: 'contact_detail.name',
label: t`Contact`,
icon: 'user',
copy: true,
hidden: !instance?.contact
},
{
type: 'text',
name: 'contact_detail.email',
label: t`Contact Email`,
icon: 'email',
copy: true,
hidden: !instance?.contact_detail?.email
},
{
type: 'text',
name: 'contact_detail.phone',
label: t`Contact Phone`,
icon: 'phone',
copy: true,
hidden: !instance?.contact_detail?.phone
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !instance?.project_code
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !instance?.responsible
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Creation Date`,
copy: true,
icon: 'calendar'
},
{
type: 'date',
name: 'issue_date',
label: t`Issue Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.issue_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.start_date
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.target_date
},
{
type: 'date',
name: 'complete_date',
icon: 'calendar_check',
label: t`Completion Date`,
copy: true,
hidden: !instance?.complete_date
},
{
type: 'date',
name: 'updated_at',
label: t`Last Updated`,
icon: 'calendar',
copy: true,
showTime: true,
hidden: !instance?.updated_at
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.purchaseorder,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ fields: tr, item: instance },
{ fields: bl, item: instance },
{ fields: br, item: instance },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.purchase_order : undefined}
imageActions={
allowImageEdit ? { uploadFile: true, deleteFile: true } : {}
}
apiPath={apiUrl(ApiEndpoints.company_list, instance?.supplier)}
src={instance?.supplier_detail?.image}
pk={instance?.supplier}
refresh={refreshInstance}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={instance} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
);
}
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Accordion, Grid, Skeleton, Stack, Text } from '@mantine/core';
import { Accordion, Stack } from '@mantine/core';
import { IconInfoCircle, IconList } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
@@ -9,17 +9,10 @@ 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 { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
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 { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
CancelItemAction,
@@ -34,9 +27,7 @@ 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 { RenderAddress } from '../../components/render/Company';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { formatCurrency } from '../../defaults/formatters';
import { useReturnOrderFields } from '../../forms/ReturnOrderForms';
import {
useCreateApiFormModal,
@@ -48,6 +39,7 @@ import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import ExtraLineItemTable from '../../tables/general/ExtraLineItemTable';
import ReturnOrderLineItemTable from '../../tables/sales/ReturnOrderLineItemTable';
import { ReturnOrderDetailsPanel } from './ReturnOrderDetailsPanel';
/**
* Detail page for a single ReturnOrder
@@ -99,233 +91,19 @@ export default function ReturnOrderDetail() {
);
}, [order, globalSettings]);
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'customer_reference',
label: t`Customer Reference`,
icon: 'customer',
copy: true,
hidden: !order.customer_reference
},
{
type: 'link',
name: 'customer',
icon: 'customers',
label: t`Customer`,
model: ModelType.company
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.returnorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.returnorder,
icon: 'status',
hidden:
!order.status_custom_key || order.status_custom_key == order.status
}
];
const tr: DetailsField[] = [
{
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
},
{
type: 'text',
name: 'currency',
label: t`Order Currency`,
value_formatter: () =>
order?.order_currency ?? order?.customer_detail?.currency
},
{
type: 'text',
name: 'total_price',
label: t`Total Cost`,
value_formatter: () => {
return formatCurrency(order?.total_price, {
currency: order?.order_currency || order?.customer_detail?.currency
});
}
}
];
const bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !order.link
},
{
type: 'text',
name: 'address',
label: t`Return Address`,
icon: 'address',
value_formatter: () =>
order.address_detail ? (
<RenderAddress instance={order.address_detail} />
) : (
<Text size='sm' c='red'>{t`Not specified`}</Text>
)
},
{
type: 'text',
name: 'contact_detail.name',
label: t`Contact`,
icon: 'user',
copy: true,
hidden: !order.contact
},
{
type: 'text',
name: 'contact_detail.email',
label: t`Contact Email`,
icon: 'email',
copy: true,
hidden: !order.contact_detail?.email
},
{
type: 'text',
name: 'contact_detail.phone',
label: t`Contact Phone`,
icon: 'phone',
copy: true,
hidden: !order.contact_detail?.phone
},
{
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
},
{
type: 'date',
name: 'updated_at',
label: t`Last Updated`,
icon: 'calendar',
copy: true,
showTime: true,
hidden: !order.updated_at
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.customer_detail?.image}
pk={order.customer}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<TagsList tags={order.tags} />
</Stack>
<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
content: (
<ReturnOrderDetailsPanel
instance={order}
allowImageEdit
refreshInstance={refreshInstance}
/>
)
},
{
name: 'line-items',
@@ -0,0 +1,270 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack, Text } from '@mantine/core';
import { useMemo } from 'react';
import TagsList from '@lib/components/TagsList';
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 {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
import { RenderAddress } from '../../components/render/Company';
import { formatCurrency } from '../../defaults/formatters';
import { useGlobalSettingsState } from '../../states/SettingsStates';
export function ReturnOrderDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
}>) {
const globalSettings = useGlobalSettingsState();
const orderCurrency = useMemo(
() =>
instance?.order_currency ||
instance?.customer_detail?.currency ||
globalSettings.getSetting('INVENTREE_DEFAULT_CURRENCY'),
[instance, globalSettings]
);
const tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'customer_reference',
label: t`Customer Reference`,
icon: 'customer',
copy: true,
hidden: !instance?.customer_reference
},
{
type: 'link',
name: 'customer',
icon: 'customers',
label: t`Customer`,
model: ModelType.company
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.returnorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.returnorder,
icon: 'status',
hidden:
!instance?.status_custom_key ||
instance?.status_custom_key == instance?.status
}
];
const tr: DetailsField[] = [
{
type: 'text',
name: 'line_items',
label: t`Line Items`,
icon: 'list'
},
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: instance?.line_items,
progress: instance?.completed_lines
},
{
type: 'text',
name: 'currency',
label: t`Order Currency`,
value_formatter: () =>
instance?.order_currency ?? instance?.customer_detail?.currency
},
{
type: 'text',
name: 'total_price',
label: t`Total Cost`,
value_formatter: () =>
formatCurrency(instance?.total_price, {
currency:
instance?.order_currency || instance?.customer_detail?.currency
})
}
];
const bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !instance?.link
},
{
type: 'text',
name: 'address',
label: t`Return Address`,
icon: 'address',
value_formatter: () =>
instance?.address_detail ? (
<RenderAddress instance={instance.address_detail} />
) : (
<Text size='sm' c='red'>{t`Not specified`}</Text>
)
},
{
type: 'text',
name: 'contact_detail.name',
label: t`Contact`,
icon: 'user',
copy: true,
hidden: !instance?.contact
},
{
type: 'text',
name: 'contact_detail.email',
label: t`Contact Email`,
icon: 'email',
copy: true,
hidden: !instance?.contact_detail?.email
},
{
type: 'text',
name: 'contact_detail.phone',
label: t`Contact Phone`,
icon: 'phone',
copy: true,
hidden: !instance?.contact_detail?.phone
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !instance?.project_code
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !instance?.responsible
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Creation Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.creation_date
},
{
type: 'date',
name: 'issue_date',
label: t`Issue Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.issue_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.start_date
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
copy: true,
hidden: !instance?.target_date
},
{
type: 'date',
name: 'complete_date',
icon: 'calendar_check',
label: t`Completion Date`,
copy: true,
hidden: !instance?.complete_date
},
{
type: 'date',
name: 'updated_at',
label: t`Last Updated`,
icon: 'calendar',
copy: true,
showTime: true,
hidden: !instance?.updated_at
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.returnorder,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ fields: tr, item: instance },
{ fields: bl, item: instance },
{ fields: br, item: instance },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.return_order : undefined}
imageActions={
allowImageEdit ? { uploadFile: true, deleteFile: true } : {}
}
apiPath={apiUrl(ApiEndpoints.company_list, instance?.customer)}
src={instance?.customer_detail?.image}
pk={instance?.customer}
refresh={refreshInstance}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={instance} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
);
}
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Accordion, Grid, Skeleton, Stack, Text } from '@mantine/core';
import { Accordion, Skeleton, Stack } from '@mantine/core';
import {
IconBookmark,
IconCubeSend,
@@ -11,7 +11,6 @@ import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { StylishText } from '@lib/components/StylishText';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@@ -20,12 +19,6 @@ import type { PanelType } from '@lib/types/Panel';
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 { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
CancelItemAction,
@@ -40,9 +33,7 @@ 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 { RenderAddress } from '../../components/render/Company';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { formatCurrency } from '../../defaults/formatters';
import { useSalesOrderFields } from '../../forms/SalesOrderForms';
import {
useCreateApiFormModal,
@@ -57,6 +48,7 @@ import ExtraLineItemTable from '../../tables/general/ExtraLineItemTable';
import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable';
import SalesOrderLineItemTable from '../../tables/sales/SalesOrderLineItemTable';
import SalesOrderShipmentTable from '../../tables/sales/SalesOrderShipmentTable';
import { SalesOrderDetailsPanel } from './SalesOrderDetailsPanel';
/**
* Detail page for a single SalesOrder
@@ -89,227 +81,6 @@ export default function SalesOrderDetail() {
);
}, [order, globalSettings]);
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'customer_reference',
label: t`Customer Reference`,
copy: true,
icon: 'reference',
hidden: !order.customer_reference
},
{
type: 'link',
name: 'customer',
icon: 'customers',
label: t`Customer`,
model: ModelType.company
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.salesorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.salesorder,
icon: 'status',
hidden:
!order.status_custom_key || order.status_custom_key == order.status
}
];
const tr: DetailsField[] = [
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: order.line_items,
progress: order.completed_lines,
hidden: !order.line_items
},
{
type: 'progressbar',
name: 'shipments',
icon: 'shipment',
label: t`Completed Shipments`,
total: order.shipments_count,
progress: order.completed_shipments_count,
hidden: !order.shipments_count
},
{
type: 'text',
name: 'currency',
label: t`Order Currency`,
value_formatter: () => orderCurrency
},
{
type: 'text',
name: 'total_price',
label: t`Total Cost`,
value_formatter: () => {
return formatCurrency(order?.total_price, {
currency: orderCurrency
});
}
}
];
const bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !order.link
},
{
type: 'text',
name: 'address',
label: t`Shipping Address`,
icon: 'address',
value_formatter: () =>
order.address_detail ? (
<RenderAddress instance={order.address_detail} />
) : (
<Text size='sm' c='red'>{t`Not specified`}</Text>
)
},
{
type: 'text',
name: 'contact_detail.name',
label: t`Contact`,
icon: 'user',
copy: true,
hidden: !order.contact
},
{
type: 'text',
name: 'contact_detail.email',
label: t`Contact Email`,
icon: 'email',
copy: true,
hidden: !order.contact_detail?.email
},
{
type: 'text',
name: 'contact_detail.phone',
label: t`Contact Phone`,
icon: 'phone',
copy: true,
hidden: !order.contact_detail?.phone
},
{
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`,
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',
hidden: !order.start_date,
copy: true
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
hidden: !order.target_date,
copy: true
},
{
type: 'date',
name: 'shipment_date',
label: t`Completion Date`,
hidden: !order.shipment_date,
copy: true
},
{
type: 'date',
name: 'updated_at',
label: t`Last Updated`,
icon: 'calendar',
copy: true,
showTime: true,
hidden: !order.updated_at
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.customer_detail?.image}
pk={order.customer}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
</ItemDetailsGrid>
);
}, [order, orderCurrency, instanceQuery]);
const soStatus = useStatusCodes({ modelType: ModelType.salesorder });
const lineItemsEditable: boolean = useMemo(() => {
@@ -364,7 +135,13 @@ export default function SalesOrderDetail() {
name: 'detail',
label: t`Order Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<SalesOrderDetailsPanel
instance={order}
allowImageEdit
refreshInstance={refreshInstance}
/>
)
},
{
name: 'line-items',
@@ -0,0 +1,270 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack, Text } from '@mantine/core';
import { useMemo } from 'react';
import TagsList from '@lib/components/TagsList';
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 {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
import { RenderAddress } from '../../components/render/Company';
import { formatCurrency } from '../../defaults/formatters';
import { useGlobalSettingsState } from '../../states/SettingsStates';
export function SalesOrderDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
}>) {
const globalSettings = useGlobalSettingsState();
const orderCurrency = useMemo(
() =>
instance?.order_currency ||
instance?.customer_detail?.currency ||
globalSettings.getSetting('INVENTREE_DEFAULT_CURRENCY'),
[instance, globalSettings]
);
const tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'customer_reference',
label: t`Customer Reference`,
copy: true,
icon: 'reference',
hidden: !instance?.customer_reference
},
{
type: 'link',
name: 'customer',
icon: 'customers',
label: t`Customer`,
model: ModelType.company
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.salesorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.salesorder,
icon: 'status',
hidden:
!instance?.status_custom_key ||
instance?.status_custom_key == instance?.status
}
];
const tr: DetailsField[] = [
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: instance?.line_items,
progress: instance?.completed_lines,
hidden: !instance?.line_items
},
{
type: 'progressbar',
name: 'shipments',
icon: 'shipment',
label: t`Completed Shipments`,
total: instance?.shipments_count,
progress: instance?.completed_shipments_count,
hidden: !instance?.shipments_count
},
{
type: 'text',
name: 'currency',
label: t`Order Currency`,
value_formatter: () => orderCurrency
},
{
type: 'text',
name: 'total_price',
label: t`Total Cost`,
value_formatter: () =>
formatCurrency(instance?.total_price, {
currency: orderCurrency
})
}
];
const bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !instance?.link
},
{
type: 'text',
name: 'address',
label: t`Shipping Address`,
icon: 'address',
value_formatter: () =>
instance?.address_detail ? (
<RenderAddress instance={instance.address_detail} />
) : (
<Text size='sm' c='red'>{t`Not specified`}</Text>
)
},
{
type: 'text',
name: 'contact_detail.name',
label: t`Contact`,
icon: 'user',
copy: true,
hidden: !instance?.contact_detail?.name
},
{
type: 'text',
name: 'contact_detail.email',
label: t`Contact Email`,
icon: 'email',
copy: true,
hidden: !instance?.contact_detail?.email
},
{
type: 'text',
name: 'contact_detail.phone',
label: t`Contact Phone`,
icon: 'phone',
copy: true,
hidden: !instance?.contact_detail?.phone
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !instance?.project_code_label
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !instance?.responsible
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Creation Date`,
copy: true,
hidden: !instance?.creation_date
},
{
type: 'date',
name: 'issue_date',
label: t`Issue Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.issue_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
hidden: !instance?.start_date,
copy: true
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
hidden: !instance?.target_date,
copy: true
},
{
type: 'date',
name: 'shipment_date',
label: t`Completion Date`,
hidden: !instance?.shipment_date,
copy: true
},
{
type: 'date',
name: 'updated_at',
label: t`Last Updated`,
icon: 'calendar',
copy: true,
showTime: true,
hidden: !instance?.updated_at
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.salesorder,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ fields: tr, item: instance },
{ fields: bl, item: instance },
{ fields: br, item: instance },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.sales_order : undefined}
imageActions={
allowImageEdit ? { uploadFile: true, deleteFile: true } : {}
}
apiPath={apiUrl(ApiEndpoints.company_list, instance?.customer)}
src={instance?.customer_detail?.image}
pk={instance?.customer}
refresh={refreshInstance}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={instance} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
);
}
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack, Text } from '@mantine/core';
import { Stack } from '@mantine/core';
import {
IconBookmark,
IconCircleCheck,
@@ -13,18 +13,11 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { getDetailUrl } from '@lib/functions/Navigation';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
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 DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
CancelItemAction,
@@ -37,9 +30,6 @@ 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 { RenderAddress } from '../../components/render/Company';
import { RenderUser } from '../../components/render/User';
import { formatDate } from '../../defaults/formatters';
import {
useCheckShipmentForm,
useCompleteShipmentForm,
@@ -53,6 +43,7 @@ import {
import { useInstance } from '../../hooks/UseInstance';
import { useUserState } from '../../states/UserState';
import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable';
import { SalesOrderShipmentDetailsPanel } from './SalesOrderShipmentDetailsPanel';
export default function SalesOrderShipmentDetail() {
const { id } = useParams();
@@ -74,190 +65,21 @@ export default function SalesOrderShipmentDetail() {
}
});
const { instance: customer, instanceQuery: customerQuery } = useInstance({
endpoint: ApiEndpoints.company_list,
pk: shipment.order_detail?.customer,
hasPrimaryKey: true
});
const isPending = useMemo(() => !shipment.shipment_date, [shipment]);
const isChecked = useMemo(() => !!shipment.checked_by, [shipment]);
const detailsPanel = useMemo(() => {
if (shipmentQuery.isFetching || customerQuery.isFetching) {
return <Skeleton />;
}
const data: any = {
...shipment,
customer: customer?.pk,
customer_name: customer?.name,
customer_reference: shipment.order_detail?.customer_reference
};
// Top Left: Order / customer information
const tl: DetailsField[] = [
{
type: 'link',
model: ModelType.salesorder,
name: 'order',
label: t`Sales Order`,
icon: 'sales_orders',
model_field: 'reference'
},
{
type: 'link',
name: 'customer',
icon: 'customers',
label: t`Customer`,
model: ModelType.company,
model_field: 'name',
hidden: !data.customer
},
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !shipment.link
}
];
// Top right: Shipment information
const tr: DetailsField[] = [
{
type: 'text',
name: 'customer_reference',
icon: 'serial',
label: t`Customer Reference`,
hidden: !data.customer_reference,
copy: true
},
{
type: 'text',
name: 'reference',
icon: 'serial',
label: t`Shipment Reference`,
copy: true
},
{
type: 'text',
name: 'tracking_number',
label: t`Tracking Number`,
icon: 'trackable',
value_formatter: () => shipment.tracking_number || '---',
copy: !!shipment.tracking_number
},
{
type: 'text',
name: 'invoice_number',
label: t`Invoice Number`,
icon: 'serial',
value_formatter: () => shipment.invoice_number || '---',
copy: !!shipment.invoice_number
}
];
const address: any =
shipment.shipment_address_detail || shipment.order_detail?.address_detail;
const bl: DetailsField[] = [
{
type: 'text',
name: 'address',
label: t`Shipping Address`,
icon: 'address',
value_formatter: () =>
address ? (
<RenderAddress
instance={
shipment.shipment_address_detail ||
shipment.order_detail?.address_detail
}
/>
) : (
<Text size='sm' c='red'>{t`Not specified`}</Text>
)
}
];
const br: DetailsField[] = [
{
type: 'text',
name: 'allocated_items',
icon: 'packages',
label: t`Allocated Items`
},
{
type: 'text',
name: 'checked_by',
label: t`Checked By`,
icon: 'check',
value_formatter: () =>
shipment.checked_by_detail ? (
<RenderUser instance={shipment.checked_by_detail} />
) : (
<Text size='sm' c='red'>{t`Not checked`}</Text>
)
},
{
type: 'text',
name: 'shipment_date',
label: t`Shipment Date`,
icon: 'calendar',
value_formatter: () => formatDate(shipment.shipment_date),
hidden: !shipment.shipment_date
},
{
type: 'text',
name: 'delivery_date',
label: t`Delivery Date`,
icon: 'calendar',
value_formatter: () => formatDate(shipment.delivery_date),
hidden: !shipment.delivery_date
}
];
return (
<>
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.sales_order}
apiPath={ApiEndpoints.company_list}
src={customer?.image}
pk={customer?.pk}
imageActions={{
selectExisting: false,
downloadImage: false,
uploadFile: false,
deleteFile: false
}}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={shipment.tags} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
</ItemDetailsGrid>
</>
);
}, [shipment, shipmentQuery, customer, customerQuery]);
const shipmentPanels: PanelType[] = useMemo(() => {
return [
{
name: 'detail',
label: t`Shipment Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<SalesOrderShipmentDetailsPanel
instance={shipment}
refreshInstance={refreshShipment}
/>
)
},
{
name: 'items',
@@ -288,7 +110,7 @@ export default function SalesOrderShipmentDetail() {
has_note: !!shipment.notes
})
];
}, [isPending, shipment, detailsPanel]);
}, [isPending, shipment]);
const editShipmentFields = useSalesOrderShipmentFields({
pending: isPending,
@@ -454,7 +276,7 @@ export default function SalesOrderShipmentDetail() {
}
]}
badges={shipmentBadges}
imageUrl={customer?.image}
imageUrl={shipment.order_detail?.customer_detail?.image}
editAction={editShipment.open}
editEnabled={user.hasChangePermission(ModelType.salesordershipment)}
actions={shipmentActions}
@@ -0,0 +1,205 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack, Text } from '@mantine/core';
import { useMemo } from 'react';
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 { TagsList } from '@lib/index';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
import { RenderAddress } from '../../components/render/Company';
import { RenderUser } from '../../components/render/User';
import { formatDate } from '../../defaults/formatters';
import { useInstance } from '../../hooks/UseInstance';
export function SalesOrderShipmentDetailsPanel({
instance,
refreshInstance
}: Readonly<{
instance: any;
refreshInstance?: () => void;
}>) {
const { instance: customer } = useInstance({
endpoint: ApiEndpoints.company_list,
pk: instance?.order_detail?.customer,
hasPrimaryKey: true
});
const data = useMemo(
() => ({
...instance,
customer: customer?.pk,
customer_name: customer?.name,
customer_reference: instance?.order_detail?.customer_reference
}),
[instance, customer]
);
const address = useMemo(
() =>
instance?.shipment_address_detail ||
instance?.order_detail?.address_detail,
[instance]
);
const tl: DetailsField[] = [
{
type: 'link',
model: ModelType.salesorder,
name: 'order',
label: t`Sales Order`,
icon: 'sales_orders',
model_field: 'reference'
},
{
type: 'link',
name: 'customer',
icon: 'customers',
label: t`Customer`,
model: ModelType.company,
model_field: 'name',
hidden: !data.customer
},
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !instance?.link
}
];
const tr: DetailsField[] = [
{
type: 'text',
name: 'customer_reference',
icon: 'serial',
label: t`Customer Reference`,
hidden: !data.customer_reference,
copy: true
},
{
type: 'text',
name: 'reference',
icon: 'serial',
label: t`Shipment Reference`,
copy: true
},
{
type: 'text',
name: 'tracking_number',
label: t`Tracking Number`,
icon: 'trackable',
value_formatter: () => instance?.tracking_number || '---',
copy: !!instance?.tracking_number
},
{
type: 'text',
name: 'invoice_number',
label: t`Invoice Number`,
icon: 'serial',
value_formatter: () => instance?.invoice_number || '---',
copy: !!instance?.invoice_number
}
];
const bl: DetailsField[] = [
{
type: 'text',
name: 'address',
label: t`Shipping Address`,
icon: 'address',
value_formatter: () =>
address ? (
<RenderAddress instance={address} />
) : (
<Text size='sm' c='red'>{t`Not specified`}</Text>
)
}
];
const br: DetailsField[] = [
{
type: 'text',
name: 'allocated_items',
icon: 'packages',
label: t`Allocated Items`
},
{
type: 'text',
name: 'checked_by',
label: t`Checked By`,
icon: 'check',
value_formatter: () =>
instance?.checked_by_detail ? (
<RenderUser instance={instance.checked_by_detail} />
) : (
<Text size='sm' c='red'>{t`Not checked`}</Text>
)
},
{
type: 'text',
name: 'shipment_date',
label: t`Shipment Date`,
icon: 'calendar',
value_formatter: () => formatDate(instance?.shipment_date),
hidden: !instance?.shipment_date
},
{
type: 'text',
name: 'delivery_date',
label: t`Delivery Date`,
icon: 'calendar',
value_formatter: () => formatDate(instance?.delivery_date),
hidden: !instance?.delivery_date
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.salesordershipment,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ fields: tr, item: data },
{ fields: bl, item: data },
{ fields: br, item: data },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.sales_order}
apiPath={apiUrl(ApiEndpoints.company_list, customer?.pk)}
src={customer?.image}
pk={customer?.pk}
imageActions={{
selectExisting: false,
downloadImage: false,
uploadFile: false,
deleteFile: false
}}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
);
}
@@ -7,7 +7,7 @@ import type { TableFilter } from '@lib/index';
import type { StockOperationProps } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel';
import { t } from '@lingui/core/macro';
import { Group, Skeleton, Stack } from '@mantine/core';
import { Skeleton, Stack } from '@mantine/core';
import {
IconCalendar,
IconInfoCircle,
@@ -24,11 +24,6 @@ import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialo
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import OrderCalendar from '../../components/calendar/OrderCalendar';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
DeleteItemAction,
@@ -60,6 +55,7 @@ import StockLocationParametricTable from '../../tables/stock/StockLocationParame
import { StockLocationTable } from '../../tables/stock/StockLocationTable';
import TransferOrderParametricTable from '../../tables/stock/TransferOrderParametricTable';
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
import { StockLocationDetailsPanel } from './StockLocationDetailsPanel';
function TransferOrderCalendar() {
const calendarFilters: TableFilter[] = useMemo(() => {
@@ -105,92 +101,12 @@ export default function Stock() {
}
});
const detailsPanel = useMemo(() => {
if (id && instanceQuery.isFetching) {
return <Skeleton />;
}
const left: DetailsField[] = [
{
type: 'text',
name: 'name',
label: t`Name`,
copy: true,
value_formatter: () => (
<Group gap='xs'>
{location.icon && <ApiIcon name={location.icon} />}
{location.name}
</Group>
)
},
{
type: 'text',
name: 'pathstring',
label: t`Path`,
icon: 'sitemap',
copy: true,
hidden: !id
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
name: 'parent',
model_field: 'name',
icon: 'location',
label: t`Parent Location`,
model: ModelType.stocklocation,
hidden: !location?.parent
}
];
const right: DetailsField[] = [
{
type: 'text',
name: 'items',
icon: 'stock',
label: t`Stock Items`,
value_formatter: () => location?.items || '0'
},
{
type: 'text',
name: 'sublocations',
icon: 'location',
label: t`Sublocations`,
hidden: !location?.sublocations
},
{
type: 'boolean',
name: 'structural',
label: t`Structural`,
icon: 'sitemap'
},
{
type: 'boolean',
name: 'external',
label: t`External`
},
{
type: 'string',
// TODO: render location type icon here (ref: #7237)
name: 'location_type_detail.name',
label: t`Location Type`,
hidden: !location?.location_type,
icon: 'packages'
}
];
return (
<ItemDetailsGrid>
{id && location?.pk && <DetailsTable item={location} fields={left} />}
{id && location?.pk && <DetailsTable item={location} fields={right} />}
</ItemDetailsGrid>
const detailsPanel =
id && instanceQuery.isFetching ? (
<Skeleton />
) : (
<StockLocationDetailsPanel instance={id ? location : undefined} />
);
}, [location, instanceQuery]);
const [sublocationView, setSublocationView] = useState<string>('table');
const [transferOrderView, setTransferOrderView] = useState<string>('table');
+12 -383
View File
@@ -1,18 +1,6 @@
import { t } from '@lingui/core/macro';
import { Accordion, Skeleton, Stack } from '@mantine/core';
import {
Accordion,
Button,
Grid,
Group,
Skeleton,
Space,
Stack,
Text,
Tooltip
} from '@mantine/core';
import {
IconArrowLeft,
IconArrowRight,
IconArrowsSplit,
IconBookmark,
IconBoxPadding,
@@ -20,7 +8,6 @@ import {
IconHistory,
IconInfoCircle,
IconPackages,
IconSearch,
IconShoppingCart,
IconSitemap,
IconTransform
@@ -29,27 +16,19 @@ import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ActionButton } from '@lib/components/ActionButton';
import { StylishText } from '@lib/components/StylishText';
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 { getDetailUrl, getOverviewUrl } from '@lib/functions/Navigation';
import { TagsList } from '@lib/index';
import type { ApiFormFieldSet, StockOperationProps } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel';
import { notifications } from '@mantine/notifications';
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
ActionDropdown,
BarcodeActionDropdown,
@@ -68,10 +47,9 @@ import LocateItemButton from '../../components/plugins/LocateItemButton';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { useApi } from '../../contexts/ApiContext';
import { formatCurrency, formatDecimal } from '../../defaults/formatters';
import { formatDecimal } from '../../defaults/formatters';
import {
useDisassembleStockItem,
useFindSerialNumberForm,
useStockFields,
useStockItemSerializeFields
} from '../../forms/StockForms';
@@ -92,6 +70,7 @@ import { StockItemTable } from '../../tables/stock/StockItemTable';
import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable';
import { StockTrackingTable } from '../../tables/stock/StockTrackingTable';
import TransferOrderAllocationTable from '../../tables/stock/TransferOrderAllocationTable';
import { StockDetailsPanel } from './StockDetailsPanel';
export default function StockDetail() {
const { id } = useParams();
@@ -126,367 +105,13 @@ export default function StockDetail() {
}
});
const { instance: part, instanceQuery: partQuery } = useInstance({
const { instance: part } = useInstance({
endpoint: ApiEndpoints.part_list,
pk: stockitem?.part,
hasPrimaryKey: true,
defaultValue: {}
});
const { instance: serialNumbers, instanceQuery: serialNumbersQuery } =
useInstance({
endpoint: ApiEndpoints.stock_serial_info,
pk: id
});
const findBySerialNumber = useFindSerialNumberForm({
partId: stockitem.part
});
const detailsPanel = useMemo(() => {
const data = { ...stockitem };
const part = stockitem?.part_detail ?? {};
data.available_stock = Math.max(0, data.quantity - data.allocated);
if (instanceQuery.isFetching) {
return <Skeleton />;
}
// Top left - core part information
const tl: DetailsField[] = [
{
name: 'part',
label: t`Base Part`,
type: 'link',
model: ModelType.part
},
{
name: 'part_detail.IPN',
label: t`IPN`,
type: 'text',
copy: true,
icon: 'part',
hidden: !part.IPN
},
{
name: 'part_detail.revision',
label: t`Revision`,
type: 'string',
copy: true,
icon: 'revision',
hidden: !part.revision
},
{
name: 'status',
type: 'status',
label: t`Status`,
model: ModelType.stockitem
},
{
name: 'status_custom_key',
type: 'status',
label: t`Custom Status`,
model: ModelType.stockitem,
icon: 'status',
hidden:
!stockitem.status_custom_key ||
stockitem.status_custom_key == stockitem.status
},
{
type: 'link',
name: 'link',
label: t`Link`,
external: true,
copy: true,
hidden: !stockitem.link
}
];
// Top right - available stock information
const tr: DetailsField[] = [
{
type: 'text',
name: 'serial',
label: t`Serial Number`,
hidden: !stockitem.serial,
value_formatter: () => (
<Group gap='xs' justify='space-apart'>
<Text>{stockitem.serial}</Text>
<Space flex={10} />
<Group gap={2} justify='right'>
{serialNumbers.previous?.pk && (
<Tooltip label={t`Previous serial number`} position='top'>
<Button
p={3}
aria-label='previous-serial-number'
leftSection={<IconArrowLeft />}
variant='transparent'
size='sm'
onClick={() => {
navigate(
getDetailUrl(
ModelType.stockitem,
serialNumbers.previous.pk
)
);
}}
>
{serialNumbers.previous.serial}
</Button>
</Tooltip>
)}
<ActionButton
icon={<IconSearch size={18} />}
tooltip={t`Find serial number`}
tooltipAlignment='top'
variant='transparent'
onClick={findBySerialNumber.open}
/>
{serialNumbers.next?.pk && (
<Tooltip label={t`Next serial number`} position='top'>
<Button
p={3}
aria-label='next-serial-number'
rightSection={<IconArrowRight />}
variant='transparent'
size='sm'
onClick={() => {
navigate(
getDetailUrl(ModelType.stockitem, serialNumbers.next.pk)
);
}}
>
{serialNumbers.next.serial}
</Button>
</Tooltip>
)}
</Group>
</Group>
)
},
{
type: 'number',
name: 'quantity',
label: t`Quantity`,
unit: part?.units,
hidden: !!stockitem.serial && stockitem.quantity == 1
},
{
type: 'number',
name: 'available_stock',
label: t`Available`,
unit: part?.units,
icon: 'stock',
hidden: stockitem.in_stock == false
},
{
type: 'number',
name: 'allocated',
label: t`Allocated to Orders`,
unit: part?.units,
icon: 'tick_off',
hidden: !stockitem.allocated
},
{
type: 'text',
name: 'batch',
label: t`Batch Code`,
hidden: !stockitem.batch
}
];
// Bottom left: location information
const bl: DetailsField[] = [
{
name: 'supplier_part',
label: t`Supplier Part`,
type: 'link',
model_field: 'SKU',
model: ModelType.supplierpart,
hidden: !stockitem.supplier_part
},
{
type: 'link',
name: 'location',
label: t`Location`,
model: ModelType.stocklocation,
hidden: !stockitem.location
},
{
type: 'link',
name: 'belongs_to',
label: t`Installed In`,
model_filters: {
part_detail: true
},
model_formatter: (model: any) => {
let text = model?.part_detail?.full_name ?? model?.name;
if (model.serial && model.quantity == 1) {
text += ` # ${model.serial}`;
}
return text;
},
icon: 'stock',
model: ModelType.stockitem,
hidden: !stockitem.belongs_to
},
{
type: 'link',
name: 'parent',
icon: 'sitemap',
label: t`Parent Item`,
model: ModelType.stockitem,
hidden: !stockitem.parent,
model_formatter: (model: any) => {
return t`Parent stock item`;
}
},
{
type: 'link',
name: 'consumed_by',
label: t`Consumed By`,
model: ModelType.build,
hidden: !stockitem.consumed_by,
icon: 'build',
model_field: 'reference'
},
{
type: 'link',
name: 'build',
label: t`Build Order`,
model: ModelType.build,
hidden: !stockitem.build,
model_field: 'reference'
},
{
type: 'link',
name: 'purchase_order',
label: t`Purchase Order`,
model: ModelType.purchaseorder,
hidden: !stockitem.purchase_order,
icon: 'purchase_orders',
model_field: 'reference'
},
{
type: 'link',
name: 'sales_order',
label: t`Sales Order`,
model: ModelType.salesorder,
hidden: !stockitem.sales_order,
icon: 'sales_orders',
model_field: 'reference'
},
{
type: 'link',
name: 'customer',
label: t`Customer`,
model: ModelType.company,
hidden: !stockitem.customer
}
];
// Bottom right - any other information
const br: DetailsField[] = [
// Expiry date
{
type: 'date',
name: 'expiry_date',
label: t`Expiry Date`,
hidden: !enableExpiry || !stockitem.expiry_date,
icon: 'calendar'
},
// TODO: Ownership
{
type: 'text',
name: 'purchase_price',
label: t`Unit Price`,
icon: 'currency',
hidden: !stockitem.purchase_price,
value_formatter: () => {
return formatCurrency(stockitem.purchase_price, {
currency: stockitem.purchase_price_currency
});
}
},
{
type: 'text',
name: 'stock_value',
label: t`Stock Value`,
icon: 'currency',
hidden:
!stockitem.purchase_price ||
stockitem.quantity == 1 ||
stockitem.quantity == 0,
value_formatter: () => {
return formatCurrency(stockitem.purchase_price, {
currency: stockitem.purchase_price_currency,
multiplier: stockitem.quantity
});
}
},
{
type: 'text',
name: 'packaging',
icon: 'part',
label: t`Packaging`,
hidden: !stockitem.packaging
},
{
type: 'date',
name: 'creation_date',
icon: 'calendar',
label: t`Created`,
hidden: !stockitem.creation_date
},
{
type: 'date',
name: 'updated',
icon: 'calendar',
label: t`Last Updated`
},
{
type: 'date',
name: 'stocktake_date',
icon: 'calendar',
label: t`Last Stocktake`,
hidden: !stockitem.stocktake_date
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
apiPath={ApiEndpoints.part_list}
src={
stockitem.part_detail?.image ??
stockitem?.part_detail?.thumbnail
}
pk={stockitem.part}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={stockitem.tags} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
</ItemDetailsGrid>
);
}, [
stockitem,
serialNumbers,
serialNumbersQuery.isFetching,
instanceQuery.isFetching,
enableExpiry
]);
const showBuildAllocations: boolean = useMemo(() => {
// Determine if "build allocations" should be shown for this stock item
return (
@@ -557,7 +182,14 @@ export default function StockDetail() {
name: 'details',
label: t`Stock Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<StockDetailsPanel
instance={stockitem}
allowImageEdit
showSerialNav
refreshInstance={refreshInstance}
/>
)
},
{
name: 'tracking',
@@ -688,8 +320,6 @@ export default function StockDetail() {
showBuildAllocations,
showInstalledItems,
stockitem,
serialNumbers,
serialNumbersQuery,
id,
user
]);
@@ -1082,7 +712,6 @@ export default function StockDetail() {
return (
<>
{findBySerialNumber.modal}
{scanIntoLocation.dialog}
<InstanceDetail
query={instanceQuery}
@@ -0,0 +1,413 @@
import { t } from '@lingui/core/macro';
import {
Button,
Grid,
Group,
Skeleton,
Space,
Stack,
Text,
Tooltip
} from '@mantine/core';
import { IconArrowLeft, IconArrowRight, IconSearch } from '@tabler/icons-react';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { ActionButton } from '@lib/components/ActionButton';
import TagsList from '@lib/components/TagsList';
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 { getDetailUrl } from '@lib/functions/Navigation';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { formatCurrency } from '../../defaults/formatters';
import { useFindSerialNumberForm } from '../../forms/StockForms';
import { useInstance } from '../../hooks/UseInstance';
import { useGlobalSettingsState } from '../../states/SettingsStates';
export function StockDetailsPanel({
instance,
allowImageEdit = false,
showSerialNav = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
showSerialNav?: boolean;
refreshInstance?: () => void;
}>) {
const navigate = useNavigate();
const globalSettings = useGlobalSettingsState();
const enableExpiry = useMemo(
() => globalSettings.isSet('STOCK_ENABLE_EXPIRY'),
[globalSettings]
);
const { instance: serialNumbers } = useInstance({
endpoint: ApiEndpoints.stock_serial_info,
pk: instance?.pk,
hasPrimaryKey: true,
disabled: !instance?.pk,
defaultValue: {}
});
const { instance: fetchedPart } = useInstance({
endpoint: ApiEndpoints.part_list,
pk: instance?.part,
hasPrimaryKey: true,
disabled: !instance?.part || !!instance?.part_detail,
defaultValue: {}
});
const findBySerialNumber = useFindSerialNumberForm({
partId: instance?.part
});
const part = instance?.part_detail ?? fetchedPart ?? {};
const data = useMemo(() => {
const d = { ...instance };
d.available_stock = Math.max(0, (d.quantity ?? 0) - (d.allocated ?? 0));
return d;
}, [instance]);
const tl: DetailsField[] = [
{
name: 'part',
label: t`Base Part`,
type: 'link',
model: ModelType.part
},
{
name: 'part_detail.IPN',
label: t`IPN`,
type: 'text',
copy: true,
icon: 'part',
hidden: !part.IPN
},
{
name: 'part_detail.revision',
label: t`Revision`,
type: 'string',
copy: true,
icon: 'revision',
hidden: !part.revision
},
{
name: 'status',
type: 'status',
label: t`Status`,
model: ModelType.stockitem
},
{
name: 'status_custom_key',
type: 'status',
label: t`Custom Status`,
model: ModelType.stockitem,
icon: 'status',
hidden:
!instance?.status_custom_key ||
instance?.status_custom_key == instance?.status
},
{
type: 'link',
name: 'link',
label: t`Link`,
external: true,
copy: true,
hidden: !instance?.link
}
];
const tr: DetailsField[] = [
{
type: 'text',
name: 'serial',
label: t`Serial Number`,
hidden: !instance?.serial,
...(showSerialNav && {
value_formatter: () => (
<Group gap='xs' justify='space-apart'>
<Text>{instance?.serial}</Text>
<Space flex={10} />
<Group gap={2} justify='right'>
{serialNumbers?.previous?.pk && (
<Tooltip label={t`Previous serial number`} position='top'>
<Button
p={3}
aria-label='previous-serial-number'
leftSection={<IconArrowLeft />}
variant='transparent'
size='sm'
onClick={() => {
navigate(
getDetailUrl(
ModelType.stockitem,
serialNumbers.previous.pk
)
);
}}
>
{serialNumbers.previous.serial}
</Button>
</Tooltip>
)}
<ActionButton
icon={<IconSearch size={18} />}
tooltip={t`Find serial number`}
tooltipAlignment='top'
variant='transparent'
onClick={findBySerialNumber.open}
/>
{serialNumbers?.next?.pk && (
<Tooltip label={t`Next serial number`} position='top'>
<Button
p={3}
aria-label='next-serial-number'
rightSection={<IconArrowRight />}
variant='transparent'
size='sm'
onClick={() => {
navigate(
getDetailUrl(ModelType.stockitem, serialNumbers.next.pk)
);
}}
>
{serialNumbers.next.serial}
</Button>
</Tooltip>
)}
</Group>
</Group>
)
})
},
{
type: 'number',
name: 'quantity',
label: t`Quantity`,
unit: part?.units,
hidden: !!instance?.serial && instance?.quantity == 1
},
{
type: 'number',
name: 'available_stock',
label: t`Available`,
unit: part?.units,
icon: 'stock',
hidden: !!instance?.serial || instance.in_stock === false
},
{
type: 'number',
name: 'allocated',
label: t`Allocated to Orders`,
unit: part?.units,
icon: 'tick_off',
hidden: !instance?.allocated
},
{
type: 'text',
name: 'batch',
label: t`Batch Code`,
hidden: !instance?.batch
}
];
const bl: DetailsField[] = [
{
name: 'supplier_part',
label: t`Supplier Part`,
type: 'link',
model_field: 'SKU',
model: ModelType.supplierpart,
hidden: !instance?.supplier_part
},
{
type: 'link',
name: 'location',
label: t`Location`,
model: ModelType.stocklocation,
hidden: !instance?.location
},
{
type: 'link',
name: 'belongs_to',
label: t`Installed In`,
model_filters: {
part_detail: true
},
model_formatter: (model: any) => {
let text = model?.part_detail?.full_name ?? model?.name;
if (model.serial && model.quantity == 1) {
text += ` # ${model.serial}`;
}
return text;
},
icon: 'stock',
model: ModelType.stockitem,
hidden: !instance?.belongs_to
},
{
type: 'link',
name: 'parent',
icon: 'sitemap',
label: t`Parent Item`,
model: ModelType.stockitem,
hidden: !instance?.parent,
model_formatter: () => t`Parent stock item`
},
{
type: 'link',
name: 'consumed_by',
label: t`Consumed By`,
model: ModelType.build,
hidden: !instance?.consumed_by,
icon: 'build',
model_field: 'reference'
},
{
type: 'link',
name: 'build',
label: t`Build Order`,
model: ModelType.build,
hidden: !instance?.build,
model_field: 'reference'
},
{
type: 'link',
name: 'purchase_order',
label: t`Purchase Order`,
model: ModelType.purchaseorder,
hidden: !instance?.purchase_order,
icon: 'purchase_orders',
model_field: 'reference'
},
{
type: 'link',
name: 'sales_order',
label: t`Sales Order`,
model: ModelType.salesorder,
hidden: !instance?.sales_order,
icon: 'sales_orders',
model_field: 'reference'
},
{
type: 'link',
name: 'customer',
label: t`Customer`,
model: ModelType.company,
hidden: !instance?.customer
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'expiry_date',
label: t`Expiry Date`,
hidden: !enableExpiry || !instance?.expiry_date,
icon: 'calendar'
},
{
type: 'text',
name: 'purchase_price',
label: t`Unit Price`,
icon: 'currency',
hidden: !instance?.purchase_price,
value_formatter: () =>
formatCurrency(instance?.purchase_price, {
currency: instance?.purchase_price_currency
})
},
{
type: 'text',
name: 'stock_value',
label: t`Stock Value`,
icon: 'currency',
hidden:
!instance?.purchase_price ||
instance?.quantity == 1 ||
instance?.quantity == 0,
value_formatter: () =>
formatCurrency(instance?.purchase_price, {
currency: instance?.purchase_price_currency,
multiplier: instance?.quantity
})
},
{
type: 'text',
name: 'packaging',
icon: 'part',
label: t`Packaging`,
hidden: !instance?.packaging
},
{
type: 'date',
name: 'creation_date',
icon: 'calendar',
label: t`Created`,
hidden: !instance?.creation_date
},
{
type: 'date',
name: 'updated',
icon: 'calendar',
label: t`Last Updated`
},
{
type: 'date',
name: 'stocktake_date',
icon: 'calendar',
label: t`Last Stocktake`,
hidden: !instance?.stocktake_date
}
];
if (!instance?.pk) return <Skeleton />;
return (
<>
{showSerialNav && findBySerialNumber.modal}
<ItemDetailsGrid
tables={[
{ fields: tr, item: data },
{ fields: bl, item: data },
{ fields: br, item: data }
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.part : undefined}
imageActions={
allowImageEdit
? {
uploadFile: true,
deleteFile: true
}
: {}
}
apiPath={apiUrl(ApiEndpoints.part_list, instance?.part)}
src={part?.image ?? part?.thumbnail}
pk={instance?.part}
refresh={refreshInstance}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
</>
);
}
@@ -0,0 +1,106 @@
import { t } from '@lingui/core/macro';
import { Group, Skeleton } from '@mantine/core';
import { ModelType } from '@lib/enums/ModelType';
import type { DetailsField } from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
import { ApiIcon } from '../../components/items/ApiIcon';
export function StockLocationDetailsPanel({
instance
}: Readonly<{
instance: any;
}>) {
const left: DetailsField[] = [
{
type: 'text',
name: 'name',
label: t`Name`,
copy: true,
value_formatter: () => (
<Group gap='xs'>
{instance?.icon && <ApiIcon name={instance.icon} />}
{instance?.name}
</Group>
)
},
{
type: 'text',
name: 'pathstring',
label: t`Path`,
icon: 'sitemap',
copy: true,
hidden: !instance?.pathstring
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true,
hidden: !instance?.description
},
{
type: 'link',
name: 'parent',
model_field: 'name',
icon: 'location',
label: t`Parent Location`,
model: ModelType.stocklocation,
hidden: !instance?.parent
}
];
const right: DetailsField[] = [
{
type: 'text',
name: 'items',
icon: 'stock',
label: t`Stock Items`,
value_formatter: () => instance?.items || '0'
},
{
type: 'text',
name: 'sublocations',
icon: 'location',
label: t`Sublocations`,
hidden: !instance?.sublocations
},
{
type: 'boolean',
name: 'structural',
label: t`Structural`,
icon: 'sitemap'
},
{
type: 'boolean',
name: 'external',
label: t`External`
},
{
type: 'string',
name: 'location_type_detail.name',
label: t`Location Type`,
hidden: !instance?.location_type,
icon: 'packages'
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.stocklocation,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ item: instance, fields: left },
{ item: instance, fields: right },
parametersTable
]}
/>
);
}
@@ -1,12 +1,12 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { 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, TagsList, apiUrl } from '@lib/index';
import { type PanelType, apiUrl } from '@lib/index';
import {
IconBookmark,
IconInfoCircle,
@@ -16,11 +16,6 @@ import {
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,
@@ -47,6 +42,7 @@ import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import TransferOrderAllocationTable from '../../tables/stock/TransferOrderAllocationTable';
import TransferOrderLineItemTable from '../../tables/stock/TransferOrderLineItemTable';
import { TransferOrderDetailsPanel } from './TransferOrderDetailsPanel';
export default function TransferOrderDetail() {
const { id } = useParams();
@@ -93,169 +89,11 @@ export default function TransferOrderDetail() {
);
}, [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>
<Stack gap='xs'>
<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>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
</ItemDetailsGrid>
);
}, [order, instanceQuery]);
const detailsPanel = instanceQuery.isFetching ? (
<Skeleton />
) : (
<TransferOrderDetailsPanel instance={order} />
);
const orderPanels: PanelType[] = useMemo(() => {
return [
@@ -0,0 +1,181 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { ModelType } from '@lib/enums/ModelType';
import { TagsList } from '@lib/index';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
export function TransferOrderDetailsPanel({
instance
}: Readonly<{
instance: any;
}>) {
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:
!instance?.status_custom_key ||
instance?.status_custom_key == instance?.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: instance?.line_items,
progress: instance?.completed_lines
}
];
const bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !instance?.link
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !instance?.project_code
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !instance?.responsible
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Creation Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.creation_date
},
{
type: 'date',
name: 'issue_date',
label: t`Issue Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.issue_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.start_date
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
copy: true,
hidden: !instance?.target_date
},
{
type: 'date',
name: 'complete_date',
icon: 'calendar_check',
label: t`Completion Date`,
copy: true,
hidden: !instance?.complete_date
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.transferorder,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ fields: tr, item: instance },
{ fields: bl, item: instance },
{ fields: br, item: instance },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={instance} />
</Grid.Col>
</Grid>
<TagsList tags={instance.tags} />
</Stack>
</ItemDetailsGrid>
);
}
@@ -0,0 +1,98 @@
import type { ModelType } from '@lib/enums/ModelType';
import type { PreviewType } from '@lib/types/Preview';
import { create } from 'zustand';
type PreviewDrawerId = number | string | undefined;
interface PreviewDrawerStateProps {
isOpen: boolean;
modelType?: ModelType;
id?: number | string;
instance?: any;
filters?: Record<string, any>;
preview?: PreviewType;
targetUrl?: string;
onCloseCallback?: () => void;
openPreview: (
modelType: ModelType,
id: PreviewDrawerId,
instance?: any,
preview?: PreviewType,
onClose?: () => void,
targetUrl?: string,
filters?: Record<string, any>
) => void;
closePreview: () => void;
}
export const usePreviewDrawerState = create<PreviewDrawerStateProps>()(
(set, get) => ({
isOpen: false,
modelType: undefined,
id: undefined,
instance: undefined,
filters: undefined,
preview: undefined,
targetUrl: undefined,
onCloseCallback: undefined,
openPreview: (
modelType: ModelType,
id: number | string | undefined,
instance?: any,
preview?: PreviewType,
onClose?: () => void,
targetUrl?: string,
filters?: Record<string, any>
) => {
set({
modelType,
id,
instance,
preview,
targetUrl,
filters,
isOpen: true,
onCloseCallback: onClose
});
},
closePreview: () => {
const callback = get().onCloseCallback;
set({
isOpen: false,
instance: undefined,
id: undefined,
preview: undefined,
targetUrl: undefined,
filters: undefined,
onCloseCallback: undefined
});
callback?.();
}
})
);
export function openGlobalPreview(
modelType: ModelType,
id?: number | string | undefined,
instance?: any,
preview?: PreviewType,
onClose?: () => void,
targetUrl?: string,
filters?: Record<string, any>
) {
usePreviewDrawerState
.getState()
.openPreview(modelType, id, instance, preview, onClose, targetUrl, filters);
}
export function closeGlobalPreview() {
usePreviewDrawerState.getState().closePreview();
}
export function getGlobalPreviewState() {
return usePreviewDrawerState.getState();
}
@@ -1,6 +1,6 @@
import { ActionButton } from '@lib/components/ActionButton';
import { ProgressBar } from '@lib/components/ProgressBar';
import { RowEditAction, RowViewAction } from '@lib/components/RowActions';
import { RowEditAction } from '@lib/components/RowActions';
import { YesNoButton } from '@lib/components/YesNoButton';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
@@ -37,6 +37,8 @@ import {
} from '../../components/tables/ColumnRenderers';
import { PartCategoryFilter } from '../../components/tables/Filter';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { AppRowViewAction } from '../../components/tables/AppRowActions';
import RowExpansionIcon from '../../components/tables/RowExpansionIcon';
import { TableHoverCard } from '../../components/tables/TableHoverCard';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
@@ -132,7 +134,7 @@ export function BuildLineSubTable({
onDeleteAllocation?.(record.pk);
}
},
RowViewAction({
AppRowViewAction({
title: t`View Stock Item`,
modelType: ModelType.stockitem,
modelId: record.stock_item,
@@ -866,7 +868,7 @@ export default function BuildLineTable({
newBuildOrder.open();
}
},
RowViewAction({
AppRowViewAction({
title: t`View Part`,
modelType: ModelType.part,
modelId: record.part,
@@ -8,7 +8,7 @@ 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 { navigateToLink } from '@lib/functions/Navigation';
import { eventModified, navigateToLink } from '@lib/functions/Navigation';
import useTable from '@lib/hooks/UseTable';
import type { TableFilter } from '@lib/types/Filters';
import {
@@ -23,6 +23,8 @@ import {
useCreateApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { usePreviewDrawerState } from '../../states/PreviewDrawerState';
import { useUserSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
/**
@@ -49,6 +51,12 @@ export function CompanyTable({
const navigate = useNavigate();
const user = useUserState();
const previewDrawer = usePreviewDrawerState();
const userSettings = useUserSettingsState();
const showPreviewPanel = useMemo(() => {
return userSettings.isSet('ENABLE_PREVIEW_PANEL');
}, [userSettings]);
const columns = useMemo(() => {
return [
@@ -166,10 +174,20 @@ export function CompanyTable({
params: {
...params
},
onRowClick: (record: any, index: number, event: any) => {
if (record.pk) {
const base = path ?? 'company';
navigateToLink(`/${base}/${record.pk}`, navigate, event);
onRowClick: (record: any, _index: number, event: any) => {
if (!record.pk) return;
const url = `/${path ?? 'company'}/${record.pk}`;
if (!showPreviewPanel || eventModified(event as any)) {
navigateToLink(url, navigate, event);
} else {
previewDrawer.openPreview(
ModelType.company,
Number(record.pk),
undefined,
undefined,
undefined,
url
);
}
},
modelType: ModelType.company,
@@ -1,5 +1,5 @@
import { ActionButton } from '@lib/components/ActionButton';
import { type RowAction, RowViewAction } from '@lib/components/RowActions';
import type { RowAction } from '@lib/components/RowActions';
import useTable from '@lib/hooks/UseTable';
import type { TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
@@ -9,6 +9,8 @@ import { useNavigate } from 'react-router-dom';
import type { BarcodeScanItem } from '../../components/barcodes/BarcodeScanItem';
import { RenderInstance } from '../../components/render/Instance';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { AppRowViewAction } from '../../components/tables/AppRowActions';
import { useUserState } from '../../states/UserState';
/**
@@ -71,7 +73,7 @@ export default function BarcodeScanTable({
if (record.model && record.pk && record.instance) {
actions.push(
RowViewAction({
AppRowViewAction({
title: t`View Item`,
modelId: record.instance?.pk,
modelType: record.model,
@@ -1,5 +1,9 @@
import { cancelEvent } from '@lib/functions/Events';
import { getDetailUrl, navigateToLink } from '@lib/functions/Navigation';
import {
eventModified,
getDetailUrl,
navigateToLink
} from '@lib/functions/Navigation';
import useTable from '@lib/hooks/UseTable';
import {
ApiEndpoints,
@@ -28,6 +32,7 @@ import {
useCreateApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { openGlobalPreview } from '../../states/PreviewDrawerState';
import { useUserState } from '../../states/UserState';
import {
PARAMETER_FILTER_OPERATORS,
@@ -471,9 +476,12 @@ export default function ParametricDataTable({
const col = column as any;
onParameterClick(col.extra.template, record);
} else if (record?.pk) {
// Navigate through to the detail page
const url = getDetailUrl(modelType, record.pk);
navigateToLink(url, navigate, event);
if (eventModified(event as any)) {
navigateToLink(url, navigate, event);
} else {
openGlobalPreview(modelType, record.pk);
}
}
}
}}
@@ -5,7 +5,6 @@ import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { ProgressBar } from '@lib/components/ProgressBar';
import { RowViewAction } from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@@ -22,6 +21,8 @@ import {
} from '../../components/tables/ColumnRenderers';
import { IncludeVariantsFilter } from '../../components/tables/Filter';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { AppRowViewAction } from '../../components/tables/AppRowActions';
import RowExpansionIcon from '../../components/tables/RowExpansionIcon';
import { useUserState } from '../../states/UserState';
import { BuildLineSubTable } from '../build/BuildLineTable';
@@ -120,7 +121,7 @@ export default function PartBuildAllocationsTable({
const rowActions = useCallback(
(record: any) => {
return [
RowViewAction({
AppRowViewAction({
title: t`View Build Order`,
modelType: ModelType.build,
modelId: record.build,
@@ -5,7 +5,6 @@ import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { ProgressBar } from '@lib/components/ProgressBar';
import { RowViewAction } from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@@ -22,6 +21,8 @@ import {
} from '../../components/tables/ColumnRenderers';
import { IncludeVariantsFilter } from '../../components/tables/Filter';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { AppRowViewAction } from '../../components/tables/AppRowActions';
import RowExpansionIcon from '../../components/tables/RowExpansionIcon';
import { useUserState } from '../../states/UserState';
import SalesOrderAllocationTable from '../sales/SalesOrderAllocationTable';
@@ -84,7 +85,7 @@ export default function PartSalesAllocationsTable({
const rowActions = useCallback(
(record: any) => {
return [
RowViewAction({
AppRowViewAction({
title: t`View Sales Order`,
modelType: ModelType.salesorder,
modelId: record.order,
@@ -9,8 +9,7 @@ import { AddItemButton } from '@lib/components/AddItemButton';
import {
type RowAction,
RowDeleteAction,
RowEditAction,
RowViewAction
RowEditAction
} from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
@@ -26,6 +25,8 @@ import {
DescriptionColumn
} from '../../components/tables/ColumnRenderers';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { AppRowViewAction } from '../../components/tables/AppRowActions';
import { TableHoverCard } from '../../components/tables/TableHoverCard';
import {
useCreateApiFormModal,
@@ -208,7 +209,7 @@ export default function PartTestTemplateTable({
if (record.part != partId) {
// This test is defined for a parent part
return [
RowViewAction({
AppRowViewAction({
title: t`View Parent Part`,
modelType: ModelType.part,
modelId: record.part,
@@ -10,8 +10,7 @@ import {
type RowAction,
RowDeleteAction,
RowDuplicateAction,
RowEditAction,
RowViewAction
RowEditAction
} from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
@@ -36,6 +35,8 @@ import {
TargetDateColumn
} from '../../components/tables/ColumnRenderers';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { AppRowViewAction } from '../../components/tables/AppRowActions';
import { TableHoverCard } from '../../components/tables/TableHoverCard';
import { formatCurrency } from '../../defaults/formatters';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
@@ -376,7 +377,7 @@ export function PurchaseOrderLineItemTable({
deleteLine.open();
}
}),
RowViewAction({
AppRowViewAction({
hidden: !record.build_order,
title: t`View Build Order`,
modelType: ModelType.build,
@@ -2,11 +2,7 @@ import { t } from '@lingui/core/macro';
import { useCallback, useMemo, useState } from 'react';
import { ActionButton } from '@lib/components/ActionButton';
import {
type RowAction,
RowEditAction,
RowViewAction
} from '@lib/components/RowActions';
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';
@@ -31,6 +27,8 @@ import {
StockLocationFilter
} from '../../components/tables/Filter';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { AppRowViewAction } from '../../components/tables/AppRowActions';
import { formatDate } from '../../defaults/formatters';
import { useSalesOrderAllocationFields } from '../../forms/SalesOrderForms';
import {
@@ -259,7 +257,7 @@ export default function SalesOrderAllocationTable({
deleteAllocation.open();
}
},
RowViewAction({
AppRowViewAction({
tooltip: t`View Shipment`,
title: t`View Shipment`,
hidden: !record.shipment || !!shipmentId,
@@ -19,8 +19,7 @@ import {
type RowAction,
RowDeleteAction,
RowDuplicateAction,
RowEditAction,
RowViewAction
RowEditAction
} from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
@@ -42,6 +41,8 @@ import {
RenderPartColumn
} from '../../components/tables/ColumnRenderers';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { AppRowViewAction } from '../../components/tables/AppRowActions';
import RowExpansionIcon from '../../components/tables/RowExpansionIcon';
import { TableHoverCard } from '../../components/tables/TableHoverCard';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
@@ -549,7 +550,7 @@ export default function SalesOrderLineItemTable({
deleteLine.open();
}
}),
RowViewAction({
AppRowViewAction({
title: t`View Part`,
modelType: ModelType.part,
modelId: record.part,
@@ -11,8 +11,7 @@ import { AddItemButton } from '@lib/components/AddItemButton';
import {
type RowAction,
RowCancelAction,
RowEditAction,
RowViewAction
RowEditAction
} from '@lib/components/RowActions';
import { YesNoButton } from '@lib/components/YesNoButton';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
@@ -30,6 +29,8 @@ import {
} from '../../components/tables/ColumnRenderers';
import { TagsFilter } from '../../components/tables/Filter';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { AppRowViewAction } from '../../components/tables/AppRowActions';
import {
useCheckShipmentForm,
useCompleteShipmentForm,
@@ -255,7 +256,7 @@ export default function SalesOrderShipmentTable({
deleteShipment.open();
}
}),
RowViewAction({
AppRowViewAction({
title: t`View Sales Order`,
modelType: ModelType.salesorder,
modelId: record.order,
@@ -1,8 +1,7 @@
import {
RowDeleteAction,
RowDuplicateAction,
RowEditAction,
RowViewAction
RowEditAction
} from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
@@ -39,6 +38,8 @@ import {
RenderPartColumn
} from '../../components/tables/ColumnRenderers';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { AppRowViewAction } from '../../components/tables/AppRowActions';
import RowExpansionIcon from '../../components/tables/RowExpansionIcon';
import { TableHoverCard } from '../../components/tables/TableHoverCard';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
@@ -445,7 +446,7 @@ export default function TransferOrderLineItemTable({
deleteLine.open();
}
}),
RowViewAction({
AppRowViewAction({
title: t`View Part`,
modelType: ModelType.part,
modelId: record.part,
+23 -1
View File
@@ -14,6 +14,9 @@ import {
showParametricView,
showTableView
} from '../helpers';
import { adminuser } from '../defaults';
import { doCachedLogin } from '../login';
import { setPluginState, setSettingState } from '../settings';
@@ -676,7 +679,10 @@ test('Parts - Allocations', async ({ browser }) => {
test('Parts - Pricing (Nothing, BOM)', async ({ browser }) => {
// Part with no history
const page = await doCachedLogin(browser, { url: 'part/82/pricing' });
const page = await doCachedLogin(browser, {
url: 'part/82/pricing',
user: adminuser
});
await page.getByText('Small plastic enclosure, black').waitFor();
await loadTab(page, 'Part Pricing');
@@ -713,6 +719,18 @@ test('Parts - Pricing (Nothing, BOM)', async ({ browser }) => {
await page.getByRole('button', { name: 'Quantity Not sorted' }).waitFor();
await page.getByRole('button', { name: 'Unit Price Not sorted' }).waitFor();
// View part details via detail drawer
await page
.getByRole('cell', { name: 'Thumbnail Blue Paint' })
.first()
.click();
await page.getByRole('link', { name: 'details-part-' }).first().waitFor();
await page.getByText('Allocated to Build Orders').waitFor();
await page.getByText('440[litres]').waitFor();
// Close the drawer with the escape key
await page.keyboard.press('Escape');
// We expect some pricing data to be displayed
await page
.getByLabel('BOM Pricing')
@@ -726,6 +744,10 @@ test('Parts - Pricing (Nothing, BOM)', async ({ browser }) => {
.getByRole('table')
.getByText('Wood Screw')
.click();
// We need to navigate via the preview drawer
await page.getByRole('link', { name: 'details-part-98' }).click();
await page.waitForURL('**/part/98/**');
});
@@ -179,7 +179,7 @@ test('Purchase Orders - General', async ({ browser }) => {
await page.waitForURL('**/purchasing/index/**');
await page.getByRole('cell', { name: 'PO0012' }).click();
await page.waitForTimeout(200);
await page.waitForLoadState('networkidle');
await loadTab(page, 'Line Items');
await loadTab(page, 'Received Stock');
+11
View File
@@ -35,6 +35,17 @@ test('Exporting - Orders', async ({ browser }) => {
// Download list of purchase order items
await page.getByRole('cell', { name: 'PO0014' }).click();
// Click through to the PurchaseOrder from the detail tab
await page
.getByLabel('Purchase Order PO0014 (PCBWOY)')
.getByRole('cell', { name: 'Ordering some PCBs' })
.waitFor();
await page
.getByRole('link', { name: 'details-purchaseorder-14' })
.first()
.click();
await loadTab(page, 'Line Items');
await openExportDialog(page);
await page.getByRole('button', { name: 'Export', exact: true }).click();
@@ -75,6 +75,22 @@ test('Permissions - Reader', async ({ browser }) => {
.first()
.click();
// Click on the link in the detail drawer
await page.getByText('Allocated to Build Orders').waitFor();
await page.getByText('Component Part').waitFor();
await page
.getByRole('link', { name: 'Chair', exact: true })
.first()
.waitFor();
await page
.getByRole('link', { name: 'Chairs', exact: true })
.first()
.waitFor();
await page
.getByRole('link', { name: 'details-part-108', exact: true })
.first()
.click();
await page
.getByLabel('Part Details')
.getByText('A chair - with blue paint')