diff --git a/src/frontend/lib/types/Forms.tsx b/src/frontend/lib/types/Forms.tsx index 43096ef968..cf9b6e43fd 100644 --- a/src/frontend/lib/types/Forms.tsx +++ b/src/frontend/lib/types/Forms.tsx @@ -57,6 +57,7 @@ export type ApiFormFieldHeader = { * @param preFieldContent : Content to render before the field * @param postFieldContent : Content to render after the field * @param autoFill: Whether to automatically fill the field with data from the API + * @param autoFillFilters: Optional filters to apply when auto-filling the field * @param onValueChange : Callback function to call when the field value changes * @param adjustFilters : Callback function to adjust the filters for a related field before a query is made * @param adjustValue : Callback function to adjust the value of the field before it is sent to the API @@ -106,6 +107,7 @@ export type ApiFormFieldType = { preFieldContent?: JSX.Element; postFieldContent?: JSX.Element; autoFill?: boolean; + autoFillFilters?: any; adjustValue?: (value: any) => any; onValueChange?: (value: any, record?: any) => void; adjustFilters?: (value: ApiFormAdjustFilterType) => any; diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 5b3622d35c..2034cff465 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -73,6 +73,7 @@ export function ApiFormField({ return { ...fieldDefinition, autoFill: undefined, + autoFillFilters: undefined, onValueChange: undefined, adjustFilters: undefined, adjustValue: undefined, diff --git a/src/frontend/src/components/forms/fields/RelatedModelField.tsx b/src/frontend/src/components/forms/fields/RelatedModelField.tsx index 791cda6c58..43f1d3456b 100644 --- a/src/frontend/src/components/forms/fields/RelatedModelField.tsx +++ b/src/frontend/src/components/forms/fields/RelatedModelField.tsx @@ -65,7 +65,11 @@ export function RelatedModelField({ return; } - const params = definition?.filters ?? {}; + // Construct parameters for auto-filling the field + const params = { + ...(definition?.filters ?? {}), + ...(definition?.autoFillFilters ?? {}) + }; api .get(definition.api_url, { diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index e0ccb48cd9..772841178f 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -1,7 +1,8 @@ import { t } from '@lingui/core/macro'; -import { Alert, List, Stack, Table } from '@mantine/core'; +import { Alert, Divider, List, Stack, Table } from '@mantine/core'; import { IconCalendar, + IconInfoCircle, IconLink, IconList, IconSitemap, @@ -24,6 +25,7 @@ import { type TableFieldRowProps } from '../components/forms/fields/TableField'; import { StatusRenderer } from '../components/render/StatusRenderer'; +import { RenderStockItem } from '../components/render/Stock'; import { useCreateApiFormModal } from '../hooks/UseForm'; import { useBatchCodeGenerator, @@ -473,10 +475,12 @@ export function useCancelBuildOutputsForm({ // Construct a single row in the 'allocate stock to build' table function BuildAllocateLineRow({ props, + output, record, sourceLocation }: Readonly<{ props: TableFieldRowProps; + output: any; record: any; sourceLocation: number | undefined; }>) { @@ -485,6 +489,10 @@ function BuildAllocateLineRow({ field_type: 'related field', api_url: apiUrl(ApiEndpoints.stock_item_list), model: ModelType.stockitem, + autoFill: !!output?.serial, + autoFillFilters: { + serial: output?.serial + }, filters: { available: true, part_detail: true, @@ -564,12 +572,14 @@ function BuildAllocateLineRow({ */ export function useAllocateStockToBuildForm({ buildId, + output, outputId, build, lineItems, onFormSuccess }: { buildId?: number; + output?: any; outputId?: number | null; build?: any; lineItems: any[]; @@ -598,6 +608,7 @@ export function useAllocateStockToBuildForm({ return ( { setSourceLocation(build?.take_from); @@ -633,10 +644,22 @@ export function useAllocateStockToBuildForm({ const preFormContent = useMemo(() => { return ( + {output?.pk && ( + + } + title={t`Build Output`} + > + + + + + )} ); - }, [sourceLocationField]); + }, [output, sourceLocationField]); return useCreateApiFormModal({ url: ApiEndpoints.build_order_allocate, diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index f104f3e2a4..faf0c17b8f 100644 --- a/src/frontend/src/tables/build/BuildLineTable.tsx +++ b/src/frontend/src/tables/build/BuildLineTable.tsx @@ -518,6 +518,7 @@ export default function BuildLineTable({ const allocateStock = useAllocateStockToBuildForm({ build: build, + output: output, outputId: output?.pk ?? null, buildId: build.pk, lineItems: selectedRows, diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx index defa22d8bc..9254bbb2e8 100644 --- a/src/frontend/src/tables/build/BuildOutputTable.tsx +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -160,7 +160,7 @@ export default function BuildOutputTable({ const buildStatus = useStatusCodes({ modelType: ModelType.build }); // Fetch the test templates associated with the partId - const { data: testTemplates } = useQuery({ + const { data: testTemplates, refetch: refetchTestTemplates } = useQuery({ queryKey: ['buildoutputtests', partId, build], queryFn: async () => { if (!partId || partId < 0) { @@ -291,7 +291,12 @@ export default function BuildOutputTable({ batch_code: build.batch, location: build.destination ?? build.part_detail?.default_location }, - table: table + onFormSuccess: () => { + // Refresh all associated table data + refetchTrackedItems(); + refetchTestTemplates(); + table.refreshTable(true); + } }); const [selectedOutputs, setSelectedOutputs] = useState([]); diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index c4c8fdf922..df7a400690 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -326,6 +326,87 @@ test('Build Order - Allocation', async ({ browser }) => { .waitFor(); }); +test('Build Order - Tracked Outputs', async ({ browser }) => { + const page = await doCachedLogin(browser, { + url: 'manufacturing/build-order/10/incomplete-outputs' + }); + + // Create a new build output, serial number 15 + await page + .getByRole('button', { name: 'action-button-add-build-output' }) + .click(); + await page.getByLabel('number-field-quantity').fill('1'); + await page.getByLabel('text-field-serial_numbers').fill('15'); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByText('Build output created').waitFor(); + + const cell = await page.getByRole('cell', { name: '# 15' }); + const row = await getRowFromCell(cell); + + // Open allocation menu for this output + await clickOnRowMenu(cell); + await page.getByRole('menuitem', { name: 'Allocate', exact: true }).click(); + + // Select a particular tracked item to allocate + const allocationCell = await page.getByRole('cell', { name: '002.01-PCBA' }); + const allocationRow = await getRowFromCell(allocationCell); + await clickOnRowMenu(allocationCell); + await page + .getByRole('menuitem', { name: 'Allocate Stock', exact: true }) + .click(); + + // Check for expected text + await page + .getByLabel('Build Output', { exact: true }) + .getByText('Serial Number: 15') + .waitFor(); + + // The stock item should be pre-filled based on serial number + await page.getByRole('button', { name: 'Submit' }).isEnabled(); + await page.getByRole('button', { name: 'Submit' }).click(); + + await allocationRow.getByText('1 / 1').waitFor(); + + // Close the allocation wizard + await page.getByRole('banner').getByRole('button').click(); + + // Check that the output is now allocated as expected + await row.getByText('1 / 6').waitFor(); + await row.getByText('0 / 2').waitFor(); + + // Cancel the build output to return to the original state + await clickOnRowMenu(cell); + await page.getByRole('menuitem', { name: 'Cancel' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByText('Build outputs have been cancelled').waitFor(); + + // Next, complete a new output and auto-allocate items based on serial number + await page + .getByRole('button', { name: 'action-button-add-build-output' }) + .click(); + await page.getByLabel('number-field-quantity').fill('1'); + await page.getByLabel('text-field-serial_numbers').fill('16'); + await page + .locator('label') + .filter({ hasText: 'Auto Allocate Serial' }) + .locator('div') + .first() + .click(); + await page.getByRole('button', { name: 'Submit' }).click(); + + const newCell = await page.getByRole('cell', { name: '# 16' }); + const newRow = await getRowFromCell(newCell); + + await newRow.getByText('1 / 6').waitFor(); + await newRow.getByText('0 / 2').waitFor(); + + // Cancel this output too + await clickOnRowMenu(newCell); + await page.getByRole('menuitem', { name: 'Cancel' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByText('Build outputs have been cancelled').waitFor(); +}); + test('Build Order - Filters', async ({ browser }) => { const page = await doCachedLogin(browser);