From a68c1d28c602af5863ce3472fd04b086a5b07934 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Sun, 17 Sep 2023 00:21:59 +1000
Subject: [PATCH] [PUI] Part category page (#5555)

* Replace PartIndex with CategoryDetail

- Can pass a category ID to show a single category
- Otherwise, show the top-level parts category

* Refactor <InvenTreeTable> component

- Simplify property passing
- Easier tableRefresh mechanism

* Refetch table data when base parameters change

* Correctly update pages when ID changes

* Notification panel cleanup

* Remove column from InvenTreeTableProps type

* more fancy

* Fix notification alert

* Implement useLocalStorage hook

* useLocalStorage hook for table filters too
---
 .../src/components/items/StylishText.tsx      |  10 +-
 src/frontend/src/components/nav/Header.tsx    |   5 +-
 .../src/components/nav/NotificationDrawer.tsx |  12 +-
 .../src/components/nav/PageDetail.tsx         |   3 +-
 .../src/components/tables/AttachmentTable.tsx |  20 +-
 .../src/components/tables/InvenTreeTable.tsx  | 305 +++++++++---------
 .../tables/build/BuildOrderTable.tsx          |  40 ++-
 .../notifications/NotificationsTable.tsx      |  10 +-
 .../tables/part/PartCategoryTable.tsx         |  63 ++++
 .../src/components/tables/part/PartTable.tsx  |  47 ++-
 .../tables/part/RelatedPartTable.tsx          |  18 +-
 .../tables/stock/StockItemTable.tsx           |  46 +--
 src/frontend/src/hooks/TableRefresh.tsx       |  18 +-
 src/frontend/src/pages/Index/Stock.tsx        |  39 ++-
 src/frontend/src/pages/Notifications.tsx      |  15 +-
 src/frontend/src/pages/build/BuildDetail.tsx  |   6 +-
 .../src/pages/part/CategoryDetail.tsx         | 108 +++++++
 src/frontend/src/pages/part/PartDetail.tsx    |  16 +-
 src/frontend/src/pages/part/PartIndex.tsx     |  61 ----
 src/frontend/src/router.tsx                   |  11 +-
 20 files changed, 502 insertions(+), 351 deletions(-)
 create mode 100644 src/frontend/src/components/tables/part/PartCategoryTable.tsx
 create mode 100644 src/frontend/src/pages/part/CategoryDetail.tsx
 delete mode 100644 src/frontend/src/pages/part/PartIndex.tsx

diff --git a/src/frontend/src/components/items/StylishText.tsx b/src/frontend/src/components/items/StylishText.tsx
index cbd63f019c..877fa7a680 100644
--- a/src/frontend/src/components/items/StylishText.tsx
+++ b/src/frontend/src/components/items/StylishText.tsx
@@ -2,10 +2,16 @@ import { Text } from '@mantine/core';
 
 import { InvenTreeStyle } from '../../globalStyle';
 
-export function StylishText({ children }: { children: JSX.Element | string }) {
+export function StylishText({
+  children,
+  size = 'md'
+}: {
+  children: JSX.Element | string;
+  size?: string;
+}) {
   const { classes } = InvenTreeStyle();
   return (
-    <Text className={classes.signText} variant="gradient">
+    <Text size={size} className={classes.signText} variant="gradient">
       {children}
     </Text>
   );
diff --git a/src/frontend/src/components/nav/Header.tsx b/src/frontend/src/components/nav/Header.tsx
index 15edb27a71..18ae20dbf2 100644
--- a/src/frontend/src/components/nav/Header.tsx
+++ b/src/frontend/src/components/nav/Header.tsx
@@ -62,7 +62,10 @@ export function Header() {
       <NavigationDrawer opened={navDrawerOpened} close={closeNavDrawer} />
       <NotificationDrawer
         opened={notificationDrawerOpened}
-        onClose={closeNotificationDrawer}
+        onClose={() => {
+          notifications.refetch();
+          closeNotificationDrawer();
+        }}
       />
       <Container className={classes.layoutHeaderSection} size={'xl'}>
         <Group position="apart">
diff --git a/src/frontend/src/components/nav/NotificationDrawer.tsx b/src/frontend/src/components/nav/NotificationDrawer.tsx
index f2e0391047..acd5e3b1df 100644
--- a/src/frontend/src/components/nav/NotificationDrawer.tsx
+++ b/src/frontend/src/components/nav/NotificationDrawer.tsx
@@ -1,16 +1,17 @@
 import { t } from '@lingui/macro';
 import {
   ActionIcon,
+  Alert,
   Divider,
   Drawer,
   LoadingOverlay,
   Space,
   Tooltip
 } from '@mantine/core';
-import { Badge, Group, Stack, Text } from '@mantine/core';
-import { IconBellCheck, IconBellPlus, IconBookmark } from '@tabler/icons-react';
-import { IconMacro } from '@tabler/icons-react';
+import { Group, Stack, Text } from '@mantine/core';
+import { IconBellCheck, IconBellPlus } from '@tabler/icons-react';
 import { useQuery } from '@tanstack/react-query';
+import { useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 
 import { api } from '../../App';
@@ -79,6 +80,11 @@ export function NotificationDrawer({
       <Stack spacing="xs">
         <Divider />
         <LoadingOverlay visible={notificationQuery.isFetching} />
+        {notificationQuery.data?.results?.length == 0 && (
+          <Alert color="green">
+            <Text size="sm">{t`You have no unread notifications.`}</Text>
+          </Alert>
+        )}
         {notificationQuery.data?.results.map((notification: any) => (
           <Group position="apart">
             <Stack spacing="3">
diff --git a/src/frontend/src/components/nav/PageDetail.tsx b/src/frontend/src/components/nav/PageDetail.tsx
index 87e9329bf8..c82aa62540 100644
--- a/src/frontend/src/components/nav/PageDetail.tsx
+++ b/src/frontend/src/components/nav/PageDetail.tsx
@@ -1,6 +1,7 @@
 import { Group, Paper, Space, Stack, Text } from '@mantine/core';
 import { ReactNode } from 'react';
 
+import { StylishText } from '../items/StylishText';
 import { Breadcrumb, BreadcrumbList } from './BreadcrumbList';
 
 /**
@@ -33,7 +34,7 @@ export function PageDetail({
         <Stack spacing="xs">
           <Group position="apart">
             <Group position="left">
-              <Text size="xl">{title}</Text>
+              <StylishText size="xl">{title}</StylishText>
               {subtitle && <Text size="lg">{subtitle}</Text>}
             </Group>
             <Space />
diff --git a/src/frontend/src/components/tables/AttachmentTable.tsx b/src/frontend/src/components/tables/AttachmentTable.tsx
index 6a529f7b8c..895ab9722f 100644
--- a/src/frontend/src/components/tables/AttachmentTable.tsx
+++ b/src/frontend/src/components/tables/AttachmentTable.tsx
@@ -81,9 +81,7 @@ export function AttachmentTable({
   pk: number;
   model: string;
 }): ReactNode {
-  const tableId = useId();
-
-  const { refreshId, refreshTable } = useTableRefresh();
+  const { tableKey, refreshTable } = useTableRefresh(`${model}-attachments`);
 
   const tableColumns = useMemo(() => attachmentTableColumns(), []);
 
@@ -224,14 +222,16 @@ export function AttachmentTable({
     <Stack spacing="xs">
       <InvenTreeTable
         url={url}
-        tableKey={tableId}
-        refreshId={refreshId}
-        params={{
-          [model]: pk
-        }}
-        customActionGroups={customActionGroups}
+        tableKey={tableKey}
         columns={tableColumns}
-        rowActions={allowEdit && allowDelete ? rowActions : undefined}
+        props={{
+          enableSelection: true,
+          customActionGroups: customActionGroups,
+          rowActions: allowEdit && allowDelete ? rowActions : undefined,
+          params: {
+            [model]: pk
+          }
+        }}
       />
       {allowEdit && (
         <Dropzone onDrop={uploadFiles}>
diff --git a/src/frontend/src/components/tables/InvenTreeTable.tsx b/src/frontend/src/components/tables/InvenTreeTable.tsx
index d00dda1a16..cf04a888ba 100644
--- a/src/frontend/src/components/tables/InvenTreeTable.tsx
+++ b/src/frontend/src/components/tables/InvenTreeTable.tsx
@@ -1,6 +1,7 @@
 import { t } from '@lingui/macro';
 import { ActionIcon, Indicator, Space, Stack, Tooltip } from '@mantine/core';
 import { Group } from '@mantine/core';
+import { useLocalStorage } from '@mantine/hooks';
 import { IconFilter, IconRefresh } from '@tabler/icons-react';
 import { IconBarcode, IconPrinter } from '@tabler/icons-react';
 import { useQuery } from '@tanstack/react-query';
@@ -18,96 +19,33 @@ import { FilterSelectModal } from './FilterSelectModal';
 import { RowAction, RowActions } from './RowActions';
 import { TableSearchInput } from './Search';
 
-/*
- * Load list of hidden columns from local storage.
- * Returns a list of column names which are "hidden" for the current table
- */
-function loadHiddenColumns(tableKey: string) {
-  return JSON.parse(
-    localStorage.getItem(`inventree-hidden-table-columns-${tableKey}`) || '[]'
-  );
-}
+const defaultPageSize: number = 25;
 
 /**
- * Write list of hidden columns to local storage
- * @param tableKey : string - unique key for the table
- * @param columns : string[] - list of column names
- */
-function saveHiddenColumns(tableKey: string, columns: any[]) {
-  localStorage.setItem(
-    `inventree-hidden-table-columns-${tableKey}`,
-    JSON.stringify(columns)
-  );
-}
-
-/**
- * Loads the list of active filters from local storage
- * @param tableKey : string - unique key for the table
- * @param filterList : TableFilter[] - list of available filters
- * @returns a map of active filters for the current table, {name: value}
- */
-function loadActiveFilters(tableKey: string, filterList: TableFilter[]) {
-  let active = JSON.parse(
-    localStorage.getItem(`inventree-active-table-filters-${tableKey}`) || '{}'
-  );
-
-  // We expect that the active filter list is a map of {name: value}
-  // Return *only* those filters which are in the filter list
-  let x = filterList
-    .filter((f) => f.name in active)
-    .map((f) => ({
-      ...f,
-      value: active[f.name]
-    }));
-
-  return x;
-}
-
-/**
- * Write the list of active filters to local storage
- * @param tableKey : string - unique key for the table
- * @param filters : any - map of active filters, {name: value}
- */
-function saveActiveFilters(tableKey: string, filters: TableFilter[]) {
-  let active = Object.fromEntries(filters.map((flt) => [flt.name, flt.value]));
-
-  localStorage.setItem(
-    `inventree-active-table-filters-${tableKey}`,
-    JSON.stringify(active)
-  );
-}
-
-/**
- * Table Component which extends DataTable with custom InvenTree functionality
+ * Set of optional properties which can be passed to an InvenTreeTable component
  *
- * TODO: Refactor table props into a single type
+ * @param url : string - The API endpoint to query
+ * @param params : any - Base query parameters
+ * @param tableKey : string - Unique key for the table (used for local storage)
+ * @param refreshId : string - Unique ID for the table (used to trigger a refresh)
+ * @param defaultSortColumn : string - Default column to sort by
+ * @param noRecordsText : string - Text to display when no records are found
+ * @param enableDownload : boolean - Enable download actions
+ * @param enableFilters : boolean - Enable filter actions
+ * @param enableSelection : boolean - Enable row selection
+ * @param enableSearch : boolean - Enable search actions
+ * @param enablePagination : boolean - Enable pagination
+ * @param enableRefresh : boolean - Enable refresh actions
+ * @param pageSize : number - Number of records per page
+ * @param barcodeActions : any[] - List of barcode actions
+ * @param customFilters : TableFilter[] - List of custom filters
+ * @param customActionGroups : any[] - List of custom action groups
+ * @param printingActions : any[] - List of printing actions
+ * @param rowActions : (record: any) => RowAction[] - Callback function to generate row actions
+ * @param onRowClick : (record: any, index: number, event: any) => void - Callback function when a row is clicked
  */
-export function InvenTreeTable({
-  url,
-  params,
-  columns,
-  enableDownload = false,
-  enableFilters = true,
-  enablePagination = true,
-  enableRefresh = true,
-  enableSearch = true,
-  enableSelection = false,
-  pageSize = 25,
-  tableKey = '',
-  defaultSortColumn = '',
-  noRecordsText = t`No records found`,
-  printingActions = [],
-  barcodeActions = [],
-  customActionGroups = [],
-  customFilters = [],
-  rowActions,
-  onRowClick,
-  refreshId
-}: {
-  url: string;
-  params: any;
-  columns: TableColumn[];
-  tableKey: string;
+export type InvenTreeTableProps = {
+  params?: any;
   defaultSortColumn?: string;
   noRecordsText?: string;
   enableDownload?: boolean;
@@ -117,23 +55,79 @@ export function InvenTreeTable({
   enablePagination?: boolean;
   enableRefresh?: boolean;
   pageSize?: number;
-  printingActions?: any[];
   barcodeActions?: any[];
-  customActionGroups?: any[];
   customFilters?: TableFilter[];
+  customActionGroups?: any[];
+  printingActions?: any[];
   rowActions?: (record: any) => RowAction[];
   onRowClick?: (record: any, index: number, event: any) => void;
-  refreshId?: string;
+};
+
+/**
+ * Default table properties (used if not specified)
+ */
+const defaultInvenTreeTableProps: InvenTreeTableProps = {
+  params: {},
+  noRecordsText: t`No records found`,
+  enableDownload: false,
+  enableFilters: true,
+  enablePagination: true,
+  enableRefresh: true,
+  enableSearch: true,
+  enableSelection: false,
+  pageSize: defaultPageSize,
+  defaultSortColumn: '',
+  printingActions: [],
+  barcodeActions: [],
+  customFilters: [],
+  customActionGroups: [],
+  rowActions: (record: any) => [],
+  onRowClick: (record: any, index: number, event: any) => {}
+};
+
+/**
+ * Table Component which extends DataTable with custom InvenTree functionality
+ */
+export function InvenTreeTable({
+  url,
+  tableKey,
+  columns,
+  props
+}: {
+  url: string;
+  tableKey: string;
+  columns: TableColumn[];
+  props: InvenTreeTableProps;
 }) {
+  // Use the first part of the table key as the table name
+  const tableName: string = useMemo(() => {
+    return tableKey.split('-')[0];
+  }, []);
+
+  // Build table properties based on provided props (and default props)
+  const tableProps: InvenTreeTableProps = useMemo(() => {
+    return {
+      ...defaultInvenTreeTableProps,
+      ...props
+    };
+  }, [props]);
+
   // Check if any columns are switchable (can be hidden)
   const hasSwitchableColumns = columns.some(
     (col: TableColumn) => col.switchable
   );
 
-  // Manage state for switchable columns (initially load from local storage)
-  let [hiddenColumns, setHiddenColumns] = useState(() =>
-    loadHiddenColumns(tableKey)
-  );
+  // A list of hidden columns, saved to local storage
+  const [hiddenColumns, setHiddenColumns] = useLocalStorage<string[]>({
+    key: `inventree-hidden-table-columns-${tableName}`,
+    defaultValue: []
+  });
+
+  // Active filters (saved to local storage)
+  const [activeFilters, setActiveFilters] = useLocalStorage<any[]>({
+    key: `inventree-active-table-filters-${tableName}`,
+    defaultValue: []
+  });
 
   // Data selection
   const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
@@ -158,7 +152,7 @@ export function InvenTreeTable({
     });
 
     // If row actions are available, add a column for them
-    if (rowActions) {
+    if (tableProps.rowActions) {
       cols.push({
         accessor: 'actions',
         title: '',
@@ -168,7 +162,7 @@ export function InvenTreeTable({
         render: function (record: any) {
           return (
             <RowActions
-              actions={rowActions(record)}
+              actions={tableProps.rowActions?.(record) ?? []}
               disabled={selectedRecords.length > 0}
             />
           );
@@ -177,7 +171,13 @@ export function InvenTreeTable({
     }
 
     return cols;
-  }, [columns, hiddenColumns, rowActions, enableSelection, selectedRecords]);
+  }, [
+    columns,
+    hiddenColumns,
+    tableProps.rowActions,
+    tableProps.enableSelection,
+    selectedRecords
+  ]);
 
   // Callback when column visibility is toggled
   function toggleColumn(columnName: string) {
@@ -189,20 +189,11 @@ export function InvenTreeTable({
       newColumns[colIdx].hidden = !newColumns[colIdx].hidden;
     }
 
-    let hiddenColumnNames = newColumns
-      .filter((col) => col.hidden)
-      .map((col) => col.accessor);
-
-    // Save list of hidden columns to local storage
-    saveHiddenColumns(tableKey, hiddenColumnNames);
-
-    // Refresh state
-    setHiddenColumns(loadHiddenColumns(tableKey));
+    setHiddenColumns(
+      newColumns.filter((col) => col.hidden).map((col) => col.accessor)
+    );
   }
 
-  // Check if custom filtering is enabled for this table
-  const hasCustomFilters = enableFilters && customFilters.length > 0;
-
   // Filter selection open state
   const [filterSelectOpen, setFilterSelectOpen] = useState<boolean>(false);
 
@@ -212,11 +203,6 @@ export function InvenTreeTable({
   // Filter list visibility
   const [filtersVisible, setFiltersVisible] = useState<boolean>(false);
 
-  // Map of currently active filters, {name: value}
-  const [activeFilters, setActiveFilters] = useState(() =>
-    loadActiveFilters(tableKey, customFilters)
-  );
-
   /*
    * Callback for the "add filter" button.
    * Launches a modal dialog to add a new filter
@@ -224,7 +210,7 @@ export function InvenTreeTable({
   function onFilterAdd(name: string, value: string) {
     let filters = [...activeFilters];
 
-    let newFilter = customFilters.find((flt) => flt.name == name);
+    let newFilter = tableProps.customFilters?.find((flt) => flt.name == name);
 
     if (newFilter) {
       filters.push({
@@ -232,7 +218,6 @@ export function InvenTreeTable({
         value: value
       });
 
-      saveActiveFilters(tableKey, filters);
       setActiveFilters(filters);
     }
   }
@@ -242,7 +227,7 @@ export function InvenTreeTable({
    */
   function onFilterRemove(filterName: string) {
     let filters = activeFilters.filter((flt) => flt.name != filterName);
-    saveActiveFilters(tableKey, filters);
+
     setActiveFilters(filters);
   }
 
@@ -250,7 +235,6 @@ export function InvenTreeTable({
    * Callback function when all custom filters are removed from the table
    */
   function onFilterClearAll() {
-    saveActiveFilters(tableKey, []);
     setActiveFilters([]);
   }
 
@@ -266,7 +250,9 @@ export function InvenTreeTable({
    * Construct query filters for the current table
    */
   function getTableFilters(paginate: boolean = false) {
-    let queryParams = { ...params };
+    let queryParams = {
+      ...tableProps.params
+    };
 
     // Add custom filters
     activeFilters.forEach((flt) => (queryParams[flt.name] = flt.value));
@@ -277,7 +263,8 @@ export function InvenTreeTable({
     }
 
     // Pagination
-    if (enablePagination && paginate) {
+    if (tableProps.enablePagination && paginate) {
+      let pageSize = tableProps.pageSize ?? defaultPageSize;
       queryParams.limit = pageSize;
       queryParams.offset = (page - 1) * pageSize;
     }
@@ -315,7 +302,7 @@ export function InvenTreeTable({
 
   // Data Sorting
   const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
-    columnAccessor: defaultSortColumn,
+    columnAccessor: tableProps.defaultSortColumn ?? '',
     direction: 'asc'
   });
 
@@ -335,8 +322,9 @@ export function InvenTreeTable({
   }
 
   // Missing records text (based on server response)
-  const [missingRecordsText, setMissingRecordsText] =
-    useState<string>(noRecordsText);
+  const [missingRecordsText, setMissingRecordsText] = useState<string>(
+    tableProps.noRecordsText ?? t`No records found`
+  );
 
   const handleSortStatusChange = (status: DataTableSortStatus) => {
     setPage(1);
@@ -355,7 +343,9 @@ export function InvenTreeTable({
       .then(function (response) {
         switch (response.status) {
           case 200:
-            setMissingRecordsText(noRecordsText);
+            setMissingRecordsText(
+              tableProps.noRecordsText ?? t`No records found`
+            );
             return response.data;
           case 400:
             setMissingRecordsText(t`Bad request`);
@@ -386,7 +376,7 @@ export function InvenTreeTable({
 
   const { data, isError, isFetching, isLoading, refetch } = useQuery(
     [
-      `table-${tableKey}`,
+      `table-${tableName}`,
       sortStatus.columnAccessor,
       sortStatus.direction,
       page,
@@ -407,15 +397,13 @@ export function InvenTreeTable({
    * Implement this using the custom useTableRefresh hook
    */
   useEffect(() => {
-    if (refreshId) {
-      refetch();
-    }
-  }, [refreshId]);
+    refetch();
+  }, [tableKey, props.params]);
 
   return (
     <>
       <FilterSelectModal
-        availableFilters={customFilters}
+        availableFilters={tableProps.customFilters ?? []}
         activeFilters={activeFilters}
         opened={filterSelectOpen}
         onCreateFilter={onFilterAdd}
@@ -424,35 +412,37 @@ export function InvenTreeTable({
       <Stack>
         <Group position="apart">
           <Group position="left" spacing={5}>
-            {customActionGroups.map((group: any, idx: number) => group)}
-            {barcodeActions.length > 0 && (
+            {tableProps.customActionGroups?.map(
+              (group: any, idx: number) => group
+            )}
+            {(tableProps.barcodeActions?.length ?? 0 > 0) && (
               <ButtonMenu
                 icon={<IconBarcode />}
                 label={t`Barcode actions`}
                 tooltip={t`Barcode actions`}
-                actions={barcodeActions}
+                actions={tableProps.barcodeActions ?? []}
               />
             )}
-            {printingActions.length > 0 && (
+            {(tableProps.printingActions?.length ?? 0 > 0) && (
               <ButtonMenu
                 icon={<IconPrinter />}
                 label={t`Print actions`}
                 tooltip={t`Print actions`}
-                actions={printingActions}
+                actions={tableProps.printingActions ?? []}
               />
             )}
-            {enableDownload && (
+            {tableProps.enableDownload && (
               <DownloadAction downloadCallback={downloadData} />
             )}
           </Group>
           <Space />
           <Group position="right" spacing={5}>
-            {enableSearch && (
+            {tableProps.enableSearch && (
               <TableSearchInput
                 searchCallback={(term: string) => setSearchTerm(term)}
               />
             )}
-            {enableRefresh && (
+            {tableProps.enableRefresh && (
               <ActionIcon>
                 <Tooltip label={t`Refresh data`}>
                   <IconRefresh onClick={() => refetch()} />
@@ -465,21 +455,22 @@ export function InvenTreeTable({
                 onToggleColumn={toggleColumn}
               />
             )}
-            {hasCustomFilters && (
-              <Indicator
-                size="xs"
-                label={activeFilters.length}
-                disabled={activeFilters.length == 0}
-              >
-                <ActionIcon>
-                  <Tooltip label={t`Table filters`}>
-                    <IconFilter
-                      onClick={() => setFiltersVisible(!filtersVisible)}
-                    />
-                  </Tooltip>
-                </ActionIcon>
-              </Indicator>
-            )}
+            {tableProps.enableFilters &&
+              (tableProps.customFilters?.length ?? 0 > 0) && (
+                <Indicator
+                  size="xs"
+                  label={activeFilters.length}
+                  disabled={activeFilters.length == 0}
+                >
+                  <ActionIcon>
+                    <Tooltip label={t`Table filters`}>
+                      <IconFilter
+                        onClick={() => setFiltersVisible(!filtersVisible)}
+                      />
+                    </Tooltip>
+                  </ActionIcon>
+                </Indicator>
+              )}
           </Group>
         </Group>
         {filtersVisible && (
@@ -498,20 +489,22 @@ export function InvenTreeTable({
           idAccessor={'pk'}
           minHeight={200}
           totalRecords={data?.count ?? data?.length ?? 0}
-          recordsPerPage={pageSize}
+          recordsPerPage={tableProps.pageSize ?? defaultPageSize}
           page={page}
           onPageChange={setPage}
           sortStatus={sortStatus}
           onSortStatusChange={handleSortStatusChange}
-          selectedRecords={enableSelection ? selectedRecords : undefined}
+          selectedRecords={
+            tableProps.enableSelection ? selectedRecords : undefined
+          }
           onSelectedRecordsChange={
-            enableSelection ? onSelectedRecordsChange : undefined
+            tableProps.enableSelection ? onSelectedRecordsChange : undefined
           }
           fetching={isFetching}
           noRecordsText={missingRecordsText}
           records={data?.results ?? data ?? []}
           columns={dataColumns}
-          onRowClick={onRowClick}
+          onRowClick={tableProps.onRowClick}
         />
       </Stack>
     </>
diff --git a/src/frontend/src/components/tables/build/BuildOrderTable.tsx b/src/frontend/src/components/tables/build/BuildOrderTable.tsx
index d4d9f36343..eb01c3b6c4 100644
--- a/src/frontend/src/components/tables/build/BuildOrderTable.tsx
+++ b/src/frontend/src/components/tables/build/BuildOrderTable.tsx
@@ -1,8 +1,9 @@
 import { t } from '@lingui/macro';
-import { Progress } from '@mantine/core';
+import { Progress, Text } from '@mantine/core';
 import { useMemo } from 'react';
 import { useNavigate } from 'react-router-dom';
 
+import { useTableRefresh } from '../../../hooks/TableRefresh';
 import { ThumbnailHoverCard } from '../../items/Thumbnail';
 import { TableColumn } from '../Column';
 import { TableFilter } from '../Filter';
@@ -27,11 +28,12 @@ function buildOrderTableColumns(): TableColumn[] {
         let part = record.part_detail;
         return (
           part && (
-            <ThumbnailHoverCard
-              src={part.thumbnail || part.image}
-              text={part.full_name}
-              link=""
-            />
+            <Text>{part.full_name}</Text>
+            // <ThumbnailHoverCard
+            //   src={part.thumbnail || part.image}
+            //   text={part.full_name}
+            //   link=""
+            // />
           )
         );
       }
@@ -127,35 +129,31 @@ function buildOrderTableFilters(): TableFilter[] {
   return [];
 }
 
-function buildOrderTableParams(params: any): any {
-  return {
-    ...params,
-    part_detail: true
-  };
-}
-
 /*
  * Construct a table of build orders, according to the provided parameters
  */
 export function BuildOrderTable({ params = {} }: { params?: any }) {
-  // Add required query parameters
-  const tableParams = useMemo(() => buildOrderTableParams(params), [params]);
   const tableColumns = useMemo(() => buildOrderTableColumns(), []);
   const tableFilters = useMemo(() => buildOrderTableFilters(), []);
 
   const navigate = useNavigate();
 
-  tableParams.part_detail = true;
+  const { tableKey, refreshTable } = useTableRefresh('buildorder');
 
   return (
     <InvenTreeTable
       url="build/"
-      enableDownload
-      tableKey="build-order-table"
-      params={tableParams}
+      tableKey={tableKey}
       columns={tableColumns}
-      customFilters={tableFilters}
-      onRowClick={(row) => navigate(`/build/${row.pk}`)}
+      props={{
+        enableDownload: true,
+        params: {
+          ...params,
+          part_detail: true
+        },
+        customFilters: tableFilters,
+        onRowClick: (row) => navigate(`/build/${row.pk}`)
+      }}
     />
   );
 }
diff --git a/src/frontend/src/components/tables/notifications/NotificationsTable.tsx b/src/frontend/src/components/tables/notifications/NotificationsTable.tsx
index 08212a6363..70d745240d 100644
--- a/src/frontend/src/components/tables/notifications/NotificationsTable.tsx
+++ b/src/frontend/src/components/tables/notifications/NotificationsTable.tsx
@@ -7,12 +7,10 @@ import { RowAction } from '../RowActions';
 
 export function NotificationTable({
   params,
-  refreshId,
   tableKey,
   actions
 }: {
   params: any;
-  refreshId: string;
   tableKey: string;
   actions: (record: any) => RowAction[];
 }) {
@@ -43,10 +41,12 @@ export function NotificationTable({
     <InvenTreeTable
       url="/notifications/"
       tableKey={tableKey}
-      refreshId={refreshId}
-      params={params}
-      rowActions={actions}
       columns={columns}
+      props={{
+        rowActions: actions,
+        enableSelection: true,
+        params: params
+      }}
     />
   );
 }
diff --git a/src/frontend/src/components/tables/part/PartCategoryTable.tsx b/src/frontend/src/components/tables/part/PartCategoryTable.tsx
new file mode 100644
index 0000000000..1f87635996
--- /dev/null
+++ b/src/frontend/src/components/tables/part/PartCategoryTable.tsx
@@ -0,0 +1,63 @@
+import { t } from '@lingui/macro';
+import { useMemo } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { useTableRefresh } from '../../../hooks/TableRefresh';
+import { TableColumn } from '../Column';
+import { InvenTreeTable } from '../InvenTreeTable';
+
+/**
+ * PartCategoryTable - Displays a table of part categories
+ */
+export function PartCategoryTable({ params = {} }: { params?: any }) {
+  const navigate = useNavigate();
+
+  const { tableKey, refreshTable } = useTableRefresh('partcategory');
+
+  const tableColumns: TableColumn[] = useMemo(() => {
+    return [
+      {
+        accessor: 'name',
+        title: t`Name`,
+        sortable: true,
+        switchable: false
+      },
+      {
+        accessor: 'description',
+        title: t`Description`,
+        sortable: false,
+        switchable: true
+      },
+      {
+        accessor: 'pathstring',
+        title: t`Path`,
+        sortable: false,
+        switchable: true
+      },
+      {
+        accessor: 'part_count',
+        title: t`Parts`,
+        sortable: true,
+        switchable: true
+      }
+    ];
+  }, []);
+
+  return (
+    <InvenTreeTable
+      url="part/category/"
+      tableKey={tableKey}
+      columns={tableColumns}
+      props={{
+        enableDownload: true,
+        enableSelection: true,
+        params: {
+          ...params
+        },
+        onRowClick: (record, index, event) => {
+          navigate(`/part/category/${record.pk}`);
+        }
+      }}
+    />
+  );
+}
diff --git a/src/frontend/src/components/tables/part/PartTable.tsx b/src/frontend/src/components/tables/part/PartTable.tsx
index c64a1fce0f..2e7fc0df60 100644
--- a/src/frontend/src/components/tables/part/PartTable.tsx
+++ b/src/frontend/src/components/tables/part/PartTable.tsx
@@ -1,16 +1,16 @@
 import { t } from '@lingui/macro';
 import { Text } from '@mantine/core';
-import { IconEdit, IconTrash } from '@tabler/icons-react';
 import { useMemo } from 'react';
 import { useNavigate } from 'react-router-dom';
 
 import { editPart } from '../../../functions/forms/PartForms';
 import { notYetImplemented } from '../../../functions/notifications';
 import { shortenString } from '../../../functions/tables';
+import { useTableRefresh } from '../../../hooks/TableRefresh';
 import { ThumbnailHoverCard } from '../../items/Thumbnail';
 import { TableColumn } from '../Column';
 import { TableFilter } from '../Filter';
-import { InvenTreeTable } from '../InvenTreeTable';
+import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
 import { RowAction } from '../RowActions';
 
 /**
@@ -26,11 +26,12 @@ function partTableColumns(): TableColumn[] {
       render: function (record: any) {
         // TODO - Link to the part detail page
         return (
-          <ThumbnailHoverCard
-            src={record.thumbnail || record.image}
-            text={record.name}
-            link=""
-          />
+          <Text>{record.full_name}</Text>
+          // <ThumbnailHoverCard
+          //   src={record.thumbnail || record.image}
+          //   text={record.name}
+          //   link=""
+          // />
         );
       }
     },
@@ -178,23 +179,17 @@ function partTableFilters(): TableFilter[] {
   ];
 }
 
-function partTableParams(params: any): any {
-  return {
-    ...params,
-    category_detail: true
-  };
-}
-
 /**
  * PartListTable - Displays a list of parts, based on the provided parameters
  * @param {Object} params - The query parameters to pass to the API
  * @returns
  */
-export function PartListTable({ params = {} }: { params?: any }) {
-  let tableParams = useMemo(() => partTableParams(params), [params]);
+export function PartListTable({ props }: { props: InvenTreeTableProps }) {
   let tableColumns = useMemo(() => partTableColumns(), []);
   let tableFilters = useMemo(() => partTableFilters(), []);
 
+  const { tableKey, refreshTable } = useTableRefresh('part');
+
   // Callback function for generating set of row actions
   function partTableRowActions(record: any): RowAction[] {
     let actions: RowAction[] = [];
@@ -227,16 +222,18 @@ export function PartListTable({ params = {} }: { params?: any }) {
   return (
     <InvenTreeTable
       url="part/"
-      enableDownload
-      tableKey="part-table"
-      printingActions={[
-        <Text onClick={notYetImplemented}>Hello</Text>,
-        <Text onClick={notYetImplemented}>World</Text>
-      ]}
-      params={tableParams}
+      tableKey={tableKey}
       columns={tableColumns}
-      customFilters={tableFilters}
-      rowActions={partTableRowActions}
+      props={{
+        ...props,
+        enableDownload: true,
+        customFilters: tableFilters,
+        rowActions: partTableRowActions,
+        params: {
+          ...props.params,
+          category_detail: true
+        }
+      }}
     />
   );
 }
diff --git a/src/frontend/src/components/tables/part/RelatedPartTable.tsx b/src/frontend/src/components/tables/part/RelatedPartTable.tsx
index c1831cff03..2f9246cde9 100644
--- a/src/frontend/src/components/tables/part/RelatedPartTable.tsx
+++ b/src/frontend/src/components/tables/part/RelatedPartTable.tsx
@@ -11,7 +11,7 @@ import { TableColumn } from '../Column';
 import { InvenTreeTable } from '../InvenTreeTable';
 
 export function RelatedPartTable({ partId }: { partId: number }): ReactNode {
-  const { refreshId, refreshTable } = useTableRefresh();
+  const { tableKey, refreshTable } = useTableRefresh('relatedparts');
 
   const navigate = useNavigate();
 
@@ -116,14 +116,16 @@ export function RelatedPartTable({ partId }: { partId: number }): ReactNode {
   return (
     <InvenTreeTable
       url="/part/related/"
-      tableKey="related-part-table"
-      refreshId={refreshId}
-      params={{
-        part: partId
-      }}
-      rowActions={rowActions}
+      tableKey={tableKey}
       columns={tableColumns}
-      customActionGroups={customActions}
+      props={{
+        params: {
+          part: partId,
+          catefory_detail: true
+        },
+        rowActions: rowActions,
+        customActionGroups: customActions
+      }}
     />
   );
 }
diff --git a/src/frontend/src/components/tables/stock/StockItemTable.tsx b/src/frontend/src/components/tables/stock/StockItemTable.tsx
index f9dbba62a3..76b726b192 100644
--- a/src/frontend/src/components/tables/stock/StockItemTable.tsx
+++ b/src/frontend/src/components/tables/stock/StockItemTable.tsx
@@ -1,10 +1,9 @@
 import { t } from '@lingui/macro';
-import { Group } from '@mantine/core';
-import { IconEdit, IconTrash } from '@tabler/icons-react';
-import { useEffect, useMemo, useState } from 'react';
+import { Text } from '@mantine/core';
+import { useMemo } from 'react';
 
 import { notYetImplemented } from '../../../functions/notifications';
-import { ActionButton } from '../../items/ActionButton';
+import { useTableRefresh } from '../../../hooks/TableRefresh';
 import { ThumbnailHoverCard } from '../../items/Thumbnail';
 import { TableColumn } from '../Column';
 import { TableFilter } from '../Filter';
@@ -23,11 +22,12 @@ function stockItemTableColumns(): TableColumn[] {
       render: function (record: any) {
         let part = record.part_detail;
         return (
-          <ThumbnailHoverCard
-            src={part.thumbnail || part.image}
-            text={part.name}
-            link=""
-          />
+          <Text>{part.full_name}</Text>
+          // <ThumbnailHoverCard
+          //   src={part.thumbnail || part.image}
+          //   text={part.name}
+          //   link=""
+          // />
         );
       }
     },
@@ -102,17 +102,11 @@ function stockItemTableFilters(): TableFilter[] {
  * Load a table of stock items
  */
 export function StockItemTable({ params = {} }: { params?: any }) {
-  let tableParams = useMemo(() => {
-    return {
-      part_detail: true,
-      location_detail: true,
-      ...params
-    };
-  }, [params]);
-
   let tableColumns = useMemo(() => stockItemTableColumns(), []);
   let tableFilters = useMemo(() => stockItemTableFilters(), []);
 
+  const { tableKey, refreshTable } = useTableRefresh('stockitem');
+
   function stockItemRowActions(record: any): RowAction[] {
     let actions: RowAction[] = [];
 
@@ -129,13 +123,19 @@ export function StockItemTable({ params = {} }: { params?: any }) {
   return (
     <InvenTreeTable
       url="stock/"
-      tableKey="stock-table"
-      enableDownload
-      enableSelection
-      params={tableParams}
+      tableKey={tableKey}
       columns={tableColumns}
-      customFilters={tableFilters}
-      rowActions={stockItemRowActions}
+      props={{
+        enableDownload: true,
+        enableSelection: true,
+        customFilters: tableFilters,
+        rowActions: stockItemRowActions,
+        params: {
+          ...params,
+          part_detail: true,
+          location_detail: true
+        }
+      }}
     />
   );
 }
diff --git a/src/frontend/src/hooks/TableRefresh.tsx b/src/frontend/src/hooks/TableRefresh.tsx
index b45e1b4544..3e5a2a0116 100644
--- a/src/frontend/src/hooks/TableRefresh.tsx
+++ b/src/frontend/src/hooks/TableRefresh.tsx
@@ -5,21 +5,25 @@ import { useCallback, useState } from 'react';
  * Custom hook for refreshing an InvenTreeTable externally
  * Returns a unique ID for the table, which can be updated to trigger a refresh of the <table className=""></table>
  *
- * @returns [refreshId, refreshTable]
+ * @returns { tableKey, refreshTable }
  *
  * To use this hook:
- * const [refreshId, refreshTable] = useTableRefresh();
+ * const { tableKey, refreshTable } = useTableRefresh();
  *
  * Then, pass the refreshId to the InvenTreeTable component:
- * <InvenTreeTable refreshId={refreshId} ... />
+ * <InvenTreeTable tableKey={tableKey} ... />
  */
-export function useTableRefresh() {
-  const [refreshId, setRefreshId] = useState<string>(randomId());
+export function useTableRefresh(tableName: string) {
+  const [tableKey, setTableKey] = useState<string>(generateTableName());
+
+  function generateTableName() {
+    return `${tableName}-${randomId()}`;
+  }
 
   // Generate a new ID to refresh the table
   const refreshTable = useCallback(function () {
-    setRefreshId(randomId());
+    setTableKey(generateTableName());
   }, []);
 
-  return { refreshId, refreshTable };
+  return { tableKey, refreshTable };
 }
diff --git a/src/frontend/src/pages/Index/Stock.tsx b/src/frontend/src/pages/Index/Stock.tsx
index 8676c29096..b9b6d7020d 100644
--- a/src/frontend/src/pages/Index/Stock.tsx
+++ b/src/frontend/src/pages/Index/Stock.tsx
@@ -1,20 +1,37 @@
-import { Trans } from '@lingui/macro';
-import { Group } from '@mantine/core';
+import { t } from '@lingui/macro';
+import { Stack } from '@mantine/core';
+import { IconPackages, IconSitemap } from '@tabler/icons-react';
+import { useMemo } from 'react';
 
-import { PlaceholderPill } from '../../components/items/Placeholder';
-import { StylishText } from '../../components/items/StylishText';
+import { PlaceholderPanel } from '../../components/items/Placeholder';
+import { PageDetail } from '../../components/nav/PageDetail';
+import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
 import { StockItemTable } from '../../components/tables/stock/StockItemTable';
 
 export default function Stock() {
+  const categoryPanels: PanelType[] = useMemo(() => {
+    return [
+      {
+        name: 'stock-items',
+        label: t`Stock Items`,
+        icon: <IconPackages size="18" />,
+        content: <StockItemTable />
+      },
+      {
+        name: 'sublocations',
+        label: t`Sublocations`,
+        icon: <IconSitemap size="18" />,
+        content: <PlaceholderPanel />
+      }
+    ];
+  }, []);
+
   return (
     <>
-      <Group>
-        <StylishText>
-          <Trans>Stock Items</Trans>
-        </StylishText>
-        <PlaceholderPill />
-      </Group>
-      <StockItemTable />
+      <Stack>
+        <PageDetail title={t`Stock Items`} />
+        <PanelGroup panels={categoryPanels} />
+      </Stack>
     </>
   );
 }
diff --git a/src/frontend/src/pages/Notifications.tsx b/src/frontend/src/pages/Notifications.tsx
index e6a8b40f2c..538815a646 100644
--- a/src/frontend/src/pages/Notifications.tsx
+++ b/src/frontend/src/pages/Notifications.tsx
@@ -5,13 +5,14 @@ import { useMemo } from 'react';
 
 import { api } from '../App';
 import { StylishText } from '../components/items/StylishText';
+import { PageDetail } from '../components/nav/PageDetail';
 import { PanelGroup } from '../components/nav/PanelGroup';
 import { NotificationTable } from '../components/tables/notifications/NotificationsTable';
 import { useTableRefresh } from '../hooks/TableRefresh';
 
 export default function NotificationsPage() {
-  const unreadRefresh = useTableRefresh();
-  const historyRefresh = useTableRefresh();
+  const unreadRefresh = useTableRefresh('unreadnotifications');
+  const historyRefresh = useTableRefresh('readnotifications');
 
   const notificationPanels = useMemo(() => {
     return [
@@ -22,8 +23,7 @@ export default function NotificationsPage() {
         content: (
           <NotificationTable
             params={{ read: false }}
-            refreshId={unreadRefresh.refreshId}
-            tableKey="notifications-unread"
+            tableKey={unreadRefresh.tableKey}
             actions={(record) => [
               {
                 title: t`Mark as read`,
@@ -48,8 +48,7 @@ export default function NotificationsPage() {
         content: (
           <NotificationTable
             params={{ read: true }}
-            refreshId={historyRefresh.refreshId}
-            tableKey="notifications-history"
+            tableKey={historyRefresh.tableKey}
             actions={(record) => [
               {
                 title: t`Mark as unread`,
@@ -83,8 +82,8 @@ export default function NotificationsPage() {
 
   return (
     <>
-      <Stack spacing="xs">
-        <StylishText>{t`Notifications`}</StylishText>
+      <Stack>
+        <PageDetail title={t`Notifications`} />
         <PanelGroup panels={notificationPanels} />
       </Stack>
     </>
diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx
index 67064419ad..01e600f19a 100644
--- a/src/frontend/src/pages/build/BuildDetail.tsx
+++ b/src/frontend/src/pages/build/BuildDetail.tsx
@@ -12,7 +12,7 @@ import {
   IconSitemap
 } from '@tabler/icons-react';
 import { useQuery } from '@tanstack/react-query';
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
 import { useParams } from 'react-router-dom';
 
 import { api } from '../../App';
@@ -36,6 +36,10 @@ export default function BuildDetail() {
   // Build data
   const [build, setBuild] = useState<any>({});
 
+  useEffect(() => {
+    setBuild({});
+  }, [id]);
+
   // Query hook for fetching build data
   const buildQuery = useQuery(['build', id ?? -1], async () => {
     let url = `/build/${id}/`;
diff --git a/src/frontend/src/pages/part/CategoryDetail.tsx b/src/frontend/src/pages/part/CategoryDetail.tsx
new file mode 100644
index 0000000000..512b4d6533
--- /dev/null
+++ b/src/frontend/src/pages/part/CategoryDetail.tsx
@@ -0,0 +1,108 @@
+import { t } from '@lingui/macro';
+import { Stack, Text } from '@mantine/core';
+import {
+  IconCategory,
+  IconListDetails,
+  IconSitemap
+} from '@tabler/icons-react';
+import { useQuery } from '@tanstack/react-query';
+import { useEffect, useMemo, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import { api } from '../../App';
+import { PlaceholderPanel } from '../../components/items/Placeholder';
+import { PageDetail } from '../../components/nav/PageDetail';
+import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
+import { PartCategoryTable } from '../../components/tables/part/PartCategoryTable';
+import { PartListTable } from '../../components/tables/part/PartTable';
+
+/**
+ * Detail view for a single PartCategory instance.
+ *
+ * Note: If no category ID is supplied, this acts as the top-level part category page
+ */
+export default function CategoryDetail({}: {}) {
+  const { id } = useParams();
+
+  const [category, setCategory] = useState<any>({});
+
+  useEffect(() => {
+    setCategory({});
+  }, [id]);
+
+  const categoryQuery = useQuery({
+    enabled: id != null && id != undefined,
+    queryKey: ['category', id],
+    queryFn: async () => {
+      return api
+        .get(`/part/category/${id}/`)
+        .then((response) => {
+          setCategory(response.data);
+          return response.data;
+        })
+        .catch((error) => {
+          console.error('Error fetching category data:', error);
+        });
+    }
+  });
+
+  const categoryPanels: PanelType[] = useMemo(
+    () => [
+      {
+        name: 'parts',
+        label: t`Parts`,
+        icon: <IconCategory size="18" />,
+        content: (
+          <PartListTable
+            props={{
+              params: {
+                category: category.pk ?? null
+              }
+            }}
+          />
+        )
+      },
+      {
+        name: 'subcategories',
+        label: t`Subcategories`,
+        icon: <IconSitemap size="18" />,
+        content: (
+          <PartCategoryTable
+            params={{
+              parent: category.pk ?? null
+            }}
+          />
+        )
+      },
+      {
+        name: 'parameters',
+        label: t`Parameters`,
+        icon: <IconListDetails size="18" />,
+        content: <PlaceholderPanel />
+      }
+    ],
+    [category, id]
+  );
+
+  return (
+    <Stack spacing="xs">
+      <PageDetail
+        title={t`Part Category`}
+        detail={<Text>{category.name ?? 'Top level'}</Text>}
+        breadcrumbs={
+          id
+            ? [
+                { name: t`Parts`, url: '/part' },
+                { name: '...', url: '' },
+                {
+                  name: category.name ?? t`Top level`,
+                  url: `/part/category/${category.pk}`
+                }
+              ]
+            : []
+        }
+      />
+      <PanelGroup panels={categoryPanels} />
+    </Stack>
+  );
+}
diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx
index 6c6169fbb5..d49746925a 100644
--- a/src/frontend/src/pages/part/PartDetail.tsx
+++ b/src/frontend/src/pages/part/PartDetail.tsx
@@ -25,15 +25,12 @@ import {
   IconVersions
 } from '@tabler/icons-react';
 import { useQuery } from '@tanstack/react-query';
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
 import { useMemo } from 'react';
 import { useNavigate, useParams } from 'react-router-dom';
 
 import { api } from '../../App';
-import {
-  PlaceholderPanel,
-  PlaceholderPill
-} from '../../components/items/Placeholder';
+import { PlaceholderPanel } from '../../components/items/Placeholder';
 import { PageDetail } from '../../components/nav/PageDetail';
 import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
 import { AttachmentTable } from '../../components/tables/AttachmentTable';
@@ -45,12 +42,19 @@ import {
 } from '../../components/widgets/MarkdownEditor';
 import { editPart } from '../../functions/forms/PartForms';
 
+/**
+ * Detail view for a single Part instance
+ */
 export default function PartDetail() {
   const { id } = useParams();
 
   // Part data
   const [part, setPart] = useState<any>({});
 
+  useEffect(() => {
+    setPart({});
+  }, [id]);
+
   // Part data panels (recalculate when part data changes)
   const partPanels: PanelType[] = useMemo(() => {
     return [
@@ -212,7 +216,7 @@ export default function PartDetail() {
           breadcrumbs={[
             { name: t`Parts`, url: '/part' },
             { name: '...', url: '' },
-            { name: part.full_name, url: `/part/${part.pk}` }
+            { name: part.name, url: `/part/${part.pk}` }
           ]}
           actions={[
             <Button
diff --git a/src/frontend/src/pages/part/PartIndex.tsx b/src/frontend/src/pages/part/PartIndex.tsx
deleted file mode 100644
index 078cad45f4..0000000000
--- a/src/frontend/src/pages/part/PartIndex.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { Trans, t } from '@lingui/macro';
-import { Stack } from '@mantine/core';
-import {
-  IconCategory,
-  IconListDetails,
-  IconSitemap
-} from '@tabler/icons-react';
-import { useMemo } from 'react';
-
-import { PlaceholderPill } from '../../components/items/Placeholder';
-import { StylishText } from '../../components/items/StylishText';
-import { PageDetail } from '../../components/nav/PageDetail';
-import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
-import { PartListTable } from '../../components/tables/part/PartTable';
-
-/**
- * Part index page
- */
-export default function PartIndex() {
-  const panels: PanelType[] = useMemo(() => {
-    return [
-      {
-        name: 'parts',
-        label: t`Parts`,
-        icon: <IconCategory size="18" />,
-        content: <PartListTable />
-      },
-      {
-        name: 'categories',
-        label: t`Categories`,
-        icon: <IconSitemap size="18" />,
-        content: <PlaceholderPill />
-      },
-      {
-        name: 'parameters',
-        label: t`Parameters`,
-        icon: <IconListDetails size="18" />,
-        content: <PlaceholderPill />
-      }
-    ];
-  }, []);
-
-  return (
-    <>
-      <Stack>
-        <PageDetail
-          title={t`Parts`}
-          breadcrumbs={
-            [
-              // {
-              //   name: t`Parts`,
-              //   url: '/part',
-              // }
-            ]
-          }
-        />
-        <PanelGroup panels={panels} />
-      </Stack>
-    </>
-  );
-}
diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx
index d26d53c21c..966b20fdd3 100644
--- a/src/frontend/src/router.tsx
+++ b/src/frontend/src/router.tsx
@@ -11,7 +11,10 @@ export const Home = Loadable(lazy(() => import('./pages/Index/Home')));
 export const Playground = Loadable(
   lazy(() => import('./pages/Index/Playground'))
 );
-export const PartIndex = Loadable(lazy(() => import('./pages/part/PartIndex')));
+
+export const CategoryDetail = Loadable(
+  lazy(() => import('./pages/part/CategoryDetail'))
+);
 export const PartDetail = Loadable(
   lazy(() => import('./pages/part/PartDetail'))
 );
@@ -87,7 +90,11 @@ export const router = createBrowserRouter(
         },
         {
           path: 'part/',
-          element: <PartIndex />
+          element: <CategoryDetail />
+        },
+        {
+          path: 'part/category/:id',
+          element: <CategoryDetail />
         },
         {
           path: 'part/:id',