This commit is contained in:
Oliver 2026-07-06 09:23:41 +10:00 committed by GitHub
commit 48f698d755
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 3899 additions and 2703 deletions

View File

@ -25,6 +25,7 @@ The *Display Settings* screen shows general display configuration options:
{{ usersetting("BARCODE_IN_FORM_FIELDS") }}
{{ usersetting("DATE_DISPLAY_FORMAT") }}
{{ usersetting("FORMS_CLOSE_USING_ESCAPE") }}
{{ usersetting("ENABLE_PREVIEW_PANEL") }}
{{ usersetting("DISPLAY_STOCKTAKE_TAB") }}
{{ usersetting("SHOW_FULL_CATEGORY_IN_TABLES")}}
{{ usersetting("SHOW_BOM_SUBASSEMBLY_LEVELS")}}

View File

@ -223,6 +223,12 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
('MMM DD YYYY', 'Feb 22 2022'),
],
},
'ENABLE_PREVIEW_PANEL': {
'name': _('Enable Preview Panel'),
'description': _('Display a preview panel when selecting items in tables'),
'default': True,
'validator': bool,
},
'DISPLAY_STOCKTAKE_TAB': {
'name': _('Show Stock History'),
'description': _('Display stock history information in the part detail page'),

View File

@ -69,6 +69,12 @@ export type ImporterDrawerContext = {
sessionId: () => number | null;
};
export type PreviewDrawerContext = {
open: (modelType: ModelType, id?: number, instance?: any) => 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 +96,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 +132,7 @@ export type InvenTreePluginContext = {
};
tables: InvenTreeTablesContext<any>;
importer: ImporterDrawerContext;
preview: PreviewDrawerContext;
model?: ModelType | string;
id?: string | number | null;
instance?: any;

View File

@ -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,7 @@ 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 { useUserState } from '../../states/UserState';
import {
AssignedToMeFilter,
@ -166,14 +171,18 @@ 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) {
if (!info.event.id) return;
if (eventModified(event as any)) {
navigateToLink(
getDetailUrl(model, info.event.id),
navigate,
info.jsEvent
);
} else {
openGlobalPreview(model, Number.parseInt(info.event.id));
}
};

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]);
@ -553,7 +561,12 @@ export function DetailsTable({
<Table striped verticalSpacing={5} horizontalSpacing='sm'>
<Table.Tbody>
{visibleFields.map((field: DetailsField, index: number) => (
<DetailsTableField field={field} item={item} key={index} />
<DetailsTableField
field={field}
item={item}
showIcons={showIcons}
key={index}
/>
))}
</Table.Tbody>
</Table>

View File

@ -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);

View File

@ -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,10 @@ export function ItemDetailsGrid(props: React.PropsWithChildren<{}>) {
spacing='xs'
verticalSpacing='xs'
>
{props.children}
{children}
{visibleTables.map((props, index) => (
<DetailsTable key={index} {...props} />
))}
</SimpleGrid>
</Paper>
);

View File

@ -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
};
}

View File

@ -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>
);

View File

