[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}
size={props.size}
onClick={props.onClick ?? notYetImplemented}
variant={props.variant}
variant={props.variant ?? 'light'}
>
<Group spacing="xs" noWrap={true}>
{props.icon}

View File

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

View File

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

View File

@ -1,7 +1,8 @@
import { t } from '@lingui/macro';
import { LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import {
IconBuildingWarehouse,
IconDots,
IconInfoCircle,
IconList,
IconPaperclip
@ -9,18 +10,38 @@ import {
import { useMemo } from 'react';
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 { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
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 { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import ManufacturerPartParameterTable from '../../tables/purchasing/ManufacturerPartParameterTable';
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
export default function ManufacturerPartDetail() {
const { id } = useParams();
const user = useUserState();
const { instance: manufacturerPart, instanceQuery } = useInstance({
const {
instance: manufacturerPart,
instanceQuery,
refreshInstance
} = useInstance({
endpoint: ApiEndpoints.manufacturer_part_list,
pk: id,
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(() => {
return [
{
name: 'details',
label: t`Details`,
icon: <IconInfoCircle />
label: t`Manufacturer Part Details`,
icon: <IconInfoCircle />,
content: detailsPanel
},
{
name: 'parameters',
@ -78,6 +178,38 @@ export default function ManufacturerPartDetail() {
];
}, [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(() => {
return [
{
@ -92,15 +224,19 @@ export default function ManufacturerPartDetail() {
}, [manufacturerPart]);
return (
<Stack spacing="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
title={t`ManufacturerPart`}
subtitle={`${manufacturerPart.MPN} - ${manufacturerPart.part_detail?.name}`}
breadcrumbs={breadcrumbs}
imageUrl={manufacturerPart?.part_detail?.thumbnail}
/>
<PanelGroup pageKey="manufacturerpart" panels={panels} />
</Stack>
<>
{editManufacturerPart.modal}
<Stack spacing="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
title={t`ManufacturerPart`}
subtitle={`${manufacturerPart.MPN} - ${manufacturerPart.part_detail?.name}`}
breadcrumbs={breadcrumbs}
actions={manufacturerPartActions}
imageUrl={manufacturerPart?.part_detail?.thumbnail}
/>
<PanelGroup pageKey="manufacturerpart" panels={panels} />
</Stack>
</>
);
}

View File

@ -1,7 +1,8 @@
import { t } from '@lingui/macro';
import { LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import {
IconCurrencyDollar,
IconDots,
IconInfoCircle,
IconPackages,
IconShoppingCart
@ -9,16 +10,37 @@ import {
import { useMemo } from 'react';
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 { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
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 { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
export default function SupplierPartDetail() {
const { id } = useParams();
const { instance: supplierPart, instanceQuery } = useInstance({
const user = useUserState();
const {
instance: supplierPart,
instanceQuery,
refreshInstance
} = useInstance({
endpoint: ApiEndpoints.supplier_part_list,
pk: id,
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(() => {
return [
{
name: 'details',
label: t`Details`,
icon: <IconInfoCircle />
label: t`Supplier Part Details`,
icon: <IconInfoCircle />,
content: detailsPanel
},
{
name: 'stock',
@ -58,6 +221,41 @@ export default function SupplierPartDetail() {
];
}, [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(() => {
return [
{
@ -72,15 +270,19 @@ export default function SupplierPartDetail() {
}, [supplierPart]);
return (
<Stack spacing="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
title={t`Supplier Part`}
subtitle={`${supplierPart.SKU} - ${supplierPart?.part_detail?.name}`}
breadcrumbs={breadcrumbs}
imageUrl={supplierPart?.part_detail?.thumbnail}
/>
<PanelGroup pageKey="supplierpart" panels={panels} />
</Stack>
<>
{editSuppliertPart.modal}
<Stack spacing="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
title={t`Supplier Part`}
subtitle={`${supplierPart.SKU} - ${supplierPart?.part_detail?.name}`}
breadcrumbs={breadcrumbs}
actions={supplierPartActions}
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 partActions = useMemo(() => {
// TODO: Disable actions based on user permissions
return [
<BarcodeActionDropdown
actions={[
@ -679,6 +678,7 @@ export default function PartDetail() {
),
name: t`Count Stock`,
tooltip: t`Count part stock`,
hidden: !user.hasChangeRole(UserRoles.stock),
onClick: () => {
part.pk && countStockItems.open();
}
@ -689,6 +689,7 @@ export default function PartDetail() {
),
name: t`Transfer Stock`,
tooltip: t`Transfer part stock`,
hidden: !user.hasChangeRole(UserRoles.stock),
onClick: () => {
part.pk && transferStockItems.open();
}
@ -700,13 +701,15 @@ export default function PartDetail() {
tooltip={t`Part Actions`}
icon={<IconDots />}
actions={[
DuplicateItemAction({}),
DuplicateItemAction({
hidden: !user.hasAddRole(UserRoles.part)
}),
EditItemAction({
hidden: !user.hasChangeRole(UserRoles.part),
onClick: () => editPart.open()
}),
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 { notYetImplemented } from '../../functions/notifications';
import { getDetailUrl } from '../../functions/urls';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
@ -61,9 +62,15 @@ export function ManufacturerPartTable({ params }: { params: any }): ReactNode {
];
}, [params]);
const addManufacturerPart = useCallback(() => {
notYetImplemented();
}, []);
const createManufacturerPart = useCreateApiFormModal({
url: ApiEndpoints.manufacturer_part_list,
title: t`Create Manufacturer Part`,
fields: useManufacturerPartFields(),
onFormSuccess: table.refreshTable,
initialData: {
manufacturer: params?.manufacturer
}
});
const tableActions = useMemo(() => {
let can_add =
@ -73,7 +80,7 @@ export function ManufacturerPartTable({ params }: { params: any }): ReactNode {
return [
<AddItemButton
tooltip={t`Add Manufacturer Part`}
onClick={addManufacturerPart}
onClick={() => createManufacturerPart.open()}
hidden={!can_add}
/>
];
@ -118,24 +125,27 @@ export function ManufacturerPartTable({ params }: { params: any }): ReactNode {
);
return (
<InvenTreeTable
url={apiUrl(ApiEndpoints.manufacturer_part_list)}
tableState={table}
columns={tableColumns}
props={{
params: {
...params,
part_detail: true,
manufacturer_detail: true
},
rowActions: rowActions,
tableActions: tableActions,
onRowClick: (record: any) => {
if (record?.pk) {
navigate(getDetailUrl(ModelType.manufacturerpart, record.pk));
<>
{createManufacturerPart.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.manufacturer_part_list)}
tableState={table}
columns={tableColumns}
props={{
params: {
...params,
part_detail: true,
manufacturer_detail: true
},
rowActions: rowActions,
tableActions: tableActions,
onRowClick: (record: any) => {
if (record?.pk) {
navigate(getDetailUrl(ModelType.manufacturerpart, record.pk));
}
}
}
}}
/>
}}
/>
</>
);
}