diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index 38efa98fc1..cf610785f2 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -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) { diff --git a/src/frontend/src/components/nav/PanelGroup.tsx b/src/frontend/src/components/nav/PanelGroup.tsx new file mode 100644 index 0000000000..57106a2c62 --- /dev/null +++ b/src/frontend/src/components/nav/PanelGroup.tsx @@ -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( + 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 ( + + + {panels.map( + (panel, idx) => + !panel.hidden && ( + + ) + )} + + {panels.map( + (panel, idx) => + !panel.hidden && ( + + {panel.content} + + ) + )} + + ); +} diff --git a/src/frontend/src/components/nav/SearchDrawer.tsx b/src/frontend/src/components/nav/SearchDrawer.tsx index 2c47065e47..bff4997253 100644 --- a/src/frontend/src/components/nav/SearchDrawer.tsx +++ b/src/frontend/src/components/nav/SearchDrawer.tsx @@ -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({ {query.results.results.map((result: any) => ( - +
onResultClick(query.name, result.pk)}> + +
))}
@@ -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(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 ( removeResults(query)} + onResultClick={(query, pk) => onResultClick(query, pk)} /> ))} diff --git a/src/frontend/src/components/tables/RowActions.tsx b/src/frontend/src/components/tables/RowActions.tsx index 1476092805..a619d4a67b 100644 --- a/src/frontend/src/components/tables/RowActions.tsx +++ b/src/frontend/src/components/tables/RowActions.tsx @@ -25,7 +25,7 @@ export function RowActions({ }): ReactNode { return ( actions.length > 0 && ( - + diff --git a/src/frontend/src/components/tables/part/PartTable.tsx b/src/frontend/src/components/tables/part/PartTable.tsx index 98d547ba28..c64a1fce0f 100644 --- a/src/frontend/src/components/tables/part/PartTable.tsx +++ b/src/frontend/src/components/tables/part/PartTable.tsx @@ -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 ( Home, name: 'home' }, { text: Dashboard, name: 'dashboard' }, - { text: Parts, name: 'parts' }, + { text: Parts, name: 'part' }, { text: Stock, name: 'stock' }, { text: Build, name: 'build' } ]; diff --git a/src/frontend/src/functions/forms/PartForms.tsx b/src/frontend/src/functions/forms/PartForms.tsx index eeb81cf403..4e9d202156 100644 --- a/src/frontend/src/functions/forms/PartForms.tsx +++ b/src/frontend/src/functions/forms/PartForms.tsx @@ -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 }); } diff --git a/src/frontend/src/pages/Index/Part.tsx b/src/frontend/src/pages/Index/Part.tsx deleted file mode 100644 index bd8a8f3611..0000000000 --- a/src/frontend/src/pages/Index/Part.tsx +++ /dev/null @@ -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 ( - <> - - - Parts - - - - - - ); -} diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx new file mode 100644 index 0000000000..184e6e68c1 --- /dev/null +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -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({}); + + // Part data panels (recalculate when part data changes) + const partPanels: PanelType[] = useMemo(() => { + return [ + { + name: 'details', + label: t`Details`, + icon: , + content: part details go here + }, + { + name: 'stock', + label: t`Stock`, + icon: , + content: partStockTab() + }, + { + name: 'variants', + label: t`Variants`, + icon: , + hidden: !part.is_template, + content: part variants go here + }, + { + name: 'bom', + label: t`Bill of Materials`, + icon: , + hidden: !part.assembly, + content: part.assembly && part BOM goes here + }, + { + name: 'builds', + label: t`Build Orders`, + icon: , + hidden: !part.assembly && !part.component, + content: part builds go here + }, + { + name: 'used_in', + label: t`Used In`, + icon: , + hidden: !part.component, + content: part used in goes here + }, + { + name: 'pricing', + label: t`Pricing`, + icon: , + content: part pricing goes here + }, + { + name: 'suppliers', + label: t`Suppliers`, + icon: , + content: part suppliers go here, + hidden: !part.purchaseable + }, + { + name: 'purchase_orders', + label: t`Purchase Orders`, + icon: , + content: part purchase orders go here, + hidden: !part.purchaseable + }, + { + name: 'sales_orders', + label: t`Sales Orders`, + icon: , + content: part sales orders go here, + hidden: !part.salable + }, + { + name: 'test_templates', + label: t`Test Templates`, + icon: , + content: part test templates go here, + hidden: !part.trackable + }, + { + name: 'related_parts', + label: t`Related Parts`, + icon: , + content: part related parts go here + }, + { + name: 'attachments', + label: t`Attachments`, + icon: , + content: part attachments go here + }, + { + name: 'notes', + label: t`Notes`, + icon: , + content: part notes go here + } + ]; + }, [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 ( + + ); + } + return ( + <> + + + + + Part Detail + {part.name} + {part.description} + + + In Stock: {part.total_in_stock} + + + + + + ); +} diff --git a/src/frontend/src/pages/part/PartIndex.tsx b/src/frontend/src/pages/part/PartIndex.tsx new file mode 100644 index 0000000000..a72d7200dd --- /dev/null +++ b/src/frontend/src/pages/part/PartIndex.tsx @@ -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: , + content: + }, + { + name: 'categories', + label: t`Categories`, + icon: , + content: + }, + { + name: 'parameters', + label: t`Parameters`, + icon: , + content: + } + ]; + }, []); + + return ( + <> + + + Parts + + + + + ); +} diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx index ef2d97e39d..2a757ad618 100644 --- a/src/frontend/src/router.tsx +++ b/src/frontend/src/router.tsx @@ -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: }, { - path: 'parts/', - element: + path: 'part/', + element: + }, + { + path: 'part/:id', + element: }, { path: 'stock/',