@ -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 { InvenTreeTableInternal } from '../../tables/InvenTreeTable';
import { EditApiForm } from '../forms/ApiForm';
@ -97,6 +102,12 @@ export const useInvenTreeContext = () => {
isOpen: () => getGlobalImporterState().isOpen,
sessionId: () => getGlobalImporterState().sessionId
},
preview: {
open: (modelType, id?, instance?) =>
openGlobalPreview(modelType, id, instance),
close: () => closeGlobalPreview(),
isOpen: () => getGlobalPreviewState().isOpen
},
tables: {
renderTable: (props: InvenTreeTableRenderProps<any>) => (
<InvenTreeTableInternal

View File

@ -0,0 +1,30 @@
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 isOpen = usePreviewDrawerState((state) => state.isOpen);
const modelType = usePreviewDrawerState((state) => state.modelType);
const id = usePreviewDrawerState((state) => state.id);
const instance = usePreviewDrawerState((state) => state.instance);
const preview = usePreviewDrawerState((state) => state.preview);
const closePreview = usePreviewDrawerState((state) => state.closePreview);
if (!enabled) {
return null;
}
return (
<PreviewDrawer
modelType={modelType}
id={id}
instance={instance}
preview={preview}
opened={isOpen}
onClose={closePreview}
/>
);
}

View File

@ -0,0 +1,172 @@
import {
ActionIcon,
Anchor,
Divider,
Drawer,
Group,
LoadingOverlay,
Stack,
Tooltip
} from '@mantine/core';
import { StylishText } from '@lib/components/StylishText';
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import { cancelEvent } from '@lib/functions/Events';
import {
eventModified,
getDetailUrl,
navigateToLink
} from '@lib/functions/Navigation';
import type { ModelType } from '@lib/index';
import { t } from '@lingui/core/macro';
import { IconArrowRight } from '@tabler/icons-react';
import type React from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useInstance } from '../../hooks/UseInstance';
import { getModelInfo } from '../render/ModelType';
import { type PreviewType, getPreviewComponentForModel } from './PreviewType';
import { FallbackPreviewComponent } from './models/Fallback';
export default function PreviewDrawer({
modelType,
id,
instance: providedInstance,
filters,
preview: providedPreview,
opened,
onClose
}: Readonly<{
modelType?: ModelType;
id?: number | string;
instance?: any;
filters?: Record<string, any>;
preview?: PreviewType;
opened: boolean;
onClose: () => void;
}>) {
const navigate = useNavigate();
const modelInfo = modelType ? getModelInfo(modelType) : null;
const apiEndpoint = modelType
? ModelInformationDict[modelType].api_endpoint
: undefined;
const { instance: fetchedInstance, instanceQuery } = useInstance({
endpoint: apiEndpoint!,
pk: id,
hasPrimaryKey: true,
defaultValue: {},
params: filters,
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: PreviewType | null = getPreviewComponentForModel({
modelType,
instance,
modelId: typeof id === 'string' ? Number(id) : id
});
if (component == null) {
return FallbackPreviewComponent({
modelInfo,
modelType,
modelId: id,
instance
});
}
return component;
}, [providedPreview, modelType, id, instance]);
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 clickTitle = useCallback(
(
event: Parameters<
React.AnchorHTMLAttributes<HTMLAnchorElement>['onClick'] & {}
>[0]
) => {
if (!modelType || !id) return;
if (!eventModified(event as any)) {
onClose();
}
navigateToLink(getDetailUrl(modelType!, id!), navigate, event as any);
},
[modelType, id, navigate]
);
return (
<Drawer
position='right'
size='xl'
title={
previewComponent ? (
!!modelType && !!id ? (
<Anchor
href={getDetailUrl(modelType!, id, true)}
onClick={(e) => clickTitle(e)}
>
<Group gap='xs'>
<Tooltip label={t`View Details`} position='left'>
<ActionIcon variant='transparent' size='lg'>
<IconArrowRight />
</ActionIcon>
</Tooltip>
<StylishText size='lg'>{previewComponent.title}</StylishText>
</Group>
</Anchor>
) : (
<StylishText size='lg'>{previewComponent.title}</StylishText>
)
) : null
}
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>
);
}

View File

@ -0,0 +1,67 @@
import { ModelType } from '@lib/enums/ModelType';
import type { ReactNode } from 'react';
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';
export interface PreviewType {
preview: ReactNode;
title: string;
}
export type PreviewComponentProps = {
instance: any;
};
export type PreviewComponent = (props: PreviewComponentProps) => PreviewType;
export function getPreviewComponentForModel({
modelType,
instance,
modelId
}: {
modelType: ModelType;
instance: any;
modelId: number;
}): PreviewType | null {
switch (modelType) {
case ModelType.part:
return PartPreviewComponent({ instance, modelId });
case ModelType.stockitem:
return StockPreviewComponent({ instance, modelId });
case ModelType.purchaseorder:
return PurchaseOrderPreviewComponent({ instance, modelId });
case ModelType.salesorder:
return SalesOrderPreviewComponent({ instance, modelId });
case ModelType.returnorder:
return ReturnOrderPreviewComponent({ instance, modelId });
case ModelType.supplierpart:
return SupplierPartPreviewComponent({ instance, modelId });
case ModelType.manufacturerpart:
return ManufacturerPartPreviewComponent({ instance, modelId });
case ModelType.company:
return CompanyPreviewComponent({ instance, modelId });
case ModelType.build:
return BuildOrderPreviewComponent({ instance, modelId });
case ModelType.salesordershipment:
return SalesOrderShipmentPreviewComponent({ instance, modelId });
case ModelType.transferorder:
return TransferOrderPreviewComponent({ instance, modelId });
case ModelType.stocklocation:
return StockLocationPreviewComponent({ instance, modelId });
case ModelType.partcategory:
return PartCategoryPreviewComponent({ instance, modelId });
default:
return null;
}
}

View File

@ -0,0 +1,25 @@
import { t } from '@lingui/core/macro';
import { BuildOrderDetailsPanel } from '../../../pages/build/BuildOrderDetailsPanel';
import type { PreviewType } from '../PreviewType';
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} />
};
}

View File

