mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
[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:
parent
a9d18ba28f
commit
9705521cd2
@ -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(
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
18
src/frontend/src/components/items/YesNoButton.tsx
Normal file
18
src/frontend/src/components/items/YesNoButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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) =>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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`,
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -387,7 +387,7 @@ export function InvenTreeTable({
|
||||
fetchTableData,
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: 'always'
|
||||
refetchOnMount: true
|
||||
}
|
||||
);
|
||||
|
||||
|
170
src/frontend/src/components/tables/part/PartParameterTable.tsx
Normal file
170
src/frontend/src/components/tables/part/PartParameterTable.tsx
Normal 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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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
|
||||
});
|
||||
}
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
@ -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',
|
||||
|
@ -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 }
|
||||
},
|
||||
{
|
||||
|
@ -88,7 +88,7 @@ export default function NotificationsPage() {
|
||||
<>
|
||||
<Stack>
|
||||
<PageDetail title={t`Notifications`} />
|
||||
<PanelGroup panels={notificationPanels} />
|
||||
<PanelGroup pageKey="notifications" panels={notificationPanels} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user