@dainamite/cpq 0.5.0 → 0.6.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 (233) hide show
  1. package/CHANGELOG.md +48 -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/page.meta.js +1 -1
  34. package/dist/modules/cpq/backend/cpq/inventory/page.meta.js.map +1 -1
  35. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js +22 -8
  36. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js.map +2 -2
  37. package/dist/modules/cpq/backend/cpq/orders/[id]/page.js +89 -232
  38. package/dist/modules/cpq/backend/cpq/orders/[id]/page.js.map +2 -2
  39. package/dist/modules/cpq/backend/cpq/orders/page.meta.js +1 -1
  40. package/dist/modules/cpq/backend/cpq/orders/page.meta.js.map +1 -1
  41. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js +205 -0
  42. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js.map +7 -0
  43. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js +79 -0
  44. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js.map +7 -0
  45. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js +53 -0
  46. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js.map +7 -0
  47. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js +107 -0
  48. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js.map +7 -0
  49. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js +130 -0
  50. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js.map +7 -0
  51. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js +144 -221
  52. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js.map +3 -3
  53. package/dist/modules/cpq/backend/cpq/quotes/page.meta.js +1 -1
  54. package/dist/modules/cpq/backend/cpq/quotes/page.meta.js.map +1 -1
  55. package/dist/modules/cpq/ce.js +8 -0
  56. package/dist/modules/cpq/ce.js.map +2 -2
  57. package/dist/modules/cpq/cli.js +34 -1
  58. package/dist/modules/cpq/cli.js.map +2 -2
  59. package/dist/modules/cpq/commands/price-adjustments.js +107 -0
  60. package/dist/modules/cpq/commands/price-adjustments.js.map +7 -0
  61. package/dist/modules/cpq/components/ArcLineDiff.js +53 -0
  62. package/dist/modules/cpq/components/ArcLineDiff.js.map +7 -0
  63. package/dist/modules/cpq/components/ChargeBreakdownTable.js +154 -0
  64. package/dist/modules/cpq/components/ChargeBreakdownTable.js.map +7 -0
  65. package/dist/modules/cpq/components/PriceWaterfall.js +66 -0
  66. package/dist/modules/cpq/components/PriceWaterfall.js.map +7 -0
  67. package/dist/modules/cpq/components/PricingSummaryCard.js +117 -0
  68. package/dist/modules/cpq/components/PricingSummaryCard.js.map +7 -0
  69. package/dist/modules/cpq/components/useGrantedFeatures.js +27 -0
  70. package/dist/modules/cpq/components/useGrantedFeatures.js.map +7 -0
  71. package/dist/modules/cpq/data/entities.js +97 -0
  72. package/dist/modules/cpq/data/entities.js.map +2 -2
  73. package/dist/modules/cpq/data/price-adjustments.js +70 -0
  74. package/dist/modules/cpq/data/price-adjustments.js.map +7 -0
  75. package/dist/modules/cpq/data/validators.js +4 -0
  76. package/dist/modules/cpq/data/validators.js.map +2 -2
  77. package/dist/modules/cpq/di.js +6 -0
  78. package/dist/modules/cpq/di.js.map +2 -2
  79. package/dist/modules/cpq/events.js +47 -0
  80. package/dist/modules/cpq/events.js.map +2 -2
  81. package/dist/modules/cpq/index.js +1 -1
  82. package/dist/modules/cpq/index.js.map +2 -2
  83. package/dist/modules/cpq/lib/auditActor.js +10 -0
  84. package/dist/modules/cpq/lib/auditActor.js.map +7 -0
  85. package/dist/modules/cpq/lib/formatMoney.js +7 -0
  86. package/dist/modules/cpq/lib/formatMoney.js.map +7 -0
  87. package/dist/modules/cpq/lib/priceAdjustmentDefaults.js +60 -0
  88. package/dist/modules/cpq/lib/priceAdjustmentDefaults.js.map +7 -0
  89. package/dist/modules/cpq/lib/priceAdjustmentView.js +169 -0
  90. package/dist/modules/cpq/lib/priceAdjustmentView.js.map +7 -0
  91. package/dist/modules/cpq/lib/quotePermissions.js +13 -0
  92. package/dist/modules/cpq/lib/quotePermissions.js.map +7 -0
  93. package/dist/modules/cpq/lib/quoteSummaryView.js +28 -0
  94. package/dist/modules/cpq/lib/quoteSummaryView.js.map +7 -0
  95. package/dist/modules/cpq/migrations/Migration20261110213052.js +22 -0
  96. package/dist/modules/cpq/migrations/Migration20261110213052.js.map +7 -0
  97. package/dist/modules/cpq/services/arcLineRemoval.js +53 -0
  98. package/dist/modules/cpq/services/arcLineRemoval.js.map +7 -0
  99. package/dist/modules/cpq/services/arcPriceFreeze.js +79 -0
  100. package/dist/modules/cpq/services/arcPriceFreeze.js.map +7 -0
  101. package/dist/modules/cpq/services/cpqOrderService.js +25 -28
  102. package/dist/modules/cpq/services/cpqOrderService.js.map +2 -2
  103. package/dist/modules/cpq/services/cpqPricingService.js +7 -0
  104. package/dist/modules/cpq/services/cpqPricingService.js.map +2 -2
  105. package/dist/modules/cpq/services/cpqQuotingService.js +330 -109
  106. package/dist/modules/cpq/services/cpqQuotingService.js.map +2 -2
  107. package/dist/modules/cpq/services/orderPricingSummary.js +26 -0
  108. package/dist/modules/cpq/services/orderPricingSummary.js.map +7 -0
  109. package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js +264 -0
  110. package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js.map +7 -0
  111. package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js +25 -0
  112. package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js.map +7 -0
  113. package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js +38 -0
  114. package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js.map +7 -0
  115. package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js +104 -0
  116. package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js.map +7 -0
  117. package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js +102 -0
  118. package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js.map +7 -0
  119. package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js +52 -0
  120. package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js.map +7 -0
  121. package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js +37 -0
  122. package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js.map +7 -0
  123. package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js +107 -0
  124. package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js.map +7 -0
  125. package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js +30 -0
  126. package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js.map +7 -0
  127. package/dist/modules/cpq/services/types.js.map +2 -2
  128. package/dist/modules/cpq/setup.js +6 -0
  129. package/dist/modules/cpq/setup.js.map +2 -2
  130. package/package.json +1 -1
  131. package/src/modules/cpq/__integration__/TC-CPQ-ARC-012.spec.ts +134 -0
  132. package/src/modules/cpq/acl.ts +1 -0
  133. package/src/modules/cpq/api/__tests__/adjustmentRoutes.test.ts +79 -0
  134. package/src/modules/cpq/api/__tests__/quoteWriteGuards.test.ts +100 -0
  135. package/src/modules/cpq/api/_helpers/__tests__/quoteMutationGuard.test.ts +48 -0
  136. package/src/modules/cpq/api/_helpers/adjustmentCommandRoute.ts +45 -0
  137. package/src/modules/cpq/api/_helpers/quoteMutationGuard.ts +38 -0
  138. package/src/modules/cpq/api/_helpers/quoteRouteOpenApi.ts +19 -0
  139. package/src/modules/cpq/api/orders/[orderId]/route.ts +18 -0
  140. package/src/modules/cpq/api/orders/route.ts +51 -1
  141. package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.ts +56 -0
  142. package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.ts +48 -1
  143. package/src/modules/cpq/api/quotes/[quoteId]/items/route.ts +31 -1
  144. package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.ts +39 -0
  145. package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.ts +24 -0
  146. package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.ts +40 -0
  147. package/src/modules/cpq/api/quotes/[quoteId]/recalculate/__tests__/route.test.ts +95 -0
  148. package/src/modules/cpq/api/quotes/[quoteId]/recalculate/route.ts +40 -1
  149. package/src/modules/cpq/api/quotes/[quoteId]/status/__tests__/route.test.ts +74 -0
  150. package/src/modules/cpq/api/quotes/[quoteId]/status/route.ts +37 -1
  151. package/src/modules/cpq/api/quotes/price/__tests__/route.test.ts +37 -0
  152. package/src/modules/cpq/api/quotes/price/route.ts +28 -2
  153. package/src/modules/cpq/backend/cpq/inventory/page.meta.ts +1 -1
  154. package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.tsx +32 -10
  155. package/src/modules/cpq/backend/cpq/orders/[id]/page.tsx +60 -269
  156. package/src/modules/cpq/backend/cpq/orders/page.meta.ts +1 -1
  157. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.tsx +260 -0
  158. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.tsx +124 -0
  159. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/__tests__/PriceAdjustmentsPanel.test.tsx +76 -0
  160. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.tsx +76 -0
  161. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.tsx +133 -0
  162. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.tsx +165 -0
  163. package/src/modules/cpq/backend/cpq/quotes/[id]/page.tsx +174 -263
  164. package/src/modules/cpq/backend/cpq/quotes/page.meta.ts +1 -1
  165. package/src/modules/cpq/ce.ts +8 -0
  166. package/src/modules/cpq/cli.ts +48 -1
  167. package/src/modules/cpq/commands/__tests__/price-adjustments.test.ts +54 -0
  168. package/src/modules/cpq/commands/price-adjustments.ts +90 -0
  169. package/src/modules/cpq/components/ArcLineDiff.tsx +87 -0
  170. package/src/modules/cpq/components/ChargeBreakdownTable.tsx +201 -0
  171. package/src/modules/cpq/components/PriceWaterfall.tsx +94 -0
  172. package/src/modules/cpq/components/PricingSummaryCard.tsx +128 -0
  173. package/src/modules/cpq/components/__tests__/PriceWaterfall.test.tsx +38 -0
  174. package/src/modules/cpq/components/useGrantedFeatures.ts +35 -0
  175. package/src/modules/cpq/data/__tests__/price-adjustment-migration.test.ts +11 -0
  176. package/src/modules/cpq/data/__tests__/price-adjustments.test.ts +26 -0
  177. package/src/modules/cpq/data/entities.ts +83 -0
  178. package/src/modules/cpq/data/price-adjustments.ts +67 -0
  179. package/src/modules/cpq/data/validators.ts +3 -0
  180. package/src/modules/cpq/di.ts +6 -0
  181. package/src/modules/cpq/events.ts +53 -2
  182. package/src/modules/cpq/index.ts +1 -1
  183. package/src/modules/cpq/lib/__tests__/auditActor.test.ts +12 -0
  184. package/src/modules/cpq/lib/__tests__/priceAdjustmentDefaults.test.ts +37 -0
  185. package/src/modules/cpq/lib/__tests__/priceAdjustmentView.test.ts +152 -0
  186. package/src/modules/cpq/lib/__tests__/quotePermissions.test.ts +30 -0
  187. package/src/modules/cpq/lib/__tests__/quoteSummaryView.test.ts +56 -0
  188. package/src/modules/cpq/lib/auditActor.ts +13 -0
  189. package/src/modules/cpq/lib/formatMoney.ts +4 -0
  190. package/src/modules/cpq/lib/priceAdjustmentDefaults.ts +36 -0
  191. package/src/modules/cpq/lib/priceAdjustmentView.ts +259 -0
  192. package/src/modules/cpq/lib/quotePermissions.ts +22 -0
  193. package/src/modules/cpq/lib/quoteSummaryView.ts +51 -0
  194. package/src/modules/cpq/migrations/.snapshot-open-mercato.json +3298 -2423
  195. package/src/modules/cpq/migrations/Migration20261110213052.ts +22 -0
  196. package/src/modules/cpq/services/__tests__/_helpers/mockEm.ts +9 -2
  197. package/src/modules/cpq/services/__tests__/adjustmentService.test.ts +183 -0
  198. package/src/modules/cpq/services/__tests__/arcLineRemoval.test.ts +109 -0
  199. package/src/modules/cpq/services/__tests__/arcPriceFreeze.test.ts +141 -0
  200. package/src/modules/cpq/services/__tests__/chargeWaterfall.test.ts +28 -0
  201. package/src/modules/cpq/services/__tests__/cpqOrderService.test.ts +35 -2
  202. package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +57 -0
  203. package/src/modules/cpq/services/__tests__/cpqQuotingService.arc.test.ts +237 -0
  204. package/src/modules/cpq/services/__tests__/cpqQuotingService.test.ts +58 -3
  205. package/src/modules/cpq/services/__tests__/orderPricingSummary.test.ts +50 -0
  206. package/src/modules/cpq/services/__tests__/pricingSnapshot.test.ts +62 -0
  207. package/src/modules/cpq/services/__tests__/quoteAdjustmentLifecycle.test.ts +35 -0
  208. package/src/modules/cpq/services/__tests__/quoteAdjustmentPricing.test.ts +65 -0
  209. package/src/modules/cpq/services/__tests__/quoteCloneAdjustments.test.ts +45 -0
  210. package/src/modules/cpq/services/__tests__/quoteDetachTargetAdjustments.test.ts +54 -0
  211. package/src/modules/cpq/services/__tests__/quoteEventsService.test.ts +72 -0
  212. package/src/modules/cpq/services/__tests__/quotePricingEvents.test.ts +126 -0
  213. package/src/modules/cpq/services/__tests__/quotePricingIsolation.test.ts +52 -0
  214. package/src/modules/cpq/services/__tests__/quotePricingWrite.test.ts +74 -0
  215. package/src/modules/cpq/services/__tests__/resolveManualAdjustments.test.ts +90 -0
  216. package/src/modules/cpq/services/__tests__/storedAdjustmentFailure.test.ts +9 -0
  217. package/src/modules/cpq/services/arcLineRemoval.ts +87 -0
  218. package/src/modules/cpq/services/arcPriceFreeze.ts +165 -0
  219. package/src/modules/cpq/services/cpqOrderService.ts +22 -28
  220. package/src/modules/cpq/services/cpqPricingService.ts +8 -0
  221. package/src/modules/cpq/services/cpqQuotingService.ts +374 -125
  222. package/src/modules/cpq/services/orderPricingSummary.ts +47 -0
  223. package/src/modules/cpq/services/priceAdjustments/adjustmentService.ts +249 -0
  224. package/src/modules/cpq/services/priceAdjustments/chargeWaterfall.ts +28 -0
  225. package/src/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.ts +25 -0
  226. package/src/modules/cpq/services/priceAdjustments/pricingSnapshot.ts +86 -0
  227. package/src/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.ts +68 -0
  228. package/src/modules/cpq/services/priceAdjustments/quotePricingEvents.ts +94 -0
  229. package/src/modules/cpq/services/priceAdjustments/quotePricingWrite.ts +51 -0
  230. package/src/modules/cpq/services/priceAdjustments/resolveManualAdjustments.ts +116 -0
  231. package/src/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.ts +31 -0
  232. package/src/modules/cpq/services/types.ts +34 -1
  233. package/src/modules/cpq/setup.ts +7 -1
