[PUI] Attachment table (#5525)

* Basic AttachmentTable

* Add form for editing an attachment

* Fix columns for InvenTreeTable component

* Update part attachment table

* Add dropzone to attachments table

* Handle file upload with Dropzone

* Add header for panelgroup

* Improve rendering of attachment files

* Allow various attachment list API endpoints to be searched

* Determine available attachment actions based on user permissions

* Reload attachment table after upload

* Delete attachments via table

* ts fix

* Clip width of actions column

* More updates

- Add manual buttons for adding link or file
- Edit link or file

* Add tooltip for row actions

* Adds a custom hook for refreshing tables

- So much cleaner :)

* Change export type

* Disable row action column when checkbox selection is active

* Fix(?) for custom hook

* Badge tweak
This commit is contained in:
Oliver 2023-09-12 11:45:23 +10:00 committed by GitHub
parent f11a9e97d2
commit 7e753523d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 554 additions and 64 deletions

View File

@ -228,6 +228,12 @@ class AttachmentMixin:
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
search_fields = [
'attachment',
'comment',
'link',
]
def perform_create(self, serializer): def perform_create(self, serializer):
"""Save the user information when a file is uploaded.""" """Save the user information when a file is uploaded."""
attachment = serializer.save() attachment = serializer.save()

View File

@ -2,11 +2,14 @@
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 133 INVENTREE_API_VERSION = 134
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v134 -> 2023-09-11 : https://github.com/inventree/InvenTree/pull/5525
- Allow "Attachment" list endpoints to be searched by attachment, link and comment fields
v133 -> 2023-09-08 : https://github.com/inventree/InvenTree/pull/5518 v133 -> 2023-09-08 : https://github.com/inventree/InvenTree/pull/5518
- Add extra optional fields which can be used for StockAdjustment endpoints - Add extra optional fields which can be used for StockAdjustment endpoints

View File

@ -583,10 +583,6 @@ class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = BuildOrderAttachment.objects.all() queryset = BuildOrderAttachment.objects.all()
serializer_class = build.serializers.BuildAttachmentSerializer serializer_class = build.serializers.BuildAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
]
filterset_fields = [ filterset_fields = [
'build', 'build',
] ]

View File

@ -4,7 +4,6 @@ from django.db.models import Q
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django_filters import rest_framework as rest_filters from django_filters import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend
import part.models import part.models
from InvenTree.api import (AttachmentMixin, ListCreateDestroyAPIView, from InvenTree.api import (AttachmentMixin, ListCreateDestroyAPIView,
@ -89,10 +88,6 @@ class CompanyAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = CompanyAttachment.objects.all() queryset = CompanyAttachment.objects.all()
serializer_class = CompanyAttachmentSerializer serializer_class = CompanyAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
]
filterset_fields = [ filterset_fields = [
'company', 'company',
] ]
@ -246,10 +241,6 @@ class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = ManufacturerPartAttachment.objects.all() queryset = ManufacturerPartAttachment.objects.all()
serializer_class = ManufacturerPartAttachmentSerializer serializer_class = ManufacturerPartAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
]
filterset_fields = [ filterset_fields = [
'manufacturer_part', 'manufacturer_part',
] ]

View File

@ -583,10 +583,6 @@ class SalesOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = models.SalesOrderAttachment.objects.all() queryset = models.SalesOrderAttachment.objects.all()
serializer_class = serializers.SalesOrderAttachmentSerializer serializer_class = serializers.SalesOrderAttachmentSerializer
filter_backends = [
rest_filters.DjangoFilterBackend,
]
filterset_fields = [ filterset_fields = [
'order', 'order',
] ]
@ -1079,10 +1075,6 @@ class PurchaseOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = models.PurchaseOrderAttachment.objects.all() queryset = models.PurchaseOrderAttachment.objects.all()
serializer_class = serializers.PurchaseOrderAttachmentSerializer serializer_class = serializers.PurchaseOrderAttachmentSerializer
filter_backends = [
rest_filters.DjangoFilterBackend,
]
filterset_fields = [ filterset_fields = [
'order', 'order',
] ]
@ -1359,10 +1351,6 @@ class ReturnOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = models.ReturnOrderAttachment.objects.all() queryset = models.ReturnOrderAttachment.objects.all()
serializer_class = serializers.ReturnOrderAttachmentSerializer serializer_class = serializers.ReturnOrderAttachmentSerializer
filter_backends = [
rest_filters.DjangoFilterBackend,
]
filterset_fields = [ filterset_fields = [
'order', 'order',
] ]

