From 8b7b00b7cb556dd257b2f6470c87184aa36f3940 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 8 Jun 2026 04:36:16 +0000 Subject: [PATCH 01/34] Add component --- src/frontend/src/components/nav/Layout.tsx | 2 + .../previews/GlobalPreviewDrawer.tsx | 22 +++++++++++ .../src/components/previews/PreviewDrawer.tsx | 39 +++++++++++++++++++ .../src/states/PreviewDrawerState.tsx | 37 ++++++++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 src/frontend/src/components/previews/GlobalPreviewDrawer.tsx create mode 100644 src/frontend/src/components/previews/PreviewDrawer.tsx create mode 100644 src/frontend/src/states/PreviewDrawerState.tsx diff --git a/src/frontend/src/components/nav/Layout.tsx b/src/frontend/src/components/nav/Layout.tsx index c3b084c835..c13e8e50b8 100644 --- a/src/frontend/src/components/nav/Layout.tsx +++ b/src/frontend/src/components/nav/Layout.tsx @@ -29,6 +29,7 @@ import { type PluginUIFeature, PluginUIFeatureType } from '../plugins/PluginUIFeature'; +import GlobalPreviewDrawer from '../previews/GlobalPreviewDrawer'; import { Footer } from './Footer'; import { Header } from './Header'; @@ -146,6 +147,7 @@ export default function LayoutComponent() { )} + ); diff --git a/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx b/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx new file mode 100644 index 0000000000..c926fef5a3 --- /dev/null +++ b/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx @@ -0,0 +1,22 @@ +import { usePreviewDrawerState } from '../../states/PreviewDrawerState'; +import PreviewDrawer from './PreviewDrawer'; + +export default function GlobalPreviewDrawer() { + const isOpen = usePreviewDrawerState((state) => state.isOpen); + const modelType = usePreviewDrawerState((state) => state.modelType); + const instance = usePreviewDrawerState((state) => state.instance); + const closePreview = usePreviewDrawerState((state) => state.closePreview); + + if (!isOpen || instance === null) { + return null; + } + + return ( + + ); +} diff --git a/src/frontend/src/components/previews/PreviewDrawer.tsx b/src/frontend/src/components/previews/PreviewDrawer.tsx new file mode 100644 index 0000000000..1a0be1edaa --- /dev/null +++ b/src/frontend/src/components/previews/PreviewDrawer.tsx @@ -0,0 +1,39 @@ +import { t } from '@lingui/core/macro'; +import { Drawer, Stack, Text } from '@mantine/core'; + +import { StylishText } from '@lib/components/StylishText'; +import type { ModelType } from '@lib/index'; +import { getModelInfo } from '../render/ModelType'; + +export default function PreviewDrawer({ + modelType, + instance, + opened, + onClose +}: Readonly<{ + modelType?: ModelType; + instance?: any; + opened: boolean; + onClose: () => void; +}>) { + const modelInfo = getModelInfo(instance.model); + + return ( + + TODO - implement actual preview content + + } + opened={opened} + onClose={onClose} + withCloseButton + > + + {t`Preview for ${modelInfo.label} #${instance.pk}`} + + + ); +} diff --git a/src/frontend/src/states/PreviewDrawerState.tsx b/src/frontend/src/states/PreviewDrawerState.tsx new file mode 100644 index 0000000000..9ff5574c7f --- /dev/null +++ b/src/frontend/src/states/PreviewDrawerState.tsx @@ -0,0 +1,37 @@ +import type { ModelType } from '@lib/enums/ModelType'; +import { create } from 'zustand'; + +interface PreviewDrawerStateProps { + isOpen: boolean; + modelType?: ModelType; + instance?: any; + openPreview: (modelType: ModelType, instance: any) => void; + closePreview: () => void; +} + +export const usePreviewDrawerState = create()( + (set) => ({ + isOpen: false, + instance: null, + + openPreview: (modelType: ModelType, instance: any) => { + set({ modelType, instance, isOpen: true }); + }, + + closePreview: () => { + set({ modelType: undefined, instance: null, isOpen: false }); + } + }) +); + +export function openGlobalPreview(modelType: ModelType, instance: any) { + usePreviewDrawerState.getState().openPreview(modelType, instance); +} + +export function closeGlobalPreview() { + usePreviewDrawerState.getState().closePreview(); +} + +export function getGlobalPreviewState() { + return usePreviewDrawerState.getState(); +} From c7299e2ad8e9f651214c6417e154cb51a74a3b4a Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 8 Jun 2026 04:50:52 +0000 Subject: [PATCH 02/34] Allow plugins to interact --- src/frontend/lib/types/Plugins.tsx | 8 ++++++++ src/frontend/src/components/plugins/PluginContext.tsx | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/frontend/lib/types/Plugins.tsx b/src/frontend/lib/types/Plugins.tsx index 2038b1f297..bf8bc48c59 100644 --- a/src/frontend/lib/types/Plugins.tsx +++ b/src/frontend/lib/types/Plugins.tsx @@ -69,6 +69,12 @@ export type ImporterDrawerContext = { sessionId: () => number | null; }; +export type PreviewDrawerContext = { + open: (modelType: ModelType, instance: any) => void; + close: () => void; + isOpen: () => boolean; +}; + /** * A set of properties which are passed to a plugin, * for rendering an element in the user interface. @@ -90,6 +96,7 @@ export type ImporterDrawerContext = { * @param forms - A set of functions for opening various API forms (see ../components/Forms.tsx) * @param tables - A set of functions for rendering API tables * @param importer - A set of functions for controlling the global importer drawer (see ../components/importer/GlobalImporterDrawer.tsx) + * @param preview - A set of functions for controlling the global preview drawer (see ../components/previews/GlobalPreviewDrawer.tsx) * @param model - The model type associated with the rendered component (if applicable) * @param id - The ID (primary key) of the model instance for the plugin (if applicable) * @param instance - The model instance data (if available) @@ -125,6 +132,7 @@ export type InvenTreePluginContext = { }; tables: InvenTreeTablesContext; importer: ImporterDrawerContext; + preview: PreviewDrawerContext; model?: ModelType | string; id?: string | number | null; instance?: any; diff --git a/src/frontend/src/components/plugins/PluginContext.tsx b/src/frontend/src/components/plugins/PluginContext.tsx index e6790c4c49..fac14d4626 100644 --- a/src/frontend/src/components/plugins/PluginContext.tsx +++ b/src/frontend/src/components/plugins/PluginContext.tsx @@ -46,6 +46,11 @@ import { openGlobalImporter } from '../../states/ImporterState'; import { usePluginState } from '../../states/PluginState'; +import { + closeGlobalPreview, + getGlobalPreviewState, + openGlobalPreview +} from '../../states/PreviewDrawerState'; import { useServerApiState } from '../../states/ServerApiState'; import { InvenTreeTableInternal } from '../../tables/InvenTreeTable'; import { EditApiForm } from '../forms/ApiForm'; @@ -97,6 +102,11 @@ export const useInvenTreeContext = () => { isOpen: () => getGlobalImporterState().isOpen, sessionId: () => getGlobalImporterState().sessionId }, + preview: { + open: (modelType, instance) => openGlobalPreview(modelType, instance), + close: () => closeGlobalPreview(), + isOpen: () => getGlobalPreviewState().isOpen + }, tables: { renderTable: (props: InvenTreeTableRenderProps) => ( Date: Mon, 8 Jun 2026 04:55:41 +0000 Subject: [PATCH 03/34] provide either pk or instance data --- src/frontend/lib/types/Plugins.tsx | 2 +- .../src/components/plugins/PluginContext.tsx | 3 +- .../previews/GlobalPreviewDrawer.tsx | 4 ++- .../src/components/previews/PreviewDrawer.tsx | 35 +++++++++++++------ .../src/states/PreviewDrawerState.tsx | 26 ++++++++++---- 5 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/frontend/lib/types/Plugins.tsx b/src/frontend/lib/types/Plugins.tsx index bf8bc48c59..f5c9255b41 100644 --- a/src/frontend/lib/types/Plugins.tsx +++ b/src/frontend/lib/types/Plugins.tsx @@ -70,7 +70,7 @@ export type ImporterDrawerContext = { }; export type PreviewDrawerContext = { - open: (modelType: ModelType, instance: any) => void; + open: (modelType: ModelType, id?: number, instance?: any) => void; close: () => void; isOpen: () => boolean; }; diff --git a/src/frontend/src/components/plugins/PluginContext.tsx b/src/frontend/src/components/plugins/PluginContext.tsx index fac14d4626..c00ccb2ebc 100644 --- a/src/frontend/src/components/plugins/PluginContext.tsx +++ b/src/frontend/src/components/plugins/PluginContext.tsx @@ -103,7 +103,8 @@ export const useInvenTreeContext = () => { sessionId: () => getGlobalImporterState().sessionId }, preview: { - open: (modelType, instance) => openGlobalPreview(modelType, instance), + open: (modelType, id?, instance?) => + openGlobalPreview(modelType, id, instance), close: () => closeGlobalPreview(), isOpen: () => getGlobalPreviewState().isOpen }, diff --git a/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx b/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx index c926fef5a3..078d13a211 100644 --- a/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx +++ b/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx @@ -4,16 +4,18 @@ import PreviewDrawer from './PreviewDrawer'; export default function GlobalPreviewDrawer() { const isOpen = usePreviewDrawerState((state) => state.isOpen); const modelType = usePreviewDrawerState((state) => state.modelType); + const id = usePreviewDrawerState((state) => state.id); const instance = usePreviewDrawerState((state) => state.instance); const closePreview = usePreviewDrawerState((state) => state.closePreview); - if (!isOpen || instance === null) { + if (!isOpen || modelType === undefined || id === undefined) { return null; } return ( void; }>) { - const modelInfo = getModelInfo(instance.model); + const modelInfo = getModelInfo(modelType); + const apiEndpoint = ModelInformationDict[modelType].api_endpoint; + + const { instance: fetchedInstance, instanceQuery } = useInstance({ + endpoint: apiEndpoint, + pk: id, + hasPrimaryKey: true, + defaultValue: {}, + disabled: !!providedInstance + }); + + const instance = providedInstance ?? fetchedInstance; return ( - TODO - implement actual preview content - - } + title={{`${modelInfo.label} #${id}`}} opened={opened} onClose={onClose} withCloseButton > - {t`Preview for ${modelInfo.label} #${instance.pk}`} + {!instance && instanceQuery.isFetching ? ( + + ) : ( + {t`Preview for ${modelInfo.label} #${id}`} + )} ); diff --git a/src/frontend/src/states/PreviewDrawerState.tsx b/src/frontend/src/states/PreviewDrawerState.tsx index 9ff5574c7f..b079958d62 100644 --- a/src/frontend/src/states/PreviewDrawerState.tsx +++ b/src/frontend/src/states/PreviewDrawerState.tsx @@ -4,28 +4,40 @@ import { create } from 'zustand'; interface PreviewDrawerStateProps { isOpen: boolean; modelType?: ModelType; + id?: number; instance?: any; - openPreview: (modelType: ModelType, instance: any) => void; + openPreview: (modelType: ModelType, id: number, instance?: any) => void; closePreview: () => void; } export const usePreviewDrawerState = create()( (set) => ({ isOpen: false, - instance: null, + modelType: undefined, + id: undefined, + instance: undefined, - openPreview: (modelType: ModelType, instance: any) => { - set({ modelType, instance, isOpen: true }); + openPreview: (modelType: ModelType, id: number, instance?: any) => { + set({ modelType, id, instance, isOpen: true }); }, closePreview: () => { - set({ modelType: undefined, instance: null, isOpen: false }); + set({ + modelType: undefined, + id: undefined, + instance: undefined, + isOpen: false + }); } }) ); -export function openGlobalPreview(modelType: ModelType, instance: any) { - usePreviewDrawerState.getState().openPreview(modelType, instance); +export function openGlobalPreview( + modelType: ModelType, + id?: number, + instance?: any +) { + usePreviewDrawerState.getState().openPreview(modelType, id, instance); } export function closeGlobalPreview() { From 9269b6b9dac5fa4317c042591e5297ca4d9742e3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 8 Jun 2026 08:45:45 +0000 Subject: [PATCH 04/34] open preview drawer from calendar views --- src/frontend/src/components/calendar/OrderCalendar.tsx | 9 +++++++-- .../src/components/previews/GlobalPreviewDrawer.tsx | 3 ++- src/frontend/src/components/previews/PreviewDrawer.tsx | 7 ++++++- src/frontend/src/states/PreviewDrawerState.tsx | 7 +------ 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/frontend/src/components/calendar/OrderCalendar.tsx b/src/frontend/src/components/calendar/OrderCalendar.tsx index 0f468a7041..4bf4ea9ca8 100644 --- a/src/frontend/src/components/calendar/OrderCalendar.tsx +++ b/src/frontend/src/components/calendar/OrderCalendar.tsx @@ -22,6 +22,7 @@ import { useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { api } from '../../App'; import useCalendar from '../../hooks/UseCalendar'; +import { openGlobalPreview } from '../../states/PreviewDrawerState'; import { useUserState } from '../../states/UserState'; import { AssignedToMeFilter, @@ -166,14 +167,18 @@ export default function OrderCalendar({ } }; - // Callback when PurchaseOrder is clicked + // Callback when an order is clicked - open preview drawer, or navigate on modifier click const onClickOrder = (info: EventClickArg) => { - if (!!info.event.id) { + if (!info.event.id) return; + + if (info.jsEvent.ctrlKey || info.jsEvent.metaKey || info.jsEvent.shiftKey) { navigateToLink( getDetailUrl(model, info.event.id), navigate, info.jsEvent ); + } else { + openGlobalPreview(model, Number.parseInt(info.event.id)); } }; diff --git a/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx b/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx index 078d13a211..d398f404ea 100644 --- a/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx +++ b/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx @@ -8,7 +8,8 @@ export default function GlobalPreviewDrawer() { const instance = usePreviewDrawerState((state) => state.instance); const closePreview = usePreviewDrawerState((state) => state.closePreview); - if (!isOpen || modelType === undefined || id === undefined) { + // Only skip rendering before the drawer has ever been opened + if (modelType === undefined || id === undefined) { return null; } diff --git a/src/frontend/src/components/previews/PreviewDrawer.tsx b/src/frontend/src/components/previews/PreviewDrawer.tsx index 12c11c6eb9..195b3872cf 100644 --- a/src/frontend/src/components/previews/PreviewDrawer.tsx +++ b/src/frontend/src/components/previews/PreviewDrawer.tsx @@ -36,11 +36,16 @@ export default function PreviewDrawer({ return ( {`${modelInfo.label} #${id}`}} opened={opened} onClose={onClose} withCloseButton + transitionProps={{ + transition: 'slide-left', + duration: 300, + timingFunction: 'ease' + }} > {!instance && instanceQuery.isFetching ? ( diff --git a/src/frontend/src/states/PreviewDrawerState.tsx b/src/frontend/src/states/PreviewDrawerState.tsx index b079958d62..29488ce97a 100644 --- a/src/frontend/src/states/PreviewDrawerState.tsx +++ b/src/frontend/src/states/PreviewDrawerState.tsx @@ -22,12 +22,7 @@ export const usePreviewDrawerState = create()( }, closePreview: () => { - set({ - modelType: undefined, - id: undefined, - instance: undefined, - isOpen: false - }); + set({ isOpen: false }); } }) ); From a0f90b2204f604f449eb9902dce82085a89ba35b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 09:39:08 +0000 Subject: [PATCH 05/34] Launch preview drawer directly from tables --- .../src/states/PreviewDrawerState.tsx | 14 ++++++++-- src/frontend/src/tables/InvenTreeTable.tsx | 28 +++++++++++++++++-- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/frontend/src/states/PreviewDrawerState.tsx b/src/frontend/src/states/PreviewDrawerState.tsx index 29488ce97a..8e7fc84c29 100644 --- a/src/frontend/src/states/PreviewDrawerState.tsx +++ b/src/frontend/src/states/PreviewDrawerState.tsx @@ -4,9 +4,13 @@ import { create } from 'zustand'; interface PreviewDrawerStateProps { isOpen: boolean; modelType?: ModelType; - id?: number; + id?: number | string; instance?: any; - openPreview: (modelType: ModelType, id: number, instance?: any) => void; + openPreview: ( + modelType: ModelType, + id: number | string, + instance?: any + ) => void; closePreview: () => void; } @@ -17,7 +21,11 @@ export const usePreviewDrawerState = create()( id: undefined, instance: undefined, - openPreview: (modelType: ModelType, id: number, instance?: any) => { + openPreview: ( + modelType: ModelType, + id: number | string, + instance?: any + ) => { set({ modelType, id, instance, isOpen: true }); }, diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index 29ac6661cc..b6b1981f4c 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -5,7 +5,7 @@ import { ModelInformationDict } from '@lib/enums/ModelInformation'; import { resolveItem } from '@lib/functions/Conversion'; import { cancelEvent } from '@lib/functions/Events'; import { mapFields } from '@lib/functions/Forms'; -import { getDetailUrl } from '@lib/functions/Navigation'; +import { eventModified, getDetailUrl } from '@lib/functions/Navigation'; import { navigateToLink } from '@lib/functions/Navigation'; import { useStoredTableState } from '@lib/states/StoredTableState'; import type { TableFilter } from '@lib/types/Filters'; @@ -37,6 +37,7 @@ import { useApi } from '../contexts/ApiContext'; import { extractAvailableFields } from '../functions/forms'; import { showApiErrorMessage } from '../functions/notifications'; import { useLocalState } from '../states/LocalState'; +import { usePreviewDrawerState } from '../states/PreviewDrawerState'; import { useUserSettingsState } from '../states/SettingsStates'; import { ColumnFilterPopover } from './FilterSelectDrawer'; import InvenTreeTableHeader from './InvenTreeTableHeader'; @@ -700,6 +701,18 @@ export function InvenTreeTableInternal>({ tableState.setRecords(tableData ?? apiData ?? []); }, [tableData, apiData]); + const previewDrawer = usePreviewDrawerState(); + + // Callback to display "preview" view for a row (if available) + const showRowPreview = useCallback( + (pk: string | number) => { + if (tableProps.modelType && pk) { + previewDrawer.openPreview(tableProps.modelType, Number(pk)); + } + }, + [tableProps.modelType] + ); + // Callback when a cell is clicked const handleCellClick = useCallback( ({ @@ -732,7 +745,12 @@ export function InvenTreeTableInternal>({ cancelEvent(event); // If a model type is provided, navigate to the detail view for that model const url = getDetailUrl(tableProps.modelType, pk); - navigateToLink(url, navigate, event); + + if (eventModified(event as any)) { + navigateToLink(url, navigate, event); + } else { + showRowPreview(pk); + } } } }, @@ -796,7 +814,11 @@ export function InvenTreeTableInternal>({ icon: , onClick: (event: any) => { cancelEvent(event); - navigateToLink(url, navigate, event); + if (eventModified(event as any)) { + navigateToLink(url, navigate, event); + } else { + showRowPreview(pk); + } } }); } From ce87b33c8463068fe0305294d59758e7fe81774f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 09:58:27 +0000 Subject: [PATCH 06/34] Add preview types --- .../src/components/previews/PreviewDrawer.tsx | 40 ++++++++++++++----- .../src/components/previews/PreviewType.tsx | 25 ++++++++++++ .../components/previews/models/Fallback.tsx | 31 ++++++++++++++ 3 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 src/frontend/src/components/previews/PreviewType.tsx create mode 100644 src/frontend/src/components/previews/models/Fallback.tsx diff --git a/src/frontend/src/components/previews/PreviewDrawer.tsx b/src/frontend/src/components/previews/PreviewDrawer.tsx index 195b3872cf..dc3173c719 100644 --- a/src/frontend/src/components/previews/PreviewDrawer.tsx +++ b/src/frontend/src/components/previews/PreviewDrawer.tsx @@ -1,22 +1,26 @@ -import { t } from '@lingui/core/macro'; -import { Drawer, Loader, Stack, Text } from '@mantine/core'; +import { Divider, Drawer, LoadingOverlay, Stack } from '@mantine/core'; import { StylishText } from '@lib/components/StylishText'; import { ModelInformationDict } from '@lib/enums/ModelInformation'; import type { ModelType } from '@lib/index'; +import { useMemo } from 'react'; import { useInstance } from '../../hooks/UseInstance'; import { getModelInfo } from '../render/ModelType'; +import { type PreviewType, getPreviewComponentForModel } from './PreviewType'; +import { FallbackPreviewComponent } from './models/Fallback'; export default function PreviewDrawer({ modelType, id, instance: providedInstance, + filters, opened, onClose }: Readonly<{ modelType: ModelType; id: number; instance?: any; + filters?: Record; opened: boolean; onClose: () => void; }>) { @@ -28,16 +32,36 @@ export default function PreviewDrawer({ pk: id, hasPrimaryKey: true, defaultValue: {}, + params: filters, disabled: !!providedInstance }); - const instance = providedInstance ?? fetchedInstance; + const instance = useMemo(() => { + return providedInstance ?? fetchedInstance; + }, [providedInstance, fetchedInstance]); + + const previewComponent: PreviewType = useMemo(() => { + const component: PreviewType | null = getPreviewComponentForModel({ + modelType + }); + + if (component == null) { + return FallbackPreviewComponent({ + modelInfo, + modelType, + modelId: id, + instance + }); + } + + return component; + }, [modelType]); return ( {`${modelInfo.label} #${id}`}} + title={{previewComponent.title}} opened={opened} onClose={onClose} withCloseButton @@ -48,11 +72,9 @@ export default function PreviewDrawer({ }} > - {!instance && instanceQuery.isFetching ? ( - - ) : ( - {t`Preview for ${modelInfo.label} #${id}`} - )} + + + {previewComponent.preview} ); diff --git a/src/frontend/src/components/previews/PreviewType.tsx b/src/frontend/src/components/previews/PreviewType.tsx new file mode 100644 index 0000000000..7f63922c8e --- /dev/null +++ b/src/frontend/src/components/previews/PreviewType.tsx @@ -0,0 +1,25 @@ +import type { ModelType } from '@lib/enums/ModelType'; +import type { ReactNode } from 'react'; + +export interface PreviewType { + preview: ReactNode; + title: string; +} + +export type PreviewComponentProps = { + instance: any; +}; + +export type PreviewComponent = (props: PreviewComponentProps) => PreviewType; + +export function getPreviewComponentForModel({ + modelType +}: { + modelType: ModelType; +}): PreviewType | null { + switch (modelType) { + default: + // Return null to indicate that this model type is not supported + return null; + } +} diff --git a/src/frontend/src/components/previews/models/Fallback.tsx b/src/frontend/src/components/previews/models/Fallback.tsx new file mode 100644 index 0000000000..8e7998a479 --- /dev/null +++ b/src/frontend/src/components/previews/models/Fallback.tsx @@ -0,0 +1,31 @@ +import type { ModelInformationInterface } from '@lib/enums/ModelInformation'; +import type { ModelType } from '@lib/enums/ModelType'; +import { t } from '@lingui/core/macro'; +import { Alert, Text } from '@mantine/core'; +import { IconExclamationCircle } from '@tabler/icons-react'; +import type { PreviewType } from '../PreviewType'; + +export function FallbackPreviewComponent({ + modelInfo, + modelType, + modelId, + instance +}: { + modelInfo: ModelInformationInterface; + modelType: ModelType; + modelId: number | string; + instance: any; +}): PreviewType { + return { + title: `${modelInfo.label} #${modelId}`, + preview: ( + } + > + {t`No preview available for this model.`} + + ) + }; +} From 847e34b84976066a37fd174b530e22a69311c57d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 09:59:13 +0000 Subject: [PATCH 07/34] Fix types --- src/frontend/src/states/PreviewDrawerState.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/states/PreviewDrawerState.tsx b/src/frontend/src/states/PreviewDrawerState.tsx index 8e7fc84c29..9e2c82bb54 100644 --- a/src/frontend/src/states/PreviewDrawerState.tsx +++ b/src/frontend/src/states/PreviewDrawerState.tsx @@ -8,7 +8,7 @@ interface PreviewDrawerStateProps { instance?: any; openPreview: ( modelType: ModelType, - id: number | string, + id: number | string | undefined, instance?: any ) => void; closePreview: () => void; @@ -23,7 +23,7 @@ export const usePreviewDrawerState = create()( openPreview: ( modelType: ModelType, - id: number | string, + id: number | string | undefined, instance?: any ) => { set({ modelType, id, instance, isOpen: true }); @@ -37,7 +37,7 @@ export const usePreviewDrawerState = create()( export function openGlobalPreview( modelType: ModelType, - id?: number, + id?: number | string | undefined, instance?: any ) { usePreviewDrawerState.getState().openPreview(modelType, id, instance); From 98dcf482cc1cc3f6ef7939b8c4637360cecd2432 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 09:59:32 +0000 Subject: [PATCH 08/34] CLear data on close --- src/frontend/src/states/PreviewDrawerState.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/states/PreviewDrawerState.tsx b/src/frontend/src/states/PreviewDrawerState.tsx index 9e2c82bb54..479e7644e3 100644 --- a/src/frontend/src/states/PreviewDrawerState.tsx +++ b/src/frontend/src/states/PreviewDrawerState.tsx @@ -30,7 +30,12 @@ export const usePreviewDrawerState = create()( }, closePreview: () => { - set({ isOpen: false }); + set({ + isOpen: false, + modelType: undefined, + id: undefined, + instance: undefined + }); } }) ); From 4c8c54877067562319dd3737e87f8d6c2377b095 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 10:10:19 +0000 Subject: [PATCH 09/34] Fix transition --- .../previews/GlobalPreviewDrawer.tsx | 5 --- .../src/components/previews/PreviewDrawer.tsx | 40 +++++++++++++------ .../src/states/PreviewDrawerState.tsx | 7 +--- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx b/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx index d398f404ea..43b768d470 100644 --- a/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx +++ b/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx @@ -8,11 +8,6 @@ export default function GlobalPreviewDrawer() { const instance = usePreviewDrawerState((state) => state.instance); const closePreview = usePreviewDrawerState((state) => state.closePreview); - // Only skip rendering before the drawer has ever been opened - if (modelType === undefined || id === undefined) { - return null; - } - return ( ; opened: boolean; onClose: () => void; }>) { - const modelInfo = getModelInfo(modelType); - const apiEndpoint = ModelInformationDict[modelType].api_endpoint; + const modelInfo = modelType ? getModelInfo(modelType) : null; + const apiEndpoint = modelType + ? ModelInformationDict[modelType].api_endpoint + : undefined; const { instance: fetchedInstance, instanceQuery } = useInstance({ - endpoint: apiEndpoint, + endpoint: apiEndpoint!, pk: id, hasPrimaryKey: true, defaultValue: {}, params: filters, - disabled: !!providedInstance + disabled: !!providedInstance || !modelType || !id }); const instance = useMemo(() => { return providedInstance ?? fetchedInstance; }, [providedInstance, fetchedInstance]); - const previewComponent: PreviewType = useMemo(() => { + const previewComponent: PreviewType | null = useMemo(() => { + if (!modelType || !modelInfo || id == null) return null; + const component: PreviewType | null = getPreviewComponentForModel({ - modelType + modelType, + instance, + modelId: typeof id === 'string' ? Number(id) : id }); if (component == null) { @@ -55,13 +61,17 @@ export default function PreviewDrawer({ } return component; - }, [modelType]); + }, [modelType, id, instance]); return ( {previewComponent.title}} + title={ + previewComponent ? ( + {previewComponent.title} + ) : null + } opened={opened} onClose={onClose} withCloseButton @@ -72,9 +82,13 @@ export default function PreviewDrawer({ }} > - - - {previewComponent.preview} + {previewComponent && ( + <> + + + {previewComponent.preview} + + )} ); diff --git a/src/frontend/src/states/PreviewDrawerState.tsx b/src/frontend/src/states/PreviewDrawerState.tsx index 479e7644e3..9e2c82bb54 100644 --- a/src/frontend/src/states/PreviewDrawerState.tsx +++ b/src/frontend/src/states/PreviewDrawerState.tsx @@ -30,12 +30,7 @@ export const usePreviewDrawerState = create()( }, closePreview: () => { - set({ - isOpen: false, - modelType: undefined, - id: undefined, - instance: undefined - }); + set({ isOpen: false }); } }) ); From 448d50066593f3d215de9204bb0e4963e8a107a8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 10:12:36 +0000 Subject: [PATCH 10/34] Allow caller to pass preview component --- .../src/components/previews/GlobalPreviewDrawer.tsx | 2 ++ .../src/components/previews/PreviewDrawer.tsx | 5 ++++- src/frontend/src/components/previews/PreviewType.tsx | 12 +++++++++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx b/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx index 43b768d470..33029df5da 100644 --- a/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx +++ b/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx @@ -6,6 +6,7 @@ export default function GlobalPreviewDrawer() { const modelType = usePreviewDrawerState((state) => state.modelType); const id = usePreviewDrawerState((state) => state.id); const instance = usePreviewDrawerState((state) => state.instance); + const preview = usePreviewDrawerState((state) => state.preview); const closePreview = usePreviewDrawerState((state) => state.closePreview); return ( @@ -13,6 +14,7 @@ export default function GlobalPreviewDrawer() { modelType={modelType} id={id} instance={instance} + preview={preview} opened={isOpen} onClose={closePreview} /> diff --git a/src/frontend/src/components/previews/PreviewDrawer.tsx b/src/frontend/src/components/previews/PreviewDrawer.tsx index 8848b4d250..b1af737a6c 100644 --- a/src/frontend/src/components/previews/PreviewDrawer.tsx +++ b/src/frontend/src/components/previews/PreviewDrawer.tsx @@ -14,6 +14,7 @@ export default function PreviewDrawer({ id, instance: providedInstance, filters, + preview: providedPreview, opened, onClose }: Readonly<{ @@ -21,6 +22,7 @@ export default function PreviewDrawer({ id?: number | string; instance?: any; filters?: Record; + preview?: PreviewType; opened: boolean; onClose: () => void; }>) { @@ -43,6 +45,7 @@ export default function PreviewDrawer({ }, [providedInstance, fetchedInstance]); const previewComponent: PreviewType | null = useMemo(() => { + if (providedPreview) return providedPreview; if (!modelType || !modelInfo || id == null) return null; const component: PreviewType | null = getPreviewComponentForModel({ @@ -61,7 +64,7 @@ export default function PreviewDrawer({ } return component; - }, [modelType, id, instance]); + }, [providedPreview, modelType, id, instance]); return ( PreviewType; export function getPreviewComponentForModel({ - modelType + modelType, + instance, + modelId }: { modelType: ModelType; + instance: any; + modelId: number; }): PreviewType | null { switch (modelType) { + case ModelType.part: + return PartPreviewComponent({ instance, modelId }); default: - // Return null to indicate that this model type is not supported return null; } } From 194780f0b7523e2c91013b3fe03fa345b4dafa66 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 10:25:43 +0000 Subject: [PATCH 11/34] AttributeGrid --- .../src/components/previews/AttributeGrid.tsx | 40 +++++ .../previews/models/PartPreview.tsx | 160 ++++++++++++++++++ .../src/states/PreviewDrawerState.tsx | 18 +- 3 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 src/frontend/src/components/previews/AttributeGrid.tsx create mode 100644 src/frontend/src/components/previews/models/PartPreview.tsx diff --git a/src/frontend/src/components/previews/AttributeGrid.tsx b/src/frontend/src/components/previews/AttributeGrid.tsx new file mode 100644 index 0000000000..8f0c6e2204 --- /dev/null +++ b/src/frontend/src/components/previews/AttributeGrid.tsx @@ -0,0 +1,40 @@ +import { Divider, SimpleGrid, Text, Title } from '@mantine/core'; +import type { ReactNode } from 'react'; + +export interface AttributeRow { + label: string; + value: ReactNode | null | undefined; +} + +export function AttributeGrid({ + title, + items +}: Readonly<{ + title: string; + items: AttributeRow[]; +}>) { + const valid = items.filter( + (item) => item.value !== null && item.value !== undefined + ); + + if (valid.length === 0) return null; + + return ( + <> + + {title} + + {valid.map((item) => ( + <> + + {item.label} + + + {item.value} + + + ))} + + + ); +} diff --git a/src/frontend/src/components/previews/models/PartPreview.tsx b/src/frontend/src/components/previews/models/PartPreview.tsx new file mode 100644 index 0000000000..d0e97e7bd6 --- /dev/null +++ b/src/frontend/src/components/previews/models/PartPreview.tsx @@ -0,0 +1,160 @@ +import { + ApiEndpoints, + ModelType, + TagsList, + apiUrl, + formatDecimal +} from '@lib/index'; +import { t } from '@lingui/core/macro'; +import { Group, Stack, Text } from '@mantine/core'; +import { useQuery } from '@tanstack/react-query'; +import { useApi } from '../../../contexts/ApiContext'; +import { useInstance } from '../../../hooks/UseInstance'; +import { ApiImage } from '../../images/ApiImage'; +import { AttributeGrid, type AttributeRow } from '../AttributeGrid'; +import type { PreviewType } from '../PreviewType'; + +function PartPreviewContent({ instance }: Readonly<{ instance: any }>) { + const api = useApi(); + + const { instance: requirements } = useInstance({ + endpoint: ApiEndpoints.part_requirements, + pk: instance?.pk, + hasPrimaryKey: true, + disabled: !instance?.pk, + defaultValue: {} + }); + + const { data: parameters = [] } = useQuery({ + queryKey: ['part-preview-parameters', instance?.pk], + enabled: !!instance?.pk, + queryFn: () => + api + .get(apiUrl(ApiEndpoints.parameter_list), { + params: { + model_type: ModelType.part, + model_id: instance.pk, + limit: 100 + } + }) + .then((res) => res.data?.results ?? []) + }); + + const totalStock = requirements?.total_stock ?? instance?.total_in_stock ?? 0; + const availableStock = + requirements?.unallocated_stock ?? instance?.unallocated_stock ?? 0; + const minStock = instance?.minimum_stock ?? 0; + const ordering = requirements?.ordering ?? 0; + const isPurchaseable = instance?.purchaseable ?? false; + + const category = instance?.category_detail; + + const stockItems: AttributeRow[] = !instance?.virtual + ? [ + { + label: t`In Stock`, + value: + formatDecimal(totalStock) + + (instance?.units ? ` ${instance.units}` : '') + }, + availableStock !== totalStock + ? { + label: t`Available`, + value: + formatDecimal(availableStock) + + (instance?.units ? ` ${instance.units}` : '') + } + : null, + minStock > 0 + ? { + label: t`Minimum`, + value: + formatDecimal(minStock) + + (instance?.units ? ` ${instance.units}` : '') + } + : null, + isPurchaseable && ordering > 0 + ? { + label: t`On Order`, + value: + formatDecimal(ordering) + + (instance?.units ? ` ${instance.units}` : '') + } + : null + ].filter(Boolean) + : []; + + const parameterItems: AttributeRow[] = parameters.map((param: any) => ({ + label: param.template_detail?.name, + value: + param.data + + (param.template_detail?.units ? ` ${param.template_detail.units}` : '') + })); + + return ( + + {/* Headline: image + name / description / IPN / category */} + + + + + {instance?.full_name || instance?.name} + + {instance?.description && ( + + {instance.description} + + )} + {instance?.IPN && ( + + + {t`IPN`}: + + + {instance.IPN} + + + )} + {category && ( + + + {t`Category`}: + + {category.pathstring ?? category.name} + + )} + + + + {instance?.tags?.length > 0 && } + + {!instance?.virtual && ( + + )} + + + ); +} + +export function PartPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + return { + title: instance?.full_name || instance?.name || `Part #${modelId}`, + preview: + }; +} diff --git a/src/frontend/src/states/PreviewDrawerState.tsx b/src/frontend/src/states/PreviewDrawerState.tsx index 9e2c82bb54..aa837f0492 100644 --- a/src/frontend/src/states/PreviewDrawerState.tsx +++ b/src/frontend/src/states/PreviewDrawerState.tsx @@ -1,15 +1,18 @@ import type { ModelType } from '@lib/enums/ModelType'; import { create } from 'zustand'; +import type { PreviewType } from '../components/previews/PreviewType'; interface PreviewDrawerStateProps { isOpen: boolean; modelType?: ModelType; id?: number | string; instance?: any; + preview?: PreviewType; openPreview: ( modelType: ModelType, id: number | string | undefined, - instance?: any + instance?: any, + preview?: PreviewType ) => void; closePreview: () => void; } @@ -20,13 +23,15 @@ export const usePreviewDrawerState = create()( modelType: undefined, id: undefined, instance: undefined, + preview: undefined, openPreview: ( modelType: ModelType, id: number | string | undefined, - instance?: any + instance?: any, + preview?: PreviewType ) => { - set({ modelType, id, instance, isOpen: true }); + set({ modelType, id, instance, preview, isOpen: true }); }, closePreview: () => { @@ -38,9 +43,12 @@ export const usePreviewDrawerState = create()( export function openGlobalPreview( modelType: ModelType, id?: number | string | undefined, - instance?: any + instance?: any, + preview?: PreviewType ) { - usePreviewDrawerState.getState().openPreview(modelType, id, instance); + usePreviewDrawerState + .getState() + .openPreview(modelType, id, instance, preview); } export function closeGlobalPreview() { From d962d62ebb0ab68a09d6a065d9e765094b9b1f61 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 10:26:54 +0000 Subject: [PATCH 12/34] Use a table component --- .../src/components/previews/AttributeGrid.tsx | 26 +++++++++---------- .../previews/models/PartPreview.tsx | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/frontend/src/components/previews/AttributeGrid.tsx b/src/frontend/src/components/previews/AttributeGrid.tsx index 8f0c6e2204..e63e79e8b6 100644 --- a/src/frontend/src/components/previews/AttributeGrid.tsx +++ b/src/frontend/src/components/previews/AttributeGrid.tsx @@ -1,4 +1,4 @@ -import { Divider, SimpleGrid, Text, Title } from '@mantine/core'; +import { Divider, Table, Title } from '@mantine/core'; import type { ReactNode } from 'react'; export interface AttributeRow { @@ -23,18 +23,18 @@ export function AttributeGrid({ <> {title} - - {valid.map((item) => ( - <> - - {item.label} - - - {item.value} - - - ))} - + + + {valid.map((item) => ( + + + {item.label} + + {item.value} + + ))} + +
); } diff --git a/src/frontend/src/components/previews/models/PartPreview.tsx b/src/frontend/src/components/previews/models/PartPreview.tsx index d0e97e7bd6..da5b7c6671 100644 --- a/src/frontend/src/components/previews/models/PartPreview.tsx +++ b/src/frontend/src/components/previews/models/PartPreview.tsx @@ -81,7 +81,7 @@ function PartPreviewContent({ instance }: Readonly<{ instance: any }>) { (instance?.units ? ` ${instance.units}` : '') } : null - ].filter(Boolean) + ].filter((x): x is NonNullable => x !== null) : []; const parameterItems: AttributeRow[] = parameters.map((param: any) => ({ From 499bbabe64ad372c7d60b25d7393b4e9d7b1473f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 10:34:22 +0000 Subject: [PATCH 13/34] Display info items --- .../previews/models/PartPreview.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/frontend/src/components/previews/models/PartPreview.tsx b/src/frontend/src/components/previews/models/PartPreview.tsx index da5b7c6671..5bfc77d088 100644 --- a/src/frontend/src/components/previews/models/PartPreview.tsx +++ b/src/frontend/src/components/previews/models/PartPreview.tsx @@ -9,19 +9,24 @@ import { t } from '@lingui/core/macro'; import { Group, Stack, Text } from '@mantine/core'; import { useQuery } from '@tanstack/react-query'; import { useApi } from '../../../contexts/ApiContext'; +import { formatDate } from '../../../defaults/formatters'; import { useInstance } from '../../../hooks/UseInstance'; import { ApiImage } from '../../images/ApiImage'; +import { RenderPartCategory } from '../../render/Part'; +import { RenderUser } from '../../render/User'; import { AttributeGrid, type AttributeRow } from '../AttributeGrid'; import type { PreviewType } from '../PreviewType'; function PartPreviewContent({ instance }: Readonly<{ instance: any }>) { const api = useApi(); + // Fetch part requirements const { instance: requirements } = useInstance({ endpoint: ApiEndpoints.part_requirements, pk: instance?.pk, hasPrimaryKey: true, disabled: !instance?.pk, + params: {}, defaultValue: {} }); @@ -91,6 +96,25 @@ function PartPreviewContent({ instance }: Readonly<{ instance: any }>) { (param.template_detail?.units ? ` ${param.template_detail.units}` : '') })); + const infoItems: AttributeRow[] = [ + { + label: t`Category`, + value: instance?.category_detail + ? RenderPartCategory({ instance: instance.category_detail, link: true }) + : null + }, + { + label: t`Creation Date`, + value: instance?.creation_date ? formatDate(instance.creation_date) : null + }, + { + label: t`Created By`, + value: instance?.creation_user_detail + ? RenderUser({ instance: instance.creation_user_detail }) + : null + } + ]; + return ( {/* Headline: image + name / description / IPN / category */} @@ -138,6 +162,7 @@ function PartPreviewContent({ instance }: Readonly<{ instance: any }>) { {instance?.tags?.length > 0 && } + {!instance?.virtual && ( )} From b84bdac78f646472301dd7dfd98a3cca4e9e2b90 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 10:54:03 +0000 Subject: [PATCH 14/34] Navigate to detail page from drawer --- .../src/components/previews/AttributeGrid.tsx | 5 ++-- .../src/components/previews/PreviewDrawer.tsx | 30 ++++++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/frontend/src/components/previews/AttributeGrid.tsx b/src/frontend/src/components/previews/AttributeGrid.tsx index e63e79e8b6..0da3aeead5 100644 --- a/src/frontend/src/components/previews/AttributeGrid.tsx +++ b/src/frontend/src/components/previews/AttributeGrid.tsx @@ -1,4 +1,5 @@ -import { Divider, Table, Title } from '@mantine/core'; +import { StylishText } from '@lib/components/StylishText'; +import { Divider, Table } from '@mantine/core'; import type { ReactNode } from 'react'; export interface AttributeRow { @@ -22,7 +23,7 @@ export function AttributeGrid({ return ( <> - {title} + {title} {valid.map((item) => ( diff --git a/src/frontend/src/components/previews/PreviewDrawer.tsx b/src/frontend/src/components/previews/PreviewDrawer.tsx index b1af737a6c..ebcb8ee58c 100644 --- a/src/frontend/src/components/previews/PreviewDrawer.tsx +++ b/src/frontend/src/components/previews/PreviewDrawer.tsx @@ -1,9 +1,10 @@ -import { Divider, Drawer, LoadingOverlay, Stack } from '@mantine/core'; +import { Anchor, Divider, Drawer, LoadingOverlay, Stack } from '@mantine/core'; import { StylishText } from '@lib/components/StylishText'; import { ModelInformationDict } from '@lib/enums/ModelInformation'; -import type { ModelType } from '@lib/index'; -import { useMemo } from 'react'; +import { type ModelType, getDetailUrl, navigateToLink } from '@lib/index'; +import { useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useInstance } from '../../hooks/UseInstance'; import { getModelInfo } from '../render/ModelType'; import { type PreviewType, getPreviewComponentForModel } from './PreviewType'; @@ -26,6 +27,8 @@ export default function PreviewDrawer({ opened: boolean; onClose: () => void; }>) { + const navigate = useNavigate(); + const modelInfo = modelType ? getModelInfo(modelType) : null; const apiEndpoint = modelType ? ModelInformationDict[modelType].api_endpoint @@ -66,13 +69,32 @@ export default function PreviewDrawer({ return component; }, [providedPreview, modelType, id, instance]); + const clickTitle = useCallback( + (event: MouseEvent) => { + if (!modelType || !id) return; + + onClose(); + navigateToLink(getDetailUrl(modelType!, id!), navigate, event); + }, + [modelType, id, navigate] + ); + return ( {previewComponent.title} + !!modelType && !!id ? ( + clickTitle(e)} + > + {previewComponent.title} + + ) : ( + {previewComponent.title} + ) ) : null } opened={opened} From ff7e4d4f3baed4fb8b8156137f860d674d922b0c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 11:07:15 +0000 Subject: [PATCH 15/34] Updates --- .../src/components/calendar/OrderCalendar.tsx | 8 +- .../src/components/previews/AttributeGrid.tsx | 4 +- .../src/components/previews/PreviewDrawer.tsx | 27 ++- .../previews/models/PartPreview.tsx | 173 +++++++++++------- 4 files changed, 139 insertions(+), 73 deletions(-) diff --git a/src/frontend/src/components/calendar/OrderCalendar.tsx b/src/frontend/src/components/calendar/OrderCalendar.tsx index f855691dd4..c9d26f0ca2 100644 --- a/src/frontend/src/components/calendar/OrderCalendar.tsx +++ b/src/frontend/src/components/calendar/OrderCalendar.tsx @@ -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'; @@ -171,7 +175,7 @@ export default function OrderCalendar({ const onClickOrder = (info: EventClickArg) => { if (!info.event.id) return; - if (info.jsEvent.ctrlKey || info.jsEvent.metaKey || info.jsEvent.shiftKey) { + if (eventModified(event as any)) { navigateToLink( getDetailUrl(model, info.event.id), navigate, diff --git a/src/frontend/src/components/previews/AttributeGrid.tsx b/src/frontend/src/components/previews/AttributeGrid.tsx index 0da3aeead5..93b7578712 100644 --- a/src/frontend/src/components/previews/AttributeGrid.tsx +++ b/src/frontend/src/components/previews/AttributeGrid.tsx @@ -28,10 +28,10 @@ export function AttributeGrid({ {valid.map((item) => ( - + {item.label} - {item.value} + {item.value} ))} diff --git a/src/frontend/src/components/previews/PreviewDrawer.tsx b/src/frontend/src/components/previews/PreviewDrawer.tsx index ebcb8ee58c..fe3c97e40c 100644 --- a/src/frontend/src/components/previews/PreviewDrawer.tsx +++ b/src/frontend/src/components/previews/PreviewDrawer.tsx @@ -1,8 +1,20 @@ -import { Anchor, Divider, Drawer, LoadingOverlay, Stack } from '@mantine/core'; +import { + ActionIcon, + Anchor, + Divider, + Drawer, + Group, + LoadingOverlay, + Stack, + Tooltip +} from '@mantine/core'; import { StylishText } from '@lib/components/StylishText'; import { ModelInformationDict } from '@lib/enums/ModelInformation'; +import { eventModified } from '@lib/functions/Navigation'; import { type ModelType, getDetailUrl, navigateToLink } from '@lib/index'; +import { t } from '@lingui/core/macro'; +import { IconArrowRight } from '@tabler/icons-react'; import { useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useInstance } from '../../hooks/UseInstance'; @@ -73,7 +85,9 @@ export default function PreviewDrawer({ (event: MouseEvent) => { if (!modelType || !id) return; - onClose(); + if (!eventModified(event as any)) { + onClose(); + } navigateToLink(getDetailUrl(modelType!, id!), navigate, event); }, [modelType, id, navigate] @@ -90,7 +104,14 @@ export default function PreviewDrawer({ href={getDetailUrl(modelType!, id, true)} onClick={(e) => clickTitle(e)} > - {previewComponent.title} + + + + + + + {previewComponent.title} + ) : ( {previewComponent.title} diff --git a/src/frontend/src/components/previews/models/PartPreview.tsx b/src/frontend/src/components/previews/models/PartPreview.tsx index 5bfc77d088..d253cac760 100644 --- a/src/frontend/src/components/previews/models/PartPreview.tsx +++ b/src/frontend/src/components/previews/models/PartPreview.tsx @@ -8,11 +8,12 @@ import { import { t } from '@lingui/core/macro'; import { Group, Stack, Text } from '@mantine/core'; import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; import { useApi } from '../../../contexts/ApiContext'; -import { formatDate } from '../../../defaults/formatters'; +import { formatDate, formatPriceRange } from '../../../defaults/formatters'; import { useInstance } from '../../../hooks/UseInstance'; import { ApiImage } from '../../images/ApiImage'; -import { RenderPartCategory } from '../../render/Part'; +import { RenderRemoteInstance } from '../../render/Instance'; import { RenderUser } from '../../render/User'; import { AttributeGrid, type AttributeRow } from '../AttributeGrid'; import type { PreviewType } from '../PreviewType'; @@ -20,13 +21,11 @@ import type { PreviewType } from '../PreviewType'; function PartPreviewContent({ instance }: Readonly<{ instance: any }>) { const api = useApi(); - // Fetch part requirements const { instance: requirements } = useInstance({ endpoint: ApiEndpoints.part_requirements, pk: instance?.pk, hasPrimaryKey: true, disabled: !instance?.pk, - params: {}, defaultValue: {} }); @@ -45,49 +44,45 @@ function PartPreviewContent({ instance }: Readonly<{ instance: any }>) { .then((res) => res.data?.results ?? []) }); - const totalStock = requirements?.total_stock ?? instance?.total_in_stock ?? 0; - const availableStock = - requirements?.unallocated_stock ?? instance?.unallocated_stock ?? 0; - const minStock = instance?.minimum_stock ?? 0; - const ordering = requirements?.ordering ?? 0; - const isPurchaseable = instance?.purchaseable ?? false; + const units = instance?.units ? ` ${instance.units}` : ''; - const category = instance?.category_detail; + const stockItems: AttributeRow[] = useMemo(() => { + const totalStock = + requirements?.total_stock ?? instance?.total_in_stock ?? 0; + const availableStock = + requirements?.unallocated_stock ?? instance?.unallocated_stock ?? 0; + const minStock = instance?.minimum_stock ?? 0; + const ordering = requirements?.ordering ?? 0; + const canBuild = requirements?.can_build ?? 0; + const building = requirements?.building ?? 0; - const stockItems: AttributeRow[] = !instance?.virtual - ? [ - { - label: t`In Stock`, - value: - formatDecimal(totalStock) + - (instance?.units ? ` ${instance.units}` : '') - }, - availableStock !== totalStock - ? { - label: t`Available`, - value: - formatDecimal(availableStock) + - (instance?.units ? ` ${instance.units}` : '') - } - : null, - minStock > 0 - ? { - label: t`Minimum`, - value: - formatDecimal(minStock) + - (instance?.units ? ` ${instance.units}` : '') - } - : null, - isPurchaseable && ordering > 0 - ? { - label: t`On Order`, - value: - formatDecimal(ordering) + - (instance?.units ? ` ${instance.units}` : '') - } - : null - ].filter((x): x is NonNullable => x !== null) - : []; + return !instance?.virtual + ? [ + { + label: t`In Stock`, + value: formatDecimal(totalStock) + units + }, + availableStock !== totalStock + ? { + label: t`Available`, + value: formatDecimal(availableStock) + units + } + : null, + minStock > 0 + ? { label: t`Minimum`, value: formatDecimal(minStock) + units } + : null, + instance?.purchaseable && ordering > 0 + ? { label: t`On Order`, value: formatDecimal(ordering) + units } + : null, + instance?.assembly && canBuild >= 0 + ? { label: t`Can Build`, value: formatDecimal(canBuild) } + : null, + building > 0 + ? { label: t`In Production`, value: formatDecimal(building) } + : null + ].filter((x): x is NonNullable => x !== null) + : []; + }, [requirements, instance, units]); const parameterItems: AttributeRow[] = parameters.map((param: any) => ({ label: param.template_detail?.name, @@ -96,28 +91,64 @@ function PartPreviewContent({ instance }: Readonly<{ instance: any }>) { (param.template_detail?.units ? ` ${param.template_detail.units}` : '') })); - const infoItems: AttributeRow[] = [ - { - label: t`Category`, - value: instance?.category_detail - ? RenderPartCategory({ instance: instance.category_detail, link: true }) - : null - }, - { - label: t`Creation Date`, - value: instance?.creation_date ? formatDate(instance.creation_date) : null - }, - { - label: t`Created By`, - value: instance?.creation_user_detail - ? RenderUser({ instance: instance.creation_user_detail }) - : null - } - ]; + const infoItems: AttributeRow[] = useMemo(() => { + const priceRange = formatPriceRange( + instance?.pricing_min, + instance?.pricing_max + ); + + return [ + { + label: t`Category`, + value: instance?.category ? ( + + ) : null + }, + { + label: t`Revision Of`, + value: instance?.revision_of ? ( + + ) : null + }, + { + label: t`Variant Of`, + value: instance?.variant_of ? ( + + ) : null + }, + { + label: t`Price Range`, + value: + instance?.pricing_min != null || instance?.pricing_max != null + ? priceRange + : null + }, + { + label: t`Creation Date`, + value: instance?.creation_date + ? formatDate(instance.creation_date) + : null + }, + { + label: t`Created By`, + value: instance?.creation_user_detail + ? RenderUser({ instance: instance.creation_user_detail }) + : null + } + ]; + }, [instance]); return ( - {/* Headline: image + name / description / IPN / category */} ) { )} - {category && ( + {instance?.revision && ( + + + {t`Revision`}: + + + {instance.revision} + + + )} + {instance?.category_name && ( {t`Category`}: - {category.pathstring ?? category.name} + {instance?.category_name} )} From 9f4913d429e2d4c4b800ca362b40320ed16cb3e5 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 11:28:52 +0000 Subject: [PATCH 16/34] Refactor to use part detail view --- .../previews/models/PartPreview.tsx | 215 +-------- src/frontend/src/pages/part/PartDetail.tsx | 435 ++---------------- .../src/pages/part/PartDetailsPanel.tsx | 360 +++++++++++++++ 3 files changed, 388 insertions(+), 622 deletions(-) create mode 100644 src/frontend/src/pages/part/PartDetailsPanel.tsx diff --git a/src/frontend/src/components/previews/models/PartPreview.tsx b/src/frontend/src/components/previews/models/PartPreview.tsx index d253cac760..b6cfa9f1c7 100644 --- a/src/frontend/src/components/previews/models/PartPreview.tsx +++ b/src/frontend/src/components/previews/models/PartPreview.tsx @@ -1,217 +1,6 @@ -import { - ApiEndpoints, - ModelType, - TagsList, - apiUrl, - formatDecimal -} from '@lib/index'; -import { t } from '@lingui/core/macro'; -import { Group, Stack, Text } from '@mantine/core'; -import { useQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; -import { useApi } from '../../../contexts/ApiContext'; -import { formatDate, formatPriceRange } from '../../../defaults/formatters'; -import { useInstance } from '../../../hooks/UseInstance'; -import { ApiImage } from '../../images/ApiImage'; -import { RenderRemoteInstance } from '../../render/Instance'; -import { RenderUser } from '../../render/User'; -import { AttributeGrid, type AttributeRow } from '../AttributeGrid'; +import { PartDetailsPanel } from '../../../pages/part/PartDetailsPanel'; import type { PreviewType } from '../PreviewType'; -function PartPreviewContent({ instance }: Readonly<{ instance: any }>) { - const api = useApi(); - - const { instance: requirements } = useInstance({ - endpoint: ApiEndpoints.part_requirements, - pk: instance?.pk, - hasPrimaryKey: true, - disabled: !instance?.pk, - defaultValue: {} - }); - - const { data: parameters = [] } = useQuery({ - queryKey: ['part-preview-parameters', instance?.pk], - enabled: !!instance?.pk, - queryFn: () => - api - .get(apiUrl(ApiEndpoints.parameter_list), { - params: { - model_type: ModelType.part, - model_id: instance.pk, - limit: 100 - } - }) - .then((res) => res.data?.results ?? []) - }); - - const units = instance?.units ? ` ${instance.units}` : ''; - - const stockItems: AttributeRow[] = useMemo(() => { - const totalStock = - requirements?.total_stock ?? instance?.total_in_stock ?? 0; - const availableStock = - requirements?.unallocated_stock ?? instance?.unallocated_stock ?? 0; - const minStock = instance?.minimum_stock ?? 0; - const ordering = requirements?.ordering ?? 0; - const canBuild = requirements?.can_build ?? 0; - const building = requirements?.building ?? 0; - - return !instance?.virtual - ? [ - { - label: t`In Stock`, - value: formatDecimal(totalStock) + units - }, - availableStock !== totalStock - ? { - label: t`Available`, - value: formatDecimal(availableStock) + units - } - : null, - minStock > 0 - ? { label: t`Minimum`, value: formatDecimal(minStock) + units } - : null, - instance?.purchaseable && ordering > 0 - ? { label: t`On Order`, value: formatDecimal(ordering) + units } - : null, - instance?.assembly && canBuild >= 0 - ? { label: t`Can Build`, value: formatDecimal(canBuild) } - : null, - building > 0 - ? { label: t`In Production`, value: formatDecimal(building) } - : null - ].filter((x): x is NonNullable => x !== null) - : []; - }, [requirements, instance, units]); - - const parameterItems: AttributeRow[] = parameters.map((param: any) => ({ - label: param.template_detail?.name, - value: - param.data + - (param.template_detail?.units ? ` ${param.template_detail.units}` : '') - })); - - const infoItems: AttributeRow[] = useMemo(() => { - const priceRange = formatPriceRange( - instance?.pricing_min, - instance?.pricing_max - ); - - return [ - { - label: t`Category`, - value: instance?.category ? ( - - ) : null - }, - { - label: t`Revision Of`, - value: instance?.revision_of ? ( - - ) : null - }, - { - label: t`Variant Of`, - value: instance?.variant_of ? ( - - ) : null - }, - { - label: t`Price Range`, - value: - instance?.pricing_min != null || instance?.pricing_max != null - ? priceRange - : null - }, - { - label: t`Creation Date`, - value: instance?.creation_date - ? formatDate(instance.creation_date) - : null - }, - { - label: t`Created By`, - value: instance?.creation_user_detail - ? RenderUser({ instance: instance.creation_user_detail }) - : null - } - ]; - }, [instance]); - - return ( - - - - - - {instance?.full_name || instance?.name} - - {instance?.description && ( - - {instance.description} - - )} - {instance?.IPN && ( - - - {t`IPN`}: - - - {instance.IPN} - - - )} - {instance?.revision && ( - - - {t`Revision`}: - - - {instance.revision} - - - )} - {instance?.category_name && ( - - - {t`Category`}: - - {instance?.category_name} - - )} - - - - {instance?.tags?.length > 0 && } - - - {!instance?.virtual && ( - - )} - - - ); -} - export function PartPreviewComponent({ instance, modelId @@ -221,6 +10,6 @@ export function PartPreviewComponent({ }>): PreviewType { return { title: instance?.full_name || instance?.name || `Part #${modelId}`, - preview: + preview: }; } diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 38194069b6..2590a540b3 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -3,7 +3,6 @@ import { ActionIcon, Alert, Center, - Grid, Group, Loader, Paper, @@ -41,7 +40,6 @@ import { type ReactNode, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import Select from 'react-select'; -import TagsList from '@lib/components/TagsList'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; @@ -52,13 +50,7 @@ import type { PanelType } from '@lib/types/Panel'; import AdminButton from '../../components/buttons/AdminButton'; import { PrintingActions } from '../../components/buttons/PrintingActions'; import StarredToggleButton from '../../components/buttons/StarredToggleButton'; -import { - type DetailsField, - DetailsTable -} from '../../components/details/Details'; import DetailsBadge from '../../components/details/DetailsBadge'; -import { DetailsImage } from '../../components/details/DetailsImage'; -import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { Thumbnail } from '../../components/images/Thumbnail'; import { ActionDropdown, @@ -77,7 +69,7 @@ import { PanelGroup } from '../../components/panels/PanelGroup'; import { RenderPart } from '../../components/render/Part'; import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; import { useApi } from '../../contexts/ApiContext'; -import { formatDecimal, formatPriceRange } from '../../defaults/formatters'; +import { formatDecimal } from '../../defaults/formatters'; import { usePartFields } from '../../forms/PartForms'; import { useFindSerialNumberForm } from '../../forms/StockForms'; import { @@ -106,6 +98,7 @@ import { SalesOrderTable } from '../../tables/sales/SalesOrderTable'; import { StockItemTable } from '../../tables/stock/StockItemTable'; import { TransferOrderTable } from '../../tables/stock/TransferOrderTable'; import PartAllocationPanel from './PartAllocationPanel'; +import { PartDetailsPanel } from './PartDetailsPanel'; import PartPricingPanel from './PartPricingPanel'; import PartStockHistoryDetail from './PartStockHistoryDetail'; import PartSupplierDetail from './PartSupplierDetail'; @@ -175,14 +168,6 @@ export default function PartDetail() { refetchOnMount: true }); - const { instance: serials } = useInstance({ - endpoint: ApiEndpoints.part_serial_numbers, - pk: id, - hasPrimaryKey: true, - refetchOnMount: false, - defaultValue: {} - }); - const { instance: part, refreshInstance, @@ -275,398 +260,22 @@ export default function PartDetail() { return partRevisionOptions.length > 0 && revisionsEnabled; }, [partRevisionOptions, revisionsEnabled]); - const detailsPanel = useMemo(() => { - if (instanceQuery.isFetching) { - return ; - } - - const data = { ...part }; - - const fetching = - partRequirementsQuery.isFetching || instanceQuery.isFetching; - - // Copy part requirements data into the main part data - data.total_in_stock = - partRequirements?.total_stock ?? part?.total_in_stock ?? 0; - data.unallocated = - partRequirements?.unallocated_stock ?? part?.unallocated_stock ?? 0; - data.ordering = partRequirements?.ordering ?? part?.ordering ?? 0; - - data.required = - (partRequirements?.required_for_build_orders ?? - part?.required_for_build_orders ?? - 0) + - (partRequirements?.required_for_sales_orders ?? - part?.required_for_sales_orders ?? - 0); - - data.allocated = - (partRequirements?.allocated_to_build_orders ?? - part?.allocated_to_build_orders ?? - 0) + - (partRequirements?.allocated_to_sales_orders ?? - part?.allocated_to_sales_orders ?? - 0); - - // Extract requirements data - data.can_build = partRequirements?.can_build ?? 0; - - // Provide latest serial number info - if (!!serials.latest) { - data.latest_serial_number = serials.latest; - } - - // Top left - core part information - const tl: DetailsField[] = [ - { - type: 'string', - name: 'name', - label: t`Name`, - icon: 'part', - copy: true - }, - { - type: 'string', - name: 'IPN', - label: t`IPN`, - copy: true, - hidden: !part.IPN - }, - { - type: 'string', - name: 'description', - label: t`Description`, - copy: true - }, - { - type: 'link', - name: 'variant_of', - label: t`Variant of`, - model: ModelType.part, - model_field: 'full_name', - hidden: !part.variant_of - }, - { - type: 'link', - name: 'revision_of', - label: t`Revision of`, - model: ModelType.part, - model_field: 'full_name', - hidden: !part.revision_of - }, - { - type: 'string', - name: 'revision', - label: t`Revision`, - hidden: !part.revision, - copy: true - }, - { - type: 'link', - name: 'category', - label: t`Category`, - model: ModelType.partcategory - }, - { - type: 'link', - name: 'default_location', - label: t`Default Location`, - model: ModelType.stocklocation, - hidden: !part.default_location - }, - { - type: 'link', - name: 'category_default_location', - label: t`Category Default Location`, - model: ModelType.stocklocation, - hidden: part.default_location || !part.category_default_location - }, - { - type: 'string', - name: 'units', - label: t`Units`, - copy: true, - hidden: !part.units - }, - { - type: 'string', - name: 'keywords', - label: t`Keywords`, - copy: true, - hidden: !part.keywords - }, - { - type: 'link', - name: 'link', - label: t`Link`, - external: true, - copy: true, - hidden: !part.link - } - ]; - - // Top right - stock availability information - const tr: DetailsField[] = [ - { - type: 'number', - name: 'total_in_stock', - unit: part.units, - label: t`In Stock`, - hidden: part.virtual - }, - { - type: 'progressbar', - name: 'unallocated_stock', - total: data.total_in_stock, - progress: data.unallocated, - label: t`Available Stock`, - hidden: part.virtual || data.total_in_stock == data.unallocated - }, - { - type: 'number', - name: 'ordering', - label: t`On order`, - unit: part.units, - hidden: !part.purchaseable || part.ordering <= 0 - }, - { - type: 'number', - name: 'required', - label: t`Required for Orders`, - unit: part.units, - hidden: data.required <= 0, - icon: 'stocktake' - }, - { - type: 'progressbar', - name: 'allocated_to_build_orders', - icon: 'manufacturers', - total: partRequirements.required_for_build_orders, - progress: partRequirements.allocated_to_build_orders, - label: t`Allocated to Build Orders`, - hidden: - fetching || - (partRequirements.required_for_build_orders <= 0 && - partRequirements.allocated_to_build_orders <= 0) - }, - { - type: 'progressbar', - icon: 'sales_orders', - name: 'allocated_to_sales_orders', - total: partRequirements.required_for_sales_orders, - progress: partRequirements.allocated_to_sales_orders, - label: t`Allocated to Sales Orders`, - hidden: - fetching || - (partRequirements.required_for_sales_orders <= 0 && - partRequirements.allocated_to_sales_orders <= 0) - }, - { - type: 'progressbar', - name: 'building', - label: t`In Production`, - progress: partRequirements.building, - total: partRequirements.scheduled_to_build, - hidden: - fetching || - (!partRequirements.building && !partRequirements.scheduled_to_build) - }, - { - type: 'number', - name: 'can_build', - unit: part.units, - label: t`Can Build`, - hidden: !part.assembly || fetching - }, - { - type: 'number', - name: 'minimum_stock', - unit: part.units, - label: t`Minimum Stock`, - hidden: part.minimum_stock <= 0 - }, - { - type: 'number', - name: 'maximum_stock', - unit: part.units, - label: t`Maximum Stock`, - hidden: part.maximum_stock <= 0 - } - ]; - - // Bottom left - part attributes - const bl: DetailsField[] = [ - { - type: 'boolean', - name: 'active', - label: t`Active` - }, - { - type: 'boolean', - name: 'locked', - label: t`Locked` - }, - { - type: 'boolean', - icon: 'template', - name: 'is_template', - label: t`Template Part` - }, - { - type: 'boolean', - name: 'assembly', - label: t`Assembled Part` - }, - { - type: 'boolean', - name: 'component', - label: t`Component Part` - }, - { - type: 'boolean', - name: 'testable', - label: t`Testable Part`, - icon: 'test' - }, - { - type: 'boolean', - name: 'trackable', - label: t`Trackable Part` - }, - { - type: 'boolean', - name: 'purchaseable', - label: t`Purchaseable Part` - }, - { - type: 'boolean', - name: 'salable', - icon: 'saleable', - label: t`Saleable Part` - }, - { - type: 'boolean', - name: 'virtual', - label: t`Virtual Part` - }, - { - type: 'boolean', - name: 'starred', - label: t`Subscribed`, - icon: 'bell' - } - ]; - - // Bottom right - other part information - const br: DetailsField[] = [ - { - type: 'string', - name: 'creation_date', - label: t`Creation Date` - }, - { - type: 'string', - name: 'creation_user', - label: t`Created By`, - badge: 'user', - icon: 'user', - hidden: !part.creation_user - }, - { - type: 'string', - name: 'responsible', - label: t`Responsible`, - badge: 'owner', - hidden: !part.responsible - }, - { - name: 'default_expiry', - label: t`Default Expiry`, - hidden: !part.default_expiry, - icon: 'calendar', - type: 'string', - value_formatter: () => { - return `${part.default_expiry} ${t`days`}`; - } - } - ]; - - // Add in price range data - if (part.pricing_min || part.pricing_max) { - br.push({ - type: 'string', - name: 'pricing', - label: t`Price Range`, - value_formatter: () => { - return formatPriceRange(part.pricing_min, part.pricing_max); - } - }); - } - - br.push({ - type: 'string', - name: 'latest_serial_number', - label: t`Latest Serial Number`, - hidden: !part.trackable || !data.latest_serial_number, - icon: 'serial' - }); - - return part ? ( - + const revisionSelector = useMemo(() => { + if (!enableRevisionSelection) return null; + return ( + - - - - - - - - {enableRevisionSelection && ( - - - - - - - {t`Select Part Revision`} - - - - - )} + + + + + {t`Select Part Revision`} + + - - - - - ) : ( - + ); - }, [ - globalSettings, - part, - id, - serials, - instanceQuery.isFetching, - instanceQuery.data, - enableRevisionSelection, - partRevisionOptions, - partRequirementsQuery.isFetching, - partRequirements - ]); + }, [enableRevisionSelection, part, partRevisionOptions]); // Part data panels (recalculate when part data changes) const partPanels: PanelType[] = useMemo(() => { @@ -675,7 +284,14 @@ export default function PartDetail() { name: 'details', label: t`Part Details`, icon: , - content: detailsPanel + content: ( + + ) }, { name: 'stock', @@ -903,8 +519,9 @@ export default function PartDetail() { user, globalSettings, userSettings, - detailsPanel, - bomInformation + bomInformation, + revisionSelector, + refreshInstance ]); const breadcrumbs = useMemo(() => { diff --git a/src/frontend/src/pages/part/PartDetailsPanel.tsx b/src/frontend/src/pages/part/PartDetailsPanel.tsx new file mode 100644 index 0000000000..320513dbfa --- /dev/null +++ b/src/frontend/src/pages/part/PartDetailsPanel.tsx @@ -0,0 +1,360 @@ +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 { 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]); + + if (!instance?.pk) return ; + + return ( + + + + + + + + + + {additionalContent} + + + + + + ); +} From 8723f4dc40a4729afc3f6e7853d15a5c3781bcec Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 11:35:29 +0000 Subject: [PATCH 17/34] Display parameters --- .../src/components/details/Details.tsx | 25 +++++--- .../details/ParameterDetailsGrid.tsx | 58 +++++++++++++++++++ .../src/pages/part/PartDetailsPanel.tsx | 5 ++ 3 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 src/frontend/src/components/details/ParameterDetailsGrid.tsx diff --git a/src/frontend/src/components/details/Details.tsx b/src/frontend/src/components/details/Details.tsx index d221ea543d..3598deada7 100644 --- a/src/frontend/src/components/details/Details.tsx +++ b/src/frontend/src/components/details/Details.tsx @@ -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({ - - {field.label} + {showIcons && ( + + )} + {field.label} ) { const visibleFields = useMemo(() => { return fields.filter((field) => !field.hidden); @@ -553,7 +559,12 @@ export function DetailsTable({
{visibleFields.map((field: DetailsField, index: number) => ( - + ))}
diff --git a/src/frontend/src/components/details/ParameterDetailsGrid.tsx b/src/frontend/src/components/details/ParameterDetailsGrid.tsx new file mode 100644 index 0000000000..4849dcc25a --- /dev/null +++ b/src/frontend/src/components/details/ParameterDetailsGrid.tsx @@ -0,0 +1,58 @@ +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, DetailsTable } from './Details'; + +export function ParameterDetailsGrid({ + model_type, + model_id +}: Readonly<{ + model_type: ModelType; + model_id: number | string | undefined; +}>) { + 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 = {}; + 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]); + + if (fields.length === 0) return null; + + return ( + + ); +} diff --git a/src/frontend/src/pages/part/PartDetailsPanel.tsx b/src/frontend/src/pages/part/PartDetailsPanel.tsx index 320513dbfa..4f83264447 100644 --- a/src/frontend/src/pages/part/PartDetailsPanel.tsx +++ b/src/frontend/src/pages/part/PartDetailsPanel.tsx @@ -14,6 +14,7 @@ import { } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; +import { ParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid'; import { formatPriceRange } from '../../defaults/formatters'; import { useInstance } from '../../hooks/UseInstance'; @@ -355,6 +356,10 @@ export function PartDetailsPanel({ + ); } From bd0455c940d0f651e97223b0949dde981dde91e3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 11:37:21 +0000 Subject: [PATCH 18/34] Fix image caching --- src/frontend/src/components/details/DetailsImage.tsx | 4 ++++ src/frontend/src/states/PreviewDrawerState.tsx | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/components/details/DetailsImage.tsx b/src/frontend/src/components/details/DetailsImage.tsx index 9941ee87de..5c13e9c2bf 100644 --- a/src/frontend/src/components/details/DetailsImage.tsx +++ b/src/frontend/src/components/details/DetailsImage.tsx @@ -406,6 +406,10 @@ export function DetailsImage(props: Readonly) { const { hovered, ref } = useHover(); const [img, setImg] = useState(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); diff --git a/src/frontend/src/states/PreviewDrawerState.tsx b/src/frontend/src/states/PreviewDrawerState.tsx index aa837f0492..4a8793e6fe 100644 --- a/src/frontend/src/states/PreviewDrawerState.tsx +++ b/src/frontend/src/states/PreviewDrawerState.tsx @@ -35,7 +35,12 @@ export const usePreviewDrawerState = create()( }, closePreview: () => { - set({ isOpen: false }); + set({ + isOpen: false, + instance: undefined, + id: undefined, + preview: undefined + }); } }) ); From 85e2907f6037b5dd8b0de2474a481fdf71233731 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 13:30:12 +0000 Subject: [PATCH 19/34] Apply same to StockItem --- .../src/components/previews/PreviewType.tsx | 3 + .../previews/models/StockPreview.tsx | 30 ++ src/frontend/src/pages/stock/StockDetail.tsx | 394 +---------------- .../src/pages/stock/StockDetailsPanel.tsx | 410 ++++++++++++++++++ 4 files changed, 455 insertions(+), 382 deletions(-) create mode 100644 src/frontend/src/components/previews/models/StockPreview.tsx create mode 100644 src/frontend/src/pages/stock/StockDetailsPanel.tsx diff --git a/src/frontend/src/components/previews/PreviewType.tsx b/src/frontend/src/components/previews/PreviewType.tsx index 0cd0319822..16ad538286 100644 --- a/src/frontend/src/components/previews/PreviewType.tsx +++ b/src/frontend/src/components/previews/PreviewType.tsx @@ -1,6 +1,7 @@ import { ModelType } from '@lib/enums/ModelType'; import type { ReactNode } from 'react'; import { PartPreviewComponent } from './models/PartPreview'; +import { StockPreviewComponent } from './models/StockPreview'; export interface PreviewType { preview: ReactNode; @@ -25,6 +26,8 @@ export function getPreviewComponentForModel({ switch (modelType) { case ModelType.part: return PartPreviewComponent({ instance, modelId }); + case ModelType.stockitem: + return StockPreviewComponent({ instance, modelId }); default: return null; } diff --git a/src/frontend/src/components/previews/models/StockPreview.tsx b/src/frontend/src/components/previews/models/StockPreview.tsx new file mode 100644 index 0000000000..788d8bf1f9 --- /dev/null +++ b/src/frontend/src/components/previews/models/StockPreview.tsx @@ -0,0 +1,30 @@ +import { formatDecimal } from '@lib/functions/Formatting'; +import { StockDetailsPanel } from '../../../pages/stock/StockDetailsPanel'; +import type { PreviewType } from '../PreviewType'; + +export function StockPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const part = instance?.part_detail; + + let title = `Stock Item #${modelId}`; + + if (part) { + title = part?.full_name ?? part?.name; + + if (instance.serial) { + title += ` (# ${instance.serial})`; + } else { + title += ` (x ${formatDecimal(instance.quantity)})`; + } + } + + return { + title: title, + preview: + }; +} diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index fa4bb80fa9..3d5f32a35e 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -1,25 +1,12 @@ import { t } from '@lingui/core/macro'; +import { Accordion, Skeleton, Stack } from '@mantine/core'; import { - Accordion, - Button, - Grid, - Group, - Skeleton, - Space, - Stack, - Text, - Tooltip -} from '@mantine/core'; -import { - IconArrowLeft, - IconArrowRight, IconBookmark, IconBoxPadding, IconChecklist, IconHistory, IconInfoCircle, IconPackages, - IconSearch, IconShoppingCart, IconSitemap, IconTransform @@ -28,27 +15,19 @@ import { useQuery } from '@tanstack/react-query'; import { type ReactNode, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { ActionButton } from '@lib/components/ActionButton'; import { StylishText } from '@lib/components/StylishText'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; import { getDetailUrl, getOverviewUrl } from '@lib/functions/Navigation'; -import { TagsList } from '@lib/index'; import type { ApiFormFieldSet, StockOperationProps } from '@lib/types/Forms'; import type { PanelType } from '@lib/types/Panel'; import { notifications } from '@mantine/notifications'; import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog'; import AdminButton from '../../components/buttons/AdminButton'; import { PrintingActions } from '../../components/buttons/PrintingActions'; -import { - type DetailsField, - DetailsTable -} from '../../components/details/Details'; import DetailsBadge from '../../components/details/DetailsBadge'; -import { DetailsImage } from '../../components/details/DetailsImage'; -import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { ActionDropdown, BarcodeActionDropdown, @@ -67,9 +46,8 @@ import LocateItemButton from '../../components/plugins/LocateItemButton'; import { StatusRenderer } from '../../components/render/StatusRenderer'; import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; import { useApi } from '../../contexts/ApiContext'; -import { formatCurrency, formatDecimal } from '../../defaults/formatters'; +import { formatDecimal } from '../../defaults/formatters'; import { - useFindSerialNumberForm, useStockFields, useStockItemSerializeFields } from '../../forms/StockForms'; @@ -90,6 +68,7 @@ import { StockItemTable } from '../../tables/stock/StockItemTable'; import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable'; import { StockTrackingTable } from '../../tables/stock/StockTrackingTable'; import TransferOrderAllocationTable from '../../tables/stock/TransferOrderAllocationTable'; +import { StockDetailsPanel } from './StockDetailsPanel'; export default function StockDetail() { const { id } = useParams(); @@ -124,366 +103,13 @@ export default function StockDetail() { } }); - const { instance: part, instanceQuery: partQuery } = useInstance({ + const { instance: part } = useInstance({ endpoint: ApiEndpoints.part_list, pk: stockitem?.part, hasPrimaryKey: true, defaultValue: {} }); - const { instance: serialNumbers, instanceQuery: serialNumbersQuery } = - useInstance({ - endpoint: ApiEndpoints.stock_serial_info, - pk: id - }); - - const findBySerialNumber = useFindSerialNumberForm({ - partId: stockitem.part - }); - - const detailsPanel = useMemo(() => { - const data = { ...stockitem }; - const part = stockitem?.part_detail ?? {}; - - data.available_stock = Math.max(0, data.quantity - data.allocated); - - if (instanceQuery.isFetching) { - return ; - } - - // 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: () => ( - - {stockitem.serial} - - - {serialNumbers.previous?.pk && ( - - - - )} - } - tooltip={t`Find serial number`} - tooltipAlignment='top' - variant='transparent' - onClick={findBySerialNumber.open} - /> - {serialNumbers.next?.pk && ( - - - - )} - - - ) - }, - { - 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' - }, - { - 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 ( - - - - - - - - - - - - - - - ); - }, [ - stockitem, - serialNumbers, - serialNumbersQuery.isFetching, - instanceQuery.isFetching, - enableExpiry - ]); - const showBuildAllocations: boolean = useMemo(() => { // Determine if "build allocations" should be shown for this stock item return ( @@ -554,7 +180,14 @@ export default function StockDetail() { name: 'details', label: t`Stock Details`, icon: , - content: detailsPanel + content: ( + + ) }, { name: 'tracking', @@ -685,8 +318,6 @@ export default function StockDetail() { showBuildAllocations, showInstalledItems, stockitem, - serialNumbers, - serialNumbersQuery, id, user ]); @@ -1061,7 +692,6 @@ export default function StockDetail() { return ( <> - {findBySerialNumber.modal} {scanIntoLocation.dialog} 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: () => ( + + {instance?.serial} + + + {serialNumbers?.previous?.pk && ( + + + + )} + } + tooltip={t`Find serial number`} + tooltipAlignment='top' + variant='transparent' + onClick={findBySerialNumber.open} + /> + {serialNumbers?.next?.pk && ( + + + + )} + + + ) + }) + }, + { + 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 ; + + return ( + <> + {showSerialNav && findBySerialNumber.modal} + + + + + + + + + + + + + + + + ); +} From 9cab4c27f83dc630db78eb25929c3b6929a98a3e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 13:33:26 +0000 Subject: [PATCH 20/34] Remove old component --- .../src/components/previews/AttributeGrid.tsx | 41 ------------------- 1 file changed, 41 deletions(-) delete mode 100644 src/frontend/src/components/previews/AttributeGrid.tsx diff --git a/src/frontend/src/components/previews/AttributeGrid.tsx b/src/frontend/src/components/previews/AttributeGrid.tsx deleted file mode 100644 index 93b7578712..0000000000 --- a/src/frontend/src/components/previews/AttributeGrid.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { StylishText } from '@lib/components/StylishText'; -import { Divider, Table } from '@mantine/core'; -import type { ReactNode } from 'react'; - -export interface AttributeRow { - label: string; - value: ReactNode | null | undefined; -} - -export function AttributeGrid({ - title, - items -}: Readonly<{ - title: string; - items: AttributeRow[]; -}>) { - const valid = items.filter( - (item) => item.value !== null && item.value !== undefined - ); - - if (valid.length === 0) return null; - - return ( - <> - - {title} - - - {valid.map((item) => ( - - - {item.label} - - {item.value} - - ))} - -
- - ); -} From 8ca997f73b894d174751d369e742fa0d6a8130f3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 13:48:06 +0000 Subject: [PATCH 21/34] Refactor details grid component --- .../src/components/details/Details.tsx | 14 ++++++----- .../src/components/details/ItemDetails.tsx | 20 ++++++++++++++-- .../details/ParameterDetailsGrid.tsx | 22 ++++++++---------- .../src/components/previews/PreviewType.tsx | 3 +++ src/frontend/src/pages/build/BuildDetail.tsx | 11 +++++---- .../src/pages/company/CompanyDetail.tsx | 3 +-- .../pages/company/ManufacturerPartDetail.tsx | 5 ++-- .../src/pages/company/SupplierPartDetail.tsx | 11 +++++---- src/frontend/src/pages/core/GroupDetail.tsx | 10 ++++---- src/frontend/src/pages/core/UserDetail.tsx | 21 ++++++++--------- .../src/pages/part/CategoryDetail.tsx | 19 ++++++++------- .../src/pages/part/PartDetailsPanel.tsx | 23 +++++++++++-------- .../src/pages/sales/ReturnOrderDetail.tsx | 11 +++++---- .../src/pages/sales/SalesOrderDetail.tsx | 11 +++++---- .../pages/sales/SalesOrderShipmentDetail.tsx | 11 +++++---- .../src/pages/stock/LocationDetail.tsx | 19 ++++++++------- .../src/pages/stock/StockDetailsPanel.tsx | 11 +++++---- .../src/pages/stock/TransferOrderDetail.tsx | 11 +++++---- 18 files changed, 141 insertions(+), 95 deletions(-) diff --git a/src/frontend/src/components/details/Details.tsx b/src/frontend/src/components/details/Details.tsx index 3598deada7..58eb6312bb 100644 --- a/src/frontend/src/components/details/Details.tsx +++ b/src/frontend/src/components/details/Details.tsx @@ -529,17 +529,19 @@ export function DetailsTableField({ ); } +export interface DetailsTableProps { + item: any; + fields: DetailsField[]; + title?: string; + showIcons?: boolean; +} + export function DetailsTable({ item, fields, title, showIcons = true -}: Readonly<{ - item: any; - fields: DetailsField[]; - title?: string; - showIcons?: boolean; -}>) { +}: Readonly) { const visibleFields = useMemo(() => { return fields.filter((field) => !field.hidden); }, [fields]); diff --git a/src/frontend/src/components/details/ItemDetails.tsx b/src/frontend/src/components/details/ItemDetails.tsx index 1d0b79d7da..e8b314c150 100644 --- a/src/frontend/src/components/details/ItemDetails.tsx +++ b/src/frontend/src/components/details/ItemDetails.tsx @@ -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 ( ) { spacing='xs' verticalSpacing='xs' > - {props.children} + {children} + {visibleTables.map((props, index) => ( + + ))} ); diff --git a/src/frontend/src/components/details/ParameterDetailsGrid.tsx b/src/frontend/src/components/details/ParameterDetailsGrid.tsx index 4849dcc25a..7ed2aedabc 100644 --- a/src/frontend/src/components/details/ParameterDetailsGrid.tsx +++ b/src/frontend/src/components/details/ParameterDetailsGrid.tsx @@ -5,15 +5,15 @@ import { t } from '@lingui/core/macro'; import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; import { useApi } from '../../contexts/ApiContext'; -import { type DetailsField, DetailsTable } from './Details'; +import type { DetailsField, DetailsTableProps } from './Details'; -export function ParameterDetailsGrid({ +export function useParameterDetailsGrid({ model_type, model_id }: Readonly<{ model_type: ModelType; model_id: number | string | undefined; -}>) { +}>): DetailsTableProps { const api = useApi(); const { data: parameters = [] } = useQuery({ @@ -45,14 +45,10 @@ export function ParameterDetailsGrid({ return { fields, item }; }, [parameters]); - if (fields.length === 0) return null; - - return ( - - ); + return { + title: t`Parameters`, + fields: fields, + item: item, + showIcons: false + }; } diff --git a/src/frontend/src/components/previews/PreviewType.tsx b/src/frontend/src/components/previews/PreviewType.tsx index 16ad538286..18f36069fb 100644 --- a/src/frontend/src/components/previews/PreviewType.tsx +++ b/src/frontend/src/components/previews/PreviewType.tsx @@ -1,6 +1,7 @@ import { ModelType } from '@lib/enums/ModelType'; import type { ReactNode } from 'react'; import { PartPreviewComponent } from './models/PartPreview'; +import { PurchaseOrderPreviewComponent } from './models/PurchaseOrderPreview'; import { StockPreviewComponent } from './models/StockPreview'; export interface PreviewType { @@ -28,6 +29,8 @@ export function getPreviewComponentForModel({ return PartPreviewComponent({ instance, modelId }); case ModelType.stockitem: return StockPreviewComponent({ instance, modelId }); + case ModelType.purchaseorder: + return PurchaseOrderPreviewComponent({ instance, modelId }); default: return null; } diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 6b8d8e3459..85689fe3cb 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -439,7 +439,13 @@ export default function BuildDetail() { ]; return ( - + - - - ); }, [build, instanceQuery, partRequirements, partRequirementsQuery]); diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx index 2b779d4112..4a55122c77 100644 --- a/src/frontend/src/pages/company/CompanyDetail.tsx +++ b/src/frontend/src/pages/company/CompanyDetail.tsx @@ -155,7 +155,7 @@ export default function CompanyDetail(props: Readonly) { ]; return ( - + ) { - ); }, [company, instanceQuery]); diff --git a/src/frontend/src/pages/company/ManufacturerPartDetail.tsx b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx index 33b00ba470..f9f1b5d57b 100644 --- a/src/frontend/src/pages/company/ManufacturerPartDetail.tsx +++ b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx @@ -134,7 +134,9 @@ export default function ManufacturerPartDetail() { ]; return ( - + - ); }, [manufacturerPart, instanceQuery]); diff --git a/src/frontend/src/pages/company/SupplierPartDetail.tsx b/src/frontend/src/pages/company/SupplierPartDetail.tsx index 4f16295460..e20d942491 100644 --- a/src/frontend/src/pages/company/SupplierPartDetail.tsx +++ b/src/frontend/src/pages/company/SupplierPartDetail.tsx @@ -222,7 +222,13 @@ export default function SupplierPartDetail() { ]; return ( - + - - - ); }, [supplierPart, instanceQuery.isFetching]); diff --git a/src/frontend/src/pages/core/GroupDetail.tsx b/src/frontend/src/pages/core/GroupDetail.tsx index 4126e3f68c..86a80b45b1 100644 --- a/src/frontend/src/pages/core/GroupDetail.tsx +++ b/src/frontend/src/pages/core/GroupDetail.tsx @@ -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 ( - - + {t`Group Roles`} diff --git a/src/frontend/src/pages/core/UserDetail.tsx b/src/frontend/src/pages/core/UserDetail.tsx index 87b4192219..4c5c1f131b 100644 --- a/src/frontend/src/pages/core/UserDetail.tsx +++ b/src/frontend/src/pages/core/UserDetail.tsx @@ -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 ( - - - - {hasProfile && settings.isSet('DISPLAY_PROFILE_INFO') && ( - - )} - + ); }, [instance, userGroups, instanceQuery]); diff --git a/src/frontend/src/pages/part/CategoryDetail.tsx b/src/frontend/src/pages/part/CategoryDetail.tsx index 9d0077e9c8..6e7ec43c90 100644 --- a/src/frontend/src/pages/part/CategoryDetail.tsx +++ b/src/frontend/src/pages/part/CategoryDetail.tsx @@ -20,10 +20,7 @@ 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 type { DetailsField } from '../../components/details/Details'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { DeleteItemAction, @@ -169,10 +166,16 @@ export default function CategoryDetail() { ]; return ( - - {id && category?.pk && } - {id && category?.pk && } - + ); }, [category, instanceQuery]); diff --git a/src/frontend/src/pages/part/PartDetailsPanel.tsx b/src/frontend/src/pages/part/PartDetailsPanel.tsx index 4f83264447..15100eb889 100644 --- a/src/frontend/src/pages/part/PartDetailsPanel.tsx +++ b/src/frontend/src/pages/part/PartDetailsPanel.tsx @@ -14,7 +14,7 @@ import { } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; -import { ParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid'; +import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid'; import { formatPriceRange } from '../../defaults/formatters'; import { useInstance } from '../../hooks/UseInstance'; @@ -322,10 +322,22 @@ export function PartDetailsPanel({ return fields; }, [instance, data.latest_serial_number]); + const parametersTable = useParameterDetailsGrid({ + model_type: ModelType.part, + model_id: instance?.pk + }); + if (!instance?.pk) return ; return ( - + {additionalContent} - - - - ); } diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx index b094826d7c..b62d341e1c 100644 --- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx +++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx @@ -297,7 +297,13 @@ export default function ReturnOrderDetail() { ]; return ( - + - - - ); }, [order, instanceQuery]); diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index 959ea726d1..086400a0f8 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx @@ -288,7 +288,13 @@ export default function SalesOrderDetail() { ]; return ( - + - - - ); }, [order, orderCurrency, instanceQuery]); diff --git a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx index 7d181814e8..83d956b817 100644 --- a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx @@ -222,7 +222,13 @@ export default function SalesOrderShipmentDetail() { return ( <> - + - - - ); diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index 0b13ba9f23..24fd34a0a2 100644 --- a/src/frontend/src/pages/stock/LocationDetail.tsx +++ b/src/frontend/src/pages/stock/LocationDetail.tsx @@ -24,10 +24,7 @@ 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 type { DetailsField } from '../../components/details/Details'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { BarcodeActionDropdown, @@ -185,10 +182,16 @@ export default function Stock() { ]; return ( - - {id && location?.pk && } - {id && location?.pk && } - + ); }, [location, instanceQuery]); diff --git a/src/frontend/src/pages/stock/StockDetailsPanel.tsx b/src/frontend/src/pages/stock/StockDetailsPanel.tsx index 009ffdb5ea..00c23cc8f1 100644 --- a/src/frontend/src/pages/stock/StockDetailsPanel.tsx +++ b/src/frontend/src/pages/stock/StockDetailsPanel.tsx @@ -377,7 +377,13 @@ export function StockDetailsPanel({ return ( <> {showSerialNav && findBySerialNumber.modal} - + - - - ); diff --git a/src/frontend/src/pages/stock/TransferOrderDetail.tsx b/src/frontend/src/pages/stock/TransferOrderDetail.tsx index 39dd9d3354..41efe4e8b4 100644 --- a/src/frontend/src/pages/stock/TransferOrderDetail.tsx +++ b/src/frontend/src/pages/stock/TransferOrderDetail.tsx @@ -234,7 +234,13 @@ export default function TransferOrderDetail() { ]; return ( - + {/* TODO: what image do we show for a Transfer Order? */} @@ -250,9 +256,6 @@ export default function TransferOrderDetail() { - - - ); }, [order, instanceQuery]); From e105a93d4045b9fe10a9ef52a48b1285810665fe Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 13:50:39 +0000 Subject: [PATCH 22/34] Preview panel for PurchaseOrder model --- .../previews/models/PurchaseOrderPreview.tsx | 25 ++ .../pages/purchasing/PurchaseOrderDetail.tsx | 238 +--------------- .../purchasing/PurchaseOrderDetailsPanel.tsx | 258 ++++++++++++++++++ 3 files changed, 297 insertions(+), 224 deletions(-) create mode 100644 src/frontend/src/components/previews/models/PurchaseOrderPreview.tsx create mode 100644 src/frontend/src/pages/purchasing/PurchaseOrderDetailsPanel.tsx diff --git a/src/frontend/src/components/previews/models/PurchaseOrderPreview.tsx b/src/frontend/src/components/previews/models/PurchaseOrderPreview.tsx new file mode 100644 index 0000000000..c5033022be --- /dev/null +++ b/src/frontend/src/components/previews/models/PurchaseOrderPreview.tsx @@ -0,0 +1,25 @@ +import { t } from '@lingui/core/macro'; +import { PurchaseOrderDetailsPanel } from '../../../pages/purchasing/PurchaseOrderDetailsPanel'; +import type { PreviewType } from '../PreviewType'; + +export function PurchaseOrderPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const supplier = instance?.supplier_detail?.name ?? instance?.supplier_name; + const ref = instance?.reference ?? `#${modelId}`; + + let title = `${t`Purchase Order`} ${ref}`; + + if (supplier) { + title += ` (${supplier})`; + } + + return { + title: title, + preview: + }; +} diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx index 43f5e84301..ce2549bdf6 100644 --- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx @@ -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 ; - } - - 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 ( - - - - - - - - - - - - - - - ); - }, [order, orderCurrency, instanceQuery]); - const orderPanels: PanelType[] = useMemo(() => { return [ { name: 'detail', label: t`Order Details`, icon: , - content: detailsPanel + content: ( + + ) }, { name: 'line-items', diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetailsPanel.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetailsPanel.tsx new file mode 100644 index 0000000000..60156bf3dd --- /dev/null +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetailsPanel.tsx @@ -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 ; + + return ( + + + + + + + + + + + + ); +} From b28b1f733086f30eaf3aae31a057c4975b5bb12f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 13:55:47 +0000 Subject: [PATCH 23/34] Implement for SalesOrder --- .../src/components/previews/PreviewType.tsx | 3 + .../previews/models/SalesOrderPreview.tsx | 25 ++ .../src/pages/sales/SalesOrderDetail.tsx | 244 +--------------- .../pages/sales/SalesOrderDetailsPanel.tsx | 270 ++++++++++++++++++ 4 files changed, 307 insertions(+), 235 deletions(-) create mode 100644 src/frontend/src/components/previews/models/SalesOrderPreview.tsx create mode 100644 src/frontend/src/pages/sales/SalesOrderDetailsPanel.tsx diff --git a/src/frontend/src/components/previews/PreviewType.tsx b/src/frontend/src/components/previews/PreviewType.tsx index 18f36069fb..13a81117d8 100644 --- a/src/frontend/src/components/previews/PreviewType.tsx +++ b/src/frontend/src/components/previews/PreviewType.tsx @@ -2,6 +2,7 @@ import { ModelType } from '@lib/enums/ModelType'; import type { ReactNode } from 'react'; import { PartPreviewComponent } from './models/PartPreview'; import { PurchaseOrderPreviewComponent } from './models/PurchaseOrderPreview'; +import { SalesOrderPreviewComponent } from './models/SalesOrderPreview'; import { StockPreviewComponent } from './models/StockPreview'; export interface PreviewType { @@ -31,6 +32,8 @@ export function getPreviewComponentForModel({ return StockPreviewComponent({ instance, modelId }); case ModelType.purchaseorder: return PurchaseOrderPreviewComponent({ instance, modelId }); + case ModelType.salesorder: + return SalesOrderPreviewComponent({ instance, modelId }); default: return null; } diff --git a/src/frontend/src/components/previews/models/SalesOrderPreview.tsx b/src/frontend/src/components/previews/models/SalesOrderPreview.tsx new file mode 100644 index 0000000000..83880b0e91 --- /dev/null +++ b/src/frontend/src/components/previews/models/SalesOrderPreview.tsx @@ -0,0 +1,25 @@ +import { t } from '@lingui/core/macro'; +import { SalesOrderDetailsPanel } from '../../../pages/sales/SalesOrderDetailsPanel'; +import type { PreviewType } from '../PreviewType'; + +export function SalesOrderPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const customer = instance?.customer_detail?.name ?? instance?.customer_name; + const ref = instance?.reference ?? `#${modelId}`; + + let title = `${t`Sales Order`} ${ref}`; + + if (customer) { + title += ` (${customer})`; + } + + return { + title, + preview: + }; +} diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index 086400a0f8..4b5c4df018 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx @@ -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,230 +81,6 @@ export default function SalesOrderDetail() { ); }, [order, globalSettings]); - const detailsPanel = useMemo(() => { - if (instanceQuery.isFetching) { - return ; - } - - 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 ? ( - - ) : ( - {t`Not specified`} - ) - }, - { - 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 ( - - - - - - - - - - - - ); - }, [order, orderCurrency, instanceQuery]); - const soStatus = useStatusCodes({ modelType: ModelType.salesorder }); const lineItemsEditable: boolean = useMemo(() => { @@ -367,7 +135,13 @@ export default function SalesOrderDetail() { name: 'detail', label: t`Order Details`, icon: , - content: detailsPanel + content: ( + + ) }, { name: 'line-items', diff --git a/src/frontend/src/pages/sales/SalesOrderDetailsPanel.tsx b/src/frontend/src/pages/sales/SalesOrderDetailsPanel.tsx new file mode 100644 index 0000000000..cddd8dc626 --- /dev/null +++ b/src/frontend/src/pages/sales/SalesOrderDetailsPanel.tsx @@ -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 ? ( + + ) : ( + {t`Not specified`} + ) + }, + { + 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 ; + + return ( + + + + + + + + + + + + ); +} From ed074810a41da1d2f053d37c8ee6ebe1a476afb9 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 14:01:07 +0000 Subject: [PATCH 24/34] Support ReturnOrder --- .../src/components/previews/PreviewType.tsx | 3 + .../previews/models/ReturnOrderPreview.tsx | 25 ++ .../src/pages/sales/ReturnOrderDetail.tsx | 243 +--------------- .../pages/sales/ReturnOrderDetailsPanel.tsx | 270 ++++++++++++++++++ 4 files changed, 307 insertions(+), 234 deletions(-) create mode 100644 src/frontend/src/components/previews/models/ReturnOrderPreview.tsx create mode 100644 src/frontend/src/pages/sales/ReturnOrderDetailsPanel.tsx diff --git a/src/frontend/src/components/previews/PreviewType.tsx b/src/frontend/src/components/previews/PreviewType.tsx index 13a81117d8..bf6ab74fa2 100644 --- a/src/frontend/src/components/previews/PreviewType.tsx +++ b/src/frontend/src/components/previews/PreviewType.tsx @@ -2,6 +2,7 @@ import { ModelType } from '@lib/enums/ModelType'; import type { ReactNode } from 'react'; import { PartPreviewComponent } from './models/PartPreview'; import { PurchaseOrderPreviewComponent } from './models/PurchaseOrderPreview'; +import { ReturnOrderPreviewComponent } from './models/ReturnOrderPreview'; import { SalesOrderPreviewComponent } from './models/SalesOrderPreview'; import { StockPreviewComponent } from './models/StockPreview'; @@ -34,6 +35,8 @@ export function getPreviewComponentForModel({ return PurchaseOrderPreviewComponent({ instance, modelId }); case ModelType.salesorder: return SalesOrderPreviewComponent({ instance, modelId }); + case ModelType.returnorder: + return ReturnOrderPreviewComponent({ instance, modelId }); default: return null; } diff --git a/src/frontend/src/components/previews/models/ReturnOrderPreview.tsx b/src/frontend/src/components/previews/models/ReturnOrderPreview.tsx new file mode 100644 index 0000000000..320cff6859 --- /dev/null +++ b/src/frontend/src/components/previews/models/ReturnOrderPreview.tsx @@ -0,0 +1,25 @@ +import { t } from '@lingui/core/macro'; +import { ReturnOrderDetailsPanel } from '../../../pages/sales/ReturnOrderDetailsPanel'; +import type { PreviewType } from '../PreviewType'; + +export function ReturnOrderPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const customer = instance?.customer_detail?.name ?? instance?.customer_name; + const ref = instance?.reference ?? `#${modelId}`; + + let title = `${t`Return Order`} ${ref}`; + + if (customer) { + title += ` (${customer})`; + } + + return { + title, + preview: + }; +} diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx index b62d341e1c..763886814a 100644 --- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx +++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx @@ -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,236 +91,19 @@ export default function ReturnOrderDetail() { ); }, [order, globalSettings]); - const detailsPanel = useMemo(() => { - if (instanceQuery.isFetching) { - return ; - } - - 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 ? ( - - ) : ( - {t`Not specified`} - ) - }, - { - 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 ( - - - - - - - - - - - - ); - }, [order, instanceQuery]); - const orderPanels: PanelType[] = useMemo(() => { return [ { name: 'detail', label: t`Order Details`, icon: , - content: detailsPanel + content: ( + + ) }, { name: 'line-items', diff --git a/src/frontend/src/pages/sales/ReturnOrderDetailsPanel.tsx b/src/frontend/src/pages/sales/ReturnOrderDetailsPanel.tsx new file mode 100644 index 0000000000..aa4117700e --- /dev/null +++ b/src/frontend/src/pages/sales/ReturnOrderDetailsPanel.tsx @@ -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 ? ( + + ) : ( + {t`Not specified`} + ) + }, + { + 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 ; + + return ( + + + + + + + + + + + + ); +} From 524cf0288c164fcd65b2088f797b8279e6ed10df Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 14:11:07 +0000 Subject: [PATCH 25/34] SupplierPart and ManufacturerPart --- .../src/components/previews/PreviewType.tsx | 6 + .../models/ManufacturerPartPreview.tsx | 26 +++ .../previews/models/SupplierPartPreview.tsx | 25 +++ .../pages/company/ManufacturerPartDetail.tsx | 112 +--------- .../company/ManufacturerPartDetailsPanel.tsx | 119 ++++++++++ .../src/pages/company/SupplierPartDetail.tsx | 196 +---------------- .../company/SupplierPartDetailsPanel.tsx | 206 ++++++++++++++++++ src/frontend/src/tables/InvenTreeTable.tsx | 2 + 8 files changed, 402 insertions(+), 290 deletions(-) create mode 100644 src/frontend/src/components/previews/models/ManufacturerPartPreview.tsx create mode 100644 src/frontend/src/components/previews/models/SupplierPartPreview.tsx create mode 100644 src/frontend/src/pages/company/ManufacturerPartDetailsPanel.tsx create mode 100644 src/frontend/src/pages/company/SupplierPartDetailsPanel.tsx diff --git a/src/frontend/src/components/previews/PreviewType.tsx b/src/frontend/src/components/previews/PreviewType.tsx index bf6ab74fa2..8131348bf0 100644 --- a/src/frontend/src/components/previews/PreviewType.tsx +++ b/src/frontend/src/components/previews/PreviewType.tsx @@ -1,10 +1,12 @@ import { ModelType } from '@lib/enums/ModelType'; import type { ReactNode } from 'react'; +import { ManufacturerPartPreviewComponent } from './models/ManufacturerPartPreview'; import { PartPreviewComponent } from './models/PartPreview'; import { PurchaseOrderPreviewComponent } from './models/PurchaseOrderPreview'; import { ReturnOrderPreviewComponent } from './models/ReturnOrderPreview'; import { SalesOrderPreviewComponent } from './models/SalesOrderPreview'; import { StockPreviewComponent } from './models/StockPreview'; +import { SupplierPartPreviewComponent } from './models/SupplierPartPreview'; export interface PreviewType { preview: ReactNode; @@ -37,6 +39,10 @@ export function getPreviewComponentForModel({ return SalesOrderPreviewComponent({ instance, modelId }); case ModelType.returnorder: return ReturnOrderPreviewComponent({ instance, modelId }); + case ModelType.supplierpart: + return SupplierPartPreviewComponent({ instance, modelId }); + case ModelType.manufacturerpart: + return ManufacturerPartPreviewComponent({ instance, modelId }); default: return null; } diff --git a/src/frontend/src/components/previews/models/ManufacturerPartPreview.tsx b/src/frontend/src/components/previews/models/ManufacturerPartPreview.tsx new file mode 100644 index 0000000000..df37a939a3 --- /dev/null +++ b/src/frontend/src/components/previews/models/ManufacturerPartPreview.tsx @@ -0,0 +1,26 @@ +import { t } from '@lingui/core/macro'; +import { ManufacturerPartDetailsPanel } from '../../../pages/company/ManufacturerPartDetailsPanel'; +import type { PreviewType } from '../PreviewType'; + +export function ManufacturerPartPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const manufacturer = + instance?.manufacturer_detail?.name ?? instance?.manufacturer_name; + const mpn = instance?.MPN ?? `#${modelId}`; + + let title = `${t`Manufacturer Part`} ${mpn}`; + + if (manufacturer) { + title += ` (${manufacturer})`; + } + + return { + title, + preview: + }; +} diff --git a/src/frontend/src/components/previews/models/SupplierPartPreview.tsx b/src/frontend/src/components/previews/models/SupplierPartPreview.tsx new file mode 100644 index 0000000000..bb556c9ffe --- /dev/null +++ b/src/frontend/src/components/previews/models/SupplierPartPreview.tsx @@ -0,0 +1,25 @@ +import { t } from '@lingui/core/macro'; +import { SupplierPartDetailsPanel } from '../../../pages/company/SupplierPartDetailsPanel'; +import type { PreviewType } from '../PreviewType'; + +export function SupplierPartPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const supplier = instance?.supplier_detail?.name ?? instance?.supplier_name; + const sku = instance?.SKU ?? `#${modelId}`; + + let title = `${t`Supplier Part`} ${sku}`; + + if (supplier) { + title += ` (${supplier})`; + } + + return { + title, + preview: + }; +} diff --git a/src/frontend/src/pages/company/ManufacturerPartDetail.tsx b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx index f9f1b5d57b..55529481e8 100644 --- a/src/frontend/src/pages/company/ManufacturerPartDetail.tsx +++ b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx @@ -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,106 +58,19 @@ export default function ManufacturerPartDetail() { } }); - const detailsPanel = useMemo(() => { - if (instanceQuery.isFetching) { - return ; - } - - 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 ( - - - - - - - - - - - - ); - }, [manufacturerPart, instanceQuery]); - const panels: PanelType[] = useMemo(() => { return [ { name: 'details', label: t`Manufacturer Part Details`, icon: , - content: detailsPanel + content: ( + + ) }, { name: 'stock', diff --git a/src/frontend/src/pages/company/ManufacturerPartDetailsPanel.tsx b/src/frontend/src/pages/company/ManufacturerPartDetailsPanel.tsx new file mode 100644 index 0000000000..e8281266f7 --- /dev/null +++ b/src/frontend/src/pages/company/ManufacturerPartDetailsPanel.tsx @@ -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 ; + + return ( + + + + + + + + + + + + ); +} diff --git a/src/frontend/src/pages/company/SupplierPartDetail.tsx b/src/frontend/src/pages/company/SupplierPartDetail.tsx index e20d942491..fbdc880b74 100644 --- a/src/frontend/src/pages/company/SupplierPartDetail.tsx +++ b/src/frontend/src/pages/company/SupplierPartDetail.tsx @@ -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,190 +66,19 @@ export default function SupplierPartDetail() { } }); - const detailsPanel = useMemo(() => { - if (instanceQuery.isFetching) { - return ; - } - - 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 ( - - - - - - - - - - - - ); - }, [supplierPart, instanceQuery.isFetching]); - const panels: PanelType[] = useMemo(() => { return [ { name: 'details', label: t`Supplier Part Details`, icon: , - content: detailsPanel + content: ( + + ) }, { name: 'stock', diff --git a/src/frontend/src/pages/company/SupplierPartDetailsPanel.tsx b/src/frontend/src/pages/company/SupplierPartDetailsPanel.tsx new file mode 100644 index 0000000000..5f1d5a4458 --- /dev/null +++ b/src/frontend/src/pages/company/SupplierPartDetailsPanel.tsx @@ -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 ; + + return ( + + + + + + + + + + + + ); +} diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index b6b1981f4c..4c6fcdea89 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -741,6 +741,8 @@ export function InvenTreeTableInternal>({ const accessor = tableProps.modelField ?? 'pk'; const pk = resolveItem(record, accessor); + console.log('row click:', tableProps.modelType, accessor, '->', pk); + if (pk) { cancelEvent(event); // If a model type is provided, navigate to the detail view for that model From 95bf7f709f7cb7ab330b8599c7548b3f45fdb043 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 14:14:31 +0000 Subject: [PATCH 26/34] Support preview in parameteric tables --- src/frontend/src/tables/general/ParametricDataTable.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/tables/general/ParametricDataTable.tsx b/src/frontend/src/tables/general/ParametricDataTable.tsx index 5634480925..d86b172c3b 100644 --- a/src/frontend/src/tables/general/ParametricDataTable.tsx +++ b/src/frontend/src/tables/general/ParametricDataTable.tsx @@ -1,4 +1,5 @@ import { cancelEvent } from '@lib/functions/Events'; +import { eventModified } from '@lib/functions/Navigation'; import useTable from '@lib/hooks/UseTable'; import { ApiEndpoints, @@ -27,6 +28,7 @@ import { useCreateApiFormModal, useEditApiFormModal } from '../../hooks/UseForm'; +import { openGlobalPreview } from '../../states/PreviewDrawerState'; import { useUserState } from '../../states/UserState'; import { InvenTreeTable } from '../InvenTreeTable'; import { TableHoverCard } from '../TableHoverCard'; @@ -472,9 +474,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); + } } } }} From dd46cced53cf0e845f9f1f3ac448e826bef3d2fb Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 14:22:53 +0000 Subject: [PATCH 27/34] support Company and BuildOrder --- .../src/components/previews/PreviewType.tsx | 6 + .../previews/models/BuildOrderPreview.tsx | 25 ++ .../previews/models/CompanyPreview.tsx | 18 ++ src/frontend/src/pages/build/BuildDetail.tsx | 249 +---------------- .../pages/build/BuildOrderDetailsPanel.tsx | 262 ++++++++++++++++++ .../src/pages/company/CompanyDetail.tsx | 114 +------- .../src/pages/company/CompanyDetailsPanel.tsx | 119 ++++++++ .../src/tables/company/CompanyTable.tsx | 18 +- 8 files changed, 464 insertions(+), 347 deletions(-) create mode 100644 src/frontend/src/components/previews/models/BuildOrderPreview.tsx create mode 100644 src/frontend/src/components/previews/models/CompanyPreview.tsx create mode 100644 src/frontend/src/pages/build/BuildOrderDetailsPanel.tsx create mode 100644 src/frontend/src/pages/company/CompanyDetailsPanel.tsx diff --git a/src/frontend/src/components/previews/PreviewType.tsx b/src/frontend/src/components/previews/PreviewType.tsx index 8131348bf0..3dfb2eb19a 100644 --- a/src/frontend/src/components/previews/PreviewType.tsx +++ b/src/frontend/src/components/previews/PreviewType.tsx @@ -1,5 +1,7 @@ import { ModelType } from '@lib/enums/ModelType'; import type { ReactNode } from 'react'; +import { BuildOrderPreviewComponent } from './models/BuildOrderPreview'; +import { CompanyPreviewComponent } from './models/CompanyPreview'; import { ManufacturerPartPreviewComponent } from './models/ManufacturerPartPreview'; import { PartPreviewComponent } from './models/PartPreview'; import { PurchaseOrderPreviewComponent } from './models/PurchaseOrderPreview'; @@ -43,6 +45,10 @@ export function getPreviewComponentForModel({ return SupplierPartPreviewComponent({ instance, modelId }); case ModelType.manufacturerpart: return ManufacturerPartPreviewComponent({ instance, modelId }); + case ModelType.company: + return CompanyPreviewComponent({ instance, modelId }); + case ModelType.build: + return BuildOrderPreviewComponent({ instance, modelId }); default: return null; } diff --git a/src/frontend/src/components/previews/models/BuildOrderPreview.tsx b/src/frontend/src/components/previews/models/BuildOrderPreview.tsx new file mode 100644 index 0000000000..5735da0c80 --- /dev/null +++ b/src/frontend/src/components/previews/models/BuildOrderPreview.tsx @@ -0,0 +1,25 @@ +import { t } from '@lingui/core/macro'; +import { BuildOrderDetailsPanel } from '../../../pages/build/BuildOrderDetailsPanel'; +import type { PreviewType } from '../PreviewType'; + +export function BuildOrderPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const part = instance?.part_detail?.full_name ?? instance?.part_detail?.name; + const ref = instance?.reference ?? `#${modelId}`; + + let title = `${t`Build Order`} ${ref}`; + + if (part) { + title += ` (${part})`; + } + + return { + title, + preview: + }; +} diff --git a/src/frontend/src/components/previews/models/CompanyPreview.tsx b/src/frontend/src/components/previews/models/CompanyPreview.tsx new file mode 100644 index 0000000000..772ad33944 --- /dev/null +++ b/src/frontend/src/components/previews/models/CompanyPreview.tsx @@ -0,0 +1,18 @@ +import { t } from '@lingui/core/macro'; +import { CompanyDetailsPanel } from '../../../pages/company/CompanyDetailsPanel'; +import type { PreviewType } from '../PreviewType'; + +export function CompanyPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const name = instance?.name ?? `#${modelId}`; + + return { + title: `${t`Company`} - ${name}`, + preview: + }; +} diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 85689fe3cb..bd11686939 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -1,5 +1,5 @@ import { t } from '@lingui/core/macro'; -import { Alert, Grid, Skeleton, Stack, Text } from '@mantine/core'; +import { Alert, Skeleton, Stack, Text } from '@mantine/core'; import { IconChecklist, IconCircleCheck, @@ -21,19 +21,12 @@ import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; import { getDetailUrl } from '@lib/functions/Navigation'; -import { TagsList } from '@lib/index'; import type { ApiFormFieldSet } from '@lib/types/Forms'; import type { PanelType } from '@lib/types/Panel'; import AdminButton from '../../components/buttons/AdminButton'; import PrimaryActionButton from '../../components/buttons/PrimaryActionButton'; import { PrintingActions } from '../../components/buttons/PrintingActions'; -import { - type DetailsField, - DetailsTable -} from '../../components/details/Details'; import DetailsBadge from '../../components/details/DetailsBadge'; -import { DetailsImage } from '../../components/details/DetailsImage'; -import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { BarcodeActionDropdown, CancelItemAction, @@ -66,6 +59,7 @@ import BuildOutputTable from '../../tables/build/BuildOutputTable'; import PartTestResultTable from '../../tables/part/PartTestResultTable'; import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable'; import { StockItemTable } from '../../tables/stock/StockItemTable'; +import { BuildOrderDetailsPanel } from './BuildOrderDetailsPanel'; function NoItems() { return ( @@ -235,242 +229,19 @@ export default function BuildDetail() { refetchOnMount: true }); - const { instance: partRequirements, instanceQuery: partRequirementsQuery } = - useInstance({ - endpoint: ApiEndpoints.part_requirements, - pk: build?.part, - hasPrimaryKey: true, - defaultValue: {} - }); - - const detailsPanel = useMemo(() => { - if (instanceQuery.isFetching) { - return ; - } - - 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 ( - - - - - - - - - - - - ); - }, [build, instanceQuery, partRequirements, partRequirementsQuery]); - const buildPanels: PanelType[] = useMemo(() => { return [ { name: 'details', label: t`Build Details`, icon: , - content: detailsPanel + content: ( + + ) }, { name: 'line-items', @@ -600,7 +371,7 @@ export default function BuildDetail() { build, id, user, - partRequirements, + buildStatus, globalSettings, showChildBuilds, diff --git a/src/frontend/src/pages/build/BuildOrderDetailsPanel.tsx b/src/frontend/src/pages/build/BuildOrderDetailsPanel.tsx new file mode 100644 index 0000000000..4e09e1b59a --- /dev/null +++ b/src/frontend/src/pages/build/BuildOrderDetailsPanel.tsx @@ -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 ; + + return ( + + + + + + + + + + + + ); +} diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx index 4a55122c77..c1501b6b6d 100644 --- a/src/frontend/src/pages/company/CompanyDetail.tsx +++ b/src/frontend/src/pages/company/CompanyDetail.tsx @@ -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, EditItemAction, @@ -56,6 +48,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; @@ -85,100 +78,15 @@ export default function CompanyDetail(props: Readonly) { refetchOnMount: true }); - const detailsPanel = useMemo(() => { - if (instanceQuery.isFetching) { - return ; - } - - 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 ( - - - - - - - - - - - - ); - }, [company, instanceQuery]); + const detailsPanel = instanceQuery.isFetching ? ( + + ) : ( + + ); const companyPanels: PanelType[] = useMemo(() => { return [ diff --git a/src/frontend/src/pages/company/CompanyDetailsPanel.tsx b/src/frontend/src/pages/company/CompanyDetailsPanel.tsx new file mode 100644 index 0000000000..b98d67515c --- /dev/null +++ b/src/frontend/src/pages/company/CompanyDetailsPanel.tsx @@ -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 ; + + return ( + + + + + + + + + + + + ); +} diff --git a/src/frontend/src/tables/company/CompanyTable.tsx b/src/frontend/src/tables/company/CompanyTable.tsx index a3ebc82422..ccd0cc61c0 100644 --- a/src/frontend/src/tables/company/CompanyTable.tsx +++ b/src/frontend/src/tables/company/CompanyTable.tsx @@ -8,7 +8,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; -import { navigateToLink } from '@lib/functions/Navigation'; +import { eventModified, navigateToLink } from '@lib/functions/Navigation'; import useTable from '@lib/hooks/UseTable'; import type { TableFilter } from '@lib/types/Filters'; import { companyFields } from '../../forms/CompanyForms'; @@ -16,6 +16,7 @@ import { useCreateApiFormModal, useEditApiFormModal } from '../../hooks/UseForm'; +import { usePreviewDrawerState } from '../../states/PreviewDrawerState'; import { useUserState } from '../../states/UserState'; import { BooleanColumn, @@ -49,6 +50,7 @@ export function CompanyTable({ const navigate = useNavigate(); const user = useUserState(); + const previewDrawer = usePreviewDrawerState(); const columns = useMemo(() => { return [ @@ -166,10 +168,16 @@ export function CompanyTable({ params: { ...params }, - onRowClick: (record: any, index: number, event: any) => { - if (record.pk) { - const base = path ?? 'company'; - navigateToLink(`/${base}/${record.pk}`, navigate, event); + onRowClick: (record: any, _index: number, event: any) => { + if (!record.pk) return; + if (eventModified(event as any)) { + navigateToLink( + `/${path ?? 'company'}/${record.pk}`, + navigate, + event + ); + } else { + previewDrawer.openPreview(ModelType.company, Number(record.pk)); } }, modelType: ModelType.company, From 6de4a056c76a76075bbc8013b8326a56aee85c06 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 14:24:49 +0000 Subject: [PATCH 28/34] Fix numbervalue --- src/frontend/src/components/details/Details.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/components/details/Details.tsx b/src/frontend/src/components/details/Details.tsx index 58eb6312bb..28403e62f4 100644 --- a/src/frontend/src/components/details/Details.tsx +++ b/src/frontend/src/components/details/Details.tsx @@ -280,7 +280,7 @@ function NumberValue(props: Readonly) { 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 '---'; From 763fab2033fec495b155f8aa2312731ef25c35e9 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 14:28:53 +0000 Subject: [PATCH 29/34] BuildOrder and SalesOrderShipment --- .../src/components/previews/PreviewType.tsx | 6 + .../models/SalesOrderShipmentPreview.tsx | 25 +++ .../previews/models/TransferOrderPreview.tsx | 18 ++ .../pages/sales/SalesOrderShipmentDetail.tsx | 201 +---------------- .../sales/SalesOrderShipmentDetailsPanel.tsx | 205 ++++++++++++++++++ .../src/pages/stock/TransferOrderDetail.tsx | 181 +--------------- .../pages/stock/TransferOrderDetailsPanel.tsx | 181 ++++++++++++++++ 7 files changed, 453 insertions(+), 364 deletions(-) create mode 100644 src/frontend/src/components/previews/models/SalesOrderShipmentPreview.tsx create mode 100644 src/frontend/src/components/previews/models/TransferOrderPreview.tsx create mode 100644 src/frontend/src/pages/sales/SalesOrderShipmentDetailsPanel.tsx create mode 100644 src/frontend/src/pages/stock/TransferOrderDetailsPanel.tsx diff --git a/src/frontend/src/components/previews/PreviewType.tsx b/src/frontend/src/components/previews/PreviewType.tsx index 3dfb2eb19a..c3b0c3a433 100644 --- a/src/frontend/src/components/previews/PreviewType.tsx +++ b/src/frontend/src/components/previews/PreviewType.tsx @@ -7,8 +7,10 @@ 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 { StockPreviewComponent } from './models/StockPreview'; import { SupplierPartPreviewComponent } from './models/SupplierPartPreview'; +import { TransferOrderPreviewComponent } from './models/TransferOrderPreview'; export interface PreviewType { preview: ReactNode; @@ -49,6 +51,10 @@ export function getPreviewComponentForModel({ return CompanyPreviewComponent({ instance, modelId }); case ModelType.build: return BuildOrderPreviewComponent({ instance, modelId }); + case ModelType.salesordershipment: + return SalesOrderShipmentPreviewComponent({ instance, modelId }); + case ModelType.transferorder: + return TransferOrderPreviewComponent({ instance, modelId }); default: return null; } diff --git a/src/frontend/src/components/previews/models/SalesOrderShipmentPreview.tsx b/src/frontend/src/components/previews/models/SalesOrderShipmentPreview.tsx new file mode 100644 index 0000000000..ce85b40946 --- /dev/null +++ b/src/frontend/src/components/previews/models/SalesOrderShipmentPreview.tsx @@ -0,0 +1,25 @@ +import { t } from '@lingui/core/macro'; +import { SalesOrderShipmentDetailsPanel } from '../../../pages/sales/SalesOrderShipmentDetailsPanel'; +import type { PreviewType } from '../PreviewType'; + +export function SalesOrderShipmentPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const order = instance?.order_detail?.reference; + const ref = instance?.reference ?? `#${modelId}`; + + let title = `${t`Shipment`} ${ref}`; + + if (order) { + title += ` (${order})`; + } + + return { + title, + preview: + }; +} diff --git a/src/frontend/src/components/previews/models/TransferOrderPreview.tsx b/src/frontend/src/components/previews/models/TransferOrderPreview.tsx new file mode 100644 index 0000000000..0d8458d51b --- /dev/null +++ b/src/frontend/src/components/previews/models/TransferOrderPreview.tsx @@ -0,0 +1,18 @@ +import { t } from '@lingui/core/macro'; +import { TransferOrderDetailsPanel } from '../../../pages/stock/TransferOrderDetailsPanel'; +import type { PreviewType } from '../PreviewType'; + +export function TransferOrderPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const ref = instance?.reference ?? `#${modelId}`; + + return { + title: `${t`Transfer Order`} ${ref}`, + preview: + }; +} diff --git a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx index 83d956b817..44de746498 100644 --- a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx @@ -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,193 +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 ; - } - - 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 ? ( - - ) : ( - {t`Not specified`} - ) - } - ]; - - 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 ? ( - - ) : ( - {t`Not checked`} - ) - }, - { - 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 ( - <> - - - - - - - - - - - - - ); - }, [shipment, shipmentQuery, customer, customerQuery]); - const shipmentPanels: PanelType[] = useMemo(() => { return [ { name: 'detail', label: t`Shipment Details`, icon: , - content: detailsPanel + content: ( + + ) }, { name: 'items', @@ -291,7 +110,7 @@ export default function SalesOrderShipmentDetail() { has_note: !!shipment.notes }) ]; - }, [isPending, shipment, detailsPanel]); + }, [isPending, shipment]); const editShipmentFields = useSalesOrderShipmentFields({ pending: isPending, @@ -457,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} diff --git a/src/frontend/src/pages/sales/SalesOrderShipmentDetailsPanel.tsx b/src/frontend/src/pages/sales/SalesOrderShipmentDetailsPanel.tsx new file mode 100644 index 0000000000..5280abac94 --- /dev/null +++ b/src/frontend/src/pages/sales/SalesOrderShipmentDetailsPanel.tsx @@ -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 ? ( + + ) : ( + {t`Not specified`} + ) + } + ]; + + 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 ? ( + + ) : ( + {t`Not checked`} + ) + }, + { + 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 ; + + return ( + + + + + + + + + + + + ); +} diff --git a/src/frontend/src/pages/stock/TransferOrderDetail.tsx b/src/frontend/src/pages/stock/TransferOrderDetail.tsx index 41efe4e8b4..edc9dba94d 100644 --- a/src/frontend/src/pages/stock/TransferOrderDetail.tsx +++ b/src/frontend/src/pages/stock/TransferOrderDetail.tsx @@ -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,172 +89,11 @@ export default function TransferOrderDetail() { ); }, [order, toStatus]); - const detailsPanel = useMemo(() => { - if (instanceQuery.isFetching) { - return ; - } - - 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 ( - - - - {/* TODO: what image do we show for a Transfer Order? */} - {/* */} - - - - - - - - ); - }, [order, instanceQuery]); + const detailsPanel = instanceQuery.isFetching ? ( + + ) : ( + + ); const orderPanels: PanelType[] = useMemo(() => { return [ diff --git a/src/frontend/src/pages/stock/TransferOrderDetailsPanel.tsx b/src/frontend/src/pages/stock/TransferOrderDetailsPanel.tsx new file mode 100644 index 0000000000..a3388b7bc9 --- /dev/null +++ b/src/frontend/src/pages/stock/TransferOrderDetailsPanel.tsx @@ -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 ; + + return ( + + + + + + + + + + + ); +} From 56ed75777652ddeae76bf41d3f014ce437beb034 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Jun 2026 14:34:43 +0000 Subject: [PATCH 30/34] location and category --- .../src/components/previews/PreviewType.tsx | 6 + .../previews/models/PartCategoryPreview.tsx | 18 +++ .../previews/models/StockLocationPreview.tsx | 18 +++ .../src/pages/part/CategoryDetail.tsx | 107 +--------------- .../pages/part/PartCategoryDetailsPanel.tsx | 121 ++++++++++++++++++ .../src/pages/stock/LocationDetail.tsx | 101 +-------------- .../pages/stock/StockLocationDetailsPanel.tsx | 106 +++++++++++++++ 7 files changed, 279 insertions(+), 198 deletions(-) create mode 100644 src/frontend/src/components/previews/models/PartCategoryPreview.tsx create mode 100644 src/frontend/src/components/previews/models/StockLocationPreview.tsx create mode 100644 src/frontend/src/pages/part/PartCategoryDetailsPanel.tsx create mode 100644 src/frontend/src/pages/stock/StockLocationDetailsPanel.tsx diff --git a/src/frontend/src/components/previews/PreviewType.tsx b/src/frontend/src/components/previews/PreviewType.tsx index c3b0c3a433..5283f65389 100644 --- a/src/frontend/src/components/previews/PreviewType.tsx +++ b/src/frontend/src/components/previews/PreviewType.tsx @@ -3,11 +3,13 @@ import type { ReactNode } from 'react'; import { BuildOrderPreviewComponent } from './models/BuildOrderPreview'; import { CompanyPreviewComponent } from './models/CompanyPreview'; import { ManufacturerPartPreviewComponent } from './models/ManufacturerPartPreview'; +import { PartCategoryPreviewComponent } from './models/PartCategoryPreview'; import { PartPreviewComponent } from './models/PartPreview'; import { PurchaseOrderPreviewComponent } from './models/PurchaseOrderPreview'; import { ReturnOrderPreviewComponent } from './models/ReturnOrderPreview'; import { SalesOrderPreviewComponent } from './models/SalesOrderPreview'; import { SalesOrderShipmentPreviewComponent } from './models/SalesOrderShipmentPreview'; +import { StockLocationPreviewComponent } from './models/StockLocationPreview'; import { StockPreviewComponent } from './models/StockPreview'; import { SupplierPartPreviewComponent } from './models/SupplierPartPreview'; import { TransferOrderPreviewComponent } from './models/TransferOrderPreview'; @@ -55,6 +57,10 @@ export function getPreviewComponentForModel({ return SalesOrderShipmentPreviewComponent({ instance, modelId }); case ModelType.transferorder: return TransferOrderPreviewComponent({ instance, modelId }); + case ModelType.stocklocation: + return StockLocationPreviewComponent({ instance, modelId }); + case ModelType.partcategory: + return PartCategoryPreviewComponent({ instance, modelId }); default: return null; } diff --git a/src/frontend/src/components/previews/models/PartCategoryPreview.tsx b/src/frontend/src/components/previews/models/PartCategoryPreview.tsx new file mode 100644 index 0000000000..537f6c97e4 --- /dev/null +++ b/src/frontend/src/components/previews/models/PartCategoryPreview.tsx @@ -0,0 +1,18 @@ +import { t } from '@lingui/core/macro'; +import { PartCategoryDetailsPanel } from '../../../pages/part/PartCategoryDetailsPanel'; +import type { PreviewType } from '../PreviewType'; + +export function PartCategoryPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const name = `${t`Part Category`} - ${instance?.name ?? `#${modelId}`}`; + + return { + title: name, + preview: + }; +} diff --git a/src/frontend/src/components/previews/models/StockLocationPreview.tsx b/src/frontend/src/components/previews/models/StockLocationPreview.tsx new file mode 100644 index 0000000000..ffe0dc6536 --- /dev/null +++ b/src/frontend/src/components/previews/models/StockLocationPreview.tsx @@ -0,0 +1,18 @@ +import { t } from '@lingui/core/macro'; +import { StockLocationDetailsPanel } from '../../../pages/stock/StockLocationDetailsPanel'; +import type { PreviewType } from '../PreviewType'; + +export function StockLocationPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const name = `${t`Stock Location`} - ${instance?.name ?? `#${modelId}`}`; + + return { + title: name, + preview: + }; +} diff --git a/src/frontend/src/pages/part/CategoryDetail.tsx b/src/frontend/src/pages/part/CategoryDetail.tsx index b457719353..57c73f19be 100644 --- a/src/frontend/src/pages/part/CategoryDetail.tsx +++ b/src/frontend/src/pages/part/CategoryDetail.tsx @@ -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,8 +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 } from '../../components/details/Details'; -import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { DeleteItemAction, EditItemAction, @@ -49,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. @@ -103,106 +102,6 @@ export default function CategoryDetail() { assign: false }); - const detailsPanel = useMemo(() => { - if (id && instanceQuery.isFetching) { - return ; - } - - const left: DetailsField[] = [ - { - type: 'text', - name: 'name', - label: t`Name`, - copy: true, - value_formatter: () => ( - - {category.icon && } - {category.name} - - ) - }, - { - 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 ( - - ); - }, [category, instanceQuery]); - const editCategory = useEditApiFormModal({ url: ApiEndpoints.category_list, pk: id, @@ -299,7 +198,7 @@ export default function CategoryDetail() { name: 'details', label: t`Category Details`, icon: , - content: detailsPanel, + content: , hidden: !id || !category?.pk }, { diff --git a/src/frontend/src/pages/part/PartCategoryDetailsPanel.tsx b/src/frontend/src/pages/part/PartCategoryDetailsPanel.tsx new file mode 100644 index 0000000000..d8cb7f881a --- /dev/null +++ b/src/frontend/src/pages/part/PartCategoryDetailsPanel.tsx @@ -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: () => ( + + {instance?.icon && } + {instance?.name} + + ) + }, + { + 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 ; + + return ( + + ); +} diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index 7572e588d5..bcaf1c08b3 100644 --- a/src/frontend/src/pages/stock/LocationDetail.tsx +++ b/src/frontend/src/pages/stock/LocationDetail.tsx @@ -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,8 +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 } from '../../components/details/Details'; -import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { BarcodeActionDropdown, DeleteItemAction, @@ -57,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(() => { @@ -102,98 +101,12 @@ export default function Stock() { } }); - const detailsPanel = useMemo(() => { - if (id && instanceQuery.isFetching) { - return ; - } - - const left: DetailsField[] = [ - { - type: 'text', - name: 'name', - label: t`Name`, - copy: true, - value_formatter: () => ( - - {location.icon && } - {location.name} - - ) - }, - { - 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 ( - + const detailsPanel = + id && instanceQuery.isFetching ? ( + + ) : ( + ); - }, [location, instanceQuery]); const [sublocationView, setSublocationView] = useState('table'); const [transferOrderView, setTransferOrderView] = useState('table'); diff --git a/src/frontend/src/pages/stock/StockLocationDetailsPanel.tsx b/src/frontend/src/pages/stock/StockLocationDetailsPanel.tsx new file mode 100644 index 0000000000..22fb631548 --- /dev/null +++ b/src/frontend/src/pages/stock/StockLocationDetailsPanel.tsx @@ -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: () => ( + + {instance?.icon && } + {instance?.name} + + ) + }, + { + 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 ; + + return ( + + ); +} From 5bcf8c2ef1dadff8e95e660185128fef5bab93f2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 1 Jul 2026 13:02:08 +0000 Subject: [PATCH 31/34] add user setting --- docs/docs/settings/user.md | 1 + src/backend/InvenTree/common/setting/user.py | 6 ++++++ .../src/components/previews/GlobalPreviewDrawer.tsx | 8 ++++++++ src/frontend/src/pages/Index/Settings/UserSettings.tsx | 1 + 4 files changed, 16 insertions(+) diff --git a/docs/docs/settings/user.md b/docs/docs/settings/user.md index bc6919c780..b95abcb10a 100644 --- a/docs/docs/settings/user.md +++ b/docs/docs/settings/user.md @@ -25,6 +25,7 @@ The *Display Settings* screen shows general display configuration options: {{ usersetting("BARCODE_IN_FORM_FIELDS") }} {{ usersetting("DATE_DISPLAY_FORMAT") }} {{ usersetting("FORMS_CLOSE_USING_ESCAPE") }} +{{ usersetting("ENABLE_PREVIEW_PANEL") }} {{ usersetting("DISPLAY_STOCKTAKE_TAB") }} {{ usersetting("SHOW_FULL_CATEGORY_IN_TABLES")}} {{ usersetting("SHOW_BOM_SUBASSEMBLY_LEVELS")}} diff --git a/src/backend/InvenTree/common/setting/user.py b/src/backend/InvenTree/common/setting/user.py index f5a2cd3f38..86dd1ef033 100644 --- a/src/backend/InvenTree/common/setting/user.py +++ b/src/backend/InvenTree/common/setting/user.py @@ -223,6 +223,12 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { ('MMM DD YYYY', 'Feb 22 2022'), ], }, + 'ENABLE_PREVIEW_PANEL': { + 'name': _('Enable Preview Panel'), + 'description': _('Display a preview panel when selecting items in tables'), + 'default': True, + 'validator': bool, + }, 'DISPLAY_STOCKTAKE_TAB': { 'name': _('Show Stock History'), 'description': _('Display stock history information in the part detail page'), diff --git a/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx b/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx index 33029df5da..a99e0289b7 100644 --- a/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx +++ b/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx @@ -1,7 +1,11 @@ import { usePreviewDrawerState } from '../../states/PreviewDrawerState'; +import { useUserSettingsState } from '../../states/SettingsStates'; import PreviewDrawer from './PreviewDrawer'; export default function GlobalPreviewDrawer() { + const enabled = useUserSettingsState((state) => + state.isSet('ENABLE_PREVIEW_PANEL') + ); const isOpen = usePreviewDrawerState((state) => state.isOpen); const modelType = usePreviewDrawerState((state) => state.modelType); const id = usePreviewDrawerState((state) => state.id); @@ -9,6 +13,10 @@ export default function GlobalPreviewDrawer() { const preview = usePreviewDrawerState((state) => state.preview); const closePreview = usePreviewDrawerState((state) => state.closePreview); + if (!enabled) { + return null; + } + return ( Date: Wed, 1 Jul 2026 13:04:56 +0000 Subject: [PATCH 32/34] Fix typing --- src/frontend/src/components/previews/PreviewDrawer.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/components/previews/PreviewDrawer.tsx b/src/frontend/src/components/previews/PreviewDrawer.tsx index fe3c97e40c..ad62f03d00 100644 --- a/src/frontend/src/components/previews/PreviewDrawer.tsx +++ b/src/frontend/src/components/previews/PreviewDrawer.tsx @@ -15,6 +15,7 @@ import { eventModified } from '@lib/functions/Navigation'; import { type ModelType, getDetailUrl, navigateToLink } from '@lib/index'; import { t } from '@lingui/core/macro'; import { IconArrowRight } from '@tabler/icons-react'; +import type React from 'react'; import { useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useInstance } from '../../hooks/UseInstance'; @@ -82,13 +83,17 @@ export default function PreviewDrawer({ }, [providedPreview, modelType, id, instance]); const clickTitle = useCallback( - (event: MouseEvent) => { + ( + event: Parameters< + React.AnchorHTMLAttributes['onClick'] & {} + >[0] + ) => { if (!modelType || !id) return; if (!eventModified(event as any)) { onClose(); } - navigateToLink(getDetailUrl(modelType!, id!), navigate, event); + navigateToLink(getDetailUrl(modelType!, id!), navigate, event as any); }, [modelType, id, navigate] ); From 74e50e2deb1c22b5f2c3ea44efd647567082e022 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 1 Jul 2026 13:40:06 +0000 Subject: [PATCH 33/34] Handle link clicks within preview drawer --- .../src/components/previews/PreviewDrawer.tsx | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/components/previews/PreviewDrawer.tsx b/src/frontend/src/components/previews/PreviewDrawer.tsx index ad62f03d00..03d1090e56 100644 --- a/src/frontend/src/components/previews/PreviewDrawer.tsx +++ b/src/frontend/src/components/previews/PreviewDrawer.tsx @@ -11,12 +11,17 @@ import { import { StylishText } from '@lib/components/StylishText'; import { ModelInformationDict } from '@lib/enums/ModelInformation'; -import { eventModified } from '@lib/functions/Navigation'; -import { type ModelType, getDetailUrl, navigateToLink } from '@lib/index'; +import { cancelEvent } from '@lib/functions/Events'; +import { + eventModified, + getDetailUrl, + navigateToLink +} from '@lib/functions/Navigation'; +import type { ModelType } from '@lib/index'; import { t } from '@lingui/core/macro'; import { IconArrowRight } from '@tabler/icons-react'; import type React from 'react'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useInstance } from '../../hooks/UseInstance'; import { getModelInfo } from '../render/ModelType'; @@ -82,6 +87,27 @@ export default function PreviewDrawer({ return component; }, [providedPreview, modelType, id, instance]); + useEffect(() => { + if (!opened) return; + const handler = (event: MouseEvent) => { + const anchor = (event.target as HTMLElement).closest('a'); + if (anchor && !eventModified(event as any)) { + if (anchor.origin === window.location.origin) { + // Same-origin: prevent browser navigation and route internally + const href = anchor.pathname + anchor.search + anchor.hash; + cancelEvent(event); + onClose(); + navigateToLink(href, navigate, event as any); + } else { + // External link: let browser open it, just close the drawer + onClose(); + } + } + }; + document.addEventListener('click', handler, true); + return () => document.removeEventListener('click', handler, true); + }, [onClose, opened]); + const clickTitle = useCallback( ( event: Parameters< From 63f04b5d5c4235cce52c39fc26a9d9c7805aafcc Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 1 Jul 2026 13:40:51 +0000 Subject: [PATCH 34/34] fixes --- src/frontend/src/tables/InvenTreeTable.tsx | 2 -- src/frontend/src/tables/general/ParametricDataTable.tsx | 5 ++--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index 4c6fcdea89..b6b1981f4c 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -741,8 +741,6 @@ export function InvenTreeTableInternal>({ const accessor = tableProps.modelField ?? 'pk'; const pk = resolveItem(record, accessor); - console.log('row click:', tableProps.modelType, accessor, '->', pk); - if (pk) { cancelEvent(event); // If a model type is provided, navigate to the detail view for that model diff --git a/src/frontend/src/tables/general/ParametricDataTable.tsx b/src/frontend/src/tables/general/ParametricDataTable.tsx index d86b172c3b..5125dd3ddf 100644 --- a/src/frontend/src/tables/general/ParametricDataTable.tsx +++ b/src/frontend/src/tables/general/ParametricDataTable.tsx @@ -1,5 +1,6 @@ import { cancelEvent } from '@lib/functions/Events'; import { eventModified } from '@lib/functions/Navigation'; +import { getDetailUrl, navigateToLink } from '@lib/functions/Navigation'; import useTable from '@lib/hooks/UseTable'; import { ApiEndpoints, @@ -8,9 +9,7 @@ import { UserRoles, YesNoButton, apiUrl, - formatDecimal, - getDetailUrl, - navigateToLink + formatDecimal } from '@lib/index'; import type { TableFilter } from '@lib/types/Filters'; import type { TableColumn } from '@lib/types/Tables';