@open-mercato/core 0.4.5-develop-3ce83a8b24 → 0.4.5-develop-4849712ccb

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 (163) hide show
  1. package/dist/generated/entities/catalog_product/index.js +16 -0
  2. package/dist/generated/entities/catalog_product/index.js.map +2 -2
  3. package/dist/generated/entities/catalog_product_unit_conversion/index.js +27 -0
  4. package/dist/generated/entities/catalog_product_unit_conversion/index.js.map +7 -0
  5. package/dist/generated/entities/sales_credit_memo_line/index.js +7 -1
  6. package/dist/generated/entities/sales_credit_memo_line/index.js.map +2 -2
  7. package/dist/generated/entities/sales_invoice_line/index.js +7 -1
  8. package/dist/generated/entities/sales_invoice_line/index.js.map +2 -2
  9. package/dist/generated/entities/sales_order_line/index.js +6 -0
  10. package/dist/generated/entities/sales_order_line/index.js.map +2 -2
  11. package/dist/generated/entities/sales_quote_line/index.js +6 -0
  12. package/dist/generated/entities/sales_quote_line/index.js.map +2 -2
  13. package/dist/generated/entities.ids.generated.js +1 -0
  14. package/dist/generated/entities.ids.generated.js.map +2 -2
  15. package/dist/generated/entity-fields-registry.js +2 -0
  16. package/dist/generated/entity-fields-registry.js.map +2 -2
  17. package/dist/modules/catalog/api/prices/route.js +123 -8
  18. package/dist/modules/catalog/api/prices/route.js.map +2 -2
  19. package/dist/modules/catalog/api/product-unit-conversions/route.js +194 -0
  20. package/dist/modules/catalog/api/product-unit-conversions/route.js.map +7 -0
  21. package/dist/modules/catalog/api/products/route.js +351 -201
  22. package/dist/modules/catalog/api/products/route.js.map +2 -2
  23. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +1267 -497
  24. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  25. package/dist/modules/catalog/backend/catalog/products/create/page.js +733 -210
  26. package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
  27. package/dist/modules/catalog/commands/index.js +1 -0
  28. package/dist/modules/catalog/commands/index.js.map +2 -2
  29. package/dist/modules/catalog/commands/productUnitConversions.js +503 -0
  30. package/dist/modules/catalog/commands/productUnitConversions.js.map +7 -0
  31. package/dist/modules/catalog/commands/products.js +355 -73
  32. package/dist/modules/catalog/commands/products.js.map +2 -2
  33. package/dist/modules/catalog/commands/shared.js +18 -4
  34. package/dist/modules/catalog/commands/shared.js.map +2 -2
  35. package/dist/modules/catalog/components/products/ProductUomSection.js +591 -0
  36. package/dist/modules/catalog/components/products/ProductUomSection.js.map +7 -0
  37. package/dist/modules/catalog/components/products/productForm.js +66 -5
  38. package/dist/modules/catalog/components/products/productForm.js.map +2 -2
  39. package/dist/modules/catalog/components/products/productFormUtils.js +68 -0
  40. package/dist/modules/catalog/components/products/productFormUtils.js.map +7 -0
  41. package/dist/modules/catalog/data/entities.js +86 -0
  42. package/dist/modules/catalog/data/entities.js.map +2 -2
  43. package/dist/modules/catalog/data/validators.js +65 -3
  44. package/dist/modules/catalog/data/validators.js.map +2 -2
  45. package/dist/modules/catalog/events.js +3 -0
  46. package/dist/modules/catalog/events.js.map +2 -2
  47. package/dist/modules/catalog/lib/unitCodes.js +7 -0
  48. package/dist/modules/catalog/lib/unitCodes.js.map +7 -0
  49. package/dist/modules/catalog/lib/unitResolution.js +53 -0
  50. package/dist/modules/catalog/lib/unitResolution.js.map +7 -0
  51. package/dist/modules/catalog/migrations/Migration20260218225422.js +19 -0
  52. package/dist/modules/catalog/migrations/Migration20260218225422.js.map +7 -0
  53. package/dist/modules/catalog/migrations/Migration20260219084500.js +27 -0
  54. package/dist/modules/catalog/migrations/Migration20260219084500.js.map +7 -0
  55. package/dist/modules/catalog/search.js +69 -1
  56. package/dist/modules/catalog/search.js.map +2 -2
  57. package/dist/modules/catalog/seed/examples.js +91 -42
  58. package/dist/modules/catalog/seed/examples.js.map +2 -2
  59. package/dist/modules/dashboards/seed/analytics.js +3 -0
  60. package/dist/modules/dashboards/seed/analytics.js.map +2 -2
  61. package/dist/modules/sales/api/order-lines/route.js +98 -15
  62. package/dist/modules/sales/api/order-lines/route.js.map +2 -2
  63. package/dist/modules/sales/api/quote-lines/route.js +101 -14
  64. package/dist/modules/sales/api/quote-lines/route.js.map +2 -2
  65. package/dist/modules/sales/api/quotes/public/[token]/route.js +87 -12
  66. package/dist/modules/sales/api/quotes/public/[token]/route.js.map +2 -2
  67. package/dist/modules/sales/commands/documents.js +1424 -260
  68. package/dist/modules/sales/commands/documents.js.map +3 -3
  69. package/dist/modules/sales/commands/shared.js +6 -2
  70. package/dist/modules/sales/commands/shared.js.map +2 -2
  71. package/dist/modules/sales/components/documents/ItemsSection.js +216 -86
  72. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  73. package/dist/modules/sales/components/documents/LineItemDialog.js +913 -241
  74. package/dist/modules/sales/components/documents/LineItemDialog.js.map +3 -3
  75. package/dist/modules/sales/components/documents/ShipmentsSection.js +15 -3
  76. package/dist/modules/sales/components/documents/ShipmentsSection.js.map +2 -2
  77. package/dist/modules/sales/data/entities.js +59 -3
  78. package/dist/modules/sales/data/entities.js.map +2 -2
  79. package/dist/modules/sales/data/validators.js +35 -0
  80. package/dist/modules/sales/data/validators.js.map +2 -2
  81. package/dist/modules/sales/frontend/quote/[token]/page.js +15 -1
  82. package/dist/modules/sales/frontend/quote/[token]/page.js.map +2 -2
  83. package/dist/modules/sales/migrations/Migration20260218225423.js +31 -0
  84. package/dist/modules/sales/migrations/Migration20260218225423.js.map +7 -0
  85. package/dist/modules/sales/migrations/Migration20260219084501.js +71 -0
  86. package/dist/modules/sales/migrations/Migration20260219084501.js.map +7 -0
  87. package/dist/modules/sales/search.js +28 -0
  88. package/dist/modules/sales/search.js.map +2 -2
  89. package/dist/modules/sales/seed/examples.js +14 -1
  90. package/dist/modules/sales/seed/examples.js.map +2 -2
  91. package/dist/modules/sales/widgets/injection/document-history/widget.client.js +1 -1
  92. package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
  93. package/generated/entities/catalog_product/index.ts +8 -0
  94. package/generated/entities/catalog_product_unit_conversion/index.ts +12 -0
  95. package/generated/entities/sales_credit_memo_line/index.ts +3 -0
  96. package/generated/entities/sales_invoice_line/index.ts +3 -0
  97. package/generated/entities/sales_order_line/index.ts +3 -0
  98. package/generated/entities/sales_quote_line/index.ts +3 -0
  99. package/generated/entities.ids.generated.ts +1 -0
  100. package/generated/entity-fields-registry.ts +2 -0
  101. package/package.json +2 -2
  102. package/src/modules/auth/i18n/de.json +1 -1
  103. package/src/modules/auth/i18n/en.json +1 -1
  104. package/src/modules/auth/i18n/es.json +1 -1
  105. package/src/modules/auth/i18n/pl.json +1 -1
  106. package/src/modules/catalog/api/prices/route.ts +213 -81
  107. package/src/modules/catalog/api/product-unit-conversions/route.ts +195 -0
  108. package/src/modules/catalog/api/products/route.ts +638 -402
  109. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +2085 -1072
  110. package/src/modules/catalog/backend/catalog/products/create/page.tsx +1288 -593
  111. package/src/modules/catalog/commands/index.ts +1 -0
  112. package/src/modules/catalog/commands/productUnitConversions.ts +626 -0
  113. package/src/modules/catalog/commands/products.ts +1151 -693
  114. package/src/modules/catalog/commands/shared.ts +19 -5
  115. package/src/modules/catalog/components/products/ProductUomSection.tsx +745 -0
  116. package/src/modules/catalog/components/products/productForm.ts +369 -256
  117. package/src/modules/catalog/components/products/productFormUtils.ts +82 -0
  118. package/src/modules/catalog/data/entities.ts +82 -1
  119. package/src/modules/catalog/data/validators.ts +118 -34
  120. package/src/modules/catalog/events.ts +3 -0
  121. package/src/modules/catalog/i18n/de.json +56 -0
  122. package/src/modules/catalog/i18n/en.json +56 -0
  123. package/src/modules/catalog/i18n/es.json +56 -0
  124. package/src/modules/catalog/i18n/pl.json +56 -0
  125. package/src/modules/catalog/lib/unitCodes.ts +1 -0
  126. package/src/modules/catalog/lib/unitResolution.ts +62 -0
  127. package/src/modules/catalog/migrations/.snapshot-open-mercato.json +245 -0
  128. package/src/modules/catalog/migrations/Migration20260218225422.ts +21 -0
  129. package/src/modules/catalog/migrations/Migration20260219084500.ts +26 -0
  130. package/src/modules/catalog/search.ts +73 -1
  131. package/src/modules/catalog/seed/examples.ts +552 -479
  132. package/src/modules/dashboards/i18n/de.json +1 -1
  133. package/src/modules/dashboards/i18n/en.json +1 -1
  134. package/src/modules/dashboards/i18n/es.json +1 -1
  135. package/src/modules/dashboards/i18n/pl.json +1 -1
  136. package/src/modules/dashboards/seed/analytics.ts +3 -0
  137. package/src/modules/sales/api/order-lines/route.ts +158 -68
  138. package/src/modules/sales/api/quote-lines/route.ts +161 -67
  139. package/src/modules/sales/api/quotes/public/[token]/route.ts +122 -36
  140. package/src/modules/sales/commands/documents.ts +4250 -2424
  141. package/src/modules/sales/commands/shared.ts +7 -2
  142. package/src/modules/sales/components/documents/ItemsSection.tsx +580 -310
  143. package/src/modules/sales/components/documents/LineItemDialog.tsx +1988 -833
  144. package/src/modules/sales/components/documents/ShipmentsSection.tsx +17 -3
  145. package/src/modules/sales/components/documents/lineItemTypes.ts +6 -0
  146. package/src/modules/sales/data/entities.ts +53 -0
  147. package/src/modules/sales/data/validators.ts +36 -0
  148. package/src/modules/sales/frontend/quote/[token]/page.tsx +25 -1
  149. package/src/modules/sales/i18n/de.json +23 -3
  150. package/src/modules/sales/i18n/en.json +23 -3
  151. package/src/modules/sales/i18n/es.json +23 -3
  152. package/src/modules/sales/i18n/pl.json +23 -3
  153. package/src/modules/sales/lib/types.ts +30 -0
  154. package/src/modules/sales/migrations/.snapshot-open-mercato.json +172 -0
  155. package/src/modules/sales/migrations/Migration20260218225423.ts +37 -0
  156. package/src/modules/sales/migrations/Migration20260219084501.ts +73 -0
  157. package/src/modules/sales/search.ts +28 -0
  158. package/src/modules/sales/seed/examples.ts +20 -1
  159. package/src/modules/sales/widgets/injection/document-history/widget.client.tsx +1 -1
  160. package/src/modules/workflows/i18n/de.json +4 -4
  161. package/src/modules/workflows/i18n/en.json +4 -4
  162. package/src/modules/workflows/i18n/es.json +4 -4
  163. package/src/modules/workflows/i18n/pl.json +4 -4
@@ -1,167 +1,340 @@
1
- "use client"
1
+ "use client";
2
2
 
3
- import * as React from 'react'
4
- import { LookupSelect, type LookupSelectItem } from '@open-mercato/ui/backend/inputs'
3
+ import * as React from "react";
4
+ import {
5
+ LookupSelect,
6
+ type LookupSelectItem,
7
+ } from "@open-mercato/ui/backend/inputs";
5
8
  import {
6
9
  CrudForm,
7
10
  type CrudField,
8
11
  type CrudFormGroup,
9
12
  type CrudCustomFieldRenderProps,
10
- } from '@open-mercato/ui/backend/CrudForm'
11
- import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customFieldValues'
12
- import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
13
- import { createCrud, updateCrud } from '@open-mercato/ui/backend/utils/crud'
14
- import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
15
- import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
16
- import { Button } from '@open-mercato/ui/primitives/button'
17
- import { Input } from '@open-mercato/ui/primitives/input'
18
- import { DollarSign, Settings } from 'lucide-react'
19
- import { normalizeCustomFieldValues } from '@open-mercato/shared/lib/custom-fields/normalize'
13
+ } from "@open-mercato/ui/backend/CrudForm";
14
+ import { collectCustomFieldValues } from "@open-mercato/ui/backend/utils/customFieldValues";
15
+ import { apiCall } from "@open-mercato/ui/backend/utils/apiCall";
16
+ import { createCrud, updateCrud } from "@open-mercato/ui/backend/utils/crud";
17
+ import { createCrudFormError } from "@open-mercato/ui/backend/utils/serverErrors";
18
+ import {
19
+ Dialog,
20
+ DialogContent,
21
+ DialogHeader,
22
+ DialogTitle,
23
+ } from "@open-mercato/ui/primitives/dialog";
24
+ import { Button } from "@open-mercato/ui/primitives/button";
25
+ import { Input } from "@open-mercato/ui/primitives/input";
26
+ import { DollarSign, Settings } from "lucide-react";
27
+ import { normalizeCustomFieldValues } from "@open-mercato/shared/lib/custom-fields/normalize";
20
28
  import {
21
29
  DictionaryValue,
22
30
  renderDictionaryIcon,
23
31
  renderDictionaryColor,
24
- } from '@open-mercato/core/modules/dictionaries/components/dictionaryAppearance'
25
- import { E } from '#generated/entities.ids.generated'
26
- import { useT } from '@open-mercato/shared/lib/i18n/context'
27
- import { useOrganizationScopeDetail } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
28
- import { formatMoney, normalizeNumber } from './lineItemUtils'
29
- import type { SalesLineRecord } from './lineItemTypes'
30
- import { normalizeCustomFieldSubmitValue, extractCustomFieldValues } from './customFieldHelpers'
32
+ } from "@open-mercato/core/modules/dictionaries/components/dictionaryAppearance";
33
+ import { E } from "#generated/entities.ids.generated";
34
+ import { useT } from "@open-mercato/shared/lib/i18n/context";
35
+ import { useOrganizationScopeDetail } from "@open-mercato/shared/lib/frontend/useOrganizationScope";
36
+ import { formatMoney, normalizeNumber } from "./lineItemUtils";
37
+ import type { SalesLineRecord } from "./lineItemTypes";
38
+ import {
39
+ normalizeCustomFieldSubmitValue,
40
+ extractCustomFieldValues,
41
+ } from "./customFieldHelpers";
42
+ import { canonicalizeUnitCode } from "@open-mercato/shared/lib/units/unitCodes";
31
43
 
32
44
  type ProductOption = {
33
- id: string
34
- title: string
35
- sku: string | null
36
- thumbnailUrl: string | null
37
- taxRateId?: string | null
38
- taxRate?: number | null
39
- }
45
+ id: string;
46
+ title: string;
47
+ sku: string | null;
48
+ thumbnailUrl: string | null;
49
+ taxRateId?: string | null;
50
+ taxRate?: number | null;
51
+ defaultUnit?: string | null;
52
+ defaultSalesUnit?: string | null;
53
+ defaultSalesUnitQuantity?: number | null;
54
+ };
40
55
 
41
56
  type VariantOption = {
42
- id: string
43
- title: string
44
- sku: string | null
45
- thumbnailUrl: string | null
46
- taxRateId?: string | null
47
- taxRate?: number | null
48
- }
57
+ id: string;
58
+ title: string;
59
+ sku: string | null;
60
+ thumbnailUrl: string | null;
61
+ taxRateId?: string | null;
62
+ taxRate?: number | null;
63
+ };
49
64
 
50
65
  type PriceOption = {
51
- id: string
52
- amountNet: number | null
53
- amountGross: number | null
54
- currencyCode: string | null
55
- displayMode: 'including-tax' | 'excluding-tax' | null
56
- taxRate: number | null
57
- label: string
58
- priceKindId?: string | null
59
- priceKindTitle?: string | null
60
- priceKindCode?: string | null
61
- scopeReason?: string | null
62
- scopeTags?: string[]
63
- }
66
+ id: string;
67
+ amountNet: number | null;
68
+ amountGross: number | null;
69
+ currencyCode: string | null;
70
+ displayMode: "including-tax" | "excluding-tax" | null;
71
+ taxRate: number | null;
72
+ label: string;
73
+ priceKindId?: string | null;
74
+ priceKindTitle?: string | null;
75
+ priceKindCode?: string | null;
76
+ scopeReason?: string | null;
77
+ scopeTags?: string[];
78
+ };
64
79
 
65
80
  type TaxRateOption = {
66
- id: string
67
- name: string
68
- code: string | null
69
- rate: number | null
70
- isDefault: boolean
71
- }
81
+ id: string;
82
+ name: string;
83
+ code: string | null;
84
+ rate: number | null;
85
+ isDefault: boolean;
86
+ };
72
87
 
73
88
  type StatusOption = {
74
- id: string
75
- value: string
76
- label: string
77
- color: string | null
78
- icon: string | null
79
- }
89
+ id: string;
90
+ value: string;
91
+ label: string;
92
+ color: string | null;
93
+ icon: string | null;
94
+ };
95
+
96
+ type UnitOption = {
97
+ code: string;
98
+ toBaseFactor: number | null;
99
+ isBase: boolean;
100
+ };
80
101
 
81
102
  type LineFormState = {
82
- lineMode: 'catalog' | 'custom'
83
- productId: string | null
84
- variantId: string | null
85
- quantity: string
86
- priceId: string | null
87
- priceMode: 'net' | 'gross'
88
- unitPrice: string
89
- taxRate: number | null
90
- taxRateId: string | null
91
- name: string
92
- currencyCode: string | null
93
- catalogSnapshot?: Record<string, unknown> | null
94
- customFieldSetId?: string | null
95
- statusEntryId?: string | null
96
- }
103
+ lineMode: "catalog" | "custom";
104
+ productId: string | null;
105
+ variantId: string | null;
106
+ quantity: string;
107
+ quantityUnit: string | null;
108
+ priceId: string | null;
109
+ priceMode: "net" | "gross";
110
+ unitPrice: string;
111
+ taxRate: number | null;
112
+ taxRateId: string | null;
113
+ name: string;
114
+ currencyCode: string | null;
115
+ catalogSnapshot?: Record<string, unknown> | null;
116
+ customFieldSetId?: string | null;
117
+ statusEntryId?: string | null;
118
+ };
119
+
120
+ type FieldRenderProps = CrudCustomFieldRenderProps;
121
+
122
+ type ApiPriceKind = {
123
+ title?: string | null;
124
+ name?: string | null;
125
+ code?: string | null;
126
+ };
97
127
 
