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