@@ -0,0 +1,133 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+ import { Percent, Pencil, Trash2 } from 'lucide-react'
6
+ import { IconButton } from '@open-mercato/ui/primitives/icon-button'
7
+ import { SimpleTooltip } from '@open-mercato/ui/primitives/tooltip'
8
+ import type { AdjustmentAccess, LineEligibilityInput, WaterfallChargeInput } from '../../../../../lib/priceAdjustmentView'
9
+ import { lineAdjustmentBlock } from '../../../../../lib/priceAdjustmentView'
10
+
11
+ export type AdjustableLine = LineEligibilityInput & { lineId: string; offeringName: string }
12
+ export type AdjustableCharge = WaterfallChargeInput & { chargeCode: string; chargeName: string }
13
+
14
+ export type PriceAdjustmentUi = {
15
+ access: AdjustmentAccess
16
+ quoteType: string
17
+ cpqStatus: string
18
+ reasonLabels: Record<string, string>
19
+ /** Ids of adjustments currently flagged invalid (they keep Delete while locked). */
20
+ invalidIds: Set<string>
21
+ canDeleteInvalid: boolean
22
+ openCharge: (line: AdjustableLine, charge: AdjustableCharge) => void
23
+ openLine: (line: AdjustableLine) => void
24
+ edit: (adjustmentId: string) => void
25
+ remove: (adjustmentId: string) => void
26
+ }
27
+
28
+ const Context = React.createContext<PriceAdjustmentUi | null>(null)
29
+ export const PriceAdjustmentProvider = Context.Provider
30
+ export const usePriceAdjustmentUi = () => React.useContext(Context)
31
+
32
+ /** Quote-wide explanation for why adjustments are unavailable; null when editable. */
33
+ export function useAccessReasonText(): string | null {
34
+ const t = useT()
35
+ const ui = usePriceAdjustmentUi()
36
+ if (!ui) return null
37
+ switch (ui.access.reason) {
38
+ case null: return null
39
+ case 'locked':
40
+ return t('cpq.priceAdjustments.blocked.locked', 'Adjustments are locked in status {status} — move the quote back to Ready to change them')
41
+ .replace('{status}', ui.cpqStatus)
42
+ case 'converted': return t('cpq.priceAdjustments.blocked.converted', 'This quote has been converted to an order')
43
+ case 'no_permission': return t('cpq.priceAdjustments.blocked.permission', 'You do not have permission to adjust prices')
44
+ case 'disabled': return t('cpq.priceAdjustments.blocked.disabled', 'Manual price adjustments are turned off')
45
+ default: return t('cpq.priceAdjustments.blocked.loading', 'Loading…')
46
+ }
47
+ }
48
+
49
+ /** Disabled-state explanation for one line; null when the action is available. */
50
+ export function useAdjustmentBlockReason(line: AdjustableLine): string | null {
51
+ const t = useT()
52
+ const ui = usePriceAdjustmentUi()
53
+ const accessText = useAccessReasonText()
54
+ if (!ui) return null
55
+ const block = lineAdjustmentBlock(line, ui.quoteType)
56
+ if (block === 'mirror') return t('cpq.priceAdjustments.blocked.mirror', 'Price changes on existing subscription items arrive with a later release')
57
+ if (block === 'free_form') return t('cpq.priceAdjustments.blocked.freeForm', 'Free-form lines cannot be adjusted')
58
+ return accessText
59
+ }
60
+
61
+ /**
62
+ * Disabled buttons swallow no pointer events, so a native `title` never shows.
63
+ * Wrap a blocked action in a focusable span that carries the explanation.
64
+ */
65
+ function BlockedReason({ reason, children }: { reason: string | null; children: React.ReactNode }) {
66
+ if (!reason) return <>{children}</>
67
+ return (
68
+ <SimpleTooltip content={reason} size="sm">
69
+ <span className="inline-flex cursor-not-allowed" tabIndex={0} aria-label={reason}>{children}</span>
70
+ </SimpleTooltip>
71
+ )
72
+ }
73
+
74
+ /** Per-charge "Adjust price" action. Hidden entirely when the feature is off or not permitted. */
75
+ export function AdjustChargeAction({ line, charge }: { line: AdjustableLine; charge: AdjustableCharge }) {
76
+ const t = useT()
77
+ const ui = usePriceAdjustmentUi()
78
+ const reason = useAdjustmentBlockReason(line)
79
+ if (!ui || charge.chargeType === 'usage') return null
80
+ if (ui.access.reason === 'disabled' || ui.access.reason === 'no_permission' || ui.access.reason === 'loading') return null
81
+ const label = t('cpq.priceAdjustments.adjustPrice', 'Adjust price')
82
+ return (
83
+ <BlockedReason reason={reason}>
84
+ <IconButton
85
+ variant="ghost" size="xs" aria-label={label} title={reason ? undefined : label} disabled={!!reason}
86
+ onClick={() => ui.openCharge(line, charge)} data-testid="cpq-adjust-charge"
87
+ >
88
+ <Pencil className="h-3.5 w-3.5" />
89
+ </IconButton>
90
+ </BlockedReason>
91
+ )
92
+ }
93
+
94
+ /** Line-level "Discount line" action. */
95
+ export function DiscountLineAction({ line }: { line: AdjustableLine }) {
96
+ const t = useT()
97
+ const ui = usePriceAdjustmentUi()
98
+ const reason = useAdjustmentBlockReason(line)
99
+ if (!ui || ui.access.reason === 'disabled' || ui.access.reason === 'no_permission' || ui.access.reason === 'loading') return null
100
+ const label = t('cpq.priceAdjustments.discountLine', 'Discount line')
101
+ return (
102
+ <BlockedReason reason={reason}>
103
+ <IconButton
104
+ variant="ghost" size="sm" aria-label={label} title={reason ? undefined : label} disabled={!!reason}
105
+ onClick={() => ui.openLine(line)} data-testid="cpq-discount-line"
106
+ >
107
+ <Percent className="h-4 w-4" />
108
+ </IconButton>
109
+ </BlockedReason>
110
+ )
111
+ }
112
+
113
+ /** Edit / delete actions on a manual waterfall row or panel row. */
114
+ export function ManualAdjustmentActions({ adjustmentId }: { adjustmentId: string }) {
115
+ const t = useT()
116
+ const ui = usePriceAdjustmentUi()
117
+ if (!ui) return null
118
+ const editable = ui.access.editable
119
+ const deletable = editable || (ui.canDeleteInvalid && ui.invalidIds.has(adjustmentId))
120
+ if (!editable && !deletable) return null
121
+ return (
122
+ <span className="inline-flex items-center gap-0.5">
123
+ {editable && (
124
+ <IconButton variant="ghost" size="xs" aria-label={t('cpq.priceAdjustments.edit', 'Edit adjustment')} onClick={() => ui.edit(adjustmentId)}>
125
+ <Pencil className="h-3.5 w-3.5" />
126
+ </IconButton>
127
+ )}
128
+ <IconButton variant="ghost" size="xs" aria-label={t('cpq.priceAdjustments.delete', 'Delete adjustment')} onClick={() => ui.remove(adjustmentId)}>
129
+ <Trash2 className="h-3.5 w-3.5" />
130
+ </IconButton>
131
+ </span>
132
+ )
133
+ }
@@ -0,0 +1,165 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
6
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
7
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
8
+ import {
9
+ ADJUSTMENT_ERROR_FALLBACKS,
10
+ adjustmentAccess,
11
+ adjustmentErrorCode,
12
+ canDeleteInvalidAdjustment,
13
+ collectAdjustmentErrors,
14
+ type AdjustmentErrorSourceQuote,
15
+ type AdjustmentTarget,
16
+ } from '../../../../../lib/priceAdjustmentView'
17
+ import { PriceAdjustmentDialog, type AdjustmentReason, type ExistingAdjustment } from './PriceAdjustmentDialog'
18
+ import type { AdjustableCharge, AdjustableLine, PriceAdjustmentUi } from './priceAdjustmentContext'
19
+ import type { StoredAdjustment } from './PriceAdjustmentsPanel'
20
+
21
+ type ControllerQuote = {
22
+ id: string
23
+ cpqStatus: string
24
+ currencyCode: string
25
+ convertedOrderId: string | null
26
+ pricingRevision?: number
27
+ adjustmentErrors?: Array<{ adjustmentId: string; code: string }>
28
+ lines: Array<AdjustableLine & { mrcTotal: number; nrcTotal: number; charges: AdjustableCharge[] }>
29
+ }
30
+
31
+ type ListResponse = {
32
+ items: StoredAdjustment[]
33
+ total: number
34
+ pricingRevision: number
35
+ enabled: boolean
36
+ reasons?: AdjustmentReason[]
37
+ }
38
+
39
+ type DialogState = {
40
+ target: AdjustmentTarget
41
+ targetLabel: string
42
+ charge?: AdjustableCharge
43
+ existing?: ExistingAdjustment | null
44
+ }
45
+
46
+ /**
47
+ * Owns negotiated-price state for the quote page: the adjustment list,
48
+ * reason options, the dialog and delete confirmation. Every write returns the
49
+ * repriced quote, which is handed back through `onQuote`.
50
+ */
51
+ export function usePriceAdjustments<Q extends ControllerQuote>({ quote, quoteType, canAdjust, onQuote, reloadQuote }: {
52
+ quote: Q | null
53
+ quoteType: string
54
+ canAdjust: boolean
55
+ onQuote: (quote: Q) => void
56
+ reloadQuote: () => Promise<void>
57
+ }) {
58
+ const t = useT()
59
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
60
+ const [list, setList] = React.useState<ListResponse | null>(null)
61
+ const [dialog, setDialog] = React.useState<DialogState | null>(null)
62
+ const quoteId = quote?.id ?? null
63
+ const revision = quote?.pricingRevision ?? 0
64
+
65
+ const loadList = React.useCallback(async () => {
66
+ if (!quoteId) return
67
+ const call = await apiCall<ListResponse>(`/api/cpq/quotes/${quoteId}/price-adjustments?pageSize=100`)
68
+ if (call.ok && call.result) setList(call.result)
69
+ else setList({ items: [], total: 0, pricingRevision: 0, enabled: false })
70
+ }, [quoteId])
71
+
72
+ // Refresh whenever the quote's pricing revision moves (own writes, line edits, recalculation).
73
+ React.useEffect(() => { void loadList() }, [loadList, revision])
74
+
75
+ const reasons = React.useMemo(() => list?.reasons ?? [], [list])
76
+ const reasonLabels = React.useMemo(() => Object.fromEntries(reasons.map((reason) => [reason.value, reason.label])), [reasons])
77
+ const errorsById = React.useMemo(() => collectAdjustmentErrors(quote as AdjustmentErrorSourceQuote | null), [quote])
78
+
79
+ const accessInput = {
80
+ enabled: list ? list.enabled : null,
81
+ canAdjust,
82
+ cpqStatus: quote?.cpqStatus ?? 'new',
83
+ convertedOrderId: quote?.convertedOrderId ?? null,
84
+ }
85
+ const access = adjustmentAccess(accessInput)
86
+
87
+ const remove = React.useCallback(async (adjustmentId: string) => {
88
+ if (!quoteId) return
89
+ const ok = await confirm({
90
+ title: t('cpq.priceAdjustments.deleteConfirm.title', 'Remove this price adjustment?'),
91
+ text: t('cpq.priceAdjustments.deleteConfirm.text', 'The charge goes back to its standard price.'),
92
+ confirmText: t('cpq.priceAdjustments.deleteConfirm.confirm', 'Remove'),
93
+ variant: 'destructive',
94
+ })
95
+ if (!ok) return
96
+ const call = await apiCall<{ quote: Q }>(`/api/cpq/quotes/${quoteId}/price-adjustments/${adjustmentId}?expectedRevision=${revision}`, { method: 'DELETE' })
97
+ if (call.ok && call.result?.quote) { onQuote(call.result.quote); return }
98
+ const code = adjustmentErrorCode(call.status, call.result)
99
+ flash(t(`cpq.priceAdjustments.errors.${code}`, ADJUSTMENT_ERROR_FALLBACKS[code] ?? code), 'error')
100
+ if (code === 'STALE_REVISION') await reloadQuote()
101
+ }, [quoteId, revision, confirm, t, onQuote, reloadQuote])
102
+
103
+ const ui: PriceAdjustmentUi = {
104
+ access,
105
+ quoteType,
106
+ cpqStatus: accessInput.cpqStatus,
107
+ reasonLabels,
108
+ invalidIds: new Set(errorsById.keys()),
109
+ canDeleteInvalid: canDeleteInvalidAdjustment(accessInput),
110
+ openCharge: (line, charge) => {
111
+ const existing = list?.items.find((item) => item.level === 'charge' && item.quoteLineId === line.lineId && item.chargeCode === charge.chargeCode)
112
+ setDialog({
113
+ target: { level: 'charge', quoteLineId: line.lineId, chargeCode: charge.chargeCode },
114
+ targetLabel: `${line.offeringName} · ${charge.chargeName}`,
115
+ charge,
116
+ existing: existing ?? null,
117
+ })
118
+ },
119
+ openLine: (line) => {
120
+ const existing = list?.items.find((item) => item.level === 'line' && item.quoteLineId === line.lineId)
121
+ setDialog({ target: { level: 'line', quoteLineId: line.lineId }, targetLabel: line.offeringName, existing: existing ?? null })
122
+ },
123
+ edit: (adjustmentId) => {
124
+ const item = list?.items.find((entry) => entry.id === adjustmentId)
125
+ const line = quote?.lines.find((entry) => entry.lineId === item?.quoteLineId)
126
+ if (!item || !line) return
127
+ const charge = item.chargeCode ? line.charges.find((entry) => entry.chargeCode === item.chargeCode) : undefined
128
+ setDialog({
129
+ target: item.level === 'charge' && item.chargeCode
130
+ ? { level: 'charge', quoteLineId: item.quoteLineId, chargeCode: item.chargeCode }
131
+ : { level: 'line', quoteLineId: item.quoteLineId },
132
+ targetLabel: charge ? `${line.offeringName} · ${charge.chargeName}` : line.offeringName,
133
+ charge,
134
+ existing: item,
135
+ })
136
+ },
137
+ remove: (adjustmentId) => { void remove(adjustmentId) },
138
+ }
139
+
140
+ const dialogElement = quote && dialog ? (
141
+ <PriceAdjustmentDialog<Q>
142
+ open
143
+ quoteId={quote.id}
144
+ currency={quote.currencyCode}
145
+ pricingRevision={revision}
146
+ target={dialog.target}
147
+ targetLabel={dialog.targetLabel}
148
+ charge={dialog.charge}
149
+ existing={dialog.existing}
150
+ storedAdjustmentIds={list?.items.map((item) => item.id) ?? []}
151
+ reasons={reasons}
152
+ onClose={() => setDialog(null)}
153
+ onSaved={(next) => onQuote(next)}
154
+ onStale={() => { void reloadQuote() }}
155
+ />
156
+ ) : null
157
+
158
+ return {
159
+ ui,
160
+ items: list?.items ?? [],
161
+ total: list?.total ?? 0,
162
+ errorsById,
163
+ elements: <>{dialogElement}{ConfirmDialogElement}</>,
164
+ }
165
+ }