@open-mercato/core 0.7.1-develop.7200.1.8ba2d93d3f → 0.7.1-develop.7202.1.a52dc6707c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/catalog/components/products/ProductUomSection.js +27 -22
  3. package/dist/modules/catalog/components/products/ProductUomSection.js.map +2 -2
  4. package/dist/modules/customers/components/detail/ActivitiesCard.js +1 -14
  5. package/dist/modules/customers/components/detail/ActivitiesCard.js.map +2 -2
  6. package/dist/modules/customers/components/detail/ActivitiesDayStrip.js +1 -14
  7. package/dist/modules/customers/components/detail/ActivitiesDayStrip.js.map +2 -2
  8. package/dist/modules/customers/components/detail/MiniWeekCalendar.js +1 -3
  9. package/dist/modules/customers/components/detail/MiniWeekCalendar.js.map +2 -2
  10. package/dist/modules/customers/components/detail/PlannedActivitiesSection.js +2 -1
  11. package/dist/modules/customers/components/detail/PlannedActivitiesSection.js.map +2 -2
  12. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js +2 -1
  13. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js.map +2 -2
  14. package/dist/modules/customers/lib/localDay.js +20 -0
  15. package/dist/modules/customers/lib/localDay.js.map +7 -0
  16. package/dist/modules/sales/components/documents/PaymentDialog.js +14 -11
  17. package/dist/modules/sales/components/documents/PaymentDialog.js.map +2 -2
  18. package/dist/modules/wms/components/backend/AdjustInventoryDialog.js +8 -6
  19. package/dist/modules/wms/components/backend/AdjustInventoryDialog.js.map +2 -2
  20. package/package.json +7 -7
  21. package/src/modules/catalog/components/products/ProductUomSection.tsx +31 -21
  22. package/src/modules/customers/components/detail/ActivitiesCard.tsx +1 -19
  23. package/src/modules/customers/components/detail/ActivitiesDayStrip.tsx +1 -22
  24. package/src/modules/customers/components/detail/MiniWeekCalendar.tsx +1 -4
  25. package/src/modules/customers/components/detail/PlannedActivitiesSection.tsx +2 -1
  26. package/src/modules/customers/components/detail/schedule/DateTimeFields.tsx +2 -1
  27. package/src/modules/customers/lib/localDay.ts +36 -0
  28. package/src/modules/sales/components/documents/PaymentDialog.tsx +18 -11
  29. package/src/modules/sales/i18n/de.json +1 -0
  30. package/src/modules/sales/i18n/en.json +1 -0
  31. package/src/modules/sales/i18n/es.json +1 -0
  32. package/src/modules/sales/i18n/ko.json +1 -0
  33. package/src/modules/sales/i18n/pl.json +1 -0
  34. package/src/modules/wms/components/backend/AdjustInventoryDialog.tsx +6 -5
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { useT } from "@open-mercato/shared/lib/i18n/context";
4
+ import { useT, useLocale } from "@open-mercato/shared/lib/i18n/context";
5
+ import { parseLocaleNumber } from "@open-mercato/shared/lib/number";
5
6
  import { apiCall } from "@open-mercato/ui/backend/utils/apiCall";
6
7
  import { Button } from "@open-mercato/ui/primitives/button";
7
8
  import { Checkbox } from "@open-mercato/ui/primitives/checkbox";
@@ -56,19 +57,24 @@ const REFERENCE_UNIT_OPTIONS = REFERENCE_UNIT_CODES.map((code) => ({
56
57
  fallback: REFERENCE_UNIT_DISPLAY[code] ?? code,
57
58
  }));
58
59
 
