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