mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
[PUI] Part detail page (#5521)
* Very basic part detail page - Simply displays the ID of the part (not any actual data) - Navigate from the part table * Implement generic PanelGroup component - Used for displaying sets of panelized data - Will be used a lot within the interface * Reload part page after edit form * Fix loading overlay for part page * Fix search panel * Add panels to part index page * Fix icons * Fix table row actions menu * PanelGroup: allow active panel to be changed externally * Fix SearchDrawer issue - AbortController does not work as expected - Might need to revisit this later * Improve form loading indicator
This commit is contained in:
parent
9a6c2d2953
commit
a210e905dc
@ -151,6 +151,7 @@ export function ApiForm({
|
||||
|
||||
// Fetch initial data if the fetchInitialData property is set
|
||||
if (props.fetchInitialData) {
|
||||
initialDataQuery.remove();
|
||||
initialDataQuery.refetch();
|
||||
}
|
||||
}, []);
|
||||
@ -162,7 +163,7 @@ export function ApiForm({
|
||||
queryFn: async () => {
|
||||
let method = props.method?.toLowerCase() ?? 'get';
|
||||
|
||||
api({
|
||||
return api({
|
||||
method: method,
|
||||
url: url,
|
||||
data: form.values,
|
||||
@ -199,6 +200,8 @@ export function ApiForm({
|
||||
closeForm();
|
||||
break;
|
||||
}
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response) {
|
||||
|
87
src/frontend/src/components/nav/PanelGroup.tsx
Normal file
87
src/frontend/src/components/nav/PanelGroup.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { Tabs } from '@mantine/core';
|
||||
import { ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Type used to specify a single panel in a panel group
|
||||
*/
|
||||
export type PanelType = {
|
||||
name: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
content: ReactNode;
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param panels : PanelDefinition[] - The list of panels to display
|
||||
* @param activePanel : string - The name of the currently active panel (defaults to the first panel)
|
||||
* @param setActivePanel : (panel: string) => void - Function to set the active panel
|
||||
* @param onPanelChange : (panel: string) => void - Callback when the active panel changes
|
||||
* @returns
|
||||
*/
|
||||
export function PanelGroup({
|
||||
panels,
|
||||
selectedPanel,
|
||||
onPanelChange
|
||||
}: {
|
||||
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 : ''
|
||||
);
|
||||
|
||||
// Update the active panel when the selected panel changes
|
||||
useEffect(() => {
|
||||
if (selectedPanel) {
|
||||
setActivePanelName(selectedPanel);
|
||||
}
|
||||
}, [selectedPanel]);
|
||||
|
||||
// Callback when the active panel changes
|
||||
function handlePanelChange(panel: string) {
|
||||
setActivePanelName(panel);
|
||||
|
||||
// Optionally call external callback hook
|
||||
if (onPanelChange) {
|
||||
onPanelChange(panel);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activePanelName}
|
||||
orientation="vertical"
|
||||
onTabChange={handlePanelChange}
|
||||
keepMounted={false}
|
||||
>
|
||||
<Tabs.List>
|
||||
{panels.map(
|
||||
(panel, idx) =>
|
||||
!panel.hidden && (
|
||||
<Tabs.Tab
|
||||
value={panel.name}
|
||||
icon={panel.icon}
|
||||
hidden={panel.hidden}
|
||||
>
|
||||
{panel.label}
|
||||
</Tabs.Tab>
|
||||
)
|
||||
)}
|
||||
</Tabs.List>
|
||||
{panels.map(
|
||||
(panel, idx) =>
|
||||
!panel.hidden && (
|
||||
<Tabs.Panel key={idx} value={panel.name}>
|
||||
{panel.content}
|
||||
</Tabs.Panel>
|
||||
)
|
||||
)}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
@ -25,7 +25,8 @@ import {
|
||||
IconX
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { RenderInstance } from '../render/Instance';
|
||||
@ -172,10 +173,12 @@ function buildSearchQueries(): SearchQuery[] {
|
||||
*/
|
||||
function QueryResultGroup({
|
||||
query,
|
||||
onRemove
|
||||
onRemove,
|
||||
onResultClick
|
||||
}: {
|
||||
query: SearchQuery;
|
||||
onRemove: (query: string) => void;
|
||||
onResultClick: (query: string, pk: number) => void;
|
||||
}) {
|
||||
if (query.results.count == 0) {
|
||||
return null;
|
||||
@ -206,7 +209,13 @@ function QueryResultGroup({
|
||||
<Divider />
|
||||
<Stack>
|
||||
{query.results.results.map((result: any) => (
|
||||
<RenderInstance instance={result} model={query.name} />
|
||||
<div onClick={() => onResultClick(query.name, result.pk)}>
|
||||
<RenderInstance
|
||||
key={`${query.name}-${result.pk}`}
|
||||
instance={result}
|
||||
model={query.name}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
<Space />
|
||||
@ -263,14 +272,8 @@ export function SearchDrawer({
|
||||
params[query.name] = query.parameters;
|
||||
});
|
||||
|
||||
// Cancel any pending search queries
|
||||
getAbortController().abort();
|
||||
|
||||
return api
|
||||
.post(`/search/`, {
|
||||
params: params,
|
||||
signal: getAbortController().signal
|
||||
})
|
||||
.post(`/search/`, params)
|
||||
.then(function (response) {
|
||||
return response.data;
|
||||
})
|
||||
@ -315,26 +318,25 @@ export function SearchDrawer({
|
||||
}
|
||||
}, [searchQuery.data]);
|
||||
|
||||
// Controller to cancel previous search queries
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const getAbortController = useCallback(() => {
|
||||
if (!abortControllerRef.current) {
|
||||
abortControllerRef.current = new AbortController();
|
||||
}
|
||||
|
||||
return abortControllerRef.current;
|
||||
}, []);
|
||||
|
||||
// Callback to remove a set of results from the list
|
||||
function removeResults(query: string) {
|
||||
setQueryResults(queryResults.filter((q) => q.name != query));
|
||||
}
|
||||
|
||||
// Callback when the drawer is closed
|
||||
function closeDrawer() {
|
||||
setValue('');
|
||||
onClose();
|
||||
}
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Callback when one of the search results is clicked
|
||||
function onResultClick(query: string, pk: number) {
|
||||
closeDrawer();
|
||||
navigate(`/${query}/${pk}/`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={opened}
|
||||
@ -410,6 +412,7 @@ export function SearchDrawer({
|
||||
<QueryResultGroup
|
||||
query={query}
|
||||
onRemove={(query) => removeResults(query)}
|
||||
onResultClick={(query, pk) => onResultClick(query, pk)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
@ -25,7 +25,7 @@ export function RowActions({
|
||||
}): ReactNode {
|
||||
return (
|
||||
actions.length > 0 && (
|
||||
<Menu>
|
||||
<Menu withinPortal={true}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<IconDots />
|
||||
|
@ -2,6 +2,7 @@ import { t } from '@lingui/macro';
|
||||
import { Text } from '@mantine/core';
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { editPart } from '../../../functions/forms/PartForms';
|
||||
import { notYetImplemented } from '../../../functions/notifications';
|
||||
@ -190,13 +191,11 @@ function partTableParams(params: any): any {
|
||||
* @returns
|
||||
*/
|
||||
export function PartListTable({ params = {} }: { params?: any }) {
|
||||
let tableParams = useMemo(() => partTableParams(params), []);
|
||||
let tableParams = useMemo(() => partTableParams(params), [params]);
|
||||
let tableColumns = useMemo(() => partTableColumns(), []);
|
||||
let tableFilters = useMemo(() => partTableFilters(), []);
|
||||
|
||||
// Add required query parameters
|
||||
tableParams.category_detail = true;
|
||||
|
||||
// Callback function for generating set of row actions
|
||||
function partTableRowActions(record: any): RowAction[] {
|
||||
let actions: RowAction[] = [];
|
||||
|
||||
@ -213,16 +212,18 @@ export function PartListTable({ params = {} }: { params?: any }) {
|
||||
}
|
||||
});
|
||||
|
||||
if (record.IPN) {
|
||||
actions.push({
|
||||
title: t`View IPN`,
|
||||
onClick: () => {}
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
title: t`Detail`,
|
||||
onClick: () => {
|
||||
navigate(`/part/${record.pk}/`);
|
||||
}
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<InvenTreeTable
|
||||
url="part/"
|
||||
|
@ -23,7 +23,7 @@ export const footerLinks = [
|
||||
export const navTabs = [
|
||||
{ text: <Trans>Home</Trans>, name: 'home' },
|
||||
{ text: <Trans>Dashboard</Trans>, name: 'dashboard' },
|
||||
{ text: <Trans>Parts</Trans>, name: 'parts' },
|
||||
{ text: <Trans>Parts</Trans>, name: 'part' },
|
||||
{ text: <Trans>Stock</Trans>, name: 'stock' },
|
||||
{ text: <Trans>Build</Trans>, name: 'build' }
|
||||
];
|
||||
|
@ -97,7 +97,8 @@ export function editPart({
|
||||
url: '/part/',
|
||||
pk: part_id,
|
||||
successMessage: t`Part updated`,
|
||||
fields: partFields({ editing: true })
|
||||
fields: partFields({ editing: true }),
|
||||
onFormSuccess: callback
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,20 +0,0 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Group } from '@mantine/core';
|
||||
|
||||
import { PlaceholderPill } from '../../components/items/Placeholder';
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import { PartListTable } from '../../components/tables/part/PartTable';
|
||||
|
||||
export default function Part() {
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
<StylishText>
|
||||
<Trans>Parts</Trans>
|
||||
</StylishText>
|
||||
<PlaceholderPill />
|
||||
</Group>
|
||||
<PartListTable />
|
||||
</>
|
||||
);
|
||||
}
|
200
src/frontend/src/pages/part/PartDetail.tsx
Normal file
200
src/frontend/src/pages/part/PartDetail.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Skeleton,
|
||||
Space,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBox,
|
||||
IconBuilding,
|
||||
IconCurrencyDollar,
|
||||
IconInfoCircle,
|
||||
IconLayersLinked,
|
||||
IconList,
|
||||
IconListTree,
|
||||
IconNotes,
|
||||
IconPackages,
|
||||
IconPaperclip,
|
||||
IconShoppingCart,
|
||||
IconTestPipe,
|
||||
IconTools,
|
||||
IconTruckDelivery,
|
||||
IconVersions
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { StockItemTable } from '../../components/tables/stock/StockItemTable';
|
||||
import { editPart } from '../../functions/forms/PartForms';
|
||||
|
||||
export default function PartDetail() {
|
||||
const { id } = useParams();
|
||||
|
||||
// Part data
|
||||
const [part, setPart] = useState<any>({});
|
||||
|
||||
// Part data panels (recalculate when part data changes)
|
||||
const partPanels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'details',
|
||||
label: t`Details`,
|
||||
icon: <IconInfoCircle size="18" />,
|
||||
content: <Text>part details go here</Text>
|
||||
},
|
||||
{
|
||||
name: 'stock',
|
||||
label: t`Stock`,
|
||||
icon: <IconPackages size="18" />,
|
||||
content: partStockTab()
|
||||
},
|
||||
{
|
||||
name: 'variants',
|
||||
label: t`Variants`,
|
||||
icon: <IconVersions size="18" />,
|
||||
hidden: !part.is_template,
|
||||
content: <Text>part variants go here</Text>
|
||||
},
|
||||
{
|
||||
name: 'bom',
|
||||
label: t`Bill of Materials`,
|
||||
icon: <IconListTree size="18" />,
|
||||
hidden: !part.assembly,
|
||||
content: part.assembly && <Text>part BOM goes here</Text>
|
||||
},
|
||||
{
|
||||
name: 'builds',
|
||||
label: t`Build Orders`,
|
||||
icon: <IconTools size="18" />,
|
||||
hidden: !part.assembly && !part.component,
|
||||
content: <Text>part builds go here</Text>
|
||||
},
|
||||
{
|
||||
name: 'used_in',
|
||||
label: t`Used In`,
|
||||
icon: <IconList size="18" />,
|
||||
hidden: !part.component,
|
||||
content: <Text>part used in goes here</Text>
|
||||
},
|
||||
{
|
||||
name: 'pricing',
|
||||
label: t`Pricing`,
|
||||
icon: <IconCurrencyDollar size="18" />,
|
||||
content: <Text>part pricing goes here</Text>
|
||||
},
|
||||
{
|
||||
name: 'suppliers',
|
||||
label: t`Suppliers`,
|
||||
icon: <IconBuilding size="18" />,
|
||||
content: <Text>part suppliers go here</Text>,
|
||||
hidden: !part.purchaseable
|
||||
},
|
||||
{
|
||||
name: 'purchase_orders',
|
||||
label: t`Purchase Orders`,
|
||||
icon: <IconShoppingCart size="18" />,
|
||||
content: <Text>part purchase orders go here</Text>,
|
||||
hidden: !part.purchaseable
|
||||
},
|
||||
{
|
||||
name: 'sales_orders',
|
||||
label: t`Sales Orders`,
|
||||
icon: <IconTruckDelivery size="18" />,
|
||||
content: <Text>part sales orders go here</Text>,
|
||||
hidden: !part.salable
|
||||
},
|
||||
{
|
||||
name: 'test_templates',
|
||||
label: t`Test Templates`,
|
||||
icon: <IconTestPipe size="18" />,
|
||||
content: <Text>part test templates go here</Text>,
|
||||
hidden: !part.trackable
|
||||
},
|
||||
{
|
||||
name: 'related_parts',
|
||||
label: t`Related Parts`,
|
||||
icon: <IconLayersLinked size="18" />,
|
||||
content: <Text>part related parts go here</Text>
|
||||
},
|
||||
{
|
||||
name: 'attachments',
|
||||
label: t`Attachments`,
|
||||
icon: <IconPaperclip size="18" />,
|
||||
content: <Text>part attachments go here</Text>
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
label: t`Notes`,
|
||||
icon: <IconNotes size="18" />,
|
||||
content: <Text>part notes go here</Text>
|
||||
}
|
||||
];
|
||||
}, [part]);
|
||||
|
||||
// Query hook for fetching part data
|
||||
const partQuery = useQuery(['part', id], async () => {
|
||||
let url = `/part/${id}/`;
|
||||
|
||||
return api
|
||||
.get(url)
|
||||
.then((response) => {
|
||||
setPart(response.data);
|
||||
return response.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
setPart({});
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
function partStockTab(): React.ReactNode {
|
||||
return (
|
||||
<StockItemTable
|
||||
params={{
|
||||
part: part.pk ?? -1
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Stack spacing="xs">
|
||||
<LoadingOverlay visible={partQuery.isFetching} />
|
||||
<Group position="apart">
|
||||
<Group position="left">
|
||||
<Text size="lg">Part Detail</Text>
|
||||
<Text>{part.name}</Text>
|
||||
<Text size="sm">{part.description}</Text>
|
||||
</Group>
|
||||
<Space />
|
||||
<Text>In Stock: {part.total_in_stock}</Text>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="blue"
|
||||
onClick={() =>
|
||||
part.pk &&
|
||||
editPart({
|
||||
part_id: part.pk,
|
||||
callback: () => {
|
||||
partQuery.refetch();
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Group>
|
||||
<PanelGroup panels={partPanels} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
52
src/frontend/src/pages/part/PartIndex.tsx
Normal file
52
src/frontend/src/pages/part/PartIndex.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Stack } from '@mantine/core';
|
||||
import {
|
||||
IconCategory,
|
||||
IconListDetails,
|
||||
IconSitemap
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { PlaceholderPill } from '../../components/items/Placeholder';
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PartListTable } from '../../components/tables/part/PartTable';
|
||||
|
||||
/**
|
||||
* Part index page
|
||||
*/
|
||||
export default function PartIndex() {
|
||||
const panels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'parts',
|
||||
label: t`Parts`,
|
||||
icon: <IconCategory size="18" />,
|
||||
content: <PartListTable />
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
label: t`Categories`,
|
||||
icon: <IconSitemap size="18" />,
|
||||
content: <PlaceholderPill />
|
||||
},
|
||||
{
|
||||
name: 'parameters',
|
||||
label: t`Parameters`,
|
||||
icon: <IconListDetails size="18" />,
|
||||
content: <PlaceholderPill />
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack spacing="xs">
|
||||
<StylishText>
|
||||
<Trans>Parts</Trans>
|
||||
</StylishText>
|
||||
<PanelGroup panels={panels} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
@ -11,7 +11,7 @@ export const Home = Loadable(lazy(() => import('./pages/Index/Home')));
|
||||
export const Playground = Loadable(
|
||||
lazy(() => import('./pages/Index/Playground'))
|
||||
);
|
||||
export const Parts = Loadable(lazy(() => import('./pages/Index/Part')));
|
||||
export const PartIndex = Loadable(lazy(() => import('./pages/part/PartIndex')));
|
||||
export const Stock = Loadable(lazy(() => import('./pages/Index/Stock')));
|
||||
export const Build = Loadable(lazy(() => import('./pages/Index/Build')));
|
||||
|
||||
@ -22,6 +22,11 @@ export const ErrorPage = Loadable(lazy(() => import('./pages/ErrorPage')));
|
||||
export const Profile = Loadable(
|
||||
lazy(() => import('./pages/Index/Profile/Profile'))
|
||||
);
|
||||
|
||||
export const PartDetail = Loadable(
|
||||
lazy(() => import('./pages/part/PartDetail'))
|
||||
);
|
||||
|
||||
export const NotFound = Loadable(lazy(() => import('./pages/NotFound')));
|
||||
export const Login = Loadable(lazy(() => import('./pages/Auth/Login')));
|
||||
export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In')));
|
||||
@ -60,8 +65,12 @@ export const router = createBrowserRouter(
|
||||
element: <Playground />
|
||||
},
|
||||
{
|
||||
path: 'parts/',
|
||||
element: <Parts />
|
||||
path: 'part/',
|
||||
element: <PartIndex />
|
||||
},
|
||||
{
|
||||
path: 'part/:id',
|
||||
element: <PartDetail />
|
||||
},
|
||||
{
|
||||
path: 'stock/',
|
||||
|
Loading…
Reference in New Issue
Block a user