[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 # Cross Origin Resource Sharing (CORS) options
# Only allow CORS access to API and media endpoints # 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 # Extract CORS options from configuration file
CORS_ORIGIN_ALLOW_ALL = get_boolean_setting( CORS_ORIGIN_ALLOW_ALL = get_boolean_setting(

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@ export enum ModelType {
supplierpart = 'supplierpart', supplierpart = 'supplierpart',
manufacturerpart = 'manufacturerpart', manufacturerpart = 'manufacturerpart',
partcategory = 'partcategory', partcategory = 'partcategory',
partparametertemplate = 'partparametertemplate',
stockitem = 'stockitem', stockitem = 'stockitem',
stocklocation = 'stocklocation', stocklocation = 'stocklocation',
build = 'build', build = 'build',
@ -37,6 +38,12 @@ export const ModelInformationDict: ModelDictory = {
url_overview: '/part', url_overview: '/part',
url_detail: '/part/:pk/' url_detail: '/part/:pk/'
}, },
partparametertemplate: {
label: t`Part Parameter Template`,
label_multiple: t`Part Parameter Templates`,
url_overview: '/partparametertemplate',
url_detail: '/partparametertemplate/:pk/'
},
supplierpart: { supplierpart: {
label: t`Supplier Part`, label: t`Supplier Part`,
label_multiple: t`Supplier Parts`, 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, fetchTableData,
{ {
refetchOnWindowFocus: false, 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> <Text>{record.full_name}</Text>
</Group> </Group>
// <ThumbnailHoverCard
// src={record.thumbnail || record.image}
// text={record.name}
// link=""
// />
); );
} }
}, },
@ -68,7 +63,7 @@ function partTableColumns(): TableColumn[] {
render: function (record: any) { render: function (record: any) {
// TODO: Link to the category detail page // TODO: Link to the category detail page
return shortenString({ 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 { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
/**
* Construct a table listing related parts for a given part
*/
export function RelatedPartTable({ partId }: { partId: number }): ReactNode { export function RelatedPartTable({ partId }: { partId: number }): ReactNode {
const { tableKey, refreshTable } = useTableRefresh('relatedparts'); const { tableKey, refreshTable } = useTableRefresh('relatedparts');
@ -56,7 +59,7 @@ export function RelatedPartTable({ partId }: { partId: number }): ReactNode {
} }
} }
]; ];
}, []); }, [partId]);
const addRelatedPart = useCallback(() => { const addRelatedPart = useCallback(() => {
openCreateApiForm({ openCreateApiForm({
@ -75,7 +78,7 @@ export function RelatedPartTable({ partId }: { partId: number }): ReactNode {
successMessage: t`Related part added`, successMessage: t`Related part added`,
onFormSuccess: refreshTable onFormSuccess: refreshTable
}); });
}, []); }, [partId]);
const customActions: ReactNode[] = useMemo(() => { const customActions: ReactNode[] = useMemo(() => {
// TODO: Hide if user does not have permission to edit parts // TODO: Hide if user does not have permission to edit parts

View File

@ -1,11 +1,12 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Text } from '@mantine/core'; import { Group, Text } from '@mantine/core';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { notYetImplemented } from '../../../functions/notifications'; import { notYetImplemented } from '../../../functions/notifications';
import { useTableRefresh } from '../../../hooks/TableRefresh'; import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState'; import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { Thumbnail } from '../../images/Thumbnail';
import { TableColumn } from '../Column'; import { TableColumn } from '../Column';
import { TableFilter } from '../Filter'; import { TableFilter } from '../Filter';
import { RowAction } from '../RowActions'; import { RowAction } from '../RowActions';
@ -21,14 +22,16 @@ function stockItemTableColumns(): TableColumn[] {
sortable: true, sortable: true,
title: t`Part`, title: t`Part`,
render: function (record: any) { render: function (record: any) {
let part = record.part_detail; let part = record.part_detail ?? {};
return ( return (
<Text>{part.full_name}</Text> <Group spacing="xs" noWrap={true}>
// <ThumbnailHoverCard <Thumbnail
// src={part.thumbnail || part.image} src={part?.thumbnail || part?.image}
// text={part.name} alt={part?.name}
// link="" 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 { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState'; import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { YesNoButton } from '../../items/YesNoButton';
import { TableColumn } from '../Column'; import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
@ -44,16 +45,14 @@ export function StockLocationTable({ params = {} }: { params?: any }) {
title: t`Structural`, title: t`Structural`,
switchable: true, switchable: true,
sortable: true, sortable: true,
render: (record: any) => (record.structural ? 'Y' : 'N') render: (record: any) => <YesNoButton value={record.structural} />
// TODO: custom 'true / false' label,
}, },
{ {
accessor: 'external', accessor: 'external',
title: t`External`, title: t`External`,
switchable: true, switchable: true,
sortable: true, sortable: true,
render: (record: any) => (record.structural ? 'Y' : 'N') render: (record: any) => <YesNoButton value={record.external} />
// TODO: custom 'true / false' label,
}, },
{ {
accessor: 'location_type', accessor: 'location_type',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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