Merge branch 'master' into block-notes

This commit is contained in:
Oliver 2026-06-08 18:49:03 +10:00 committed by GitHub
commit fb621c3bb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 67 additions and 182 deletions

View File

@ -1191,6 +1191,10 @@ class PurchaseOrder(TotalPriceMixin, Order):
for item in new_items:
item.set_status(status, custom_values=custom_stock_status_values)
# run validation for serialized items plugin.validate_batch_code
item.validate_batch_code()
# run validation for serialized items plugin.validate_model_instance
item.run_plugin_validation()
stock_items.append(item)
else:
@ -1213,6 +1217,13 @@ class PurchaseOrder(TotalPriceMixin, Order):
# Bulk create new stock items
if len(bulk_create_items) > 0:
# bulk_create() bypasses save()/clean() methods, so manual validation is required for each item
for item in bulk_create_items:
# run validation for items plugin.validate_batch_code
item.validate_batch_code()
# run validation for items plugin.validate_model_instance
item.run_plugin_validation()
stock.models.StockItem.objects.bulk_create(
bulk_create_items, batch_size=250
)

View File

@ -47,12 +47,14 @@ export default function OrderCalendar({
role,
params,
filters,
initialFilters,
tooltip
}: {
model: ModelType;
role: UserRoles;
params: Record<string, any>;
filters?: TableFilter[];
initialFilters?: TableFilter[];
tooltip?: (event: EventContentArg) => React.ReactNode;
}) {
const navigate = useNavigate();
@ -94,7 +96,8 @@ export default function OrderCalendar({
const calendarState = useCalendar({
endpoint: modelInfo.api_endpoint,
name: model.toString(),
queryParams: params
queryParams: params,
initialFilters: initialFilters
});
// Build the events

View File

@ -8,7 +8,6 @@ import ColorToggleDashboardWidget from './widgets/ColorToggleWidget';
import GetStartedWidget from './widgets/GetStartedWidget';
import LanguageSelectDashboardWidget from './widgets/LanguageSelectWidget';
import NewsWidget from './widgets/NewsWidget';
import OrderHistoryWidget from './widgets/OrderHistoryWidget';
import QueryCountDashboardWidget from './widgets/QueryCountDashboardWidget';
import QueryDashboardWidget from './widgets/QueryDashboardWidget';
import StocktakeDashboardWidget from './widgets/StocktakeDashboardWidget';
@ -218,26 +217,6 @@ function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
});
}
function BuiltinHistoryWidgets(): DashboardWidgetProps[] {
return [
OrderHistoryWidget({
modelType: ModelType.build
}),
OrderHistoryWidget({
modelType: ModelType.salesorder
}),
OrderHistoryWidget({
modelType: ModelType.purchaseorder
}),
OrderHistoryWidget({
modelType: ModelType.returnorder
}),
OrderHistoryWidget({
modelType: ModelType.transferorder
})
];
}
function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] {
return [
{
@ -276,7 +255,6 @@ function BuiltinActionWidgets(): DashboardWidgetProps[] {
export default function DashboardWidgetLibrary(): DashboardWidgetProps[] {
return [
...BuiltinQueryCountWidgets(),
...BuiltinHistoryWidgets(),
...BuiltinGettingStartedWidgets(),
...BuiltinSettingsWidgets(),
...BuiltinActionWidgets()

View File

@ -1,135 +0,0 @@
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import { StylishText } from '@lib/index';
import { t } from '@lingui/core/macro';
import { BarChart } from '@mantine/charts';
import { Box, LoadingOverlay, Stack } from '@mantine/core';
import { useDocumentVisibility } from '@mantine/hooks';
import { useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import { useApi } from '../../../contexts/ApiContext';
import { useUserState } from '../../../states/UserState';
import type { DashboardWidgetProps } from '../DashboardWidget';
function OrderHistoryComponent({
modelType,
title
}: {
modelType: ModelType;
title: string;
}) {
const modelInfo = useMemo(() => {
return ModelInformationDict[modelType];
}, [modelType]);
const url = useMemo(() => {
return apiUrl(modelInfo.api_endpoint);
}, [modelInfo]);
const api = useApi();
const visibility = useDocumentVisibility();
const endDate = dayjs().add(1, 'day').format('YYYY-MM-DD');
const startDate = dayjs()
.subtract(12, 'month')
.subtract(1, 'day')
.format('YYYY-MM-DD');
const params = {
completed_after: startDate,
completed_before: endDate
};
const query = useQuery({
queryKey: ['dashboard-order-summary', modelType, params, visibility],
enabled: visibility === 'visible',
refetchOnWindowFocus: true,
refetchOnMount: true,
refetchInterval: 10 * 60 * 1000, // 10 minute refetch interval
staleTime: 5 * 60 * 1000, // 5 minute stale time
queryFn: () => {
if (visibility !== 'visible') {
return [];
}
return api.get(url, { params }).then((res) => {
return res.data ?? [];
});
}
});
const chartData = useMemo(() => {
const months = Array.from({ length: 13 }, (_, i) => ({
month: dayjs()
.subtract(12 - i, 'month')
.format('MMM YY'),
count: 0
}));
for (const order of query.data || []) {
// Build orders use `completion_date`; all other order types use `complete_date`
const dateStr =
order.complete_date ?? order.completion_date ?? order.shipment_date;
if (!dateStr) continue;
const label = dayjs(dateStr).format('MMM YY');
const entry = months.find((m) => m.month === label);
if (entry) entry.count++;
}
return months;
}, [query.data]);
return (
<Stack gap='xs'>
<StylishText size='md'>{title}</StylishText>
<Box>
<LoadingOverlay visible={query.isLoading || query.isFetching} />
<BarChart
h={200}
data={chartData}
dataKey='month'
series={[{ name: 'count', label: t`Completed`, color: 'blue.6' }]}
withYAxis={false}
yAxisProps={{ domain: [0, 'auto'] }}
/>
</Box>
</Stack>
);
}
/**
* Display a simple chart of the number of completed orders per month, for the last 12 months.
*/
export default function OrderHistoryWidget({
modelType
}: {
modelType: ModelType;
}): DashboardWidgetProps {
const user = useUserState();
const modelInfo = useMemo(() => {
return ModelInformationDict[modelType];
}, [modelType]);
// Extract translated model labels
const models = modelInfo.label_multiple();
return {
label: `${modelType}-history`,
title: t`Completed ${models}`,
description: t`Display number of completed ${models} per month`,
minHeight: 2,
minWidth: 3,
modelType: modelType,
icon: 'chart_bar',
visible: () => user.hasViewPermission(modelType),
render: () => (
<OrderHistoryComponent
modelType={modelType}
title={t`Completed ${models}`}
/>
)
};
}

View File

@ -2,7 +2,7 @@ import type FullCalendar from '@fullcalendar/react';
import type { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import useFilterSet from '@lib/hooks/UseFilterSet';
import type { FilterSetState } from '@lib/types/Filters';
import type { FilterSetState, TableFilter } from '@lib/types/Filters';
import type { UseModalReturn } from '@lib/types/Modals';
import type { DateValue } from '@mantine/dates';
import { type UseQueryResult, useQuery } from '@tanstack/react-query';
@ -54,15 +54,17 @@ export type CalendarState = {
export default function useCalendar({
name,
endpoint,
queryParams
queryParams,
initialFilters
}: {
name: string;
endpoint: ApiEndpoints;
queryParams?: any;
initialFilters?: TableFilter[];
}): CalendarState {
const ref = useRef<FullCalendar>(null as any);
const filterSet = useFilterSet(`calendar-${name}`);
const filterSet = useFilterSet(`calendar-${name}`, initialFilters);
const [searchTerm, setSearchTerm] = useState<string>('');

View File

@ -48,8 +48,9 @@ function BuildOrderCalendar() {
<OrderCalendar
model={ModelType.build}
role={UserRoles.build}
params={{ outstanding: true, part_detail: true }}
params={{ part_detail: true }}
filters={calendarFilters}
initialFilters={[{ name: 'outstanding', value: 'true' }]}
tooltip={renderTooltip}
/>
);

View File

@ -51,8 +51,9 @@ function PurchaseOrderCalendar() {
<OrderCalendar
model={ModelType.purchaseorder}
role={UserRoles.purchase_order}
params={{ outstanding: true, supplier_detail: true }}
params={{ supplier_detail: true }}
filters={calendarFilters}
initialFilters={[{ name: 'outstanding', value: 'true' }]}
tooltip={renderTooltip}
/>
);

View File

@ -49,8 +49,9 @@ function SalesOrderCalendar() {
<OrderCalendar
model={ModelType.salesorder}
role={UserRoles.sales_order}
params={{ outstanding: true, customer_detail: true }}
params={{ customer_detail: true }}
filters={calendarFilters}
initialFilters={[{ name: 'outstanding', value: 'true' }]}
tooltip={renderTooltip}
/>
);
@ -73,8 +74,9 @@ const ReturnOrderCalendar = () => {
<OrderCalendar
model={ModelType.returnorder}
role={UserRoles.return_order}
params={{ outstanding: true, customer_detail: true }}
params={{ customer_detail: true }}
filters={calendarFilters}
initialFilters={[{ name: 'outstanding', value: 'true' }]}
tooltip={renderTooltip}
/>
);

View File

@ -70,8 +70,9 @@ function TransferOrderCalendar() {
<OrderCalendar
model={ModelType.transferorder}
role={UserRoles.transfer_order}
params={{ outstanding: true }}
params={{}}
filters={calendarFilters}
initialFilters={[{ name: 'outstanding', value: 'true' }]}
/>
);
}

View File

@ -1,3 +1,14 @@
import { ActionButton } from '@lib/components/ActionButton';
import { ProgressBar } from '@lib/components/ProgressBar';
import { RowEditAction, RowViewAction } from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { formatDecimal } from '@lib/functions/Formatting';
import useTable from '@lib/hooks/UseTable';
import type { TableFilter } from '@lib/types/Filters';
import type { RowAction, TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { Alert, Group, Paper, Text } from '@mantine/core';
import {
@ -13,18 +24,6 @@ import {
import type { DataTableRowExpansionProps } from 'mantine-datatable';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ActionButton } from '@lib/components/ActionButton';
import { ProgressBar } from '@lib/components/ProgressBar';
import { RowEditAction, RowViewAction } from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { formatDecimal } from '@lib/functions/Formatting';
import useTable from '@lib/hooks/UseTable';
import type { TableFilter } from '@lib/types/Filters';
import type { RowAction, TableColumn } from '@lib/types/Tables';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import {
useAllocateStockToBuildForm,
@ -769,7 +768,9 @@ export default function BuildLineTable({
!consumable &&
user.hasChangeRole(UserRoles.build) &&
required > 0 &&
record.trackable == hasOutput;
(hasOutput ? trackable : true);
const disableAllocation = !hasOutput && trackable;
// Can de-allocate
const canDeallocate =
@ -792,6 +793,10 @@ export default function BuildLineTable({
icon: <IconArrowRight />,
title: t`Allocate Stock`,
hidden: !canAllocate,
disabled: disableAllocation,
tooltip: disableAllocation
? t`Trackable parts must be allocated via the Build Outputs tab`
: t`Allocate Stock`,
color: 'green',
onClick: () => {
setSelectedRows([record]);

View File

@ -418,6 +418,18 @@ test('Build Order - Allocation', async ({ browser }) => {
await row.getByText(/150 \/ 150/).waitFor();
// Open the allocation menu for the red widget
const mainRedWidget = await page.getByRole('cell', { name: 'Red Widget' });
const mainRedRow = await getRowFromCell(mainRedWidget);
await mainRedRow.getByLabel(/row-action-menu-/i).click();
await page
.getByRole('menuitem', { name: 'Allocate Stock', exact: true })
.waitFor();
await page.keyboard.press('Escape');
// Expand this row
await cell.click();
await page.getByRole('cell', { name: '2022-4-27', exact: true }).waitFor();
@ -494,9 +506,13 @@ test('Build Order - Allocation', async ({ browser }) => {
const redRow = await getRowFromCell(redWidget);
await redRow.getByLabel(/row-action-menu-/i).click();
await page
.getByRole('menuitem', { name: 'Allocate Stock', exact: true })
.waitFor();
const allocateStockBtn = page.getByRole('menuitem', {
name: 'Allocate Stock',
exact: true
});
await expect(allocateStockBtn).toBeEnabled();
await page
.getByRole('menuitem', { name: 'Deallocate Stock', exact: true })
.waitFor();