diff --git a/docs/docs/build/build.md b/docs/docs/build/build.md
index 92d5a8671a..7facbfbab5 100644
--- a/docs/docs/build/build.md
+++ b/docs/docs/build/build.md
@@ -49,6 +49,7 @@ The following parameters are available for each Build Order, and can be edited b
| Sales Order | Link to a *Sales Order* to which the build outputs will be allocated |
| Source Location | Stock location to source stock items from (blank = all locations) |
| Destination Location | Stock location where the build outputs will be located |
+| Start Date | The scheduled start date for the build |
| Target Date | Target date for build completion |
| Responsible | User (or group of users) who is responsible for the build |
| External Link | Link to external webpage |
@@ -262,12 +263,18 @@ The `Cancel Build` form will be displayed, click on the confirmation switch then
!!! warning "Cancelled Build"
**A cancelled build cannot be re-opened**. Make sure to use the cancel option only if you are certain that the build won't be processed.
-## Overdue Builds
+## Build Scheduling
-Build orders may (optionally) have a target complete date specified. If this date is reached but the build order remains incomplete, then the build is considered *overdue*.
+### Start Date
+
+Build orders can be optionally scheduled to *start* at a specified date. This may be useful for planning production schedules.
+
+### Overdue Builds
+
+Build orders may (optionally) have a target completion date specified. If this date is reached but the build order remains incomplete, then the build is considered *overdue*.
+
+This can be useful for tracking production delays, and can be used to generate reports on build order performance.
-- Builds can be filtered by overdue status in the build list
-- Overdue builds will be displayed on the home page
## Build Order Settings
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index b9d1ba6d65..ec19418475 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,13 +1,17 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 302
+INVENTREE_API_VERSION = 303
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v303 - 2025-01-20 - https://github.com/inventree/InvenTree/pull/8915
+ - Adds "start_date" field to Build model and API endpoints
+ - Adds additional API filtering and sorting options for Build list
+
v302 - 2025-01-18 - https://github.com/inventree/InvenTree/pull/8905
- Fix schema definition on the /label/print endpoint
diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py
index 9003100ade..455548effb 100644
--- a/src/backend/InvenTree/build/api.py
+++ b/src/backend/InvenTree/build/api.py
@@ -188,6 +188,30 @@ class BuildFilter(rest_filters.FilterSet):
label=_('Created after'), field_name='creation_date', lookup_expr='gt'
)
+ has_start_date = rest_filters.BooleanFilter(
+ label=_('Has start date'), method='filter_has_start_date'
+ )
+
+ def filter_has_start_date(self, queryset, name, value):
+ """Filter by whether or not the order has a start date."""
+ return queryset.filter(start_date__isnull=not str2bool(value))
+
+ start_date_before = InvenTreeDateFilter(
+ label=_('Start date before'), field_name='start_date', lookup_expr='lt'
+ )
+
+ start_date_after = InvenTreeDateFilter(
+ label=_('Start date after'), field_name='start_date', lookup_expr='gt'
+ )
+
+ has_target_date = rest_filters.BooleanFilter(
+ label=_('Has target date'), method='filter_has_target_date'
+ )
+
+ def filter_has_target_date(self, queryset, name, value):
+ """Filter by whether or not the order has a target date."""
+ return queryset.filter(target_date__isnull=not str2bool(value))
+
target_date_before = InvenTreeDateFilter(
label=_('Target date before'), field_name='target_date', lookup_expr='lt'
)
@@ -244,6 +268,7 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
'part__name',
'status',
'creation_date',
+ 'start_date',
'target_date',
'completion_date',
'quantity',
diff --git a/src/backend/InvenTree/build/migrations/0054_build_start_date.py b/src/backend/InvenTree/build/migrations/0054_build_start_date.py
new file mode 100644
index 0000000000..1ef87e634c
--- /dev/null
+++ b/src/backend/InvenTree/build/migrations/0054_build_start_date.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.18 on 2025-01-20 02:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('build', '0053_alter_build_part'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='build',
+ name='start_date',
+ field=models.DateField(blank=True, help_text='Scheduled start date for this build order', null=True, verbose_name='Build start date'),
+ ),
+ ]
diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py
index b00afa3093..f0916c1eb4 100644
--- a/src/backend/InvenTree/build/models.py
+++ b/src/backend/InvenTree/build/models.py
@@ -178,6 +178,12 @@ class Build(
if self.has_field_changed('part'):
raise ValidationError({'part': _('Build order part cannot be changed')})
+ # Target date should be *after* the start date
+ if self.start_date and self.target_date and self.start_date > self.target_date:
+ raise ValidationError({
+ 'target_date': _('Target date must be after start date')
+ })
+
def report_context(self) -> dict:
"""Generate custom report context data."""
return {
@@ -344,6 +350,13 @@ class Build(
auto_now_add=True, editable=False, verbose_name=_('Creation Date')
)
+ start_date = models.DateField(
+ null=True,
+ blank=True,
+ verbose_name=_('Build start date'),
+ help_text=_('Scheduled start date for this build order'),
+ )
+
target_date = models.DateField(
null=True,
blank=True,
diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py
index 2352bf3f54..a3844b0677 100644
--- a/src/backend/InvenTree/build/serializers.py
+++ b/src/backend/InvenTree/build/serializers.py
@@ -81,6 +81,7 @@ class BuildSerializer(
'reference',
'sales_order',
'quantity',
+ 'start_date',
'status',
'status_text',
'status_custom_key',
diff --git a/src/frontend/src/components/forms/fields/DateField.tsx b/src/frontend/src/components/forms/fields/DateField.tsx
index e0a9747c21..76c05409c2 100644
--- a/src/frontend/src/components/forms/fields/DateField.tsx
+++ b/src/frontend/src/components/forms/fields/DateField.tsx
@@ -22,8 +22,12 @@ export default function DateField({
fieldState: { error }
} = controller;
- const valueFormat =
- definition.field_type == 'date' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss';
+ const valueFormat = useMemo(() => {
+ // Determine the format based on the field type
+ return definition.field_type == 'date'
+ ? 'YYYY-MM-DD'
+ : 'YYYY-MM-DD HH:mm:ss';
+ }, [definition.field_type]);
const onChange = useCallback(
(value: any) => {
@@ -31,12 +35,13 @@ export default function DateField({
if (value) {
value = value.toString();
value = dayjs(value).format(valueFormat);
+ value = value.toString().split('T')[0];
}
field.onChange(value);
definition.onValueChange?.(value);
},
- [field.onChange, definition]
+ [field.onChange, definition, valueFormat]
);
const dateValue: Date | null = useMemo(() => {
@@ -62,7 +67,7 @@ export default function DateField({
ref={field.ref}
type={undefined}
error={definition.error ?? error?.message}
- value={dateValue ?? null}
+ value={dateValue}
clearable={!definition.required}
onChange={onChange}
valueFormat={valueFormat}
diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx
index 1f3c64c847..787cb2a16b 100644
--- a/src/frontend/src/forms/BuildForms.tsx
+++ b/src/frontend/src/forms/BuildForms.tsx
@@ -101,6 +101,9 @@ export function useBuildOrderFields({
value: batchCode,
onValueChange: (value: any) => setBatchCode(value)
},
+ start_date: {
+ icon:
+ },
target_date: {
icon:
},
diff --git a/src/frontend/src/functions/forms.tsx b/src/frontend/src/functions/forms.tsx
index c7c088b904..c4e20ea6e1 100644
--- a/src/frontend/src/functions/forms.tsx
+++ b/src/frontend/src/functions/forms.tsx
@@ -139,12 +139,6 @@ export function constructField({
};
switch (def.field_type) {
- case 'date':
- // Change value to a date object if required
- if (def.value) {
- def.value = new Date(def.value);
- }
- break;
case 'nested object':
def.children = {};
for (const k of Object.keys(field.children ?? {})) {
diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx
index c28090d6bd..da17487e09 100644
--- a/src/frontend/src/pages/build/BuildDetail.tsx
+++ b/src/frontend/src/pages/build/BuildDetail.tsx
@@ -181,21 +181,28 @@ export default function BuildDetail() {
hidden: !build.responsible
},
{
- type: 'text',
+ type: 'date',
name: 'creation_date',
label: t`Created`,
icon: 'calendar',
hidden: !build.creation_date
},
{
- type: 'text',
+ type: 'date',
+ name: 'start_date',
+ label: t`Start Date`,
+ icon: 'calendar',
+ hidden: !build.start_date
+ },
+ {
+ type: 'date',
name: 'target_date',
label: t`Target Date`,
icon: 'calendar',
hidden: !build.target_date
},
{
- type: 'text',
+ type: 'date',
name: 'completion_date',
label: t`Completed`,
icon: 'calendar',
diff --git a/src/frontend/src/tables/build/BuildOrderTable.tsx b/src/frontend/src/tables/build/BuildOrderTable.tsx
index b8c397ce06..faa96952ff 100644
--- a/src/frontend/src/tables/build/BuildOrderTable.tsx
+++ b/src/frontend/src/tables/build/BuildOrderTable.tsx
@@ -105,6 +105,11 @@ export function BuildOrderTable({
sortable: true
},
CreationDateColumn({}),
+ DateColumn({
+ accessor: 'start_date',
+ title: t`Start Date`,
+ sortable: true
+ }),
TargetDateColumn({}),
DateColumn({
accessor: 'completion_date',
@@ -138,6 +143,30 @@ export function BuildOrderTable({
CreatedAfterFilter(),
TargetDateBeforeFilter(),
TargetDateAfterFilter(),
+ {
+ name: 'start_date_before',
+ type: 'date',
+ label: t`Start Date Before`,
+ description: t`Show items with a start date before this date`
+ },
+ {
+ name: 'start_date_after',
+ type: 'date',
+ label: t`Start Date After`,
+ description: t`Show items with a start date after this date`
+ },
+ {
+ name: 'has_target_date',
+ type: 'boolean',
+ label: t`Has Target Date`,
+ description: t`Show orders with a target date`
+ },
+ {
+ name: 'has_start_date',
+ type: 'boolean',
+ label: t`Has Start Date`,
+ description: t`Show orders with a start date`
+ },
CompletedBeforeFilter(),
CompletedAfterFilter(),
ProjectCodeFilter({ choices: projectCodeFilters.choices }),
diff --git a/src/frontend/tests/helpers.ts b/src/frontend/tests/helpers.ts
index 36d421c865..970d7cbfd6 100644
--- a/src/frontend/tests/helpers.ts
+++ b/src/frontend/tests/helpers.ts
@@ -43,7 +43,11 @@ export const setTableChoiceFilter = async (page, filter, value) => {
await page.getByRole('button', { name: 'Add Filter' }).click();
await page.getByPlaceholder('Select filter').fill(filter);
await page.getByPlaceholder('Select filter').click();
- await page.getByRole('option', { name: filter }).click();
+
+ // Construct a regex to match the filter name exactly
+ const filterRegex = new RegExp(`^${filter}$`, 'i');
+
+ await page.getByRole('option', { name: filterRegex }).click();
await page.getByPlaceholder('Select filter value').click();
await page.getByRole('option', { name: value }).click();
diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts
index 61475b738f..7572e87a56 100644
--- a/src/frontend/tests/pages/pui_build.spec.ts
+++ b/src/frontend/tests/pages/pui_build.spec.ts
@@ -87,6 +87,33 @@ test('Build Order - Basic Tests', async ({ page }) => {
.waitFor();
});
+test('Build Order - Edit', async ({ page }) => {
+ await doQuickLogin(page);
+
+ await page.goto(`${baseUrl}/manufacturing/build-order/22/`);
+
+ // Check for expected text items
+ await page.getByText('Building for sales order').first().waitFor();
+ await page.getByText('2024-08-08').waitFor(); // Created date
+ await page.getByText('2025-01-01').waitFor(); // Start date
+ await page.getByText('2025-01-22').waitFor(); // Target date
+
+ await page.keyboard.press('Control+E');
+
+ // Edit start date
+ await page.getByLabel('date-field-start_date').fill('2026-09-09');
+
+ // Submit the form
+ await page.getByRole('button', { name: 'Submit' }).click();
+
+ // Expect error
+ await page.getByText('Errors exist for one or more form fields').waitFor();
+ await page.getByText('Target date must be after start date').waitFor();
+
+ // Cancel the form
+ await page.getByRole('button', { name: 'Cancel' }).click();
+});
+
test('Build Order - Build Outputs', async ({ page }) => {
await doQuickLogin(page);
diff --git a/src/frontend/tests/pui_tables.spec.ts b/src/frontend/tests/pui_tables.spec.ts
index 015023c443..601143fbb5 100644
--- a/src/frontend/tests/pui_tables.spec.ts
+++ b/src/frontend/tests/pui_tables.spec.ts
@@ -1,51 +1,38 @@
import { test } from './baseFixtures.js';
import { baseUrl } from './defaults.js';
-import {
- clearTableFilters,
- closeFilterDrawer,
- openFilterDrawer
-} from './helpers.js';
+import { clearTableFilters, setTableChoiceFilter } from './helpers.js';
import { doQuickLogin } from './login.js';
-// Helper function to set the value of a specific table filter
-const setFilter = async (page, name: string, value: string) => {
- await openFilterDrawer(page);
-
- await page.getByRole('button', { name: 'Add Filter' }).click();
- await page.getByPlaceholder('Select filter').click();
- await page.getByRole('option', { name: name, exact: true }).click();
- await page.getByPlaceholder('Select filter value').click();
- await page.getByRole('option', { name: value, exact: true }).click();
-
- await closeFilterDrawer(page);
-};
-
test('Tables - Filters', async ({ page }) => {
await doQuickLogin(page);
// Head to the "build order list" page
await page.goto(`${baseUrl}/manufacturing/index/`);
- await setFilter(page, 'Status', 'Complete');
- await setFilter(page, 'Responsible', 'allaccess');
- await setFilter(page, 'Project Code', 'PRJ-NIM');
+ await clearTableFilters(page);
+
+ await setTableChoiceFilter(page, 'Status', 'Complete');
+ await setTableChoiceFilter(page, 'Responsible', 'allaccess');
+ await setTableChoiceFilter(page, 'Project Code', 'PRJ-NIM');
await clearTableFilters(page);
// Head to the "part list" page
await page.goto(`${baseUrl}/part/category/index/parts/`);
- await setFilter(page, 'Assembly', 'Yes');
+ await setTableChoiceFilter(page, 'Assembly', 'Yes');
await clearTableFilters(page);
// Head to the "purchase order list" page
await page.goto(`${baseUrl}/purchasing/index/purchaseorders/`);
- await setFilter(page, 'Status', 'Complete');
- await setFilter(page, 'Responsible', 'readers');
- await setFilter(page, 'Assigned to me', 'No');
- await setFilter(page, 'Project Code', 'PRO-ZEN');
+ await clearTableFilters(page);
+
+ await setTableChoiceFilter(page, 'Status', 'Complete');
+ await setTableChoiceFilter(page, 'Responsible', 'readers');
+ await setTableChoiceFilter(page, 'Assigned to me', 'No');
+ await setTableChoiceFilter(page, 'Project Code', 'PRO-ZEN');
await clearTableFilters(page);
});