98
- type FieldRenderProps = CrudCustomFieldRenderProps
128
+ type ApiPriceItem = Record<string, unknown> & {
129
+ unit_price_net?: number | null;
130
+ unit_price_gross?: number | null;
131
+ currency_code?: string | null;
132
+ currencyCode?: string | null;
133
+ display_mode?: string | null;
134
+ displayMode?: string | null;
135
+ tax_rate?: number | null;
136
+ price_kind_id?: string | null;
137
+ priceKindId?: string | null;
138
+ price_kind_title?: string | null;
139
+ priceKindTitle?: string | null;
140
+ price_kind_code?: string | null;
141
+ priceKindCode?: string | null;
142
+ price_kind?: ApiPriceKind | null;
143
+ kind?: string | null;
144
+ };
145
+
146
+ type ApiTaxRateItem = Record<string, unknown> & {
147
+ rate?: number | null;
148
+ code?: string | null;
149
+ isDefault?: boolean;
150
+ is_default?: boolean;
151
+ min_quantity?: number | null;
152
+ max_quantity?: number | null;
153
+ starts_at?: string | null;
154
+ ends_at?: string | null;
155
+ };
156
+
157
+ type ApiProductItem = Record<string, unknown> & {
158
+ name?: string;
159
+ sku?: string;
160
+ default_media_url?: string;
161
+ defaultMediaUrl?: string;
162
+ pricing?: Record<string, unknown> | null;
163
+ metadata?: Record<string, unknown> | null;
164
+ tax_rate?: number | null;
165
+ taxRate?: number | null;
166
+ };
167
+
168
+ type ApiVariantItem = Record<string, unknown> & {
169
+ sku?: string;
170
+ default_media_url?: string;
171
+ thumbnailUrl?: string;
172
+ metadata?: Record<string, unknown> | null;
173
+ tax_rate?: number | null;
174
+ taxRate?: number | null;
175
+ };
176
+
177
+ type ApiPricingMetadata = {
178
+ tax_rate_id?: string;
179
+ taxRateId?: string;
180
+ tax_rate?: number | null;
181
+ taxRate?: number | null;
182
+ };
183
+
184
+ type LineMetadataRecord = {
185
+ lineMode?: string;
186
+ customLine?: boolean | null;
187
+ priceMode?: string;
188
+ taxRateId?: string;
189
+ priceId?: string;
190
+ productTitle?: string;
191
+ productSku?: string;
192
+ productThumbnail?: string;
193
+ variantTitle?: string;
194
+ variantSku?: string;
195
+ variantThumbnail?: string;
196
+ };
197
+
198
+ type CatalogSnapshotRecord = Record<string, unknown> & {
199
+ product?: Record<string, unknown> | null;
200
+ variant?: Record<string, unknown> | null;
201
+ };
202
+
203
+ type SnapshotEntity = {
204
+ title?: string;
205
+ sku?: string;
206
+ thumbnailUrl?: string;
207
+ thumbnail_url?: string;
208
+ taxRate?: number | null;
209
+ taxRateId?: string;
210
+ };
99
211
 
100
212
  type SalesLineDialogProps = {
101
- open: boolean
102
- kind: 'order' | 'quote'
103
- documentId: string
104
- currencyCode: string | null | undefined
105
- organizationId: string | null
106
- tenantId: string | null
107
- initialLine?: SalesLineRecord | null
108
- onOpenChange: (open: boolean) => void
109
- onSaved?: () => Promise<void> | void
110
- }
213
+ open: boolean;
214
+ kind: "order" | "quote";
215
+ documentId: string;
216
+ currencyCode: string | null | undefined;
217
+ organizationId: string | null;
218
+ tenantId: string | null;
219
+ initialLine?: SalesLineRecord | null;
220
+ onOpenChange: (open: boolean) => void;
221
+ onSaved?: () => Promise<void> | void;
222
+ };
111
223
 
112
224
  const defaultForm = (currencyCode?: string | null): LineFormState => ({
113
- lineMode: 'catalog',
225
+ lineMode: "catalog",
114
226
  productId: null,
115
227
  variantId: null,
116
- quantity: '1',
228
+ quantity: "1",
229
+ quantityUnit: null,
117
230
  priceId: null,
118
- priceMode: 'gross',
119
- unitPrice: '',
231
+ priceMode: "gross",
232
+ unitPrice: "",
120
233
  taxRate: null,
121
234
  taxRateId: null,
122
- name: '',
235
+ name: "",
123
236
  currencyCode: currencyCode ?? null,
124
237
  catalogSnapshot: null,
125
238
  customFieldSetId: null,
126
239
  statusEntryId: null,
127
- })
240
+ });
241
+
242
+ const UNIT_PRICE_INPUT_SCALE = 4;
128
243
 
129
- function buildPriceScopeReason(item: Record<string, unknown>, t: (k: string, f: string) => string): {
130
- reason: string | null
131
- tags: string[]
244
+ function buildPriceScopeReason(
245
+ item: Record<string, unknown>,
246
+ t: (k: string, f: string) => string,
247
+ ): {
248
+ reason: string | null;
249
+ tags: string[];
132
250
  } {
133
- const tags: string[] = []
134
- const add = (key: string) => tags.push(key)
135
- if (item.channel_id || item.channelId) add(t('sales.documents.items.priceScope.channel', 'Channel'))
136
- if (item.offer_id || item.offerId) add(t('sales.documents.items.priceScope.offer', 'Offer'))
137
- if (item.variant_id || item.variantId) add(t('sales.documents.items.priceScope.variant', 'Variant'))
138
- if (item.customer_group_id || item.customerGroupId) add(t('sales.documents.items.priceScope.customerGroup', 'Customer group'))
139
- if (item.customer_id || item.customerId) add(t('sales.documents.items.priceScope.customer', 'Customer'))
140
- if (item.user_group_id || item.userGroupId) add(t('sales.documents.items.priceScope.userGroup', 'User group'))
141
- if (item.user_id || item.userId) add(t('sales.documents.items.priceScope.user', 'User'))
142
- const minQty = normalizeNumber((item as any).min_quantity, Number.NaN)
143
- const maxQty = normalizeNumber((item as any).max_quantity, Number.NaN)
251
+ const tags: string[] = [];
252
+ const add = (key: string) => tags.push(key);
253
+ if (item.channel_id || item.channelId)
254
+ add(t("sales.documents.items.priceScope.channel", "Channel"));
255
+ if (item.offer_id || item.offerId)
256
+ add(t("sales.documents.items.priceScope.offer", "Offer"));
257
+ if (item.variant_id || item.variantId)
258
+ add(t("sales.documents.items.priceScope.variant", "Variant"));
259
+ if (item.customer_group_id || item.customerGroupId)
260
+ add(t("sales.documents.items.priceScope.customerGroup", "Customer group"));
261
+ if (item.customer_id || item.customerId)
262
+ add(t("sales.documents.items.priceScope.customer", "Customer"));
263
+ if (item.user_group_id || item.userGroupId)
264
+ add(t("sales.documents.items.priceScope.userGroup", "User group"));
265
+ if (item.user_id || item.userId)
266
+ add(t("sales.documents.items.priceScope.user", "User"));
267
+ const minQty = normalizeNumber((item as ApiPriceItem).min_quantity, Number.NaN);
268
+ const maxQty = normalizeNumber((item as ApiPriceItem).max_quantity, Number.NaN);
144
269
  if (Number.isFinite(minQty) || Number.isFinite(maxQty)) {
145
- add(
146
- t(
147
- 'sales.documents.items.priceScope.quantity',
148
- 'Quantity',
149
- ),
150
- )
270
+ add(t("sales.documents.items.priceScope.quantity", "Quantity"));
151
271
  }
152
- if ((item as any).starts_at || (item as any).ends_at) {
153
- add(t('sales.documents.items.priceScope.schedule', 'Scheduled'))
272
+ if ((item as ApiPriceItem).starts_at || (item as ApiPriceItem).ends_at) {
273
+ add(t("sales.documents.items.priceScope.schedule", "Scheduled"));
154
274
  }
155
- if (tags.length === 0) return { reason: null, tags }
156
- return { reason: tags.join(''), tags }
275
+ if (tags.length === 0) return { reason: null, tags };
276
+ return { reason: tags.join(""), tags };
157
277
  }
158
278
 
159
279
  function buildPlaceholder(label?: string | null) {
160
280
  return (
161
281
  <div className="flex h-8 w-8 items-center justify-center rounded border bg-muted text-[10px] uppercase text-muted-foreground">
162
- {(label ?? '').slice(0, 2) || ''}
282
+ {(label ?? "").slice(0, 2) || ""}
163
283
  </div>
164
- )
284
+ );
285
+ }
286
+
287
+ function normalizeUnitCode(value: unknown): string | null {
288
+ return canonicalizeUnitCode(value);
289
+ }
290
+
291
+ function getRecordBoolean(
292
+ record: Record<string, unknown>,
293
+ fallback: boolean,
294
+ ...keys: string[]
295
+ ): boolean {
296
+ for (const key of keys) {
297
+ const val = record[key];
298
+ if (typeof val === "boolean") return val;
299
+ }
300
+ return fallback;
301
+ }
302
+
303
+ function getUomProductFields(item: Record<string, unknown>) {
304
+ return {
305
+ defaultUnit: normalizeUnitCode(item.default_unit ?? item.defaultUnit),
306
+ defaultSalesUnit: normalizeUnitCode(
307
+ item.default_sales_unit ?? item.defaultSalesUnit,
308
+ ),
309
+ defaultSalesUnitQuantity: normalizeNumber(
310
+ item.default_sales_unit_quantity ?? item.defaultSalesUnitQuantity,
311
+ Number.NaN,
312
+ ),
313
+ };
314
+ }
315
+
316
+ function getUomConversionFields(row: Record<string, unknown>) {
317
+ return {
318
+ unitCode: normalizeUnitCode(row.unit_code ?? row.unitCode),
319
+ isActive: getRecordBoolean(row, true, "is_active", "isActive"),
320
+ toBaseFactor: normalizeNumber(
321
+ row.to_base_factor ?? row.toBaseFactor,
322
+ Number.NaN,
323
+ ),
324
+ };
325
+ }
326
+
327
+ function normalizeQuantityPreview(value: number): number {
328
+ if (!Number.isFinite(value)) return 0;
329
+ return Math.round(value * 1_000_000) / 1_000_000;
330
+ }
331
+
332
+ function normalizeUnitPriceInputValue(value: number): string {
333
+ if (!Number.isFinite(value)) return "";
334
+ const factor = 10 ** UNIT_PRICE_INPUT_SCALE;
335
+ const rounded = Math.round((value + Number.EPSILON) * factor) / factor;
336
+ if (!Number.isFinite(rounded)) return "";
337
+ return rounded.toString();
165
338
  }
166
339
 
167
340
  export function LineItemDialog({
@@ -175,209 +348,271 @@ export function LineItemDialog({
175
348
  onOpenChange,
176
349
  onSaved,
177
350
  }: SalesLineDialogProps) {
178
- const t = useT()
179
- const scope = useOrganizationScopeDetail()
180
- const resolvedOrganizationId = organizationId ?? scope.organizationId ?? null
181
- const resolvedTenantId = tenantId ?? scope.tenantId ?? null
182
- const [initialValues, setInitialValues] = React.useState<LineFormState>(() => defaultForm(currencyCode))
183
- const [lineMode, setLineMode] = React.useState<'catalog' | 'custom'>(defaultForm(currencyCode).lineMode)
184
- const [productOption, setProductOption] = React.useState<ProductOption | null>(null)
185
- const [variantOption, setVariantOption] = React.useState<VariantOption | null>(null)
186
- const [priceOptions, setPriceOptions] = React.useState<PriceOption[]>([])
187
- const [priceLoading, setPriceLoading] = React.useState(false)
188
- const [formResetKey, setFormResetKey] = React.useState(0)
189
- const [editingId, setEditingId] = React.useState<string | null>(null)
190
- const [taxRates, setTaxRates] = React.useState<TaxRateOption[]>([])
191
- const [lineStatuses, setLineStatuses] = React.useState<StatusOption[]>([])
192
- const [, setLineStatusLoading] = React.useState(false)
193
- const productOptionsRef = React.useRef<Map<string, ProductOption>>(new Map())
194
- const variantOptionsRef = React.useRef<Map<string, VariantOption>>(new Map())
195
- const taxRatesRef = React.useRef<TaxRateOption[]>([])
196
- const defaultTaxRateRef = React.useRef<TaxRateOption | null>(null)
197
- const dialogContentRef = React.useRef<HTMLDivElement | null>(null)
351
+ const t = useT();
352
+ const scope = useOrganizationScopeDetail();
353
+ const resolvedOrganizationId = organizationId ?? scope.organizationId ?? null;
354
+ const resolvedTenantId = tenantId ?? scope.tenantId ?? null;
355
+ const [initialValues, setInitialValues] = React.useState<LineFormState>(() =>
356
+ defaultForm(currencyCode),
357
+ );
358
+ const [lineMode, setLineMode] = React.useState<"catalog" | "custom">(
359
+ defaultForm(currencyCode).lineMode,
360
+ );
361
+ const [productOption, setProductOption] =
362
+ React.useState<ProductOption | null>(null);
363
+ const [variantOption, setVariantOption] =
364
+ React.useState<VariantOption | null>(null);
365
+ const [priceOptions, setPriceOptions] = React.useState<PriceOption[]>([]);
366
+ const [priceLoading, setPriceLoading] = React.useState(false);
367
+ const [formResetKey, setFormResetKey] = React.useState(0);
368
+ const [editingId, setEditingId] = React.useState<string | null>(null);
369
+ const [taxRates, setTaxRates] = React.useState<TaxRateOption[]>([]);
370
+ const [lineStatuses, setLineStatuses] = React.useState<StatusOption[]>([]);
371
+ const [unitOptions, setUnitOptions] = React.useState<UnitOption[]>([]);
372
+ const [, setLineStatusLoading] = React.useState(false);
373
+ const productOptionsRef = React.useRef<Map<string, ProductOption>>(new Map());
374
+ const variantOptionsRef = React.useRef<Map<string, VariantOption>>(new Map());
375
+ const taxRatesRef = React.useRef<TaxRateOption[]>([]);
376
+ const defaultTaxRateRef = React.useRef<TaxRateOption | null>(null);
377
+ const dialogContentRef = React.useRef<HTMLDivElement | null>(null);
198
378
 
199
379
  const resourcePath = React.useMemo(
200
- () => (kind === 'order' ? 'sales/order-lines' : 'sales/quote-lines'),
380
+ () => (kind === "order" ? "sales/order-lines" : "sales/quote-lines"),
201
381
  [kind],
202
- )
203
- const documentKey = kind === 'order' ? 'orderId' : 'quoteId'
204
- const customFieldEntityId = kind === 'order' ? E.sales.sales_order_line : E.sales.sales_quote_line
382
+ );
383
+ const documentKey = kind === "order" ? "orderId" : "quoteId";
384
+ const customFieldEntityId =
385
+ kind === "order" ? E.sales.sales_order_line : E.sales.sales_quote_line;
205
386
 
206
387
  const taxRateMap = React.useMemo(
207
388
  () =>
208
389
  taxRates.reduce<Map<string, TaxRateOption>>((acc, rate) => {
209
- acc.set(rate.id, rate)
210
- return acc
390
+ acc.set(rate.id, rate);
391
+ return acc;
211
392
  }, new Map()),
212
- [taxRates]
213
- )
393
+ [taxRates],
394
+ );
214
395
 
215
396
  const findTaxRateIdByValue = React.useCallback(
216
397
  (value: number | null | undefined): string | null => {
217
- const numeric = normalizeNumber(value, Number.NaN)
218
- if (!Number.isFinite(numeric)) return null
398
+ const numeric = normalizeNumber(value, Number.NaN);
399
+ if (!Number.isFinite(numeric)) return null;
219
400
  const match = taxRatesRef.current.find(
220
- (rate) => Number.isFinite(rate.rate) && Math.abs((rate.rate as number) - numeric) < 0.0001
221
- )
222
- return match?.id ?? null
401
+ (rate) =>
402
+ Number.isFinite(rate.rate) &&
403
+ Math.abs((rate.rate as number) - numeric) < 0.0001,
404
+ );
405
+ return match?.id ?? null;
223
406
  },
224
- []
225
- )
407
+ [],
408
+ );
226
409
 
227
410
  const resolveTaxSelection = React.useCallback(
228
- (source?: { taxRateId?: string | null; taxRate?: number | null } | null) => {
411
+ (
412
+ source?: { taxRateId?: string | null; taxRate?: number | null } | null,
413
+ ) => {
229
414
  const taxRateId =
230
- typeof source?.taxRateId === 'string' && source.taxRateId.trim().length ? source.taxRateId.trim() : null
231
- const rateFromId = taxRateId ? normalizeNumber(taxRateMap.get(taxRateId)?.rate, Number.NaN) : Number.NaN
232
- const numericRate = normalizeNumber(source?.taxRate, Number.NaN)
415
+ typeof source?.taxRateId === "string" && source.taxRateId.trim().length
416
+ ? source.taxRateId.trim()
417
+ : null;
418
+ const rateFromId = taxRateId
419
+ ? normalizeNumber(taxRateMap.get(taxRateId)?.rate, Number.NaN)
420
+ : Number.NaN;
421
+ const numericRate = normalizeNumber(source?.taxRate, Number.NaN);
233
422
  const resolvedRateId =
234
423
  taxRateId ??
235
- (Number.isFinite(numericRate) ? findTaxRateIdByValue(numericRate) : null)
424
+ (Number.isFinite(numericRate)
425
+ ? findTaxRateIdByValue(numericRate)
426
+ : null);
236
427
  const resolvedRate = Number.isFinite(rateFromId)
237
428
  ? rateFromId
238
429
  : Number.isFinite(numericRate)
239
430
  ? numericRate
240
- : null
241
- return { taxRateId: resolvedRateId, taxRate: resolvedRate }
431
+ : null;
432
+ return { taxRateId: resolvedRateId, taxRate: resolvedRate };
242
433
  },
243
- [findTaxRateIdByValue, taxRateMap]
244
- )
434
+ [findTaxRateIdByValue, taxRateMap],
435
+ );
245
436
 
246
437
  const hasTaxMetadata = React.useCallback(
247
- (source?: { taxRateId?: string | null; taxRate?: number | null } | null) => {
248
- if (!source) return false
249
- const id = typeof source.taxRateId === 'string' ? source.taxRateId.trim() : ''
250
- if (id.length) return true
251
- const numericRate = normalizeNumber(source.taxRate, Number.NaN)
252
- return Number.isFinite(numericRate)
438
+ (
439
+ source?: { taxRateId?: string | null; taxRate?: number | null } | null,
440
+ ) => {
441
+ if (!source) return false;
442
+ const id =
443
+ typeof source.taxRateId === "string" ? source.taxRateId.trim() : "";
444
+ if (id.length) return true;
445
+ const numericRate = normalizeNumber(source.taxRate, Number.NaN);
446
+ return Number.isFinite(numericRate);
253
447
  },
254
- []
255
- )
448
+ [],
449
+ );
256
450
 
257
451
  const resetForm = React.useCallback(
258
452
  (next?: Partial<LineFormState>) => {
259
- const base = { ...defaultForm(currencyCode), ...next }
260
- const defaultRate = defaultTaxRateRef.current
453
+ const base = { ...defaultForm(currencyCode), ...next };
454
+ const defaultRate = defaultTaxRateRef.current;
261
455
  if (!base.taxRateId && defaultRate) {
262
- base.taxRateId = defaultRate.id
456
+ base.taxRateId = defaultRate.id;
263
457
  base.taxRate = Number.isFinite(defaultRate.rate ?? null)
264
458
  ? (defaultRate.rate as number)
265
- : base.taxRate
459
+ : base.taxRate;
266
460
  }
267
- setInitialValues(base)
268
- setLineMode(base.lineMode)
269
- setProductOption(null)
270
- setVariantOption(null)
271
- setPriceOptions([])
272
- setEditingId(null)
273
- setFormResetKey((prev) => prev + 1)
461
+ setInitialValues(base);
462
+ setLineMode(base.lineMode);
463
+ setProductOption(null);
464
+ setVariantOption(null);
465
+ setPriceOptions([]);
466
+ setUnitOptions([]);
467
+ setEditingId(null);
468
+ setFormResetKey((prev) => prev + 1);
274
469
  },
275
470
  [currencyCode],
276
- )
471
+ );
277
472
 
278
473
  const closeDialog = React.useCallback(() => {
279
- onOpenChange(false)
280
- resetForm()
281
- }, [onOpenChange, resetForm])
474
+ onOpenChange(false);
475
+ resetForm();
476
+ }, [onOpenChange, resetForm]);
282
477
 
