mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-29 03:56:43 +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:
parent
194640f55a
commit
4f06918c36
@ -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
|
||||
|
||||
|
@ -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', [])
|
||||
|
@ -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 || '');
|
||||
|
@ -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('');
|
||||
});
|
||||
} else {
|
||||
setSerialPlaceholder('');
|
||||
}
|
||||
}, [build, trackable]);
|
||||
const serialPlaceholder = useSerialNumberPlaceholder({
|
||||
partId: build.part_detail?.pk,
|
||||
key: 'build-output',
|
||||
enabled: build.part_detail?.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) {
|
||||
function BuildOutputFormRow({
|
||||
props,
|
||||
record
|
||||
}: Readonly<{
|
||||
props: TableFieldRowProps;
|
||||
record: any;
|
||||
}>) {
|
||||
const serial = useMemo(() => {
|
||||
if (record.serial) {
|
||||
return `# ${record.serial}`;
|
||||
} else {
|
||||
return t`Quantity` + `: ${record.quantity}`;
|
||||
}
|
||||
}, [record]);
|
||||
|
||||
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) => {
|
||||
if (record.serial) {
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
<>
|
||||
<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}
|
||||
|
@ -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
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
66
src/frontend/src/hooks/UsePlaceholder.tsx
Normal file
66
src/frontend/src/hooks/UsePlaceholder.tsx
Normal 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;
|
||||
}
|
@ -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
|
||||
},
|
||||
{
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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}
|
||||
|
@ -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({}),
|
||||
{
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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 />,
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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();
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user