@ -0,0 +1,18 @@
import { t } from '@lingui/core/macro';
import { CompanyDetailsPanel } from '../../../pages/company/CompanyDetailsPanel';
import type { PreviewType } from '../PreviewType';
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} />
};
}

View File

@ -0,0 +1,31 @@
import type { ModelInformationInterface } from '@lib/enums/ModelInformation';
import type { ModelType } from '@lib/enums/ModelType';
import { t } from '@lingui/core/macro';
import { Alert, Text } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';
import type { PreviewType } from '../PreviewType';
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>
)
};
}

View File

@ -0,0 +1,26 @@
import { t } from '@lingui/core/macro';
import { ManufacturerPartDetailsPanel } from '../../../pages/company/ManufacturerPartDetailsPanel';
import type { PreviewType } from '../PreviewType';
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} />
};
}

View File

@ -0,0 +1,18 @@
import { t } from '@lingui/core/macro';
import { PartCategoryDetailsPanel } from '../../../pages/part/PartCategoryDetailsPanel';
import type { PreviewType } from '../PreviewType';
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} />
};
}

View File

@ -0,0 +1,15 @@
import { PartDetailsPanel } from '../../../pages/part/PartDetailsPanel';
import type { PreviewType } from '../PreviewType';
export function PartPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
return {
title: instance?.full_name || instance?.name || `Part #${modelId}`,
preview: <PartDetailsPanel instance={instance} />
};
}

View File

@ -0,0 +1,25 @@
import { t } from '@lingui/core/macro';
import { PurchaseOrderDetailsPanel } from '../../../pages/purchasing/PurchaseOrderDetailsPanel';
import type { PreviewType } from '../PreviewType';
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} />
};
}

View File

@ -0,0 +1,25 @@
import { t } from '@lingui/core/macro';
import { ReturnOrderDetailsPanel } from '../../../pages/sales/ReturnOrderDetailsPanel';
import type { PreviewType } from '../PreviewType';
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} />
};
}

View File

@ -0,0 +1,25 @@
import { t } from '@lingui/core/macro';
import { SalesOrderDetailsPanel } from '../../../pages/sales/SalesOrderDetailsPanel';
import type { PreviewType } from '../PreviewType';
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} />
};
}

View File

@ -0,0 +1,25 @@
import { t } from '@lingui/core/macro';
import { SalesOrderShipmentDetailsPanel } from '../../../pages/sales/SalesOrderShipmentDetailsPanel';
import type { PreviewType } from '../PreviewType';
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} />
};
}

View File

@ -0,0 +1,18 @@
import { t } from '@lingui/core/macro';
import { StockLocationDetailsPanel } from '../../../pages/stock/StockLocationDetailsPanel';
import type { PreviewType } from '../PreviewType';
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} />
};
}

View File

@ -0,0 +1,30 @@
import { formatDecimal } from '@lib/functions/Formatting';
import { StockDetailsPanel } from '../../../pages/stock/StockDetailsPanel';
import type { PreviewType } from '../PreviewType';
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} />
};
}

View File

@ -0,0 +1,25 @@
import { t } from '@lingui/core/macro';
import { SupplierPartDetailsPanel } from '../../../pages/company/SupplierPartDetailsPanel';
import type { PreviewType } from '../PreviewType';
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} />
};
}

View File

@ -0,0 +1,18 @@
import { t } from '@lingui/core/macro';
import { TransferOrderDetailsPanel } from '../../../pages/stock/TransferOrderDetailsPanel';
import type { PreviewType } from '../PreviewType';
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} />
};
}

View File

@ -59,6 +59,7 @@ export default function UserSettings() {
'BARCODE_IN_FORM_FIELDS',
'DATE_DISPLAY_FORMAT',
'FORMS_CLOSE_USING_ESCAPE',
'ENABLE_PREVIEW_PANEL',
'DISPLAY_STOCKTAKE_TAB',
'ENABLE_LAST_BREADCRUMB',
'SHOW_EXTRA_MODEL_INFO',

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 (
@ -235,239 +229,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',
@ -597,7 +371,7 @@ export default function BuildDetail() {
build,
id,
user,
partRequirements,
buildStatus,
globalSettings,
showChildBuilds,

View File

@ -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>
);
}

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 [

View File

@ -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>
);
}

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,
@ -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',

View File

@ -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>
);
}

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 {
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',

View File

@ -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>
);
}

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>

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]);

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
},
{

View File

@ -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
]}
/>
);
}

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,398 +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: '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(() => {
@ -675,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',
@ -903,8 +519,9 @@ export default function PartDetail() {
user,
globalSettings,
userSettings,
detailsPanel,
bomInformation
bomInformation,
revisionSelector,
refreshInstance
]);
const breadcrumbs = useMemo(() => {

View File

@ -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>
);
}