283
478
  const loadTaxRates = React.useCallback(async () => {
284
479
  try {
285
- const response = await apiCall<{ items?: Array<Record<string, unknown>> }>(
286
- '/api/sales/tax-rates?pageSize=200',
287
- undefined,
288
- { fallback: { items: [] } },
289
- )
290
- const items = Array.isArray(response.result?.items) ? response.result.items : []
480
+ const response = await apiCall<{
481
+ items?: Array<Record<string, unknown>>;
482
+ }>("/api/sales/tax-rates?pageSize=100", undefined, {
483
+ fallback: { items: [] },
484
+ });
485
+ const items = Array.isArray(response.result?.items)
486
+ ? response.result.items
487
+ : [];
291
488
  const parsed = items
292
489
  .map<TaxRateOption | null>((item) => {
293
- const id = typeof item.id === 'string' ? item.id : null
490
+ const id = typeof item.id === "string" ? item.id : null;
294
491
  const name =
295
- typeof item.name === 'string' && item.name.trim().length
492
+ typeof item.name === "string" && item.name.trim().length
296
493
  ? item.name.trim()
297
- : typeof item.code === 'string'
494
+ : typeof item.code === "string"
298
495
  ? item.code
299
- : null
300
- if (!id || !name) return null
301
- const rate = normalizeNumber((item as any).rate)
496
+ : null;
497
+ if (!id || !name) return null;
498
+ const rate = normalizeNumber((item as ApiTaxRateItem).rate);
302
499
  const code =
303
- typeof (item as any).code === 'string' && (item as any).code.trim().length
304
- ? (item as any).code.trim()
305
- : null
306
- const isDefault = Boolean((item as any).isDefault ?? (item as any).is_default)
307
- return { id, name, code, rate: Number.isFinite(rate) ? rate : null, isDefault }
500
+ typeof (item as ApiTaxRateItem).code === "string" &&
501
+ (item as ApiTaxRateItem).code?.trim().length
502
+ ? (item as ApiTaxRateItem).code?.trim() ?? null
503
+ : null;
504
+ const isDefault = Boolean(
505
+ (item as ApiTaxRateItem).isDefault ?? (item as ApiTaxRateItem).is_default,
506
+ );
507
+ return {
508
+ id,
509
+ name,
510
+ code,
511
+ rate: Number.isFinite(rate) ? rate : null,
512
+ isDefault,
513
+ };
308
514
  })
309
- .filter((entry): entry is TaxRateOption => Boolean(entry))
310
- taxRatesRef.current = parsed
311
- defaultTaxRateRef.current = parsed.find((rate) => rate.isDefault) ?? null
312
- setTaxRates(parsed)
313
- return parsed
515
+ .filter((entry): entry is TaxRateOption => Boolean(entry));
516
+ taxRatesRef.current = parsed;
517
+ defaultTaxRateRef.current = parsed.find((rate) => rate.isDefault) ?? null;
518
+ setTaxRates(parsed);
519
+ return parsed;
314
520
  } catch (err) {
315
- console.error('sales.tax-rates.fetch', err)
316
- taxRatesRef.current = []
317
- defaultTaxRateRef.current = null
318
- setTaxRates([])
319
- return []
521
+ console.error("sales.tax-rates.fetch", err);
522
+ taxRatesRef.current = [];
523
+ defaultTaxRateRef.current = null;
524
+ setTaxRates([]);
525
+ return [];
320
526
  }
321
- }, [])
527
+ }, []);
322
528
 
323
529
  const loadProductOptions = React.useCallback(
324
530
  async (query?: string): Promise<LookupSelectItem[]> => {
325
- const params = new URLSearchParams({ pageSize: '8' })
326
- if (query && query.trim().length) params.set('search', query.trim())
327
- const response = await apiCall<{ items?: Array<Record<string, unknown>> }>(
328
- `/api/catalog/products?${params.toString()}`,
329
- undefined,
330
- { fallback: { items: [] } },
331
- )
332
- const items = Array.isArray(response.result?.items) ? response.result?.items ?? [] : []
333
- const needle = query?.trim().toLowerCase() ?? ''
531
+ const params = new URLSearchParams({ pageSize: "8" });
532
+ if (query && query.trim().length) params.set("search", query.trim());
533
+ const response = await apiCall<{
534
+ items?: Array<Record<string, unknown>>;
535
+ }>(`/api/catalog/products?${params.toString()}`, undefined, {
536
+ fallback: { items: [] },
537
+ });
538
+ const items = Array.isArray(response.result?.items)
539
+ ? (response.result?.items ?? [])
540
+ : [];
541
+ const needle = query?.trim().toLowerCase() ?? "";
334
542
  return items
335
543
  .map((item) => {
336
- const id = typeof item.id === 'string' ? item.id : null
337
- if (!id) return null
544
+ const id = typeof item.id === "string" ? item.id : null;
545
+ if (!id) return null;
546
+ const productItem = item as ApiProductItem;
338
547
  const title =
339
- typeof item.title === 'string'
548
+ typeof item.title === "string"
340
549
  ? item.title
341
- : typeof (item as any).name === 'string'
342
- ? (item as any).name
343
- : id
344
- const sku = typeof (item as any).sku === 'string' ? (item as any).sku : null
550
+ : typeof productItem.name === "string"
551
+ ? productItem.name
552
+ : id;
553
+ const sku =
554
+ typeof productItem.sku === "string" ? productItem.sku : null;
345
555
  const thumbnail =
346
- typeof (item as any).default_media_url === 'string'
347
- ? (item as any).default_media_url
348
- : typeof (item as any).defaultMediaUrl === 'string'
349
- ? (item as any).defaultMediaUrl
350
- : null
351
- const pricing = typeof (item as any).pricing === 'object' && (item as any).pricing ? (item as any).pricing : null
352
- const metadata = typeof (item as any).metadata === 'object' && (item as any).metadata ? (item as any).metadata : null
556
+ typeof productItem.default_media_url === "string"
557
+ ? productItem.default_media_url
558
+ : typeof productItem.defaultMediaUrl === "string"
559
+ ? productItem.defaultMediaUrl
560
+ : null;
561
+ const pricing =
562
+ typeof productItem.pricing === "object" && productItem.pricing
563
+ ? productItem.pricing
564
+ : null;
565
+ const metadata =
566
+ typeof productItem.metadata === "object" && productItem.metadata
567
+ ? productItem.metadata
568
+ : null;
569
+ const pricingMeta = pricing as ApiPricingMetadata | null;
570
+ const metaMeta = metadata as ApiPricingMetadata | null;
353
571
  const pricingTaxRateId =
354
- typeof (pricing as any)?.tax_rate_id === 'string' && (pricing as any).tax_rate_id.trim().length
355
- ? (pricing as any).tax_rate_id.trim()
356
- : typeof (pricing as any)?.taxRateId === 'string' && (pricing as any).taxRateId.trim().length
357
- ? (pricing as any).taxRateId.trim()
358
- : null
572
+ typeof pricingMeta?.tax_rate_id === "string" &&
573
+ pricingMeta.tax_rate_id.trim().length
574
+ ? pricingMeta.tax_rate_id.trim()
575
+ : typeof pricingMeta?.taxRateId === "string" &&
576
+ pricingMeta.taxRateId.trim().length
577
+ ? pricingMeta.taxRateId.trim()
578
+ : null;
359
579
  const metaTaxRateId =
360
- typeof (metadata as any)?.taxRateId === 'string' && (metadata as any).taxRateId.trim().length
361
- ? (metadata as any).taxRateId.trim()
362
- : typeof (metadata as any)?.tax_rate_id === 'string' && (metadata as any).tax_rate_id.trim().length
363
- ? (metadata as any).tax_rate_id.trim()
364
- : null
580
+ typeof metaMeta?.taxRateId === "string" &&
581
+ metaMeta.taxRateId.trim().length
582
+ ? metaMeta.taxRateId.trim()
583
+ : typeof metaMeta?.tax_rate_id === "string" &&
584
+ metaMeta.tax_rate_id.trim().length
585
+ ? metaMeta.tax_rate_id.trim()
586
+ : null;
365
587
  const taxRateValue = normalizeNumber(
366
- (pricing as any)?.tax_rate ?? (pricing as any)?.taxRate ?? (item as any).tax_rate ?? (item as any).taxRate,
367
- Number.NaN
368
- )
588
+ pricingMeta?.tax_rate ??
589
+ pricingMeta?.taxRate ??
590
+ productItem.tax_rate ??
591
+ productItem.taxRate,
592
+ Number.NaN,
593
+ );
594
+ const uomFields = getUomProductFields(item);
595
+ const defaultUnit = uomFields.defaultUnit;
596
+ const defaultSalesUnit = uomFields.defaultSalesUnit;
597
+ const defaultSalesUnitQuantity = uomFields.defaultSalesUnitQuantity;
369
598
  const matches =
370
599
  !needle ||
371
600
  title.toLowerCase().includes(needle) ||
372
- (sku ? sku.toLowerCase().includes(needle) : false)
373
- if (!matches) return null
601
+ (sku ? sku.toLowerCase().includes(needle) : false);
602
+ if (!matches) return null;
374
603
  return {
375
604
  id,
376
605
  title,
377
606
  subtitle: sku ?? undefined,
378
- icon: thumbnail
379
- ? <img src={thumbnail} alt={title} className="h-8 w-8 rounded object-cover" />
380
- : buildPlaceholder(title),
607
+ icon: thumbnail ? (
608
+ <img
609
+ src={thumbnail}
610
+ alt={title}
611
+ className="h-8 w-8 rounded object-cover"
612
+ />
613
+ ) : (
614
+ buildPlaceholder(title)
615
+ ),
381
616
  option: {
382
617
  id,
383
618
  title,
@@ -385,57 +620,91 @@ export function LineItemDialog({
385
620
  thumbnailUrl: thumbnail,
386
621
  taxRateId: pricingTaxRateId ?? metaTaxRateId ?? null,
387
622
  taxRate: Number.isFinite(taxRateValue) ? taxRateValue : null,
623
+ defaultUnit,
624
+ defaultSalesUnit,
625
+ defaultSalesUnitQuantity: Number.isFinite(
626
+ defaultSalesUnitQuantity,
627
+ )
628
+ ? defaultSalesUnitQuantity
629
+ : null,
388
630
  } satisfies ProductOption,
389
- } as LookupSelectItem & { option: ProductOption }
631
+ } as LookupSelectItem & { option: ProductOption };
390
632
  })
391
- .filter((entry): entry is LookupSelectItem & { option: ProductOption } => Boolean(entry))
633
+ .filter(
634
+ (entry): entry is LookupSelectItem & { option: ProductOption } =>
635
+ Boolean(entry),
636
+ )
392
637
  .map((entry) => {
393
- productOptionsRef.current.set(entry.option.id, entry.option)
394
- return entry
395
- })
638
+ productOptionsRef.current.set(entry.option.id, entry.option);
639
+ return entry;
640
+ });
396
641
  },
397
642
  [],
398
- )
643
+ );
399
644
 
400
645
  const loadVariantOptions = React.useCallback(
401
- async (productId: string, fallbackThumbnail?: string | null): Promise<LookupSelectItem[]> => {
402
- if (!productId) return []
403
- const response = await apiCall<{ items?: Array<Record<string, unknown>> }>(
646
+ async (
647
+ productId: string,
648
+ fallbackThumbnail?: string | null,
649
+ ): Promise<LookupSelectItem[]> => {
650
+ if (!productId) return [];
651
+ const response = await apiCall<{
652
+ items?: Array<Record<string, unknown>>;
653
+ }>(
404
654
  `/api/catalog/variants?productId=${encodeURIComponent(productId)}&pageSize=50`,
405
655
  undefined,
406
656
  { fallback: { items: [] } },
407
- )
408
- const items = Array.isArray(response.result?.items) ? response.result.items : []
657
+ );
658
+ const items = Array.isArray(response.result?.items)
659
+ ? response.result.items
660
+ : [];
409
661
  return items
410
662
  .map((item) => {
411
- const id = typeof item.id === 'string' ? item.id : null
412
- if (!id) return null
413
- const title = typeof item.name === 'string' ? item.name : id
414
- const sku = typeof (item as any).sku === 'string' ? (item as any).sku : null
415
- const metadata = typeof (item as any).metadata === 'object' && (item as any).metadata ? (item as any).metadata : null
663
+ const id = typeof item.id === "string" ? item.id : null;
664
+ if (!id) return null;
665
+ const title = typeof item.name === "string" ? item.name : id;
666
+ const variantItem = item as ApiVariantItem;
667
+ const sku =
668
+ typeof variantItem.sku === "string" ? variantItem.sku : null;
669
+ const metadata =
670
+ typeof variantItem.metadata === "object" && variantItem.metadata
671
+ ? variantItem.metadata
672
+ : null;
673
+ const variantMeta = metadata as ApiPricingMetadata | null;
416
674
  const variantTaxRateId =
417
- typeof (metadata as any)?.taxRateId === 'string' && (metadata as any).taxRateId.trim().length
418
- ? (metadata as any).taxRateId.trim()
419
- : typeof (metadata as any)?.tax_rate_id === 'string' && (metadata as any).tax_rate_id.trim().length
420
- ? (metadata as any).tax_rate_id.trim()
421
- : null
675
+ typeof variantMeta?.taxRateId === "string" &&
676
+ variantMeta.taxRateId.trim().length
677
+ ? variantMeta.taxRateId.trim()
678
+ : typeof variantMeta?.tax_rate_id === "string" &&
679
+ variantMeta.tax_rate_id.trim().length
680
+ ? variantMeta.tax_rate_id.trim()
681
+ : null;
422
682
  const variantTaxRate = normalizeNumber(
423
- (item as any).tax_rate ?? (item as any).taxRate ?? (metadata as any)?.tax_rate ?? (metadata as any)?.taxRate,
424
- Number.NaN
425
- )
683
+ variantItem.tax_rate ??
684
+ variantItem.taxRate ??
685
+ variantMeta?.tax_rate ??
686
+ variantMeta?.taxRate,
687
+ Number.NaN,
688
+ );
426
689
  const thumbnail =
427
- typeof (item as any).default_media_url === 'string'
428
- ? (item as any).default_media_url
429
- : typeof (item as any).thumbnailUrl === 'string'
430
- ? (item as any).thumbnailUrl
431
- : fallbackThumbnail ?? null
690
+ typeof variantItem.default_media_url === "string"
691
+ ? variantItem.default_media_url
692
+ : typeof variantItem.thumbnailUrl === "string"
693
+ ? variantItem.thumbnailUrl
694
+ : (fallbackThumbnail ?? null);
432
695
  return {
433
696
  id,
434
697
  title,
435
698
  subtitle: sku ?? undefined,
436
- icon: thumbnail
437
- ? <img src={thumbnail} alt={title} className="h-8 w-8 rounded object-cover" />
438
- : buildPlaceholder(title),
699
+ icon: thumbnail ? (
700
+ <img
701
+ src={thumbnail}
702
+ alt={title}
703
+ className="h-8 w-8 rounded object-cover"
704
+ />
705
+ ) : (
706
+ buildPlaceholder(title)
707
+ ),
439
708
  option: {
440
709
  id,
441
710
  title,
@@ -444,114 +713,240 @@ export function LineItemDialog({
444
713
  taxRateId: variantTaxRateId,
445
714
  taxRate: Number.isFinite(variantTaxRate) ? variantTaxRate : null,
446
715
  } satisfies VariantOption,
447
- } as LookupSelectItem & { option: VariantOption }
716
+ } as LookupSelectItem & { option: VariantOption };
448
717
  })
449
- .filter((entry): entry is LookupSelectItem & { option: VariantOption } => Boolean(entry))
718
+ .filter(
719
+ (entry): entry is LookupSelectItem & { option: VariantOption } =>
720
+ Boolean(entry),
721
+ )
450
722
  .map((entry) => {
451
- variantOptionsRef.current.set(entry.option.id, entry.option)
452
- return entry
453
- })
723
+ variantOptionsRef.current.set(entry.option.id, entry.option);
724
+ return entry;
725
+ });
454
726
  },
455
727
  [],
456
- )
728
+ );
457
729
 
