[UI] Preview Drawer (#12117)

* Add <GlobalPreviewDrawer> component

* Allow plugins to interact

* provide either pk or instance data

* open preview drawer from calendar views

* Launch preview drawer directly from tables

* Add preview types

* Fix types

* CLear data on close

* Fix transition

* Allow caller to pass preview component

* AttributeGrid

* Use a table component

* Display info items

* Navigate to detail page from drawer

* Updates

* Refactor to use part detail view

* Display parameters

* Fix image caching

* Apply same to StockItem

* Remove old component

* Refactor details grid component

* Preview panel for PurchaseOrder model

* Implement for SalesOrder

* Support ReturnOrder

* SupplierPart and ManufacturerPart

* Support preview in parameteric tables

* support Company and BuildOrder

* Fix numbervalue

* BuildOrder and SalesOrderShipment

* location and category

* add user setting

* Fix typing

* Handle link clicks within preview drawer

* fixes

* Implement onClose callback

* Handle row click when panel preview is disabled

* Support custom detail links

* update playwright tests

* UI tweaks

* Add CHANGELOG entry

* docs

* define default API params per model type

* Split out more docs pages

* Simplify state ref

* Refactoring

* Simplify PreviewDrawer component

* observe user setting in calendar view

* Enable PreviewDrawer from RowViewAction

* Push render functions onto ModelInformation dict

* Fix lib compilation

* Refactor internal uses of RowViewAction

* typescript fixes

* Simplify PreviewDrawer component structure

* remove TODO placeholders
This commit is contained in:
Oliver 2026-07-12 20:11:27 +10:00 committed by GitHub
parent b42521f205
commit c5e81a7820
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
92 changed files with 4685 additions and 3221 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

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

View File

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

View File

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

View File

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

View File

@ -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`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ import {
} from '@tabler/icons-react';
import { type ReactNode, useMemo, useState } from 'react';
import { cancelEvent } from '../functions/Events';
import { getDetailUrl } from '../functions/Navigation';
import { eventModified, getDetailUrl } from '../functions/Navigation';
import { navigateToLink } from '../functions/Navigation';
import type { RowAction, RowViewProps } from '../types/Tables';
@ -23,8 +23,18 @@ export function RowViewAction(props: RowViewProps): RowAction {
color: undefined,
icon: <IconArrowRight />,
onClick: (event: any) => {
const url = getDetailUrl(props.modelType, props.modelId);
navigateToLink(url, props.navigate, event);
const showPreviewPanel = props.isPreviewEnabled?.() ?? false;
if (
!showPreviewPanel ||
eventModified(event as any) ||
!props.openPreview
) {
const url = getDetailUrl(props.modelType, props.modelId);
navigateToLink(url, props.navigate, event);
} else {
props.openPreview(props.modelType, props.modelId);
}
}
};
}

View File

@ -1,5 +1,6 @@
import { t } from '@lingui/core/macro';
import type { InvenTreeIconType } from '../types/Icons';
import type { InstanceRenderInterface } from '../types/Rendering';
import { ApiEndpoints } from './ApiEndpoints';
import type { ModelType } from './ModelType';
@ -13,6 +14,9 @@ export interface ModelInformationInterface {
pk_field?: string;
supports_barcode?: boolean;
icon: keyof InvenTreeIconType;
preview?: (props: { instance: any; modelId: number }) => any;
render?: (props: Readonly<InstanceRenderInterface>) => any;
default_query_params?: Record<string, any>;
}
export interface TranslatableModelInformationInterface
@ -25,6 +29,33 @@ export type ModelDict = {
[key in keyof typeof ModelType]: TranslatableModelInformationInterface;
};
type ModelPreviewKey = keyof typeof ModelType;
type ModelPreviewProps = { instance: any; modelId: number };
type ModelPreview = (props: ModelPreviewProps) => any;
type ModelRender = (props: Readonly<InstanceRenderInterface>) => any;
export function registerModelPreviews(
previews: Partial<Record<ModelPreviewKey, ModelPreview>>
) {
(Object.keys(previews) as ModelPreviewKey[]).forEach((model) => {
const preview = previews[model];
if (preview) {
ModelInformationDict[model].preview = preview;
}
});
}
export function registerModelRenderers(
renderers: Partial<Record<ModelPreviewKey, ModelRender>>
) {
(Object.keys(renderers) as ModelPreviewKey[]).forEach((model) => {
const renderer = renderers[model];
if (renderer) {
ModelInformationDict[model].render = renderer;
}
});
}
export const ModelInformationDict: ModelDict = {
part: {
label: () => t`Part`,
@ -64,7 +95,12 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.supplier_part_list,
admin_url: '/company/supplierpart/',
supports_barcode: true,
icon: 'supplier_part'
icon: 'supplier_part',
default_query_params: {
part_detail: true,
supplier_detail: true,
manufacturer_detail: true
}
},
manufacturerpart: {
label: () => t`Manufacturer Part`,
@ -74,7 +110,8 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.manufacturer_part_list,
admin_url: '/company/manufacturerpart/',
supports_barcode: true,
icon: 'manufacturers'
icon: 'manufacturers',
default_query_params: { part_detail: true, manufacturer_detail: true }
},
partcategory: {
label: () => t`Part Category`,
@ -93,7 +130,8 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.stock_item_list,
admin_url: '/stock/stockitem/',
supports_barcode: true,
icon: 'stock'
icon: 'stock',
default_query_params: { part_detail: true }
},
stocklocation: {
label: () => t`Stock Location`,
@ -125,7 +163,8 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.build_order_list,
admin_url: '/build/build/',
supports_barcode: true,
icon: 'build_order'
icon: 'build_order',
default_query_params: { part_detail: true }
},
buildline: {
label: () => t`Build Line`,
@ -164,7 +203,8 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.purchase_order_list,
admin_url: '/order/purchaseorder/',
supports_barcode: true,
icon: 'purchase_orders'
icon: 'purchase_orders',
default_query_params: { supplier_detail: true }
},
purchaseorderlineitem: {
label: () => t`Purchase Order Line`,
@ -180,7 +220,8 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.sales_order_list,
admin_url: '/order/salesorder/',
supports_barcode: true,
icon: 'sales_orders'
icon: 'sales_orders',
default_query_params: { customer_detail: true }
},
salesordershipment: {
label: () => t`Sales Order Shipment`,
@ -190,7 +231,8 @@ export const ModelInformationDict: ModelDict = {
admin_url: '/order/salesordershipment/',
api_endpoint: ApiEndpoints.sales_order_shipment_list,
supports_barcode: true,
icon: 'shipment'
icon: 'shipment',
default_query_params: { order_detail: true }
},
returnorder: {
label: () => t`Return Order`,
@ -200,7 +242,8 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.return_order_list,
admin_url: '/order/returnorder/',
supports_barcode: true,
icon: 'return_orders'
icon: 'return_orders',
default_query_params: { customer_detail: true }
},
returnorderlineitem: {
label: () => t`Return Order Line Item`,

View File

@ -69,6 +69,17 @@ export type ImporterDrawerContext = {
sessionId: () => number | null;
};
export type PreviewDrawerContext = {
open: (
modelType: ModelType,
id?: number,
instance?: any,
onClose?: () => void
) => void;
close: () => void;
isOpen: () => boolean;
};
/**
* A set of properties which are passed to a plugin,
* for rendering an element in the user interface.
@ -90,6 +101,7 @@ export type ImporterDrawerContext = {
* @param forms - A set of functions for opening various API forms (see ../components/Forms.tsx)
* @param tables - A set of functions for rendering API tables
* @param importer - A set of functions for controlling the global importer drawer (see ../components/importer/GlobalImporterDrawer.tsx)
* @param preview - A set of functions for controlling the global preview drawer (see ../components/previews/GlobalPreviewDrawer.tsx)
* @param model - The model type associated with the rendered component (if applicable)
* @param id - The ID (primary key) of the model instance for the plugin (if applicable)
* @param instance - The model instance data (if available)
@ -125,6 +137,7 @@ export type InvenTreePluginContext = {
};
tables: InvenTreeTablesContext<any>;
importer: ImporterDrawerContext;
preview: PreviewDrawerContext;
model?: ModelType | string;
id?: string | number | null;
instance?: any;

View File

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

View File

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

View File

@ -7,7 +7,11 @@ import { ModelInformationDict } from '@lib/enums/ModelInformation';
import type { ModelType } from '@lib/enums/ModelType';
import type { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl, navigateToLink } from '@lib/functions/Navigation';
import {
eventModified,
getDetailUrl,
navigateToLink
} from '@lib/functions/Navigation';
import type { TableFilter } from '@lib/types/Filters';
import { t } from '@lingui/core/macro';
import { ActionIcon, Group, Text } from '@mantine/core';
@ -22,6 +26,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));
}
};

View File

@ -280,7 +280,7 @@ function NumberValue(props: Readonly<FieldProps>) {
const value = props?.field_value;
// Convert to double
const numberValue = Number.parseFloat(value.toString());
const numberValue = Number.parseFloat(value?.toString() ?? '');
if (value === null || value === undefined) {
return <Text size='sm'>'---'</Text>;
@ -465,10 +465,12 @@ function CopyField({ value }: Readonly<{ value: string }>) {
export function DetailsTableField({
item,
field
field,
showIcons = true
}: Readonly<{
item: any;
field: DetailsField;
showIcons?: boolean;
}>) {
function getFieldType(type: string) {
switch (type) {
@ -502,10 +504,12 @@ export function DetailsTableField({
<Table.Tr style={{ verticalAlign: 'top' }}>
<Table.Td style={{ minWidth: 75, lineBreak: 'auto', flex: 2 }}>
<Group gap='xs' wrap='nowrap'>
<InvenTreeIcon
icon={field.icon ?? (field.name as keyof InvenTreeIconType)}
/>
<Text style={{ paddingLeft: 10 }}>{field.label}</Text>
{showIcons && (
<InvenTreeIcon
icon={field.icon ?? (field.name as keyof InvenTreeIconType)}
/>
)}
<Text style={{ paddingLeft: showIcons ? 10 : 0 }}>{field.label}</Text>
</Group>
</Table.Td>
<Table.Td
@ -525,15 +529,19 @@ export function DetailsTableField({
);
}
export function DetailsTable({
item,
fields,
title
}: Readonly<{
export interface DetailsTableProps {
item: any;
fields: DetailsField[];
title?: string;
}>) {
showIcons?: boolean;
}
export function DetailsTable({
item,
fields,
title,
showIcons = true
}: Readonly<DetailsTableProps>) {
const visibleFields = useMemo(() => {
return fields.filter((field) => !field.hidden);
}, [fields]);
@ -552,8 +560,13 @@ export function DetailsTable({
{title && <StylishText size='lg'>{title}</StylishText>}
<Table striped verticalSpacing={5} horizontalSpacing='sm'>
<Table.Tbody>
{visibleFields.map((field: DetailsField, index: number) => (
<DetailsTableField field={field} item={item} key={index} />
{visibleFields.map((field: DetailsField) => (
<DetailsTableField
field={field}
item={item}
showIcons={showIcons}
key={field.name}
/>
))}
</Table.Tbody>
</Table>

View File

@ -406,6 +406,10 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {
const { hovered, ref } = useHover();
const [img, setImg] = useState<string>(props.src ?? backup_image);
useEffect(() => {
setImg(props.src ?? backup_image);
}, [props.src]);
// Sets a new image, and triggers upstream instance refresh
const setAndRefresh = (image: string) => {
setImg(image);

View File

@ -1,7 +1,20 @@
import { Paper, SimpleGrid } from '@mantine/core';
import type React from 'react';
import { useMemo } from 'react';
import { DetailsTable, type DetailsTableProps } from './Details';
export type { DetailsTableProps };
export function ItemDetailsGrid({
children,
tables
}: React.PropsWithChildren<{ tables?: DetailsTableProps[] }>) {
const visibleTables = useMemo(
() => tables?.filter((t) => t.fields.some((f) => !f.hidden)) ?? [],
[tables]
);
export function ItemDetailsGrid(props: React.PropsWithChildren<{}>) {
return (
<Paper p='xs'>
<SimpleGrid
@ -10,7 +23,13 @@ export function ItemDetailsGrid(props: React.PropsWithChildren<{}>) {
spacing='xs'
verticalSpacing='xs'
>
{props.children}
{children}
{visibleTables.map((props) => (
<DetailsTable
key={props.fields.map((field) => field.name).join(',')}
{...props}
/>
))}
</SimpleGrid>
</Paper>
);

View File

@ -0,0 +1,54 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import { t } from '@lingui/core/macro';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useApi } from '../../contexts/ApiContext';
import type { DetailsField, DetailsTableProps } from './Details';
export function useParameterDetailsGrid({
model_type,
model_id
}: Readonly<{
model_type: ModelType;
model_id: number | string | undefined;
}>): DetailsTableProps {
const api = useApi();
const { data: parameters = [] } = useQuery({
queryKey: ['parameter-details', model_type, model_id],
enabled: !!model_id && !!model_type,
queryFn: () =>
api
.get(apiUrl(ApiEndpoints.parameter_list), {
params: { model_type, model_id, limit: 100 }
})
.then((res) => res.data?.results ?? [])
});
const { fields, item } = useMemo(() => {
const item: Record<string, string> = {};
const fields: DetailsField[] = parameters.map((param: any) => {
const key = `param_${param.pk}`;
const value =
param.data +
(param.template_detail?.units ? ` ${param.template_detail.units}` : '');
item[key] = value;
return {
type: 'string' as const,
name: key,
label: param.template_detail?.name ?? String(param.pk),
copy: true
};
});
return { fields, item };
}, [parameters]);
return {
title: t`Parameters`,
fields: fields,
item: item,
showIcons: false
};
}

View File

@ -29,6 +29,7 @@ import {
type PluginUIFeature,
PluginUIFeatureType
} from '../plugins/PluginUIFeature';
import GlobalPreviewDrawer from '../previews/GlobalPreviewDrawer';
import { Footer } from './Footer';
import { Header } from './Header';
@ -148,6 +149,7 @@ export default function LayoutComponent() {
)}
</Flex>
<GlobalImporterDrawer />
<GlobalPreviewDrawer />
</>
</ProtectedRoute>
);

View File

@ -46,6 +46,11 @@ import {
openGlobalImporter
} from '../../states/ImporterState';
import { usePluginState } from '../../states/PluginState';
import {
closeGlobalPreview,
getGlobalPreviewState,
openGlobalPreview
} from '../../states/PreviewDrawerState';
import { useServerApiState } from '../../states/ServerApiState';
import { EditApiForm } from '../forms/ApiForm';
import { Thumbnail } from '../images/Thumbnail';
@ -108,6 +113,12 @@ export const useInvenTreeContext = () => {
isOpen: () => getGlobalImporterState().isOpen,
sessionId: () => getGlobalImporterState().sessionId
},
preview: {
open: (modelType, id?, instance?, onClose?) =>
openGlobalPreview(modelType, id, instance, undefined, onClose),
close: () => closeGlobalPreview(),
isOpen: () => getGlobalPreviewState().isOpen
},
tables: {
renderTable: (props: InvenTreeTableRenderProps<any>) => (
<Suspense fallback={null}>

View File

@ -0,0 +1,28 @@
import { usePreviewDrawerState } from '../../states/PreviewDrawerState';
import { useUserSettingsState } from '../../states/SettingsStates';
import PreviewDrawer from './PreviewDrawer';
export default function GlobalPreviewDrawer() {
const enabled = useUserSettingsState((state) =>
state.isSet('ENABLE_PREVIEW_PANEL')
);
const drawer = usePreviewDrawerState((state) => state);
if (!enabled) {
return null;
}
return (
<PreviewDrawer
modelType={drawer.modelType}
id={drawer.id}
instance={drawer.instance}
filters={drawer.filters}
preview={drawer.preview}
targetUrl={drawer.targetUrl}
opened={drawer.isOpen}
onClose={drawer.closePreview}
/>
);
}

View File

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

View File

@ -0,0 +1,195 @@
import {
ActionIcon,
Anchor,
Divider,
Drawer,
Group,
LoadingOverlay,
Stack,
Tooltip
} from '@mantine/core';
import { StylishText } from '@lib/components/StylishText';
import { cancelEvent } from '@lib/functions/Events';
import {
eventModified,
getBaseUrl,
getDetailUrl,
navigateToLink
} from '@lib/functions/Navigation';
import type { ModelType } from '@lib/index';
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { IconArrowRight } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useInstance } from '../../hooks/UseInstance';
import { getModelInfo } from '../render/ModelType';
import './ModelPreviewShim';
import { FallbackPreviewComponent } from './models/Fallback';
export default function PreviewDrawer({
modelType,
id,
instance: providedInstance,
filters,
preview: providedPreview,
targetUrl,
opened,
onClose
}: Readonly<{
modelType?: ModelType;
id?: number | string;
instance?: any;
filters?: Record<string, any>;
preview?: PreviewType;
targetUrl?: string;
opened: boolean;
onClose: () => void;
}>) {
const navigate = useNavigate();
const modelInfo = useMemo(() => {
return modelType ? getModelInfo(modelType) : null;
}, [modelType]);
const apiEndpoint = modelInfo?.api_endpoint ?? undefined;
// Combine the default query params for the model type with any filters
// explicitly provided by the caller which opened the preview (which take
// precedence over the defaults).
const queryParams = useMemo(() => {
return {
...modelInfo?.default_query_params,
...filters
};
}, [modelInfo, filters]);
const { instance: fetchedInstance, instanceQuery } = useInstance({
endpoint: apiEndpoint!,
pk: id,
hasPrimaryKey: true,
defaultValue: {},
params: queryParams,
disabled: !!providedInstance || !modelType || !id
});
const instance = useMemo(() => {
return providedInstance ?? fetchedInstance;
}, [providedInstance, fetchedInstance]);
const previewComponent: PreviewType | null = useMemo(() => {
if (providedPreview) return providedPreview;
if (!modelType || !modelInfo || id == null) return null;
const component = modelInfo?.preview?.({
instance,
modelId: typeof id === 'string' ? Number(id) : id
});
if (component == null) {
return FallbackPreviewComponent({
modelInfo,
modelType,
modelId: id,
instance
});
}
return component;
}, [providedPreview, modelType, id, instance, modelInfo]);
useEffect(() => {
if (!opened) return;
const handler = (event: MouseEvent) => {
const anchor = (event.target as HTMLElement).closest('a');
if (anchor && !eventModified(event as any)) {
if (anchor.origin === window.location.origin) {
// Same-origin: prevent browser navigation and route internally
const href = anchor.pathname + anchor.search + anchor.hash;
cancelEvent(event);
onClose();
navigateToLink(href, navigate, event as any);
} else {
// External link: let browser open it, just close the drawer
onClose();
}
}
};
document.addEventListener('click', handler, true);
return () => document.removeEventListener('click', handler, true);
}, [onClose, opened]);
const primaryUrl = useMemo(() => {
if (targetUrl) return targetUrl;
if (modelType && id) return getDetailUrl(modelType, id);
return undefined;
}, [targetUrl, modelType, id]);
const primaryHref = useMemo(() => {
if (!primaryUrl) return undefined;
const base = `/${getBaseUrl()}`;
return primaryUrl.startsWith(base) ? primaryUrl : `${base}${primaryUrl}`;
}, [primaryUrl]);
const clickTitle = useCallback(
(event: any) => {
if (!primaryUrl) return;
if (!eventModified(event as any)) {
onClose();
}
navigateToLink(primaryUrl, navigate, event as any);
},
[primaryUrl, navigate]
);
const drawerTitle = useMemo(() => {
if (!previewComponent) return null;
const titleText = (
<StylishText size='lg'>{previewComponent.title}</StylishText>
);
if (!primaryHref) return titleText;
return (
<Anchor href={primaryHref} onClick={(e) => clickTitle(e)}>
<Group aria-label={`details-${modelType}-${id}`} gap='xs'>
<Tooltip label={t`View Details`} position='left'>
<ActionIcon variant='transparent' size='lg'>
<IconArrowRight />
</ActionIcon>
</Tooltip>
{titleText}
</Group>
</Anchor>
);
}, [previewComponent, primaryHref, modelType, id, clickTitle]);
return (
<Drawer
position='right'
size='xl'
title={drawerTitle}
opened={opened}
onClose={onClose}
withCloseButton
transitionProps={{
transition: 'slide-left',
duration: 300,
timingFunction: 'ease'
}}
>
<Stack gap='xs'>
{previewComponent && (
<>
<Divider />
<LoadingOverlay visible={instanceQuery.isFetching} />
{previewComponent.preview}
</>
)}
</Stack>
</Drawer>
);
}

View File

@ -0,0 +1,25 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { BuildOrderDetailsPanel } from '../../../pages/build/BuildOrderDetailsPanel';
export function BuildOrderPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const part = instance?.part_detail?.full_name ?? instance?.part_detail?.name;
const ref = instance?.reference ?? `#${modelId}`;
let title = `${t`Build Order`} ${ref}`;
if (part) {
title += ` (${part})`;
}
return {
title,
preview: <BuildOrderDetailsPanel instance={instance} />
};
}