View File

@ -326,10 +326,6 @@ class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = PartAttachment.objects.all() queryset = PartAttachment.objects.all()
serializer_class = part_serializers.PartAttachmentSerializer serializer_class = part_serializers.PartAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
]
filterset_fields = [ filterset_fields = [
'part', 'part',
] ]

View File

@ -1051,8 +1051,6 @@ class StockAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = StockItemAttachment.objects.all() queryset = StockItemAttachment.objects.all()
serializer_class = StockSerializers.StockItemAttachmentSerializer serializer_class = StockSerializers.StockItemAttachmentSerializer
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = [ filterset_fields = [
'stock_item', 'stock_item',
] ]

View File

@ -0,0 +1,63 @@
import { Group, Text } from '@mantine/core';
import { IconFileTypeJpg, IconPhoto } from '@tabler/icons-react';
import {
IconFile,
IconFileTypeCsv,
IconFileTypeDoc,
IconFileTypePdf,
IconFileTypeXls,
IconFileTypeZip
} from '@tabler/icons-react';
import { ReactNode } from 'react';
/**
* Return an icon based on the provided filename
*/
export function attachmentIcon(attachment: string): ReactNode {
const sz = 18;
let suffix = attachment.split('.').pop()?.toLowerCase() ?? '';
switch (suffix) {
case 'pdf':
return <IconFileTypePdf size={sz} />;
case 'csv':
return <IconFileTypeCsv size={sz} />;
case 'xls':
case 'xlsx':
return <IconFileTypeXls size={sz} />;
case 'doc':
case 'docx':
return <IconFileTypeDoc size={sz} />;
case 'zip':
case 'tar':
case 'gz':
case '7z':
return <IconFileTypeZip size={sz} />;
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
case 'bmp':
case 'tif':
case 'webp':
return <IconPhoto size={sz} />;
default:
return <IconFile size={sz} />;
}
}
/**
* Render a link to a file attachment, with icon and text
* @param attachment : string - The attachment filename
*/
export function AttachmentLink({
attachment
}: {
attachment: string;
}): ReactNode {
return (
<Group position="left" spacing="sm">
{attachmentIcon(attachment)}
<Text>{attachment.split('/').pop()}</Text>
</Group>
);
}

View File

@ -1,4 +1,4 @@
import { Tabs } from '@mantine/core'; import { Divider, Paper, Stack, Tabs, Text } from '@mantine/core';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@ -78,7 +78,13 @@ export function PanelGroup({
(panel, idx) => (panel, idx) =>
!panel.hidden && ( !panel.hidden && (
<Tabs.Panel key={idx} value={panel.name}> <Tabs.Panel key={idx} value={panel.name}>
{panel.content} <Paper p="md" radius="xs">
<Stack spacing="md">
<Text size="xl">{panel.label}</Text>
<Divider />
{panel.content}
</Stack>
</Paper>
</Tabs.Panel> </Tabs.Panel>
) )
)} )}

View File