458
- const loadPrices = React.useCallback(
459
- async (productId: string | null, variantId: string | null) => {
730
+ const loadProductUnits = React.useCallback(
731
+ async (
732
+ productId: string | null,
733
+ option: ProductOption | null,
734
+ ): Promise<UnitOption[]> => {
460
735
  if (!productId) {
461
- setPriceOptions([])
462
- return []
736
+ setUnitOptions([]);
737
+ return [];
738
+ }
739
+ const map = new Map<string, UnitOption>();
740
+ let baseUnit = normalizeUnitCode(option?.defaultUnit);
741
+ let defaultSalesUnit = normalizeUnitCode(option?.defaultSalesUnit);
742
+ if (!baseUnit || !defaultSalesUnit) {
743
+ try {
744
+ const response = await apiCall<{
745
+ items?: Array<Record<string, unknown>>;
746
+ }>(
747
+ `/api/catalog/products?id=${encodeURIComponent(productId)}&pageSize=1`,
748
+ undefined,
749
+ { fallback: { items: [] } },
750
+ );
751
+ const records = Array.isArray(response.result?.items)
752
+ ? response.result.items
753
+ : [];
754
+ const matched =
755
+ records.find((entry) => entry.id === productId) ??
756
+ records[0] ??
757
+ null;
758
+ if (matched) {
759
+ const matchedUom = getUomProductFields(matched);
760
+ baseUnit = baseUnit ?? matchedUom.defaultUnit;
761
+ defaultSalesUnit = defaultSalesUnit ?? matchedUom.defaultSalesUnit;
762
+ }
763
+ } catch (err) {
764
+ console.error("sales.document.items.loadProductUnits.hydration", err);
765
+ }
766
+ }
767
+ if (baseUnit) {
768
+ map.set(baseUnit, { code: baseUnit, toBaseFactor: 1, isBase: true });
463
769
  }
464
- setPriceLoading(true)
465
770
  try {
466
- const params = new URLSearchParams({ productId, pageSize: '20' })
467
- if (variantId) params.set('variantId', variantId)
468
- const response = await apiCall<{ items?: Array<Record<string, unknown>> }>(
469
- `/api/catalog/prices?${params.toString()}`,
771
+ const response = await apiCall<{
772
+ items?: Array<Record<string, unknown>>;
773
+ }>(
774
+ `/api/catalog/product-unit-conversions?productId=${encodeURIComponent(productId)}&pageSize=100`,
470
775
  undefined,
471
776
  { fallback: { items: [] } },
472
- )
473
- const items = Array.isArray(response.result?.items) ? response.result.items : []
777
+ );
778
+ const rows = Array.isArray(response.result?.items)
779
+ ? response.result.items
780
+ : [];
781
+ for (const row of rows) {
782
+ const conv = getUomConversionFields(row);
783
+ if (!conv.unitCode) continue;
784
+ if (!conv.isActive) continue;
785
+ map.set(conv.unitCode, {
786
+ code: conv.unitCode,
787
+ toBaseFactor:
788
+ Number.isFinite(conv.toBaseFactor) && conv.toBaseFactor > 0
789
+ ? conv.toBaseFactor
790
+ : null,
791
+ isBase: baseUnit ? conv.unitCode === baseUnit : false,
792
+ });
793
+ }
794
+ } catch (err) {
795
+ console.error("sales.document.items.loadUnits", err);
796
+ }
797
+ if (defaultSalesUnit && !map.has(defaultSalesUnit)) {
798
+ map.set(defaultSalesUnit, {
799
+ code: defaultSalesUnit,
800
+ toBaseFactor: baseUnit && defaultSalesUnit === baseUnit ? 1 : null,
801
+ isBase: baseUnit ? defaultSalesUnit === baseUnit : false,
802
+ });
803
+ }
804
+ const nextOptions = Array.from(map.values()).sort((left, right) =>
805
+ left.code.localeCompare(right.code),
806
+ );
807
+ setUnitOptions(nextOptions);
808
+ return nextOptions;
809
+ },
810
+ [],
811
+ );
812
+
813
+ const loadPrices = React.useCallback(
814
+ async (
815
+ productId: string | null,
816
+ variantId: string | null,
817
+ quantity?: string | number | null,
818
+ quantityUnit?: string | null,
819
+ ) => {
820
+ if (!productId) {
821
+ setPriceOptions([]);
822
+ return [];
823
+ }
824
+ setPriceLoading(true);
825
+ try {
826
+ const params = new URLSearchParams({ productId, pageSize: "20" });
827
+ if (variantId) params.set("variantId", variantId);
828
+ const quantityValue = normalizeNumber(quantity, Number.NaN);
829
+ if (Number.isFinite(quantityValue) && quantityValue > 0) {
830
+ params.set("quantity", String(quantityValue));
831
+ }
832
+ const quantityUnitCode = normalizeUnitCode(quantityUnit);
833
+ if (quantityUnitCode) {
834
+ params.set("quantityUnit", quantityUnitCode);
835
+ }
836
+ const response = await apiCall<{
837
+ items?: Array<Record<string, unknown>>;
838
+ }>(`/api/catalog/prices?${params.toString()}`, undefined, {
839
+ fallback: { items: [] },
840
+ });
841
+ const items = Array.isArray(response.result?.items)
842
+ ? response.result.items
843
+ : [];
474
844
  const mapped: PriceOption[] = items
475
845
  .map((item) => {
476
- const id = typeof item.id === 'string' ? item.id : null
477
- if (!id) return null
478
- const amountNet = normalizeNumber((item as any).unit_price_net, null as any)
479
- const amountGross = normalizeNumber((item as any).unit_price_gross, null as any)
846
+ const id = typeof item.id === "string" ? item.id : null;
847
+ if (!id) return null;
848
+ const amountNetRaw = normalizeNumber(
849
+ (item as ApiPriceItem).unit_price_net,
850
+ Number.NaN,
851
+ );
852
+ const amountGrossRaw = normalizeNumber(
853
+ (item as ApiPriceItem).unit_price_gross,
854
+ Number.NaN,
855
+ );
856
+ const amountNet = Number.isFinite(amountNetRaw)
857
+ ? amountNetRaw
858
+ : null;
859
+ const amountGross = Number.isFinite(amountGrossRaw)
860
+ ? amountGrossRaw
861
+ : null;
480
862
  const currency =
481
- typeof (item as any).currency_code === 'string'
482
- ? (item as any).currency_code
483
- : typeof (item as any).currencyCode === 'string'
484
- ? (item as any).currencyCode
485
- : null
863
+ typeof (item as ApiPriceItem).currency_code === "string"
864
+ ? (item as ApiPriceItem).currency_code
865
+ : typeof (item as ApiPriceItem).currencyCode === "string"
866
+ ? (item as ApiPriceItem).currencyCode
867
+ : null;
486
868
  const displayMode =
487
- (item as any).display_mode === 'including-tax' || (item as any).display_mode === 'excluding-tax'
488
- ? (item as any).display_mode
489
- : (item as any).displayMode === 'including-tax' || (item as any).displayMode === 'excluding-tax'
490
- ? (item as any).displayMode
491
- : null
492
- const taxRate = normalizeNumber((item as any).tax_rate, null as any)
869
+ (item as ApiPriceItem).display_mode === "including-tax" ||
870
+ (item as ApiPriceItem).display_mode === "excluding-tax"
871
+ ? (item as ApiPriceItem).display_mode
872
+ : (item as ApiPriceItem).displayMode === "including-tax" ||
873
+ (item as ApiPriceItem).displayMode === "excluding-tax"
874
+ ? (item as ApiPriceItem).displayMode
875
+ : null;
876
+ const taxRateRaw = normalizeNumber(
877
+ (item as ApiPriceItem).tax_rate,
878
+ Number.NaN,
879
+ );
880
+ const taxRate = Number.isFinite(taxRateRaw) ? taxRateRaw : null;
493
881
  const priceKindId =
494
- typeof (item as any).price_kind_id === 'string'
495
- ? (item as any).price_kind_id
496
- : typeof (item as any).priceKindId === 'string'
497
- ? (item as any).priceKindId
498
- : null
882
+ typeof (item as ApiPriceItem).price_kind_id === "string"
883
+ ? (item as ApiPriceItem).price_kind_id
884
+ : typeof (item as ApiPriceItem).priceKindId === "string"
885
+ ? (item as ApiPriceItem).priceKindId
886
+ : null;
499
887
  const priceKindTitle =
500
- typeof (item as any).price_kind_title === 'string'
501
- ? (item as any).price_kind_title
502
- : typeof (item as any).priceKindTitle === 'string'
503
- ? (item as any).priceKindTitle
504
- : typeof (item as any).price_kind === 'object' &&
888
+ typeof (item as ApiPriceItem).price_kind_title === "string"
889
+ ? (item as ApiPriceItem).price_kind_title
890
+ : typeof (item as ApiPriceItem).priceKindTitle === "string"
891
+ ? (item as ApiPriceItem).priceKindTitle
892
+ : typeof (item as ApiPriceItem).price_kind === "object" &&
505
893
  item &&
506
- typeof (item as any).price_kind?.title === 'string'
507
- ? (item as any).price_kind.title
508
- : typeof (item as any).price_kind === 'object' &&
894
+ typeof (item as ApiPriceItem).price_kind?.title === "string"
895
+ ? (item as ApiPriceItem).price_kind!.title
896
+ : typeof (item as ApiPriceItem).price_kind === "object" &&
509
897
  item &&
510
- typeof (item as any).price_kind?.name === 'string'
511
- ? (item as any).price_kind.name
512
- : null
898
+ typeof (item as ApiPriceItem).price_kind?.name === "string"
899
+ ? (item as ApiPriceItem).price_kind!.name
900
+ : null;
513
901
  const priceKindCode =
514
- typeof (item as any).price_kind_code === 'string'
515
- ? (item as any).price_kind_code
516
- : typeof (item as any).priceKindCode === 'string'
517
- ? (item as any).priceKindCode
518
- : typeof (item as any).price_kind === 'object' &&
902
+ typeof (item as ApiPriceItem).price_kind_code === "string"
903
+ ? (item as ApiPriceItem).price_kind_code
904
+ : typeof (item as ApiPriceItem).priceKindCode === "string"
905
+ ? (item as ApiPriceItem).priceKindCode
906
+ : typeof (item as ApiPriceItem).price_kind === "object" &&
519
907
  item &&
520
- typeof (item as any).price_kind?.code === 'string'
521
- ? (item as any).price_kind.code
522
- : null
908
+ typeof (item as ApiPriceItem).price_kind?.code === "string"
909
+ ? (item as ApiPriceItem).price_kind!.code
910
+ : null;
523
911
  const resolvedPriceKindTitle =
524
912
  priceKindTitle ??
525
913
  priceKindCode ??
526
- (typeof (item as any).kind === 'string' ? (item as any).kind : null)
914
+ (typeof (item as ApiPriceItem).kind === "string"
915
+ ? (item as ApiPriceItem).kind
916
+ : null);
527
917
  const labelParts = [
528
- displayMode === 'including-tax' && amountGross !== null && currency
918
+ displayMode === "including-tax" &&
919
+ amountGross !== null &&
920
+ currency
529
921
  ? formatMoney(amountGross, currency)
530
922
  : null,
531
- displayMode === 'excluding-tax' && amountNet !== null && currency
923
+ displayMode === "excluding-tax" && amountNet !== null && currency
532
924
  ? formatMoney(amountNet, currency)
533
925
  : null,
534
926
  displayMode
535
- ? displayMode === 'including-tax'
536
- ? t('sales.documents.items.priceGross', 'Gross')
537
- : t('sales.documents.items.priceNet', 'Net')
927
+ ? displayMode === "including-tax"
928
+ ? t("sales.documents.items.priceGross", "Gross")
929
+ : t("sales.documents.items.priceNet", "Net")
538
930
  : null,
539
- ].filter(Boolean)
540
- const { reason, tags } = buildPriceScopeReason(item, (key, fallback) => t(key, fallback))
931
+ ].filter(Boolean);
932
+ const { reason, tags } = buildPriceScopeReason(
933
+ item,
934
+ (key, fallback) => t(key, fallback),
935
+ );
541
936
  const label =
542
937
  labelParts.length > 0
543
- ? labelParts.join('')
938
+ ? labelParts.join("")
544
939
  : amountGross !== null && currency
545
940
  ? formatMoney(amountGross, currency)
546
941
  : amountNet !== null && currency
547
942
  ? formatMoney(amountNet, currency)
548
- : id
943
+ : id;
549
944
  return {
550
945
  id,
551
946
  amountNet: amountNet ?? null,
552
947
  amountGross: amountGross ?? null,
553
948
  currencyCode: currency,
554
- displayMode: displayMode as PriceOption['displayMode'],
949
+ displayMode: displayMode as PriceOption["displayMode"],
555
950
  taxRate: Number.isFinite(taxRate) ? taxRate : null,
556
951
  label,
557
952
  priceKindId,
@@ -559,190 +954,383 @@ export function LineItemDialog({
559
954
  priceKindCode: priceKindCode ?? null,
560
955
  scopeReason: reason,
561
956
  scopeTags: tags,
562
- } as PriceOption
957
+ } as PriceOption;
563
958
  })
564
- .filter((entry): entry is PriceOption => Boolean(entry))
565
- setPriceOptions(mapped)
566
- return mapped
959
+ .filter((entry): entry is PriceOption => Boolean(entry));
960
+ setPriceOptions(mapped);
961
+ return mapped;
567
962
  } catch (err) {
568
- console.error('sales.document.items.loadPrices', err)
569
- return []
963
+ console.error("sales.document.items.loadPrices", err);
964
+ return [];
570
965
  } finally {
571
- setPriceLoading(false)
966
+ setPriceLoading(false);
572
967
  }
573
968
  },
574
969
  [t],
575
- )
970
+ );
971
+
972
+ const selectPriceAfterRefresh = React.useCallback(
973
+ (
974
+ prices: PriceOption[],
975
+ currentPriceId: string | null,
976
+ currentPriceKindId: string | null,
977
+ ): PriceOption | null => {
978
+ if (!prices.length) return null;
979
+ if (currentPriceId) {
980
+ const sameId = prices.find((entry) => entry.id === currentPriceId);
981
+ if (sameId) return sameId;
982
+ }
983
+ if (currentPriceKindId) {
984
+ const sameKind = prices.find(
985
+ (entry) => entry.priceKindId === currentPriceKindId,
986
+ );
987
+ if (sameKind) return sameKind;
988
+ }
989
+ return prices[0] ?? null;
990
+ },
991
+ [],
992
+ );
993
+
994
+ const resolveUnitPriceFactor = React.useCallback(
995
+ (quantityUnit: string | null | undefined): number => {
996
+ const normalized = normalizeUnitCode(quantityUnit);
997
+ if (!normalized) return 1;
998
+ const unit = unitOptions.find((entry) => entry.code === normalized) ?? null;
999
+ const factor = normalizeNumber(unit?.toBaseFactor, Number.NaN);
1000
+ if (!Number.isFinite(factor) || factor <= 0) return 1;
1001
+ return factor;
1002
+ },
1003
+ [unitOptions],
1004
+ );
1005
+
1006
+ const convertUnitPriceForUnitChange = React.useCallback(
1007
+ (
1008
+ rawUnitPrice: unknown,
1009
+ fromUnit: string | null | undefined,
1010
+ toUnit: string | null | undefined,
1011
+ ): string | null => {
1012
+ const amount = normalizeNumber(rawUnitPrice, Number.NaN);
1013
+ if (!Number.isFinite(amount) || amount <= 0) return null;
1014
+ const fromCode = normalizeUnitCode(fromUnit);
1015
+ const toCode = normalizeUnitCode(toUnit);
1016
+ if (!fromCode || !toCode || fromCode === toCode) return null;
1017
+ const fromFactor = resolveUnitPriceFactor(fromCode);
1018
+ const toFactor = resolveUnitPriceFactor(toCode);
1019
+ if (
1020
+ !Number.isFinite(fromFactor) ||
1021
+ !Number.isFinite(toFactor) ||
1022
+ fromFactor <= 0 ||
1023
+ toFactor <= 0
1024
+ ) {
1025
+ return null;
1026
+ }
1027
+ const baseAmount = amount / fromFactor;
1028
+ const convertedAmount = baseAmount * toFactor;
1029
+ if (!Number.isFinite(convertedAmount) || convertedAmount <= 0) return null;
1030
+ return normalizeUnitPriceInputValue(convertedAmount);
1031
+ },
1032
+ [resolveUnitPriceFactor],
1033
+ );
576
1034
 
577
- const loadLineStatuses = React.useCallback(async (): Promise<StatusOption[]> => {
578
- setLineStatusLoading(true)
1035
+ const applyPriceSelection = React.useCallback(
1036
+ (
1037
+ selected: PriceOption | null,
1038
+ setFormValue: ((id: string, value: unknown) => void) | undefined,
1039
+ options?: {
1040
+ fallbackTaxSource?: { taxRateId?: string | null; taxRate?: number | null } | null;
1041
+ quantityUnit?: string | null;
1042
+ },
1043
+ ) => {
1044
+ if (!setFormValue) return;
1045
+ if (selected) {
1046
+ const mode =
1047
+ selected.displayMode === "excluding-tax" ? "net" : "gross";
1048
+ const amountPerBaseUnit =
1049
+ mode === "net"
1050
+ ? (selected.amountNet ?? selected.amountGross ?? 0)
1051
+ : (selected.amountGross ?? selected.amountNet ?? 0);
1052
+ const factor = resolveUnitPriceFactor(options?.quantityUnit ?? null);
1053
+ const amount = Number.isFinite(amountPerBaseUnit * factor)
1054
+ ? amountPerBaseUnit * factor
1055
+ : amountPerBaseUnit;
1056
+ setFormValue("priceId", selected.id);
1057
+ setFormValue("priceMode", mode);
1058
+ setFormValue("unitPrice", normalizeUnitPriceInputValue(amount));
1059
+ setFormValue("taxRate", selected.taxRate ?? null);
1060
+ setFormValue("taxRateId", findTaxRateIdByValue(selected.taxRate));
1061
+ setFormValue(
1062
+ "currencyCode",
1063
+ selected.currencyCode ?? currencyCode ?? null,
1064
+ );
1065
+ return;
1066
+ }
1067
+ const fallbackTax = resolveTaxSelection(options?.fallbackTaxSource ?? null);
1068
+ setFormValue("taxRate", fallbackTax.taxRate ?? null);
1069
+ setFormValue("taxRateId", fallbackTax.taxRateId ?? null);
1070
+ },
1071
+ [currencyCode, findTaxRateIdByValue, resolveTaxSelection, resolveUnitPriceFactor],
1072
+ );
1073
+
1074
+ const loadLineStatuses = React.useCallback(async (): Promise<
1075
+ StatusOption[]
1076
+ > => {
1077
+ setLineStatusLoading(true);
579
1078
  try {
580
- const params = new URLSearchParams({ page: '1', pageSize: '100' })
581
- const response = await apiCall<{ items?: Array<Record<string, unknown>> }>(
582
- `/api/sales/order-line-statuses?${params.toString()}`,
583
- undefined,
584
- { fallback: { items: [] } },
585
- )
586
- const items = Array.isArray(response.result?.items) ? response.result.items : []
1079
+ const params = new URLSearchParams({ page: "1", pageSize: "100" });
1080
+ const response = await apiCall<{
1081
+ items?: Array<Record<string, unknown>>;
1082
+ }>(`/api/sales/order-line-statuses?${params.toString()}`, undefined, {
1083
+ fallback: { items: [] },
1084
+ });
1085
+ const items = Array.isArray(response.result?.items)
1086
+ ? response.result.items
1087
+ : [];
587
1088
  const mapped = items
588
1089
  .map((entry) => {
589
- const id = typeof entry.id === 'string' ? entry.id : null
590
- const value = typeof entry.value === 'string' ? entry.value : null
591
- if (!id || !value) return null
1090
+ const id = typeof entry.id === "string" ? entry.id : null;
1091
+ const value = typeof entry.value === "string" ? entry.value : null;
1092
+ if (!id || !value) return null;
592
1093
  const label =
593
- typeof entry.label === 'string' && entry.label.trim().length
1094
+ typeof entry.label === "string" && entry.label.trim().length
594
1095
  ? entry.label
595
- : value
1096
+ : value;
596
1097
  const color =
597
- typeof entry.color === 'string' && entry.color.trim().length ? entry.color : null
1098
+ typeof entry.color === "string" && entry.color.trim().length
1099
+ ? entry.color
1100
+ : null;
598
1101
  const icon =
599
- typeof entry.icon === 'string' && entry.icon.trim().length ? entry.icon : null
600
- return { id, value, label, color, icon }
1102
+ typeof entry.icon === "string" && entry.icon.trim().length
1103
+ ? entry.icon
1104
+ : null;
1105
+ return { id, value, label, color, icon };
601
1106
  })
602
- .filter((entry): entry is StatusOption => Boolean(entry))
603
- setLineStatuses(mapped)
604
- return mapped
1107
+ .filter((entry): entry is StatusOption => Boolean(entry));
1108
+ setLineStatuses(mapped);
1109
+ return mapped;
605
1110
  } catch (err) {
606
- console.error('sales.lines.statuses.load', err)
607
- setLineStatuses([])
608
- return []
1111
+ console.error("sales.lines.statuses.load", err);
1112
+ setLineStatuses([]);
1113
+ return [];
609
1114
  } finally {
610
- setLineStatusLoading(false)
1115
+ setLineStatusLoading(false);
611
1116
  }
612
- }, [])
1117
+ }, []);
613
1118
 
614
1119
  const fetchLineStatusItems = React.useCallback(
615
1120
  async (query?: string): Promise<LookupSelectItem[]> => {
616
1121
  const options =
617
- lineStatuses.length && !query ? lineStatuses : await loadLineStatuses()
618
- const term = query?.trim().toLowerCase() ?? ''
619
- const currentMap = options.reduce<Record<string, { value: string; label: string; color?: string | null; icon?: string | null }>>(
620
- (acc, entry) => {
621
- acc[entry.value] = {
622
- value: entry.value,
623
- label: entry.label,
624
- color: entry.color,
625
- icon: entry.icon ?? null,
1122
+ lineStatuses.length && !query ? lineStatuses : await loadLineStatuses();
1123
+ const term = query?.trim().toLowerCase() ?? "";
1124
+ const currentMap = options.reduce<
1125
+ Record<
1126
+ string,
1127
+ {
1128
+ value: string;
1129
+ label: string;
1130
+ color?: string | null;
1131
+ icon?: string | null;
626
1132
  }
627
- return acc
628
- },
629
- {},
630
- )
1133
+ >
1134
+ >((acc, entry) => {
1135
+ acc[entry.value] = {
1136
+ value: entry.value,
1137
+ label: entry.label,
1138
+ color: entry.color,
1139
+ icon: entry.icon ?? null,
1140
+ };
1141
+ return acc;
1142
+ }, {});
631
1143
  return options
632
1144
  .filter(
633
1145
  (option) =>
634
1146
  !term.length ||
635
1147
  option.label.toLowerCase().includes(term) ||
636
- option.value.toLowerCase().includes(term)
1148
+ option.value.toLowerCase().includes(term),
637
1149
  )
638
1150
  .map<LookupSelectItem>((option) => ({
639
1151
  id: option.id,
640
1152
  title: option.label,
641
1153
  subtitle: option.label !== option.value ? option.value : undefined,
642
- icon: renderDictionaryIcon(option.icon, 'h-4 w-4') ?? renderDictionaryColor(option.color, 'h-4 w-4 rounded-full'),
643
- }))
1154
+ icon:
1155
+ renderDictionaryIcon(option.icon, "h-4 w-4") ??
1156
+ renderDictionaryColor(option.color, "h-4 w-4 rounded-full"),
1157
+ }));
644
1158
  },
645
1159
  [lineStatuses, loadLineStatuses],
646
- )
1160
+ );
647
1161
 
648
1162
  React.useEffect(() => {
649
- if (!open) return
650
- loadTaxRates().catch(() => {})
651
- loadLineStatuses().catch(() => {})
652
- }, [loadLineStatuses, loadTaxRates, open])
1163
+ if (!open) return;
1164
+ loadTaxRates().catch(() => {});
1165
+ loadLineStatuses().catch(() => {});
1166
+ }, [loadLineStatuses, loadTaxRates, open]);
653
1167
 
654
1168
  const handleFormSubmit = React.useCallback(
655
1169
  async (values: LineFormState & Record<string, unknown>) => {
656
- console.groupCollapsed('sales.line.submit.start')
657
- console.log('raw values', values)
658
1170
  // Resolve required scope and ids
659
- const resolvedDocumentId = typeof documentId === 'string' && documentId.trim().length ? documentId : null
660
- const resolvedOrg = resolvedOrganizationId
661
- const resolvedTenant = resolvedTenantId
1171
+ const resolvedDocumentId =
1172
+ typeof documentId === "string" && documentId.trim().length
1173
+ ? documentId
1174
+ : null;
1175
+ const resolvedOrg = resolvedOrganizationId;
1176
+ const resolvedTenant = resolvedTenantId;
662
1177
 
663
1178
  if (!resolvedOrg || !resolvedTenant || !resolvedDocumentId) {
664
1179
  throw createCrudFormError(
665
- t('sales.documents.items.errorScope', 'Organization and tenant are required.'),
666
- )
1180
+ t(
1181
+ "sales.documents.items.errorScope",
1182
+ "Organization and tenant are required.",
1183
+ ),
1184
+ );
667
1185
  }
668
- const lineMode = values.lineMode === 'custom' ? 'custom' : 'catalog'
669
- const isCustomLine = lineMode === 'custom'
1186
+ const lineMode = values.lineMode === "custom" ? "custom" : "catalog";
1187
+ const isCustomLine = lineMode === "custom";
670
1188
 
671
1189
  if (!isCustomLine && !values.productId) {
672
1190
  throw createCrudFormError(
673
- t('sales.documents.items.errorProductRequired', 'Select a product to continue.'),
674
- { productId: t('sales.documents.items.errorProductRequired', 'Select a product to continue.') },
675
- )
1191
+ t(
1192
+ "sales.documents.items.errorProductRequired",
1193
+ "Select a product to continue.",
1194
+ ),
1195
+ {
1196
+ productId: t(
1197
+ "sales.documents.items.errorProductRequired",
1198
+ "Select a product to continue.",
1199
+ ),
1200
+ },
1201
+ );
676
1202
  }
677
1203
  if (!isCustomLine && !values.variantId) {
678
1204
  throw createCrudFormError(
679
- t('sales.documents.items.errorVariantRequired', 'Select a variant to continue.'),
680
- { variantId: t('sales.documents.items.errorVariantRequired', 'Select a variant to continue.') },
681
- )
1205
+ t(
1206
+ "sales.documents.items.errorVariantRequired",
1207
+ "Select a variant to continue.",
1208
+ ),
1209
+ {
1210
+ variantId: t(
1211
+ "sales.documents.items.errorVariantRequired",
1212
+ "Select a variant to continue.",
1213
+ ),
1214
+ },
1215
+ );
682
1216
  }
