2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 12:06:44 +00:00

[PUI] Fix Build Output Forms (#8184)

* Enhancements for stock item form

* Edit stock item from "build output" table

* Rearrange menu items

* Fix build order line complete action

* Fix for other modals

* Cleanup dead code

* Reload build details after output state change

* Logic fix for plugin table

* Bump API version

* Adds hook for generating placeholder serial numbers

* Add playwright tests

* Remove unused imports

* Cleanup playwright tests
This commit is contained in:
Oliver 2024-09-27 00:35:30 +10:00 committed by GitHub
parent 194640f55a
commit 4f06918c36
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 354 additions and 228 deletions

View File

@ -1,13 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 260
INVENTREE_API_VERSION = 261
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
261 - 2024-09;26 : https://github.com/inventree/InvenTree/pull/8184
- Fixes for BuildOrder API serializers
v260 - 2024-09-26 : https://github.com/inventree/InvenTree/pull/8190
- Adds facility for server-side context data to be passed to client-side plugins

View File

@ -555,7 +555,7 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
fields = [
'outputs',
'location',
'status',
'status_custom_key',
'accept_incomplete_allocation',
'notes',
]
@ -573,7 +573,7 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
help_text=_("Location for completed build outputs"),
)
status = serializers.ChoiceField(
status_custom_key = serializers.ChoiceField(
choices=StockStatus.items(),
default=StockStatus.OK.value,
label=_("Status"),
@ -621,8 +621,8 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
data = self.validated_data
location = data['location']
status = data['status']
location = data.get('location', None)
status = data.get('status_custom_key', StockStatus.OK.value)
notes = data.get('notes', '')
outputs = data.get('outputs', [])

View File

@ -30,7 +30,7 @@ export default function TextField({
const [rawText, setRawText] = useState<string>(value || '');
const [debouncedText] = useDebouncedValue(rawText, 250);
const [debouncedText] = useDebouncedValue(rawText, 100);
useEffect(() => {
setRawText(value || '');

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Alert, Stack, Table, Text } from '@mantine/core';
import { Stack, Table } from '@mantine/core';
import {
IconCalendar,
IconLink,
@ -9,11 +9,8 @@ import {
IconUser,
IconUsersGroup
} from '@tabler/icons-react';
import { DataTable } from 'mantine-datatable';
import { useEffect, useMemo, useState } from 'react';
import { api } from '../App';
import { ActionButton } from '../components/buttons/ActionButton';
import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField';
import {
@ -22,15 +19,15 @@ import {
} from '../components/forms/fields/ApiFormField';
import { TableFieldRowProps } from '../components/forms/fields/TableField';
import { ProgressBar } from '../components/items/ProgressBar';
import { StatusRenderer } from '../components/render/StatusRenderer';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { InvenTreeIcon } from '../functions/icons';
import { useCreateApiFormModal } from '../hooks/UseForm';
import { useBatchCodeGenerator } from '../hooks/UseGenerator';
import { useSelectedRows } from '../hooks/UseSelectedRows';
import { useSerialNumberPlaceholder } from '../hooks/UsePlaceholder';
import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
import { PartColumn, StatusColumn } from '../tables/ColumnRenderers';
import { PartColumn } from '../tables/ColumnRenderers';
/**
* Field set for BuildOrder forms
@ -160,32 +157,11 @@ export function useBuildOrderOutputFields({
setQuantity(Math.max(0, build_quantity - build_complete));
}, [build]);
const [serialPlaceholder, setSerialPlaceholder] = useState<string>('');
useEffect(() => {
if (trackable) {
api
.get(apiUrl(ApiEndpoints.part_serial_numbers, build.part_detail.pk))
.then((response: any) => {
if (response.data?.next) {
setSerialPlaceholder(
t`Next serial number` + ' - ' + response.data.next
);
} else if (response.data?.latest) {
setSerialPlaceholder(
t`Latest serial number` + ' - ' + response.data.latest
);
} else {
setSerialPlaceholder('');
}
})
.catch(() => {
setSerialPlaceholder('');
const serialPlaceholder = useSerialNumberPlaceholder({
partId: build.part_detail?.pk,
key: 'build-output',
enabled: build.part_detail?.trackable
});
} else {
setSerialPlaceholder('');
}
}, [build, trackable]);
return useMemo(() => {
return {
@ -213,48 +189,37 @@ export function useBuildOrderOutputFields({
}, [quantity, serialPlaceholder, trackable]);
}
/*
* Construct a table of build outputs, for displaying at the top of a form
*/
function buildOutputFormTable(outputs: any[], onRemove: (output: any) => void) {
return (
<DataTable
idAccessor="pk"
records={outputs}
columns={[
{
accessor: 'part',
title: t`Part`,
render: (record: any) => PartColumn(record.part_detail)
},
{
accessor: 'quantity',
title: t`Quantity`,
render: (record: any) => {
function BuildOutputFormRow({
props,
record
}: Readonly<{
props: TableFieldRowProps;
record: any;
}>) {
const serial = useMemo(() => {
if (record.serial) {
return `# ${record.serial}`;
} else {
return record.quantity;
return t`Quantity` + `: ${record.quantity}`;
}
}
},
StatusColumn({ model: ModelType.stockitem, sortable: false }),
{
accessor: 'actions',
title: '',
render: (record: any) => (
<ActionButton
key={`remove-output-${record.pk}`}
tooltip={t`Remove output`}
icon={<InvenTreeIcon icon="cancel" />}
color="red"
onClick={() => onRemove(record.pk)}
disabled={outputs.length <= 1}
/>
)
}
]}
/>
}, [record]);
return (
<>
<Table.Tr>
<Table.Td>
<PartColumn part={record.part_detail} />
</Table.Td>
<Table.Td>{serial}</Table.Td>
<Table.Td>{record.batch}</Table.Td>
<Table.Td>
<StatusRenderer status={record.status} type={ModelType.stockitem} />{' '}
</Table.Td>
<Table.Td style={{ width: '1%', whiteSpace: 'nowrap' }}>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
</Table.Td>
</Table.Tr>
</>
);
}
@ -269,10 +234,6 @@ export function useCompleteBuildOutputsForm({
}) {
const [location, setLocation] = useState<number | null>(null);
const { selectedRows, removeRow } = useSelectedRows({
rows: outputs
});
useEffect(() => {
if (location) {
return;
@ -283,19 +244,22 @@ export function useCompleteBuildOutputsForm({
);
}, [location, build.destination, build.part_detail]);
const preFormContent = useMemo(() => {
return buildOutputFormTable(selectedRows, removeRow);
}, [selectedRows, removeRow]);
const buildOutputCompleteFields: ApiFormFieldSet = useMemo(() => {
return {
outputs: {
hidden: true,
value: selectedRows.map((output: any) => {
field_type: 'table',
value: outputs.map((output: any) => {
return {
output: output.pk
};
})
}),
modelRenderer: (row: TableFieldRowProps) => {
const record = outputs.find((output) => output.pk == row.item.output);
return (
<BuildOutputFormRow props={row} record={record} key={record.pk} />
);
},
headers: [t`Part`, t`Stock Item`, t`Batch`, t`Status`]
},
status_custom_key: {},
location: {
@ -303,14 +267,14 @@ export function useCompleteBuildOutputsForm({
structural: false
},
value: location,
onValueChange: (value) => {
onValueChange: (value: any) => {
setLocation(value);
}
},
notes: {},
accept_incomplete_allocation: {}
};
}, [selectedRows, location]);
}, [location, outputs]);
return useCreateApiFormModal({
url: apiUrl(ApiEndpoints.build_output_complete, build.pk),
@ -318,8 +282,8 @@ export function useCompleteBuildOutputsForm({
title: t`Complete Build Outputs`,
fields: buildOutputCompleteFields,
onFormSuccess: onFormSuccess,
preFormContent: preFormContent,
successMessage: t`Build outputs have been completed`
successMessage: t`Build outputs have been completed`,
size: '80%'
});
}
@ -337,10 +301,6 @@ export function useScrapBuildOutputsForm({
}) {
const [location, setLocation] = useState<number | null>(null);
const { selectedRows, removeRow } = useSelectedRows({
rows: outputs
});
useEffect(() => {
if (location) {
return;
@ -351,20 +311,23 @@ export function useScrapBuildOutputsForm({
);
}, [location, build.destination, build.part_detail]);
const preFormContent = useMemo(() => {
return buildOutputFormTable(selectedRows, removeRow);
}, [selectedRows, removeRow]);
const buildOutputScrapFields: ApiFormFieldSet = useMemo(() => {
return {
outputs: {
hidden: true,
value: selectedRows.map((output: any) => {
field_type: 'table',
value: outputs.map((output: any) => {
return {
output: output.pk,
quantity: output.quantity
};
})
}),
modelRenderer: (row: TableFieldRowProps) => {
const record = outputs.find((output) => output.pk == row.item.output);
return (
<BuildOutputFormRow props={row} record={record} key={record.pk} />
);
},
headers: [t`Part`, t`Stock Item`, t`Batch`, t`Status`]
},
location: {
value: location,
@ -375,7 +338,7 @@ export function useScrapBuildOutputsForm({
notes: {},
discard_allocations: {}
};
}, [location, selectedRows]);
}, [location, outputs]);
return useCreateApiFormModal({
url: apiUrl(ApiEndpoints.build_output_scrap, build.pk),
@ -383,8 +346,8 @@ export function useScrapBuildOutputsForm({
title: t`Scrap Build Outputs`,
fields: buildOutputScrapFields,
onFormSuccess: onFormSuccess,
preFormContent: preFormContent,
successMessage: t`Build outputs have been scrapped`
successMessage: t`Build outputs have been scrapped`,
size: '80%'
});
}
@ -397,89 +360,37 @@ export function useCancelBuildOutputsForm({
outputs: any[];
onFormSuccess: (response: any) => void;
}) {
const { selectedRows, removeRow } = useSelectedRows({
rows: outputs
});
const preFormContent = useMemo(() => {
return (
<Stack gap="xs">
<Alert color="red" title={t`Cancel Build Outputs`}>
<Text>{t`Selected build outputs will be deleted`}</Text>
</Alert>
{buildOutputFormTable(selectedRows, removeRow)}
</Stack>
);
}, [selectedRows, removeRow]);
const buildOutputCancelFields: ApiFormFieldSet = useMemo(() => {
return {
outputs: {
hidden: true,
value: selectedRows.map((output: any) => {
field_type: 'table',
value: outputs.map((output: any) => {
return {
output: output.pk
};
})
}),
modelRenderer: (row: TableFieldRowProps) => {
const record = outputs.find((output) => output.pk == row.item.output);
return (
<BuildOutputFormRow props={row} record={record} key={record.pk} />
);
},
headers: [t`Part`, t`Stock Item`, t`Batch`, t`Status`]
}
};
}, [selectedRows]);
}, [outputs]);
return useCreateApiFormModal({
url: apiUrl(ApiEndpoints.build_output_delete, build.pk),
method: 'POST',
title: t`Cancel Build Outputs`,
fields: buildOutputCancelFields,
preFormContent: preFormContent,
onFormSuccess: onFormSuccess,
successMessage: t`Build outputs have been cancelled`
successMessage: t`Build outputs have been cancelled`,
size: '80%'
});
}
function buildAllocationFormTable(
outputs: any[],
onRemove: (output: any) => void
) {
return (
<DataTable
idAccessor="pk"
records={outputs}
columns={[
{
accessor: 'part',
title: t`Part`,
render: (record: any) => PartColumn(record.part_detail)
},
{
accessor: 'allocated',
title: t`Allocated`,
render: (record: any) => (
<ProgressBar
value={record.allocated}
maximum={record.quantity}
progressLabel
/>
)
},
{
accessor: 'actions',
title: '',
render: (record: any) => (
<ActionButton
key={`remove-line-${record.pk}`}
tooltip={t`Remove line`}
icon={<InvenTreeIcon icon="cancel" />}
color="red"
onClick={() => onRemove(record.pk)}
disabled={outputs.length <= 1}
/>
)
}
]}
/>
);
}
// Construct a single row in the 'allocate stock to build' table
function BuildAllocateLineRow({
props,
@ -534,14 +445,11 @@ function BuildAllocateLineRow({
};
}, [props]);
const partDetail = useMemo(
() => PartColumn(record.part_detail),
[record.part_detail]
);
return (
<Table.Tr key={`table-row-${record.pk}`}>
<Table.Td>{partDetail}</Table.Td>
<Table.Td>
<PartColumn part={record.part_detail} />
</Table.Td>
<Table.Td>
<ProgressBar
value={record.allocated}

View File

@ -2,6 +2,14 @@ import { t } from '@lingui/macro';
import { Flex, Group, Skeleton, Table, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import {
IconCalendarExclamation,
IconCoins,
IconCurrencyDollar,
IconLink,
IconPackage,
IconUsersGroup
} from '@tabler/icons-react';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { Suspense, useMemo, useState } from 'react';
@ -39,10 +47,16 @@ import { useGlobalSettingsState } from '../states/SettingsState';
* Construct a set of fields for creating / editing a StockItem instance
*/
export function useStockFields({
item_detail,
part_detail,
create = false
}: {
item_detail?: any;
part_detail?: any;
create: boolean;
}): ApiFormFieldSet {
const globalSettings = useGlobalSettingsState();
const [part, setPart] = useState<number | null>(null);
const [supplierPart, setSupplierPart] = useState<number | null>(null);
@ -86,7 +100,7 @@ export function useStockFields({
}
},
supplier_part: {
// TODO: icon
hidden: part_detail?.purchaseable == false,
value: supplierPart,
onValueChange: (value) => {
setSupplierPart(value);
@ -109,6 +123,7 @@ export function useStockFields({
description: t`Add given quantity as packs instead of individual items`
},
location: {
// Cannot adjust location for existing stock items
hidden: !create,
onValueChange: (value) => {
batchGenerator.update({ location: value });
@ -135,11 +150,12 @@ export function useStockFields({
onValueChange: (value) => setSerialNumbers(value)
},
serial: {
hidden: create
// TODO: icon
hidden:
create ||
part_detail?.trackable == false ||
(!item_detail?.quantity != undefined && item_detail?.quantity != 1)
},
batch: {
// TODO: icon
value: batchCode,
onValueChange: (value) => setBatchCode(value)
},
@ -147,22 +163,23 @@ export function useStockFields({
label: t`Stock Status`
},
expiry_date: {
// TODO: icon
icon: <IconCalendarExclamation />,
hidden: !globalSettings.isSet('STOCK_ENABLE_EXPIRY')
},
purchase_price: {
// TODO: icon
icon: <IconCurrencyDollar />
},
purchase_price_currency: {
// TODO: icon
icon: <IconCoins />
},
packaging: {
// TODO: icon,
icon: <IconPackage />
},
link: {
// TODO: icon
icon: <IconLink />
},
owner: {
// TODO: icon
icon: <IconUsersGroup />
},
delete_on_deplete: {}
};
@ -171,7 +188,17 @@ export function useStockFields({
// TODO: refer to stock.py in original codebase
return fields;
}, [part, supplierPart, batchCode, serialNumbers, trackable, create]);
}, [
item_detail,
part_detail,
part,
globalSettings,
supplierPart,
batchCode,
serialNumbers,
trackable,
create
]);
}
/**

View File

@ -0,0 +1,66 @@
import { t } from '@lingui/macro';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { api } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { apiUrl } from '../states/ApiState';
/**
* Hook for generating a placeholder text for a serial number input
*
* This hook fetches the latest serial number information for a given part and generates a placeholder string.
*
* @param partId The ID of the part to fetch serial number information for
* @param key A unique key to identify the query
* @param enabled Whether the query should be enabled
*/
export function useSerialNumberPlaceholder({
partId,
key,
enabled = true
}: {
partId: number;
key: string;
enabled?: boolean;
}): string | undefined {
// Fetch serial number information (if available)
const snQuery = useQuery({
queryKey: ['serial-placeholder', key, partId],
enabled: enabled ?? true,
queryFn: async () => {
if (!partId) {
return null;
}
const url = apiUrl(ApiEndpoints.part_serial_numbers, partId);
return api
.get(url)
.then((response) => {
if (response.status === 200) {
return response.data;
} else {
return null;
}
})
.catch(() => {
return null;
});
}
});
const placeholder = useMemo(() => {
if (!enabled) {
return undefined;
} else if (snQuery.data?.next) {
return t`Next serial number` + `: ${snQuery.data.next}`;
} else if (snQuery.data?.latest) {
return t`Latest serial number` + `: ${snQuery.data.latest}`;
} else {
return undefined;
}
}, [enabled, snQuery.data]);
return placeholder;
}

View File

@ -262,7 +262,11 @@ export default function BuildDetail() {
name: 'incomplete-outputs',
label: t`Incomplete Outputs`,
icon: <IconClipboardList />,
content: build.pk ? <BuildOutputTable build={build} /> : <Skeleton />
content: build.pk ? (
<BuildOutputTable build={build} refreshBuild={refreshInstance} />
) : (
<Skeleton />
)
// TODO: Hide if build is complete
},
{

View File

@ -141,7 +141,7 @@ export default function BomPricingPanel({
title: t`Component`,
sortable: true,
switchable: false,
render: (record: any) => PartColumn(record.sub_part_detail)
render: (record: any) => PartColumn({ part: record.sub_part_detail })
},
{
accessor: 'quantity',

View File

@ -30,7 +30,7 @@ export default function VariantPricingPanel({
title: t`Variant Part`,
sortable: true,
switchable: false,
render: (record: any) => PartColumn(record, true)
render: (record: any) => PartColumn({ part: record, full_name: true })
},
{
accessor: 'pricing_min',

View File

@ -422,7 +422,10 @@ export default function StockDetail() {
[stockitem]
);
const editStockItemFields = useStockFields({ create: false });
const editStockItemFields = useStockFields({
create: false,
part_detail: stockitem.part_detail
});
const editStockItem = useEditApiFormModal({
url: ApiEndpoints.stock_item_list,

View File

@ -18,7 +18,13 @@ import { TableColumn, TableColumnProps } from './Column';
import { ProjectCodeHoverCard } from './TableHoverCard';
// Render a Part instance within a table
export function PartColumn(part: any, full_name?: boolean) {
export function PartColumn({
part,
full_name
}: {
part: any;
full_name?: boolean;
}) {
return part ? (
<Group justify="space-between" wrap="nowrap">
<Thumbnail

View File

@ -31,7 +31,7 @@ export function UsedInTable({
switchable: false,
sortable: true,
title: t`Assembly`,
render: (record: any) => PartColumn(record.part_detail)
render: (record: any) => PartColumn({ part: record.part_detail })
},
{
accessor: 'part_detail.IPN',
@ -47,7 +47,7 @@ export function UsedInTable({
accessor: 'sub_part',
sortable: true,
title: t`Component`,
render: (record: any) => PartColumn(record.sub_part_detail)
render: (record: any) => PartColumn({ part: record.sub_part_detail })
},
{
accessor: 'quantity',

View File

@ -82,7 +82,7 @@ export default function BuildAllocatedStockTable({
title: t`Part`,
sortable: true,
switchable: false,
render: (record: any) => PartColumn(record.part_detail)
render: (record: any) => PartColumn({ part: record.part_detail })
},
{
hidden: !showPartInfo,

View File

@ -175,7 +175,7 @@ export default function BuildLineTable({
ordering: 'part',
sortable: true,
switchable: false,
render: (record: any) => PartColumn(record.part_detail)
render: (record: any) => PartColumn({ part: record.part_detail })
},
{
accessor: 'part_detail.IPN',

View File

@ -45,7 +45,7 @@ export function BuildOrderTable({
accessor: 'part',
sortable: true,
switchable: false,
render: (record: any) => PartColumn(record.part_detail)
render: (record: any) => PartColumn({ part: record.part_detail })
},
{
accessor: 'part_detail.IPN',

View File

@ -17,16 +17,20 @@ import {
useCompleteBuildOutputsForm,
useScrapBuildOutputsForm
} from '../../forms/BuildForms';
import { useStockFields } from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
import { notYetImplemented } from '../../functions/notifications';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import {
useCreateApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import { LocationColumn, PartColumn, StatusColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction } from '../RowActions';
import { RowAction, RowEditAction } from '../RowActions';
import { TableHoverCard } from '../TableHoverCard';
type TestResultOverview = {
@ -34,7 +38,10 @@ type TestResultOverview = {
result: boolean;
};
export default function BuildOutputTable({ build }: Readonly<{ build: any }>) {
export default function BuildOutputTable({
build,
refreshBuild
}: Readonly<{ build: any; refreshBuild: () => void }>) {
const user = useUserState();
const table = useTable('build-outputs');
@ -186,6 +193,7 @@ export default function BuildOutputTable({ build }: Readonly<{ build: any }>) {
outputs: selectedOutputs,
onFormSuccess: () => {
table.refreshTable();
refreshBuild();
}
});
@ -194,6 +202,7 @@ export default function BuildOutputTable({ build }: Readonly<{ build: any }>) {
outputs: selectedOutputs,
onFormSuccess: () => {
table.refreshTable();
refreshBuild();
}
});
@ -202,9 +211,24 @@ export default function BuildOutputTable({ build }: Readonly<{ build: any }>) {
outputs: selectedOutputs,
onFormSuccess: () => {
table.refreshTable();
refreshBuild();
}
});
const editStockItemFields = useStockFields({
create: false,
item_detail: selectedOutputs[0],
part_detail: selectedOutputs[0]?.part_detail
});
const editBuildOutput = useEditApiFormModal({
url: ApiEndpoints.stock_item_list,
pk: selectedOutputs[0]?.pk,
title: t`Edit Build Output`,
fields: editStockItemFields,
table: table
});
const tableActions = useMemo(() => {
return [
<AddItemButton
@ -276,6 +300,13 @@ export default function BuildOutputTable({ build }: Readonly<{ build: any }>) {
completeBuildOutputsForm.open();
}
},
RowEditAction({
tooltip: t`Edit build output`,
onClick: () => {
setSelectedOutputs([record]);
editBuildOutput.open();
}
}),
{
title: t`Scrap`,
tooltip: t`Scrap build output`,
@ -306,7 +337,7 @@ export default function BuildOutputTable({ build }: Readonly<{ build: any }>) {
{
accessor: 'part',
sortable: true,
render: (record: any) => PartColumn(record?.part_detail)
render: (record: any) => PartColumn({ part: record?.part_detail })
},
{
accessor: 'quantity',
@ -321,18 +352,13 @@ export default function BuildOutputTable({ build }: Readonly<{ build: any }>) {
text = `# ${record.serial}`;
}
return (
<Group justify="left" wrap="nowrap">
<Text>{text}</Text>
{record.batch && (
<Text style={{ fontStyle: 'italic' }} size="sm">
{t`Batch`}: {record.batch}
</Text>
)}
</Group>
);
return text;
}
},
{
accessor: 'batch',
sortable: true
},
StatusColumn({
accessor: 'status',
sortable: true,
@ -410,6 +436,7 @@ export default function BuildOutputTable({ build }: Readonly<{ build: any }>) {
{addBuildOutput.modal}
{completeBuildOutputsForm.modal}
{scrapBuildOutputsForm.modal}
{editBuildOutput.modal}
{cancelBuildOutputsForm.modal}
<InvenTreeTable
tableState={table}

View File

@ -243,7 +243,7 @@ export default function ParametricPartTable({
sortable: true,
switchable: false,
noWrap: true,
render: (record: any) => PartColumn(record)
render: (record: any) => PartColumn({ part: record })
},
DescriptionColumn({}),
{

View File

@ -43,7 +43,7 @@ export function PartParameterTable({
{
accessor: 'part',
sortable: true,
render: (record: any) => PartColumn(record?.part_detail)
render: (record: any) => PartColumn({ part: record?.part_detail })
},
{
accessor: 'part_detail.IPN',

View File

@ -28,7 +28,7 @@ function partTableColumns(): TableColumn[] {
title: t`Part`,
sortable: true,
noWrap: true,
render: (record: any) => PartColumn(record)
render: (record: any) => PartColumn({ part: record })
},
{
accessor: 'IPN',

View File

@ -336,7 +336,10 @@ export default function PluginListTable() {
return [
{
hidden: record.is_builtin != false || record.is_installed != false,
hidden:
record.is_builtin != false ||
record.is_installed != true ||
record.active != true,
title: t`Deactivate`,
color: 'red',
icon: <IconCircleX />,
@ -347,7 +350,10 @@ export default function PluginListTable() {
}
},
{
hidden: record.is_builtin != false || record.is_installed != true,
hidden:
record.is_builtin != false ||
record.is_installed != true ||
record.active != false,
title: t`Activate`,
color: 'green',
icon: <IconCircleCheck />,

View File

@ -37,7 +37,7 @@ export function ManufacturerPartTable({
accessor: 'part',
switchable: 'part' in params,
sortable: true,
render: (record: any) => PartColumn(record?.part_detail)
render: (record: any) => PartColumn({ part: record?.part_detail })
},
{
accessor: 'manufacturer',

View File

@ -128,7 +128,7 @@ export function PurchaseOrderLineItemTable({
title: t`Internal Part`,
sortable: true,
switchable: false,
render: (record: any) => PartColumn(record.part_detail)
render: (record: any) => PartColumn({ part: record.part_detail })
},
{
accessor: 'description',

View File

@ -47,7 +47,7 @@ export function SupplierPartTable({
accessor: 'part',
switchable: 'part' in params,
sortable: true,
render: (record: any) => PartColumn(record?.part_detail)
render: (record: any) => PartColumn({ part: record?.part_detail })
},
{
accessor: 'supplier',

View File

@ -99,7 +99,7 @@ export default function ReturnOrderLineItemTable({
accessor: 'part',
title: t`Part`,
switchable: false,
render: (record: any) => PartColumn(record?.part_detail)
render: (record: any) => PartColumn({ part: record?.part_detail })
},
{
accessor: 'item_detail.serial',

View File

@ -68,7 +68,7 @@ export default function SalesOrderAllocationTable({
title: t`Part`,
sortable: true,
switchable: false,
render: (record: any) => PartColumn(record.part_detail)
render: (record: any) => PartColumn({ part: record.part_detail })
},
{
accessor: 'quantity',

View File

@ -59,7 +59,7 @@ export default function SalesOrderLineItemTable({
accessor: 'part',
sortable: true,
switchable: false,
render: (record: any) => PartColumn(record?.part_detail)
render: (record: any) => PartColumn({ part: record?.part_detail })
},
{
accessor: 'part_detail.IPN',

View File

@ -22,7 +22,7 @@ export default function InstalledItemsTable({
{
accessor: 'part',
switchable: false,
render: (record: any) => PartColumn(record?.part_detail)
render: (record: any) => PartColumn({ part: record?.part_detail })
},
{
accessor: 'quantity',

View File

@ -47,7 +47,7 @@ function stockItemTableColumns(): TableColumn[] {
{
accessor: 'part',
sortable: true,
render: (record: any) => PartColumn(record?.part_detail)
render: (record: any) => PartColumn({ part: record?.part_detail })
},
{
accessor: 'part_detail.IPN',

View File

@ -81,3 +81,79 @@ test('PUI - Pages - Build Order', async ({ page }) => {
.getByText('Making a high level assembly')
.waitFor();
});
test('PUI - Pages - Build Order - Build Outputs', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/part/`);
// Navigate to the correct build order
await page.getByRole('tab', { name: 'Build', exact: true }).click();
// We have now loaded the "Build Order" table. Check for some expected texts
await page.getByText('On Hold').first().waitFor();
await page.getByText('Pending').first().waitFor();
await page.getByRole('cell', { name: 'BO0011' }).click();
await page.getByRole('tab', { name: 'Incomplete Outputs' }).click();
// Create a new build output
await page.getByLabel('action-button-add-build-output').click();
await page.getByLabel('number-field-quantity').fill('5');
const placeholder = await page
.getByLabel('text-field-serial_numbers')
.getAttribute('placeholder');
let sn = 1;
if (!!placeholder && placeholder.includes('Next serial number')) {
sn = parseInt(placeholder.split(':')[1].trim());
}
// Generate some new serial numbers
await page.getByLabel('text-field-serial_numbers').fill(`${sn}, ${sn + 1}`);
await page.getByLabel('text-field-batch_code').fill('BATCH12345');
await page.getByLabel('related-field-location').click();
await page.getByText('Reel Storage').click();
await page.getByRole('button', { name: 'Submit' }).click();
// Should be an error as the number of serial numbers doesn't match the quantity
await page.getByText('Errors exist for one or more').waitFor();
await page.getByText('Number of unique serial').waitFor();
// Fix the quantity
await page.getByLabel('number-field-quantity').fill('2');
await page.waitForTimeout(250);
await page.getByRole('button', { name: 'Submit' }).click();
// Check that new serial numbers have been created
await page
.getByRole('cell', { name: `# ${sn}` })
.first()
.waitFor();
await page
.getByRole('cell', { name: `# ${sn + 1}` })
.first()
.waitFor();
// Cancel one of the newly created outputs
const cell = await page.getByRole('cell', { name: `# ${sn}` });
const row = await cell.locator('xpath=ancestor::tr').first();
await row.getByLabel(/row-action-menu-/i).click();
await page.getByRole('menuitem', { name: 'Cancel' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Build outputs have been cancelled').waitFor();
// Complete the other output
const cell2 = await page.getByRole('cell', { name: `# ${sn + 1}` });
const row2 = await cell2.locator('xpath=ancestor::tr').first();
await row2.getByLabel(/row-action-menu-/i).click();
await page.getByRole('menuitem', { name: 'Complete' }).click();
await page.getByLabel('related-field-location').click();
await page.getByText('Mechanical Lab').click();
await page.waitForTimeout(250);
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Build outputs have been completed').waitFor();
});