View File

@ -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',

View File

@ -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>
);
}

View File

@ -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',

View File

@ -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>
);
}

View File

@ -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',

View File

@ -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>
);
}

View File

@ -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}

View File

@ -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>
);
}

View File

@ -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');

View File

@ -1,25 +1,12 @@
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,
IconBookmark,
IconBoxPadding,
IconChecklist,
IconHistory,
IconInfoCircle,
IconPackages,
IconSearch,
IconShoppingCart,
IconSitemap,
IconTransform
@ -28,27 +15,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,
@ -67,9 +46,8 @@ 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 {
useFindSerialNumberForm,
useStockFields,
useStockItemSerializeFields
} from '../../forms/StockForms';
@ -90,6 +68,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();
@ -124,367 +103,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 (
@ -555,7 +180,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',
@ -686,8 +318,6 @@ export default function StockDetail() {
showBuildAllocations,
showInstalledItems,
stockitem,
serialNumbers,
serialNumbersQuery,
id,
user
]);
@ -1063,7 +693,6 @@ export default function StockDetail() {
return (
<>
{findBySerialNumber.modal}
{scanIntoLocation.dialog}
<InstanceDetail
query={instanceQuery}

View File

@ -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>
</>
);
}

View File

@ -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
]}
/>
);
}

View File

@ -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 [

View File

@ -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>
);
}

View File

@ -0,0 +1,65 @@
import type { ModelType } from '@lib/enums/ModelType';
import { create } from 'zustand';
import type { PreviewType } from '../components/previews/PreviewType';
interface PreviewDrawerStateProps {
isOpen: boolean;
modelType?: ModelType;
id?: number | string;
instance?: any;
preview?: PreviewType;
openPreview: (
modelType: ModelType,
id: number | string | undefined,
instance?: any,
preview?: PreviewType
) => void;
closePreview: () => void;
}
export const usePreviewDrawerState = create<PreviewDrawerStateProps>()(
(set) => ({
isOpen: false,
modelType: undefined,
id: undefined,
instance: undefined,
preview: undefined,
openPreview: (
modelType: ModelType,
id: number | string | undefined,
instance?: any,
preview?: PreviewType
) => {
set({ modelType, id, instance, preview, isOpen: true });
},
closePreview: () => {
set({
isOpen: false,
instance: undefined,
id: undefined,
preview: undefined
});
}
})
);
export function openGlobalPreview(
modelType: ModelType,
id?: number | string | undefined,
instance?: any,
preview?: PreviewType
) {
usePreviewDrawerState
.getState()
.openPreview(modelType, id, instance, preview);
}
export function closeGlobalPreview() {
usePreviewDrawerState.getState().closePreview();
}
export function getGlobalPreviewState() {
return usePreviewDrawerState.getState();
}

View File

@ -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';
@ -700,6 +701,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,7 +745,12 @@ 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 (eventModified(event as any)) {
navigateToLink(url, navigate, event);
} else {
showRowPreview(pk);
}
}
}
},
@ -796,7 +814,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);
}
}
});
}

View File

@ -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 { companyFields } from '../../forms/CompanyForms';
@ -16,6 +16,7 @@ import {
useCreateApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { usePreviewDrawerState } from '../../states/PreviewDrawerState';
import { useUserState } from '../../states/UserState';
import {
BooleanColumn,
@ -49,6 +50,7 @@ export function CompanyTable({
const navigate = useNavigate();
const user = useUserState();
const previewDrawer = usePreviewDrawerState();
const columns = useMemo(() => {
return [
@ -166,10 +168,16 @@ 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;
if (eventModified(event as any)) {
navigateToLink(
`/${path ?? 'company'}/${record.pk}`,
navigate,
event
);
} else {
previewDrawer.openPreview(ModelType.company, Number(record.pk));
}
},
modelType: ModelType.company,

View File

@ -1,4 +1,6 @@
import { cancelEvent } from '@lib/functions/Events';
import { eventModified } from '@lib/functions/Navigation';
import { getDetailUrl, navigateToLink } from '@lib/functions/Navigation';
import useTable from '@lib/hooks/UseTable';
import {
ApiEndpoints,
@ -7,9 +9,7 @@ import {
UserRoles,
YesNoButton,
apiUrl,
formatDecimal,
getDetailUrl,
navigateToLink
formatDecimal
} from '@lib/index';
import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables';
@ -27,6 +27,7 @@ import {
useCreateApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { openGlobalPreview } from '../../states/PreviewDrawerState';
import { useUserState } from '../../states/UserState';
import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard';
@ -472,9 +473,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);
}
}
}
}}