@dainamite/cpq 0.5.0 → 0.7.0

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 (368) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/MIGRATION.md +74 -0
  3. package/dist/modules/cpq/acl.js +1 -0
  4. package/dist/modules/cpq/acl.js.map +2 -2
  5. package/dist/modules/cpq/api/_helpers/adjustmentCommandRoute.js +45 -0
  6. package/dist/modules/cpq/api/_helpers/adjustmentCommandRoute.js.map +7 -0
  7. package/dist/modules/cpq/api/_helpers/quoteMutationGuard.js +23 -0
  8. package/dist/modules/cpq/api/_helpers/quoteMutationGuard.js.map +7 -0
  9. package/dist/modules/cpq/api/_helpers/quoteRouteOpenApi.js +19 -0
  10. package/dist/modules/cpq/api/_helpers/quoteRouteOpenApi.js.map +7 -0
  11. package/dist/modules/cpq/api/orders/[orderId]/route.js +17 -1
  12. package/dist/modules/cpq/api/orders/[orderId]/route.js.map +2 -2
  13. package/dist/modules/cpq/api/orders/route.js +47 -2
  14. package/dist/modules/cpq/api/orders/route.js.map +2 -2
  15. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.js +54 -0
  16. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.js.map +7 -0
  17. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js +42 -2
  18. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js.map +2 -2
  19. package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js +27 -2
  20. package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js.map +2 -2
  21. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.js +47 -0
  22. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.js.map +7 -0
  23. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.js +30 -0
  24. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.js.map +7 -0
  25. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.js +47 -0
  26. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.js.map +7 -0
  27. package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js +32 -2
  28. package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js.map +2 -2
  29. package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js +33 -2
  30. package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js.map +2 -2
  31. package/dist/modules/cpq/api/quotes/price/route.js +43 -3
  32. package/dist/modules/cpq/api/quotes/price/route.js.map +2 -2
  33. package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js +3 -9
  34. package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js.map +2 -2
  35. package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js +21 -25
  36. package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js.map +2 -2
  37. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.js +48 -47
  38. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.js.map +2 -2
  39. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.js +1 -1
  40. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.js.map +2 -2
  41. package/dist/modules/cpq/backend/cpq/inventory/page.meta.js +3 -3
  42. package/dist/modules/cpq/backend/cpq/inventory/page.meta.js.map +2 -2
  43. package/dist/modules/cpq/backend/cpq/inventory/shared.js +37 -2
  44. package/dist/modules/cpq/backend/cpq/inventory/shared.js.map +2 -2
  45. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js +174 -184
  46. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js.map +2 -2
  47. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.js +1 -1
  48. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.js.map +2 -2
  49. package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js +168 -143
  50. package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js.map +2 -2
  51. package/dist/modules/cpq/backend/cpq/offerings/page.js +36 -34
  52. package/dist/modules/cpq/backend/cpq/offerings/page.js.map +2 -2
  53. package/dist/modules/cpq/backend/cpq/orders/[id]/page.js +140 -280
  54. package/dist/modules/cpq/backend/cpq/orders/[id]/page.js.map +2 -2
  55. package/dist/modules/cpq/backend/cpq/orders/page.js +6 -6
  56. package/dist/modules/cpq/backend/cpq/orders/page.js.map +2 -2
  57. package/dist/modules/cpq/backend/cpq/orders/page.meta.js +1 -1
  58. package/dist/modules/cpq/backend/cpq/orders/page.meta.js.map +1 -1
  59. package/dist/modules/cpq/backend/cpq/price-rules/PriceRuleForm.js +41 -36
  60. package/dist/modules/cpq/backend/cpq/price-rules/PriceRuleForm.js.map +2 -2
  61. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.js +76 -72
  62. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.js.map +2 -2
  63. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.meta.js +1 -1
  64. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.meta.js.map +2 -2
  65. package/dist/modules/cpq/backend/cpq/price-rules/page.js +18 -15
  66. package/dist/modules/cpq/backend/cpq/price-rules/page.js.map +2 -2
  67. package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js +23 -21
  68. package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js.map +2 -2
  69. package/dist/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.js +22 -50
  70. package/dist/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.js.map +2 -2
  71. package/dist/modules/cpq/backend/cpq/pricing/[id]/page.js +74 -52
  72. package/dist/modules/cpq/backend/cpq/pricing/[id]/page.js.map +2 -2
  73. package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.js +20 -17
  74. package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.js.map +2 -2
  75. package/dist/modules/cpq/backend/cpq/pricing/page.js +1 -1
  76. package/dist/modules/cpq/backend/cpq/pricing/page.js.map +2 -2
  77. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.js +115 -123
  78. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.js.map +2 -2
  79. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js +208 -0
  80. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js.map +7 -0
  81. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js +79 -0
  82. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js.map +7 -0
  83. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js +53 -0
  84. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js.map +7 -0
  85. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js +107 -0
  86. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js.map +7 -0
  87. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js +130 -0
  88. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js.map +7 -0
  89. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js +269 -311
  90. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js.map +3 -3
  91. package/dist/modules/cpq/backend/cpq/quotes/page.js +23 -19
  92. package/dist/modules/cpq/backend/cpq/quotes/page.js.map +2 -2
  93. package/dist/modules/cpq/backend/cpq/quotes/page.meta.js +1 -1
  94. package/dist/modules/cpq/backend/cpq/quotes/page.meta.js.map +1 -1
  95. package/dist/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.js +19 -15
  96. package/dist/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.js.map +2 -2
  97. package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js +171 -178
  98. package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js.map +2 -2
  99. package/dist/modules/cpq/backend/cpq/specifications/page.js +2 -2
  100. package/dist/modules/cpq/backend/cpq/specifications/page.js.map +2 -2
  101. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.js +60 -50
  102. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.js.map +2 -2
  103. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/page.js +22 -18
  104. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/page.js.map +2 -2
  105. package/dist/modules/cpq/backend/cpq/wizards/page.js +1 -1
  106. package/dist/modules/cpq/backend/cpq/wizards/page.js.map +2 -2
  107. package/dist/modules/cpq/ce.js +8 -0
  108. package/dist/modules/cpq/ce.js.map +2 -2
  109. package/dist/modules/cpq/cli.js +34 -1
  110. package/dist/modules/cpq/cli.js.map +2 -2
  111. package/dist/modules/cpq/commands/price-adjustments.js +107 -0
  112. package/dist/modules/cpq/commands/price-adjustments.js.map +7 -0
  113. package/dist/modules/cpq/components/ArcLineDiff.js +53 -0
  114. package/dist/modules/cpq/components/ArcLineDiff.js.map +7 -0
  115. package/dist/modules/cpq/components/ChargeBreakdownTable.js +148 -0
  116. package/dist/modules/cpq/components/ChargeBreakdownTable.js.map +7 -0
  117. package/dist/modules/cpq/components/PriceWaterfall.js +68 -0
  118. package/dist/modules/cpq/components/PriceWaterfall.js.map +7 -0
  119. package/dist/modules/cpq/components/PricingSummaryCard.js +112 -0
  120. package/dist/modules/cpq/components/PricingSummaryCard.js.map +7 -0
  121. package/dist/modules/cpq/components/StatusPath.js +5 -3
  122. package/dist/modules/cpq/components/StatusPath.js.map +2 -2
  123. package/dist/modules/cpq/components/useCpqRowActions.js +10 -22
  124. package/dist/modules/cpq/components/useCpqRowActions.js.map +2 -2
  125. package/dist/modules/cpq/components/useGrantedFeatures.js +27 -0
  126. package/dist/modules/cpq/components/useGrantedFeatures.js.map +7 -0
  127. package/dist/modules/cpq/data/entities.js +97 -0
  128. package/dist/modules/cpq/data/entities.js.map +2 -2
  129. package/dist/modules/cpq/data/price-adjustments.js +70 -0
  130. package/dist/modules/cpq/data/price-adjustments.js.map +7 -0
  131. package/dist/modules/cpq/data/validators.js +4 -0
  132. package/dist/modules/cpq/data/validators.js.map +2 -2
  133. package/dist/modules/cpq/di.js +6 -0
  134. package/dist/modules/cpq/di.js.map +2 -2
  135. package/dist/modules/cpq/events.js +47 -0
  136. package/dist/modules/cpq/events.js.map +2 -2
  137. package/dist/modules/cpq/i18n/de.json +1220 -0
  138. package/dist/modules/cpq/i18n/en.json +1220 -0
  139. package/dist/modules/cpq/i18n/es.json +1220 -0
  140. package/dist/modules/cpq/i18n/pl.json +1220 -0
  141. package/dist/modules/cpq/index.js +1 -1
  142. package/dist/modules/cpq/index.js.map +2 -2
  143. package/dist/modules/cpq/lib/auditActor.js +10 -0
  144. package/dist/modules/cpq/lib/auditActor.js.map +7 -0
  145. package/dist/modules/cpq/lib/formatMoney.js +7 -0
  146. package/dist/modules/cpq/lib/formatMoney.js.map +7 -0
  147. package/dist/modules/cpq/lib/priceAdjustmentDefaults.js +60 -0
  148. package/dist/modules/cpq/lib/priceAdjustmentDefaults.js.map +7 -0
  149. package/dist/modules/cpq/lib/priceAdjustmentView.js +169 -0
  150. package/dist/modules/cpq/lib/priceAdjustmentView.js.map +7 -0
  151. package/dist/modules/cpq/lib/quotePermissions.js +13 -0
  152. package/dist/modules/cpq/lib/quotePermissions.js.map +7 -0
  153. package/dist/modules/cpq/lib/quoteSummaryView.js +28 -0
  154. package/dist/modules/cpq/lib/quoteSummaryView.js.map +7 -0
  155. package/dist/modules/cpq/migrations/Migration20261110213052.js +22 -0
  156. package/dist/modules/cpq/migrations/Migration20261110213052.js.map +7 -0
  157. package/dist/modules/cpq/services/arcLineRemoval.js +53 -0
  158. package/dist/modules/cpq/services/arcLineRemoval.js.map +7 -0
  159. package/dist/modules/cpq/services/arcPriceFreeze.js +79 -0
  160. package/dist/modules/cpq/services/arcPriceFreeze.js.map +7 -0
  161. package/dist/modules/cpq/services/cpqOrderService.js +25 -28
  162. package/dist/modules/cpq/services/cpqOrderService.js.map +2 -2
  163. package/dist/modules/cpq/services/cpqPricingService.js +7 -0
  164. package/dist/modules/cpq/services/cpqPricingService.js.map +2 -2
  165. package/dist/modules/cpq/services/cpqQuotingService.js +330 -109
  166. package/dist/modules/cpq/services/cpqQuotingService.js.map +2 -2
  167. package/dist/modules/cpq/services/orderPricingSummary.js +26 -0
  168. package/dist/modules/cpq/services/orderPricingSummary.js.map +7 -0
  169. package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js +264 -0
  170. package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js.map +7 -0
  171. package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js +25 -0
  172. package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js.map +7 -0
  173. package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js +38 -0
  174. package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js.map +7 -0
  175. package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js +104 -0
  176. package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js.map +7 -0
  177. package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js +102 -0
  178. package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js.map +7 -0
  179. package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js +52 -0
  180. package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js.map +7 -0
  181. package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js +37 -0
  182. package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js.map +7 -0
  183. package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js +107 -0
  184. package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js.map +7 -0
  185. package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js +30 -0
  186. package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js.map +7 -0
  187. package/dist/modules/cpq/services/types.js.map +2 -2
  188. package/dist/modules/cpq/setup.js +6 -0
  189. package/dist/modules/cpq/setup.js.map +2 -2
  190. package/dist/modules/cpq/widgets/injection/customer-inventory/widget.client.js +39 -31
  191. package/dist/modules/cpq/widgets/injection/customer-inventory/widget.client.js.map +2 -2
  192. package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.client.js +13 -5
  193. package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.client.js.map +2 -2
  194. package/dist/modules/cpq/widgets/injection/quote-configurator/widget.client.js +41 -21
  195. package/dist/modules/cpq/widgets/injection/quote-configurator/widget.client.js.map +2 -2
  196. package/dist/modules/cpq/widgets/injection-table.js +2 -2
  197. package/dist/modules/cpq/widgets/injection-table.js.map +2 -2
  198. package/dist/modules/cpq/workflows/WizardRunner.js +10 -8
  199. package/dist/modules/cpq/workflows/WizardRunner.js.map +2 -2
  200. package/dist/modules/cpq/workflows/WizardTrigger.js +15 -9
  201. package/dist/modules/cpq/workflows/WizardTrigger.js.map +2 -2
  202. package/dist/modules/cpq/workflows/steps/CustomerSelectStep.js +9 -7
  203. package/dist/modules/cpq/workflows/steps/CustomerSelectStep.js.map +2 -2
  204. package/dist/modules/cpq/workflows/steps/InventorySelectStep.js +9 -4
  205. package/dist/modules/cpq/workflows/steps/InventorySelectStep.js.map +2 -2
  206. package/dist/modules/cpq/workflows/steps/ItemListStep.js +16 -19
  207. package/dist/modules/cpq/workflows/steps/ItemListStep.js.map +2 -2
  208. package/dist/modules/cpq/workflows/steps/OfferingSelectStep.js +19 -32
  209. package/dist/modules/cpq/workflows/steps/OfferingSelectStep.js.map +2 -2
  210. package/dist/modules/cpq/workflows/steps/ProductConfigureStep.js +8 -6
  211. package/dist/modules/cpq/workflows/steps/ProductConfigureStep.js.map +2 -2
  212. package/dist/modules/cpq/workflows/steps/ReviewStep.js +32 -30
  213. package/dist/modules/cpq/workflows/steps/ReviewStep.js.map +2 -2
  214. package/dist/modules/cpq/workflows/steps/index.js +12 -0
  215. package/dist/modules/cpq/workflows/steps/index.js.map +2 -2
  216. package/package.json +1 -1
  217. package/src/modules/cpq/__integration__/TC-CPQ-ARC-012.spec.ts +134 -0
  218. package/src/modules/cpq/__tests__/i18n/dictionaries.test.ts +62 -0
  219. package/src/modules/cpq/__tests__/i18n/hardcodedStrings.test.ts +35 -0
  220. package/src/modules/cpq/__tests__/i18n/sourceScan.ts +109 -0
  221. package/src/modules/cpq/acl.ts +1 -0
  222. package/src/modules/cpq/api/__tests__/adjustmentRoutes.test.ts +79 -0
  223. package/src/modules/cpq/api/__tests__/quoteWriteGuards.test.ts +100 -0
  224. package/src/modules/cpq/api/_helpers/__tests__/quoteMutationGuard.test.ts +48 -0
  225. package/src/modules/cpq/api/_helpers/adjustmentCommandRoute.ts +45 -0
  226. package/src/modules/cpq/api/_helpers/quoteMutationGuard.ts +38 -0
  227. package/src/modules/cpq/api/_helpers/quoteRouteOpenApi.ts +19 -0
  228. package/src/modules/cpq/api/orders/[orderId]/route.ts +18 -0
  229. package/src/modules/cpq/api/orders/route.ts +51 -1
  230. package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.ts +56 -0
  231. package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.ts +48 -1
  232. package/src/modules/cpq/api/quotes/[quoteId]/items/route.ts +31 -1
  233. package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.ts +39 -0
  234. package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.ts +24 -0
  235. package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.ts +40 -0
  236. package/src/modules/cpq/api/quotes/[quoteId]/recalculate/__tests__/route.test.ts +95 -0
  237. package/src/modules/cpq/api/quotes/[quoteId]/recalculate/route.ts +40 -1
  238. package/src/modules/cpq/api/quotes/[quoteId]/status/__tests__/route.test.ts +74 -0
  239. package/src/modules/cpq/api/quotes/[quoteId]/status/route.ts +37 -1
  240. package/src/modules/cpq/api/quotes/price/__tests__/route.test.ts +37 -0
  241. package/src/modules/cpq/api/quotes/price/route.ts +28 -2
  242. package/src/modules/cpq/backend/cpq/inventory/AssetsTable.tsx +3 -9
  243. package/src/modules/cpq/backend/cpq/inventory/SubscriptionsTable.tsx +22 -26
  244. package/src/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.ts +1 -1
  245. package/src/modules/cpq/backend/cpq/inventory/assets/[id]/page.tsx +43 -46
  246. package/src/modules/cpq/backend/cpq/inventory/page.meta.ts +3 -3
  247. package/src/modules/cpq/backend/cpq/inventory/shared.tsx +37 -1
  248. package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.ts +1 -1
  249. package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.tsx +164 -120
  250. package/src/modules/cpq/backend/cpq/offerings/[id]/page.tsx +169 -130
  251. package/src/modules/cpq/backend/cpq/offerings/page.tsx +34 -24
  252. package/src/modules/cpq/backend/cpq/orders/[id]/page.tsx +122 -308
  253. package/src/modules/cpq/backend/cpq/orders/page.meta.ts +1 -1
  254. package/src/modules/cpq/backend/cpq/orders/page.tsx +6 -6
  255. package/src/modules/cpq/backend/cpq/price-rules/PriceRuleForm.tsx +48 -34
  256. package/src/modules/cpq/backend/cpq/price-rules/[id]/page.meta.ts +1 -1
  257. package/src/modules/cpq/backend/cpq/price-rules/[id]/page.tsx +76 -70
  258. package/src/modules/cpq/backend/cpq/price-rules/page.tsx +25 -16
  259. package/src/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.tsx +24 -18
  260. package/src/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.tsx +29 -19
  261. package/src/modules/cpq/backend/cpq/pricing/[id]/page.tsx +95 -47
  262. package/src/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.ts +23 -15
  263. package/src/modules/cpq/backend/cpq/pricing/page.tsx +1 -1
  264. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.tsx +111 -88
  265. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.tsx +260 -0
  266. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.tsx +124 -0
  267. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/__tests__/PriceAdjustmentsPanel.test.tsx +76 -0
  268. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.tsx +76 -0
  269. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.tsx +133 -0
  270. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.tsx +165 -0
  271. package/src/modules/cpq/backend/cpq/quotes/[id]/page.tsx +304 -348
  272. package/src/modules/cpq/backend/cpq/quotes/page.meta.ts +1 -1
  273. package/src/modules/cpq/backend/cpq/quotes/page.tsx +29 -21
  274. package/src/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.tsx +19 -15
  275. package/src/modules/cpq/backend/cpq/specifications/[id]/page.tsx +171 -171
  276. package/src/modules/cpq/backend/cpq/specifications/page.tsx +2 -2
  277. package/src/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.tsx +51 -41
  278. package/src/modules/cpq/backend/cpq/wizards/[definitionCode]/page.tsx +17 -13
  279. package/src/modules/cpq/backend/cpq/wizards/page.tsx +1 -1
  280. package/src/modules/cpq/ce.ts +8 -0
  281. package/src/modules/cpq/cli.ts +48 -1
  282. package/src/modules/cpq/commands/__tests__/price-adjustments.test.ts +54 -0
  283. package/src/modules/cpq/commands/price-adjustments.ts +90 -0
  284. package/src/modules/cpq/components/ArcLineDiff.tsx +87 -0
  285. package/src/modules/cpq/components/ChargeBreakdownTable.tsx +203 -0
  286. package/src/modules/cpq/components/PriceWaterfall.tsx +96 -0
  287. package/src/modules/cpq/components/PricingSummaryCard.tsx +128 -0
  288. package/src/modules/cpq/components/StatusPath.tsx +7 -4
  289. package/src/modules/cpq/components/__tests__/PriceWaterfall.test.tsx +38 -0
  290. package/src/modules/cpq/components/useCpqRowActions.tsx +17 -25
  291. package/src/modules/cpq/components/useGrantedFeatures.ts +35 -0
  292. package/src/modules/cpq/data/__tests__/price-adjustment-migration.test.ts +11 -0
  293. package/src/modules/cpq/data/__tests__/price-adjustments.test.ts +26 -0
  294. package/src/modules/cpq/data/entities.ts +83 -0
  295. package/src/modules/cpq/data/price-adjustments.ts +67 -0
  296. package/src/modules/cpq/data/validators.ts +3 -0
  297. package/src/modules/cpq/di.ts +6 -0
  298. package/src/modules/cpq/events.ts +53 -2
  299. package/src/modules/cpq/i18n/de.json +1220 -0
  300. package/src/modules/cpq/i18n/en.json +1220 -0
  301. package/src/modules/cpq/i18n/es.json +1220 -0
  302. package/src/modules/cpq/i18n/pl.json +1220 -0
  303. package/src/modules/cpq/index.ts +1 -1
  304. package/src/modules/cpq/lib/__tests__/auditActor.test.ts +12 -0
  305. package/src/modules/cpq/lib/__tests__/priceAdjustmentDefaults.test.ts +37 -0
  306. package/src/modules/cpq/lib/__tests__/priceAdjustmentView.test.ts +152 -0
  307. package/src/modules/cpq/lib/__tests__/quotePermissions.test.ts +30 -0
  308. package/src/modules/cpq/lib/__tests__/quoteSummaryView.test.ts +56 -0
  309. package/src/modules/cpq/lib/auditActor.ts +13 -0
  310. package/src/modules/cpq/lib/formatMoney.ts +4 -0
  311. package/src/modules/cpq/lib/priceAdjustmentDefaults.ts +36 -0
  312. package/src/modules/cpq/lib/priceAdjustmentView.ts +259 -0
  313. package/src/modules/cpq/lib/quotePermissions.ts +22 -0
  314. package/src/modules/cpq/lib/quoteSummaryView.ts +51 -0
  315. package/src/modules/cpq/migrations/.snapshot-open-mercato.json +3298 -2423
  316. package/src/modules/cpq/migrations/Migration20261110213052.ts +22 -0
  317. package/src/modules/cpq/services/__tests__/_helpers/mockEm.ts +9 -2
  318. package/src/modules/cpq/services/__tests__/adjustmentService.test.ts +183 -0
  319. package/src/modules/cpq/services/__tests__/arcLineRemoval.test.ts +109 -0
  320. package/src/modules/cpq/services/__tests__/arcPriceFreeze.test.ts +141 -0
  321. package/src/modules/cpq/services/__tests__/chargeWaterfall.test.ts +28 -0
  322. package/src/modules/cpq/services/__tests__/cpqOrderService.test.ts +35 -2
  323. package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +57 -0
  324. package/src/modules/cpq/services/__tests__/cpqQuotingService.arc.test.ts +237 -0
  325. package/src/modules/cpq/services/__tests__/cpqQuotingService.test.ts +58 -3
  326. package/src/modules/cpq/services/__tests__/orderPricingSummary.test.ts +50 -0
  327. package/src/modules/cpq/services/__tests__/pricingSnapshot.test.ts +62 -0
  328. package/src/modules/cpq/services/__tests__/quoteAdjustmentLifecycle.test.ts +35 -0
  329. package/src/modules/cpq/services/__tests__/quoteAdjustmentPricing.test.ts +65 -0
  330. package/src/modules/cpq/services/__tests__/quoteCloneAdjustments.test.ts +45 -0
  331. package/src/modules/cpq/services/__tests__/quoteDetachTargetAdjustments.test.ts +54 -0
  332. package/src/modules/cpq/services/__tests__/quoteEventsService.test.ts +72 -0
  333. package/src/modules/cpq/services/__tests__/quotePricingEvents.test.ts +126 -0
  334. package/src/modules/cpq/services/__tests__/quotePricingIsolation.test.ts +52 -0
  335. package/src/modules/cpq/services/__tests__/quotePricingWrite.test.ts +74 -0
  336. package/src/modules/cpq/services/__tests__/resolveManualAdjustments.test.ts +90 -0
  337. package/src/modules/cpq/services/__tests__/storedAdjustmentFailure.test.ts +9 -0
  338. package/src/modules/cpq/services/arcLineRemoval.ts +87 -0
  339. package/src/modules/cpq/services/arcPriceFreeze.ts +165 -0
  340. package/src/modules/cpq/services/cpqOrderService.ts +22 -28
  341. package/src/modules/cpq/services/cpqPricingService.ts +8 -0
  342. package/src/modules/cpq/services/cpqQuotingService.ts +374 -125
  343. package/src/modules/cpq/services/orderPricingSummary.ts +47 -0
  344. package/src/modules/cpq/services/priceAdjustments/adjustmentService.ts +249 -0
  345. package/src/modules/cpq/services/priceAdjustments/chargeWaterfall.ts +28 -0
  346. package/src/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.ts +25 -0
  347. package/src/modules/cpq/services/priceAdjustments/pricingSnapshot.ts +86 -0
  348. package/src/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.ts +68 -0
  349. package/src/modules/cpq/services/priceAdjustments/quotePricingEvents.ts +94 -0
  350. package/src/modules/cpq/services/priceAdjustments/quotePricingWrite.ts +51 -0
  351. package/src/modules/cpq/services/priceAdjustments/resolveManualAdjustments.ts +116 -0
  352. package/src/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.ts +31 -0
  353. package/src/modules/cpq/services/types.ts +34 -1
  354. package/src/modules/cpq/setup.ts +7 -1
  355. package/src/modules/cpq/widgets/injection/customer-inventory/widget.client.tsx +38 -31
  356. package/src/modules/cpq/widgets/injection/order-cpq-tab/widget.client.tsx +7 -5
  357. package/src/modules/cpq/widgets/injection/quote-configurator/widget.client.tsx +36 -21
  358. package/src/modules/cpq/widgets/injection-table.ts +8 -2
  359. package/src/modules/cpq/workflows/WizardRunner.tsx +8 -5
  360. package/src/modules/cpq/workflows/WizardTrigger.tsx +15 -9
  361. package/src/modules/cpq/workflows/steps/CustomerSelectStep.tsx +11 -7
  362. package/src/modules/cpq/workflows/steps/InventorySelectStep.tsx +9 -5
  363. package/src/modules/cpq/workflows/steps/ItemListStep.tsx +18 -12
  364. package/src/modules/cpq/workflows/steps/OfferingSelectStep.tsx +20 -16
  365. package/src/modules/cpq/workflows/steps/ProductConfigureStep.tsx +8 -6
  366. package/src/modules/cpq/workflows/steps/ReviewStep.tsx +33 -18
  367. package/src/modules/cpq/workflows/steps/index.ts +12 -0
  368. package/src/modules/cpq/workflows/types.ts +4 -0
