@open-mercato/core 0.4.5-develop-f4858e0ef3 → 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
@@ -0,0 +1,745 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { useT } from "@open-mercato/shared/lib/i18n/context";
5
+ import { apiCall } from "@open-mercato/ui/backend/utils/apiCall";
6
+ import { Button } from "@open-mercato/ui/primitives/button";
7
+ import { Checkbox } from "@open-mercato/ui/primitives/checkbox";
8
+ import { Input } from "@open-mercato/ui/primitives/input";
9
+ import { Label } from "@open-mercato/ui/primitives/label";
10
+ import { Spinner } from "@open-mercato/ui/primitives/spinner";
11
+ import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
12
+ import type {
13
+ ProductFormValues,
14
+ ProductUnitConversionDraft,
15
+ } from "./productForm";
16
+ import { createProductUnitConversionDraft } from "./productForm";
17
+ import { REFERENCE_UNIT_CODES } from "@open-mercato/shared/lib/units/unitCodes";
18
+ import { toTrimmedOrNull } from "./productFormUtils";
19
+
20
+ type UnitDictionaryEntry = {
21
+ id?: string;
22
+ value?: string;
23
+ label?: string;
24
+ };
25
+
26
+ type UnitDictionaryResponse = {
27
+ entries?: UnitDictionaryEntry[];
28
+ };
29
+
30
+ type UnitOption = {
31
+ value: string;
32
+ label: string;
33
+ };
34
+
35
+ type ProductUomSectionProps = {
36
+ values: ProductFormValues;
37
+ errors: Record<string, string>;
38
+ setValue: (id: string, value: unknown) => void;
39
+ embedded?: boolean;
40
+ };
41
+
42
+ const REFERENCE_UNIT_DISPLAY: Record<string, string> = {
43
+ kg: "1 kg", l: "1 l", m2: "1 m\u00B2", m3: "1 m\u00B3", pc: "1 pc",
44
+ };
45
+ const REFERENCE_UNIT_OPTIONS = REFERENCE_UNIT_CODES.map((code) => ({
46
+ value: code,
47
+ i18nKey: `catalog.products.unitPrice.options.${code}` as const,
48
+ fallback: REFERENCE_UNIT_DISPLAY[code] ?? code,
49
+ }));
50
+
51
+ function normalizeDecimalInput(value: string): string {
52
+ return value.replace(/,/g, ".");
53
+ }
54
+
55
+ function toPositiveNumber(value: unknown): number | null {
56
+ if (typeof value === "number") {
57
+ return Number.isFinite(value) && value > 0 ? value : null;
58
+ }
59
+ if (typeof value !== "string") return null;
60
+ const normalized = toTrimmedOrNull(value);
61
+ if (!normalized) return null;
62
+ const numeric = Number(normalized.replace(",", "."));
63
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
64
+ }
65
+
66
+ function toSortValue(value: string): number {
67
+ const numeric = Number(value);
68
+ return Number.isFinite(numeric) ? numeric : Number.MAX_SAFE_INTEGER;
69
+ }
70
+
71
+ function formatPreviewNumber(value: number): string {
72
+ if (!Number.isFinite(value)) return "0";
73
+ const rounded = Math.round(value * 1_000_000) / 1_000_000;
74
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toString();
75
+ }
76
+
77
+ function normalizeConversions(value: unknown): ProductUnitConversionDraft[] {
78
+ if (!Array.isArray(value)) return [];
79
+ const normalized = value
80
+ .map((entry) => {
81
+ if (!entry || typeof entry !== "object") return null;
82
+ const row = entry as ProductUnitConversionDraft;
83
+ return {
84
+ id: toTrimmedOrNull(row.id) ?? null,
85
+ unitCode: toTrimmedOrNull(row.unitCode) ?? "",
86
+ toBaseFactor: toTrimmedOrNull(row.toBaseFactor)
87
+ ? normalizeDecimalInput(toTrimmedOrNull(row.toBaseFactor) as string)
88
+ : "",
89
+ sortOrder: toTrimmedOrNull(row.sortOrder) ?? "",
90
+ isActive: row.isActive !== false,
91
+ } satisfies ProductUnitConversionDraft;
92
+ })
93
+ .filter((entry): entry is ProductUnitConversionDraft => Boolean(entry));
94
+ normalized.sort((left, right) => {
95
+ const leftOrder = toSortValue(left.sortOrder);
96
+ const rightOrder = toSortValue(right.sortOrder);
97
+ if (leftOrder === rightOrder) return left.unitCode.localeCompare(right.unitCode);
98
+ return leftOrder - rightOrder;
99
+ });
100
+ return normalized.map((entry, index) => ({
101
+ ...entry,
102
+ sortOrder: String((index + 1) * 10),
103
+ }));
104
+ }
105
+
106
+ function buildUnitOptions(
107
+ entries: UnitDictionaryEntry[] | undefined,
108
+ ): UnitOption[] {
109
+ const list = Array.isArray(entries) ? entries : [];
110
+ const options = list
111
+ .map((entry) => {
112
+ const value = toTrimmedOrNull(entry.value);
113
+ if (!value) return null;
114
+ return {
115
+ value,
116
+ label: toTrimmedOrNull(entry.label) ?? value,
117
+ } satisfies UnitOption;
118
+ })
119
+ .filter((entry): entry is UnitOption => Boolean(entry));
120
+ return options.sort((left, right) => left.label.localeCompare(right.label));
121
+ }
122
+
123
+ export function ProductUomSection({
124
+ values,
125
+ errors,
126
+ setValue,
127
+ embedded = false,
128
+ }: ProductUomSectionProps) {
129
+ const t = useT();
130
+ const [unitOptions, setUnitOptions] = React.useState<UnitOption[]>([]);
131
+ const [loadingUnits, setLoadingUnits] = React.useState(false);
132
+ const [errorLoadingUnits, setErrorLoadingUnits] = React.useState(false);
133
+ const conversions = React.useMemo(
134
+ () => normalizeConversions(values.unitConversions),
135
+ [values.unitConversions],
136
+ );
137
+
138
+ React.useEffect(() => {
139
+ let cancelled = false;
140
+ async function loadUnits() {
141
+ setLoadingUnits(true);
142
+ setErrorLoadingUnits(false);
143
+ try {
144
+ const response = await apiCall<UnitDictionaryResponse>(
145
+ "/api/catalog/dictionaries/unit",
146
+ undefined,
147
+ { fallback: { entries: [] } },
148
+ );
149
+ if (cancelled) return;
150
+ setUnitOptions(buildUnitOptions(response.result?.entries));
151
+ } catch {
152
+ if (!cancelled) {
153
+ setUnitOptions([]);
154
+ setErrorLoadingUnits(true);
155
+ }
156
+ } finally {
157
+ if (!cancelled) setLoadingUnits(false);
158
+ }
159
+ }
160
+ void loadUnits();
161
+ return () => {
162
+ cancelled = true;
163
+ };
164
+ }, []);
165
+
166
+ const findUnitLabel = React.useCallback(
167
+ (value: string | null | undefined) => {
168
+ const code = toTrimmedOrNull(value);
169
+ if (!code) return null;
170
+ const option = unitOptions.find((entry) => entry.value === code);
171
+ return option?.label ?? code;
172
+ },
173
+ [unitOptions],
174
+ );
175
+
176
+ const setConversions = React.useCallback(
177
+ (next: ProductUnitConversionDraft[]) => {
178
+ const normalized = next.map((entry, index) => ({
179
+ ...entry,
180
+ sortOrder: String((index + 1) * 10),
181
+ }));
182
+ setValue("unitConversions", normalized);
183
+ },
184
+ [setValue],
185
+ );
186
+
187
+ const addConversion = React.useCallback(() => {
188
+ const next = [
189
+ ...conversions,
190
+ createProductUnitConversionDraft({
191
+ sortOrder: String((conversions.length + 1) * 10),
192
+ }),
193
+ ];
194
+ setConversions(next);
195
+ }, [conversions, setConversions]);
196
+
197
+ const updateConversion = React.useCallback(
198
+ (index: number, patch: Partial<ProductUnitConversionDraft>) => {
199
+ const next = conversions.map((entry, rowIndex) =>
200
+ rowIndex === index ? { ...entry, ...patch } : entry,
201
+ );
202
+ setConversions(next);
203
+ },
204
+ [conversions, setConversions],
205
+ );
206
+
207
+ const removeConversion = React.useCallback(
208
+ (index: number) => {
209
+ const next = conversions.filter((_entry, rowIndex) => rowIndex !== index);
210
+ setConversions(next);
211
+ },
212
+ [conversions, setConversions],
213
+ );
214
+
215
+ const moveConversion = React.useCallback(
216
+ (index: number, direction: "up" | "down") => {
217
+ const targetIndex = direction === "up" ? index - 1 : index + 1;
218
+ if (targetIndex < 0 || targetIndex >= conversions.length) return;
219
+ const next = [...conversions];
220
+ const source = next[index];
221
+ next[index] = next[targetIndex];
222
+ next[targetIndex] = source;
223
+ setConversions(next);
224
+ },
225
+ [conversions, setConversions],
226
+ );
227
+
228
+ const defaultUnit = toTrimmedOrNull(values.defaultUnit) ?? "";
229
+ const defaultSalesUnit = toTrimmedOrNull(values.defaultSalesUnit) ?? "";
230
+ const defaultSalesQuantityRaw =
231
+ toTrimmedOrNull(values.defaultSalesUnitQuantity) ?? "1";
232
+ const defaultSalesQuantity = normalizeDecimalInput(defaultSalesQuantityRaw);
233
+ const unitPriceEnabled = Boolean(values.unitPriceEnabled);
234
+ const unitPriceReferenceUnit =
235
+ toTrimmedOrNull(values.unitPriceReferenceUnit) ?? "";
236
+ const unitPriceBaseQuantityRaw =
237
+ toTrimmedOrNull(values.unitPriceBaseQuantity) ?? "";
238
+ const unitPriceBaseQuantity = normalizeDecimalInput(unitPriceBaseQuantityRaw);
239
+
240
+ const baseUnitLabel = findUnitLabel(defaultUnit) ?? defaultUnit;
241
+ const salesUnitLabel =
242
+ findUnitLabel(defaultSalesUnit || defaultUnit) ??
243
+ defaultSalesUnit ??
244
+ defaultUnit;
245
+
246
+ const defaultSalesFactor = React.useMemo(() => {
247
+ const defaultUnitKey = defaultUnit.toLowerCase();
248
+ const defaultSalesKey = (defaultSalesUnit || defaultUnit).toLowerCase();
249
+ if (!defaultUnitKey || !defaultSalesKey) return null;
250
+ if (defaultSalesKey === defaultUnitKey) return 1;
251
+ const row = conversions.find(
252
+ (entry) =>
253
+ entry.isActive &&
254
+ entry.unitCode.toLowerCase() === defaultSalesKey &&
255
+ toPositiveNumber(entry.toBaseFactor) !== null,
256
+ );
257
+ return row ? toPositiveNumber(row.toBaseFactor) : null;
258
+ }, [conversions, defaultSalesUnit, defaultUnit]);
259
+
260
+ const defaultSalesQuantityNumber = toPositiveNumber(defaultSalesQuantity);
261
+ const defaultSalesQuantityNormalized =
262
+ defaultSalesQuantityNumber && defaultSalesFactor
263
+ ? defaultSalesQuantityNumber * defaultSalesFactor
264
+ : null;
265
+ const unitPriceBaseQuantityNumber = toPositiveNumber(unitPriceBaseQuantity);
266
+
267
+ const validConversions = conversions.filter(
268
+ (entry) =>
269
+ toTrimmedOrNull(entry.unitCode) && toTrimmedOrNull(entry.toBaseFactor),
270
+ );
271
+ const conversionPreviewItems = validConversions
272
+ .slice(0, 3)
273
+ .map((entry) => {
274
+ const label = findUnitLabel(entry.unitCode) ?? entry.unitCode;
275
+ const baseLabel = findUnitLabel(defaultUnit) ?? defaultUnit;
276
+ const factor = toTrimmedOrNull(entry.toBaseFactor) ?? "1";
277
+ return `1 ${label} = ${factor} ${baseLabel || t("catalog.products.uom.baseUnit", "base unit")}`;
278
+ });
279
+ const conversionPreview =
280
+ validConversions.length > 3
281
+ ? `${conversionPreviewItems.join(" • ")} (+${validConversions.length - 3})`
282
+ : conversionPreviewItems.join(" • ");
283
+
284
+ return (
285
+ <div
286
+ className={
287
+ embedded ? "space-y-5" : "space-y-5 rounded-lg border bg-card p-4"
288
+ }
289
+ >
290
+ <div className="space-y-1">
291
+ <h3 className="text-sm font-semibold">
292
+ {t("catalog.products.uom.title", "Units of measure")}
293
+ </h3>
294
+ <p className="text-xs text-muted-foreground">
295
+ {t(
296
+ "catalog.products.uom.description",
297
+ "Set base unit, sales unit, and packaging conversions.",
298
+ )}
299
+ </p>
300
+ </div>
301
+
302
+ {loadingUnits ? (
303
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
304
+ <Spinner className="h-3 w-3" />
305
+ {t("catalog.products.uom.loadingUnits", "Loading units...")}
306
+ </div>
307
+ ) : null}
308
+
309
+ {errorLoadingUnits ? (
310
+ <p className="text-xs text-destructive">
311
+ {t(
312
+ "catalog.products.uom.errors.loadUnits",
313
+ "Failed to load units. Please try refreshing the page.",
314
+ )}
315
+ </p>
316
+ ) : null}
317
+
318
+ <div className="grid gap-4 md:grid-cols-2">
319
+ <div className="space-y-2">
320
+ <Label htmlFor="catalog-product-uom-base-unit">{t("catalog.products.uom.baseUnit", "Base unit")}</Label>
321
+ <select
322
+ id="catalog-product-uom-base-unit"
323
+ 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"
324
+ value={defaultUnit}
325
+ onChange={(event) =>
326
+ setValue("defaultUnit", event.target.value || null)
327
+ }
328
+ disabled={loadingUnits}
329
+ >
330
+ <option value="">
331
+ {t("catalog.products.uom.selectUnit", "Select unit")}
332
+ </option>
333
+ {unitOptions.map((option) => (
334
+ <option key={option.value} value={option.value}>
335
+ {option.label}
336
+ </option>
337
+ ))}
338
+ </select>
339
+ {errors.defaultUnit ? (
340
+ <p className="text-xs text-destructive">{errors.defaultUnit}</p>
341
+ ) : null}
342
+ </div>
343
+
344
+ <div className="space-y-2">
345
+ <Label htmlFor="catalog-product-uom-sales-unit">
346
+ {t("catalog.products.uom.defaultSalesUnit", "Default sales unit")}
347
+ </Label>
348
+ <select
349
+ id="catalog-product-uom-sales-unit"
350
+ 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"
351
+ value={defaultSalesUnit}
352
+ onChange={(event) =>
353
+ setValue("defaultSalesUnit", event.target.value || null)
354
+ }
355
+ disabled={loadingUnits}
356
+ >
357
+ <option value="">
358
+ {t("catalog.products.uom.selectUnit", "Select unit")}
359
+ </option>
360
+ {unitOptions.map((option) => (
361
+ <option key={option.value} value={option.value}>
362
+ {option.label}
363
+ </option>
364
+ ))}
365
+ </select>
366
+ {errors.defaultSalesUnit ? (
367
+ <p className="text-xs text-destructive">{errors.defaultSalesUnit}</p>
368
+ ) : null}
369
+ </div>
370
+
371
+ <div className="space-y-2 md:col-span-2">
372
+ <Label htmlFor="catalog-product-uom-default-sales-quantity">
373
+ {t(
374
+ "catalog.products.uom.defaultSalesQuantityLabel",
375
+ "Default line quantity (in sales unit)",
376
+ )}
377
+ </Label>
378
+ <Input
379
+ id="catalog-product-uom-default-sales-quantity"
380
+ type="text"
381
+ inputMode="decimal"
382
+ value={defaultSalesQuantity}
383
+ onChange={(event) =>
384
+ setValue(
385
+ "defaultSalesUnitQuantity",
386
+ normalizeDecimalInput(event.target.value),
387
+ )
388
+ }
389
+ placeholder="1"
390
+ />
391
+ <p className="text-xs text-muted-foreground">
392
+ {t(
393
+ "catalog.products.uom.defaultSalesQuantityHint",
394
+ "Used to prefill quantity in quote/order lines. Value is interpreted in Default sales unit.",
395
+ )}
396
+ </p>
397
+ {defaultSalesQuantityNumber && salesUnitLabel ? (
398
+ <p className="text-xs text-muted-foreground">
399
+ {defaultSalesQuantityNormalized && baseUnitLabel
400
+ ? t(
401
+ "catalog.products.uom.defaultSalesQuantityPreviewWithNormalization",
402
+ "Default line: {{quantity}} {{salesUnit}} (= {{normalized}} {{baseUnit}}).",
403
+ {
404
+ quantity: formatPreviewNumber(defaultSalesQuantityNumber),
405
+ salesUnit: salesUnitLabel,
406
+ normalized: formatPreviewNumber(
407
+ defaultSalesQuantityNormalized,
408
+ ),
409
+ baseUnit: baseUnitLabel,
410
+ },
411
+ )
412
+ : t(
413
+ "catalog.products.uom.defaultSalesQuantityPreview",
414
+ "Default line: {{quantity}} {{salesUnit}}.",
415
+ {
416
+ quantity: formatPreviewNumber(defaultSalesQuantityNumber),
417
+ salesUnit: salesUnitLabel,
418
+ },
419
+ )}
420
+ </p>
421
+ ) : null}
422
+ {errors.defaultSalesUnitQuantity ? (
423
+ <p className="text-xs text-destructive">
424
+ {errors.defaultSalesUnitQuantity}
425
+ </p>
426
+ ) : null}
427
+ </div>
428
+ </div>
429
+
430
+ <div className="grid gap-4 md:grid-cols-2">
431
+ <div className="space-y-2">
432
+ <Label htmlFor="catalog-product-uom-rounding-mode">
433
+ {t("catalog.products.uom.roundingMode", "Rounding mode")}
434
+ </Label>
435
+ <select
436
+ id="catalog-product-uom-rounding-mode"
437
+ 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"
438
+ value={values.uomRoundingMode ?? "half_up"}
439
+ onChange={(event) =>
440
+ setValue("uomRoundingMode", event.target.value)
441
+ }
442
+ >
443
+ <option value="half_up">
444
+ {t("catalog.products.uom.roundingModeHalfUp", "Half up (default)")}
445
+ </option>
446
+ <option value="down">
447
+ {t("catalog.products.uom.roundingModeDown", "Round down")}
448
+ </option>
449
+ <option value="up">
450
+ {t("catalog.products.uom.roundingModeUp", "Round up")}
451
+ </option>
452
+ </select>
453
+ </div>
454
+ <div className="space-y-2">
455
+ <Label htmlFor="catalog-product-uom-rounding-scale">
456
+ {t("catalog.products.uom.roundingScale", "Rounding scale (decimal places)")}
457
+ </Label>
458
+ <Input
459
+ id="catalog-product-uom-rounding-scale"
460
+ type="number"
461
+ min={0}
462
+ max={6}
463
+ value={values.uomRoundingScale ?? 4}
464
+ onChange={(event) => {
465
+ const parsed = Number.parseInt(event.target.value, 10);
466
+ setValue("uomRoundingScale", Number.isFinite(parsed) ? Math.max(0, Math.min(6, parsed)) : 4);
467
+ }}
468
+ />
469
+ </div>
470
+ </div>
471
+
472
+ <div className="space-y-3">
473
+ <div className="flex items-center gap-2">
474
+ <Checkbox
475
+ id="catalog-product-unit-price-enabled"
476
+ checked={unitPriceEnabled}
477
+ onCheckedChange={(checked) =>
478
+ setValue("unitPriceEnabled", checked === true)
479
+ }
480
+ />
481
+ <Label
482
+ htmlFor="catalog-product-unit-price-enabled"
483
+ className="text-sm"
484
+ >
485
+ {t(
486
+ "catalog.products.unitPrice.enable",
487
+ "Enable EU unit price display",
488
+ )}
489
+ </Label>
490
+ </div>
491
+
492
+ {unitPriceEnabled ? (
493
+ <div className="grid gap-3 md:grid-cols-2">
494
+ <div className="space-y-2">
495
+ <Label htmlFor="catalog-product-uom-reference-unit">
496
+ {t(
497
+ "catalog.products.unitPrice.referenceUnit",
498
+ "Reference unit",
499
+ )}
500
+ </Label>
501
+ <select
502
+ id="catalog-product-uom-reference-unit"
503
+ 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"
504
+ value={unitPriceReferenceUnit}
505
+ onChange={(event) =>
506
+ setValue("unitPriceReferenceUnit", event.target.value || null)
507
+ }
508
+ >
509
+ <option value="">
510
+ {t(
511
+ "catalog.products.unitPrice.selectReferenceUnit",
512
+ "Select reference unit",
513
+ )}
514
+ </option>
515
+ {REFERENCE_UNIT_OPTIONS.map((option) => (
516
+ <option key={option.value} value={option.value}>
517
+ {t(option.i18nKey, option.fallback)}
518
+ </option>
519
+ ))}
520
+ </select>
521
+ </div>
522
+ <div className="space-y-2">
523
+ <Label htmlFor="catalog-product-uom-unit-price-base-quantity">
524
+ {t(
525
+ "catalog.products.unitPrice.baseQuantity",
526
+ "Reference quantity (in base unit)",
527
+ )}
528
+ </Label>
529
+ <Input
530
+ id="catalog-product-uom-unit-price-base-quantity"
531
+ type="text"
532
+ inputMode="decimal"
533
+ value={unitPriceBaseQuantity}
534
+ onChange={(event) =>
535
+ setValue(
536
+ "unitPriceBaseQuantity",
537
+ normalizeDecimalInput(event.target.value),
538
+ )
539
+ }
540
+ placeholder="1"
541
+ />
542
+ </div>
543
+ </div>
544
+ ) : null}
545
+ {unitPriceEnabled ? (
546
+ <p className="text-xs text-muted-foreground">
547
+ {unitPriceReferenceUnit && unitPriceBaseQuantityNumber
548
+ ? t(
549
+ "catalog.products.unitPrice.hintWithPreview",
550
+ "Show calculated price per {{quantity}} {{unit}}. For most products use 1 (for example: 1 kg, 1 l, 1 m²).",
551
+ {
552
+ quantity: formatPreviewNumber(unitPriceBaseQuantityNumber),
553
+ unit: unitPriceReferenceUnit,
554
+ },
555
+ )
556
+ : t(
557
+ "catalog.products.unitPrice.hint",
558
+ "Show calculated price per selected reference unit. In most cases set quantity to 1.",
559
+ )}
560
+ </p>
561
+ ) : null}
562
+ </div>
563
+
564
+ <div className="space-y-3">
565
+ <div className="flex items-center justify-between gap-2">
566
+ <Label className="text-sm">
567
+ {t("catalog.products.uom.conversions", "Product conversions")}
568
+ </Label>
569
+ <Button
570
+ type="button"
571
+ variant="outline"
572
+ size="sm"
573
+ onClick={addConversion}
574
+ >
575
+ <Plus className="mr-1.5 h-4 w-4" />
576
+ {t("catalog.products.uom.addConversion", "Add conversion")}
577
+ </Button>
578
+ </div>
579
+
580
+ {conversions.length === 0 ? (
581
+ <p className="text-xs text-muted-foreground">
582
+ {t(
583
+ "catalog.products.uom.emptyConversions",
584
+ "No conversions configured yet.",
585
+ )}
586
+ </p>
587
+ ) : (
588
+ <div className="space-y-2">
589
+ {conversions.map((entry, index) => {
590
+ const conversionFactor = toPositiveNumber(entry.toBaseFactor);
591
+ const conversionPreviewText =
592
+ entry.unitCode && conversionFactor !== null
593
+ ? t(
594
+ "catalog.products.uom.conversionPreview",
595
+ "1 {{fromUnit}} = {{factor}} {{baseUnit}}",
596
+ {
597
+ fromUnit: findUnitLabel(entry.unitCode) ?? entry.unitCode,
598
+ factor: formatPreviewNumber(conversionFactor),
599
+ baseUnit:
600
+ findUnitLabel(defaultUnit) ??
601
+ defaultUnit ??
602
+ t("catalog.products.uom.baseUnit", "base unit"),
603
+ },
604
+ )
605
+ : null;
606
+
607
+ return (
608
+ <div
609
+ key={entry.id ?? `uom-conversion-${index}`}
610
+ className="grid gap-3 rounded-md border p-3 md:grid-cols-12"
611
+ >
612
+ <div className="space-y-1 md:col-span-4">
613
+ <Label htmlFor={`catalog-product-uom-conversion-unit-${index}`} className="text-xs text-muted-foreground">
614
+ {t("catalog.products.uom.conversionUnit", "Sales unit")}
615
+ </Label>
616
+ <select
617
+ id={`catalog-product-uom-conversion-unit-${index}`}
618
+ 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"
619
+ value={entry.unitCode}
620
+ onChange={(event) =>
621
+ updateConversion(index, { unitCode: event.target.value })
622
+ }
623
+ disabled={loadingUnits}
624
+ >
625
+ <option value="">
626
+ {t("catalog.products.uom.selectUnit", "Select unit")}
627
+ </option>
628
+ {unitOptions.map((option) => (
629
+ <option key={option.value} value={option.value}>
630
+ {option.label}
631
+ </option>
632
+ ))}
633
+ </select>
634
+ </div>
635
+
636
+ <div className="space-y-1 md:col-span-3">
637
+ <Label htmlFor={`catalog-product-uom-conversion-factor-${index}`} className="text-xs text-muted-foreground">
638
+ {t(
639
+ "catalog.products.uom.toBaseFactor",
640
+ "Base units per 1 sales unit",
641
+ )}
642
+ </Label>
643
+ <Input
644
+ id={`catalog-product-uom-conversion-factor-${index}`}
645
+ type="text"
646
+ inputMode="decimal"
647
+ value={entry.toBaseFactor}
648
+ onChange={(event) =>
649
+ updateConversion(index, {
650
+ toBaseFactor: normalizeDecimalInput(event.target.value),
651
+ })
652
+ }
653
+ placeholder="1"
654
+ />
655
+ </div>
656
+
657
+ <div className="flex items-end gap-2 md:col-span-3">
658
+ <div className="inline-flex h-9 rounded-md border">
659
+ <Button
660
+ type="button"
661
+ variant="ghost"
662
+ size="icon"
663
+ className="h-9 w-9 rounded-none border-r"
664
+ onClick={() => moveConversion(index, "up")}
665
+ disabled={index === 0}
666
+ aria-label={t(
667
+ "catalog.products.uom.moveUp",
668
+ "Move conversion up",
669
+ )}
670
+ >
671
+ <ArrowUp className="h-4 w-4" />
672
+ </Button>
673
+ <Button
674
+ type="button"
675
+ variant="ghost"
676
+ size="icon"
677
+ className="h-9 w-9 rounded-none"
678
+ onClick={() => moveConversion(index, "down")}
679
+ disabled={index === conversions.length - 1}
680
+ aria-label={t(
681
+ "catalog.products.uom.moveDown",
682
+ "Move conversion down",
683
+ )}
684
+ >
685
+ <ArrowDown className="h-4 w-4" />
686
+ </Button>
687
+ </div>
688
+ <label
689
+ htmlFor={`catalog-product-uom-conversion-active-${index}`}
690
+ className="flex items-center gap-2 px-1 text-xs text-muted-foreground"
691
+ >
692
+ <Checkbox
693
+ id={`catalog-product-uom-conversion-active-${index}`}
694
+ checked={entry.isActive}
695
+ onCheckedChange={(checked) =>
696
+ updateConversion(index, { isActive: checked === true })
697
+ }
698
+ />
699
+ {t("catalog.products.uom.active", "Active")}
700
+ </label>
701
+ </div>
702
+
703
+ <div className="flex items-end justify-end md:col-span-2">
704
+ <Button
705
+ type="button"
706
+ variant="ghost"
707
+ size="icon"
708
+ className="text-destructive"
709
+ onClick={() => removeConversion(index)}
710
+ aria-label={t(
711
+ "catalog.products.uom.removeConversion",
712
+ "Remove conversion",
713
+ )}
714
+ >
715
+ <Trash2 className="h-4 w-4" />
716
+ </Button>
717
+ </div>
718
+
719
+ {conversionPreviewText && (
720
+ <p className="text-xs text-muted-foreground md:col-span-12">
721
+ {conversionPreviewText}
722
+ </p>
723
+ )}
724
+ </div>
725
+ );
726
+ })}
727
+ </div>
728
+ )}
729
+
730
+ {conversions.length > 1 ? (
731
+ <p className="text-xs text-muted-foreground">
732
+ {t(
733
+ "catalog.products.uom.conversionOrderHint",
734
+ "Use arrows to reorder conversion priority.",
735
+ )}
736
+ </p>
737
+ ) : null}
738
+
739
+ {conversionPreview ? (
740
+ <p className="text-xs text-muted-foreground">{conversionPreview}</p>
741
+ ) : null}
742
+ </div>
743
+ </div>
744
+ );
745
+ }