From 2dc2b44b61603cc65e5acc889b457869ab98ec86 Mon Sep 17 00:00:00 2001 From: stipsitzm <159707412+stipsitzm@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:20:38 +0200 Subject: [PATCH 01/18] fix(gantt-chart): handle update validation errors and rollback state --- frontend/src/__tests__/GanttChart.test.tsx | 92 ++++++++++++++++++++++ frontend/src/pages/GanttChart.tsx | 17 +++- 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/GanttChart.test.tsx b/frontend/src/__tests__/GanttChart.test.tsx index 6807c381..a680cef2 100644 --- a/frontend/src/__tests__/GanttChart.test.tsx +++ b/frontend/src/__tests__/GanttChart.test.tsx @@ -42,12 +42,29 @@ vi.mock('react-modern-gantt', () => ({ default: (props: { tasks: Array<{ name: string; tasks: Array & { id: string; name: string }> }>; renderTooltip?: ({ task }: { task: Record }) => ReactNode; + onTaskUpdate?: (groupId: string, task: { id: string; startDate: Date }) => void | Promise; locale?: string; localeText?: Record; }) => { mocks.ganttProps(props); + const firstTask = props.tasks[0]?.tasks[0]; + const firstGroupName = props.tasks[0]?.name ?? ''; return (
+ {firstTask && props.onTaskUpdate ? ( + + ) : null} {props.tasks.map((group) => (
{group.name} @@ -317,4 +334,79 @@ describe('GanttChartPage', () => { fireEvent.mouseOver(editButton); expect(await screen.findByText('Bearbeitungsmodus: Anbaupläne können per Drag & Drop direkt im Kalender verschoben und angepasst werden.')).toBeInTheDocument(); }); + + it('shows backend validation errors and reloads plans after failed task update', async () => { + const initialPlan = { + id: 10, + culture: 5, + culture_name: 'Salat', + bed: 3, + planting_date: '2026-04-01', + harvest_date: '2026-05-01', + }; + const reloadedPlan = { + ...initialPlan, + planting_date: '2026-04-01', + }; + mocks.planList + .mockResolvedValueOnce({ data: { results: [initialPlan] } }) + .mockResolvedValueOnce({ data: { results: [reloadedPlan] } }); + mocks.cultureList.mockResolvedValue({ data: { results: [{ id: 5, name: 'Salat' }] } }); + mocks.planUpdate.mockRejectedValue({ + isAxiosError: true, + response: { + status: 400, + data: { + area_usage_sqm: ['Die Fläche dieses Beets wird im überlappenden Zeitraum überschritten.'], + }, + }, + }); + + render( + + + + + , + ); + + await screen.findByText('Feld / Beet 1'); + fireEvent.click(screen.getByTestId('mock-update-task')); + + expect(await screen.findByText('Fläche (m²): Die Fläche dieses Beets wird im überlappenden Zeitraum überschritten.')).toBeInTheDocument(); + await waitFor(() => expect(mocks.planList).toHaveBeenCalledTimes(2)); + }); + + it('keeps successful task updates working', async () => { + const initialPlan = { + id: 10, + culture: 5, + culture_name: 'Salat', + bed: 3, + planting_date: '2026-04-01', + harvest_date: '2026-05-01', + }; + mocks.planList.mockResolvedValue({ data: { results: [initialPlan] } }); + mocks.cultureList.mockResolvedValue({ data: { results: [{ id: 5, name: 'Salat' }] } }); + mocks.planUpdate.mockResolvedValue({ + data: { + ...initialPlan, + planting_date: '2026-04-05', + }, + }); + + render( + + + + + , + ); + + await screen.findByText('Feld / Beet 1'); + fireEvent.click(screen.getByTestId('mock-update-task')); + + await waitFor(() => expect(mocks.planUpdate).toHaveBeenCalledTimes(1)); + expect(screen.queryByText('Fehler beim Aktualisieren des Anbauplans')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/pages/GanttChart.tsx b/frontend/src/pages/GanttChart.tsx index 9cba56ed..27bfbad8 100644 --- a/frontend/src/pages/GanttChart.tsx +++ b/frontend/src/pages/GanttChart.tsx @@ -43,6 +43,7 @@ import PageHeader from '../components/layout/PageHeader'; import ProjectRequiredState from '../components/project/ProjectRequiredState'; import type { CommandSpec } from '../commands/types'; import { useProjectRequirement } from '../hooks/useProjectRequirement'; +import { extractApiErrorMessage } from '../api/errors'; import { buildFieldOccupancyTaskGroups, buildOccupancyTooltipDetails, @@ -124,6 +125,7 @@ function GanttChartPage(): React.ReactElement { const [plantingPlans, setPlantingPlans] = useState([]); const [cultures, setCultures] = useState([]); const [weeklyYield, setWeeklyYield] = useState([]); + const [ganttRenderKey, setGanttRenderKey] = useState(0); const [calendarMode, setCalendarMode] = useState('occupancy'); const [editMode, setEditMode] = useState(false); @@ -199,6 +201,11 @@ function GanttChartPage(): React.ReactElement { } }, [displayYear]); + const refreshPlantingPlans = useCallback(async (): Promise => { + const plansRes = await plantingPlanAPI.list(); + setPlantingPlans(plansRes.data.results); + }, []); + const handleTaskUpdate = async (_groupId: string, updatedTask: GanttTask) => { try { const planIdMatch = updatedTask.id.match(/^plan-(\d+)-/); @@ -240,11 +247,18 @@ function GanttChartPage(): React.ReactElement { setPlantingPlans((previous) => previous.map((entry) => ( entry.id === planId ? response.data : entry ))); + setError(null); await refreshWeeklyYield(); } catch (err) { console.error('Error updating planting plan:', err); - setError(t('ganttChart:errors.updatePlan')); + setError(extractApiErrorMessage(err, t, t('ganttChart:errors.updatePlan'))); + try { + await refreshPlantingPlans(); + } catch (refreshError) { + console.error('Error reloading planting plans after failed update:', refreshError); + } + setGanttRenderKey((value) => value + 1); } }; @@ -453,6 +467,7 @@ function GanttChartPage(): React.ReactElement { ) : ( {t('ganttChart:errors.render')}}> Date: Fri, 24 Apr 2026 15:49:16 +0200 Subject: [PATCH 02/18] fix(cultures): support decimal thousand kernel weight values --- .../migrations/0064_tkg_decimal_precision.py | 26 ++++++ backend/farm/models.py | 8 +- backend/farm/serializers.py | 30 +++++- .../farm/tests/test_culture_supplier_data.py | 61 ++++++++++++ frontend/src/__tests__/CultureDetail.test.tsx | 46 +++++++++ frontend/src/__tests__/CultureForm.test.tsx | 45 +++++++++ frontend/src/api/errors.ts | 1 + frontend/src/cultures/CultureDetail.tsx | 11 +-- frontend/src/cultures/CultureForm.tsx | 93 +++++++++++++++++-- frontend/src/i18n/locales/de/common.json | 3 +- frontend/src/i18n/locales/de/cultures.json | 1 + frontend/src/pages/culturesSaveUtils.ts | 20 +++- 12 files changed, 326 insertions(+), 19 deletions(-) create mode 100644 backend/farm/migrations/0064_tkg_decimal_precision.py diff --git a/backend/farm/migrations/0064_tkg_decimal_precision.py b/backend/farm/migrations/0064_tkg_decimal_precision.py new file mode 100644 index 00000000..98faaa81 --- /dev/null +++ b/backend/farm/migrations/0064_tkg_decimal_precision.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('farm', '0063_location_agronomic_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='culture', + name='thousand_kernel_weight_g', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, help_text='Weight of 1000 kernels in grams'), + ), + migrations.AlterField( + model_name='culturesupplierdata', + name='thousand_kernel_weight_g', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True), + ), + migrations.AlterField( + model_name='publicculture', + name='thousand_kernel_weight_g', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True), + ), + ] diff --git a/backend/farm/models.py b/backend/farm/models.py index bf66d60f..ef45e420 100644 --- a/backend/farm/models.py +++ b/backend/farm/models.py @@ -820,7 +820,9 @@ class Culture(TimestampedModel): blank=True, help_text="Safety margin for pre-cultivation/transplanting in percent (0-100)" ) - thousand_kernel_weight_g = models.FloatField( + thousand_kernel_weight_g = models.DecimalField( + max_digits=6, + decimal_places=2, null=True, blank=True, help_text="Weight of 1000 kernels in grams" @@ -1119,7 +1121,7 @@ class CultureSupplierData(TimestampedModel): supplier_product_name = models.CharField(max_length=255, blank=True) supplier_product_url = models.URLField(blank=True) packaging_sizes = models.JSONField(default=list, blank=True) - thousand_kernel_weight_g = models.FloatField(null=True, blank=True) + thousand_kernel_weight_g = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True) germination_rate = models.FloatField(null=True, blank=True) price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) notes = models.TextField(blank=True) @@ -1182,7 +1184,7 @@ class PublicCulture(TimestampedModel): seed_rate_unit = models.CharField(max_length=30, null=True, blank=True) seed_rate_by_cultivation = models.JSONField(null=True, blank=True) sowing_calculation_safety_percent = models.FloatField(null=True, blank=True) - thousand_kernel_weight_g = models.FloatField(null=True, blank=True) + thousand_kernel_weight_g = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True) seeding_requirement = models.FloatField(null=True, blank=True) seeding_requirement_type = models.CharField(max_length=30, blank=True) display_color = models.CharField(max_length=7, blank=True) diff --git a/backend/farm/serializers.py b/backend/farm/serializers.py index b7ff6562..776df895 100644 --- a/backend/farm/serializers.py +++ b/backend/farm/serializers.py @@ -82,6 +82,26 @@ def to_internal_value(self, data): return cm_value / 100.0 +class LocalizedDecimalField(serializers.DecimalField): + """Decimal field that accepts comma decimals and returns float JSON values.""" + + default_error_messages = { + 'invalid': 'Please enter a valid numeric value, e.g. 3.9.', + } + + def to_internal_value(self, data): + normalized = data + if isinstance(data, str): + normalized = data.strip().replace(',', '.') + return super().to_internal_value(normalized) + + def to_representation(self, value): + decimal_value = super().to_representation(value) + if decimal_value is None: + return None + return float(decimal_value) + + class LocationSerializer(serializers.ModelSerializer): @staticmethod @@ -397,6 +417,12 @@ class CultureSupplierDataSerializer(serializers.ModelSerializer): allow_null=True, ) supplier_name_input = serializers.CharField(write_only=True, required=False, allow_blank=True, allow_null=True) + thousand_kernel_weight_g = LocalizedDecimalField( + max_digits=6, + decimal_places=2, + required=False, + allow_null=True, + ) class Meta: model = CultureSupplierData @@ -613,7 +639,9 @@ class CultureSerializer(serializers.ModelSerializer): seed_rate_pre_cultivation_value = serializers.FloatField(required=False, allow_null=True) seed_rate_pre_cultivation_unit = serializers.CharField(required=False, allow_blank=True, allow_null=True) sowing_calculation_safety_percent_pre_cultivation = serializers.FloatField(required=False, allow_null=True) - thousand_kernel_weight_g = serializers.FloatField( + thousand_kernel_weight_g = LocalizedDecimalField( + max_digits=6, + decimal_places=2, required=False, allow_null=True, help_text='Weight of 1000 kernels in grams' diff --git a/backend/farm/tests/test_culture_supplier_data.py b/backend/farm/tests/test_culture_supplier_data.py index a49efb6a..4dddf60e 100644 --- a/backend/farm/tests/test_culture_supplier_data.py +++ b/backend/farm/tests/test_culture_supplier_data.py @@ -1,4 +1,5 @@ from django.contrib.auth import get_user_model +from decimal import Decimal from rest_framework import status from rest_framework.test import APITestCase @@ -57,3 +58,63 @@ def test_culture_detail_includes_supplier_data(self): response.data['supplier_data'][0]['packaging_sizes'], [{'size_value': 5, 'size_unit': 'g'}, {'size_value': 25, 'size_unit': 'g'}], ) + + def test_supplier_tkg_accepts_decimal_values_and_comma_inputs(self): + payloads = [ + 4, + 3.9, + '3,9', + 3.85, + ] + + for index, tkg_value in enumerate(payloads): + payload = { + 'culture': self.culture.id, + 'supplier_id': self.supplier.id, + 'supplier_product_name': f'Nantaise fein {index}', + 'packaging_sizes': [{'size_value': 25, 'size_unit': 'g'}], + 'thousand_kernel_weight_g': tkg_value, + } + if index == 0: + response = self.client.post('/openfarmplanner/api/culture-supplier-data/', payload, format='json') + else: + existing = CultureSupplierData.objects.get(culture=self.culture, supplier=self.supplier) + response = self.client.patch(f'/openfarmplanner/api/culture-supplier-data/{existing.id}/', payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK if index > 0 else status.HTTP_201_CREATED) + expected_decimal = Decimal(str(tkg_value).replace(',', '.')) + self.assertEqual(Decimal(str(response.data['thousand_kernel_weight_g'])), expected_decimal) + + stored = CultureSupplierData.objects.get(culture=self.culture, supplier=self.supplier) + self.assertEqual(stored.thousand_kernel_weight_g, Decimal('3.85')) + + def test_supplier_tkg_accepts_empty_value_when_optional(self): + payload = { + 'culture': self.culture.id, + 'supplier_id': self.supplier.id, + 'supplier_product_name': 'Nantaise fein', + 'packaging_sizes': [{'size_value': 25, 'size_unit': 'g'}], + 'thousand_kernel_weight_g': '', + } + + response = self.client.post('/openfarmplanner/api/culture-supplier-data/', payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIsNone(response.data['thousand_kernel_weight_g']) + + def test_supplier_tkg_rejects_non_numeric_values(self): + payload = { + 'culture': self.culture.id, + 'supplier_id': self.supplier.id, + 'supplier_product_name': 'Nantaise fein', + 'packaging_sizes': [{'size_value': 25, 'size_unit': 'g'}], + 'thousand_kernel_weight_g': 'abc', + } + + response = self.client.post('/openfarmplanner/api/culture-supplier-data/', payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data['thousand_kernel_weight_g'][0], + 'Please enter a valid numeric value, e.g. 3.9.', + ) diff --git a/frontend/src/__tests__/CultureDetail.test.tsx b/frontend/src/__tests__/CultureDetail.test.tsx index 13fa752d..3c948f1c 100644 --- a/frontend/src/__tests__/CultureDetail.test.tsx +++ b/frontend/src/__tests__/CultureDetail.test.tsx @@ -235,6 +235,52 @@ describe('CultureDetail Component', () => { expect(screen.getByText('4 g')).toBeInTheDocument(); }); + it('formats supplier TKG values in German number style', () => { + const mockOnSelect = vi.fn(); + const culturesWithDecimalTkg: Culture[] = [ + { + id: 17, + name: 'Dill', + supplier_data: [ + { + supplier_name: 'ReinSaat', + thousand_kernel_weight_g: 3.9, + packaging_sizes: [{ size_value: 25, size_unit: 'g' }], + }, + ], + }, + { + id: 18, + name: 'Koriander', + supplier_data: [ + { + supplier_name: 'ReinSaat', + thousand_kernel_weight_g: 3.85, + packaging_sizes: [{ size_value: 25, size_unit: 'g' }], + }, + ], + }, + ]; + + const { rerender } = render( + + ); + expect(screen.getByText('3,9 g')).toBeInTheDocument(); + + rerender( + + ); + expect(screen.getByText('3,85 g')).toBeInTheDocument(); + }); + it('renders no-data state when supplier package sizes are empty or invalid', () => { const mockOnSelect = vi.fn(); const culturesWithEmptySupplierPackages: Culture[] = [ diff --git a/frontend/src/__tests__/CultureForm.test.tsx b/frontend/src/__tests__/CultureForm.test.tsx index 558334d4..00252975 100644 --- a/frontend/src/__tests__/CultureForm.test.tsx +++ b/frontend/src/__tests__/CultureForm.test.tsx @@ -284,4 +284,49 @@ describe('CultureForm', () => { expect(scrollByMock).toHaveBeenCalled(); }); + + it('normalizes comma decimals for supplier TKG before save', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + supplierListMock.mockResolvedValueOnce({ data: { results: [{ id: 10, name: 'Bingenheimer' }] } }); + + render( + {}} + /> + ); + + const tkgInput = await screen.findByLabelText('form.thousandKernelWeightLabel'); + fireEvent.change(tkgInput, { target: { value: '3,9' } }); + fireEvent.blur(tkgInput); + fireEvent.click(screen.getByRole('button', { name: 'form.save' })); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + supplier_data: [expect.objectContaining({ thousand_kernel_weight_g: 3.9 })], + })); + }); + + it('shows invalid supplier TKG message and blocks save for non-numeric input', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + supplierListMock.mockResolvedValueOnce({ data: { results: [{ id: 10, name: 'Bingenheimer' }] } }); + + render( + {}} + /> + ); + + const tkgInput = await screen.findByLabelText('form.thousandKernelWeightLabel'); + fireEvent.change(tkgInput, { target: { value: 'abc' } }); + fireEvent.blur(tkgInput); + + expect(await screen.findByText('form.thousandKernelWeightInvalidNumber')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'form.save' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'form.save' })); + expect(onSave).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/api/errors.ts b/frontend/src/api/errors.ts index db853241..ebddc352 100644 --- a/frontend/src/api/errors.ts +++ b/frontend/src/api/errors.ts @@ -41,6 +41,7 @@ const backendMessageMap: Record = { 'bed not found.': 'errors.bedNotFound', 'uploaded file exceeds the 10mb size limit.': 'errors.fileTooLarge', 'uploaded file is not a valid image.': 'errors.invalidImage', + 'please enter a valid numeric value, e.g. 3.9.': 'validation.invalidNumberExample', }; function localizeBackendMessage(message: string, t: TFunction): string { diff --git a/frontend/src/cultures/CultureDetail.tsx b/frontend/src/cultures/CultureDetail.tsx index cd3ce828..174e75ad 100644 --- a/frontend/src/cultures/CultureDetail.tsx +++ b/frontend/src/cultures/CultureDetail.tsx @@ -78,13 +78,10 @@ function formatNumber(value: number | null | undefined, t: (key: string) => stri // Round to 2 decimal places to avoid floating point precision issues const rounded = Math.round(value * 100) / 100; - - // If the result is a whole number, return as integer - if (rounded === Math.floor(rounded)) { - return rounded.toString(); - } - - return rounded.toString(); + return new Intl.NumberFormat('de-DE', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(rounded); } /** diff --git a/frontend/src/cultures/CultureForm.tsx b/frontend/src/cultures/CultureForm.tsx index 161328fa..e70fb11e 100644 --- a/frontend/src/cultures/CultureForm.tsx +++ b/frontend/src/cultures/CultureForm.tsx @@ -33,6 +33,7 @@ import DeleteIcon from '@mui/icons-material/Delete'; import { supplierAPI } from '../api/api'; import { useNavigate } from 'react-router-dom'; import { validateCulture } from './validation'; +import { formatLocalizedNumber, parseLocalizedNumber, resolveLocaleFromLanguage } from '../utils/numberLocalization'; import { BasicInfoSection } from './sections/BasicInfoSection'; import { TimingSection } from './sections/TimingSection'; import { HarvestSection } from './sections/HarvestSection'; @@ -128,7 +129,7 @@ export function CultureForm({ onSave, onCancel, }: CultureFormProps): React.ReactElement { - const { t } = useTranslation('cultures'); + const { t, i18n } = useTranslation('cultures'); const navigate = useNavigate(); const isEdit = Boolean(culture); const [saveError, setSaveError] = useState(''); @@ -151,8 +152,11 @@ export function CultureForm({ const [supplierOptions, setSupplierOptions] = useState([]); const [isDirty, setIsDirty] = useState(false); const [isValid, setIsValid] = useState(true); + const [supplierTkgRawValues, setSupplierTkgRawValues] = useState>({}); + const [supplierTkgErrors, setSupplierTkgErrors] = useState>({}); const dialogContentRef = useRef(null); const supplierOptionsRef = useRef([]); + const locale = resolveLocaleFromLanguage(i18n?.resolvedLanguage || i18n?.language || 'de'); const loadSuppliers = useCallback(async () => { try { @@ -204,6 +208,8 @@ export function CultureForm({ setIsDirty(false); setIsValid(true); setSaveError(''); + setSupplierTkgRawValues({}); + setSupplierTkgErrors({}); }, [culture]); useEffect(() => { @@ -253,6 +259,74 @@ export function CultureForm({ }; const supplierRows = formData.supplier_data ?? []; + const formatSupplierTkg = (value: number | null | undefined): string => { + if (value === null || value === undefined || !Number.isFinite(value)) { + return ''; + } + return formatLocalizedNumber(value, locale, { + maximumFractionDigits: 2, + minimumFractionDigits: 0, + }); + }; + + const handleSupplierTkgChange = (index: number, rawValue: string) => { + setSupplierTkgRawValues((previous) => ({ ...previous, [index]: rawValue })); + setSupplierTkgErrors((previous) => { + if (!(index in previous)) { + return previous; + } + const next = { ...previous }; + delete next[index]; + return next; + }); + setIsDirty(true); + }; + + const handleSupplierTkgBlur = (index: number, rawValue: string) => { + const trimmed = rawValue.trim(); + if (!trimmed) { + updateSupplierRow(index, { thousand_kernel_weight_g: null }); + setSupplierTkgRawValues((previous) => ({ ...previous, [index]: '' })); + setSupplierTkgErrors((previous) => { + if (!(index in previous)) { + return previous; + } + const next = { ...previous }; + delete next[index]; + return next; + }); + return; + } + + const parsed = parseLocalizedNumber(trimmed, locale); + if (parsed === null) { + setSupplierTkgErrors((previous) => ({ + ...previous, + [index]: t('form.thousandKernelWeightInvalidNumber'), + })); + return; + } + + if (parsed <= 0) { + setSupplierTkgErrors((previous) => ({ + ...previous, + [index]: t('form.thousandKernelWeightError'), + })); + return; + } + + updateSupplierRow(index, { thousand_kernel_weight_g: parsed }); + setSupplierTkgRawValues((previous) => ({ ...previous, [index]: formatSupplierTkg(parsed) })); + setSupplierTkgErrors((previous) => { + if (!(index in previous)) { + return previous; + } + const next = { ...previous }; + delete next[index]; + return next; + }); + }; + const updateSupplierRow = (index: number, patch: Record) => { const nextRows = supplierRows.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row)); handleChange('supplier_data', nextRows); @@ -262,6 +336,8 @@ export function CultureForm({ }; const removeSupplierRow = (index: number) => { handleChange('supplier_data', supplierRows.filter((_row, rowIndex) => rowIndex !== index)); + setSupplierTkgRawValues({}); + setSupplierTkgErrors({}); }; const addPackageRow = (supplierIndex: number) => { const currentPackages = supplierRows[supplierIndex]?.packaging_sizes ?? []; @@ -276,6 +352,7 @@ export function CultureForm({ const currentPackages = supplierRows[supplierIndex]?.packaging_sizes ?? []; updateSupplierRow(supplierIndex, { packaging_sizes: currentPackages.filter((_pkg, index) => index !== packageIndex) }); }; + const hasSupplierTkgErrors = Object.keys(supplierTkgErrors).length > 0; const handleDialogContentScrollKey = (event: { key: string; altKey: boolean; ctrlKey: boolean; metaKey: boolean; preventDefault: () => void }, contentElement: HTMLDivElement) => { if (event.altKey || event.ctrlKey || event.metaKey) { @@ -446,9 +523,13 @@ export function CultureForm({ /> updateSupplierRow(supplierIndex, { thousand_kernel_weight_g: event.target.value ? Number(event.target.value) : null })} + type="text" + inputMode="decimal" + value={supplierTkgRawValues[supplierIndex] ?? formatSupplierTkg(row.thousand_kernel_weight_g)} + onChange={(event) => handleSupplierTkgChange(supplierIndex, event.target.value)} + onBlur={(event) => handleSupplierTkgBlur(supplierIndex, event.target.value)} + error={Boolean(supplierTkgErrors[supplierIndex])} + helperText={supplierTkgErrors[supplierIndex]} fullWidth /> {t('form.seedPackagesLabel')} @@ -490,7 +571,7 @@ export function CultureForm({ ) : null} {isDirty && ( - {isValid + {isValid && !hasSupplierTkgErrors ? t('messages.unsavedChanges', { defaultValue: 'Ungespeicherte Änderungen' }) : t('messages.fixErrors', { defaultValue: 'Bitte beheben Sie die Validierungsfehler' })} @@ -501,7 +582,7 @@ export function CultureForm({