@dainamite/cpq 0.2.2

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 (504) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/LICENSE +21 -0
  3. package/MIGRATION.md +236 -0
  4. package/README.md +108 -0
  5. package/dist/modules/cpq/acl.js +30 -0
  6. package/dist/modules/cpq/acl.js.map +7 -0
  7. package/dist/modules/cpq/api/_helpers/handleArcError.js +37 -0
  8. package/dist/modules/cpq/api/_helpers/handleArcError.js.map +7 -0
  9. package/dist/modules/cpq/api/attribute-options/route.js +47 -0
  10. package/dist/modules/cpq/api/attribute-options/route.js.map +7 -0
  11. package/dist/modules/cpq/api/bundle-slots/route.js +101 -0
  12. package/dist/modules/cpq/api/bundle-slots/route.js.map +7 -0
  13. package/dist/modules/cpq/api/bundle-tree/route.js +28 -0
  14. package/dist/modules/cpq/api/bundle-tree/route.js.map +7 -0
  15. package/dist/modules/cpq/api/context.js +29 -0
  16. package/dist/modules/cpq/api/context.js.map +7 -0
  17. package/dist/modules/cpq/api/inventory/assets/route.js +113 -0
  18. package/dist/modules/cpq/api/inventory/assets/route.js.map +7 -0
  19. package/dist/modules/cpq/api/inventory/assets/status/route.js +35 -0
  20. package/dist/modules/cpq/api/inventory/assets/status/route.js.map +7 -0
  21. package/dist/modules/cpq/api/inventory/customer/[customerId]/parent-items/route.js +30 -0
  22. package/dist/modules/cpq/api/inventory/customer/[customerId]/parent-items/route.js.map +7 -0
  23. package/dist/modules/cpq/api/inventory/customer/[customerId]/route.js +31 -0
  24. package/dist/modules/cpq/api/inventory/customer/[customerId]/route.js.map +7 -0
  25. package/dist/modules/cpq/api/inventory/subscription-items/route.js +99 -0
  26. package/dist/modules/cpq/api/inventory/subscription-items/route.js.map +7 -0
  27. package/dist/modules/cpq/api/inventory/subscriptions/[subscriptionId]/change-log/route.js +57 -0
  28. package/dist/modules/cpq/api/inventory/subscriptions/[subscriptionId]/change-log/route.js.map +7 -0
  29. package/dist/modules/cpq/api/inventory/subscriptions/expiring/route.js +62 -0
  30. package/dist/modules/cpq/api/inventory/subscriptions/expiring/route.js.map +7 -0
  31. package/dist/modules/cpq/api/inventory/subscriptions/route.js +116 -0
  32. package/dist/modules/cpq/api/inventory/subscriptions/route.js.map +7 -0
  33. package/dist/modules/cpq/api/inventory/subscriptions/status/route.js +35 -0
  34. package/dist/modules/cpq/api/inventory/subscriptions/status/route.js.map +7 -0
  35. package/dist/modules/cpq/api/offering-components/route.js +91 -0
  36. package/dist/modules/cpq/api/offering-components/route.js.map +7 -0
  37. package/dist/modules/cpq/api/orders/[orderId]/activate/route.js +39 -0
  38. package/dist/modules/cpq/api/orders/[orderId]/activate/route.js.map +7 -0
  39. package/dist/modules/cpq/api/orders/[orderId]/route.js +28 -0
  40. package/dist/modules/cpq/api/orders/[orderId]/route.js.map +7 -0
  41. package/dist/modules/cpq/api/orders/[orderId]/status/route.js +38 -0
  42. package/dist/modules/cpq/api/orders/[orderId]/status/route.js.map +7 -0
  43. package/dist/modules/cpq/api/orders/route.js +71 -0
  44. package/dist/modules/cpq/api/orders/route.js.map +7 -0
  45. package/dist/modules/cpq/api/price-rules/route.js +180 -0
  46. package/dist/modules/cpq/api/price-rules/route.js.map +7 -0
  47. package/dist/modules/cpq/api/pricing-table-entries/route.js +142 -0
  48. package/dist/modules/cpq/api/pricing-table-entries/route.js.map +7 -0
  49. package/dist/modules/cpq/api/pricing-tables/route.js +179 -0
  50. package/dist/modules/cpq/api/pricing-tables/route.js.map +7 -0
  51. package/dist/modules/cpq/api/product-attributes/route.js +160 -0
  52. package/dist/modules/cpq/api/product-attributes/route.js.map +7 -0
  53. package/dist/modules/cpq/api/product-charges/route.js +166 -0
  54. package/dist/modules/cpq/api/product-charges/route.js.map +7 -0
  55. package/dist/modules/cpq/api/product-offerings/route.js +294 -0
  56. package/dist/modules/cpq/api/product-offerings/route.js.map +7 -0
  57. package/dist/modules/cpq/api/product-relationships/route.js +144 -0
  58. package/dist/modules/cpq/api/product-relationships/route.js.map +7 -0
  59. package/dist/modules/cpq/api/product-specifications/route.js +292 -0
  60. package/dist/modules/cpq/api/product-specifications/route.js.map +7 -0
  61. package/dist/modules/cpq/api/quotes/[quoteId]/cancel-meta/route.js +71 -0
  62. package/dist/modules/cpq/api/quotes/[quoteId]/cancel-meta/route.js.map +7 -0
  63. package/dist/modules/cpq/api/quotes/[quoteId]/clone/route.js +29 -0
  64. package/dist/modules/cpq/api/quotes/[quoteId]/clone/route.js.map +7 -0
  65. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js +54 -0
  66. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js.map +7 -0
  67. package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js +27 -0
  68. package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js.map +7 -0
  69. package/dist/modules/cpq/api/quotes/[quoteId]/merge-meta/route.js +72 -0
  70. package/dist/modules/cpq/api/quotes/[quoteId]/merge-meta/route.js.map +7 -0
  71. package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js +35 -0
  72. package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js.map +7 -0
  73. package/dist/modules/cpq/api/quotes/[quoteId]/route.js +102 -0
  74. package/dist/modules/cpq/api/quotes/[quoteId]/route.js.map +7 -0
  75. package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js +27 -0
  76. package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js.map +7 -0
  77. package/dist/modules/cpq/api/quotes/[quoteId]/target-subscriptions/[targetId]/route.js +79 -0
  78. package/dist/modules/cpq/api/quotes/[quoteId]/target-subscriptions/[targetId]/route.js.map +7 -0
  79. package/dist/modules/cpq/api/quotes/[quoteId]/target-subscriptions/route.js +95 -0
  80. package/dist/modules/cpq/api/quotes/[quoteId]/target-subscriptions/route.js.map +7 -0
  81. package/dist/modules/cpq/api/quotes/attributes/route.js +52 -0
  82. package/dist/modules/cpq/api/quotes/attributes/route.js.map +7 -0
  83. package/dist/modules/cpq/api/quotes/from-subscription/route.js +72 -0
  84. package/dist/modules/cpq/api/quotes/from-subscription/route.js.map +7 -0
  85. package/dist/modules/cpq/api/quotes/price/route.js +69 -0
  86. package/dist/modules/cpq/api/quotes/price/route.js.map +7 -0
  87. package/dist/modules/cpq/api/quotes/route.js +97 -0
  88. package/dist/modules/cpq/api/quotes/route.js.map +7 -0
  89. package/dist/modules/cpq/api/quotes/validate-relationships/route.js +86 -0
  90. package/dist/modules/cpq/api/quotes/validate-relationships/route.js.map +7 -0
  91. package/dist/modules/cpq/api/resolveQuotingService.js +7 -0
  92. package/dist/modules/cpq/api/resolveQuotingService.js.map +7 -0
  93. package/dist/modules/cpq/api/wizards/resolve/route.js +38 -0
  94. package/dist/modules/cpq/api/wizards/resolve/route.js.map +7 -0
  95. package/dist/modules/cpq/api/wizards/route.js +111 -0
  96. package/dist/modules/cpq/api/wizards/route.js.map +7 -0
  97. package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js +136 -0
  98. package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js.map +7 -0
  99. package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js +185 -0
  100. package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js.map +7 -0
  101. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.js +343 -0
  102. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.js.map +7 -0
  103. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.js +13 -0
  104. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.js.map +7 -0
  105. package/dist/modules/cpq/backend/cpq/inventory/page.js +35 -0
  106. package/dist/modules/cpq/backend/cpq/inventory/page.js.map +7 -0
  107. package/dist/modules/cpq/backend/cpq/inventory/page.meta.js +18 -0
  108. package/dist/modules/cpq/backend/cpq/inventory/page.meta.js.map +7 -0
  109. package/dist/modules/cpq/backend/cpq/inventory/shared.js +20 -0
  110. package/dist/modules/cpq/backend/cpq/inventory/shared.js.map +7 -0
  111. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js +794 -0
  112. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js.map +7 -0
  113. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.js +13 -0
  114. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.js.map +7 -0
  115. package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js +917 -0
  116. package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js.map +7 -0
  117. package/dist/modules/cpq/backend/cpq/offerings/page.js +405 -0
  118. package/dist/modules/cpq/backend/cpq/offerings/page.js.map +7 -0
  119. package/dist/modules/cpq/backend/cpq/offerings/page.meta.js +18 -0
  120. package/dist/modules/cpq/backend/cpq/offerings/page.meta.js.map +7 -0
  121. package/dist/modules/cpq/backend/cpq/orders/[id]/page.js +677 -0
  122. package/dist/modules/cpq/backend/cpq/orders/[id]/page.js.map +7 -0
  123. package/dist/modules/cpq/backend/cpq/orders/page.js +134 -0
  124. package/dist/modules/cpq/backend/cpq/orders/page.js.map +7 -0
  125. package/dist/modules/cpq/backend/cpq/orders/page.meta.js +18 -0
  126. package/dist/modules/cpq/backend/cpq/orders/page.meta.js.map +7 -0
  127. package/dist/modules/cpq/backend/cpq/price-rules/PriceRuleForm.js +272 -0
  128. package/dist/modules/cpq/backend/cpq/price-rules/PriceRuleForm.js.map +7 -0
  129. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.js +407 -0
  130. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.js.map +7 -0
  131. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.meta.js +13 -0
  132. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.meta.js.map +7 -0
  133. package/dist/modules/cpq/backend/cpq/price-rules/page.js +211 -0
  134. package/dist/modules/cpq/backend/cpq/price-rules/page.js.map +7 -0
  135. package/dist/modules/cpq/backend/cpq/price-rules/page.meta.js +18 -0
  136. package/dist/modules/cpq/backend/cpq/price-rules/page.meta.js.map +7 -0
  137. package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js +275 -0
  138. package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js.map +7 -0
  139. package/dist/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.js +112 -0
  140. package/dist/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.js.map +7 -0
  141. package/dist/modules/cpq/backend/cpq/pricing/[id]/page.js +492 -0
  142. package/dist/modules/cpq/backend/cpq/pricing/[id]/page.js.map +7 -0
  143. package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxExport.js +50 -0
  144. package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxExport.js.map +7 -0
  145. package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.js +249 -0
  146. package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.js.map +7 -0
  147. package/dist/modules/cpq/backend/cpq/pricing/page.js +87 -0
  148. package/dist/modules/cpq/backend/cpq/pricing/page.js.map +7 -0
  149. package/dist/modules/cpq/backend/cpq/pricing/page.meta.js +18 -0
  150. package/dist/modules/cpq/backend/cpq/pricing/page.meta.js.map +7 -0
  151. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.js +652 -0
  152. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.js.map +7 -0
  153. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js +1289 -0
  154. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js.map +7 -0
  155. package/dist/modules/cpq/backend/cpq/quotes/page.js +283 -0
  156. package/dist/modules/cpq/backend/cpq/quotes/page.js.map +7 -0
  157. package/dist/modules/cpq/backend/cpq/quotes/page.meta.js +18 -0
  158. package/dist/modules/cpq/backend/cpq/quotes/page.meta.js.map +7 -0
  159. package/dist/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.js +214 -0
  160. package/dist/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.js.map +7 -0
  161. package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js +1399 -0
  162. package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js.map +7 -0
  163. package/dist/modules/cpq/backend/cpq/specifications/page.js +148 -0
  164. package/dist/modules/cpq/backend/cpq/specifications/page.js.map +7 -0
  165. package/dist/modules/cpq/backend/cpq/specifications/page.meta.js +18 -0
  166. package/dist/modules/cpq/backend/cpq/specifications/page.meta.js.map +7 -0
  167. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.js +204 -0
  168. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.js.map +7 -0
  169. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/page.js +191 -0
  170. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/page.js.map +7 -0
  171. package/dist/modules/cpq/backend/cpq/wizards/page.js +119 -0
  172. package/dist/modules/cpq/backend/cpq/wizards/page.js.map +7 -0
  173. package/dist/modules/cpq/backend/cpq/wizards/page.meta.js +18 -0
  174. package/dist/modules/cpq/backend/cpq/wizards/page.meta.js.map +7 -0
  175. package/dist/modules/cpq/ce.js +151 -0
  176. package/dist/modules/cpq/ce.js.map +7 -0
  177. package/dist/modules/cpq/cli.js +11 -0
  178. package/dist/modules/cpq/cli.js.map +7 -0
  179. package/dist/modules/cpq/components/CpqListView.js +265 -0
  180. package/dist/modules/cpq/components/CpqListView.js.map +7 -0
  181. package/dist/modules/cpq/components/NumberInput.js +95 -0
  182. package/dist/modules/cpq/components/NumberInput.js.map +7 -0
  183. package/dist/modules/cpq/components/StatusPath.js +60 -0
  184. package/dist/modules/cpq/components/StatusPath.js.map +7 -0
  185. package/dist/modules/cpq/components/statusMaps.js +66 -0
  186. package/dist/modules/cpq/components/statusMaps.js.map +7 -0
  187. package/dist/modules/cpq/components/useCpqRowActions.js +125 -0
  188. package/dist/modules/cpq/components/useCpqRowActions.js.map +7 -0
  189. package/dist/modules/cpq/data/entities.js +1556 -0
  190. package/dist/modules/cpq/data/entities.js.map +7 -0
  191. package/dist/modules/cpq/data/validators.js +575 -0
  192. package/dist/modules/cpq/data/validators.js.map +7 -0
  193. package/dist/modules/cpq/di.js +57 -0
  194. package/dist/modules/cpq/di.js.map +7 -0
  195. package/dist/modules/cpq/events.js +50 -0
  196. package/dist/modules/cpq/events.js.map +7 -0
  197. package/dist/modules/cpq/index.js +16 -0
  198. package/dist/modules/cpq/index.js.map +7 -0
  199. package/dist/modules/cpq/lib/seeds/api.js +32 -0
  200. package/dist/modules/cpq/lib/seeds/api.js.map +7 -0
  201. package/dist/modules/cpq/lib/seeds/tenant-provisioning.js +158 -0
  202. package/dist/modules/cpq/lib/seeds/tenant-provisioning.js.map +7 -0
  203. package/dist/modules/cpq/lib/seeds.js +6 -0
  204. package/dist/modules/cpq/lib/seeds.js.map +7 -0
  205. package/dist/modules/cpq/migrations/Migration20260220220000_cpq.js +40 -0
  206. package/dist/modules/cpq/migrations/Migration20260220220000_cpq.js.map +7 -0
  207. package/dist/modules/cpq/migrations/Migration20260330000000_cpq_product_domain.js +54 -0
  208. package/dist/modules/cpq/migrations/Migration20260330000000_cpq_product_domain.js.map +7 -0
  209. package/dist/modules/cpq/migrations/Migration20260331000000_cpq_charge_unique_per_offering.js +15 -0
  210. package/dist/modules/cpq/migrations/Migration20260331000000_cpq_charge_unique_per_offering.js.map +7 -0
  211. package/dist/modules/cpq/migrations/Migration20260401000000_cpq_quoting_v2.js +62 -0
  212. package/dist/modules/cpq/migrations/Migration20260401000000_cpq_quoting_v2.js.map +7 -0
  213. package/dist/modules/cpq/migrations/Migration20260402000000_cpq_workflows.js +56 -0
  214. package/dist/modules/cpq/migrations/Migration20260402000000_cpq_workflows.js.map +7 -0
  215. package/dist/modules/cpq/migrations/Migration20260407000000_cpq_pricing_tables_v2.js +26 -0
  216. package/dist/modules/cpq/migrations/Migration20260407000000_cpq_pricing_tables_v2.js.map +7 -0
  217. package/dist/modules/cpq/migrations/Migration20260407100000_cpq_price_rules.js +37 -0
  218. package/dist/modules/cpq/migrations/Migration20260407100000_cpq_price_rules.js.map +7 -0
  219. package/dist/modules/cpq/migrations/Migration20260408000000_cpq_price_rules.js +35 -0
  220. package/dist/modules/cpq/migrations/Migration20260408000000_cpq_price_rules.js.map +7 -0
  221. package/dist/modules/cpq/migrations/Migration20260410000000_cpq_drop_datacentres_inventory.js +21 -0
  222. package/dist/modules/cpq/migrations/Migration20260410000000_cpq_drop_datacentres_inventory.js.map +7 -0
  223. package/dist/modules/cpq/migrations/Migration20260410000000_cpq_wizard_rename.js +46 -0
  224. package/dist/modules/cpq/migrations/Migration20260410000000_cpq_wizard_rename.js.map +7 -0
  225. package/dist/modules/cpq/migrations/Migration20260410100000_cpq_inventory.js +118 -0
  226. package/dist/modules/cpq/migrations/Migration20260410100000_cpq_inventory.js.map +7 -0
  227. package/dist/modules/cpq/migrations/Migration20260411000000_cpq_orders.js +63 -0
  228. package/dist/modules/cpq/migrations/Migration20260411000000_cpq_orders.js.map +7 -0
  229. package/dist/modules/cpq/migrations/Migration20260412000000_cpq_bundled_offerings.js +28 -0
  230. package/dist/modules/cpq/migrations/Migration20260412000000_cpq_bundled_offerings.js.map +7 -0
  231. package/dist/modules/cpq/migrations/Migration20260504064621_cpq_arc.js +36 -0
  232. package/dist/modules/cpq/migrations/Migration20260504064621_cpq_arc.js.map +7 -0
  233. package/dist/modules/cpq/migrations/Migration20260514000000_cpq_spec_merge_retired_deprecated.js +14 -0
  234. package/dist/modules/cpq/migrations/Migration20260514000000_cpq_spec_merge_retired_deprecated.js.map +7 -0
  235. package/dist/modules/cpq/migrations/Migration20260514100000_cpq_offering_merge_retired_deprecated.js +14 -0
  236. package/dist/modules/cpq/migrations/Migration20260514100000_cpq_offering_merge_retired_deprecated.js.map +7 -0
  237. package/dist/modules/cpq/migrations/Migration20260514110000_cpq_list_search_indexes.js +58 -0
  238. package/dist/modules/cpq/migrations/Migration20260514110000_cpq_list_search_indexes.js.map +7 -0
  239. package/dist/modules/cpq/migrations/Migration20260515000000_cpq_inventory_subscriptions_search_indexes.js +29 -0
  240. package/dist/modules/cpq/migrations/Migration20260515000000_cpq_inventory_subscriptions_search_indexes.js.map +7 -0
  241. package/dist/modules/cpq/services/cpqBundleService.js +447 -0
  242. package/dist/modules/cpq/services/cpqBundleService.js.map +7 -0
  243. package/dist/modules/cpq/services/cpqInventoryService.js +1371 -0
  244. package/dist/modules/cpq/services/cpqInventoryService.js.map +7 -0
  245. package/dist/modules/cpq/services/cpqOrderService.js +901 -0
  246. package/dist/modules/cpq/services/cpqOrderService.js.map +7 -0
  247. package/dist/modules/cpq/services/cpqPricingService.js +289 -0
  248. package/dist/modules/cpq/services/cpqPricingService.js.map +7 -0
  249. package/dist/modules/cpq/services/cpqProductService.js +204 -0
  250. package/dist/modules/cpq/services/cpqProductService.js.map +7 -0
  251. package/dist/modules/cpq/services/cpqQuotingService.js +1867 -0
  252. package/dist/modules/cpq/services/cpqQuotingService.js.map +7 -0
  253. package/dist/modules/cpq/services/cpqValidationService.js +234 -0
  254. package/dist/modules/cpq/services/cpqValidationService.js.map +7 -0
  255. package/dist/modules/cpq/services/cpqWizardService.js +137 -0
  256. package/dist/modules/cpq/services/cpqWizardService.js.map +7 -0
  257. package/dist/modules/cpq/services/types.js +114 -0
  258. package/dist/modules/cpq/services/types.js.map +7 -0
  259. package/dist/modules/cpq/setup.js +49 -0
  260. package/dist/modules/cpq/setup.js.map +7 -0
  261. package/dist/modules/cpq/subscribers/subscription-amended-log.js +18 -0
  262. package/dist/modules/cpq/subscribers/subscription-amended-log.js.map +7 -0
  263. package/dist/modules/cpq/subscribers/subscription-cancelled-log.js +19 -0
  264. package/dist/modules/cpq/subscribers/subscription-cancelled-log.js.map +7 -0
  265. package/dist/modules/cpq/subscribers/subscription-merged-log.js +20 -0
  266. package/dist/modules/cpq/subscribers/subscription-merged-log.js.map +7 -0
  267. package/dist/modules/cpq/subscribers/subscription-renewed-log.js +18 -0
  268. package/dist/modules/cpq/subscribers/subscription-renewed-log.js.map +7 -0
  269. package/dist/modules/cpq/subscribers/subscription-superseded-log.js +18 -0
  270. package/dist/modules/cpq/subscribers/subscription-superseded-log.js.map +7 -0
  271. package/dist/modules/cpq/widgets/injection/customer-inventory/widget.client.js +192 -0
  272. package/dist/modules/cpq/widgets/injection/customer-inventory/widget.client.js.map +7 -0
  273. package/dist/modules/cpq/widgets/injection/customer-inventory/widget.js +17 -0
  274. package/dist/modules/cpq/widgets/injection/customer-inventory/widget.js.map +7 -0
  275. package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.client.js +79 -0
  276. package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.client.js.map +7 -0
  277. package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.js +17 -0
  278. package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.js.map +7 -0
  279. package/dist/modules/cpq/widgets/injection/quote-configurator/widget.client.js +181 -0
  280. package/dist/modules/cpq/widgets/injection/quote-configurator/widget.client.js.map +7 -0
  281. package/dist/modules/cpq/widgets/injection/quote-configurator/widget.js +17 -0
  282. package/dist/modules/cpq/widgets/injection/quote-configurator/widget.js.map +7 -0
  283. package/dist/modules/cpq/widgets/injection-table.js +44 -0
  284. package/dist/modules/cpq/widgets/injection-table.js.map +7 -0
  285. package/dist/modules/cpq/workflows/WizardRunner.js +113 -0
  286. package/dist/modules/cpq/workflows/WizardRunner.js.map +7 -0
  287. package/dist/modules/cpq/workflows/WizardTrigger.js +116 -0
  288. package/dist/modules/cpq/workflows/WizardTrigger.js.map +7 -0
  289. package/dist/modules/cpq/workflows/engine.js +188 -0
  290. package/dist/modules/cpq/workflows/engine.js.map +7 -0
  291. package/dist/modules/cpq/workflows/registry.js +16 -0
  292. package/dist/modules/cpq/workflows/registry.js.map +7 -0
  293. package/dist/modules/cpq/workflows/steps/CustomerSelectStep.js +119 -0
  294. package/dist/modules/cpq/workflows/steps/CustomerSelectStep.js.map +7 -0
  295. package/dist/modules/cpq/workflows/steps/InventorySelectStep.js +24 -0
  296. package/dist/modules/cpq/workflows/steps/InventorySelectStep.js.map +7 -0
  297. package/dist/modules/cpq/workflows/steps/ItemListStep.js +115 -0
  298. package/dist/modules/cpq/workflows/steps/ItemListStep.js.map +7 -0
  299. package/dist/modules/cpq/workflows/steps/OfferingSelectStep.js +327 -0
  300. package/dist/modules/cpq/workflows/steps/OfferingSelectStep.js.map +7 -0
  301. package/dist/modules/cpq/workflows/steps/ProductConfigureStep.js +231 -0
  302. package/dist/modules/cpq/workflows/steps/ProductConfigureStep.js.map +7 -0
  303. package/dist/modules/cpq/workflows/steps/ReviewStep.js +152 -0
  304. package/dist/modules/cpq/workflows/steps/ReviewStep.js.map +7 -0
  305. package/dist/modules/cpq/workflows/steps/index.js +49 -0
  306. package/dist/modules/cpq/workflows/steps/index.js.map +7 -0
  307. package/dist/modules/cpq/workflows/types.js +1 -0
  308. package/dist/modules/cpq/workflows/types.js.map +7 -0
  309. package/package.json +150 -0
  310. package/src/modules/cpq/__integration__/TC-CPQ-001.spec.ts +177 -0
  311. package/src/modules/cpq/__integration__/TC-CPQ-002.spec.ts +123 -0
  312. package/src/modules/cpq/__integration__/TC-CPQ-003.spec.ts +115 -0
  313. package/src/modules/cpq/__integration__/TC-CPQ-004.spec.ts +184 -0
  314. package/src/modules/cpq/__integration__/TC-CPQ-005.spec.ts +43 -0
  315. package/src/modules/cpq/__integration__/TC-CPQ-006.spec.ts +87 -0
  316. package/src/modules/cpq/__integration__/TC-CPQ-007.spec.ts +79 -0
  317. package/src/modules/cpq/__integration__/TC-CPQ-008.spec.ts +55 -0
  318. package/src/modules/cpq/__integration__/TC-CPQ-009.spec.ts +45 -0
  319. package/src/modules/cpq/__integration__/TC-CPQ-010.spec.ts +54 -0
  320. package/src/modules/cpq/__integration__/TC-CPQ-011.spec.ts +24 -0
  321. package/src/modules/cpq/__integration__/TC-CPQ-012.spec.ts +62 -0
  322. package/src/modules/cpq/__integration__/TC-CPQ-ARC-001.spec.ts +52 -0
  323. package/src/modules/cpq/__integration__/TC-CPQ-ARC-002.spec.ts +65 -0
  324. package/src/modules/cpq/__integration__/TC-CPQ-ARC-003.spec.ts +107 -0
  325. package/src/modules/cpq/__integration__/TC-CPQ-ARC-004.spec.ts +74 -0
  326. package/src/modules/cpq/__integration__/TC-CPQ-ARC-005.spec.ts +86 -0
  327. package/src/modules/cpq/__integration__/TC-CPQ-ARC-006.spec.ts +79 -0
  328. package/src/modules/cpq/__integration__/TC-CPQ-ARC-007.spec.ts +63 -0
  329. package/src/modules/cpq/__integration__/TC-CPQ-ARC-008.spec.ts +62 -0
  330. package/src/modules/cpq/__integration__/TC-CPQ-ARC-009.spec.ts +62 -0
  331. package/src/modules/cpq/__integration__/TC-CPQ-ARC-010.spec.ts +59 -0
  332. package/src/modules/cpq/__integration__/TC-CPQ-ARC-011.spec.ts +128 -0
  333. package/src/modules/cpq/__integration__/_helpers/cpqArcFixtures.ts +208 -0
  334. package/src/modules/cpq/__tests__/package-isolation.test.ts +232 -0
  335. package/src/modules/cpq/acl.ts +26 -0
  336. package/src/modules/cpq/api/_helpers/__tests__/handleArcError.test.ts +117 -0
  337. package/src/modules/cpq/api/_helpers/handleArcError.ts +81 -0
  338. package/src/modules/cpq/api/attribute-options/route.ts +55 -0
  339. package/src/modules/cpq/api/bundle-slots/route.ts +112 -0
  340. package/src/modules/cpq/api/bundle-tree/route.ts +31 -0
  341. package/src/modules/cpq/api/context.ts +39 -0
  342. package/src/modules/cpq/api/inventory/assets/route.ts +123 -0
  343. package/src/modules/cpq/api/inventory/assets/status/route.ts +34 -0
  344. package/src/modules/cpq/api/inventory/customer/[customerId]/parent-items/route.ts +30 -0
  345. package/src/modules/cpq/api/inventory/customer/[customerId]/route.ts +31 -0
  346. package/src/modules/cpq/api/inventory/subscription-items/route.ts +107 -0
  347. package/src/modules/cpq/api/inventory/subscriptions/[subscriptionId]/change-log/route.ts +64 -0
  348. package/src/modules/cpq/api/inventory/subscriptions/expiring/route.ts +66 -0
  349. package/src/modules/cpq/api/inventory/subscriptions/route.ts +126 -0
  350. package/src/modules/cpq/api/inventory/subscriptions/status/route.ts +34 -0
  351. package/src/modules/cpq/api/offering-components/route.ts +103 -0
  352. package/src/modules/cpq/api/orders/[orderId]/activate/route.ts +48 -0
  353. package/src/modules/cpq/api/orders/[orderId]/route.ts +28 -0
  354. package/src/modules/cpq/api/orders/[orderId]/status/route.ts +38 -0
  355. package/src/modules/cpq/api/orders/route.ts +77 -0
  356. package/src/modules/cpq/api/price-rules/route.ts +210 -0
  357. package/src/modules/cpq/api/pricing-table-entries/route.ts +159 -0
  358. package/src/modules/cpq/api/pricing-tables/route.ts +202 -0
  359. package/src/modules/cpq/api/product-attributes/route.ts +175 -0
  360. package/src/modules/cpq/api/product-charges/route.ts +181 -0
  361. package/src/modules/cpq/api/product-offerings/route.ts +329 -0
  362. package/src/modules/cpq/api/product-relationships/route.ts +156 -0
  363. package/src/modules/cpq/api/product-specifications/route.ts +325 -0
  364. package/src/modules/cpq/api/quotes/[quoteId]/cancel-meta/route.ts +77 -0
  365. package/src/modules/cpq/api/quotes/[quoteId]/clone/route.ts +28 -0
  366. package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.ts +55 -0
  367. package/src/modules/cpq/api/quotes/[quoteId]/items/route.ts +26 -0
  368. package/src/modules/cpq/api/quotes/[quoteId]/merge-meta/route.ts +78 -0
  369. package/src/modules/cpq/api/quotes/[quoteId]/recalculate/route.ts +34 -0
  370. package/src/modules/cpq/api/quotes/[quoteId]/route.ts +109 -0
  371. package/src/modules/cpq/api/quotes/[quoteId]/status/route.ts +26 -0
  372. package/src/modules/cpq/api/quotes/[quoteId]/target-subscriptions/[targetId]/route.ts +91 -0
  373. package/src/modules/cpq/api/quotes/[quoteId]/target-subscriptions/route.ts +107 -0
  374. package/src/modules/cpq/api/quotes/attributes/route.ts +61 -0
  375. package/src/modules/cpq/api/quotes/from-subscription/route.ts +85 -0
  376. package/src/modules/cpq/api/quotes/price/route.ts +81 -0
  377. package/src/modules/cpq/api/quotes/route.ts +114 -0
  378. package/src/modules/cpq/api/quotes/validate-relationships/route.ts +100 -0
  379. package/src/modules/cpq/api/resolveQuotingService.ts +11 -0
  380. package/src/modules/cpq/api/wizards/resolve/route.ts +39 -0
  381. package/src/modules/cpq/api/wizards/route.ts +127 -0
  382. package/src/modules/cpq/backend/cpq/inventory/AssetsTable.tsx +166 -0
  383. package/src/modules/cpq/backend/cpq/inventory/SubscriptionsTable.tsx +251 -0
  384. package/src/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.ts +9 -0
  385. package/src/modules/cpq/backend/cpq/inventory/assets/[id]/page.tsx +440 -0
  386. package/src/modules/cpq/backend/cpq/inventory/page.meta.ts +16 -0
  387. package/src/modules/cpq/backend/cpq/inventory/page.tsx +49 -0
  388. package/src/modules/cpq/backend/cpq/inventory/shared.tsx +32 -0
  389. package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.ts +9 -0
  390. package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.tsx +1085 -0
  391. package/src/modules/cpq/backend/cpq/offerings/[id]/page.tsx +1165 -0
  392. package/src/modules/cpq/backend/cpq/offerings/page.meta.ts +16 -0
  393. package/src/modules/cpq/backend/cpq/offerings/page.tsx +468 -0
  394. package/src/modules/cpq/backend/cpq/orders/[id]/page.tsx +921 -0
  395. package/src/modules/cpq/backend/cpq/orders/page.meta.ts +16 -0
  396. package/src/modules/cpq/backend/cpq/orders/page.tsx +182 -0
  397. package/src/modules/cpq/backend/cpq/price-rules/PriceRuleForm.tsx +310 -0
  398. package/src/modules/cpq/backend/cpq/price-rules/[id]/page.meta.ts +9 -0
  399. package/src/modules/cpq/backend/cpq/price-rules/[id]/page.tsx +472 -0
  400. package/src/modules/cpq/backend/cpq/price-rules/page.meta.ts +16 -0
  401. package/src/modules/cpq/backend/cpq/price-rules/page.tsx +253 -0
  402. package/src/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.tsx +431 -0
  403. package/src/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.tsx +122 -0
  404. package/src/modules/cpq/backend/cpq/pricing/[id]/page.tsx +588 -0
  405. package/src/modules/cpq/backend/cpq/pricing/[id]/xlsxExport.ts +90 -0
  406. package/src/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.ts +338 -0
  407. package/src/modules/cpq/backend/cpq/pricing/page.meta.ts +16 -0
  408. package/src/modules/cpq/backend/cpq/pricing/page.tsx +111 -0
  409. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.tsx +737 -0
  410. package/src/modules/cpq/backend/cpq/quotes/[id]/page.tsx +1721 -0
  411. package/src/modules/cpq/backend/cpq/quotes/page.meta.ts +16 -0
  412. package/src/modules/cpq/backend/cpq/quotes/page.tsx +370 -0
  413. package/src/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.tsx +300 -0
  414. package/src/modules/cpq/backend/cpq/specifications/[id]/page.tsx +1722 -0
  415. package/src/modules/cpq/backend/cpq/specifications/page.meta.ts +16 -0
  416. package/src/modules/cpq/backend/cpq/specifications/page.tsx +191 -0
  417. package/src/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.tsx +319 -0
  418. package/src/modules/cpq/backend/cpq/wizards/[definitionCode]/page.tsx +234 -0
  419. package/src/modules/cpq/backend/cpq/wizards/page.meta.ts +16 -0
  420. package/src/modules/cpq/backend/cpq/wizards/page.tsx +141 -0
  421. package/src/modules/cpq/ce.ts +150 -0
  422. package/src/modules/cpq/cli.ts +10 -0
  423. package/src/modules/cpq/components/CpqListView.tsx +431 -0
  424. package/src/modules/cpq/components/NumberInput.tsx +166 -0
  425. package/src/modules/cpq/components/StatusPath.tsx +116 -0
  426. package/src/modules/cpq/components/statusMaps.ts +148 -0
  427. package/src/modules/cpq/components/useCpqRowActions.tsx +183 -0
  428. package/src/modules/cpq/data/__tests__/validators.arc.test.ts +271 -0
  429. package/src/modules/cpq/data/__tests__/validators.test.ts +782 -0
  430. package/src/modules/cpq/data/entities.ts +1467 -0
  431. package/src/modules/cpq/data/validators.ts +663 -0
  432. package/src/modules/cpq/di.ts +70 -0
  433. package/src/modules/cpq/events.ts +62 -0
  434. package/src/modules/cpq/index.ts +15 -0
  435. package/src/modules/cpq/lib/seeds/__tests__/api.test.ts +86 -0
  436. package/src/modules/cpq/lib/seeds/__tests__/tenant-provisioning.test.ts +349 -0
  437. package/src/modules/cpq/lib/seeds/api.ts +119 -0
  438. package/src/modules/cpq/lib/seeds/tenant-provisioning.ts +239 -0
  439. package/src/modules/cpq/lib/seeds.ts +18 -0
  440. package/src/modules/cpq/migrations/.snapshot-open-mercato.json +7473 -0
  441. package/src/modules/cpq/migrations/Migration20260220220000_cpq.ts +51 -0
  442. package/src/modules/cpq/migrations/Migration20260330000000_cpq_product_domain.ts +70 -0
  443. package/src/modules/cpq/migrations/Migration20260331000000_cpq_charge_unique_per_offering.ts +15 -0
  444. package/src/modules/cpq/migrations/Migration20260401000000_cpq_quoting_v2.ts +73 -0
  445. package/src/modules/cpq/migrations/Migration20260402000000_cpq_workflows.ts +59 -0
  446. package/src/modules/cpq/migrations/Migration20260407000000_cpq_pricing_tables_v2.ts +30 -0
  447. package/src/modules/cpq/migrations/Migration20260407100000_cpq_price_rules.ts +36 -0
  448. package/src/modules/cpq/migrations/Migration20260408000000_cpq_price_rules.ts +33 -0
  449. package/src/modules/cpq/migrations/Migration20260410000000_cpq_drop_datacentres_inventory.ts +22 -0
  450. package/src/modules/cpq/migrations/Migration20260410000000_cpq_wizard_rename.ts +56 -0
  451. package/src/modules/cpq/migrations/Migration20260410100000_cpq_inventory.ts +118 -0
  452. package/src/modules/cpq/migrations/Migration20260411000000_cpq_orders.ts +62 -0
  453. package/src/modules/cpq/migrations/Migration20260412000000_cpq_bundled_offerings.ts +33 -0
  454. package/src/modules/cpq/migrations/Migration20260504064621_cpq_arc.ts +68 -0
  455. package/src/modules/cpq/migrations/Migration20260514000000_cpq_spec_merge_retired_deprecated.ts +30 -0
  456. package/src/modules/cpq/migrations/Migration20260514100000_cpq_offering_merge_retired_deprecated.ts +27 -0
  457. package/src/modules/cpq/migrations/Migration20260514110000_cpq_list_search_indexes.ts +82 -0
  458. package/src/modules/cpq/migrations/Migration20260515000000_cpq_inventory_subscriptions_search_indexes.ts +52 -0
  459. package/src/modules/cpq/services/__tests__/_helpers/mockEm.ts +59 -0
  460. package/src/modules/cpq/services/__tests__/cpqBundleService.test.ts +661 -0
  461. package/src/modules/cpq/services/__tests__/cpqInventoryService.arc.test.ts +357 -0
  462. package/src/modules/cpq/services/__tests__/cpqInventoryService.test.ts +718 -0
  463. package/src/modules/cpq/services/__tests__/cpqOrderService.test.ts +603 -0
  464. package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +1012 -0
  465. package/src/modules/cpq/services/__tests__/cpqProductService.test.ts +603 -0
  466. package/src/modules/cpq/services/__tests__/cpqQuotingService.arc.test.ts +143 -0
  467. package/src/modules/cpq/services/__tests__/cpqQuotingService.test.ts +658 -0
  468. package/src/modules/cpq/services/__tests__/cpqValidationService.test.ts +811 -0
  469. package/src/modules/cpq/services/__tests__/cpqWizardService.test.ts +389 -0
  470. package/src/modules/cpq/services/__tests__/types.test.ts +359 -0
  471. package/src/modules/cpq/services/cpqBundleService.ts +671 -0
  472. package/src/modules/cpq/services/cpqInventoryService.ts +1929 -0
  473. package/src/modules/cpq/services/cpqOrderService.ts +1230 -0
  474. package/src/modules/cpq/services/cpqPricingService.ts +412 -0
  475. package/src/modules/cpq/services/cpqProductService.ts +311 -0
  476. package/src/modules/cpq/services/cpqQuotingService.ts +2638 -0
  477. package/src/modules/cpq/services/cpqValidationService.ts +323 -0
  478. package/src/modules/cpq/services/cpqWizardService.ts +204 -0
  479. package/src/modules/cpq/services/types.ts +303 -0
  480. package/src/modules/cpq/setup.ts +63 -0
  481. package/src/modules/cpq/subscribers/subscription-amended-log.ts +20 -0
  482. package/src/modules/cpq/subscribers/subscription-cancelled-log.ts +19 -0
  483. package/src/modules/cpq/subscribers/subscription-merged-log.ts +22 -0
  484. package/src/modules/cpq/subscribers/subscription-renewed-log.ts +18 -0
  485. package/src/modules/cpq/subscribers/subscription-superseded-log.ts +19 -0
  486. package/src/modules/cpq/widgets/injection/customer-inventory/widget.client.tsx +339 -0
  487. package/src/modules/cpq/widgets/injection/customer-inventory/widget.ts +16 -0
  488. package/src/modules/cpq/widgets/injection/order-cpq-tab/widget.client.tsx +96 -0
  489. package/src/modules/cpq/widgets/injection/order-cpq-tab/widget.ts +16 -0
  490. package/src/modules/cpq/widgets/injection/quote-configurator/widget.client.tsx +265 -0
  491. package/src/modules/cpq/widgets/injection/quote-configurator/widget.ts +16 -0
  492. package/src/modules/cpq/widgets/injection-table.ts +42 -0
  493. package/src/modules/cpq/workflows/WizardRunner.tsx +158 -0
  494. package/src/modules/cpq/workflows/WizardTrigger.tsx +142 -0
  495. package/src/modules/cpq/workflows/engine.tsx +278 -0
  496. package/src/modules/cpq/workflows/registry.ts +15 -0
  497. package/src/modules/cpq/workflows/steps/CustomerSelectStep.tsx +136 -0
  498. package/src/modules/cpq/workflows/steps/InventorySelectStep.tsx +34 -0
  499. package/src/modules/cpq/workflows/steps/ItemListStep.tsx +146 -0
  500. package/src/modules/cpq/workflows/steps/OfferingSelectStep.tsx +438 -0
  501. package/src/modules/cpq/workflows/steps/ProductConfigureStep.tsx +287 -0
  502. package/src/modules/cpq/workflows/steps/ReviewStep.tsx +219 -0
  503. package/src/modules/cpq/workflows/steps/index.ts +51 -0
  504. package/src/modules/cpq/workflows/types.ts +102 -0
@@ -0,0 +1,1722 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
4
+ import { useParams, useRouter } from 'next/navigation'
5
+ import { Tag } from '@open-mercato/ui/primitives/tag'
6
+ import { Alert } from '@open-mercato/ui/primitives/alert'
7
+ import { Button } from '@open-mercato/ui/primitives/button'
8
+ import { NumberInput } from '../../../../components/NumberInput'
9
+ import {
10
+ formatStatusLabel,
11
+ lifecycleStatusMap,
12
+ specificationLifecycleStatusMap,
13
+ type LifecycleStatus,
14
+ type SpecificationLifecycleStatus,
15
+ } from '../../../../components/statusMaps'
16
+ import { DependsOnRulesEditor } from './DependsOnRulesEditor'
17
+
18
+ // ─── Types ──────────────────────────────────────────────────────
19
+
20
+ // Attribute → attribute dependency contract. The validator
21
+ // (`cpqValidationService.validateAttributes`) reads `attributeCode` to look
22
+ // up the parent value and walks `rules[].when` to decide what happens to
23
+ // this attribute. `effect` controls the interpretation of `rules[].then`:
24
+ //
25
+ // - `filter_options`: `then` is a comma-separated list of allowed values
26
+ // that this attribute may take when the parent matches `when`.
27
+ // - `set_value`: `then` is a single value to assign to this attribute
28
+ // when the parent matches `when`.
29
+ // - `toggle_required`: `then` is a boolean — `true` makes this attribute
30
+ // required when the parent matches `when`, `false` makes it optional.
31
+ //
32
+ // Storage stays generic (`{ when: string; then: unknown }`) so we don't
33
+ // have to migrate older configs when the surface grows.
34
+ export type DependsOnEffect = 'filter_options' | 'set_value' | 'toggle_required'
35
+
36
+ export type DependsOnRule = {
37
+ when: string
38
+ then: unknown
39
+ }
40
+
41
+ export type AttributeDependsOn = {
42
+ attributeCode: string
43
+ effect: DependsOnEffect
44
+ rules: DependsOnRule[]
45
+ }
46
+
47
+ type Specification = {
48
+ id: string
49
+ productId: string
50
+ code: string
51
+ name: string
52
+ description: string | null
53
+ specType: string
54
+ isAssetizable: boolean
55
+ lifecycleStatus: string
56
+ version: number
57
+ effectiveFrom: string | null
58
+ effectiveTo: string | null
59
+ metadata: Record<string, unknown> | null
60
+ isActive: boolean
61
+ }
62
+
63
+ type Attribute = {
64
+ id: string
65
+ code: string
66
+ name: string
67
+ attributeType: string
68
+ resolutionTime: string
69
+ options: Array<{ value: string; label: string }> | null
70
+ constraints: Record<string, unknown> | null
71
+ referenceEntity: string | null
72
+ referenceFilter: Record<string, unknown> | null
73
+ dependsOn: AttributeDependsOn | null
74
+ defaultValue: unknown | null
75
+ helpText: string | null
76
+ sortOrder: number
77
+ isRequired: boolean
78
+ isActive: boolean
79
+ }
80
+
81
+ type Offering = {
82
+ id: string
83
+ code: string
84
+ name: string
85
+ description: string | null
86
+ offeringType: string
87
+ designTimeValues: Record<string, unknown>
88
+ lifecycleStatus: string
89
+ isActive: boolean
90
+ }
91
+
92
+ type BundleSlot = {
93
+ id: string
94
+ specId: string
95
+ targetSpecId: string
96
+ targetSpec: { id: string; code: string; name: string; specType: string } | null
97
+ componentGroup: string
98
+ name: string
99
+ description: string | null
100
+ cardinalityMin: number
101
+ cardinalityMax: number | null
102
+ sortOrder: number
103
+ isActive: boolean
104
+ }
105
+
106
+ type EditingSlot = {
107
+ id?: string
108
+ targetSpecId: string
109
+ componentGroup: string
110
+ name: string
111
+ description: string
112
+ cardinalityMin: number
113
+ cardinalityMax: number | null
114
+ sortOrder: number
115
+ }
116
+
117
+ type Relationship = {
118
+ id: string
119
+ sourceSpecId: string
120
+ targetSpecId: string
121
+ relationshipType: string
122
+ cardinalityMin: number
123
+ cardinalityMax: number | null
124
+ condition: Record<string, unknown> | null
125
+ isActive: boolean
126
+ }
127
+
128
+ type Charge = {
129
+ id: string
130
+ code: string
131
+ name: string
132
+ description: string | null
133
+ chargeType: string
134
+ pricingMethod: string
135
+ pricingTableId: string | null
136
+ priceColumnKey: string | null
137
+ fixedPrice: string | null
138
+ currencyCode: string | null
139
+ quantityAttributeCode: string | null
140
+ applicabilityCondition: Record<string, unknown> | null
141
+ sortOrder: number
142
+ isActive: boolean
143
+ }
144
+
145
+ type PricingTableRef = { id: string; code: string; name: string; dimensions: Array<{ key: string; label: string }>; priceColumns: Array<{ key: string; label: string }> }
146
+
147
+ type EditingAttribute = Omit<Attribute, 'id'> & { id?: string }
148
+ type EditingOffering = Omit<Offering, 'id'> & { id?: string }
149
+ type EditingRelationship = Omit<Relationship, 'id'> & { id?: string }
150
+ type EditingCharge = Omit<Charge, 'id'> & { id?: string }
151
+
152
+ // ─── Defaults ───────────────────────────────────────────────────
153
+
154
+ const EMPTY_SPEC = {
155
+ code: '',
156
+ name: '',
157
+ description: '',
158
+ specType: 'simple' as string,
159
+ isAssetizable: false,
160
+ lifecycleStatus: 'draft',
161
+ version: 1,
162
+ effectiveFrom: null as string | null,
163
+ effectiveTo: null as string | null,
164
+ isActive: true,
165
+ }
166
+
167
+ const EMPTY_SLOT: EditingSlot = {
168
+ targetSpecId: '',
169
+ componentGroup: '',
170
+ name: '',
171
+ description: '',
172
+ cardinalityMin: 0,
173
+ cardinalityMax: null,
174
+ sortOrder: 0,
175
+ }
176
+
177
+ const EMPTY_ATTRIBUTE: EditingAttribute = {
178
+ code: '',
179
+ name: '',
180
+ attributeType: 'text',
181
+ resolutionTime: 'run_time',
182
+ options: null,
183
+ constraints: null,
184
+ referenceEntity: null,
185
+ referenceFilter: null,
186
+ dependsOn: null,
187
+ defaultValue: null,
188
+ helpText: null,
189
+ sortOrder: 0,
190
+ isRequired: true,
191
+ isActive: true,
192
+ }
193
+
194
+ const EMPTY_OFFERING: EditingOffering = {
195
+ code: '',
196
+ name: '',
197
+ description: null,
198
+ offeringType: 'simple',
199
+ designTimeValues: {},
200
+ lifecycleStatus: 'draft',
201
+ isActive: true,
202
+ }
203
+
204
+ const EMPTY_CHARGE: EditingCharge = {
205
+ code: '',
206
+ name: '',
207
+ description: null,
208
+ chargeType: 'mrc',
209
+ pricingMethod: 'flat',
210
+ pricingTableId: null,
211
+ priceColumnKey: null,
212
+ fixedPrice: null,
213
+ currencyCode: null,
214
+ quantityAttributeCode: null,
215
+ applicabilityCondition: null,
216
+ sortOrder: 0,
217
+ isActive: true,
218
+ }
219
+
220
+ const EMPTY_RELATIONSHIP: EditingRelationship = {
221
+ sourceSpecId: '',
222
+ targetSpecId: '',
223
+ relationshipType: 'requires',
224
+ cardinalityMin: 0,
225
+ cardinalityMax: null,
226
+ condition: null,
227
+ isActive: true,
228
+ }
229
+
230
+ // ─── Component ──────────────────────────────────────────────────
231
+
232
+ export default function SpecificationDetailPage(props: { params?: { id?: string } }) {
233
+ const t = useT()
234
+ const router = useRouter()
235
+ const urlParams = useParams()
236
+ const specId = (props.params?.id ?? urlParams?.id) as string
237
+ const isNew = specId === 'new'
238
+
239
+ // ─── State ──────────────────────────────────────────────────
240
+
241
+ const [tab, setTab] = React.useState<'general' | 'attributes' | 'offerings' | 'relationships' | 'slots'>('general')
242
+ const [form, setForm] = React.useState(EMPTY_SPEC)
243
+ const [attributes, setAttributes] = React.useState<Attribute[]>([])
244
+ const [offerings, setOfferings] = React.useState<Offering[]>([])
245
+ const [relationships, setRelationships] = React.useState<Relationship[]>([])
246
+ const [bundleSlots, setBundleSlots] = React.useState<BundleSlot[]>([])
247
+ const [allSpecs, setAllSpecs] = React.useState<Array<{ id: string; code: string; name: string; specType?: string }>>([])
248
+
249
+ const [loading, setLoading] = React.useState(!isNew)
250
+ const [saving, setSaving] = React.useState(false)
251
+ const [error, setError] = React.useState<string | null>(null)
252
+
253
+ const [editingAttr, setEditingAttr] = React.useState<EditingAttribute | null>(null)
254
+ const [editingOffering, setEditingOffering] = React.useState<EditingOffering | null>(null)
255
+ const [editingRel, setEditingRel] = React.useState<EditingRelationship | null>(null)
256
+ const [editingSlot, setEditingSlot] = React.useState<EditingSlot | null>(null)
257
+ const [editingOptions, setEditingOptions] = React.useState<Array<{ value: string; label: string }>>([])
258
+ const [editingDtValues, setEditingDtValues] = React.useState<Record<string, unknown>>({})
259
+ const [selectedOfferingIds, setSelectedOfferingIds] = React.useState<Set<string>>(new Set())
260
+ const [offeringCharges, setOfferingCharges] = React.useState<Record<string, Charge[]>>({})
261
+ const [chargeFormOpen, setChargeFormOpen] = React.useState(false)
262
+ const [chargeForm, setChargeForm] = React.useState({ code: '', name: '', description: '', chargeType: 'mrc', pricingMethod: 'flat' })
263
+ const [chargePrices, setChargePrices] = React.useState<Record<string, string>>({})
264
+ const [chargePricingTableId, setChargePricingTableId] = React.useState<string | null>(null)
265
+ const [chargePriceColumnKey, setChargePriceColumnKey] = React.useState<string | null>(null)
266
+ const [chargeCurrency, setChargeCurrency] = React.useState('USD')
267
+ const [pricingTables, setPricingTables] = React.useState<PricingTableRef[]>([])
268
+ const [conditionJson, setConditionJson] = React.useState('null')
269
+ const [dependsOnAttrCode, setDependsOnAttrCode] = React.useState('')
270
+ const [dependsOnEffect, setDependsOnEffect] = React.useState<DependsOnEffect>('filter_options')
271
+ const [dependsOnEnabled, setDependsOnEnabled] = React.useState(false)
272
+ const [dependsOnRules, setDependsOnRules] = React.useState<DependsOnRule[]>([])
273
+ // OM-standard detail page contract: existing records open in read-only
274
+ // "view" mode and require an explicit Edit click to allow editing. New
275
+ // records (`/specifications/new`) skip view mode since there's nothing
276
+ // to display yet.
277
+ const [generalMode, setGeneralMode] = React.useState<'view' | 'edit'>(isNew ? 'edit' : 'view')
278
+ // Snapshot of the form taken when entering edit mode — used by Cancel to
279
+ // revert local changes without re-fetching.
280
+ const [formSnapshot, setFormSnapshot] = React.useState<typeof EMPTY_SPEC | null>(null)
281
+
282
+ // ─── Load data ──────────────────────────────────────────────
283
+
284
+ React.useEffect(() => {
285
+ let cancelled = false
286
+
287
+ async function load() {
288
+ try {
289
+ if (isNew) {
290
+ return
291
+ }
292
+
293
+ // Load all specs for relationship target selector
294
+ const specsRes = await fetch('/api/cpq/product-specifications?pageSize=200')
295
+ if (specsRes.ok) {
296
+ const d = await specsRes.json()
297
+ if (!cancelled) setAllSpecs(d.items ?? [])
298
+ }
299
+
300
+ // Load spec detail with nested data
301
+ const res = await fetch(`/api/cpq/product-specifications?id=${encodeURIComponent(specId)}`)
302
+ if (!res.ok) {
303
+ if (!cancelled) setError('Failed to load specification')
304
+ return
305
+ }
306
+
307
+ const spec = await res.json()
308
+ if (!cancelled) {
309
+ setForm({
310
+ code: spec.code,
311
+ name: spec.name,
312
+ description: spec.description ?? '',
313
+ specType: spec.specType ?? 'simple',
314
+ isAssetizable: spec.isAssetizable ?? false,
315
+ lifecycleStatus: spec.lifecycleStatus,
316
+ version: spec.version,
317
+ effectiveFrom: spec.effectiveFrom,
318
+ effectiveTo: spec.effectiveTo,
319
+ isActive: spec.isActive,
320
+ })
321
+ setAttributes(spec.attributes ?? [])
322
+ setOfferings(spec.offerings ?? [])
323
+ setRelationships(spec.relationships ?? [])
324
+ setBundleSlots(spec.slots ?? [])
325
+ }
326
+ } catch (err) {
327
+ console.error('Failed to load', err)
328
+ if (!cancelled) setError('Failed to load')
329
+ } finally {
330
+ if (!cancelled) setLoading(false)
331
+ }
332
+ }
333
+
334
+ load()
335
+ return () => { cancelled = true }
336
+ }, [specId, isNew])
337
+
338
+ // ─── Save specification ─────────────────────────────────────
339
+
340
+ const saveSpec = async () => {
341
+ setSaving(true)
342
+ setError(null)
343
+ try {
344
+ const { ...formData } = form
345
+ const payload = isNew
346
+ ? { ...formData, description: formData.description || null }
347
+ : { id: specId, ...formData, description: formData.description || null }
348
+
349
+ const res = await fetch('/api/cpq/product-specifications', {
350
+ method: isNew ? 'POST' : 'PUT',
351
+ headers: { 'Content-Type': 'application/json' },
352
+ body: JSON.stringify(payload),
353
+ })
354
+
355
+ if (!res.ok) {
356
+ const d = await res.json()
357
+ setError(d.error ?? 'Failed to save')
358
+ return
359
+ }
360
+
361
+ const saved = await res.json()
362
+ if (isNew) {
363
+ router.push(`/backend/cpq/specifications/${saved.id}`)
364
+ } else {
365
+ setForm({
366
+ code: saved.code,
367
+ name: saved.name,
368
+ description: saved.description ?? '',
369
+ specType: saved.specType ?? form.specType,
370
+ isAssetizable: saved.isAssetizable ?? form.isAssetizable,
371
+ lifecycleStatus: saved.lifecycleStatus,
372
+ version: saved.version,
373
+ effectiveFrom: saved.effectiveFrom,
374
+ effectiveTo: saved.effectiveTo,
375
+ isActive: saved.isActive,
376
+ })
377
+ // Save succeeded — drop the snapshot and flip back to read-only.
378
+ setFormSnapshot(null)
379
+ setGeneralMode('view')
380
+ }
381
+ } catch {
382
+ setError('Failed to save')
383
+ } finally {
384
+ setSaving(false)
385
+ }
386
+ }
387
+
388
+ const deleteSpec = async () => {
389
+ if (!confirm(t('cpq.specifications.confirmDelete', 'Delete this specification and all its offerings, attributes and relationships?'))) return
390
+ try {
391
+ const res = await fetch('/api/cpq/product-specifications', {
392
+ method: 'DELETE',
393
+ headers: { 'Content-Type': 'application/json' },
394
+ body: JSON.stringify({ id: specId }),
395
+ })
396
+ if (res.ok) {
397
+ router.push('/backend/cpq/specifications')
398
+ } else {
399
+ const d = await res.json()
400
+ setError(d.error ?? 'Failed to delete')
401
+ }
402
+ } catch {
403
+ setError('Failed to delete')
404
+ }
405
+ }
406
+
407
+ // ─── Attribute CRUD ─────────────────────────────────────────
408
+
409
+ const saveAttribute = async () => {
410
+ if (!editingAttr) return
411
+ setSaving(true)
412
+ setError(null)
413
+ try {
414
+ const options = (editingAttr.attributeType === 'select' || editingAttr.attributeType === 'multi-select')
415
+ ? editingOptions.filter((o) => o.value.trim() !== '')
416
+ : null
417
+
418
+ const dependsOn: AttributeDependsOn | null =
419
+ dependsOnEnabled && dependsOnAttrCode
420
+ ? {
421
+ attributeCode: dependsOnAttrCode,
422
+ effect: dependsOnEffect,
423
+ rules: dependsOnRules.filter((r) => r.when.trim().length > 0),
424
+ }
425
+ : null
426
+
427
+ const isEdit = !!editingAttr.id
428
+ const payload = {
429
+ ...editingAttr,
430
+ options,
431
+ dependsOn,
432
+ specId,
433
+ }
434
+ const res = await fetch('/api/cpq/product-attributes', {
435
+ method: isEdit ? 'PUT' : 'POST',
436
+ headers: { 'Content-Type': 'application/json' },
437
+ body: JSON.stringify(payload),
438
+ })
439
+ if (!res.ok) {
440
+ const d = await res.json()
441
+ setError(d.error ?? 'Failed to save attribute')
442
+ return
443
+ }
444
+ const saved = await res.json()
445
+ if (isEdit) {
446
+ setAttributes((prev) => prev.map((a) => (a.id === saved.id ? saved : a)))
447
+ } else {
448
+ setAttributes((prev) => [...prev, saved])
449
+ }
450
+ setEditingAttr(null)
451
+ } catch {
452
+ setError('Failed to save attribute')
453
+ } finally {
454
+ setSaving(false)
455
+ }
456
+ }
457
+
458
+ const deleteAttribute = async (attrId: string) => {
459
+ if (!confirm('Delete this attribute?')) return
460
+ try {
461
+ const res = await fetch('/api/cpq/product-attributes', {
462
+ method: 'DELETE',
463
+ headers: { 'Content-Type': 'application/json' },
464
+ body: JSON.stringify({ id: attrId }),
465
+ })
466
+ if (res.ok) setAttributes((prev) => prev.filter((a) => a.id !== attrId))
467
+ } catch {
468
+ setError('Failed to delete attribute')
469
+ }
470
+ }
471
+
472
+ const startEditAttr = (attr?: Attribute) => {
473
+ const editing = attr ? { ...attr } : { ...EMPTY_ATTRIBUTE }
474
+ // Normalize legacy 'enum' type to 'select' for the editor
475
+ if (editing.attributeType === 'enum') editing.attributeType = 'select'
476
+ setEditingAttr(editing)
477
+ setEditingOptions(editing.options ? [...editing.options] : [])
478
+ const dep = editing.dependsOn
479
+ setDependsOnEnabled(!!dep)
480
+ setDependsOnAttrCode(dep?.attributeCode ?? '')
481
+ setDependsOnEffect((dep?.effect as DependsOnEffect) ?? 'filter_options')
482
+ setDependsOnRules(Array.isArray(dep?.rules) ? dep.rules : [])
483
+ }
484
+
485
+ // ─── Offering CRUD ──────────────────────────────────────────
486
+
487
+ const saveOffering = async () => {
488
+ if (!editingOffering) return
489
+ setSaving(true)
490
+ setError(null)
491
+ try {
492
+ const isEdit = !!editingOffering.id
493
+ const payload = {
494
+ ...editingOffering,
495
+ designTimeValues: editingDtValues,
496
+ specId,
497
+ }
498
+ const res = await fetch('/api/cpq/product-offerings', {
499
+ method: isEdit ? 'PUT' : 'POST',
500
+ headers: { 'Content-Type': 'application/json' },
501
+ body: JSON.stringify(payload),
502
+ })
503
+ if (!res.ok) {
504
+ const d = await res.json()
505
+ setError(d.error ?? 'Failed to save offering')
506
+ return
507
+ }
508
+ const saved = await res.json()
509
+ if (isEdit) {
510
+ setOfferings((prev) => prev.map((o) => (o.id === saved.id ? saved : o)))
511
+ } else {
512
+ setOfferings((prev) => [...prev, saved])
513
+ }
514
+ setEditingOffering(null)
515
+ } catch {
516
+ setError('Failed to save offering')
517
+ } finally {
518
+ setSaving(false)
519
+ }
520
+ }
521
+
522
+ const deleteOffering = async (offeringId: string) => {
523
+ if (!confirm('Delete this offering?')) return
524
+ try {
525
+ const res = await fetch('/api/cpq/product-offerings', {
526
+ method: 'DELETE',
527
+ headers: { 'Content-Type': 'application/json' },
528
+ body: JSON.stringify({ id: offeringId }),
529
+ })
530
+ if (res.ok) setOfferings((prev) => prev.filter((o) => o.id !== offeringId))
531
+ } catch {
532
+ setError('Failed to delete offering')
533
+ }
534
+ }
535
+
536
+ const startEditOffering = (offering?: Offering) => {
537
+ const editing = offering ? { ...offering } : { ...EMPTY_OFFERING }
538
+ setEditingOffering(editing)
539
+ setEditingDtValues(editing.designTimeValues ? { ...editing.designTimeValues } : {})
540
+ }
541
+
542
+ // ─── Charge management (multi-offering) ─────────────────────
543
+
544
+ const loadAllOfferingCharges = async () => {
545
+ try {
546
+ const results = await Promise.all(
547
+ offerings.map((o) => fetch(`/api/cpq/product-offerings?id=${encodeURIComponent(o.id)}`).then((r) => r.ok ? r.json() : null))
548
+ )
549
+ const chargeMap: Record<string, Charge[]> = {}
550
+ results.forEach((data, idx) => {
551
+ if (data) chargeMap[offerings[idx].id] = data.charges ?? []
552
+ })
553
+ setOfferingCharges(chargeMap)
554
+ } catch {
555
+ console.error('Failed to load charges')
556
+ }
557
+ }
558
+
559
+ const loadPricingTables = async () => {
560
+ if (pricingTables.length > 0) return
561
+ const res = await fetch('/api/cpq/pricing-tables?pageSize=200')
562
+ if (res.ok) {
563
+ const data = await res.json()
564
+ setPricingTables(data.items ?? [])
565
+ }
566
+ }
567
+
568
+ const toggleOfferingSelection = (id: string) => {
569
+ setSelectedOfferingIds((prev) => {
570
+ const next = new Set(prev)
571
+ if (next.has(id)) next.delete(id); else next.add(id)
572
+ return next
573
+ })
574
+ }
575
+
576
+ const toggleAllOfferings = () => {
577
+ if (selectedOfferingIds.size === offerings.length) {
578
+ setSelectedOfferingIds(new Set())
579
+ } else {
580
+ setSelectedOfferingIds(new Set(offerings.map((o) => o.id)))
581
+ }
582
+ }
583
+
584
+ const openChargeForm = () => {
585
+ setChargeForm({ code: '', name: '', description: '', chargeType: 'mrc', pricingMethod: 'flat' })
586
+ setChargePrices({})
587
+ setChargePricingTableId(null)
588
+ setChargePriceColumnKey(null)
589
+ setChargeCurrency('USD')
590
+ setChargeFormOpen(true)
591
+ loadPricingTables()
592
+ }
593
+
594
+ const saveChargesForSelected = async () => {
595
+ const targets = offerings.filter((o) => selectedOfferingIds.has(o.id))
596
+ if (!targets.length || !chargeForm.code || !chargeForm.name) return
597
+ setSaving(true)
598
+ setError(null)
599
+ try {
600
+ for (const offering of targets) {
601
+ const chargeCode = targets.length > 1 ? `${offering.code}--${chargeForm.code}` : chargeForm.code
602
+ const payload: Record<string, unknown> = {
603
+ offeringId: offering.id,
604
+ code: chargeCode,
605
+ name: chargeForm.name,
606
+ description: chargeForm.description || null,
607
+ chargeType: chargeForm.chargeType,
608
+ pricingMethod: chargeForm.pricingMethod === 'flat' ? 'flat' : chargeForm.pricingMethod,
609
+ isActive: true,
610
+ sortOrder: 0,
611
+ }
612
+ if (chargeForm.pricingMethod === 'flat') {
613
+ payload.fixedPrice = chargePrices[offering.id] || null
614
+ payload.currencyCode = chargeCurrency
615
+ } else {
616
+ payload.pricingTableId = chargePricingTableId
617
+ payload.priceColumnKey = chargePriceColumnKey
618
+ }
619
+ const res = await fetch('/api/cpq/product-charges', {
620
+ method: 'POST',
621
+ headers: { 'Content-Type': 'application/json' },
622
+ body: JSON.stringify(payload),
623
+ })
624
+ if (!res.ok) {
625
+ const d = await res.json()
626
+ setError(d.error ?? `Failed to save charge for ${offering.name}`)
627
+ break
628
+ }
629
+ }
630
+ setChargeFormOpen(false)
631
+ await loadAllOfferingCharges()
632
+ } catch {
633
+ setError('Failed to save charges')
634
+ } finally {
635
+ setSaving(false)
636
+ }
637
+ }
638
+
639
+ const deleteCharge = async (offeringId: string, chargeId: string) => {
640
+ if (!confirm('Delete this charge?')) return
641
+ try {
642
+ const res = await fetch('/api/cpq/product-charges', {
643
+ method: 'DELETE',
644
+ headers: { 'Content-Type': 'application/json' },
645
+ body: JSON.stringify({ id: chargeId }),
646
+ })
647
+ if (res.ok) {
648
+ setOfferingCharges((prev) => ({
649
+ ...prev,
650
+ [offeringId]: (prev[offeringId] ?? []).filter((c) => c.id !== chargeId),
651
+ }))
652
+ }
653
+ } catch {
654
+ setError('Failed to delete charge')
655
+ }
656
+ }
657
+
658
+ const selectedPricingTableRef = chargePricingTableId
659
+ ? pricingTables.find((pt) => pt.id === chargePricingTableId)
660
+ : null
661
+
662
+ // ─── Relationship CRUD ──────────────────────────────────────
663
+
664
+ const saveRelationship = async () => {
665
+ if (!editingRel) return
666
+ setSaving(true)
667
+ setError(null)
668
+ try {
669
+ let parsedCondition = editingRel.condition
670
+ try { parsedCondition = conditionJson === 'null' ? null : JSON.parse(conditionJson) } catch { /* keep */ }
671
+
672
+ const isEdit = !!editingRel.id
673
+ const payload = {
674
+ ...editingRel,
675
+ condition: parsedCondition,
676
+ sourceSpecId: editingRel.sourceSpecId || specId,
677
+ }
678
+ const res = await fetch('/api/cpq/product-relationships', {
679
+ method: isEdit ? 'PUT' : 'POST',
680
+ headers: { 'Content-Type': 'application/json' },
681
+ body: JSON.stringify(payload),
682
+ })
683
+ if (!res.ok) {
684
+ const d = await res.json()
685
+ setError(d.error ?? 'Failed to save relationship')
686
+ return
687
+ }
688
+ const saved = await res.json()
689
+ if (isEdit) {
690
+ setRelationships((prev) => prev.map((r) => (r.id === saved.id ? saved : r)))
691
+ } else {
692
+ setRelationships((prev) => [...prev, saved])
693
+ }
694
+ setEditingRel(null)
695
+ } catch {
696
+ setError('Failed to save relationship')
697
+ } finally {
698
+ setSaving(false)
699
+ }
700
+ }
701
+
702
+ const deleteRelationship = async (relId: string) => {
703
+ if (!confirm('Delete this relationship?')) return
704
+ try {
705
+ const res = await fetch('/api/cpq/product-relationships', {
706
+ method: 'DELETE',
707
+ headers: { 'Content-Type': 'application/json' },
708
+ body: JSON.stringify({ id: relId }),
709
+ })
710
+ if (res.ok) setRelationships((prev) => prev.filter((r) => r.id !== relId))
711
+ } catch {
712
+ setError('Failed to delete relationship')
713
+ }
714
+ }
715
+
716
+ const startEditRel = (rel?: Relationship) => {
717
+ const editing = rel ? { ...rel } : { ...EMPTY_RELATIONSHIP, sourceSpecId: specId }
718
+ setEditingRel(editing)
719
+ setConditionJson(editing.condition ? JSON.stringify(editing.condition, null, 2) : 'null')
720
+ }
721
+
722
+ // ─── Helpers ────────────────────────────────────────────────
723
+
724
+ const getSpecName = (id: string) => allSpecs.find((s) => s.id === id)?.name ?? id.slice(0, 8)
725
+
726
+ // ─── Render ─────────────────────────────────────────────────
727
+
728
+ if (loading) {
729
+ return <div className="text-sm text-muted-foreground">{t('common.loading', 'Loading...')}</div>
730
+ }
731
+
732
+ const designTimeAttrs = attributes.filter((a) => a.resolutionTime === 'design_time')
733
+
734
+ return (
735
+ <div className="space-y-6">
736
+ {/* Header */}
737
+ <div className="flex items-center justify-between">
738
+ <div className="flex items-center gap-3">
739
+ <button
740
+ onClick={() => router.push('/backend/cpq/specifications')}
741
+ className="text-sm text-muted-foreground hover:text-foreground"
742
+ >
743
+ ← {t('common.back', 'Back')}
744
+ </button>
745
+ <h1 className="text-2xl font-bold">
746
+ {isNew ? t('cpq.specifications.new', 'New Specification') : form.name}
747
+ </h1>
748
+ {!isNew && (
749
+ <Tag variant={specificationLifecycleStatusMap[form.lifecycleStatus as SpecificationLifecycleStatus] ?? 'neutral'} dot>
750
+ {formatStatusLabel(form.lifecycleStatus)}
751
+ </Tag>
752
+ )}
753
+ </div>
754
+ {!isNew && (
755
+ <div className="flex items-center gap-2">
756
+ {/* Edit lives in the page header next to Delete (OM standard
757
+ detail-page layout). The inline Edit button inside the
758
+ General card is removed in favour of this one. */}
759
+ {generalMode === 'view' && tab === 'general' && (
760
+ <Button
761
+ type="button"
762
+ onClick={() => {
763
+ setFormSnapshot(form)
764
+ setGeneralMode('edit')
765
+ }}
766
+ >
767
+ {t('common.edit', 'Edit')}
768
+ </Button>
769
+ )}
770
+ <Button type="button" variant="destructive" onClick={deleteSpec}>
771
+ {t('common.delete', 'Delete')}
772
+ </Button>
773
+ </div>
774
+ )}
775
+ </div>
776
+
777
+ {error && (
778
+ <Alert variant="destructive">{error}</Alert>
779
+ )}
780
+
781
+ {/* Tabs */}
782
+ {!isNew && (
783
+ <div className="flex gap-1 border-b">
784
+ {([
785
+ 'general',
786
+ 'attributes',
787
+ 'offerings',
788
+ 'relationships',
789
+ ...(form.specType === 'bundle' ? ['slots' as const] : []),
790
+ ] as const).map((t_) => (
791
+ <button
792
+ key={t_}
793
+ onClick={() => { setTab(t_ as typeof tab); if (t_ === 'offerings' && offerings.length > 0 && Object.keys(offeringCharges).length === 0) loadAllOfferingCharges() }}
794
+ className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
795
+ tab === t_ ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
796
+ }`}
797
+ >
798
+ {t_ === 'general' && 'General'}
799
+ {t_ === 'attributes' && `Attributes (${attributes.length})`}
800
+ {t_ === 'offerings' && `Offerings (${offerings.length})`}
801
+ {t_ === 'relationships' && `Relationships (${relationships.length})`}
802
+ {t_ === 'slots' && `Bundle Slots (${bundleSlots.length})`}
803
+ </button>
804
+ ))}
805
+ </div>
806
+ )}
807
+
808
+ {/* ─── General Tab / New Form ─────────────────────────────── */}
809
+ {(isNew || tab === 'general') && (() => {
810
+ const isViewing = generalMode === 'view' && !isNew
811
+ // Spec type can never be changed once anything depends on it. We
812
+ // still surface the constraint in the read-only view so a user
813
+ // doesn't toggle into edit mode and find a disabled field.
814
+ const specTypeLocked = !isNew && (offerings.length > 0 || bundleSlots.length > 0)
815
+ return (
816
+ <div className="rounded-lg border bg-card p-6 space-y-4">
817
+ <div className="grid grid-cols-2 gap-4">
818
+ <div>
819
+ <label className="block text-sm font-medium mb-1">{t('cpq.specifications.code', 'Code')}</label>
820
+ {isViewing ? (
821
+ <p className="text-sm font-mono py-2">{form.code || '—'}</p>
822
+ ) : (
823
+ <input
824
+ type="text"
825
+ value={form.code}
826
+ onChange={(e) => setForm({ ...form, code: e.target.value })}
827
+ placeholder="e.g. gix-access-port"
828
+ className="w-full rounded-md border px-3 py-2 text-sm"
829
+ />
830
+ )}
831
+ </div>
832
+ <div>
833
+ <label className="block text-sm font-medium mb-1">{t('cpq.specifications.name', 'Name')}</label>
834
+ {isViewing ? (
835
+ <p className="text-sm py-2">{form.name || '—'}</p>
836
+ ) : (
837
+ <input
838
+ type="text"
839
+ value={form.name}
840
+ onChange={(e) => setForm({ ...form, name: e.target.value })}
841
+ className="w-full rounded-md border px-3 py-2 text-sm"
842
+ />
843
+ )}
844
+ </div>
845
+ <div>
846
+ <label className="block text-sm font-medium mb-1">{t('cpq.specifications.lifecycleStatus', 'Lifecycle Status')}</label>
847
+ {isViewing ? (
848
+ <div className="py-2">
849
+ <Tag variant={specificationLifecycleStatusMap[form.lifecycleStatus as SpecificationLifecycleStatus] ?? 'neutral'} dot>
850
+ {formatStatusLabel(form.lifecycleStatus)}
851
+ </Tag>
852
+ </div>
853
+ ) : (
854
+ <select
855
+ value={form.lifecycleStatus}
856
+ onChange={(e) => setForm({ ...form, lifecycleStatus: e.target.value })}
857
+ className="w-full rounded-md border px-3 py-2 text-sm"
858
+ >
859
+ <option value="draft">Draft</option>
860
+ <option value="active">Active</option>
861
+ <option value="deprecated">Deprecated</option>
862
+ </select>
863
+ )}
864
+ </div>
865
+ <div>
866
+ <label className="block text-sm font-medium mb-1">{t('cpq.specifications.isActive', 'Is Active?')}</label>
867
+ <div className="flex items-center h-[38px]">
868
+ {isViewing ? (
869
+ <Tag variant={form.isActive ? 'success' : 'neutral'} dot>
870
+ {form.isActive ? t('common.yes', 'Yes') : t('common.no', 'No')}
871
+ </Tag>
872
+ ) : (
873
+ <input
874
+ type="checkbox"
875
+ checked={form.isActive}
876
+ onChange={(e) => setForm({ ...form, isActive: e.target.checked })}
877
+ className="rounded border"
878
+ />
879
+ )}
880
+ </div>
881
+ </div>
882
+ <div>
883
+ <label className="block text-sm font-medium mb-1">Spec Type</label>
884
+ {isViewing ? (
885
+ <p className="text-sm py-2">{form.specType === 'bundle' ? 'Bundle' : 'Simple'}</p>
886
+ ) : (
887
+ <>
888
+ <select
889
+ value={form.specType}
890
+ onChange={(e) => setForm({ ...form, specType: e.target.value })}
891
+ disabled={specTypeLocked}
892
+ className="w-full rounded-md border px-3 py-2 text-sm disabled:opacity-50"
893
+ >
894
+ <option value="simple">Simple</option>
895
+ <option value="bundle">Bundle</option>
896
+ </select>
897
+ {specTypeLocked && (
898
+ <p className="text-xs text-muted-foreground mt-1">Cannot change after offerings or slots exist</p>
899
+ )}
900
+ </>
901
+ )}
902
+ </div>
903
+ <div>
904
+ <label className="block text-sm font-medium mb-1">Is Assetizable?</label>
905
+ <div className="flex items-center h-[38px]">
906
+ {isViewing ? (
907
+ <Tag variant={form.isAssetizable ? 'warning' : 'neutral'} dot>
908
+ {form.isAssetizable ? t('common.yes', 'Yes') : t('common.no', 'No')}
909
+ </Tag>
910
+ ) : (
911
+ <>
912
+ <input
913
+ type="checkbox"
914
+ checked={form.isAssetizable}
915
+ onChange={(e) => setForm({ ...form, isAssetizable: e.target.checked })}
916
+ className="rounded border"
917
+ />
918
+ <span className="ml-2 text-xs text-muted-foreground">Creates asset records during fulfilment</span>
919
+ </>
920
+ )}
921
+ </div>
922
+ </div>
923
+ </div>
924
+ <div>
925
+ <label className="block text-sm font-medium mb-1">{t('cpq.specifications.description', 'Description')}</label>
926
+ {isViewing ? (
927
+ <p className="text-sm text-muted-foreground py-2 whitespace-pre-wrap">{form.description || '—'}</p>
928
+ ) : (
929
+ <textarea
930
+ value={form.description ?? ''}
931
+ onChange={(e) => setForm({ ...form, description: e.target.value })}
932
+ rows={3}
933
+ className="w-full rounded-md border px-3 py-2 text-sm"
934
+ />
935
+ )}
936
+ </div>
937
+ {!isNew && (
938
+ <span className="text-xs text-muted-foreground">Version {form.version}</span>
939
+ )}
940
+ {/* In view mode the Edit button lives in the page header next to
941
+ Delete (OM standard). Here we only render the Save/Cancel
942
+ pair when actually editing. */}
943
+ {!isViewing && (
944
+ <div className="flex gap-3">
945
+ <Button
946
+ type="button"
947
+ onClick={saveSpec}
948
+ disabled={saving || !form.code || !form.name}
949
+ >
950
+ {saving ? t('common.saving', 'Saving...') : t('common.save', 'Save')}
951
+ </Button>
952
+ <Button
953
+ type="button"
954
+ variant="outline"
955
+ onClick={() => {
956
+ if (isNew) {
957
+ router.push('/backend/cpq/specifications')
958
+ return
959
+ }
960
+ // Revert any in-flight edits and go back to read-only.
961
+ if (formSnapshot) setForm(formSnapshot)
962
+ setFormSnapshot(null)
963
+ setGeneralMode('view')
964
+ }}
965
+ >
966
+ {t('common.cancel', 'Cancel')}
967
+ </Button>
968
+ </div>
969
+ )}
970
+ </div>
971
+ )
972
+ })()}
973
+
974
+ {/* ─── Attributes Tab ─────────────────────────────────────── */}
975
+ {tab === 'attributes' && !isNew && (
976
+ <div className="space-y-4">
977
+ <div className="flex justify-end">
978
+ <button
979
+ onClick={() => startEditAttr()}
980
+ className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
981
+ >
982
+ {t('cpq.specifications.addAttribute', 'Add Attribute')}
983
+ </button>
984
+ </div>
985
+
986
+ {editingAttr && (
987
+ <div className="rounded-lg border bg-card p-4 space-y-3">
988
+ <h3 className="font-medium text-sm">
989
+ {editingAttr.id ? 'Edit Attribute' : 'New Attribute'}
990
+ </h3>
991
+ <div className="grid grid-cols-3 gap-3">
992
+ <div>
993
+ <label className="block text-xs font-medium mb-1">Code</label>
994
+ <input type="text" value={editingAttr.code} onChange={(e) => setEditingAttr({ ...editingAttr, code: e.target.value })} className="w-full rounded-md border px-2 py-1.5 text-sm" />
995
+ </div>
996
+ <div>
997
+ <label className="block text-xs font-medium mb-1">Name</label>
998
+ <input type="text" value={editingAttr.name} onChange={(e) => setEditingAttr({ ...editingAttr, name: e.target.value })} className="w-full rounded-md border px-2 py-1.5 text-sm" />
999
+ </div>
1000
+ <div>
1001
+ <label className="block text-xs font-medium mb-1">Type</label>
1002
+ <select value={editingAttr.attributeType} onChange={(e) => setEditingAttr({ ...editingAttr, attributeType: e.target.value })} className="w-full rounded-md border px-2 py-1.5 text-sm">
1003
+ <option value="text">Text</option>
1004
+ <option value="number">Number</option>
1005
+ <option value="boolean">Boolean</option>
1006
+ <option value="select">Select</option>
1007
+ <option value="multi-select">Multi-Select</option>
1008
+ <option value="reference">Reference</option>
1009
+ </select>
1010
+ </div>
1011
+ <div>
1012
+ <label className="block text-xs font-medium mb-1">Resolution Time</label>
1013
+ <select value={editingAttr.resolutionTime} onChange={(e) => setEditingAttr({ ...editingAttr, resolutionTime: e.target.value })} className="w-full rounded-md border px-2 py-1.5 text-sm">
1014
+ <option value="run_time">Run-time (quoting)</option>
1015
+ <option value="design_time">Design-time (offering)</option>
1016
+ </select>
1017
+ </div>
1018
+ <div>
1019
+ <label className="block text-xs font-medium mb-1">Sort Order</label>
1020
+ <NumberInput
1021
+ integer
1022
+ value={editingAttr.sortOrder}
1023
+ onChange={(n) => setEditingAttr({ ...editingAttr, sortOrder: n ?? 0 })}
1024
+ />
1025
+ </div>
1026
+ <div>
1027
+ <label className="block text-xs font-medium mb-1">Help Text</label>
1028
+ <input type="text" value={editingAttr.helpText ?? ''} onChange={(e) => setEditingAttr({ ...editingAttr, helpText: e.target.value || null })} className="w-full rounded-md border px-2 py-1.5 text-sm" />
1029
+ </div>
1030
+ {editingAttr.attributeType === 'reference' && (
1031
+ <>
1032
+ <div>
1033
+ <label className="block text-xs font-medium mb-1">Reference Entity</label>
1034
+ <input type="text" value={editingAttr.referenceEntity ?? ''} onChange={(e) => setEditingAttr({ ...editingAttr, referenceEntity: e.target.value || null })} placeholder="e.g. CatalogProduct" className="w-full rounded-md border px-2 py-1.5 text-sm" />
1035
+ </div>
1036
+ </>
1037
+ )}
1038
+ {(editingAttr.attributeType === 'select' || editingAttr.attributeType === 'multi-select') && (
1039
+ <div className="col-span-3 space-y-2">
1040
+ <div className="flex items-center justify-between">
1041
+ <label className="block text-xs font-medium">Options</label>
1042
+ <button
1043
+ type="button"
1044
+ onClick={() => setEditingOptions([...editingOptions, { value: '', label: '' }])}
1045
+ className="text-xs text-primary hover:underline"
1046
+ >
1047
+ + Add option
1048
+ </button>
1049
+ </div>
1050
+ {editingOptions.length === 0 ? (
1051
+ <div className="text-xs text-muted-foreground py-2">No options defined. Click &quot;Add option&quot; to start.</div>
1052
+ ) : (
1053
+ <div className="space-y-1.5">
1054
+ {editingOptions.map((opt, idx) => (
1055
+ <div key={idx} className="flex items-center gap-2">
1056
+ <input
1057
+ type="text"
1058
+ value={opt.value}
1059
+ onChange={(e) => {
1060
+ const next = [...editingOptions]
1061
+ next[idx] = { ...next[idx], value: e.target.value }
1062
+ setEditingOptions(next)
1063
+ }}
1064
+ placeholder="Value (e.g. aws)"
1065
+ className="flex-1 rounded-md border px-2 py-1.5 text-sm"
1066
+ />
1067
+ <input
1068
+ type="text"
1069
+ value={opt.label}
1070
+ onChange={(e) => {
1071
+ const next = [...editingOptions]
1072
+ next[idx] = { ...next[idx], label: e.target.value }
1073
+ setEditingOptions(next)
1074
+ }}
1075
+ placeholder="Label (e.g. Amazon Web Services)"
1076
+ className="flex-1 rounded-md border px-2 py-1.5 text-sm"
1077
+ />
1078
+ <button
1079
+ type="button"
1080
+ onClick={() => setEditingOptions(editingOptions.filter((_, i) => i !== idx))}
1081
+ className="text-destructive hover:text-destructive/80 text-sm px-1"
1082
+ title="Remove"
1083
+ >
1084
+ ×
1085
+ </button>
1086
+ </div>
1087
+ ))}
1088
+ </div>
1089
+ )}
1090
+ </div>
1091
+ )}
1092
+ <div className="col-span-3 space-y-2">
1093
+ <label className="flex items-center gap-1.5 text-xs font-medium">
1094
+ <input
1095
+ type="checkbox"
1096
+ checked={dependsOnEnabled}
1097
+ onChange={(e) => setDependsOnEnabled(e.target.checked)}
1098
+ className="rounded border"
1099
+ />
1100
+ Depends on another attribute
1101
+ </label>
1102
+ {dependsOnEnabled && (() => {
1103
+ const parentAttr = attributes.find((a) => a.code === dependsOnAttrCode)
1104
+ return (
1105
+ <div className="space-y-3">
1106
+ <div className="grid grid-cols-2 gap-3">
1107
+ <div>
1108
+ <label className="block text-xs font-medium mb-1">Parent Attribute Code</label>
1109
+ <select
1110
+ value={dependsOnAttrCode}
1111
+ onChange={(e) => {
1112
+ setDependsOnAttrCode(e.target.value)
1113
+ // Parent changed — `when` values that referenced the old
1114
+ // parent's options no longer make sense, but clearing them
1115
+ // would silently destroy user work. Leave them as text; the
1116
+ // RuleRow will fall back to free-text input when the new
1117
+ // parent doesn't have enumerated options.
1118
+ }}
1119
+ className="w-full rounded-md border px-2 py-1.5 text-sm"
1120
+ >
1121
+ <option value="">Select attribute...</option>
1122
+ {attributes.filter((a) => editingAttr && a.code !== editingAttr.code).map((a) => (
1123
+ <option key={a.code} value={a.code}>{a.name} ({a.code})</option>
1124
+ ))}
1125
+ </select>
1126
+ </div>
1127
+ <div>
1128
+ <label className="block text-xs font-medium mb-1">Effect</label>
1129
+ <select
1130
+ value={dependsOnEffect}
1131
+ onChange={(e) => {
1132
+ const next = e.target.value as DependsOnEffect
1133
+ setDependsOnEffect(next)
1134
+ // `then` shape differs per effect — drop existing rules so
1135
+ // we never persist a mis-typed payload.
1136
+ setDependsOnRules([])
1137
+ }}
1138
+ className="w-full rounded-md border px-2 py-1.5 text-sm"
1139
+ >
1140
+ <option value="filter_options">Filter options</option>
1141
+ <option value="set_value">Set value</option>
1142
+ <option value="toggle_required">Toggle required</option>
1143
+ </select>
1144
+ </div>
1145
+ </div>
1146
+ <div>
1147
+ <label className="block text-xs font-medium mb-2">Rules</label>
1148
+ <DependsOnRulesEditor
1149
+ effect={dependsOnEffect}
1150
+ rules={dependsOnRules}
1151
+ onChange={setDependsOnRules}
1152
+ parentOptions={parentAttr?.options ?? null}
1153
+ childOptions={editingOptions.length > 0 ? editingOptions : null}
1154
+ />
1155
+ </div>
1156
+ </div>
1157
+ )
1158
+ })()}
1159
+ </div>
1160
+ <div className="flex items-end gap-4">
1161
+ <label className="flex items-center gap-1.5 text-sm">
1162
+ <input type="checkbox" checked={editingAttr.isRequired} onChange={(e) => setEditingAttr({ ...editingAttr, isRequired: e.target.checked })} className="rounded border" />
1163
+ Required
1164
+ </label>
1165
+ <label className="flex items-center gap-1.5 text-sm">
1166
+ <input type="checkbox" checked={editingAttr.isActive} onChange={(e) => setEditingAttr({ ...editingAttr, isActive: e.target.checked })} className="rounded border" />
1167
+ Active
1168
+ </label>
1169
+ </div>
1170
+ </div>
1171
+ <div className="flex gap-2">
1172
+ <button onClick={saveAttribute} disabled={saving} className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">
1173
+ {saving ? 'Saving...' : 'Save'}
1174
+ </button>
1175
+ <button onClick={() => setEditingAttr(null)} className="inline-flex items-center rounded-md border px-3 py-1.5 text-sm font-medium hover:bg-muted">Cancel</button>
1176
+ </div>
1177
+ </div>
1178
+ )}
1179
+
1180
+ {attributes.length === 0 ? (
1181
+ <div className="rounded-lg border bg-card p-6 text-center text-sm text-muted-foreground">
1182
+ No attributes defined. Add design-time and run-time attributes for this specification.
1183
+ </div>
1184
+ ) : (
1185
+ <div className="rounded-lg border">
1186
+ <table className="w-full text-sm">
1187
+ <thead>
1188
+ <tr className="border-b bg-muted/50">
1189
+ <th className="px-4 py-3 text-left font-medium">Code</th>
1190
+ <th className="px-4 py-3 text-left font-medium">Name</th>
1191
+ <th className="px-4 py-3 text-left font-medium">Type</th>
1192
+ <th className="px-4 py-3 text-left font-medium">Resolution</th>
1193
+ <th className="px-4 py-3 text-left font-medium">Options</th>
1194
+ <th className="px-4 py-3 text-left font-medium">Required</th>
1195
+ <th className="px-4 py-3 text-right font-medium">Actions</th>
1196
+ </tr>
1197
+ </thead>
1198
+ <tbody>
1199
+ {attributes.map((attr) => (
1200
+ <tr key={attr.id} className="border-b">
1201
+ <td className="px-4 py-3 font-mono text-xs">{attr.code}</td>
1202
+ <td className="px-4 py-3">{attr.name}</td>
1203
+ <td className="px-4 py-3">{attr.attributeType}</td>
1204
+ <td className="px-4 py-3">
1205
+ <Tag variant={attr.resolutionTime === 'design_time' ? 'info' : 'neutral'} className="px-2 text-xs">
1206
+ {attr.resolutionTime === 'design_time' ? 'design' : 'run'}
1207
+ </Tag>
1208
+ </td>
1209
+ <td className="px-4 py-3 text-muted-foreground">{attr.options ? `${attr.options.length} options` : '—'}</td>
1210
+ <td className="px-4 py-3">{attr.isRequired ? 'Yes' : 'No'}</td>
1211
+ <td className="px-4 py-3 text-right">
1212
+ <button onClick={() => startEditAttr(attr)} className="text-xs text-primary hover:underline mr-2">Edit</button>
1213
+ <button onClick={() => deleteAttribute(attr.id)} className="text-xs text-destructive hover:underline">Delete</button>
1214
+ </td>
1215
+ </tr>
1216
+ ))}
1217
+ </tbody>
1218
+ </table>
1219
+ </div>
1220
+ )}
1221
+ </div>
1222
+ )}
1223
+
1224
+ {/* ─── Offerings Tab ──────────────────────────────────────── */}
1225
+ {tab === 'offerings' && !isNew && (
1226
+ <div className="space-y-4">
1227
+ <div className="flex justify-end">
1228
+ <button
1229
+ onClick={() => startEditOffering()}
1230
+ className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
1231
+ >
1232
+ {t('cpq.specifications.addOffering', 'Add Offering')}
1233
+ </button>
1234
+ </div>
1235
+
1236
+ {editingOffering && (
1237
+ <div className="rounded-lg border bg-card p-4 space-y-3">
1238
+ <h3 className="font-medium text-sm">
1239
+ {editingOffering.id ? 'Edit Offering' : 'New Offering'}
1240
+ </h3>
1241
+ <div className="grid grid-cols-4 gap-3">
1242
+ <div>
1243
+ <label className="block text-xs font-medium mb-1">Code</label>
1244
+ <input type="text" value={editingOffering.code} onChange={(e) => setEditingOffering({ ...editingOffering, code: e.target.value })} placeholder="e.g. gix-cloud-connect-aws" className="w-full rounded-md border px-2 py-1.5 text-sm" />
1245
+ </div>
1246
+ <div>
1247
+ <label className="block text-xs font-medium mb-1">Name</label>
1248
+ <input type="text" value={editingOffering.name} onChange={(e) => setEditingOffering({ ...editingOffering, name: e.target.value })} className="w-full rounded-md border px-2 py-1.5 text-sm" />
1249
+ </div>
1250
+ <div>
1251
+ <label className="block text-xs font-medium mb-1">Lifecycle Status</label>
1252
+ <select value={editingOffering.lifecycleStatus} onChange={(e) => setEditingOffering({ ...editingOffering, lifecycleStatus: e.target.value })} className="w-full rounded-md border px-2 py-1.5 text-sm">
1253
+ <option value="draft">Draft</option>
1254
+ <option value="active">Active</option>
1255
+ <option value="deprecated">Deprecated</option>
1256
+ </select>
1257
+ </div>
1258
+ <div>
1259
+ <label className="block text-xs font-medium mb-1">Is Active?</label>
1260
+ <div className="flex items-center h-[30px]">
1261
+ <input type="checkbox" checked={editingOffering.isActive} onChange={(e) => setEditingOffering({ ...editingOffering, isActive: e.target.checked })} className="rounded border" />
1262
+ </div>
1263
+ </div>
1264
+ </div>
1265
+ <div>
1266
+ <label className="block text-xs font-medium mb-1">Description</label>
1267
+ <input type="text" value={editingOffering.description ?? ''} onChange={(e) => setEditingOffering({ ...editingOffering, description: e.target.value || null })} className="w-full rounded-md border px-2 py-1.5 text-sm" />
1268
+ </div>
1269
+ {designTimeAttrs.length > 0 ? (
1270
+ <div className="space-y-3">
1271
+ <label className="block text-xs font-medium">Design-Time Values</label>
1272
+ <div className="grid grid-cols-2 gap-3">
1273
+ {designTimeAttrs.map((attr) => (
1274
+ <div key={attr.code}>
1275
+ <label className="block text-xs font-medium mb-1">
1276
+ {attr.name}
1277
+ {attr.isRequired && <span className="text-destructive ml-0.5">*</span>}
1278
+ </label>
1279
+ {(attr.attributeType === 'select' || attr.attributeType === 'enum') && attr.options ? (
1280
+ <select
1281
+ value={String(editingDtValues[attr.code] ?? '')}
1282
+ onChange={(e) => setEditingDtValues({ ...editingDtValues, [attr.code]: e.target.value || null })}
1283
+ className="w-full rounded-md border px-2 py-1.5 text-sm"
1284
+ >
1285
+ <option value="">Select {attr.name}...</option>
1286
+ {attr.options.map((opt) => (
1287
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
1288
+ ))}
1289
+ </select>
1290
+ ) : attr.attributeType === 'number' ? (
1291
+ <NumberInput
1292
+ value={typeof editingDtValues[attr.code] === 'number' ? (editingDtValues[attr.code] as number) : null}
1293
+ onChange={(n) => setEditingDtValues({ ...editingDtValues, [attr.code]: n })}
1294
+ />
1295
+ ) : attr.attributeType === 'boolean' ? (
1296
+ <div className="pt-1">
1297
+ <label className="flex items-center gap-1.5 text-sm">
1298
+ <input
1299
+ type="checkbox"
1300
+ checked={!!editingDtValues[attr.code]}
1301
+ onChange={(e) => setEditingDtValues({ ...editingDtValues, [attr.code]: e.target.checked })}
1302
+ className="rounded border"
1303
+ />
1304
+ {attr.name}
1305
+ </label>
1306
+ </div>
1307
+ ) : (
1308
+ <input
1309
+ type="text"
1310
+ value={String(editingDtValues[attr.code] ?? '')}
1311
+ onChange={(e) => setEditingDtValues({ ...editingDtValues, [attr.code]: e.target.value || null })}
1312
+ className="w-full rounded-md border px-2 py-1.5 text-sm"
1313
+ placeholder={attr.helpText ?? undefined}
1314
+ />
1315
+ )}
1316
+ {attr.helpText && <p className="text-xs text-muted-foreground mt-0.5">{attr.helpText}</p>}
1317
+ </div>
1318
+ ))}
1319
+ </div>
1320
+ </div>
1321
+ ) : (
1322
+ <div className="text-xs text-muted-foreground py-1">
1323
+ No design-time attributes defined on this specification. All attributes are run-time.
1324
+ </div>
1325
+ )}
1326
+ <div className="flex gap-2">
1327
+ <button onClick={saveOffering} disabled={saving} className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">
1328
+ {saving ? 'Saving...' : 'Save'}
1329
+ </button>
1330
+ <button onClick={() => setEditingOffering(null)} className="inline-flex items-center rounded-md border px-3 py-1.5 text-sm font-medium hover:bg-muted">Cancel</button>
1331
+ </div>
1332
+ </div>
1333
+ )}
1334
+
1335
+ {offerings.length === 0 ? (
1336
+ <div className="rounded-lg border bg-card p-6 text-center text-sm text-muted-foreground">
1337
+ No offerings created. An offering is a sellable instance of this specification with fixed design-time values and attached charges.
1338
+ </div>
1339
+ ) : (
1340
+ <div className="rounded-lg border">
1341
+ <table className="w-full text-sm">
1342
+ <thead>
1343
+ <tr className="border-b bg-muted/50">
1344
+ <th className="px-3 py-3 text-left font-medium">Code</th>
1345
+ <th className="px-3 py-3 text-left font-medium">Name</th>
1346
+ <th className="px-3 py-3 text-left font-medium">Status</th>
1347
+ <th className="px-3 py-3 text-left font-medium">Design-Time Values</th>
1348
+ <th className="px-3 py-3 text-right font-medium">Actions</th>
1349
+ </tr>
1350
+ </thead>
1351
+ <tbody>
1352
+ {offerings.map((o) => (
1353
+ <tr
1354
+ key={o.id}
1355
+ onClick={() => router.push(`/backend/cpq/offerings/${o.id}`)}
1356
+ className="border-b cursor-pointer hover:bg-muted/30 transition-colors"
1357
+ >
1358
+ <td className="px-3 py-3 font-mono text-xs">{o.code}</td>
1359
+ <td className="px-3 py-3 font-medium">{o.name}</td>
1360
+ <td className="px-3 py-3">
1361
+ <Tag variant={lifecycleStatusMap[o.lifecycleStatus as LifecycleStatus] ?? 'neutral'} dot>
1362
+ {formatStatusLabel(o.lifecycleStatus)}
1363
+ </Tag>
1364
+ </td>
1365
+ <td className="px-3 py-3 text-muted-foreground text-xs">
1366
+ {Object.keys(o.designTimeValues).length > 0
1367
+ ? Object.entries(o.designTimeValues).map(([k, v]) => `${k}=${v}`).join(', ')
1368
+ : '—'}
1369
+ </td>
1370
+ <td className="px-3 py-3 text-right">
1371
+ <button onClick={(e) => { e.stopPropagation(); startEditOffering(o) }} className="text-xs text-primary hover:underline mr-2">Edit</button>
1372
+ <button onClick={(e) => { e.stopPropagation(); deleteOffering(o.id) }} className="text-xs text-destructive hover:underline">Delete</button>
1373
+ </td>
1374
+ </tr>
1375
+ ))}
1376
+ </tbody>
1377
+ </table>
1378
+ </div>
1379
+ )}
1380
+ </div>
1381
+ )}
1382
+
1383
+ {/* ─── Relationships Tab ──────────────────────────────────── */}
1384
+ {tab === 'relationships' && !isNew && (
1385
+ <div className="space-y-4">
1386
+ <div className="flex justify-end">
1387
+ <button
1388
+ onClick={() => startEditRel()}
1389
+ className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
1390
+ >
1391
+ {t('cpq.specifications.addRelationship', 'Add Relationship')}
1392
+ </button>
1393
+ </div>
1394
+
1395
+ {editingRel && (
1396
+ <div className="rounded-lg border bg-card p-4 space-y-3">
1397
+ <h3 className="font-medium text-sm">
1398
+ {editingRel.id ? 'Edit Relationship' : 'New Relationship'}
1399
+ </h3>
1400
+ <div className="grid grid-cols-3 gap-3">
1401
+ <div>
1402
+ <label className="block text-xs font-medium mb-1">Type</label>
1403
+ <select value={editingRel.relationshipType} onChange={(e) => setEditingRel({ ...editingRel, relationshipType: e.target.value })} className="w-full rounded-md border px-2 py-1.5 text-sm">
1404
+ <option value="parent_child">Parent / Child</option>
1405
+ <option value="requires">Requires</option>
1406
+ <option value="excludes">Excludes</option>
1407
+ </select>
1408
+ </div>
1409
+ <div>
1410
+ <label className="block text-xs font-medium mb-1">Target Specification</label>
1411
+ <select value={editingRel.targetSpecId} onChange={(e) => setEditingRel({ ...editingRel, targetSpecId: e.target.value })} className="w-full rounded-md border px-2 py-1.5 text-sm">
1412
+ <option value="">Select...</option>
1413
+ {allSpecs.filter((s) => s.id !== specId).map((s) => (
1414
+ <option key={s.id} value={s.id}>{s.name} ({s.code})</option>
1415
+ ))}
1416
+ </select>
1417
+ </div>
1418
+ <div className="grid grid-cols-2 gap-2">
1419
+ <div>
1420
+ <label className="block text-xs font-medium mb-1">Min</label>
1421
+ <NumberInput
1422
+ integer
1423
+ min={0}
1424
+ value={editingRel.cardinalityMin}
1425
+ onChange={(n) => setEditingRel({ ...editingRel, cardinalityMin: n ?? 0 })}
1426
+ />
1427
+ </div>
1428
+ <div>
1429
+ <label className="block text-xs font-medium mb-1">Max</label>
1430
+ <NumberInput
1431
+ integer
1432
+ min={0}
1433
+ value={editingRel.cardinalityMax}
1434
+ onChange={(n) => setEditingRel({ ...editingRel, cardinalityMax: n })}
1435
+ placeholder="∞"
1436
+ />
1437
+ </div>
1438
+ </div>
1439
+ </div>
1440
+ <div>
1441
+ <label className="block text-xs font-medium mb-1">Condition (JSON)</label>
1442
+ <textarea value={conditionJson} onChange={(e) => setConditionJson(e.target.value)} rows={3} className="w-full rounded-md border px-2 py-1.5 text-sm font-mono" placeholder='null or {"attribute":"direction","operator":"in","value":["inbound"]}' />
1443
+ </div>
1444
+ <div className="flex gap-2">
1445
+ <button onClick={saveRelationship} disabled={saving} className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">
1446
+ {saving ? 'Saving...' : 'Save'}
1447
+ </button>
1448
+ <button onClick={() => setEditingRel(null)} className="inline-flex items-center rounded-md border px-3 py-1.5 text-sm font-medium hover:bg-muted">Cancel</button>
1449
+ </div>
1450
+ </div>
1451
+ )}
1452
+
1453
+ {relationships.length === 0 ? (
1454
+ <div className="rounded-lg border bg-card p-6 text-center text-sm text-muted-foreground">
1455
+ No relationships defined. Declare parent/child, requires, or excludes relationships with other specifications.
1456
+ </div>
1457
+ ) : (
1458
+ <div className="rounded-lg border">
1459
+ <table className="w-full text-sm">
1460
+ <thead>
1461
+ <tr className="border-b bg-muted/50">
1462
+ <th className="px-4 py-3 text-left font-medium">Type</th>
1463
+ <th className="px-4 py-3 text-left font-medium">Source</th>
1464
+ <th className="px-4 py-3 text-left font-medium">Target</th>
1465
+ <th className="px-4 py-3 text-left font-medium">Cardinality</th>
1466
+ <th className="px-4 py-3 text-left font-medium">Condition</th>
1467
+ <th className="px-4 py-3 text-right font-medium">Actions</th>
1468
+ </tr>
1469
+ </thead>
1470
+ <tbody>
1471
+ {relationships.map((rel) => (
1472
+ <tr key={rel.id} className="border-b">
1473
+ <td className="px-4 py-3">
1474
+ <Tag
1475
+ variant={
1476
+ rel.relationshipType === 'parent_child' ? 'brand' :
1477
+ rel.relationshipType === 'requires' ? 'info' :
1478
+ 'error'
1479
+ }
1480
+ >
1481
+ {rel.relationshipType}
1482
+ </Tag>
1483
+ </td>
1484
+ <td className="px-4 py-3 text-sm">{getSpecName(rel.sourceSpecId)}</td>
1485
+ <td className="px-4 py-3 text-sm">{getSpecName(rel.targetSpecId)}</td>
1486
+ <td className="px-4 py-3 text-muted-foreground">{rel.cardinalityMin}..{rel.cardinalityMax ?? '∞'}</td>
1487
+ <td className="px-4 py-3 text-muted-foreground text-xs">{rel.condition ? JSON.stringify(rel.condition) : '—'}</td>
1488
+ <td className="px-4 py-3 text-right">
1489
+ <button onClick={() => startEditRel(rel)} className="text-xs text-primary hover:underline mr-2">Edit</button>
1490
+ <button onClick={() => deleteRelationship(rel.id)} className="text-xs text-destructive hover:underline">Delete</button>
1491
+ </td>
1492
+ </tr>
1493
+ ))}
1494
+ </tbody>
1495
+ </table>
1496
+ </div>
1497
+ )}
1498
+ </div>
1499
+ )}
1500
+
1501
+ {/* ─── Bundle Slots Tab ──────────────────────────────────── */}
1502
+ {tab === 'slots' && !isNew && form.specType === 'bundle' && (
1503
+ <div className="space-y-4">
1504
+ <div className="flex justify-end">
1505
+ <button
1506
+ onClick={() => setEditingSlot({ ...EMPTY_SLOT })}
1507
+ className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
1508
+ >
1509
+ + Add Slot
1510
+ </button>
1511
+ </div>
1512
+
1513
+ {editingSlot && (
1514
+ <div className="rounded-lg border bg-card p-6 space-y-4">
1515
+ <h3 className="text-sm font-semibold">{editingSlot.id ? 'Edit Bundle Slot' : 'New Bundle Slot'}</h3>
1516
+ <div className="grid grid-cols-2 gap-4">
1517
+ <div>
1518
+ <label className="block text-sm font-medium mb-1">Name</label>
1519
+ <input
1520
+ type="text"
1521
+ value={editingSlot.name}
1522
+ onChange={(e) => setEditingSlot({ ...editingSlot, name: e.target.value })}
1523
+ placeholder="e.g. Internet Component"
1524
+ className="w-full rounded-md border px-3 py-2 text-sm"
1525
+ />
1526
+ </div>
1527
+ <div>
1528
+ <label className="block text-sm font-medium mb-1">Component Group</label>
1529
+ <input
1530
+ type="text"
1531
+ value={editingSlot.componentGroup}
1532
+ onChange={(e) => setEditingSlot({ ...editingSlot, componentGroup: e.target.value })}
1533
+ placeholder="e.g. internet, tv, phone"
1534
+ className="w-full rounded-md border px-3 py-2 text-sm"
1535
+ />
1536
+ </div>
1537
+ <div>
1538
+ <label className="block text-sm font-medium mb-1">Target Specification</label>
1539
+ <select
1540
+ value={editingSlot.targetSpecId}
1541
+ onChange={(e) => setEditingSlot({ ...editingSlot, targetSpecId: e.target.value })}
1542
+ className="w-full rounded-md border px-3 py-2 text-sm"
1543
+ >
1544
+ <option value="">— Select specification —</option>
1545
+ {allSpecs.filter((s) => s.id !== specId).map((s) => (
1546
+ <option key={s.id} value={s.id}>{s.name} ({s.code})</option>
1547
+ ))}
1548
+ </select>
1549
+ </div>
1550
+ <div>
1551
+ <label className="block text-sm font-medium mb-1">Sort Order</label>
1552
+ <NumberInput
1553
+ integer
1554
+ value={editingSlot.sortOrder}
1555
+ onChange={(n) => setEditingSlot({ ...editingSlot, sortOrder: n ?? 0 })}
1556
+ />
1557
+ </div>
1558
+ <div>
1559
+ <label className="block text-sm font-medium mb-1">Cardinality Min</label>
1560
+ <NumberInput
1561
+ integer
1562
+ min={0}
1563
+ value={editingSlot.cardinalityMin}
1564
+ onChange={(n) => setEditingSlot({ ...editingSlot, cardinalityMin: n ?? 0 })}
1565
+ />
1566
+ </div>
1567
+ <div>
1568
+ <label className="block text-sm font-medium mb-1">Cardinality Max</label>
1569
+ <NumberInput
1570
+ integer
1571
+ min={0}
1572
+ placeholder="∞ (unlimited)"
1573
+ value={editingSlot.cardinalityMax}
1574
+ onChange={(n) => setEditingSlot({ ...editingSlot, cardinalityMax: n })}
1575
+ />
1576
+ </div>
1577
+ </div>
1578
+ <div>
1579
+ <label className="block text-sm font-medium mb-1">Description</label>
1580
+ <textarea
1581
+ value={editingSlot.description ?? ''}
1582
+ onChange={(e) => setEditingSlot({ ...editingSlot, description: e.target.value })}
1583
+ rows={2}
1584
+ className="w-full rounded-md border px-3 py-2 text-sm"
1585
+ />
1586
+ </div>
1587
+ <div className="flex gap-3">
1588
+ <button
1589
+ onClick={async () => {
1590
+ setSaving(true)
1591
+ setError(null)
1592
+ try {
1593
+ const isEdit = !!editingSlot.id
1594
+ const payload = isEdit
1595
+ ? { id: editingSlot.id, ...editingSlot, description: editingSlot.description || null }
1596
+ : { specId, ...editingSlot, description: editingSlot.description || null }
1597
+ const res = await fetch('/api/cpq/bundle-slots', {
1598
+ method: isEdit ? 'PUT' : 'POST',
1599
+ headers: { 'Content-Type': 'application/json' },
1600
+ body: JSON.stringify(payload),
1601
+ })
1602
+ if (!res.ok) {
1603
+ const d = await res.json()
1604
+ setError(d.error ?? 'Failed to save slot')
1605
+ return
1606
+ }
1607
+ setEditingSlot(null)
1608
+ const slotsRes = await fetch(`/api/cpq/bundle-slots?specId=${specId}`)
1609
+ if (slotsRes.ok) {
1610
+ const d = await slotsRes.json()
1611
+ setBundleSlots(d.items ?? [])
1612
+ }
1613
+ } catch {
1614
+ setError('Failed to save slot')
1615
+ } finally {
1616
+ setSaving(false)
1617
+ }
1618
+ }}
1619
+ disabled={saving || !editingSlot.name || !editingSlot.componentGroup || !editingSlot.targetSpecId}
1620
+ className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
1621
+ >
1622
+ {saving ? 'Saving...' : (editingSlot.id ? 'Update' : 'Create')}
1623
+ </button>
1624
+ <button
1625
+ onClick={() => setEditingSlot(null)}
1626
+ className="inline-flex items-center justify-center rounded-md border px-4 py-2 text-sm font-medium hover:bg-muted"
1627
+ >
1628
+ Cancel
1629
+ </button>
1630
+ </div>
1631
+ </div>
1632
+ )}
1633
+
1634
+ {bundleSlots.length === 0 && !editingSlot ? (
1635
+ <div className="rounded-lg border bg-card p-6 text-center text-sm text-muted-foreground">
1636
+ No bundle slots defined yet. Add slots to define what component specifications can fill this bundle.
1637
+ </div>
1638
+ ) : bundleSlots.length > 0 && (
1639
+ <div className="rounded-lg border overflow-hidden">
1640
+ <table className="w-full text-sm">
1641
+ <thead>
1642
+ <tr className="border-b bg-muted/50">
1643
+ <th className="px-4 py-3 text-left font-medium">Name</th>
1644
+ <th className="px-4 py-3 text-left font-medium">Group</th>
1645
+ <th className="px-4 py-3 text-left font-medium">Target Spec</th>
1646
+ <th className="px-4 py-3 text-left font-medium">Cardinality</th>
1647
+ <th className="px-4 py-3 text-left font-medium">Order</th>
1648
+ <th className="px-4 py-3 text-right font-medium">Actions</th>
1649
+ </tr>
1650
+ </thead>
1651
+ <tbody>
1652
+ {bundleSlots.map((slot) => (
1653
+ <tr key={slot.id} className="border-b">
1654
+ <td className="px-4 py-3 font-medium">{slot.name}</td>
1655
+ <td className="px-4 py-3">
1656
+ <Tag variant="info">{slot.componentGroup}</Tag>
1657
+ </td>
1658
+ <td className="px-4 py-3 text-sm">
1659
+ {slot.targetSpec ? (
1660
+ <span>
1661
+ {slot.targetSpec.name}
1662
+ {slot.targetSpec.specType === 'bundle' && (
1663
+ <Tag variant="brand" className="ml-1 px-1.5 text-[10px]">bundle</Tag>
1664
+ )}
1665
+ </span>
1666
+ ) : (
1667
+ <span className="text-muted-foreground">Unknown</span>
1668
+ )}
1669
+ </td>
1670
+ <td className="px-4 py-3 text-muted-foreground">{slot.cardinalityMin}..{slot.cardinalityMax ?? '∞'}</td>
1671
+ <td className="px-4 py-3 text-muted-foreground">{slot.sortOrder}</td>
1672
+ <td className="px-4 py-3 text-right">
1673
+ <button
1674
+ onClick={() => setEditingSlot({
1675
+ id: slot.id,
1676
+ targetSpecId: slot.targetSpecId,
1677
+ componentGroup: slot.componentGroup,
1678
+ name: slot.name,
1679
+ description: slot.description ?? '',
1680
+ cardinalityMin: slot.cardinalityMin,
1681
+ cardinalityMax: slot.cardinalityMax,
1682
+ sortOrder: slot.sortOrder,
1683
+ })}
1684
+ className="text-xs text-primary hover:underline mr-2"
1685
+ >
1686
+ Edit
1687
+ </button>
1688
+ <button
1689
+ onClick={async () => {
1690
+ if (!confirm('Delete this bundle slot?')) return
1691
+ try {
1692
+ const res = await fetch('/api/cpq/bundle-slots', {
1693
+ method: 'DELETE',
1694
+ headers: { 'Content-Type': 'application/json' },
1695
+ body: JSON.stringify({ id: slot.id }),
1696
+ })
1697
+ if (res.ok) {
1698
+ setBundleSlots((prev) => prev.filter((s) => s.id !== slot.id))
1699
+ } else {
1700
+ const d = await res.json()
1701
+ setError(d.error ?? 'Failed to delete slot')
1702
+ }
1703
+ } catch {
1704
+ setError('Failed to delete slot')
1705
+ }
1706
+ }}
1707
+ className="text-xs text-destructive hover:underline"
1708
+ >
1709
+ Delete
1710
+ </button>
1711
+ </td>
1712
+ </tr>
1713
+ ))}
1714
+ </tbody>
1715
+ </table>
1716
+ </div>
1717
+ )}
1718
+ </div>
1719
+ )}
1720
+ </div>
1721
+ )
1722
+ }