2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 19:46:46 +00:00

[PUI] Details Pages (#6718)

* Add "details" view to SupplierPart page

* Fix PartActions

* Add placeholder for actions

* Add "title" option to DetailsTable

* Add edit form to supplier part page

* Fix link to manufacturer part

* Add "details" view to ManufacturerPartDetail page

* Add edit for ManufacturerPart

* Create new manufacturer part from company table

* Tweak ActionIcon
This commit is contained in:
Oliver 2024-03-15 17:12:53 +11:00 committed by GitHub
parent 57a1a81e9b
commit 160d014e44
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 425 additions and 64 deletions

View File

@ -39,7 +39,7 @@ export function ActionButton(props: ActionButtonProps) {
color={props.color} color={props.color}
size={props.size} size={props.size}
onClick={props.onClick ?? notYetImplemented} onClick={props.onClick ?? notYetImplemented}
variant={props.variant} variant={props.variant ?? 'light'}
> >
<Group spacing="xs" noWrap={true}> <Group spacing="xs" noWrap={true}>
{props.icon} {props.icon}

View File

@ -7,6 +7,7 @@ import {
Group, Group,
Paper, Paper,
Skeleton, Skeleton,
Stack,
Table, Table,
Text, Text,
Tooltip Tooltip
@ -22,6 +23,7 @@ import { getDetailUrl } from '../../functions/urls';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState'; import { useGlobalSettingsState } from '../../states/SettingsState';
import { ProgressBar } from '../items/ProgressBar'; import { ProgressBar } from '../items/ProgressBar';
import { StylishText } from '../items/StylishText';
import { YesNoButton } from '../items/YesNoButton'; import { YesNoButton } from '../items/YesNoButton';
import { getModelInfo } from '../render/ModelType'; import { getModelInfo } from '../render/ModelType';
import { StatusRenderer } from '../render/StatusRenderer'; import { StatusRenderer } from '../render/StatusRenderer';
@ -385,22 +387,27 @@ export function DetailsTableField({
export function DetailsTable({ export function DetailsTable({
item, item,
fields fields,
title
}: { }: {
item: any; item: any;
fields: DetailsField[]; fields: DetailsField[];
title?: string;
}) { }) {
return ( return (
<Paper p="xs" withBorder radius="xs"> <Paper p="xs" withBorder radius="xs">
<Table striped> <Stack spacing="xs">
<tbody> {title && <StylishText size="lg">{title}</StylishText>}
{fields <Table striped>
.filter((field: DetailsField) => !field.hidden) <tbody>
.map((field: DetailsField, index: number) => ( {fields
<DetailsTableField field={field} item={item} key={index} /> .filter((field: DetailsField) => !field.hidden)
))} .map((field: DetailsField, index: number) => (
</tbody> <DetailsTableField field={field} item={item} key={index} />
</Table> ))}
</tbody>
</Table>
</Stack>
</Paper> </Paper>
); );
} }

View File

@ -117,12 +117,15 @@ const icons = {
test_templates: IconTestPipe, test_templates: IconTestPipe,
related_parts: IconLayersLinked, related_parts: IconLayersLinked,
attachments: IconPaperclip, attachments: IconPaperclip,
note: IconNotes,
notes: IconNotes, notes: IconNotes,
photo: IconPhoto, photo: IconPhoto,
upload: IconFileUpload, upload: IconFileUpload,
reject: IconX, reject: IconX,
select_image: IconGridDots, select_image: IconGridDots,
delete: IconTrash, delete: IconTrash,
packaging: IconPackage,
packages: IconPackages,
// Part Icons // Part Icons
active: IconCheck, active: IconCheck,

View File

@ -1,7 +1,8 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { LoadingOverlay, Skeleton, Stack } from '@mantine/core'; import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { import {
IconBuildingWarehouse, IconBuildingWarehouse,
IconDots,
IconInfoCircle, IconInfoCircle,
IconList, IconList,
IconPaperclip IconPaperclip
@ -9,18 +10,38 @@ import {
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
ActionDropdown,
DeleteItemAction,
DuplicateItemAction,
EditItemAction
} from '../../components/items/ActionDropdown';
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 { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useManufacturerPartFields } from '../../forms/CompanyForms';
import { useEditApiFormModal } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable'; import { AttachmentTable } from '../../tables/general/AttachmentTable';
import ManufacturerPartParameterTable from '../../tables/purchasing/ManufacturerPartParameterTable'; import ManufacturerPartParameterTable from '../../tables/purchasing/ManufacturerPartParameterTable';
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable'; import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
export default function ManufacturerPartDetail() { export default function ManufacturerPartDetail() {
const { id } = useParams(); const { id } = useParams();
const user = useUserState();
const { instance: manufacturerPart, instanceQuery } = useInstance({ const {
instance: manufacturerPart,
instanceQuery,
refreshInstance
} = useInstance({
endpoint: ApiEndpoints.manufacturer_part_list, endpoint: ApiEndpoints.manufacturer_part_list,
pk: id, pk: id,
hasPrimaryKey: true, hasPrimaryKey: true,
@ -30,12 +51,91 @@ export default function ManufacturerPartDetail() {
} }
}); });
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
let data = manufacturerPart ?? {};
let tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Internal Part`,
model: ModelType.part,
hidden: !manufacturerPart.part
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true,
hidden: !manufacturerPart.description
},
{
type: 'link',
external: true,
name: 'link',
label: t`External Link`,
copy: true,
hidden: !manufacturerPart.link
}
];
let tr: DetailsField[] = [
{
type: 'link',
name: 'manufacturer',
label: t`Manufacturer`,
icon: 'manufacturers',
model: ModelType.company,
hidden: !manufacturerPart.manufacturer
},
{
type: 'string',
name: 'MPN',
label: t`Manufacturer Part Number`,
copy: true,
hidden: !manufacturerPart.MPN,
icon: 'reference'
}
];
return (
<ItemDetailsGrid>
<Grid>
<Grid.Col span={4}>
<DetailsImage
appRole={UserRoles.part}
src={manufacturerPart?.part_detail?.image}
apiPath={apiUrl(
ApiEndpoints.part_list,
manufacturerPart?.part_detail?.pk
)}
pk={manufacturerPart?.part_detail?.pk}
/>
</Grid.Col>
<Grid.Col span={8}>
<DetailsTable
title={t`Manufacturer Part`}
fields={tl}
item={data}
/>
</Grid.Col>
</Grid>
<DetailsTable title={t`Manufacturer Details`} fields={tr} item={data} />
</ItemDetailsGrid>
);
}, [manufacturerPart, instanceQuery]);
const panels: PanelType[] = useMemo(() => { const panels: PanelType[] = useMemo(() => {
return [ return [
{ {
name: 'details', name: 'details',
label: t`Details`, label: t`Manufacturer Part Details`,
icon: <IconInfoCircle /> icon: <IconInfoCircle />,
content: detailsPanel
}, },
{ {
name: 'parameters', name: 'parameters',
@ -78,6 +178,38 @@ export default function ManufacturerPartDetail() {
]; ];
}, [manufacturerPart]); }, [manufacturerPart]);
const editManufacturerPartFields = useManufacturerPartFields();
const editManufacturerPart = useEditApiFormModal({
url: ApiEndpoints.manufacturer_part_list,
pk: manufacturerPart?.pk,
title: t`Edit Manufacturer Part`,
fields: editManufacturerPartFields,
onFormSuccess: refreshInstance
});
const manufacturerPartActions = useMemo(() => {
return [
<ActionDropdown
key="part"
tooltip={t`Manufacturer Part Actions`}
icon={<IconDots />}
actions={[
DuplicateItemAction({
hidden: !user.hasAddRole(UserRoles.purchase_order)
}),
EditItemAction({
hidden: !user.hasChangeRole(UserRoles.purchase_order),
onClick: () => editManufacturerPart.open()
}),
DeleteItemAction({
hidden: !user.hasDeleteRole(UserRoles.purchase_order)
})
]}
/>
];
}, [user]);
const breadcrumbs = useMemo(() => { const breadcrumbs = useMemo(() => {
return [ return [
{ {
@ -92,15 +224,19 @@ export default function ManufacturerPartDetail() {
}, [manufacturerPart]); }, [manufacturerPart]);
return ( return (
<Stack spacing="xs"> <>
<LoadingOverlay visible={instanceQuery.isFetching} /> {editManufacturerPart.modal}
<PageDetail <Stack spacing="xs">
title={t`ManufacturerPart`} <LoadingOverlay visible={instanceQuery.isFetching} />
subtitle={`${manufacturerPart.MPN} - ${manufacturerPart.part_detail?.name}`} <PageDetail
breadcrumbs={breadcrumbs} title={t`ManufacturerPart`}
imageUrl={manufacturerPart?.part_detail?.thumbnail} subtitle={`${manufacturerPart.MPN} - ${manufacturerPart.part_detail?.name}`}
/> breadcrumbs={breadcrumbs}
<PanelGroup pageKey="manufacturerpart" panels={panels} /> actions={manufacturerPartActions}
</Stack> imageUrl={manufacturerPart?.part_detail?.thumbnail}
/>
<PanelGroup pageKey="manufacturerpart" panels={panels} />
</Stack>
</>
); );
} }

View File

@ -1,7 +1,8 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { LoadingOverlay, Skeleton, Stack } from '@mantine/core'; import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { import {
IconCurrencyDollar, IconCurrencyDollar,
IconDots,
IconInfoCircle, IconInfoCircle,
IconPackages, IconPackages,
IconShoppingCart IconShoppingCart
@ -9,16 +10,37 @@ import {
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
ActionDropdown,
DeleteItemAction,
DuplicateItemAction,
EditItemAction
} from '../../components/items/ActionDropdown';
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 { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useSupplierPartFields } from '../../forms/CompanyForms';
import { useEditApiFormModal } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable'; import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
export default function SupplierPartDetail() { export default function SupplierPartDetail() {
const { id } = useParams(); const { id } = useParams();
const { instance: supplierPart, instanceQuery } = useInstance({ const user = useUserState();
const {
instance: supplierPart,
instanceQuery,
refreshInstance
} = useInstance({
endpoint: ApiEndpoints.supplier_part_list, endpoint: ApiEndpoints.supplier_part_list,
pk: id, pk: id,
hasPrimaryKey: true, hasPrimaryKey: true,
@ -28,12 +50,153 @@ export default function SupplierPartDetail() {
} }
}); });
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
let data = supplierPart ?? {};
// Access nested data
data.manufacturer = data.manufacturer_detail?.pk;
data.MPN = data.manufacturer_part_detail?.MPN;
data.manufacturer_part = data.manufacturer_part_detail?.pk;
let tl: DetailsField[] = [
{
type: 'link',
name: 'part',
label: t`Internal Part`,
model: ModelType.part,
hidden: !supplierPart.part
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true
},
{
type: 'link',
external: true,
name: 'link',
label: t`External Link`,
copy: true,
hidden: !supplierPart.link
},
{
type: 'string',
name: 'note',
label: t`Note`,
copy: true,
hidden: !supplierPart.note
}
];
let tr: DetailsField[] = [
{
type: 'link',
name: 'supplier',
label: t`Supplier`,
model: ModelType.company,
icon: 'suppliers',
hidden: !supplierPart.supplier
},
{
type: 'string',
name: 'SKU',
label: t`SKU`,
copy: true,
icon: 'reference'
},
{
type: 'link',
name: 'manufacturer',
label: t`Manufacturer`,
model: ModelType.company,
icon: 'manufacturers',
hidden: !data.manufacturer
},
{
type: 'link',
name: 'manufacturer_part',
model_field: 'MPN',
label: t`Manufacturer Part Number`,
model: ModelType.manufacturerpart,
copy: true,
icon: 'reference',
hidden: !data.manufacturer_part
}
];
let bl: DetailsField[] = [
{
type: 'string',
name: 'packaging',
label: t`Packaging`,
copy: true,
hidden: !data.packaging
},
{
type: 'string',
name: 'pack_quantity',
label: t`Pack Quantity`,
copy: true,
hidden: !data.pack_quantity,
icon: 'packages'
}
];
let br: DetailsField[] = [
{
type: 'string',
name: 'available',
label: t`Supplier Availability`,
copy: true,
icon: 'packages'
},
{
type: 'string',
name: 'availability_updated',
label: t`Availability Updated`,
copy: true,
hidden: !data.availability_updated,
icon: 'calendar'
}
];
return (
<ItemDetailsGrid>
<Grid>
<Grid.Col span={4}>
<DetailsImage
appRole={UserRoles.part}
src={supplierPart?.part_detail?.image}
apiPath={apiUrl(
ApiEndpoints.part_list,
supplierPart?.part_detail?.pk
)}
pk={supplierPart?.part_detail?.pk}
/>
</Grid.Col>
<Grid.Col span={8}>
<DetailsTable title={t`Supplier Part`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<DetailsTable title={t`Supplier`} fields={tr} item={data} />
<DetailsTable title={t`Packaging`} fields={bl} item={data} />
<DetailsTable title={t`Availability`} fields={br} item={data} />
</ItemDetailsGrid>
);
}, [supplierPart, instanceQuery.isFetching]);
const panels: PanelType[] = useMemo(() => { const panels: PanelType[] = useMemo(() => {
return [ return [
{ {
name: 'details', name: 'details',
label: t`Details`, label: t`Supplier Part Details`,
icon: <IconInfoCircle /> icon: <IconInfoCircle />,
content: detailsPanel
}, },
{ {
name: 'stock', name: 'stock',
@ -58,6 +221,41 @@ export default function SupplierPartDetail() {
]; ];
}, [supplierPart]); }, [supplierPart]);
const supplierPartActions = useMemo(() => {
return [
<ActionDropdown
key="part"
tooltip={t`Supplier Part Actions`}
icon={<IconDots />}
actions={[
DuplicateItemAction({
hidden: !user.hasAddRole(UserRoles.purchase_order)
}),
EditItemAction({
hidden: !user.hasChangeRole(UserRoles.purchase_order),
onClick: () => editSuppliertPart.open()
}),
DeleteItemAction({
hidden: !user.hasDeleteRole(UserRoles.purchase_order)
})
]}
/>
];
}, [user]);
const editSupplierPartFields = useSupplierPartFields({
hidePart: true,
partPk: supplierPart?.pk
});
const editSuppliertPart = useEditApiFormModal({
url: ApiEndpoints.supplier_part_list,
pk: supplierPart?.pk,
title: t`Edit Supplier Part`,
fields: editSupplierPartFields,
onFormSuccess: refreshInstance
});
const breadcrumbs = useMemo(() => { const breadcrumbs = useMemo(() => {
return [ return [
{ {
@ -72,15 +270,19 @@ export default function SupplierPartDetail() {
}, [supplierPart]); }, [supplierPart]);
return ( return (
<Stack spacing="xs"> <>
<LoadingOverlay visible={instanceQuery.isFetching} /> {editSuppliertPart.modal}
<PageDetail <Stack spacing="xs">
title={t`Supplier Part`} <LoadingOverlay visible={instanceQuery.isFetching} />
subtitle={`${supplierPart.SKU} - ${supplierPart?.part_detail?.name}`} <PageDetail
breadcrumbs={breadcrumbs} title={t`Supplier Part`}
imageUrl={supplierPart?.part_detail?.thumbnail} subtitle={`${supplierPart.SKU} - ${supplierPart?.part_detail?.name}`}
/> breadcrumbs={breadcrumbs}
<PanelGroup pageKey="supplierpart" panels={panels} /> actions={supplierPartActions}
</Stack> imageUrl={supplierPart?.part_detail?.thumbnail}
/>
<PanelGroup pageKey="supplierpart" panels={panels} />
</Stack>
</>
); );
} }

View File

@ -655,7 +655,6 @@ export default function PartDetail() {
const transferStockItems = useTransferStockItem(stockActionProps); const transferStockItems = useTransferStockItem(stockActionProps);
const partActions = useMemo(() => { const partActions = useMemo(() => {
// TODO: Disable actions based on user permissions
return [ return [
<BarcodeActionDropdown <BarcodeActionDropdown
actions={[ actions={[
@ -679,6 +678,7 @@ export default function PartDetail() {
), ),
name: t`Count Stock`, name: t`Count Stock`,
tooltip: t`Count part stock`, tooltip: t`Count part stock`,
hidden: !user.hasChangeRole(UserRoles.stock),
onClick: () => { onClick: () => {
part.pk && countStockItems.open(); part.pk && countStockItems.open();
} }
@ -689,6 +689,7 @@ export default function PartDetail() {
), ),
name: t`Transfer Stock`, name: t`Transfer Stock`,
tooltip: t`Transfer part stock`, tooltip: t`Transfer part stock`,
hidden: !user.hasChangeRole(UserRoles.stock),
onClick: () => { onClick: () => {
part.pk && transferStockItems.open(); part.pk && transferStockItems.open();
} }
@ -700,13 +701,15 @@ export default function PartDetail() {
tooltip={t`Part Actions`} tooltip={t`Part Actions`}
icon={<IconDots />} icon={<IconDots />}
actions={[ actions={[
DuplicateItemAction({}), DuplicateItemAction({
hidden: !user.hasAddRole(UserRoles.part)
}),
EditItemAction({ EditItemAction({
hidden: !user.hasChangeRole(UserRoles.part), hidden: !user.hasChangeRole(UserRoles.part),
onClick: () => editPart.open() onClick: () => editPart.open()
}), }),
DeleteItemAction({ DeleteItemAction({
hidden: part?.active hidden: part?.active || !user.hasDeleteRole(UserRoles.part)
}) })
]} ]}
/> />

View File

@ -11,6 +11,7 @@ import { useManufacturerPartFields } from '../../forms/CompanyForms';
import { openDeleteApiForm, openEditApiForm } from '../../functions/forms'; import { openDeleteApiForm, openEditApiForm } from '../../functions/forms';
import { notYetImplemented } from '../../functions/notifications'; import { notYetImplemented } from '../../functions/notifications';
import { getDetailUrl } from '../../functions/urls'; import { getDetailUrl } from '../../functions/urls';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable'; import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
@ -61,9 +62,15 @@ export function ManufacturerPartTable({ params }: { params: any }): ReactNode {
]; ];
}, [params]); }, [params]);
const addManufacturerPart = useCallback(() => { const createManufacturerPart = useCreateApiFormModal({
notYetImplemented(); url: ApiEndpoints.manufacturer_part_list,
}, []); title: t`Create Manufacturer Part`,
fields: useManufacturerPartFields(),
onFormSuccess: table.refreshTable,
initialData: {
manufacturer: params?.manufacturer
}
});
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
let can_add = let can_add =
@ -73,7 +80,7 @@ export function ManufacturerPartTable({ params }: { params: any }): ReactNode {
return [ return [
<AddItemButton <AddItemButton
tooltip={t`Add Manufacturer Part`} tooltip={t`Add Manufacturer Part`}
onClick={addManufacturerPart} onClick={() => createManufacturerPart.open()}
hidden={!can_add} hidden={!can_add}
/> />
]; ];
@ -118,24 +125,27 @@ export function ManufacturerPartTable({ params }: { params: any }): ReactNode {
); );
return ( return (
<InvenTreeTable <>
url={apiUrl(ApiEndpoints.manufacturer_part_list)} {createManufacturerPart.modal}
tableState={table} <InvenTreeTable
columns={tableColumns} url={apiUrl(ApiEndpoints.manufacturer_part_list)}
props={{ tableState={table}
params: { columns={tableColumns}
...params, props={{
part_detail: true, params: {
manufacturer_detail: true ...params,
}, part_detail: true,
rowActions: rowActions, manufacturer_detail: true
tableActions: tableActions, },
onRowClick: (record: any) => { rowActions: rowActions,
if (record?.pk) { tableActions: tableActions,
navigate(getDetailUrl(ModelType.manufacturerpart, record.pk)); onRowClick: (record: any) => {
if (record?.pk) {
navigate(getDetailUrl(ModelType.manufacturerpart, record.pk));
}
} }
} }}
}} />
/> </>
); );
} }