From 69871699c0d997c7b0ce0892fe10865563b94214 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Fri, 1 Mar 2024 17:13:08 +1100
Subject: [PATCH] Details updates (#6605)

* Fix onClick behaviour for details image

* Moving items

- These are not "tables" per-se

* Refactoring for DetailsTable

* Skip hidden fields

* Cleanup table column widths

* Update part details

* Fix icons

* Add image back to part details

- Also fix onClick events

* Update stockitem details page

* Implement details page for build order

* Implement CompanyDetails page

* Implemented salesorder details

* Update SalesOrder detalis

* ReturnOrder detail

* PurchaseOrder detail page

* Cleanup build details page

* Stock location detail

* Part Category detail

* Bump API version

* Bug fixes

* Use image, not thumbnail

* Fix field copy

* Cleanup imgae hover

* Improve PartDetail

- Add more data
- Add icons

* Refactoring

- Move Details out of "tables" directory

* Remove old file

* Revert "Remove old file"

This reverts commit 6fd131f2a597963f28434d665f6f24c90d909357.

* Fix files

* Fix unused import
---
 InvenTree/InvenTree/api_version.py            |   8 +-
 InvenTree/company/serializers.py              |   6 +-
 InvenTree/part/serializers.py                 |  12 +-
 InvenTree/stock/serializers.py                |  10 +-
 .../details}/Details.tsx                      | 297 +++-----
 .../{images => details}/DetailsImage.tsx      |  80 ++-
 .../src/components/details/ItemDetails.tsx    |  14 +
 .../src/components/details/PartIcons.tsx      |  90 +++
 .../src/components/render/StatusRenderer.tsx  |   4 +-
 src/frontend/src/functions/icons.tsx          |  35 +-
 src/frontend/src/functions/urls.tsx           |   8 +-
 src/frontend/src/pages/build/BuildDetail.tsx  | 157 ++++-
 .../src/pages/company/CompanyDetail.tsx       |  94 ++-
 .../src/pages/part/CategoryDetail.tsx         |  85 ++-
 src/frontend/src/pages/part/PartDetail.tsx    | 639 +++++++++---------
 .../pages/purchasing/PurchaseOrderDetail.tsx  | 182 ++++-
 .../src/pages/sales/ReturnOrderDetail.tsx     | 165 ++++-
 .../src/pages/sales/SalesOrderDetail.tsx      | 153 ++++-
 .../src/pages/stock/LocationDetail.tsx        |  89 ++-
 src/frontend/src/pages/stock/StockDetail.tsx  | 161 ++++-
 src/frontend/src/tables/ItemDetails.tsx       |  88 ---
 21 files changed, 1691 insertions(+), 686 deletions(-)
 rename src/frontend/src/{tables => components/details}/Details.tsx (53%)
 rename src/frontend/src/components/{images => details}/DetailsImage.tsx (82%)
 create mode 100644 src/frontend/src/components/details/ItemDetails.tsx
 create mode 100644 src/frontend/src/components/details/PartIcons.tsx
 delete mode 100644 src/frontend/src/tables/ItemDetails.tsx

diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py
index 48dc8df775..c99f1d2faf 100644
--- a/InvenTree/InvenTree/api_version.py
+++ b/InvenTree/InvenTree/api_version.py
@@ -1,11 +1,17 @@
 """InvenTree API version information."""
 
 # InvenTree API version
-INVENTREE_API_VERSION = 178
+INVENTREE_API_VERSION = 179
 """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
 
 INVENTREE_API_TEXT = """
 
+v179 - 2024-03-01 : https://github.com/inventree/InvenTree/pull/6605
+    - Adds "subcategories" count to PartCategory serializer
+    - Adds "sublocations" count to StockLocation serializer
+    - Adds "image" field to PartBrief serializer
+    - Adds "image" field to CompanyBrief serializer
+
 v178 - 2024-02-29 : https://github.com/inventree/InvenTree/pull/6604
     - Adds "external_stock" field to the Part API endpoint
     - Adds "external_stock" field to the BomItem API endpoint
diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py
index ad6202e83a..f94333aca8 100644
--- a/InvenTree/company/serializers.py
+++ b/InvenTree/company/serializers.py
@@ -42,11 +42,13 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
         """Metaclass options."""
 
         model = Company
-        fields = ['pk', 'url', 'name', 'description', 'image']
+        fields = ['pk', 'url', 'name', 'description', 'image', 'thumbnail']
 
     url = serializers.CharField(source='get_absolute_url', read_only=True)
 
-    image = serializers.CharField(source='get_thumbnail_url', read_only=True)
+    image = InvenTreeImageSerializerField(read_only=True)
+
+    thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
 
 
 class AddressSerializer(InvenTreeModelSerializer):
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index c09504853c..38e2b7157d 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -74,6 +74,7 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer):
             'level',
             'parent',
             'part_count',
+            'subcategories',
             'pathstring',
             'path',
             'starred',
@@ -99,13 +100,18 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer):
     def annotate_queryset(queryset):
         """Annotate extra information to the queryset."""
         # Annotate the number of 'parts' which exist in each category (including subcategories!)
-        queryset = queryset.annotate(part_count=part.filters.annotate_category_parts())
+        queryset = queryset.annotate(
+            part_count=part.filters.annotate_category_parts(),
+            subcategories=part.filters.annotate_sub_categories(),
+        )
 
         return queryset
 
     url = serializers.CharField(source='get_absolute_url', read_only=True)
 
-    part_count = serializers.IntegerField(read_only=True)
+    part_count = serializers.IntegerField(read_only=True, label=_('Parts'))
+
+    subcategories = serializers.IntegerField(read_only=True, label=_('Subcategories'))
 
     level = serializers.IntegerField(read_only=True)
 
@@ -282,6 +288,7 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
             'revision',
             'full_name',
             'description',
+            'image',
             'thumbnail',
             'active',
             'assembly',
@@ -307,6 +314,7 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
             self.fields.pop('pricing_min')
             self.fields.pop('pricing_max')
 
+    image = InvenTree.serializers.InvenTreeImageSerializerField(read_only=True)
     thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
 
     # Pricing fields
diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py
index cae8460a41..88ed8e05a2 100644
--- a/InvenTree/stock/serializers.py
+++ b/InvenTree/stock/serializers.py
@@ -886,6 +886,7 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
             'pathstring',
             'path',
             'items',
+            'sublocations',
             'owner',
             'icon',
             'custom_icon',
@@ -911,13 +912,18 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
     def annotate_queryset(queryset):
         """Annotate extra information to the queryset."""
         # Annotate the number of stock items which exist in this category (including subcategories)
-        queryset = queryset.annotate(items=stock.filters.annotate_location_items())
+        queryset = queryset.annotate(
+            items=stock.filters.annotate_location_items(),
+            sublocations=stock.filters.annotate_sub_locations(),
+        )
 
         return queryset
 
     url = serializers.CharField(source='get_absolute_url', read_only=True)
 
-    items = serializers.IntegerField(read_only=True)
+    items = serializers.IntegerField(read_only=True, label=_('Stock Items'))
+
+    sublocations = serializers.IntegerField(read_only=True, label=_('Sublocations'))
 
     level = serializers.IntegerField(read_only=True)
 
