mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-18 10:46:31 +00:00
StockItem Serialization Improvements (#9968)
* Add "serialize" form to build output table * Fix dependencies for useGenerator hook * Improve serializing of stock - Copy test results - Ensure fields get copied across * Fix rendering for StockItemTestResultTable * Enhanced playwright test * Fix code * Fix for unit test
This commit is contained in:
@@ -340,9 +340,10 @@ export function useStockItemSerializeFields({
|
||||
partId: number;
|
||||
trackable: boolean;
|
||||
modalId: string;
|
||||
}) {
|
||||
}): ApiFormFieldSet {
|
||||
const serialGenerator = useSerialNumberGenerator({
|
||||
modalId: modalId,
|
||||
isEnabled: () => trackable,
|
||||
initialQuery: {
|
||||
part: partId
|
||||
}
|
||||
|
@@ -79,6 +79,7 @@ export function useGenerator(props: GeneratorProps): GeneratorState {
|
||||
'generator',
|
||||
props.key,
|
||||
props.endpoint,
|
||||
props.modalId,
|
||||
props.initialQuery,
|
||||
modalState.openModals,
|
||||
debouncedQuery
|
||||
|
@@ -863,7 +863,6 @@ export default function StockDetail() {
|
||||
name: t`Serialize`,
|
||||
tooltip: t`Serialize stock`,
|
||||
hidden:
|
||||
isBuilding ||
|
||||
serialized ||
|
||||
stockitem?.quantity < 1 ||
|
||||
stockitem?.part_detail?.trackable != true,
|
||||
@@ -1038,15 +1037,15 @@ export default function StockDetail() {
|
||||
id={stockitem.pk}
|
||||
instance={stockitem}
|
||||
/>
|
||||
{editStockItem.modal}
|
||||
{duplicateStockItem.modal}
|
||||
{deleteStockItem.modal}
|
||||
{serializeStockItem.modal}
|
||||
{returnStockItem.modal}
|
||||
{stockAdjustActions.modals.map((modal) => modal.modal)}
|
||||
{orderPartsWizard.wizard}
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
{editStockItem.modal}
|
||||
{duplicateStockItem.modal}
|
||||
{deleteStockItem.modal}
|
||||
{serializeStockItem.modal}
|
||||
{returnStockItem.modal}
|
||||
{stockAdjustActions.modals.map((modal) => modal.modal)}
|
||||
{orderPartsWizard.wizard}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -38,7 +38,8 @@ import {
|
||||
} from '../../forms/BuildForms';
|
||||
import {
|
||||
type StockOperationProps,
|
||||
useStockFields
|
||||
useStockFields,
|
||||
useStockItemSerializeFields
|
||||
} from '../../forms/StockForms';
|
||||
import { InvenTreeIcon } from '../../functions/icons';
|
||||
import {
|
||||
@@ -356,6 +357,28 @@ export default function BuildOutputTable({
|
||||
}
|
||||
});
|
||||
|
||||
const serializeStockFields = useStockItemSerializeFields({
|
||||
partId: selectedOutputs[0]?.part,
|
||||
trackable: selectedOutputs[0]?.part_detail?.trackable,
|
||||
modalId: 'build-output-serialize'
|
||||
});
|
||||
|
||||
const serializeOutput = useCreateApiFormModal({
|
||||
url: ApiEndpoints.stock_serialize,
|
||||
pk: selectedOutputs[0]?.pk,
|
||||
title: t`Serialize Build Output`,
|
||||
modalId: 'build-output-serialize',
|
||||
fields: serializeStockFields,
|
||||
initialData: {
|
||||
quantity: selectedOutputs[0]?.quantity ?? 1,
|
||||
destination: selectedOutputs[0]?.location ?? build.destination
|
||||
},
|
||||
onFormSuccess: () => {
|
||||
table.refreshTable(true);
|
||||
refreshBuild();
|
||||
}
|
||||
});
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@@ -475,6 +498,17 @@ export default function BuildOutputTable({
|
||||
deallocateBuildOutput.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t`Serialize`,
|
||||
tooltip: t`Serialize build output`,
|
||||
color: 'blue',
|
||||
hidden: !record.part_detail?.trackable || !!record.serial,
|
||||
icon: <InvenTreeIcon icon='serial' />,
|
||||
onClick: () => {
|
||||
setSelectedOutputs([record]);
|
||||
serializeOutput.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t`Complete`,
|
||||
tooltip: t`Complete build output`,
|
||||
@@ -627,6 +661,7 @@ export default function BuildOutputTable({
|
||||
{editBuildOutput.modal}
|
||||
{deallocateBuildOutput.modal}
|
||||
{cancelBuildOutputsForm.modal}
|
||||
{serializeOutput.modal}
|
||||
{stockAdjustActions.modals.map((modal) => modal.modal)}
|
||||
<OutputAllocationDrawer
|
||||
build={build}
|
||||
|
@@ -7,7 +7,7 @@ import {
|
||||
IconInfoCircle
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DataTable } from 'mantine-datatable';
|
||||
import { DataTable, type DataTableRowExpansionProps } from 'mantine-datatable';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
RowDeleteAction,
|
||||
RowEditAction
|
||||
} from '../RowActions';
|
||||
import RowExpansionIcon from '../RowExpansionIcon';
|
||||
|
||||
export default function StockItemTestResultTable({
|
||||
partId,
|
||||
@@ -131,123 +132,138 @@ export default function StockItemTestResultTable({
|
||||
[partId, itemId, testTemplates]
|
||||
);
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'test',
|
||||
title: t`Test`,
|
||||
switchable: false,
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
const enabled = record.enabled ?? record.template_detail?.enabled;
|
||||
const installed =
|
||||
record.stock_item != undefined && record.stock_item != itemId;
|
||||
const constructTableColumns = useCallback(
|
||||
(child: boolean) => {
|
||||
return [
|
||||
{
|
||||
accessor: 'test',
|
||||
title: t`Test`,
|
||||
switchable: false,
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
const enabled = record.enabled ?? record.template_detail?.enabled;
|
||||
const installed =
|
||||
record.stock_item != undefined && record.stock_item != itemId;
|
||||
|
||||
const multipleResults = record.results && record.results.length > 1;
|
||||
|
||||
return (
|
||||
<Group justify='space-between' wrap='nowrap'>
|
||||
<Text
|
||||
style={{ fontStyle: installed ? 'italic' : undefined }}
|
||||
c={enabled ? undefined : 'red'}
|
||||
>
|
||||
{!record.templateId && '- '}
|
||||
{record.test_name ?? record.template_detail?.test_name}
|
||||
</Text>
|
||||
<Group justify='right'>
|
||||
{record.results && record.results.length > 1 && (
|
||||
<Tooltip label={t`Test Results`}>
|
||||
<Badge color='lightblue' variant='filled'>
|
||||
{record.results.length}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{installed && (
|
||||
<Tooltip label={t`Test result for installed stock item`}>
|
||||
<IconInfoCircle size={16} color='blue' />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'result',
|
||||
title: t`Result`,
|
||||
switchable: false,
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
if (record.result === undefined) {
|
||||
return (
|
||||
<Badge color='lightblue' variant='filled'>{t`No Result`}</Badge>
|
||||
<Group justify='space-between' wrap='nowrap'>
|
||||
{!child && (
|
||||
<RowExpansionIcon
|
||||
enabled={multipleResults}
|
||||
expanded={table.isRowExpanded(record.pk)}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
style={{ fontStyle: installed ? 'italic' : undefined }}
|
||||
c={enabled ? undefined : 'red'}
|
||||
>
|
||||
{!record.templateId && '- '}
|
||||
{record.test_name ?? record.template_detail?.test_name}
|
||||
</Text>
|
||||
<Group justify='right'>
|
||||
{record.results && record.results.length > 1 && (
|
||||
<Tooltip label={t`Test Results`}>
|
||||
<Badge color='lightblue' variant='filled'>
|
||||
{record.results.length}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{installed && (
|
||||
<Tooltip label={t`Test result for installed stock item`}>
|
||||
<IconInfoCircle size={16} color='blue' />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'result',
|
||||
title: t`Result`,
|
||||
switchable: false,
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
if (record.result === undefined) {
|
||||
return (
|
||||
<Badge color='lightblue' variant='filled'>{t`No Result`}</Badge>
|
||||
);
|
||||
} else {
|
||||
return <PassFailButton value={record.result} />;
|
||||
}
|
||||
}
|
||||
},
|
||||
DescriptionColumn({}),
|
||||
{
|
||||
accessor: 'value',
|
||||
title: t`Value`
|
||||
},
|
||||
{
|
||||
accessor: 'attachment',
|
||||
title: t`Attachment`,
|
||||
render: (record: any) =>
|
||||
record.attachment && (
|
||||
<AttachmentLink attachment={record.attachment} />
|
||||
),
|
||||
noContext: true
|
||||
},
|
||||
NoteColumn({}),
|
||||
DateColumn({}),
|
||||
{
|
||||
accessor: 'user',
|
||||
title: t`User`,
|
||||
sortable: false,
|
||||
render: (record: any) =>
|
||||
record.user_detail && <RenderUser instance={record.user_detail} />
|
||||
},
|
||||
{
|
||||
accessor: 'test_station',
|
||||
sortable: true,
|
||||
title: t`Test station`,
|
||||
hidden: !includeTestStation
|
||||
},
|
||||
{
|
||||
accessor: 'started_datetime',
|
||||
sortable: true,
|
||||
title: t`Started`,
|
||||
hidden: !includeTestStation,
|
||||
render: (record: any) => {
|
||||
return (
|
||||
<Group justify='space-between'>
|
||||
{formatDate(record.started_datetime, {
|
||||
showTime: true,
|
||||
showSeconds: true
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'finished_datetime',
|
||||
sortable: true,
|
||||
title: t`Finished`,
|
||||
hidden: !includeTestStation,
|
||||
render: (record: any) => {
|
||||
return (
|
||||
<Group justify='space-between'>
|
||||
{formatDate(record.finished_datetime, {
|
||||
showTime: true,
|
||||
showSeconds: true
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
} else {
|
||||
return <PassFailButton value={record.result} />;
|
||||
}
|
||||
}
|
||||
},
|
||||
DescriptionColumn({}),
|
||||
{
|
||||
accessor: 'value',
|
||||
title: t`Value`
|
||||
},
|
||||
{
|
||||
accessor: 'attachment',
|
||||
title: t`Attachment`,
|
||||
render: (record: any) =>
|
||||
record.attachment && (
|
||||
<AttachmentLink attachment={record.attachment} />
|
||||
),
|
||||
noContext: true
|
||||
},
|
||||
NoteColumn({}),
|
||||
DateColumn({}),
|
||||
{
|
||||
accessor: 'user',
|
||||
title: t`User`,
|
||||
sortable: false,
|
||||
render: (record: any) =>
|
||||
record.user_detail && <RenderUser instance={record.user_detail} />
|
||||
},
|
||||
{
|
||||
accessor: 'test_station',
|
||||
sortable: true,
|
||||
title: t`Test station`,
|
||||
hidden: !includeTestStation
|
||||
},
|
||||
{
|
||||
accessor: 'started_datetime',
|
||||
sortable: true,
|
||||
title: t`Started`,
|
||||
hidden: !includeTestStation,
|
||||
render: (record: any) => {
|
||||
return (
|
||||
<Group justify='space-between'>
|
||||
{formatDate(record.started_datetime, {
|
||||
showTime: true,
|
||||
showSeconds: true
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'finished_datetime',
|
||||
sortable: true,
|
||||
title: t`Finished`,
|
||||
hidden: !includeTestStation,
|
||||
render: (record: any) => {
|
||||
return (
|
||||
<Group justify='space-between'>
|
||||
{formatDate(record.finished_datetime, {
|
||||
showTime: true,
|
||||
showSeconds: true
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}, [itemId, includeTestStation]);
|
||||
];
|
||||
},
|
||||
[itemId, includeTestStation, table.expandedRecords]
|
||||
);
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return constructTableColumns(false);
|
||||
}, [itemId, includeTestStation, table.expandedRecords]);
|
||||
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<number | undefined>(
|
||||
undefined
|
||||
@@ -286,7 +302,7 @@ export default function StockItemTestResultTable({
|
||||
pk: selectedTest,
|
||||
fields: useMemo(() => ({ ...editResultFields }), [editResultFields]),
|
||||
title: t`Edit Test Result`,
|
||||
table: table,
|
||||
onFormSuccess: () => table.refreshTable,
|
||||
successMessage: t`Test result updated`
|
||||
});
|
||||
|
||||
@@ -418,9 +434,9 @@ export default function StockItemTestResultTable({
|
||||
}, [user]);
|
||||
|
||||
// Row expansion controller
|
||||
const rowExpansion: any = useMemo(() => {
|
||||
const rowExpansion: DataTableRowExpansionProps<any> = useMemo(() => {
|
||||
const cols: any = [
|
||||
...tableColumns,
|
||||
...constructTableColumns(true),
|
||||
{
|
||||
accessor: 'actions',
|
||||
title: ' ',
|
||||
@@ -435,7 +451,12 @@ export default function StockItemTestResultTable({
|
||||
|
||||
return {
|
||||
allowMultiple: true,
|
||||
expandable: (record: any) => record.results && record.results.length > 1,
|
||||
expandable: ({ record }: { record: any }) => {
|
||||
return (
|
||||
table.isRowExpanded(record.pk) ||
|
||||
(record.results && record.results.length > 1)
|
||||
);
|
||||
},
|
||||
content: ({ record }: { record: any }) => {
|
||||
if (!record || !record.results || record.results.length < 2) {
|
||||
return null;
|
||||
@@ -454,7 +475,7 @@ export default function StockItemTestResultTable({
|
||||
);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [constructTableColumns, table.isRowExpanded]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { test } from '../baseFixtures.js';
|
||||
import { expect, test } from '../baseFixtures.js';
|
||||
import {
|
||||
clearTableFilters,
|
||||
clickButtonIfVisible,
|
||||
@@ -199,12 +199,25 @@ test('Stock - Serialize', async ({ browser }) => {
|
||||
await page.getByLabel('action-menu-stock-operations').click();
|
||||
await page.getByLabel('action-menu-stock-operations-serialize').click();
|
||||
|
||||
// Check for expected placeholder value
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'text-field-serial_numbers' })
|
||||
).toHaveAttribute('placeholder', 'Next serial number: 365');
|
||||
|
||||
await page.getByLabel('text-field-serial_numbers').fill('200-250');
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page
|
||||
.getByText('Group range 200-250 exceeds allowed quantity')
|
||||
.waitFor();
|
||||
|
||||
await page.getByLabel('text-field-serial_numbers').fill('1, 2, 3');
|
||||
await page.waitForTimeout(250);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page
|
||||
.getByText('Number of unique serial numbers (3) must match quantity (10)')
|
||||
.waitFor();
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
|
Reference in New Issue
Block a user