683
1217
 
684
- const qtyNumber = Number(values.quantity ?? values.quantity ?? 0)
685
- console.log('quantity raw -> parsed', { raw: values.quantity, parsed: qtyNumber })
1218
+ const qtyNumber = Number(values.quantity ?? 0);
686
1219
  if (!Number.isFinite(qtyNumber) || qtyNumber <= 0) {
687
1220
  throw createCrudFormError(
688
- t('sales.documents.items.errorQuantity', 'Quantity must be greater than 0.'),
689
- { quantity: t('sales.documents.items.errorQuantity', 'Quantity must be greater than 0.') },
690
- )
1221
+ t(
1222
+ "sales.documents.items.errorQuantity",
1223
+ "Quantity must be greater than 0.",
1224
+ ),
1225
+ {
1226
+ quantity: t(
1227
+ "sales.documents.items.errorQuantity",
1228
+ "Quantity must be greater than 0.",
1229
+ ),
1230
+ },
1231
+ );
691
1232
  }
1233
+ const resolvedQuantityUnit = (() => {
1234
+ const entered = normalizeUnitCode(values.quantityUnit);
1235
+ if (isCustomLine) return entered;
1236
+ return (
1237
+ entered ??
1238
+ normalizeUnitCode(productOption?.defaultSalesUnit) ??
1239
+ normalizeUnitCode(productOption?.defaultUnit)
1240
+ );
1241
+ })();
692
1242
 
693
- const unitPriceNumber = Number(values.unitPrice ?? values.unitPrice ?? 0)
694
- console.log('unit price raw -> parsed', { raw: values.unitPrice, parsed: unitPriceNumber })
1243
+ const unitPriceNumber = Number(values.unitPrice ?? 0);
695
1244
  if (!Number.isFinite(unitPriceNumber) || unitPriceNumber <= 0) {
696
1245
  throw createCrudFormError(
697
- t('sales.documents.items.errorUnitPrice', 'Unit price must be greater than 0.'),
698
- { unitPrice: t('sales.documents.items.errorUnitPrice', 'Unit price must be greater than 0.') },
699
- )
1246
+ t(
1247
+ "sales.documents.items.errorUnitPrice",
1248
+ "Unit price must be greater than 0.",
1249
+ ),
1250
+ {
1251
+ unitPrice: t(
1252
+ "sales.documents.items.errorUnitPrice",
1253
+ "Unit price must be greater than 0.",
1254
+ ),
1255
+ },
1256
+ );
700
1257
  }
701
1258
 
702
- const selectedPrice = !isCustomLine && values.priceId
703
- ? priceOptions.find((price) => price.id === values.priceId) ?? null
704
- : null
1259
+ const selectedPrice =
1260
+ !isCustomLine && values.priceId
1261
+ ? (priceOptions.find((price) => price.id === values.priceId) ?? null)
1262
+ : null;
705
1263
  const resolvedCurrency =
706
1264
  (values.currencyCode as string | null | undefined) ??
707
1265
  selectedPrice?.currencyCode ??
708
1266
  currencyCode ??
709
- null
1267
+ null;
710
1268
  if (!resolvedCurrency) {
711
1269
  throw createCrudFormError(
712
- t('sales.documents.items.errorCurrency', 'Currency is required.'),
713
- { priceId: t('sales.documents.items.errorCurrency', 'Currency is required.') },
714
- )
1270
+ t("sales.documents.items.errorCurrency", "Currency is required."),
1271
+ {
1272
+ priceId: t(
1273
+ "sales.documents.items.errorCurrency",
1274
+ "Currency is required.",
1275
+ ),
1276
+ },
1277
+ );
715
1278
  }
716
1279
 
717
- const resolvedNameRaw = (values.name ?? '').toString().trim()
1280
+ const resolvedNameRaw = (values.name ?? "").toString().trim();
718
1281
  const resolvedName = isCustomLine
719
1282
  ? resolvedNameRaw
720
- : resolvedNameRaw || variantOption?.title || productOption?.title || undefined
1283
+ : resolvedNameRaw ||
1284
+ variantOption?.title ||
1285
+ productOption?.title ||
1286
+ undefined;
721
1287
  if (isCustomLine && !resolvedName) {
722
1288
  throw createCrudFormError(
723
- t('sales.documents.items.errorNameRequired', 'Name is required for custom lines.'),
724
- { name: t('sales.documents.items.errorNameRequired', 'Name is required for custom lines.') },
725
- )
1289
+ t(
1290
+ "sales.documents.items.errorNameRequired",
1291
+ "Name is required for custom lines.",
1292
+ ),
1293
+ {
1294
+ name: t(
1295
+ "sales.documents.items.errorNameRequired",
1296
+ "Name is required for custom lines.",
1297
+ ),
1298
+ },
1299
+ );
726
1300
  }
727
- const resolvedPriceMode = values.priceMode === 'net' ? 'net' : 'gross'
1301
+ const resolvedPriceMode = values.priceMode === "net" ? "net" : "gross";
728
1302
  const catalogSnapshot =
729
- !isCustomLine && typeof values.catalogSnapshot === 'object' && values.catalogSnapshot ? values.catalogSnapshot : null
1303
+ !isCustomLine &&
1304
+ typeof values.catalogSnapshot === "object" &&
1305
+ values.catalogSnapshot
1306
+ ? values.catalogSnapshot
1307
+ : null;
730
1308
  const selectedTaxRateId =
731
- typeof values.taxRateId === 'string' && values.taxRateId.trim().length
1309
+ typeof values.taxRateId === "string" && values.taxRateId.trim().length
732
1310
  ? values.taxRateId
733
- : null
1311
+ : null;
734
1312
  const resolvedTaxRate = Number.isFinite(values.taxRate)
735
1313
  ? (values.taxRate as number)
736
- : normalizeNumber(values.taxRate)
737
- const normalizedTaxRate = Number.isFinite(resolvedTaxRate) ? resolvedTaxRate : 0
1314
+ : normalizeNumber(values.taxRate);
1315
+ const normalizedTaxRate = Number.isFinite(resolvedTaxRate)
1316
+ ? resolvedTaxRate
1317
+ : 0;
738
1318
  const unitPriceNetValue =
739
- resolvedPriceMode === 'net' ? unitPriceNumber : unitPriceNumber / (1 + normalizedTaxRate / 100)
1319
+ resolvedPriceMode === "net"
1320
+ ? unitPriceNumber
1321
+ : unitPriceNumber / (1 + normalizedTaxRate / 100);
740
1322
  const unitPriceGrossValue =
741
- resolvedPriceMode === 'gross' ? unitPriceNumber : unitPriceNumber * (1 + normalizedTaxRate / 100)
742
- const safeUnitPriceNet = Number.isFinite(unitPriceNetValue) ? unitPriceNetValue : unitPriceNumber
743
- const safeUnitPriceGross = Number.isFinite(unitPriceGrossValue) ? unitPriceGrossValue : unitPriceNumber
744
- const totalNetAmount = safeUnitPriceNet * qtyNumber
745
- const totalGrossAmount = safeUnitPriceGross * qtyNumber
1323
+ resolvedPriceMode === "gross"
1324
+ ? unitPriceNumber
1325
+ : unitPriceNumber * (1 + normalizedTaxRate / 100);
1326
+ const safeUnitPriceNet = Number.isFinite(unitPriceNetValue)
1327
+ ? unitPriceNetValue
1328
+ : unitPriceNumber;
1329
+ const safeUnitPriceGross = Number.isFinite(unitPriceGrossValue)
1330
+ ? unitPriceGrossValue
1331
+ : unitPriceNumber;
1332
+ const totalNetAmount = safeUnitPriceNet * qtyNumber;
1333
+ const totalGrossAmount = safeUnitPriceGross * qtyNumber;
746
1334
 
747
1335
  const metadata = {
748
1336
  ...(catalogSnapshot ?? {}),
@@ -760,22 +1348,36 @@ export function LineItemDialog({
760
1348
  ? {
761
1349
  variantTitle: variantOption.title,
762
1350
  variantSku: variantOption.sku ?? null,
763
- variantThumbnail: variantOption.thumbnailUrl ?? productOption?.thumbnailUrl ?? null,
1351
+ variantThumbnail:
1352
+ variantOption.thumbnailUrl ??
1353
+ productOption?.thumbnailUrl ??
1354
+ null,
764
1355
  }
765
1356
  : {}),
766
1357
  ...(isCustomLine ? { customLine: true } : {}),
767
1358
  lineMode,
768
- }
1359
+ ...(resolvedQuantityUnit ? { quantityUnit: resolvedQuantityUnit } : {}),
1360
+ };
769
1361
 
770
1362
  const payload: Record<string, unknown> = {
771
1363
  [documentKey]: String(resolvedDocumentId),
772
1364
  organizationId: String(resolvedOrg),
773
1365
  tenantId: String(resolvedTenant),
774
- productId: isCustomLine ? undefined : values.productId ? String(values.productId) : undefined,
775
- productVariantId: isCustomLine ? undefined : values.variantId ? String(values.variantId) : undefined,
1366
+ productId: isCustomLine
1367
+ ? undefined
1368
+ : values.productId
1369
+ ? String(values.productId)
1370
+ : undefined,
1371
+ productVariantId: isCustomLine
1372
+ ? undefined
1373
+ : values.variantId
1374
+ ? String(values.variantId)
1375
+ : undefined,
776
1376
  quantity: qtyNumber,
1377
+ quantityUnit: resolvedQuantityUnit ?? undefined,
777
1378
  currencyCode: String(resolvedCurrency),
778
- priceId: !isCustomLine && values.priceId ? String(values.priceId) : undefined,
1379
+ priceId:
1380
+ !isCustomLine && values.priceId ? String(values.priceId) : undefined,
779
1381
  priceMode: resolvedPriceMode,
780
1382
  taxRate: Number.isFinite(resolvedTaxRate) ? resolvedTaxRate : undefined,
781
1383
  unitPriceNet: safeUnitPriceNet,
@@ -785,41 +1387,38 @@ export function LineItemDialog({
785
1387
  ...(catalogSnapshot ? { catalogSnapshot } : {}),
786
1388
  metadata,
787
1389
  customFieldSetId: values.customFieldSetId ?? undefined,
788
- ...(typeof values.statusEntryId === 'string' && values.statusEntryId.trim().length
1390
+ ...(typeof values.statusEntryId === "string" &&
1391
+ values.statusEntryId.trim().length
789
1392
  ? { statusEntryId: values.statusEntryId.trim() }
790
1393
  : {}),
791
- }
1394
+ };
792
1395
 
793
1396
  const customFields = collectCustomFieldValues(values, {
794
1397
  transform: (value) => normalizeCustomFieldSubmitValue(value),
795
- })
1398
+ });
796
1399
  if (Object.keys(customFields).length) {
797
- payload.customFields = normalizeCustomFieldValues(customFields)
1400
+ payload.customFields = normalizeCustomFieldValues(customFields);
798
1401
  }
799
- if (resolvedName) payload.name = resolvedName
800
-
801
- console.debug('resolved scope', { resolvedDocumentId, resolvedOrg, resolvedTenant, resolvedCurrency })
802
- console.debug('parsed numbers', { qtyNumber, unitPriceNumber })
803
- console.log('sales.line.submit.payload', payload)
804
- console.log('sales.line.submit.payload.json', JSON.stringify(payload))
805
- console.groupEnd()
1402
+ if (resolvedName) payload.name = resolvedName;
806
1403
 
807
1404
  try {
808
- const action = editingId ? updateCrud : createCrud
1405
+ const action = editingId ? updateCrud : createCrud;
809
1406
  const result = await action(
810
1407
  resourcePath,
811
1408
  editingId ? { id: editingId, ...payload } : payload,
812
1409
  {
813
- errorMessage: t('sales.documents.items.errorSave', 'Failed to save line.'),
1410
+ errorMessage: t(
1411
+ "sales.documents.items.errorSave",
1412
+ "Failed to save line.",
1413
+ ),
814
1414
  },
815
- )
1415
+ );
816
1416
  if (result.ok) {
817
- if (onSaved) await onSaved()
818
- closeDialog()
1417
+ if (onSaved) await onSaved();
1418
+ closeDialog();
819
1419
  }
820
1420
  } catch (err) {
821
- console.error('sales.line.submit.error', err)
822
- throw err
1421
+ throw err;
823
1422
  }
824
1423
  },
825
1424
  [
@@ -837,110 +1436,151 @@ export function LineItemDialog({
837
1436
  resolvedOrganizationId,
838
1437
  resolvedTenantId,
839
1438
  ],
840
- )
1439
+ );
841
1440
 
