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/lib/types/Plugins.tsx b/src/frontend/lib/types/Plugins.tsx index 2038b1f297..f5c9255b41 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, id?: number, instance?: any) => void; + close: () => void; + isOpen: () => boolean; +}; + /** * A set of properties which are passed to a plugin, * for rendering an element in the user interface. @@ -90,6 +96,7 @@ export type ImporterDrawerContext = { * @param forms - A set of functions for opening various API forms (see ../components/Forms.tsx) * @param tables - A set of functions for rendering API tables * @param importer - A set of functions for controlling the global importer drawer (see ../components/importer/GlobalImporterDrawer.tsx) + * @param preview - A set of functions for controlling the global preview drawer (see ../components/previews/GlobalPreviewDrawer.tsx) * @param model - The model type associated with the rendered component (if applicable) * @param id - The ID (primary key) of the model instance for the plugin (if applicable) * @param instance - The model instance data (if available) @@ -125,6 +132,7 @@ export type InvenTreePluginContext = { }; tables: InvenTreeTablesContext; importer: ImporterDrawerContext; + preview: PreviewDrawerContext; model?: ModelType | string; id?: string | number | null; instance?: any; diff --git a/src/frontend/src/components/calendar/OrderCalendar.tsx b/src/frontend/src/components/calendar/OrderCalendar.tsx index e9c65e7e56..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'; @@ -22,6 +26,7 @@ import { useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { api } from '../../App'; import useCalendar from '../../hooks/UseCalendar'; +import { openGlobalPreview } from '../../states/PreviewDrawerState'; import { useUserState } from '../../states/UserState'; import { AssignedToMeFilter, @@ -166,14 +171,18 @@ export default function OrderCalendar({ } }; - // Callback when PurchaseOrder is clicked + // Callback when an order is clicked - open preview drawer, or navigate on modifier click const onClickOrder = (info: EventClickArg) => { - if (!!info.event.id) { + if (!info.event.id) return; + + if (eventModified(event as any)) { navigateToLink( getDetailUrl(model, info.event.id), navigate, info.jsEvent ); + } else { + openGlobalPreview(model, Number.parseInt(info.event.id)); } }; diff --git a/src/frontend/src/components/details/Details.tsx b/src/frontend/src/components/details/Details.tsx index d221ea543d..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 '---'; @@ -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} ) { + showIcons?: boolean; +} + +export function DetailsTable({ + item, + fields, + title, + showIcons = true +}: Readonly) { const visibleFields = useMemo(() => { return fields.filter((field) => !field.hidden); }, [fields]); @@ -553,7 +561,12 @@ export function DetailsTable({ {visibleFields.map((field: DetailsField, index: number) => ( - + ))}
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/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 new file mode 100644 index 0000000000..7ed2aedabc --- /dev/null +++ b/src/frontend/src/components/details/ParameterDetailsGrid.tsx @@ -0,0 +1,54 @@ +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import type { ModelType } from '@lib/enums/ModelType'; +import { apiUrl } from '@lib/functions/Api'; +import { t } from '@lingui/core/macro'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useApi } from '../../contexts/ApiContext'; +import type { DetailsField, DetailsTableProps } from './Details'; + +export function useParameterDetailsGrid({ + model_type, + model_id +}: Readonly<{ + model_type: ModelType; + model_id: number | string | undefined; +}>): DetailsTableProps { + const api = useApi(); + + const { data: parameters = [] } = useQuery({ + queryKey: ['parameter-details', model_type, model_id], + enabled: !!model_id && !!model_type, + queryFn: () => + api + .get(apiUrl(ApiEndpoints.parameter_list), { + params: { model_type, model_id, limit: 100 } + }) + .then((res) => res.data?.results ?? []) + }); + + const { fields, item } = useMemo(() => { + const item: Record = {}; + const fields: DetailsField[] = parameters.map((param: any) => { + const key = `param_${param.pk}`; + const value = + param.data + + (param.template_detail?.units ? ` ${param.template_detail.units}` : ''); + item[key] = value; + return { + type: 'string' as const, + name: key, + label: param.template_detail?.name ?? String(param.pk), + copy: true + }; + }); + return { fields, item }; + }, [parameters]); + + return { + title: t`Parameters`, + fields: fields, + item: item, + showIcons: false + }; +} diff --git a/src/frontend/src/components/nav/Layout.tsx b/src/frontend/src/components/nav/Layout.tsx index d3d36df9fa..3c81b6542a 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'; @@ -148,6 +149,7 @@ export default function LayoutComponent() { )} + ); diff --git a/src/frontend/src/components/plugins/PluginContext.tsx b/src/frontend/src/components/plugins/PluginContext.tsx index e6790c4c49..c00ccb2ebc 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,12 @@ export const useInvenTreeContext = () => { isOpen: () => getGlobalImporterState().isOpen, sessionId: () => getGlobalImporterState().sessionId }, + preview: { + open: (modelType, id?, instance?) => + openGlobalPreview(modelType, id, instance), + close: () => closeGlobalPreview(), + isOpen: () => getGlobalPreviewState().isOpen + }, tables: { renderTable: (props: InvenTreeTableRenderProps) => ( + state.isSet('ENABLE_PREVIEW_PANEL') + ); + const isOpen = usePreviewDrawerState((state) => state.isOpen); + const modelType = usePreviewDrawerState((state) => state.modelType); + const id = usePreviewDrawerState((state) => state.id); + const instance = usePreviewDrawerState((state) => state.instance); + const preview = usePreviewDrawerState((state) => state.preview); + const closePreview = usePreviewDrawerState((state) => state.closePreview); + + if (!enabled) { + return null; + } + + return ( + + ); +} diff --git a/src/frontend/src/components/previews/PreviewDrawer.tsx b/src/frontend/src/components/previews/PreviewDrawer.tsx new file mode 100644 index 0000000000..03d1090e56 --- /dev/null +++ b/src/frontend/src/components/previews/PreviewDrawer.tsx @@ -0,0 +1,172 @@ +import { + ActionIcon, + Anchor, + Divider, + Drawer, + Group, + LoadingOverlay, + Stack, + Tooltip +} from '@mantine/core'; + +import { StylishText } from '@lib/components/StylishText'; +import { ModelInformationDict } from '@lib/enums/ModelInformation'; +import { cancelEvent } from '@lib/functions/Events'; +import { + eventModified, + getDetailUrl, + navigateToLink +} from '@lib/functions/Navigation'; +import type { ModelType } from '@lib/index'; +import { t } from '@lingui/core/macro'; +import { IconArrowRight } from '@tabler/icons-react'; +import type React from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useInstance } from '../../hooks/UseInstance'; +import { getModelInfo } from '../render/ModelType'; +import { type PreviewType, getPreviewComponentForModel } from './PreviewType'; +import { FallbackPreviewComponent } from './models/Fallback'; + +export default function PreviewDrawer({ + modelType, + id, + instance: providedInstance, + filters, + preview: providedPreview, + opened, + onClose +}: Readonly<{ + modelType?: ModelType; + id?: number | string; + instance?: any; + filters?: Record; + preview?: PreviewType; + opened: boolean; + onClose: () => void; +}>) { + const navigate = useNavigate(); + + const modelInfo = modelType ? getModelInfo(modelType) : null; + const apiEndpoint = modelType + ? ModelInformationDict[modelType].api_endpoint + : undefined; + + const { instance: fetchedInstance, instanceQuery } = useInstance({ + endpoint: apiEndpoint!, + pk: id, + hasPrimaryKey: true, + defaultValue: {}, + params: filters, + disabled: !!providedInstance || !modelType || !id + }); + + const instance = useMemo(() => { + return providedInstance ?? fetchedInstance; + }, [providedInstance, fetchedInstance]); + + const previewComponent: PreviewType | null = useMemo(() => { + if (providedPreview) return providedPreview; + if (!modelType || !modelInfo || id == null) return null; + + const component: PreviewType | null = getPreviewComponentForModel({ + modelType, + instance, + modelId: typeof id === 'string' ? Number(id) : id + }); + + if (component == null) { + return FallbackPreviewComponent({ + modelInfo, + modelType, + modelId: id, + instance + }); + } + + return component; + }, [providedPreview, modelType, id, instance]); + + useEffect(() => { + if (!opened) return; + const handler = (event: MouseEvent) => { + const anchor = (event.target as HTMLElement).closest('a'); + if (anchor && !eventModified(event as any)) { + if (anchor.origin === window.location.origin) { + // Same-origin: prevent browser navigation and route internally + const href = anchor.pathname + anchor.search + anchor.hash; + cancelEvent(event); + onClose(); + navigateToLink(href, navigate, event as any); + } else { + // External link: let browser open it, just close the drawer + onClose(); + } + } + }; + document.addEventListener('click', handler, true); + return () => document.removeEventListener('click', handler, true); + }, [onClose, opened]); + + const clickTitle = useCallback( + ( + event: Parameters< + React.AnchorHTMLAttributes['onClick'] & {} + >[0] + ) => { + if (!modelType || !id) return; + + if (!eventModified(event as any)) { + onClose(); + } + navigateToLink(getDetailUrl(modelType!, id!), navigate, event as any); + }, + [modelType, id, navigate] + ); + + return ( + clickTitle(e)} + > + + + + + + + {previewComponent.title} + + + ) : ( + {previewComponent.title} + ) + ) : null + } + opened={opened} + onClose={onClose} + withCloseButton + transitionProps={{ + transition: 'slide-left', + duration: 300, + timingFunction: 'ease' + }} + > + + {previewComponent && ( + <> + + + {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..5283f65389 --- /dev/null +++ b/src/frontend/src/components/previews/PreviewType.tsx @@ -0,0 +1,67 @@ +import { ModelType } from '@lib/enums/ModelType'; +import type { ReactNode } from 'react'; +import { BuildOrderPreviewComponent } from './models/BuildOrderPreview'; +import { CompanyPreviewComponent } from './models/CompanyPreview'; +import { ManufacturerPartPreviewComponent } from './models/ManufacturerPartPreview'; +import { PartCategoryPreviewComponent } from './models/PartCategoryPreview'; +import { PartPreviewComponent } from './models/PartPreview'; +import { PurchaseOrderPreviewComponent } from './models/PurchaseOrderPreview'; +import { ReturnOrderPreviewComponent } from './models/ReturnOrderPreview'; +import { SalesOrderPreviewComponent } from './models/SalesOrderPreview'; +import { SalesOrderShipmentPreviewComponent } from './models/SalesOrderShipmentPreview'; +import { StockLocationPreviewComponent } from './models/StockLocationPreview'; +import { StockPreviewComponent } from './models/StockPreview'; +import { SupplierPartPreviewComponent } from './models/SupplierPartPreview'; +import { TransferOrderPreviewComponent } from './models/TransferOrderPreview'; + +export interface PreviewType { + preview: ReactNode; + title: string; +} + +export type PreviewComponentProps = { + instance: any; +}; + +export type PreviewComponent = (props: PreviewComponentProps) => PreviewType; + +export function getPreviewComponentForModel({ + modelType, + instance, + modelId +}: { + modelType: ModelType; + instance: any; + modelId: number; +}): PreviewType | null { + switch (modelType) { + case ModelType.part: + return PartPreviewComponent({ instance, modelId }); + case ModelType.stockitem: + return StockPreviewComponent({ instance, modelId }); + case ModelType.purchaseorder: + return PurchaseOrderPreviewComponent({ instance, modelId }); + case ModelType.salesorder: + return SalesOrderPreviewComponent({ instance, modelId }); + case ModelType.returnorder: + return ReturnOrderPreviewComponent({ instance, modelId }); + case ModelType.supplierpart: + return SupplierPartPreviewComponent({ instance, modelId }); + case ModelType.manufacturerpart: + return ManufacturerPartPreviewComponent({ instance, modelId }); + case ModelType.company: + return CompanyPreviewComponent({ instance, modelId }); + case ModelType.build: + return BuildOrderPreviewComponent({ instance, modelId }); + case ModelType.salesordershipment: + return SalesOrderShipmentPreviewComponent({ instance, modelId }); + case ModelType.transferorder: + return TransferOrderPreviewComponent({ instance, modelId }); + case ModelType.stocklocation: + return StockLocationPreviewComponent({ instance, modelId }); + case ModelType.partcategory: + return PartCategoryPreviewComponent({ instance, modelId }); + default: + return null; + } +} 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/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.`} + + ) + }; +} 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/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/PartPreview.tsx b/src/frontend/src/components/previews/models/PartPreview.tsx new file mode 100644 index 0000000000..b6cfa9f1c7 --- /dev/null +++ b/src/frontend/src/components/previews/models/PartPreview.tsx @@ -0,0 +1,15 @@ +import { PartDetailsPanel } from '../../../pages/part/PartDetailsPanel'; +import type { PreviewType } from '../PreviewType'; + +export function PartPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + return { + title: instance?.full_name || instance?.name || `Part #${modelId}`, + preview: + }; +} 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/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/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/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/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/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/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/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/Index/Settings/UserSettings.tsx b/src/frontend/src/pages/Index/Settings/UserSettings.tsx index ae71430928..fc376bb4aa 100644 --- a/src/frontend/src/pages/Index/Settings/UserSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/UserSettings.tsx @@ -59,6 +59,7 @@ export default function UserSettings() { 'BARCODE_IN_FORM_FIELDS', 'DATE_DISPLAY_FORMAT', 'FORMS_CLOSE_USING_ESCAPE', + 'ENABLE_PREVIEW_PANEL', 'DISPLAY_STOCKTAKE_TAB', 'ENABLE_LAST_BREADCRUMB', 'SHOW_EXTRA_MODEL_INFO', diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 7fbd0d3307..388f817269 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,239 +229,19 @@ export default function BuildDetail() { refetchOnMount: true }); - const { instance: partRequirements, instanceQuery: partRequirementsQuery } = - useInstance({ - endpoint: ApiEndpoints.part_requirements, - pk: build?.part, - hasPrimaryKey: true, - defaultValue: {} - }); - - const detailsPanel = useMemo(() => { - if (instanceQuery.isFetching) { - return ; - } - - 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', @@ -597,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 5765cc377f..d5c9c8d0e2 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, DuplicateItemAction, @@ -58,6 +50,7 @@ import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable'; import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable'; import { SalesOrderTable } from '../../tables/sales/SalesOrderTable'; import { StockItemTable } from '../../tables/stock/StockItemTable'; +import { CompanyDetailsPanel } from './CompanyDetailsPanel'; export type CompanyDetailProps = { title: string; @@ -87,101 +80,15 @@ export default function CompanyDetail(props: Readonly) { 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/pages/company/ManufacturerPartDetail.tsx b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx index bbaf85b512..fdca15b137 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,105 +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 9243b42dd0..3e7696d84a 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,187 +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/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 77c2a8bf13..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,11 +21,6 @@ import type { PanelType } from '@lib/types/Panel'; import { useLocalStorage } from '@mantine/hooks'; import AdminButton from '../../components/buttons/AdminButton'; import StarredToggleButton from '../../components/buttons/StarredToggleButton'; -import { - type DetailsField, - DetailsTable -} from '../../components/details/Details'; -import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { DeleteItemAction, EditItemAction, @@ -52,6 +47,7 @@ import { PartCategoryTable } from '../../tables/part/PartCategoryTable'; import PartCategoryTemplateTable from '../../tables/part/PartCategoryTemplateTable'; import { PartListTable } from '../../tables/part/PartTable'; import { StockItemTable } from '../../tables/stock/StockItemTable'; +import { PartCategoryDetailsPanel } from './PartCategoryDetailsPanel'; /** * Detail view for a single PartCategory instance. @@ -106,100 +102,6 @@ export default function CategoryDetail() { assign: false }); - const detailsPanel = useMemo(() => { - if (id && instanceQuery.isFetching) { - return ; - } - - 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 ( - - {id && category?.pk && } - {id && category?.pk && } - - ); - }, [category, instanceQuery]); - const editCategory = useEditApiFormModal({ url: ApiEndpoints.category_list, pk: id, @@ -296,7 +198,7 @@ export default function CategoryDetail() { name: 'details', label: t`Category Details`, icon: , - 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/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 5349cd3f1c..c299d0adb8 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..15100eb889 --- /dev/null +++ b/src/frontend/src/pages/part/PartDetailsPanel.tsx @@ -0,0 +1,370 @@ +import { t } from '@lingui/core/macro'; +import { Grid, Skeleton, Stack } from '@mantine/core'; +import { type ReactNode, useMemo } from 'react'; + +import TagsList from '@lib/components/TagsList'; +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { ModelType } from '@lib/enums/ModelType'; +import { UserRoles } from '@lib/enums/Roles'; +import { apiUrl } from '@lib/functions/Api'; + +import { + type DetailsField, + DetailsTable +} from '../../components/details/Details'; +import { DetailsImage } from '../../components/details/DetailsImage'; +import { ItemDetailsGrid } from '../../components/details/ItemDetails'; +import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid'; +import { formatPriceRange } from '../../defaults/formatters'; +import { useInstance } from '../../hooks/UseInstance'; + +export function PartDetailsPanel({ + instance, + allowImageEdit = false, + refreshInstance, + additionalContent +}: Readonly<{ + instance: any; + allowImageEdit?: boolean; + refreshInstance?: () => void; + additionalContent?: ReactNode; +}>) { + const { instance: requirements, instanceQuery: requirementsQuery } = + useInstance({ + endpoint: ApiEndpoints.part_requirements, + pk: instance?.pk, + hasPrimaryKey: true, + disabled: !instance?.pk, + defaultValue: {} + }); + + const { instance: serials } = useInstance({ + endpoint: ApiEndpoints.part_serial_numbers, + pk: instance?.pk, + hasPrimaryKey: true, + disabled: !instance?.pk, + defaultValue: {} + }); + + const data = useMemo(() => { + if (!instance) return {}; + const d = { ...instance }; + d.total_in_stock = + requirements?.total_stock ?? instance?.total_in_stock ?? 0; + d.unallocated = + requirements?.unallocated_stock ?? instance?.unallocated_stock ?? 0; + d.ordering = requirements?.ordering ?? instance?.ordering ?? 0; + d.required = + (requirements?.required_for_build_orders ?? + instance?.required_for_build_orders ?? + 0) + + (requirements?.required_for_sales_orders ?? + instance?.required_for_sales_orders ?? + 0); + d.allocated = + (requirements?.allocated_to_build_orders ?? + instance?.allocated_to_build_orders ?? + 0) + + (requirements?.allocated_to_sales_orders ?? + instance?.allocated_to_sales_orders ?? + 0); + d.can_build = requirements?.can_build ?? 0; + if (serials?.latest) d.latest_serial_number = serials.latest; + return d; + }, [instance, requirements, serials]); + + const fetching = requirementsQuery?.isFetching ?? false; + + const tl: DetailsField[] = [ + { type: 'string', name: 'name', label: t`Name`, icon: 'part', copy: true }, + { + type: 'string', + name: 'IPN', + label: t`IPN`, + copy: true, + hidden: !instance?.IPN + }, + { type: 'string', name: 'description', label: t`Description`, copy: true }, + { + type: 'link', + name: 'variant_of', + label: t`Variant of`, + model: ModelType.part, + model_field: 'full_name', + hidden: !instance?.variant_of + }, + { + type: 'link', + name: 'revision_of', + label: t`Revision of`, + model: ModelType.part, + model_field: 'full_name', + hidden: !instance?.revision_of + }, + { + type: 'string', + name: 'revision', + label: t`Revision`, + hidden: !instance?.revision, + copy: true + }, + { + type: 'link', + name: 'category', + label: t`Category`, + model: ModelType.partcategory + }, + { + type: 'link', + name: 'default_location', + label: t`Default Location`, + model: ModelType.stocklocation, + hidden: !instance?.default_location + }, + { + type: 'link', + name: 'category_default_location', + label: t`Category Default Location`, + model: ModelType.stocklocation, + hidden: instance?.default_location || !instance?.category_default_location + }, + { + type: 'string', + name: 'units', + label: t`Units`, + copy: true, + hidden: !instance?.units + }, + { + type: 'string', + name: 'keywords', + label: t`Keywords`, + copy: true, + hidden: !instance?.keywords + }, + { + type: 'link', + name: 'link', + label: t`Link`, + external: true, + copy: true, + hidden: !instance?.link + } + ]; + + const tr: DetailsField[] = [ + { + type: 'number', + name: 'total_in_stock', + unit: instance?.units, + label: t`In Stock`, + hidden: instance?.virtual + }, + { + type: 'progressbar', + name: 'unallocated_stock', + total: data.total_in_stock, + progress: data.unallocated, + label: t`Available Stock`, + hidden: instance?.virtual || data.total_in_stock == data.unallocated + }, + { + type: 'number', + name: 'ordering', + label: t`On Order`, + unit: instance?.units, + hidden: !instance?.purchaseable || instance?.ordering <= 0 + }, + { + type: 'number', + name: 'required', + label: t`Required for Orders`, + unit: instance?.units, + hidden: data.required <= 0, + icon: 'stocktake' + }, + { + type: 'progressbar', + name: 'allocated_to_build_orders', + icon: 'manufacturers', + total: requirements?.required_for_build_orders, + progress: requirements?.allocated_to_build_orders, + label: t`Allocated to Build Orders`, + hidden: + fetching || + (requirements?.required_for_build_orders <= 0 && + requirements?.allocated_to_build_orders <= 0) + }, + { + type: 'progressbar', + icon: 'sales_orders', + name: 'allocated_to_sales_orders', + total: requirements?.required_for_sales_orders, + progress: requirements?.allocated_to_sales_orders, + label: t`Allocated to Sales Orders`, + hidden: + fetching || + (requirements?.required_for_sales_orders <= 0 && + requirements?.allocated_to_sales_orders <= 0) + }, + { + type: 'progressbar', + name: 'building', + label: t`In Production`, + progress: requirements?.building, + total: requirements?.scheduled_to_build, + hidden: + fetching || + (!requirements?.building && !requirements?.scheduled_to_build) + }, + { + type: 'number', + name: 'can_build', + unit: instance?.units, + label: t`Can Build`, + hidden: !instance?.assembly || fetching + }, + { + type: 'number', + name: 'minimum_stock', + unit: instance?.units, + label: t`Minimum Stock`, + hidden: instance?.minimum_stock <= 0 + }, + { + type: 'number', + name: 'maximum_stock', + unit: instance?.units, + label: t`Maximum Stock`, + hidden: instance?.maximum_stock <= 0 + } + ]; + + const bl: DetailsField[] = [ + { type: 'boolean', name: 'active', label: t`Active` }, + { type: 'boolean', name: 'locked', label: t`Locked` }, + { + type: 'boolean', + icon: 'template', + name: 'is_template', + label: t`Template Part` + }, + { type: 'boolean', name: 'assembly', label: t`Assembled Part` }, + { type: 'boolean', name: 'component', label: t`Component Part` }, + { + type: 'boolean', + name: 'testable', + label: t`Testable Part`, + icon: 'test' + }, + { type: 'boolean', name: 'trackable', label: t`Trackable Part` }, + { type: 'boolean', name: 'purchaseable', label: t`Purchaseable Part` }, + { + type: 'boolean', + name: 'salable', + icon: 'saleable', + label: t`Saleable Part` + }, + { type: 'boolean', name: 'virtual', label: t`Virtual Part` }, + { type: 'boolean', name: 'starred', label: t`Subscribed`, icon: 'bell' } + ]; + + const br: DetailsField[] = useMemo(() => { + const fields: DetailsField[] = [ + { + type: 'string', + name: 'creation_date', + label: t`Creation Date` + }, + { + type: 'string', + name: 'creation_user', + label: t`Created By`, + badge: 'user', + icon: 'user', + hidden: !instance?.creation_user + }, + { + type: 'string', + name: 'responsible', + label: t`Responsible`, + badge: 'owner', + hidden: !instance?.responsible + }, + { + name: 'default_expiry', + label: t`Default Expiry`, + hidden: !instance?.default_expiry, + icon: 'calendar', + type: 'string', + value_formatter: () => `${instance?.default_expiry} ${t`days`}` + } + ]; + + if (instance?.pricing_min || instance?.pricing_max) { + fields.push({ + type: 'string', + name: 'pricing', + label: t`Price Range`, + value_formatter: () => + formatPriceRange(instance?.pricing_min, instance?.pricing_max) + }); + } + + fields.push({ + type: 'string', + name: 'latest_serial_number', + label: t`Latest Serial Number`, + hidden: !instance?.trackable || !data.latest_serial_number, + icon: 'serial' + }); + + return fields; + }, [instance, data.latest_serial_number]); + + const parametersTable = useParameterDetailsGrid({ + model_type: ModelType.part, + model_id: instance?.pk + }); + + if (!instance?.pk) return ; + + return ( + + + + + + + + + + {additionalContent} + + + ); +} 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 ( + + + + + + + + + + + + ); +} diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx index b094826d7c..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,233 +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 ( + + + + + + + + + + + + ); +} diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index 959ea726d1..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,227 +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(() => { @@ -364,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 ( + + + + + + + + + + + + ); +} diff --git a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx index 7d181814e8..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,190 +65,21 @@ export default function SalesOrderShipmentDetail() { } }); - const { instance: customer, instanceQuery: customerQuery } = useInstance({ - endpoint: ApiEndpoints.company_list, - pk: shipment.order_detail?.customer, - hasPrimaryKey: true - }); - const isPending = useMemo(() => !shipment.shipment_date, [shipment]); - const isChecked = useMemo(() => !!shipment.checked_by, [shipment]); - const detailsPanel = useMemo(() => { - if (shipmentQuery.isFetching || customerQuery.isFetching) { - return ; - } - - 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', @@ -288,7 +110,7 @@ export default function SalesOrderShipmentDetail() { has_note: !!shipment.notes }) ]; - }, [isPending, shipment, detailsPanel]); + }, [isPending, shipment]); const editShipmentFields = useSalesOrderShipmentFields({ pending: isPending, @@ -454,7 +276,7 @@ export default function SalesOrderShipmentDetail() { } ]} badges={shipmentBadges} - imageUrl={customer?.image} + imageUrl={shipment.order_detail?.customer_detail?.image} editAction={editShipment.open} editEnabled={user.hasChangePermission(ModelType.salesordershipment)} actions={shipmentActions} 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/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index f9bdbad8fa..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,11 +24,6 @@ import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialo import AdminButton from '../../components/buttons/AdminButton'; import { PrintingActions } from '../../components/buttons/PrintingActions'; import OrderCalendar from '../../components/calendar/OrderCalendar'; -import { - type DetailsField, - DetailsTable -} from '../../components/details/Details'; -import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { BarcodeActionDropdown, DeleteItemAction, @@ -60,6 +55,7 @@ import StockLocationParametricTable from '../../tables/stock/StockLocationParame import { StockLocationTable } from '../../tables/stock/StockLocationTable'; import TransferOrderParametricTable from '../../tables/stock/TransferOrderParametricTable'; import { TransferOrderTable } from '../../tables/stock/TransferOrderTable'; +import { StockLocationDetailsPanel } from './StockLocationDetailsPanel'; function TransferOrderCalendar() { const calendarFilters: TableFilter[] = useMemo(() => { @@ -105,92 +101,12 @@ export default function Stock() { } }); - const detailsPanel = useMemo(() => { - if (id && instanceQuery.isFetching) { - return ; - } - - 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 ( - - {id && location?.pk && } - {id && location?.pk && } - + 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/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 5e118b4c6a..21ab56d97b 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,367 +103,13 @@ export default function StockDetail() { } }); - const { instance: part, instanceQuery: partQuery } = useInstance({ + const { instance: part } = useInstance({ endpoint: ApiEndpoints.part_list, pk: stockitem?.part, hasPrimaryKey: true, defaultValue: {} }); - const { instance: serialNumbers, instanceQuery: serialNumbersQuery } = - useInstance({ - endpoint: ApiEndpoints.stock_serial_info, - pk: id - }); - - const findBySerialNumber = useFindSerialNumberForm({ - partId: stockitem.part - }); - - const detailsPanel = useMemo(() => { - const data = { ...stockitem }; - const part = stockitem?.part_detail ?? {}; - - data.available_stock = Math.max(0, data.quantity - data.allocated); - - if (instanceQuery.isFetching) { - return ; - } - - // 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', - hidden: stockitem.in_stock == false - }, - { - type: 'number', - name: 'allocated', - label: t`Allocated to Orders`, - unit: part?.units, - icon: 'tick_off', - hidden: !stockitem.allocated - }, - { - type: 'text', - name: 'batch', - label: t`Batch Code`, - hidden: !stockitem.batch - } - ]; - - // Bottom left: location information - const bl: DetailsField[] = [ - { - name: 'supplier_part', - label: t`Supplier Part`, - type: 'link', - model_field: 'SKU', - model: ModelType.supplierpart, - hidden: !stockitem.supplier_part - }, - { - type: 'link', - name: 'location', - label: t`Location`, - model: ModelType.stocklocation, - hidden: !stockitem.location - }, - { - type: 'link', - name: 'belongs_to', - label: t`Installed In`, - model_filters: { - part_detail: true - }, - model_formatter: (model: any) => { - let text = model?.part_detail?.full_name ?? model?.name; - if (model.serial && model.quantity == 1) { - text += ` # ${model.serial}`; - } - - return text; - }, - icon: 'stock', - model: ModelType.stockitem, - hidden: !stockitem.belongs_to - }, - { - type: 'link', - name: 'parent', - icon: 'sitemap', - label: t`Parent Item`, - model: ModelType.stockitem, - hidden: !stockitem.parent, - model_formatter: (model: any) => { - return t`Parent stock item`; - } - }, - { - type: 'link', - name: 'consumed_by', - label: t`Consumed By`, - model: ModelType.build, - hidden: !stockitem.consumed_by, - icon: 'build', - model_field: 'reference' - }, - { - type: 'link', - name: 'build', - label: t`Build Order`, - model: ModelType.build, - hidden: !stockitem.build, - model_field: 'reference' - }, - { - type: 'link', - name: 'purchase_order', - label: t`Purchase Order`, - model: ModelType.purchaseorder, - hidden: !stockitem.purchase_order, - icon: 'purchase_orders', - model_field: 'reference' - }, - { - type: 'link', - name: 'sales_order', - label: t`Sales Order`, - model: ModelType.salesorder, - hidden: !stockitem.sales_order, - icon: 'sales_orders', - model_field: 'reference' - }, - { - type: 'link', - name: 'customer', - label: t`Customer`, - model: ModelType.company, - hidden: !stockitem.customer - } - ]; - - // Bottom right - any other information - const br: DetailsField[] = [ - // Expiry date - { - type: 'date', - name: 'expiry_date', - label: t`Expiry Date`, - hidden: !enableExpiry || !stockitem.expiry_date, - icon: 'calendar' - }, - // TODO: Ownership - { - type: 'text', - name: 'purchase_price', - label: t`Unit Price`, - icon: 'currency', - hidden: !stockitem.purchase_price, - value_formatter: () => { - return formatCurrency(stockitem.purchase_price, { - currency: stockitem.purchase_price_currency - }); - } - }, - { - type: 'text', - name: 'stock_value', - label: t`Stock Value`, - icon: 'currency', - hidden: - !stockitem.purchase_price || - stockitem.quantity == 1 || - stockitem.quantity == 0, - value_formatter: () => { - return formatCurrency(stockitem.purchase_price, { - currency: stockitem.purchase_price_currency, - multiplier: stockitem.quantity - }); - } - }, - { - type: 'text', - name: 'packaging', - icon: 'part', - label: t`Packaging`, - hidden: !stockitem.packaging - }, - { - type: 'date', - name: 'creation_date', - icon: 'calendar', - label: t`Created`, - hidden: !stockitem.creation_date - }, - { - type: 'date', - name: 'updated', - icon: 'calendar', - label: t`Last Updated` - }, - { - type: 'date', - name: 'stocktake_date', - icon: 'calendar', - label: t`Last Stocktake`, - hidden: !stockitem.stocktake_date - } - ]; - - return ( - - - - - - - - - - - - - - - ); - }, [ - stockitem, - serialNumbers, - serialNumbersQuery.isFetching, - instanceQuery.isFetching, - enableExpiry - ]); - const showBuildAllocations: boolean = useMemo(() => { // Determine if "build allocations" should be shown for this stock item return ( @@ -555,7 +180,14 @@ export default function StockDetail() { name: 'details', label: t`Stock Details`, icon: , - content: detailsPanel + content: ( + + ) }, { name: 'tracking', @@ -686,8 +318,6 @@ export default function StockDetail() { showBuildAllocations, showInstalledItems, stockitem, - serialNumbers, - serialNumbersQuery, id, user ]); @@ -1063,7 +693,6 @@ export default function StockDetail() { return ( <> - {findBySerialNumber.modal} {scanIntoLocation.dialog} 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} + + + + + + + + + + + + + ); +} 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 ( + + ); +} diff --git a/src/frontend/src/pages/stock/TransferOrderDetail.tsx b/src/frontend/src/pages/stock/TransferOrderDetail.tsx index 39dd9d3354..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,169 +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 ( + + + + + + + + + + + ); +} diff --git a/src/frontend/src/states/PreviewDrawerState.tsx b/src/frontend/src/states/PreviewDrawerState.tsx new file mode 100644 index 0000000000..4a8793e6fe --- /dev/null +++ b/src/frontend/src/states/PreviewDrawerState.tsx @@ -0,0 +1,65 @@ +import type { ModelType } from '@lib/enums/ModelType'; +import { create } from 'zustand'; +import type { PreviewType } from '../components/previews/PreviewType'; + +interface PreviewDrawerStateProps { + isOpen: boolean; + modelType?: ModelType; + id?: number | string; + instance?: any; + preview?: PreviewType; + openPreview: ( + modelType: ModelType, + id: number | string | undefined, + instance?: any, + preview?: PreviewType + ) => void; + closePreview: () => void; +} + +export const usePreviewDrawerState = create()( + (set) => ({ + isOpen: false, + modelType: undefined, + id: undefined, + instance: undefined, + preview: undefined, + + openPreview: ( + modelType: ModelType, + id: number | string | undefined, + instance?: any, + preview?: PreviewType + ) => { + set({ modelType, id, instance, preview, isOpen: true }); + }, + + closePreview: () => { + set({ + isOpen: false, + instance: undefined, + id: undefined, + preview: undefined + }); + } + }) +); + +export function openGlobalPreview( + modelType: ModelType, + id?: number | string | undefined, + instance?: any, + preview?: PreviewType +) { + usePreviewDrawerState + .getState() + .openPreview(modelType, id, instance, preview); +} + +export function closeGlobalPreview() { + usePreviewDrawerState.getState().closePreview(); +} + +export function getGlobalPreviewState() { + return usePreviewDrawerState.getState(); +} 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); + } } }); } 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, diff --git a/src/frontend/src/tables/general/ParametricDataTable.tsx b/src/frontend/src/tables/general/ParametricDataTable.tsx index 5634480925..5125dd3ddf 100644 --- a/src/frontend/src/tables/general/ParametricDataTable.tsx +++ b/src/frontend/src/tables/general/ParametricDataTable.tsx @@ -1,4 +1,6 @@ import { cancelEvent } from '@lib/functions/Events'; +import { eventModified } from '@lib/functions/Navigation'; +import { getDetailUrl, navigateToLink } from '@lib/functions/Navigation'; import useTable from '@lib/hooks/UseTable'; import { ApiEndpoints, @@ -7,9 +9,7 @@ import { UserRoles, YesNoButton, apiUrl, - formatDecimal, - getDetailUrl, - navigateToLink + formatDecimal } from '@lib/index'; import type { TableFilter } from '@lib/types/Filters'; import type { TableColumn } from '@lib/types/Tables'; @@ -27,6 +27,7 @@ import { useCreateApiFormModal, useEditApiFormModal } from '../../hooks/UseForm'; +import { openGlobalPreview } from '../../states/PreviewDrawerState'; import { useUserState } from '../../states/UserState'; import { InvenTreeTable } from '../InvenTreeTable'; import { TableHoverCard } from '../TableHoverCard'; @@ -472,9 +473,12 @@ export default function ParametricDataTable({ const col = column as any; onParameterClick(col.extra.template, record); } else if (record?.pk) { - // Navigate through to the detail page const url = getDetailUrl(modelType, record.pk); - navigateToLink(url, navigate, event); + if (eventModified(event as any)) { + navigateToLink(url, navigate, event); + } else { + openGlobalPreview(modelType, record.pk); + } } } }}