@ -0,0 +1,248 @@
import { t } from '@lingui/macro';
import { Badge, Group, Stack, Text, Tooltip } from '@mantine/core';
import { ActionIcon } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useId } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconExternalLink, IconFileUpload } from '@tabler/icons-react';
import { ReactNode, useEffect, useMemo, useState } from 'react';
import { api } from '../../App';
import {
addAttachment,
deleteAttachment,
editAttachment
} from '../../functions/forms/AttachmentForms';
import { useTableRefresh } from '../../hooks/TableRefresh';
import { AttachmentLink } from '../items/AttachmentLink';
import { TableColumn } from './Column';
import { InvenTreeTable } from './InvenTreeTable';
import { RowAction } from './RowActions';
/**
* Define set of columns to display for the attachment table
*/
function attachmentTableColumns(): TableColumn[] {
return [
{
accessor: 'attachment',
title: t`Attachment`,
sortable: false,
switchable: false,
noWrap: true,
render: function (record: any) {
if (record.attachment) {
return <AttachmentLink attachment={record.attachment} />;
} else if (record.link) {
// TODO: Custom renderer for links
return record.link;
} else {
return '-';
}
}
},
{
accessor: 'comment',
title: t`Comment`,
sortable: false,
switchable: true,
render: function (record: any) {
return record.comment;
}
},
{
accessor: 'uploaded',
title: t`Uploaded`,
sortable: false,
switchable: true,
render: function (record: any) {
return (
<Group position="apart">
<Text>{record.upload_date}</Text>
{record.user_detail && (
<Badge size="xs">{record.user_detail.username}</Badge>
)}
</Group>
);
}
}
];
}
/**
* Construct a table for displaying uploaded attachments
*/
export function AttachmentTable({
url,
model,
pk
}: {
url: string;
pk: number;
model: string;
}): ReactNode {
const tableId = useId();
const { refreshId, refreshTable } = useTableRefresh();
const tableColumns = useMemo(() => attachmentTableColumns(), []);
const [allowEdit, setAllowEdit] = useState<boolean>(false);
const [allowDelete, setAllowDelete] = useState<boolean>(false);
// Determine which permissions are available for this URL
useEffect(() => {
api
.options(url)
.then((response) => {
let actions: any = response.data?.actions ?? {};
setAllowEdit('POST' in actions);
setAllowDelete('DELETE' in actions);
return response;
})
.catch((error) => {
return error;
});
}, []);
// Construct row actions for the attachment table
function rowActions(record: any): RowAction[] {
let actions: RowAction[] = [];
if (allowEdit) {
actions.push({
title: t`Edit`,
onClick: () => {
editAttachment({
url: url,
model: model,
pk: record.pk,
attachmentType: record.attachment ? 'file' : 'link',
callback: refreshTable
});
}
});
}
if (allowDelete) {
actions.push({
title: t`Delete`,
color: 'red',
onClick: () => {
deleteAttachment({
url: url,
pk: record.pk,
callback: refreshTable
});
}
});
}
return actions;
}
// Callback to upload file attachment(s)
function uploadFiles(files: File[]) {
files.forEach((file) => {
let formData = new FormData();
formData.append('attachment', file);
formData.append(model, pk.toString());
api
.post(url, formData)
.then((response) => {
notifications.show({
title: t`File uploaded`,
message: t`File ${file.name} uploaded successfully`,
color: 'green'
});
refreshTable();
return response;
})
.catch((error) => {
console.error('error uploading attachment:', file, '->', error);
notifications.show({
title: t`Upload Error`,
message: t`File could not be uploaded`,
color: 'red'
});
return error;
});
});
}
function customActionGroups(): ReactNode[] {
let actions = [];
if (allowEdit) {
actions.push(
<Tooltip label={t`Add attachment`}>
<ActionIcon
radius="sm"
onClick={() => {
addAttachment({
url: url,
model: model,
pk: pk,
attachmentType: 'file',
callback: refreshTable
});
}}
>
<IconFileUpload />
</ActionIcon>
</Tooltip>
);
actions.push(
<Tooltip label={t`Add external link`}>
<ActionIcon
radius="sm"
onClick={() => {
addAttachment({
url: url,
model: model,
pk: pk,
attachmentType: 'link',
callback: refreshTable
});
}}
>
<IconExternalLink />
</ActionIcon>
</Tooltip>
);
}
return actions;
}
return (
<Stack spacing="xs">
<InvenTreeTable
url={url}
tableKey={tableId}
refreshId={refreshId}
params={{
[model]: pk
}}
customActionGroups={customActionGroups()}
columns={tableColumns}
rowActions={allowEdit && allowDelete ? rowActions : undefined}
/>
{allowEdit && (
<Dropzone onDrop={uploadFiles}>
<Dropzone.Idle>
<Group position="center">
<IconFileUpload size={24} />
<Text size="sm">{t`Upload attachment`}</Text>
</Group>
</Dropzone.Idle>
</Dropzone>
)}
</Stack>
);
}