842
1441
  const fields = React.useMemo<CrudField[]>(() => {
843
- const isCustomLine = lineMode === 'custom'
1442
+ const isCustomLine = lineMode === "custom";
844
1443
  return [
845
1444
  {
846
- id: 'lineMode',
847
- label: t('sales.documents.items.lineMode.label', 'Line type'),
848
- type: 'custom',
849
- layout: 'full',
1445
+ id: "lineMode",
1446
+ label: t("sales.documents.items.lineMode.label", "Line type"),
1447
+ type: "custom",
1448
+ layout: "full",
850
1449
  component: ({ value, setValue, setFormValue }: FieldRenderProps) => {
851
- const mode = value === 'custom' ? 'custom' : 'catalog'
852
- const switchMode = (next: 'catalog' | 'custom') => {
853
- if (next === mode) return
854
- setValue(next)
855
- setLineMode(next)
856
- if (next === 'custom') {
857
- setProductOption(null)
858
- setVariantOption(null)
859
- setPriceOptions([])
860
- setFormValue?.('productId', null)
861
- setFormValue?.('variantId', null)
862
- setFormValue?.('priceId', null)
863
- setFormValue?.('catalogSnapshot', null)
1450
+ const mode = value === "custom" ? "custom" : "catalog";
1451
+ const switchMode = (next: "catalog" | "custom") => {
1452
+ if (next === mode) return;
1453
+ setValue(next);
1454
+ setLineMode(next);
1455
+ if (next === "custom") {
1456
+ setProductOption(null);
1457
+ setVariantOption(null);
1458
+ setPriceOptions([]);
1459
+ setUnitOptions([]);
1460
+ setFormValue?.("productId", null);
1461
+ setFormValue?.("variantId", null);
1462
+ setFormValue?.("priceId", null);
1463
+ setFormValue?.("catalogSnapshot", null);
1464
+ setFormValue?.("quantityUnit", null);
864
1465
  } else {
865
- setFormValue?.('unitPrice', '')
866
- setFormValue?.('priceMode', 'gross')
1466
+ setFormValue?.("unitPrice", "");
1467
+ setFormValue?.("priceMode", "gross");
867
1468
  }
868
- }
1469
+ };
869
1470
  return (
870
1471
  <div className="flex flex-col gap-2">
871
1472
  <div className="inline-flex w-fit gap-1 rounded-md border bg-muted/50 p-1">
872
1473
  <Button
873
1474
  type="button"
874
1475
  size="sm"
875
- variant={mode === 'catalog' ? 'default' : 'ghost'}
876
- onClick={() => switchMode('catalog')}
1476
+ variant={mode === "catalog" ? "default" : "ghost"}
1477
+ onClick={() => switchMode("catalog")}
877
1478
  >
878
- {t('sales.documents.items.lineMode.catalog', 'Catalog item')}
1479
+ {t("sales.documents.items.lineMode.catalog", "Catalog item")}
879
1480
  </Button>
880
1481
  <Button
881
1482
  type="button"
882
1483
  size="sm"
883
- variant={mode === 'custom' ? 'default' : 'ghost'}
884
- onClick={() => switchMode('custom')}
1484
+ variant={mode === "custom" ? "default" : "ghost"}
1485
+ onClick={() => switchMode("custom")}
885
1486
  >
886
- {t('sales.documents.items.lineMode.custom', 'Custom line')}
1487
+ {t("sales.documents.items.lineMode.custom", "Custom line")}
887
1488
  </Button>
888
1489
  </div>
889
1490
  <p className="text-xs text-muted-foreground">
890
1491
  {t(
891
- 'sales.documents.items.lineMode.helper',
892
- 'Use catalog products or create a freeform line with your own price.',
1492
+ "sales.documents.items.lineMode.helper",
1493
+ "Use catalog products or create a freeform line with your own price.",
893
1494
  )}
894
1495
  </p>
895
1496
  </div>
896
- )
1497
+ );
897
1498
  },
898
1499
  } satisfies CrudField,
899
1500
  ...(!isCustomLine
900
1501
  ? [
901
1502
  {
902
- id: 'productId',
903
- label: t('sales.documents.items.product', 'Product'),
904
- type: 'custom',
1503
+ id: "productId",
1504
+ label: t("sales.documents.items.product", "Product"),
1505
+ type: "custom",
905
1506
  required: true,
906
- layout: 'half',
907
- component: ({ value, setValue, setFormValue, values }: FieldRenderProps) => (
1507
+ layout: "half",
1508
+ component: ({
1509
+ value,
1510
+ setValue,
1511
+ setFormValue,
1512
+ values,
1513
+ }: FieldRenderProps) => (
908
1514
  <LookupSelect
909
- value={typeof value === 'string' ? value : null}
1515
+ value={typeof value === "string" ? value : null}
910
1516
  onChange={(next) => {
911
- const selectedOption = next ? productOptionsRef.current.get(next) ?? null : null
912
- setProductOption(selectedOption)
913
- setVariantOption(null)
914
- setPriceOptions([])
915
- setValue(next ?? null)
916
- setFormValue?.('variantId', null)
917
- setFormValue?.('priceId', null)
918
- setFormValue?.('unitPrice', '')
919
- setFormValue?.('priceMode', 'gross')
1517
+ const selectedOption = next
1518
+ ? (productOptionsRef.current.get(next) ?? null)
1519
+ : null;
1520
+ setProductOption(selectedOption);
1521
+ setVariantOption(null);
1522
+ setPriceOptions([]);
1523
+ setUnitOptions([]);
1524
+ setValue(next ?? null);
1525
+ setFormValue?.("variantId", null);
1526
+ setFormValue?.("priceId", null);
1527
+ setFormValue?.("unitPrice", "");
1528
+ setFormValue?.("priceMode", "gross");
1529
+ const defaultQuantityUnit =
1530
+ selectedOption?.defaultSalesUnit ??
1531
+ selectedOption?.defaultUnit ??
1532
+ null;
1533
+ setFormValue?.("quantityUnit", defaultQuantityUnit);
1534
+ if (
1535
+ typeof selectedOption?.defaultSalesUnitQuantity ===
1536
+ "number" &&
1537
+ Number.isFinite(
1538
+ selectedOption.defaultSalesUnitQuantity,
1539
+ ) &&
1540
+ selectedOption.defaultSalesUnitQuantity > 0
1541
+ ) {
1542
+ setFormValue?.(
1543
+ "quantity",
1544
+ String(selectedOption.defaultSalesUnitQuantity),
1545
+ );
1546
+ }
920
1547
  const taxSelection = selectedOption
921
1548
  ? resolveTaxSelection(selectedOption)
922
- : { taxRate: null, taxRateId: null }
923
- setFormValue?.('taxRate', taxSelection.taxRate ?? null)
924
- setFormValue?.('taxRateId', taxSelection.taxRateId ?? null)
925
- const existingName = typeof values?.name === 'string' ? values.name : ''
1549
+ : { taxRate: null, taxRateId: null };
1550
+ setFormValue?.("taxRate", taxSelection.taxRate ?? null);
1551
+ setFormValue?.("taxRateId", taxSelection.taxRateId ?? null);
1552
+ const existingName =
1553
+ typeof values?.name === "string" ? values.name : "";
926
1554
  if (!existingName.trim() && selectedOption?.title) {
927
- setFormValue?.('name', selectedOption.title)
1555
+ setFormValue?.("name", selectedOption.title);
928
1556
  }
929
1557
  setFormValue?.(
930
- 'catalogSnapshot',
1558
+ "catalogSnapshot",
931
1559
  next
932
1560
  ? {
933
1561
  product: {
934
1562
  id: next,
935
1563
  title: selectedOption?.title ?? null,
936
1564
  sku: selectedOption?.sku ?? null,
937
- thumbnailUrl: selectedOption?.thumbnailUrl ?? null,
1565
+ thumbnailUrl:
1566
+ selectedOption?.thumbnailUrl ?? null,
1567
+ defaultUnit: selectedOption?.defaultUnit ?? null,
1568
+ defaultSalesUnit:
1569
+ selectedOption?.defaultSalesUnit ?? null,
938
1570
  },
939
1571
  }
940
1572
  : null,
941
- )
1573
+ );
942
1574
  if (next) {
943
- void loadPrices(next, null)
1575
+ void loadProductUnits(next, selectedOption);
1576
+ void loadPrices(
1577
+ next,
1578
+ null,
1579
+ typeof values?.quantity === "string"
1580
+ ? values.quantity
1581
+ : 1,
1582
+ defaultQuantityUnit,
1583
+ );
944
1584
  }
945
1585
  }}
946
1586
  fetchItems={loadProductOptions}
@@ -951,65 +1591,106 @@ export function LineItemDialog({
951
1591
  id: productOption.id,
952
1592
  title: productOption.title || productOption.id,
953
1593
  subtitle: productOption.sku ?? undefined,
954
- icon: productOption.thumbnailUrl
955
- ? <img src={productOption.thumbnailUrl} alt={productOption.title ?? productOption.id} className="h-8 w-8 rounded object-cover" />
956
- : buildPlaceholder(productOption.title || productOption.id),
1594
+ icon: productOption.thumbnailUrl ? (
1595
+ <img
1596
+ src={productOption.thumbnailUrl}
1597
+ alt={productOption.title ?? productOption.id}
1598
+ className="h-8 w-8 rounded object-cover"
1599
+ />
1600
+ ) : (
1601
+ buildPlaceholder(
1602
+ productOption.title || productOption.id,
1603
+ )
1604
+ ),
957
1605
  },
958
1606
  ]
959
1607
  : undefined
960
1608
  }
961
1609
  minQuery={1}
962
- searchPlaceholder={t('sales.documents.items.productSearch', 'Search product')}
963
- selectLabel={t('ui.lookupSelect.select', 'Select')}
964
- selectedLabel={t('ui.lookupSelect.selected', 'Selected')}
965
- clearLabel={t('ui.lookupSelect.clearSelection', 'Clear selection')}
966
- emptyLabel={t('ui.lookupSelect.noResults', 'No results')}
967
- loadingLabel={t('ui.lookupSelect.searching', 'Searching…')}
968
- startTypingLabel={t('ui.lookupSelect.startTyping', 'Start typing to search.')}
1610
+ searchPlaceholder={t(
1611
+ "sales.documents.items.productSearch",
1612
+ "Search product",
1613
+ )}
1614
+ selectLabel={t("ui.lookupSelect.select", "Select")}
1615
+ selectedLabel={t("ui.lookupSelect.selected", "Selected")}
1616
+ clearLabel={t(
1617
+ "ui.lookupSelect.clearSelection",
1618
+ "Clear selection",
1619
+ )}
1620
+ emptyLabel={t("ui.lookupSelect.noResults", "No results")}
1621
+ loadingLabel={t("ui.lookupSelect.searching", "Searching…")}
1622
+ startTypingLabel={t(
1623
+ "ui.lookupSelect.startTyping",
1624
+ "Start typing to search.",
1625
+ )}
969
1626
  selectedHintLabel={(id) =>
970
- t('sales.documents.items.selectedProduct', 'Selected {{id}}', {
971
- id: productOption?.title ?? id,
972
- })
1627
+ t(
1628
+ "sales.documents.items.selectedProduct",
1629
+ "Selected {{id}}",
1630
+ {
1631
+ id: productOption?.title ?? id,
1632
+ },
1633
+ )
973
1634
  }
974
1635
  />
975
1636
  ),
976
1637
  } satisfies CrudField,
977
1638
  {
978
- id: 'variantId',
979
- label: t('sales.documents.items.variant', 'Variant'),
980
- type: 'custom',
1639
+ id: "variantId",
1640
+ label: t("sales.documents.items.variant", "Variant"),
1641
+ type: "custom",
981
1642
  required: true,
982
- layout: 'half',
983
- component: ({ value, setValue, setFormValue, values }: FieldRenderProps) => {
984
- const productId = typeof values?.productId === 'string' ? values.productId : null
1643
+ layout: "half",
1644
+ component: ({
1645
+ value,
1646
+ setValue,
1647
+ setFormValue,
1648
+ values,
1649
+ }: FieldRenderProps) => {
1650
+ const productId =
1651
+ typeof values?.productId === "string"
1652
+ ? values.productId
1653
+ : null;
985
1654
  return (
986
1655
  <LookupSelect
987
- key={productId ?? 'no-product'}
988
- value={typeof value === 'string' ? value : null}
1656
+ key={productId ?? "no-product"}
1657
+ value={typeof value === "string" ? value : null}
989
1658
  onChange={(next) => {
990
- const selectedOption = next ? variantOptionsRef.current.get(next) ?? null : null
991
- setVariantOption(selectedOption)
992
- setValue(next ?? null)
993
- const existingName = typeof values?.name === 'string' ? values.name : ''
1659
+ const selectedOption = next
1660
+ ? (variantOptionsRef.current.get(next) ?? null)
1661
+ : null;
1662
+ setVariantOption(selectedOption);
1663
+ setValue(next ?? null);
1664
+ const existingName =
1665
+ typeof values?.name === "string" ? values.name : "";
994
1666
  if (!existingName.trim()) {
995
- setFormValue?.('name', selectedOption?.title ?? productOption?.title ?? existingName)
1667
+ setFormValue?.(
1668
+ "name",
1669
+ selectedOption?.title ??
1670
+ productOption?.title ??
1671
+ existingName,
1672
+ );
996
1673
  }
997
1674
  const taxSource = hasTaxMetadata(selectedOption)
998
1675
  ? selectedOption
999
1676
  : hasTaxMetadata(productOption)
1000
1677
  ? productOption
1001
- : null
1678
+ : null;
1002
1679
  if (taxSource) {
1003
- const taxSelection = resolveTaxSelection(taxSource)
1004
- setFormValue?.('taxRate', taxSelection.taxRate ?? null)
1005
- setFormValue?.('taxRateId', taxSelection.taxRateId ?? null)
1680
+ const taxSelection = resolveTaxSelection(taxSource);
1681
+ setFormValue?.("taxRate", taxSelection.taxRate ?? null);
1682
+ setFormValue?.(
1683
+ "taxRateId",
1684
+ taxSelection.taxRateId ?? null,
1685
+ );
1006
1686
  }
1007
1687
  const prevSnapshot =
1008
- typeof values?.catalogSnapshot === 'object' && values.catalogSnapshot
1688
+ typeof values?.catalogSnapshot === "object" &&
1689
+ values.catalogSnapshot
1009
1690
  ? (values.catalogSnapshot as Record<string, unknown>)
1010
- : null
1691
+ : null;
1011
1692
  if (next) {
1012
- setFormValue?.('catalogSnapshot', {
1693
+ setFormValue?.("catalogSnapshot", {
1013
1694
  ...(prevSnapshot ?? {}),
1014
1695
  variant: {
1015
1696
  id: next,
@@ -1017,34 +1698,67 @@ export function LineItemDialog({
1017
1698
  sku: selectedOption?.sku ?? null,
1018
1699
  thumbnailUrl: selectedOption?.thumbnailUrl ?? null,
1019
1700
  },
1020
- })
1701
+ });
1021
1702
  } else if (prevSnapshot) {
1022
- const snapshot = { ...prevSnapshot }
1023
- if ('variant' in snapshot) delete (snapshot as any).variant
1024
- setFormValue?.('catalogSnapshot', Object.keys(snapshot).length ? snapshot : null)
1703
+ const snapshot = { ...prevSnapshot };
1704
+ if ("variant" in snapshot)
1705
+ delete (snapshot as CatalogSnapshotRecord).variant;
1706
+ setFormValue?.(
1707
+ "catalogSnapshot",
1708
+ Object.keys(snapshot).length ? snapshot : null,
1709
+ );
1025
1710
  } else {
1026
- setFormValue?.('catalogSnapshot', null)
1711
+ setFormValue?.("catalogSnapshot", null);
1027
1712
  }
1028
1713
  if (productId) {
1029
- void loadPrices(productId, next)
1714
+ const currentQuantity =
1715
+ typeof values?.quantity === "string"
1716
+ ? values.quantity
1717
+ : 1;
1718
+ const currentQuantityUnit =
1719
+ typeof values?.quantityUnit === "string"
1720
+ ? values.quantityUnit
1721
+ : null;
1722
+ void loadPrices(
1723
+ productId,
1724
+ next,
1725
+ currentQuantity,
1726
+ currentQuantityUnit,
1727
+ );
1030
1728
  }
1031
1729
  }}
1032
1730
  fetchItems={async (query) => {
1033
- if (!productId) return []
1034
- const productThumb = productId ? productOptionsRef.current.get(productId)?.thumbnailUrl : null
1035
- const options = await loadVariantOptions(productId, productThumb)
1036
- const needle = query?.trim().toLowerCase() ?? ''
1731
+ if (!productId) return [];
1732
+ const productThumb = productId
1733
+ ? productOptionsRef.current.get(productId)?.thumbnailUrl
1734
+ : null;
1735
+ const options = await loadVariantOptions(
1736
+ productId,
1737
+ productThumb,
1738
+ );
1739
+ const needle = query?.trim().toLowerCase() ?? "";
1037
1740
  return needle.length
1038
- ? options.filter((option) => option.title.toLowerCase().includes(needle))
1039
- : options
1741
+ ? options.filter((option) =>
1742
+ option.title.toLowerCase().includes(needle),
1743
+ )
1744
+ : options;
1040
1745
  }}
1041
- searchPlaceholder={t('sales.documents.items.variantSearch', 'Search variant')}
1042
- selectLabel={t('ui.lookupSelect.select', 'Select')}
1043
- selectedLabel={t('ui.lookupSelect.selected', 'Selected')}
1044
- clearLabel={t('ui.lookupSelect.clearSelection', 'Clear selection')}
1045
- emptyLabel={t('ui.lookupSelect.noResults', 'No results')}
1046
- loadingLabel={t('ui.lookupSelect.searching', 'Searching…')}
1047
- startTypingLabel={t('ui.lookupSelect.startTyping', 'Start typing to search.')}
1746
+ searchPlaceholder={t(
1747
+ "sales.documents.items.variantSearch",
1748
+ "Search variant",
1749
+ )}
1750
+ selectLabel={t("ui.lookupSelect.select", "Select")}
1751
+ selectedLabel={t("ui.lookupSelect.selected", "Selected")}
1752
+ clearLabel={t(
1753
+ "ui.lookupSelect.clearSelection",
1754
+ "Clear selection",
1755
+ )}
1756
+ emptyLabel={t("ui.lookupSelect.noResults", "No results")}
1757
+ loadingLabel={t("ui.lookupSelect.searching", "Searching…")}
1758
+ startTypingLabel={t(
1759
+ "ui.lookupSelect.startTyping",
1760
+ "Start typing to search.",
1761
+ )}
1048
1762
  minQuery={0}
1049
1763
  options={
1050
1764
  variantOption
@@ -1060,64 +1774,88 @@ export function LineItemDialog({
1060
1774
  className="h-8 w-8 rounded object-cover"
1061
1775
  />
1062
1776
  ) : (
1063
- buildPlaceholder(variantOption.title || variantOption.id)
1777
+ buildPlaceholder(
1778
+ variantOption.title || variantOption.id,
1779
+ )
1064
1780
  ),
1065
1781
  },
1066
1782
  ]
1067
1783
  : undefined
1068
1784
  }
1069
1785
  selectedHintLabel={(id) =>
1070
- t('sales.documents.items.selectedVariant', 'Selected {{id}}', {
1071
- id: variantOption?.title ?? id,
1072
- })
1786
+ t(
1787
+ "sales.documents.items.selectedVariant",
1788
+ "Selected {{id}}",
1789
+ {
1790
+ id: variantOption?.title ?? id,
1791
+ },
1792
+ )
1073
1793
  }
1074
1794
  disabled={!productId}
1075
1795
  />
1076
- )
1796
+ );
1077
1797
  },
1078
1798
  } satisfies CrudField,
1079
1799
  {
1080
- id: 'priceId',
1081
- label: t('sales.documents.items.price', 'Price'),
1082
- type: 'custom',
1083
- layout: 'half',
1084
- component: ({ value, setValue, setFormValue, values }: FieldRenderProps) => {
1085
- const productId = typeof values?.productId === 'string' ? values.productId : null
1086
- const variantId = typeof values?.variantId === 'string' ? values.variantId : null
1800
+ id: "priceId",
1801
+ label: t("sales.documents.items.price", "Price"),
1802
+ type: "custom",
1803
+ layout: "half",
1804
+ component: ({
1805
+ value,
1806
+ setValue,
1807
+ setFormValue,
1808
+ values,
1809
+ }: FieldRenderProps) => {
1810
+ const productId =
1811
+ typeof values?.productId === "string"
1812
+ ? values.productId
1813
+ : null;
1814
+ const variantId =
1815
+ typeof values?.variantId === "string"
1816
+ ? values.variantId
1817
+ : null;
1087
1818
  return (
1088
1819
  <LookupSelect
1089
- key={productId ? `${productId}-${variantId ?? 'no-variant'}` : 'price'}
1090
- value={typeof value === 'string' ? value : null}
1820
+ key={
1821
+ productId
1822
+ ? `${productId}-${variantId ?? "no-variant"}`
1823
+ : "price"
1824
+ }
1825
+ value={typeof value === "string" ? value : null}
1091
1826
  onChange={(next) => {
1092
- setValue(next ?? null)
1093
- const selected = next ? priceOptions.find((entry) => entry.id === next) ?? null : null
1094
- if (selected) {
1095
- const mode = selected.displayMode === 'excluding-tax' ? 'net' : 'gross'
1096
- const amount =
1097
- mode === 'net'
1098
- ? selected.amountNet ?? selected.amountGross ?? 0
1099
- : selected.amountGross ?? selected.amountNet ?? 0
1100
- setFormValue?.('priceMode', mode)
1101
- setFormValue?.('unitPrice', amount.toString())
1102
- setFormValue?.('taxRate', selected.taxRate ?? null)
1103
- const matchedRateId = findTaxRateIdByValue(selected.taxRate)
1104
- setFormValue?.('taxRateId', matchedRateId)
1105
- setFormValue?.(
1106
- 'currencyCode',
1107
- selected.currencyCode ?? values?.currencyCode ?? currencyCode ?? null,
1108
- )
1109
- } else {
1110
- const fallbackTax = resolveTaxSelection(variantOption ?? productOption ?? null)
1111
- setFormValue?.('taxRate', fallbackTax.taxRate ?? null)
1112
- setFormValue?.('taxRateId', fallbackTax.taxRateId ?? null)
1113
- }
1827
+ setValue(next ?? null);
1828
+ const selected = next
1829
+ ? (priceOptions.find((entry) => entry.id === next) ??
1830
+ null)
1831
+ : null;
1832
+ applyPriceSelection(
1833
+ selected,
1834
+ setFormValue,
1835
+ {
1836
+ fallbackTaxSource: variantOption ?? productOption ?? null,
1837
+ quantityUnit:
1838
+ typeof values?.quantityUnit === "string"
1839
+ ? values.quantityUnit
1840
+ : null,
1841
+ },
1842
+ );
1114
1843
  }}
1115
1844
  fetchItems={async (query) => {
1116
- const prices = await loadPrices(productId, variantId)
1117
- const needle = query?.trim().toLowerCase() ?? ''
1845
+ const prices = await loadPrices(
1846
+ productId,
1847
+ variantId,
1848
+ typeof values?.quantity === "string"
1849
+ ? values.quantity
1850
+ : 1,
1851
+ typeof values?.quantityUnit === "string"
1852
+ ? values.quantityUnit
1853
+ : null,
1854
+ );
1855
+ const needle = query?.trim().toLowerCase() ?? "";
1118
1856
  return prices
1119
1857
  .filter((price) => {
1120
- if (!needle.length) return true
1858
+ if (!needle.length) return true;
1121
1859
  const haystack = [
1122
1860
  price.label,
1123
1861
  price.priceKindTitle,
@@ -1126,99 +1864,208 @@ export function LineItemDialog({
1126
1864
  ...(price.scopeTags ?? []),
1127
1865
  ]
1128
1866
  .filter(Boolean)
1129
- .join(' ')
1130
- .toLowerCase()
1131
- return haystack.includes(needle)
1867
+ .join(" ")
1868
+ .toLowerCase();
1869
+ return haystack.includes(needle);
1132
1870
  })
1133
1871
  .map<LookupSelectItem>((price) => ({
1134
1872
  id: price.id,
1135
1873
  title: price.label,
1136
- subtitle: price.priceKindTitle ?? price.priceKindCode ?? undefined,
1874
+ subtitle:
1875
+ price.priceKindTitle ??
1876
+ price.priceKindCode ??
1877
+ undefined,
1137
1878
  description: price.scopeReason ?? undefined,
1138
1879
  rightLabel: price.currencyCode ?? undefined,
1139
- icon: <DollarSign className="h-5 w-5 text-muted-foreground" />,
1140
- }))
1880
+ icon: (
1881
+ <DollarSign className="h-5 w-5 text-muted-foreground" />
1882
+ ),
1883
+ }));
1141
1884
  }}
1142
1885
  minQuery={0}
1143
1886
  loading={priceLoading}
1144
- searchPlaceholder={t('sales.documents.items.priceSearch', 'Select price')}
1145
- selectLabel={t('ui.lookupSelect.select', 'Select')}
1146
- selectedLabel={t('ui.lookupSelect.selected', 'Selected')}
1147
- clearLabel={t('ui.lookupSelect.clearSelection', 'Clear selection')}
1148
- emptyLabel={t('ui.lookupSelect.noResults', 'No results')}
1149
- loadingLabel={t('ui.lookupSelect.searching', 'Searching…')}
1150
- startTypingLabel={t('ui.lookupSelect.startTyping', 'Start typing to search.')}
1887
+ searchPlaceholder={t(
1888
+ "sales.documents.items.priceSearch",
1889
+ "Select price",
1890
+ )}
1891
+ selectLabel={t("ui.lookupSelect.select", "Select")}
1892
+ selectedLabel={t("ui.lookupSelect.selected", "Selected")}
1893
+ clearLabel={t(
1894
+ "ui.lookupSelect.clearSelection",
1895
+ "Clear selection",
1896
+ )}
1897
+ emptyLabel={t("ui.lookupSelect.noResults", "No results")}
1898
+ loadingLabel={t("ui.lookupSelect.searching", "Searching…")}
1899
+ startTypingLabel={t(
1900
+ "ui.lookupSelect.startTyping",
1901
+ "Start typing to search.",
1902
+ )}
1151
1903
  disabled={!productId}
1152
1904
  />
1153
- )
1905
+ );
1154
1906
  },
1155
1907
  } satisfies CrudField,
1156
1908
  ]
1157
1909
  : []),
1158
1910
  {
1159
- id: 'unitPrice',
1160
- label: t('sales.documents.items.unitPrice', 'Unit price'),
1161
- type: 'custom',
1162
- layout: 'half',
1163
- component: ({ value, setValue, setFormValue, values }: FieldRenderProps) => {
1164
- const mode = values?.priceMode === 'net' ? 'net' : 'gross'
1911
+ id: "unitPrice",
1912
+ label: t("sales.documents.items.unitPrice", "Unit price"),
1913
+ type: "custom",
1914
+ layout: "half",
1915
+ component: ({
1916
+ value,
1917
+ setValue,
1918
+ setFormValue,
1919
+ values,
1920
+ }: FieldRenderProps) => {
1921
+ const mode = values?.priceMode === "net" ? "net" : "gross";
1922
+ const selectedPriceId =
1923
+ typeof values?.priceId === "string" ? values.priceId : null;
1924
+ const selectedPrice = selectedPriceId
1925
+ ? (priceOptions.find((entry) => entry.id === selectedPriceId) ?? null)
1926
+ : null;
1927
+ const selectedCurrency =
1928
+ selectedPrice?.currencyCode ??
1929
+ (typeof values?.currencyCode === "string" ? values.currencyCode : null);
1930
+ const quantityUnitCode = normalizeUnitCode(values?.quantityUnit);
1931
+ const baseUnitCode =
1932
+ unitOptions.find((option) => option.isBase)?.code ?? null;
1933
+ const selectedUnitOption = quantityUnitCode
1934
+ ? (unitOptions.find((option) => option.code === quantityUnitCode) ?? null)
1935
+ : null;
1936
+ const unitFactor = (() => {
1937
+ if (!quantityUnitCode) return null;
1938
+ if (baseUnitCode && quantityUnitCode === baseUnitCode) return 1;
1939
+ const value = normalizeNumber(selectedUnitOption?.toBaseFactor, Number.NaN);
1940
+ return Number.isFinite(value) && value > 0 ? value : null;
1941
+ })();
1942
+ const selectedBaseAmount = selectedPrice
1943
+ ? mode === "net"
1944
+ ? (selectedPrice.amountNet ?? selectedPrice.amountGross ?? null)
1945
+ : (selectedPrice.amountGross ?? selectedPrice.amountNet ?? null)
1946
+ : null;
1947
+ const convertedAmount =
1948
+ selectedBaseAmount !== null &&
1949
+ unitFactor !== null &&
1950
+ Number.isFinite(selectedBaseAmount * unitFactor)
1951
+ ? selectedBaseAmount * unitFactor
1952
+ : null;
1953
+ const isCatalogLine = lineMode !== "custom";
1165
1954
  return (
1166
- <div className="flex gap-2">
1167
- <Input
1168
- value={typeof value === 'string' ? value : value == null ? '' : String(value)}
1169
- onChange={(event) => setValue(event.target.value)}
1170
- placeholder="0.00"
1171
- />
1172
- <select
1173
- className="w-32 rounded border px-2 text-sm"
1174
- value={mode}
1175
- onChange={(event) => {
1176
- const nextMode = event.target.value === 'net' ? 'net' : 'gross'
1177
- setFormValue?.('priceMode', nextMode)
1178
- }}
1179
- >
1180
- <option value="gross">{t('sales.documents.items.priceGross', 'Gross')}</option>
1181
- <option value="net">{t('sales.documents.items.priceNet', 'Net')}</option>
1182
- </select>
1955
+ <div className="space-y-2">
1956
+ <div className="flex gap-2">
1957
+ <Input
1958
+ value={
1959
+ typeof value === "string"
1960
+ ? value
1961
+ : value == null
1962
+ ? ""
1963
+ : String(value)
1964
+ }
1965
+ onChange={(event) => setValue(event.target.value)}
1966
+ placeholder="0.00"
1967
+ />
1968
+ <select
1969
+ className="w-32 rounded border px-2 text-sm"
1970
+ value={mode}
1971
+ onChange={(event) => {
1972
+ const nextMode =
1973
+ event.target.value === "net" ? "net" : "gross";
1974
+ setFormValue?.("priceMode", nextMode);
1975
+ }}
1976
+ >
1977
+ <option value="gross">
1978
+ {t("sales.documents.items.priceGross", "Gross")}
1979
+ </option>
1980
+ <option value="net">
1981
+ {t("sales.documents.items.priceNet", "Net")}
1982
+ </option>
1983
+ </select>
1984
+ </div>
1985
+ {isCatalogLine && selectedPrice && quantityUnitCode && baseUnitCode ? (
1986
+ unitFactor !== null && convertedAmount !== null ? (
1987
+ <p className="text-xs text-muted-foreground">
1988
+ {t(
1989
+ "sales.documents.items.priceBasisTemplate",
1990
+ "Catalog price basis: {{baseAmount}} / {{baseUnit}}. Converted for {{unit}}: {{baseAmount}} × {{factor}} = {{convertedAmount}}.",
1991
+ {
1992
+ baseAmount: formatMoney(selectedBaseAmount as number, selectedCurrency),
1993
+ baseUnit: baseUnitCode,
1994
+ unit: quantityUnitCode,
1995
+ factor: unitFactor,
1996
+ convertedAmount: formatMoney(convertedAmount, selectedCurrency),
1997
+ },
1998
+ )}
1999
+ </p>
2000
+ ) : (
2001
+ <p className="text-xs text-muted-foreground">
2002
+ {t(
2003
+ "sales.documents.items.priceBasisMissingConversion",
2004
+ "Catalog price basis is base unit, but conversion for selected unit is missing.",
2005
+ )}
2006
+ </p>
2007
+ )
2008
+ ) : null}
2009
+ {isCatalogLine && !selectedPrice ? (
2010
+ <p className="text-xs text-muted-foreground">
2011
+ {t(
2012
+ "sales.documents.items.priceBasisManual",
2013
+ "Manual unit price mode. Price will not auto-convert until you select a catalog price.",
2014
+ )}
2015
+ </p>
2016
+ ) : null}
1183
2017
  </div>
1184
- )
2018
+ );
1185
2019
  },
1186
2020
  } satisfies CrudField,
1187
2021
  {
1188
- id: 'taxRateId',
1189
- label: t('sales.documents.items.taxRate', 'Tax class'),
1190
- type: 'custom',
1191
- layout: 'half',
1192
- component: ({ value, setValue, setFormValue, values }: FieldRenderProps) => {
2022
+ id: "taxRateId",
2023
+ label: t("sales.documents.items.taxRate", "Tax class"),
2024
+ type: "custom",
2025
+ layout: "half",
2026
+ component: ({
2027
+ value,
2028
+ setValue,
2029
+ setFormValue,
2030
+ values,
2031
+ }: FieldRenderProps) => {
1193
2032
  const resolvedValue =
1194
- typeof value === 'string' && value.trim().length
2033
+ typeof value === "string" && value.trim().length
1195
2034
  ? value
1196
- : findTaxRateIdByValue((values as any)?.taxRate)
1197
- const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
1198
- const nextId = event.target.value || null
1199
- const option = nextId ? taxRateMap.get(nextId) ?? null : null
1200
- setValue(nextId)
1201
- const rate = normalizeNumber(option?.rate)
1202
- setFormValue?.('taxRate', Number.isFinite(rate) ? rate : null)
1203
- }
2035
+ : findTaxRateIdByValue((values as Record<string, unknown>)?.taxRate as number | null | undefined);
2036
+ const handleChange = (
2037
+ event: React.ChangeEvent<HTMLSelectElement>,
2038
+ ) => {
2039
+ const nextId = event.target.value || null;
2040
+ const option = nextId ? (taxRateMap.get(nextId) ?? null) : null;
2041
+ setValue(nextId);
2042
+ const rate = normalizeNumber(option?.rate);
2043
+ setFormValue?.("taxRate", Number.isFinite(rate) ? rate : null);
2044
+ };
1204
2045
  return (
1205
2046
  <div className="flex items-center gap-2">
1206
2047
  <select
1207
2048
  className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
1208
- value={resolvedValue ?? ''}
2049
+ value={resolvedValue ?? ""}
1209
2050
  onChange={handleChange}
1210
2051
  disabled={!taxRates.length}
1211
2052
  >
1212
2053
  <option value="">
1213
2054
  {taxRates.length
1214
- ? t('sales.documents.items.taxRate.none', 'No tax class selected')
1215
- : t('sales.documents.items.taxRate.empty', 'No tax classes available')}
2055
+ ? t(
2056
+ "sales.documents.items.taxRate.none",
2057
+ "No tax class selected",
2058
+ )
2059
+ : t(
2060
+ "sales.documents.items.taxRate.empty",
2061
+ "No tax classes available",
2062
+ )}
1216
2063
  </option>
1217
2064
  {taxRates.map((rate) => (
1218
2065
  <option key={rate.id} value={rate.id}>
1219
2066
  {rate.name}
1220
- {rate.code ? ` • ${rate.code.toUpperCase()}` : ''}
1221
- {Number.isFinite(rate.rate) ? ` • ${rate.rate}%` : ''}
2067
+ {rate.code ? ` • ${rate.code.toUpperCase()}` : ""}
2068
+ {Number.isFinite(rate.rate) ? ` • ${rate.rate}%` : ""}
1222
2069
  </option>
1223
2070
  ))}
1224
2071
  </select>
@@ -1227,271 +2074,579 @@ export function LineItemDialog({
1227
2074
  variant="ghost"
1228
2075
  size="icon"
1229
2076
  onClick={() => {
1230
- if (typeof window !== 'undefined') {
1231
- window.open('/backend/config/sales?section=tax-rates', '_blank', 'noopener,noreferrer')
2077
+ if (typeof window !== "undefined") {
2078
+ window.open(
2079
+ "/backend/config/sales?section=tax-rates",
2080
+ "_blank",
2081
+ "noopener,noreferrer",
2082
+ );
1232
2083
  }
1233
2084
  }}
1234
- title={t('catalog.products.create.taxRates.manage', 'Manage tax classes')}
2085
+ title={t(
2086
+ "catalog.products.create.taxRates.manage",
2087
+ "Manage tax classes",
2088
+ )}
1235
2089
  >
1236
2090
  <Settings className="h-4 w-4" />
1237
2091
  </Button>
1238
2092
  </div>
1239
- )
2093
+ );
1240
2094
  },
1241
2095
  } satisfies CrudField,
1242
2096
  {
1243
- id: 'quantity',
1244
- label: t('sales.documents.items.quantity', 'Quantity'),
1245
- type: 'custom',
1246
- layout: 'half',
1247
- component: ({ value, setValue }: FieldRenderProps) => (
2097
+ id: "quantityUnit",
2098
+ label: t("sales.documents.items.quantityUnit", "Unit"),
2099
+ type: "custom",
2100
+ layout: "half",
2101
+ component: ({ value, setValue, setFormValue, values }: FieldRenderProps) => {
2102
+ const productId =
2103
+ typeof values?.productId === "string" ? values.productId : null;
2104
+ const variantId =
2105
+ typeof values?.variantId === "string" ? values.variantId : null;
2106
+ if (isCustomLine) {
2107
+ return (
2108
+ <Input
2109
+ value={typeof value === "string" ? value : ""}
2110
+ onChange={(event) => setValue(event.target.value || null)}
2111
+ placeholder={t(
2112
+ "sales.documents.items.quantityUnitPlaceholder",
2113
+ "e.g. pc",
2114
+ )}
2115
+ />
2116
+ );
2117
+ }
2118
+ return (
2119
+ <select
2120
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
2121
+ value={typeof value === "string" ? value : ""}
2122
+ onChange={(event) => {
2123
+ const nextValue = event.target.value || null;
2124
+ const nextUnitPrice = convertUnitPriceForUnitChange(
2125
+ values?.unitPrice,
2126
+ typeof value === "string" ? value : null,
2127
+ nextValue,
2128
+ );
2129
+ setValue(nextValue);
2130
+ if (nextUnitPrice) {
2131
+ setFormValue?.("unitPrice", nextUnitPrice);
2132
+ }
2133
+ if (productId) {
2134
+ const selectedPriceId =
2135
+ typeof values?.priceId === "string" ? values.priceId : null;
2136
+ const selectedPriceKindId =
2137
+ selectedPriceId
2138
+ ? (priceOptions.find(
2139
+ (entry) => entry.id === selectedPriceId,
2140
+ )?.priceKindId ?? null)
2141
+ : null;
2142
+ void loadPrices(
2143
+ productId,
2144
+ variantId,
2145
+ typeof values?.quantity === "string" ? values.quantity : 1,
2146
+ nextValue,
2147
+ ).then((prices) => {
2148
+ if (!selectedPriceId) return;
2149
+ const nextSelected = selectPriceAfterRefresh(
2150
+ prices,
2151
+ selectedPriceId,
2152
+ selectedPriceKindId,
2153
+ );
2154
+ if (!nextSelected) {
2155
+ setFormValue?.("priceId", null);
2156
+ return;
2157
+ }
2158
+ applyPriceSelection(
2159
+ nextSelected,
2160
+ setFormValue,
2161
+ {
2162
+ fallbackTaxSource: variantOption ?? productOption ?? null,
2163
+ quantityUnit: nextValue,
2164
+ },
2165
+ );
2166
+ });
2167
+ }
2168
+ }}
2169
+ disabled={!productId}
2170
+ >
2171
+ <option value="">
2172
+ {t("sales.documents.items.quantityUnitSelect", "Select unit")}
2173
+ </option>
2174
+ {unitOptions.map((option) => (
2175
+ <option key={option.code} value={option.code}>
2176
+ {option.isBase
2177
+ ? `${option.code} (${t("sales.documents.items.baseUnitTag", "base")})`
2178
+ : option.code}
2179
+ </option>
2180
+ ))}
2181
+ </select>
2182
+ );
2183
+ },
2184
+ } satisfies CrudField,
2185
+ {
2186
+ id: "quantity",
2187
+ label: t("sales.documents.items.quantity", "Quantity"),
2188
+ type: "custom",
2189
+ layout: "half",
2190
+ component: ({ value, setValue, setFormValue, values }: FieldRenderProps) => (
1248
2191
  <Input
1249
- value={typeof value === 'string' ? value : value == null ? '' : String(value)}
1250
- onChange={(event) => setValue(event.target.value)}
2192
+ value={
2193
+ typeof value === "string"
2194
+ ? value
2195
+ : value == null
2196
+ ? ""
2197
+ : String(value)
2198
+ }
2199
+ onChange={(event) => {
2200
+ const nextQuantity = event.target.value;
2201
+ setValue(nextQuantity);
2202
+ const productId =
2203
+ typeof values?.productId === "string" ? values.productId : null;
2204
+ const variantId =
2205
+ typeof values?.variantId === "string" ? values.variantId : null;
2206
+ const quantityUnit =
2207
+ typeof values?.quantityUnit === "string"
2208
+ ? values.quantityUnit
2209
+ : null;
2210
+ if (productId) {
2211
+ const selectedPriceId =
2212
+ typeof values?.priceId === "string" ? values.priceId : null;
2213
+ const selectedPriceKindId =
2214
+ selectedPriceId
2215
+ ? (priceOptions.find((entry) => entry.id === selectedPriceId)
2216
+ ?.priceKindId ?? null)
2217
+ : null;
2218
+ void loadPrices(
2219
+ productId,
2220
+ variantId,
2221
+ nextQuantity,
2222
+ quantityUnit,
2223
+ ).then((prices) => {
2224
+ if (!selectedPriceId) return;
2225
+ const nextSelected = selectPriceAfterRefresh(
2226
+ prices,
2227
+ selectedPriceId,
2228
+ selectedPriceKindId,
2229
+ );
2230
+ if (!nextSelected) {
2231
+ setFormValue?.("priceId", null);
2232
+ return;
2233
+ }
2234
+ applyPriceSelection(
2235
+ nextSelected,
2236
+ setFormValue,
2237
+ {
2238
+ fallbackTaxSource: variantOption ?? productOption ?? null,
2239
+ quantityUnit,
2240
+ },
2241
+ );
2242
+ });
2243
+ }
2244
+ }}
1251
2245
  placeholder="1"
1252
2246
  />
1253
2247
  ),
1254
2248
  } satisfies CrudField,
1255
2249
  {
1256
- id: 'statusEntryId',
1257
- label: t('sales.documents.items.status', 'Status'),
1258
- type: 'custom',
1259
- layout: 'half',
2250
+ id: "uomPreview",
2251
+ label: t("sales.documents.items.uomPreview", "Normalized quantity"),
2252
+ type: "custom",
2253
+ layout: "full",
2254
+ component: ({ values }: FieldRenderProps) => {
2255
+ if (isCustomLine) return null;
2256
+ const quantity = normalizeNumber(values?.quantity, Number.NaN);
2257
+ const enteredUnit = normalizeUnitCode(values?.quantityUnit);
2258
+ if (!Number.isFinite(quantity) || quantity <= 0 || !enteredUnit) {
2259
+ return (
2260
+ <p className="text-xs text-muted-foreground">
2261
+ {t(
2262
+ "sales.documents.items.uomPreviewEmpty",
2263
+ "Select unit and quantity to preview normalization.",
2264
+ )}
2265
+ </p>
2266
+ );
2267
+ }
2268
+ const selectedOption =
2269
+ unitOptions.find((option) => option.code === enteredUnit) ?? null;
2270
+ const baseOption =
2271
+ unitOptions.find((option) => option.isBase) ?? null;
2272
+ const factor = selectedOption?.toBaseFactor;
2273
+ if (!Number.isFinite(factor) || !factor || !baseOption) {
2274
+ return (
2275
+ <p className="text-xs text-muted-foreground">
2276
+ {t(
2277
+ "sales.documents.items.uomPreviewUnavailable",
2278
+ "Missing conversion to base unit.",
2279
+ )}
2280
+ </p>
2281
+ );
2282
+ }
2283
+ const normalized = normalizeQuantityPreview(quantity * factor);
2284
+ return (
2285
+ <p className="text-xs text-muted-foreground">
2286
+ {t(
2287
+ "sales.documents.items.uomPreviewTemplate",
2288
+ "{{quantity}} {{unit}} → {{normalized}} {{baseUnit}}",
2289
+ {
2290
+ quantity,
2291
+ unit: enteredUnit,
2292
+ normalized,
2293
+ baseUnit: baseOption.code,
2294
+ },
2295
+ )}
2296
+ </p>
2297
+ );
2298
+ },
2299
+ } satisfies CrudField,
2300
+ {
2301
+ id: "statusEntryId",
2302
+ label: t("sales.documents.items.status", "Status"),
2303
+ type: "custom",
2304
+ layout: "half",
1260
2305
  component: ({ value, setValue }: FieldRenderProps) => (
1261
2306
  <LookupSelect
1262
- value={typeof value === 'string' ? value : null}
2307
+ value={typeof value === "string" ? value : null}
1263
2308
  onChange={(next) => setValue(next ?? null)}
1264
- placeholder={t('sales.documents.items.statusPlaceholder', 'Select status')}
1265
- emptyLabel={t('sales.documents.items.statusEmpty', 'No status')}
2309
+ placeholder={t(
2310
+ "sales.documents.items.statusPlaceholder",
2311
+ "Select status",
2312
+ )}
2313
+ emptyLabel={t("sales.documents.items.statusEmpty", "No status")}
1266
2314
  fetchItems={fetchLineStatusItems}
1267
- loadingLabel={t('sales.documents.items.statusLoading', 'Loading statuses…')}
1268
- selectLabel={t('ui.lookupSelect.select', 'Select')}
1269
- selectedLabel={t('ui.lookupSelect.selected', 'Selected')}
1270
- clearLabel={t('ui.lookupSelect.clearSelection', 'Clear selection')}
1271
- startTypingLabel={t('ui.lookupSelect.startTyping', 'Start typing to search.')}
2315
+ loadingLabel={t(
2316
+ "sales.documents.items.statusLoading",
2317
+ "Loading statuses…",
2318
+ )}
2319
+ selectLabel={t("ui.lookupSelect.select", "Select")}
2320
+ selectedLabel={t("ui.lookupSelect.selected", "Selected")}
2321
+ clearLabel={t("ui.lookupSelect.clearSelection", "Clear selection")}
2322
+ startTypingLabel={t(
2323
+ "ui.lookupSelect.startTyping",
2324
+ "Start typing to search.",
2325
+ )}
1272
2326
  minQuery={0}
1273
2327
  />
1274
2328
  ),
1275
2329
  } satisfies CrudField,
1276
2330
  {
1277
- id: 'name',
1278
- label: t('sales.documents.items.name', 'Name'),
1279
- type: 'text',
1280
- placeholder: t('sales.documents.items.namePlaceholder', 'Optional line name'),
1281
- layout: 'full',
2331
+ id: "name",
2332
+ label: t("sales.documents.items.name", "Name"),
2333
+ type: "text",
2334
+ placeholder: t(
2335
+ "sales.documents.items.namePlaceholder",
2336
+ "Optional line name",
2337
+ ),
2338
+ layout: "full",
1282
2339
  required: isCustomLine,
1283
2340
  } satisfies CrudField,
1284
- ]
2341
+ ];
1285
2342
  }, [
2343
+ applyPriceSelection,
2344
+ convertUnitPriceForUnitChange,
1286
2345
  currencyCode,
1287
2346
  findTaxRateIdByValue,
1288
2347
  loadPrices,
2348
+ loadProductUnits,
1289
2349
  loadProductOptions,
1290
2350
  loadVariantOptions,
1291
2351
  fetchLineStatusItems,
1292
2352
  priceLoading,
1293
2353
  priceOptions,
1294
2354
  productOption,
2355
+ unitOptions,
1295
2356
  lineMode,
1296
2357
  variantOption,
1297
2358
  t,
1298
2359
  taxRateMap,
1299
- taxRates.length,
2360
+ taxRates,
1300
2361
  resolveTaxSelection,
2362
+ selectPriceAfterRefresh,
1301
2363
  hasTaxMetadata,
1302
- ])
2364
+ ]);
1303
2365
 
