2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 20:16: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 information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 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 - 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 = [ fields = [
'outputs', 'outputs',
'location', 'location',
'status', 'status_custom_key',
'accept_incomplete_allocation', 'accept_incomplete_allocation',
'notes', 'notes',
] ]
@ -573,7 +573,7 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
help_text=_("Location for completed build outputs"), help_text=_("Location for completed build outputs"),
) )
status = serializers.ChoiceField( status_custom_key = serializers.ChoiceField(
choices=StockStatus.items(), choices=StockStatus.items(),
default=StockStatus.OK.value, default=StockStatus.OK.value,
label=_("Status"), label=_("Status"),
@ -621,8 +621,8 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
data = self.validated_data data = self.validated_data
location = data['location'] location = data.get('location', None)
status = data['status'] status = data.get('status_custom_key', StockStatus.OK.value)
notes = data.get('notes', '') notes = data.get('notes', '')
outputs = data.get('outputs', []) outputs = data.get('outputs', [])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,7 +18,13 @@ import { TableColumn, TableColumnProps } from './Column';
import { ProjectCodeHoverCard } from './TableHoverCard'; import { ProjectCodeHoverCard } from './TableHoverCard';
// Render a Part instance within a table // 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 ? ( return part ? (
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Thumbnail <Thumbnail

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -336,7 +336,10 @@ export default function PluginListTable() {
return [ 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`, title: t`Deactivate`,
color: 'red', color: 'red',
icon: <IconCircleX />, 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`, title: t`Activate`,
color: 'green', color: 'green',
icon: <IconCircleCheck />, icon: <IconCircleCheck />,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -81,3 +81,79 @@ test('PUI - Pages - Build Order', async ({ page }) => {
.getByText('Making a high level assembly') .getByText('Making a high level assembly')
.waitFor(); .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();
});