View File

@ -5,7 +5,7 @@ import { IconFilter, IconRefresh } from '@tabler/icons-react';
import { IconBarcode, IconPrinter } from '@tabler/icons-react'; import { IconBarcode, IconPrinter } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { DataTable, DataTableSortStatus } from 'mantine-datatable'; import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { api } from '../../App'; import { api } from '../../App';
import { ButtonMenu } from '../items/ButtonMenu'; import { ButtonMenu } from '../items/ButtonMenu';
@ -98,7 +98,8 @@ export function InvenTreeTable({
barcodeActions = [], barcodeActions = [],
customActionGroups = [], customActionGroups = [],
customFilters = [], customFilters = [],
rowActions rowActions,
refreshId
}: { }: {
url: string; url: string;
params: any; params: any;
@ -118,10 +119,8 @@ export function InvenTreeTable({
customActionGroups?: any[]; customActionGroups?: any[];
customFilters?: TableFilter[]; customFilters?: TableFilter[];
rowActions?: (record: any) => RowAction[]; rowActions?: (record: any) => RowAction[];
refreshId?: string;
}) { }) {
// Data columns
const [dataColumns, setDataColumns] = useState<any[]>(columns);
// Check if any columns are switchable (can be hidden) // Check if any columns are switchable (can be hidden)
const hasSwitchableColumns = columns.some( const hasSwitchableColumns = columns.some(
(col: TableColumn) => col.switchable (col: TableColumn) => col.switchable
@ -132,10 +131,17 @@ export function InvenTreeTable({
loadHiddenColumns(tableKey) loadHiddenColumns(tableKey)
); );
// Data selection
const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
function onSelectedRecordsChange(records: any[]) {
setSelectedRecords(records);
}
// Update column visibility when hiddenColumns change // Update column visibility when hiddenColumns change
useEffect(() => { const dataColumns: any = useMemo(() => {
let cols = dataColumns.map((col) => { let cols = columns.map((col) => {
let hidden: boolean = col.hidden; let hidden: boolean = col.hidden ?? false;
if (col.switchable) { if (col.switchable) {
hidden = hiddenColumns.includes(col.accessor); hidden = hiddenColumns.includes(col.accessor);
@ -154,14 +160,20 @@ export function InvenTreeTable({
title: '', title: '',
hidden: false, hidden: false,
switchable: false, switchable: false,
width: 48,
render: function (record: any) { render: function (record: any) {
return <RowActions actions={rowActions(record)} />; return (
<RowActions
actions={rowActions(record)}
disabled={selectedRecords.length > 0}
/>
);
} }
}); });
} }
setDataColumns(cols); return cols;
}, [columns, hiddenColumns, rowActions]); }, [columns, hiddenColumns, rowActions, enableSelection, selectedRecords]);
// Callback when column visibility is toggled // Callback when column visibility is toggled
function toggleColumn(columnName: string) { function toggleColumn(columnName: string) {
@ -309,7 +321,7 @@ export function InvenTreeTable({
// Find matching column: // Find matching column:
// If column provides custom ordering term, use that // If column provides custom ordering term, use that
let column = dataColumns.find((col) => col.accessor == key); let column = dataColumns.find((col: any) => col.accessor == key);
return column?.ordering || key; return column?.ordering || key;
} }
@ -317,13 +329,6 @@ export function InvenTreeTable({
const [missingRecordsText, setMissingRecordsText] = const [missingRecordsText, setMissingRecordsText] =
useState<string>(noRecordsText); useState<string>(noRecordsText);
// Data selection
const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
function onSelectedRecordsChange(records: any[]) {
setSelectedRecords(records);
}
const handleSortStatusChange = (status: DataTableSortStatus) => { const handleSortStatusChange = (status: DataTableSortStatus) => {
setPage(1); setPage(1);
setSortStatus(status); setSortStatus(status);
@ -386,6 +391,18 @@ export function InvenTreeTable({
} }
); );
/*
* Reload the table whenever the refetch changes
* this allows us to programmatically refresh the table
*
* Implement this using the custom useTableRefresh hook
*/
useEffect(() => {
if (refreshId) {
refetch();
}
}, [refreshId]);
return ( return (
<> <>
<FilterSelectModal <FilterSelectModal

View File

@ -1,12 +1,13 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { ActionIcon } from '@mantine/core'; import { ActionIcon, Tooltip } from '@mantine/core';
import { Menu } from '@mantine/core'; import { Menu, Text } from '@mantine/core';
import { IconDots } from '@tabler/icons-react'; import { IconDots } from '@tabler/icons-react';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
// Type definition for a table row action // Type definition for a table row action
export type RowAction = { export type RowAction = {
title: string; title: string;
color?: string;
onClick: () => void; onClick: () => void;
tooltip?: string; tooltip?: string;
icon?: ReactNode; icon?: ReactNode;
@ -18,18 +19,22 @@ export type RowAction = {
*/ */
export function RowActions({ export function RowActions({
title, title,
actions actions,
disabled = false
}: { }: {
title?: string; title?: string;
disabled?: boolean;
actions: RowAction[]; actions: RowAction[];
}): ReactNode { }): ReactNode {
return ( return (
actions.length > 0 && ( actions.length > 0 && (
<Menu withinPortal={true}> <Menu withinPortal={true} disabled={disabled}>
<Menu.Target> <Menu.Target>
<ActionIcon variant="subtle" color="gray"> <Tooltip label={title || t`Actions`}>
<IconDots /> <ActionIcon disabled={disabled} variant="subtle" color="gray">
</ActionIcon> <IconDots />
</ActionIcon>
</Tooltip>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Label>{title || t`Actions`}</Menu.Label> <Menu.Label>{title || t`Actions`}</Menu.Label>
@ -40,7 +45,9 @@ export function RowActions({
icon={action.icon} icon={action.icon}
title={action.tooltip || action.title} title={action.tooltip || action.title}
> >
{action.title} <Text size="sm" color={action.color}>
{action.title}
</Text>
</Menu.Item> </Menu.Item>
))} ))}
</Menu.Dropdown> </Menu.Dropdown>

View File

@ -0,0 +1,135 @@
import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
import {
openCreateApiForm,
openDeleteApiForm,
openEditApiForm
} from '../forms';
export function attachmentFields(editing: boolean): ApiFormFieldSet {
let fields: ApiFormFieldSet = {
attachment: {},
comment: {}
};
if (editing) {
delete fields['attachment'];
}
return fields;
}
/**
* Add a new attachment (either a file or a link)
*/
export function addAttachment({
url,
model,
pk,
attachmentType,
callback
}: {
url: string;
model: string;
pk: number;
attachmentType: 'file' | 'link';
callback?: () => void;
}) {
let formFields: ApiFormFieldSet = {
attachment: {},
link: {},
comment: {}
};
if (attachmentType === 'link') {
delete formFields['attachment'];
} else {
delete formFields['link'];
}
formFields[model] = {
value: pk,
hidden: true
};
let title = attachmentType === 'file' ? t`Add File` : t`Add Link`;
let message = attachmentType === 'file' ? t`File added` : t`Link added`;
openCreateApiForm({
name: 'attachment-add',
title: title,
url: url,
successMessage: message,
fields: formFields,
onFormSuccess: callback
});
}
/**
* Edit an existing attachment (either a file or a link)
*/
export function editAttachment({
url,
model,
pk,
attachmentType,
callback
}: {
url: string;
model: string;
pk: number;
attachmentType: 'file' | 'link';
callback?: () => void;
}) {
let formFields: ApiFormFieldSet = {
link: {},
comment: {}
};
if (attachmentType === 'file') {
delete formFields['link'];
}
formFields[model] = {
value: pk,
hidden: true
};
let title = attachmentType === 'file' ? t`Edit File` : t`Edit Link`;
let message = attachmentType === 'file' ? t`File updated` : t`Link updated`;
openEditApiForm({
name: 'attachment-edit',
title: title,
url: url,
pk: pk,
successMessage: message,
fields: formFields,
onFormSuccess: callback
});
}
export function deleteAttachment({
url,
pk,
callback
}: {
url: string;
pk: number;
callback: () => void;
}) {
openDeleteApiForm({
url: url,
pk: pk,
name: 'attachment-edit',
title: t`Delete Attachment`,
successMessage: t`Attachment deleted`,
onFormSuccess: callback,
fields: {},
preFormContent: (
<Text>{t`Are you sure you want to delete this attachment?`}</Text>
)
});
}

View File

@ -0,0 +1,25 @@
import { randomId } from '@mantine/hooks';
import { useCallback, useState } from 'react';
/**
* Custom hook for refreshing an InvenTreeTable externally
* Returns a unique ID for the table, which can be updated to trigger a refresh of the <table className=""></table>
*
* @returns [refreshId, refreshTable]
*
* To use this hook:
* const [refreshId, refreshTable] = useTableRefresh();
*
* Then, pass the refreshId to the InvenTreeTable component:
* <InvenTreeTable refreshId={refreshId} ... />
*/
export function useTableRefresh() {
const [refreshId, setRefreshId] = useState<string>(randomId());
// Generate a new ID to refresh the table
const refreshTable = useCallback(function () {
setRefreshId(randomId());
}, []);
return { refreshId, refreshTable };
}

View File

@ -27,12 +27,13 @@ import {
IconVersions IconVersions
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useState } from 'react'; import React, { useState } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../../App'; import { api } from '../../App';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { AttachmentTable } from '../../components/tables/AttachmentTable';
import { StockItemTable } from '../../components/tables/stock/StockItemTable'; import { StockItemTable } from '../../components/tables/stock/StockItemTable';
import { editPart } from '../../functions/forms/PartForms'; import { editPart } from '../../functions/forms/PartForms';
@ -129,7 +130,7 @@ export default function PartDetail() {
name: 'attachments', name: 'attachments',
label: t`Attachments`, label: t`Attachments`,
icon: <IconPaperclip size="18" />, icon: <IconPaperclip size="18" />,
content: <Text>part attachments go here</Text> content: partAttachmentsTab()
}, },
{ {
name: 'notes', name: 'notes',
@ -156,6 +157,16 @@ export default function PartDetail() {
}); });
}); });
function partAttachmentsTab(): React.ReactNode {
return (
<AttachmentTable
url="/part/attachment/"
model="part"
pk={part.pk ?? -1}
/>
);
}
function partStockTab(): React.ReactNode { function partStockTab(): React.ReactNode {
return ( return (
<StockItemTable <StockItemTable
@ -190,7 +201,7 @@ export default function PartDetail() {
}) })
} }
> >
Edit Edit Part
</Button> </Button>
</Group> </Group>
<PanelGroup panels={partPanels} /> <PanelGroup panels={partPanels} />