59
- function normalizeDecimalInput(value: string): string {
60
- return value.replace(/,/g, ".");
60
+ // Keeps the field submit-ready (dot-decimal, no grouping characters) while the user is
61
+ // still typing. Falls back to the raw value when it isn't parseable yet (empty, a lone
62
+ // sign) so the field stays editable. Delegates to `parseLocaleNumber` instead of a
63
+ // hard-coded `,` -> `.` swap, which broke on grouped input such as `1 234,56` (#5828).
64
+ export function normalizeDecimalInput(value: string, locale?: string): string {
65
+ const parsed = parseLocaleNumber(value, locale);
66
+ return parsed === null ? value : String(parsed);
61
67
  }
62
68
 
63
- function toPositiveNumber(value: unknown): number | null {
69
+ export function toPositiveNumber(value: unknown, locale?: string): number | null {
64
70
  if (typeof value === "number") {
65
71
  return Number.isFinite(value) && value > 0 ? value : null;
66
72
  }
67
73
  if (typeof value !== "string") return null;
68
74
  const normalized = toTrimmedOrNull(value);
69
75
  if (!normalized) return null;
70
- const numeric = Number(normalized.replace(",", "."));
71
- return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
76
+ const numeric = parseLocaleNumber(normalized, locale);
77
+ return numeric !== null && numeric > 0 ? numeric : null;
72
78
  }
73
79
 
74
80
  function toSortValue(value: string): number {
@@ -82,7 +88,10 @@ function formatPreviewNumber(value: number): string {
82
88
  return Number.isInteger(rounded) ? String(rounded) : rounded.toString();
83
89
  }
84
90
 
85
- function normalizeConversions(value: unknown): ProductUnitConversionDraft[] {
91
+ function normalizeConversions(
92
+ value: unknown,
93
+ locale?: string,
94
+ ): ProductUnitConversionDraft[] {
86
95
  if (!Array.isArray(value)) return [];
87
96
  const normalized = value
88
97
  .map((entry) => {
@@ -92,7 +101,7 @@ function normalizeConversions(value: unknown): ProductUnitConversionDraft[] {
92
101
  id: toTrimmedOrNull(row.id) ?? null,
93
102
  unitCode: toTrimmedOrNull(row.unitCode) ?? "",
94
103
  toBaseFactor: toTrimmedOrNull(row.toBaseFactor)
95
- ? normalizeDecimalInput(toTrimmedOrNull(row.toBaseFactor) as string)
104
+ ? normalizeDecimalInput(toTrimmedOrNull(row.toBaseFactor) as string, locale)
96
105
  : "",
97
106
  sortOrder: toTrimmedOrNull(row.sortOrder) ?? "",
98
107
  isActive: row.isActive !== false,
@@ -135,13 +144,14 @@ export function ProductUomSection({
135
144
  embedded = false,
136
145
  }: ProductUomSectionProps) {
137
146
  const t = useT();
147
+ const locale = useLocale();
138
148
  const { enabled: unitPriceDisplayEnabled } = useUnitPriceDisplayEnabled();
139
149
  const [unitOptions, setUnitOptions] = React.useState<UnitOption[]>([]);
140
150
  const [loadingUnits, setLoadingUnits] = React.useState(false);
141
151
  const [errorLoadingUnits, setErrorLoadingUnits] = React.useState(false);
142
152
  const conversions = React.useMemo(
143
- () => normalizeConversions(values.unitConversions),
144
- [values.unitConversions],
153
+ () => normalizeConversions(values.unitConversions, locale),
154
+ [values.unitConversions, locale],
145
155
  );
146
156
 
147
157
  React.useEffect(() => {
@@ -238,13 +248,13 @@ export function ProductUomSection({
238
248
  const defaultSalesUnit = toTrimmedOrNull(values.defaultSalesUnit) ?? "";
239
249
  const defaultSalesQuantityRaw =
240
250
  toTrimmedOrNull(values.defaultSalesUnitQuantity) ?? "1";
241
- const defaultSalesQuantity = normalizeDecimalInput(defaultSalesQuantityRaw);
251
+ const defaultSalesQuantity = normalizeDecimalInput(defaultSalesQuantityRaw, locale);
242
252
  const unitPriceEnabled = Boolean(values.unitPriceEnabled);
243
253
  const unitPriceReferenceUnit =
244
254
  toTrimmedOrNull(values.unitPriceReferenceUnit) ?? "";
245
255
  const unitPriceBaseQuantityRaw =
246
256
  toTrimmedOrNull(values.unitPriceBaseQuantity) ?? "";
247
- const unitPriceBaseQuantity = normalizeDecimalInput(unitPriceBaseQuantityRaw);
257
+ const unitPriceBaseQuantity = normalizeDecimalInput(unitPriceBaseQuantityRaw, locale);
248
258
 
249
259
  const baseUnitLabel = findUnitLabel(defaultUnit) ?? defaultUnit;
250
260
  const salesUnitLabel =
@@ -261,17 +271,17 @@ export function ProductUomSection({
261
271
  (entry) =>
262
272
  entry.isActive &&
263
273
  entry.unitCode.toLowerCase() === defaultSalesKey &&
264
- toPositiveNumber(entry.toBaseFactor) !== null,
274
+ toPositiveNumber(entry.toBaseFactor, locale) !== null,
265
275
  );
266
- return row ? toPositiveNumber(row.toBaseFactor) : null;
267
- }, [conversions, defaultSalesUnit, defaultUnit]);
276
+ return row ? toPositiveNumber(row.toBaseFactor, locale) : null;
277
+ }, [conversions, defaultSalesUnit, defaultUnit, locale]);
268
278
 
269
- const defaultSalesQuantityNumber = toPositiveNumber(defaultSalesQuantity);
279
+ const defaultSalesQuantityNumber = toPositiveNumber(defaultSalesQuantity, locale);
270
280
  const defaultSalesQuantityNormalized =
271
281
  defaultSalesQuantityNumber && defaultSalesFactor
272
282
  ? defaultSalesQuantityNumber * defaultSalesFactor
273
283
  : null;
274
- const unitPriceBaseQuantityNumber = toPositiveNumber(unitPriceBaseQuantity);
284
+ const unitPriceBaseQuantityNumber = toPositiveNumber(unitPriceBaseQuantity, locale);
275
285
 
276
286
  const validConversions = conversions.filter(
277
287
  (entry) =>
@@ -388,7 +398,7 @@ export function ProductUomSection({
388
398
  onChange={(event) =>
389
399
  setValue(
390
400
  "defaultSalesUnitQuantity",
391
- normalizeDecimalInput(event.target.value),
401
+ normalizeDecimalInput(event.target.value, locale),
392
402
  )
393
403
  }
394
404
  placeholder="1"
@@ -541,7 +551,7 @@ export function ProductUomSection({
541
551
  onChange={(event) =>
542
552
  setValue(
543
553
  "unitPriceBaseQuantity",
544
- normalizeDecimalInput(event.target.value),
554
+ normalizeDecimalInput(event.target.value, locale),
545
555
  )
546
556
  }
547
557
  placeholder="1"
@@ -595,7 +605,7 @@ export function ProductUomSection({
595
605
  ) : (
596
606
  <div className="space-y-2">
597
607
  {conversions.map((entry, index) => {
598
- const conversionFactor = toPositiveNumber(entry.toBaseFactor);
608
+ const conversionFactor = toPositiveNumber(entry.toBaseFactor, locale);
599
609
  const conversionPreviewText =
600
610
  entry.unitCode && conversionFactor !== null
601
611
  ? t(
@@ -653,7 +663,7 @@ export function ProductUomSection({
653
663
  value={entry.toBaseFactor}
654
664
  onChange={(event) =>
655
665
  updateConversion(index, {
656
- toBaseFactor: normalizeDecimalInput(event.target.value),
666
+ toBaseFactor: normalizeDecimalInput(event.target.value, locale),
657
667
  })
658
668
  }
659
669
  placeholder="1"
@@ -2,7 +2,6 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { Calendar, CalendarClock, Clock, Mail, Phone, StickyNote, Users } from 'lucide-react'
5
- import { toZonedTime } from 'date-fns-tz'
6
5
  import { cn } from '@open-mercato/shared/lib/utils'
7
6
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
7
  import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
@@ -11,6 +10,7 @@ import { ActivitiesDayStrip } from './ActivitiesDayStrip'
11
10
  import { ActivitiesAddNewMenu, type ActivityKind } from './ActivitiesAddNewMenu'
12
11
  import type { InteractionSummary } from './types'
13
12
  import { isOpenInteractionStatus } from '../../lib/interactionStatus'
13
+ import { isSameDay, toLocalZonedDate } from '../../lib/localDay'
14
14
  import { createLogger } from '@open-mercato/shared/lib/logger'
15
15
 
16
16
  const logger = createLogger('customers')
@@ -43,30 +43,12 @@ const TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> =
43
43
  note: StickyNote,
44
44
  }
45
45
 
46
- const USER_TIMEZONE = (() => {
47
- try {
48
- return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
49
- } catch {
50
- return 'UTC'
51
- }
52
- })()
53
-
54
- // Project a UTC instant to the user's local timezone before extracting day/month/year
55
- // for "same day" comparisons (issue #1809 — E3 timezone drift).
56
- function toLocalZonedDate(value: string | Date): Date {
57
- return toZonedTime(value, USER_TIMEZONE)
58
- }
59
-
60
46
  function startOfDay(date: Date): Date {
61
47
  const next = new Date(date)
62
48
  next.setHours(0, 0, 0, 0)
63
49
  return next
64
50
  }
65
51
 
66
- function isSameDay(a: Date, b: Date): boolean {
67
- return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
68
- }
69
-
70
52
  function isOverdue(activity: InteractionSummary, now: Date): boolean {
71
53
  const scheduled = activity.scheduledAt ?? activity.occurredAt
72
54
  if (!scheduled) return false
@@ -2,12 +2,12 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { ChevronLeft, ChevronRight } from 'lucide-react'
5
- import { toZonedTime } from 'date-fns-tz'
6
5
  import { cn } from '@open-mercato/shared/lib/utils'
7
6
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
7
  import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
9
8
  import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
10
9
  import type { InteractionSummary } from './types'
10
+ import { isSameDay, toLocalZonedDate } from '../../lib/localDay'
11
11
  import { createLogger } from '@open-mercato/shared/lib/logger'
12
12
 
13
13
  const logger = createLogger('customers')
@@ -25,23 +25,6 @@ interface ActivitiesDayStripProps {
25
25
  events?: InteractionSummary[]
26
26
  }
27
27
 
28
- const USER_TIMEZONE = (() => {
29
- try {
30
- return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
31
- } catch {
32
- return 'UTC'
33
- }
34
- })()
35
-
36
- // Project a UTC ISO timestamp to the user's local timezone before comparing
37
- // "same day" (issue #1809 — E3). The browser's `new Date(iso)` treats the
38
- // instant correctly, but `getDate()/getMonth()/getFullYear()` reflect the
39
- // user's local day, so for activities scheduled at e.g. 23:30 local on a UTC
40
- // boundary the day-strip and list now agree.
41
- function toLocalZonedDate(value: string | Date): Date {
42
- return toZonedTime(value, USER_TIMEZONE)
43
- }
44
-
45
28
  const VISIBLE_DAYS = 5
46
29
  const BUSYNESS_SLOTS = 10
47
30
  const SLOT_START_HOUR = 7
@@ -84,10 +67,6 @@ function endOfDay(date: Date): Date {
84
67
  return next
85
68
  }
86
69
 
87
- function isSameDay(a: Date, b: Date): boolean {
88
- return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
89
- }
90
-
91
70
  function isWeekend(date: Date): boolean {
92
71
  const day = date.getDay()
93
72
  return day === 0 || day === 6
@@ -9,6 +9,7 @@ import { Button } from '@open-mercato/ui/primitives/button'
9
9
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
10
10
  import type { InteractionSummary } from './types'
11
11
  import { loadLegacyActivitiesInRange } from './legacyActivities'
12
+ import { isSameDay } from '../../lib/localDay'
12
13
 
13
14
  interface MiniWeekCalendarProps {
14
15
  entityId: string
@@ -30,10 +31,6 @@ function getWeekDays(baseDate: Date): Date[] {
30
31
  return days
31
32
  }
32
33
 
33
- function isSameDay(a: Date, b: Date): boolean {
34
- return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
35
- }
36
-
37
34
  function getDayLabels(t: ReturnType<typeof useT>): string[] {
38
35
  return [
39
36
  t('customers.calendar.day.mon', 'MON'),
@@ -7,6 +7,7 @@ import { Button } from '@open-mercato/ui/primitives/button'
7
7
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
8
8
  import { Popover, PopoverContent, PopoverTrigger } from '@open-mercato/ui/primitives/popover'
9
9
  import type { InteractionSummary } from './types'
10
+ import { isSameDay } from '../../lib/localDay'
10
11
 
11
12
  const TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
12
13
  call: Phone,
@@ -245,7 +246,7 @@ function formatScheduledDate(isoString: string): string {
245
246
  const dateStr = date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' })
246
247
  const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
247
248
 
248
- if (date.toDateString() === tomorrow.toDateString()) {
249
+ if (isSameDay(date, tomorrow)) {
249
250
  return `Tomorrow ${timeStr}`
250
251
  }
251
252
  return `${dayName}, ${dateStr} · ${timeStr}`
@@ -16,6 +16,7 @@ import {
16
16
  } from '@open-mercato/ui/primitives/select'
17
17
  import type { ActivityType, ScheduleFieldId } from './fieldConfig'
18
18
  import { isVisible, getFieldLabel } from './fieldConfig'
19
+ import { USER_TIMEZONE } from '../../../lib/localDay'
19
20
 
20
21
  function parseIsoDate(value: string): Date | null {
21
22
  if (!value) return null
@@ -193,7 +194,7 @@ export function DateTimeFields({
193
194
  <span className="text-muted-foreground">&middot;</span>
194
195
  <span className="flex items-center gap-1.5">
195
196
  <Globe className="size-3.5" />
196
- {Intl.DateTimeFormat().resolvedOptions().timeZone} (GMT{new Date().getTimezoneOffset() <= 0 ? '+' : '-'}{String(Math.abs(Math.floor(new Date().getTimezoneOffset() / 60))).padStart(1, '0')})
197
+ {USER_TIMEZONE} (GMT{new Date().getTimezoneOffset() <= 0 ? '+' : '-'}{String(Math.abs(Math.floor(new Date().getTimezoneOffset() / 60))).padStart(1, '0')})
197
198
  </span>
198
199
  {showRecurrence && (
199
200
  <>
@@ -0,0 +1,36 @@
1
+ // Single source of truth for "what timezone is the reader in, and is this the same
2
+ // calendar day" across the customers detail components.
3
+ //
4
+ // Every activity surface in `components/detail/` answers the same two questions, and
5
+ // until issue #6011 each answered them with its own byte-identical copy. They are kept
6
+ // together here because they are one decision, not two: `isSameDay` is only meaningful
7
+ // once both operands have been projected into the same zone, which is what
8
+ // `toLocalZonedDate` does with `USER_TIMEZONE`.
9
+ //
10
+ // The zone is deliberately the reader's own runtime zone. Host/tenant-configured
11
+ // display timezones are planned in `.ai/specs/2026-05-18-date-locale-settings.md`
12
+ // (issue #1964); when that lands, threading a configured zone through the detail
13
+ // components is an edit to this file rather than a hunt through every component.
14
+
15
+ import { toZonedTime } from 'date-fns-tz'
16
+
17
+ export const USER_TIMEZONE = (() => {
18
+ try {
19
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
20
+ } catch {
21
+ return 'UTC'
22
+ }
23
+ })()
24
+
25
+ // Project a UTC instant to the user's local timezone before extracting day/month/year
26
+ // for "same day" comparisons (issue #1809 — E3 timezone drift). The browser's
27
+ // `new Date(iso)` treats the instant correctly, but `getDate()/getMonth()/getFullYear()`
28
+ // reflect the user's local day, so an activity scheduled at e.g. 23:30 local on a UTC
29
+ // boundary lands on the day the reader would call it.
30
+ export function toLocalZonedDate(value: string | Date): Date {
31
+ return toZonedTime(value, USER_TIMEZONE)
32
+ }
33
+
34
+ export function isSameDay(a: Date, b: Date): boolean {
35
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
36
+ }
@@ -15,7 +15,8 @@ import { useDialogKeyHandler } from '@open-mercato/ui/hooks/useDialogKeyHandler'
15
15
  import { Input } from '@open-mercato/ui/primitives/input'
16
16
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
17
17
  import { E } from '#generated/entities.ids.generated'
18
- import { useT } from '@open-mercato/shared/lib/i18n/context'
18
+ import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
19
+ import { parseLocaleNumber } from '@open-mercato/shared/lib/number'
19
20
  import { normalizeCustomFieldSubmitValue, extractCustomFieldValues } from './customFieldHelpers'
20
21
  import { createLogger } from '@open-mercato/shared/lib/logger'
21
22
 
@@ -66,13 +67,15 @@ type PaymentDialogProps = {
66
67
  onSaved?: (totals?: PaymentTotals | null) => void | Promise<void>
67
68
  }
68
69
 
69
- const normalizeNumber = (value: unknown): number => {
70
- if (typeof value === 'number' && Number.isFinite(value)) return value
71
- if (typeof value === 'string' && value.trim().length) {
72
- const parsed = Number(value)
73
- if (!Number.isNaN(parsed)) return parsed
74
- }
75
- return 0
70
+ // The amount field is a hand-rolled text input, so the raw string a user typed reaches
71
+ // the submit handler carrying the separator the surrounding UI displays — `110,70` under
72
+ // Polish (issue #5828, same defect class as #5552). A blank/absent value keeps the old
73
+ // "must be a positive amount" message; only genuinely unparseable input returns null.
74
+ const parseAmountInput = (value: unknown, locale?: string): number | null => {
75
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
76
+ if (typeof value !== 'string') return 0
77
+ if (!value.trim()) return 0
78
+ return parseLocaleNumber(value, locale)
76
79
  }
77
80
 
78
81
  export function PaymentDialog({
@@ -88,6 +91,7 @@ export function PaymentDialog({
88
91
  onSaved,
89
92
  }: PaymentDialogProps) {
90
93
  const t = useT()
94
+ const locale = useLocale()
91
95
  const dialogContentRef = React.useRef<HTMLDivElement | null>(null)
92
96
  const [formResetKey, setFormResetKey] = React.useState(0)
93
97
  const [paymentMethods, setPaymentMethods] = React.useState<PaymentMethodOption[]>([])
@@ -352,9 +356,8 @@ export function PaymentDialog({
352
356
  return (
353
357
  <div className="flex items-center gap-2">
354
358
  <Input
355
- type="number"
359
+ type="text"
356
360
  inputMode="decimal"
357
- step="0.01"
358
361
  value={normalized as string | number}
359
362
  onChange={(event) => setValue(event.target.value)}
360
363
  placeholder="0.00"
@@ -493,10 +496,14 @@ export function PaymentDialog({
493
496
  const handleSubmit = React.useCallback(
494
497
  async (values: Record<string, unknown>) => {
495
498
  const resolvedCurrency = currencyCode ? currencyCode.toUpperCase() : ''
496
- const amountValue = normalizeNumber(values.amount)
499
+ const amountValue = parseAmountInput(values.amount, locale)
497
500
  if (!resolvedCurrency.trim()) {
498
501
  throw createCrudFormError(t('sales.documents.payments.currencyRequired', 'Currency is required.'))
499
502
  }
503
+ if (amountValue === null) {
504
+ const message = t('sales.documents.payments.amountInvalid', 'Enter the amount as a number.')
505
+ throw createCrudFormError(message, { amount: message })
506
+ }
500
507
  if (amountValue <= 0) {
501
508
  throw createCrudFormError(t('sales.documents.payments.amountRequired', 'Enter a positive amount.'), {
502
509
  amount: t('sales.documents.payments.amountRequired', 'Enter a positive amount.'),
@@ -1139,6 +1139,7 @@
1139
1139
  "sales.documents.list.table.unassigned": "Nicht zugewiesen",
1140
1140
  "sales.documents.payments.add": "Zahlung hinzufügen",
1141
1141
  "sales.documents.payments.amount": "Betrag",
1142
+ "sales.documents.payments.amountInvalid": "Geben Sie den Betrag als Zahl ein.",
1142
1143
  "sales.documents.payments.amountRequired": "Geben Sie einen positiven Betrag ein.",
1143
1144
  "sales.documents.payments.created": "Zahlung erfasst.",
1144
1145
  "sales.documents.payments.createdAt": "Erstellt",
@@ -1139,6 +1139,7 @@
1139
1139
  "sales.documents.list.table.unassigned": "Unassigned",
1140
1140
  "sales.documents.payments.add": "Add payment",
1141
1141
  "sales.documents.payments.amount": "Amount",
1142
+ "sales.documents.payments.amountInvalid": "Enter the amount as a number.",
1142
1143
  "sales.documents.payments.amountRequired": "Enter a positive amount.",
1143
1144
  "sales.documents.payments.created": "Payment recorded.",
1144
1145
  "sales.documents.payments.createdAt": "Created",
@@ -1139,6 +1139,7 @@
1139
1139
  "sales.documents.list.table.unassigned": "Sin asignar",
1140
1140
  "sales.documents.payments.add": "Agregar pago",
1141
1141
  "sales.documents.payments.amount": "Importe",
1142
+ "sales.documents.payments.amountInvalid": "Introduce el importe como un número.",
1142
1143
  "sales.documents.payments.amountRequired": "Introduce un importe positivo.",
1143
1144
  "sales.documents.payments.created": "Pago registrado.",
1144
1145
  "sales.documents.payments.createdAt": "Creado",
@@ -1139,6 +1139,7 @@
1139
1139
  "sales.documents.list.table.unassigned": "미지정",
1140
1140
  "sales.documents.payments.add": "결제 추가",
1141
1141
  "sales.documents.payments.amount": "금액",
1142
+ "sales.documents.payments.amountInvalid": "금액을 숫자로 입력하세요.",
1142
1143
  "sales.documents.payments.amountRequired": "0보다 큰 금액을 입력하세요.",
1143
1144
  "sales.documents.payments.created": "결제가 등록되었습니다.",
1144
1145
  "sales.documents.payments.createdAt": "등록일",
@@ -1139,6 +1139,7 @@
1139
1139
  "sales.documents.list.table.unassigned": "Nieprzypisany",
1140
1140
  "sales.documents.payments.add": "Dodaj płatność",
1141
1141
  "sales.documents.payments.amount": "Kwota",
1142
+ "sales.documents.payments.amountInvalid": "Podaj kwotę jako liczbę.",
1142
1143
  "sales.documents.payments.amountRequired": "Wprowadź dodatnią kwotę.",
1143
1144
  "sales.documents.payments.created": "Płatność zarejestrowana.",
1144
1145
  "sales.documents.payments.createdAt": "Utworzono",
@@ -32,7 +32,8 @@ import {
32
32
  } from '@open-mercato/ui/primitives/select'
33
33
  import { StatusBadge } from '@open-mercato/ui/primitives/status-badge'
34
34
  import { Textarea } from '@open-mercato/ui/primitives/textarea'
35
- import { useT } from '@open-mercato/shared/lib/i18n/context'
35
+ import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
36
+ import { parseLocaleNumber } from '@open-mercato/shared/lib/number'
36
37
  import { createLogger } from '@open-mercato/shared/lib/logger'
37
38
  import { buildInventoryMutationReferenceId } from '../../lib/inventoryMutationUi'
38
39
  import {
@@ -91,11 +92,10 @@ const EMPTY_FORM: AdjustFormValues = {
91
92
  serialNumber: '',
92
93
  }
93
94
 
94
- function parseDeltaInput(value: string): number | null {
95
+ export function parseDeltaInput(value: string, locale?: string): number | null {
95
96
  const trimmed = value.trim()
96
97
  if (!trimmed || trimmed === '-' || trimmed === '+') return null
97
- const parsed = Number(trimmed)
98
- return Number.isFinite(parsed) ? parsed : null
98
+ return parseLocaleNumber(trimmed, locale)
99
99
  }
100
100
 
101
101
  export function AdjustInventoryDialog({
@@ -108,6 +108,7 @@ export function AdjustInventoryDialog({
108
108
  initialLotId,
109
109
  }: AdjustInventoryDialogProps) {
110
110
  const t = useT()
111
+ const locale = useLocale()
111
112
  const queryClient = useQueryClient()
112
113
  const formRef = React.useRef<HTMLFormElement>(null)
113
114
  const { runMutation, retryLastMutation } = useGuardedMutation<{
@@ -690,7 +691,7 @@ export function AdjustInventoryDialog({
690
691
  inputMode="decimal"
691
692
  value={String(form.delta)}
692
693
  onChange={(event) => {
693
- const parsed = parseDeltaInput(event.target.value)
694
+ const parsed = parseDeltaInput(event.target.value, locale)
694
695
  if (parsed == null) return
695
696
  patchForm({ delta: parsed })
696
697
  }}