diff --git a/CHANGELOG.md b/CHANGELOG.md index f49f649aca..f3da3da15b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [#12117](https://github.com/inventree/InvenTree/pull/12117) adds a "preview" drawer to the InvenTree table component, allowing users to preview the details of a selected row without navigating away from the table view. This feature is optional and can be enabled or disabled via the `PREVIEW_DRAWER_ENABLED` system setting. - [#12341](https://github.com/inventree/InvenTree/pull/12341) adds support for importing internal part prices. - [#12295](https://github.com/inventree/InvenTree/pull/12295) adds "consumable" field to the Part model and API endpoints - [#12250](https://github.com/inventree/InvenTree/pull/12250) adds "active" field to the ProjectCode model and API endpoints diff --git a/docs/docs/assets/images/concepts/ui_preview_details_link.png b/docs/docs/assets/images/concepts/ui_preview_details_link.png new file mode 100644 index 0000000000..8b375591c2 Binary files /dev/null and b/docs/docs/assets/images/concepts/ui_preview_details_link.png differ diff --git a/docs/docs/assets/images/concepts/ui_preview_drawer.png b/docs/docs/assets/images/concepts/ui_preview_drawer.png new file mode 100644 index 0000000000..17964acc3e Binary files /dev/null and b/docs/docs/assets/images/concepts/ui_preview_drawer.png differ diff --git a/docs/docs/concepts/data_import.md b/docs/docs/concepts/data_import.md index ecee56daeb..eac5712c86 100644 --- a/docs/docs/concepts/data_import.md +++ b/docs/docs/concepts/data_import.md @@ -57,7 +57,7 @@ An import session can be initiated from a number of different contexts within th Staff users can create an import session from within the [Admin Center](../settings/admin.md#admin-center). This is a general-purpose import session, and the user will be required to select the type of data to import. -Users can quickly navigate to the data import management page from the [spotlight search](../concepts/user_interface.md#spotlight), by searching for "import" and selecting the "Import data" option. +Users can quickly navigate to the data import management page from the [spotlight search](./ui/index.md#spotlight), by searching for "import" and selecting the "Import data" option. ### Data Tables diff --git a/docs/docs/concepts/ui/forms.md b/docs/docs/concepts/ui/forms.md new file mode 100644 index 0000000000..8c153eacce --- /dev/null +++ b/docs/docs/concepts/ui/forms.md @@ -0,0 +1,37 @@ +--- +title: Forms +--- + +## Forms + +Data entry and editing within InvenTree is typically performed through the use of forms, which provide a structured interface for inputting and modifying data. Forms are designed to be user-friendly and efficient, allowing users to quickly enter and update information within the system. + +Forms are typically displayed as a modal dialog, separated into multiple sections and fields. + +### Data Creation + +Example: Creating a new part via the "Add Part" form: + +{{ image("concepts/ui_form_add_part.png", "Add Part Button") }} + +On several forms is displayed option "Keep form open" in bottom part of the form on left side of Submit button (option is visible on the screenshot above). When this switch is turned on, form window is not closed after submit and filled form data is not reset. This is useful for creating more entries at one time with similar properties (e.g. only different number in name). + +### Data Editing + +Example: Editing an existing purchase order via the "Edit Purchase Order" form: + +{{ image("concepts/ui_form_edit_po.png", "Edit Purchase Order") }} + +### Form Submission + +A form can be submitted by clicking the "Submit" button located at the bottom-right corner of the form. + +Alternatively, an open form can be submitted using the keyboard shortcut `Ctrl + Enter` (or `Cmd + Enter` on macOS). This shortcut works even while typing in a form field, providing a quick way to submit the form without needing to reach for the mouse. It is particularly useful in multi-line text fields, where pressing `Enter` inserts a new line rather than submitting the form. + +The keyboard shortcut is only active while a form is open, and follows the same rules as the "Submit" button - for example, it has no effect while the form is loading, or when editing an existing item without any changes. + +### Confirm Actions + +Many actions within InvenTree require user confirmation before they can be executed. This is typically implemented through the use of confirmation dialogs, which prompt the user to confirm their intention before proceeding with the action. + +{{ image("concepts/ui_form_hold_po.png", "Confirmation Dialog") }} diff --git a/docs/docs/concepts/ui/global_search.md b/docs/docs/concepts/ui/global_search.md new file mode 100644 index 0000000000..c6a81385ec --- /dev/null +++ b/docs/docs/concepts/ui/global_search.md @@ -0,0 +1,31 @@ +--- +title: Global Search +--- + +## Global Search + +Accessible from the [main menu](./index.md#main-menu), the global search functionality allows users to quickly find specific items or information within the InvenTree system. The search icon is located at the top of the interface and provides a convenient way to search across all sections of the system. + +Clicking on the "search" icon (in the menu bar) opens the search menu, which allows users to enter search queries and view results from across the system. + +{{ image("concepts/ui_global_search.png", "Global Search") }} + +Search results are organized by category (e.g. Parts, Stock, Manufacturing, etc.) and provide quick access to the relevant pages for each search result. + +### Detail View + +To navigate to the detail page for a particular search result, simply click on the desired result from the search results list. This will take you directly to the relevant page within the InvenTree system, allowing you to view and interact with the specific item or information you were searching for. + +### Full Results + +The "global search" menu provides a limited set of search results for each category, typically showing the most relevant or recent results. To view the full set of search results for a particular category, click on the "View all results" button located at the top-left of the search results list for that category: + +{{ image("concepts/ui_global_search_view_all.png", "View Full Search Results") }} + +### Collapse Result Groups + +To collapse a particular category of search results in the global search menu, click on the "collapse" icon located at the top-right corner of the search results list for that category. This will hide the search results for that category, allowing you to focus on other categories or search results. + +### Remove Result Groups + +To remove a particular category of search results from the global search menu, click on the "remove" icon located at the top-right corner of the search results list for that category. diff --git a/docs/docs/concepts/ui/index.md b/docs/docs/concepts/ui/index.md new file mode 100644 index 0000000000..7410220a53 --- /dev/null +++ b/docs/docs/concepts/ui/index.md @@ -0,0 +1,182 @@ +--- +title: User Interface +--- + +## User Interface + +The InvenTree user interface is designed to be intuitive and user-friendly, providing easy access to the various features and functions of the system. The interface is organized into several key components, including navigation menus, settings, forms, tables, search functionality, and more. + +The interface is designed for large-format displays, and as such is explicitly *not* optimized for mobile devices. However, the interface is responsive and should work on a wide range of desktop screen sizes. + +## Navigation + +Navigation throughout the InvenTree interface is designed to be straightforward and efficient, allowing users to quickly access the various sections and features of the system. The navigation is organized into several key areas, including the main menu, navigation menu, and page panels. + +### Main Menu + +The main menu is located at the top of the interface and provides access to the primary sections of the system: + +{{ image("concepts/ui_main_menu.png", "Main Menu") }} + +From the main menu, users can access the following items: + +- [Navigation Menu](#navigation-menu) +- [Dashboard](#dashboard) +- [Global Search](./global_search.md) +- [Spotlight](#spotlight) +- [Barcode Scanning](#barcode-scanning) +- [Notifications](#notifications) +- [User Menu](#user-menu) + +As well as allowing navigation to the following main sections: + +- [Parts](../../part/index.md) +- [Stock](../../stock/index.md) +- [Manufacturing](../../manufacturing/index.md) +- [Purchasing](../../purchasing/index.md) +- [Sales](../../sales/index.md) + +### Navigation Menu + +The global navigation menu is located on the left-hand side of the interface and provides access to the various sections of the system. + +{{ image("concepts/ui_navigation_menu.png", "Navigation Menu") }} + +The navigation menu is organized into several key areas, including: + +- **Navigation:** Provides access to the main sections of the system, including Parts, Stock, Manufacturing, Purchasing, and Sales. +- **Settings:** Quick access to user settings, system settings, and the admin center. +- **Actions:** Provides quick access to commonly used actions +- **Documentation:** Links to the online documentation. +- **About:** InvenTree version and license information. + +### User Menu + +The user menu is located in the top-right corner of the interface and provides access to user-specific settings and actions. + +{{ image("concepts/ui_user_menu.png", "User Menu") }} + +The user menu provides access to the following items: + +- **User Settings:** Access to [user settings](../../settings/user.md). +- **System Settings:** Access to [global settings](../../settings/global.md) settings. *Note: Access to system settings may be restricted based on user permissions.* +- **Admin Center:** Access to the [admin center](../../settings/admin.md#admin-center) for operational administration +- **Database Admin Interface:** Low level administration via the [Database Admin Interface](../../settings/db_admin.md). *Note: Access may be restricted based on user permissions.* +- **Change Color Mode:** Toggle between light and dark color modes. +- **About InvenTree:** View version and license information about InvenTree. +- **Logout:** Log out of the InvenTree system. + +### Page Panels + +Most detail pages views within InvenTree are organized into panels, which provide a structured layout for displaying information and actions related to the current page. + +Panels are arranged in a vertical stack on the left side of the page, with the main content area on the right. Each panel contains related information and actions, allowing users to easily navigate and interact with the content. + +{{ image("concepts/ui_panels.png", "Page Panels") }} + +#### Collapse Panels + +The panel sidebar can be collapsed to provide more space for the main content area. To collapse or expand the panel sidebar, click the collapse icon located at the bottom of the sidebar. To expand the sidebar again, click the expand icon that appears when the sidebar is collapsed. + +### Breadcrumbs + +On some pages, a breadcrumb navigation trail is provided at the top of the page, just below the main menu. Breadcrumbs provide a visual representation of the user's current location within the system and allow for easy navigation back to previous pages. + +{{ image("concepts/ui_breadcrumbs.png", "Breadcrumb Navigation") }} + +### Navigation Tree + +On some pages, a navigation tree is provided on the left-hand side of the page, next to the breadcrumbs. The navigation tree provides a hierarchical view of the current section of the system, allowing users to quickly navigate to related pages and sections. + +Click on the navigation tree icon to expand the tree and view the available navigation options: + +{{ image("concepts/ui_navigation_tree.png", "Navigation Tree") }} + +#### Searching + +The navigation tree includes a search bar at the top of the panel. Typing into the search bar filters the tree to show only entries that match the search query. When a search is active, all matching results are expanded and displayed in a flat list. Clearing the search field returns the tree to its normal browsing mode. + +#### Highlight Selected Entry + +The currently selected entry in the navigation tree is highlighted with a distinct background color, making it easy to identify the active page or section within the hierarchy. + +#### Auto-Expand to Selected Entry + +When the navigation tree is opened, it automatically expands to reveal the currently selected entry. All ancestor nodes in the hierarchy are expanded so the active entry is immediately visible, without requiring manual navigation through the tree. + +## Dashboard + +The dashboard provides a customizable landing page for users when they log in to the system. The dashboard can be configured to display a variety of widgets and information panels, providing users with quick access to important data and actions. + +{{ image("concepts/ui_dashboard.png", "Dashboard") }} + +### Editing Layout + +To edit the layout (add, remove, or rearrange widgets) of the dashboard, open the dashboard context menu (located at the top-right corner of the dashboard) and view the available options: + +{{ image("concepts/ui_dashboard_edit.png", "Dashboard Context Menu") }} + +### Custom Widgets + +In addition to the set of built-in widgets provided by InvenTree, custom dashboard widgets can be implemented using [plugins](../../plugins/mixins/ui.md#dashboard-items). This allows users to create personalized dashboard experiences tailored to their specific needs and workflows. + +## Tables + +Information throughout the InvenTree interface is often presented in tabular format. Table views support a wide range of features, including pagination, sorting, filtering, and data export. + +Read more about working with table views on the dedicated [Tables](./tables.md) page. + +## Preview Panels + +Rather than navigating directly to an item's detail page, clicking on a table row can instead open a "preview" drawer, providing a quick summary of that item without leaving the current view. + +Read more about this optional feature on the dedicated [Preview Panels](./preview_panels.md) page. + +## Forms + +Data entry and editing within InvenTree is typically performed through the use of forms, which provide a structured interface for inputting and modifying data. + +Read more about working with forms on the dedicated [Forms](./forms.md) page. + +## Global Search + +Accessible from the [main menu](#main-menu), the global search functionality allows users to quickly find specific items or information within the InvenTree system. + +Read more about the global search feature on the dedicated [Global Search](./global_search.md) page. + +## Spotlight + +The user interface features a "spotlight" search functionality, which provides a quick and efficient way to access common actions or navigate to specific pages within the InvenTree system. The spotlight search is designed to enhance user productivity by allowing users to quickly find and execute actions without needing to navigate through menus or remember specific page locations. + +{{ image("concepts/ui_spotlight.png", "Spotlight Search") }} + +### Open Spotlight + +To open the "spotlight" search, click on the "spotlight" icon located in the main menu at the top of the interface. This will open the spotlight search menu, allowing you to enter search queries and view available actions. + +Alternatively, the spotlight search can be opened using the keyboard shortcut `Ctrl + K` (or `Cmd + K` on macOS), providing a quick and convenient way to access the spotlight functionality without needing to click on the menu icon. + +### Disable Spotlight + +Users may opt to disable the spotlight search functionality if they do not find it useful or prefer not to use it. To disable the spotlight search, navigate to your [user settings](../../settings/user.md) and locate the option to disable the spotlight feature. Once disabled, the spotlight search will no longer be accessible from the main menu or via keyboard shortcuts. + +## Copy Button + +Many fields within the InvenTree user interface include a "copy" button, which allows users to quickly copy the value of that field to their clipboard. This is particularly useful for fields that contain important identifiers, such as part numbers, stock item codes, or other relevant data that may need to be easily copied and pasted elsewhere. + +!!! important "Secure Context" + The "copy" button functionality relies on the browser's clipboard API, which may not be available in all contexts (e.g. if the user is accessing the InvenTree interface via a non-https connection, or through an embedded iframe or a non-standard browser). In such cases, the "copy" button may not function as intended. + +## User Permissions + +Many aspects of the user interface are controlled by user permissions, which determine what actions and features are available to each user based on their assigned roles and permissions within the system. This allows for a highly customizable user experience, where different users can have access to different features and functionality based on their specific needs and responsibilities within the organization. + +If a user does not have permission to access a particular feature or section of the system, that feature will be hidden from their view in the user interface. This helps to ensure that users only see the features and information that are relevant to their role, reducing clutter and improving usability. + +## Language Support + +The InvenTree user interface supports multiple languages, allowing users to interact with the system in their preferred language. + +The default system language can be configured by the system administrator in the [server configuration options](../../start/config.md#basic-options). + +Additionally, users can select their preferred language in their [user settings](../../settings/user.md), allowing them to override the system default language with their own choice. This provides a personalized experience for each user, ensuring that they can interact with the system in the language they are most comfortable with. diff --git a/docs/docs/concepts/ui/preview_panels.md b/docs/docs/concepts/ui/preview_panels.md new file mode 100644 index 0000000000..1f16485417 --- /dev/null +++ b/docs/docs/concepts/ui/preview_panels.md @@ -0,0 +1,37 @@ +--- +title: Preview Panels +--- + +## Preview Panels + +Many [table views](./tables.md) in InvenTree link each row to the detail page for the underlying item. By default, clicking on a row navigates directly to that detail page. + +If the preview panel feature is enabled, clicking on a row instead opens a "preview" drawer on the right-hand side of the screen. The preview drawer displays a summary of the selected item, without navigating away from the current table view. + +{{ image("concepts/ui_preview_drawer.png", "Preview Drawer") }} + +## Enabling the Preview Panel + +The preview panel is disabled by default. It can be enabled on a per-user basis via the **Table Preview Panel** option, found in the *Display Options* tab of the [user settings](../../settings/user.md) page. + +## Using the Preview Panel + +Once enabled, clicking on a row in a supported table opens the preview drawer for that item, rather than navigating to its detail page. + +### Viewing Full Details + +The preview drawer title includes an arrow icon, linking to the full detail page for the previewed item. Click on the title (or the arrow icon) to navigate to the detail page and close the drawer. + +{{ image("concepts/ui_preview_details_link.png", "View Details Link") }} + +### Following Links + +Any link within the preview drawer (for example, a link to a related part or category) can be clicked to navigate directly to that page. The preview drawer closes automatically when a link is followed. + +### Bypassing the Preview + +To navigate directly to an item's detail page without opening the preview drawer, hold `Ctrl` (or `Cmd` on macOS) while clicking the row, or middle-click the row to open the detail page in a new tab. + +### Closing the Preview + +The preview drawer can be closed by clicking the close button, clicking outside the drawer, or pressing `Escape`. diff --git a/docs/docs/concepts/ui/tables.md b/docs/docs/concepts/ui/tables.md new file mode 100644 index 0000000000..a1ba89e020 --- /dev/null +++ b/docs/docs/concepts/ui/tables.md @@ -0,0 +1,149 @@ +--- +title: Tables +--- + +## Table Views + +Information throughout the InvenTree interface is often presented in tabular format, allowing users to easily view and interact with large datasets. Tables are designed to be flexible and customizable, providing a range of features to enhance the user experience. + +{{ image("concepts/ui_table.png", "Table View") }} + +### Pagination + +The pagination controls are located at the bottom of the table, allowing users to navigate through large datasets by moving between pages. Users can also adjust the number of rows displayed per page using the pagination settings. + +### Row Selection + +For tables where data selection is supported, a checkbox is provided at the left-hand side of each row, allowing users to select one or more rows for further actions. A master checkbox is also provided in the table header, allowing users to quickly select or deselect all rows in the table. + +!!! info "Pagination and Row Selection" + When using the "master select" checkbox to select all rows, only the rows on the current page will be selected. + +{{ image("concepts/ui_table_row_selection.png", "Row Selection") }} + +### Table Actions + +A particular table view may have a set of actions associated with it, which are typically located at the top-left corner of the table. These actions may include options for adding new entries, or performing bulk actions on [selected rows](#row-selection). + +{{ image("concepts/ui_table_actions.png", "Table Actions") }} + +### Searching + +Some tables support searching, allowing users to quickly find specific entries within the dataset. The search bar is located at the top-right corner of the table view: + +{{ image("concepts/ui_table_search.png", "Table Search") }} + +### Column Selection + +Some tables allow the user to toggle the visibility of certain columns to, enabling a more customized view of the data. + +Column selection is accessed via the "Select Columns" menu, located to the top-right of the table view: + +{{ image("concepts/ui_table_column_selection.png", "Column Selection") }} + +### Filtering + +The dataset (which is fetched dynamically from the server via an API request) can be filtered by providing query parameters to the API endpoint. + +Select the "table filters" button to open the filter selection menu + +{{ image("concepts/ui_table_filter_button.png", "Table Filter Button") }} + +{{ image("concepts/ui_table_filter_menu.png", "Table Filter Menu") }} + +Table filters are saved across browser sessions, allowing users to maintain their preferred filter settings when returning to the particular table view. + +#### Column Filters + +Many table columns expose an inline filter icon directly in the column header, providing a quick way to filter by that column without opening the full filter drawer. Columns that support filtering display a small filter icon alongside the column title. The icon is highlighted when a filter for that column is currently active, giving an at-a-glance indication of which columns have active filters. + +Clicking the icon opens a compact popover anchored to the column header: + +{{ image("concepts/ui_table_column_filter_popover.png", "Column Filter Popover") }} + +**Single-filter columns** — for columns linked to one filter (e.g. *Active*, *Has IPN*, *Status*), selecting a value immediately applies the filter and the popover closes automatically. + +**Range columns** — for columns that represent a range concept (e.g. *Start Date*, *Target Date*, *Creation Date*), the popover stays open and presents multiple controls — for example *before* and *after* date pickers — so both bounds can be set in a single interaction. + +Once a filter is active, the popover shows a badge with the current value and a remove button (red ×) instead of the value picker. Clicking the × clears only that column's filter. + +!!! info "Column filters and the filter drawer share the same state" + Filters applied via a column popover appear immediately in the filter drawer's active-filter list, and filters added through the drawer are reflected in the column icons. Clearing all filters from the drawer also removes any filters set via column popovers. + +#### Saved Filter Groups + +Frequently used combinations of filters can be saved as a named *filter group*, allowing them to be quickly recalled later without having to re-add each filter individually. + +The **Saved Filter Groups** panel is displayed at the bottom of the filter drawer. When one or more filters are active, a **Save current filters** button is available. Clicking it opens an inline name input — enter a name and press Enter (or click the confirm icon) to save the group. Press Escape or click the cancel icon to discard. + +{{ image("concepts/ui_table_filter_group.png", "Filter Groups") }} + +Previously saved filter groups are listed in the panel. Each entry shows the group name alongside two actions: + +- **Load** (green reload icon): Replaces the current active filters with the filters stored in that group. The table immediately re-fetches data using the restored filters. +- **Delete** (red × icon): Permanently removes the saved filter group. + +Saved filter groups are stored in the browser's local storage and are specific to each table or calendar view, so groups saved for one view are not available in another. They persist across local browser sessions until explicitly deleted. Filter groups are not shared to other devices. + +!!! info "Loading a filter group replaces active filters" + Loading a saved filter group replaces all currently active filters with those stored in the group. Any unsaved active filters will be overwritten. + +### Data Sorting + +Some table columns support data sorting, allowing the dataset to be sorted in ascending or descending order based on the values in that column. To sort a column, click on the column header. Clicking the column header again will toggle the sort order between ascending and descending. The current sort order is indicated by an arrow icon in the column header. + +{{ image("concepts/ui_table_sorting.png", "Data Sorting") }} + +### Data Export + +Some tables support downloading of the dataset in various formats (e.g. CSV, Excel, PDF). If data download is available for a given table, the "export data" button will be located at the top-right corner of the table view. + +This opens the "Export Data" form, which allows the user to select the desired file format for download, as well as any additional options related to the data export. + +{{ image("concepts/ui_table_download.png", "Data Download") }} + +### Row Actions + +In some tables, there may be specific actions associated with individual rows, allowing users to perform actions directly on a particular entry in the dataset. Row actions are typically accessed via an "actions" menu located at the right-hand side of each row. + +{{ image("concepts/ui_table_row_actions.png", "Row Actions") }} + +### Right-Click Context Menu + +For rows that support row actions, a right-click context menu is also available, providing quick access to the same set of actions without needing to click on the "actions" menu. + +{{ image("concepts/ui_table_context_menu.png", "Right-Click Context Menu") }} + +### Row Navigation + +For tables which reference other objects within the system, clicking on a row will navigate to the detail page for that particular entry. For example, clicking on a row in the "Part" table will navigate to the detail page for that specific part. + +If the [preview panel](./preview_panels.md) feature is enabled, clicking on a row instead opens a preview drawer for that entry, rather than navigating directly to its detail page. + +## Calendar Views + +Some [table views](#table-views) associated with various order types can be switched to a calendar view, which provides a visual representation of data based on date fields. The calendar view allows users to easily see and interact with data that is organized by date, such as scheduled tasks, events, or deadlines. + +To switch to the "calendar view" (for a table which supports it), click on the "calendar view" button located above and to the right of the table view: + +{{ image("concepts/ui_calendar_select.png", "Calendar View Button") }} + +This will display the data in a calendar format: + +{{ image("concepts/ui_calendar_view.png", "Calendar View") }} + +### Calendar Horizon + +The calendar view provides a configurable "horizon" setting, which allows users to adjust the number of months displayed in the calendar view. + +## Parametric Views + +Some [table views](#table-views) can be switched to a parametric view, which provides a visual representation of data based on specific parameters or attributes. The parametric view allows users to easily see and interact with data that is organized by certain characteristics, such as categories, types, or other relevant attributes. + +To switch to the "parametric view" (for a table which supports it), click on the "parametric view" button located above and to the right of the table view: + +{{ image("concepts/ui_parametric_select.png", "Parametric View Button") }} + +This will display the data in a parametric format: + +{{ image("concepts/ui_parametric_view.png", "Parametric View") }} diff --git a/docs/docs/concepts/user_interface.md b/docs/docs/concepts/user_interface.md deleted file mode 100644 index b919a8ea74..0000000000 --- a/docs/docs/concepts/user_interface.md +++ /dev/null @@ -1,364 +0,0 @@ ---- -title: User Interface ---- - -## User Interface - -The InvenTree user interface is designed to be intuitive and user-friendly, providing easy access to the various features and functions of the system. The interface is organized into several key components, including navigation menus, settings, forms, tables, search functionality, and more. - -The interface is designed for large-format displays, and as such is explicitly *not* optimized for mobile devices. However, the interface is responsive and should work on a wide range of desktop screen sizes. - -## Navigation - -Navigation throughout the InvenTree interface is designed to be straightforward and efficient, allowing users to quickly access the various sections and features of the system. The navigation is organized into several key areas, including the main menu, navigation menu, and page panels. - -### Main Menu - -The main menu is located at the top of the interface and provides access to the primary sections of the system: - -{{ image("concepts/ui_main_menu.png", "Main Menu") }} - -From the main menu, users can access the following items: - -- [Navigation Menu](#navigation-menu) -- [Dashboard](#dashboard) -- [Global Search](#search) -- [Spotlight](#spotlight) -- [Barcode Scanning](#barcode-scanning) -- [Notifications](#notifications) -- [User Menu](#user-menu) - -As well as allowing navigation to the following main sections: - -- [Parts](../part/index.md) -- [Stock](../stock/index.md) -- [Manufacturing](../manufacturing/index.md) -- [Purchasing](../purchasing/index.md) -- [Sales](../sales/index.md) - -### Navigation Menu - -The global navigation menu is located on the left-hand side of the interface and provides access to the various sections of the system. - -{{ image("concepts/ui_navigation_menu.png", "Navigation Menu") }} - -The navigation menu is organized into several key areas, including: - -- **Navigation:** Provides access to the main sections of the system, including Parts, Stock, Manufacturing, Purchasing, and Sales. -- **Settings:** Quick access to user settings, system settings, and the admin interface. -- **Actions:** Provides quick access to commonly used actions -- **Documentation:** Links to the online documentation. -- **About:** InvenTree version and license information. - -### User Menu - -The user menu is located in the top-right corner of the interface and provides access to user-specific settings and actions. - -{{ image("concepts/ui_user_menu.png", "User Menu") }} - -The user menu provides access to the following items: - -- **User Settings:** Access to [user settings](../settings/user.md). -- **System Settings:** Access to [global settings](../settings/global.md) settings. *Note: Access to system settings may be restricted based on user permissions.* -- **Admin Center:** Access to the [Admin Center](../settings/admin.md#admin-center) for operational administration. -- **Database Admin Interface:** Low-level administration via the [Database Admin interface](../settings/db_admin.md). *Note: Access may be restricted based on user permissions and staff status.* -- **Change Color Mode:** Toggle between light and dark color modes. -- **About InvenTree:** View version and license information about InvenTree. -- **Logout:** Log out of the InvenTree system. - -### Page Panels - -Most detail pages views within InvenTree are organized into panels, which provide a structured layout for displaying information and actions related to the current page. - -Panels are arranged in a vertical stack on the left side of the page, with the main content area on the right. Each panel contains related information and actions, allowing users to easily navigate and interact with the content. - -{{ image("concepts/ui_panels.png", "Page Panels") }} - -#### Collapse Panels - -The panel sidebar can be collapsed to provide more space for the main content area. To collapse or expand the panel sidebar, click the collapse icon located at the bottom of the sidebar. To expand the sidebar again, click the expand icon that appears when the sidebar is collapsed. - -### Breadcrumbs - -On some pages, a breadcrumb navigation trail is provided at the top of the page, just below the main menu. Breadcrumbs provide a visual representation of the user's current location within the system and allow for easy navigation back to previous pages. - -{{ image("concepts/ui_breadcrumbs.png", "Breadcrumb Navigation") }} - -### Navigation Tree - -On some pages, a navigation tree is provided on the left-hand side of the page, next to the breadcrumbs. The navigation tree provides a hierarchical view of the current section of the system, allowing users to quickly navigate to related pages and sections. - -Click on the navigation tree icon to expand the tree and view the available navigation options: - -{{ image("concepts/ui_navigation_tree.png", "Navigation Tree") }} - -#### Searching - -The navigation tree includes a search bar at the top of the panel. Typing into the search bar filters the tree to show only entries that match the search query. When a search is active, all matching results are expanded and displayed in a flat list. Clearing the search field returns the tree to its normal browsing mode. - -#### Highlight Selected Entry - -The currently selected entry in the navigation tree is highlighted with a distinct background color, making it easy to identify the active page or section within the hierarchy. - -#### Auto-Expand to Selected Entry - -When the navigation tree is opened, it automatically expands to reveal the currently selected entry. All ancestor nodes in the hierarchy are expanded so the active entry is immediately visible, without requiring manual navigation through the tree. - -## Dashboard - -The dashboard provides a customizable landing page for users when they log in to the system. The dashboard can be configured to display a variety of widgets and information panels, providing users with quick access to important data and actions. - -{{ image("concepts/ui_dashboard.png", "Dashboard") }} - -### Editing Layout - -To edit the layout (add, remove, or rearrange widgets) of the dashboard, open the dashboard context menu (located at the top-right corner of the dashboard) and view the available options: - -{{ image("concepts/ui_dashboard_edit.png", "Dashboard Context Menu") }} - -### Custom Widgets - -In addition to the set of built-in widgets provided by InvenTree, custom dashboard widgets can be implemented using [plugins](../plugins/mixins/ui.md#dashboard-items). This allows users to create personalized dashboard experiences tailored to their specific needs and workflows. - -## Table Views - -Information throughout the InvenTree interface is often presented in tabular format, allowing users to easily view and interact with large datasets. Tables are designed to be flexible and customizable, providing a range of features to enhance the user experience. - -{{ image("concepts/ui_table.png", "Table View") }} - -### Pagination - -The pagination controls are located at the bottom of the table, allowing users to navigate through large datasets by moving between pages. Users can also adjust the number of rows displayed per page using the pagination settings. - -### Row Selection - -For tables where data selection is supported, a checkbox is provided at the left-hand side of each row, allowing users to select one or more rows for further actions. A master checkbox is also provided in the table header, allowing users to quickly select or deselect all rows in the table. - -!!! info "Pagination and Row Selection" - When using the "master select" checkbox to select all rows, only the rows on the current page will be selected. - -{{ image("concepts/ui_table_row_selection.png", "Row Selection") }} - -### Table Actions - -A particular table view may have a set of actions associated with it, which are typically located at the top-left corner of the table. These actions may include options for adding new entries, or performing bulk actions on [selected rows](#row-selection). - -{{ image("concepts/ui_table_actions.png", "Table Actions") }} - -### Searching - -Some tables support searching, allowing users to quickly find specific entries within the dataset. The search bar is located at the top-right corner of the table view: - -{{ image("concepts/ui_table_search.png", "Table Search") }} - -### Column Selection - -Some tables allow the user to toggle the visibility of certain columns to, enabling a more customized view of the data. - -Column selection is accessed via the "Select Columns" menu, located to the top-right of the table view: - -{{ image("concepts/ui_table_column_selection.png", "Column Selection") }} - -### Filtering - -The dataset (which is fetched dynamically from the server via an API request) can be filtered by providing query parameters to the API endpoint. - -Select the "table filters" button to open the filter selection menu - -{{ image("concepts/ui_table_filter_button.png", "Table Filter Button") }} - -{{ image("concepts/ui_table_filter_menu.png", "Table Filter Menu") }} - -Table filters are saved across browser sessions, allowing users to maintain their preferred filter settings when returning to the particular table view. - -#### Column Filters - -Many table columns expose an inline filter icon directly in the column header, providing a quick way to filter by that column without opening the full filter drawer. Columns that support filtering display a small filter icon alongside the column title. The icon is highlighted when a filter for that column is currently active, giving an at-a-glance indication of which columns have active filters. - -Clicking the icon opens a compact popover anchored to the column header: - -{{ image("concepts/ui_table_column_filter_popover.png", "Column Filter Popover") }} - -**Single-filter columns** — for columns linked to one filter (e.g. *Active*, *Has IPN*, *Status*), selecting a value immediately applies the filter and the popover closes automatically. - -**Range columns** — for columns that represent a range concept (e.g. *Start Date*, *Target Date*, *Creation Date*), the popover stays open and presents multiple controls — for example *before* and *after* date pickers — so both bounds can be set in a single interaction. - -Once a filter is active, the popover shows a badge with the current value and a remove button (red ×) instead of the value picker. Clicking the × clears only that column's filter. - -!!! info "Column filters and the filter drawer share the same state" - Filters applied via a column popover appear immediately in the filter drawer's active-filter list, and filters added through the drawer are reflected in the column icons. Clearing all filters from the drawer also removes any filters set via column popovers. - -#### Saved Filter Groups - -Frequently used combinations of filters can be saved as a named *filter group*, allowing them to be quickly recalled later without having to re-add each filter individually. - -The **Saved Filter Groups** panel is displayed at the bottom of the filter drawer. When one or more filters are active, a **Save current filters** button is available. Clicking it opens an inline name input — enter a name and press Enter (or click the confirm icon) to save the group. Press Escape or click the cancel icon to discard. - -{{ image("concepts/ui_table_filter_group.png", "Filter Groups") }} - -Previously saved filter groups are listed in the panel. Each entry shows the group name alongside two actions: - -- **Load** (green reload icon): Replaces the current active filters with the filters stored in that group. The table immediately re-fetches data using the restored filters. -- **Delete** (red × icon): Permanently removes the saved filter group. - -Saved filter groups are stored in the browser's local storage and are specific to each table or calendar view, so groups saved for one view are not available in another. They persist across local browser sessions until explicitly deleted. Filter groups are not shared to other devices. - -!!! info "Loading a filter group replaces active filters" - Loading a saved filter group replaces all currently active filters with those stored in the group. Any unsaved active filters will be overwritten. - -### Data Sorting - -Some table columns support data sorting, allowing the dataset to be sorted in ascending or descending order based on the values in that column. To sort a column, click on the column header. Clicking the column header again will toggle the sort order between ascending and descending. The current sort order is indicated by an arrow icon in the column header. - -{{ image("concepts/ui_table_sorting.png", "Data Sorting") }} - -### Data Export - -Some tables support downloading of the dataset in various formats (e.g. CSV, Excel, PDF). If data download is available for a given table, the "export data" button will be located at the top-right corner of the table view. - -This opens the "Export Data" form, which allows the user to select the desired file format for download, as well as any additional options related to the data export. - -{{ image("concepts/ui_table_download.png", "Data Download") }} - -### Row Actions - -In some tables, there may be specific actions associated with individual rows, allowing users to perform actions directly on a particular entry in the dataset. Row actions are typically accessed via an "actions" menu located at the right-hand side of each row. - -{{ image("concepts/ui_table_row_actions.png", "Row Actions") }} - -### Right-Click Context Menu - -For rows that support row actions, a right-click context menu is also available, providing quick access to the same set of actions without needing to click on the "actions" menu. - -{{ image("concepts/ui_table_context_menu.png", "Right-Click Context Menu") }} - -### Row Navigation - -For tables which reference other objects within the system, clicking on a row will navigate to the detail page for that particular entry. For example, clicking on a row in the "Part" table will navigate to the detail page for that specific part. - -## Calendar Views - -Some [table views](#table-views) associated with various order types can be switched to a calendar view, which provides a visual representation of data based on date fields. The calendar view allows users to easily see and interact with data that is organized by date, such as scheduled tasks, events, or deadlines. - -To switch to the "calendar view" (for a table which supports it), click on the "calendar view" button located above and to the right of the table view: - -{{ image("concepts/ui_calendar_select.png", "Calendar View Button") }} - -This will display the data in a calendar format: - -{{ image("concepts/ui_calendar_view.png", "Calendar View") }} - -### Calendar Horizon - -The calendar view provides a configurable "horizon" setting, which allows users to adjust the number of months displayed in the calendar view. - -## Parametric Views - -Some [table views](#table-views) can be switched to a parametric view, which provides a visual representation of data based on specific parameters or attributes. The parametric view allows users to easily see and interact with data that is organized by certain characteristics, such as categories, types, or other relevant attributes. - -To switch to the "parametric view" (for a table which supports it), click on the "parametric view" button located above and to the right of the table view: - -{{ image("concepts/ui_parametric_select.png", "Parametric View Button") }} - -This will display the data in a parametric format: - -{{ image("concepts/ui_parametric_view.png", "Parametric View") }} - -## Forms - -Data entry and editing within InvenTree is typically performed through the use of forms, which provide a structured interface for inputting and modifying data. Forms are designed to be user-friendly and efficient, allowing users to quickly enter and update information within the system. - -Forms are typically displayed as a modal dialog, separated into multiple sections and fields. - -### Data Creation - -Example: Creating a new part via the "Add Part" form: - -{{ image("concepts/ui_form_add_part.png", "Add Part Button") }} - -On several forms is displayed option "Keep form open" in bottom part of the form on left side of Submit button (option is visible on the screenshot above). When this switch is turned on, form window is not closed after submit and filled form data is not reset. This is useful for creating more entries at one time with similar properties (e.g. only different number in name). - -### Data Editing - -Example: Editing an existing purchase order via the "Edit Purchase Order" form: - -{{ image("concepts/ui_form_edit_po.png", "Edit Purchase Order") }} - -### Form Submission - -A form can be submitted by clicking the "Submit" button located at the bottom-right corner of the form. - -Alternatively, an open form can be submitted using the keyboard shortcut `Ctrl + Enter` (or `Cmd + Enter` on macOS). This shortcut works even while typing in a form field, providing a quick way to submit the form without needing to reach for the mouse. It is particularly useful in multi-line text fields, where pressing `Enter` inserts a new line rather than submitting the form. - -The keyboard shortcut is only active while a form is open, and follows the same rules as the "Submit" button - for example, it has no effect while the form is loading, or when editing an existing item without any changes. - -### Confirm Actions - -Many actions within InvenTree require user confirmation before they can be executed. This is typically implemented through the use of confirmation dialogs, which prompt the user to confirm their intention before proceeding with the action. - -{{ image("concepts/ui_form_hold_po.png", "Confirmation Dialog") }} - -## Global Search - -Accessible from the [main menu](#main-menu), the global search functionality allows users to quickly find specific items or information within the InvenTree system. The search icon is located at the top of the interface and provides a convenient way to search across all sections of the system. - -Clicking on the "search" icon (in the menu bar) opens the search menu, which allows users to enter search queries and view results from across the system. - -{{ image("concepts/ui_global_search.png", "Global Search") }} - -Search results are organized by category (e.g. Parts, Stock, Manufacturing, etc.) and provide quick access to the relevant pages for each search result. - -### Detail View - -To navigate to the detail page for a particular search result, simply click on the desired result from the search results list. This will take you directly to the relevant page within the InvenTree system, allowing you to view and interact with the specific item or information you were searching for. - -### Full Results - -The "global search" menu provides a limited set of search results for each category, typically showing the most relevant or recent results. To view the full set of search results for a particular category, click on the "View all results" button located at the top-left of the search results list for that category: - -{{ image("concepts/ui_global_search_view_all.png", "View Full Search Results") }} - -### Collapse Result Groups - -To collapse a particular category of search results in the global search menu, click on the "collapse" icon located at the top-right corner of the search results list for that category. This will hide the search results for that category, allowing you to focus on other categories or search results. - -### Remove Result Groups - -To remove a particular category of search results from the global search menu, click on the "remove" icon located at the top-right corner of the search results list for that category. - -## Spotlight - -The user interface features a "spotlight" search functionality, which provides a quick and efficient way to access common actions or navigate to specific pages within the InvenTree system. The spotlight search is designed to enhance user productivity by allowing users to quickly find and execute actions without needing to navigate through menus or remember specific page locations. - -{{ image("concepts/ui_spotlight.png", "Spotlight Search") }} - -### Open Spotlight - -To open the "spotlight" search, click on the "spotlight" icon located in the main menu at the top of the interface. This will open the spotlight search menu, allowing you to enter search queries and view available actions. - -Alternatively, the spotlight search can be opened using the keyboard shortcut `Ctrl + K` (or `Cmd + K` on macOS), providing a quick and convenient way to access the spotlight functionality without needing to click on the menu icon. - -### Disable Spotlight - -Users may opt to disable the spotlight search functionality if they do not find it useful or prefer not to use it. To disable the spotlight search, navigate to your [user settings](../settings/user.md) and locate the option to disable the spotlight feature. Once disabled, the spotlight search will no longer be accessible from the main menu or via keyboard shortcuts. - -## Copy Button - -Many fields within the InvenTree user interface include a "copy" button, which allows users to quickly copy the value of that field to their clipboard. This is particularly useful for fields that contain important identifiers, such as part numbers, stock item codes, or other relevant data that may need to be easily copied and pasted elsewhere. - -!!! important "Secure Context" - The "copy" button functionality relies on the browser's clipboard API, which may not be available in all contexts (e.g. if the user is accessing the InvenTree interface via a non-https connection, or through an embedded iframe or a non-standard browser). In such cases, the "copy" button may not function as intended. - -## User Permissions - -Many aspects of the user interface are controlled by user permissions, which determine what actions and features are available to each user based on their assigned roles and permissions within the system. This allows for a highly customizable user experience, where different users can have access to different features and functionality based on their specific needs and responsibilities within the organization. - -If a user does not have permission to access a particular feature or section of the system, that feature will be hidden from their view in the user interface. This helps to ensure that users only see the features and information that are relevant to their role, reducing clutter and improving usability. - -## Language Support - -The InvenTree user interface supports multiple languages, allowing users to interact with the system in their preferred language. - -The default system language can be configured by the system administrator in the [server configuration options](../start/config.md#basic-options). - -Additionally, users can select their preferred language in their [user settings](../settings/user.md), allowing them to override the system default language with their own choice. This provides a personalized experience for each user, ensuring that they can interact with the system in the language they are most comfortable with. 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/docs/mkdocs.yml b/docs/mkdocs.yml index 610a8b6a98..0eed940e1a 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -91,7 +91,12 @@ nav: - Privacy: privacy.md - Concepts: - Terminology: concepts/terminology.md - - User Interface: concepts/user_interface.md + - User Interface: + - Overview: concepts/ui/index.md + - Tables: concepts/ui/tables.md + - Preview Panels: concepts/ui/preview_panels.md + - Forms: concepts/ui/forms.md + - Global Search: concepts/ui/global_search.md - Threat Model: concepts/threat_model.md - Physical Units: concepts/units.md - Companies: concepts/company.md diff --git a/src/backend/InvenTree/common/setting/user.py b/src/backend/InvenTree/common/setting/user.py index f5a2cd3f38..7d7891854d 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': _('Table Preview Panel'), + 'description': _('Display a preview panel when selecting items in tables'), + 'default': False, + 'validator': bool, + }, 'DISPLAY_STOCKTAKE_TAB': { 'name': _('Show Stock History'), 'description': _('Display stock history information in the part detail page'), diff --git a/src/frontend/lib/components/RowActions.tsx b/src/frontend/lib/components/RowActions.tsx index ff2ad44c40..89d57d3109 100644 --- a/src/frontend/lib/components/RowActions.tsx +++ b/src/frontend/lib/components/RowActions.tsx @@ -10,7 +10,7 @@ import { } from '@tabler/icons-react'; import { type ReactNode, useMemo, useState } from 'react'; import { cancelEvent } from '../functions/Events'; -import { getDetailUrl } from '../functions/Navigation'; +import { eventModified, getDetailUrl } from '../functions/Navigation'; import { navigateToLink } from '../functions/Navigation'; import type { RowAction, RowViewProps } from '../types/Tables'; @@ -23,8 +23,18 @@ export function RowViewAction(props: RowViewProps): RowAction { color: undefined, icon: , onClick: (event: any) => { - const url = getDetailUrl(props.modelType, props.modelId); - navigateToLink(url, props.navigate, event); + const showPreviewPanel = props.isPreviewEnabled?.() ?? false; + + if ( + !showPreviewPanel || + eventModified(event as any) || + !props.openPreview + ) { + const url = getDetailUrl(props.modelType, props.modelId); + navigateToLink(url, props.navigate, event); + } else { + props.openPreview(props.modelType, props.modelId); + } } }; } diff --git a/src/frontend/lib/enums/ModelInformation.tsx b/src/frontend/lib/enums/ModelInformation.tsx index 5989e0acf9..39d597202b 100644 --- a/src/frontend/lib/enums/ModelInformation.tsx +++ b/src/frontend/lib/enums/ModelInformation.tsx @@ -1,5 +1,6 @@ import { t } from '@lingui/core/macro'; import type { InvenTreeIconType } from '../types/Icons'; +import type { InstanceRenderInterface } from '../types/Rendering'; import { ApiEndpoints } from './ApiEndpoints'; import type { ModelType } from './ModelType'; @@ -13,6 +14,9 @@ export interface ModelInformationInterface { pk_field?: string; supports_barcode?: boolean; icon: keyof InvenTreeIconType; + preview?: (props: { instance: any; modelId: number }) => any; + render?: (props: Readonly) => any; + default_query_params?: Record; } export interface TranslatableModelInformationInterface @@ -25,6 +29,33 @@ export type ModelDict = { [key in keyof typeof ModelType]: TranslatableModelInformationInterface; }; +type ModelPreviewKey = keyof typeof ModelType; +type ModelPreviewProps = { instance: any; modelId: number }; +type ModelPreview = (props: ModelPreviewProps) => any; +type ModelRender = (props: Readonly) => any; + +export function registerModelPreviews( + previews: Partial> +) { + (Object.keys(previews) as ModelPreviewKey[]).forEach((model) => { + const preview = previews[model]; + if (preview) { + ModelInformationDict[model].preview = preview; + } + }); +} + +export function registerModelRenderers( + renderers: Partial> +) { + (Object.keys(renderers) as ModelPreviewKey[]).forEach((model) => { + const renderer = renderers[model]; + if (renderer) { + ModelInformationDict[model].render = renderer; + } + }); +} + export const ModelInformationDict: ModelDict = { part: { label: () => t`Part`, @@ -64,7 +95,12 @@ export const ModelInformationDict: ModelDict = { api_endpoint: ApiEndpoints.supplier_part_list, admin_url: '/company/supplierpart/', supports_barcode: true, - icon: 'supplier_part' + icon: 'supplier_part', + default_query_params: { + part_detail: true, + supplier_detail: true, + manufacturer_detail: true + } }, manufacturerpart: { label: () => t`Manufacturer Part`, @@ -74,7 +110,8 @@ export const ModelInformationDict: ModelDict = { api_endpoint: ApiEndpoints.manufacturer_part_list, admin_url: '/company/manufacturerpart/', supports_barcode: true, - icon: 'manufacturers' + icon: 'manufacturers', + default_query_params: { part_detail: true, manufacturer_detail: true } }, partcategory: { label: () => t`Part Category`, @@ -93,7 +130,8 @@ export const ModelInformationDict: ModelDict = { api_endpoint: ApiEndpoints.stock_item_list, admin_url: '/stock/stockitem/', supports_barcode: true, - icon: 'stock' + icon: 'stock', + default_query_params: { part_detail: true } }, stocklocation: { label: () => t`Stock Location`, @@ -125,7 +163,8 @@ export const ModelInformationDict: ModelDict = { api_endpoint: ApiEndpoints.build_order_list, admin_url: '/build/build/', supports_barcode: true, - icon: 'build_order' + icon: 'build_order', + default_query_params: { part_detail: true } }, buildline: { label: () => t`Build Line`, @@ -164,7 +203,8 @@ export const ModelInformationDict: ModelDict = { api_endpoint: ApiEndpoints.purchase_order_list, admin_url: '/order/purchaseorder/', supports_barcode: true, - icon: 'purchase_orders' + icon: 'purchase_orders', + default_query_params: { supplier_detail: true } }, purchaseorderlineitem: { label: () => t`Purchase Order Line`, @@ -180,7 +220,8 @@ export const ModelInformationDict: ModelDict = { api_endpoint: ApiEndpoints.sales_order_list, admin_url: '/order/salesorder/', supports_barcode: true, - icon: 'sales_orders' + icon: 'sales_orders', + default_query_params: { customer_detail: true } }, salesordershipment: { label: () => t`Sales Order Shipment`, @@ -190,7 +231,8 @@ export const ModelInformationDict: ModelDict = { admin_url: '/order/salesordershipment/', api_endpoint: ApiEndpoints.sales_order_shipment_list, supports_barcode: true, - icon: 'shipment' + icon: 'shipment', + default_query_params: { order_detail: true } }, returnorder: { label: () => t`Return Order`, @@ -200,7 +242,8 @@ export const ModelInformationDict: ModelDict = { api_endpoint: ApiEndpoints.return_order_list, admin_url: '/order/returnorder/', supports_barcode: true, - icon: 'return_orders' + icon: 'return_orders', + default_query_params: { customer_detail: true } }, returnorderlineitem: { label: () => t`Return Order Line Item`, diff --git a/src/frontend/lib/types/Plugins.tsx b/src/frontend/lib/types/Plugins.tsx index 2038b1f297..d142500ae5 100644 --- a/src/frontend/lib/types/Plugins.tsx +++ b/src/frontend/lib/types/Plugins.tsx @@ -69,6 +69,17 @@ export type ImporterDrawerContext = { sessionId: () => number | null; }; +export type PreviewDrawerContext = { + open: ( + modelType: ModelType, + id?: number, + instance?: any, + onClose?: () => void + ) => void; + close: () => void; + isOpen: () => boolean; +}; + /** * A set of properties which are passed to a plugin, * for rendering an element in the user interface. @@ -90,6 +101,7 @@ export type ImporterDrawerContext = { * @param forms - A set of functions for opening various API forms (see ../components/Forms.tsx) * @param tables - A set of functions for rendering API tables * @param importer - A set of functions for controlling the global importer drawer (see ../components/importer/GlobalImporterDrawer.tsx) + * @param preview - A set of functions for controlling the global preview drawer (see ../components/previews/GlobalPreviewDrawer.tsx) * @param model - The model type associated with the rendered component (if applicable) * @param id - The ID (primary key) of the model instance for the plugin (if applicable) * @param instance - The model instance data (if available) @@ -125,6 +137,7 @@ export type InvenTreePluginContext = { }; tables: InvenTreeTablesContext; importer: ImporterDrawerContext; + preview: PreviewDrawerContext; model?: ModelType | string; id?: string | number | null; instance?: any; diff --git a/src/frontend/lib/types/Preview.tsx b/src/frontend/lib/types/Preview.tsx new file mode 100644 index 0000000000..963598afe3 --- /dev/null +++ b/src/frontend/lib/types/Preview.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from 'react'; + +export interface PreviewType { + preview: ReactNode; + title: string; +} + +export type PreviewComponentProps = { + instance: any; +}; + +export type PreviewComponent = (props: PreviewComponentProps) => PreviewType; diff --git a/src/frontend/lib/types/Tables.tsx b/src/frontend/lib/types/Tables.tsx index b3b4e9f0b4..44bb08cda2 100644 --- a/src/frontend/lib/types/Tables.tsx +++ b/src/frontend/lib/types/Tables.tsx @@ -154,7 +154,12 @@ type RowModelProps = { navigate: NavigateFunction; }; -export type RowViewProps = RowAction & RowModelProps; +type RowViewBehaviorProps = { + isPreviewEnabled?: () => boolean; + openPreview?: (modelType: ModelType, modelId: number) => void; +}; + +export type RowViewProps = RowAction & RowModelProps & RowViewBehaviorProps; /** * Set of optional properties which can be passed to an InvenTreeTable component diff --git a/src/frontend/src/components/calendar/OrderCalendar.tsx b/src/frontend/src/components/calendar/OrderCalendar.tsx index 365d3d5ceb..247f3fe53a 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,8 @@ import { useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { api } from '../../App'; import useCalendar from '../../hooks/UseCalendar'; +import { openGlobalPreview } from '../../states/PreviewDrawerState'; +import { useUserSettingsState } from '../../states/SettingsStates'; import { useUserState } from '../../states/UserState'; import { StatusRenderer, getStatusColor } from '../render/StatusRenderer'; import { @@ -59,6 +65,9 @@ export default function OrderCalendar({ }) { const navigate = useNavigate(); const user = useUserState(); + const previewPanelEnabled = useUserSettingsState((state) => + state.isSet('ENABLE_PREVIEW_PANEL') + ); // These filters apply to all order types const orderFilters: TableFilter[] = useMemo(() => { @@ -166,14 +175,16 @@ export default function OrderCalendar({ } }; - // Callback when PurchaseOrder is clicked + // Callback when an order is clicked - open preview drawer, or navigate on modifier click const onClickOrder = (info: EventClickArg) => { - if (!!info.event.id) { - navigateToLink( - getDetailUrl(model, info.event.id), - navigate, - info.jsEvent - ); + if (!info.event.id) return; + + const detailUrl = getDetailUrl(model, info.event.id); + + if (eventModified(info.jsEvent as any) || !previewPanelEnabled) { + navigateToLink(detailUrl, navigate, info.jsEvent); + } else { + openGlobalPreview(model, Number.parseInt(info.event.id)); } }; diff --git a/src/frontend/src/components/details/Details.tsx b/src/frontend/src/components/details/Details.tsx index d221ea543d..5bc219285a 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]); @@ -552,8 +560,13 @@ export function DetailsTable({ {title && {title}} - {visibleFields.map((field: DetailsField, index: number) => ( - + {visibleFields.map((field: DetailsField) => ( + ))}
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..c429ee1f0f 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) => ( + field.name).join(',')} + {...props} + /> + ))} ); 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 f9167be29d..844707ae59 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 { EditApiForm } from '../forms/ApiForm'; import { Thumbnail } from '../images/Thumbnail'; @@ -108,6 +113,12 @@ export const useInvenTreeContext = () => { isOpen: () => getGlobalImporterState().isOpen, sessionId: () => getGlobalImporterState().sessionId }, + preview: { + open: (modelType, id?, instance?, onClose?) => + openGlobalPreview(modelType, id, instance, undefined, onClose), + close: () => closeGlobalPreview(), + isOpen: () => getGlobalPreviewState().isOpen + }, tables: { renderTable: (props: InvenTreeTableRenderProps) => ( diff --git a/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx b/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx new file mode 100644 index 0000000000..afb9a6d208 --- /dev/null +++ b/src/frontend/src/components/previews/GlobalPreviewDrawer.tsx @@ -0,0 +1,28 @@ +import { usePreviewDrawerState } from '../../states/PreviewDrawerState'; +import { useUserSettingsState } from '../../states/SettingsStates'; +import PreviewDrawer from './PreviewDrawer'; + +export default function GlobalPreviewDrawer() { + const enabled = useUserSettingsState((state) => + state.isSet('ENABLE_PREVIEW_PANEL') + ); + + const drawer = usePreviewDrawerState((state) => state); + + if (!enabled) { + return null; + } + + return ( + + ); +} diff --git a/src/frontend/src/components/previews/ModelPreviewShim.ts b/src/frontend/src/components/previews/ModelPreviewShim.ts new file mode 100644 index 0000000000..de643ab544 --- /dev/null +++ b/src/frontend/src/components/previews/ModelPreviewShim.ts @@ -0,0 +1,30 @@ +import { registerModelPreviews } from '@lib/enums/ModelInformation'; +import { BuildOrderPreviewComponent } from './models/BuildOrderPreview'; +import { CompanyPreviewComponent } from './models/CompanyPreview'; +import { ManufacturerPartPreviewComponent } from './models/ManufacturerPartPreview'; +import { PartCategoryPreviewComponent } from './models/PartCategoryPreview'; +import { PartPreviewComponent } from './models/PartPreview'; +import { PurchaseOrderPreviewComponent } from './models/PurchaseOrderPreview'; +import { ReturnOrderPreviewComponent } from './models/ReturnOrderPreview'; +import { SalesOrderPreviewComponent } from './models/SalesOrderPreview'; +import { SalesOrderShipmentPreviewComponent } from './models/SalesOrderShipmentPreview'; +import { StockLocationPreviewComponent } from './models/StockLocationPreview'; +import { StockPreviewComponent } from './models/StockPreview'; +import { SupplierPartPreviewComponent } from './models/SupplierPartPreview'; +import { TransferOrderPreviewComponent } from './models/TransferOrderPreview'; + +registerModelPreviews({ + part: PartPreviewComponent, + supplierpart: SupplierPartPreviewComponent, + manufacturerpart: ManufacturerPartPreviewComponent, + partcategory: PartCategoryPreviewComponent, + stockitem: StockPreviewComponent, + stocklocation: StockLocationPreviewComponent, + build: BuildOrderPreviewComponent, + company: CompanyPreviewComponent, + purchaseorder: PurchaseOrderPreviewComponent, + salesorder: SalesOrderPreviewComponent, + salesordershipment: SalesOrderShipmentPreviewComponent, + returnorder: ReturnOrderPreviewComponent, + transferorder: TransferOrderPreviewComponent +}); diff --git a/src/frontend/src/components/previews/PreviewDrawer.tsx b/src/frontend/src/components/previews/PreviewDrawer.tsx new file mode 100644 index 0000000000..ed45e53aaa --- /dev/null +++ b/src/frontend/src/components/previews/PreviewDrawer.tsx @@ -0,0 +1,195 @@ +import { + ActionIcon, + Anchor, + Divider, + Drawer, + Group, + LoadingOverlay, + Stack, + Tooltip +} from '@mantine/core'; + +import { StylishText } from '@lib/components/StylishText'; +import { cancelEvent } from '@lib/functions/Events'; +import { + eventModified, + getBaseUrl, + getDetailUrl, + navigateToLink +} from '@lib/functions/Navigation'; +import type { ModelType } from '@lib/index'; +import type { PreviewType } from '@lib/types/Preview'; +import { t } from '@lingui/core/macro'; +import { IconArrowRight } from '@tabler/icons-react'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useInstance } from '../../hooks/UseInstance'; +import { getModelInfo } from '../render/ModelType'; +import './ModelPreviewShim'; +import { FallbackPreviewComponent } from './models/Fallback'; + +export default function PreviewDrawer({ + modelType, + id, + instance: providedInstance, + filters, + preview: providedPreview, + targetUrl, + opened, + onClose +}: Readonly<{ + modelType?: ModelType; + id?: number | string; + instance?: any; + filters?: Record; + preview?: PreviewType; + targetUrl?: string; + opened: boolean; + onClose: () => void; +}>) { + const navigate = useNavigate(); + + const modelInfo = useMemo(() => { + return modelType ? getModelInfo(modelType) : null; + }, [modelType]); + + const apiEndpoint = modelInfo?.api_endpoint ?? undefined; + + // Combine the default query params for the model type with any filters + // explicitly provided by the caller which opened the preview (which take + // precedence over the defaults). + const queryParams = useMemo(() => { + return { + ...modelInfo?.default_query_params, + ...filters + }; + }, [modelInfo, filters]); + + const { instance: fetchedInstance, instanceQuery } = useInstance({ + endpoint: apiEndpoint!, + pk: id, + hasPrimaryKey: true, + defaultValue: {}, + params: queryParams, + disabled: !!providedInstance || !modelType || !id + }); + + const instance = useMemo(() => { + return providedInstance ?? fetchedInstance; + }, [providedInstance, fetchedInstance]); + + const previewComponent: PreviewType | null = useMemo(() => { + if (providedPreview) return providedPreview; + if (!modelType || !modelInfo || id == null) return null; + + const component = modelInfo?.preview?.({ + instance, + modelId: typeof id === 'string' ? Number(id) : id + }); + + if (component == null) { + return FallbackPreviewComponent({ + modelInfo, + modelType, + modelId: id, + instance + }); + } + + return component; + }, [providedPreview, modelType, id, instance, modelInfo]); + + useEffect(() => { + if (!opened) return; + const handler = (event: MouseEvent) => { + const anchor = (event.target as HTMLElement).closest('a'); + if (anchor && !eventModified(event as any)) { + if (anchor.origin === window.location.origin) { + // Same-origin: prevent browser navigation and route internally + const href = anchor.pathname + anchor.search + anchor.hash; + cancelEvent(event); + onClose(); + navigateToLink(href, navigate, event as any); + } else { + // External link: let browser open it, just close the drawer + onClose(); + } + } + }; + document.addEventListener('click', handler, true); + return () => document.removeEventListener('click', handler, true); + }, [onClose, opened]); + + const primaryUrl = useMemo(() => { + if (targetUrl) return targetUrl; + if (modelType && id) return getDetailUrl(modelType, id); + return undefined; + }, [targetUrl, modelType, id]); + + const primaryHref = useMemo(() => { + if (!primaryUrl) return undefined; + const base = `/${getBaseUrl()}`; + return primaryUrl.startsWith(base) ? primaryUrl : `${base}${primaryUrl}`; + }, [primaryUrl]); + + const clickTitle = useCallback( + (event: any) => { + if (!primaryUrl) return; + + if (!eventModified(event as any)) { + onClose(); + } + navigateToLink(primaryUrl, navigate, event as any); + }, + [primaryUrl, navigate] + ); + + const drawerTitle = useMemo(() => { + if (!previewComponent) return null; + + const titleText = ( + {previewComponent.title} + ); + + if (!primaryHref) return titleText; + + return ( + clickTitle(e)}> + + + + + + + {titleText} + + + ); + }, [previewComponent, primaryHref, modelType, id, clickTitle]); + + return ( + + + {previewComponent && ( + <> + + + {previewComponent.preview} + + )} + + + ); +} 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..12254376c2 --- /dev/null +++ b/src/frontend/src/components/previews/models/BuildOrderPreview.tsx @@ -0,0 +1,25 @@ +import type { PreviewType } from '@lib/types/Preview'; +import { t } from '@lingui/core/macro'; +import { BuildOrderDetailsPanel } from '../../../pages/build/BuildOrderDetailsPanel'; + +export function BuildOrderPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const part = instance?.part_detail?.full_name ?? instance?.part_detail?.name; + const ref = instance?.reference ?? `#${modelId}`; + + let title = `${t`Build Order`} ${ref}`; + + if (part) { + title += ` (${part})`; + } + + return { + title, + preview: + }; +} 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..6bda9d0e05 --- /dev/null +++ b/src/frontend/src/components/previews/models/CompanyPreview.tsx @@ -0,0 +1,18 @@ +import type { PreviewType } from '@lib/types/Preview'; +import { t } from '@lingui/core/macro'; +import { CompanyDetailsPanel } from '../../../pages/company/CompanyDetailsPanel'; + +export function CompanyPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const name = instance?.name ?? `#${modelId}`; + + return { + title: `${t`Company`} - ${name}`, + preview: + }; +} 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..bea6225d69 --- /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 type { PreviewType } from '@lib/types/Preview'; +import { t } from '@lingui/core/macro'; +import { Alert, Text } from '@mantine/core'; +import { IconExclamationCircle } from '@tabler/icons-react'; + +export function FallbackPreviewComponent({ + modelInfo, + modelType, + modelId, + instance +}: { + modelInfo: ModelInformationInterface; + modelType: ModelType; + modelId: number | string; + instance: any; +}): PreviewType { + return { + title: `${modelInfo.label} #${modelId}`, + preview: ( + } + > + {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..11c217df65 --- /dev/null +++ b/src/frontend/src/components/previews/models/ManufacturerPartPreview.tsx @@ -0,0 +1,26 @@ +import type { PreviewType } from '@lib/types/Preview'; +import { t } from '@lingui/core/macro'; +import { ManufacturerPartDetailsPanel } from '../../../pages/company/ManufacturerPartDetailsPanel'; + +export function ManufacturerPartPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const manufacturer = + instance?.manufacturer_detail?.name ?? instance?.manufacturer_name; + const mpn = instance?.MPN ?? `#${modelId}`; + + let title = `${t`Manufacturer Part`} ${mpn}`; + + if (manufacturer) { + title += ` (${manufacturer})`; + } + + return { + title, + preview: + }; +} 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..cb1429b6af --- /dev/null +++ b/src/frontend/src/components/previews/models/PartCategoryPreview.tsx @@ -0,0 +1,18 @@ +import type { PreviewType } from '@lib/types/Preview'; +import { t } from '@lingui/core/macro'; +import { PartCategoryDetailsPanel } from '../../../pages/part/PartCategoryDetailsPanel'; + +export function PartCategoryPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const name = `${t`Part Category`} - ${instance?.name ?? `#${modelId}`}`; + + return { + title: name, + preview: + }; +} 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..1c6318daef --- /dev/null +++ b/src/frontend/src/components/previews/models/PartPreview.tsx @@ -0,0 +1,15 @@ +import type { PreviewType } from '@lib/types/Preview'; +import { PartDetailsPanel } from '../../../pages/part/PartDetailsPanel'; + +export function PartPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + return { + title: instance?.full_name || instance?.name || `Part #${modelId}`, + preview: + }; +} 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..81d60e9573 --- /dev/null +++ b/src/frontend/src/components/previews/models/PurchaseOrderPreview.tsx @@ -0,0 +1,25 @@ +import type { PreviewType } from '@lib/types/Preview'; +import { t } from '@lingui/core/macro'; +import { PurchaseOrderDetailsPanel } from '../../../pages/purchasing/PurchaseOrderDetailsPanel'; + +export function PurchaseOrderPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const supplier = instance?.supplier_detail?.name ?? instance?.supplier_name; + const ref = instance?.reference ?? `#${modelId}`; + + let title = `${t`Purchase Order`} ${ref}`; + + if (supplier) { + title += ` (${supplier})`; + } + + return { + title: title, + preview: + }; +} 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..19f1586421 --- /dev/null +++ b/src/frontend/src/components/previews/models/ReturnOrderPreview.tsx @@ -0,0 +1,25 @@ +import type { PreviewType } from '@lib/types/Preview'; +import { t } from '@lingui/core/macro'; +import { ReturnOrderDetailsPanel } from '../../../pages/sales/ReturnOrderDetailsPanel'; + +export function ReturnOrderPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const customer = instance?.customer_detail?.name ?? instance?.customer_name; + const ref = instance?.reference ?? `#${modelId}`; + + let title = `${t`Return Order`} ${ref}`; + + if (customer) { + title += ` (${customer})`; + } + + return { + title, + preview: + }; +} 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..51de65df7a --- /dev/null +++ b/src/frontend/src/components/previews/models/SalesOrderPreview.tsx @@ -0,0 +1,25 @@ +import type { PreviewType } from '@lib/types/Preview'; +import { t } from '@lingui/core/macro'; +import { SalesOrderDetailsPanel } from '../../../pages/sales/SalesOrderDetailsPanel'; + +export function SalesOrderPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const customer = instance?.customer_detail?.name ?? instance?.customer_name; + const ref = instance?.reference ?? `#${modelId}`; + + let title = `${t`Sales Order`} ${ref}`; + + if (customer) { + title += ` (${customer})`; + } + + return { + title, + preview: + }; +} 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..9b9e784416 --- /dev/null +++ b/src/frontend/src/components/previews/models/SalesOrderShipmentPreview.tsx @@ -0,0 +1,25 @@ +import type { PreviewType } from '@lib/types/Preview'; +import { t } from '@lingui/core/macro'; +import { SalesOrderShipmentDetailsPanel } from '../../../pages/sales/SalesOrderShipmentDetailsPanel'; + +export function SalesOrderShipmentPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const order = instance?.order_detail?.reference; + const ref = instance?.reference ?? `#${modelId}`; + + let title = `${t`Shipment`} ${ref}`; + + if (order) { + title += ` (${order})`; + } + + return { + title, + preview: + }; +} 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..36cd30614d --- /dev/null +++ b/src/frontend/src/components/previews/models/StockLocationPreview.tsx @@ -0,0 +1,18 @@ +import type { PreviewType } from '@lib/types/Preview'; +import { t } from '@lingui/core/macro'; +import { StockLocationDetailsPanel } from '../../../pages/stock/StockLocationDetailsPanel'; + +export function StockLocationPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const name = `${t`Stock Location`} - ${instance?.name ?? `#${modelId}`}`; + + return { + title: name, + preview: + }; +} 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..e51a5cd446 --- /dev/null +++ b/src/frontend/src/components/previews/models/StockPreview.tsx @@ -0,0 +1,30 @@ +import { formatDecimal } from '@lib/functions/Formatting'; +import type { PreviewType } from '@lib/types/Preview'; +import { StockDetailsPanel } from '../../../pages/stock/StockDetailsPanel'; + +export function StockPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const part = instance?.part_detail; + + let title = `Stock Item #${modelId}`; + + if (part) { + title = part?.full_name ?? part?.name; + + if (instance.serial) { + title += ` (# ${instance.serial})`; + } else { + title += ` (x ${formatDecimal(instance.quantity)})`; + } + } + + return { + title: title, + preview: + }; +} 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..5c41640489 --- /dev/null +++ b/src/frontend/src/components/previews/models/SupplierPartPreview.tsx @@ -0,0 +1,25 @@ +import type { PreviewType } from '@lib/types/Preview'; +import { t } from '@lingui/core/macro'; +import { SupplierPartDetailsPanel } from '../../../pages/company/SupplierPartDetailsPanel'; + +export function SupplierPartPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const supplier = instance?.supplier_detail?.name ?? instance?.supplier_name; + const sku = instance?.SKU ?? `#${modelId}`; + + let title = `${t`Supplier Part`} ${sku}`; + + if (supplier) { + title += ` (${supplier})`; + } + + return { + title, + preview: + }; +} 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..6db68e95cc --- /dev/null +++ b/src/frontend/src/components/previews/models/TransferOrderPreview.tsx @@ -0,0 +1,18 @@ +import type { PreviewType } from '@lib/types/Preview'; +import { t } from '@lingui/core/macro'; +import { TransferOrderDetailsPanel } from '../../../pages/stock/TransferOrderDetailsPanel'; + +export function TransferOrderPreviewComponent({ + instance, + modelId +}: Readonly<{ + instance: any; + modelId: number; +}>): PreviewType { + const ref = instance?.reference ?? `#${modelId}`; + + return { + title: `${t`Transfer Order`} ${ref}`, + preview: + }; +} diff --git a/src/frontend/src/components/render/Instance.tsx b/src/frontend/src/components/render/Instance.tsx index e0ae4b04d8..018a052c39 100644 --- a/src/frontend/src/components/render/Instance.tsx +++ b/src/frontend/src/components/render/Instance.tsx @@ -17,11 +17,10 @@ import { useQuery } from '@tanstack/react-query'; import { type ReactNode, useCallback, useMemo } from 'react'; import { ModelInformationDict } from '@lib/enums/ModelInformation'; -import { ModelType } from '@lib/enums/ModelType'; +import type { ModelType } from '@lib/enums/ModelType'; import { apiUrl } from '@lib/functions/Api'; import type { InstanceRenderInterface, - ModelRendererDict, RemoteInstanceProps, RenderInlineModelProps, RenderInstanceProps @@ -39,107 +38,12 @@ import { useApi } from '../../contexts/ApiContext'; import { usePluginState } from '../../states/PluginState'; import { useUserSettingsState } from '../../states/SettingsStates'; import { Thumbnail } from '../images/Thumbnail'; -import { RenderBuildItem, RenderBuildLine, RenderBuildOrder } from './Build'; -import { - RenderAddress, - RenderCompany, - RenderContact, - RenderManufacturerPart, - RenderSupplierPart -} from './Company'; -import { - RenderContentType, - RenderError, - RenderImportSession, - RenderParameter, - RenderParameterTemplate, - RenderProjectCode, - RenderSelectionEntry, - RenderSelectionList, - RenderTag -} from './Generic'; -import { - RenderPurchaseOrder, - RenderReturnOrder, - RenderReturnOrderLineItem, - RenderSalesOrder, - RenderSalesOrderShipment, - RenderTransferOrder, - RenderTransferOrderLineItem -} from './Order'; -import { RenderPart, RenderPartCategory, RenderPartTestTemplate } from './Part'; -import { RenderPlugin } from './Plugin'; -import { RenderLabelTemplate, RenderReportTemplate } from './Report'; -import { - RenderStockItem, - RenderStockLocation, - RenderStockLocationType -} from './Stock'; -import { RenderGroup, RenderOwner, RenderUser } from './User'; - -/** - * Lookup table for rendering a model instance - */ -export const RendererLookup: ModelRendererDict = { - [ModelType.address]: RenderAddress, - [ModelType.build]: RenderBuildOrder, - [ModelType.buildline]: RenderBuildLine, - [ModelType.builditem]: RenderBuildItem, - [ModelType.company]: RenderCompany, - [ModelType.contact]: RenderContact, - [ModelType.parameter]: RenderParameter, - [ModelType.parametertemplate]: RenderParameterTemplate, - [ModelType.manufacturerpart]: RenderManufacturerPart, - [ModelType.owner]: RenderOwner, - [ModelType.part]: RenderPart, - [ModelType.partcategory]: RenderPartCategory, - [ModelType.parttesttemplate]: RenderPartTestTemplate, - [ModelType.projectcode]: RenderProjectCode, - [ModelType.purchaseorder]: RenderPurchaseOrder, - [ModelType.purchaseorderlineitem]: RenderPurchaseOrder, - [ModelType.returnorder]: RenderReturnOrder, - [ModelType.returnorderlineitem]: RenderReturnOrderLineItem, - [ModelType.salesorder]: RenderSalesOrder, - [ModelType.salesordershipment]: RenderSalesOrderShipment, - [ModelType.transferorder]: RenderTransferOrder, - [ModelType.transferorderlineitem]: RenderTransferOrderLineItem, - [ModelType.stocklocation]: RenderStockLocation, - [ModelType.stocklocationtype]: RenderStockLocationType, - [ModelType.stockitem]: RenderStockItem, - [ModelType.stockhistory]: RenderStockItem, - [ModelType.supplierpart]: RenderSupplierPart, - [ModelType.user]: RenderUser, - [ModelType.group]: RenderGroup, - [ModelType.importsession]: RenderImportSession, - [ModelType.reporttemplate]: RenderReportTemplate, - [ModelType.labeltemplate]: RenderLabelTemplate, - [ModelType.pluginconfig]: RenderPlugin, - [ModelType.contenttype]: RenderContentType, - [ModelType.selectionlist]: RenderSelectionList, - [ModelType.selectionentry]: RenderSelectionEntry, - [ModelType.error]: RenderError, - [ModelType.tag]: RenderTag -}; +import './ModelRenderShim'; /** * Render an instance of a database model, depending on the provided data */ export function RenderInstance(props: RenderInstanceProps): ReactNode { - let RenderComponent: - | ((props: Readonly) => ReactNode) - | undefined; - // core model renderer - if (props.model !== undefined && props.custom_model === undefined) { - RenderComponent = - RendererLookup[props.model.toString().toLowerCase() as ModelType]; - } - // custom model renderer (registered by a plugin) as a fallback to the core model renderer - RenderComponent ??= usePluginState().getRenderer( - props.custom_model ?? props.model ?? '' - ); - - const userSettings = useUserSettingsState(); - // Extract model information from the defined model type const modelInfo = useMemo(() => { if (!props.model) { @@ -151,6 +55,22 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode { ]; }, [props.model]); + let RenderComponent: + | ((props: Readonly) => ReactNode) + | undefined; + + // core model renderer + if (props.model !== undefined && props.custom_model === undefined) { + RenderComponent = modelInfo?.render; + } + + // custom model renderer (registered by a plugin) as a fallback to the core model renderer + RenderComponent ??= usePluginState().getRenderer( + props.custom_model ?? props.model ?? '' + ); + + const userSettings = useUserSettingsState(); + const showHover: boolean = useMemo(() => { if (!modelInfo) { return false; diff --git a/src/frontend/src/components/render/ModelRenderShim.ts b/src/frontend/src/components/render/ModelRenderShim.ts new file mode 100644 index 0000000000..2703b3e1e8 --- /dev/null +++ b/src/frontend/src/components/render/ModelRenderShim.ts @@ -0,0 +1,80 @@ +import { registerModelRenderers } from '@lib/enums/ModelInformation'; +import { ModelType } from '@lib/enums/ModelType'; +import { RenderBuildItem, RenderBuildLine, RenderBuildOrder } from './Build'; +import { + RenderAddress, + RenderCompany, + RenderContact, + RenderManufacturerPart, + RenderSupplierPart +} from './Company'; +import { + RenderContentType, + RenderError, + RenderImportSession, + RenderParameter, + RenderParameterTemplate, + RenderProjectCode, + RenderSelectionEntry, + RenderSelectionList, + RenderTag +} from './Generic'; +import { + RenderPurchaseOrder, + RenderReturnOrder, + RenderReturnOrderLineItem, + RenderSalesOrder, + RenderSalesOrderShipment, + RenderTransferOrder, + RenderTransferOrderLineItem +} from './Order'; +import { RenderPart, RenderPartCategory, RenderPartTestTemplate } from './Part'; +import { RenderPlugin } from './Plugin'; +import { RenderLabelTemplate, RenderReportTemplate } from './Report'; +import { + RenderStockItem, + RenderStockLocation, + RenderStockLocationType +} from './Stock'; +import { RenderGroup, RenderOwner, RenderUser } from './User'; + +registerModelRenderers({ + [ModelType.address]: RenderAddress, + [ModelType.build]: RenderBuildOrder, + [ModelType.buildline]: RenderBuildLine, + [ModelType.builditem]: RenderBuildItem, + [ModelType.company]: RenderCompany, + [ModelType.contact]: RenderContact, + [ModelType.parameter]: RenderParameter, + [ModelType.parametertemplate]: RenderParameterTemplate, + [ModelType.manufacturerpart]: RenderManufacturerPart, + [ModelType.owner]: RenderOwner, + [ModelType.part]: RenderPart, + [ModelType.partcategory]: RenderPartCategory, + [ModelType.parttesttemplate]: RenderPartTestTemplate, + [ModelType.projectcode]: RenderProjectCode, + [ModelType.purchaseorder]: RenderPurchaseOrder, + [ModelType.purchaseorderlineitem]: RenderPurchaseOrder, + [ModelType.returnorder]: RenderReturnOrder, + [ModelType.returnorderlineitem]: RenderReturnOrderLineItem, + [ModelType.salesorder]: RenderSalesOrder, + [ModelType.salesordershipment]: RenderSalesOrderShipment, + [ModelType.transferorder]: RenderTransferOrder, + [ModelType.transferorderlineitem]: RenderTransferOrderLineItem, + [ModelType.stocklocation]: RenderStockLocation, + [ModelType.stocklocationtype]: RenderStockLocationType, + [ModelType.stockitem]: RenderStockItem, + [ModelType.stockhistory]: RenderStockItem, + [ModelType.supplierpart]: RenderSupplierPart, + [ModelType.user]: RenderUser, + [ModelType.group]: RenderGroup, + [ModelType.importsession]: RenderImportSession, + [ModelType.reporttemplate]: RenderReportTemplate, + [ModelType.labeltemplate]: RenderLabelTemplate, + [ModelType.pluginconfig]: RenderPlugin, + [ModelType.contenttype]: RenderContentType, + [ModelType.selectionlist]: RenderSelectionList, + [ModelType.selectionentry]: RenderSelectionEntry, + [ModelType.error]: RenderError, + [ModelType.tag]: RenderTag +}); diff --git a/src/frontend/src/components/tables/AppRowActions.tsx b/src/frontend/src/components/tables/AppRowActions.tsx new file mode 100644 index 0000000000..7500c141d8 --- /dev/null +++ b/src/frontend/src/components/tables/AppRowActions.tsx @@ -0,0 +1,22 @@ +import { + type RowAction, + RowViewAction, + type RowViewProps +} from '@lib/components/RowActions'; + +import { openGlobalPreview } from '../../states/PreviewDrawerState'; +import { useUserSettingsState } from '../../states/SettingsStates'; + +/* + * An internal wrapper for the RowViewAction component, which adds support for the global preview panel. + */ +export function AppRowViewAction(props: RowViewProps): RowAction { + return RowViewAction({ + ...props, + isPreviewEnabled: () => + useUserSettingsState.getState().isSet('ENABLE_PREVIEW_PANEL'), + openPreview: (modelType, modelId) => { + openGlobalPreview(modelType, modelId); + } + }); +} diff --git a/src/frontend/src/components/tables/InvenTreeTable.tsx b/src/frontend/src/components/tables/InvenTreeTable.tsx index ae56b28219..43f70b70b9 100644 --- a/src/frontend/src/components/tables/InvenTreeTable.tsx +++ b/src/frontend/src/components/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'; @@ -105,6 +106,10 @@ export function InvenTreeTableInternal>({ const userSettings = useUserSettingsState(); + const showPreviewPanel = useMemo(() => { + return userSettings.isSet('ENABLE_PREVIEW_PANEL'); + }, [userSettings]); + const stickyTableHeader = useMemo(() => { return userSettings.isSet('STICKY_TABLE_HEADER'); }, [userSettings]); @@ -700,6 +705,18 @@ export function InvenTreeTableInternal>({ tableState.setRecords(tableData ?? apiData ?? []); }, [tableData, apiData]); + const previewDrawer = usePreviewDrawerState(); + + // Callback to display "preview" view for a row (if available) + const showRowPreview = useCallback( + (pk: string | number) => { + if (tableProps.modelType && pk) { + previewDrawer.openPreview(tableProps.modelType, Number(pk)); + } + }, + [tableProps.modelType] + ); + // Callback when a cell is clicked const handleCellClick = useCallback( ({ @@ -732,11 +749,16 @@ export function InvenTreeTableInternal>({ cancelEvent(event); // If a model type is provided, navigate to the detail view for that model const url = getDetailUrl(tableProps.modelType, pk); - navigateToLink(url, navigate, event); + + if (!showPreviewPanel || eventModified(event as any)) { + navigateToLink(url, navigate, event); + } else { + showRowPreview(pk); + } } } }, - [props.onRowClick, props.onCellClick] + [props.onRowClick, props.onCellClick, showPreviewPanel] ); const supportsContextMenu = useMemo(() => { @@ -796,7 +818,11 @@ export function InvenTreeTableInternal>({ 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/pages/Index/Settings/UserSettings.tsx b/src/frontend/src/pages/Index/Settings/UserSettings.tsx index ae71430928..64a3d2b8fe 100644 --- a/src/frontend/src/pages/Index/Settings/UserSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/UserSettings.tsx @@ -55,6 +55,7 @@ export default function UserSettings() { 'ICONS_IN_NAVBAR', 'STICKY_HEADER', 'STICKY_TABLE_HEADER', + 'ENABLE_PREVIEW_PANEL', 'SHOW_SPOTLIGHT', 'BARCODE_IN_FORM_FIELDS', 'DATE_DISPLAY_FORMAT', diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index e52a818a2a..0805fbcc49 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 ( @@ -237,239 +231,19 @@ export default function BuildDetail() { refetchOnMount: true }); - const { instance: partRequirements, instanceQuery: partRequirementsQuery } = - useInstance({ - endpoint: ApiEndpoints.part_requirements, - pk: build?.part, - hasPrimaryKey: true, - defaultValue: {} - }); - - const detailsPanel = useMemo(() => { - if (instanceQuery.isFetching) { - return ; - } - - 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', @@ -599,7 +373,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 b159930beb..6fe4ea4de7 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,403 +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: 'consumable', - label: t`Consumable Part` - }, - { - type: 'boolean', - name: 'starred', - label: t`Subscribed`, - icon: 'bell' - } - ]; - - // Bottom right - other part information - const br: DetailsField[] = [ - { - type: 'string', - name: 'creation_date', - label: t`Creation Date` - }, - { - type: 'string', - name: 'creation_user', - label: t`Created By`, - badge: 'user', - icon: 'user', - hidden: !part.creation_user - }, - { - type: 'string', - name: 'responsible', - label: t`Responsible`, - badge: 'owner', - hidden: !part.responsible - }, - { - name: 'default_expiry', - label: t`Default Expiry`, - hidden: !part.default_expiry, - icon: 'calendar', - type: 'string', - value_formatter: () => { - return `${part.default_expiry} ${t`days`}`; - } - } - ]; - - // Add in price range data - if (part.pricing_min || part.pricing_max) { - br.push({ - type: 'string', - name: 'pricing', - label: t`Price Range`, - value_formatter: () => { - return formatPriceRange(part.pricing_min, part.pricing_max); - } - }); - } - - br.push({ - type: 'string', - name: 'latest_serial_number', - label: t`Latest Serial Number`, - hidden: !part.trackable || !data.latest_serial_number, - icon: 'serial' - }); - - return part ? ( - + 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(() => { @@ -680,7 +284,14 @@ export default function PartDetail() { name: 'details', label: t`Part Details`, icon: , - content: detailsPanel + content: ( + + ) }, { name: 'stock', @@ -908,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 f93b0628c1..f8a5f05007 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -1,18 +1,6 @@ import { t } from '@lingui/core/macro'; +import { Accordion, Skeleton, Stack } from '@mantine/core'; import { - Accordion, - Button, - Grid, - Group, - Skeleton, - Space, - Stack, - Text, - Tooltip -} from '@mantine/core'; -import { - IconArrowLeft, - IconArrowRight, IconArrowsSplit, IconBookmark, IconBoxPadding, @@ -20,7 +8,6 @@ import { IconHistory, IconInfoCircle, IconPackages, - IconSearch, IconShoppingCart, IconSitemap, IconTransform @@ -29,27 +16,19 @@ import { useQuery } from '@tanstack/react-query'; import { type ReactNode, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { ActionButton } from '@lib/components/ActionButton'; import { StylishText } from '@lib/components/StylishText'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; import { getDetailUrl, getOverviewUrl } from '@lib/functions/Navigation'; -import { TagsList } from '@lib/index'; import type { ApiFormFieldSet, StockOperationProps } from '@lib/types/Forms'; import type { PanelType } from '@lib/types/Panel'; import { notifications } from '@mantine/notifications'; import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog'; import AdminButton from '../../components/buttons/AdminButton'; import { PrintingActions } from '../../components/buttons/PrintingActions'; -import { - type DetailsField, - DetailsTable -} from '../../components/details/Details'; import DetailsBadge from '../../components/details/DetailsBadge'; -import { DetailsImage } from '../../components/details/DetailsImage'; -import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { ActionDropdown, BarcodeActionDropdown, @@ -68,10 +47,9 @@ import LocateItemButton from '../../components/plugins/LocateItemButton'; import { StatusRenderer } from '../../components/render/StatusRenderer'; import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; import { useApi } from '../../contexts/ApiContext'; -import { formatCurrency, formatDecimal } from '../../defaults/formatters'; +import { formatDecimal } from '../../defaults/formatters'; import { useDisassembleStockItem, - useFindSerialNumberForm, useStockFields, useStockItemSerializeFields } from '../../forms/StockForms'; @@ -92,6 +70,7 @@ import { StockItemTable } from '../../tables/stock/StockItemTable'; import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable'; import { StockTrackingTable } from '../../tables/stock/StockTrackingTable'; import TransferOrderAllocationTable from '../../tables/stock/TransferOrderAllocationTable'; +import { StockDetailsPanel } from './StockDetailsPanel'; export default function StockDetail() { const { id } = useParams(); @@ -126,367 +105,13 @@ export default function StockDetail() { } }); - const { instance: part, instanceQuery: partQuery } = useInstance({ + const { instance: part } = useInstance({ endpoint: ApiEndpoints.part_list, pk: stockitem?.part, hasPrimaryKey: true, defaultValue: {} }); - const { instance: serialNumbers, instanceQuery: serialNumbersQuery } = - useInstance({ - endpoint: ApiEndpoints.stock_serial_info, - pk: id - }); - - const findBySerialNumber = useFindSerialNumberForm({ - partId: stockitem.part - }); - - const detailsPanel = useMemo(() => { - const data = { ...stockitem }; - const part = stockitem?.part_detail ?? {}; - - data.available_stock = Math.max(0, data.quantity - data.allocated); - - if (instanceQuery.isFetching) { - return ; - } - - // 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 ( @@ -557,7 +182,14 @@ export default function StockDetail() { name: 'details', label: t`Stock Details`, icon: , - content: detailsPanel + content: ( + + ) }, { name: 'tracking', @@ -688,8 +320,6 @@ export default function StockDetail() { showBuildAllocations, showInstalledItems, stockitem, - serialNumbers, - serialNumbersQuery, id, user ]); @@ -1082,7 +712,6 @@ export default function StockDetail() { return ( <> - {findBySerialNumber.modal} {scanIntoLocation.dialog} 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..3768272511 --- /dev/null +++ b/src/frontend/src/states/PreviewDrawerState.tsx @@ -0,0 +1,98 @@ +import type { ModelType } from '@lib/enums/ModelType'; +import type { PreviewType } from '@lib/types/Preview'; +import { create } from 'zustand'; + +type PreviewDrawerId = number | string | undefined; + +interface PreviewDrawerStateProps { + isOpen: boolean; + modelType?: ModelType; + id?: number | string; + instance?: any; + filters?: Record; + preview?: PreviewType; + targetUrl?: string; + onCloseCallback?: () => void; + openPreview: ( + modelType: ModelType, + id: PreviewDrawerId, + instance?: any, + preview?: PreviewType, + onClose?: () => void, + targetUrl?: string, + filters?: Record + ) => void; + closePreview: () => void; +} + +export const usePreviewDrawerState = create()( + (set, get) => ({ + isOpen: false, + modelType: undefined, + id: undefined, + instance: undefined, + filters: undefined, + preview: undefined, + targetUrl: undefined, + onCloseCallback: undefined, + + openPreview: ( + modelType: ModelType, + id: number | string | undefined, + instance?: any, + preview?: PreviewType, + onClose?: () => void, + targetUrl?: string, + filters?: Record + ) => { + set({ + modelType, + id, + instance, + preview, + targetUrl, + filters, + isOpen: true, + onCloseCallback: onClose + }); + }, + + closePreview: () => { + const callback = get().onCloseCallback; + + set({ + isOpen: false, + instance: undefined, + id: undefined, + preview: undefined, + targetUrl: undefined, + filters: undefined, + onCloseCallback: undefined + }); + + callback?.(); + } + }) +); + +export function openGlobalPreview( + modelType: ModelType, + id?: number | string | undefined, + instance?: any, + preview?: PreviewType, + onClose?: () => void, + targetUrl?: string, + filters?: Record +) { + usePreviewDrawerState + .getState() + .openPreview(modelType, id, instance, preview, onClose, targetUrl, filters); +} + +export function closeGlobalPreview() { + usePreviewDrawerState.getState().closePreview(); +} + +export function getGlobalPreviewState() { + return usePreviewDrawerState.getState(); +} diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index 576d8b31bd..bd2c53a1df 100644 --- a/src/frontend/src/tables/build/BuildLineTable.tsx +++ b/src/frontend/src/tables/build/BuildLineTable.tsx @@ -1,6 +1,6 @@ import { ActionButton } from '@lib/components/ActionButton'; import { ProgressBar } from '@lib/components/ProgressBar'; -import { RowEditAction, RowViewAction } from '@lib/components/RowActions'; +import { RowEditAction } from '@lib/components/RowActions'; import { YesNoButton } from '@lib/components/YesNoButton'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; @@ -37,6 +37,8 @@ import { } from '../../components/tables/ColumnRenderers'; import { PartCategoryFilter } from '../../components/tables/Filter'; import { InvenTreeTable } from '../../components/tables/InvenTreeTable'; + +import { AppRowViewAction } from '../../components/tables/AppRowActions'; import RowExpansionIcon from '../../components/tables/RowExpansionIcon'; import { TableHoverCard } from '../../components/tables/TableHoverCard'; import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; @@ -132,7 +134,7 @@ export function BuildLineSubTable({ onDeleteAllocation?.(record.pk); } }, - RowViewAction({ + AppRowViewAction({ title: t`View Stock Item`, modelType: ModelType.stockitem, modelId: record.stock_item, @@ -866,7 +868,7 @@ export default function BuildLineTable({ newBuildOrder.open(); } }, - RowViewAction({ + AppRowViewAction({ title: t`View Part`, modelType: ModelType.part, modelId: record.part, diff --git a/src/frontend/src/tables/company/CompanyTable.tsx b/src/frontend/src/tables/company/CompanyTable.tsx index efdb1d0712..f4705f363b 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 { @@ -23,6 +23,8 @@ import { useCreateApiFormModal, useEditApiFormModal } from '../../hooks/UseForm'; +import { usePreviewDrawerState } from '../../states/PreviewDrawerState'; +import { useUserSettingsState } from '../../states/SettingsStates'; import { useUserState } from '../../states/UserState'; /** @@ -49,6 +51,12 @@ export function CompanyTable({ const navigate = useNavigate(); const user = useUserState(); + const previewDrawer = usePreviewDrawerState(); + const userSettings = useUserSettingsState(); + + const showPreviewPanel = useMemo(() => { + return userSettings.isSet('ENABLE_PREVIEW_PANEL'); + }, [userSettings]); const columns = useMemo(() => { return [ @@ -166,10 +174,20 @@ export function CompanyTable({ params: { ...params }, - onRowClick: (record: any, index: number, event: any) => { - if (record.pk) { - const base = path ?? 'company'; - navigateToLink(`/${base}/${record.pk}`, navigate, event); + onRowClick: (record: any, _index: number, event: any) => { + if (!record.pk) return; + const url = `/${path ?? 'company'}/${record.pk}`; + if (!showPreviewPanel || eventModified(event as any)) { + navigateToLink(url, navigate, event); + } else { + previewDrawer.openPreview( + ModelType.company, + Number(record.pk), + undefined, + undefined, + undefined, + url + ); } }, modelType: ModelType.company, diff --git a/src/frontend/src/tables/general/BarcodeScanTable.tsx b/src/frontend/src/tables/general/BarcodeScanTable.tsx index 578749dd46..97bf63f92e 100644 --- a/src/frontend/src/tables/general/BarcodeScanTable.tsx +++ b/src/frontend/src/tables/general/BarcodeScanTable.tsx @@ -1,5 +1,5 @@ import { ActionButton } from '@lib/components/ActionButton'; -import { type RowAction, RowViewAction } from '@lib/components/RowActions'; +import type { RowAction } from '@lib/components/RowActions'; import useTable from '@lib/hooks/UseTable'; import type { TableColumn } from '@lib/types/Tables'; import { t } from '@lingui/core/macro'; @@ -9,6 +9,8 @@ import { useNavigate } from 'react-router-dom'; import type { BarcodeScanItem } from '../../components/barcodes/BarcodeScanItem'; import { RenderInstance } from '../../components/render/Instance'; import { InvenTreeTable } from '../../components/tables/InvenTreeTable'; + +import { AppRowViewAction } from '../../components/tables/AppRowActions'; import { useUserState } from '../../states/UserState'; /** @@ -71,7 +73,7 @@ export default function BarcodeScanTable({ if (record.model && record.pk && record.instance) { actions.push( - RowViewAction({ + AppRowViewAction({ title: t`View Item`, modelId: record.instance?.pk, modelType: record.model, diff --git a/src/frontend/src/tables/general/ParametricDataTable.tsx b/src/frontend/src/tables/general/ParametricDataTable.tsx index 780ffd871d..ca8b77fd3b 100644 --- a/src/frontend/src/tables/general/ParametricDataTable.tsx +++ b/src/frontend/src/tables/general/ParametricDataTable.tsx @@ -1,5 +1,9 @@ import { cancelEvent } from '@lib/functions/Events'; -import { getDetailUrl, navigateToLink } from '@lib/functions/Navigation'; +import { + eventModified, + getDetailUrl, + navigateToLink +} from '@lib/functions/Navigation'; import useTable from '@lib/hooks/UseTable'; import { ApiEndpoints, @@ -28,6 +32,7 @@ import { useCreateApiFormModal, useEditApiFormModal } from '../../hooks/UseForm'; +import { openGlobalPreview } from '../../states/PreviewDrawerState'; import { useUserState } from '../../states/UserState'; import { PARAMETER_FILTER_OPERATORS, @@ -471,9 +476,12 @@ export default function ParametricDataTable({ const col = column as any; onParameterClick(col.extra.template, record); } else if (record?.pk) { - // Navigate through to the detail page const url = getDetailUrl(modelType, record.pk); - navigateToLink(url, navigate, event); + if (eventModified(event as any)) { + navigateToLink(url, navigate, event); + } else { + openGlobalPreview(modelType, record.pk); + } } } }} diff --git a/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx b/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx index 2bd3ddbeef..13508f0fae 100644 --- a/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx +++ b/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx @@ -5,7 +5,6 @@ import { useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { ProgressBar } from '@lib/components/ProgressBar'; -import { RowViewAction } from '@lib/components/RowActions'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; @@ -22,6 +21,8 @@ import { } from '../../components/tables/ColumnRenderers'; import { IncludeVariantsFilter } from '../../components/tables/Filter'; import { InvenTreeTable } from '../../components/tables/InvenTreeTable'; + +import { AppRowViewAction } from '../../components/tables/AppRowActions'; import RowExpansionIcon from '../../components/tables/RowExpansionIcon'; import { useUserState } from '../../states/UserState'; import { BuildLineSubTable } from '../build/BuildLineTable'; @@ -120,7 +121,7 @@ export default function PartBuildAllocationsTable({ const rowActions = useCallback( (record: any) => { return [ - RowViewAction({ + AppRowViewAction({ title: t`View Build Order`, modelType: ModelType.build, modelId: record.build, diff --git a/src/frontend/src/tables/part/PartSalesAllocationsTable.tsx b/src/frontend/src/tables/part/PartSalesAllocationsTable.tsx index eefa093079..d457a66f3f 100644 --- a/src/frontend/src/tables/part/PartSalesAllocationsTable.tsx +++ b/src/frontend/src/tables/part/PartSalesAllocationsTable.tsx @@ -5,7 +5,6 @@ import { useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { ProgressBar } from '@lib/components/ProgressBar'; -import { RowViewAction } from '@lib/components/RowActions'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; @@ -22,6 +21,8 @@ import { } from '../../components/tables/ColumnRenderers'; import { IncludeVariantsFilter } from '../../components/tables/Filter'; import { InvenTreeTable } from '../../components/tables/InvenTreeTable'; + +import { AppRowViewAction } from '../../components/tables/AppRowActions'; import RowExpansionIcon from '../../components/tables/RowExpansionIcon'; import { useUserState } from '../../states/UserState'; import SalesOrderAllocationTable from '../sales/SalesOrderAllocationTable'; @@ -84,7 +85,7 @@ export default function PartSalesAllocationsTable({ const rowActions = useCallback( (record: any) => { return [ - RowViewAction({ + AppRowViewAction({ title: t`View Sales Order`, modelType: ModelType.salesorder, modelId: record.order, diff --git a/src/frontend/src/tables/part/PartTestTemplateTable.tsx b/src/frontend/src/tables/part/PartTestTemplateTable.tsx index e50a0da5cc..734263e7ac 100644 --- a/src/frontend/src/tables/part/PartTestTemplateTable.tsx +++ b/src/frontend/src/tables/part/PartTestTemplateTable.tsx @@ -9,8 +9,7 @@ import { AddItemButton } from '@lib/components/AddItemButton'; import { type RowAction, RowDeleteAction, - RowEditAction, - RowViewAction + RowEditAction } from '@lib/components/RowActions'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; @@ -26,6 +25,8 @@ import { DescriptionColumn } from '../../components/tables/ColumnRenderers'; import { InvenTreeTable } from '../../components/tables/InvenTreeTable'; + +import { AppRowViewAction } from '../../components/tables/AppRowActions'; import { TableHoverCard } from '../../components/tables/TableHoverCard'; import { useCreateApiFormModal, @@ -208,7 +209,7 @@ export default function PartTestTemplateTable({ if (record.part != partId) { // This test is defined for a parent part return [ - RowViewAction({ + AppRowViewAction({ title: t`View Parent Part`, modelType: ModelType.part, modelId: record.part, diff --git a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx index f479c5e5bf..773ca49af2 100644 --- a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx +++ b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx @@ -10,8 +10,7 @@ import { type RowAction, RowDeleteAction, RowDuplicateAction, - RowEditAction, - RowViewAction + RowEditAction } from '@lib/components/RowActions'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; @@ -36,6 +35,8 @@ import { TargetDateColumn } from '../../components/tables/ColumnRenderers'; import { InvenTreeTable } from '../../components/tables/InvenTreeTable'; + +import { AppRowViewAction } from '../../components/tables/AppRowActions'; import { TableHoverCard } from '../../components/tables/TableHoverCard'; import { formatCurrency } from '../../defaults/formatters'; import { dataImporterSessionFields } from '../../forms/ImporterForms'; @@ -376,7 +377,7 @@ export function PurchaseOrderLineItemTable({ deleteLine.open(); } }), - RowViewAction({ + AppRowViewAction({ hidden: !record.build_order, title: t`View Build Order`, modelType: ModelType.build, diff --git a/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx b/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx index 59ae5495f8..8615a58832 100644 --- a/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx @@ -2,11 +2,7 @@ import { t } from '@lingui/core/macro'; import { useCallback, useMemo, useState } from 'react'; import { ActionButton } from '@lib/components/ActionButton'; -import { - type RowAction, - RowEditAction, - RowViewAction -} from '@lib/components/RowActions'; +import { type RowAction, RowEditAction } from '@lib/components/RowActions'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; @@ -31,6 +27,8 @@ import { StockLocationFilter } from '../../components/tables/Filter'; import { InvenTreeTable } from '../../components/tables/InvenTreeTable'; + +import { AppRowViewAction } from '../../components/tables/AppRowActions'; import { formatDate } from '../../defaults/formatters'; import { useSalesOrderAllocationFields } from '../../forms/SalesOrderForms'; import { @@ -259,7 +257,7 @@ export default function SalesOrderAllocationTable({ deleteAllocation.open(); } }, - RowViewAction({ + AppRowViewAction({ tooltip: t`View Shipment`, title: t`View Shipment`, hidden: !record.shipment || !!shipmentId, diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx index 411c715de2..b165244112 100644 --- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx @@ -19,8 +19,7 @@ import { type RowAction, RowDeleteAction, RowDuplicateAction, - RowEditAction, - RowViewAction + RowEditAction } from '@lib/components/RowActions'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; @@ -42,6 +41,8 @@ import { RenderPartColumn } from '../../components/tables/ColumnRenderers'; import { InvenTreeTable } from '../../components/tables/InvenTreeTable'; + +import { AppRowViewAction } from '../../components/tables/AppRowActions'; import RowExpansionIcon from '../../components/tables/RowExpansionIcon'; import { TableHoverCard } from '../../components/tables/TableHoverCard'; import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; @@ -549,7 +550,7 @@ export default function SalesOrderLineItemTable({ deleteLine.open(); } }), - RowViewAction({ + AppRowViewAction({ title: t`View Part`, modelType: ModelType.part, modelId: record.part, diff --git a/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx b/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx index f2996948ce..db6381176e 100644 --- a/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx @@ -11,8 +11,7 @@ import { AddItemButton } from '@lib/components/AddItemButton'; import { type RowAction, RowCancelAction, - RowEditAction, - RowViewAction + RowEditAction } from '@lib/components/RowActions'; import { YesNoButton } from '@lib/components/YesNoButton'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; @@ -30,6 +29,8 @@ import { } from '../../components/tables/ColumnRenderers'; import { TagsFilter } from '../../components/tables/Filter'; import { InvenTreeTable } from '../../components/tables/InvenTreeTable'; + +import { AppRowViewAction } from '../../components/tables/AppRowActions'; import { useCheckShipmentForm, useCompleteShipmentForm, @@ -255,7 +256,7 @@ export default function SalesOrderShipmentTable({ deleteShipment.open(); } }), - RowViewAction({ + AppRowViewAction({ title: t`View Sales Order`, modelType: ModelType.salesorder, modelId: record.order, diff --git a/src/frontend/src/tables/stock/TransferOrderLineItemTable.tsx b/src/frontend/src/tables/stock/TransferOrderLineItemTable.tsx index 3f21fd5d4d..fb1534b6ea 100644 --- a/src/frontend/src/tables/stock/TransferOrderLineItemTable.tsx +++ b/src/frontend/src/tables/stock/TransferOrderLineItemTable.tsx @@ -1,8 +1,7 @@ import { RowDeleteAction, RowDuplicateAction, - RowEditAction, - RowViewAction + RowEditAction } from '@lib/components/RowActions'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { apiUrl } from '@lib/functions/Api'; @@ -39,6 +38,8 @@ import { RenderPartColumn } from '../../components/tables/ColumnRenderers'; import { InvenTreeTable } from '../../components/tables/InvenTreeTable'; + +import { AppRowViewAction } from '../../components/tables/AppRowActions'; import RowExpansionIcon from '../../components/tables/RowExpansionIcon'; import { TableHoverCard } from '../../components/tables/TableHoverCard'; import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; @@ -445,7 +446,7 @@ export default function TransferOrderLineItemTable({ deleteLine.open(); } }), - RowViewAction({ + AppRowViewAction({ title: t`View Part`, modelType: ModelType.part, modelId: record.part, diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index 8508c00060..1dbd45f086 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -14,6 +14,9 @@ import { showParametricView, showTableView } from '../helpers'; + +import { adminuser } from '../defaults'; + import { doCachedLogin } from '../login'; import { setPluginState, setSettingState } from '../settings'; @@ -676,7 +679,10 @@ test('Parts - Allocations', async ({ browser }) => { test('Parts - Pricing (Nothing, BOM)', async ({ browser }) => { // Part with no history - const page = await doCachedLogin(browser, { url: 'part/82/pricing' }); + const page = await doCachedLogin(browser, { + url: 'part/82/pricing', + user: adminuser + }); await page.getByText('Small plastic enclosure, black').waitFor(); await loadTab(page, 'Part Pricing'); @@ -713,6 +719,18 @@ test('Parts - Pricing (Nothing, BOM)', async ({ browser }) => { await page.getByRole('button', { name: 'Quantity Not sorted' }).waitFor(); await page.getByRole('button', { name: 'Unit Price Not sorted' }).waitFor(); + // View part details via detail drawer + await page + .getByRole('cell', { name: 'Thumbnail Blue Paint' }) + .first() + .click(); + await page.getByRole('link', { name: 'details-part-' }).first().waitFor(); + await page.getByText('Allocated to Build Orders').waitFor(); + await page.getByText('440[litres]').waitFor(); + + // Close the drawer with the escape key + await page.keyboard.press('Escape'); + // We expect some pricing data to be displayed await page .getByLabel('BOM Pricing') @@ -726,6 +744,10 @@ test('Parts - Pricing (Nothing, BOM)', async ({ browser }) => { .getByRole('table') .getByText('Wood Screw') .click(); + + // We need to navigate via the preview drawer + await page.getByRole('link', { name: 'details-part-98' }).click(); + await page.waitForURL('**/part/98/**'); }); diff --git a/src/frontend/tests/pages/pui_purchasing.spec.ts b/src/frontend/tests/pages/pui_purchasing.spec.ts index 6a3b1abf98..5ea23cb541 100644 --- a/src/frontend/tests/pages/pui_purchasing.spec.ts +++ b/src/frontend/tests/pages/pui_purchasing.spec.ts @@ -179,7 +179,7 @@ test('Purchase Orders - General', async ({ browser }) => { await page.waitForURL('**/purchasing/index/**'); await page.getByRole('cell', { name: 'PO0012' }).click(); - await page.waitForTimeout(200); + await page.waitForLoadState('networkidle'); await loadTab(page, 'Line Items'); await loadTab(page, 'Received Stock'); diff --git a/src/frontend/tests/pui_exporting.spec.ts b/src/frontend/tests/pui_exporting.spec.ts index dc32370560..f39d0b2e41 100644 --- a/src/frontend/tests/pui_exporting.spec.ts +++ b/src/frontend/tests/pui_exporting.spec.ts @@ -35,6 +35,17 @@ test('Exporting - Orders', async ({ browser }) => { // Download list of purchase order items await page.getByRole('cell', { name: 'PO0014' }).click(); + + // Click through to the PurchaseOrder from the detail tab + await page + .getByLabel('Purchase Order PO0014 (PCBWOY)') + .getByRole('cell', { name: 'Ordering some PCBs' }) + .waitFor(); + await page + .getByRole('link', { name: 'details-purchaseorder-14' }) + .first() + .click(); + await loadTab(page, 'Line Items'); await openExportDialog(page); await page.getByRole('button', { name: 'Export', exact: true }).click(); diff --git a/src/frontend/tests/pui_permissions.spec.ts b/src/frontend/tests/pui_permissions.spec.ts index 9abc356cb1..73d29b0d6c 100644 --- a/src/frontend/tests/pui_permissions.spec.ts +++ b/src/frontend/tests/pui_permissions.spec.ts @@ -75,6 +75,22 @@ test('Permissions - Reader', async ({ browser }) => { .first() .click(); + // Click on the link in the detail drawer + await page.getByText('Allocated to Build Orders').waitFor(); + await page.getByText('Component Part').waitFor(); + await page + .getByRole('link', { name: 'Chair', exact: true }) + .first() + .waitFor(); + await page + .getByRole('link', { name: 'Chairs', exact: true }) + .first() + .waitFor(); + await page + .getByRole('link', { name: 'details-part-108', exact: true }) + .first() + .click(); + await page .getByLabel('Part Details') .getByText('A chair - with blue paint')