1304
2366
  const groups = React.useMemo<CrudFormGroup[]>(() => {
1305
2367
  return [
1306
- { id: 'line-core', fields },
2368
+ { id: "line-core", fields },
1307
2369
  {
1308
- id: 'line-custom',
2370
+ id: "line-custom",
1309
2371
  column: 2,
1310
- title: t('entities.customFields.title', 'Custom fields'),
1311
- kind: 'customFields',
2372
+ title: t("entities.customFields.title", "Custom fields"),
2373
+ kind: "customFields",
1312
2374
  },
1313
- ]
1314
- }, [fields, t])
2375
+ ];
2376
+ }, [fields, t]);
1315
2377
 
1316
2378
  React.useEffect(() => {
1317
- if (!open) return
2379
+ if (!open) return;
1318
2380
  if (!initialLine) {
1319
- resetForm()
1320
- return
2381
+ resetForm();
2382
+ return;
1321
2383
  }
1322
- setEditingId(initialLine.id)
1323
- const nextForm = defaultForm(initialLine.currencyCode ?? currencyCode)
1324
- const meta = initialLine.metadata ?? {}
1325
- const snapshot = (initialLine.catalogSnapshot as Record<string, unknown> | null | undefined) ?? null
2384
+ setEditingId(initialLine.id);
2385
+ const nextForm = defaultForm(initialLine.currencyCode ?? currencyCode);
2386
+ const meta = initialLine.metadata ?? {};
2387
+ const snapshot =
2388
+ (initialLine.catalogSnapshot as
2389
+ | Record<string, unknown>
2390
+ | null
2391
+ | undefined) ?? null;
1326
2392
  const snapshotProduct =
1327
- snapshot && typeof snapshot === 'object' && typeof (snapshot as any).product === 'object' && (snapshot as any).product
1328
- ? ((snapshot as any).product as Record<string, unknown>)
1329
- : null
2393
+ snapshot &&
2394
+ typeof snapshot === "object" &&
2395
+ typeof (snapshot as CatalogSnapshotRecord).product === "object" &&
2396
+ (snapshot as CatalogSnapshotRecord).product
2397
+ ? ((snapshot as CatalogSnapshotRecord).product as Record<string, unknown>)
2398
+ : null;
1330
2399
  const snapshotVariant =
1331
- snapshot && typeof snapshot === 'object' && typeof (snapshot as any).variant === 'object' && (snapshot as any).variant
1332
- ? ((snapshot as any).variant as Record<string, unknown>)
1333
- : null
2400
+ snapshot &&
2401
+ typeof snapshot === "object" &&
2402
+ typeof (snapshot as CatalogSnapshotRecord).variant === "object" &&
2403
+ (snapshot as CatalogSnapshotRecord).variant
2404
+ ? ((snapshot as CatalogSnapshotRecord).variant as Record<string, unknown>)
2405
+ : null;
2406
+ const metaRec = (typeof meta === "object" && meta ? meta : null) as LineMetadataRecord | null;
1334
2407
  const metaLineMode =
1335
- typeof (meta as any)?.lineMode === 'string' && ((meta as any).lineMode === 'custom' || (meta as any).lineMode === 'catalog')
1336
- ? ((meta as any).lineMode as 'custom' | 'catalog')
1337
- : (meta as any)?.customLine
1338
- ? 'custom'
1339
- : undefined
1340
- nextForm.productId = initialLine.productId
1341
- nextForm.variantId = initialLine.productVariantId
1342
- nextForm.quantity = initialLine.quantity.toString()
1343
- const metaMode = (meta as any)?.priceMode
2408
+ typeof metaRec?.lineMode === "string" &&
2409
+ (metaRec.lineMode === "custom" || metaRec.lineMode === "catalog")
2410
+ ? (metaRec.lineMode as "custom" | "catalog")
2411
+ : metaRec?.customLine
2412
+ ? "custom"
2413
+ : undefined;
2414
+ nextForm.productId = initialLine.productId;
2415
+ nextForm.variantId = initialLine.productVariantId;
2416
+ nextForm.quantity = initialLine.quantity.toString();
2417
+ nextForm.quantityUnit = normalizeUnitCode(initialLine.quantityUnit) ?? null;
2418
+ const metaMode = metaRec?.priceMode;
1344
2419
  const resolvedPriceMode =
1345
- metaMode === 'net' || metaMode === 'gross' ? metaMode : initialLine.priceMode ?? 'gross'
2420
+ metaMode === "net" || metaMode === "gross"
2421
+ ? metaMode
2422
+ : (initialLine.priceMode ?? "gross");
1346
2423
  nextForm.unitPrice =
1347
- resolvedPriceMode === 'net' ? initialLine.unitPriceNet.toString() : initialLine.unitPriceGross.toString()
1348
- nextForm.priceMode = resolvedPriceMode
1349
- nextForm.taxRate = Number.isFinite(initialLine.taxRate) ? initialLine.taxRate : null
1350
- nextForm.name = initialLine.name ?? ''
1351
- nextForm.catalogSnapshot = snapshot ?? null
1352
- nextForm.customFieldSetId = initialLine.customFieldSetId ?? null
1353
- nextForm.statusEntryId = initialLine.statusEntryId ?? null
2424
+ resolvedPriceMode === "net"
2425
+ ? initialLine.unitPriceNet.toString()
2426
+ : initialLine.unitPriceGross.toString();
2427
+ nextForm.priceMode = resolvedPriceMode;
2428
+ nextForm.taxRate = Number.isFinite(initialLine.taxRate)
2429
+ ? initialLine.taxRate
2430
+ : null;
2431
+ nextForm.name = initialLine.name ?? "";
2432
+ nextForm.catalogSnapshot = snapshot ?? null;
2433
+ nextForm.customFieldSetId = initialLine.customFieldSetId ?? null;
2434
+ nextForm.statusEntryId = initialLine.statusEntryId ?? null;
1354
2435
  nextForm.lineMode =
1355
2436
  metaLineMode ??
1356
- (initialLine.productId || initialLine.productVariantId ? 'catalog' : 'custom')
2437
+ (initialLine.productId || initialLine.productVariantId
2438
+ ? "catalog"
2439
+ : "custom");
1357
2440
  const metaTaxRateId =
1358
- typeof (meta as any).taxRateId === 'string' ? ((meta as any).taxRateId as string) : null
1359
- const fallbackTaxRateId = findTaxRateIdByValue(nextForm.taxRate)
2441
+ typeof metaRec?.taxRateId === "string"
2442
+ ? metaRec.taxRateId
2443
+ : null;
2444
+ const fallbackTaxRateId = findTaxRateIdByValue(nextForm.taxRate);
1360
2445
  nextForm.taxRateId =
1361
2446
  metaTaxRateId ??
1362
2447
  fallbackTaxRateId ??
1363
- (defaultTaxRateRef.current ? defaultTaxRateRef.current.id : null)
2448
+ (defaultTaxRateRef.current ? defaultTaxRateRef.current.id : null);
1364
2449
  if (!Number.isFinite(nextForm.taxRate) && nextForm.taxRateId) {
1365
- const matched = taxRatesRef.current.find((rate) => rate.id === nextForm.taxRateId)
1366
- const numericRate = normalizeNumber(matched?.rate)
2450
+ const matched = taxRatesRef.current.find(
2451
+ (rate) => rate.id === nextForm.taxRateId,
2452
+ );
2453
+ const numericRate = normalizeNumber(matched?.rate);
1367
2454
  if (Number.isFinite(numericRate)) {
1368
- nextForm.taxRate = numericRate
2455
+ nextForm.taxRate = numericRate;
1369
2456
  }
1370
2457
  }
1371
- let resolvedProductOption: ProductOption | null = null
1372
- let resolvedVariantOption: VariantOption | null = null
1373
- if (typeof meta === 'object' && meta) {
1374
- const mode = (meta as any).priceMode
1375
- if (mode === 'net' || mode === 'gross') {
1376
- nextForm.priceMode = mode
2458
+ let resolvedProductOption: ProductOption | null = null;
2459
+ let resolvedVariantOption: VariantOption | null = null;
2460
+ if (metaRec) {
2461
+ const metaRecord = metaRec;
2462
+ const mode = metaRecord.priceMode;
2463
+ if (mode === "net" || mode === "gross") {
2464
+ nextForm.priceMode = mode;
1377
2465
  nextForm.unitPrice =
1378
- mode === 'net' ? initialLine.unitPriceNet.toString() : initialLine.unitPriceGross.toString()
2466
+ mode === "net"
2467
+ ? initialLine.unitPriceNet.toString()
2468
+ : initialLine.unitPriceGross.toString();
1379
2469
  }
1380
2470
  nextForm.priceId =
1381
- typeof (meta as any).priceId === 'string' ? ((meta as any).priceId as string) : null
1382
- const productTitle = typeof (meta as any).productTitle === 'string' ? (meta as any).productTitle : initialLine.name
1383
- const productSku = typeof (meta as any).productSku === 'string' ? (meta as any).productSku : null
2471
+ typeof metaRecord.priceId === "string"
2472
+ ? metaRecord.priceId
2473
+ : null;
2474
+ const productTitle =
2475
+ typeof metaRecord.productTitle === "string"
2476
+ ? metaRecord.productTitle
2477
+ : initialLine.name;
2478
+ const productSku =
2479
+ typeof metaRecord.productSku === "string"
2480
+ ? metaRecord.productSku
2481
+ : null;
1384
2482
  const productThumbnail =
1385
- typeof (meta as any).productThumbnail === 'string' ? (meta as any).productThumbnail : null
2483
+ typeof metaRecord.productThumbnail === "string"
2484
+ ? metaRecord.productThumbnail
2485
+ : null;
1386
2486
  if (productTitle && initialLine.productId) {
1387
- const option = { id: initialLine.productId, title: productTitle, sku: productSku, thumbnailUrl: productThumbnail }
1388
- productOptionsRef.current.set(initialLine.productId, option)
1389
- resolvedProductOption = option
2487
+ const option = {
2488
+ id: initialLine.productId,
2489
+ title: productTitle,
2490
+ sku: productSku,
2491
+ thumbnailUrl: productThumbnail,
2492
+ };
2493
+ productOptionsRef.current.set(initialLine.productId, option);
2494
+ resolvedProductOption = option;
1390
2495
  }
1391
- const variantTitle = typeof (meta as any).variantTitle === 'string' ? (meta as any).variantTitle : null
1392
- const variantSku = typeof (meta as any).variantSku === 'string' ? (meta as any).variantSku : null
2496
+ const variantTitle =
2497
+ typeof metaRecord.variantTitle === "string"
2498
+ ? metaRecord.variantTitle
2499
+ : null;
2500
+ const variantSku =
2501
+ typeof metaRecord.variantSku === "string"
2502
+ ? metaRecord.variantSku
2503
+ : null;
1393
2504
  const variantThumb =
1394
- typeof (meta as any).variantThumbnail === 'string' ? (meta as any).variantThumbnail : productThumbnail
2505
+ typeof metaRecord.variantThumbnail === "string"
2506
+ ? metaRecord.variantThumbnail
2507
+ : productThumbnail;
1395
2508
  if (variantTitle && initialLine.productVariantId) {
1396
2509
  const option = {
1397
2510
  id: initialLine.productVariantId,
1398
2511
  title: variantTitle,
1399
2512
  sku: variantSku,
1400
2513
  thumbnailUrl: variantThumb ?? null,
1401
- }
1402
- variantOptionsRef.current.set(initialLine.productVariantId, option)
1403
- resolvedVariantOption = option
2514
+ };
2515
+ variantOptionsRef.current.set(initialLine.productVariantId, option);
2516
+ resolvedVariantOption = option;
1404
2517
  }
1405
2518
  }
1406
2519
  if (!resolvedProductOption && initialLine.productId && snapshotProduct) {
2520
+ const sp = snapshotProduct as SnapshotEntity;
1407
2521
  const snapshotTitle =
1408
- typeof (snapshotProduct as any).title === 'string' && (snapshotProduct as any).title.trim().length
1409
- ? (snapshotProduct as any).title
1410
- : initialLine.name ?? initialLine.productId
2522
+ typeof sp.title === "string" && sp.title.trim().length
2523
+ ? sp.title
2524
+ : (initialLine.name ?? initialLine.productId);
1411
2525
  const snapshotSku =
1412
- typeof (snapshotProduct as any).sku === 'string' && (snapshotProduct as any).sku.trim().length
1413
- ? (snapshotProduct as any).sku
1414
- : null
2526
+ typeof sp.sku === "string" && sp.sku.trim().length
2527
+ ? sp.sku
2528
+ : null;
1415
2529
  const snapshotThumb =
1416
- typeof (snapshotProduct as any).thumbnailUrl === 'string'
1417
- ? (snapshotProduct as any).thumbnailUrl
1418
- : typeof (snapshotProduct as any).thumbnail_url === 'string'
1419
- ? (snapshotProduct as any).thumbnail_url
1420
- : null
1421
- const snapshotTaxRate = normalizeNumber((snapshotProduct as any).taxRate, Number.NaN)
1422
- const option = {
2530
+ typeof sp.thumbnailUrl === "string"
2531
+ ? sp.thumbnailUrl
2532
+ : typeof sp.thumbnail_url === "string"
2533
+ ? sp.thumbnail_url
2534
+ : null;
2535
+ const snapshotTaxRate = normalizeNumber(sp.taxRate, Number.NaN);
2536
+ const option: ProductOption = {
1423
2537
  id: initialLine.productId,
1424
2538
  title: snapshotTitle,
1425
2539
  sku: snapshotSku,
1426
2540
  thumbnailUrl: snapshotThumb,
1427
- taxRateId: typeof (snapshotProduct as any).taxRateId === 'string' ? (snapshotProduct as any).taxRateId : null,
2541
+ taxRateId: typeof sp.taxRateId === "string" ? sp.taxRateId : null,
1428
2542
  taxRate: Number.isFinite(snapshotTaxRate) ? snapshotTaxRate : null,
1429
- }
1430
- productOptionsRef.current.set(initialLine.productId, option)
1431
- resolvedProductOption = option
2543
+ };
2544
+ productOptionsRef.current.set(initialLine.productId, option);
2545
+ resolvedProductOption = option;
1432
2546
  }
1433
- if (!resolvedVariantOption && initialLine.productVariantId && snapshotVariant) {
2547
+ if (
2548
+ !resolvedVariantOption &&
2549
+ initialLine.productVariantId &&
2550
+ snapshotVariant
2551
+ ) {
2552
+ const sv = snapshotVariant as SnapshotEntity;
1434
2553
  const snapshotTitle =
1435
- typeof (snapshotVariant as any).title === 'string' && (snapshotVariant as any).title.trim().length
1436
- ? (snapshotVariant as any).title
1437
- : initialLine.name ?? initialLine.productVariantId
2554
+ typeof sv.title === "string" && sv.title.trim().length
2555
+ ? sv.title
2556
+ : (initialLine.name ?? initialLine.productVariantId);
1438
2557
  const snapshotSku =
1439
- typeof (snapshotVariant as any).sku === 'string' && (snapshotVariant as any).sku.trim().length
1440
- ? (snapshotVariant as any).sku
1441
- : null
2558
+ typeof sv.sku === "string" && sv.sku.trim().length
2559
+ ? sv.sku
2560
+ : null;
1442
2561
  const snapshotThumb =
1443
- typeof (snapshotVariant as any).thumbnailUrl === 'string'
1444
- ? (snapshotVariant as any).thumbnailUrl
1445
- : typeof (snapshotVariant as any).thumbnail_url === 'string'
1446
- ? (snapshotVariant as any).thumbnail_url
1447
- : resolvedProductOption?.thumbnailUrl ?? productOptionsRef.current.get(initialLine.productId ?? '')?.thumbnailUrl ?? null
1448
- const snapshotTaxRate = normalizeNumber((snapshotVariant as any).taxRate, Number.NaN)
1449
- const option = {
2562
+ typeof sv.thumbnailUrl === "string"
2563
+ ? sv.thumbnailUrl
2564
+ : typeof sv.thumbnail_url === "string"
2565
+ ? sv.thumbnail_url
2566
+ : (resolvedProductOption?.thumbnailUrl ??
2567
+ productOptionsRef.current.get(initialLine.productId ?? "")
2568
+ ?.thumbnailUrl ??
2569
+ null);
2570
+ const snapshotTaxRate = normalizeNumber(sv.taxRate, Number.NaN);
2571
+ const option: VariantOption = {
1450
2572
  id: initialLine.productVariantId,
1451
2573
  title: snapshotTitle,
1452
2574
  sku: snapshotSku,
1453
2575
  thumbnailUrl: snapshotThumb,
1454
- taxRateId: typeof (snapshotVariant as any).taxRateId === 'string' ? (snapshotVariant as any).taxRateId : null,
2576
+ taxRateId: typeof sv.taxRateId === "string" ? sv.taxRateId : null,
1455
2577
  taxRate: Number.isFinite(snapshotTaxRate) ? snapshotTaxRate : null,
1456
- }
1457
- variantOptionsRef.current.set(initialLine.productVariantId, option)
1458
- resolvedVariantOption = option
2578
+ };
2579
+ variantOptionsRef.current.set(initialLine.productVariantId, option);
2580
+ resolvedVariantOption = option;
1459
2581
  }
1460
- if (resolvedProductOption) setProductOption(resolvedProductOption)
1461
- if (resolvedVariantOption) setVariantOption(resolvedVariantOption)
1462
- const customValues = extractCustomFieldValues(initialLine as Record<string, unknown>)
1463
- const merged = { ...nextForm, ...customValues }
1464
- setInitialValues(merged)
1465
- setLineMode(merged.lineMode)
1466
- setFormResetKey((prev) => prev + 1)
2582
+ if (resolvedProductOption) setProductOption(resolvedProductOption);
2583
+ if (resolvedVariantOption) setVariantOption(resolvedVariantOption);
2584
+ const customValues = extractCustomFieldValues(
2585
+ initialLine as Record<string, unknown>,
2586
+ );
2587
+ const merged = { ...nextForm, ...customValues };
2588
+ setInitialValues(merged);
2589
+ setLineMode(merged.lineMode);
2590
+ setFormResetKey((prev) => prev + 1);
1467
2591
  if (initialLine.productId) {
1468
- void loadPrices(initialLine.productId, initialLine.productVariantId)
2592
+ void loadProductUnits(initialLine.productId, resolvedProductOption).then(
2593
+ (options) => {
2594
+ const requestedUnit = normalizeUnitCode(nextForm.quantityUnit);
2595
+ if (
2596
+ requestedUnit &&
2597
+ !options.some((entry) => entry.code === requestedUnit)
2598
+ ) {
2599
+ setUnitOptions((prev) => [
2600
+ ...prev,
2601
+ { code: requestedUnit, toBaseFactor: null, isBase: false },
2602
+ ]);
2603
+ }
2604
+ },
2605
+ );
2606
+ void loadPrices(
2607
+ initialLine.productId,
2608
+ initialLine.productVariantId,
2609
+ nextForm.quantity,
2610
+ nextForm.quantityUnit,
2611
+ );
1469
2612
  } else {
1470
- setPriceOptions([])
2613
+ setPriceOptions([]);
2614
+ setUnitOptions([]);
1471
2615
  }
1472
- }, [currencyCode, findTaxRateIdByValue, initialLine, loadPrices, open, resetForm])
2616
+ }, [
2617
+ currencyCode,
2618
+ findTaxRateIdByValue,
2619
+ initialLine,
2620
+ loadPrices,
2621
+ loadProductUnits,
2622
+ open,
2623
+ resetForm,
2624
+ ]);
1473
2625
 