View File

@ -0,0 +1,18 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { CompanyDetailsPanel } from '../../../pages/company/CompanyDetailsPanel';
export function CompanyPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const name = instance?.name ?? `#${modelId}`;
return {
title: `${t`Company`} - ${name}`,
preview: <CompanyDetailsPanel instance={instance} />
};
}

View File

@ -0,0 +1,31 @@
import type { ModelInformationInterface } from '@lib/enums/ModelInformation';
import type { ModelType } from '@lib/enums/ModelType';
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { Alert, Text } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';
export function FallbackPreviewComponent({
modelInfo,
modelType,
modelId,
instance
}: {
modelInfo: ModelInformationInterface;
modelType: ModelType;
modelId: number | string;
instance: any;
}): PreviewType {
return {
title: `${modelInfo.label} #${modelId}`,
preview: (
<Alert
color='red'
title={t`No preview available`}
icon={<IconExclamationCircle />}
>
<Text>{t`No preview available for this model.`}</Text>
</Alert>
)
};
}

View File

@ -0,0 +1,26 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { ManufacturerPartDetailsPanel } from '../../../pages/company/ManufacturerPartDetailsPanel';
export function ManufacturerPartPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const manufacturer =
instance?.manufacturer_detail?.name ?? instance?.manufacturer_name;
const mpn = instance?.MPN ?? `#${modelId}`;
let title = `${t`Manufacturer Part`} ${mpn}`;
if (manufacturer) {
title += ` (${manufacturer})`;
}
return {
title,
preview: <ManufacturerPartDetailsPanel instance={instance} />
};
}

View File

@ -0,0 +1,18 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { PartCategoryDetailsPanel } from '../../../pages/part/PartCategoryDetailsPanel';
export function PartCategoryPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const name = `${t`Part Category`} - ${instance?.name ?? `#${modelId}`}`;
return {
title: name,
preview: <PartCategoryDetailsPanel instance={instance} />
};
}

View File

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

View File

@ -0,0 +1,25 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { PurchaseOrderDetailsPanel } from '../../../pages/purchasing/PurchaseOrderDetailsPanel';
export function PurchaseOrderPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const supplier = instance?.supplier_detail?.name ?? instance?.supplier_name;
const ref = instance?.reference ?? `#${modelId}`;
let title = `${t`Purchase Order`} ${ref}`;
if (supplier) {
title += ` (${supplier})`;
}
return {
title: title,
preview: <PurchaseOrderDetailsPanel instance={instance} />
};
}

View File

@ -0,0 +1,25 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { ReturnOrderDetailsPanel } from '../../../pages/sales/ReturnOrderDetailsPanel';
export function ReturnOrderPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const customer = instance?.customer_detail?.name ?? instance?.customer_name;
const ref = instance?.reference ?? `#${modelId}`;
let title = `${t`Return Order`} ${ref}`;
if (customer) {
title += ` (${customer})`;
}
return {
title,
preview: <ReturnOrderDetailsPanel instance={instance} />
};
}

View File

@ -0,0 +1,25 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { SalesOrderDetailsPanel } from '../../../pages/sales/SalesOrderDetailsPanel';
export function SalesOrderPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const customer = instance?.customer_detail?.name ?? instance?.customer_name;
const ref = instance?.reference ?? `#${modelId}`;
let title = `${t`Sales Order`} ${ref}`;
if (customer) {
title += ` (${customer})`;
}
return {
title,
preview: <SalesOrderDetailsPanel instance={instance} />
};
}

View File

@ -0,0 +1,25 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { SalesOrderShipmentDetailsPanel } from '../../../pages/sales/SalesOrderShipmentDetailsPanel';
export function SalesOrderShipmentPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const order = instance?.order_detail?.reference;
const ref = instance?.reference ?? `#${modelId}`;
let title = `${t`Shipment`} ${ref}`;
if (order) {
title += ` (${order})`;
}
return {
title,
preview: <SalesOrderShipmentDetailsPanel instance={instance} />
};
}

View File

@ -0,0 +1,18 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { StockLocationDetailsPanel } from '../../../pages/stock/StockLocationDetailsPanel';
export function StockLocationPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const name = `${t`Stock Location`} - ${instance?.name ?? `#${modelId}`}`;
return {
title: name,
preview: <StockLocationDetailsPanel instance={instance} />
};
}

View File

@ -0,0 +1,30 @@
import { formatDecimal } from '@lib/functions/Formatting';
import type { PreviewType } from '@lib/types/Preview';
import { StockDetailsPanel } from '../../../pages/stock/StockDetailsPanel';
export function StockPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const part = instance?.part_detail;
let title = `Stock Item #${modelId}`;
if (part) {
title = part?.full_name ?? part?.name;
if (instance.serial) {
title += ` (# ${instance.serial})`;
} else {
title += ` (x ${formatDecimal(instance.quantity)})`;
}
}
return {
title: title,
preview: <StockDetailsPanel instance={instance} />
};
}

View File

@ -0,0 +1,25 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { SupplierPartDetailsPanel } from '../../../pages/company/SupplierPartDetailsPanel';
export function SupplierPartPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const supplier = instance?.supplier_detail?.name ?? instance?.supplier_name;
const sku = instance?.SKU ?? `#${modelId}`;
let title = `${t`Supplier Part`} ${sku}`;
if (supplier) {
title += ` (${supplier})`;
}
return {
title,
preview: <SupplierPartDetailsPanel instance={instance} />
};
}

View File

@ -0,0 +1,18 @@
import type { PreviewType } from '@lib/types/Preview';
import { t } from '@lingui/core/macro';
import { TransferOrderDetailsPanel } from '../../../pages/stock/TransferOrderDetailsPanel';
export function TransferOrderPreviewComponent({
instance,
modelId
}: Readonly<{
instance: any;
modelId: number;
}>): PreviewType {
const ref = instance?.reference ?? `#${modelId}`;
return {
title: `${t`Transfer Order`} ${ref}`,
preview: <TransferOrderDetailsPanel instance={instance} />
};
}

View File

