2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 12:05:53 +00:00

[WIP] Background reports (#9199)

* Update report generation progress

* Add shim task for offloading report printing

* Cleanup

* Add detail endpoints for label and report outputs

* Display report printing progress in UI

* Implement similar for label printing

* Reduce output for CI

* Add plugin slug

* Bump API version

* Ensure it works with machine printing

* Fix null comparison

* Fix SKU link

* Update playwright tests

* Massively reduce log output when printing
This commit is contained in:
Oliver
2025-03-04 23:40:54 +11:00
committed by GitHub
parent d5a176c121
commit d822b9b574
15 changed files with 407 additions and 130 deletions

View File

@ -1,10 +1,15 @@
import { t } from '@lingui/macro';
import { notifications } from '@mantine/notifications';
import { IconPrinter, IconReport, IconTags } from '@tabler/icons-react';
import { notifications, showNotification } from '@mantine/notifications';
import {
IconCircleCheck,
IconPrinter,
IconReport,
IconTags
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { api } from '../../App';
import { useApi } from '../../contexts/ApiContext';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import type { ModelType } from '../../enums/ModelType';
import { extractAvailableFields } from '../../functions/forms';
@ -17,6 +22,94 @@ import {
} from '../../states/SettingsState';
import type { ApiFormFieldSet } from '../forms/fields/ApiFormField';
import { ActionDropdown } from '../items/ActionDropdown';
import { ProgressBar } from '../items/ProgressBar';
/**
* Hook to track the progress of a printing operation
*/
function usePrintingProgress({
title,
outputId,
endpoint
}: {
title: string;
outputId?: number;
endpoint: ApiEndpoints;
}) {
const api = useApi();
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
if (!!outputId) {
setLoading(true);
showNotification({
id: `printing-progress-${endpoint}-${outputId}`,
title: title,
loading: true,
autoClose: false,
withCloseButton: false,
message: <ProgressBar size='lg' value={0} progressLabel />
});
} else {
setLoading(false);
}
}, [outputId, endpoint, title]);
const progress = useQuery({
enabled: !!outputId && loading,
refetchInterval: 750,
queryKey: ['printingProgress', endpoint, outputId],
queryFn: () =>
api
.get(apiUrl(endpoint, outputId))
.then((response) => {
const data = response?.data ?? {};
if (data.pk && data.pk == outputId) {
if (data.complete) {
setLoading(false);
notifications.hide(`printing-progress-${endpoint}-${outputId}`);
notifications.hide('print-success');
notifications.show({
id: 'print-success',
title: t`Printing`,
message: t`Printing completed successfully`,
color: 'green',
icon: <IconCircleCheck />
});
if (data.output) {
const url = generateUrl(data.output);
window.open(url.toString(), '_blank');
}
} else {
notifications.update({
id: `printing-progress-${endpoint}-${outputId}`,
autoClose: false,
withCloseButton: false,
message: (
<ProgressBar
size='lg'
value={data.progress}
maximum={data.items}
progressLabel
/>
)
});
}
}
return data;
})
.catch(() => {
notifications.hide(`printing-progress-${endpoint}-${outputId}`);
setLoading(false);
return {};
})
});
}
export function PrintingActions({
items,
@ -46,6 +139,21 @@ export function PrintingActions({
return enableReports && globalSettings.isSet('REPORT_ENABLE');
}, [enableReports, globalSettings]);
const [labelId, setLabelId] = useState<number | undefined>(undefined);
const [reportId, setReportId] = useState<number | undefined>(undefined);
const labelProgress = usePrintingProgress({
title: t`Printing Labels`,
outputId: labelId,
endpoint: ApiEndpoints.label_output
});
const reportProgress = usePrintingProgress({
title: t`Printing Reports`,
outputId: reportId,
endpoint: ApiEndpoints.report_output
});
// Fetch available printing fields via OPTIONS request
const printingFields = useQuery({
enabled: labelPrintingEnabled,
@ -106,36 +214,22 @@ export function PrintingActions({
url: apiUrl(ApiEndpoints.label_print),
title: t`Print Label`,
fields: labelFields,
timeout: (items.length + 1) * 5000,
timeout: 5000,
onClose: () => {
setPluginKey('');
},
submitText: t`Print`,
successMessage: t`Label printing completed successfully`,
successMessage: null,
onFormSuccess: (response: any) => {
setPluginKey('');
if (!response.complete) {
// TODO: Periodically check for completion (requires server-side changes)
notifications.show({
title: t`Error`,
message: t`The label could not be generated`,
color: 'red'
});
return;
}
if (response.output) {
// An output file was generated
const url = generateUrl(response.output);
window.open(url.toString(), '_blank');
}
setLabelId(response.pk);
}
});
const reportModal = useCreateApiFormModal({
title: t`Print Report`,
url: apiUrl(ApiEndpoints.report_print),
timeout: (items.length + 1) * 5000,
timeout: 5000,
fields: {
template: {
filters: {
@ -149,24 +243,10 @@ export function PrintingActions({
value: items
}
},
submitText: t`Generate`,
successMessage: t`Report printing completed successfully`,
submitText: t`Print`,
successMessage: null,
onFormSuccess: (response: any) => {
if (!response.complete) {
// TODO: Periodically check for completion (requires server-side changes)
notifications.show({
title: t`Error`,
message: t`The report could not be generated`,
color: 'red'
});
return;
}
if (response.output) {
// An output file was generated
const url = generateUrl(response.output);
window.open(url.toString(), '_blank');
}
setReportId(response.pk);
}
});

View File

@ -92,7 +92,7 @@ export interface ApiFormProps {
preFormWarning?: string;
preFormSuccess?: string;
postFormContent?: JSX.Element;
successMessage?: string;
successMessage?: string | null;
onFormSuccess?: (data: any) => void;
onFormError?: (response: any) => void;
processFormData?: (data: any) => any;

View File

@ -84,7 +84,10 @@ export function useCreateApiFormModal(props: ApiFormModalProps) {
() => ({
...props,
fetchInitialData: props.fetchInitialData ?? false,
successMessage: props.successMessage ?? t`Item Created`,
successMessage:
props.successMessage === null
? null
: (props.successMessage ?? t`Item Created`),
method: 'POST'
}),
[props]
@ -101,7 +104,10 @@ export function useEditApiFormModal(props: ApiFormModalProps) {
() => ({
...props,
fetchInitialData: props.fetchInitialData ?? true,
successMessage: props.successMessage ?? t`Item Updated`,
successMessage:
props.successMessage === null
? null
: (props.successMessage ?? t`Item Updated`),
method: 'PATCH'
}),
[props]
@ -120,7 +126,10 @@ export function useDeleteApiFormModal(props: ApiFormModalProps) {
method: 'DELETE',
submitText: t`Delete`,
submitColor: 'red',
successMessage: props.successMessage ?? t`Item Deleted`,
successMessage:
props.successMessage === null
? null
: (props.successMessage ?? t`Item Deleted`),
preFormContent: props.preFormContent ?? (
<Alert
color={'red'}

View File

@ -122,7 +122,7 @@ export default function SupplierPartDetail() {
}
];
const tr: DetailsField[] = [
const bl: DetailsField[] = [
{
type: 'link',
name: 'supplier',
@ -165,7 +165,7 @@ export default function SupplierPartDetail() {
}
];
const bl: DetailsField[] = [
const br: DetailsField[] = [
{
type: 'string',
name: 'packaging',
@ -183,7 +183,7 @@ export default function SupplierPartDetail() {
}
];
const br: DetailsField[] = [
const tr: DetailsField[] = [
{
type: 'string',
name: 'in_stock',
@ -232,9 +232,9 @@ export default function SupplierPartDetail() {
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<DetailsTable title={t`Supplier`} fields={tr} item={data} />
<DetailsTable title={t`Packaging`} fields={bl} item={data} />
<DetailsTable title={t`Availability`} fields={br} item={data} />
<DetailsTable title={t`Supplier`} fields={bl} item={data} />
<DetailsTable title={t`Packaging`} fields={br} item={data} />
<DetailsTable title={t`Availability`} fields={tr} item={data} />
</ItemDetailsGrid>
);
}, [supplierPart, instanceQuery.isFetching]);

View File

@ -212,6 +212,7 @@ export default function StockDetail() {
name: 'supplier_part',
label: t`Supplier Part`,
type: 'link',
model_field: 'SKU',
model: ModelType.supplierpart,
hidden: !stockitem.supplier_part
},

View File

@ -41,9 +41,7 @@ test('Label Printing', async ({ page }) => {
await page.getByRole('button', { name: 'Print', exact: true }).isEnabled();
await page.getByRole('button', { name: 'Print', exact: true }).click();
await page.locator('#form-success').waitFor();
await page.getByText('Label printing completed').waitFor();
await page.getByText('Printing completed successfully').first().waitFor();
await page.context().close();
});
@ -75,12 +73,10 @@ test('Report Printing', async ({ page }) => {
await page.waitForTimeout(100);
// Submit the print form (should result in success)
await page.getByRole('button', { name: 'Generate', exact: true }).isEnabled();
await page.getByRole('button', { name: 'Generate', exact: true }).click();
await page.locator('#form-success').waitFor();
await page.getByText('Report printing completed').waitFor();
await page.getByRole('button', { name: 'Print', exact: true }).isEnabled();
await page.getByRole('button', { name: 'Print', exact: true }).click();
await page.getByText('Printing completed successfully').first().waitFor();
await page.context().close();
});