1474
2626
  return (
1475
- <Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : closeDialog())}>
2627
+ <Dialog
2628
+ open={open}
2629
+ onOpenChange={(next) => (next ? onOpenChange(true) : closeDialog())}
2630
+ >
1476
2631
  <DialogContent
1477
2632
  className="sm:max-w-5xl"
1478
2633
  ref={dialogContentRef}
1479
2634
  onKeyDown={(event) => {
1480
- if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
1481
- event.preventDefault()
1482
- dialogContentRef.current?.querySelector('form')?.requestSubmit()
2635
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
2636
+ event.preventDefault();
2637
+ dialogContentRef.current?.querySelector("form")?.requestSubmit();
1483
2638
  }
1484
- if (event.key === 'Escape') {
1485
- event.preventDefault()
1486
- closeDialog()
2639
+ if (event.key === "Escape") {
2640
+ event.preventDefault();
2641
+ closeDialog();
1487
2642
  }
1488
2643
  }}
1489
2644
  >
1490
2645
  <DialogHeader>
1491
2646
  <DialogTitle>
1492
2647
  {editingId
1493
- ? t('sales.documents.items.editTitle', 'Edit line')
1494
- : t('sales.documents.items.addTitle', 'Add line')}
2648
+ ? t("sales.documents.items.editTitle", "Edit line")
2649
+ : t("sales.documents.items.addTitle", "Add line")}
1495
2650
  </DialogTitle>
1496
2651
  </DialogHeader>
1497
2652
  <CrudForm<LineFormState>
@@ -1503,13 +2658,13 @@ export function LineItemDialog({
1503
2658
  initialValues={initialValues}
1504
2659
  submitLabel={
1505
2660
  editingId
1506
- ? t('sales.documents.items.save', 'Save changes')
1507
- : t('sales.documents.items.addLine', 'Add item')
2661
+ ? t("sales.documents.items.save", "Save changes")
2662
+ : t("sales.documents.items.addLine", "Add item")
1508
2663
  }
1509
2664
  onSubmit={handleFormSubmit}
1510
- loadingMessage={t('sales.documents.items.loading', 'Loading items…')}
2665
+ loadingMessage={t("sales.documents.items.loading", "Loading items…")}
1511
2666
  />
1512
2667
  </DialogContent>
1513
2668
  </Dialog>
1514
- )
2669
+ );
1515
2670
  }