@@ -1,17 +1,29 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
+ import { useT, type TranslateFn } from '@open-mercato/shared/lib/i18n/context'
3
4
  import { useRouter, useParams, useSearchParams } from 'next/navigation'
4
5
  import ArcQuoteConfigurator, {
5
6
  type AttachedTarget,
6
7
  } from './_components/ArcQuoteConfigurator'
7
8
  import { StatusPath } from '../../../../components/StatusPath'
8
- import { ArrowRight, ExternalLink, Plus } from 'lucide-react'
9
+ import { ArrowRight, ExternalLink, Plus, RotateCcw } from 'lucide-react'
9
10
  import { Alert, AlertTitle, AlertDescription } from '@open-mercato/ui/primitives/alert'
10
11
  import { Button } from '@open-mercato/ui/primitives/button'
11
12
  import { Tag } from '@open-mercato/ui/primitives/tag'
12
13
  import { FormHeader } from '@open-mercato/ui/backend/forms'
13
14
  import type { ActionItem } from '@open-mercato/ui/backend/forms/ActionsDropdown'
14
15
  import { NumberInput } from '../../../../components/NumberInput'
16
+ import { ChargeBreakdownTable } from '../../../../components/ChargeBreakdownTable'
17
+ import { PricingSummaryCard } from '../../../../components/PricingSummaryCard'
18
+ import { ArcLineDiff, LineActionBadge } from '../../../../components/ArcLineDiff'
19
+ import { ADJUST_PRICE_FEATURE } from '../../../../lib/priceAdjustmentView'
20
+ import { arcRecurringDelta } from '../../../../lib/quoteSummaryView'
21
+ import { usePriceAdjustments } from './_components/usePriceAdjustments'
22
+ import { PriceAdjustmentsPanel } from './_components/PriceAdjustmentsPanel'
23
+ import { ArcLineRemovalProvider, useArcLineRemoval, useArcLineRemovalUi } from './_components/arcLineRemoval'
24
+ import { AdjustChargeAction, DiscountLineAction, ManualAdjustmentActions, PriceAdjustmentProvider, usePriceAdjustmentUi } from './_components/priceAdjustmentContext'
25
+ import { useGrantedFeatures } from '../../../../components/useGrantedFeatures'
26
+ import { canRecalculateQuote, QUOTE_MANAGE_FEATURE } from '../../../../lib/quotePermissions'
15
27
  import {
16
28
  formatStatusLabel,
17
29
  quoteCpqStatusMap,
@@ -32,7 +44,12 @@ type ConstrainedAttribute = {
32
44
  }
33
45
 
34
46
  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 }