@ -17,11 +17,10 @@ import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useCallback, useMemo } from 'react';
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import { ModelType } from '@lib/enums/ModelType';
import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import type {
InstanceRenderInterface,
ModelRendererDict,
RemoteInstanceProps,
RenderInlineModelProps,
RenderInstanceProps
@ -39,107 +38,12 @@ import { useApi } from '../../contexts/ApiContext';
import { usePluginState } from '../../states/PluginState';
import { useUserSettingsState } from '../../states/SettingsStates';
import { Thumbnail } from '../images/Thumbnail';
import { RenderBuildItem, RenderBuildLine, RenderBuildOrder } from './Build';
import {
RenderAddress,
RenderCompany,
RenderContact,
RenderManufacturerPart,
RenderSupplierPart
} from './Company';
import {
RenderContentType,
RenderError,
RenderImportSession,
RenderParameter,
RenderParameterTemplate,
RenderProjectCode,
RenderSelectionEntry,
RenderSelectionList,
RenderTag
} from './Generic';
import {
RenderPurchaseOrder,
RenderReturnOrder,
RenderReturnOrderLineItem,
RenderSalesOrder,
RenderSalesOrderShipment,
RenderTransferOrder,
RenderTransferOrderLineItem
} from './Order';
import { RenderPart, RenderPartCategory, RenderPartTestTemplate } from './Part';
import { RenderPlugin } from './Plugin';
import { RenderLabelTemplate, RenderReportTemplate } from './Report';
import {
RenderStockItem,
RenderStockLocation,
RenderStockLocationType
} from './Stock';
import { RenderGroup, RenderOwner, RenderUser } from './User';
/**
* Lookup table for rendering a model instance
*/
export const RendererLookup: ModelRendererDict = {
[ModelType.address]: RenderAddress,
[ModelType.build]: RenderBuildOrder,
[ModelType.buildline]: RenderBuildLine,
[ModelType.builditem]: RenderBuildItem,
[ModelType.company]: RenderCompany,
[ModelType.contact]: RenderContact,
[ModelType.parameter]: RenderParameter,
[ModelType.parametertemplate]: RenderParameterTemplate,
[ModelType.manufacturerpart]: RenderManufacturerPart,
[ModelType.owner]: RenderOwner,
[ModelType.part]: RenderPart,
[ModelType.partcategory]: RenderPartCategory,
[ModelType.parttesttemplate]: RenderPartTestTemplate,
[ModelType.projectcode]: RenderProjectCode,
[ModelType.purchaseorder]: RenderPurchaseOrder,
[ModelType.purchaseorderlineitem]: RenderPurchaseOrder,
[ModelType.returnorder]: RenderReturnOrder,
[ModelType.returnorderlineitem]: RenderReturnOrderLineItem,
[ModelType.salesorder]: RenderSalesOrder,
[ModelType.salesordershipment]: RenderSalesOrderShipment,
[ModelType.transferorder]: RenderTransferOrder,
[ModelType.transferorderlineitem]: RenderTransferOrderLineItem,
[ModelType.stocklocation]: RenderStockLocation,
[ModelType.stocklocationtype]: RenderStockLocationType,
[ModelType.stockitem]: RenderStockItem,
[ModelType.stockhistory]: RenderStockItem,
[ModelType.supplierpart]: RenderSupplierPart,
[ModelType.user]: RenderUser,
[ModelType.group]: RenderGroup,
[ModelType.importsession]: RenderImportSession,
[ModelType.reporttemplate]: RenderReportTemplate,
[ModelType.labeltemplate]: RenderLabelTemplate,
[ModelType.pluginconfig]: RenderPlugin,
[ModelType.contenttype]: RenderContentType,
[ModelType.selectionlist]: RenderSelectionList,
[ModelType.selectionentry]: RenderSelectionEntry,
[ModelType.error]: RenderError,
[ModelType.tag]: RenderTag
};
import './ModelRenderShim';
/**
* Render an instance of a database model, depending on the provided data
*/
export function RenderInstance(props: RenderInstanceProps): ReactNode {
let RenderComponent:
| ((props: Readonly<InstanceRenderInterface>) => ReactNode)
| undefined;
// core model renderer
if (props.model !== undefined && props.custom_model === undefined) {
RenderComponent =
RendererLookup[props.model.toString().toLowerCase() as ModelType];
}
// custom model renderer (registered by a plugin) as a fallback to the core model renderer
RenderComponent ??= usePluginState().getRenderer(
props.custom_model ?? props.model ?? ''
);
const userSettings = useUserSettingsState();
// Extract model information from the defined model type
const modelInfo = useMemo(() => {
if (!props.model) {
@ -151,6 +55,22 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode {
];
}, [props.model]);
let RenderComponent:
| ((props: Readonly<InstanceRenderInterface>) => ReactNode)
| undefined;
// core model renderer
if (props.model !== undefined && props.custom_model === undefined) {
RenderComponent = modelInfo?.render;
}
// custom model renderer (registered by a plugin) as a fallback to the core model renderer
RenderComponent ??= usePluginState().getRenderer(
props.custom_model ?? props.model ?? ''
);
const userSettings = useUserSettingsState();
const showHover: boolean = useMemo(() => {
if (!modelInfo) {
return false;

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import { ModelInformationDict } from '@lib/enums/ModelInformation';
import { resolveItem } from '@lib/functions/Conversion';
import { cancelEvent } from '@lib/functions/Events';
import { mapFields } from '@lib/functions/Forms';
import { getDetailUrl } from '@lib/functions/Navigation';
import { eventModified, getDetailUrl } from '@lib/functions/Navigation';
import { navigateToLink } from '@lib/functions/Navigation';
import { useStoredTableState } from '@lib/states/StoredTableState';
import type { TableFilter } from '@lib/types/Filters';
@ -37,6 +37,7 @@ import { useApi } from '../../contexts/ApiContext';
import { extractAvailableFields } from '../../functions/forms';
import { showApiErrorMessage } from '../../functions/notifications';
import { useLocalState } from '../../states/LocalState';
import { usePreviewDrawerState } from '../../states/PreviewDrawerState';
import { useUserSettingsState } from '../../states/SettingsStates';
import { ColumnFilterPopover } from './FilterSelectDrawer';
import InvenTreeTableHeader from './InvenTreeTableHeader';
@ -105,6 +106,10 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
const userSettings = useUserSettingsState();
const showPreviewPanel = useMemo(() => {
return userSettings.isSet('ENABLE_PREVIEW_PANEL');
}, [userSettings]);
const stickyTableHeader = useMemo(() => {
return userSettings.isSet('STICKY_TABLE_HEADER');
}, [userSettings]);
@ -700,6 +705,18 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
tableState.setRecords(tableData ?? apiData ?? []);
}, [tableData, apiData]);
const previewDrawer = usePreviewDrawerState();
// Callback to display "preview" view for a row (if available)
const showRowPreview = useCallback(
(pk: string | number) => {
if (tableProps.modelType && pk) {
previewDrawer.openPreview(tableProps.modelType, Number(pk));
}
},
[tableProps.modelType]
);
// Callback when a cell is clicked
const handleCellClick = useCallback(
({
@ -732,11 +749,16 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
cancelEvent(event);
// If a model type is provided, navigate to the detail view for that model
const url = getDetailUrl(tableProps.modelType, pk);
navigateToLink(url, navigate, event);
if (!showPreviewPanel || eventModified(event as any)) {
navigateToLink(url, navigate, event);
} else {
showRowPreview(pk);
}
}
}
},
[props.onRowClick, props.onCellClick]
[props.onRowClick, props.onCellClick, showPreviewPanel]
);
const supportsContextMenu = useMemo(() => {
@ -796,7 +818,11 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
icon: <IconArrowRight />,
onClick: (event: any) => {
cancelEvent(event);
navigateToLink(url, navigate, event);
if (eventModified(event as any)) {
navigateToLink(url, navigate, event);
} else {
showRowPreview(pk);
}
}
});
}

View File

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

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Alert, Grid, Skeleton, Stack, Text } from '@mantine/core';
import { Alert, Skeleton, Stack, Text } from '@mantine/core';
import {
IconChecklist,
IconCircleCheck,
@ -21,19 +21,12 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import { TagsList } from '@lib/index';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
CancelItemAction,
@ -66,6 +59,7 @@ import BuildOutputTable from '../../tables/build/BuildOutputTable';
import PartTestResultTable from '../../tables/part/PartTestResultTable';
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { BuildOrderDetailsPanel } from './BuildOrderDetailsPanel';
function NoItems() {
return (
@ -237,239 +231,19 @@ export default function BuildDetail() {
refetchOnMount: true
});
const { instance: partRequirements, instanceQuery: partRequirementsQuery } =
useInstance({
endpoint: ApiEndpoints.part_requirements,
pk: build?.part,
hasPrimaryKey: true,
defaultValue: {}
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const data = {
...build,
can_build: partRequirements?.can_build ?? 0
};
const tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Part`,
model: ModelType.part
},
{
type: 'text',
name: 'part_detail.IPN',
icon: 'part',
label: t`IPN`,
hidden: !build.part_detail?.IPN,
copy: true
},
{
type: 'string',
name: 'part_detail.revision',
icon: 'revision',
label: t`Revision`,
hidden: !build.part_detail?.revision,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.build
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.build,
icon: 'status',
hidden:
!build.status_custom_key || build.status_custom_key == build.status
},
{
type: 'boolean',
name: 'external',
label: t`External`,
icon: 'manufacturers',
hidden: !build.external
},
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'title',
label: t`Description`,
icon: 'description',
copy: true
},
{
type: 'link',
name: 'parent',
icon: 'builds',
label: t`Parent Build`,
model_field: 'reference',
model: ModelType.build,
hidden: !build.parent
}
];
const tr: DetailsField[] = [
{
type: 'number',
name: 'quantity',
label: t`Build Quantity`
},
{
type: 'number',
name: 'can_build',
unit: build.part_detail?.units,
label: t`Can Build`,
hidden: partRequirements?.can_build === undefined
},
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
total: build.quantity,
progress: build.completed,
label: t`Completed Outputs`
},
{
type: 'link',
name: 'sales_order',
label: t`Sales Order`,
icon: 'sales_orders',
model: ModelType.salesorder,
model_field: 'reference',
hidden: !build.sales_order
}
];
const bl: DetailsField[] = [
{
type: 'text',
name: 'issued_by',
label: t`Issued By`,
icon: 'user',
badge: 'user',
hidden: !build.issued_by
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !build.responsible
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !build.project_code
},
{
type: 'link',
name: 'take_from',
icon: 'location',
model: ModelType.stocklocation,
label: t`Source Location`,
backup_value: t`Any location`
},
{
type: 'link',
name: 'destination',
icon: 'location',
model: ModelType.stocklocation,
label: t`Destination Location`,
hidden: !build.destination
},
{
type: 'text',
name: 'batch',
label: t`Batch Code`,
hidden: !build.batch,
copy: true
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Created`,
icon: 'calendar',
copy: true,
hidden: !build.creation_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
copy: true,
hidden: !build.start_date
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
icon: 'calendar',
copy: true,
hidden: !build.target_date
},
{
type: 'date',
name: 'completion_date',
label: t`Completed`,
icon: 'calendar',
copy: true,
hidden: !build.completion_date
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
apiPath={ApiEndpoints.part_list}
src={build.part_detail?.image ?? build.part_detail?.thumbnail}
pk={build.part}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={build.tags} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
</ItemDetailsGrid>
);
}, [build, instanceQuery, partRequirements, partRequirementsQuery]);
const buildPanels: PanelType[] = useMemo(() => {
return [
{
name: 'details',
label: t`Build Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<BuildOrderDetailsPanel
instance={build}
allowImageEdit
refreshInstance={refreshInstance}
/>
)
},
{
name: 'line-items',
@ -599,7 +373,7 @@ export default function BuildDetail() {
build,
id,
user,
partRequirements,
buildStatus,
globalSettings,
showChildBuilds,

View File

@ -0,0 +1,262 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { useMemo } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { TagsList } from '@lib/index';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
import { useInstance } from '../../hooks/UseInstance';
export function BuildOrderDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
}>) {
const { instance: partRequirements } = useInstance({
endpoint: ApiEndpoints.part_requirements,
pk: instance?.part,
hasPrimaryKey: true,
defaultValue: {}
});
const data = useMemo(
() => ({ ...instance, can_build: partRequirements?.can_build ?? 0 }),
[instance, partRequirements]
);
const tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Part`,
model: ModelType.part
},
{
type: 'text',
name: 'part_detail.IPN',
icon: 'part',
label: t`IPN`,
hidden: !instance?.part_detail?.IPN,
copy: true
},
{
type: 'string',
name: 'part_detail.revision',
icon: 'revision',
label: t`Revision`,
hidden: !instance?.part_detail?.revision,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.build
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.build,
icon: 'status',
hidden:
!instance?.status_custom_key ||
instance?.status_custom_key == instance?.status
},
{
type: 'boolean',
name: 'external',
label: t`External`,
icon: 'manufacturers',
hidden: !instance?.external
},
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'title',
label: t`Description`,
icon: 'description',
copy: true
},
{
type: 'link',
name: 'parent',
icon: 'builds',
label: t`Parent Build`,
model_field: 'reference',
model: ModelType.build,
hidden: !instance?.parent
}
];
const tr: DetailsField[] = [
{
type: 'number',
name: 'quantity',
label: t`Build Quantity`
},
{
type: 'number',
name: 'can_build',
unit: instance?.part_detail?.units,
label: t`Can Build`,
hidden: partRequirements?.can_build === undefined
},
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
total: instance?.quantity,
progress: instance?.completed,
label: t`Completed Outputs`
},
{
type: 'link',
name: 'sales_order',
label: t`Sales Order`,
icon: 'sales_orders',
model: ModelType.salesorder,
model_field: 'reference',
hidden: !instance?.sales_order
}
];
const bl: DetailsField[] = [
{
type: 'text',
name: 'issued_by',
label: t`Issued By`,
icon: 'user',
badge: 'user',
hidden: !instance?.issued_by
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !instance?.responsible
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !instance?.project_code
},
{
type: 'link',
name: 'take_from',
icon: 'location',
model: ModelType.stocklocation,
label: t`Source Location`,
backup_value: t`Any location`
},
{
type: 'link',
name: 'destination',
icon: 'location',
model: ModelType.stocklocation,
label: t`Destination Location`,
hidden: !instance?.destination
},
{
type: 'text',
name: 'batch',
label: t`Batch Code`,
hidden: !instance?.batch,
copy: true
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Created`,
icon: 'calendar',
copy: true,
hidden: !instance?.creation_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.start_date
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.target_date
},
{
type: 'date',
name: 'completion_date',
label: t`Completed`,
icon: 'calendar',
copy: true,
hidden: !instance?.completion_date
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.build,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ fields: tr, item: data },
{ fields: bl, item: data },
{ fields: br, item: data },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.part : undefined}
apiPath={apiUrl(ApiEndpoints.part_list, instance?.part)}
src={
instance?.part_detail?.image ?? instance?.part_detail?.thumbnail
}
pk={instance?.part}
refresh={refreshInstance}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { Skeleton, Stack } from '@mantine/core';
import {
IconBuildingWarehouse,
IconInfoCircle,
@ -17,18 +17,10 @@ import { useNavigate, useParams } from 'react-router-dom';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
DeleteItemAction,
DuplicateItemAction,
@ -58,6 +50,7 @@ import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable';
import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { CompanyDetailsPanel } from './CompanyDetailsPanel';
export type CompanyDetailProps = {
title: string;
@ -87,101 +80,15 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
refetchOnMount: true
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const tl: DetailsField[] = [
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
name: 'website',
label: t`Website`,
external: true,
copy: true,
hidden: !company.website
},
{
type: 'text',
name: 'phone',
label: t`Phone Number`,
copy: true,
hidden: !company.phone
},
{
type: 'text',
name: 'email',
label: t`Email Address`,
copy: true,
hidden: !company.email
},
{
type: 'text',
name: 'tax_id',
label: t`Tax ID`,
copy: true,
hidden: !company.tax_id
}
];
const tr: DetailsField[] = [
{
type: 'string',
name: 'currency',
label: t`Default Currency`
},
{
type: 'boolean',
name: 'is_supplier',
label: t`Supplier`,
icon: 'suppliers'
},
{
type: 'boolean',
name: 'is_manufacturer',
label: t`Manufacturer`,
icon: 'manufacturers'
},
{
type: 'boolean',
name: 'is_customer',
label: t`Customer`,
icon: 'customers'
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={apiUrl(ApiEndpoints.company_list, company.pk)}
src={company.image}
pk={company.pk}
refresh={refreshInstance}
imageActions={{
uploadFile: true,
downloadImage: true,
deleteFile: true
}}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable item={company} fields={tl} />
</Grid.Col>
</Grid>
<TagsList tags={company.tags} />
</Stack>
<DetailsTable item={company} fields={tr} />
</ItemDetailsGrid>
);
}, [company, instanceQuery]);
const detailsPanel = instanceQuery.isFetching ? (
<Skeleton />
) : (
<CompanyDetailsPanel
instance={company}
allowImageEdit
refreshInstance={refreshInstance}
/>
);
const companyPanels: PanelType[] = useMemo(() => {
return [

View File

@ -0,0 +1,119 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { TagsList } from '@lib/index';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
export function CompanyDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
}>) {
const tl: DetailsField[] = [
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
name: 'website',
label: t`Website`,
external: true,
copy: true,
hidden: !instance?.website
},
{
type: 'text',
name: 'phone',
label: t`Phone Number`,
copy: true,
hidden: !instance?.phone
},
{
type: 'text',
name: 'email',
label: t`Email Address`,
copy: true,
hidden: !instance?.email
},
{
type: 'text',
name: 'tax_id',
label: t`Tax ID`,
copy: true,
hidden: !instance?.tax_id
}
];
const tr: DetailsField[] = [
{
type: 'string',
name: 'currency',
label: t`Default Currency`
},
{
type: 'boolean',
name: 'is_supplier',
label: t`Supplier`,
icon: 'suppliers'
},
{
type: 'boolean',
name: 'is_manufacturer',
label: t`Manufacturer`,
icon: 'manufacturers'
},
{
type: 'boolean',
name: 'is_customer',
label: t`Customer`,
icon: 'customers'
}
];
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid tables={[{ item: instance, fields: tr }]}>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.purchase_order : undefined}
apiPath={apiUrl(ApiEndpoints.company_list, instance.pk)}
src={instance.image}
pk={instance.pk}
refresh={refreshInstance}
imageActions={
allowImageEdit
? {
uploadFile: true,
downloadImage: true,
deleteFile: true
}
: {}
}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable item={instance} fields={tl} />
</Grid.Col>
</Grid>
<TagsList tags={instance.tags} />
</Stack>
</ItemDetailsGrid>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { Skeleton, Stack } from '@mantine/core';
import {
IconBuildingWarehouse,
IconInfoCircle,
@ -8,20 +8,12 @@ import {
import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
DeleteItemAction,
DuplicateItemAction,
@ -44,6 +36,7 @@ import { useInstance } from '../../hooks/UseInstance';
import { useUserState } from '../../states/UserState';
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { ManufacturerPartDetailsPanel } from './ManufacturerPartDetailsPanel';
export default function ManufacturerPartDetail() {
const { id } = useParams();
@ -65,105 +58,19 @@ export default function ManufacturerPartDetail() {
}
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const data = manufacturerPart ?? {};
const tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Internal Part`,
model: ModelType.part,
hidden: !manufacturerPart.part
},
{
type: 'string',
name: 'part_detail.IPN',
label: t`IPN`,
copy: true,
icon: 'serial',
hidden: !data.part_detail?.IPN
},
{
type: 'string',
name: 'part_detail.description',
label: t`Description`,
copy: true,
icon: 'info',
hidden: !manufacturerPart.description
}
];
const tr: DetailsField[] = [
{
type: 'link',
name: 'manufacturer',
label: t`Manufacturer`,
icon: 'manufacturers',
model: ModelType.company,
hidden: !manufacturerPart.manufacturer
},
{
type: 'string',
name: 'MPN',
label: t`Manufacturer Part Number`,
copy: true,
hidden: !manufacturerPart.MPN,
icon: 'reference'
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true,
hidden: !manufacturerPart.description,
icon: 'info'
},
{
type: 'link',
external: true,
name: 'link',
label: t`External Link`,
copy: true,
hidden: !manufacturerPart.link
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
src={manufacturerPart?.part_detail?.image}
apiPath={apiUrl(
ApiEndpoints.part_list,
manufacturerPart?.part_detail?.pk
)}
pk={manufacturerPart?.part_detail?.pk}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={manufacturerPart.tags} />
</Stack>
<DetailsTable title={t`Manufacturer Details`} fields={tr} item={data} />
</ItemDetailsGrid>
);
}, [manufacturerPart, instanceQuery]);
const panels: PanelType[] = useMemo(() => {
return [
{
name: 'details',
label: t`Manufacturer Part Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<ManufacturerPartDetailsPanel
instance={manufacturerPart}
allowImageEdit
refreshInstance={refreshInstance}
/>
)
},
{
name: 'stock',

View File

@ -0,0 +1,119 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
export function ManufacturerPartDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
}>) {
const tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Internal Part`,
model: ModelType.part,
hidden: !instance?.part
},
{
type: 'string',
name: 'part_detail.IPN',
label: t`IPN`,
copy: true,
icon: 'serial',
hidden: !instance?.part_detail?.IPN
},
{
type: 'string',
name: 'part_detail.description',
label: t`Description`,
copy: true,
icon: 'info',
hidden: !instance?.description
}
];
const tr: DetailsField[] = [
{
type: 'link',
name: 'manufacturer',
label: t`Manufacturer`,
icon: 'manufacturers',
model: ModelType.company,
hidden: !instance?.manufacturer
},
{
type: 'string',
name: 'MPN',
label: t`Manufacturer Part Number`,
copy: true,
hidden: !instance?.MPN,
icon: 'reference'
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true,
hidden: !instance?.description,
icon: 'info'
},
{
type: 'link',
external: true,
name: 'link',
label: t`External Link`,
copy: true,
hidden: !instance?.link
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.manufacturerpart,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ title: t`Manufacturer Details`, fields: tr, item: instance },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.part : undefined}
src={instance?.part_detail?.image}
apiPath={apiUrl(ApiEndpoints.part_list, instance?.part_detail?.pk)}
pk={instance?.part_detail?.pk}
refresh={refreshInstance}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable title={t`Part Details`} fields={tl} item={instance} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { Skeleton, Stack } from '@mantine/core';
import {
IconCurrencyDollar,
IconInfoCircle,
@ -9,22 +9,14 @@ import {
import { type ReactNode, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { formatDecimal } from '@lib/functions/Formatting';
import { getDetailUrl } from '@lib/functions/Navigation';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
DeleteItemAction,
@ -49,6 +41,7 @@ import { useUserState } from '../../states/UserState';
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
import SupplierPriceBreakTable from '../../tables/purchasing/SupplierPriceBreakTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { SupplierPartDetailsPanel } from './SupplierPartDetailsPanel';
export default function SupplierPartDetail() {
const { id } = useParams();
@ -73,187 +66,19 @@ export default function SupplierPartDetail() {
}
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const data = supplierPart ?? {};
// Access nested data
data.manufacturer =
supplierPart.manufacturer || data.manufacturer_detail?.pk;
data.MPN = supplierPart.MPN || data.manufacturer_part_detail?.MPN;
data.manufacturer_part =
supplierPart.manufacturer_part || data.manufacturer_part_detail?.pk;
const tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Internal Part`,
model: ModelType.part,
hidden: !supplierPart.part
},
{
type: 'string',
name: 'part_detail.IPN',
label: t`IPN`,
copy: true,
hidden: !data.part_detail?.IPN,
icon: 'serial'
},
{
type: 'string',
name: 'part_detail.description',
label: t`Part Description`,
copy: true,
icon: 'info',
hidden: !data.part_detail?.description
},
{
type: 'link',
external: true,
name: 'link',
label: t`External Link`,
copy: true,
hidden: !supplierPart.link
},
{
type: 'string',
name: 'note',
label: t`Note`,
copy: true,
hidden: !supplierPart.note
}
];
const bl: DetailsField[] = [
{
type: 'link',
name: 'supplier',
label: t`Supplier`,
model: ModelType.company,
icon: 'suppliers',
hidden: !supplierPart.supplier
},
{
type: 'string',
name: 'SKU',
label: t`SKU`,
copy: true,
icon: 'reference'
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true,
hidden: !data.description
},
{
type: 'link',
name: 'manufacturer',
label: t`Manufacturer`,
model: ModelType.company,
icon: 'manufacturers',
hidden: !data.manufacturer
},
{
type: 'link',
name: 'manufacturer_part',
model_field: 'MPN',
label: t`Manufacturer Part`,
model: ModelType.manufacturerpart,
icon: 'reference',
hidden: !data.manufacturer_part
}
];
const br: DetailsField[] = [
{
type: 'string',
name: 'packaging',
label: t`Packaging`,
copy: true,
hidden: !data.packaging
},
{
type: 'string',
name: 'pack_quantity',
label: t`Pack Quantity`,
copy: true,
hidden: !data.pack_quantity,
icon: 'packages'
}
];
const tr: DetailsField[] = [
{
type: 'number',
name: 'in_stock',
label: t`In Stock`,
copy: true,
icon: 'stock'
},
{
type: 'number',
name: 'on_order',
label: t`On Order`,
copy: true,
icon: 'purchase_orders'
},
{
type: 'number',
name: 'available',
label: t`Supplier Availability`,
hidden: !data.availability_updated,
copy: true,
icon: 'packages'
},
{
type: 'date',
name: 'availability_updated',
label: t`Availability Updated`,
copy: true,
hidden: !data.availability_updated,
icon: 'calendar'
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
src={supplierPart?.part_detail?.image}
apiPath={apiUrl(
ApiEndpoints.part_list,
supplierPart?.part_detail?.pk
)}
pk={supplierPart?.part_detail?.pk}
/>
<Grid.Col span={8}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={supplierPart.tags} />
</Stack>
<DetailsTable title={t`Supplier`} fields={bl} item={data} />
<DetailsTable title={t`Packaging`} fields={br} item={data} />
<DetailsTable title={t`Availability`} fields={tr} item={data} />
</ItemDetailsGrid>
);
}, [supplierPart, instanceQuery.isFetching]);
const panels: PanelType[] = useMemo(() => {
return [
{
name: 'details',
label: t`Supplier Part Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<SupplierPartDetailsPanel
instance={supplierPart}
allowImageEdit
refreshInstance={refreshInstance}
/>
)
},
{
name: 'stock',

View File

@ -0,0 +1,206 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { useMemo } from 'react';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
export function SupplierPartDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
}>) {
const data = useMemo(() => {
if (!instance) return {};
return {
...instance,
manufacturer: instance.manufacturer || instance.manufacturer_detail?.pk,
MPN: instance.MPN || instance.manufacturer_part_detail?.MPN,
manufacturer_part:
instance.manufacturer_part || instance.manufacturer_part_detail?.pk
};
}, [instance]);
const tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Internal Part`,
model: ModelType.part,
hidden: !instance?.part
},
{
type: 'string',
name: 'part_detail.IPN',
label: t`IPN`,
copy: true,
hidden: !data.part_detail?.IPN,
icon: 'serial'
},
{
type: 'string',
name: 'part_detail.description',
label: t`Part Description`,
copy: true,
icon: 'info',
hidden: !data.part_detail?.description
},
{
type: 'link',
external: true,
name: 'link',
label: t`External Link`,
copy: true,
hidden: !instance?.link
},
{
type: 'string',
name: 'note',
label: t`Note`,
copy: true,
hidden: !instance?.note
}
];
const bl: DetailsField[] = [
{
type: 'link',
name: 'supplier',
label: t`Supplier`,
model: ModelType.company,
icon: 'suppliers',
hidden: !instance?.supplier
},
{
type: 'string',
name: 'SKU',
label: t`SKU`,
copy: true,
icon: 'reference'
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true,
hidden: !data.description
},
{
type: 'link',
name: 'manufacturer',
label: t`Manufacturer`,
model: ModelType.company,
icon: 'manufacturers',
hidden: !data.manufacturer
},
{
type: 'link',
name: 'manufacturer_part',
model_field: 'MPN',
label: t`Manufacturer Part`,
model: ModelType.manufacturerpart,
icon: 'reference',
hidden: !data.manufacturer_part
}
];
const br: DetailsField[] = [
{
type: 'string',
name: 'packaging',
label: t`Packaging`,
copy: true,
hidden: !data.packaging
},
{
type: 'string',
name: 'pack_quantity',
label: t`Pack Quantity`,
copy: true,
hidden: !data.pack_quantity,
icon: 'packages'
}
];
const tr: DetailsField[] = [
{
type: 'number',
name: 'in_stock',
label: t`In Stock`,
copy: true,
icon: 'stock'
},
{
type: 'number',
name: 'on_order',
label: t`On Order`,
copy: true,
icon: 'purchase_orders'
},
{
type: 'number',
name: 'available',
label: t`Supplier Availability`,
hidden: !data.availability_updated,
copy: true,
icon: 'packages'
},
{
type: 'date',
name: 'availability_updated',
label: t`Availability Updated`,
copy: true,
hidden: !data.availability_updated,
icon: 'calendar'
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.supplierpart,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ title: t`Supplier`, fields: bl, item: data },
{ title: t`Packaging`, fields: br, item: data },
{ title: t`Availability`, fields: tr, item: data },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.part : undefined}
src={instance?.part_detail?.image}
apiPath={apiUrl(ApiEndpoints.part_list, instance?.part_detail?.pk)}
pk={instance?.part_detail?.pk}
refresh={refreshInstance}
/>
<Grid.Col span={8}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
);
}

View File

@ -7,10 +7,7 @@ import { Paper, Skeleton, Stack } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import type { DetailsField } from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {} from '../../components/items/ActionDropdown';
import { RoleTable, type RuleSet } from '../../components/items/RoleTable';
@ -48,8 +45,9 @@ export default function GroupDetail() {
];
return (
<ItemDetailsGrid>
<DetailsTable fields={tl} item={instance} title={t`Group Details`} />
<ItemDetailsGrid
tables={[{ fields: tl, item: instance, title: t`Group Details` }]}
>
<Paper p='xs' withBorder>
<Stack gap='xs'>
<StylishText size='lg'>{t`Group Roles`}</StylishText>

View File

@ -6,10 +6,7 @@ import { Badge, Group, Skeleton, Stack } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import type { DetailsField } from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
@ -171,13 +168,15 @@ export default function UserDetail() {
instance.location;
return (
<ItemDetailsGrid>
<DetailsTable fields={tl} item={instance} title={t`User Information`} />
<DetailsTable fields={tr} item={instance} title={t`User Permissions`} />
{hasProfile && settings.isSet('DISPLAY_PROFILE_INFO') && (
<DetailsTable fields={br} item={instance} title={t`User Profile`} />
)}
</ItemDetailsGrid>
<ItemDetailsGrid
tables={[
{ fields: tl, item: instance, title: t`User Information` },
{ fields: tr, item: instance, title: t`User Permissions` },
...(hasProfile && settings.isSet('DISPLAY_PROFILE_INFO')
? [{ fields: br, item: instance, title: t`User Profile` }]
: [])
]}
/>
);
}, [instance, userGroups, instanceQuery]);

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Group, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { LoadingOverlay, Stack } from '@mantine/core';
import {
IconCategory,
IconInfoCircle,
@ -21,11 +21,6 @@ import type { PanelType } from '@lib/types/Panel';
import { useLocalStorage } from '@mantine/hooks';
import AdminButton from '../../components/buttons/AdminButton';
import StarredToggleButton from '../../components/buttons/StarredToggleButton';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
DeleteItemAction,
EditItemAction,
@ -52,6 +47,7 @@ import { PartCategoryTable } from '../../tables/part/PartCategoryTable';
import PartCategoryTemplateTable from '../../tables/part/PartCategoryTemplateTable';
import { PartListTable } from '../../tables/part/PartTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { PartCategoryDetailsPanel } from './PartCategoryDetailsPanel';
/**
* Detail view for a single PartCategory instance.
@ -106,100 +102,6 @@ export default function CategoryDetail() {
assign: false
});
const detailsPanel = useMemo(() => {
if (id && instanceQuery.isFetching) {
return <Skeleton />;
}
const left: DetailsField[] = [
{
type: 'text',
name: 'name',
label: t`Name`,
copy: true,
value_formatter: () => (
<Group gap='xs'>
{category.icon && <ApiIcon name={category.icon} />}
{category.name}
</Group>
)
},
{
type: 'text',
name: 'pathstring',
label: t`Path`,
icon: 'sitemap',
copy: true,
hidden: !id
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
name: 'parent',
model_field: 'name',
icon: 'location',
label: t`Parent Category`,
model: ModelType.partcategory,
hidden: !category?.parent
},
{
type: 'boolean',
name: 'starred',
icon: 'notification',
label: t`Subscribed`
}
];
const right: DetailsField[] = [
{
type: 'text',
name: 'part_count',
label: t`Parts`,
icon: 'part',
value_formatter: () => category?.part_count || '0'
},
{
type: 'text',
name: 'subcategories',
label: t`Subcategories`,
icon: 'sitemap',
hidden: !category?.subcategories
},
{
type: 'boolean',
name: 'structural',
label: t`Structural`,
icon: 'sitemap'
},
{
type: 'link',
name: 'parent_default_location',
label: t`Parent default location`,
model: ModelType.stocklocation,
hidden: !category.parent_default_location || category.default_location
},
{
type: 'link',
name: 'default_location',
label: t`Default location`,
model: ModelType.stocklocation,
hidden: !category.default_location
}
];
return (
<ItemDetailsGrid>
{id && category?.pk && <DetailsTable item={category} fields={left} />}
{id && category?.pk && <DetailsTable item={category} fields={right} />}
</ItemDetailsGrid>
);
}, [category, instanceQuery]);
const editCategory = useEditApiFormModal({
url: ApiEndpoints.category_list,
pk: id,
@ -296,7 +198,7 @@ export default function CategoryDetail() {
name: 'details',
label: t`Category Details`,
icon: <IconInfoCircle />,
content: detailsPanel,
content: <PartCategoryDetailsPanel instance={category} />,
hidden: !id || !category?.pk
},
{

View File

@ -0,0 +1,121 @@
import { t } from '@lingui/core/macro';
import { Group, Skeleton } from '@mantine/core';
import { useMemo } from 'react';
import { ModelType } from '@lib/enums/ModelType';
import type { DetailsField } from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
import { ApiIcon } from '../../components/items/ApiIcon';
export function PartCategoryDetailsPanel({
instance
}: Readonly<{
instance: any;
}>) {
const left: DetailsField[] = useMemo(
() => [
{
type: 'text',
name: 'name',
label: t`Name`,
copy: true,
value_formatter: () => (
<Group gap='xs'>
{instance?.icon && <ApiIcon name={instance.icon} />}
{instance?.name}
</Group>
)
},
{
type: 'text',
name: 'pathstring',
label: t`Path`,
icon: 'sitemap',
copy: true,
hidden: !instance?.pathstring
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
name: 'parent',
model_field: 'name',
icon: 'location',
label: t`Parent Category`,
model: ModelType.partcategory,
hidden: !instance?.parent
},
{
type: 'boolean',
name: 'starred',
icon: 'notification',
label: t`Subscribed`
}
],
[instance]
);
const right: DetailsField[] = useMemo(
() => [
{
type: 'text',
name: 'part_count',
label: t`Parts`,
icon: 'part',
value_formatter: () => instance?.part_count || '0'
},
{
type: 'text',
name: 'subcategories',
label: t`Subcategories`,
icon: 'sitemap',
hidden: !instance?.subcategories
},
{
type: 'boolean',
name: 'structural',
label: t`Structural`,
icon: 'sitemap'
},
{
type: 'link',
name: 'parent_default_location',
label: t`Parent default location`,
model: ModelType.stocklocation,
hidden:
!instance?.parent_default_location || !!instance?.default_location
},
{
type: 'link',
name: 'default_location',
label: t`Default location`,
model: ModelType.stocklocation,
hidden: !instance?.default_location
}
],
[instance]
);
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.partcategory,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ item: instance, fields: left },
{ item: instance, fields: right },
parametersTable
]}
/>
);
}

View File

@ -3,7 +3,6 @@ import {
ActionIcon,
Alert,
Center,
Grid,
Group,
Loader,
Paper,
@ -41,7 +40,6 @@ import { type ReactNode, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Select from 'react-select';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@ -52,13 +50,7 @@ import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import StarredToggleButton from '../../components/buttons/StarredToggleButton';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { Thumbnail } from '../../components/images/Thumbnail';
import {
ActionDropdown,
@ -77,7 +69,7 @@ import { PanelGroup } from '../../components/panels/PanelGroup';
import { RenderPart } from '../../components/render/Part';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { useApi } from '../../contexts/ApiContext';
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
import { formatDecimal } from '../../defaults/formatters';
import { usePartFields } from '../../forms/PartForms';
import { useFindSerialNumberForm } from '../../forms/StockForms';
import {
@ -106,6 +98,7 @@ import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
import PartAllocationPanel from './PartAllocationPanel';
import { PartDetailsPanel } from './PartDetailsPanel';
import PartPricingPanel from './PartPricingPanel';
import PartStockHistoryDetail from './PartStockHistoryDetail';
import PartSupplierDetail from './PartSupplierDetail';
@ -175,14 +168,6 @@ export default function PartDetail() {
refetchOnMount: true
});
const { instance: serials } = useInstance({
endpoint: ApiEndpoints.part_serial_numbers,
pk: id,
hasPrimaryKey: true,
refetchOnMount: false,
defaultValue: {}
});
const {
instance: part,
refreshInstance,
@ -275,403 +260,22 @@ export default function PartDetail() {
return partRevisionOptions.length > 0 && revisionsEnabled;
}, [partRevisionOptions, revisionsEnabled]);
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const data = { ...part };
const fetching =
partRequirementsQuery.isFetching || instanceQuery.isFetching;
// Copy part requirements data into the main part data
data.total_in_stock =
partRequirements?.total_stock ?? part?.total_in_stock ?? 0;
data.unallocated =
partRequirements?.unallocated_stock ?? part?.unallocated_stock ?? 0;
data.ordering = partRequirements?.ordering ?? part?.ordering ?? 0;
data.required =
(partRequirements?.required_for_build_orders ??
part?.required_for_build_orders ??
0) +
(partRequirements?.required_for_sales_orders ??
part?.required_for_sales_orders ??
0);
data.allocated =
(partRequirements?.allocated_to_build_orders ??
part?.allocated_to_build_orders ??
0) +
(partRequirements?.allocated_to_sales_orders ??
part?.allocated_to_sales_orders ??
0);
// Extract requirements data
data.can_build = partRequirements?.can_build ?? 0;
// Provide latest serial number info
if (!!serials.latest) {
data.latest_serial_number = serials.latest;
}
// Top left - core part information
const tl: DetailsField[] = [
{
type: 'string',
name: 'name',
label: t`Name`,
icon: 'part',
copy: true
},
{
type: 'string',
name: 'IPN',
label: t`IPN`,
copy: true,
hidden: !part.IPN
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
name: 'variant_of',
label: t`Variant of`,
model: ModelType.part,
model_field: 'full_name',
hidden: !part.variant_of
},
{
type: 'link',
name: 'revision_of',
label: t`Revision of`,
model: ModelType.part,
model_field: 'full_name',
hidden: !part.revision_of
},
{
type: 'string',
name: 'revision',
label: t`Revision`,
hidden: !part.revision,
copy: true
},
{
type: 'link',
name: 'category',
label: t`Category`,
model: ModelType.partcategory
},
{
type: 'link',
name: 'default_location',
label: t`Default Location`,
model: ModelType.stocklocation,
hidden: !part.default_location
},
{
type: 'link',
name: 'category_default_location',
label: t`Category Default Location`,
model: ModelType.stocklocation,
hidden: part.default_location || !part.category_default_location
},
{
type: 'string',
name: 'units',
label: t`Units`,
copy: true,
hidden: !part.units
},
{
type: 'string',
name: 'keywords',
label: t`Keywords`,
copy: true,
hidden: !part.keywords
},
{
type: 'link',
name: 'link',
label: t`Link`,
external: true,
copy: true,
hidden: !part.link
}
];
// Top right - stock availability information
const tr: DetailsField[] = [
{
type: 'number',
name: 'total_in_stock',
unit: part.units,
label: t`In Stock`,
hidden: part.virtual
},
{
type: 'progressbar',
name: 'unallocated_stock',
total: data.total_in_stock,
progress: data.unallocated,
label: t`Available Stock`,
hidden: part.virtual || data.total_in_stock == data.unallocated
},
{
type: 'number',
name: 'ordering',
label: t`On order`,
unit: part.units,
hidden: !part.purchaseable || part.ordering <= 0
},
{
type: 'number',
name: 'required',
label: t`Required for Orders`,
unit: part.units,
hidden: data.required <= 0,
icon: 'stocktake'
},
{
type: 'progressbar',
name: 'allocated_to_build_orders',
icon: 'manufacturers',
total: partRequirements.required_for_build_orders,
progress: partRequirements.allocated_to_build_orders,
label: t`Allocated to Build Orders`,
hidden:
fetching ||
(partRequirements.required_for_build_orders <= 0 &&
partRequirements.allocated_to_build_orders <= 0)
},
{
type: 'progressbar',
icon: 'sales_orders',
name: 'allocated_to_sales_orders',
total: partRequirements.required_for_sales_orders,
progress: partRequirements.allocated_to_sales_orders,
label: t`Allocated to Sales Orders`,
hidden:
fetching ||
(partRequirements.required_for_sales_orders <= 0 &&
partRequirements.allocated_to_sales_orders <= 0)
},
{
type: 'progressbar',
name: 'building',
label: t`In Production`,
progress: partRequirements.building,
total: partRequirements.scheduled_to_build,
hidden:
fetching ||
(!partRequirements.building && !partRequirements.scheduled_to_build)
},
{
type: 'number',
name: 'can_build',
unit: part.units,
label: t`Can Build`,
hidden: !part.assembly || fetching
},
{
type: 'number',
name: 'minimum_stock',
unit: part.units,
label: t`Minimum Stock`,
hidden: part.minimum_stock <= 0
},
{
type: 'number',
name: 'maximum_stock',
unit: part.units,
label: t`Maximum Stock`,
hidden: part.maximum_stock <= 0
}
];
// Bottom left - part attributes
const bl: DetailsField[] = [
{
type: 'boolean',
name: 'active',
label: t`Active`
},
{
type: 'boolean',
name: 'locked',
label: t`Locked`
},
{
type: 'boolean',
icon: 'template',
name: 'is_template',
label: t`Template Part`
},
{
type: 'boolean',
name: 'assembly',
label: t`Assembled Part`
},
{
type: 'boolean',
name: 'component',
label: t`Component Part`
},
{
type: 'boolean',
name: 'testable',
label: t`Testable Part`,
icon: 'test'
},
{
type: 'boolean',
name: 'trackable',
label: t`Trackable Part`
},
{
type: 'boolean',
name: 'purchaseable',
label: t`Purchaseable Part`
},
{
type: 'boolean',
name: 'salable',
icon: 'saleable',
label: t`Saleable Part`
},
{
type: 'boolean',
name: 'virtual',
label: t`Virtual Part`
},
{
type: 'boolean',
name: 'consumable',
label: t`Consumable Part`
},
{
type: 'boolean',
name: 'starred',
label: t`Subscribed`,
icon: 'bell'
}
];
// Bottom right - other part information
const br: DetailsField[] = [
{
type: 'string',
name: 'creation_date',
label: t`Creation Date`
},
{
type: 'string',
name: 'creation_user',
label: t`Created By`,
badge: 'user',
icon: 'user',
hidden: !part.creation_user
},
{
type: 'string',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !part.responsible
},
{
name: 'default_expiry',
label: t`Default Expiry`,
hidden: !part.default_expiry,
icon: 'calendar',
type: 'string',
value_formatter: () => {
return `${part.default_expiry} ${t`days`}`;
}
}
];
// Add in price range data
if (part.pricing_min || part.pricing_max) {
br.push({
type: 'string',
name: 'pricing',
label: t`Price Range`,
value_formatter: () => {
return formatPriceRange(part.pricing_min, part.pricing_max);
}
});
}
br.push({
type: 'string',
name: 'latest_serial_number',
label: t`Latest Serial Number`,
hidden: !part.trackable || !data.latest_serial_number,
icon: 'serial'
});
return part ? (
<ItemDetailsGrid>
const revisionSelector = useMemo(() => {
if (!enableRevisionSelection) return null;
return (
<Paper p='sm' withBorder>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
imageActions={{
selectExisting: true,
downloadImage: true,
uploadFile: true,
deleteFile: true
}}
src={part.image}
thumbnail={part.thumbnail}
apiPath={apiUrl(ApiEndpoints.part_list, part.pk)}
refresh={refreshInstance}
pk={part.pk}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={part.tags} />
{enableRevisionSelection && (
<Paper p='sm' withBorder>
<Stack gap='xs'>
<Group gap='xs'>
<ActionIcon variant='transparent'>
<IconVersions />
</ActionIcon>
<Text>{t`Select Part Revision`}</Text>
</Group>
<RevisionSelector part={part} options={partRevisionOptions} />
</Stack>
</Paper>
)}
<Group gap='xs'>
<ActionIcon variant='transparent'>
<IconVersions />
</ActionIcon>
<Text>{t`Select Part Revision`}</Text>
</Group>
<RevisionSelector part={part} options={partRevisionOptions} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
</ItemDetailsGrid>
) : (
<Skeleton />
</Paper>
);
}, [
globalSettings,
part,
id,
serials,
instanceQuery.isFetching,
instanceQuery.data,
enableRevisionSelection,
partRevisionOptions,
partRequirementsQuery.isFetching,
partRequirements
]);
}, [enableRevisionSelection, part, partRevisionOptions]);
// Part data panels (recalculate when part data changes)
const partPanels: PanelType[] = useMemo(() => {
@ -680,7 +284,14 @@ export default function PartDetail() {
name: 'details',
label: t`Part Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<PartDetailsPanel
instance={part}
allowImageEdit
refreshInstance={refreshInstance}
additionalContent={revisionSelector}
/>
)
},
{
name: 'stock',
@ -908,8 +519,9 @@ export default function PartDetail() {
user,
globalSettings,
userSettings,
detailsPanel,
bomInformation
bomInformation,
revisionSelector,
refreshInstance
]);
const breadcrumbs = useMemo(() => {

View File

@ -0,0 +1,370 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { type ReactNode, useMemo } from 'react';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
import { formatPriceRange } from '../../defaults/formatters';
import { useInstance } from '../../hooks/UseInstance';
export function PartDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance,
additionalContent
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
additionalContent?: ReactNode;
}>) {
const { instance: requirements, instanceQuery: requirementsQuery } =
useInstance({
endpoint: ApiEndpoints.part_requirements,
pk: instance?.pk,
hasPrimaryKey: true,
disabled: !instance?.pk,
defaultValue: {}
});
const { instance: serials } = useInstance({
endpoint: ApiEndpoints.part_serial_numbers,
pk: instance?.pk,
hasPrimaryKey: true,
disabled: !instance?.pk,
defaultValue: {}
});
const data = useMemo(() => {
if (!instance) return {};
const d = { ...instance };
d.total_in_stock =
requirements?.total_stock ?? instance?.total_in_stock ?? 0;
d.unallocated =
requirements?.unallocated_stock ?? instance?.unallocated_stock ?? 0;
d.ordering = requirements?.ordering ?? instance?.ordering ?? 0;
d.required =
(requirements?.required_for_build_orders ??
instance?.required_for_build_orders ??
0) +
(requirements?.required_for_sales_orders ??
instance?.required_for_sales_orders ??
0);
d.allocated =
(requirements?.allocated_to_build_orders ??
instance?.allocated_to_build_orders ??
0) +
(requirements?.allocated_to_sales_orders ??
instance?.allocated_to_sales_orders ??
0);
d.can_build = requirements?.can_build ?? 0;
if (serials?.latest) d.latest_serial_number = serials.latest;
return d;
}, [instance, requirements, serials]);
const fetching = requirementsQuery?.isFetching ?? false;
const tl: DetailsField[] = [
{ type: 'string', name: 'name', label: t`Name`, icon: 'part', copy: true },
{
type: 'string',
name: 'IPN',
label: t`IPN`,
copy: true,
hidden: !instance?.IPN
},
{ type: 'string', name: 'description', label: t`Description`, copy: true },
{
type: 'link',
name: 'variant_of',
label: t`Variant of`,
model: ModelType.part,
model_field: 'full_name',
hidden: !instance?.variant_of
},
{
type: 'link',
name: 'revision_of',
label: t`Revision of`,
model: ModelType.part,
model_field: 'full_name',
hidden: !instance?.revision_of
},
{
type: 'string',
name: 'revision',
label: t`Revision`,
hidden: !instance?.revision,
copy: true
},
{
type: 'link',
name: 'category',
label: t`Category`,
model: ModelType.partcategory
},
{
type: 'link',
name: 'default_location',
label: t`Default Location`,
model: ModelType.stocklocation,
hidden: !instance?.default_location
},
{
type: 'link',
name: 'category_default_location',
label: t`Category Default Location`,
model: ModelType.stocklocation,
hidden: instance?.default_location || !instance?.category_default_location
},
{
type: 'string',
name: 'units',
label: t`Units`,
copy: true,
hidden: !instance?.units
},
{
type: 'string',
name: 'keywords',
label: t`Keywords`,
copy: true,
hidden: !instance?.keywords
},
{
type: 'link',
name: 'link',
label: t`Link`,
external: true,
copy: true,
hidden: !instance?.link
}
];
const tr: DetailsField[] = [
{
type: 'number',
name: 'total_in_stock',
unit: instance?.units,
label: t`In Stock`,
hidden: instance?.virtual
},
{
type: 'progressbar',
name: 'unallocated_stock',
total: data.total_in_stock,
progress: data.unallocated,
label: t`Available Stock`,
hidden: instance?.virtual || data.total_in_stock == data.unallocated
},
{
type: 'number',
name: 'ordering',
label: t`On Order`,
unit: instance?.units,
hidden: !instance?.purchaseable || instance?.ordering <= 0
},
{
type: 'number',
name: 'required',
label: t`Required for Orders`,
unit: instance?.units,
hidden: data.required <= 0,
icon: 'stocktake'
},
{
type: 'progressbar',
name: 'allocated_to_build_orders',
icon: 'manufacturers',
total: requirements?.required_for_build_orders,
progress: requirements?.allocated_to_build_orders,
label: t`Allocated to Build Orders`,
hidden:
fetching ||
(requirements?.required_for_build_orders <= 0 &&
requirements?.allocated_to_build_orders <= 0)
},
{
type: 'progressbar',
icon: 'sales_orders',
name: 'allocated_to_sales_orders',
total: requirements?.required_for_sales_orders,
progress: requirements?.allocated_to_sales_orders,
label: t`Allocated to Sales Orders`,
hidden:
fetching ||
(requirements?.required_for_sales_orders <= 0 &&
requirements?.allocated_to_sales_orders <= 0)
},
{
type: 'progressbar',
name: 'building',
label: t`In Production`,
progress: requirements?.building,
total: requirements?.scheduled_to_build,
hidden:
fetching ||
(!requirements?.building && !requirements?.scheduled_to_build)
},
{
type: 'number',
name: 'can_build',
unit: instance?.units,
label: t`Can Build`,
hidden: !instance?.assembly || fetching
},
{
type: 'number',
name: 'minimum_stock',
unit: instance?.units,
label: t`Minimum Stock`,
hidden: instance?.minimum_stock <= 0
},
{
type: 'number',
name: 'maximum_stock',
unit: instance?.units,
label: t`Maximum Stock`,
hidden: instance?.maximum_stock <= 0
}
];
const bl: DetailsField[] = [
{ type: 'boolean', name: 'active', label: t`Active` },
{ type: 'boolean', name: 'locked', label: t`Locked` },
{
type: 'boolean',
icon: 'template',
name: 'is_template',
label: t`Template Part`
},
{ type: 'boolean', name: 'assembly', label: t`Assembled Part` },
{ type: 'boolean', name: 'component', label: t`Component Part` },
{
type: 'boolean',
name: 'testable',
label: t`Testable Part`,
icon: 'test'
},
{ type: 'boolean', name: 'trackable', label: t`Trackable Part` },
{ type: 'boolean', name: 'purchaseable', label: t`Purchaseable Part` },
{
type: 'boolean',
name: 'salable',
icon: 'saleable',
label: t`Saleable Part`
},
{ type: 'boolean', name: 'virtual', label: t`Virtual Part` },
{ type: 'boolean', name: 'starred', label: t`Subscribed`, icon: 'bell' }
];
const br: DetailsField[] = useMemo(() => {
const fields: DetailsField[] = [
{
type: 'string',
name: 'creation_date',
label: t`Creation Date`
},
{
type: 'string',
name: 'creation_user',
label: t`Created By`,
badge: 'user',
icon: 'user',
hidden: !instance?.creation_user
},
{
type: 'string',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !instance?.responsible
},
{
name: 'default_expiry',
label: t`Default Expiry`,
hidden: !instance?.default_expiry,
icon: 'calendar',
type: 'string',
value_formatter: () => `${instance?.default_expiry} ${t`days`}`
}
];
if (instance?.pricing_min || instance?.pricing_max) {
fields.push({
type: 'string',
name: 'pricing',
label: t`Price Range`,
value_formatter: () =>
formatPriceRange(instance?.pricing_min, instance?.pricing_max)
});
}
fields.push({
type: 'string',
name: 'latest_serial_number',
label: t`Latest Serial Number`,
hidden: !instance?.trackable || !data.latest_serial_number,
icon: 'serial'
});
return fields;
}, [instance, data.latest_serial_number]);
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.part,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ fields: tr, item: data },
{ fields: bl, item: data },
{ fields: br, item: data },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.part : undefined}
imageActions={
allowImageEdit
? {
selectExisting: true,
downloadImage: true,
uploadFile: true,
deleteFile: true
}
: {}
}
src={instance.image}
thumbnail={instance.thumbnail}
apiPath={apiUrl(ApiEndpoints.part_list, instance.pk)}
refresh={refreshInstance}
pk={instance.pk}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={instance.tags} />
{additionalContent}
</Stack>
</ItemDetailsGrid>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Accordion, Grid, Skeleton, Stack } from '@mantine/core';
import { Accordion, Stack } from '@mantine/core';
import { IconInfoCircle, IconList, IconPackages } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
@ -9,17 +9,10 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
CancelItemAction,
@ -35,7 +28,6 @@ import NotesPanel from '../../components/panels/NotesPanel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import ParametersPanel from '../../components/panels/ParametersPanel';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { formatCurrency } from '../../defaults/formatters';
import { usePurchaseOrderFields } from '../../forms/PurchaseOrderForms';
import {
useCreateApiFormModal,
@ -48,6 +40,7 @@ import { useUserState } from '../../states/UserState';
import ExtraLineItemTable from '../../tables/general/ExtraLineItemTable';
import { PurchaseOrderLineItemTable } from '../../tables/purchasing/PurchaseOrderLineItemTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { PurchaseOrderDetailsPanel } from './PurchaseOrderDetailsPanel';
/**
* Detail page for a single PurchaseOrder
@ -72,13 +65,13 @@ export default function PurchaseOrderDetail() {
refetchOnMount: true
});
const orderCurrency = useMemo(() => {
return (
const orderCurrency = useMemo(
() =>
order.order_currency ||
order.supplier_detail?.currency ||
globalSettings.getSetting('INVENTREE_DEFAULT_CURRENCY')
);
}, [order, globalSettings]);
globalSettings.getSetting('INVENTREE_DEFAULT_CURRENCY'),
[order, globalSettings]
);
const purchaseOrderFields = usePurchaseOrderFields({});
@ -133,222 +126,19 @@ export default function PurchaseOrderDetail() {
modelType: ModelType.purchaseorder
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'supplier_reference',
label: t`Supplier Reference`,
icon: 'reference',
hidden: !order.supplier_reference,
copy: true
},
{
type: 'link',
name: 'supplier',
icon: 'suppliers',
label: t`Supplier`,
model: ModelType.company
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.purchaseorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.purchaseorder,
icon: 'status',
hidden:
!order.status_custom_key || order.status_custom_key == order.status
}
];
const tr: DetailsField[] = [
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: order.line_items,
progress: order.completed_lines
},
{
type: 'link',
model: ModelType.stocklocation,
link: true,
name: 'destination',
label: t`Destination`,
hidden: !order.destination
},
{
type: 'text',
name: 'currency',
label: t`Order Currency`,
value_formatter: () => orderCurrency
},
{
type: 'text',
name: 'total_price',
label: t`Total Cost`,
value_formatter: () => {
return formatCurrency(order?.total_price, {
currency: order?.order_currency || order?.supplier_detail?.currency
});
}
}
];
const bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !order.link
},
{
type: 'text',
name: 'contact_detail.name',
label: t`Contact`,
icon: 'user',
copy: true,
hidden: !order.contact
},
{
type: 'text',
name: 'contact_detail.email',
label: t`Contact Email`,
icon: 'email',
copy: true,
hidden: !order.contact_detail?.email
},
{
type: 'text',
name: 'contact_detail.phone',
label: t`Contact Phone`,
icon: 'phone',
copy: true,
hidden: !order.contact_detail?.phone
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !order.project_code
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !order.responsible
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Creation Date`,
copy: true,
icon: 'calendar'
},
{
type: 'date',
name: 'issue_date',
label: t`Issue Date`,
icon: 'calendar',
copy: true,
hidden: !order.issue_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
copy: true,
hidden: !order.start_date
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
icon: 'calendar',
copy: true,
hidden: !order.target_date
},
{
type: 'date',
name: 'complete_date',
icon: 'calendar_check',
label: t`Completion Date`,
copy: true,
hidden: !order.complete_date
},
{
type: 'date',
name: 'updated_at',
label: t`Last Updated`,
icon: 'calendar',
copy: true,
showTime: true,
hidden: !order.updated_at
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.supplier_detail?.image}
pk={order.supplier}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
</ItemDetailsGrid>
);
}, [order, orderCurrency, instanceQuery]);
const orderPanels: PanelType[] = useMemo(() => {
return [
{
name: 'detail',
label: t`Order Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<PurchaseOrderDetailsPanel
instance={order}
allowImageEdit
refreshInstance={refreshInstance}
/>
)
},
{
name: 'line-items',

View File

@ -0,0 +1,258 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { useMemo } from 'react';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
import { formatCurrency } from '../../defaults/formatters';
import { useGlobalSettingsState } from '../../states/SettingsStates';
export function PurchaseOrderDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
}>) {
const globalSettings = useGlobalSettingsState();
const orderCurrency = useMemo(
() =>
instance?.order_currency ||
instance?.supplier_detail?.currency ||
globalSettings.getSetting('INVENTREE_DEFAULT_CURRENCY'),
[instance, globalSettings]
);
const tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'supplier_reference',
label: t`Supplier Reference`,
icon: 'reference',
hidden: !instance?.supplier_reference,
copy: true
},
{
type: 'link',
name: 'supplier',
icon: 'suppliers',
label: t`Supplier`,
model: ModelType.company
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.purchaseorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.purchaseorder,
icon: 'status',
hidden:
!instance?.status_custom_key ||
instance?.status_custom_key == instance?.status
}
];
const tr: DetailsField[] = [
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: instance?.line_items,
progress: instance?.completed_lines
},
{
type: 'link',
model: ModelType.stocklocation,
link: true,
name: 'destination',
label: t`Destination`,
hidden: !instance?.destination
},
{
type: 'text',
name: 'currency',
label: t`Order Currency`,
value_formatter: () => orderCurrency
},
{
type: 'text',
name: 'total_price',
label: t`Total Cost`,
value_formatter: () =>
formatCurrency(instance?.total_price, {
currency:
instance?.order_currency || instance?.supplier_detail?.currency
})
}
];
const bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !instance?.link
},
{
type: 'text',
name: 'contact_detail.name',
label: t`Contact`,
icon: 'user',
copy: true,
hidden: !instance?.contact
},
{
type: 'text',
name: 'contact_detail.email',
label: t`Contact Email`,
icon: 'email',
copy: true,
hidden: !instance?.contact_detail?.email
},
{
type: 'text',
name: 'contact_detail.phone',
label: t`Contact Phone`,
icon: 'phone',
copy: true,
hidden: !instance?.contact_detail?.phone
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !instance?.project_code
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !instance?.responsible
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Creation Date`,
copy: true,
icon: 'calendar'
},
{
type: 'date',
name: 'issue_date',
label: t`Issue Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.issue_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.start_date
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.target_date
},
{
type: 'date',
name: 'complete_date',
icon: 'calendar_check',
label: t`Completion Date`,
copy: true,
hidden: !instance?.complete_date
},
{
type: 'date',
name: 'updated_at',
label: t`Last Updated`,
icon: 'calendar',
copy: true,
showTime: true,
hidden: !instance?.updated_at
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.purchaseorder,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ fields: tr, item: instance },
{ fields: bl, item: instance },
{ fields: br, item: instance },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.purchase_order : undefined}
imageActions={
allowImageEdit ? { uploadFile: true, deleteFile: true } : {}
}
apiPath={apiUrl(ApiEndpoints.company_list, instance?.supplier)}
src={instance?.supplier_detail?.image}
pk={instance?.supplier}
refresh={refreshInstance}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={instance} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Accordion, Grid, Skeleton, Stack, Text } from '@mantine/core';
import { Accordion, Stack } from '@mantine/core';
import { IconInfoCircle, IconList } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
@ -9,17 +9,10 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
CancelItemAction,
@ -34,9 +27,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import ParametersPanel from '../../components/panels/ParametersPanel';
import { RenderAddress } from '../../components/render/Company';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { formatCurrency } from '../../defaults/formatters';
import { useReturnOrderFields } from '../../forms/ReturnOrderForms';
import {
useCreateApiFormModal,
@ -48,6 +39,7 @@ import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import ExtraLineItemTable from '../../tables/general/ExtraLineItemTable';
import ReturnOrderLineItemTable from '../../tables/sales/ReturnOrderLineItemTable';
import { ReturnOrderDetailsPanel } from './ReturnOrderDetailsPanel';
/**
* Detail page for a single ReturnOrder
@ -99,233 +91,19 @@ export default function ReturnOrderDetail() {
);
}, [order, globalSettings]);
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'customer_reference',
label: t`Customer Reference`,
icon: 'customer',
copy: true,
hidden: !order.customer_reference
},
{
type: 'link',
name: 'customer',
icon: 'customers',
label: t`Customer`,
model: ModelType.company
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.returnorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.returnorder,
icon: 'status',
hidden:
!order.status_custom_key || order.status_custom_key == order.status
}
];
const tr: DetailsField[] = [
{
type: 'text',
name: 'line_items',
label: t`Line Items`,
icon: 'list'
},
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: order.line_items,
progress: order.completed_lines
},
{
type: 'text',
name: 'currency',
label: t`Order Currency`,
value_formatter: () =>
order?.order_currency ?? order?.customer_detail?.currency
},
{
type: 'text',
name: 'total_price',
label: t`Total Cost`,
value_formatter: () => {
return formatCurrency(order?.total_price, {
currency: order?.order_currency || order?.customer_detail?.currency
});
}
}
];
const bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !order.link
},
{
type: 'text',
name: 'address',
label: t`Return Address`,
icon: 'address',
value_formatter: () =>
order.address_detail ? (
<RenderAddress instance={order.address_detail} />
) : (
<Text size='sm' c='red'>{t`Not specified`}</Text>
)
},
{
type: 'text',
name: 'contact_detail.name',
label: t`Contact`,
icon: 'user',
copy: true,
hidden: !order.contact
},
{
type: 'text',
name: 'contact_detail.email',
label: t`Contact Email`,
icon: 'email',
copy: true,
hidden: !order.contact_detail?.email
},
{
type: 'text',
name: 'contact_detail.phone',
label: t`Contact Phone`,
icon: 'phone',
copy: true,
hidden: !order.contact_detail?.phone
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !order.project_code
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !order.responsible
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Creation Date`,
icon: 'calendar',
copy: true,
hidden: !order.creation_date
},
{
type: 'date',
name: 'issue_date',
label: t`Issue Date`,
icon: 'calendar',
copy: true,
hidden: !order.issue_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
copy: true,
hidden: !order.start_date
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
copy: true,
hidden: !order.target_date
},
{
type: 'date',
name: 'complete_date',
icon: 'calendar_check',
label: t`Completion Date`,
copy: true,
hidden: !order.complete_date
},
{
type: 'date',
name: 'updated_at',
label: t`Last Updated`,
icon: 'calendar',
copy: true,
showTime: true,
hidden: !order.updated_at
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.customer_detail?.image}
pk={order.customer}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
</ItemDetailsGrid>
);
}, [order, instanceQuery]);
const orderPanels: PanelType[] = useMemo(() => {
return [
{
name: 'detail',
label: t`Order Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<ReturnOrderDetailsPanel
instance={order}
allowImageEdit
refreshInstance={refreshInstance}
/>
)
},
{
name: 'line-items',

View File

@ -0,0 +1,270 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack, Text } from '@mantine/core';
import { useMemo } from 'react';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
import { RenderAddress } from '../../components/render/Company';
import { formatCurrency } from '../../defaults/formatters';
import { useGlobalSettingsState } from '../../states/SettingsStates';
export function ReturnOrderDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
}>) {
const globalSettings = useGlobalSettingsState();
const orderCurrency = useMemo(
() =>
instance?.order_currency ||
instance?.customer_detail?.currency ||
globalSettings.getSetting('INVENTREE_DEFAULT_CURRENCY'),
[instance, globalSettings]
);
const tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'customer_reference',
label: t`Customer Reference`,
icon: 'customer',
copy: true,
hidden: !instance?.customer_reference
},
{
type: 'link',
name: 'customer',
icon: 'customers',
label: t`Customer`,
model: ModelType.company
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.returnorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.returnorder,
icon: 'status',
hidden:
!instance?.status_custom_key ||
instance?.status_custom_key == instance?.status
}
];
const tr: DetailsField[] = [
{
type: 'text',
name: 'line_items',
label: t`Line Items`,
icon: 'list'
},
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: instance?.line_items,
progress: instance?.completed_lines
},
{
type: 'text',
name: 'currency',
label: t`Order Currency`,
value_formatter: () =>
instance?.order_currency ?? instance?.customer_detail?.currency
},
{
type: 'text',
name: 'total_price',
label: t`Total Cost`,
value_formatter: () =>
formatCurrency(instance?.total_price, {
currency:
instance?.order_currency || instance?.customer_detail?.currency
})
}
];
const bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !instance?.link
},
{
type: 'text',
name: 'address',
label: t`Return Address`,
icon: 'address',
value_formatter: () =>
instance?.address_detail ? (
<RenderAddress instance={instance.address_detail} />
) : (
<Text size='sm' c='red'>{t`Not specified`}</Text>
)
},
{
type: 'text',
name: 'contact_detail.name',
label: t`Contact`,
icon: 'user',
copy: true,
hidden: !instance?.contact
},
{
type: 'text',
name: 'contact_detail.email',
label: t`Contact Email`,
icon: 'email',
copy: true,
hidden: !instance?.contact_detail?.email
},
{
type: 'text',
name: 'contact_detail.phone',
label: t`Contact Phone`,
icon: 'phone',
copy: true,
hidden: !instance?.contact_detail?.phone
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !instance?.project_code
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !instance?.responsible
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Creation Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.creation_date
},
{
type: 'date',
name: 'issue_date',
label: t`Issue Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.issue_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.start_date
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
copy: true,
hidden: !instance?.target_date
},
{
type: 'date',
name: 'complete_date',
icon: 'calendar_check',
label: t`Completion Date`,
copy: true,
hidden: !instance?.complete_date
},
{
type: 'date',
name: 'updated_at',
label: t`Last Updated`,
icon: 'calendar',
copy: true,
showTime: true,
hidden: !instance?.updated_at
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.returnorder,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ fields: tr, item: instance },
{ fields: bl, item: instance },
{ fields: br, item: instance },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.return_order : undefined}
imageActions={
allowImageEdit ? { uploadFile: true, deleteFile: true } : {}
}
apiPath={apiUrl(ApiEndpoints.company_list, instance?.customer)}
src={instance?.customer_detail?.image}
pk={instance?.customer}
refresh={refreshInstance}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={instance} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Accordion, Grid, Skeleton, Stack, Text } from '@mantine/core';
import { Accordion, Skeleton, Stack } from '@mantine/core';
import {
IconBookmark,
IconCubeSend,
@ -11,7 +11,6 @@ import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { StylishText } from '@lib/components/StylishText';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@ -20,12 +19,6 @@ import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
CancelItemAction,
@ -40,9 +33,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import ParametersPanel from '../../components/panels/ParametersPanel';
import { RenderAddress } from '../../components/render/Company';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { formatCurrency } from '../../defaults/formatters';
import { useSalesOrderFields } from '../../forms/SalesOrderForms';
import {
useCreateApiFormModal,
@ -57,6 +48,7 @@ import ExtraLineItemTable from '../../tables/general/ExtraLineItemTable';
import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable';
import SalesOrderLineItemTable from '../../tables/sales/SalesOrderLineItemTable';
import SalesOrderShipmentTable from '../../tables/sales/SalesOrderShipmentTable';
import { SalesOrderDetailsPanel } from './SalesOrderDetailsPanel';
/**
* Detail page for a single SalesOrder
@ -89,227 +81,6 @@ export default function SalesOrderDetail() {
);
}, [order, globalSettings]);
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'customer_reference',
label: t`Customer Reference`,
copy: true,
icon: 'reference',
hidden: !order.customer_reference
},
{
type: 'link',
name: 'customer',
icon: 'customers',
label: t`Customer`,
model: ModelType.company
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.salesorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.salesorder,
icon: 'status',
hidden:
!order.status_custom_key || order.status_custom_key == order.status
}
];
const tr: DetailsField[] = [
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: order.line_items,
progress: order.completed_lines,
hidden: !order.line_items
},
{
type: 'progressbar',
name: 'shipments',
icon: 'shipment',
label: t`Completed Shipments`,
total: order.shipments_count,
progress: order.completed_shipments_count,
hidden: !order.shipments_count
},
{
type: 'text',
name: 'currency',
label: t`Order Currency`,
value_formatter: () => orderCurrency
},
{
type: 'text',
name: 'total_price',
label: t`Total Cost`,
value_formatter: () => {
return formatCurrency(order?.total_price, {
currency: orderCurrency
});
}
}
];
const bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !order.link
},
{
type: 'text',
name: 'address',
label: t`Shipping Address`,
icon: 'address',
value_formatter: () =>
order.address_detail ? (
<RenderAddress instance={order.address_detail} />
) : (
<Text size='sm' c='red'>{t`Not specified`}</Text>
)
},
{
type: 'text',
name: 'contact_detail.name',
label: t`Contact`,
icon: 'user',
copy: true,
hidden: !order.contact
},
{
type: 'text',
name: 'contact_detail.email',
label: t`Contact Email`,
icon: 'email',
copy: true,
hidden: !order.contact_detail?.email
},
{
type: 'text',
name: 'contact_detail.phone',
label: t`Contact Phone`,
icon: 'phone',
copy: true,
hidden: !order.contact_detail?.phone
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !order.project_code
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !order.responsible
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Creation Date`,
copy: true,
hidden: !order.creation_date
},
{
type: 'date',
name: 'issue_date',
label: t`Issue Date`,
icon: 'calendar',
copy: true,
hidden: !order.issue_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
hidden: !order.start_date,
copy: true
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
hidden: !order.target_date,
copy: true
},
{
type: 'date',
name: 'shipment_date',
label: t`Completion Date`,
hidden: !order.shipment_date,
copy: true
},
{
type: 'date',
name: 'updated_at',
label: t`Last Updated`,
icon: 'calendar',
copy: true,
showTime: true,
hidden: !order.updated_at
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.customer_detail?.image}
pk={order.customer}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
</ItemDetailsGrid>
);
}, [order, orderCurrency, instanceQuery]);
const soStatus = useStatusCodes({ modelType: ModelType.salesorder });
const lineItemsEditable: boolean = useMemo(() => {
@ -364,7 +135,13 @@ export default function SalesOrderDetail() {
name: 'detail',
label: t`Order Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<SalesOrderDetailsPanel
instance={order}
allowImageEdit
refreshInstance={refreshInstance}
/>
)
},
{
name: 'line-items',

View File

@ -0,0 +1,270 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack, Text } from '@mantine/core';
import { useMemo } from 'react';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
import { RenderAddress } from '../../components/render/Company';
import { formatCurrency } from '../../defaults/formatters';
import { useGlobalSettingsState } from '../../states/SettingsStates';
export function SalesOrderDetailsPanel({
instance,
allowImageEdit = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
refreshInstance?: () => void;
}>) {
const globalSettings = useGlobalSettingsState();
const orderCurrency = useMemo(
() =>
instance?.order_currency ||
instance?.customer_detail?.currency ||
globalSettings.getSetting('INVENTREE_DEFAULT_CURRENCY'),
[instance, globalSettings]
);
const tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'text',
name: 'customer_reference',
label: t`Customer Reference`,
copy: true,
icon: 'reference',
hidden: !instance?.customer_reference
},
{
type: 'link',
name: 'customer',
icon: 'customers',
label: t`Customer`,
model: ModelType.company
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.salesorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.salesorder,
icon: 'status',
hidden:
!instance?.status_custom_key ||
instance?.status_custom_key == instance?.status
}
];
const tr: DetailsField[] = [
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: instance?.line_items,
progress: instance?.completed_lines,
hidden: !instance?.line_items
},
{
type: 'progressbar',
name: 'shipments',
icon: 'shipment',
label: t`Completed Shipments`,
total: instance?.shipments_count,
progress: instance?.completed_shipments_count,
hidden: !instance?.shipments_count
},
{
type: 'text',
name: 'currency',
label: t`Order Currency`,
value_formatter: () => orderCurrency
},
{
type: 'text',
name: 'total_price',
label: t`Total Cost`,
value_formatter: () =>
formatCurrency(instance?.total_price, {
currency: orderCurrency
})
}
];
const bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !instance?.link
},
{
type: 'text',
name: 'address',
label: t`Shipping Address`,
icon: 'address',
value_formatter: () =>
instance?.address_detail ? (
<RenderAddress instance={instance.address_detail} />
) : (
<Text size='sm' c='red'>{t`Not specified`}</Text>
)
},
{
type: 'text',
name: 'contact_detail.name',
label: t`Contact`,
icon: 'user',
copy: true,
hidden: !instance?.contact_detail?.name
},
{
type: 'text',
name: 'contact_detail.email',
label: t`Contact Email`,
icon: 'email',
copy: true,
hidden: !instance?.contact_detail?.email
},
{
type: 'text',
name: 'contact_detail.phone',
label: t`Contact Phone`,
icon: 'phone',
copy: true,
hidden: !instance?.contact_detail?.phone
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !instance?.project_code_label
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !instance?.responsible
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Creation Date`,
copy: true,
hidden: !instance?.creation_date
},
{
type: 'date',
name: 'issue_date',
label: t`Issue Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.issue_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
hidden: !instance?.start_date,
copy: true
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
hidden: !instance?.target_date,
copy: true
},
{
type: 'date',
name: 'shipment_date',
label: t`Completion Date`,
hidden: !instance?.shipment_date,
copy: true
},
{
type: 'date',
name: 'updated_at',
label: t`Last Updated`,
icon: 'calendar',
copy: true,
showTime: true,
hidden: !instance?.updated_at
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.salesorder,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ fields: tr, item: instance },
{ fields: bl, item: instance },
{ fields: br, item: instance },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.sales_order : undefined}
imageActions={
allowImageEdit ? { uploadFile: true, deleteFile: true } : {}
}
apiPath={apiUrl(ApiEndpoints.company_list, instance?.customer)}
src={instance?.customer_detail?.image}
pk={instance?.customer}
refresh={refreshInstance}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={instance} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack, Text } from '@mantine/core';
import { Stack } from '@mantine/core';
import {
IconBookmark,
IconCircleCheck,
@ -13,18 +13,11 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { getDetailUrl } from '@lib/functions/Navigation';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
CancelItemAction,
@ -37,9 +30,6 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import ParametersPanel from '../../components/panels/ParametersPanel';
import { RenderAddress } from '../../components/render/Company';
import { RenderUser } from '../../components/render/User';
import { formatDate } from '../../defaults/formatters';
import {
useCheckShipmentForm,
useCompleteShipmentForm,
@ -53,6 +43,7 @@ import {
import { useInstance } from '../../hooks/UseInstance';
import { useUserState } from '../../states/UserState';
import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable';
import { SalesOrderShipmentDetailsPanel } from './SalesOrderShipmentDetailsPanel';
export default function SalesOrderShipmentDetail() {
const { id } = useParams();
@ -74,190 +65,21 @@ export default function SalesOrderShipmentDetail() {
}
});
const { instance: customer, instanceQuery: customerQuery } = useInstance({
endpoint: ApiEndpoints.company_list,
pk: shipment.order_detail?.customer,
hasPrimaryKey: true
});
const isPending = useMemo(() => !shipment.shipment_date, [shipment]);
const isChecked = useMemo(() => !!shipment.checked_by, [shipment]);
const detailsPanel = useMemo(() => {
if (shipmentQuery.isFetching || customerQuery.isFetching) {
return <Skeleton />;
}
const data: any = {
...shipment,
customer: customer?.pk,
customer_name: customer?.name,
customer_reference: shipment.order_detail?.customer_reference
};
// Top Left: Order / customer information
const tl: DetailsField[] = [
{
type: 'link',
model: ModelType.salesorder,
name: 'order',
label: t`Sales Order`,
icon: 'sales_orders',
model_field: 'reference'
},
{
type: 'link',
name: 'customer',
icon: 'customers',
label: t`Customer`,
model: ModelType.company,
model_field: 'name',
hidden: !data.customer
},
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !shipment.link
}
];
// Top right: Shipment information
const tr: DetailsField[] = [
{
type: 'text',
name: 'customer_reference',
icon: 'serial',
label: t`Customer Reference`,
hidden: !data.customer_reference,
copy: true
},
{
type: 'text',
name: 'reference',
icon: 'serial',
label: t`Shipment Reference`,
copy: true
},
{
type: 'text',
name: 'tracking_number',
label: t`Tracking Number`,
icon: 'trackable',
value_formatter: () => shipment.tracking_number || '---',
copy: !!shipment.tracking_number
},
{
type: 'text',
name: 'invoice_number',
label: t`Invoice Number`,
icon: 'serial',
value_formatter: () => shipment.invoice_number || '---',
copy: !!shipment.invoice_number
}
];
const address: any =
shipment.shipment_address_detail || shipment.order_detail?.address_detail;
const bl: DetailsField[] = [
{
type: 'text',
name: 'address',
label: t`Shipping Address`,
icon: 'address',
value_formatter: () =>
address ? (
<RenderAddress
instance={
shipment.shipment_address_detail ||
shipment.order_detail?.address_detail
}
/>
) : (
<Text size='sm' c='red'>{t`Not specified`}</Text>
)
}
];
const br: DetailsField[] = [
{
type: 'text',
name: 'allocated_items',
icon: 'packages',
label: t`Allocated Items`
},
{
type: 'text',
name: 'checked_by',
label: t`Checked By`,
icon: 'check',
value_formatter: () =>
shipment.checked_by_detail ? (
<RenderUser instance={shipment.checked_by_detail} />
) : (
<Text size='sm' c='red'>{t`Not checked`}</Text>
)
},
{
type: 'text',
name: 'shipment_date',
label: t`Shipment Date`,
icon: 'calendar',
value_formatter: () => formatDate(shipment.shipment_date),
hidden: !shipment.shipment_date
},
{
type: 'text',
name: 'delivery_date',
label: t`Delivery Date`,
icon: 'calendar',
value_formatter: () => formatDate(shipment.delivery_date),
hidden: !shipment.delivery_date
}
];
return (
<>
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.sales_order}
apiPath={ApiEndpoints.company_list}
src={customer?.image}
pk={customer?.pk}
imageActions={{
selectExisting: false,
downloadImage: false,
uploadFile: false,
deleteFile: false
}}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={shipment.tags} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
</ItemDetailsGrid>
</>
);
}, [shipment, shipmentQuery, customer, customerQuery]);
const shipmentPanels: PanelType[] = useMemo(() => {
return [
{
name: 'detail',
label: t`Shipment Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<SalesOrderShipmentDetailsPanel
instance={shipment}
refreshInstance={refreshShipment}
/>
)
},
{
name: 'items',
@ -288,7 +110,7 @@ export default function SalesOrderShipmentDetail() {
has_note: !!shipment.notes
})
];
}, [isPending, shipment, detailsPanel]);
}, [isPending, shipment]);
const editShipmentFields = useSalesOrderShipmentFields({
pending: isPending,
@ -454,7 +276,7 @@ export default function SalesOrderShipmentDetail() {
}
]}
badges={shipmentBadges}
imageUrl={customer?.image}
imageUrl={shipment.order_detail?.customer_detail?.image}
editAction={editShipment.open}
editEnabled={user.hasChangePermission(ModelType.salesordershipment)}
actions={shipmentActions}

View File

@ -0,0 +1,205 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack, Text } from '@mantine/core';
import { useMemo } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { TagsList } from '@lib/index';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
import { RenderAddress } from '../../components/render/Company';
import { RenderUser } from '../../components/render/User';
import { formatDate } from '../../defaults/formatters';
import { useInstance } from '../../hooks/UseInstance';
export function SalesOrderShipmentDetailsPanel({
instance,
refreshInstance
}: Readonly<{
instance: any;
refreshInstance?: () => void;
}>) {
const { instance: customer } = useInstance({
endpoint: ApiEndpoints.company_list,
pk: instance?.order_detail?.customer,
hasPrimaryKey: true
});
const data = useMemo(
() => ({
...instance,
customer: customer?.pk,
customer_name: customer?.name,
customer_reference: instance?.order_detail?.customer_reference
}),
[instance, customer]
);
const address = useMemo(
() =>
instance?.shipment_address_detail ||
instance?.order_detail?.address_detail,
[instance]
);
const tl: DetailsField[] = [
{
type: 'link',
model: ModelType.salesorder,
name: 'order',
label: t`Sales Order`,
icon: 'sales_orders',
model_field: 'reference'
},
{
type: 'link',
name: 'customer',
icon: 'customers',
label: t`Customer`,
model: ModelType.company,
model_field: 'name',
hidden: !data.customer
},
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !instance?.link
}
];
const tr: DetailsField[] = [
{
type: 'text',
name: 'customer_reference',
icon: 'serial',
label: t`Customer Reference`,
hidden: !data.customer_reference,
copy: true
},
{
type: 'text',
name: 'reference',
icon: 'serial',
label: t`Shipment Reference`,
copy: true
},
{
type: 'text',
name: 'tracking_number',
label: t`Tracking Number`,
icon: 'trackable',
value_formatter: () => instance?.tracking_number || '---',
copy: !!instance?.tracking_number
},
{
type: 'text',
name: 'invoice_number',
label: t`Invoice Number`,
icon: 'serial',
value_formatter: () => instance?.invoice_number || '---',
copy: !!instance?.invoice_number
}
];
const bl: DetailsField[] = [
{
type: 'text',
name: 'address',
label: t`Shipping Address`,
icon: 'address',
value_formatter: () =>
address ? (
<RenderAddress instance={address} />
) : (
<Text size='sm' c='red'>{t`Not specified`}</Text>
)
}
];
const br: DetailsField[] = [
{
type: 'text',
name: 'allocated_items',
icon: 'packages',
label: t`Allocated Items`
},
{
type: 'text',
name: 'checked_by',
label: t`Checked By`,
icon: 'check',
value_formatter: () =>
instance?.checked_by_detail ? (
<RenderUser instance={instance.checked_by_detail} />
) : (
<Text size='sm' c='red'>{t`Not checked`}</Text>
)
},
{
type: 'text',
name: 'shipment_date',
label: t`Shipment Date`,
icon: 'calendar',
value_formatter: () => formatDate(instance?.shipment_date),
hidden: !instance?.shipment_date
},
{
type: 'text',
name: 'delivery_date',
label: t`Delivery Date`,
icon: 'calendar',
value_formatter: () => formatDate(instance?.delivery_date),
hidden: !instance?.delivery_date
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.salesordershipment,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ fields: tr, item: data },
{ fields: bl, item: data },
{ fields: br, item: data },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.sales_order}
apiPath={apiUrl(ApiEndpoints.company_list, customer?.pk)}
src={customer?.image}
pk={customer?.pk}
imageActions={{
selectExisting: false,
downloadImage: false,
uploadFile: false,
deleteFile: false
}}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
);
}

View File

@ -7,7 +7,7 @@ import type { TableFilter } from '@lib/index';
import type { StockOperationProps } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel';
import { t } from '@lingui/core/macro';
import { Group, Skeleton, Stack } from '@mantine/core';
import { Skeleton, Stack } from '@mantine/core';
import {
IconCalendar,
IconInfoCircle,
@ -24,11 +24,6 @@ import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialo
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import OrderCalendar from '../../components/calendar/OrderCalendar';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
DeleteItemAction,
@ -60,6 +55,7 @@ import StockLocationParametricTable from '../../tables/stock/StockLocationParame
import { StockLocationTable } from '../../tables/stock/StockLocationTable';
import TransferOrderParametricTable from '../../tables/stock/TransferOrderParametricTable';
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
import { StockLocationDetailsPanel } from './StockLocationDetailsPanel';
function TransferOrderCalendar() {
const calendarFilters: TableFilter[] = useMemo(() => {
@ -105,92 +101,12 @@ export default function Stock() {
}
});
const detailsPanel = useMemo(() => {
if (id && instanceQuery.isFetching) {
return <Skeleton />;
}
const left: DetailsField[] = [
{
type: 'text',
name: 'name',
label: t`Name`,
copy: true,
value_formatter: () => (
<Group gap='xs'>
{location.icon && <ApiIcon name={location.icon} />}
{location.name}
</Group>
)
},
{
type: 'text',
name: 'pathstring',
label: t`Path`,
icon: 'sitemap',
copy: true,
hidden: !id
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
name: 'parent',
model_field: 'name',
icon: 'location',
label: t`Parent Location`,
model: ModelType.stocklocation,
hidden: !location?.parent
}
];
const right: DetailsField[] = [
{
type: 'text',
name: 'items',
icon: 'stock',
label: t`Stock Items`,
value_formatter: () => location?.items || '0'
},
{
type: 'text',
name: 'sublocations',
icon: 'location',
label: t`Sublocations`,
hidden: !location?.sublocations
},
{
type: 'boolean',
name: 'structural',
label: t`Structural`,
icon: 'sitemap'
},
{
type: 'boolean',
name: 'external',
label: t`External`
},
{
type: 'string',
// TODO: render location type icon here (ref: #7237)
name: 'location_type_detail.name',
label: t`Location Type`,
hidden: !location?.location_type,
icon: 'packages'
}
];
return (
<ItemDetailsGrid>
{id && location?.pk && <DetailsTable item={location} fields={left} />}
{id && location?.pk && <DetailsTable item={location} fields={right} />}
</ItemDetailsGrid>
const detailsPanel =
id && instanceQuery.isFetching ? (
<Skeleton />
) : (
<StockLocationDetailsPanel instance={id ? location : undefined} />
);
}, [location, instanceQuery]);
const [sublocationView, setSublocationView] = useState<string>('table');
const [transferOrderView, setTransferOrderView] = useState<string>('table');

View File

@ -1,18 +1,6 @@
import { t } from '@lingui/core/macro';
import { Accordion, Skeleton, Stack } from '@mantine/core';
import {
Accordion,
Button,
Grid,
Group,
Skeleton,
Space,
Stack,
Text,
Tooltip
} from '@mantine/core';
import {
IconArrowLeft,
IconArrowRight,
IconArrowsSplit,
IconBookmark,
IconBoxPadding,
@ -20,7 +8,6 @@ import {
IconHistory,
IconInfoCircle,
IconPackages,
IconSearch,
IconShoppingCart,
IconSitemap,
IconTransform
@ -29,27 +16,19 @@ import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ActionButton } from '@lib/components/ActionButton';
import { StylishText } from '@lib/components/StylishText';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl, getOverviewUrl } from '@lib/functions/Navigation';
import { TagsList } from '@lib/index';
import type { ApiFormFieldSet, StockOperationProps } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel';
import { notifications } from '@mantine/notifications';
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
ActionDropdown,
BarcodeActionDropdown,
@ -68,10 +47,9 @@ import LocateItemButton from '../../components/plugins/LocateItemButton';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { useApi } from '../../contexts/ApiContext';
import { formatCurrency, formatDecimal } from '../../defaults/formatters';
import { formatDecimal } from '../../defaults/formatters';
import {
useDisassembleStockItem,
useFindSerialNumberForm,
useStockFields,
useStockItemSerializeFields
} from '../../forms/StockForms';
@ -92,6 +70,7 @@ import { StockItemTable } from '../../tables/stock/StockItemTable';
import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable';
import { StockTrackingTable } from '../../tables/stock/StockTrackingTable';
import TransferOrderAllocationTable from '../../tables/stock/TransferOrderAllocationTable';
import { StockDetailsPanel } from './StockDetailsPanel';
export default function StockDetail() {
const { id } = useParams();
@ -126,367 +105,13 @@ export default function StockDetail() {
}
});
const { instance: part, instanceQuery: partQuery } = useInstance({
const { instance: part } = useInstance({
endpoint: ApiEndpoints.part_list,
pk: stockitem?.part,
hasPrimaryKey: true,
defaultValue: {}
});
const { instance: serialNumbers, instanceQuery: serialNumbersQuery } =
useInstance({
endpoint: ApiEndpoints.stock_serial_info,
pk: id
});
const findBySerialNumber = useFindSerialNumberForm({
partId: stockitem.part
});
const detailsPanel = useMemo(() => {
const data = { ...stockitem };
const part = stockitem?.part_detail ?? {};
data.available_stock = Math.max(0, data.quantity - data.allocated);
if (instanceQuery.isFetching) {
return <Skeleton />;
}
// Top left - core part information
const tl: DetailsField[] = [
{
name: 'part',
label: t`Base Part`,
type: 'link',
model: ModelType.part
},
{
name: 'part_detail.IPN',
label: t`IPN`,
type: 'text',
copy: true,
icon: 'part',
hidden: !part.IPN
},
{
name: 'part_detail.revision',
label: t`Revision`,
type: 'string',
copy: true,
icon: 'revision',
hidden: !part.revision
},
{
name: 'status',
type: 'status',
label: t`Status`,
model: ModelType.stockitem
},
{
name: 'status_custom_key',
type: 'status',
label: t`Custom Status`,
model: ModelType.stockitem,
icon: 'status',
hidden:
!stockitem.status_custom_key ||
stockitem.status_custom_key == stockitem.status
},
{
type: 'link',
name: 'link',
label: t`Link`,
external: true,
copy: true,
hidden: !stockitem.link
}
];
// Top right - available stock information
const tr: DetailsField[] = [
{
type: 'text',
name: 'serial',
label: t`Serial Number`,
hidden: !stockitem.serial,
value_formatter: () => (
<Group gap='xs' justify='space-apart'>
<Text>{stockitem.serial}</Text>
<Space flex={10} />
<Group gap={2} justify='right'>
{serialNumbers.previous?.pk && (
<Tooltip label={t`Previous serial number`} position='top'>
<Button
p={3}
aria-label='previous-serial-number'
leftSection={<IconArrowLeft />}
variant='transparent'
size='sm'
onClick={() => {
navigate(
getDetailUrl(
ModelType.stockitem,
serialNumbers.previous.pk
)
);
}}
>
{serialNumbers.previous.serial}
</Button>
</Tooltip>
)}
<ActionButton
icon={<IconSearch size={18} />}
tooltip={t`Find serial number`}
tooltipAlignment='top'
variant='transparent'
onClick={findBySerialNumber.open}
/>
{serialNumbers.next?.pk && (
<Tooltip label={t`Next serial number`} position='top'>
<Button
p={3}
aria-label='next-serial-number'
rightSection={<IconArrowRight />}
variant='transparent'
size='sm'
onClick={() => {
navigate(
getDetailUrl(ModelType.stockitem, serialNumbers.next.pk)
);
}}
>
{serialNumbers.next.serial}
</Button>
</Tooltip>
)}
</Group>
</Group>
)
},
{
type: 'number',
name: 'quantity',
label: t`Quantity`,
unit: part?.units,
hidden: !!stockitem.serial && stockitem.quantity == 1
},
{
type: 'number',
name: 'available_stock',
label: t`Available`,
unit: part?.units,
icon: 'stock',
hidden: stockitem.in_stock == false
},
{
type: 'number',
name: 'allocated',
label: t`Allocated to Orders`,
unit: part?.units,
icon: 'tick_off',
hidden: !stockitem.allocated
},
{
type: 'text',
name: 'batch',
label: t`Batch Code`,
hidden: !stockitem.batch
}
];
// Bottom left: location information
const bl: DetailsField[] = [
{
name: 'supplier_part',
label: t`Supplier Part`,
type: 'link',
model_field: 'SKU',
model: ModelType.supplierpart,
hidden: !stockitem.supplier_part
},
{
type: 'link',
name: 'location',
label: t`Location`,
model: ModelType.stocklocation,
hidden: !stockitem.location
},
{
type: 'link',
name: 'belongs_to',
label: t`Installed In`,
model_filters: {
part_detail: true
},
model_formatter: (model: any) => {
let text = model?.part_detail?.full_name ?? model?.name;
if (model.serial && model.quantity == 1) {
text += ` # ${model.serial}`;
}
return text;
},
icon: 'stock',
model: ModelType.stockitem,
hidden: !stockitem.belongs_to
},
{
type: 'link',
name: 'parent',
icon: 'sitemap',
label: t`Parent Item`,
model: ModelType.stockitem,
hidden: !stockitem.parent,
model_formatter: (model: any) => {
return t`Parent stock item`;
}
},
{
type: 'link',
name: 'consumed_by',
label: t`Consumed By`,
model: ModelType.build,
hidden: !stockitem.consumed_by,
icon: 'build',
model_field: 'reference'
},
{
type: 'link',
name: 'build',
label: t`Build Order`,
model: ModelType.build,
hidden: !stockitem.build,
model_field: 'reference'
},
{
type: 'link',
name: 'purchase_order',
label: t`Purchase Order`,
model: ModelType.purchaseorder,
hidden: !stockitem.purchase_order,
icon: 'purchase_orders',
model_field: 'reference'
},
{
type: 'link',
name: 'sales_order',
label: t`Sales Order`,
model: ModelType.salesorder,
hidden: !stockitem.sales_order,
icon: 'sales_orders',
model_field: 'reference'
},
{
type: 'link',
name: 'customer',
label: t`Customer`,
model: ModelType.company,
hidden: !stockitem.customer
}
];
// Bottom right - any other information
const br: DetailsField[] = [
// Expiry date
{
type: 'date',
name: 'expiry_date',
label: t`Expiry Date`,
hidden: !enableExpiry || !stockitem.expiry_date,
icon: 'calendar'
},
// TODO: Ownership
{
type: 'text',
name: 'purchase_price',
label: t`Unit Price`,
icon: 'currency',
hidden: !stockitem.purchase_price,
value_formatter: () => {
return formatCurrency(stockitem.purchase_price, {
currency: stockitem.purchase_price_currency
});
}
},
{
type: 'text',
name: 'stock_value',
label: t`Stock Value`,
icon: 'currency',
hidden:
!stockitem.purchase_price ||
stockitem.quantity == 1 ||
stockitem.quantity == 0,
value_formatter: () => {
return formatCurrency(stockitem.purchase_price, {
currency: stockitem.purchase_price_currency,
multiplier: stockitem.quantity
});
}
},
{
type: 'text',
name: 'packaging',
icon: 'part',
label: t`Packaging`,
hidden: !stockitem.packaging
},
{
type: 'date',
name: 'creation_date',
icon: 'calendar',
label: t`Created`,
hidden: !stockitem.creation_date
},
{
type: 'date',
name: 'updated',
icon: 'calendar',
label: t`Last Updated`
},
{
type: 'date',
name: 'stocktake_date',
icon: 'calendar',
label: t`Last Stocktake`,
hidden: !stockitem.stocktake_date
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
apiPath={ApiEndpoints.part_list}
src={
stockitem.part_detail?.image ??
stockitem?.part_detail?.thumbnail
}
pk={stockitem.part}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={stockitem.tags} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
</ItemDetailsGrid>
);
}, [
stockitem,
serialNumbers,
serialNumbersQuery.isFetching,
instanceQuery.isFetching,
enableExpiry
]);
const showBuildAllocations: boolean = useMemo(() => {
// Determine if "build allocations" should be shown for this stock item
return (
@ -557,7 +182,14 @@ export default function StockDetail() {
name: 'details',
label: t`Stock Details`,
icon: <IconInfoCircle />,
content: detailsPanel
content: (
<StockDetailsPanel
instance={stockitem}
allowImageEdit
showSerialNav
refreshInstance={refreshInstance}
/>
)
},
{
name: 'tracking',
@ -688,8 +320,6 @@ export default function StockDetail() {
showBuildAllocations,
showInstalledItems,
stockitem,
serialNumbers,
serialNumbersQuery,
id,
user
]);
@ -1082,7 +712,6 @@ export default function StockDetail() {
return (
<>
{findBySerialNumber.modal}
{scanIntoLocation.dialog}
<InstanceDetail
query={instanceQuery}

View File

@ -0,0 +1,413 @@
import { t } from '@lingui/core/macro';
import {
Button,
Grid,
Group,
Skeleton,
Space,
Stack,
Text,
Tooltip
} from '@mantine/core';
import { IconArrowLeft, IconArrowRight, IconSearch } from '@tabler/icons-react';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { ActionButton } from '@lib/components/ActionButton';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { formatCurrency } from '../../defaults/formatters';
import { useFindSerialNumberForm } from '../../forms/StockForms';
import { useInstance } from '../../hooks/UseInstance';
import { useGlobalSettingsState } from '../../states/SettingsStates';
export function StockDetailsPanel({
instance,
allowImageEdit = false,
showSerialNav = false,
refreshInstance
}: Readonly<{
instance: any;
allowImageEdit?: boolean;
showSerialNav?: boolean;
refreshInstance?: () => void;
}>) {
const navigate = useNavigate();
const globalSettings = useGlobalSettingsState();
const enableExpiry = useMemo(
() => globalSettings.isSet('STOCK_ENABLE_EXPIRY'),
[globalSettings]
);
const { instance: serialNumbers } = useInstance({
endpoint: ApiEndpoints.stock_serial_info,
pk: instance?.pk,
hasPrimaryKey: true,
disabled: !instance?.pk,
defaultValue: {}
});
const { instance: fetchedPart } = useInstance({
endpoint: ApiEndpoints.part_list,
pk: instance?.part,
hasPrimaryKey: true,
disabled: !instance?.part || !!instance?.part_detail,
defaultValue: {}
});
const findBySerialNumber = useFindSerialNumberForm({
partId: instance?.part
});
const part = instance?.part_detail ?? fetchedPart ?? {};
const data = useMemo(() => {
const d = { ...instance };
d.available_stock = Math.max(0, (d.quantity ?? 0) - (d.allocated ?? 0));
return d;
}, [instance]);
const tl: DetailsField[] = [
{
name: 'part',
label: t`Base Part`,
type: 'link',
model: ModelType.part
},
{
name: 'part_detail.IPN',
label: t`IPN`,
type: 'text',
copy: true,
icon: 'part',
hidden: !part.IPN
},
{
name: 'part_detail.revision',
label: t`Revision`,
type: 'string',
copy: true,
icon: 'revision',
hidden: !part.revision
},
{
name: 'status',
type: 'status',
label: t`Status`,
model: ModelType.stockitem
},
{
name: 'status_custom_key',
type: 'status',
label: t`Custom Status`,
model: ModelType.stockitem,
icon: 'status',
hidden:
!instance?.status_custom_key ||
instance?.status_custom_key == instance?.status
},
{
type: 'link',
name: 'link',
label: t`Link`,
external: true,
copy: true,
hidden: !instance?.link
}
];
const tr: DetailsField[] = [
{
type: 'text',
name: 'serial',
label: t`Serial Number`,
hidden: !instance?.serial,
...(showSerialNav && {
value_formatter: () => (
<Group gap='xs' justify='space-apart'>
<Text>{instance?.serial}</Text>
<Space flex={10} />
<Group gap={2} justify='right'>
{serialNumbers?.previous?.pk && (
<Tooltip label={t`Previous serial number`} position='top'>
<Button
p={3}
aria-label='previous-serial-number'
leftSection={<IconArrowLeft />}
variant='transparent'
size='sm'
onClick={() => {
navigate(
getDetailUrl(
ModelType.stockitem,
serialNumbers.previous.pk
)
);
}}
>
{serialNumbers.previous.serial}
</Button>
</Tooltip>
)}
<ActionButton
icon={<IconSearch size={18} />}
tooltip={t`Find serial number`}
tooltipAlignment='top'
variant='transparent'
onClick={findBySerialNumber.open}
/>
{serialNumbers?.next?.pk && (
<Tooltip label={t`Next serial number`} position='top'>
<Button
p={3}
aria-label='next-serial-number'
rightSection={<IconArrowRight />}
variant='transparent'
size='sm'
onClick={() => {
navigate(
getDetailUrl(ModelType.stockitem, serialNumbers.next.pk)
);
}}
>
{serialNumbers.next.serial}
</Button>
</Tooltip>
)}
</Group>
</Group>
)
})
},
{
type: 'number',
name: 'quantity',
label: t`Quantity`,
unit: part?.units,
hidden: !!instance?.serial && instance?.quantity == 1
},
{
type: 'number',
name: 'available_stock',
label: t`Available`,
unit: part?.units,
icon: 'stock',
hidden: !!instance?.serial || instance.in_stock === false
},
{
type: 'number',
name: 'allocated',
label: t`Allocated to Orders`,
unit: part?.units,
icon: 'tick_off',
hidden: !instance?.allocated
},
{
type: 'text',
name: 'batch',
label: t`Batch Code`,
hidden: !instance?.batch
}
];
const bl: DetailsField[] = [
{
name: 'supplier_part',
label: t`Supplier Part`,
type: 'link',
model_field: 'SKU',
model: ModelType.supplierpart,
hidden: !instance?.supplier_part
},
{
type: 'link',
name: 'location',
label: t`Location`,
model: ModelType.stocklocation,
hidden: !instance?.location
},
{
type: 'link',
name: 'belongs_to',
label: t`Installed In`,
model_filters: {
part_detail: true
},
model_formatter: (model: any) => {
let text = model?.part_detail?.full_name ?? model?.name;
if (model.serial && model.quantity == 1) {
text += ` # ${model.serial}`;
}
return text;
},
icon: 'stock',
model: ModelType.stockitem,
hidden: !instance?.belongs_to
},
{
type: 'link',
name: 'parent',
icon: 'sitemap',
label: t`Parent Item`,
model: ModelType.stockitem,
hidden: !instance?.parent,
model_formatter: () => t`Parent stock item`
},
{
type: 'link',
name: 'consumed_by',
label: t`Consumed By`,
model: ModelType.build,
hidden: !instance?.consumed_by,
icon: 'build',
model_field: 'reference'
},
{
type: 'link',
name: 'build',
label: t`Build Order`,
model: ModelType.build,
hidden: !instance?.build,
model_field: 'reference'
},
{
type: 'link',
name: 'purchase_order',
label: t`Purchase Order`,
model: ModelType.purchaseorder,
hidden: !instance?.purchase_order,
icon: 'purchase_orders',
model_field: 'reference'
},
{
type: 'link',
name: 'sales_order',
label: t`Sales Order`,
model: ModelType.salesorder,
hidden: !instance?.sales_order,
icon: 'sales_orders',
model_field: 'reference'
},
{
type: 'link',
name: 'customer',
label: t`Customer`,
model: ModelType.company,
hidden: !instance?.customer
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'expiry_date',
label: t`Expiry Date`,
hidden: !enableExpiry || !instance?.expiry_date,
icon: 'calendar'
},
{
type: 'text',
name: 'purchase_price',
label: t`Unit Price`,
icon: 'currency',
hidden: !instance?.purchase_price,
value_formatter: () =>
formatCurrency(instance?.purchase_price, {
currency: instance?.purchase_price_currency
})
},
{
type: 'text',
name: 'stock_value',
label: t`Stock Value`,
icon: 'currency',
hidden:
!instance?.purchase_price ||
instance?.quantity == 1 ||
instance?.quantity == 0,
value_formatter: () =>
formatCurrency(instance?.purchase_price, {
currency: instance?.purchase_price_currency,
multiplier: instance?.quantity
})
},
{
type: 'text',
name: 'packaging',
icon: 'part',
label: t`Packaging`,
hidden: !instance?.packaging
},
{
type: 'date',
name: 'creation_date',
icon: 'calendar',
label: t`Created`,
hidden: !instance?.creation_date
},
{
type: 'date',
name: 'updated',
icon: 'calendar',
label: t`Last Updated`
},
{
type: 'date',
name: 'stocktake_date',
icon: 'calendar',
label: t`Last Stocktake`,
hidden: !instance?.stocktake_date
}
];
if (!instance?.pk) return <Skeleton />;
return (
<>
{showSerialNav && findBySerialNumber.modal}
<ItemDetailsGrid
tables={[
{ fields: tr, item: data },
{ fields: bl, item: data },
{ fields: br, item: data }
]}
>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={allowImageEdit ? UserRoles.part : undefined}
imageActions={
allowImageEdit
? {
uploadFile: true,
deleteFile: true
}
: {}
}
apiPath={apiUrl(ApiEndpoints.part_list, instance?.part)}
src={part?.image ?? part?.thumbnail}
pk={instance?.part}
refresh={refreshInstance}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={instance?.tags} />
</Stack>
</ItemDetailsGrid>
</>
);
}

View File

@ -0,0 +1,106 @@
import { t } from '@lingui/core/macro';
import { Group, Skeleton } from '@mantine/core';
import { ModelType } from '@lib/enums/ModelType';
import type { DetailsField } from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
import { ApiIcon } from '../../components/items/ApiIcon';
export function StockLocationDetailsPanel({
instance
}: Readonly<{
instance: any;
}>) {
const left: DetailsField[] = [
{
type: 'text',
name: 'name',
label: t`Name`,
copy: true,
value_formatter: () => (
<Group gap='xs'>
{instance?.icon && <ApiIcon name={instance.icon} />}
{instance?.name}
</Group>
)
},
{
type: 'text',
name: 'pathstring',
label: t`Path`,
icon: 'sitemap',
copy: true,
hidden: !instance?.pathstring
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true,
hidden: !instance?.description
},
{
type: 'link',
name: 'parent',
model_field: 'name',
icon: 'location',
label: t`Parent Location`,
model: ModelType.stocklocation,
hidden: !instance?.parent
}
];
const right: DetailsField[] = [
{
type: 'text',
name: 'items',
icon: 'stock',
label: t`Stock Items`,
value_formatter: () => instance?.items || '0'
},
{
type: 'text',
name: 'sublocations',
icon: 'location',
label: t`Sublocations`,
hidden: !instance?.sublocations
},
{
type: 'boolean',
name: 'structural',
label: t`Structural`,
icon: 'sitemap'
},
{
type: 'boolean',
name: 'external',
label: t`External`
},
{
type: 'string',
name: 'location_type_detail.name',
label: t`Location Type`,
hidden: !instance?.location_type,
icon: 'packages'
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.stocklocation,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ item: instance, fields: left },
{ item: instance, fields: right },
parametersTable
]}
/>
);
}

View File

@ -1,12 +1,12 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { Skeleton, Stack } from '@mantine/core';
import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { type PanelType, TagsList, apiUrl } from '@lib/index';
import { type PanelType, apiUrl } from '@lib/index';
import {
IconBookmark,
IconInfoCircle,
@ -16,11 +16,6 @@ import {
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
BarcodeActionDropdown,
CancelItemAction,
@ -47,6 +42,7 @@ import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import TransferOrderAllocationTable from '../../tables/stock/TransferOrderAllocationTable';
import TransferOrderLineItemTable from '../../tables/stock/TransferOrderLineItemTable';
import { TransferOrderDetailsPanel } from './TransferOrderDetailsPanel';
export default function TransferOrderDetail() {
const { id } = useParams();
@ -93,169 +89,11 @@ export default function TransferOrderDetail() {
);
}, [order, toStatus]);
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'link',
name: 'take_from',
icon: 'location',
label: t`Source Location`,
model: ModelType.stocklocation
},
{
type: 'link',
name: 'destination',
icon: 'location',
label: t`Destination Location`,
model: ModelType.stocklocation
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.transferorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.transferorder,
icon: 'status',
hidden:
!order.status_custom_key || order.status_custom_key == order.status
}
];
const tr: DetailsField[] = [
{
type: 'boolean',
name: 'consume',
icon: 'consume',
label: t`Consume Stock`
},
{
type: 'text',
name: 'line_items',
label: t`Line Items`,
icon: 'list'
},
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: order.line_items,
progress: order.completed_lines
}
];
const bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !order.link
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !order.project_code
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !order.responsible
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Creation Date`,
icon: 'calendar',
copy: true,
hidden: !order.creation_date
},
{
type: 'date',
name: 'issue_date',
label: t`Issue Date`,
icon: 'calendar',
copy: true,
hidden: !order.issue_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
copy: true,
hidden: !order.start_date
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
copy: true,
hidden: !order.target_date
},
{
type: 'date',
name: 'complete_date',
icon: 'calendar_check',
label: t`Completion Date`,
copy: true,
hidden: !order.complete_date
}
];
return (
<ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow>
{/* TODO: what image do we show for a Transfer Order? */}
{/* <DetailsImage
appRole={UserRoles.transfer_order}
apiPath={ApiEndpoints.transfer_order_list}
src="/static/img/blank_image.png"
pk={order.pk}
/> */}
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
</ItemDetailsGrid>
);
}, [order, instanceQuery]);
const detailsPanel = instanceQuery.isFetching ? (
<Skeleton />
) : (
<TransferOrderDetailsPanel instance={order} />
);
const orderPanels: PanelType[] = useMemo(() => {
return [

View File

@ -0,0 +1,181 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { ModelType } from '@lib/enums/ModelType';
import { TagsList } from '@lib/index';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
export function TransferOrderDetailsPanel({
instance
}: Readonly<{
instance: any;
}>) {
const tl: DetailsField[] = [
{
type: 'text',
name: 'reference',
label: t`Reference`,
copy: true
},
{
type: 'link',
name: 'take_from',
icon: 'location',
label: t`Source Location`,
model: ModelType.stocklocation
},
{
type: 'link',
name: 'destination',
icon: 'location',
label: t`Destination Location`,
model: ModelType.stocklocation
},
{
type: 'text',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'status',
name: 'status',
label: t`Status`,
model: ModelType.transferorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.transferorder,
icon: 'status',
hidden:
!instance?.status_custom_key ||
instance?.status_custom_key == instance?.status
}
];
const tr: DetailsField[] = [
{
type: 'boolean',
name: 'consume',
icon: 'consume',
label: t`Consume Stock`
},
{
type: 'text',
name: 'line_items',
label: t`Line Items`,
icon: 'list'
},
{
type: 'progressbar',
name: 'completed',
icon: 'progress',
label: t`Completed Line Items`,
total: instance?.line_items,
progress: instance?.completed_lines
}
];
const bl: DetailsField[] = [
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !instance?.link
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !instance?.project_code
},
{
type: 'text',
name: 'responsible',
label: t`Responsible`,
badge: 'owner',
hidden: !instance?.responsible
}
];
const br: DetailsField[] = [
{
type: 'date',
name: 'creation_date',
label: t`Creation Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.creation_date
},
{
type: 'date',
name: 'issue_date',
label: t`Issue Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.issue_date
},
{
type: 'date',
name: 'start_date',
label: t`Start Date`,
icon: 'calendar',
copy: true,
hidden: !instance?.start_date
},
{
type: 'date',
name: 'target_date',
label: t`Target Date`,
copy: true,
hidden: !instance?.target_date
},
{
type: 'date',
name: 'complete_date',
icon: 'calendar_check',
label: t`Completion Date`,
copy: true,
hidden: !instance?.complete_date
}
];
const parametersTable = useParameterDetailsGrid({
model_type: ModelType.transferorder,
model_id: instance?.pk
});
if (!instance?.pk) return <Skeleton />;
return (
<ItemDetailsGrid
tables={[
{ fields: tr, item: instance },
{ fields: bl, item: instance },
{ fields: br, item: instance },
parametersTable
]}
>
<Stack gap='xs'>
<Grid grow>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={instance} />
</Grid.Col>
</Grid>
<TagsList tags={instance.tags} />
</Stack>
</ItemDetailsGrid>
);
}

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { navigateToLink } from '@lib/functions/Navigation';
import { eventModified, navigateToLink } from '@lib/functions/Navigation';
import useTable from '@lib/hooks/UseTable';
import type { TableFilter } from '@lib/types/Filters';
import {
@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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