diff --git a/src/frontend/src/tables/Details.tsx b/src/frontend/src/components/details/Details.tsx
similarity index 53%
rename from src/frontend/src/tables/Details.tsx
rename to src/frontend/src/components/details/Details.tsx
index b062635f5f..02de921a19 100644
--- a/src/frontend/src/tables/Details.tsx
+++ b/src/frontend/src/components/details/Details.tsx
@@ -14,15 +14,17 @@ import {
 import { useSuspenseQuery } from '@tanstack/react-query';
 import { Suspense, useMemo } from 'react';
 
-import { api } from '../App';
-import { ProgressBar } from '../components/items/ProgressBar';
-import { getModelInfo } from '../components/render/ModelType';
-import { ApiEndpoints } from '../enums/ApiEndpoints';
-import { ModelType } from '../enums/ModelType';
-import { InvenTreeIcon } from '../functions/icons';
-import { getDetailUrl } from '../functions/urls';
-import { apiUrl } from '../states/ApiState';
-import { useGlobalSettingsState } from '../states/SettingsState';
+import { api } from '../../App';
+import { ApiEndpoints } from '../../enums/ApiEndpoints';
+import { ModelType } from '../../enums/ModelType';
+import { InvenTreeIcon } from '../../functions/icons';
+import { getDetailUrl } from '../../functions/urls';
+import { apiUrl } from '../../states/ApiState';
+import { useGlobalSettingsState } from '../../states/SettingsState';
+import { ProgressBar } from '../items/ProgressBar';
+import { YesNoButton } from '../items/YesNoButton';
+import { getModelInfo } from '../render/ModelType';
+import { StatusRenderer } from '../render/StatusRenderer';
 
 export type PartIconsType = {
   assembly: boolean;
@@ -37,12 +39,20 @@ export type PartIconsType = {
 
 export type DetailsField =
   | {
+      hidden?: boolean;
+      icon?: string;
       name: string;
       label?: string;
       badge?: BadgeType;
       copy?: boolean;
       value_formatter?: () => ValueFormatterReturn;
-    } & (StringDetailField | LinkDetailField | ProgressBarfield);
+    } & (
+      | StringDetailField
+      | BooleanField
+      | LinkDetailField
+      | ProgressBarfield
+      | StatusField
+    );
 
 type BadgeType = 'owner' | 'user' | 'group';
 type ValueFormatterReturn = string | number | null;
@@ -52,12 +62,20 @@ type StringDetailField = {
   unit?: boolean;
 };
 
+type BooleanField = {
+  type: 'boolean';
+};
+
 type LinkDetailField = {
   type: 'link';
+  link?: boolean;
 } & (InternalLinkField | ExternalLinkField);
 
 type InternalLinkField = {
   model: ModelType;
+  model_field?: string;
+  model_formatter?: (value: any) => string;
+  backup_value?: string;
 };
 
 type ExternalLinkField = {
@@ -70,6 +88,11 @@ type ProgressBarfield = {
   total: number;
 };
 
+type StatusField = {
+  type: 'status';
+  model: ModelType;
+};
+
 type FieldValueType = string | number | undefined;
 
 type FieldProps = {
@@ -78,101 +101,6 @@ type FieldProps = {
   unit?: string | null;
 };
 
-/**
- * Fetches and wraps an InvenTreeIcon in a flex div
- * @param icon name of icon
- *
- */
-function PartIcon(icon: string) {
-  return (
-    <div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
-      <InvenTreeIcon icon={icon} />
-    </div>
-  );
-}
-
-/**
- * Generates a table cell with Part icons.
- * Only used for Part Model Details
- */
-function PartIcons({
-  assembly,
-  template,
-  component,
-  trackable,
-  purchaseable,
-  saleable,
-  virtual,
-  active
-}: PartIconsType) {
-  return (
-    <td colSpan={2}>
-      <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
-        {!active && (
-          <Tooltip label={t`Part is not active`}>
-            <Badge color="red" variant="filled">
-              <div
-                style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
-              >
-                <InvenTreeIcon icon="inactive" iconProps={{ size: 19 }} />{' '}
-                <Trans>Inactive</Trans>
-              </div>
-            </Badge>
-          </Tooltip>
-        )}
-        {template && (
-          <Tooltip
-            label={t`Part is a template part (variants can be made from this part)`}
-            children={PartIcon('template')}
-          />
-        )}
-        {assembly && (
-          <Tooltip
-            label={t`Part can be assembled from other parts`}
-            children={PartIcon('assembly')}
-          />
-        )}
-        {component && (
-          <Tooltip
-            label={t`Part can be used in assemblies`}
-            children={PartIcon('component')}
-          />
-        )}
-        {trackable && (
-          <Tooltip
-            label={t`Part stock is tracked by serial number`}
-            children={PartIcon('trackable')}
-          />
-        )}
-        {purchaseable && (
-          <Tooltip
-            label={t`Part can be purchased from external suppliers`}
-            children={PartIcon('purchaseable')}
-          />
-        )}
-        {saleable && (
-          <Tooltip
-            label={t`Part can be sold to customers`}
-            children={PartIcon('saleable')}
-          />
-        )}
-        {virtual && (
-          <Tooltip label={t`Part is virtual (not a physical part)`}>
-            <Badge color="yellow" variant="filled">
-              <div
-                style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
-              >
-                <InvenTreeIcon icon="virtual" iconProps={{ size: 18 }} />{' '}
-                <Trans>Virtual</Trans>
-              </div>
-            </Badge>
-          </Tooltip>
-        )}
-      </div>
-    </td>
-  );
-}
-
 /**
  * Fetches user or group info from backend and formats into a badge.
  * Badge shows username, full name, or group name depending on server settings.
@@ -253,13 +181,17 @@ function NameBadge({ pk, type }: { pk: string | number; type: BadgeType }) {
  * If user is defined, a badge is rendered in addition to main value
  */
 function TableStringValue(props: FieldProps) {
-  let value = props.field_value;
+  let value = props?.field_value;
 
-  if (props.field_data.value_formatter) {
+  if (value === undefined) {
+    return '---';
+  }
+
+  if (props.field_data?.value_formatter) {
     value = props.field_data.value_formatter();
   }
 
-  if (props.field_data.badge) {
+  if (props.field_data?.badge) {
     return <NameBadge pk={value} type={props.field_data.badge} />;
   }
 
@@ -267,17 +199,21 @@ function TableStringValue(props: FieldProps) {
     <div style={{ display: 'flex', justifyContent: 'space-between' }}>
       <Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
         <span>
-          {value ? value : props.field_data.unit && '0'}{' '}
+          {value ? value : props.field_data?.unit && '0'}{' '}
           {props.field_data.unit == true && props.unit}
         </span>
       </Suspense>
       {props.field_data.user && (
-        <NameBadge pk={props.field_data.user} type="user" />
+        <NameBadge pk={props.field_data?.user} type="user" />
       )}
     </div>
   );
 }
 
+function BooleanValue(props: FieldProps) {
+  return <YesNoButton value={props.field_value} />;
+}
+
 function TableAnchorValue(props: FieldProps) {
   if (props.field_data.external) {
     return (
@@ -299,7 +235,7 @@ function TableAnchorValue(props: FieldProps) {
     queryFn: async () => {
       const modelDef = getModelInfo(props.field_data.model);
 
-      if (!modelDef.api_endpoint) {
+      if (!modelDef?.api_endpoint) {
         return {};
       }
 
@@ -325,15 +261,37 @@ function TableAnchorValue(props: FieldProps) {
     return getDetailUrl(props.field_data.model, props.field_value);
   }, [props.field_data.model, props.field_value]);
 
+  let make_link = props.field_data?.link ?? true;
+
+  // Construct the "return value" for the fetched data
+  let value = undefined;
+
+  if (props.field_data.model_formatter) {
+    value = props.field_data.model_formatter(data) ?? value;
+  } else if (props.field_data.model_field) {
+    value = data?.[props.field_data.model_field] ?? value;
+  } else {
+    value = data?.name;
+  }
+
+  if (value === undefined) {
+    value = data?.name ?? props.field_data?.backup_value ?? 'No name defined';
+    make_link = false;
+  }
+
   return (
     <Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
-      <Anchor
-        href={`/platform${detailUrl}`}
-        target={data?.external ? '_blank' : undefined}
-        rel={data?.external ? 'noreferrer noopener' : undefined}
-      >
-        <Text>{data.name ?? 'No name defined'}</Text>
-      </Anchor>
+      {make_link ? (
+        <Anchor
+          href={`/platform${detailUrl}`}
+          target={data?.external ? '_blank' : undefined}
+          rel={data?.external ? 'noreferrer noopener' : undefined}
+        >
+          <Text>{value}</Text>
+        </Anchor>
+      ) : (
+        <Text>{value}</Text>
+      )}
     </Suspense>
   );
 }
@@ -348,6 +306,12 @@ function ProgressBarValue(props: FieldProps) {
   );
 }
 
+function StatusValue(props: FieldProps) {
+  return (
+    <StatusRenderer type={props.field_data.model} status={props.field_value} />
+  );
+}
+
 function CopyField({ value }: { value: string }) {
   return (
     <CopyButton value={value}>
@@ -366,27 +330,33 @@ function CopyField({ value }: { value: string }) {
   );
 }
 
-function TableField({
-  field_data,
-  field_value,
-  unit = null
+export function DetailsTableField({
+  item,
+  field
 }: {
-  field_data: DetailsField[];
-  field_value: FieldValueType[];
-  unit?: string | null;
+  item: any;
+  field: DetailsField;
 }) {
   function getFieldType(type: string) {
     switch (type) {
       case 'text':
       case 'string':
         return TableStringValue;
+      case 'boolean':
+        return BooleanValue;
       case 'link':
         return TableAnchorValue;
       case 'progressbar':
         return ProgressBarValue;
+      case 'status':
+        return StatusValue;
+      default:
+        return TableStringValue;
     }
   }
 
+  const FieldType: any = getFieldType(field.type);
+
   return (
     <tr>
       <td
@@ -394,35 +364,20 @@ function TableField({
           display: 'flex',
           alignItems: 'center',
           gap: '20px',
+          width: '50',
           justifyContent: 'flex-start'
         }}
       >
-        <InvenTreeIcon icon={field_data[0].name} />
-        <Text>{field_data[0].label}</Text>
+        <InvenTreeIcon icon={field.icon ?? field.name} />
+      </td>
+      <td>
+        <Text>{field.label}</Text>
       </td>
       <td style={{ minWidth: '40%' }}>
-        <div style={{ display: 'flex', justifyContent: 'space-between' }}>
-          <div
-            style={{
-              display: 'flex',
-              justifyContent: 'space-between',
-              flexGrow: '1'
-            }}
-          >
-            {field_data.map((data: DetailsField, index: number) => {
-              let FieldType: any = getFieldType(data.type);
-              return (
-                <FieldType
-                  field_data={data}
-                  field_value={field_value[index]}
-                  unit={unit}
-                  key={index}
-                />
-              );
-            })}
-          </div>
-          {field_data[0].copy && <CopyField value={`${field_value[0]}`} />}
-        </div>
+        <FieldType field_data={field} field_value={item[field.name]} />
+      </td>
+      <td style={{ width: '50' }}>
+        {field.copy && <CopyField value={item[field.name]} />}
       </td>
     </tr>
   );
@@ -430,50 +385,20 @@ function TableField({
 
 export function DetailsTable({
   item,
-  fields,
-  partIcons = false
+  fields
 }: {
   item: any;
-  fields: DetailsField[][];
-  partIcons?: boolean;
+  fields: DetailsField[];
 }) {
   return (
     <Paper p="xs" withBorder radius="xs">
       <Table striped>
         <tbody>
-          {partIcons && (
-            <tr>
-              <PartIcons
-                assembly={item.assembly}
-                template={item.is_template}
-                component={item.component}
-                trackable={item.trackable}
-                purchaseable={item.purchaseable}
-                saleable={item.salable}
-                virtual={item.virtual}
-                active={item.active}
-              />
-            </tr>
-          )}
-          {fields.map((data: DetailsField[], index: number) => {
-            let value: FieldValueType[] = [];
-            for (const val of data) {
-              if (val.value_formatter) {
-                value.push(undefined);
-              } else {
-                value.push(item[val.name]);
-              }
-            }
-
-            return (
-              <TableField
-                field_data={data}
-                field_value={value}
-                key={index}
-                unit={item.units}
-              />
-            );
-          })}
+          {fields
+            .filter((field: DetailsField) => !field.hidden)
+            .map((field: DetailsField, index: number) => (
+              <DetailsTableField field={field} item={item} key={index} />
+            ))}
         </tbody>
       </Table>
     </Paper>
diff --git a/src/frontend/src/components/images/DetailsImage.tsx b/src/frontend/src/components/details/DetailsImage.tsx
similarity index 82%
rename from src/frontend/src/components/images/DetailsImage.tsx
rename to src/frontend/src/components/details/DetailsImage.tsx
index 4eda7d0809..6aed0729b9 100644
--- a/src/frontend/src/components/images/DetailsImage.tsx
+++ b/src/frontend/src/components/details/DetailsImage.tsx
@@ -4,7 +4,6 @@ import {
   Button,
   Group,
   Image,
-  Modal,
   Overlay,
   Paper,
   Text,
@@ -12,9 +11,9 @@ import {
   useMantineTheme
 } from '@mantine/core';
 import { Dropzone, FileWithPath, IMAGE_MIME_TYPE } from '@mantine/dropzone';
-import { useDisclosure, useHover } from '@mantine/hooks';
+import { useHover } from '@mantine/hooks';
 import { modals } from '@mantine/modals';
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
 
 import { api } from '../../App';
 import { UserRoles } from '../../enums/Roles';
@@ -22,8 +21,8 @@ import { InvenTreeIcon } from '../../functions/icons';
 import { useUserState } from '../../states/UserState';
 import { PartThumbTable } from '../../tables/part/PartThumbTable';
 import { ActionButton } from '../buttons/ActionButton';
+import { ApiImage } from '../images/ApiImage';
 import { StylishText } from '../items/StylishText';
-import { ApiImage } from './ApiImage';
 
 /**
  * Props for detail image
@@ -32,7 +31,7 @@ export type DetailImageProps = {
   appRole: UserRoles;
   src: string;
   apiPath: string;
-  refresh: () => void;
+  refresh?: () => void;
   imageActions?: DetailImageButtonProps;
   pk: string;
 };
@@ -267,7 +266,10 @@ function ImageActionButtons({
               variant="outline"
               size="lg"
               tooltipAlignment="top"
-              onClick={() => {
+              onClick={(event: any) => {
+                event?.preventDefault();
+                event?.stopPropagation();
+                event?.nativeEvent?.stopImmediatePropagation();
                 modals.open({
                   title: <StylishText size="xl">{t`Select Image`}</StylishText>,
                   size: 'xxl',
@@ -285,7 +287,10 @@ function ImageActionButtons({
               variant="outline"
               size="lg"
               tooltipAlignment="top"
-              onClick={() => {
+              onClick={(event: any) => {
+                event?.preventDefault();
+                event?.stopPropagation();
+                event?.nativeEvent?.stopImmediatePropagation();
                 modals.open({
                   title: <StylishText size="xl">{t`Upload Image`}</StylishText>,
                   children: (
@@ -304,7 +309,12 @@ function ImageActionButtons({
               variant="outline"
               size="lg"
               tooltipAlignment="top"
-              onClick={() => removeModal(apiPath, setImage)}
+              onClick={(event: any) => {
+                event?.preventDefault();
+                event?.stopPropagation();
+                event?.nativeEvent?.stopImmediatePropagation();
+                removeModal(apiPath, setImage);
+              }}
             />
           )}
         </Group>
@@ -324,11 +334,30 @@ export function DetailsImage(props: DetailImageProps) {
   // Sets a new image, and triggers upstream instance refresh
   const setAndRefresh = (image: string) => {
     setImg(image);
-    props.refresh();
+    props.refresh && props.refresh();
   };
 
   const permissions = useUserState();
 
+  const hasOverlay: boolean = useMemo(() => {
+    return (
+      props.imageActions?.selectExisting ||
+      props.imageActions?.uploadFile ||
+      props.imageActions?.deleteFile ||
+      false
+    );
+  }, [props.imageActions]);
+
+  const expandImage = (event: any) => {
+    event?.preventDefault();
+    event?.stopPropagation();
+    event?.nativeEvent?.stopImmediatePropagation();
+    modals.open({
+      children: <ApiImage src={img} />,
+      withCloseButton: false
+    });
+  };
+
   return (
     <>
       <AspectRatio ref={ref} maw={IMAGE_DIMENSION} ratio={1}>
@@ -337,25 +366,22 @@ export function DetailsImage(props: DetailImageProps) {
             src={img}
             height={IMAGE_DIMENSION}
             width={IMAGE_DIMENSION}
-            onClick={() => {
-              modals.open({
-                children: <ApiImage src={img} />,
-                withCloseButton: false
-              });
-            }}
+            onClick={expandImage}
           />
-          {permissions.hasChangeRole(props.appRole) && hovered && (
-            <Overlay color="black" opacity={0.8}>
-              <ImageActionButtons
-                visible={hovered}
-                actions={props.imageActions}
-                apiPath={props.apiPath}
-                hasImage={props.src ? true : false}
-                pk={props.pk}
-                setImage={setAndRefresh}
-              />
-            </Overlay>
-          )}
+          {permissions.hasChangeRole(props.appRole) &&
+            hasOverlay &&
+            hovered && (
+              <Overlay color="black" opacity={0.8} onClick={expandImage}>
+                <ImageActionButtons
+                  visible={hovered}
+                  actions={props.imageActions}
+                  apiPath={props.apiPath}
+                  hasImage={props.src ? true : false}
+                  pk={props.pk}
+                  setImage={setAndRefresh}
+                />
+              </Overlay>
+            )}
         </>
       </AspectRatio>
     </>
diff --git a/src/frontend/src/components/details/ItemDetails.tsx b/src/frontend/src/components/details/ItemDetails.tsx
new file mode 100644
index 0000000000..d8e1069d2a
--- /dev/null
+++ b/src/frontend/src/components/details/ItemDetails.tsx
@@ -0,0 +1,14 @@
+import { Paper, SimpleGrid } from '@mantine/core';
+import React from 'react';
+
+import { DetailImageButtonProps } from './DetailsImage';
+
+export function ItemDetailsGrid(props: React.PropsWithChildren<{}>) {
+  return (
+    <Paper p="xs">
+      <SimpleGrid cols={2} spacing="xs" verticalSpacing="xs">
+        {props.children}
+      </SimpleGrid>
+    </Paper>
+  );
+}
diff --git a/src/frontend/src/components/details/PartIcons.tsx b/src/frontend/src/components/details/PartIcons.tsx
new file mode 100644
index 0000000000..ccaf7bef73
--- /dev/null
+++ b/src/frontend/src/components/details/PartIcons.tsx
@@ -0,0 +1,90 @@
+import { Trans, t } from '@lingui/macro';
+import { Badge, Tooltip } from '@mantine/core';
+
+import { InvenTreeIcon } from '../../functions/icons';
+
+/**
+ * Fetches and wraps an InvenTreeIcon in a flex div
+ * @param icon name of icon
+ *
+ */
+function PartIcon(icon: string) {
+  return (
+    <div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
+      <InvenTreeIcon icon={icon} />
+    </div>
+  );
+}
+
+/**
+ * Generates a table cell with Part icons.
+ * Only used for Part Model Details
+ */
+export function PartIcons({ part }: { part: any }) {
+  return (
+    <td colSpan={2}>
+      <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
+        {!part.active && (
+          <Tooltip label={t`Part is not active`}>
+            <Badge color="red" variant="filled">
+              <div
+                style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
+              >
+                <InvenTreeIcon icon="inactive" iconProps={{ size: 19 }} />{' '}
+                <Trans>Inactive</Trans>
+              </div>
+            </Badge>
+          </Tooltip>
+        )}
+        {part.template && (
+          <Tooltip
+            label={t`Part is a template part (variants can be made from this part)`}
+            children={PartIcon('template')}
+          />
+        )}
+        {part.assembly && (
+          <Tooltip
+            label={t`Part can be assembled from other parts`}
+            children={PartIcon('assembly')}
+          />
+        )}
+        {part.component && (
+          <Tooltip
+            label={t`Part can be used in assemblies`}
+            children={PartIcon('component')}
+          />
+        )}
+        {part.trackable && (
+          <Tooltip
+            label={t`Part stock is tracked by serial number`}
+            children={PartIcon('trackable')}
+          />
+        )}
+        {part.purchaseable && (
+          <Tooltip
+            label={t`Part can be purchased from external suppliers`}
+            children={PartIcon('purchaseable')}
+          />
+        )}
+        {part.saleable && (
+          <Tooltip
+            label={t`Part can be sold to customers`}
+            children={PartIcon('saleable')}
+          />
+        )}
+        {part.virtual && (
+          <Tooltip label={t`Part is virtual (not a physical part)`}>
+            <Badge color="yellow" variant="filled">
+              <div
+                style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
+              >
+                <InvenTreeIcon icon="virtual" iconProps={{ size: 18 }} />{' '}
+                <Trans>Virtual</Trans>
+              </div>
+            </Badge>
+          </Tooltip>
+        )}
+      </div>
+    </td>
+  );
+}
diff --git a/src/frontend/src/components/render/StatusRenderer.tsx b/src/frontend/src/components/render/StatusRenderer.tsx
index 21bcc549ac..c737c7e905 100644
--- a/src/frontend/src/components/render/StatusRenderer.tsx
+++ b/src/frontend/src/components/render/StatusRenderer.tsx
@@ -22,7 +22,7 @@ interface renderStatusLabelOptionsInterface {
  * Generic function to render a status label
  */
 function renderStatusLabel(
-  key: string,
+  key: string | number,
   codes: StatusCodeListInterface,
   options: renderStatusLabelOptionsInterface = {}
 ) {
@@ -68,7 +68,7 @@ export const StatusRenderer = ({
   type,
   options
 }: {
-  status: string;
+  status: string | number;
   type: ModelType | string;
   options?: renderStatusLabelOptionsInterface;
 }) => {
diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx
index e0774beed9..73fac29841 100644
--- a/src/frontend/src/functions/icons.tsx
+++ b/src/frontend/src/functions/icons.tsx
@@ -2,32 +2,44 @@ import {
   Icon123,
   IconBinaryTree2,
   IconBookmarks,
+  IconBox,
   IconBuilding,
   IconBuildingFactory2,
+  IconBuildingStore,
+  IconCalendar,
   IconCalendarStats,
   IconCheck,
   IconClipboardList,
   IconCopy,
   IconCornerUpRightDouble,
   IconCurrencyDollar,
+  IconDotsCircleHorizontal,
   IconExternalLink,
   IconFileUpload,
   IconGitBranch,
   IconGridDots,
+  IconHash,
   IconLayersLinked,
   IconLink,
   IconList,
   IconListTree,
+  IconMail,
+  IconMapPin,
   IconMapPinHeart,
   IconNotes,
+  IconNumbers,
   IconPackage,
+  IconPackageImport,
   IconPackages,
   IconPaperclip,
+  IconPhone,
   IconPhoto,
+  IconProgressCheck,
   IconQuestionMark,
   IconRulerMeasure,
   IconShoppingCart,
   IconShoppingCartHeart,
+  IconSitemap,
   IconStack2,
   IconStatusChange,
   IconTag,
@@ -41,6 +53,7 @@ import {
   IconUserStar,
   IconUsersGroup,
   IconVersions,
+  IconWorld,
   IconWorldCode,
   IconX
 } from '@tabler/icons-react';
@@ -67,6 +80,8 @@ const icons: { [key: string]: (props: TablerIconsProps) => React.JSX.Element } =
     revision: IconGitBranch,
     units: IconRulerMeasure,
     keywords: IconTag,
+    status: IconInfoCircle,
+    info: IconInfoCircle,
     details: IconInfoCircle,
     parameters: IconList,
     stock: IconPackages,
@@ -77,8 +92,10 @@ const icons: { [key: string]: (props: TablerIconsProps) => React.JSX.Element } =
     used_in: IconStack2,
     manufacturers: IconBuildingFactory2,
     suppliers: IconBuilding,
+    customers: IconBuildingStore,
     purchase_orders: IconShoppingCart,
     sales_orders: IconTruckDelivery,
+    shipment: IconTruckDelivery,
     scheduling: IconCalendarStats,
     test_templates: IconTestPipe,
     related_parts: IconLayersLinked,
@@ -91,6 +108,7 @@ const icons: { [key: string]: (props: TablerIconsProps) => React.JSX.Element } =
     delete: IconTrash,
 
     // Part Icons
+    active: IconCheck,
     template: IconCopy,
     assembly: IconTool,
     component: IconGridDots,
@@ -99,19 +117,31 @@ const icons: { [key: string]: (props: TablerIconsProps) => React.JSX.Element } =
     saleable: IconCurrencyDollar,
     virtual: IconWorldCode,
     inactive: IconX,
+    part: IconBox,
+    supplier_part: IconPackageImport,
 
+    calendar: IconCalendar,
     external: IconExternalLink,
     creation_date: IconCalendarTime,
+    location: IconMapPin,
     default_location: IconMapPinHeart,
     default_supplier: IconShoppingCartHeart,
     link: IconLink,
     responsible: IconUserStar,
     pricing: IconCurrencyDollar,
+    currency: IconCurrencyDollar,
     stocktake: IconClipboardList,
     user: IconUser,
     group: IconUsersGroup,
     check: IconCheck,
-    copy: IconCopy
+    copy: IconCopy,
+    quantity: IconNumbers,
+    progress: IconProgressCheck,
+    reference: IconHash,
+    website: IconWorld,
+    email: IconMail,
+    phone: IconPhone,
+    sitemap: IconSitemap
   };
 
 /**
@@ -138,3 +168,6 @@ export function InvenTreeIcon(props: IconProps) {
 
   return <Icon {...props.iconProps} />;
 }
+function IconShapes(props: TablerIconsProps): Element {
+  throw new Error('Function not implemented.');
+}
diff --git a/src/frontend/src/functions/urls.tsx b/src/frontend/src/functions/urls.tsx
index 5920439c89..55a3ae687c 100644
--- a/src/frontend/src/functions/urls.tsx
+++ b/src/frontend/src/functions/urls.tsx
@@ -7,10 +7,14 @@ import { ModelType } from '../enums/ModelType';
 export function getDetailUrl(model: ModelType, pk: number | string): string {
   const modelInfo = ModelInformationDict[model];
 
-  if (modelInfo && modelInfo.url_detail) {
+  if (pk === undefined || pk === null) {
+    return '';
+  }
+
+  if (!!pk && modelInfo && modelInfo.url_detail) {
     return modelInfo.url_detail.replace(':pk', pk.toString());
   }
 
-  console.error(`No detail URL found for model ${model}!`);
+  console.error(`No detail URL found for model ${model} <${pk}>`);
   return '';
 }
diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx
index 5f32b55fc5..bb13247656 100644
--- a/src/frontend/src/pages/build/BuildDetail.tsx
+++ b/src/frontend/src/pages/build/BuildDetail.tsx
@@ -1,5 +1,5 @@
 import { t } from '@lingui/macro';
-import { Group, LoadingOverlay, Skeleton, Stack, Table } from '@mantine/core';
+import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
 import {
   IconClipboardCheck,
   IconClipboardList,
@@ -17,6 +17,9 @@ import {
 import { useMemo } from 'react';
 import { useParams } from 'react-router-dom';
 
+import { DetailsField, DetailsTable } from '../../components/details/Details';
+import { DetailsImage } from '../../components/details/DetailsImage';
+import { ItemDetailsGrid } from '../../components/details/ItemDetails';
 import {
   ActionDropdown,
   DuplicateItemAction,
@@ -33,6 +36,7 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
 import { ModelType } from '../../enums/ModelType';
 import { UserRoles } from '../../enums/Roles';
 import { buildOrderFields } from '../../forms/BuildForms';
+import { partCategoryFields } from '../../forms/PartForms';
 import { useEditApiFormModal } from '../../hooks/UseForm';
 import { useInstance } from '../../hooks/UseInstance';
 import { apiUrl } from '../../states/ApiState';
@@ -63,36 +67,127 @@ export default function BuildDetail() {
     refetchOnMount: true
   });
 
-  const buildDetailsPanel = useMemo(() => {
+  const detailsPanel = useMemo(() => {
+    if (instanceQuery.isFetching) {
+      return <Skeleton />;
+    }
+
+    let tl: DetailsField[] = [
+      {
+        type: 'link',
+        name: 'part',
+        label: t`Part`,
+        model: ModelType.part
+      },
+      {
+        type: 'status',
+        name: 'status',
+        label: t`Status`,
+        model: ModelType.build
+      },
+      {
+        type: 'text',
+        name: 'reference',
+        label: t`Reference`
+      },
+      {
+        type: 'text',
+        name: 'title',
+        label: t`Description`,
+        icon: 'description'
+      },
+      {
+        type: 'link',
+        name: 'parent',
+        icon: 'builds',
+        label: t`Parent Build`,
+        model_field: 'reference',
+        model: ModelType.build,
+        hidden: !build.parent
+      }
+    ];
+
+    let tr: DetailsField[] = [
+      {
+        type: 'text',
+        name: 'quantity',
+        label: t`Build Quantity`
+      },
+      {
+        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
+      }
+    ];
+
+    let bl: DetailsField[] = [
+      {
+        type: 'text',
+        name: 'issued_by',
+        label: t`Issued By`,
+        badge: 'user'
+      },
+      {
+        type: 'text',
+        name: 'responsible',
+        label: t`Responsible`,
+        badge: 'owner',
+        hidden: !build.responsible
+      }
+    ];
+
+    let br: DetailsField[] = [
+      {
+        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
+      }
+    ];
+
     return (
-      <Group position="apart" grow>
-        <Table striped>
-          <tbody>
-            <tr>
-              <td>{t`Base Part`}</td>
-              <td>{build.part_detail?.name}</td>
-            </tr>
-            <tr>
-              <td>{t`Quantity`}</td>
-              <td>{build.quantity}</td>
-            </tr>
-            <tr>
-              <td>{t`Build Status`}</td>
-              <td>
-                {build?.status && (
-                  <StatusRenderer
-                    status={build.status}
-                    type={ModelType.build}
-                  />
-                )}
-              </td>
-            </tr>
-          </tbody>
-        </Table>
-        <Table></Table>
-      </Group>
+      <ItemDetailsGrid>
+        <Grid>
+          <Grid.Col span={4}>
+            <DetailsImage
+              appRole={UserRoles.part}
+              apiPath={ApiEndpoints.part_list}
+              src={build.part_detail?.image ?? build.part_detail?.thumbnail}
+              pk={build.part}
+            />
+          </Grid.Col>
+          <Grid.Col span={8}>
+            <DetailsTable fields={tl} item={build} />
+          </Grid.Col>
+        </Grid>
+        <DetailsTable fields={tr} item={build} />
+        <DetailsTable fields={bl} item={build} />
+        <DetailsTable fields={br} item={build} />
+      </ItemDetailsGrid>
     );
-  }, [build]);
+  }, [build, instanceQuery]);
 
   const buildPanels: PanelType[] = useMemo(() => {
     return [
@@ -100,7 +195,7 @@ export default function BuildDetail() {
         name: 'details',
         label: t`Build Details`,
         icon: <IconInfoCircle />,
-        content: buildDetailsPanel
+        content: detailsPanel
       },
       {
         name: 'allocate-stock',
@@ -259,7 +354,7 @@ export default function BuildDetail() {
           title={build.reference}
           subtitle={build.title}
           detail={buildDetail}
-          imageUrl={build.part_detail?.thumbnail}
+          imageUrl={build.part_detail?.image ?? build.part_detail?.thumbnail}
           breadcrumbs={[
             { name: t`Build Orders`, url: '/build' },
             { name: build.reference, url: `/build/${build.pk}` }
diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx
index 6728cb1caf..dbbc2957e6 100644
--- a/src/frontend/src/pages/company/CompanyDetail.tsx
+++ b/src/frontend/src/pages/company/CompanyDetail.tsx
@@ -1,5 +1,5 @@
 import { t } from '@lingui/macro';
-import { LoadingOverlay, Skeleton, Stack } from '@mantine/core';
+import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
 import {
   IconBuildingFactory2,
   IconBuildingWarehouse,
@@ -18,6 +18,9 @@ import {
 import { useMemo } from 'react';
 import { useParams } from 'react-router-dom';
 
+import { DetailsField, DetailsTable } from '../../components/details/Details';
+import { DetailsImage } from '../../components/details/DetailsImage';
+import { ItemDetailsGrid } from '../../components/details/ItemDetails';
 import {
   ActionDropdown,
   DeleteItemAction,
@@ -69,12 +72,99 @@ export default function CompanyDetail(props: CompanyDetailProps) {
     refetchOnMount: true
   });
 
+  const detailsPanel = useMemo(() => {
+    if (instanceQuery.isFetching) {
+      return <Skeleton />;
+    }
+
+    let tl: DetailsField[] = [
+      {
+        type: 'text',
+        name: 'description',
+        label: t`Description`
+      },
+      {
+        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
+      }
+    ];
+
+    let 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>
+        <Grid>
+          <Grid.Col span={4}>
+            <DetailsImage
+              appRole={UserRoles.purchase_order}
+              apiPath={ApiEndpoints.company_list}
+              src={company.image}
+              pk={company.pk}
+              refresh={refreshInstance}
+              imageActions={{
+                uploadFile: true,
+                deleteFile: true
+              }}
+            />
+          </Grid.Col>
+          <Grid.Col span={8}>
+            <DetailsTable item={company} fields={tl} />
+          </Grid.Col>
+        </Grid>
+        <DetailsTable item={company} fields={tr} />
+      </ItemDetailsGrid>
+    );
+  }, [company, instanceQuery]);
+
   const companyPanels: PanelType[] = useMemo(() => {
     return [
       {
         name: 'details',
         label: t`Details`,
-        icon: <IconInfoCircle />
+        icon: <IconInfoCircle />,
+        content: detailsPanel
       },
       {
         name: 'manufactured-parts',
diff --git a/src/frontend/src/pages/part/CategoryDetail.tsx b/src/frontend/src/pages/part/CategoryDetail.tsx
index 938fe0356e..a7921e1011 100644
--- a/src/frontend/src/pages/part/CategoryDetail.tsx
+++ b/src/frontend/src/pages/part/CategoryDetail.tsx
@@ -1,21 +1,24 @@
 import { t } from '@lingui/macro';
-import { LoadingOverlay, Stack, Text } from '@mantine/core';
+import { LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core';
 import {
   IconCategory,
+  IconInfoCircle,
   IconListDetails,
   IconSitemap
 } from '@tabler/icons-react';
 import { useMemo, useState } from 'react';
 import { useParams } from 'react-router-dom';
 
+import { DetailsField, DetailsTable } from '../../components/details/Details';
+import { ItemDetailsGrid } from '../../components/details/ItemDetails';
 import { PageDetail } from '../../components/nav/PageDetail';
 import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
 import { PartCategoryTree } from '../../components/nav/PartCategoryTree';
 import { ApiEndpoints } from '../../enums/ApiEndpoints';
+import { ModelType } from '../../enums/ModelType';
 import { useInstance } from '../../hooks/UseInstance';
 import ParametricPartTable from '../../tables/part/ParametricPartTable';
 import { PartCategoryTable } from '../../tables/part/PartCategoryTable';
-import { PartParameterTable } from '../../tables/part/PartParameterTable';
 import { PartListTable } from '../../tables/part/PartTable';
 
 /**
@@ -45,8 +48,86 @@ export default function CategoryDetail({}: {}) {
     }
   });
 
+  const detailsPanel = useMemo(() => {
+    if (id && instanceQuery.isFetching) {
+      return <Skeleton />;
+    }
+
+    let left: DetailsField[] = [
+      {
+        type: 'text',
+        name: 'name',
+        label: t`Name`,
+        copy: true
+      },
+      {
+        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
+      }
+    ];
+
+    let right: DetailsField[] = [
+      {
+        type: 'text',
+        name: 'part_count',
+        label: t`Parts`,
+        icon: 'part'
+      },
+      {
+        type: 'text',
+        name: 'subcategories',
+        label: t`Subcategories`,
+        icon: 'sitemap',
+        hidden: !category?.subcategories
+      },
+      {
+        type: 'boolean',
+        name: 'structural',
+        label: t`Structural`,
+        icon: 'sitemap'
+      }
+    ];
+
+    return (
+      <ItemDetailsGrid>
+        {id && category?.pk ? (
+          <DetailsTable item={category} fields={left} />
+        ) : (
+          <Text>{t`Top level part category`}</Text>
+        )}
+        {id && category?.pk && <DetailsTable item={category} fields={right} />}
+      </ItemDetailsGrid>
+    );
+  }, [category, instanceQuery]);
+
   const categoryPanels: PanelType[] = useMemo(
     () => [
+      {
+        name: 'details',
+        label: t`Category Details`,
+        icon: <IconInfoCircle />,
+        content: detailsPanel
+        // hidden: !category?.pk,
+      },
       {
         name: 'parts',
         label: t`Parts`,
diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx
index 196425b335..dd36532e3f 100644
--- a/src/frontend/src/pages/part/PartDetail.tsx
+++ b/src/frontend/src/pages/part/PartDetail.tsx
@@ -1,5 +1,12 @@
 import { t } from '@lingui/macro';
-import { Group, LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core';
+import {
+  Grid,
+  Group,
+  LoadingOverlay,
+  Skeleton,
+  Stack,
+  Text
+} from '@mantine/core';
 import {
   IconBookmarks,
   IconBuilding,
@@ -28,6 +35,10 @@ import { useMemo, useState } from 'react';
 import { useParams } from 'react-router-dom';
 
 import { api } from '../../App';
+import { DetailsField, DetailsTable } from '../../components/details/Details';
+import { DetailsImage } from '../../components/details/DetailsImage';
+import { ItemDetailsGrid } from '../../components/details/ItemDetails';
+import { PartIcons } from '../../components/details/PartIcons';
 import {
   ActionDropdown,
   BarcodeActionDropdown,
@@ -51,12 +62,6 @@ import { useEditApiFormModal } from '../../hooks/UseForm';
 import { useInstance } from '../../hooks/UseInstance';
 import { apiUrl } from '../../states/ApiState';
 import { useUserState } from '../../states/UserState';
-import { DetailsField } from '../../tables/Details';
-import {
-  DetailsImageType,
-  ItemDetailFields,
-  ItemDetails
-} from '../../tables/ItemDetails';
 import { BomTable } from '../../tables/bom/BomTable';
 import { UsedInTable } from '../../tables/bom/UsedInTable';
 import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
@@ -93,188 +98,186 @@ export default function PartDetail() {
     refetchOnMount: true
   });
 
-  const detailFields = (part: any): ItemDetailFields => {
-    let left: DetailsField[][] = [];
-    let right: DetailsField[][] = [];
-    let bottom_right: DetailsField[][] = [];
-    let bottom_left: DetailsField[][] = [];
+  const detailsPanel = useMemo(() => {
+    if (instanceQuery.isFetching) {
+      return <Skeleton />;
+    }
 
-    let image: DetailsImageType = {
-      name: 'image',
-      imageActions: {
-        selectExisting: true,
-        uploadFile: true,
-        deleteFile: true
-      }
-    };
-
-    left.push([
+    // Construct the details tables
+    let tl: DetailsField[] = [
       {
         type: 'text',
         name: 'description',
         label: t`Description`,
         copy: true
+      },
+      {
+        type: 'link',
+        name: 'variant_of',
+        label: t`Variant of`,
+        model: ModelType.part,
+        hidden: !part.variant_of
+      },
+      {
+        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: 'string',
+        name: 'IPN',
+        label: t`IPN`,
+        copy: true,
+        hidden: !part.IPN
+      },
+      {
+        type: 'string',
+        name: 'revision',
+        label: t`Revision`,
+        copy: true,
+        hidden: !part.revision
+      },
+      {
+        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
       }
-    ]);
+    ];
 
-    if (part.variant_of) {
-      left.push([
-        {
-          type: 'link',
-          name: 'variant_of',
-          label: t`Variant of`,
-          model: ModelType.part
-        }
-      ]);
-    }
-
-    right.push([
+    let tr: DetailsField[] = [
       {
         type: 'string',
         name: 'unallocated_stock',
         unit: true,
         label: t`Available Stock`
-      }
-    ]);
-
-    right.push([
+      },
       {
         type: 'string',
         name: 'total_in_stock',
         unit: true,
         label: t`In Stock`
+      },
+      {
+        type: 'string',
+        name: 'minimum_stock',
+        unit: true,
+        label: t`Minimum Stock`,
+        hidden: part.minimum_stock <= 0
+      },
+      {
+        type: 'string',
+        name: 'ordering',
+        label: t`On order`,
+        unit: true,
+        hidden: part.ordering <= 0
+      },
+      {
+        type: 'progressbar',
+        name: 'allocated_to_build_orders',
+        total: part.required_for_build_orders,
+        progress: part.allocated_to_build_orders,
+        label: t`Allocated to Build Orders`,
+        hidden:
+          !part.assembly ||
+          (part.allocated_to_build_orders <= 0 &&
+            part.required_for_build_orders <= 0)
+      },
+      {
+        type: 'progressbar',
+        name: 'allocated_to_sales_orders',
+        total: part.required_for_sales_orders,
+        progress: part.allocated_to_sales_orders,
+        label: t`Allocated to Sales Orders`,
+        hidden:
+          !part.salable ||
+          (part.allocated_to_sales_orders <= 0 &&
+            part.required_for_sales_orders <= 0)
+      },
+      {
+        type: 'string',
+        name: 'can_build',
+        unit: true,
+        label: t`Can Build`,
+        hidden: !part.assembly
+      },
+      {
+        type: 'string',
+        name: 'building',
+        unit: true,
+        label: t`Building`,
+        hidden: !part.assembly
       }
-    ]);
+    ];
 
-    if (part.minimum_stock) {
-      right.push([
-        {
-          type: 'string',
-          name: 'minimum_stock',
-          unit: true,
-          label: t`Minimum Stock`
-        }
-      ]);
-    }
+    let bl: DetailsField[] = [
+      {
+        type: 'boolean',
+        name: 'active',
+        label: t`Active`
+      },
+      {
+        type: 'boolean',
+        name: 'template',
+        label: t`Template Part`
+      },
+      {
+        type: 'boolean',
+        name: 'assembly',
+        label: t`Assembled Part`
+      },
+      {
+        type: 'boolean',
+        name: 'component',
+        label: t`Component Part`
+      },
+      {
+        type: 'boolean',
+        name: 'trackable',
+        label: t`Trackable Part`
+      },
+      {
+        type: 'boolean',
+        name: 'purchaseable',
+        label: t`Purchaseable Part`
+      },
+      {
+        type: 'boolean',
+        name: 'saleable',
+        label: t`Saleable Part`
+      },
+      {
+        type: 'boolean',
+        name: 'virtual',
+        label: t`Virtual Part`
+      }
+    ];
 
-    if (part.ordering <= 0) {
-      right.push([
-        {
-          type: 'string',
-          name: 'ordering',
-          label: t`On order`,
-          unit: true
-        }
-      ]);
-    }
-
-    if (
-      part.assembly &&
-      (part.allocated_to_build_orders > 0 || part.required_for_build_orders > 0)
-    ) {
-      right.push([
-        {
-          type: 'progressbar',
-          name: 'allocated_to_build_orders',
-          total: part.required_for_build_orders,
-          progress: part.allocated_to_build_orders,
-          label: t`Allocated to Build Orders`
-        }
-      ]);
-    }
-
-    if (
-      part.salable &&
-      (part.allocated_to_sales_orders > 0 || part.required_for_sales_orders > 0)
-    ) {
-      right.push([
-        {
-          type: 'progressbar',
-          name: 'allocated_to_sales_orders',
-          total: part.required_for_sales_orders,
-          progress: part.allocated_to_sales_orders,
-          label: t`Allocated to Sales Orders`
-        }
-      ]);
-    }
-
-    if (part.assembly) {
-      right.push([
-        {
-          type: 'string',
-          name: 'can_build',
-          unit: true,
-          label: t`Can Build`
-        }
-      ]);
-    }
-
-    if (part.assembly) {
-      right.push([
-        {
-          type: 'string',
-          name: 'building',
-          unit: true,
-          label: t`Building`
-        }
-      ]);
-    }
-
-    if (part.category) {
-      bottom_left.push([
-        {
-          type: 'link',
-          name: 'category',
-          label: t`Category`,
-          model: ModelType.partcategory
-        }
-      ]);
-    }
-
-    if (part.IPN) {
-      bottom_left.push([
-        {
-          type: 'string',
-          name: 'IPN',
-          label: t`IPN`,
-          copy: true
-        }
-      ]);
-    }
-
-    if (part.revision) {
-      bottom_left.push([
-        {
-          type: 'string',
-          name: 'revision',
-          label: t`Revision`,
-          copy: true
-        }
-      ]);
-    }
-
-    if (part.units) {
-      bottom_left.push([
-        {
-          type: 'string',
-          name: 'units',
-          label: t`Units`
-        }
-      ]);
-    }
-
-    if (part.keywords) {
-      bottom_left.push([
-        {
-          type: 'string',
-          name: 'keywords',
-          label: t`Keywords`,
-          copy: true
-        }
-      ]);
-    }
-
-    bottom_right.push([
+    let br: DetailsField[] = [
       {
         type: 'string',
         name: 'creation_date',
@@ -283,181 +286,169 @@ export default function PartDetail() {
       {
         type: 'string',
         name: 'creation_user',
-        badge: 'user'
+        label: t`Created By`,
+        badge: 'user',
+        icon: 'user'
+      },
+      {
+        type: 'string',
+        name: 'responsible',
+        label: t`Responsible`,
+        badge: 'owner',
+        hidden: !part.responsible
+      },
+      {
+        type: 'link',
+        name: 'default_supplier',
+        label: t`Default Supplier`,
+        model: ModelType.supplierpart,
+        hidden: !part.default_supplier
       }
-    ]);
+    ];
 
+    // Add in price range data
     id &&
-      bottom_right.push([
-        {
-          type: 'string',
-          name: 'pricing',
-          label: t`Price Range`,
-          value_formatter: () => {
-            const { data } = useSuspenseQuery({
-              queryKey: ['pricing', id],
-              queryFn: async () => {
-                const url = apiUrl(ApiEndpoints.part_pricing_get, null, {
-                  id: id
+      br.push({
+        type: 'string',
+        name: 'pricing',
+        label: t`Price Range`,
+        value_formatter: () => {
+          const { data } = useSuspenseQuery({
+            queryKey: ['pricing', id],
+            queryFn: async () => {
+              const url = apiUrl(ApiEndpoints.part_pricing_get, null, {
+                id: id
+              });
+
+              return api
+                .get(url)
+                .then((response) => {
+                  switch (response.status) {
+                    case 200:
+                      return response.data;
+                    default:
+                      return null;
+                  }
+                })
+                .catch(() => {
+                  return null;
                 });
+            }
+          });
+          return `${formatPriceRange(data.overall_min, data.overall_max)}${
+            part.units && ' / ' + part.units
+          }`;
+        }
+      });
 
-                return api
-                  .get(url)
-                  .then((response) => {
-                    switch (response.status) {
-                      case 200:
-                        return response.data;
-                      default:
-                        return null;
-                    }
-                  })
-                  .catch(() => {
-                    return null;
-                  });
-              }
-            });
-            return `${formatPriceRange(data.overall_min, data.overall_max)}${
-              part.units && ' / ' + part.units
-            }`;
+    // Add in stocktake information
+    if (id && part.last_stocktake) {
+      br.push({
+        type: 'string',
+        name: 'stocktake',
+        label: t`Last Stocktake`,
+        unit: true,
+        value_formatter: () => {
+          const { data } = useSuspenseQuery({
+            queryKey: ['stocktake', id],
+            queryFn: async () => {
+              const url = apiUrl(ApiEndpoints.part_stocktake_list);
+
+              return api
+                .get(url, { params: { part: id, ordering: 'date' } })
+                .then((response) => {
+                  switch (response.status) {
+                    case 200:
+                      return response.data[response.data.length - 1];
+                    default:
+                      return null;
+                  }
+                })
+                .catch(() => {
+                  return null;
+                });
+            }
+          });
+
+          if (data.quantity) {
+            return `${data.quantity} (${data.date})`;
+          } else {
+            return '-';
           }
         }
-      ]);
+      });
 
-    id &&
-      part.last_stocktake &&
-      bottom_right.push([
-        {
-          type: 'string',
-          name: 'stocktake',
-          label: t`Last Stocktake`,
-          unit: true,
-          value_formatter: () => {
-            const { data } = useSuspenseQuery({
-              queryKey: ['stocktake', id],
-              queryFn: async () => {
-                const url = apiUrl(ApiEndpoints.part_stocktake_list);
+      br.push({
+        type: 'string',
+        name: 'stocktake_user',
+        label: t`Stocktake By`,
+        badge: 'user',
+        icon: 'user',
+        value_formatter: () => {
+          const { data } = useSuspenseQuery({
+            queryKey: ['stocktake', id],
+            queryFn: async () => {
+              const url = apiUrl(ApiEndpoints.part_stocktake_list);
 
-                return api
-                  .get(url, { params: { part: id, ordering: 'date' } })
-                  .then((response) => {
-                    switch (response.status) {
-                      case 200:
-                        return response.data[response.data.length - 1];
-                      default:
-                        return null;
-                    }
-                  })
-                  .catch(() => {
-                    return null;
-                  });
-              }
-            });
-            return data?.quantity;
-          }
-        },
-        {
-          type: 'string',
-          name: 'stocktake_user',
-          badge: 'user',
-          value_formatter: () => {
-            const { data } = useSuspenseQuery({
-              queryKey: ['stocktake', id],
-              queryFn: async () => {
-                const url = apiUrl(ApiEndpoints.part_stocktake_list);
-
-                return api
-                  .get(url, { params: { part: id, ordering: 'date' } })
-                  .then((response) => {
-                    switch (response.status) {
-                      case 200:
-                        return response.data[response.data.length - 1];
-                      default:
-                        return null;
-                    }
-                  })
-                  .catch(() => {
-                    return null;
-                  });
-              }
-            });
-            return data?.user;
-          }
+              return api
+                .get(url, { params: { part: id, ordering: 'date' } })
+                .then((response) => {
+                  switch (response.status) {
+                    case 200:
+                      return response.data[response.data.length - 1];
+                    default:
+                      return null;
+                  }
+                })
+                .catch(() => {
+                  return null;
+                });
+            }
+          });
+          return data?.user;
         }
-      ]);
-
-    if (part.default_location) {
-      bottom_right.push([
-        {
-          type: 'link',
-          name: 'default_location',
-          label: t`Default Location`,
-          model: ModelType.stocklocation
-        }
-      ]);
+      });
     }
 
-    if (part.default_supplier) {
-      bottom_right.push([
-        {
-          type: 'link',
-          name: 'default_supplier',
-          label: t`Default Supplier`,
-          model: ModelType.supplierpart
-        }
-      ]);
-    }
-
-    if (part.link) {
-      bottom_right.push([
-        {
-          type: 'link',
-          name: 'link',
-          label: t`Link`,
-          external: true,
-          copy: true
-        }
-      ]);
-    }
-
-    if (part.responsible) {
-      bottom_right.push([
-        {
-          type: 'string',
-          name: 'responsible',
-          label: t`Responsible`,
-          badge: 'owner'
-        }
-      ]);
-    }
-
-    let fields: ItemDetailFields = {
-      left: left,
-      right: right,
-      bottom_left: bottom_left,
-      bottom_right: bottom_right,
-      image: image
-    };
-
-    return fields;
-  };
+    return (
+      <ItemDetailsGrid>
+        <Grid>
+          <Grid.Col span={4}>
+            <DetailsImage
+              appRole={UserRoles.part}
+              imageActions={{
+                selectExisting: true,
+                uploadFile: true,
+                deleteFile: true
+              }}
+              src={part.image}
+              apiPath={apiUrl(ApiEndpoints.part_list, part.pk)}
+              refresh={refreshInstance}
+              pk={part.pk}
+            />
+          </Grid.Col>
+          <Grid.Col span={8}>
+            <Stack spacing="xs">
+              <PartIcons part={part} />
+              <DetailsTable fields={tl} item={part} />
+            </Stack>
+          </Grid.Col>
+        </Grid>
+        <DetailsTable fields={tr} item={part} />
+        <DetailsTable fields={bl} item={part} />
+        <DetailsTable fields={br} item={part} />
+      </ItemDetailsGrid>
+    );
+  }, [part, instanceQuery]);
 
   // Part data panels (recalculate when part data changes)
   const partPanels: PanelType[] = useMemo(() => {
     return [
       {
         name: 'details',
-        label: t`Details`,
+        label: t`Part Details`,
         icon: <IconInfoCircle />,
-        content: !instanceQuery.isFetching && (
-          <ItemDetails
-            appRole={UserRoles.part}
-            params={part}
-            apiPath={apiUrl(ApiEndpoints.part_list, part.pk)}
-            refresh={refreshInstance}
-            fields={detailFields(part)}
-            partModel
-          />
-        )
+        content: detailsPanel
       },
       {
         name: 'parameters',
diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx
index 0464f7d4b5..1030eb4d8c 100644
--- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx
+++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx
@@ -1,5 +1,5 @@
 import { t } from '@lingui/macro';
-import { LoadingOverlay, Stack } from '@mantine/core';
+import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
 import {
   IconDots,
   IconInfoCircle,
@@ -11,6 +11,9 @@ import {
 import { useMemo } from 'react';
 import { useParams } from 'react-router-dom';
 
+import { DetailsField, DetailsTable } from '../../components/details/Details';
+import { DetailsImage } from '../../components/details/DetailsImage';
+import { ItemDetailsGrid } from '../../components/details/ItemDetails';
 import {
   ActionDropdown,
   BarcodeActionDropdown,
@@ -24,6 +27,10 @@ import { PageDetail } from '../../components/nav/PageDetail';
 import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
 import { NotesEditor } from '../../components/widgets/MarkdownEditor';
 import { ApiEndpoints } from '../../enums/ApiEndpoints';
+import { ModelType } from '../../enums/ModelType';
+import { UserRoles } from '../../enums/Roles';
+import { purchaseOrderFields } from '../../forms/PurchaseOrderForms';
+import { useEditApiFormModal } from '../../hooks/UseForm';
 import { useInstance } from '../../hooks/UseInstance';
 import { apiUrl } from '../../states/ApiState';
 import { useUserState } from '../../states/UserState';
@@ -39,7 +46,11 @@ export default function PurchaseOrderDetail() {
 
   const user = useUserState();
 
-  const { instance: order, instanceQuery } = useInstance({
+  const {
+    instance: order,
+    instanceQuery,
+    refreshInstance
+  } = useInstance({
     endpoint: ApiEndpoints.purchase_order_list,
     pk: id,
     params: {
@@ -48,12 +59,167 @@ export default function PurchaseOrderDetail() {
     refetchOnMount: true
   });
 
+  const editPurchaseOrder = useEditApiFormModal({
+    url: ApiEndpoints.purchase_order_list,
+    pk: id,
+    title: t`Edit Purchase Order`,
+    fields: purchaseOrderFields(),
+    onFormSuccess: () => {
+      refreshInstance();
+    }
+  });
+
+  const detailsPanel = useMemo(() => {
+    if (instanceQuery.isFetching) {
+      return <Skeleton />;
+    }
+
+    let 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
+      }
+    ];
+
+    let 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: 'progressbar',
+        name: 'shipments',
+        icon: 'shipment',
+        label: t`Completed Shipments`,
+        total: order.shipments,
+        progress: order.completed_shipments
+        // TODO: Fix this progress bar
+      },
+      {
+        type: 'text',
+        name: 'currency',
+        label: t`Order Currency,`
+      },
+      {
+        type: 'text',
+        name: 'total_cost',
+        label: t`Total Cost`
+        // TODO: Implement this!
+      }
+    ];
+
+    let bl: DetailsField[] = [
+      {
+        type: 'link',
+        external: true,
+        name: 'link',
+        label: t`Link`,
+        copy: true,
+        hidden: !order.link
+      },
+      {
+        type: 'link',
+        model: ModelType.contact,
+        link: false,
+        name: 'contact',
+        label: t`Contact`,
+        icon: 'user',
+        copy: true
+      }
+      // TODO: Project code
+    ];
+
+    let br: DetailsField[] = [
+      {
+        type: 'text',
+        name: 'creation_date',
+        label: t`Created On`,
+        icon: 'calendar'
+      },
+      {
+        type: 'text',
+        name: 'target_date',
+        label: t`Target Date`,
+        icon: 'calendar',
+        hidden: !order.target_date
+      },
+      {
+        type: 'text',
+        name: 'responsible',
+        label: t`Responsible`,
+        badge: 'owner',
+        hidden: !order.responsible
+      }
+    ];
+
+    return (
+      <ItemDetailsGrid>
+        <Grid>
+          <Grid.Col span={4}>
+            <DetailsImage
+              appRole={UserRoles.purchase_order}
+              apiPath={ApiEndpoints.company_list}
+              src={order.supplier_detail?.image}
+              pk={order.supplier}
+            />
+          </Grid.Col>
+          <Grid.Col span={8}>
+            <DetailsTable fields={tl} item={order} />
+          </Grid.Col>
+        </Grid>
+        <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 />
+        icon: <IconInfoCircle />,
+        content: detailsPanel
       },
       {
         name: 'line-items',
@@ -118,13 +284,21 @@ export default function PurchaseOrderDetail() {
         key="order-actions"
         tooltip={t`Order Actions`}
         icon={<IconDots />}
-        actions={[EditItemAction({}), DeleteItemAction({})]}
+        actions={[
+          EditItemAction({
+            onClick: () => {
+              editPurchaseOrder.open();
+            }
+          }),
+          DeleteItemAction({})
+        ]}
       />
     ];
   }, [id, order, user]);
 
   return (
     <>
+      {editPurchaseOrder.modal}
       <Stack spacing="xs">
         <LoadingOverlay visible={instanceQuery.isFetching} />
         <PageDetail
diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx
index d08be33201..c8b405ecfa 100644
--- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx
+++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx
@@ -1,13 +1,23 @@
 import { t } from '@lingui/macro';
-import { LoadingOverlay, Stack } from '@mantine/core';
-import { IconInfoCircle, IconNotes, IconPaperclip } from '@tabler/icons-react';
+import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
+import {
+  IconInfoCircle,
+  IconList,
+  IconNotes,
+  IconPaperclip
+} from '@tabler/icons-react';
 import { useMemo } from 'react';
 import { useParams } from 'react-router-dom';
 
+import { DetailsField, DetailsTable } from '../../components/details/Details';
+import { DetailsImage } from '../../components/details/DetailsImage';
+import { ItemDetailsGrid } from '../../components/details/ItemDetails';
 import { PageDetail } from '../../components/nav/PageDetail';
 import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
 import { NotesEditor } from '../../components/widgets/MarkdownEditor';
 import { ApiEndpoints } from '../../enums/ApiEndpoints';
+import { ModelType } from '../../enums/ModelType';
+import { UserRoles } from '../../enums/Roles';
 import { useInstance } from '../../hooks/UseInstance';
 import { apiUrl } from '../../states/ApiState';
 import { AttachmentTable } from '../../tables/general/AttachmentTable';
@@ -26,12 +36,161 @@ export default function ReturnOrderDetail() {
     }
   });
 
+  const detailsPanel = useMemo(() => {
+    if (instanceQuery.isFetching) {
+      return <Skeleton />;
+    }
+
+    let tl: DetailsField[] = [
+      {
+        type: 'text',
+        name: 'reference',
+        label: t`Reference`,
+        copy: true
+      },
+      {
+        type: 'text',
+        name: 'customer_reference',
+        label: t`Customer Reference`,
+        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.salesorder
+      }
+    ];
+
+    let 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: 'progressbar',
+        name: 'shipments',
+        icon: 'shipment',
+        label: t`Completed Shipments`,
+        total: order.shipments,
+        progress: order.completed_shipments
+        // TODO: Fix this progress bar
+      },
+      {
+        type: 'text',
+        name: 'currency',
+        label: t`Order Currency,`
+      },
+      {
+        type: 'text',
+        name: 'total_cost',
+        label: t`Total Cost`
+        // TODO: Implement this!
+      }
+    ];
+
+    let bl: DetailsField[] = [
+      {
+        type: 'link',
+        external: true,
+        name: 'link',
+        label: t`Link`,
+        copy: true,
+        hidden: !order.link
+      },
+      {
+        type: 'link',
+        model: ModelType.contact,
+        link: false,
+        name: 'contact',
+        label: t`Contact`,
+        icon: 'user',
+        copy: true
+      }
+      // TODO: Project code
+    ];
+
+    let br: DetailsField[] = [
+      {
+        type: 'text',
+        name: 'creation_date',
+        label: t`Created On`,
+        icon: 'calendar'
+      },
+      {
+        type: 'text',
+        name: 'target_date',
+        label: t`Target Date`,
+        icon: 'calendar',
+        hidden: !order.target_date
+      },
+      {
+        type: 'text',
+        name: 'responsible',
+        label: t`Responsible`,
+        badge: 'owner',
+        hidden: !order.responsible
+      }
+    ];
+
+    return (
+      <ItemDetailsGrid>
+        <Grid>
+          <Grid.Col span={4}>
+            <DetailsImage
+              appRole={UserRoles.purchase_order}
+              apiPath={ApiEndpoints.company_list}
+              src={order.customer_detail?.image}
+              pk={order.customer}
+            />
+          </Grid.Col>
+          <Grid.Col span={8}>
+            <DetailsTable fields={tl} item={order} />
+          </Grid.Col>
+        </Grid>
+        <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 />
+        icon: <IconInfoCircle />,
+        content: detailsPanel
+      },
+      {
+        name: 'line-items',
+        label: t`Line Items`,
+        icon: <IconList />
       },
       {
         name: 'attachments',
diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx
index 04908963ca..84db04670e 100644
--- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx
+++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx
@@ -1,5 +1,5 @@
 import { t } from '@lingui/macro';
-import { LoadingOverlay, Skeleton, Stack } from '@mantine/core';
+import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
 import {
   IconInfoCircle,
   IconList,
@@ -12,10 +12,15 @@ import {
 import { useMemo } from 'react';
 import { useParams } from 'react-router-dom';
 
+import { DetailsField, DetailsTable } from '../../components/details/Details';
+import { DetailsImage } from '../../components/details/DetailsImage';
+import { ItemDetailsGrid } from '../../components/details/ItemDetails';
 import { PageDetail } from '../../components/nav/PageDetail';
 import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
 import { NotesEditor } from '../../components/widgets/MarkdownEditor';
 import { ApiEndpoints } from '../../enums/ApiEndpoints';
+import { ModelType } from '../../enums/ModelType';
+import { UserRoles } from '../../enums/Roles';
 import { useInstance } from '../../hooks/UseInstance';
 import { apiUrl } from '../../states/ApiState';
 import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
@@ -35,12 +40,156 @@ export default function SalesOrderDetail() {
     }
   });
 
+  const detailsPanel = useMemo(() => {
+    if (instanceQuery.isFetching) {
+      return <Skeleton />;
+    }
+
+    let tl: DetailsField[] = [
+      {
+        type: 'text',
+        name: 'reference',
+        label: t`Reference`,
+        copy: true
+      },
+      {
+        type: 'text',
+        name: 'customer_reference',
+        label: t`Customer Reference`,
+        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.salesorder
+      }
+    ];
+
+    let 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: 'progressbar',
+        name: 'shipments',
+        icon: 'shipment',
+        label: t`Completed Shipments`,
+        total: order.shipments,
+        progress: order.completed_shipments
+        // TODO: Fix this progress bar
+      },
+      {
+        type: 'text',
+        name: 'currency',
+        label: t`Order Currency,`
+      },
+      {
+        type: 'text',
+        name: 'total_cost',
+        label: t`Total Cost`
+        // TODO: Implement this!
+      }
+    ];
+
+    let bl: DetailsField[] = [
+      {
+        type: 'link',
+        external: true,
+        name: 'link',
+        label: t`Link`,
+        copy: true,
+        hidden: !order.link
+      },
+      {
+        type: 'link',
+        model: ModelType.contact,
+        link: false,
+        name: 'contact',
+        label: t`Contact`,
+        icon: 'user',
+        copy: true
+      }
+      // TODO: Project code
+    ];
+
+    let br: DetailsField[] = [
+      {
+        type: 'text',
+        name: 'creation_date',
+        label: t`Created On`,
+        icon: 'calendar'
+      },
+      {
+        type: 'text',
+        name: 'target_date',
+        label: t`Target Date`,
+        icon: 'calendar',
+        hidden: !order.target_date
+      },
+      {
+        type: 'text',
+        name: 'responsible',
+        label: t`Responsible`,
+        badge: 'owner',
+        hidden: !order.responsible
+      }
+    ];
+
+    return (
+      <ItemDetailsGrid>
+        <Grid>
+          <Grid.Col span={4}>
+            <DetailsImage
+              appRole={UserRoles.purchase_order}
+              apiPath={ApiEndpoints.company_list}
+              src={order.customer_detail?.image}
+              pk={order.customer}
+            />
+          </Grid.Col>
+          <Grid.Col span={8}>
+            <DetailsTable fields={tl} item={order} />
+          </Grid.Col>
+        </Grid>
+        <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 />
+        icon: <IconInfoCircle />,
+        content: detailsPanel
       },
       {
         name: 'line-items',
diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx
index a1da38dd23..3214ed8183 100644
--- a/src/frontend/src/pages/stock/LocationDetail.tsx
+++ b/src/frontend/src/pages/stock/LocationDetail.tsx
@@ -1,13 +1,16 @@
 import { t } from '@lingui/macro';
-import { LoadingOverlay, Stack, Text } from '@mantine/core';
-import { IconPackages, IconSitemap } from '@tabler/icons-react';
+import { LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core';
+import { IconInfoCircle, IconPackages, IconSitemap } from '@tabler/icons-react';
 import { useMemo, useState } from 'react';
 import { useParams } from 'react-router-dom';
 
+import { DetailsField, DetailsTable } from '../../components/details/Details';
+import { ItemDetailsGrid } from '../../components/details/ItemDetails';
 import { PageDetail } from '../../components/nav/PageDetail';
 import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
 import { StockLocationTree } from '../../components/nav/StockLocationTree';
 import { ApiEndpoints } from '../../enums/ApiEndpoints';
+import { ModelType } from '../../enums/ModelType';
 import { useInstance } from '../../hooks/UseInstance';
 import { StockItemTable } from '../../tables/stock/StockItemTable';
 import { StockLocationTable } from '../../tables/stock/StockLocationTable';
@@ -35,8 +38,90 @@ export default function Stock() {
     }
   });
 
+  const detailsPanel = useMemo(() => {
+    if (id && instanceQuery.isFetching) {
+      return <Skeleton />;
+    }
+
+    let left: DetailsField[] = [
+      {
+        type: 'text',
+        name: 'name',
+        label: t`Name`,
+        copy: true
+      },
+      {
+        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
+      }
+    ];
+
+    let right: DetailsField[] = [
+      {
+        type: 'text',
+        name: 'items',
+        icon: 'stock',
+        label: t`Stock Items`
+      },
+      {
+        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`
+      }
+    ];
+
+    return (
+      <ItemDetailsGrid>
+        {id && location?.pk ? (
+          <DetailsTable item={location} fields={left} />
+        ) : (
+          <Text>{t`Top level stock location`}</Text>
+        )}
+        {id && location?.pk && <DetailsTable item={location} fields={right} />}
+      </ItemDetailsGrid>
+    );
+  }, [location, instanceQuery]);
+
   const locationPanels: PanelType[] = useMemo(() => {
     return [
+      {
+        name: 'details',
+        label: t`Location Details`,
+        icon: <IconInfoCircle />,
+        content: detailsPanel
+      },
       {
         name: 'stock-items',
         label: t`Stock Items`,
diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx
index 4901bf20ed..7daeb55285 100644
--- a/src/frontend/src/pages/stock/StockDetail.tsx
+++ b/src/frontend/src/pages/stock/StockDetail.tsx
@@ -1,5 +1,12 @@
 import { t } from '@lingui/macro';
-import { Alert, LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core';
+import {
+  Alert,
+  Grid,
+  LoadingOverlay,
+  Skeleton,
+  Stack,
+  Text
+} from '@mantine/core';
 import {
   IconBookmark,
   IconBoxPadding,
@@ -20,6 +27,9 @@ import {
 import { useMemo, useState } from 'react';
 import { useParams } from 'react-router-dom';
 
+import { DetailsField, DetailsTable } from '../../components/details/Details';
+import { DetailsImage } from '../../components/details/DetailsImage';
+import { ItemDetailsGrid } from '../../components/details/ItemDetails';
 import {
   ActionDropdown,
   BarcodeActionDropdown,
@@ -34,6 +44,8 @@ import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
 import { StockLocationTree } from '../../components/nav/StockLocationTree';
 import { NotesEditor } from '../../components/widgets/MarkdownEditor';
 import { ApiEndpoints } from '../../enums/ApiEndpoints';
+import { ModelType } from '../../enums/ModelType';
+import { UserRoles } from '../../enums/Roles';
 import { useEditStockItem } from '../../forms/StockForms';
 import { useInstance } from '../../hooks/UseInstance';
 import { apiUrl } from '../../states/ApiState';
@@ -63,12 +75,155 @@ export default function StockDetail() {
     }
   });
 
+  const detailsPanel = useMemo(() => {
+    let data = stockitem;
+
+    data.available_stock = Math.max(0, data.quantity - data.allocated);
+
+    if (instanceQuery.isFetching) {
+      return <Skeleton />;
+    }
+
+    // Top left - core part information
+    let tl: DetailsField[] = [
+      {
+        name: 'part',
+        label: t`Base Part`,
+        type: 'link',
+        model: ModelType.part
+      },
+      {
+        name: 'status',
+        type: 'text',
+        label: t`Stock Status`
+      },
+      {
+        type: 'text',
+        name: 'tests',
+        label: `Completed Tests`,
+        icon: 'progress'
+      },
+      {
+        type: 'text',
+        name: 'updated',
+        icon: 'calendar',
+        label: t`Last Updated`
+      },
+      {
+        type: 'text',
+        name: 'stocktake',
+        icon: 'calendar',
+        label: t`Last Stocktake`,
+        hidden: !stockitem.stocktake
+      }
+    ];
+
+    // Top right - available stock information
+    let tr: DetailsField[] = [
+      {
+        type: 'text',
+        name: 'quantity',
+        label: t`Quantity`
+      },
+      {
+        type: 'text',
+        name: 'serial',
+        label: t`Serial Number`,
+        hidden: !stockitem.serial
+      },
+      {
+        type: 'text',
+        name: 'available_stock',
+        label: t`Available`
+      }
+      // TODO: allocated_to_sales_orders
+      // TODO: allocated_to_build_orders
+    ];
+
+    // Bottom left: location information
+    let bl: DetailsField[] = [
+      {
+        name: 'supplier_part',
+        label: t`Supplier Part`,
+        type: 'link',
+        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: ModelType.stockitem,
+        hidden: !stockitem.belongs_to
+      },
+      {
+        type: 'link',
+        name: 'consumed_by',
+        label: t`Consumed By`,
+        model: ModelType.build,
+        hidden: !stockitem.consumed_by
+      },
+      {
+        type: 'link',
+        name: 'sales_order',
+        label: t`Sales Order`,
+        model: ModelType.salesorder,
+        hidden: !stockitem.sales_order
+      }
+    ];
+
+    // Bottom right - any other information
+    let br: DetailsField[] = [
+      // TODO: Expiry date
+      // TODO: Ownership
+      {
+        type: 'text',
+        name: 'packaging',
+        icon: 'part',
+        label: t`Packaging`,
+        hidden: !stockitem.packaging
+      }
+    ];
+
+    return (
+      <ItemDetailsGrid>
+        <Grid>
+          <Grid.Col span={4}>
+            <DetailsImage
+              appRole={UserRoles.part}
+              apiPath={ApiEndpoints.part_list}
+              src={
+                stockitem.part_detail?.image ??
+                stockitem?.part_detail?.thumbnail
+              }
+              pk={stockitem.part}
+            />
+          </Grid.Col>
+          <Grid.Col span={8}>
+            <DetailsTable fields={tl} item={stockitem} />
+          </Grid.Col>
+        </Grid>
+        <DetailsTable fields={tr} item={stockitem} />
+        <DetailsTable fields={bl} item={stockitem} />
+        <DetailsTable fields={br} item={stockitem} />
+      </ItemDetailsGrid>
+    );
+  }, [stockitem, instanceQuery]);
+
   const stockPanels: PanelType[] = useMemo(() => {
     return [
       {
         name: 'details',
-        label: t`Details`,
-        icon: <IconInfoCircle />
+        label: t`Stock Details`,
+        icon: <IconInfoCircle />,
+        content: detailsPanel
       },
       {
         name: 'tracking',
diff --git a/src/frontend/src/tables/ItemDetails.tsx b/src/frontend/src/tables/ItemDetails.tsx
deleted file mode 100644
index 1ffc49473f..0000000000
--- a/src/frontend/src/tables/ItemDetails.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import { Grid, Group, Paper, SimpleGrid } from '@mantine/core';
-
-import {
-  DetailImageButtonProps,
-  DetailsImage
-} from '../components/images/DetailsImage';
-import { UserRoles } from '../enums/Roles';
-import { DetailsField, DetailsTable } from './Details';
-
-/**
- * Type for defining field arrays
- */
-export type ItemDetailFields = {
-  left: DetailsField[][];
-  right?: DetailsField[][];
-  bottom_left?: DetailsField[][];
-  bottom_right?: DetailsField[][];
-  image?: DetailsImageType;
-};
-
-/**
- * Type for defining details image
- */
-export type DetailsImageType = {
-  name: string;
-  imageActions: DetailImageButtonProps;
-};
-
-/**
- * Render a Details panel of the given model
- * @param params Object with the data of the model to render
- * @param apiPath Path to use for image updating
- * @param refresh useInstance refresh method to refresh when making updates
- * @param fields Object with all field sections
- * @param partModel set to true only if source model is Part
- */
-export function ItemDetails({
-  appRole,
-  params = {},
-  apiPath,
-  refresh,
-  fields,
-  partModel = false
-}: {
-  appRole: UserRoles;
-  params?: any;
-  apiPath: string;
-  refresh: () => void;
-  fields: ItemDetailFields;
-  partModel: boolean;
-}) {
-  return (
-    <Paper p="xs">
-      <SimpleGrid cols={2} spacing="xs" verticalSpacing="xs">
-        <Grid>
-          {fields.image && (
-            <Grid.Col span={4}>
-              <DetailsImage
-                appRole={appRole}
-                imageActions={fields.image.imageActions}
-                src={params.image}
-                apiPath={apiPath}
-                refresh={refresh}
-                pk={params.pk}
-              />
-            </Grid.Col>
-          )}
-          <Grid.Col span={8}>
-            {fields.left && (
-              <DetailsTable
-                item={params}
-                fields={fields.left}
-                partIcons={partModel}
-              />
-            )}
-          </Grid.Col>
-        </Grid>
-        {fields.right && <DetailsTable item={params} fields={fields.right} />}
-        {fields.bottom_left && (
-          <DetailsTable item={params} fields={fields.bottom_left} />
-        )}
-        {fields.bottom_right && (
-          <DetailsTable item={params} fields={fields.bottom_right} />
-        )}
-      </SimpleGrid>
-    </Paper>
-  );
-}