[React] Part parameters table (#5709)

* Track current panel selection in local storage

* Simplify part detail tabs

* Fix <PanelGroup> instances

* Handle missing model type for rendering

* Add some more API endpoints

* Add PartParameter table

* Add callback to create new part parameter

* Allow PartParameter list API endpoint to be searched

* More PanelGroup collapse tweaks

- Still requires more attention

* Fix logic for related part table

- Need to rebuild columns when part id changes

* Further fixes for related part table

* Re-implement change to PanelGroup

- useLocalStorage
- Change got clobbered in recent merge conflict

* Add part thumbnail to StockItemTable

* Add simple <YesNo> button

- Can be improved later

* Fix for PartTable

* Allow CORS requests to /static/ endpoint

* Updates to other existing tables

* Update URLs for dashboard items
This commit is contained in:
Oliver 2023-10-16 23:34:16 +11:00 committed by GitHub
parent a9d18ba28f
commit 9705521cd2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 383 additions and 125 deletions

View File

@ -140,7 +140,7 @@ ALLOWED_HOSTS = get_setting(
# Cross Origin Resource Sharing (CORS) options
# Only allow CORS access to API and media endpoints
CORS_URLS_REGEX = r'^/(api|media)/.*$'
CORS_URLS_REGEX = r'^/(api|media|static)/.*$'
# Extract CORS options from configuration file
CORS_ORIGIN_ALLOW_ALL = get_boolean_setting(

View File

@ -1509,6 +1509,13 @@ class PartParameterList(PartParameterAPIMixin, ListCreateAPI):
'data': ['data_numeric', 'data'],
}
search_fields = [
'data',
'template__name',
'template__description',
'template__units',
]
filterset_fields = [
'part',
'template',

View File

@ -207,6 +207,7 @@ export function ApiForm({
// Data validation error
form.setErrors(error.response.data);
setNonFieldErrors(error.response.data.non_field_errors ?? []);
setIsLoading(false);
break;
default:
// Unexpected state on form error

View File

@ -0,0 +1,18 @@
import { t } from '@lingui/macro';
import { Badge } from '@mantine/core';
export function YesNoButton(value: any) {
const bool =
String(value).toLowerCase().trim() in ['true', '1', 't', 'y', 'yes'];
return (
<Badge
color={bool ? 'green' : 'red'}
variant="filled"
radius="lg"
size="sm"
>
{bool ? t`Yes` : t`No`}
</Badge>
);
}

View File

@ -1,4 +1,12 @@
import { Divider, Paper, Stack, Tabs, Tooltip } from '@mantine/core';
import {
ActionIcon,
Divider,
Paper,
Stack,
Tabs,
Tooltip
} from '@mantine/core';
import { useLocalStorage } from '@mantine/hooks';
import {
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarRightCollapse
@ -29,29 +37,31 @@ export type PanelType = {
* @returns
*/
export function PanelGroup({
pageKey,
panels,
selectedPanel,
onPanelChange
}: {
pageKey: string;
panels: PanelType[];
selectedPanel?: string;
onPanelChange?: (panel: string) => void;
}): ReactNode {
// Default to the provided panel name, or the first panel
const [activePanelName, setActivePanelName] = useState<string>(
selectedPanel || panels.length > 0 ? panels[0].name : ''
);
const [activePanel, setActivePanel] = useLocalStorage<string>({
key: `panel-group-active-panel-${pageKey}`,
defaultValue: selectedPanel || panels.length > 0 ? panels[0].name : ''
});
// Update the active panel when the selected panel changes
useEffect(() => {
if (selectedPanel) {
setActivePanelName(selectedPanel);
setActivePanel(selectedPanel);
}
}, [selectedPanel]);
// Callback when the active panel changes
function handlePanelChange(panel: string) {
setActivePanelName(panel);
setActivePanel(panel);
// Optionally call external callback hook
if (onPanelChange) {
@ -64,7 +74,7 @@ export function PanelGroup({
return (
<Paper p="sm" radius="xs" shadow="xs">
<Tabs
value={activePanelName}
value={activePanel}
orientation="vertical"
onTabChange={handlePanelChange}
keepMounted={false}
@ -89,19 +99,18 @@ export function PanelGroup({
</Tooltip>
)
)}
<Tabs.Tab
key="panel-tab-collapse-toggle"
p="xs"
value="collapse-toggle"
<ActionIcon
style={{
paddingLeft: '10px'
}}
onClick={() => setExpanded(!expanded)}
icon={
expanded ? (
<IconLayoutSidebarLeftCollapse opacity={0.35} size={18} />
) : (
<IconLayoutSidebarRightCollapse opacity={0.35} size={18} />
)
}
/>
>
{expanded ? (
<IconLayoutSidebarLeftCollapse opacity={0.5} />
) : (
<IconLayoutSidebarRightCollapse opacity={0.5} />
)}
</ActionIcon>
</Tabs.List>
{panels.map(
(panel, idx) =>

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Alert } from '@mantine/core';
import { Alert, Space } from '@mantine/core';
import { Group, Text } from '@mantine/core';
import { ReactNode } from 'react';
@ -18,7 +18,11 @@ import {
RenderSalesOrder,
RenderSalesOrderShipment
} from './Order';
import { RenderPart, RenderPartCategory } from './Part';
import {
RenderPart,
RenderPartCategory,
RenderPartParameterTemplate
} from './Part';
import { RenderStockItem, RenderStockLocation } from './Stock';
import { RenderOwner, RenderUser } from './User';
@ -40,6 +44,7 @@ const RendererLookup: EnumDictionary<
[ModelType.owner]: RenderOwner,
[ModelType.part]: RenderPart,
[ModelType.partcategory]: RenderPartCategory,
[ModelType.partparametertemplate]: RenderPartParameterTemplate,
[ModelType.purchaseorder]: RenderPurchaseOrder,
[ModelType.returnorder]: RenderReturnOrder,
[ModelType.salesorder]: RenderSalesOrder,
@ -63,8 +68,18 @@ export function RenderInstance({
model: ModelType | undefined;
instance: any;
}): ReactNode {
if (model === undefined) return <UnknownRenderer model={model} />;
if (model === undefined) {
console.error('RenderInstance: No model provided');
return <UnknownRenderer model={model} />;
}
const RenderComponent = RendererLookup[model];
if (!RenderComponent) {
console.error(`RenderInstance: No renderer for model ${model}`);
return <UnknownRenderer model={model} />;
}
return <RenderComponent instance={instance} />;
}
@ -74,12 +89,14 @@ export function RenderInstance({
export function RenderInlineModel({
primary,
secondary,
suffix,
image,
labels,
url
}: {
primary: string;
secondary?: string;
suffix?: string;
image?: string;
labels?: string[];
url?: string;
@ -88,10 +105,18 @@ export function RenderInlineModel({
// TODO: Handle URL
return (
<Group spacing="xs">
{image && Thumbnail({ src: image, size: 18 })}
<Text size="sm">{primary}</Text>
{secondary && <Text size="xs">{secondary}</Text>}
<Group spacing="xs" position="apart">
<Group spacing="xs" position="left">
{image && Thumbnail({ src: image, size: 18 })}
<Text size="sm">{primary}</Text>
{secondary && <Text size="xs">{secondary}</Text>}
</Group>
{suffix && (
<>
<Space />
<Text size="xs">{suffix}</Text>
</>
)}
</Group>
);
}

View File

@ -5,6 +5,7 @@ export enum ModelType {
supplierpart = 'supplierpart',
manufacturerpart = 'manufacturerpart',
partcategory = 'partcategory',
partparametertemplate = 'partparametertemplate',
stockitem = 'stockitem',
stocklocation = 'stocklocation',
build = 'build',
@ -37,6 +38,12 @@ export const ModelInformationDict: ModelDictory = {
url_overview: '/part',
url_detail: '/part/:pk/'
},
partparametertemplate: {
label: t`Part Parameter Template`,
label_multiple: t`Part Parameter Templates`,
url_overview: '/partparametertemplate',
url_detail: '/partparametertemplate/:pk/'
},
supplierpart: {
label: t`Supplier Part`,
label_multiple: t`Supplier Parts`,

View File

@ -30,3 +30,20 @@ export function RenderPartCategory({ instance }: { instance: any }): ReactNode {
/>
);
}
/**
* Inline rendering of a PartParameterTemplate instance
*/
export function RenderPartParameterTemplate({
instance
}: {
instance: any;
}): ReactNode {
return (
<RenderInlineModel
primary={instance.name}
secondary={instance.description}
suffix={instance.units}
/>
);
}

View File

@ -387,7 +387,7 @@ export function InvenTreeTable({
fetchTableData,
{
refetchOnWindowFocus: false,
refetchOnMount: 'always'
refetchOnMount: true
}
);

View File

@ -0,0 +1,170 @@
import { t } from '@lingui/macro';
import { ActionIcon, Text, Tooltip } from '@mantine/core';
import { IconTextPlus } from '@tabler/icons-react';
import { useCallback, useMemo } from 'react';
import {
openCreateApiForm,
openDeleteApiForm,
openEditApiForm
} from '../../../functions/forms';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { YesNoButton } from '../../items/YesNoButton';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
/**
* Construct a table listing parameters for a given part
*/
export function PartParameterTable({ partId }: { partId: any }) {
const { tableKey, refreshTable } = useTableRefresh('part-parameters');
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'name',
title: t`Parameter`,
switchable: false,
sortable: true,
render: (record) => record.template_detail?.name
},
{
accessor: 'description',
title: t`Description`,
sortable: false,
switchable: true,
render: (record) => record.template_detail?.description
},
{
accessor: 'data',
title: t`Value`,
switchable: false,
sortable: true,
render: (record) => {
let template = record.template_detail;
if (template?.checkbox) {
return <YesNoButton value={record.data} />;
}
if (record.data_numeric) {
// TODO: Numeric data
}
// TODO: Units
return record.data;
}
},
{
accessor: 'units',
title: t`Units`,
switchable: true,
sortable: true,
render: (record) => record.template_detail?.units
}
];
}, []);
// Callback for row actions
// TODO: Adjust based on user permissions
const rowActions = useCallback((record: any) => {
let actions = [];
actions.push({
title: t`Edit`,
onClick: () => {
openEditApiForm({
name: 'edit-part-parameter',
url: ApiPaths.part_parameter_list,
pk: record.pk,
title: t`Edit Part Parameter`,
fields: {
part: {
hidden: true
},
template: {},
data: {}
},
successMessage: t`Part parameter updated`,
onFormSuccess: refreshTable
});
}
});
actions.push({
title: t`Delete`,
color: 'red',
onClick: () => {
openDeleteApiForm({
name: 'delete-part-parameter',
url: ApiPaths.part_parameter_list,
pk: record.pk,
title: t`Delete Part Parameter`,
successMessage: t`Part parameter deleted`,
onFormSuccess: refreshTable,
preFormContent: (
<Text>{t`Are you sure you want to remove this parameter?`}</Text>
)
});
}
});
return actions;
}, []);
const addParameter = useCallback(() => {
if (!partId) {
return;
}
openCreateApiForm({
name: 'add-part-parameter',
url: ApiPaths.part_parameter_list,
title: t`Add Part Parameter`,
fields: {
part: {
hidden: true,
value: partId
},
template: {},
data: {}
},
successMessage: t`Part parameter added`,
onFormSuccess: refreshTable
});
}, [partId]);
// Custom table actions
const tableActions = useMemo(() => {
let actions = [];
// TODO: Hide if user does not have permission to edit parts
actions.push(
<Tooltip label={t`Add parameter`}>
<ActionIcon radius="sm" onClick={addParameter}>
<IconTextPlus color="green" />
</ActionIcon>
</Tooltip>
);
return actions;
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiPaths.part_parameter_list)}
tableKey={tableKey}
columns={tableColumns}
props={{
rowActions: rowActions,
customActionGroups: tableActions,
params: {
part: partId,
template_detail: true
}
}}
/>
);
}

View File

@ -35,11 +35,6 @@ function partTableColumns(): TableColumn[] {
/>
<Text>{record.full_name}</Text>
</Group>
// <ThumbnailHoverCard
// src={record.thumbnail || record.image}
// text={record.name}
// link=""
// />
);
}
},
@ -68,7 +63,7 @@ function partTableColumns(): TableColumn[] {
render: function (record: any) {
// TODO: Link to the category detail page
return shortenString({
str: record.category_detail.pathstring
str: record.category_detail?.pathstring
});
}
},

View File

@ -11,6 +11,9 @@ import { Thumbnail } from '../../images/Thumbnail';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
/**
* Construct a table listing related parts for a given part
*/
export function RelatedPartTable({ partId }: { partId: number }): ReactNode {
const { tableKey, refreshTable } = useTableRefresh('relatedparts');
@ -56,7 +59,7 @@ export function RelatedPartTable({ partId }: { partId: number }): ReactNode {
}
}
];
}, []);
}, [partId]);
const addRelatedPart = useCallback(() => {
openCreateApiForm({
@ -75,7 +78,7 @@ export function RelatedPartTable({ partId }: { partId: number }): ReactNode {
successMessage: t`Related part added`,
onFormSuccess: refreshTable
});
}, []);
}, [partId]);
const customActions: ReactNode[] = useMemo(() => {
// TODO: Hide if user does not have permission to edit parts

View File

@ -1,11 +1,12 @@
import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import { Group, Text } from '@mantine/core';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { notYetImplemented } from '../../../functions/notifications';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { Thumbnail } from '../../images/Thumbnail';
import { TableColumn } from '../Column';
import { TableFilter } from '../Filter';
import { RowAction } from '../RowActions';
@ -21,14 +22,16 @@ function stockItemTableColumns(): TableColumn[] {
sortable: true,
title: t`Part`,
render: function (record: any) {
let part = record.part_detail;
let part = record.part_detail ?? {};
return (
<Text>{part.full_name}</Text>
// <ThumbnailHoverCard
// src={part.thumbnail || part.image}
// text={part.name}
// link=""
// />
<Group spacing="xs" noWrap={true}>
<Thumbnail
src={part?.thumbnail || part?.image}
alt={part?.name}
size={24}
/>
<Text>{part.full_name}</Text>
</Group>
);
}
},

View File

@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { YesNoButton } from '../../items/YesNoButton';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
@ -44,16 +45,14 @@ export function StockLocationTable({ params = {} }: { params?: any }) {
title: t`Structural`,
switchable: true,
sortable: true,
render: (record: any) => (record.structural ? 'Y' : 'N')
// TODO: custom 'true / false' label,
render: (record: any) => <YesNoButton value={record.structural} />
},
{
accessor: 'external',
title: t`External`,
switchable: true,
sortable: true,
render: (record: any) => (record.structural ? 'Y' : 'N')
// TODO: custom 'true / false' label,
render: (record: any) => <YesNoButton value={record.external} />
},
{
accessor: 'location_type',

View File

@ -1,116 +1,118 @@
import { t } from '@lingui/macro';
import { ApiPaths, apiUrl } from '../states/ApiState';
export const dashboardItems = [
{
id: 'starred-parts',
text: t`Subscribed Parts`,
icon: 'fa-bell',
url: 'part',
url: apiUrl(ApiPaths.part_list),
params: { starred: true }
},
{
id: 'starred-categories',
text: t`Subscribed Categories`,
icon: 'fa-bell',
url: 'part/category',
url: apiUrl(ApiPaths.category_list),
params: { starred: true }
},
{
id: 'latest-parts',
text: t`Latest Parts`,
icon: 'fa-newspaper',
url: 'part',
url: apiUrl(ApiPaths.part_list),
params: { ordering: '-creation_date', limit: 10 }
},
{
id: 'bom-validation',
text: t`BOM Waiting Validation`,
icon: 'fa-times-circle',
url: 'part',
url: apiUrl(ApiPaths.part_list),
params: { bom_valid: false }
},
{
id: 'recently-updated-stock',
text: t`Recently Updated`,
icon: 'fa-clock',
url: 'stock',
url: apiUrl(ApiPaths.stock_item_list),
params: { part_detail: true, ordering: '-updated', limit: 10 }
},
{
id: 'low-stock',
text: t`Low Stock`,
icon: 'fa-flag',
url: 'part',
url: apiUrl(ApiPaths.part_list),
params: { low_stock: true }
},
{
id: 'depleted-stock',
text: t`Depleted Stock`,
icon: 'fa-times',
url: 'part',
url: apiUrl(ApiPaths.part_list),
params: { depleted_stock: true }
},
{
id: 'stock-to-build',
text: t`Required for Build Orders`,
icon: 'fa-bullhorn',
url: 'part',
url: apiUrl(ApiPaths.part_list),
params: { stock_to_build: true }
},
{
id: 'expired-stock',
text: t`Expired Stock`,
icon: 'fa-calendar-times',
url: 'stock',
url: apiUrl(ApiPaths.stock_item_list),
params: { expired: true }
},
{
id: 'stale-stock',
text: t`Stale Stock`,
icon: 'fa-stopwatch',
url: 'stock',
url: apiUrl(ApiPaths.stock_item_list),
params: { stale: true, expired: true }
},
{
id: 'build-pending',
text: t`Build Orders In Progress`,
icon: 'fa-cogs',
url: 'build',
url: apiUrl(ApiPaths.build_order_list),
params: { active: true }
},
{
id: 'build-overdue',
text: t`Overdue Build Orders`,
icon: 'fa-calendar-times',
url: 'build',
url: apiUrl(ApiPaths.build_order_list),
params: { overdue: true }
},
{
id: 'po-outstanding',
text: t`Outstanding Purchase Orders`,
icon: 'fa-sign-in-alt',
url: 'order/po',
url: apiUrl(ApiPaths.purchase_order_list),
params: { supplier_detail: true, outstanding: true }
},
{
id: 'po-overdue',
text: t`Overdue Purchase Orders`,
icon: 'fa-calendar-times',
url: 'order/po',
url: apiUrl(ApiPaths.purchase_order_list),
params: { supplier_detail: true, overdue: true }
},
{
id: 'so-outstanding',
text: t`Outstanding Sales Orders`,
icon: 'fa-sign-out-alt',
url: 'order/so',
url: apiUrl(ApiPaths.sales_order_list),
params: { customer_detail: true, outstanding: true }
},
{
id: 'so-overdue',
text: t`Overdue Sales Orders`,
icon: 'fa-calendar-times',
url: 'order/so',
url: apiUrl(ApiPaths.sales_order_list),
params: { customer_detail: true, overdue: true }
},
{

View File

@ -88,7 +88,7 @@ export default function NotificationsPage() {
<>
<Stack>
<PageDetail title={t`Notifications`} />
<PanelGroup panels={notificationPanels} />
<PanelGroup pageKey="notifications" panels={notificationPanels} />
</Stack>
</>
);

View File

@ -148,7 +148,7 @@ export default function BuildDetail() {
actions={[<PlaceholderPill key="1" />]}
/>
<LoadingOverlay visible={instanceQuery.isFetching} />
<PanelGroup panels={buildPanels} />
<PanelGroup pageKey="build" panels={buildPanels} />
</Stack>
</>
);

View File

@ -43,7 +43,7 @@ export default function CategoryDetail({}: {}) {
{
name: 'parts',
label: t`Parts`,
icon: <IconCategory size="18" />,
icon: <IconCategory />,
content: (
<PartListTable
props={{
@ -56,8 +56,8 @@ export default function CategoryDetail({}: {}) {
},
{
name: 'subcategories',
label: t`Subcategories`,
icon: <IconSitemap size="18" />,
label: t`Part Categories`,
icon: <IconSitemap />,
content: (
<PartCategoryTable
params={{
@ -69,7 +69,7 @@ export default function CategoryDetail({}: {}) {
{
name: 'parameters',
label: t`Parameters`,
icon: <IconListDetails size="18" />,
icon: <IconListDetails />,
content: <PlaceholderPanel />
}
],
@ -95,7 +95,7 @@ export default function CategoryDetail({}: {}) {
detail={<Text>{category.name ?? 'Top level'}</Text>}
breadcrumbs={breadcrumbs}
/>
<PanelGroup panels={categoryPanels} />
<PanelGroup pageKey="partcategory" panels={categoryPanels} />
</Stack>
);
}

View File

@ -18,22 +18,21 @@ import {
IconPackages,
IconPaperclip,
IconShoppingCart,
IconStack2,
IconTestPipe,
IconTools,
IconTruckDelivery,
IconVersions
} from '@tabler/icons-react';
import React from 'react';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { api } from '../../App';
import { ApiImage } from '../../components/images/ApiImage';
import { Thumbnail } from '../../components/images/Thumbnail';
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';
import { PartParameterTable } from '../../components/tables/part/PartParameterTable';
import { RelatedPartTable } from '../../components/tables/part/RelatedPartTable';
import { StockItemTable } from '../../components/tables/stock/StockItemTable';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
@ -67,87 +66,99 @@ export default function PartDetail() {
{
name: 'details',
label: t`Details`,
icon: <IconInfoCircle size="18" />,
icon: <IconInfoCircle />,
content: <PlaceholderPanel />
},
{
name: 'parameters',
label: t`Parameters`,
icon: <IconList />,
content: <PartParameterTable partId={id ?? -1} />
},
{
name: 'stock',
label: t`Stock`,
icon: <IconPackages size="18" />,
content: partStockTab()
icon: <IconPackages />,
content: (
<StockItemTable
params={{
part: part.pk ?? -1
}}
/>
)
},
{
name: 'variants',
label: t`Variants`,
icon: <IconVersions size="18" />,
icon: <IconVersions />,
hidden: !part.is_template,
content: <PlaceholderPanel />
},
{
name: 'bom',
label: t`Bill of Materials`,
icon: <IconListTree size="18" />,
icon: <IconListTree />,
hidden: !part.assembly,
content: <PlaceholderPanel />
},
{
name: 'builds',
label: t`Build Orders`,
icon: <IconTools size="18" />,
icon: <IconTools />,
hidden: !part.assembly && !part.component,
content: <PlaceholderPanel />
},
{
name: 'used_in',
label: t`Used In`,
icon: <IconList size="18" />,
icon: <IconStack2 />,
hidden: !part.component,
content: <PlaceholderPanel />
},
{
name: 'pricing',
label: t`Pricing`,
icon: <IconCurrencyDollar size="18" />,
icon: <IconCurrencyDollar />,
content: <PlaceholderPanel />
},
{
name: 'suppliers',
label: t`Suppliers`,
icon: <IconBuilding size="18" />,
icon: <IconBuilding />,
hidden: !part.purchaseable,
content: <PlaceholderPanel />
},
{
name: 'purchase_orders',
label: t`Purchase Orders`,
icon: <IconShoppingCart size="18" />,
icon: <IconShoppingCart />,
content: <PlaceholderPanel />,
hidden: !part.purchaseable
},
{
name: 'sales_orders',
label: t`Sales Orders`,
icon: <IconTruckDelivery size="18" />,
icon: <IconTruckDelivery />,
content: <PlaceholderPanel />,
hidden: !part.salable
},
{
name: 'test_templates',
label: t`Test Templates`,
icon: <IconTestPipe size="18" />,
icon: <IconTestPipe />,
content: <PlaceholderPanel />,
hidden: !part.trackable
},
{
name: 'related_parts',
label: t`Related Parts`,
icon: <IconLayersLinked size="18" />,
icon: <IconLayersLinked />,
content: <RelatedPartTable partId={part.pk ?? -1} />
},
{
name: 'attachments',
label: t`Attachments`,
icon: <IconPaperclip size="18" />,
icon: <IconPaperclip />,
content: (
<AttachmentTable
endpoint={ApiPaths.part_attachment_list}
@ -159,33 +170,18 @@ export default function PartDetail() {
{
name: 'notes',
label: t`Notes`,
icon: <IconNotes size="18" />,
content: partNotesTab()
icon: <IconNotes />,
content: (
<NotesEditor
url={apiUrl(ApiPaths.part_list, part.pk)}
data={part.notes ?? ''}
allowEdit={true}
/>
)
}
];
}, [part]);
function partNotesTab(): React.ReactNode {
// TODO: Set edit permission based on user permissions
return (
<NotesEditor
url={apiUrl(ApiPaths.part_list, part.pk)}
data={part.notes ?? ''}
allowEdit={true}
/>
);
}
function partStockTab(): React.ReactNode {
return (
<StockItemTable
params={{
part: part.pk ?? -1
}}
/>
);
}
const breadcrumbs = useMemo(
() => [
{ name: t`Parts`, url: '/part' },
@ -239,7 +235,7 @@ export default function PartDetail() {
</Button>
]}
/>
<PanelGroup panels={partPanels} />
<PanelGroup pageKey="part" panels={partPanels} />
</Stack>
</>
);

View File

@ -31,7 +31,7 @@ export default function Stock() {
{
name: 'stock-items',
label: t`Stock Items`,
icon: <IconPackages size="18" />,
icon: <IconPackages />,
content: (
<StockItemTable
params={{
@ -42,8 +42,8 @@ export default function Stock() {
},
{
name: 'sublocations',
label: t`Sublocations`,
icon: <IconSitemap size="18" />,
label: t`Stock Locations`,
icon: <IconSitemap />,
content: (
<StockLocationTable
params={{
@ -75,7 +75,7 @@ export default function Stock() {
detail={<Text>{location.name ?? 'Top level'}</Text>}
breadcrumbs={breadcrumbs}
/>
<PanelGroup panels={locationPanels} />
<PanelGroup pageKey="stocklocation" panels={locationPanels} />
</Stack>
</>
);

View File

@ -42,37 +42,37 @@ export default function StockDetail() {
{
name: 'details',
label: t`Details`,
icon: <IconInfoCircle size="18" />,
icon: <IconInfoCircle />,
content: <PlaceholderPanel />
},
{
name: 'tracking',
label: t`Stock Tracking`,
icon: <IconHistory size="18" />,
icon: <IconHistory />,
content: <PlaceholderPanel />
},
{
name: 'allocations',
label: t`Allocations`,
icon: <IconBookmark size="18" />,
icon: <IconBookmark />,
content: <PlaceholderPanel />
},
{
name: 'installed_items',
label: t`Installed Items`,
icon: <IconBoxPadding size="18" />,
icon: <IconBoxPadding />,
content: <PlaceholderPanel />
},
{
name: 'child_items',
label: t`Child Items`,
icon: <IconSitemap size="18" />,
icon: <IconSitemap />,
content: <PlaceholderPanel />
},
{
name: 'attachments',
label: t`Attachments`,
icon: <IconPaperclip size="18" />,
icon: <IconPaperclip />,
content: (
<AttachmentTable
endpoint={ApiPaths.stock_attachment_list}
@ -84,7 +84,7 @@ export default function StockDetail() {
{
name: 'notes',
label: t`Notes`,
icon: <IconNotes size="18" />,
icon: <IconNotes />,
content: (
<NotesEditor
url={apiUrl(ApiPaths.stock_item_list, stockitem.pk)}
@ -120,7 +120,7 @@ export default function StockDetail() {
}
breadcrumbs={breadcrumbs}
/>
<PanelGroup panels={stockPanels} />
<PanelGroup pageKey="stockitem" panels={stockPanels} />
</Stack>
);
}

View File

@ -49,6 +49,8 @@ export enum ApiPaths {
category_list = 'api-category-list',
related_part_list = 'api-related-part-list',
part_attachment_list = 'api-part-attachment-list',
part_parameter_list = 'api-part-parameter-list',
part_parameter_template_list = 'api-part-parameter-template-list',
// Company URLs
company_list = 'api-company-list',
@ -111,6 +113,10 @@ export function apiEndpoint(path: ApiPaths): string {
return 'build/attachment/';
case ApiPaths.part_list:
return 'part/';
case ApiPaths.part_parameter_list:
return 'part/parameter/';
case ApiPaths.part_parameter_template_list:
return 'part/parameter/template/';
case ApiPaths.category_list:
return 'part/category/';
case ApiPaths.related_part_list: