@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,1721 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { useRouter, useParams, useSearchParams } from 'next/navigation'
4
+ import ArcQuoteConfigurator, {
5
+ type AttachedTarget,
6
+ } from './_components/ArcQuoteConfigurator'
7
+ import { StatusPath } from '../../../../components/StatusPath'
8
+ import { ArrowRight, ExternalLink, Plus } from 'lucide-react'
9
+ import { Alert, AlertTitle, AlertDescription } from '@open-mercato/ui/primitives/alert'
10
+ import { Button } from '@open-mercato/ui/primitives/button'
11
+ import { Tag } from '@open-mercato/ui/primitives/tag'
12
+ import { FormHeader } from '@open-mercato/ui/backend/forms'
13
+ import type { ActionItem } from '@open-mercato/ui/backend/forms/ActionsDropdown'
14
+ import { NumberInput } from '../../../../components/NumberInput'
15
+ import {
16
+ formatStatusLabel,
17
+ quoteCpqStatusMap,
18
+ type QuoteCpqStatus,
19
+ } from '../../../../components/statusMaps'
20
+
21
+ // ─── Types ───────────────────────────────────────────────────────
22
+
23
+ type ConstrainedAttribute = {
24
+ code: string
25
+ name: string
26
+ attributeType: string
27
+ isRequired: boolean
28
+ options: Array<{ value: string; label: string }> | null
29
+ constraints: Record<string, unknown> | null
30
+ defaultValue: unknown | null
31
+ helpText: string | null
32
+ }
33
+
34
+ type TierBreakdownEntry = { tier: number; from: number; to: number; pricePerUnit: number; quantity: number; subtotal: number }
35
+ type RuleAdjustment = { ruleCode: string; ruleName: string; ruleType: string; value: number; unitPriceBefore: number; unitPriceAfter: number; delta: number }
36
+
37
+ type ResolvedCharge = {
38
+ chargeCode: string
39
+ chargeName: string
40
+ chargeType: 'nrc' | 'mrc' | 'usage'
41
+ pricingMethod?: 'flat' | 'tiered' | 'per_unit'
42
+ unitPrice: number
43
+ quantity: number | null
44
+ totalPrice: number | null
45
+ currencyCode: string
46
+ breakdown?: { tiers: TierBreakdownEntry[] } | null
47
+ note?: string | null
48
+ adjustments?: RuleAdjustment[] | null
49
+ }
50
+
51
+ type QuoteLineAction = 'add' | 'modify' | 'cancel'
52
+
53
+ type ArcLineSource = {
54
+ subscriptionItemId: string
55
+ name: string
56
+ mrcAmount: number
57
+ nrcAmount: number
58
+ quantity: number
59
+ }
60
+
61
+ type QuoteLine = {
62
+ lineId: string
63
+ offeringId: string | null
64
+ offeringName: string
65
+ offeringType: string | null
66
+ parentLineId: string | null
67
+ action: QuoteLineAction
68
+ quantity: number
69
+ configuration: Record<string, unknown>
70
+ nrcTotal: number
71
+ mrcTotal: number
72
+ charges: ResolvedCharge[]
73
+ isConfigured: boolean
74
+ validationErrors: Array<{ message: string }> | null
75
+ arcSource: ArcLineSource | null
76
+ targetSubscriptionId: string | null
77
+ }
78
+
79
+ type QuoteResult = {
80
+ id: string
81
+ quoteId: string
82
+ quoteNumber: string
83
+ customerId: string
84
+ customerName: string | null
85
+ cpqStatus: string
86
+ version: number
87
+ currencyCode: string
88
+ convertedOrderId: string | null
89
+ validationResult: { valid: boolean; errors: Array<{ message: string }> }
90
+ pricingSummary: {
91
+ nrcTotal: number
92
+ mrcTotal: number
93
+ usageCharges: Array<{ chargeCode: string; chargeName: string; unitPrice: number; note: string | null }>
94
+ usageTotalEstimated: number | null
95
+ currencyCode: string
96
+ }
97
+ lines: QuoteLine[]
98
+ }
99
+
100
+ type Offering = {
101
+ id: string
102
+ code: string
103
+ name: string
104
+ description?: string | null
105
+ specId: string
106
+ lifecycleStatus: string
107
+ }
108
+
109
+ type BundleTreeSlot = {
110
+ slotId: string
111
+ componentGroup: string
112
+ name: string
113
+ cardinalityMin: number
114
+ cardinalityMax: number | null
115
+ components: Array<{
116
+ componentId: string
117
+ childOffering: { offeringId: string; offeringCode: string; offeringName: string; offeringType: string }
118
+ isDefault: boolean
119
+ sortOrder: number
120
+ }>
121
+ }
122
+
123
+ type BundleTree = {
124
+ offeringId: string
125
+ offeringName: string
126
+ offeringType: string
127
+ slots: BundleTreeSlot[]
128
+ }
129
+
130
+ // ─── Constants ───────────────────────────────────────────────────
131
+
132
+ // `ALLOWED_TRANSITIONS` used to mirror the backend state machine on the
133
+ // client to gate the inline status menu. Per product decision the UI
134
+ // now lets the operator jump between any pair of statuses; the backend
135
+ // is the single source of truth, so the table is no longer needed here.
136
+
137
+ function fmt(amount: number, currency: string): string {
138
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
139
+ }
140
+
141
+ const RULE_TYPE_LABELS: Record<string, string> = {
142
+ discount_percent: '% discount',
143
+ discount_absolute: 'fixed discount',
144
+ surcharge_percent: '% surcharge',
145
+ surcharge_absolute: 'fixed surcharge',
146
+ price_override: 'override',
147
+ }
148
+
149
+ function fmtRuleValue(adj: RuleAdjustment, currency?: string): string {
150
+ if (adj.ruleType === 'discount_percent' || adj.ruleType === 'surcharge_percent') return `${adj.value}%`
151
+ if (currency) return fmt(adj.value, currency)
152
+ return String(adj.value)
153
+ }
154
+
155
+ async function apiJson<T>(url: string, init?: RequestInit): Promise<T> {
156
+ const res = await fetch(url, init)
157
+ const json = await res.json()
158
+ if (!res.ok) throw new Error(json?.error ?? `Request failed (${res.status})`)
159
+ return json as T
160
+ }
161
+
162
+ // ─── Header sub-components ─────────────────────────────────────
163
+
164
+ function MetaCard({ label, children }: { label: string; children: React.ReactNode }) {
165
+ return (
166
+ <div className="rounded-lg border bg-card p-4">
167
+ <div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
168
+ {label}
169
+ </div>
170
+ <div className="mt-1 text-sm">{children}</div>
171
+ </div>
172
+ )
173
+ }
174
+
175
+ function MoreActionsDropdown({ items }: { items: ActionItem[] }) {
176
+ // Local dropdown — `ActionsDropdown` inside FormHeader.actionsContent
177
+ // would conflict with the inline button placement. This is a thin
178
+ // <details>-style trigger; menuActions on FormHeader would render it
179
+ // inside the header layout, but here we want it adjacent to other
180
+ // primary buttons.
181
+ const [open, setOpen] = React.useState(false)
182
+ const ref = React.useRef<HTMLDivElement>(null)
183
+ React.useEffect(() => {
184
+ if (!open) return
185
+ const handler = (e: MouseEvent) => {
186
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
187
+ }
188
+ document.addEventListener('mousedown', handler)
189
+ return () => document.removeEventListener('mousedown', handler)
190
+ }, [open])
191
+
192
+ if (items.length === 0) return null
193
+ return (
194
+ <div className="relative" ref={ref}>
195
+ <Button type="button" variant="outline" onClick={() => setOpen((v) => !v)}>
196
+ Actions
197
+ <svg className="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor">
198
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
199
+ </svg>
200
+ </Button>
201
+ {open && (
202
+ <div className="absolute right-0 top-full mt-1 z-50 min-w-[200px] rounded-md border bg-popover shadow-md py-1">
203
+ {items.map((item) => {
204
+ const isDelete = item.id === 'delete'
205
+ return (
206
+ <button
207
+ key={item.id}
208
+ type="button"
209
+ onClick={() => { setOpen(false); item.onSelect() }}
210
+ disabled={item.disabled || item.loading}
211
+ // Use bg-accent on hover (same token as Radix-style menu items in OM)
212
+ // and tint delete with destructive colors so the affordance is obvious.
213
+ className={
214
+ 'w-full px-3 py-2 text-left text-sm flex items-center gap-2 transition-colors ' +
215
+ 'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent ' +
216
+ (isDelete
217
+ ? 'text-destructive hover:bg-destructive/10 focus:bg-destructive/10 focus:outline-none'
218
+ : 'hover:bg-accent focus:bg-accent focus:outline-none')
219
+ }
220
+ >
221
+ {item.icon ? <item.icon className="h-4 w-4 text-muted-foreground" /> : null}
222
+ <span>{item.label}</span>
223
+ </button>
224
+ )
225
+ })}
226
+ </div>
227
+ )}
228
+ </div>
229
+ )
230
+ }
231
+
232
+ function buildMoreActions({
233
+ recalculate,
234
+ submitting,
235
+ deletable,
236
+ onDelete,
237
+ deleting,
238
+ }: {
239
+ recalculate: () => void
240
+ submitting: boolean
241
+ deletable: boolean
242
+ onDelete: () => void
243
+ deleting: boolean
244
+ }): ActionItem[] {
245
+ const items: ActionItem[] = [
246
+ {
247
+ id: 'recalculate',
248
+ label: 'Recalculate',
249
+ onSelect: recalculate,
250
+ loading: submitting,
251
+ disabled: submitting,
252
+ },
253
+ ]
254
+ if (deletable) {
255
+ items.push({
256
+ id: 'delete',
257
+ label: 'Delete',
258
+ onSelect: onDelete,
259
+ disabled: deleting,
260
+ loading: deleting,
261
+ })
262
+ }
263
+ return items
264
+ }
265
+
266
+ // ─── Main Page ───────────────────────────────────────────────────
267
+
268
+ export default function CpqQuoteDetailPage(props: { params?: { id?: string } }) {
269
+ const router = useRouter()
270
+ const urlParams = useParams()
271
+ const searchParams = useSearchParams()
272
+ const cpqConfigId = (props.params?.id ?? urlParams?.id) as string
273
+ const isNew = cpqConfigId === 'new'
274
+
275
+ const [cpqQuote, setCpqQuote] = React.useState<QuoteResult | null>(null)
276
+ const [loading, setLoading] = React.useState(true)
277
+ const [error, setError] = React.useState<string | null>(null)
278
+ const [submitting, setSubmitting] = React.useState(false)
279
+
280
+ // Configurator state
281
+ const [view, setView] = React.useState<'summary' | 'add-offering' | 'configure' | 'edit'>('summary')
282
+ const [offerings, setOfferings] = React.useState<Offering[]>([])
283
+ const [offeringsLoading, setOfferingsLoading] = React.useState(false)
284
+ const [offeringSearch, setOfferingSearch] = React.useState('')
285
+ const [selectedOffering, setSelectedOffering] = React.useState<Offering | null>(null)
286
+ const [attributes, setAttributes] = React.useState<ConstrainedAttribute[]>([])
287
+ const [config, setConfig] = React.useState<Record<string, unknown>>({})
288
+ const [quantity, setQuantity] = React.useState(1)
289
+ const [editingLineId, setEditingLineId] = React.useState<string | null>(null)
290
+ const [addingToParentLineId, setAddingToParentLineId] = React.useState<string | null>(null)
291
+ // XD-250 multi-target ARC: when adding a top-level offering operator can
292
+ // pick 1..N target subs (default = all) and we fire one POST per target.
293
+ // For "Add Component" to a bundle, this is forced to a single id (the
294
+ // parent line's target) since a bundle component must follow its bundle.
295
+ const [addingTargetSubIds, setAddingTargetSubIds] = React.useState<string[]>([])
296
+ const [expandedLines, setExpandedLines] = React.useState<Set<string>>(new Set())
297
+ const [converting, setConverting] = React.useState(false)
298
+ const [transitioning, setTransitioning] = React.useState(false)
299
+ const [deleting, setDeleting] = React.useState(false)
300
+ const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false)
301
+ const [pricingDetail, setPricingDetail] = React.useState<{ lineIdx: number; chargeIdx: number } | null>(null)
302
+
303
+ // ─── XD-250 ARC state ──────────────────────────────────────────
304
+ const [arcTargets, setArcTargets] = React.useState<AttachedTarget[]>([])
305
+ const [arcQuoteType, setArcQuoteType] = React.useState<'new' | 'amend' | 'renew' | 'cancel'>('new')
306
+ const [arcDrawerOpen, setArcDrawerOpen] = React.useState(false)
307
+
308
+ const loadArcTargets = React.useCallback(async () => {
309
+ try {
310
+ const res = await fetch(`/api/cpq/quotes/${cpqConfigId}/target-subscriptions`)
311
+ if (!res.ok) return
312
+ const data = await res.json()
313
+ const items: AttachedTarget[] = data.items ?? []
314
+ setArcTargets(items)
315
+ if (items.length > 0) {
316
+ setArcQuoteType(items[0].quoteType as 'amend' | 'renew' | 'cancel')
317
+ } else {
318
+ setArcQuoteType('new')
319
+ }
320
+ } catch {
321
+ // best-effort — non-ARC quotes have no targets, that's fine.
322
+ }
323
+ }, [cpqConfigId])
324
+
325
+ React.useEffect(() => {
326
+ if (!cpqConfigId || isNew) return
327
+ void loadArcTargets()
328
+ }, [cpqConfigId, isNew, loadArcTargets])
329
+
330
+ // ─── Load or create ──────────────────────────────────────────
331
+
332
+ React.useEffect(() => {
333
+ if (isNew) {
334
+ // Create new CPQ quote from query params
335
+ const quoteId = searchParams.get('quoteId')
336
+ const customerId = searchParams.get('customerId')
337
+ if (!customerId) {
338
+ setError('customerId is required to create a CPQ quote')
339
+ setLoading(false)
340
+ return
341
+ }
342
+ async function create() {
343
+ try {
344
+ const body: Record<string, string> = { customerId: customerId! }
345
+ if (quoteId) body.quoteId = quoteId
346
+ const result = await apiJson<QuoteResult>('/api/cpq/quotes', {
347
+ method: 'POST',
348
+ headers: { 'Content-Type': 'application/json' },
349
+ body: JSON.stringify(body),
350
+ })
351
+ router.replace(`/backend/cpq/quotes/${result.id}`)
352
+ } catch (err) {
353
+ setError((err as Error).message)
354
+ setLoading(false)
355
+ }
356
+ }
357
+ create()
358
+ return
359
+ }
360
+
361
+ let cancelled = false
362
+ async function load() {
363
+ try {
364
+ const result = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqConfigId}`)
365
+ if (!cancelled) setCpqQuote(result)
366
+ } catch (err) {
367
+ if (!cancelled) setError((err as Error).message)
368
+ } finally {
369
+ if (!cancelled) setLoading(false)
370
+ }
371
+ }
372
+ load()
373
+ return () => { cancelled = true }
374
+ }, [cpqConfigId, isNew, searchParams, router])
375
+
376
+ // ─── Actions ─────────────────────────────────────────────────
377
+
378
+ const loadOfferings = async () => {
379
+ setOfferingsLoading(true)
380
+ try {
381
+ const result = await apiJson<{ items: Offering[] }>('/api/cpq/product-offerings?pageSize=100&lifecycleStatus=active')
382
+ setOfferings(result.items ?? [])
383
+ } catch { setOfferings([]) }
384
+ finally { setOfferingsLoading(false) }
385
+ }
386
+
387
+ const loadAttributes = async (offeringId: string, currentConfig: Record<string, unknown> = {}) => {
388
+ try {
389
+ const params = new URLSearchParams({ offeringId })
390
+ if (Object.keys(currentConfig).length > 0) params.set('configuration', JSON.stringify(currentConfig))
391
+ const result = await apiJson<{ attributes: ConstrainedAttribute[] }>(`/api/cpq/quotes/attributes?${params}`)
392
+ setAttributes(result.attributes ?? [])
393
+ } catch { setAttributes([]) }
394
+ }
395
+
396
+ const addItem = async () => {
397
+ if (!cpqQuote || !selectedOffering) return
398
+ try {
399
+ setSubmitting(true); setError(null)
400
+ // XD-250 multi-target ARC: when ≥2 targets selected, fire one POST per
401
+ // target — backend creates one line per request, each tagged with its
402
+ // own targetSubscriptionId. Single target / non-ARC quote: list is
403
+ // empty or 1-elem, we just do one POST and let the server auto-inherit.
404
+ const targetsToPost = addingTargetSubIds.length > 0 ? addingTargetSubIds : [null]
405
+ let lastResult: QuoteResult | null = null
406
+ for (const tid of targetsToPost) {
407
+ const body: Record<string, unknown> = { offeringId: selectedOffering.id, configuration: config, quantity }
408
+ if (addingToParentLineId) body.parentLineId = addingToParentLineId
409
+ if (tid) body.targetSubscriptionId = tid
410
+ lastResult = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqQuote.id}/items`, {
411
+ method: 'POST',
412
+ headers: { 'Content-Type': 'application/json' },
413
+ body: JSON.stringify(body),
414
+ })
415
+ }
416
+ if (lastResult) setCpqQuote(lastResult)
417
+ resetConfigState()
418
+ setView('summary')
419
+ } catch (err) { setError((err as Error).message) }
420
+ finally { setSubmitting(false) }
421
+ }
422
+
423
+ const updateItem = async (lineId: string) => {
424
+ if (!cpqQuote) return
425
+ try {
426
+ setSubmitting(true); setError(null)
427
+ const result = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqQuote.id}/items/${lineId}`, {
428
+ method: 'PUT',
429
+ headers: { 'Content-Type': 'application/json' },
430
+ body: JSON.stringify({ configuration: config, quantity }),
431
+ })
432
+ setCpqQuote(result)
433
+ resetConfigState()
434
+ setView('summary')
435
+ } catch (err) { setError((err as Error).message) }
436
+ finally { setSubmitting(false) }
437
+ }
438
+
439
+ const removeItem = async (lineId: string) => {
440
+ if (!cpqQuote) return
441
+ try {
442
+ setError(null)
443
+ const result = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqQuote.id}/items/${lineId}`, { method: 'DELETE' })
444
+ setCpqQuote(result)
445
+ } catch (err) { setError((err as Error).message) }
446
+ }
447
+
448
+ const recalculate = async () => {
449
+ if (!cpqQuote) return
450
+ try {
451
+ setSubmitting(true); setError(null)
452
+ const result = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqQuote.id}/recalculate`, {
453
+ method: 'POST',
454
+ headers: { 'Content-Type': 'application/json' },
455
+ body: JSON.stringify({ save: true }),
456
+ })
457
+ setCpqQuote(result)
458
+ } catch (err) { setError((err as Error).message) }
459
+ finally { setSubmitting(false) }
460
+ }
461
+
462
+ const convertToOrder = async () => {
463
+ if (!cpqQuote) return
464
+ try {
465
+ setConverting(true); setError(null)
466
+ const result = await apiJson<{ id: string }>('/api/cpq/orders', {
467
+ method: 'POST',
468
+ headers: { 'Content-Type': 'application/json' },
469
+ body: JSON.stringify({ quoteId: cpqQuote.id }),
470
+ })
471
+ router.push(`/backend/cpq/orders/${result.id}`)
472
+ } catch (err) { setError((err as Error).message) }
473
+ finally { setConverting(false) }
474
+ }
475
+
476
+ const transitionStatus = async (targetStatus: string) => {
477
+ if (!cpqQuote) return
478
+ try {
479
+ setTransitioning(true); setError(null)
480
+ const result = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqQuote.id}/status`, {
481
+ method: 'POST',
482
+ headers: { 'Content-Type': 'application/json' },
483
+ body: JSON.stringify({ targetStatus }),
484
+ })
485
+ setCpqQuote(result)
486
+ } catch (err) { setError((err as Error).message) }
487
+ finally { setTransitioning(false) }
488
+ }
489
+
490
+ const deleteQuote = async () => {
491
+ if (!cpqQuote) return
492
+ try {
493
+ setDeleting(true); setError(null); setShowDeleteConfirm(false)
494
+ await apiJson<{ ok: boolean }>(`/api/cpq/quotes/${cpqQuote.id}`, { method: 'DELETE' })
495
+ router.push('/backend/cpq/quotes')
496
+ } catch (err) { setError((err as Error).message) }
497
+ finally { setDeleting(false) }
498
+ }
499
+
500
+ const resetConfigState = () => {
501
+ setSelectedOffering(null)
502
+ setAttributes([])
503
+ setConfig({})
504
+ setQuantity(1)
505
+ setEditingLineId(null)
506
+ setAddingToParentLineId(null)
507
+ }
508
+
509
+ const startAddOffering = () => {
510
+ resetConfigState()
511
+ // Default: apply to all attached targets (operator can uncheck in the picker).
512
+ setAddingTargetSubIds(arcTargets.map((t) => t.subscriptionId))
513
+ loadOfferings()
514
+ setView('add-offering')
515
+ }
516
+
517
+ const startAddComponent = async (parentLineId: string, offeringId?: string) => {
518
+ setSelectedOffering(null)
519
+ setAttributes([])
520
+ setConfig({})
521
+ setQuantity(1)
522
+ setEditingLineId(null)
523
+ setAddingToParentLineId(parentLineId)
524
+ // Inherit the parent line's target so a bundle component lands on the
525
+ // same subscription as its parent (XD-250 multi-target ARC). For
526
+ // components there's no choice — bundle ↔ component must agree.
527
+ const parent = cpqQuote?.lines.find((l) => l.lineId === parentLineId)
528
+ const inherited = parent?.targetSubscriptionId ?? arcTargets[0]?.subscriptionId
529
+ setAddingTargetSubIds(inherited ? [inherited] : [])
530
+ if (offeringId) {
531
+ try {
532
+ const result = await apiJson<{ items: Offering[] }>('/api/cpq/product-offerings?pageSize=100&lifecycleStatus=active')
533
+ const match = (result.items ?? []).find((o) => o.id === offeringId)
534
+ if (match) {
535
+ selectOffering(match)
536
+ return
537
+ }
538
+ } catch { /* fall through to browser */ }
539
+ }
540
+ loadOfferings()
541
+ setView('add-offering')
542
+ }
543
+
544
+ const selectOffering = (o: Offering) => {
545
+ setSelectedOffering(o); setConfig({}); setQuantity(1)
546
+ loadAttributes(o.id)
547
+ setView('configure')
548
+ }
549
+
550
+ const startEditLine = (line: QuoteLine) => {
551
+ setEditingLineId(line.lineId)
552
+ setConfig(line.configuration ?? {})
553
+ setQuantity(line.quantity)
554
+ if (line.offeringId) loadAttributes(line.offeringId, line.configuration ?? {})
555
+ setView('edit')
556
+ }
557
+
558
+ const toggleLine = (id: string) => {
559
+ setExpandedLines((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
560
+ }
561
+
562
+ // ─── Render: loading / error ─────────────────────────────────
563
+
564
+ if (loading) {
565
+ return (
566
+ <div className="flex items-center gap-2 py-12 justify-center text-sm text-muted-foreground">
567
+ <Spinner /> Loading…
568
+ </div>
569
+ )
570
+ }
571
+
572
+ if (!cpqQuote) {
573
+ return (
574
+ <div className="space-y-4">
575
+ <button onClick={() => router.push('/backend/cpq/quotes')} className="text-sm text-muted-foreground hover:text-foreground">← Back to Quotes</button>
576
+ {error && <ErrorBanner message={error} />}
577
+ <div className="rounded-lg border bg-card p-6 text-center text-sm text-muted-foreground">Quote not found.</div>
578
+ </div>
579
+ )
580
+ }
581
+
582
+ // ─── Render: detail ──────────────────────────────────────────
583
+
584
+ const { pricingSummary: summary, currencyCode: currency } = cpqQuote
585
+
586
+ const arcEditable = ['new', 'incomplete', 'ready'].includes(cpqQuote.cpqStatus)
587
+ const isMergeRenew =
588
+ arcQuoteType === 'renew' &&
589
+ arcTargets.length >= 2 &&
590
+ arcTargets.every((t) => t.mergeAction === 'absorb')
591
+
592
+ // XD-250 multi-target ARC: map subscriptionId → friendly code (e.g. SUB-...).
593
+ // Used for the per-line target badge so the operator can see which sub each
594
+ // line acts on; only meaningful when there are 2+ targets. Plain map (not
595
+ // useMemo) because this whole block is after the loading / not-found early
596
+ // returns — adding a hook here would violate Rules of Hooks. Tiny N (count
597
+ // of attached ARC targets) so re-building per render is fine.
598
+ const arcTargetCodeMap = new Map<string, string>()
599
+ for (const t of arcTargets) {
600
+ if (t.subscription?.code) arcTargetCodeMap.set(t.subscriptionId, t.subscription.code)
601
+ }
602
+
603
+ return (
604
+ <div className="space-y-6">
605
+ {arcDrawerOpen && (
606
+ <ArcQuoteConfigurator
607
+ quoteId={cpqQuote.id}
608
+ customerId={cpqQuote.customerId}
609
+ customerName={cpqQuote.customerName}
610
+ currencyCode={cpqQuote.currencyCode}
611
+ initialType={arcQuoteType}
612
+ initialTargets={arcTargets}
613
+ editable={arcEditable}
614
+ onClose={() => setArcDrawerOpen(false)}
615
+ onChanged={async () => {
616
+ await loadArcTargets()
617
+ // Drawer attached / detached targets — backend may have mirrored
618
+ // items from new targets (XD-250 multi-target ARC), so refetch
619
+ // the full quote to pick up the new lines without a hard refresh.
620
+ try {
621
+ const fresh = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqQuote.id}`)
622
+ setCpqQuote(fresh)
623
+ } catch {
624
+ /* ignore — loadArcTargets already surfaced any error */
625
+ }
626
+ }}
627
+ />
628
+ )}
629
+ {/* Header — uses the standard Open Mercato `FormHeader mode="detail"`
630
+ so CPQ matches the rest of the admin. Title is just the quote
631
+ number (no "Quote …" prefix); badges sit underneath; secondary
632
+ context (customer / version / currency) lives in dedicated meta
633
+ cards below the header, not in a thin meta line. */}
634
+ <FormHeader
635
+ mode="detail"
636
+ backHref="/backend/cpq/quotes"
637
+ backLabel="Back to CPQ Quotes"
638
+ entityTypeLabel="CPQ Quote"
639
+ title={cpqQuote.quoteNumber || cpqQuote.customerName || 'Untitled'}
640
+ statusBadge={
641
+ <div className="flex flex-wrap items-center gap-2 pt-1">
642
+ <Tag variant={quoteCpqStatusMap[cpqQuote.cpqStatus as QuoteCpqStatus] ?? 'neutral'} dot>
643
+ {formatStatusLabel(cpqQuote.cpqStatus)}
644
+ </Tag>
645
+ {arcQuoteType !== 'new' && (
646
+ <Tag variant="brand" className="uppercase">{arcQuoteType}</Tag>
647
+ )}
648
+ {cpqQuote.convertedOrderId && <Tag variant="success" dot>Converted</Tag>}
649
+ </div>
650
+ }
651
+ // FormHeader.detail ignores `utilityActions` whenever `actionsContent`
652
+ // is set, so we render the "View Sales Quote" link inline alongside
653
+ // the primary actions to keep it visible.
654
+ actionsContent={
655
+ <>
656
+ <Button
657
+ type="button"
658
+ variant="outline"
659
+ onClick={() => router.push(`/backend/sales/quotes/${cpqQuote.quoteId}`)}
660
+ >
661
+ <ExternalLink className="h-3.5 w-3.5" />
662
+ View Sales Quote
663
+ </Button>
664
+ {cpqQuote.convertedOrderId ? (
665
+ <Button
666
+ type="button"
667
+ onClick={() => router.push(`/backend/cpq/orders/${cpqQuote.convertedOrderId}`)}
668
+ >
669
+ <ArrowRight className="h-4 w-4" />
670
+ Go to order
671
+ </Button>
672
+ ) : view !== 'summary' ? (
673
+ <Button type="button" variant="outline" onClick={() => { resetConfigState(); setView('summary') }}>
674
+ Cancel
675
+ </Button>
676
+ ) : (
677
+ <>
678
+ <Button type="button" onClick={startAddOffering}>
679
+ <Plus className="h-4 w-4" />
680
+ Add Offering
681
+ </Button>
682
+ {arcEditable && (
683
+ <Button type="button" variant="outline" onClick={() => setArcDrawerOpen(true)}>
684
+ Modify Subscription
685
+ </Button>
686
+ )}
687
+ {cpqQuote.cpqStatus === 'accepted' && (
688
+ <Button type="button" onClick={convertToOrder} disabled={converting}>
689
+ {converting ? <Spinner /> : <ArrowRight className="h-4 w-4" />}
690
+ Convert to Order
691
+ </Button>
692
+ )}
693
+ <MoreActionsDropdown
694
+ items={buildMoreActions({
695
+ recalculate,
696
+ submitting,
697
+ deletable: !['accepted', 'rejected', 'cancelled'].includes(cpqQuote.cpqStatus),
698
+ onDelete: () => setShowDeleteConfirm(true),
699
+ deleting,
700
+ })}
701
+ />
702
+ </>
703
+ )}
704
+ </>
705
+ }
706
+ />
707
+
708
+ {/* Per user feedback, order below the FormHeader is:
709
+ 1. Status path (operator-facing lifecycle nav)
710
+ 2. ARC info (amend/renew/cancel banner with targets)
711
+ 3. Customer / Version / Currency meta cards
712
+ 4. Pricing summary + line items (further down in the page)
713
+ Status path and ARC info are higher because they describe what
714
+ the user is *doing*; the meta cards are reference info. */}
715
+
716
+ {/* Status path — every step is clickable so the operator can move
717
+ between any pair of statuses. Backend enforces ARC-specific
718
+ guards (validateArcQuote on submit-for-approval) but no longer
719
+ gates simple transitions. */}
720
+ <div className="flex items-center gap-3 flex-wrap">
721
+ <StatusPath
722
+ // Per product decision the quote status path lets the operator
723
+ // jump between any pair of statuses, so `allowedTransitions` is
724
+ // omitted — backend validates ARC-specific guards.
725
+ // `pre_approved` aliases into the `approved` slot for layout.
726
+ current={cpqQuote.cpqStatus}
727
+ path={
728
+ cpqQuote.cpqStatus === 'pre_approved'
729
+ ? ['new', 'incomplete', 'ready', 'in_approval', 'pre_approved', 'with_customer', 'accepted']
730
+ : ['new', 'incomplete', 'ready', 'in_approval', 'approved', 'with_customer', 'accepted']
731
+ }
732
+ terminals={['rejected', 'cancelled']}
733
+ statusMap={quoteCpqStatusMap}
734
+ onTransition={transitionStatus}
735
+ disabled={transitioning}
736
+ ariaLabel="Quote status path"
737
+ />
738
+ {transitioning && <Spinner />}
739
+ </div>
740
+
741
+ {error && <ErrorBanner message={error} onDismiss={() => setError(null)} />}
742
+
743
+ {arcQuoteType !== 'new' && arcTargets.length > 0 && (
744
+ <div className="rounded-md border border-brand-violet/30 bg-brand-violet/10 px-4 py-3 text-sm text-brand-violet">
745
+ <div className="flex items-center justify-between">
746
+ <div>
747
+ <span className="font-medium uppercase">{arcQuoteType}</span>
748
+ {' • '}
749
+ {arcTargets.length} target{arcTargets.length === 1 ? '' : 's'}:
750
+ {' '}
751
+ {arcTargets.map((t, i) => (
752
+ <span key={t.id}>
753
+ {i > 0 && ', '}
754
+ <button
755
+ onClick={() =>
756
+ router.push(`/backend/cpq/inventory/subscriptions/${t.subscriptionId}`)
757
+ }
758
+ className="text-brand-violet hover:underline"
759
+ >
760
+ {t.subscription?.code ?? t.subscriptionId.slice(0, 8)}
761
+ </button>
762
+ </span>
763
+ ))}
764
+ {isMergeRenew && (
765
+ <span className="ml-2 text-status-warning-text font-medium">
766
+ • Merging into a new contract
767
+ </span>
768
+ )}
769
+ </div>
770
+ </div>
771
+ </div>
772
+ )}
773
+
774
+ {/* Meta cards — secondary reference info (customer, version,
775
+ currency). Mirrors the sidebar tiles in the standard
776
+ sales-document detail layout. */}
777
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
778
+ <MetaCard label="Customer">
779
+ {cpqQuote.customerName ? (
780
+ <span className="font-medium">{cpqQuote.customerName}</span>
781
+ ) : (
782
+ <span className="font-mono text-xs text-muted-foreground">
783
+ {cpqQuote.customerId.slice(0, 8)}…
784
+ </span>
785
+ )}
786
+ </MetaCard>
787
+ <MetaCard label="Version">
788
+ <span className="font-medium">v{cpqQuote.version}</span>
789
+ </MetaCard>
790
+ <MetaCard label="Currency">
791
+ <span className="font-medium">{currency}</span>
792
+ </MetaCard>
793
+ </div>
794
+
795
+ {showDeleteConfirm && (
796
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowDeleteConfirm(false)}>
797
+ <div className="w-full max-w-sm rounded-lg border bg-card shadow-xl" onClick={(e) => e.stopPropagation()}>
798
+ <div className="px-4 py-3 border-b">
799
+ <h3 className="text-sm font-medium">Delete Quote</h3>
800
+ </div>
801
+ <div className="p-4">
802
+ <p className="text-sm text-muted-foreground">Are you sure you want to delete this quote? This will soft-delete the quote and all its line items.</p>
803
+ </div>
804
+ <div className="flex justify-end gap-2 px-4 py-3 border-t">
805
+ <Button type="button" variant="outline" onClick={() => setShowDeleteConfirm(false)}>
806
+ Cancel
807
+ </Button>
808
+ <Button type="button" variant="destructive" onClick={deleteQuote} disabled={deleting}>
809
+ {deleting ? <Spinner /> : null} Delete
810
+ </Button>
811
+ </div>
812
+ </div>
813
+ </div>
814
+ )}
815
+
816
+ {/* Summary view */}
817
+ {view === 'summary' && (
818
+ <>
819
+ {/* Pricing Summary Card */}
820
+ <div className="rounded-lg border bg-card p-4">
821
+ <h3 className="text-sm font-medium mb-3">Pricing Summary</h3>
822
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
823
+ <div>
824
+ <p className="text-xs text-muted-foreground">Non-Recurring (NRC)</p>
825
+ <p className="text-lg font-semibold font-mono">{fmt(summary.nrcTotal, currency)}</p>
826
+ </div>
827
+ <div>
828
+ <p className="text-xs text-muted-foreground">Monthly Recurring (MRC)</p>
829
+ <p className="text-lg font-semibold font-mono">{fmt(summary.mrcTotal, currency)}</p>
830
+ </div>
831
+ {summary.usageTotalEstimated != null && (
832
+ <div>
833
+ <p className="text-xs text-muted-foreground">Usage (est.)</p>
834
+ <p className="text-lg font-semibold font-mono">{fmt(summary.usageTotalEstimated, currency)}</p>
835
+ </div>
836
+ )}
837
+ {summary.usageCharges.length > 0 && (
838
+ <div>
839
+ <p className="text-xs text-muted-foreground">Usage Charges</p>
840
+ {summary.usageCharges.map((uc, i) => (
841
+ <p key={i} className="text-xs mt-0.5">{uc.chargeName}: {fmt(uc.unitPrice, currency)}/unit</p>
842
+ ))}
843
+ </div>
844
+ )}
845
+ </div>
846
+ </div>
847
+
848
+ {/* Validation errors */}
849
+ {!cpqQuote.validationResult.valid && (
850
+ <Alert variant="warning">
851
+ <AlertTitle>Validation Issues</AlertTitle>
852
+ <AlertDescription>
853
+ {/* AlertDescription renders a <p>, so the error list MUST be
854
+ stacked <span>s — `<ul>`/`<li>` inside `<p>` is invalid
855
+ HTML and Next 16 surfaces it as a hydration error. */}
856
+ <span className="text-xs space-y-0.5 block">
857
+ {cpqQuote.validationResult.errors.map((e, i) => (
858
+ <span key={i} className="block">— {e.message}</span>
859
+ ))}
860
+ </span>
861
+ </AlertDescription>
862
+ </Alert>
863
+ )}
864
+
865
+ {/* Quote Lines */}
866
+ <QuoteLineTree
867
+ lines={cpqQuote.lines}
868
+ currency={currency}
869
+ expandedLines={expandedLines}
870
+ pricingDetail={pricingDetail}
871
+ targetCodes={arcTargetCodeMap}
872
+ onToggleLine={toggleLine}
873
+ onEditLine={startEditLine}
874
+ onRemoveLine={removeItem}
875
+ onAddComponent={startAddComponent}
876
+ onTogglePricingDetail={(lineIdx, chargeIdx) =>
877
+ setPricingDetail(
878
+ pricingDetail?.lineIdx === lineIdx && pricingDetail?.chargeIdx === chargeIdx
879
+ ? null
880
+ : { lineIdx, chargeIdx },
881
+ )
882
+ }
883
+ />
884
+ </>
885
+ )}
886
+
887
+ {/* Add offering — browse */}
888
+ {view === 'add-offering' && (
889
+ <>
890
+ {addingToParentLineId && (
891
+ <Alert variant="info">
892
+ Adding component to bundle: <span className="font-medium">{cpqQuote.lines.find((l) => l.lineId === addingToParentLineId)?.offeringName ?? 'Bundle'}</span>
893
+ </Alert>
894
+ )}
895
+ <OfferingBrowser
896
+ offerings={offerings}
897
+ loading={offeringsLoading}
898
+ search={offeringSearch}
899
+ onSearchChange={setOfferingSearch}
900
+ onSelect={selectOffering}
901
+ />
902
+ </>
903
+ )}
904
+
905
+ {/* Configure new offering */}
906
+ {view === 'configure' && selectedOffering && (
907
+ <ConfigurePanel
908
+ title={addingToParentLineId ? `Add Component: ${selectedOffering.name}` : `Configure: ${selectedOffering.name}`}
909
+ attributes={attributes}
910
+ config={config}
911
+ quantity={quantity}
912
+ arcTargetOptions={
913
+ arcTargets.length >= 2 && !addingToParentLineId
914
+ ? arcTargets.map((t) => ({
915
+ subscriptionId: t.subscriptionId,
916
+ code: t.subscription?.code ?? t.subscriptionId.slice(0, 8),
917
+ name: t.subscription?.name ?? null,
918
+ }))
919
+ : null
920
+ }
921
+ arcSelectedTargetIds={addingTargetSubIds}
922
+ onArcTargetToggle={(subId) => {
923
+ setAddingTargetSubIds((prev) =>
924
+ prev.includes(subId) ? prev.filter((id) => id !== subId) : [...prev, subId],
925
+ )
926
+ }}
927
+ onConfigChange={(key, value) => {
928
+ const next = { ...config, [key]: value }
929
+ setConfig(next)
930
+ if (selectedOffering) loadAttributes(selectedOffering.id, next)
931
+ }}
932
+ onQuantityChange={setQuantity}
933
+ onSubmit={addItem}
934
+ submitLabel="Add to Quote"
935
+ submitting={submitting}
936
+ />
937
+ )}
938
+
939
+ {/* Edit existing line */}
940
+ {view === 'edit' && editingLineId && (
941
+ <ConfigurePanel
942
+ title={`Edit: ${cpqQuote.lines.find((l) => l.lineId === editingLineId)?.offeringName ?? 'Line'}`}
943
+ attributes={attributes}
944
+ config={config}
945
+ quantity={quantity}
946
+ arcTargetOptions={null}
947
+ arcSelectedTargetIds={[]}
948
+ onArcTargetToggle={() => {}}
949
+ onConfigChange={(key, value) => {
950
+ const next = { ...config, [key]: value }
951
+ setConfig(next)
952
+ const line = cpqQuote.lines.find((l) => l.lineId === editingLineId)
953
+ if (line?.offeringId) loadAttributes(line.offeringId, next)
954
+ }}
955
+ onQuantityChange={setQuantity}
956
+ onSubmit={() => updateItem(editingLineId)}
957
+ submitLabel="Update Line"
958
+ submitting={submitting}
959
+ />
960
+ )}
961
+ </div>
962
+ )
963
+ }
964
+
965
+ // ─── Sub-components ──────────────────────────────────────────────
966
+
967
+ function QuoteLineTree({ lines, currency, expandedLines, pricingDetail, targetCodes, onToggleLine, onEditLine, onRemoveLine, onAddComponent, onTogglePricingDetail }: {
968
+ lines: QuoteLine[]; currency: string; expandedLines: Set<string>
969
+ pricingDetail: { lineIdx: number; chargeIdx: number } | null
970
+ targetCodes: Map<string, string>
971
+ onToggleLine: (id: string) => void; onEditLine: (line: QuoteLine) => void
972
+ onRemoveLine: (lineId: string) => void; onAddComponent: (parentLineId: string, offeringId?: string) => void
973
+ onTogglePricingDetail: (lineIdx: number, chargeIdx: number) => void
974
+ }) {
975
+ const [bundleTrees, setBundleTrees] = React.useState<Map<string, BundleTree>>(new Map())
976
+ const [loadingTrees, setLoadingTrees] = React.useState<Set<string>>(new Set())
977
+
978
+ const rootLines = lines.filter((l) => !l.parentLineId)
979
+ const childrenByParent = new Map<string, QuoteLine[]>()
980
+ for (const line of lines) {
981
+ if (line.parentLineId) {
982
+ const arr = childrenByParent.get(line.parentLineId) ?? []
983
+ arr.push(line)
984
+ childrenByParent.set(line.parentLineId, arr)
985
+ }
986
+ }
987
+
988
+ const fetchBundleTree = React.useCallback(async (offeringId: string) => {
989
+ if (bundleTrees.has(offeringId) || loadingTrees.has(offeringId)) return
990
+ setLoadingTrees((prev) => new Set(prev).add(offeringId))
991
+ try {
992
+ const result = await apiJson<{ tree: BundleTree }>(`/api/cpq/bundle-tree?offeringId=${offeringId}`)
993
+ setBundleTrees((prev) => new Map(prev).set(offeringId, result.tree))
994
+ } catch { /* ignore — will just not show structure */ }
995
+ finally { setLoadingTrees((prev) => { const n = new Set(prev); n.delete(offeringId); return n }) }
996
+ }, [bundleTrees, loadingTrees])
997
+
998
+ return (
999
+ <div>
1000
+ <h3 className="text-sm font-medium mb-2 px-1">Line Items</h3>
1001
+ {lines.length === 0 ? (
1002
+ <div className="rounded-lg border bg-card py-8 text-center text-sm text-muted-foreground">
1003
+ No items configured yet. Click &quot;Add Offering&quot; to start.
1004
+ </div>
1005
+ ) : (
1006
+ <div className="rounded-lg border bg-card divide-y">
1007
+ {rootLines.map((line) => {
1008
+ const lineIdx = lines.indexOf(line)
1009
+ const isBundle = line.offeringType === 'bundle'
1010
+ const children = childrenByParent.get(line.lineId) ?? []
1011
+ const isExpanded = expandedLines.has(line.lineId)
1012
+ const bundleTree = isBundle && line.offeringId ? bundleTrees.get(line.offeringId) : undefined
1013
+ const treeLoading = isBundle && line.offeringId ? loadingTrees.has(line.offeringId) : false
1014
+
1015
+ if (isBundle && isExpanded && line.offeringId && !bundleTree && !treeLoading) {
1016
+ fetchBundleTree(line.offeringId)
1017
+ }
1018
+
1019
+ return (
1020
+ <div key={line.lineId}>
1021
+ <QuoteLineRow
1022
+ line={line} lineIdx={lineIdx} currency={currency}
1023
+ isExpanded={isExpanded}
1024
+ pricingDetail={pricingDetail} isBundle={isBundle}
1025
+ childCount={children.length} indent={0}
1026
+ targetCodes={targetCodes}
1027
+ onToggle={() => onToggleLine(line.lineId)}
1028
+ onEdit={() => onEditLine(line)}
1029
+ onRemove={() => onRemoveLine(line.lineId)}
1030
+ onAddComponent={isBundle ? () => onAddComponent(line.lineId) : undefined}
1031
+ onTogglePricingDetail={onTogglePricingDetail}
1032
+ />
1033
+ {/* Bundle structure: slots and child lines */}
1034
+ {isBundle && isExpanded && (
1035
+ <BundleSlotPanel
1036
+ bundleTree={bundleTree}
1037
+ treeLoading={treeLoading}
1038
+ children={children}
1039
+ allLines={lines}
1040
+ currency={currency}
1041
+ expandedLines={expandedLines}
1042
+ pricingDetail={pricingDetail}
1043
+ parentLineId={line.lineId}
1044
+ targetCodes={targetCodes}
1045
+ onToggleLine={onToggleLine}
1046
+ onEditLine={onEditLine}
1047
+ onRemoveLine={onRemoveLine}
1048
+ onAddComponent={onAddComponent}
1049
+ onTogglePricingDetail={onTogglePricingDetail}
1050
+ />
1051
+ )}
1052
+ </div>
1053
+ )
1054
+ })}
1055
+ </div>
1056
+ )}
1057
+ </div>
1058
+ )
1059
+ }
1060
+
1061
+ function BundleSlotPanel({ bundleTree, treeLoading, children, allLines, currency, expandedLines, pricingDetail, parentLineId, targetCodes, onToggleLine, onEditLine, onRemoveLine, onAddComponent, onTogglePricingDetail }: {
1062
+ bundleTree: BundleTree | undefined; treeLoading: boolean
1063
+ children: QuoteLine[]; allLines: QuoteLine[]; currency: string
1064
+ expandedLines: Set<string>; pricingDetail: { lineIdx: number; chargeIdx: number } | null
1065
+ parentLineId: string
1066
+ targetCodes: Map<string, string>
1067
+ onToggleLine: (id: string) => void; onEditLine: (line: QuoteLine) => void
1068
+ onRemoveLine: (lineId: string) => void; onAddComponent: (parentLineId: string, offeringId?: string) => void
1069
+ onTogglePricingDetail: (lineIdx: number, chargeIdx: number) => void
1070
+ }) {
1071
+ if (treeLoading) {
1072
+ return (
1073
+ <div className="border-t bg-muted/5 px-8 py-3">
1074
+ <div className="flex items-center gap-2 text-xs text-muted-foreground"><Spinner /> Loading bundle structure…</div>
1075
+ </div>
1076
+ )
1077
+ }
1078
+
1079
+ if (!bundleTree || bundleTree.slots.length === 0) {
1080
+ if (children.length === 0) return null
1081
+ return (
1082
+ <div className="border-t bg-muted/5">
1083
+ {children.map((child) => {
1084
+ const childIdx = allLines.indexOf(child)
1085
+ return (
1086
+ <QuoteLineRow
1087
+ key={child.lineId}
1088
+ line={child} lineIdx={childIdx} currency={currency}
1089
+ isExpanded={expandedLines.has(child.lineId)}
1090
+ pricingDetail={pricingDetail} isBundle={false}
1091
+ childCount={0} indent={1}
1092
+ targetCodes={targetCodes}
1093
+ onToggle={() => onToggleLine(child.lineId)}
1094
+ onEdit={() => onEditLine(child)}
1095
+ onRemove={() => onRemoveLine(child.lineId)}
1096
+ onTogglePricingDetail={onTogglePricingDetail}
1097
+ />
1098
+ )
1099
+ })}
1100
+ </div>
1101
+ )
1102
+ }
1103
+
1104
+ const childOfferingIds = new Set(children.map((c) => c.offeringId).filter(Boolean))
1105
+
1106
+ return (
1107
+ <div className="border-t bg-muted/5">
1108
+ {bundleTree.slots.map((slot) => {
1109
+ const filledChildren = children.filter((c) =>
1110
+ slot.components.some((comp) => comp.childOffering.offeringId === c.offeringId),
1111
+ )
1112
+ const filledCount = filledChildren.length
1113
+ const isRequired = slot.cardinalityMin > 0
1114
+ const isFull = slot.cardinalityMax != null && filledCount >= slot.cardinalityMax
1115
+ const canAdd = !isFull
1116
+
1117
+ return (
1118
+ <div key={slot.slotId} className="border-b last:border-b-0">
1119
+ {/* Slot header */}
1120
+ <div className="flex items-center gap-2 px-6 py-2 bg-muted/20">
1121
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{slot.name}</span>
1122
+ {isRequired ? (
1123
+ <Tag variant="warning" className="text-[10px] px-1.5">required</Tag>
1124
+ ) : (
1125
+ <Tag variant="neutral" className="text-[10px] px-1.5">optional</Tag>
1126
+ )}
1127
+ <span className="text-[10px] text-muted-foreground">
1128
+ {filledCount}/{slot.cardinalityMax ?? '∞'}
1129
+ {slot.cardinalityMin > 0 && ` (min ${slot.cardinalityMin})`}
1130
+ </span>
1131
+ <div className="flex-1" />
1132
+ {/* Available offerings in this slot */}
1133
+ <div className="flex items-center gap-1">
1134
+ {slot.components.map((comp) => {
1135
+ const isFilled = childOfferingIds.has(comp.childOffering.offeringId)
1136
+ return (
1137
+ <button
1138
+ key={comp.componentId}
1139
+ onClick={() => !isFilled && canAdd && onAddComponent(parentLineId, comp.childOffering.offeringId)}
1140
+ disabled={isFilled || !canAdd}
1141
+ className={`inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-medium transition-colors ${
1142
+ isFilled
1143
+ ? 'border-status-success-border bg-status-success-bg text-status-success-text cursor-default'
1144
+ : canAdd
1145
+ ? 'border-border hover:border-primary hover:bg-primary/5 hover:text-primary cursor-pointer'
1146
+ : 'border-border text-muted-foreground opacity-50 cursor-not-allowed'
1147
+ }`}
1148
+ title={isFilled ? 'Already added' : canAdd ? `Add ${comp.childOffering.offeringName}` : 'Slot is full'}
1149
+ >
1150
+ {isFilled ? (
1151
+ <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor">
1152
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
1153
+ </svg>
1154
+ ) : (
1155
+ <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor">
1156
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
1157
+ </svg>
1158
+ )}
1159
+ {comp.childOffering.offeringName}
1160
+ </button>
1161
+ )
1162
+ })}
1163
+ </div>
1164
+ </div>
1165
+ {/* Filled child lines for this slot */}
1166
+ {filledChildren.map((child) => {
1167
+ const childIdx = allLines.indexOf(child)
1168
+ return (
1169
+ <QuoteLineRow
1170
+ key={child.lineId}
1171
+ line={child} lineIdx={childIdx} currency={currency}
1172
+ isExpanded={expandedLines.has(child.lineId)}
1173
+ pricingDetail={pricingDetail} isBundle={false}
1174
+ childCount={0} indent={1}
1175
+ targetCodes={targetCodes}
1176
+ onToggle={() => onToggleLine(child.lineId)}
1177
+ onEdit={() => onEditLine(child)}
1178
+ onRemove={() => onRemoveLine(child.lineId)}
1179
+ onTogglePricingDetail={onTogglePricingDetail}
1180
+ />
1181
+ )
1182
+ })}
1183
+ </div>
1184
+ )
1185
+ })}
1186
+ {/* Show any child lines not matching any slot */}
1187
+ {children.filter((c) => !bundleTree.slots.some((s) => s.components.some((comp) => comp.childOffering.offeringId === c.offeringId))).map((child) => {
1188
+ const childIdx = allLines.indexOf(child)
1189
+ return (
1190
+ <QuoteLineRow
1191
+ key={child.lineId}
1192
+ line={child} lineIdx={childIdx} currency={currency}
1193
+ isExpanded={expandedLines.has(child.lineId)}
1194
+ pricingDetail={pricingDetail} isBundle={false}
1195
+ childCount={0} indent={1}
1196
+ targetCodes={targetCodes}
1197
+ onToggle={() => onToggleLine(child.lineId)}
1198
+ onEdit={() => onEditLine(child)}
1199
+ onRemove={() => onRemoveLine(child.lineId)}
1200
+ onTogglePricingDetail={onTogglePricingDetail}
1201
+ />
1202
+ )
1203
+ })}
1204
+ </div>
1205
+ )
1206
+ }
1207
+
1208
+ function QuoteLineRow({ line, lineIdx, currency, isExpanded, pricingDetail, isBundle, childCount, indent, targetCodes, onToggle, onEdit, onRemove, onAddComponent, onTogglePricingDetail }: {
1209
+ line: QuoteLine; lineIdx: number; currency: string; isExpanded: boolean
1210
+ pricingDetail: { lineIdx: number; chargeIdx: number } | null
1211
+ isBundle: boolean; childCount: number; indent: number
1212
+ targetCodes: Map<string, string>
1213
+ onToggle: () => void; onEdit: () => void; onRemove: () => void
1214
+ onAddComponent?: () => void
1215
+ onTogglePricingDetail: (lineIdx: number, chargeIdx: number) => void
1216
+ }) {
1217
+ const paddingLeft = indent > 0 ? `${indent * 2 + 1}rem` : undefined
1218
+
1219
+ return (
1220
+ <div>
1221
+ <div className="flex items-center gap-3 px-4 py-3 hover:bg-muted/30 transition-colors" style={paddingLeft ? { paddingLeft } : undefined}>
1222
+ {/* Expand toggle */}
1223
+ <button onClick={onToggle} className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground">
1224
+ <svg className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor">
1225
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
1226
+ </svg>
1227
+ </button>
1228
+
1229
+ {/* Line info */}
1230
+ <div className="flex-1 min-w-0">
1231
+ <div className="flex items-center gap-2">
1232
+ {indent > 0 && (
1233
+ <span className="text-muted-foreground text-xs">└</span>
1234
+ )}
1235
+ {isBundle && (
1236
+ <Tag variant="brand" className="px-1.5 text-xs">bundle</Tag>
1237
+ )}
1238
+ <span className="font-medium text-sm">{line.offeringName}</span>
1239
+ {line.quantity > 1 && <span className="rounded bg-muted px-1.5 py-0.5 text-xs font-medium">×{line.quantity}</span>}
1240
+ <ActionBadge action={line.action} />
1241
+ {targetCodes.size >= 2 && line.targetSubscriptionId && targetCodes.get(line.targetSubscriptionId) && (
1242
+ <Tag
1243
+ variant="brand"
1244
+ className="px-1.5 text-xs"
1245
+ title={`Acts on subscription ${targetCodes.get(line.targetSubscriptionId)}`}
1246
+ >
1247
+ → {targetCodes.get(line.targetSubscriptionId)}
1248
+ </Tag>
1249
+ )}
1250
+ {line.isConfigured ? (
1251
+ <Tag variant="success" className="px-1.5 text-xs">configured</Tag>
1252
+ ) : (
1253
+ <Tag variant="warning" className="px-1.5 text-xs">incomplete</Tag>
1254
+ )}
1255
+ {isBundle && (
1256
+ <span className="text-xs text-muted-foreground">{childCount} component{childCount !== 1 ? 's' : ''}</span>
1257
+ )}
1258
+ </div>
1259
+ {line.validationErrors && line.validationErrors.length > 0 && (
1260
+ <p className="text-xs text-destructive mt-0.5">{line.validationErrors[0].message}</p>
1261
+ )}
1262
+ </div>
1263
+
1264
+ {/* Pricing */}
1265
+ <div className="shrink-0 text-right text-xs space-y-0.5">
1266
+ {line.nrcTotal > 0 && <div><span className="text-muted-foreground">NRC </span><span className="font-mono font-medium">{fmt(line.nrcTotal, currency)}</span></div>}
1267
+ {line.mrcTotal > 0 && <div><span className="text-muted-foreground">MRC </span><span className="font-mono font-medium">{fmt(line.mrcTotal, currency)}</span></div>}
1268
+ </div>
1269
+
1270
+ {/* Actions */}
1271
+ <div className="flex items-center gap-1 shrink-0">
1272
+ {onAddComponent && (
1273
+ <button onClick={onAddComponent} className="rounded p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/5 transition-colors" title="Add Component">
1274
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
1275
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
1276
+ </svg>
1277
+ </button>
1278
+ )}
1279
+ <button onClick={onEdit} className="rounded p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" title="Edit">
1280
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
1281
+ <path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
1282
+ </svg>
1283
+ </button>
1284
+ <button onClick={onRemove} className="rounded p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors" title="Remove">
1285
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
1286
+ <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
1287
+ </svg>
1288
+ </button>
1289
+ </div>
1290
+ </div>
1291
+
1292
+ {/* XD-250 ARC: before/after preview when line mirrors a sub item */}
1293
+ {isExpanded && line.arcSource && (
1294
+ <ArcLineDiff line={line} currency={currency} />
1295
+ )}
1296
+ {/* Charge breakdown (non-bundle or any expanded line) */}
1297
+ {isExpanded && !isBundle && line.charges.length > 0 && (
1298
+ <ChargeBreakdown line={line} lineIdx={lineIdx} currency={currency} pricingDetail={pricingDetail} onTogglePricingDetail={onTogglePricingDetail} />
1299
+ )}
1300
+ {/* For bundles: show own charges first, then children are rendered by parent */}
1301
+ {isExpanded && isBundle && line.charges.length > 0 && (
1302
+ <ChargeBreakdown line={line} lineIdx={lineIdx} currency={currency} pricingDetail={pricingDetail} onTogglePricingDetail={onTogglePricingDetail} />
1303
+ )}
1304
+ {/* Fallback: line has no per-charge breakdown and no ARC diff (truly empty) */}
1305
+ {isExpanded && line.charges.length === 0 && !line.arcSource && (
1306
+ <LineSummaryFallback line={line} currency={currency} />
1307
+ )}
1308
+ </div>
1309
+ )
1310
+ }
1311
+
1312
+ function ArcLineDiff({ line, currency }: { line: QuoteLine; currency: string }) {
1313
+ const src = line.arcSource
1314
+ if (!src) return null
1315
+ const isCancel = line.action === 'cancel'
1316
+ const beforeMrc = src.mrcAmount
1317
+ const beforeNrc = src.nrcAmount
1318
+ const beforeQty = src.quantity
1319
+ const afterMrc = isCancel ? 0 : line.mrcTotal
1320
+ const afterNrc = isCancel ? 0 : line.nrcTotal
1321
+ const afterQty = isCancel ? 0 : line.quantity
1322
+ const fmtVal = (v: number, asCurrency: boolean) =>
1323
+ asCurrency ? fmt(v, currency) : String(v)
1324
+ const rows: Array<{ label: string; before: number; after: number; asCurrency: boolean }> = [
1325
+ { label: 'Quantity', before: beforeQty, after: afterQty, asCurrency: false },
1326
+ { label: 'MRC', before: beforeMrc, after: afterMrc, asCurrency: true },
1327
+ { label: 'NRC', before: beforeNrc, after: afterNrc, asCurrency: true },
1328
+ ]
1329
+
1330
+ return (
1331
+ <div className="border-t bg-muted/10 px-12 py-2 space-y-1.5">
1332
+ <p className="text-xs font-medium text-muted-foreground">Change Preview</p>
1333
+ <div className="rounded border bg-background overflow-hidden">
1334
+ <table className="w-full text-xs">
1335
+ <thead className="bg-muted/40">
1336
+ <tr className="text-muted-foreground">
1337
+ <th className="text-left px-3 py-1.5 font-medium" />
1338
+ <th className="text-right px-3 py-1.5 font-medium">Before</th>
1339
+ <th className="text-right px-3 py-1.5 font-medium">After</th>
1340
+ <th className="text-right px-3 py-1.5 font-medium">Δ</th>
1341
+ </tr>
1342
+ </thead>
1343
+ <tbody>
1344
+ {rows.map((r) => {
1345
+ const delta = r.after - r.before
1346
+ const showDelta = delta !== 0
1347
+ return (
1348
+ <tr key={r.label} className="border-t border-dashed border-border/40">
1349
+ <td className="px-3 py-1 text-muted-foreground">{r.label}</td>
1350
+ <td className="px-3 py-1 text-right font-mono">{fmtVal(r.before, r.asCurrency)}</td>
1351
+ <td className={`px-3 py-1 text-right font-mono ${isCancel ? 'text-muted-foreground line-through' : ''}`}>
1352
+ {fmtVal(r.after, r.asCurrency)}
1353
+ </td>
1354
+ <td className={`px-3 py-1 text-right font-mono font-medium ${showDelta ? (delta > 0 ? 'text-status-success-text' : 'text-destructive') : 'text-muted-foreground'}`}>
1355
+ {showDelta ? `${delta > 0 ? '+' : ''}${fmtVal(delta, r.asCurrency)}` : '—'}
1356
+ </td>
1357
+ </tr>
1358
+ )
1359
+ })}
1360
+ </tbody>
1361
+ </table>
1362
+ </div>
1363
+ {isCancel && (
1364
+ <p className="text-xs text-destructive italic">Cancellation — item will be terminated on activation.</p>
1365
+ )}
1366
+ </div>
1367
+ )
1368
+ }
1369
+
1370
+ function ActionBadge({ action }: { action: QuoteLineAction }) {
1371
+ const variantByAction: Record<QuoteLineAction, 'info' | 'warning' | 'error'> = {
1372
+ add: 'info',
1373
+ modify: 'warning',
1374
+ cancel: 'error',
1375
+ }
1376
+ return (
1377
+ <Tag variant={variantByAction[action]} className="px-1.5 text-xs">
1378
+ {action}
1379
+ </Tag>
1380
+ )
1381
+ }
1382
+
1383
+ function LineSummaryFallback({ line, currency }: { line: QuoteLine; currency: string }) {
1384
+ const hasTotals = line.nrcTotal > 0 || line.mrcTotal > 0
1385
+ const mirroredFromItemId = (line.configuration?._arcMirroredFromItemId as string | undefined) ?? null
1386
+ return (
1387
+ <div className="border-t bg-muted/10 px-12 py-2 text-xs text-muted-foreground space-y-1">
1388
+ <div>
1389
+ No detailed charge breakdown available for this line.
1390
+ {mirroredFromItemId && ' Mirrored from existing subscription item.'}
1391
+ </div>
1392
+ {hasTotals && (
1393
+ <div className="flex gap-4">
1394
+ {line.nrcTotal > 0 && (
1395
+ <span>NRC <span className="font-mono font-medium text-foreground">{fmt(line.nrcTotal, currency)}</span></span>
1396
+ )}
1397
+ {line.mrcTotal > 0 && (
1398
+ <span>MRC <span className="font-mono font-medium text-foreground">{fmt(line.mrcTotal, currency)}</span></span>
1399
+ )}
1400
+ </div>
1401
+ )}
1402
+ </div>
1403
+ )
1404
+ }
1405
+
1406
+ function ChargeBreakdown({ line, lineIdx, currency, pricingDetail, onTogglePricingDetail }: {
1407
+ line: QuoteLine; lineIdx: number; currency: string
1408
+ pricingDetail: { lineIdx: number; chargeIdx: number } | null
1409
+ onTogglePricingDetail: (lineIdx: number, chargeIdx: number) => void
1410
+ }) {
1411
+ return (
1412
+ <div className="border-t bg-muted/10 px-12 py-2">
1413
+ <table className="w-full text-xs">
1414
+ <thead>
1415
+ <tr className="text-muted-foreground">
1416
+ <th className="text-left py-1 font-medium">Charge</th>
1417
+ <th className="text-left py-1 font-medium">Type</th>
1418
+ <th className="text-right py-1 font-medium">Unit Price</th>
1419
+ <th className="text-right py-1 font-medium">Qty</th>
1420
+ <th className="text-right py-1 font-medium">Total</th>
1421
+ <th className="w-8" />
1422
+ </tr>
1423
+ </thead>
1424
+ <tbody>
1425
+ {line.charges.map((c, i) => {
1426
+ const hasDetail = !!(c.breakdown?.tiers?.length || c.adjustments?.length)
1427
+ const isDetailOpen = pricingDetail?.lineIdx === lineIdx && pricingDetail?.chargeIdx === i
1428
+ return (
1429
+ <React.Fragment key={i}>
1430
+ <tr className="border-t border-dashed border-border/50">
1431
+ <td className="py-1">{c.chargeName}</td>
1432
+ <td className="py-1 uppercase">{c.chargeType}</td>
1433
+ <td className="py-1 text-right font-mono">{fmt(c.unitPrice, currency)}</td>
1434
+ <td className="py-1 text-right font-mono">{c.quantity ?? '—'}</td>
1435
+ <td className="py-1 text-right font-mono">{c.totalPrice != null ? fmt(c.totalPrice, currency) : c.note ?? '—'}</td>
1436
+ <td className="py-1 text-center">
1437
+ {hasDetail && (
1438
+ <button
1439
+ onClick={() => onTogglePricingDetail(lineIdx, i)}
1440
+ className="inline-flex items-center justify-center rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
1441
+ title="Show pricing breakdown"
1442
+ >
1443
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-3.5 h-3.5">
1444
+ <path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
1445
+ </svg>
1446
+ </button>
1447
+ )}
1448
+ </td>
1449
+ </tr>
1450
+ {isDetailOpen && (
1451
+ <tr>
1452
+ <td colSpan={6} className="pb-3 pt-1">
1453
+ <div className="rounded border bg-background p-3 space-y-3 text-xs">
1454
+ <div className="flex items-center justify-between">
1455
+ <span className="font-semibold text-sm">Pricing Details — {c.chargeName}</span>
1456
+ <span className="text-muted-foreground">{c.pricingMethod ?? 'flat'}</span>
1457
+ </div>
1458
+
1459
+ {c.breakdown?.tiers && c.breakdown.tiers.length > 0 && (
1460
+ <div>
1461
+ <div className="font-medium text-muted-foreground mb-1">Tier Breakdown</div>
1462
+ <table className="w-full">
1463
+ <thead>
1464
+ <tr className="text-muted-foreground">
1465
+ <th className="text-left py-0.5 font-medium">Tier</th>
1466
+ <th className="text-right py-0.5 font-medium">Range</th>
1467
+ <th className="text-right py-0.5 font-medium">Price/Unit</th>
1468
+ <th className="text-right py-0.5 font-medium">Qty</th>
1469
+ <th className="text-right py-0.5 font-medium">Subtotal</th>
1470
+ </tr>
1471
+ </thead>
1472
+ <tbody>
1473
+ {c.breakdown.tiers.map((t) => (
1474
+ <tr key={t.tier} className="border-t border-border/30">
1475
+ <td className="py-0.5">{t.tier}</td>
1476
+ <td className="py-0.5 text-right font-mono">{t.from}–{t.to}</td>
1477
+ <td className="py-0.5 text-right font-mono">{fmt(t.pricePerUnit, currency)}</td>
1478
+ <td className="py-0.5 text-right font-mono">{t.quantity}</td>
1479
+ <td className="py-0.5 text-right font-mono">{fmt(t.subtotal, currency)}</td>
1480
+ </tr>
1481
+ ))}
1482
+ <tr className="border-t font-medium">
1483
+ <td colSpan={4} className="py-0.5 text-right">Tiered Total</td>
1484
+ <td className="py-0.5 text-right font-mono">
1485
+ {fmt(c.breakdown.tiers.reduce((s, t) => s + t.subtotal, 0), currency)}
1486
+ </td>
1487
+ </tr>
1488
+ </tbody>
1489
+ </table>
1490
+ </div>
1491
+ )}
1492
+
1493
+ {c.adjustments && c.adjustments.length > 0 && (
1494
+ <div>
1495
+ <div className="font-medium text-muted-foreground mb-1">Applied Price Rules</div>
1496
+ <table className="w-full">
1497
+ <thead>
1498
+ <tr className="text-muted-foreground">
1499
+ <th className="text-left py-0.5 font-medium">Rule</th>
1500
+ <th className="text-left py-0.5 font-medium">Type</th>
1501
+ <th className="text-right py-0.5 font-medium">Value</th>
1502
+ <th className="text-right py-0.5 font-medium">Before</th>
1503
+ <th className="text-right py-0.5 font-medium">After</th>
1504
+ <th className="text-right py-0.5 font-medium">Delta</th>
1505
+ </tr>
1506
+ </thead>
1507
+ <tbody>
1508
+ {c.adjustments.map((adj, ai) => (
1509
+ <tr key={ai} className="border-t border-border/30">
1510
+ <td className="py-0.5">{adj.ruleName}</td>
1511
+ <td className="py-0.5">{RULE_TYPE_LABELS[adj.ruleType] ?? adj.ruleType}</td>
1512
+ <td className="py-0.5 text-right font-mono">{fmtRuleValue(adj, currency)}</td>
1513
+ <td className="py-0.5 text-right font-mono">{fmt(adj.unitPriceBefore, currency)}</td>
1514
+ <td className="py-0.5 text-right font-mono">{fmt(adj.unitPriceAfter, currency)}</td>
1515
+ <td className={`py-0.5 text-right font-mono ${adj.delta < 0 ? 'text-destructive' : adj.delta > 0 ? 'text-status-success-text' : ''}`}>
1516
+ {adj.delta > 0 ? '+' : ''}{fmt(adj.delta, currency)}
1517
+ </td>
1518
+ </tr>
1519
+ ))}
1520
+ </tbody>
1521
+ </table>
1522
+ </div>
1523
+ )}
1524
+
1525
+ <div className="flex items-center justify-between pt-1 border-t text-sm font-semibold">
1526
+ <span>Final Total</span>
1527
+ <span className="font-mono">{c.totalPrice != null ? fmt(c.totalPrice, currency) : '—'}</span>
1528
+ </div>
1529
+ </div>
1530
+ </td>
1531
+ </tr>
1532
+ )}
1533
+ </React.Fragment>
1534
+ )
1535
+ })}
1536
+ </tbody>
1537
+ </table>
1538
+ </div>
1539
+ )
1540
+ }
1541
+
1542
+ function Spinner() {
1543
+ return (
1544
+ <svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
1545
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
1546
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
1547
+ </svg>
1548
+ )
1549
+ }
1550
+
1551
+ function ErrorBanner({ message, onDismiss }: { message: string; onDismiss?: () => void }) {
1552
+ return (
1553
+ <Alert variant="destructive" className="flex items-start gap-2">
1554
+ <span className="flex-1">{message}</span>
1555
+ {onDismiss && (
1556
+ <button
1557
+ onClick={onDismiss}
1558
+ className="text-destructive/70 hover:text-destructive text-sm font-bold"
1559
+ aria-label="Dismiss"
1560
+ >
1561
+ ×
1562
+ </button>
1563
+ )}
1564
+ </Alert>
1565
+ )
1566
+ }
1567
+
1568
+ function OfferingBrowser({ offerings, loading, search, onSearchChange, onSelect }: {
1569
+ offerings: Offering[]; loading: boolean; search: string; onSearchChange: (v: string) => void; onSelect: (o: Offering) => void
1570
+ }) {
1571
+ const filtered = offerings.filter((o) => {
1572
+ if (!search.trim()) return true
1573
+ const q = search.toLowerCase()
1574
+ return o.name.toLowerCase().includes(q) || o.code.toLowerCase().includes(q) || (o.description ?? '').toLowerCase().includes(q)
1575
+ })
1576
+
1577
+ return (
1578
+ <div className="space-y-3">
1579
+ <h3 className="text-sm font-medium px-1">Select Product Offering</h3>
1580
+ <div className="relative">
1581
+ <svg className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor">
1582
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
1583
+ </svg>
1584
+ <input type="text" placeholder="Search offerings…" value={search} onChange={(e) => onSearchChange(e.target.value)}
1585
+ className="w-full rounded-md border bg-background pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50" />
1586
+ </div>
1587
+ {loading ? (
1588
+ <div className="flex items-center gap-2 py-8 justify-center text-sm text-muted-foreground"><Spinner /> Loading offerings…</div>
1589
+ ) : filtered.length === 0 ? (
1590
+ <div className="py-8 text-center text-sm text-muted-foreground">{search ? 'No offerings match your search.' : 'No active offerings available.'}</div>
1591
+ ) : (
1592
+ <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
1593
+ {filtered.map((offering) => (
1594
+ <button key={offering.id} onClick={() => onSelect(offering)}
1595
+ className="rounded-lg border bg-card p-4 text-left hover:border-primary hover:bg-primary/5 transition-colors group">
1596
+ <p className="text-sm font-medium group-hover:text-primary transition-colors">{offering.name}</p>
1597
+ <p className="text-xs text-muted-foreground font-mono mt-0.5">{offering.code}</p>
1598
+ {offering.description && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{offering.description}</p>}
1599
+ </button>
1600
+ ))}
1601
+ </div>
1602
+ )}
1603
+ </div>
1604
+ )
1605
+ }
1606
+
1607
+ function ConfigurePanel({ title, attributes, config, quantity, arcTargetOptions, arcSelectedTargetIds, onArcTargetToggle, onConfigChange, onQuantityChange, onSubmit, submitLabel, submitting }: {
1608
+ title: string; attributes: ConstrainedAttribute[]; config: Record<string, unknown>; quantity: number
1609
+ arcTargetOptions: Array<{ subscriptionId: string; code: string; name: string | null }> | null
1610
+ arcSelectedTargetIds: string[]
1611
+ onArcTargetToggle: (subId: string) => void
1612
+ onConfigChange: (key: string, value: unknown) => void; onQuantityChange: (q: number) => void
1613
+ onSubmit: () => void; submitLabel: string; submitting: boolean
1614
+ }) {
1615
+ const noTargetsPicked =
1616
+ arcTargetOptions !== null && arcTargetOptions.length > 0 && arcSelectedTargetIds.length === 0
1617
+ return (
1618
+ <div className="rounded-lg border bg-card">
1619
+ <div className="border-b px-4 py-3"><h3 className="text-sm font-medium">{title}</h3></div>
1620
+ <div className="p-4 space-y-4">
1621
+ {arcTargetOptions && arcTargetOptions.length > 0 && (
1622
+ <div>
1623
+ <label className="block text-sm font-medium mb-1">
1624
+ Apply to subscription(s)
1625
+ <span className="text-destructive ml-1">*</span>
1626
+ </label>
1627
+ <div className="space-y-1.5 rounded-md border bg-background px-3 py-2">
1628
+ {arcTargetOptions.map((opt) => {
1629
+ const checked = arcSelectedTargetIds.includes(opt.subscriptionId)
1630
+ return (
1631
+ <label
1632
+ key={opt.subscriptionId}
1633
+ className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/40 rounded px-1 py-0.5"
1634
+ >
1635
+ <input
1636
+ type="checkbox"
1637
+ checked={checked}
1638
+ onChange={() => onArcTargetToggle(opt.subscriptionId)}
1639
+ className="h-4 w-4 rounded border-border"
1640
+ />
1641
+ <span className="font-mono font-medium">{opt.code}</span>
1642
+ {opt.name && <span className="text-muted-foreground">— {opt.name}</span>}
1643
+ </label>
1644
+ )
1645
+ })}
1646
+ </div>
1647
+ <p className="text-xs text-muted-foreground mt-1">
1648
+ Pick one or more — a separate line will be created on each selected subscription.
1649
+ </p>
1650
+ {noTargetsPicked && (
1651
+ <p className="text-xs text-destructive mt-1">Select at least one target.</p>
1652
+ )}
1653
+ </div>
1654
+ )}
1655
+ <div>
1656
+ <label className="block text-sm font-medium mb-1">Quantity</label>
1657
+ <NumberInput
1658
+ integer
1659
+ min={1}
1660
+ value={quantity}
1661
+ onChange={(n) => onQuantityChange(Math.max(1, n ?? 1))}
1662
+ className="w-24"
1663
+ />
1664
+ </div>
1665
+ {attributes.length === 0 ? (
1666
+ <p className="text-sm text-muted-foreground">No configurable attributes for this offering.</p>
1667
+ ) : (
1668
+ attributes.map((attr) => <AttributeField key={attr.code} attribute={attr} value={config[attr.code]} onChange={(v) => onConfigChange(attr.code, v)} />)
1669
+ )}
1670
+ </div>
1671
+ <div className="border-t px-4 py-3 flex justify-end">
1672
+ <button onClick={onSubmit} disabled={submitting || noTargetsPicked}
1673
+ className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors disabled:opacity-50">
1674
+ {submitting ? <Spinner /> : null} {submitLabel}
1675
+ </button>
1676
+ </div>
1677
+ </div>
1678
+ )
1679
+ }
1680
+
1681
+ function AttributeField({ attribute, value, onChange }: { attribute: ConstrainedAttribute; value: unknown; onChange: (v: unknown) => void }) {
1682
+ const { code, name, attributeType, isRequired, options, constraints, helpText, defaultValue } = attribute
1683
+ const currentValue = value ?? defaultValue ?? ''
1684
+
1685
+ return (
1686
+ <div>
1687
+ <label className="block text-sm font-medium mb-1">
1688
+ {name}{isRequired && <span className="text-destructive ml-0.5">*</span>}
1689
+ </label>
1690
+ {(attributeType === 'select' || attributeType === 'enum') && options && options.length > 0 ? (
1691
+ <select value={String(currentValue)} onChange={(e) => onChange(e.target.value)}
1692
+ className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50">
1693
+ <option value="">— Select —</option>
1694
+ {options.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
1695
+ </select>
1696
+ ) : attributeType === 'boolean' ? (
1697
+ <label className="flex items-center gap-2">
1698
+ <input type="checkbox" checked={Boolean(currentValue)} onChange={(e) => onChange(e.target.checked)} className="rounded border-border" />
1699
+ <span className="text-sm text-muted-foreground">{helpText ?? 'Enable'}</span>
1700
+ </label>
1701
+ ) : attributeType === 'number' ? (
1702
+ <NumberInput
1703
+ value={
1704
+ typeof currentValue === 'number'
1705
+ ? currentValue
1706
+ : currentValue === '' || currentValue == null
1707
+ ? null
1708
+ : Number(currentValue)
1709
+ }
1710
+ min={(constraints as Record<string, unknown>)?.min as number | undefined}
1711
+ max={(constraints as Record<string, unknown>)?.max as number | undefined}
1712
+ onChange={(n) => onChange(n ?? '')}
1713
+ />
1714
+ ) : (
1715
+ <input type="text" value={String(currentValue)} onChange={(e) => onChange(e.target.value)}
1716
+ className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50" />
1717
+ )}
1718
+ {helpText && attributeType !== 'boolean' && <p className="text-xs text-muted-foreground mt-1">{helpText}</p>}
1719
+ </div>
1720
+ )
1721
+ }