47
+ type RuleAdjustment = {
48
+ ruleCode: string; ruleName: string; ruleType: string; value: number; unitPriceBefore: number; unitPriceAfter: number; delta: number
49
+ /** Negotiated-pricing fields; absent on legacy snapshots (treated as rules). */
50
+ origin?: 'rule' | 'manual'; adjustmentId?: string; reasonCode?: string; createdByUserId?: string
51
+ }
52
+ type AdjustmentErrorEntry = { adjustmentId: string; quoteLineId: string; chargeCode?: string; code: string }
36
53
 
37
54
  type ResolvedCharge = {
38
55
  chargeCode: string
@@ -46,6 +63,11 @@ type ResolvedCharge = {
46
63
  breakdown?: { tiers: TierBreakdownEntry[] } | null
47
64
  note?: string | null
48
65
  adjustments?: RuleAdjustment[] | null
66
+ listUnitPrice?: number
67
+ listTotalPrice?: number | null
68
+ standardUnitPrice?: number
69
+ standardTotalPrice?: number | null
70
+ adjustmentErrors?: AdjustmentErrorEntry[]
49
71
  }
50
72
 
51
73
  type QuoteLineAction = 'add' | 'modify' | 'cancel'
@@ -61,6 +83,7 @@ type ArcLineSource = {
61
83
  type QuoteLine = {
62
84
  lineId: string
63
85
  offeringId: string | null
86
+ productId?: string | null
64
87
  offeringName: string
65
88
  offeringType: string | null
66
89
  parentLineId: string | null
@@ -84,15 +107,23 @@ type QuoteResult = {
84
107
  customerName: string | null
85
108
  cpqStatus: string
86
109
  version: number
110
+ pricingRevision?: number
87
111
  currencyCode: string
88
112
  convertedOrderId: string | null
89
113
  validationResult: { valid: boolean; errors: Array<{ message: string }> }
114
+ adjustmentErrors?: AdjustmentErrorEntry[]
90
115
  pricingSummary: {
91
116
  nrcTotal: number
92
117
  mrcTotal: number
93
118
  usageCharges: Array<{ chargeCode: string; chargeName: string; unitPrice: number; note: string | null }>
94
119
  usageTotalEstimated: number | null
95
120
  currencyCode: string
121
+ listNrcTotal?: number
122
+ listMrcTotal?: number
123
+ dealValue?: { list: number; standard: number; offer: number }
124
+ discountDepth?: { rule: number | null; manual: number | null; total: number | null; maxLineManual: number | null }
125
+ ruleDiscountTotal?: number
126
+ manualDiscountTotal?: number
96
127
  }
97
128
  lines: QuoteLine[]
98
129
  }
@@ -138,27 +169,26 @@ function fmt(amount: number, currency: string): string {
138
169
  return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
139
170
  }
140
171
 
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> {
172
+ async function apiJson<T>(t: TranslateFn, url: string, init?: RequestInit): Promise<T> {
156
173
  const res = await fetch(url, init)
157
174
  const json = await res.json()
158
- if (!res.ok) throw new Error(json?.error ?? `Request failed (${res.status})`)
175
+ if (!res.ok) throw new Error(json?.error ?? t('cpq.common.requestFailed', 'Request failed ({status})', { status: res.status }))
159
176
  return json as T
160
177
  }
161
178
 
179
+ function quoteTypeLabel(t: TranslateFn, quoteType: string): string {
180
+ switch (quoteType) {
181
+ case 'amend':
182
+ return t('cpq.quotes.type.amend', 'amend')
183
+ case 'renew':
184
+ return t('cpq.quotes.type.renew', 'renew')
185
+ case 'cancel':
186
+ return t('cpq.quotes.type.cancel', 'cancel')
187
+ default:
188
+ return quoteType
189
+ }
190
+ }
191
+
162
192
  // ─── Header sub-components ─────────────────────────────────────
163
193
 
164
194
  function MetaCard({ label, children }: { label: string; children: React.ReactNode }) {
@@ -172,12 +202,15 @@ function MetaCard({ label, children }: { label: string; children: React.ReactNod
172
202
  )
173
203
  }
174
204
 
205
+ // Pricing-summary figure: label, monospace amount, and an optional smaller
206
+ // bracketed note (discount depth / change %) inline after the amount.
175
207
  function MoreActionsDropdown({ items }: { items: ActionItem[] }) {
176
208
  // Local dropdown — `ActionsDropdown` inside FormHeader.actionsContent
177
209
  // would conflict with the inline button placement. This is a thin
178
210
  // <details>-style trigger; menuActions on FormHeader would render it
179
211
  // inside the header layout, but here we want it adjacent to other
180
212
  // primary buttons.
213
+ const t = useT()
181
214
  const [open, setOpen] = React.useState(false)
182
215
  const ref = React.useRef<HTMLDivElement>(null)
183
216
  React.useEffect(() => {
@@ -193,7 +226,7 @@ function MoreActionsDropdown({ items }: { items: ActionItem[] }) {
193
226
  return (
194
227
  <div className="relative" ref={ref}>
195
228
  <Button type="button" variant="outline" onClick={() => setOpen((v) => !v)}>
196
- Actions
229
+ {t('cpq.quotes.detail.actions', 'Actions')}
197
230
  <svg className="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor">
198
231
  <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
199
232
  </svg>
@@ -230,31 +263,38 @@ function MoreActionsDropdown({ items }: { items: ActionItem[] }) {
230
263
  }
231
264
 
232
265
  function buildMoreActions({
266
+ t,
233
267
  recalculate,
268
+ canRecalculate,
234
269
  submitting,
235
270
  deletable,
236
271
  onDelete,
237
272
  deleting,
238
273
  }: {
274
+ t: TranslateFn
239
275
  recalculate: () => void
276
+ canRecalculate: boolean
240
277
  submitting: boolean
241
278
  deletable: boolean
242
279
  onDelete: () => void
243
280
  deleting: boolean
244
281
  }): ActionItem[] {
245
- const items: ActionItem[] = [
246
- {
282
+ const items: ActionItem[] = []
283
+ // Recalculate persists prices: offered only to users with cpq.quotes.manage
284
+ // and only on non-terminal quotes (the API refuses both otherwise).
285
+ if (canRecalculate) {
286
+ items.push({
247
287
  id: 'recalculate',
248
- label: 'Recalculate',
288
+ label: t('cpq.quotes.detail.recalculate', 'Recalculate'),
249
289
  onSelect: recalculate,
250
290
  loading: submitting,
251
291
  disabled: submitting,
252
- },
253
- ]
292
+ })
293
+ }
254
294
  if (deletable) {
255
295
  items.push({
256
296
  id: 'delete',
257
- label: 'Delete',
297
+ label: t('cpq.common.delete', 'Delete'),
258
298
  onSelect: onDelete,
259
299
  disabled: deleting,
260
300
  loading: deleting,
@@ -272,7 +312,9 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
272
312
  const cpqConfigId = (props.params?.id ?? urlParams?.id) as string
273
313
  const isNew = cpqConfigId === 'new'
274
314
 
315
+ const t = useT()
275
316
  const [cpqQuote, setCpqQuote] = React.useState<QuoteResult | null>(null)
317
+ const grantedFeatures = useGrantedFeatures([QUOTE_MANAGE_FEATURE, ADJUST_PRICE_FEATURE])
276
318
  const [loading, setLoading] = React.useState(true)
277
319
  const [error, setError] = React.useState<string | null>(null)
278
320
  const [submitting, setSubmitting] = React.useState(false)
@@ -327,6 +369,22 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
327
369
  void loadArcTargets()
328
370
  }, [cpqConfigId, isNew, loadArcTargets])
329
371
 
372
+ // ─── Negotiated prices ─────────────────────────────────────────
373
+ const reloadQuote = React.useCallback(async () => {
374
+ try {
375
+ setCpqQuote(await apiJson<QuoteResult>(t, `/api/cpq/quotes/${cpqConfigId}`))
376
+ } catch (err) {
377
+ setError((err as Error).message)
378
+ }
379
+ }, [cpqConfigId, t])
380
+ const priceAdjustments = usePriceAdjustments<QuoteResult>({
381
+ quote: isNew ? null : cpqQuote,
382
+ quoteType: arcQuoteType,
383
+ canAdjust: grantedFeatures.includes(QUOTE_MANAGE_FEATURE) && grantedFeatures.includes(ADJUST_PRICE_FEATURE),
384
+ onQuote: setCpqQuote,
385
+ reloadQuote,
386
+ })
387
+
330
388
  // ─── Load or create ──────────────────────────────────────────
331
389
 
332
390
  React.useEffect(() => {
@@ -335,7 +393,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
335
393
  const quoteId = searchParams.get('quoteId')
336
394
  const customerId = searchParams.get('customerId')
337
395
  if (!customerId) {
338
- setError('customerId is required to create a CPQ quote')
396
+ setError(t('cpq.quotes.detail.customerIdRequired', 'customerId is required to create a CPQ quote'))
339
397
  setLoading(false)
340
398
  return
341
399
  }
@@ -343,7 +401,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
343
401
  try {
344
402
  const body: Record<string, string> = { customerId: customerId! }
345
403
  if (quoteId) body.quoteId = quoteId
346
- const result = await apiJson<QuoteResult>('/api/cpq/quotes', {
404
+ const result = await apiJson<QuoteResult>(t, '/api/cpq/quotes', {
347
405
  method: 'POST',
348
406
  headers: { 'Content-Type': 'application/json' },
349
407
  body: JSON.stringify(body),
@@ -361,7 +419,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
361
419
  let cancelled = false
362
420
  async function load() {
363
421
  try {
364
- const result = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqConfigId}`)
422
+ const result = await apiJson<QuoteResult>(t, `/api/cpq/quotes/${cpqConfigId}`)
365
423
  if (!cancelled) setCpqQuote(result)
366
424
  } catch (err) {
367
425
  if (!cancelled) setError((err as Error).message)
@@ -371,14 +429,14 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
371
429
  }
372
430
  load()
373
431
  return () => { cancelled = true }
374
- }, [cpqConfigId, isNew, searchParams, router])
432
+ }, [cpqConfigId, isNew, searchParams, router, t])
375
433
 
376
434
  // ─── Actions ─────────────────────────────────────────────────
377
435
 
378
436
  const loadOfferings = async () => {
379
437
  setOfferingsLoading(true)
380
438
  try {
381
- const result = await apiJson<{ items: Offering[] }>('/api/cpq/product-offerings?pageSize=100&lifecycleStatus=active')
439
+ const result = await apiJson<{ items: Offering[] }>(t, '/api/cpq/product-offerings?pageSize=100&lifecycleStatus=active')
382
440
  setOfferings(result.items ?? [])
383
441
  } catch { setOfferings([]) }
384
442
  finally { setOfferingsLoading(false) }
@@ -388,7 +446,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
388
446
  try {
389
447
  const params = new URLSearchParams({ offeringId })
390
448
  if (Object.keys(currentConfig).length > 0) params.set('configuration', JSON.stringify(currentConfig))
391
- const result = await apiJson<{ attributes: ConstrainedAttribute[] }>(`/api/cpq/quotes/attributes?${params}`)
449
+ const result = await apiJson<{ attributes: ConstrainedAttribute[] }>(t, `/api/cpq/quotes/attributes?${params}`)
392
450
  setAttributes(result.attributes ?? [])
393
451
  } catch { setAttributes([]) }
394
452
  }
@@ -407,7 +465,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
407
465
  const body: Record<string, unknown> = { offeringId: selectedOffering.id, configuration: config, quantity }
408
466
  if (addingToParentLineId) body.parentLineId = addingToParentLineId
409
467
  if (tid) body.targetSubscriptionId = tid
410
- lastResult = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqQuote.id}/items`, {
468
+ lastResult = await apiJson<QuoteResult>(t, `/api/cpq/quotes/${cpqQuote.id}/items`, {
411
469
  method: 'POST',
412
470
  headers: { 'Content-Type': 'application/json' },
413
471
  body: JSON.stringify(body),
@@ -424,7 +482,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
424
482
  if (!cpqQuote) return
425
483
  try {
426
484
  setSubmitting(true); setError(null)
427
- const result = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqQuote.id}/items/${lineId}`, {
485
+ const result = await apiJson<QuoteResult>(t, `/api/cpq/quotes/${cpqQuote.id}/items/${lineId}`, {
428
486
  method: 'PUT',
429
487
  headers: { 'Content-Type': 'application/json' },
430
488
  body: JSON.stringify({ configuration: config, quantity }),
@@ -436,20 +494,21 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
436
494
  finally { setSubmitting(false) }
437
495
  }
438
496
 
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
- }
497
+ // Amend / Renew: removing an existing subscription item confirms first and
498
+ // marks it for removal (a `cancel` line) instead of deleting the line.
499
+ const lineRemoval = useArcLineRemoval<QuoteResult>({
500
+ quote: isNew ? null : cpqQuote,
501
+ quoteType: arcQuoteType,
502
+ onQuote: setCpqQuote,
503
+ onError: setError,
504
+ })
505
+ const removeItem = lineRemoval.remove
447
506
 
448
507
  const recalculate = async () => {
449
508
  if (!cpqQuote) return
450
509
  try {
451
510
  setSubmitting(true); setError(null)
452
- const result = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqQuote.id}/recalculate`, {
511
+ const result = await apiJson<QuoteResult>(t, `/api/cpq/quotes/${cpqQuote.id}/recalculate`, {
453
512
  method: 'POST',
454
513
  headers: { 'Content-Type': 'application/json' },
455
514
  body: JSON.stringify({ save: true }),
@@ -463,7 +522,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
463
522
  if (!cpqQuote) return
464
523
  try {
465
524
  setConverting(true); setError(null)
466
- const result = await apiJson<{ id: string }>('/api/cpq/orders', {
525
+ const result = await apiJson<{ id: string }>(t, '/api/cpq/orders', {
467
526
  method: 'POST',
468
527
  headers: { 'Content-Type': 'application/json' },
469
528
  body: JSON.stringify({ quoteId: cpqQuote.id }),
@@ -477,7 +536,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
477
536
  if (!cpqQuote) return
478
537
  try {
479
538
  setTransitioning(true); setError(null)
480
- const result = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqQuote.id}/status`, {
539
+ const result = await apiJson<QuoteResult>(t, `/api/cpq/quotes/${cpqQuote.id}/status`, {
481
540
  method: 'POST',
482
541
  headers: { 'Content-Type': 'application/json' },
483
542
  body: JSON.stringify({ targetStatus }),
@@ -491,7 +550,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
491
550
  if (!cpqQuote) return
492
551
  try {
493
552
  setDeleting(true); setError(null); setShowDeleteConfirm(false)
494
- await apiJson<{ ok: boolean }>(`/api/cpq/quotes/${cpqQuote.id}`, { method: 'DELETE' })
553
+ await apiJson<{ ok: boolean }>(t, `/api/cpq/quotes/${cpqQuote.id}`, { method: 'DELETE' })
495
554
  router.push('/backend/cpq/quotes')
496
555
  } catch (err) { setError((err as Error).message) }
497
556
  finally { setDeleting(false) }
@@ -529,7 +588,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
529
588
  setAddingTargetSubIds(inherited ? [inherited] : [])
530
589
  if (offeringId) {
531
590
  try {
532
- const result = await apiJson<{ items: Offering[] }>('/api/cpq/product-offerings?pageSize=100&lifecycleStatus=active')
591
+ const result = await apiJson<{ items: Offering[] }>(t, '/api/cpq/product-offerings?pageSize=100&lifecycleStatus=active')
533
592
  const match = (result.items ?? []).find((o) => o.id === offeringId)
534
593
  if (match) {
535
594
  selectOffering(match)
@@ -564,7 +623,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
564
623
  if (loading) {
565
624
  return (
566
625
  <div className="flex items-center gap-2 py-12 justify-center text-sm text-muted-foreground">
567
- <Spinner /> Loading…
626
+ <Spinner /> {t('cpq.quotes.detail.loading', 'Loading…')}
568
627
  </div>
569
628
  )
570
629
  }
@@ -572,9 +631,9 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
572
631
  if (!cpqQuote) {
573
632
  return (
574
633
  <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>
634
+ <button onClick={() => router.push('/backend/cpq/quotes')} className="text-sm text-muted-foreground hover:text-foreground">{t('cpq.quotes.detail.backToQuotes', '← Back to Quotes')}</button>
576
635
  {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>
636
+ <div className="rounded-lg border bg-card p-6 text-center text-sm text-muted-foreground">{t('cpq.quotes.detail.notFound', 'Quote not found.')}</div>
578
637
  </div>
579
638
  )
580
639
  }
@@ -582,6 +641,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
582
641
  // ─── Render: detail ──────────────────────────────────────────
583
642
 
584
643
  const { pricingSummary: summary, currencyCode: currency } = cpqQuote
644
+ const arcDelta = arcQuoteType === 'amend' || arcQuoteType === 'renew' ? arcRecurringDelta(cpqQuote.lines) : null
585
645
 
586
646
  const arcEditable = ['new', 'incomplete', 'ready'].includes(cpqQuote.cpqStatus)
587
647
  const isMergeRenew =
@@ -601,7 +661,11 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
601
661
  }
602
662
 
603
663
  return (
664
+ <PriceAdjustmentProvider value={priceAdjustments.ui}>
665
+ <ArcLineRemovalProvider value={lineRemoval.ui}>
604
666
  <div className="space-y-6">
667
+ {priceAdjustments.elements}
668
+ {lineRemoval.ConfirmDialogElement}
605
669
  {arcDrawerOpen && (
606
670
  <ArcQuoteConfigurator
607
671
  quoteId={cpqQuote.id}
@@ -618,7 +682,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
618
682
  // items from new targets (XD-250 multi-target ARC), so refetch
619
683
  // the full quote to pick up the new lines without a hard refresh.
620
684
  try {
621
- const fresh = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqQuote.id}`)
685
+ const fresh = await apiJson<QuoteResult>(t, `/api/cpq/quotes/${cpqQuote.id}`)
622
686
  setCpqQuote(fresh)
623
687
  } catch {
624
688
  /* ignore — loadArcTargets already surfaced any error */
@@ -634,18 +698,18 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
634
698
  <FormHeader
635
699
  mode="detail"
636
700
  backHref="/backend/cpq/quotes"
637
- backLabel="Back to CPQ Quotes"
638
- entityTypeLabel="CPQ Quote"
639
- title={cpqQuote.quoteNumber || cpqQuote.customerName || 'Untitled'}
701
+ backLabel={t('cpq.quotes.detail.backLabel', 'Back to CPQ Quotes')}
702
+ entityTypeLabel={t('cpq.quotes.detail.entityType', 'CPQ Quote')}
703
+ title={cpqQuote.quoteNumber || cpqQuote.customerName || t('cpq.quotes.detail.untitled', 'Untitled')}
640
704
  statusBadge={
641
705
  <div className="flex flex-wrap items-center gap-2 pt-1">
642
706
  <Tag variant={quoteCpqStatusMap[cpqQuote.cpqStatus as QuoteCpqStatus] ?? 'neutral'} dot>
643
707
  {formatStatusLabel(cpqQuote.cpqStatus)}
644
708
  </Tag>
645
709
  {arcQuoteType !== 'new' && (
646
- <Tag variant="brand" className="uppercase">{arcQuoteType}</Tag>
710
+ <Tag variant="brand" className="uppercase">{quoteTypeLabel(t, arcQuoteType)}</Tag>
647
711
  )}
648
- {cpqQuote.convertedOrderId && <Tag variant="success" dot>Converted</Tag>}
712
+ {cpqQuote.convertedOrderId && <Tag variant="success" dot>{t('cpq.quotes.detail.converted', 'Converted')}</Tag>}
649
713
  </div>
650
714
  }
651
715
  // FormHeader.detail ignores `utilityActions` whenever `actionsContent`
@@ -659,7 +723,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
659
723
  onClick={() => router.push(`/backend/sales/quotes/${cpqQuote.quoteId}`)}
660
724
  >
661
725
  <ExternalLink className="h-3.5 w-3.5" />
662
- View Sales Quote
726
+ {t('cpq.quotes.detail.viewSalesQuote', 'View Sales Quote')}
663
727
  </Button>
664
728
  {cpqQuote.convertedOrderId ? (
665
729
  <Button
@@ -667,32 +731,34 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
667
731
  onClick={() => router.push(`/backend/cpq/orders/${cpqQuote.convertedOrderId}`)}
668
732
  >
669
733
  <ArrowRight className="h-4 w-4" />
670
- Go to order
734
+ {t('cpq.quotes.detail.goToOrder', 'Go to order')}
671
735
  </Button>
672
736
  ) : view !== 'summary' ? (
673
737
  <Button type="button" variant="outline" onClick={() => { resetConfigState(); setView('summary') }}>
674
- Cancel
738
+ {t('cpq.common.cancel', 'Cancel')}
675
739
  </Button>
676
740
  ) : (
677
741
  <>
678
742
  <Button type="button" onClick={startAddOffering}>
679
743
  <Plus className="h-4 w-4" />
680
- Add Offering
744
+ {t('cpq.quotes.detail.addOffering', 'Add Offering')}
681
745
  </Button>
682
746
  {arcEditable && (
683
747
  <Button type="button" variant="outline" onClick={() => setArcDrawerOpen(true)}>
684
- Modify Subscription
748
+ {t('cpq.quotes.detail.modifySubscription', 'Modify Subscription')}
685
749
  </Button>
686
750
  )}
687
751
  {cpqQuote.cpqStatus === 'accepted' && (
688
752
  <Button type="button" onClick={convertToOrder} disabled={converting}>
689
753
  {converting ? <Spinner /> : <ArrowRight className="h-4 w-4" />}
690
- Convert to Order
754
+ {t('cpq.quotes.detail.convertToOrder', 'Convert to Order')}
691
755
  </Button>
692
756
  )}
693
757
  <MoreActionsDropdown
694
758
  items={buildMoreActions({
759
+ t,
695
760
  recalculate,
761
+ canRecalculate: canRecalculateQuote(grantedFeatures, cpqQuote.cpqStatus),
696
762
  submitting,
697
763
  deletable: !['accepted', 'rejected', 'cancelled'].includes(cpqQuote.cpqStatus),
698
764
  onDelete: () => setShowDeleteConfirm(true),
@@ -733,7 +799,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
733
799
  statusMap={quoteCpqStatusMap}
734
800
  onTransition={transitionStatus}
735
801
  disabled={transitioning}
736
- ariaLabel="Quote status path"
802
+ ariaLabel={t('cpq.quotes.detail.statusPath', 'Quote status path')}
737
803
  />
738
804
  {transitioning && <Spinner />}
739
805
  </div>
@@ -744,9 +810,11 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
744
810
  <div className="rounded-md border border-brand-violet/30 bg-brand-violet/10 px-4 py-3 text-sm text-brand-violet">
745
811
  <div className="flex items-center justify-between">
746
812
  <div>
747
- <span className="font-medium uppercase">{arcQuoteType}</span>
813
+ <span className="font-medium uppercase">{quoteTypeLabel(t, arcQuoteType)}</span>
748
814
  {' • '}
749
- {arcTargets.length} target{arcTargets.length === 1 ? '' : 's'}:
815
+ {arcTargets.length === 1
816
+ ? t('cpq.quotes.detail.arcTargets.one', '{count} target:', { count: arcTargets.length })
817
+ : t('cpq.quotes.detail.arcTargets.other', '{count} targets:', { count: arcTargets.length })}
750
818
  {' '}
751
819
  {arcTargets.map((t, i) => (
752
820
  <span key={t.id}>
@@ -763,7 +831,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
763
831
  ))}
764
832
  {isMergeRenew && (
765
833
  <span className="ml-2 text-status-warning-text font-medium">
766
- • Merging into a new contract
834
+ {t('cpq.quotes.detail.mergingRenew', '• Merging into a new contract')}
767
835
  </span>
768
836
  )}
769
837
  </div>
@@ -775,7 +843,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
775
843
  currency). Mirrors the sidebar tiles in the standard
776
844
  sales-document detail layout. */}
777
845
  <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
778
- <MetaCard label="Customer">
846
+ <MetaCard label={t('cpq.quotes.detail.meta.customer', 'Customer')}>
779
847
  {cpqQuote.customerName ? (
780
848
  <span className="font-medium">{cpqQuote.customerName}</span>
781
849
  ) : (
@@ -784,10 +852,10 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
784
852
  </span>
785
853
  )}
786
854
  </MetaCard>
787
- <MetaCard label="Version">
855
+ <MetaCard label={t('cpq.quotes.detail.meta.version', 'Version')}>
788
856
  <span className="font-medium">v{cpqQuote.version}</span>
789
857
  </MetaCard>
790
- <MetaCard label="Currency">
858
+ <MetaCard label={t('cpq.quotes.detail.meta.currency', 'Currency')}>
791
859
  <span className="font-medium">{currency}</span>
792
860
  </MetaCard>
793
861
  </div>
@@ -796,17 +864,17 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
796
864
  <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowDeleteConfirm(false)}>
797
865
  <div className="w-full max-w-sm rounded-lg border bg-card shadow-xl" onClick={(e) => e.stopPropagation()}>
798
866
  <div className="px-4 py-3 border-b">
799
- <h3 className="text-sm font-medium">Delete Quote</h3>
867
+ <h3 className="text-sm font-medium">{t('cpq.quotes.detail.deleteConfirm.title', 'Delete Quote')}</h3>
800
868
  </div>
801
869
  <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>
870
+ <p className="text-sm text-muted-foreground">{t('cpq.quotes.detail.deleteConfirm.text', 'Are you sure you want to delete this quote? This will soft-delete the quote and all its line items.')}</p>
803
871
  </div>
804
872
  <div className="flex justify-end gap-2 px-4 py-3 border-t">
805
873
  <Button type="button" variant="outline" onClick={() => setShowDeleteConfirm(false)}>
806
- Cancel
874
+ {t('cpq.common.cancel', 'Cancel')}
807
875
  </Button>
808
876
  <Button type="button" variant="destructive" onClick={deleteQuote} disabled={deleting}>
809
- {deleting ? <Spinner /> : null} Delete
877
+ {deleting ? <Spinner /> : null} {t('cpq.common.delete', 'Delete')}
810
878
  </Button>
811
879
  </div>
812
880
  </div>
@@ -816,39 +884,12 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
816
884
  {/* Summary view */}
817
885
  {view === 'summary' && (
818
886
  <>
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>
887
+ <PricingSummaryCard summary={summary} currency={currency} arcDelta={arcDelta} />
847
888
 
848
889
  {/* Validation errors */}
849
890
  {!cpqQuote.validationResult.valid && (
850
891
  <Alert variant="warning">
851
- <AlertTitle>Validation Issues</AlertTitle>
892
+ <AlertTitle>{t('cpq.quotes.detail.validationIssues', 'Validation Issues')}</AlertTitle>
852
893
  <AlertDescription>
853
894
  {/* Stacked <span>s rather than <ul>/<li>: before Open Mercato
854
895
  0.8 AlertDescription rendered a <p>, where a list is invalid
@@ -863,6 +904,23 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
863
904
  </Alert>
864
905
  )}
865
906
 
907
+ {/* Amend lines the rep reconfigured are re-priced at today's catalog
908
+ price (server marks them `_arcRepriced`); negotiated prices are
909
+ not carried over yet (later release). */}
910
+ {arcQuoteType === 'amend' && cpqQuote.lines.some((l) => l.action !== 'cancel' && l.configuration?._arcRepriced === true) && (
911
+ <Alert status="information" style="lighter">
912
+ <AlertTitle>
913
+ {t('cpq.quotes.arc.repricedTitle', 'Some existing items use the current catalog price')}
914
+ </AlertTitle>
915
+ <AlertDescription>
916
+ {t(
917
+ 'cpq.quotes.arc.repricedDescription',
918
+ "Reconfigured lines were re-priced at today's catalog price. Negotiated prices from the subscription are not carried over to reconfigured lines yet.",
919
+ )}
920
+ </AlertDescription>
921
+ </Alert>
922
+ )}
923
+
866
924
  {/* Quote Lines */}
867
925
  <QuoteLineTree
868
926
  lines={cpqQuote.lines}
@@ -882,6 +940,15 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
882
940
  )
883
941
  }
884
942
  />
943
+
944
+ <PriceAdjustmentsPanel
945
+ quoteId={cpqQuote.id}
946
+ items={priceAdjustments.items}
947
+ total={priceAdjustments.total}
948
+ lines={cpqQuote.lines}
949
+ currency={currency}
950
+ errorsById={priceAdjustments.errorsById}
951
+ />
885
952
  </>
886
953
  )}
887
954
 
@@ -890,7 +957,8 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
890
957
  <>
891
958
  {addingToParentLineId && (
892
959
  <Alert variant="info">
893
- Adding component to bundle: <span className="font-medium">{cpqQuote.lines.find((l) => l.lineId === addingToParentLineId)?.offeringName ?? 'Bundle'}</span>
960
+ {t('cpq.quotes.detail.addingComponentTo', 'Adding component to bundle:')}{' '}
961
+ <span className="font-medium">{cpqQuote.lines.find((l) => l.lineId === addingToParentLineId)?.offeringName ?? t('cpq.quotes.detail.bundleFallback', 'Bundle')}</span>
894
962
  </Alert>
895
963
  )}
896
964
  <OfferingBrowser
@@ -906,7 +974,11 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
906
974
  {/* Configure new offering */}
907
975
  {view === 'configure' && selectedOffering && (
908
976
  <ConfigurePanel
909
- title={addingToParentLineId ? `Add Component: ${selectedOffering.name}` : `Configure: ${selectedOffering.name}`}
977
+ title={
978
+ addingToParentLineId
979
+ ? t('cpq.quotes.detail.configure.addComponentTitle', 'Add Component: {name}', { name: selectedOffering.name })
980
+ : t('cpq.quotes.detail.configure.configureTitle', 'Configure: {name}', { name: selectedOffering.name })
981
+ }
910
982
  attributes={attributes}
911
983
  config={config}
912
984
  quantity={quantity}
@@ -932,7 +1004,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
932
1004
  }}
933
1005
  onQuantityChange={setQuantity}
934
1006
  onSubmit={addItem}
935
- submitLabel="Add to Quote"
1007
+ submitLabel={t('cpq.quotes.detail.configure.addToQuote', 'Add to Quote')}
936
1008
  submitting={submitting}
937
1009
  />
938
1010
  )}
@@ -940,7 +1012,9 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
940
1012
  {/* Edit existing line */}
941
1013
  {view === 'edit' && editingLineId && (
942
1014
  <ConfigurePanel
943
- title={`Edit: ${cpqQuote.lines.find((l) => l.lineId === editingLineId)?.offeringName ?? 'Line'}`}
1015
+ title={t('cpq.quotes.detail.configure.editTitle', 'Edit: {name}', {
1016
+ name: cpqQuote.lines.find((l) => l.lineId === editingLineId)?.offeringName ?? t('cpq.quotes.detail.configure.lineFallback', 'Line'),
1017
+ })}
944
1018
  attributes={attributes}
945
1019
  config={config}
946
1020
  quantity={quantity}
@@ -955,11 +1029,13 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
955
1029
  }}
956
1030
  onQuantityChange={setQuantity}
957
1031
  onSubmit={() => updateItem(editingLineId)}
958
- submitLabel="Update Line"
1032
+ submitLabel={t('cpq.quotes.detail.configure.updateLine', 'Update Line')}
959
1033
  submitting={submitting}
960
1034
  />
961
1035
  )}
962
1036
  </div>
1037
+ </ArcLineRemovalProvider>
1038
+ </PriceAdjustmentProvider>
963
1039
  )
964
1040
  }
965
1041
 
@@ -973,6 +1049,7 @@ function QuoteLineTree({ lines, currency, expandedLines, pricingDetail, targetCo
973
1049
  onRemoveLine: (lineId: string) => void; onAddComponent: (parentLineId: string, offeringId?: string) => void
974
1050
  onTogglePricingDetail: (lineIdx: number, chargeIdx: number) => void
975
1051
  }) {
1052
+ const t = useT()
976
1053
  const [bundleTrees, setBundleTrees] = React.useState<Map<string, BundleTree>>(new Map())
977
1054
  const [loadingTrees, setLoadingTrees] = React.useState<Set<string>>(new Set())
978
1055
 
@@ -990,18 +1067,18 @@ function QuoteLineTree({ lines, currency, expandedLines, pricingDetail, targetCo
990
1067
  if (bundleTrees.has(offeringId) || loadingTrees.has(offeringId)) return
991
1068
  setLoadingTrees((prev) => new Set(prev).add(offeringId))
992
1069
  try {
993
- const result = await apiJson<{ tree: BundleTree }>(`/api/cpq/bundle-tree?offeringId=${offeringId}`)
1070
+ const result = await apiJson<{ tree: BundleTree }>(t, `/api/cpq/bundle-tree?offeringId=${offeringId}`)
994
1071
  setBundleTrees((prev) => new Map(prev).set(offeringId, result.tree))
995
1072
  } catch { /* ignore — will just not show structure */ }
996
1073
  finally { setLoadingTrees((prev) => { const n = new Set(prev); n.delete(offeringId); return n }) }
997
- }, [bundleTrees, loadingTrees])
1074
+ }, [bundleTrees, loadingTrees, t])
998
1075
 
999
1076
  return (
1000
1077
  <div>
1001
- <h3 className="text-sm font-medium mb-2 px-1">Line Items</h3>
1078
+ <h3 className="text-sm font-medium mb-2 px-1">{t('cpq.quotes.detail.lineItems', 'Line Items')}</h3>
1002
1079
  {lines.length === 0 ? (
1003
1080
  <div className="rounded-lg border bg-card py-8 text-center text-sm text-muted-foreground">
1004
- No items configured yet. Click &quot;Add Offering&quot; to start.
1081
+ {t('cpq.quotes.detail.noLines', 'No items configured yet. Click "Add Offering" to start.')}
1005
1082
  </div>
1006
1083
  ) : (
1007
1084
  <div className="rounded-lg border bg-card divide-y">
@@ -1069,10 +1146,11 @@ function BundleSlotPanel({ bundleTree, treeLoading, children, allLines, currency
1069
1146
  onRemoveLine: (lineId: string) => void; onAddComponent: (parentLineId: string, offeringId?: string) => void
1070
1147
  onTogglePricingDetail: (lineIdx: number, chargeIdx: number) => void
1071
1148
  }) {
1149
+ const t = useT()
1072
1150
  if (treeLoading) {
1073
1151
  return (
1074
1152
  <div className="border-t bg-muted/5 px-8 py-3">
1075
- <div className="flex items-center gap-2 text-xs text-muted-foreground"><Spinner /> Loading bundle structure…</div>
1153
+ <div className="flex items-center gap-2 text-xs text-muted-foreground"><Spinner /> {t('cpq.quotes.bundle.loadingStructure', 'Loading bundle structure…')}</div>
1076
1154
  </div>
1077
1155
  )
1078
1156
  }
@@ -1121,13 +1199,14 @@ function BundleSlotPanel({ bundleTree, treeLoading, children, allLines, currency
1121
1199
  <div className="flex items-center gap-2 px-6 py-2 bg-muted/20">
1122
1200
  <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{slot.name}</span>
1123
1201
  {isRequired ? (
1124
- <Tag variant="warning" className="text-[10px] px-1.5">required</Tag>
1202
+ <Tag variant="warning" className="text-[10px] px-1.5">{t('cpq.quotes.bundle.slotRequired', 'required')}</Tag>
1125
1203
  ) : (
1126
- <Tag variant="neutral" className="text-[10px] px-1.5">optional</Tag>
1204
+ <Tag variant="neutral" className="text-[10px] px-1.5">{t('cpq.quotes.bundle.slotOptional', 'optional')}</Tag>
1127
1205
  )}
1128
1206
  <span className="text-[10px] text-muted-foreground">
1129
1207
  {filledCount}/{slot.cardinalityMax ?? '∞'}
1130
- {slot.cardinalityMin > 0 && ` (min ${slot.cardinalityMin})`}
1208
+ {slot.cardinalityMin > 0 && ' '}
1209
+ {slot.cardinalityMin > 0 && t('cpq.quotes.bundle.slotMin', '(min {min})', { min: slot.cardinalityMin })}
1131
1210
  </span>
1132
1211
  <div className="flex-1" />
1133
1212
  {/* Available offerings in this slot */}
@@ -1146,7 +1225,13 @@ function BundleSlotPanel({ bundleTree, treeLoading, children, allLines, currency
1146
1225
  ? 'border-border hover:border-primary hover:bg-primary/5 hover:text-primary cursor-pointer'
1147
1226
  : 'border-border text-muted-foreground opacity-50 cursor-not-allowed'
1148
1227
  }`}
1149
- title={isFilled ? 'Already added' : canAdd ? `Add ${comp.childOffering.offeringName}` : 'Slot is full'}
1228
+ title={
1229
+ isFilled
1230
+ ? t('cpq.quotes.bundle.alreadyAdded', 'Already added')
1231
+ : canAdd
1232
+ ? t('cpq.quotes.bundle.addComponent', 'Add {name}', { name: comp.childOffering.offeringName })
1233
+ : t('cpq.quotes.bundle.slotFull', 'Slot is full')
1234
+ }
1150
1235
  >
1151
1236
  {isFilled ? (
1152
1237
  <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor">
@@ -1215,11 +1300,20 @@ function QuoteLineRow({ line, lineIdx, currency, isExpanded, pricingDetail, isBu
1215
1300
  onAddComponent?: () => void
1216
1301
  onTogglePricingDetail: (lineIdx: number, chargeIdx: number) => void
1217
1302
  }) {
1303
+ const t = useT()
1304
+ const adjustmentUi = usePriceAdjustmentUi()
1305
+ const removalUi = useArcLineRemovalUi()
1306
+ const isRemoved = removalUi?.isRemoved(line) ?? false
1218
1307
  const paddingLeft = indent > 0 ? `${indent * 2 + 1}rem` : undefined
1308
+ const chargeActions = {
1309
+ reasonLabels: adjustmentUi?.reasonLabels,
1310
+ renderChargeActions: (charge: ResolvedCharge) => <AdjustChargeAction line={line} charge={charge} />,
1311
+ renderManualActions: (adjustmentId: string) => <ManualAdjustmentActions adjustmentId={adjustmentId} />,
1312
+ }
1219
1313
 
1220
1314
  return (
1221
1315
  <div>
1222
- <div className="flex items-center gap-3 px-4 py-3 hover:bg-muted/30 transition-colors" style={paddingLeft ? { paddingLeft } : undefined}>
1316
+ <div className={`flex items-center gap-3 px-4 py-3 hover:bg-muted/30 transition-colors ${isRemoved ? 'bg-muted/20' : ''}`} style={paddingLeft ? { paddingLeft } : undefined} data-testid="cpq-quote-line" data-removed={isRemoved ? 'true' : undefined}>
1223
1317
  {/* Expand toggle */}
1224
1318
  <button onClick={onToggle} className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground">
1225
1319
  <svg className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor">
@@ -1234,27 +1328,47 @@ function QuoteLineRow({ line, lineIdx, currency, isExpanded, pricingDetail, isBu
1234
1328
  <span className="text-muted-foreground text-xs">└</span>
1235
1329
  )}
1236
1330
  {isBundle && (
1237
- <Tag variant="brand" className="px-1.5 text-xs">bundle</Tag>
1331
+ <Tag variant="brand" className="px-1.5 text-xs">{t('cpq.lines.bundle', 'bundle')}</Tag>
1238
1332
  )}
1239
- <span className="font-medium text-sm">{line.offeringName}</span>
1333
+ <span className={`font-medium text-sm ${isRemoved ? 'text-muted-foreground line-through' : ''}`}>{line.offeringName}</span>
1240
1334
  {line.quantity > 1 && <span className="rounded bg-muted px-1.5 py-0.5 text-xs font-medium">×{line.quantity}</span>}
1241
- <ActionBadge action={line.action} />
1335
+ <LineActionBadge
1336
+ action={line.action}
1337
+ label={isRemoved ? t('cpq.quotes.arc.lineRemoved', 'remove') : undefined}
1338
+ title={isRemoved ? t('cpq.quotes.arc.lineRemovedHint', 'This item is cancelled on the subscription when the order is activated') : undefined}
1339
+ />
1340
+ {!isRemoved && line.configuration?._arcPriceFrozen === true && (
1341
+ <Tag
1342
+ variant="neutral"
1343
+ className="px-1.5 text-xs"
1344
+ title={t(
1345
+ 'cpq.quotes.arc.priceKeptHint',
1346
+ 'Price kept from the subscription — it changes only if you reconfigure this line',
1347
+ )}
1348
+ >
1349
+ {t('cpq.quotes.arc.priceKept', 'price kept')}
1350
+ </Tag>
1351
+ )}
1242
1352
  {targetCodes.size >= 2 && line.targetSubscriptionId && targetCodes.get(line.targetSubscriptionId) && (
1243
1353
  <Tag
1244
1354
  variant="brand"
1245
1355
  className="px-1.5 text-xs"
1246
- title={`Acts on subscription ${targetCodes.get(line.targetSubscriptionId)}`}
1356
+ title={t('cpq.quotes.detail.actsOnSubscription', 'Acts on subscription {code}', { code: targetCodes.get(line.targetSubscriptionId) ?? '' })}
1247
1357
  >
1248
1358
  → {targetCodes.get(line.targetSubscriptionId)}
1249
1359
  </Tag>
1250
1360
  )}
1251
- {line.isConfigured ? (
1252
- <Tag variant="success" className="px-1.5 text-xs">configured</Tag>
1361
+ {isRemoved ? null : line.isConfigured ? (
1362
+ <Tag variant="success" className="px-1.5 text-xs">{t('cpq.quotes.detail.lineConfigured', 'configured')}</Tag>
1253
1363
  ) : (
1254
- <Tag variant="warning" className="px-1.5 text-xs">incomplete</Tag>
1364
+ <Tag variant="warning" className="px-1.5 text-xs">{t('cpq.quotes.detail.lineIncomplete', 'incomplete')}</Tag>
1255
1365
  )}
1256
1366
  {isBundle && (
1257
- <span className="text-xs text-muted-foreground">{childCount} component{childCount !== 1 ? 's' : ''}</span>
1367
+ <span className="text-xs text-muted-foreground">
1368
+ {childCount !== 1
1369
+ ? t('cpq.lines.componentCount.other', '{count} components', { count: childCount })
1370
+ : t('cpq.lines.componentCount.one', '{count} component', { count: childCount })}
1371
+ </span>
1258
1372
  )}
1259
1373
  </div>
1260
1374
  {line.validationErrors && line.validationErrors.length > 0 && (
@@ -1264,43 +1378,66 @@ function QuoteLineRow({ line, lineIdx, currency, isExpanded, pricingDetail, isBu
1264
1378
 
1265
1379
  {/* Pricing */}
1266
1380
  <div className="shrink-0 text-right text-xs space-y-0.5">
1267
- {line.nrcTotal > 0 && <div><span className="text-muted-foreground">NRC </span><span className="font-mono font-medium">{fmt(line.nrcTotal, currency)}</span></div>}
1268
- {line.mrcTotal > 0 && <div><span className="text-muted-foreground">MRC </span><span className="font-mono font-medium">{fmt(line.mrcTotal, currency)}</span></div>}
1381
+ {isRemoved && line.arcSource && line.arcSource.mrcAmount > 0 && (
1382
+ <div>
1383
+ <span className="text-muted-foreground">{t('cpq.lines.mrc', 'MRC')} </span>
1384
+ <span className="font-mono text-muted-foreground line-through">{fmt(line.arcSource.mrcAmount, currency)}</span>
1385
+ </div>
1386
+ )}
1387
+ {line.nrcTotal > 0 && <div><span className="text-muted-foreground">{t('cpq.lines.nrc', 'NRC')} </span><span className="font-mono font-medium">{fmt(line.nrcTotal, currency)}</span></div>}
1388
+ {line.mrcTotal > 0 && <div><span className="text-muted-foreground">{t('cpq.lines.mrc', 'MRC')} </span><span className="font-mono font-medium">{fmt(line.mrcTotal, currency)}</span></div>}
1269
1389
  </div>
1270
1390
 
1271
1391
  {/* Actions */}
1392
+ {isRemoved ? (
1393
+ <div className="flex items-center gap-1 shrink-0">
1394
+ <Button
1395
+ type="button" variant="ghost" size="sm"
1396
+ onClick={() => removalUi?.restore(line.lineId)}
1397
+ title={t('cpq.quotes.arc.restoreHint', 'Keep this item on the subscription')}
1398
+ data-testid="cpq-line-restore"
1399
+ >
1400
+ <RotateCcw className="h-4 w-4" />
1401
+ {t('cpq.quotes.arc.restore', 'Restore')}
1402
+ </Button>
1403
+ </div>
1404
+ ) : (
1272
1405
  <div className="flex items-center gap-1 shrink-0">
1406
+ <DiscountLineAction line={line} />
1273
1407
  {onAddComponent && (
1274
- <button onClick={onAddComponent} className="rounded p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/5 transition-colors" title="Add Component">
1408
+ <button onClick={onAddComponent} className="rounded p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/5 transition-colors" title={t('cpq.quotes.detail.addComponent', 'Add Component')}>
1275
1409
  <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
1276
1410
  <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
1277
1411
  </svg>
1278
1412
  </button>
1279
1413
  )}
1280
- <button onClick={onEdit} className="rounded p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" title="Edit">
1414
+ <button onClick={onEdit} className="rounded p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" title={t('cpq.common.edit', 'Edit')}>
1281
1415
  <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
1282
1416
  <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" />
1283
1417
  </svg>
1284
1418
  </button>
1285
- <button onClick={onRemove} className="rounded p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors" title="Remove">
1419
+ <button onClick={onRemove} className="rounded p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors" title={t('cpq.quotes.detail.removeLine', 'Remove')}>
1286
1420
  <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
1287
1421
  <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" />
1288
1422
  </svg>
1289
1423
  </button>
1290
1424
  </div>
1425
+ )}
1291
1426
  </div>
1292
1427
 
1293
- {/* XD-250 ARC: before/after preview when line mirrors a sub item */}
1294
- {isExpanded && line.arcSource && (
1295
- <ArcLineDiff line={line} currency={currency} />
1296
- )}
1297
1428
  {/* Charge breakdown (non-bundle or any expanded line) */}
1298
1429
  {isExpanded && !isBundle && line.charges.length > 0 && (
1299
- <ChargeBreakdown line={line} lineIdx={lineIdx} currency={currency} pricingDetail={pricingDetail} onTogglePricingDetail={onTogglePricingDetail} />
1430
+ <ChargeBreakdown line={line} lineIdx={lineIdx} currency={currency} pricingDetail={pricingDetail} onTogglePricingDetail={onTogglePricingDetail} {...chargeActions} />
1300
1431
  )}
1301
1432
  {/* For bundles: show own charges first, then children are rendered by parent */}
1302
1433
  {isExpanded && isBundle && line.charges.length > 0 && (
1303
- <ChargeBreakdown line={line} lineIdx={lineIdx} currency={currency} pricingDetail={pricingDetail} onTogglePricingDetail={onTogglePricingDetail} />
1434
+ <ChargeBreakdown line={line} lineIdx={lineIdx} currency={currency} pricingDetail={pricingDetail} onTogglePricingDetail={onTogglePricingDetail} {...chargeActions} />
1435
+ )}
1436
+ {/* XD-250 ARC: before/after preview, below the charges, when line mirrors a sub item */}
1437
+ {isExpanded && line.arcSource && (
1438
+ <div className="border-t bg-muted/10 px-12 py-2">
1439
+ <ArcLineDiff line={line} currency={currency} />
1440
+ </div>
1304
1441
  )}
1305
1442
  {/* Fallback: line has no per-charge breakdown and no ARC diff (truly empty) */}
1306
1443
  {isExpanded && line.charges.length === 0 && !line.arcSource && (
@@ -1310,93 +1447,24 @@ function QuoteLineRow({ line, lineIdx, currency, isExpanded, pricingDetail, isBu
1310
1447
  )
1311
1448
  }
1312
1449
 
1313
- function ArcLineDiff({ line, currency }: { line: QuoteLine; currency: string }) {
1314
- const src = line.arcSource
1315
- if (!src) return null
1316
- const isCancel = line.action === 'cancel'
1317
- const beforeMrc = src.mrcAmount
1318
- const beforeNrc = src.nrcAmount
1319
- const beforeQty = src.quantity
1320
- const afterMrc = isCancel ? 0 : line.mrcTotal
1321
- const afterNrc = isCancel ? 0 : line.nrcTotal
1322
- const afterQty = isCancel ? 0 : line.quantity
1323
- const fmtVal = (v: number, asCurrency: boolean) =>
1324
- asCurrency ? fmt(v, currency) : String(v)
1325
- const rows: Array<{ label: string; before: number; after: number; asCurrency: boolean }> = [
1326
- { label: 'Quantity', before: beforeQty, after: afterQty, asCurrency: false },
1327
- { label: 'MRC', before: beforeMrc, after: afterMrc, asCurrency: true },
1328
- { label: 'NRC', before: beforeNrc, after: afterNrc, asCurrency: true },
1329
- ]
1330
-
1331
- return (
1332
- <div className="border-t bg-muted/10 px-12 py-2 space-y-1.5">
1333
- <p className="text-xs font-medium text-muted-foreground">Change Preview</p>
1334
- <div className="rounded border bg-background overflow-hidden">
1335
- <table className="w-full text-xs">
1336
- <thead className="bg-muted/40">
1337
- <tr className="text-muted-foreground">
1338
- <th className="text-left px-3 py-1.5 font-medium" />
1339
- <th className="text-right px-3 py-1.5 font-medium">Before</th>
1340
- <th className="text-right px-3 py-1.5 font-medium">After</th>
1341
- <th className="text-right px-3 py-1.5 font-medium">Δ</th>
1342
- </tr>
1343
- </thead>
1344
- <tbody>
1345
- {rows.map((r) => {
1346
- const delta = r.after - r.before
1347
- const showDelta = delta !== 0
1348
- return (
1349
- <tr key={r.label} className="border-t border-dashed border-border/40">
1350
- <td className="px-3 py-1 text-muted-foreground">{r.label}</td>
1351
- <td className="px-3 py-1 text-right font-mono">{fmtVal(r.before, r.asCurrency)}</td>
1352
- <td className={`px-3 py-1 text-right font-mono ${isCancel ? 'text-muted-foreground line-through' : ''}`}>
1353
- {fmtVal(r.after, r.asCurrency)}
1354
- </td>
1355
- <td className={`px-3 py-1 text-right font-mono font-medium ${showDelta ? (delta > 0 ? 'text-status-success-text' : 'text-destructive') : 'text-muted-foreground'}`}>
1356
- {showDelta ? `${delta > 0 ? '+' : ''}${fmtVal(delta, r.asCurrency)}` : '—'}
1357
- </td>
1358
- </tr>
1359
- )
1360
- })}
1361
- </tbody>
1362
- </table>
1363
- </div>
1364
- {isCancel && (
1365
- <p className="text-xs text-destructive italic">Cancellation — item will be terminated on activation.</p>
1366
- )}
1367
- </div>
1368
- )
1369
- }
1370
-
1371
- function ActionBadge({ action }: { action: QuoteLineAction }) {
1372
- const variantByAction: Record<QuoteLineAction, 'info' | 'warning' | 'error'> = {
1373
- add: 'info',
1374
- modify: 'warning',
1375
- cancel: 'error',
1376
- }
1377
- return (
1378
- <Tag variant={variantByAction[action]} className="px-1.5 text-xs">
1379
- {action}
1380
- </Tag>
1381
- )
1382
- }
1383
-
1384
1450
  function LineSummaryFallback({ line, currency }: { line: QuoteLine; currency: string }) {
1451
+ const t = useT()
1385
1452
  const hasTotals = line.nrcTotal > 0 || line.mrcTotal > 0
1386
1453
  const mirroredFromItemId = (line.configuration?._arcMirroredFromItemId as string | undefined) ?? null
1387
1454
  return (
1388
1455
  <div className="border-t bg-muted/10 px-12 py-2 text-xs text-muted-foreground space-y-1">
1389
1456
  <div>
1390
- No detailed charge breakdown available for this line.
1391
- {mirroredFromItemId && ' Mirrored from existing subscription item.'}
1457
+ {t('cpq.quotes.detail.noBreakdown', 'No detailed charge breakdown available for this line.')}
1458
+ {mirroredFromItemId && ' '}
1459
+ {mirroredFromItemId && t('cpq.orders.detail.mirroredItem', 'Mirrored from existing subscription item.')}
1392
1460
  </div>
1393
1461
  {hasTotals && (
1394
1462
  <div className="flex gap-4">
1395
1463
  {line.nrcTotal > 0 && (
1396
- <span>NRC <span className="font-mono font-medium text-foreground">{fmt(line.nrcTotal, currency)}</span></span>
1464
+ <span>{t('cpq.lines.nrc', 'NRC')} <span className="font-mono font-medium text-foreground">{fmt(line.nrcTotal, currency)}</span></span>
1397
1465
  )}
1398
1466
  {line.mrcTotal > 0 && (
1399
- <span>MRC <span className="font-mono font-medium text-foreground">{fmt(line.mrcTotal, currency)}</span></span>
1467
+ <span>{t('cpq.lines.mrc', 'MRC')} <span className="font-mono font-medium text-foreground">{fmt(line.mrcTotal, currency)}</span></span>
1400
1468
  )}
1401
1469
  </div>
1402
1470
  )}
@@ -1404,138 +1472,22 @@ function LineSummaryFallback({ line, currency }: { line: QuoteLine; currency: st
1404
1472
  )
1405
1473
  }
1406
1474
 
1407
- function ChargeBreakdown({ line, lineIdx, currency, pricingDetail, onTogglePricingDetail }: {
1475
+ function ChargeBreakdown({ line, lineIdx, pricingDetail, onTogglePricingDetail, ...tableProps }: {
1408
1476
  line: QuoteLine; lineIdx: number; currency: string
1409
1477
  pricingDetail: { lineIdx: number; chargeIdx: number } | null
1410
1478
  onTogglePricingDetail: (lineIdx: number, chargeIdx: number) => void
1479
+ reasonLabels?: Record<string, string>
1480
+ renderChargeActions?: (charge: ResolvedCharge) => React.ReactNode
1481
+ renderManualActions?: (adjustmentId: string) => React.ReactNode
1411
1482
  }) {
1412
1483
  return (
1413
1484
  <div className="border-t bg-muted/10 px-12 py-2">
1414
- <table className="w-full text-xs">
1415
- <thead>
1416
- <tr className="text-muted-foreground">
1417
- <th className="text-left py-1 font-medium">Charge</th>
1418
- <th className="text-left py-1 font-medium">Type</th>
1419
- <th className="text-right py-1 font-medium">Unit Price</th>
1420
- <th className="text-right py-1 font-medium">Qty</th>
1421
- <th className="text-right py-1 font-medium">Total</th>
1422
- <th className="w-8" />
1423
- </tr>
1424
- </thead>
1425
- <tbody>
1426
- {line.charges.map((c, i) => {
1427
- const hasDetail = !!(c.breakdown?.tiers?.length || c.adjustments?.length)
1428
- const isDetailOpen = pricingDetail?.lineIdx === lineIdx && pricingDetail?.chargeIdx === i
1429
- return (
1430
- <React.Fragment key={i}>
1431
- <tr className="border-t border-dashed border-border/50">
1432
- <td className="py-1">{c.chargeName}</td>
1433
- <td className="py-1 uppercase">{c.chargeType}</td>
1434
- <td className="py-1 text-right font-mono">{fmt(c.unitPrice, currency)}</td>
1435
- <td className="py-1 text-right font-mono">{c.quantity ?? '—'}</td>
1436
- <td className="py-1 text-right font-mono">{c.totalPrice != null ? fmt(c.totalPrice, currency) : c.note ?? '—'}</td>
1437
- <td className="py-1 text-center">
1438
- {hasDetail && (
1439
- <button
1440
- onClick={() => onTogglePricingDetail(lineIdx, i)}
1441
- className="inline-flex items-center justify-center rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
1442
- title="Show pricing breakdown"
1443
- >
1444
- <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">
1445
- <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" />
1446
- </svg>
1447
- </button>
1448
- )}
1449
- </td>
1450
- </tr>
1451
- {isDetailOpen && (
1452
- <tr>
1453
- <td colSpan={6} className="pb-3 pt-1">
1454
- <div className="rounded border bg-background p-3 space-y-3 text-xs">
1455
- <div className="flex items-center justify-between">
1456
- <span className="font-semibold text-sm">Pricing Details — {c.chargeName}</span>
1457
- <span className="text-muted-foreground">{c.pricingMethod ?? 'flat'}</span>
1458
- </div>
1459
-
1460
- {c.breakdown?.tiers && c.breakdown.tiers.length > 0 && (
1461
- <div>
1462
- <div className="font-medium text-muted-foreground mb-1">Tier Breakdown</div>
1463
- <table className="w-full">
1464
- <thead>
1465
- <tr className="text-muted-foreground">
1466
- <th className="text-left py-0.5 font-medium">Tier</th>
1467
- <th className="text-right py-0.5 font-medium">Range</th>
1468
- <th className="text-right py-0.5 font-medium">Price/Unit</th>
1469
- <th className="text-right py-0.5 font-medium">Qty</th>
1470
- <th className="text-right py-0.5 font-medium">Subtotal</th>
1471
- </tr>
1472
- </thead>
1473
- <tbody>
1474
- {c.breakdown.tiers.map((t) => (
1475
- <tr key={t.tier} className="border-t border-border/30">
1476
- <td className="py-0.5">{t.tier}</td>
1477
- <td className="py-0.5 text-right font-mono">{t.from}–{t.to}</td>
1478
- <td className="py-0.5 text-right font-mono">{fmt(t.pricePerUnit, currency)}</td>
1479
- <td className="py-0.5 text-right font-mono">{t.quantity}</td>
1480
- <td className="py-0.5 text-right font-mono">{fmt(t.subtotal, currency)}</td>
1481
- </tr>
1482
- ))}
1483
- <tr className="border-t font-medium">
1484
- <td colSpan={4} className="py-0.5 text-right">Tiered Total</td>
1485
- <td className="py-0.5 text-right font-mono">
1486
- {fmt(c.breakdown.tiers.reduce((s, t) => s + t.subtotal, 0), currency)}
1487
- </td>
1488
- </tr>
1489
- </tbody>
1490
- </table>
1491
- </div>
1492
- )}
1493
-
1494
- {c.adjustments && c.adjustments.length > 0 && (
1495
- <div>
1496
- <div className="font-medium text-muted-foreground mb-1">Applied Price Rules</div>
1497
- <table className="w-full">
1498
- <thead>
1499
- <tr className="text-muted-foreground">
1500
- <th className="text-left py-0.5 font-medium">Rule</th>
1501
- <th className="text-left py-0.5 font-medium">Type</th>
1502
- <th className="text-right py-0.5 font-medium">Value</th>
1503
- <th className="text-right py-0.5 font-medium">Before</th>
1504
- <th className="text-right py-0.5 font-medium">After</th>
1505
- <th className="text-right py-0.5 font-medium">Delta</th>
1506
- </tr>
1507
- </thead>
1508
- <tbody>
1509
- {c.adjustments.map((adj, ai) => (
1510
- <tr key={ai} className="border-t border-border/30">
1511
- <td className="py-0.5">{adj.ruleName}</td>
1512
- <td className="py-0.5">{RULE_TYPE_LABELS[adj.ruleType] ?? adj.ruleType}</td>
1513
- <td className="py-0.5 text-right font-mono">{fmtRuleValue(adj, currency)}</td>
1514
- <td className="py-0.5 text-right font-mono">{fmt(adj.unitPriceBefore, currency)}</td>
1515
- <td className="py-0.5 text-right font-mono">{fmt(adj.unitPriceAfter, currency)}</td>
1516
- <td className={`py-0.5 text-right font-mono ${adj.delta < 0 ? 'text-destructive' : adj.delta > 0 ? 'text-status-success-text' : ''}`}>
1517
- {adj.delta > 0 ? '+' : ''}{fmt(adj.delta, currency)}
1518
- </td>
1519
- </tr>
1520
- ))}
1521
- </tbody>
1522
- </table>
1523
- </div>
1524
- )}
1525
-
1526
- <div className="flex items-center justify-between pt-1 border-t text-sm font-semibold">
1527
- <span>Final Total</span>
1528
- <span className="font-mono">{c.totalPrice != null ? fmt(c.totalPrice, currency) : '—'}</span>
1529
- </div>
1530
- </div>
1531
- </td>
1532
- </tr>
1533
- )}
1534
- </React.Fragment>
1535
- )
1536
- })}
1537
- </tbody>
1538
- </table>
1485
+ <ChargeBreakdownTable
1486
+ charges={line.charges}
1487
+ openDetailIndex={pricingDetail?.lineIdx === lineIdx ? pricingDetail.chargeIdx : null}
1488
+ onToggleDetail={(chargeIdx) => onTogglePricingDetail(lineIdx, chargeIdx)}
1489
+ {...tableProps}
1490
+ />
1539
1491
  </div>
1540
1492
  )
1541
1493
  }
@@ -1550,6 +1502,7 @@ function Spinner() {
1550
1502
  }
1551
1503
 
1552
1504
  function ErrorBanner({ message, onDismiss }: { message: string; onDismiss?: () => void }) {
1505
+ const t = useT()
1553
1506
  return (
1554
1507
  <Alert variant="destructive" className="flex items-start gap-2">
1555
1508
  <span className="flex-1">{message}</span>
@@ -1557,7 +1510,7 @@ function ErrorBanner({ message, onDismiss }: { message: string; onDismiss?: () =
1557
1510
  <button
1558
1511
  onClick={onDismiss}
1559
1512
  className="text-destructive/70 hover:text-destructive text-sm font-bold"
1560
- aria-label="Dismiss"
1513
+ aria-label={t('cpq.common.dismiss', 'Dismiss')}
1561
1514
  >
1562
1515
  ×
1563
1516
  </button>
@@ -1569,6 +1522,7 @@ function ErrorBanner({ message, onDismiss }: { message: string; onDismiss?: () =
1569
1522
  function OfferingBrowser({ offerings, loading, search, onSearchChange, onSelect }: {
1570
1523
  offerings: Offering[]; loading: boolean; search: string; onSearchChange: (v: string) => void; onSelect: (o: Offering) => void
1571
1524
  }) {
1525
+ const t = useT()
1572
1526
  const filtered = offerings.filter((o) => {
1573
1527
  if (!search.trim()) return true
1574
1528
  const q = search.toLowerCase()
@@ -1577,18 +1531,18 @@ function OfferingBrowser({ offerings, loading, search, onSearchChange, onSelect
1577
1531
 
1578
1532
  return (
1579
1533
  <div className="space-y-3">
1580
- <h3 className="text-sm font-medium px-1">Select Product Offering</h3>
1534
+ <h3 className="text-sm font-medium px-1">{t('cpq.quotes.offeringBrowser.title', 'Select Product Offering')}</h3>
1581
1535
  <div className="relative">
1582
1536
  <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">
1583
1537
  <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" />
1584
1538
  </svg>
1585
- <input type="text" placeholder="Search offerings…" value={search} onChange={(e) => onSearchChange(e.target.value)}
1539
+ <input type="text" placeholder={t('cpq.quotes.offeringBrowser.searchPlaceholder', 'Search offerings…')} value={search} onChange={(e) => onSearchChange(e.target.value)}
1586
1540
  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" />
1587
1541
  </div>
1588
1542
  {loading ? (
1589
- <div className="flex items-center gap-2 py-8 justify-center text-sm text-muted-foreground"><Spinner /> Loading offerings…</div>
1543
+ <div className="flex items-center gap-2 py-8 justify-center text-sm text-muted-foreground"><Spinner /> {t('cpq.quotes.offeringBrowser.loading', 'Loading offerings…')}</div>
1590
1544
  ) : filtered.length === 0 ? (
1591
- <div className="py-8 text-center text-sm text-muted-foreground">{search ? 'No offerings match your search.' : 'No active offerings available.'}</div>
1545
+ <div className="py-8 text-center text-sm text-muted-foreground">{search ? t('cpq.quotes.offeringBrowser.noMatch', 'No offerings match your search.') : t('cpq.quotes.offeringBrowser.empty', 'No active offerings available.')}</div>
1592
1546
  ) : (
1593
1547
  <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
1594
1548
  {filtered.map((offering) => (
@@ -1613,6 +1567,7 @@ function ConfigurePanel({ title, attributes, config, quantity, arcTargetOptions,
1613
1567
  onConfigChange: (key: string, value: unknown) => void; onQuantityChange: (q: number) => void
1614
1568
  onSubmit: () => void; submitLabel: string; submitting: boolean
1615
1569
  }) {
1570
+ const t = useT()
1616
1571
  const noTargetsPicked =
1617
1572
  arcTargetOptions !== null && arcTargetOptions.length > 0 && arcSelectedTargetIds.length === 0
1618
1573
  return (
@@ -1622,7 +1577,7 @@ function ConfigurePanel({ title, attributes, config, quantity, arcTargetOptions,
1622
1577
  {arcTargetOptions && arcTargetOptions.length > 0 && (
1623
1578
  <div>
1624
1579
  <label className="block text-sm font-medium mb-1">
1625
- Apply to subscription(s)
1580
+ {t('cpq.quotes.detail.configure.applyToSubscriptions', 'Apply to subscription(s)')}
1626
1581
  <span className="text-destructive ml-1">*</span>
1627
1582
  </label>
1628
1583
  <div className="space-y-1.5 rounded-md border bg-background px-3 py-2">
@@ -1646,15 +1601,15 @@ function ConfigurePanel({ title, attributes, config, quantity, arcTargetOptions,
1646
1601
  })}
1647
1602
  </div>
1648
1603
  <p className="text-xs text-muted-foreground mt-1">
1649
- Pick one or more — a separate line will be created on each selected subscription.
1604
+ {t('cpq.quotes.detail.configure.applyToSubscriptionsHint', 'Pick one or more — a separate line will be created on each selected subscription.')}
1650
1605
  </p>
1651
1606
  {noTargetsPicked && (
1652
- <p className="text-xs text-destructive mt-1">Select at least one target.</p>
1607
+ <p className="text-xs text-destructive mt-1">{t('cpq.quotes.detail.configure.selectTarget', 'Select at least one target.')}</p>
1653
1608
  )}
1654
1609
  </div>
1655
1610
  )}
1656
1611
  <div>
1657
- <label className="block text-sm font-medium mb-1">Quantity</label>
1612
+ <label className="block text-sm font-medium mb-1">{t('cpq.quotes.detail.configure.quantity', 'Quantity')}</label>
1658
1613
  <NumberInput
1659
1614
  integer
1660
1615
  min={1}
@@ -1664,7 +1619,7 @@ function ConfigurePanel({ title, attributes, config, quantity, arcTargetOptions,
1664
1619
  />
1665
1620
  </div>
1666
1621
  {attributes.length === 0 ? (
1667
- <p className="text-sm text-muted-foreground">No configurable attributes for this offering.</p>
1622
+ <p className="text-sm text-muted-foreground">{t('cpq.quotes.detail.configure.noAttributes', 'No configurable attributes for this offering.')}</p>
1668
1623
  ) : (
1669
1624
  attributes.map((attr) => <AttributeField key={attr.code} attribute={attr} value={config[attr.code]} onChange={(v) => onConfigChange(attr.code, v)} />)
1670
1625
  )}
@@ -1680,6 +1635,7 @@ function ConfigurePanel({ title, attributes, config, quantity, arcTargetOptions,
1680
1635
  }
1681
1636
 
1682
1637
  function AttributeField({ attribute, value, onChange }: { attribute: ConstrainedAttribute; value: unknown; onChange: (v: unknown) => void }) {
1638
+ const t = useT()
1683
1639
  const { code, name, attributeType, isRequired, options, constraints, helpText, defaultValue } = attribute
1684
1640
  const currentValue = value ?? defaultValue ?? ''
1685
1641
 
@@ -1691,13 +1647,13 @@ function AttributeField({ attribute, value, onChange }: { attribute: Constrained
1691
1647
  {(attributeType === 'select' || attributeType === 'enum') && options && options.length > 0 ? (
1692
1648
  <select value={String(currentValue)} onChange={(e) => onChange(e.target.value)}
1693
1649
  className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50">
1694
- <option value="">— Select —</option>
1650
+ <option value="">{t('cpq.quotes.detail.configure.selectPlaceholder', '— Select —')}</option>
1695
1651
  {options.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
1696
1652
  </select>
1697
1653
  ) : attributeType === 'boolean' ? (
1698
1654
  <label className="flex items-center gap-2">
1699
1655
  <input type="checkbox" checked={Boolean(currentValue)} onChange={(e) => onChange(e.target.checked)} className="rounded border-border" />
1700
- <span className="text-sm text-muted-foreground">{helpText ?? 'Enable'}</span>
1656
+ <span className="text-sm text-muted-foreground">{helpText ?? t('cpq.quotes.detail.configure.enable', 'Enable')}</span>
1701
1657
  </label>
1702
1658
  ) : attributeType === 'number' ? (
1703
1659
  <NumberInput