@dainamite/cpq 0.4.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.
- package/CHANGELOG.md +70 -0
- package/MIGRATION.md +101 -0
- package/dist/modules/cpq/acl.js +1 -0
- package/dist/modules/cpq/acl.js.map +2 -2
- package/dist/modules/cpq/api/_helpers/adjustmentCommandRoute.js +45 -0
- package/dist/modules/cpq/api/_helpers/adjustmentCommandRoute.js.map +7 -0
- package/dist/modules/cpq/api/_helpers/handleArcError.js +2 -2
- package/dist/modules/cpq/api/_helpers/handleArcError.js.map +2 -2
- package/dist/modules/cpq/api/_helpers/quoteMutationGuard.js +23 -0
- package/dist/modules/cpq/api/_helpers/quoteMutationGuard.js.map +7 -0
- package/dist/modules/cpq/api/_helpers/quoteRouteOpenApi.js +19 -0
- package/dist/modules/cpq/api/_helpers/quoteRouteOpenApi.js.map +7 -0
- package/dist/modules/cpq/api/orders/[orderId]/activate/route.js +2 -2
- package/dist/modules/cpq/api/orders/[orderId]/activate/route.js.map +2 -2
- package/dist/modules/cpq/api/orders/[orderId]/route.js +19 -3
- package/dist/modules/cpq/api/orders/[orderId]/route.js.map +2 -2
- package/dist/modules/cpq/api/orders/[orderId]/status/route.js +2 -2
- package/dist/modules/cpq/api/orders/[orderId]/status/route.js.map +2 -2
- package/dist/modules/cpq/api/orders/route.js +49 -4
- package/dist/modules/cpq/api/orders/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/clone/route.js +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/clone/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.js +54 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js +45 -5
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js +27 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.js +47 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.js +30 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.js +47 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js +34 -4
- package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/route.js +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js +33 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/price/route.js +43 -3
- package/dist/modules/cpq/api/quotes/price/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/route.js +2 -2
- package/dist/modules/cpq/api/quotes/route.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/inventory/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/inventory/page.meta.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js +22 -8
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/offerings/page.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/orders/[id]/page.js +89 -232
- package/dist/modules/cpq/backend/cpq/orders/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/orders/page.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/orders/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/orders/page.meta.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/price-rules/page.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/pricing/page.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js +205 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js +79 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js +53 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js +107 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js +130 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js +144 -221
- package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js.map +3 -3
- package/dist/modules/cpq/backend/cpq/quotes/page.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/quotes/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/quotes/page.meta.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/specifications/page.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/wizards/page.js.map +1 -1
- package/dist/modules/cpq/ce.js +8 -0
- package/dist/modules/cpq/ce.js.map +2 -2
- package/dist/modules/cpq/cli.js +34 -1
- package/dist/modules/cpq/cli.js.map +2 -2
- package/dist/modules/cpq/commands/price-adjustments.js +107 -0
- package/dist/modules/cpq/commands/price-adjustments.js.map +7 -0
- package/dist/modules/cpq/components/ArcLineDiff.js +53 -0
- package/dist/modules/cpq/components/ArcLineDiff.js.map +7 -0
- package/dist/modules/cpq/components/ChargeBreakdownTable.js +154 -0
- package/dist/modules/cpq/components/ChargeBreakdownTable.js.map +7 -0
- package/dist/modules/cpq/components/CpqListView.js.map +2 -2
- package/dist/modules/cpq/components/PriceWaterfall.js +66 -0
- package/dist/modules/cpq/components/PriceWaterfall.js.map +7 -0
- package/dist/modules/cpq/components/PricingSummaryCard.js +117 -0
- package/dist/modules/cpq/components/PricingSummaryCard.js.map +7 -0
- package/dist/modules/cpq/components/useGrantedFeatures.js +27 -0
- package/dist/modules/cpq/components/useGrantedFeatures.js.map +7 -0
- package/dist/modules/cpq/data/entities.js +97 -0
- package/dist/modules/cpq/data/entities.js.map +2 -2
- package/dist/modules/cpq/data/price-adjustments.js +70 -0
- package/dist/modules/cpq/data/price-adjustments.js.map +7 -0
- package/dist/modules/cpq/data/validators.js +4 -0
- package/dist/modules/cpq/data/validators.js.map +2 -2
- package/dist/modules/cpq/di.js +6 -0
- package/dist/modules/cpq/di.js.map +2 -2
- package/dist/modules/cpq/events.js +47 -0
- package/dist/modules/cpq/events.js.map +2 -2
- package/dist/modules/cpq/index.js +1 -1
- package/dist/modules/cpq/index.js.map +2 -2
- package/dist/modules/cpq/lib/auditActor.js +10 -0
- package/dist/modules/cpq/lib/auditActor.js.map +7 -0
- package/dist/modules/cpq/lib/formatMoney.js +7 -0
- package/dist/modules/cpq/lib/formatMoney.js.map +7 -0
- package/dist/modules/cpq/lib/priceAdjustmentDefaults.js +60 -0
- package/dist/modules/cpq/lib/priceAdjustmentDefaults.js.map +7 -0
- package/dist/modules/cpq/lib/priceAdjustmentView.js +169 -0
- package/dist/modules/cpq/lib/priceAdjustmentView.js.map +7 -0
- package/dist/modules/cpq/lib/quotePermissions.js +13 -0
- package/dist/modules/cpq/lib/quotePermissions.js.map +7 -0
- package/dist/modules/cpq/lib/quoteSummaryView.js +28 -0
- package/dist/modules/cpq/lib/quoteSummaryView.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20261110213052.js +22 -0
- package/dist/modules/cpq/migrations/Migration20261110213052.js.map +7 -0
- package/dist/modules/cpq/services/arcLineRemoval.js +53 -0
- package/dist/modules/cpq/services/arcLineRemoval.js.map +7 -0
- package/dist/modules/cpq/services/arcPriceFreeze.js +79 -0
- package/dist/modules/cpq/services/arcPriceFreeze.js.map +7 -0
- package/dist/modules/cpq/services/cpqOrderService.js +34 -30
- package/dist/modules/cpq/services/cpqOrderService.js.map +2 -2
- package/dist/modules/cpq/services/cpqPricingService.js +7 -0
- package/dist/modules/cpq/services/cpqPricingService.js.map +2 -2
- package/dist/modules/cpq/services/cpqQuotingService.js +339 -111
- package/dist/modules/cpq/services/cpqQuotingService.js.map +2 -2
- package/dist/modules/cpq/services/orderPricingSummary.js +26 -0
- package/dist/modules/cpq/services/orderPricingSummary.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js +264 -0
- package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js +25 -0
- package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js +38 -0
- package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js +104 -0
- package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js +102 -0
- package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js +52 -0
- package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js +37 -0
- package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js +107 -0
- package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js +30 -0
- package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js.map +7 -0
- package/dist/modules/cpq/services/types.js.map +2 -2
- package/dist/modules/cpq/setup.js +6 -0
- package/dist/modules/cpq/setup.js.map +2 -2
- package/package.json +9 -9
- package/src/modules/cpq/__integration__/TC-CPQ-002.spec.ts +8 -5
- package/src/modules/cpq/__integration__/TC-CPQ-ARC-012.spec.ts +134 -0
- package/src/modules/cpq/acl.ts +1 -0
- package/src/modules/cpq/api/__tests__/adjustmentRoutes.test.ts +79 -0
- package/src/modules/cpq/api/__tests__/quoteWriteGuards.test.ts +100 -0
- package/src/modules/cpq/api/_helpers/__tests__/quoteMutationGuard.test.ts +48 -0
- package/src/modules/cpq/api/_helpers/adjustmentCommandRoute.ts +45 -0
- package/src/modules/cpq/api/_helpers/handleArcError.ts +12 -12
- package/src/modules/cpq/api/_helpers/quoteMutationGuard.ts +38 -0
- package/src/modules/cpq/api/_helpers/quoteRouteOpenApi.ts +19 -0
- package/src/modules/cpq/api/orders/[orderId]/activate/route.ts +2 -2
- package/src/modules/cpq/api/orders/[orderId]/route.ts +20 -2
- package/src/modules/cpq/api/orders/[orderId]/status/route.ts +2 -2
- package/src/modules/cpq/api/orders/route.ts +53 -3
- package/src/modules/cpq/api/quotes/[quoteId]/clone/route.ts +2 -2
- package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.ts +56 -0
- package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.ts +51 -4
- package/src/modules/cpq/api/quotes/[quoteId]/items/route.ts +31 -1
- package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.ts +39 -0
- package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.ts +24 -0
- package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.ts +40 -0
- package/src/modules/cpq/api/quotes/[quoteId]/recalculate/__tests__/route.test.ts +95 -0
- package/src/modules/cpq/api/quotes/[quoteId]/recalculate/route.ts +42 -3
- package/src/modules/cpq/api/quotes/[quoteId]/route.ts +2 -2
- package/src/modules/cpq/api/quotes/[quoteId]/status/__tests__/route.test.ts +74 -0
- package/src/modules/cpq/api/quotes/[quoteId]/status/route.ts +37 -1
- package/src/modules/cpq/api/quotes/price/__tests__/route.test.ts +37 -0
- package/src/modules/cpq/api/quotes/price/route.ts +28 -2
- package/src/modules/cpq/api/quotes/route.ts +2 -2
- package/src/modules/cpq/backend/cpq/inventory/AssetsTable.tsx +1 -1
- package/src/modules/cpq/backend/cpq/inventory/SubscriptionsTable.tsx +1 -1
- package/src/modules/cpq/backend/cpq/inventory/page.meta.ts +1 -1
- package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.tsx +32 -10
- package/src/modules/cpq/backend/cpq/offerings/page.tsx +1 -1
- package/src/modules/cpq/backend/cpq/orders/[id]/page.tsx +60 -269
- package/src/modules/cpq/backend/cpq/orders/page.meta.ts +1 -1
- package/src/modules/cpq/backend/cpq/orders/page.tsx +1 -1
- package/src/modules/cpq/backend/cpq/price-rules/page.tsx +1 -1
- package/src/modules/cpq/backend/cpq/pricing/page.tsx +1 -1
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.tsx +260 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.tsx +124 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/__tests__/PriceAdjustmentsPanel.test.tsx +76 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.tsx +76 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.tsx +133 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.tsx +165 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/page.tsx +178 -266
- package/src/modules/cpq/backend/cpq/quotes/page.meta.ts +1 -1
- package/src/modules/cpq/backend/cpq/quotes/page.tsx +1 -1
- package/src/modules/cpq/backend/cpq/specifications/page.tsx +1 -1
- package/src/modules/cpq/backend/cpq/wizards/page.tsx +1 -1
- package/src/modules/cpq/ce.ts +8 -0
- package/src/modules/cpq/cli.ts +48 -1
- package/src/modules/cpq/commands/__tests__/price-adjustments.test.ts +54 -0
- package/src/modules/cpq/commands/price-adjustments.ts +90 -0
- package/src/modules/cpq/components/ArcLineDiff.tsx +87 -0
- package/src/modules/cpq/components/ChargeBreakdownTable.tsx +201 -0
- package/src/modules/cpq/components/CpqListView.tsx +2 -1
- package/src/modules/cpq/components/PriceWaterfall.tsx +94 -0
- package/src/modules/cpq/components/PricingSummaryCard.tsx +128 -0
- package/src/modules/cpq/components/__tests__/PriceWaterfall.test.tsx +38 -0
- package/src/modules/cpq/components/useGrantedFeatures.ts +35 -0
- package/src/modules/cpq/data/__tests__/price-adjustment-migration.test.ts +11 -0
- package/src/modules/cpq/data/__tests__/price-adjustments.test.ts +26 -0
- package/src/modules/cpq/data/entities.ts +83 -0
- package/src/modules/cpq/data/price-adjustments.ts +67 -0
- package/src/modules/cpq/data/validators.ts +3 -0
- package/src/modules/cpq/di.ts +6 -0
- package/src/modules/cpq/events.ts +53 -2
- package/src/modules/cpq/index.ts +1 -1
- package/src/modules/cpq/lib/__tests__/auditActor.test.ts +12 -0
- package/src/modules/cpq/lib/__tests__/priceAdjustmentDefaults.test.ts +37 -0
- package/src/modules/cpq/lib/__tests__/priceAdjustmentView.test.ts +152 -0
- package/src/modules/cpq/lib/__tests__/quotePermissions.test.ts +30 -0
- package/src/modules/cpq/lib/__tests__/quoteSummaryView.test.ts +56 -0
- package/src/modules/cpq/lib/auditActor.ts +13 -0
- package/src/modules/cpq/lib/formatMoney.ts +4 -0
- package/src/modules/cpq/lib/priceAdjustmentDefaults.ts +36 -0
- package/src/modules/cpq/lib/priceAdjustmentView.ts +259 -0
- package/src/modules/cpq/lib/quotePermissions.ts +22 -0
- package/src/modules/cpq/lib/quoteSummaryView.ts +51 -0
- package/src/modules/cpq/migrations/.snapshot-open-mercato.json +3298 -2423
- package/src/modules/cpq/migrations/Migration20261110213052.ts +22 -0
- package/src/modules/cpq/services/__tests__/_helpers/mockEm.ts +9 -2
- package/src/modules/cpq/services/__tests__/adjustmentService.test.ts +183 -0
- package/src/modules/cpq/services/__tests__/arcLineRemoval.test.ts +109 -0
- package/src/modules/cpq/services/__tests__/arcPriceFreeze.test.ts +141 -0
- package/src/modules/cpq/services/__tests__/chargeWaterfall.test.ts +28 -0
- package/src/modules/cpq/services/__tests__/cpqOrderService.test.ts +35 -2
- package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +57 -0
- package/src/modules/cpq/services/__tests__/cpqQuotingService.arc.test.ts +237 -0
- package/src/modules/cpq/services/__tests__/cpqQuotingService.test.ts +58 -3
- package/src/modules/cpq/services/__tests__/errorBrands.test.ts +72 -0
- package/src/modules/cpq/services/__tests__/orderPricingSummary.test.ts +50 -0
- package/src/modules/cpq/services/__tests__/pricingSnapshot.test.ts +62 -0
- package/src/modules/cpq/services/__tests__/quoteAdjustmentLifecycle.test.ts +35 -0
- package/src/modules/cpq/services/__tests__/quoteAdjustmentPricing.test.ts +65 -0
- package/src/modules/cpq/services/__tests__/quoteCloneAdjustments.test.ts +45 -0
- package/src/modules/cpq/services/__tests__/quoteDetachTargetAdjustments.test.ts +54 -0
- package/src/modules/cpq/services/__tests__/quoteEventsService.test.ts +72 -0
- package/src/modules/cpq/services/__tests__/quotePricingEvents.test.ts +126 -0
- package/src/modules/cpq/services/__tests__/quotePricingIsolation.test.ts +52 -0
- package/src/modules/cpq/services/__tests__/quotePricingWrite.test.ts +74 -0
- package/src/modules/cpq/services/__tests__/resolveManualAdjustments.test.ts +90 -0
- package/src/modules/cpq/services/__tests__/storedAdjustmentFailure.test.ts +9 -0
- package/src/modules/cpq/services/arcLineRemoval.ts +87 -0
- package/src/modules/cpq/services/arcPriceFreeze.ts +165 -0
- package/src/modules/cpq/services/cpqOrderService.ts +32 -28
- package/src/modules/cpq/services/cpqPricingService.ts +8 -0
- package/src/modules/cpq/services/cpqQuotingService.ts +388 -125
- package/src/modules/cpq/services/orderPricingSummary.ts +47 -0
- package/src/modules/cpq/services/priceAdjustments/adjustmentService.ts +249 -0
- package/src/modules/cpq/services/priceAdjustments/chargeWaterfall.ts +28 -0
- package/src/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.ts +25 -0
- package/src/modules/cpq/services/priceAdjustments/pricingSnapshot.ts +86 -0
- package/src/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.ts +68 -0
- package/src/modules/cpq/services/priceAdjustments/quotePricingEvents.ts +94 -0
- package/src/modules/cpq/services/priceAdjustments/quotePricingWrite.ts +51 -0
- package/src/modules/cpq/services/priceAdjustments/resolveManualAdjustments.ts +116 -0
- package/src/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.ts +31 -0
- package/src/modules/cpq/services/types.ts +34 -1
- package/src/modules/cpq/setup.ts +7 -1
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
5
|
+
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
6
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
7
|
+
import { Input } from '@open-mercato/ui/primitives/input'
|
|
8
|
+
import { Textarea } from '@open-mercato/ui/primitives/textarea'
|
|
9
|
+
import { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'
|
|
10
|
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
|
|
11
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@open-mercato/ui/primitives/select'
|
|
12
|
+
import {
|
|
13
|
+
ADJUSTMENT_ERROR_FALLBACKS,
|
|
14
|
+
adjustmentErrorCode,
|
|
15
|
+
allowedAdjustmentTypes,
|
|
16
|
+
buildAdjustmentDraft,
|
|
17
|
+
formatDepth,
|
|
18
|
+
discountDepth,
|
|
19
|
+
toAdjustmentPatch,
|
|
20
|
+
type AdjustmentForm,
|
|
21
|
+
type AdjustmentTarget,
|
|
22
|
+
type AdjustmentType,
|
|
23
|
+
type WaterfallChargeInput,
|
|
24
|
+
} from '../../../../../lib/priceAdjustmentView'
|
|
25
|
+
|
|
26
|
+
export type AdjustmentReason = { value: string; label: string }
|
|
27
|
+
|
|
28
|
+
/** Minimal view of the quote the dialog needs, kept local so the page owns the full type. */
|
|
29
|
+
type PreviewQuote = {
|
|
30
|
+
pricingRevision?: number
|
|
31
|
+
lines: Array<{ lineId: string; mrcTotal: number; nrcTotal: number; charges: Array<WaterfallChargeInput & { chargeCode: string; adjustmentErrors?: Array<{ adjustmentId: string; code: string }> }> }>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ExistingAdjustment = {
|
|
35
|
+
id: string
|
|
36
|
+
adjustmentType: AdjustmentType
|
|
37
|
+
value: number
|
|
38
|
+
reasonCode: string
|
|
39
|
+
reasonNote?: string | null
|
|
40
|
+
chargeTypeFilter?: 'nrc' | 'mrc' | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type PriceAdjustmentDialogProps<Q extends PreviewQuote> = {
|
|
44
|
+
open: boolean
|
|
45
|
+
quoteId: string
|
|
46
|
+
currency: string
|
|
47
|
+
pricingRevision: number
|
|
48
|
+
target: AdjustmentTarget
|
|
49
|
+
/** Display name of the line / charge being adjusted. */
|
|
50
|
+
targetLabel: string
|
|
51
|
+
/** The charge for charge-level targets (drives type options and the current price). */
|
|
52
|
+
charge?: WaterfallChargeInput & { chargeCode: string; chargeName: string }
|
|
53
|
+
existing?: ExistingAdjustment | null
|
|
54
|
+
/** Ids of adjustments already stored on the quote; their errors don't block this draft. */
|
|
55
|
+
storedAdjustmentIds: string[]
|
|
56
|
+
reasons: AdjustmentReason[]
|
|
57
|
+
onClose: () => void
|
|
58
|
+
onSaved: (quote: Q) => void
|
|
59
|
+
/** Called when the server reports a stale revision; the page reloads the quote. */
|
|
60
|
+
onStale: () => void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const TYPE_FALLBACKS: Record<AdjustmentType, string> = {
|
|
64
|
+
price_override: 'Set a new unit price',
|
|
65
|
+
discount_percent: 'Percentage discount',
|
|
66
|
+
discount_absolute: 'Amount off the charge total',
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function money(amount: number, currency: string) {
|
|
70
|
+
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function PriceAdjustmentDialog<Q extends PreviewQuote>(props: PriceAdjustmentDialogProps<Q>) {
|
|
74
|
+
const { open, quoteId, currency, pricingRevision, target, targetLabel, charge, existing, storedAdjustmentIds, reasons, onClose, onSaved, onStale } = props
|
|
75
|
+
const t = useT()
|
|
76
|
+
const types = allowedAdjustmentTypes(target.level, charge)
|
|
77
|
+
const initialForm = React.useCallback((): AdjustmentForm => ({
|
|
78
|
+
adjustmentType: existing?.adjustmentType ?? types[0] ?? 'discount_percent',
|
|
79
|
+
value: existing ? String(existing.value) : '',
|
|
80
|
+
reasonCode: existing?.reasonCode ?? '',
|
|
81
|
+
reasonNote: existing?.reasonNote ?? '',
|
|
82
|
+
chargeTypeFilter: existing?.chargeTypeFilter ?? '',
|
|
83
|
+
}), [existing, types])
|
|
84
|
+
const [form, setForm] = React.useState<AdjustmentForm>(initialForm)
|
|
85
|
+
const [preview, setPreview] = React.useState<{ quote: Q; errors: Array<{ adjustmentId: string; code: string }> } | null>(null)
|
|
86
|
+
const [previewing, setPreviewing] = React.useState(false)
|
|
87
|
+
const [errorCode, setErrorCode] = React.useState<string | null>(null)
|
|
88
|
+
const [saving, setSaving] = React.useState(false)
|
|
89
|
+
|
|
90
|
+
React.useEffect(() => {
|
|
91
|
+
if (open) { setForm(initialForm()); setPreview(null); setErrorCode(null) }
|
|
92
|
+
// Reset only when the dialog (re)opens.
|
|
93
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
94
|
+
}, [open])
|
|
95
|
+
|
|
96
|
+
const draft = buildAdjustmentDraft(form, target, currency, pricingRevision)
|
|
97
|
+
const draftKey = draft && form.reasonCode ? JSON.stringify(draft) : null
|
|
98
|
+
const message = (code: string) => t(`cpq.priceAdjustments.errors.${code}`, ADJUSTMENT_ERROR_FALLBACKS[code] ?? code)
|
|
99
|
+
|
|
100
|
+
// Debounced, side-effect-free preview of the draft.
|
|
101
|
+
React.useEffect(() => {
|
|
102
|
+
if (!open || !draftKey) { setPreview(null); return }
|
|
103
|
+
let cancelled = false
|
|
104
|
+
const timer = window.setTimeout(async () => {
|
|
105
|
+
setPreviewing(true)
|
|
106
|
+
const call = await apiCall<{ quote: Q; errors: Array<{ adjustmentId: string; code: string }> }>(
|
|
107
|
+
`/api/cpq/quotes/${quoteId}/price-adjustments/preview`,
|
|
108
|
+
{ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ draft: JSON.parse(draftKey), ...(existing ? { replaceAdjustmentId: existing.id } : {}) }) },
|
|
109
|
+
)
|
|
110
|
+
if (cancelled) return
|
|
111
|
+
setPreviewing(false)
|
|
112
|
+
if (call.ok && call.result) { setPreview(call.result); setErrorCode(null) }
|
|
113
|
+
else { setPreview(null); setErrorCode(adjustmentErrorCode(call.status, call.result)) }
|
|
114
|
+
}, 400)
|
|
115
|
+
return () => { cancelled = true; window.clearTimeout(timer) }
|
|
116
|
+
}, [open, draftKey, quoteId, existing])
|
|
117
|
+
|
|
118
|
+
const previewLine = preview?.quote.lines.find((line) => line.lineId === target.quoteLineId)
|
|
119
|
+
const previewCharge = target.level === 'charge' ? previewLine?.charges.find((entry) => entry.chargeCode === target.chargeCode) : undefined
|
|
120
|
+
// The draft's id is server-generated on create, so "not stored" identifies it; on edit it keeps its id.
|
|
121
|
+
const previewErrors = preview?.errors.filter((error) => existing ? error.adjustmentId === existing.id : !storedAdjustmentIds.includes(error.adjustmentId)) ?? []
|
|
122
|
+
const noteRequired = form.reasonCode === 'other' && !form.reasonNote.trim()
|
|
123
|
+
const canSave = !!draft && !!form.reasonCode && !noteRequired && !saving && previewErrors.length === 0
|
|
124
|
+
|
|
125
|
+
const save = async () => {
|
|
126
|
+
if (!draft || !canSave) return
|
|
127
|
+
setSaving(true); setErrorCode(null)
|
|
128
|
+
const call = existing
|
|
129
|
+
? await apiCall<{ quote: Q }>(`/api/cpq/quotes/${quoteId}/price-adjustments/${existing.id}`, {
|
|
130
|
+
method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(toAdjustmentPatch(draft)),
|
|
131
|
+
})
|
|
132
|
+
: await apiCall<{ quote: Q }>(`/api/cpq/quotes/${quoteId}/price-adjustments`, {
|
|
133
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(draft),
|
|
134
|
+
})
|
|
135
|
+
setSaving(false)
|
|
136
|
+
if (call.ok && call.result?.quote) { onSaved(call.result.quote); onClose(); return }
|
|
137
|
+
setErrorCode(adjustmentErrorCode(call.status, call.result))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const onKeyDown = (event: React.KeyboardEvent) => {
|
|
141
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); void save() }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const standardUnit = charge?.standardUnitPrice ?? charge?.unitPrice
|
|
145
|
+
const valueHint = form.adjustmentType === 'discount_percent'
|
|
146
|
+
? t('cpq.priceAdjustments.dialog.percentHint', 'Percent off the standard price (0–100)')
|
|
147
|
+
: form.adjustmentType === 'price_override'
|
|
148
|
+
? t('cpq.priceAdjustments.dialog.overrideHint', 'New unit price, at most the standard price')
|
|
149
|
+
: t('cpq.priceAdjustments.dialog.absoluteHint', 'Amount taken off this charge’s total')
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
|
|
153
|
+
<DialogContent onKeyDown={onKeyDown} data-testid="cpq-price-adjustment-dialog">
|
|
154
|
+
<DialogHeader>
|
|
155
|
+
<DialogTitle>
|
|
156
|
+
{existing
|
|
157
|
+
? t('cpq.priceAdjustments.dialog.editTitle', 'Edit price adjustment')
|
|
158
|
+
: target.level === 'line'
|
|
159
|
+
? t('cpq.priceAdjustments.dialog.lineTitle', 'Discount line')
|
|
160
|
+
: t('cpq.priceAdjustments.dialog.chargeTitle', 'Adjust price')}
|
|
161
|
+
</DialogTitle>
|
|
162
|
+
<DialogDescription>
|
|
163
|
+
{targetLabel}
|
|
164
|
+
{charge && standardUnit !== undefined && (
|
|
165
|
+
<> · {t('cpq.priceAdjustments.dialog.standard', 'Standard')} {money(standardUnit, currency)}</>
|
|
166
|
+
)}
|
|
167
|
+
</DialogDescription>
|
|
168
|
+
</DialogHeader>
|
|
169
|
+
|
|
170
|
+
<div className="space-y-3 text-sm">
|
|
171
|
+
<label className="block space-y-1">
|
|
172
|
+
<span className="text-xs font-medium">{t('cpq.priceAdjustments.dialog.type', 'Adjustment')}</span>
|
|
173
|
+
<Select value={form.adjustmentType} onValueChange={(value) => setForm({ ...form, adjustmentType: value as AdjustmentType })}>
|
|
174
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
175
|
+
<SelectContent>
|
|
176
|
+
{types.map((type) => (
|
|
177
|
+
<SelectItem key={type} value={type}>{t(`cpq.priceAdjustments.dialog.types.${type}`, TYPE_FALLBACKS[type])}</SelectItem>
|
|
178
|
+
))}
|
|
179
|
+
</SelectContent>
|
|
180
|
+
</Select>
|
|
181
|
+
</label>
|
|
182
|
+
|
|
183
|
+
{target.level === 'line' && (
|
|
184
|
+
<label className="block space-y-1">
|
|
185
|
+
<span className="text-xs font-medium">{t('cpq.priceAdjustments.dialog.appliesTo', 'Applies to')}</span>
|
|
186
|
+
<Select value={form.chargeTypeFilter || 'all'} onValueChange={(value) => setForm({ ...form, chargeTypeFilter: value === 'all' ? '' : value as 'nrc' | 'mrc' })}>
|
|
187
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
188
|
+
<SelectContent>
|
|
189
|
+
<SelectItem value="all">{t('cpq.priceAdjustments.dialog.allCharges', 'All one-time and recurring charges')}</SelectItem>
|
|
190
|
+
<SelectItem value="mrc">{t('cpq.priceAdjustments.dialog.mrcOnly', 'Recurring charges only')}</SelectItem>
|
|
191
|
+
<SelectItem value="nrc">{t('cpq.priceAdjustments.dialog.nrcOnly', 'One-time charges only')}</SelectItem>
|
|
192
|
+
</SelectContent>
|
|
193
|
+
</Select>
|
|
194
|
+
</label>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
<label className="block space-y-1">
|
|
198
|
+
<span className="text-xs font-medium">{t('cpq.priceAdjustments.dialog.value', 'Value')}</span>
|
|
199
|
+
<Input inputMode="decimal" autoFocus value={form.value} onChange={(event) => setForm({ ...form, value: event.target.value })} aria-describedby="cpq-adjustment-value-hint" />
|
|
200
|
+
<span id="cpq-adjustment-value-hint" className="block text-xs text-muted-foreground">{valueHint}</span>
|
|
201
|
+
</label>
|
|
202
|
+
|
|
203
|
+
<label className="block space-y-1">
|
|
204
|
+
<span className="text-xs font-medium">{t('cpq.priceAdjustments.dialog.reason', 'Reason')}</span>
|
|
205
|
+
<Select value={form.reasonCode} onValueChange={(value) => setForm({ ...form, reasonCode: value })}>
|
|
206
|
+
<SelectTrigger><SelectValue placeholder={t('cpq.priceAdjustments.dialog.reasonPlaceholder', 'Pick a reason')} /></SelectTrigger>
|
|
207
|
+
<SelectContent>
|
|
208
|
+
{reasons.map((reason) => <SelectItem key={reason.value} value={reason.value}>{reason.label}</SelectItem>)}
|
|
209
|
+
</SelectContent>
|
|
210
|
+
</Select>
|
|
211
|
+
</label>
|
|
212
|
+
|
|
213
|
+
<label className="block space-y-1">
|
|
214
|
+
<span className="text-xs font-medium">
|
|
215
|
+
{t('cpq.priceAdjustments.dialog.note', 'Note')}{form.reasonCode === 'other' ? ' *' : ''}
|
|
216
|
+
</span>
|
|
217
|
+
<Textarea rows={2} maxLength={500} value={form.reasonNote} onChange={(event) => setForm({ ...form, reasonNote: event.target.value })} />
|
|
218
|
+
</label>
|
|
219
|
+
|
|
220
|
+
<div className="rounded border bg-muted/20 p-2 text-xs" aria-live="polite" data-testid="cpq-adjustment-preview">
|
|
221
|
+
{!draftKey && <span className="text-muted-foreground">{t('cpq.priceAdjustments.dialog.previewEmpty', 'Enter a value and reason to preview the new price.')}</span>}
|
|
222
|
+
{draftKey && previewing && <span className="text-muted-foreground">{t('cpq.priceAdjustments.dialog.previewing', 'Calculating…')}</span>}
|
|
223
|
+
{draftKey && !previewing && previewCharge && (
|
|
224
|
+
<span>
|
|
225
|
+
{t('cpq.priceAdjustments.dialog.previewCharge', 'New offer price')}: <span className="font-mono font-medium">{money(previewCharge.unitPrice, currency)}</span>
|
|
226
|
+
{' '}{formatDepth(discountDepth(previewCharge.standardUnitPrice ?? standardUnit, previewCharge.unitPrice)) ?? ''}
|
|
227
|
+
</span>
|
|
228
|
+
)}
|
|
229
|
+
{draftKey && !previewing && !previewCharge && previewLine && (
|
|
230
|
+
<span>
|
|
231
|
+
{t('cpq.priceAdjustments.dialog.previewLine', 'Line totals')}: MRC <span className="font-mono font-medium">{money(previewLine.mrcTotal, currency)}</span>
|
|
232
|
+
{' · '}NRC <span className="font-mono font-medium">{money(previewLine.nrcTotal, currency)}</span>
|
|
233
|
+
</span>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
{(errorCode || previewErrors.length > 0 || noteRequired) && (
|
|
238
|
+
<Alert variant="warning">
|
|
239
|
+
<AlertDescription>
|
|
240
|
+
{errorCode ? message(errorCode) : previewErrors.length > 0 ? message(previewErrors[0].code) : message('REASON_NOTE_REQUIRED')}
|
|
241
|
+
{errorCode === 'STALE_REVISION' && (
|
|
242
|
+
<Button variant="outline" size="sm" className="ml-2" onClick={() => { onStale(); onClose() }}>
|
|
243
|
+
{t('cpq.priceAdjustments.dialog.reload', 'Reload')}
|
|
244
|
+
</Button>
|
|
245
|
+
)}
|
|
246
|
+
</AlertDescription>
|
|
247
|
+
</Alert>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<DialogFooter>
|
|
252
|
+
<Button variant="outline" onClick={onClose}>{t('cpq.priceAdjustments.dialog.cancel', 'Cancel')}</Button>
|
|
253
|
+
<Button onClick={() => void save()} disabled={!canSave} title="Ctrl/Cmd+Enter">
|
|
254
|
+
{saving ? t('cpq.priceAdjustments.dialog.saving', 'Saving…') : t('cpq.priceAdjustments.dialog.save', 'Save')}
|
|
255
|
+
</Button>
|
|
256
|
+
</DialogFooter>
|
|
257
|
+
</DialogContent>
|
|
258
|
+
</Dialog>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
5
|
+
import { Tag } from '@open-mercato/ui/primitives/tag'
|
|
6
|
+
import { InjectionSpot } from '@open-mercato/ui/backend/injection/InjectionSpot'
|
|
7
|
+
import { ADJUSTMENT_ERROR_FALLBACKS } from '../../../../../lib/priceAdjustmentView'
|
|
8
|
+
import { ManualAdjustmentActions, useAccessReasonText, usePriceAdjustmentUi } from './priceAdjustmentContext'
|
|
9
|
+
|
|
10
|
+
export const PRICE_ADJUSTMENTS_SPOT_ID = 'data-table:cpq.quote_price_adjustments'
|
|
11
|
+
|
|
12
|
+
export type StoredAdjustment = {
|
|
13
|
+
id: string
|
|
14
|
+
level: 'charge' | 'line'
|
|
15
|
+
quoteLineId: string
|
|
16
|
+
chargeCode?: string | null
|
|
17
|
+
chargeTypeFilter?: 'nrc' | 'mrc' | null
|
|
18
|
+
adjustmentType: 'discount_percent' | 'discount_absolute' | 'price_override'
|
|
19
|
+
value: number
|
|
20
|
+
currencyCode?: string | null
|
|
21
|
+
reasonCode: string
|
|
22
|
+
reasonNote?: string | null
|
|
23
|
+
createdByUserId: string
|
|
24
|
+
createdAt: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type LineRef = { lineId: string; offeringName: string; offeringId: string | null; configuration?: Record<string, unknown> | null; charges: Array<{ chargeCode: string; chargeName: string }> }
|
|
28
|
+
|
|
29
|
+
const TYPE_FALLBACKS: Record<StoredAdjustment['adjustmentType'], string> = {
|
|
30
|
+
price_override: 'Price override',
|
|
31
|
+
discount_percent: '% discount',
|
|
32
|
+
discount_absolute: 'Amount off',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function money(amount: number, currency: string) {
|
|
36
|
+
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function PriceAdjustmentsPanel({ quoteId, items, total, lines, currency, errorsById }: {
|
|
40
|
+
quoteId: string
|
|
41
|
+
items: StoredAdjustment[]
|
|
42
|
+
total: number
|
|
43
|
+
lines: LineRef[]
|
|
44
|
+
currency: string
|
|
45
|
+
/** adjustmentId → first error code reported by the latest pricing. */
|
|
46
|
+
errorsById: Map<string, string>
|
|
47
|
+
}) {
|
|
48
|
+
const t = useT()
|
|
49
|
+
const ui = usePriceAdjustmentUi()
|
|
50
|
+
const lineById = React.useMemo(() => new Map(lines.map((line) => [line.lineId, line])), [lines])
|
|
51
|
+
const accessText = useAccessReasonText()
|
|
52
|
+
// Toggle off: stored adjustments still price the quote, so keep them visible (read-only).
|
|
53
|
+
if (!ui || (ui.access.reason === 'disabled' && items.length === 0)) return null
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="rounded-lg border bg-card" data-testid="cpq-price-adjustments-panel">
|
|
57
|
+
<div className="flex items-center justify-between border-b px-4 py-3">
|
|
58
|
+
<h3 className="text-sm font-medium">
|
|
59
|
+
{t('cpq.priceAdjustments.panel.title', 'Price adjustments')}
|
|
60
|
+
<span className="ml-2 text-xs text-muted-foreground">{total}</span>
|
|
61
|
+
</h3>
|
|
62
|
+
{accessText && ui.access.reason !== 'loading' && (
|
|
63
|
+
<span className="text-xs text-muted-foreground" data-testid="cpq-price-adjustments-locked">{accessText}</span>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
{items.length === 0 ? (
|
|
67
|
+
<p className="px-4 py-3 text-xs text-muted-foreground">
|
|
68
|
+
{t('cpq.priceAdjustments.panel.empty', 'No negotiated prices yet. Use “Adjust price” on a charge or “Discount line” on a line.')}
|
|
69
|
+
</p>
|
|
70
|
+
) : (
|
|
71
|
+
<table className="w-full text-xs">
|
|
72
|
+
<thead>
|
|
73
|
+
<tr className="text-muted-foreground">
|
|
74
|
+
<th className="px-4 py-1.5 text-left font-medium">{t('cpq.priceAdjustments.panel.target', 'Applies to')}</th>
|
|
75
|
+
<th className="py-1.5 text-left font-medium">{t('cpq.priceAdjustments.panel.type', 'Type')}</th>
|
|
76
|
+
<th className="py-1.5 text-right font-medium">{t('cpq.priceAdjustments.panel.value', 'Value')}</th>
|
|
77
|
+
<th className="py-1.5 pl-4 text-left font-medium">{t('cpq.priceAdjustments.panel.reason', 'Reason')}</th>
|
|
78
|
+
<th className="py-1.5 text-left font-medium">{t('cpq.priceAdjustments.panel.author', 'Author')}</th>
|
|
79
|
+
<th className="py-1.5 text-left font-medium">{t('cpq.priceAdjustments.panel.date', 'Date')}</th>
|
|
80
|
+
<th className="w-16" />
|
|
81
|
+
</tr>
|
|
82
|
+
</thead>
|
|
83
|
+
<tbody>
|
|
84
|
+
{items.map((item) => {
|
|
85
|
+
const line = lineById.get(item.quoteLineId)
|
|
86
|
+
const chargeName = item.chargeCode ? line?.charges.find((charge) => charge.chargeCode === item.chargeCode)?.chargeName ?? item.chargeCode : null
|
|
87
|
+
const scope = item.level === 'line'
|
|
88
|
+
? item.chargeTypeFilter === 'mrc' ? t('cpq.priceAdjustments.panel.lineMrc', 'whole line, recurring')
|
|
89
|
+
: item.chargeTypeFilter === 'nrc' ? t('cpq.priceAdjustments.panel.lineNrc', 'whole line, one-time')
|
|
90
|
+
: t('cpq.priceAdjustments.panel.line', 'whole line')
|
|
91
|
+
: chargeName
|
|
92
|
+
const error = errorsById.get(item.id)
|
|
93
|
+
return (
|
|
94
|
+
<tr key={item.id} className="border-t" data-testid="cpq-price-adjustment-row">
|
|
95
|
+
<td className="px-4 py-1.5">
|
|
96
|
+
<span className="font-medium">{line?.offeringName ?? t('cpq.priceAdjustments.panel.removedLine', 'Removed line')}</span>
|
|
97
|
+
<span className="text-muted-foreground"> · {scope}</span>
|
|
98
|
+
{error && (
|
|
99
|
+
<Tag variant="warning" className="ml-1.5 px-1.5 text-xs">
|
|
100
|
+
{t('cpq.priceAdjustments.invalid', 'Invalid')}: {t(`cpq.priceAdjustments.errors.${error}`, ADJUSTMENT_ERROR_FALLBACKS[error] ?? error)}
|
|
101
|
+
</Tag>
|
|
102
|
+
)}
|
|
103
|
+
</td>
|
|
104
|
+
<td className="py-1.5">{t(`cpq.priceAdjustments.panel.types.${item.adjustmentType}`, TYPE_FALLBACKS[item.adjustmentType])}</td>
|
|
105
|
+
<td className="py-1.5 text-right font-mono">
|
|
106
|
+
{item.adjustmentType === 'discount_percent' ? `${item.value}%` : money(item.value, item.currencyCode ?? currency)}
|
|
107
|
+
</td>
|
|
108
|
+
<td className="py-1.5 pl-4">
|
|
109
|
+
{ui.reasonLabels[item.reasonCode] ?? item.reasonCode}
|
|
110
|
+
{item.reasonNote && <span className="block text-muted-foreground">{item.reasonNote}</span>}
|
|
111
|
+
</td>
|
|
112
|
+
<td className="py-1.5 font-mono text-muted-foreground" title={item.createdByUserId}>{item.createdByUserId.slice(0, 8)}</td>
|
|
113
|
+
<td className="py-1.5 text-muted-foreground">{new Date(item.createdAt).toLocaleDateString()}</td>
|
|
114
|
+
<td className="py-1.5 pr-2 text-right"><ManualAdjustmentActions adjustmentId={item.id} /></td>
|
|
115
|
+
</tr>
|
|
116
|
+
)
|
|
117
|
+
})}
|
|
118
|
+
</tbody>
|
|
119
|
+
</table>
|
|
120
|
+
)}
|
|
121
|
+
<InjectionSpot spotId={PRICE_ADJUSTMENTS_SPOT_ID} context={{ quoteId, adjustments: items }} />
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
}
|
package/src/modules/cpq/backend/cpq/quotes/[id]/_components/__tests__/PriceAdjustmentsPanel.test.tsx
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
4
|
+
import { PriceAdjustmentsPanel, PRICE_ADJUSTMENTS_SPOT_ID } from '../PriceAdjustmentsPanel'
|
|
5
|
+
import { AdjustChargeAction, DiscountLineAction, PriceAdjustmentProvider, type PriceAdjustmentUi } from '../priceAdjustmentContext'
|
|
6
|
+
import { adjustmentAccess } from '../../../../../../lib/priceAdjustmentView'
|
|
7
|
+
|
|
8
|
+
jest.mock('@open-mercato/ui/backend/injection/InjectionSpot', () => ({
|
|
9
|
+
InjectionSpot: ({ spotId }: { spotId: string }) => <div data-spot={spotId} />,
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
const text = (html: string) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
|
13
|
+
const line = { lineId: 'l1', offeringName: 'Fiber 1G', offeringId: 'o1', configuration: {}, charges: [{ chargeCode: 'fee', chargeName: 'Monthly fee' }] }
|
|
14
|
+
const item = {
|
|
15
|
+
id: 'a1', level: 'charge' as const, quoteLineId: 'l1', chargeCode: 'fee', adjustmentType: 'price_override' as const, value: 80,
|
|
16
|
+
currencyCode: 'USD', reasonCode: 'competitive_match', reasonNote: 'Beat rival', createdByUserId: '77777777-7777-4777-8777-777777777777', createdAt: '2026-11-10T10:00:00.000Z',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ui(patch: Partial<Parameters<typeof adjustmentAccess>[0]> = {}, extra: Partial<PriceAdjustmentUi> = {}): PriceAdjustmentUi {
|
|
20
|
+
const input = { enabled: true, canAdjust: true, cpqStatus: 'ready', convertedOrderId: null, ...patch }
|
|
21
|
+
return {
|
|
22
|
+
access: adjustmentAccess(input), quoteType: 'new', cpqStatus: input.cpqStatus, reasonLabels: { competitive_match: 'Competitive match' },
|
|
23
|
+
invalidIds: new Set(), canDeleteInvalid: false, openCharge: jest.fn(), openLine: jest.fn(), edit: jest.fn(), remove: jest.fn(), ...extra,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const render = (value: PriceAdjustmentUi, node: React.ReactNode) =>
|
|
27
|
+
renderToStaticMarkup(<I18nProvider locale="en" dict={{}}><PriceAdjustmentProvider value={value}>{node}</PriceAdjustmentProvider></I18nProvider>)
|
|
28
|
+
const panel = (errors = new Map<string, string>()) => <PriceAdjustmentsPanel quoteId="q1" items={[item]} total={1} lines={[line]} currency="USD" errorsById={errors} />
|
|
29
|
+
|
|
30
|
+
it('lists the adjustment with target, value, reason label, note and edit/delete, plus the injection spot', () => {
|
|
31
|
+
const html = render(ui(), panel())
|
|
32
|
+
const content = text(html)
|
|
33
|
+
expect(content).toContain('Fiber 1G · Monthly fee')
|
|
34
|
+
expect(content).toContain('Price override')
|
|
35
|
+
expect(content).toContain('$80.00')
|
|
36
|
+
expect(content).toContain('Competitive match Beat rival')
|
|
37
|
+
expect(html).toContain('aria-label="Edit adjustment"')
|
|
38
|
+
expect(html).toContain('aria-label="Delete adjustment"')
|
|
39
|
+
expect(html).toContain(`data-spot="${PRICE_ADJUSTMENTS_SPOT_ID}"`)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('when locked, explains why and offers no edit or delete for valid rows', () => {
|
|
43
|
+
const html = render(ui({ cpqStatus: 'in_approval' }), panel())
|
|
44
|
+
expect(text(html)).toContain('Adjustments are locked in status in_approval')
|
|
45
|
+
expect(html).not.toContain('Edit adjustment')
|
|
46
|
+
expect(html).not.toContain('Delete adjustment')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('keeps Delete for an invalid row while locked and names the error in text', () => {
|
|
50
|
+
const html = render(ui({ cpqStatus: 'in_approval' }, { invalidIds: new Set(['a1']), canDeleteInvalid: true }), panel(new Map([['a1', 'ABOVE_STANDARD']])))
|
|
51
|
+
expect(text(html)).toContain('Invalid: The price is above the standard price')
|
|
52
|
+
expect(html).toContain('Delete adjustment')
|
|
53
|
+
expect(html).not.toContain('Edit adjustment')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('stays visible but read-only when the toggle is off and adjustments exist', () => {
|
|
57
|
+
const html = render(ui({ enabled: false }), panel())
|
|
58
|
+
expect(text(html)).toContain('Manual price adjustments are turned off')
|
|
59
|
+
expect(text(html)).toContain('Competitive match')
|
|
60
|
+
expect(html).not.toContain('Edit adjustment')
|
|
61
|
+
expect(html).not.toContain('Delete adjustment')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('renders nothing when the toggle is off and nothing is stored', () => {
|
|
65
|
+
expect(render(ui({ enabled: false }), <PriceAdjustmentsPanel quoteId="q1" items={[]} total={0} lines={[line]} currency="USD" errorsById={new Map()} />)).toBe('')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('hides actions without permission and disables them with a tooltip on ARC mirror lines', () => {
|
|
69
|
+
const charge = { chargeCode: 'fee', chargeName: 'Monthly fee', chargeType: 'mrc' as const, unitPrice: 100, totalPrice: 100 }
|
|
70
|
+
expect(render(ui({ canAdjust: false }), <><DiscountLineAction line={line} /><AdjustChargeAction line={line} charge={charge} /></>)).toBe('')
|
|
71
|
+
const mirror = { ...line, configuration: { _arcMirroredFromItemId: 'item' } }
|
|
72
|
+
const html = render(ui({}, { quoteType: 'amend' }), <AdjustChargeAction line={mirror} charge={charge} />)
|
|
73
|
+
expect(html).toContain('disabled')
|
|
74
|
+
expect(html).toContain('Price changes on existing subscription items arrive with a later release')
|
|
75
|
+
expect(render(ui(), <AdjustChargeAction line={line} charge={{ ...charge, chargeType: 'usage' }} />)).toBe('')
|
|
76
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
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 { isArcCancelledLine, removalCancelsItem, type ArcRemovalLine } from '../../../../../services/arcLineRemoval'
|
|
8
|
+
|
|
9
|
+
type RemovableLine = Pick<ArcRemovalLine, 'action' | 'configuration'> & { lineId: string; offeringName: string }
|
|
10
|
+
|
|
11
|
+
export type ArcLineRemovalUi = {
|
|
12
|
+
/** The line is an existing subscription item marked for removal. */
|
|
13
|
+
isRemoved: (line: Pick<RemovableLine, 'action'>) => boolean
|
|
14
|
+
restore: (lineId: string) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ArcLineRemovalContext = React.createContext<ArcLineRemovalUi | null>(null)
|
|
18
|
+
|
|
19
|
+
export const ArcLineRemovalProvider = ArcLineRemovalContext.Provider
|
|
20
|
+
|
|
21
|
+
export function useArcLineRemovalUi(): ArcLineRemovalUi | null {
|
|
22
|
+
return React.useContext(ArcLineRemovalContext)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Line removal on the quote page. On an Amend / Renew quote, removing a line
|
|
27
|
+
* that mirrors a live subscription item asks for confirmation and marks the
|
|
28
|
+
* item for removal (server turns it into a `cancel` line); it can be restored.
|
|
29
|
+
* Other lines are deleted as before.
|
|
30
|
+
*/
|
|
31
|
+
export function useArcLineRemoval<Q extends { id: string; lines: RemovableLine[] }>({ quote, quoteType, onQuote, onError }: {
|
|
32
|
+
quote: Q | null
|
|
33
|
+
quoteType: string
|
|
34
|
+
onQuote: (quote: Q) => void
|
|
35
|
+
onError: (message: string | null) => void
|
|
36
|
+
}) {
|
|
37
|
+
const t = useT()
|
|
38
|
+
const { confirm, ConfirmDialogElement } = useConfirmDialog()
|
|
39
|
+
|
|
40
|
+
const send = React.useCallback(async (url: string, method: 'DELETE' | 'POST') => {
|
|
41
|
+
onError(null)
|
|
42
|
+
const call = await apiCall<Q & { error?: string }>(url, { method })
|
|
43
|
+
if (call.ok && call.result) onQuote(call.result)
|
|
44
|
+
else onError(call.result?.error ?? t('cpq.quotes.lineUpdateFailed', 'Could not update the line'))
|
|
45
|
+
}, [onError, onQuote, t])
|
|
46
|
+
|
|
47
|
+
const remove = React.useCallback(async (lineId: string) => {
|
|
48
|
+
if (!quote) return
|
|
49
|
+
const line = quote.lines.find((candidate) => candidate.lineId === lineId)
|
|
50
|
+
if (line && removalCancelsItem(line, quoteType)) {
|
|
51
|
+
const ok = await confirm({
|
|
52
|
+
title: t('cpq.quotes.arc.removeConfirm.title', 'Remove {name} from the subscription?').replace('{name}', line.offeringName),
|
|
53
|
+
text: t(
|
|
54
|
+
'cpq.quotes.arc.removeConfirm.text',
|
|
55
|
+
'This item already exists on the subscription. Removing it here does not delete it from the quote — it stays as a "remove" line, and when the order is activated the item (and its components) is cancelled on the subscription and its recurring charge stops. You can restore it until then.',
|
|
56
|
+
),
|
|
57
|
+
confirmText: t('cpq.quotes.arc.removeConfirm.confirm', 'Remove item'),
|
|
58
|
+
variant: 'destructive',
|
|
59
|
+
})
|
|
60
|
+
if (!ok) return
|
|
61
|
+
}
|
|
62
|
+
await send(`/api/cpq/quotes/${quote.id}/items/${lineId}`, 'DELETE')
|
|
63
|
+
}, [confirm, quote, quoteType, send, t])
|
|
64
|
+
|
|
65
|
+
const restore = React.useCallback((lineId: string) => {
|
|
66
|
+
if (!quote) return
|
|
67
|
+
void send(`/api/cpq/quotes/${quote.id}/items/${lineId}/restore`, 'POST')
|
|
68
|
+
}, [quote, send])
|
|
69
|
+
|
|
70
|
+
const ui = React.useMemo<ArcLineRemovalUi>(() => ({
|
|
71
|
+
isRemoved: (line) => isArcCancelledLine(line, quoteType),
|
|
72
|
+
restore,
|
|
73
|
+
}), [quoteType, restore])
|
|
74
|
+
|
|
75
|
+
return { remove, ui, ConfirmDialogElement }
|
|
76
|
+
}
|