diff --git a/osComponents/ProductConfiguration/classes/InsuranceRatingApexService.cls b/osComponents/ProductConfiguration/classes/InsuranceRatingApexService.cls new file mode 100644 index 0000000..80d85c0 --- /dev/null +++ b/osComponents/ProductConfiguration/classes/InsuranceRatingApexService.cls @@ -0,0 +1,360 @@ +public class InsuranceRatingApexService implements Callable { + + private static final String POST_RATING_METHOD = 'postRating'; + private static final String PATCH_RATING_METHOD = 'patchRating'; + + /** + * @description AuraEnabled method for LWC to call POST rating + * @param postPayloadJson JSON string containing rating request parameters + * @return Map containing the rating response + */ + @AuraEnabled + public static Map postRatingFromLwc(String postPayloadJson) { + InsuranceRatingApexService service = new InsuranceRatingApexService(); + Map output = new Map(); + Map inputs = new Map(); + + // Deserialize JSON string to Map to avoid type conversion issues + Map postPayload = (Map) JSON.deserializeUntyped(postPayloadJson); + inputs.put('postPayload', postPayload); + + service.invokeMethod(POST_RATING_METHOD, inputs, output, new Map()); + + return output; + } + + /** + * @description AuraEnabled method for LWC to call PATCH rating + * @param patchPayloadJson JSON string containing rating request parameters + * @return Map containing the rating response + */ + @AuraEnabled + public static Map patchRatingFromLwc(String patchPayloadJson) { + InsuranceRatingApexService service = new InsuranceRatingApexService(); + Map output = new Map(); + Map inputs = new Map(); + + // Deserialize JSON string to Map to avoid type conversion issues + Map patchPayload = (Map) JSON.deserializeUntyped(patchPayloadJson); + inputs.put('patchPayload', patchPayload); + + service.invokeMethod(PATCH_RATING_METHOD, inputs, output, new Map()); + return output; + } + + /** + * @description Implementation of Callable interface for OmniStudio + * @param action Method name to invoke + * @param args Map containing input, output, and options + * @return Object result of the method invocation + */ + public Object call(String action, Map args) { + System.debug('InsuranceRatingApexService: call ' + action); + + Map input = (Map) args.get('input'); + Map output = (Map) args.get('output'); + Map options = (Map) args.get('options'); + + return invokeMethod(action, input, output, options); + } + // Invoke methods, and if the method is not supported, it will be ignored. + public Boolean invokeMethod( + String methodName, + Map inputs, + Map output, + Map options + ) { + Boolean response = false; + + System.debug('Insurance Rating Apex Service - Method: ' + methodName); + + try { + if (POST_RATING_METHOD.equals(methodName)) { + response = createRating(inputs, output, options); + } else if (PATCH_RATING_METHOD.equals(methodName)) { + response = reprice(inputs, output, options); + } else { + throw new UnsupportedOperationException('this method name is incorrect or not supported'); + } + } catch (Exception e) { + response = false; + output.put('success', false); + output.put('errorMessage', 'Exception occurred: ' + e.getMessage()); + System.debug(LoggingLevel.ERROR, 'Exception in invokeMethod: ' + methodName + ', Error: ' + e.getMessage()); + System.debug(LoggingLevel.ERROR, 'Stack trace: ' + e.getStackTraceString()); + } + System.debug('Output from invoke:' + output); + return response; + } + + private Boolean createRating(Map inputs, Map output, Map options) { + Map postPayload = (Map) getValueFromMap('postPayload', inputs, options); + System.debug('Rating post Input: ' + postPayload); + + // Handle null postPayload + if (postPayload == null) { + output.put('success', false); + output.put('errorMessage', 'postPayload is required for rating patch'); + output.put('errorType', 'VALIDATION_ERROR'); + return false; + } + + try { + System.debug('Calling Invocable Action: createInsuranceRating'); + + // Create and configure the invocable action + Invocable.Action action = Invocable.Action.createStandardAction('createInsuranceRating'); + + + System.debug('RatingInputs::::::::' + postPayload.get('ratingInputs')); + setInvocationParameter(action, postPayload, 'ratingInputs', true); + + action.setInvocationParameter('transactionType', postPayload.get('transactionType')); + + setInvocationParameter(action, postPayload, 'additionalFields', true); + + action.setInvocationParameter('effectiveDate', postPayload.get('effectiveDate')); + + // Set options from payload + if (postPayload.containsKey('ratingOptions')) { + System.debug('Options:' + postPayload.get('ratingOptions')); + Map ratingOptions = (Map) postPayload.get('ratingOptions'); + //ratingOptions.put('executePricing', false); + action.setInvocationParameter('ratingOptions', ratingOptions); + } + + // Execute the action + List results = action.invoke(); + + if (results != null && !results.isEmpty()) { + Invocable.Action.Result result = results[0]; + + if (result.isSuccess()) { + System.debug('Rating post successfully via Invocable Action'); + output.put('ratingData', result.getOutputParameters()); + + // Extract key information from the result + Map outputParams = result.getOutputParameters(); + System.debug('Rating post data extracted: ' + outputParams); + List actionErrors = result.getErrors(); + if (actionErrors != null && !actionErrors.isEmpty()) { + output.put('success', false); + output.put('error', result.getErrors()); + return false; + } + output.put('success', true); + return true; + + } else { + // Action failed - get error details + List actionErrors = result.getErrors(); + String errorMessage = 'Rating post Invocable Action failed'; + + if (actionErrors != null && !actionErrors.isEmpty()) { + List errorMessages = new List(); + List> errorDetailsList = new List>(); + + for (Invocable.Action.Error actionError : actionErrors) { + errorMessages.add(actionError.getMessage()); + errorDetailsList.add(new Map{ + 'message' => actionError.getMessage(), + 'code' => actionError.getCode() + }); + } + + errorMessage = String.join(errorMessages, '; '); + output.put('actionErrors', errorDetailsList); + } + + output.put('success', false); + output.put('errorMessage', errorMessage); + + System.debug(LoggingLevel.ERROR, 'Rating post Invocable Action failed: ' + errorMessage); + return false; + } + + } else { + output.put('success', false); + output.put('errorMessage', 'Rating post Invocable Action returned null or empty results'); + System.debug(LoggingLevel.WARN, 'Rating post Invocable Action returned null/empty results'); + return false; + } + + } catch (Exception e) { + String errorMsg = 'Exception: ' + e.getMessage(); + output.put('success', false); + output.put('errorMessage', errorMsg); + output.put('errorType', 'EXCEPTION'); + System.debug(LoggingLevel.ERROR, 'Exception while rating post: ' + errorMsg); + return false; + } + } + + + private Boolean reprice(Map inputs, Map output, Map options) { + Map patchPayload = (Map) getValueFromMap('patchPayload', inputs, options); + System.debug('Rating patch Input: ' + patchPayload); + + // Handle null patchPayload + if (patchPayload == null) { + output.put('success', false); + output.put('errorMessage', 'patchPayload is required for rating patch'); + output.put('errorType', 'VALIDATION_ERROR'); + return false; + } + + try { + System.debug('Calling Invocable Action: repriceInsuranceProduct'); + + // Create and configure the invocable action + Invocable.Action action = Invocable.Action.createStandardAction('repriceInsuranceProduct'); + + // Set required invocation parameters + System.debug('Context Id::::::::' + patchPayload.get('contextId')); + action.setInvocationParameter('contextId', patchPayload.get('contextId')); + + System.debug('Updated Nodes::::::::' + patchPayload.get('updatedNodes')); + setInvocationParameter(action, patchPayload, 'updatedNodes', true); + + System.debug('Added Nodes::::::::' + patchPayload.get('addedNodes')); + setInvocationParameter(action, patchPayload, 'addedNodes', true); + + System.debug('Deleted Nodes::::::::' + patchPayload.get('deletedNodes')); + setInvocationParameter(action, patchPayload, 'deletedNodes', true); + + // Set options from payload + if (patchPayload.containsKey('executeRating')) { + System.debug('Options::::::::' + patchPayload.get('executeRating')); + action.setInvocationParameter('executeRating', patchPayload.get('executeRating')); + } + + if (patchPayload.containsKey('executeConfigurationRules')) { + System.debug('Options::::::::' + patchPayload.get('executeConfigurationRules')); + action.setInvocationParameter('executeConfigurationRules', patchPayload.get('executeConfigurationRules')); + } + + if (patchPayload.containsKey('returnContextJSON')) { + System.debug('Options::::::::' + patchPayload.get('returnContextJSON')); + action.setInvocationParameter('returnContextJSON', patchPayload.get('returnContextJSON')); + } + + if (patchPayload.containsKey('returnProductDetails')) { + System.debug('Options::::::::' + patchPayload.get('returnProductDetails')); + action.setInvocationParameter('returnProductDetails', patchPayload.get('returnProductDetails')); + } + + if (patchPayload.containsKey('returnRatingResults')) { + System.debug('Options::::::::' + patchPayload.get('returnRatingResults')); + action.setInvocationParameter('returnRatingResults', patchPayload.get('returnRatingResults')); + } + + // Execute the action + List results = action.invoke(); + + if (results != null && !results.isEmpty()) { + Invocable.Action.Result result = results[0]; + + if (result.isSuccess()) { + System.debug('Rating patch successfully via Invocable Action'); + output.put('ratingData', result.getOutputParameters()); + + // Extract key information from the result + Map outputParams = result.getOutputParameters(); + System.debug('Rating Patch data extracted: ' + outputParams); + if (outputParams != null) { + if (outputParams.containsKey('contextId')) { + output.put('contextId', outputParams.get('contextId')); + } + if (outputParams.containsKey('contextJSON')) { + output.put('contextJSON', outputParams.get('contextJSON')); + } + if (outputParams.containsKey('pricingResult')) { + output.put('pricingResult', outputParams.get('pricingResult')); + } + if (outputParams.containsKey('ratingResult')) { + output.put('ratingResult', outputParams.get('ratingResult')); + } + if (outputParams.containsKey('productDetails')) { + output.put('productDetails', outputParams.get('productDetails')); + } + + System.debug('Rating Patch data extracted: ' + outputParams); + } + List actionErrors = result.getErrors(); + if (actionErrors != null && !actionErrors.isEmpty()) { + output.put('success', false); + output.put('error', result.getErrors()); + return false; + } + output.put('success', true); + return true; + + } else { + // Action failed - get error details + List actionErrors = result.getErrors(); + String errorMessage = 'Rating Patch Invocable Action failed'; + + if (actionErrors != null && !actionErrors.isEmpty()) { + List errorMessages = new List(); + List> errorDetailsList = new List>(); + + for (Invocable.Action.Error actionError : actionErrors) { + errorMessages.add(actionError.getMessage()); + errorDetailsList.add(new Map{ + 'message' => actionError.getMessage(), + 'code' => actionError.getCode() + }); + } + + errorMessage = String.join(errorMessages, '; '); + output.put('actionErrors', errorDetailsList); + } + + output.put('success', false); + output.put('errorMessage', errorMessage); + + System.debug(LoggingLevel.ERROR, 'Rating Patch Invocable Action failed: ' + errorMessage); + return false; + } + + } else { + output.put('success', false); + output.put('errorMessage', 'Rating Patch Invocable Action returned null or empty results'); + System.debug(LoggingLevel.WARN, 'Rating Patch Invocable Action returned null/empty results'); + return false; + } + + } catch (Exception e) { + String errorMsg = 'Exception: ' + e.getMessage(); + output.put('success', false); + output.put('errorMessage', errorMsg); + output.put('errorType', 'EXCEPTION'); + System.debug(LoggingLevel.ERROR, 'Exception while rating patch: ' + errorMsg); + return false; + } + } + + private Object getValueFromMap(String key, Map inputs, Map options) { + if (inputs != null && inputs.get(key) != null && String.isNotBlank(String.valueOf(inputs.get(key)))) { + return inputs.get(key); + } + else if (options != null && options.get(key) != null && String.isNotBlank(String.valueOf(options.get(key)))) { + return options.get(key); + } + + return null; + } + + private void setInvocationParameter(Invocable.Action action, Map payload, String inputAttribute, boolean serializeJSON) { + if (payload.containsKey(inputAttribute) && payload.get(inputAttribute) != null) { + System.debug(inputAttribute + ': ' + JSON.serialize(payload.get(inputAttribute))); + if (serializeJSON) { + action.setInvocationParameter(inputAttribute, JSON.serialize(payload.get(inputAttribute))); + } else { + action.setInvocationParameter(inputAttribute, payload.get(inputAttribute)); + } + } else { + System.debug('payload does not contain attribute: ' + inputAttribute); + } + } +} \ No newline at end of file diff --git a/osComponents/ProductConfiguration/labels/ProductConfigurationLabels.labels b/osComponents/ProductConfiguration/labels/ProductConfigurationLabels.labels new file mode 100644 index 0000000..fab4d7a --- /dev/null +++ b/osComponents/ProductConfiguration/labels/ProductConfigurationLabels.labels @@ -0,0 +1,200 @@ + + + + PRODUCT_CONFIG_MESSAGES + en_US + false + Messages + Messages + + + PRODUCT_CONFIG_COVERAGES + en_US + false + Coverages + Coverages + + + PRODUCT_CONFIG_LOADING + en_US + false + Loading + Loading + + + PRODUCT_CONFIG_PRODUCT_CONFIGURATION + en_US + false + Product Configuration + Product Configuration + + + PRODUCT_CONFIG_TOGGLE_MESSAGES + en_US + false + Toggle Messages + Toggle Messages + + + PRODUCT_CONFIG_ERROR + en_US + false + Error + Error + + + PRODUCT_CONFIG_DETAILS + en_US + false + Details + Details + + + PRODUCT_CONFIG_SELECT_ITEM_MESSAGE + en_US + false + Select Item Message + Select an item from the tree to see its details. + + + PRODUCT_CONFIG_PRICE_SUMMARY + en_US + false + Price Summary + Price Summary + + + PRODUCT_CONFIG_INSTANT_PRICING + en_US + false + Instant Pricing + Instance Pricing + + + PRODUCT_CONFIG_UPDATE_PRICES + en_US + false + Update Prices + Update Prices + + + PRODUCT_CONFIG_TOTAL_PREMIUM + en_US + false + Total Premium + Total Premium + + + PRODUCT_CONFIG_TAXES_FEES_SURCHARGE + en_US + false + Taxes Fees Surcharge + Taxes, Fees and Surcharge + + + PRODUCT_CONFIG_PREMIUM + en_US + false + Premium + Premium + + + PRODUCT_CONFIG_ERROR_OCCURRED + en_US + false + Error Occurred + An error occurred + + + PRODUCT_CONFIG_INVALID_DATA_RECEIVED + en_US + false + Invalid Data Received + Invalid data received + + + PRODUCT_CONFIG_SELECT_COVERAGE_FIRST + en_US + false + Select Coverage First + Please select the coverage first. + + + PRODUCT_CONFIG_DELETE + en_US + false + Delete + Delete + + + PRODUCT_CONFIG_CONFIRM_DELETE + en_US + false + Confirm Delete + Confirm Delete + + + PRODUCT_CONFIG_DELETE_CONFIRMATION_MESSAGE + en_US + false + Delete Confirmation Message + Are you sure you want to delete? + + + PRODUCT_CONFIG_CANCEL + en_US + false + Cancel + Cancel + + + PRODUCT_CONFIG_CONFIRM + en_US + false + Confirm + Confirm + + + PRODUCT_CONFIG_REQUIRED_ATTRIBUTES_MSG + en_US + false + Required Attributes Message + Complete the required fields. + + + PRODUCT_CONFIG_NO_ATTRIBUTES_MSG + en_US + false + No Attributes Message + No attributes to show. + + + PRODUCT_CONFIG_MULTI_VALUE_DECODER_MESSAGE + en_US + false + Multi Value Decoder Message + Shows the value in the format + + + PRODUCT_CONFIG_NEXT + en_US + false + Next + Next + + + PRODUCT_CONFIG_PREVIOUS + en_US + false + Previous + Previous + + + PRODUCT_CONFIG_TAX_AMOUNT + en_US + false + Tax Amount + Tax Amount + + + diff --git a/osComponents/ProductConfiguration/lwc/productConfiguration/README.md b/osComponents/ProductConfiguration/lwc/productConfiguration/README.md new file mode 100644 index 0000000..4542b97 --- /dev/null +++ b/osComponents/ProductConfiguration/lwc/productConfiguration/README.md @@ -0,0 +1,117 @@ +# Product Configuration Lightning Web Component + +## Overview + +The `productConfiguration` Lightning Web Component (LWC) is designed for Digital Insurance Product Configuration workflows. It extends `OmniscriptBaseMixin` to integrate seamlessly with OmniScripts, and makes it easy for users to view, configure, and rate insurance products. + +--- + +## Dependencies + +- **Apex Controller**: `InsuranceRatingApexService.postRatingFromLwc` +- **Apex Controller**: `InsuranceRatingApexService.patchRatingFromLwc` +- **Mixin**: `c/omniscriptBaseMixin` (follow the steps under [Prerequisites](../../../README.md#prerequisites)) +- **Helper Modules**: + - `dataManager.js` – Tree building and grid transformation utilities + - `labelsAndConstants.js` – Validation messages, labels, and constants + +## Components Included + +| Component | Description | +| :---- | :---- | +| `productConfiguration` | Main component for displaying, configuring and rating insurance products.| +| `productConfigurationMessageItem` | Notification component used to display validation results, configuration errors, or informational messages within the product.| + +### Supporting Files + +| File | Type | Description | +| :---- | :---- | :---- | +| `InsuranceRatingApexService.cls` | Apex Class | Service class that wraps the createInsuranceRating and repriceInsuranceProduct invocable action.| +| `ProductConfigurationLabels.labels` | Custom Labels | UI text labels for translation support | + +--- + +## Public Properties (`@api`) + +| Property | Type | Default | Description | +| :---- | :---- | :---- | :---- | +| `additionalFields` | `Object` | `{}` | Additional fields to include in the rating request payload. | +| `clearStateOnPrev` | `Boolean/String` | — | When `true` or `'true'`, enables custom navigation buttons and clears state when navigating to the previous step. | +| `ratingInputs` | `Array` | `[]` | Array of rating input objects containing product configuration data. Supports reusable and non-reusable inputs with `instanceKeys`. | +| `ratingOptions` | `Object` | `{}` | Rating options passed to the API. Defaults `executePricing` and `executeConfigurationRules` to `true`. | +| `transactionType` | `String` | — | The transaction type for the rating request | +| `contextId` | `String` | — | Input property for PATCH first scenario | + +Rating inputs and options are based on the [Insurance Product Rating API](https://developer.salesforce.com/docs/atlas.en-us.insurance_developer_guide.meta/insurance_developer_guide/connect_resources_product_rating.htm%20%20). + +## HTML Markup + +### Side Panel Layout + +Displays a tree navigation showing the product hierarchy, allowing users to select products/coverages to configure. + +- First product selected as default +- Selected item highlights and drives the main area content + +### Main Section Layout + +Shows the selected product's details, attributes (read-only), and associated coverages with their configurable attributes. + +**Attribute Details Card:** +- Displays product title, attributes and price with tax info. +- Product attributes grouped by category (read-only display) + +**Coverages Card (conditional):** +- Displays if product has coverages +- When selected, expands to show editable coverage attributes +- Coverage attributes grouped by category in two-column layout + +### Configuration Messages Layout +- Collapsible message notification area with toggle button +- Uses the product-configuration-message-item component + +--- + +## Component Usage + +Embed the productConfiguration LWC in an Omniscript by using the Custom Lightning Web Component element in a step. + +### Adding to an OmniScript Step + +1. Open your OmniScript in OmniStudio Designer +2. Add a **Custom Lightning Web Component** element to your step +3. Set the **LWC Component Name** to `c-product-configuration` +4. Configure the component properties (see [Public Properties](#public-properties-api)) + +If you set `clearStateOnPrev` to `true`, hide the standard Previous and Next buttons in the step by reducing their width to 0\. The productConfiguration LWC shows Previous and Next buttons for navigation. + +### Data Output Structure + +The component outputs data to OmniScript via `omniUpdateDataJson()`: + +```javascript +{ contextId: "contextId-123" } +``` + +--- + +## Troubleshooting + +### Common Issues + +| Issue | Possible Cause | Solution | +| :---- | :---- | :---- | +| "No products are available" | Rating API returned no products or ran into error | Verify `ratingInputs` are correct. | +| OmniscriptBaseMixin not found on component deployment | Missing Omniscript customization package | Ensure `omniscriptBaseMixin` and utility modules are deployed by following the [Prerequisites](../../../README.md#prerequisites). | +| Missing context or product details on rating call response | Wrong API version | Ensure that all components and supporting files use Salesforce API version **66.0** or later. | +| State not persisting | clearStateOnPrev is not set to true | Hide step buttons by setting width to 0 and set `clearStateOnPrev` to true. | + +### Debugging + +1. **Debug Logs:** Enable debug logs for the `InsuranceRatingApexService` class. +2. **Browser console:** The component logs errors to the console. +3. **OmniScript data panel:** Check the data JSON for output values. + +## API Version + +All components and supporting files must use Salesforce API version **66.0** or later. \ No newline at end of file diff --git a/osComponents/ProductConfiguration/lwc/productConfiguration/dataManager.js b/osComponents/ProductConfiguration/lwc/productConfiguration/dataManager.js new file mode 100644 index 0000000..bf738d8 --- /dev/null +++ b/osComponents/ProductConfiguration/lwc/productConfiguration/dataManager.js @@ -0,0 +1,390 @@ +import { CONSTANTS } from './labelsAndConstants'; + +/** + * Builds tree structure from contextJSON for lightning-tree component + * @param {Object} contextJSON - The context JSON object + * @param {Array} productDetails - The product details array (changed from object to array) + * @returns {Array} - Clean tree structure array for lightning-tree component + */ +export function buildTreeFromContextJSON(contextJSON, productDetails) { + // Create a map from productCode to productDetail for efficient lookup + const productDetailsMap = (productDetails || []).reduce((acc, detail) => { + if (detail.productCode) { + acc[detail.productCode] = detail; + } + return acc; + }, {}); + + if (!contextJSON?.salesTransactions?.[0]?.salesTransactionItems) { + return []; + } + const rootItems = contextJSON.salesTransactions[0].salesTransactionItems; + const tree = rootItems + .map(rootItem => { + const itemData = rootItem.fields; + if (itemData.ProductSpec === 'Coverage') { + return null; // Skip root-level coverages + } + // Use AggregationKeyLevel1 for instance key (used by backend for pricing/configuration) + const rootInstanceKey = itemData.AggregationKeyLevel1 || itemData.InstanceKey || itemData.id; + return buildTreeNode(rootItem, productDetailsMap, [rootInstanceKey]); + }) + .filter(item => item !== null); // Filter out the skipped items + return tree; +} + +/** + * Helper function to get the instance key from aggregation key levels + * @param {Object} fields - The STI fields object + * @param {number} depth - Current depth level (1-based: 1 for root, 2 for children, etc.) + * @returns {string} - The instance key for this level + */ +function getInstanceKeyForLevel(fields, depth) { + const levelKey = `AggregationKeyLevel${depth}`; + return fields[levelKey] || fields.InstanceKey || fields.id; +} + +/** + * Recursively builds a single node for the tree structure + * @param {Object} salesTransactionItem - The sales transaction item object with fields, childNodes, etc. + * @param {Object} productDetailsMap - Map of productCode to product details + * @param {Array} instanceKeysPath - The path of instance keys from root to this node + * @returns {Object} - A tree node object + */ +function buildTreeNode(salesTransactionItem, productDetailsMap, instanceKeysPath = []) { + const itemData = salesTransactionItem.fields; + const productDetail = productDetailsMap[itemData.ProductCode] || {}; + const childNodes = salesTransactionItem.childNodes || []; + const currentDepth = instanceKeysPath.length; + + // Step 1: Recursively build all child items and extract coverages + const childItems = []; + const coverages = []; + const selectedCoverageCodes = new Set(); + + childNodes.forEach(childNode => { + const childData = childNode.fields; + // Check if it's a coverage by examining both ProductSpec and productSpecificationType + const isCoverage = childData.ProductSpec === 'Coverage' || + productDetailsMap[childData.ProductCode]?.productSpecificationType?.name === 'Coverage'; + + if (isCoverage) { + selectedCoverageCodes.add(childData.ProductCode); + const coverageProductDetail = productDetailsMap[childData.ProductCode] || {}; + // For coverages, use the next aggregation level (current depth + 1) + const coverageInstanceKey = getInstanceKeyForLevel(childData, currentDepth + 1); + const coverageInstanceKeys = [...instanceKeysPath, coverageInstanceKey]; + coverages.push({ + key: childData.id, + name: coverageProductDetail.name || childData.ProductName, + productCode: childData.ProductCode, + prcId: coverageProductDetail.productRelatedComponent?.id, + stiId: childData.id, // Store the coverage's stiId for UI treatments + isSelected: true, + attributes: getAttributesWithDetails(childNode, coverageProductDetail), + netUnitPrice: childData.NetUnitPrice, + proratedQLITaxAmount: childData.ProratedQLITaxAmount, + instanceKeys: coverageInstanceKeys, + instanceKeysString: coverageInstanceKeys.join(',') + }); + } else { + // For child products, use the next aggregation level + const childInstanceKey = getInstanceKeyForLevel(childData, currentDepth + 1); + const childInstanceKeys = [...instanceKeysPath, childInstanceKey]; + childItems.push(buildTreeNode(childNode, productDetailsMap, childInstanceKeys)); + } + }); + + // Step 2: Create the current node + const node = { + label: itemData.CustomProductName || productDetail.name || itemData.ProductName || itemData.id, + name: itemData.InstanceKey, + id: itemData.id, // Store the id for navigation purposes + expanded: false, + items: childItems, + productCode: itemData.ProductCode, + attributes: getAttributesWithDetails(salesTransactionItem, productDetail), + coverages, + netUnitPrice: itemData.NetUnitPrice, + proratedQLITaxAmount: itemData.ProratedQLITaxAmount + }; + + // Step 3: Add unselected coverages from product details + if (productDetail.productComponentGroups) { + productDetail.productComponentGroups.forEach(group => { + if (group.name === 'Coverages' && group.components) { + group.components.forEach(comp => { + if (comp.productCode && !selectedCoverageCodes.has(comp.productCode)) { + // For unselected coverages, use a generated key based on product code + const unselectedKey = comp.name || comp.productCode; + const unselectedInstanceKeys = [...instanceKeysPath, unselectedKey]; + // Get the full product detail for this coverage's productCode + const unselectedCoverageProductDetail = productDetailsMap[comp.productCode] || {}; + node.coverages.push({ + key: comp.productCode, + name: comp.name, + productCode: comp.productCode, + prcId: comp.productRelatedComponent?.id, + isSelected: false, + attributes: getAttributesWithDetails({fields: {}}, unselectedCoverageProductDetail), + instanceKeys: unselectedInstanceKeys, + instanceKeysString: unselectedInstanceKeys.join(',') + }); + } + }); + } + }); + } + return node; +} + + +/** + * Resolves the attribute value based on data type and context + * @param {Object} record - The attribute record from product details + * @param {Object} contextAttr - The attribute from context JSON + * @returns {*} - The resolved attribute value + */ +function resolveAttributeValue(record, contextAttr) { + let value = null; + + // For picklist attributes, use AttributePicklistValue ID to lookup textValue from catalog + if (record.dataType === 'PICKLIST' && contextAttr?.AttributePicklistValue) { + const picklistOption = record.attributePickList?.values.find( + option => option.id === contextAttr.AttributePicklistValue + ); + value = picklistOption?.textValue ?? contextAttr?.AttributeValue ?? record.defaultValue ?? null; + } else { + // For non-picklist attributes, use AttributeValue directly + value = contextAttr?.AttributeValue ?? record.defaultValue ?? null; + } + + // Convert string boolean values to actual booleans for CHECKBOX dataType + if (record.dataType === 'CHECKBOX') { + value = normalizeCheckboxValue(value); + } + + return value; +} + +/** + * Normalizes checkbox values to boolean + * @param {*} value - The value to normalize + * @returns {boolean} - The normalized boolean value + */ +function normalizeCheckboxValue(value) { + if (value === null || value === undefined) { + return false; + } + if (typeof value === 'string') { + const lowerValue = value.toLowerCase().trim(); + if (lowerValue === 'true') { + return true; + } + if (lowerValue === 'false') { + return false; + } + return Boolean(value); + } + return value; +} + +/** + * Gets active picklist options from attribute record + * @param {Object} record - The attribute record + * @returns {Array} - Array of active picklist options + */ +function getActivePicklistOptions(record) { + return record.attributePickList?.values + .filter(p => p.status === 'Active') + .map(p => ({ + label: p.label || p.displayValue, + value: p.textValue, + id: p.id + })) || []; +} + +/** + * Determines data type and additional fields for lookups + * @param {Object} record - The attribute record + * @returns {Object} - Object containing dataType and additionalFields + */ +function getDataTypeAndFields(record) { + let dataType = record.dataType; + let additionalFields = null; + + if (record.additionalFields?.ReferenceObject && + record.additionalFields?.ReferenceFieldApiName?.toLowerCase() === CONSTANTS.REFERENCE_FIELD_ID) { + dataType = 'lookup'; + additionalFields = { + referenceObject: record.additionalFields.ReferenceObject, + referenceField: record.additionalFields.ReferenceFieldApiName + }; + } + + return { dataType, additionalFields }; +} + +/** + * Builds an attribute object from a record and context + * @param {Object} record - The attribute record from product details + * @param {Object} contextAttr - The attribute from context JSON + * @param {string|null} categoryName - The category name (null for uncategorized) + * @returns {Object} - The formatted attribute object + */ +function buildAttributeObject(record, contextAttr, categoryName) { + const value = resolveAttributeValue(record, contextAttr); + const picklistOptions = getActivePicklistOptions(record); + const { dataType, additionalFields } = getDataTypeAndFields(record); + + return { + id: record.id, + code: record.code, + developerName: record.developerName, + label: record.attributeNameOverride || record.name, + value, + dataType, + additionalFields, + displayTypeOverride: record.displayTypeOverride, + valueDecoder: record.valueDecoder, + minimumValue: record.minimumValue, + maximumValue: record.maximumValue, + stepValue: record.stepValue, + isReadOnly: record.isReadOnly || false, + isRequired: record.isRequired || false, + categoryName, + sequence: record.sequence, + options: picklistOptions + }; +} + +/** + * Processes a single attribute record + * @param {Object} record - The attribute record + * @param {Object} contextAttributes - Map of context attributes + * @param {string|null} categoryName - The category name (null for uncategorized) + * @returns {Object|null} - The attribute object or null if should be skipped + */ +function processAttributeRecord(record, contextAttributes, categoryName) { + // Only show attributes that are not hidden and have Active status + if (record.hidden || record.status !== 'Active') { + return null; + } + + // Match by code (AttributeDefinitionCode) or developerName (AttributeDeveloperName) + const contextAttr = contextAttributes[record.code] ?? contextAttributes[record.developerName]; + return buildAttributeObject(record, contextAttr, categoryName); +} + +/** + * Merges attributes from contextJSON with metadata from productDetails. + * @param {Object} salesTransactionItem - The sales transaction item object with fields, attributes, etc. + * @param {Object} productDetail - The product catalog data for the item. + * @returns {Array} - An array of formatted attribute objects. + */ +function getAttributesWithDetails(salesTransactionItem, productDetail) { + const attributes = []; + + // Build map using AttributeDefinitionCode (matches productDetails.code) + const contextAttributes = (salesTransactionItem.salesTransactionItemAttributes || []).reduce((acc, attr) => { + // In V2 API, attribute data is in attr.fields + const attrFields = attr.fields || attr; + // Prefer AttributeDefinitionCode, but fall back to AttributeDeveloperName as temporary workaround + const attrKey = attrFields.AttributeDefinitionCode || attrFields.AttributeDeveloperName; + if (attrKey) { + // Store full attribute object to access both AttributeValue and AttributePicklistValue + acc[attrKey] = attrFields; + } + return acc; + }, {}); + + // Process categorized attributes - exclude hidden and inactive attributes + if (productDetail?.attributeCategories) { + productDetail.attributeCategories.forEach(category => { + category.records.forEach(record => { + const attribute = processAttributeRecord(record, contextAttributes, category.name); + if (attribute) { + attributes.push(attribute); + } + }); + }); + } + + // Process uncategorized attributes - exclude hidden and inactive attributes + if (productDetail?.attributes) { + productDetail.attributes.forEach(record => { + const attribute = processAttributeRecord(record, contextAttributes, null); + if (attribute) { + attributes.push(attribute); + } + }); + } + + return attributes; +} + + +/** + * Finds the selected tree node by name recursively. + * @param {Array} treeItems - Array of tree items. + * @param {string} selectedName - The selected tree item name. + * @returns {Object|null} - The found tree node or null. + */ +export function findSelectedTreeNode(treeItems, selectedName) { + for (const item of treeItems) { + if (item.name === selectedName) { + return item; + } + if (item.items && item.items.length > 0) { + const childResult = findSelectedTreeNode(item.items, selectedName); + if (childResult) { + return childResult; + } + } + } + return null; +} + +/** + * Finds a tree node by id recursively. + * @param {Array} treeItems - Array of tree items. + * @param {string} id - The id to search for. + * @returns {Object|null} - The found tree node or null. + */ +export function findTreeNodeById(treeItems, id) { + for (const item of treeItems) { + if (item.id === id) { + return item; + } + if (item.items && item.items.length > 0) { + const childResult = findTreeNodeById(item.items, id); + if (childResult) { + return childResult; + } + } + } + return null; +} + +/** + * Finds the instance keys path for a tree node by name recursively. + * @param {Array} treeItems - Array of tree items. + * @param {string} targetName - The name of the target node. + * @param {Array} currentPath - The current path of instance keys. + * @returns {Array|null} - The instance keys path or null if not found. + */ +export function findInstanceKeysForNode(treeItems, targetName, currentPath = []) { + for (const item of treeItems) { + const newPath = [...currentPath, item.name]; + if (item.name === targetName) { + return newPath; + } + if (item.items && item.items.length > 0) { + const childResult = findInstanceKeysForNode(item.items, targetName, newPath); + if (childResult) { + return childResult; + } + } + } + return null; +} diff --git a/osComponents/ProductConfiguration/lwc/productConfiguration/labelsAndConstants.js b/osComponents/ProductConfiguration/lwc/productConfiguration/labelsAndConstants.js new file mode 100644 index 0000000..cbc45f3 --- /dev/null +++ b/osComponents/ProductConfiguration/lwc/productConfiguration/labelsAndConstants.js @@ -0,0 +1,78 @@ +// Import Custom Labels +import MESSAGES from '@salesforce/label/c.PRODUCT_CONFIG_MESSAGES'; +import COVERAGES from '@salesforce/label/c.PRODUCT_CONFIG_COVERAGES'; +import LOADING from '@salesforce/label/c.PRODUCT_CONFIG_LOADING'; +import PRODUCT_CONFIGURATION from '@salesforce/label/c.PRODUCT_CONFIG_PRODUCT_CONFIGURATION'; +import TOGGLE_MESSAGES from '@salesforce/label/c.PRODUCT_CONFIG_TOGGLE_MESSAGES'; +import ERROR from '@salesforce/label/c.PRODUCT_CONFIG_ERROR'; +import DETAILS from '@salesforce/label/c.PRODUCT_CONFIG_DETAILS'; +import SELECT_ITEM_MESSAGE from '@salesforce/label/c.PRODUCT_CONFIG_SELECT_ITEM_MESSAGE'; +import PRICE_SUMMARY from '@salesforce/label/c.PRODUCT_CONFIG_PRICE_SUMMARY'; +import INSTANT_PRICING from '@salesforce/label/c.PRODUCT_CONFIG_INSTANT_PRICING'; +import UPDATE_PRICES from '@salesforce/label/c.PRODUCT_CONFIG_UPDATE_PRICES'; +import TOTAL_PREMIUM from '@salesforce/label/c.PRODUCT_CONFIG_TOTAL_PREMIUM'; +import TAXES_FEES_SURCHARGE from '@salesforce/label/c.PRODUCT_CONFIG_TAXES_FEES_SURCHARGE'; +import PREMIUM from '@salesforce/label/c.PRODUCT_CONFIG_PREMIUM'; +import ERROR_OCCURRED from '@salesforce/label/c.PRODUCT_CONFIG_ERROR_OCCURRED'; +import INVALID_DATA_RECEIVED from '@salesforce/label/c.PRODUCT_CONFIG_INVALID_DATA_RECEIVED'; +import SELECT_COVERAGE_FIRST from '@salesforce/label/c.PRODUCT_CONFIG_SELECT_COVERAGE_FIRST'; +import DELETE from '@salesforce/label/c.PRODUCT_CONFIG_DELETE'; +import CONFIRM_DELETE from '@salesforce/label/c.PRODUCT_CONFIG_CONFIRM_DELETE'; +import DELETE_CONFIRMATION_MESSAGE from '@salesforce/label/c.PRODUCT_CONFIG_DELETE_CONFIRMATION_MESSAGE'; +import CANCEL from '@salesforce/label/c.PRODUCT_CONFIG_CANCEL'; +import CONFIRM from '@salesforce/label/c.PRODUCT_CONFIG_CONFIRM'; +import REQUIRED_ATTRIBUTES_MSG from '@salesforce/label/c.PRODUCT_CONFIG_REQUIRED_ATTRIBUTES_MSG'; +import NO_ATTRIBUTES_MSG from '@salesforce/label/c.PRODUCT_CONFIG_NO_ATTRIBUTES_MSG'; +import MULTI_VALUE_DECODER_MESSAGE from '@salesforce/label/c.PRODUCT_CONFIG_MULTI_VALUE_DECODER_MESSAGE'; +import NEXT from '@salesforce/label/c.PRODUCT_CONFIG_NEXT'; +import PREVIOUS from '@salesforce/label/c.PRODUCT_CONFIG_PREVIOUS'; +import TAX_AMOUNT from '@salesforce/label/c.PRODUCT_CONFIG_TAX_AMOUNT'; + +// Product Configuration Labels +export const LABELS = { + MESSAGES, + COVERAGES, + LOADING, + PRODUCT_CONFIGURATION, + TOGGLE_MESSAGES, + ERROR, + DETAILS, + SELECT_ITEM_MESSAGE, + PRICE_SUMMARY, + INSTANT_PRICING, + UPDATE_PRICES, + TOTAL_PREMIUM, + TAXES_FEES_SURCHARGE, + PREMIUM, + ERROR_OCCURRED, + INVALID_DATA_RECEIVED, + SELECT_COVERAGE_FIRST, + DELETE, + CONFIRM_DELETE, + DELETE_CONFIRMATION_MESSAGE, + CANCEL, + CONFIRM, + REQUIRED_ATTRIBUTES_MSG, + NO_ATTRIBUTES_MSG, + MULTI_VALUE_DECODER_MESSAGE, + NEXT, + PREVIOUS, + TAX_AMOUNT +}; + +export const CONSTANTS = { + // Component constants + REFERENCE_FIELD_ID: 'id', + EVENT_NAMES: { + VIEW: 'productConfiguration.view', + ATTRIBUTE_CHANGE: 'productConfiguration.attributechange', + COVERAGE_SELECTION_CHANGE: 'productConfiguration.coverageselectionchange', + UPDATE_PRICES: 'productConfiguration.updateprices', + NODE_SELECT: 'productConfiguration.nodeselect', + DELETE_BUTTON_CLICK: 'productConfiguration.deletebuttonclick', + DELETE_CANCEL: 'productConfiguration.deletecancel', + DELETE_CONFIRM: 'productConfiguration.deleteconfirm', + ERROR: 'productConfiguration.error', + STI_DELETE: 'productConfiguration.stidelete' + } +}; \ No newline at end of file diff --git a/osComponents/ProductConfiguration/lwc/productConfiguration/productConfiguration.css b/osComponents/ProductConfiguration/lwc/productConfiguration/productConfiguration.css new file mode 100644 index 0000000..2ef58d2 --- /dev/null +++ b/osComponents/ProductConfiguration/lwc/productConfiguration/productConfiguration.css @@ -0,0 +1,13 @@ +.detailHeader { + background-color: var(--slds-g-color-palette-neutral-95, #f3f3f3); + border-radius: var(--slds-c-button-radius-border, 0.25rem); +} + +/* Sticky pricing summary footer */ +.sticky-footer { + position: sticky; + bottom: 0; + background-color: var(--slds-g-color-neutral-base-100, #ffffff); + border-top: var(--slds-g-sizing-border-1, 1px) solid var(--slds-g-color-border-1, #c9c9c9); + z-index: var(--slds-c-modal-sizing-z-index, 100); +} \ No newline at end of file diff --git a/osComponents/ProductConfiguration/lwc/productConfiguration/productConfiguration.html b/osComponents/ProductConfiguration/lwc/productConfiguration/productConfiguration.html new file mode 100644 index 0000000..6cfe1b2 --- /dev/null +++ b/osComponents/ProductConfiguration/lwc/productConfiguration/productConfiguration.html @@ -0,0 +1,432 @@ + \ No newline at end of file diff --git a/osComponents/ProductConfiguration/lwc/productConfiguration/productConfiguration.js b/osComponents/ProductConfiguration/lwc/productConfiguration/productConfiguration.js new file mode 100644 index 0000000..69d61c1 --- /dev/null +++ b/osComponents/ProductConfiguration/lwc/productConfiguration/productConfiguration.js @@ -0,0 +1,1762 @@ +import { LightningElement, track, api } from 'lwc'; +import { buildTreeFromContextJSON, findSelectedTreeNode, findTreeNodeById, findInstanceKeysForNode } from './dataManager'; +import LOCALE from '@salesforce/i18n/locale'; +import CURRENCY from '@salesforce/i18n/currency'; +import postRatingFromLwc from '@salesforce/apex/InsuranceRatingApexService.postRatingFromLwc'; +import patchRatingFromLwc from '@salesforce/apex/InsuranceRatingApexService.patchRatingFromLwc'; +import { OmniscriptBaseMixin } from 'c/omniscriptBaseMixin'; +import { LABELS } from './labelsAndConstants'; + +const DELIMITERS = ['/', '#']; + +export default class ProdCfg extends OmniscriptBaseMixin(LightningElement) { + @track treeItems = []; + @track selectedNode; + @track error; + @track configMessages = []; + showDeleteConfirmation = false; + @track validationMsg = ''; // Validation message for required attributes + isLoading = false; + instantPricing = true; // Toggle for executePricing in PATCH calls + executeConfigurationRules; + isMessagesExpanded = true; + _internalContextId = null; // Internal property to store contextId from POST/PATCH response or saved state + attributeOriginalValues = new Map(); // Map to store original attribute values during editing + parentNodeNameBeforeDeletion = null; // Store parent node name before deletion for selection + _navigationDirection = null; // Track navigation direction: 'next' or 'previous' + + + // Input properties + @api additionalFields = {}; + @api ratingInputs = []; + @api ratingOptions = {}; + @api transactionType; + @api contextId = null; // Input property for PATCH-first scenario (from parent component) + @api clearStateOnPrev; // Use property if no prodSel LWC configured that clears state on update of plan + + constructor() { + super(); + } + + connectedCallback() { + const stateData = this.omniGetSaveState(); + if (stateData && stateData.savedProduct) { + this.parseSavedState(stateData); + } else { + this.loadData(); + } + } + + /** + * OS - Set UI to previous saved state + * @param {Object} stateData + */ + parseSavedState(stateData) { + this.treeItems = stateData.savedProduct; + const name = this.treeItems?.[0]?.name; + this.handleSelect({ detail: { name } }); + this._internalContextId = stateData?.contextId; + this._savedPricingSummary = stateData?.pricingSummary; + this._savedCurrencyCode = stateData?.currencyCode; + this.isLoading = false; + } + + async loadData() { + this.isLoading = true; + this.error = null; + try { + // Initialize internal contextId from input contextId if provided, otherwise keep existing internal value + this._internalContextId = this.contextId || this._internalContextId; + + let result; + + // If contextId is provided, use PATCH for initial load + if (this._internalContextId) { + const request = { + contextId: this._internalContextId, + ratingOptions: this.formatRatingOptions(), + additionalFields: this.formatAdditionalFields() + }; + result = await patchRatingFromLwc({ patchPayloadJson: JSON.stringify(request) }); + + } else { + // Otherwise, use POST with ratingInputs + const ratingInputArr = JSON.parse(JSON.stringify(this.ratingInputs)); + const request = { + ratingInputs: ratingInputArr, + ratingOptions: this.formatRatingOptions(), + additionalFields: this.formatAdditionalFields(), + transactionType: this.transactionType + }; + result = await postRatingFromLwc({ postPayloadJson: JSON.stringify(request) }); + } + + if (result && result.success) { + this.processApiResponse(result); + } else { + console.error('Rating Failed:', result?.errorMessage); + if (result?.actionErrors) { + console.error('Detailed Errors:', result.actionErrors); + } + throw new Error(result?.errorMessage || 'Unknown Error'); + } + } catch (error) { + const errorMessage = error?.body ? JSON.stringify(error.body, null, 2) : error?.message || LABELS.ERROR_OCCURRED; + this.error = errorMessage; + } finally { + this.isLoading = false; + } + } + + formatAdditionalFields() { + const additionalFields = { ...this.additionalFields }; + if (!additionalFields.CurrencyIsoCode__std) { + additionalFields.CurrencyIsoCode__std = CURRENCY; + } + return additionalFields; + } + + formatRatingOptions() { + const ratingOptions = { ...this.ratingOptions }; + ratingOptions.returnContextJson = true; // RATING IA TAKES CAMEL CASE (returnContextJson instead of returnContextJSON) + ratingOptions.returnProductDetails = true; + ratingOptions.returnRatingResults = true; // REQUIRED FOR RATING IA + + if (ratingOptions.executePricing === null || ratingOptions.executePricing === undefined) { + ratingOptions.executePricing = true; + } + if (ratingOptions.executeConfigurationRules === null || ratingOptions.executeConfigurationRules === undefined) { + ratingOptions.executeConfigurationRules = true; + } + + return ratingOptions; + } + + processApiResponse(response) { + response = response.ratingData; + if (!response) { + this.error = LABELS.INVALID_DATA_RECEIVED; + return; + } + + // Check for errors in response first + if (response.errors && Array.isArray(response.errors) && response.errors.length > 0) { + const errorMessage = response.errors[0].message || LABELS.ERROR_OCCURRED; + this.error = errorMessage; + return; + } + + // Then check for valid data structure + if (!response.productRatingOutput.contextJSON || !response.productRatingOutput.productDetails) { + this.error = LABELS.INVALID_DATA_RECEIVED; + return; + } + + // Clear any previous errors + this.error = null; + + // Store contextId for PATCH requests + this._internalContextId = response.contextId; + + // Update omniscript data JSON with contextId + this.omniUpdateDataJson(this.getOmniDataOutput()); + + // Process configuration messages + this.processConfigMessages(response.productRatingOutput.configMessages); + + // Store the raw response for pricing calculations + this.apiResponse = response; + + // Clear saved pricing and currency values so getters compute fresh values from new apiResponse + this._savedPricingSummary = null; + this._savedCurrencyCode = null; + + // Process uiTreatments to identify disabled/hidden components and attributes + this.uiTreatments = this.processUiTreatments(response.productRatingOutput.uiTreatments); + + // Sync selectedNode changes back to treeItems before rebuilding + this.syncSelectedNodeToTreeItems(); + + // Preserve current selection and expansion state before rebuilding tree + const previouslySelectedNodeName = this.selectedNode?.name; + const expandedNodeNames = this.getExpandedNodeNames(this.treeItems); + + const builtData = buildTreeFromContextJSON(response.productRatingOutput.contextJSON, response.productRatingOutput.productDetails); + const preparedData = this.prepareDataForUI(builtData); + + // Restore expansion states or default expand first nodes + if (preparedData.length > 0) { + if (expandedNodeNames.size > 0) { + // Restore previous expansion state + this.restoreExpansionState(preparedData, expandedNodeNames); + } else { + // Default behavior: expand first node and its children + const firstNode = preparedData[0]; + firstNode.expanded = true; + if (firstNode.items) { + firstNode.items.forEach(child => { + child.expanded = true; + }); + } + } + + // Re-assign to trigger the tree render with expanded nodes + this.treeItems = [...preparedData]; + + // Determine which node to select after tree rebuild + let nodeNameToSelect; + + // Priority 1: If we have a parent node from a deletion, try to select it + if (this.parentNodeNameBeforeDeletion) { + const parentNodeExists = findSelectedTreeNode(preparedData, this.parentNodeNameBeforeDeletion); + if (parentNodeExists) { + nodeNameToSelect = this.parentNodeNameBeforeDeletion; + } + // Clear the stored parent name after use + this.parentNodeNameBeforeDeletion = null; + } + + // Priority 2: If previously selected node still exists, select it + if (!nodeNameToSelect && previouslySelectedNodeName) { + const previousNodeStillExists = findSelectedTreeNode(preparedData, previouslySelectedNodeName); + if (previousNodeStillExists) { + nodeNameToSelect = previouslySelectedNodeName; + } + } + + // Priority 3: Default to first node + if (!nodeNameToSelect) { + nodeNameToSelect = preparedData[0].name; + } + + this.handleSelect({ detail: { name: nodeNameToSelect } }); + } + + // Validate required attributes after processing response + const isValid = this.validateRequiredAttributes(); + this.omniValidate(isValid); + } + + getExpandedNodeNames(treeItems) { + const expandedNames = new Set(); + if (!treeItems || treeItems.length === 0) { + return expandedNames; + } + const traverse = (items) => { + if (!items) { + return; + } + items.forEach(item => { + if (item.expanded) { + expandedNames.add(item.name); + } + if (item.items && item.items.length > 0) { + traverse(item.items); + } + }); + }; + traverse(treeItems); + return expandedNames; + } + + restoreExpansionState(treeItems, expandedNames) { + const traverse = (items) => { + if (!items) { + return; + } + items.forEach(item => { + if (expandedNames.has(item.name)) { + item.expanded = true; + } + if (item.items && item.items.length > 0) { + traverse(item.items); + } + }); + }; + traverse(treeItems); + } + + prepareDataForUI(data) { + return data.map(node => { + const preparedNode = { ...node }; + if (node.coverages) { + // Filter out hidden coverages and process the rest + preparedNode.coverages = node.coverages + .filter(coverage => !this.isCoverageHidden(node.id, coverage.prcId)) + .map(coverage => { + const preparedCoverage = { ...coverage }; + + // Check if this coverage should be disabled based on uiTreatments + const isDisabled = this.isCoverageDisabled(node.id, coverage.prcId); + preparedCoverage.isDisabled = isDisabled; + preparedCoverage.containerClass = isDisabled + ? 'slds-box slds-box_x-small slds-m-bottom_small slds-is-disabled' + : 'slds-box slds-box_x-small slds-m-bottom_small'; + + // Pass raw price and formatted tax for coverage + const currency = this.getCurrencyForPriceSummary(); + preparedCoverage.price = coverage.netUnitPrice; + preparedCoverage.tax = (coverage.proratedQLITaxAmount !== null && coverage.proratedQLITaxAmount !== undefined) + ? `${LABELS.TAX_AMOUNT} ` + this.formatCurrency(coverage.proratedQLITaxAmount, currency) + : null; + + if (coverage.attributes) { + // Use coverage's stiId for attribute treatments (not parent node's id) + const coverageStiId = coverage.stiId || node.id; + + // Filter out hidden attributes and process the rest + preparedCoverage.attributes = coverage.attributes + .filter(attr => !this.isAttributeHidden(coverageStiId, attr.id)) + .map(attr => { + const isAttrDisabled = this.isAttributeDisabled(coverageStiId, attr.id); + const controlType = this.attributeInputType(attr.dataType, attr.displayTypeOverride); + const preparedAttr = { + ...attr, + controlType, + isPicklist: attr.dataType === 'PICKLIST', + inputType: this.attributeInputType(attr.dataType, attr.displayTypeOverride), + isReadOnly: attr.isReadOnly || isDisabled || isAttrDisabled, + isDisabled: isDisabled || isAttrDisabled, + // Boolean flags for template rendering + isCombobox: controlType === 'combobox', + isRadio: controlType === 'radio', + isSlider: controlType === 'slider', + isToggle: controlType === 'toggle', + isMultivalue: controlType === 'multivalue', + isLookup: attr.dataType === 'lookup', + isStandardInput: !['combobox', 'radio', 'slider', 'toggle', 'multivalue', 'lookup'].includes(controlType), + // Toggle-specific properties + toggleVariant: attr.value ? 'success' : 'default', + toggleLabel: attr.value ? 'On' : 'Off', + // Formatting properties for number inputs + formatter: this._getAttributeFormatter(attr.dataType), + step: this._getAttributeStep(attr.dataType), + // Lookup-specific properties + lookupObjectApiName: attr.additionalFields?.referenceObject || null, + // Decoder value for multivalue display + decoderValue: controlType === 'multivalue' ? this.getDecoderValue(attr) : null + }; + + // Filter picklist options to hide specific values + if (preparedAttr.isPicklist && preparedAttr.options) { + const hiddenValues = this.getHiddenPicklistValues(coverageStiId, attr.id); + if (hiddenValues.size > 0) { + preparedAttr.options = preparedAttr.options.filter( + option => !hiddenValues.has(option.id) + ); + } + } + + return preparedAttr; + }); + } + return preparedCoverage; + }); + } + if (node.items && node.items.length > 0) { + preparedNode.items = this.prepareDataForUI(node.items); + } + return preparedNode; + }); + } + + isCoverageDisabled(stiId, prcId) { + if (!this.uiTreatments || !stiId || !prcId) { + return false; + } + const disabledPrcIds = this.uiTreatments.disabledComponents.get(stiId); + return disabledPrcIds ? disabledPrcIds.has(prcId) : false; + } + + isCoverageHidden(stiId, prcId) { + if (!this.uiTreatments || !stiId || !prcId) { + return false; + } + const hiddenPrcIds = this.uiTreatments.hiddenComponents.get(stiId); + return hiddenPrcIds ? hiddenPrcIds.has(prcId) : false; + } + + isAttributeDisabled(stiId, attributeId) { + if (!this.uiTreatments || !stiId || !attributeId) { + return false; + } + const disabledAttrIds = this.uiTreatments.disabledAttributes.get(stiId); + return disabledAttrIds ? disabledAttrIds.has(attributeId) : false; + } + + isAttributeHidden(stiId, attributeId) { + if (!this.uiTreatments || !stiId || !attributeId) { + return false; + } + const hiddenAttrIds = this.uiTreatments.hiddenAttributes.get(stiId); + return hiddenAttrIds ? hiddenAttrIds.has(attributeId) : false; + } + + getHiddenPicklistValues(stiId, attributeId) { + if (!this.uiTreatments || !stiId || !attributeId) { + return new Set(); + } + const stiMap = this.uiTreatments.hiddenPicklistValues.get(stiId); + if (!stiMap) { + return new Set(); + } + return stiMap.get(attributeId) || new Set(); + } + + handleSelect(event) { + const selectedName = event.detail.name; + + // Ensure we're searching through the current treeItems + if (!this.treeItems || this.treeItems.length === 0) { + return; + } + + const foundNode = findSelectedTreeNode(this.treeItems, selectedName); + + if (foundNode) { + // Deep clone the node to ensure LWC detects the change and re-renders. + // This is the key fix for the reactivity issue. + this.selectedNode = JSON.parse(JSON.stringify(foundNode)); + } else { + // If node not found (e.g., after deletion), select the first available node + this.selectedNode = JSON.parse(JSON.stringify(this.treeItems[0])); + } + } + + async handleCoverageSelectionChange(event) { + const coverageKey = event.target.dataset.coverageKey; + const isSelected = event.target.checked; + const coverage = this.selectedNode.coverages.find(c => c.key === coverageKey); + + if (!coverage) { + return; + } + + // Update local state + coverage.isSelected = isSelected; + this.selectedNode = { ...this.selectedNode }; + + // Only call PATCH if contextId exists + if (!this._internalContextId) { + this.error = LABELS.ERROR_OCCURRED; + // Revert checkbox state since change cannot be persisted + coverage.isSelected = !isSelected; + this.selectedNode = { ...this.selectedNode }; + return; + } + + // Parse instanceKeys + const instanceKeysArray = coverage.instanceKeys || []; + const productCode = coverage.productCode; + + let patchPayload; + + if (isSelected) { + // Coverage is being added - use addedNodes + // Collect all attribute values for this coverage using attributeCode + const attributes = {}; + if (coverage.attributes) { + coverage.attributes.forEach(attr => { + if (attr.value !== null && attr.value !== undefined && attr.value !== '') { + attributes[attr.code] = attr.value; + } + }); + } + + patchPayload = { + contextId: this._internalContextId, + ratingOptions: { + executePricing: this.instantPricing, + executeConfigurationRules: true, + returnContextJson: true, + returnProductDetails: true, + returnRatingResults: true + }, + addedNodes: [ + { + instanceKeys: instanceKeysArray, + productCode, + attributes + } + ] + }; + + } else { + // Coverage is being removed - use deletedNodes + patchPayload = { + contextId: this._internalContextId, + ratingOptions: { + executePricing: this.instantPricing, + executeConfigurationRules: this.executeConfigurationRules, + returnContextJson: true, + returnProductDetails: true, + returnRatingResults: true + }, + deletedNodes: [ + { + instanceKeys: instanceKeysArray, + productCode + } + ] + }; + } + + this.isLoading = true; + this.error = null; + + try { + const result = await patchRatingFromLwc({ patchPayloadJson: JSON.stringify(patchPayload) }); + + if (result && result.success) { + this.processApiResponse(result); + const isValid = this.validateRequiredAttributes(); + this.omniValidate(isValid); + } else { + console.error('Coverage Update Failed:', result?.errorMessage); + + if (result?.actionErrors) { + console.error('Detailed Errors:', result.actionErrors); + } + throw new Error(result?.errorMessage || 'Error saving selection'); + } + + } catch (error) { + this.error = error?.body ? JSON.stringify(error.body, null, 2) : error?.message || LABELS.ERROR_OCCURRED; + + // Revert the UI state on error + coverage.isSelected = !isSelected; + this.selectedNode = { ...this.selectedNode }; + } finally { + this.isLoading = false; + } + } + + handleInstantPricingToggle(event) { + this.instantPricing = event.target.checked; + } + + async handleUpdatePrices() { + if (!this._internalContextId) { + return; + } + + // Build the PATCH payload for updating prices + const patchPayload = { + contextId: this._internalContextId, + ratingOptions: { + executePricing: true, + executeConfigurationRules: this.executeConfigurationRules, + returnContextJson: true, + returnProductDetails: true, + returnRatingResults: true + } + }; + + this.isLoading = true; + this.error = null; + + try { + const result = await patchRatingFromLwc({ patchPayloadJson: JSON.stringify(patchPayload) }); + + if (result && result.success) { + this.processApiResponse(result); + } else { + console.error('Update Failed:', result?.errorMessage); + if (result?.actionErrors) { + console.error('Detailed Errors:', result.actionErrors); + } + throw new Error(result?.errorMessage || 'Error saving selection'); + } + } catch (error) { + const errorMessage = error?.body ? JSON.stringify(error.body, null, 2) : error?.message || LABELS.ERROR_OCCURRED; + this.error = errorMessage; + } finally { + this.isLoading = false; + } + } + + /** + * Handler for custom Previous button + * Calls OmniScript's previous step method (which tracks direction) + */ + handleCustomPrevious() { + this.omniPrevStep(); + } + + /** + * Handler for custom Next button + * Validates required fields before navigating to next step + */ + handleCustomNext() { + // Validate required attributes before proceeding + const isValid = this.validateRequiredAttributes(); + + if (!isValid) { + // Scroll to top to show validation message + const card = this.template.querySelector('lightning-card'); + if (card) { + card.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + return; + } + // Clear validation message if it was previously set + this.validationMsg = ''; + + // Proceed to next step + this.omniNextStep(); + } + + handleDeleteButtonClick() { + // Show confirmation modal + this.showDeleteConfirmation = true; + } + + handleCancelDelete() { + // Close modal and navigate to home (first root node) + this.showDeleteConfirmation = false; + if (this.treeItems.length > 0) { + const firstNode = this.treeItems[0]; + this.expandAndSelectNode(firstNode.name); + } + } + + async handleConfirmDelete() { + // Close modal and proceed with delete + this.showDeleteConfirmation = false; + + if (!this._internalContextId || !this.selectedNode) { + return; + } + + // Find parent node before deletion so we can select it after deletion + const parentNode = this.findParentNode(this.treeItems, this.selectedNode.name); + this.parentNodeNameBeforeDeletion = parentNode?.name || null; + + // Find the instance keys path for this node + const instanceKeys = findInstanceKeysForNode(this.treeItems, this.selectedNode.name); + + if (!instanceKeys || instanceKeys.length === 0) { + this.error = LABELS.ERROR_OCCURRED; + return; + } + + const productCode = this.selectedNode.productCode; + + // Build the PATCH payload with deletedNodes + const patchPayload = { + contextId: this._internalContextId, + ratingOptions: { + executePricing: this.instantPricing, + executeConfigurationRules: this.executeConfigurationRules, + returnContextJson: true, + returnProductDetails: true, + returnRatingResults: true + }, + deletedNodes: [ + { + instanceKeys, + productCode + } + ] + }; + + this.isLoading = true; + this.error = null; + + try { + const result = await patchRatingFromLwc({ patchPayloadJson: JSON.stringify(patchPayload) }); + if (result && result.success) { + this.processApiResponse(result); + } else { + console.error('Delete Failed:', result?.errorMessage); + + if (result?.actionErrors) { + console.error('Detailed Errors:', result.actionErrors); + } + throw new Error(result?.errorMessage || 'Error saving selection'); + } + + } catch (error) { + const errorMessage = error?.body ? JSON.stringify(error.body, null, 2) : error?.message || LABELS.ERROR_OCCURRED; + this.error = errorMessage; + } finally { + this.isLoading = false; + } + } + + handleAttributeChange(event) { + const coverageKey = event.target.dataset.coverageKey; + const attrDevName = event.target.dataset.attrDevName; + const controlType = event.target.dataset.controlType; + + // For toggle/checkbox controls, use checked instead of value + const newValue = (controlType === 'toggle' || controlType === 'checkbox') + ? event.target.checked + : event.target.value; + + const coverage = this.selectedNode.coverages.find(c => c.key === coverageKey); + if (coverage?.attributes) { + const attribute = coverage.attributes.find(a => a.developerName === attrDevName); + if (attribute) { + // Store original value before updating (for comparison in handleAttributeBlur) + // Use stiId or instanceKeysString to ensure unique key across all coverage instances + const key = `${coverage.stiId || coverage.instanceKeysString}_${attribute.code}`; + if (!this.attributeOriginalValues.has(key)) { + this.attributeOriginalValues.set(key, attribute.value); + } + attribute.value = newValue; + this.selectedNode = { ...this.selectedNode }; + + // For controls that don't have blur events (slider, radio, toggle, multivalue), + // trigger the PATCH immediately on change + const controlsWithoutBlur = ['slider', 'radio', 'toggle', 'multivalue']; + if (controlsWithoutBlur.includes(controlType)) { + // Trigger blur handler to make PATCH call + this.handleAttributeBlur(event); + } + } + } + } + + async handleAttributeBlur(event) { + const eventData = this._extractEventData(event); + const { coverage, attribute } = this._findCoverageAndAttribute(eventData.coverageKey, eventData.attrDevName); + + if (!coverage || !attribute) { + return; + } + + const key = `${coverage.stiId || coverage.instanceKeysString}_${attribute.code}`; + const originalValue = this._getOriginalValue(key, attribute); + + // Check if value has changed + if (!this._hasValueChanged(originalValue, eventData.newValue)) { + this.attributeOriginalValues.delete(key); + return; + } + + // Validate coverage is selected + if (!this._validateCoverageSelected(coverage, attribute, originalValue, key)) { + return; + } + + // Only call PATCH if contextId exists + if (!this._internalContextId) { + return; + } + + await this._patchAttributeUpdate(eventData, key, attribute, originalValue); + } + + _extractEventData(event) { + const controlType = event.target.dataset.controlType; + let newValue; + + if (controlType === 'toggle' || controlType === 'checkbox') { + newValue = event.target.checked; + } else { + newValue = event.target.value; + } + + return { + coverageKey: event.target.dataset.coverageKey, + attrDevName: event.target.dataset.attrDevName, + attrCode: event.target.dataset.attrCode, + valueDecoder: event.target.dataset.valueDecoder, + productCode: event.target.dataset.productCode, + instanceKeys: event.target.dataset.instanceKeys, + controlType, + newValue + }; + } + + _findCoverageAndAttribute(coverageKey, attrDevName) { + if (!this.selectedNode || !this.selectedNode.coverages) { + return {}; + } + + const coverage = this.selectedNode.coverages.find(c => c.key === coverageKey); + if (!coverage) { + return {}; + } + + const attribute = coverage.attributes?.find(a => a.developerName === attrDevName); + return { coverage, attribute }; + } + + _getOriginalValue(key, attribute) { + return this.attributeOriginalValues.has(key) + ? this.attributeOriginalValues.get(key) + : attribute.value; + } + + _hasValueChanged(originalValue, newValue) { + const isOriginalEmpty = originalValue === null || originalValue === undefined || originalValue === ''; + const isNewEmpty = newValue === null || newValue === undefined || newValue === ''; + + // If both are empty, no change + if (isOriginalEmpty && isNewEmpty) { + return false; + } + + // If both are non-empty and equal, no change + if (!isOriginalEmpty && !isNewEmpty && originalValue === newValue) { + return false; + } + + return true; + } + + _validateCoverageSelected(coverage, attribute, originalValue, key) { + if (!coverage.isSelected) { + this.error = LABELS.SELECT_COVERAGE_FIRST; + attribute.value = originalValue; + this.attributeOriginalValues.delete(key); + this.selectedNode = { ...this.selectedNode }; + return false; + } + return true; + } + + async _patchAttributeUpdate(eventData, key, attribute, originalValue) { + const instanceKeysArray = eventData.instanceKeys ? eventData.instanceKeys.split(',') : []; + + const patchPayload = { + contextId: this._internalContextId, + ratingOptions: { + executePricing: this.instantPricing, + executeConfigurationRules: true, + returnContextJson: true, + returnProductDetails: true, + returnRatingResults: true + }, + updatedNodes: [ + { + instanceKeys: instanceKeysArray, + productCode: eventData.productCode, + attributes: { + [eventData.attrCode]: eventData.newValue + } + } + ] + }; + + this.isLoading = true; + this.error = null; + + try { + const result = await patchRatingFromLwc({ patchPayloadJson: JSON.stringify(patchPayload) }); + + if (result && result.success) { + this.processApiResponse(result); + + // Clean up stored original value after successful update + this.attributeOriginalValues.delete(key); + + // Validate after attribute change + const isValid = this.validateRequiredAttributes(); + this.omniValidate(isValid); + } else { + console.error('Error:', result?.errorMessage); + + if (result?.actionErrors) { + console.error('Detailed Errors:', result.actionErrors); + } + throw new Error(result?.errorMessage || 'Error saving selection'); + } + } catch (error) { + this._handlePatchError(error, attribute, originalValue, key); + } finally { + this.isLoading = false; + } + } + + _handlePatchError(error, attribute, originalValue, key) { + this.error = error?.body ? JSON.stringify(error.body, null, 2) : error?.message || LABELS.ERROR_OCCURRED; + + const storedOriginalValue = this.attributeOriginalValues.get(key); + if (storedOriginalValue !== undefined) { + attribute.value = storedOriginalValue; + this.attributeOriginalValues.delete(key); + this.selectedNode = { ...this.selectedNode }; + } + } + + /** + * Handles itemoutputchange event from force-lookup component for attributes in Details section + * Displays read-only Name of the record tied to the attribute + */ + handleAttributeLookupChange(event) { + if (!event || !event.detail || !event.detail.value) { + return; + } + + const attrId = event.target?.dataset?.attrId; + if (!attrId) { + return; + } + + const displayValue = event.detail.value.displayValue; + + // Update the lookup display name in the categorized attributes + if (this.selectedNode && this.selectedNode.attributes) { + const attribute = this.selectedNode.attributes.find(a => a.id === attrId); + if (attribute) { + attribute.lookupDisplayName = displayValue; + // Trigger reactivity + this.selectedNode = { ...this.selectedNode }; + } + } + } + + /** + * Handles itemoutputchange event from force-lookup component for coverage attributes + * Displays read-only Name of the record tied to the coverage attribute + */ + handleCoverageAttributeLookupChange(event) { + if (!event || !event.detail || !event.detail.value) { + return; + } + + const coverageKey = event.target?.dataset?.coverageKey; + const attrId = event.target?.dataset?.attrId; + + if (!coverageKey || !attrId) { + return; + } + + const displayValue = event.detail.value.displayValue; + + // Find the coverage and attribute + const coverage = this.selectedNode?.coverages?.find(c => c.key === coverageKey); + if (coverage?.attributes) { + const attribute = coverage.attributes.find(a => a.id === attrId); + if (attribute) { + attribute.lookupDisplayName = displayValue; + // Trigger reactivity + this.selectedNode = { ...this.selectedNode }; + } + } + } + + handleNavigate(event) { + const recordId = event.detail.recordId; + if (!recordId) { + return; + } + // Find the node with the matching recordId (id) in the tree + const targetNode = findTreeNodeById(this.treeItems, recordId); + if (targetNode) { + // Expand parent nodes if needed and select the target node + this.expandAndSelectNodeById(recordId); + } + } + + findParentNode(items, targetName, parent = null) { + // Find the parent node of a given node by name + for (const item of items) { + if (item.name === targetName) { + return parent; + } + if (item.items && item.items.length > 0) { + const foundParent = this.findParentNode(item.items, targetName, item); + if (foundParent !== undefined) { + return foundParent; + } + } + } + return undefined; + } + + expandAndSelectNode(nodeName) { + // Expand all parent nodes and select the target node by name + const expandParents = (items, targetName, parentPath = []) => { + for (const item of items) { + if (item.name === targetName) { + // Found the target - expand all parents + parentPath.forEach(parent => { + parent.expanded = true; + }); + // Trigger selection using the node's name (required by lightning-tree) + this.handleSelect({ detail: { name: item.name } }); + return true; + } + if (item.items && item.items.length > 0) { + if (expandParents(item.items, targetName, [...parentPath, item])) { + return true; + } + } + } + return false; + }; + + expandParents(this.treeItems, nodeName); + // Re-assign to trigger reactivity + this.treeItems = [...this.treeItems]; + } + + expandAndSelectNodeById(id) { + // Expand all parent nodes and select the target node by id + const expandParents = (items, targetId, parentPath = []) => { + for (const item of items) { + if (item.id === targetId) { + // Found the target - collect parent names to expand + const nodeNamesToExpand = new Set(parentPath.map(p => p.name)); + // Trigger selection using the node's name (required by lightning-tree) + this.handleSelect({ detail: { name: item.name } }); + return nodeNamesToExpand; + } + if (item.items && item.items.length > 0) { + const result = expandParents(item.items, targetId, [...parentPath, item]); + if (result) { + return result; + } + } + } + return null; + }; + + const nodeNamesToExpand = expandParents(this.treeItems, id); + if (nodeNamesToExpand) { + // Restore expansion using the safe method + this.restoreExpansionState(this.treeItems, nodeNamesToExpand); + // Re-assign to trigger reactivity + this.treeItems = [...this.treeItems]; + } + } + + + get productTitle() { + return this.selectedNode ? this.selectedNode.label : 'Details'; + } + + get selectedNodePrice() { + if (!this.selectedNode || this.selectedNode.netUnitPrice === null || this.selectedNode.netUnitPrice === undefined) { + return null; + } + return this.selectedNode.netUnitPrice; + } + + get selectedNodeTax() { + if (!this.selectedNode || this.selectedNode.proratedQLITaxAmount === null || this.selectedNode.proratedQLITaxAmount === undefined) { + return ''; + } + const currency = this.getCurrencyForPriceSummary(); + const formattedCurrency = this.formatCurrency(this.selectedNode.proratedQLITaxAmount, currency); + return `${LABELS.TAX_AMOUNT} ${formattedCurrency}`; + } + + get currencyCode() { + return this._savedCurrencyCode || this.getCurrencyForPriceSummary(); + } + + get hasCoverages() { + return this.selectedNode?.coverages?.length > 0; + } + + // Computed property that adds isDisabledOrLoading dynamically based on current isLoading state + /** + * Helper method to group and sort attributes by category + */ + groupAttributesByCategory(attributes) { + if (!attributes || attributes.length === 0) { + return []; + } + + // Helper function to sort attributes by sequence then label + const sortAttributes = (attrs) => { + return attrs.sort((a, b) => { + const aSeq = a.sequence ?? Number.MAX_SAFE_INTEGER; + const bSeq = b.sequence ?? Number.MAX_SAFE_INTEGER; + + if (aSeq !== bSeq) { + return aSeq - bSeq; + } + + return (a.label || '').localeCompare(b.label || ''); + }); + }; + + // Group attributes by category + const categoryMap = new Map(); + const uncategorizedAttrs = []; + + attributes.forEach(attr => { + if (attr.categoryName) { + if (!categoryMap.has(attr.categoryName)) { + categoryMap.set(attr.categoryName, []); + } + categoryMap.get(attr.categoryName).push(attr); + } else { + uncategorizedAttrs.push(attr); + } + }); + + // Build result array with categorized groups + const result = []; + + // Add categorized groups (sorted by category name) + const sortedCategories = Array.from(categoryMap.keys()).sort(); + sortedCategories.forEach(categoryName => { + const attrs = categoryMap.get(categoryName); + result.push({ + categoryName, + attributes: sortAttributes(attrs), + key: `category-${categoryName}` + }); + }); + + // Add uncategorized group if exists + if (uncategorizedAttrs.length > 0) { + result.push({ + categoryName: 'Uncategorized', + attributes: sortAttributes(uncategorizedAttrs), + key: 'category-uncategorized' + }); + } + + return result; + } + + get displayNode() { + if (!this.selectedNode) { + return null; + } + + const node = { ...this.selectedNode }; + + if (node.coverages) { + node.coverages = node.coverages.map(coverage => { + const updatedCoverage = { ...coverage }; + updatedCoverage.isDisabledOrLoading = coverage.isDisabled || this.isLoading; + + if (coverage.attributes) { + updatedCoverage.attributes = coverage.attributes.map(attr => { + const updatedAttr = { ...attr }; + updatedAttr.isDisabledOrLoading = attr.isDisabled || this.isLoading; + updatedAttr.displayValue = this.getAttributeDisplayValue(attr); + return updatedAttr; + }); + + // Add categorized attributes for display + updatedCoverage.categorizedAttributes = this.groupAttributesByCategory(updatedCoverage.attributes); + } + + return updatedCoverage; + }).sort((a, b) => { + // Sort coverages alphabetically by name + return (a.name || '').localeCompare(b.name || ''); + }); + } + + return node; + } + + get hasConfigMessages() { + return this.configMessages && this.configMessages.length > 0; + } + + get configMessageCount() { + return this.configMessages ? this.configMessages.length : 0; + } + + handleToggleMessages() { + this.isMessagesExpanded = !this.isMessagesExpanded; + } + + get messagesIconName() { + return this.isMessagesExpanded ? 'utility:chevrondown' : 'utility:chevronright'; + } + + get showDeleteButton() { + if (!this.selectedNode) { + return false; + } + // Check if this node is a root product by checking if it exists in the top level of treeItems + const isRootProduct = this.treeItems.some(item => item.name === this.selectedNode.name); + return !isRootProduct; + } + + processConfigMessages(messages) { + if (messages && Array.isArray(messages)) { + // Add unique keys to messages for proper rendering in template loops + this.configMessages = messages.map((message, index) => ({ + ...message, + key: message.key || `message-${index}` + })); + } else { + this.configMessages = []; + } + } + + processUiTreatments(uiTreatments) { + const treatments = { + disabledComponents: new Map(), // stiId -> Set of prcIds + hiddenComponents: new Map(), // stiId -> Set of prcIds + disabledAttributes: new Map(), // stiId -> Set of attributeIds + hiddenAttributes: new Map(), // stiId -> Set of attributeIds + hiddenPicklistValues: new Map() // stiId -> Map(attributeId -> Set of valueIds) + }; + + if (uiTreatments && Array.isArray(uiTreatments)) { + uiTreatments.forEach(treatment => { + const { uiTreatmentType, uiTreatmentTarget, details } = treatment; + if (!details) { + return; + } + + // Component treatments + if (uiTreatmentTarget === 'component') { + const { stiId, prcId } = details; + if (!stiId || !prcId) { + return; + } + + if (uiTreatmentType === 'disable') { + if (!treatments.disabledComponents.has(stiId)) { + treatments.disabledComponents.set(stiId, new Set()); + } + treatments.disabledComponents.get(stiId).add(prcId); + } else if (uiTreatmentType === 'hide') { + if (!treatments.hiddenComponents.has(stiId)) { + treatments.hiddenComponents.set(stiId, new Set()); + } + treatments.hiddenComponents.get(stiId).add(prcId); + } + } + + // Attribute treatments + if (uiTreatmentTarget === 'attribute') { + const { stiId, attributeId } = details; + if (!stiId || !attributeId) { + return; + } + + if (uiTreatmentType === 'disable') { + if (!treatments.disabledAttributes.has(stiId)) { + treatments.disabledAttributes.set(stiId, new Set()); + } + treatments.disabledAttributes.get(stiId).add(attributeId); + } else if (uiTreatmentType === 'hide') { + if (!treatments.hiddenAttributes.has(stiId)) { + treatments.hiddenAttributes.set(stiId, new Set()); + } + treatments.hiddenAttributes.get(stiId).add(attributeId); + } + } + + // Attribute picklist value treatments + if (uiTreatmentTarget === 'attribute_picklist_value') { + const { stiId, attributeId, attributePicklistValueId } = details; + if (!stiId || !attributeId || !attributePicklistValueId) { + return; + } + + if (uiTreatmentType === 'hide') { + if (!treatments.hiddenPicklistValues.has(stiId)) { + treatments.hiddenPicklistValues.set(stiId, new Map()); + } + const stiMap = treatments.hiddenPicklistValues.get(stiId); + if (!stiMap.has(attributeId)) { + stiMap.set(attributeId, new Set()); + } + stiMap.get(attributeId).add(attributePicklistValueId); + } + } + }); + } + return treatments; + } + + // Pricing getter methods + getCurrencyForPriceSummary() { + if (!this.apiResponse?.contextJSON?.salesTransactions?.[0]?.salesTransactionItems) { + return CURRENCY; // Fall back to org's default currency + } + const firstItem = this.apiResponse.contextJSON.salesTransactions[0].salesTransactionItems[0]; + const currency = firstItem?.fields?.STICurrencyIsoCode__std || CURRENCY; + return currency; + } + + formatCurrency(amount, currencyCode) { + const currency = currencyCode || CURRENCY; + return new Intl.NumberFormat(LOCALE, { + style: 'currency', + currency, + currencyDisplay: 'symbol' + }).format(amount); + } + + getPriceValue(fieldName) { + if (!this.apiResponse?.contextJSON?.salesTransactions?.[0]?.salesTransactionItems) { + return 0; + } + const firstItem = this.apiResponse.contextJSON.salesTransactions[0].salesTransactionItems[0]; + return firstItem?.fields?.[fieldName] || 0; + } + + get priceSummary() { + if (this._savedPricingSummary) { + return this._savedPricingSummary; + } + return { + premium: this.getPriceValue('NetUnitPrice'), + taxesFees: this.getPriceValue('ProratedQLITaxAmount'), + totalPremium: this.getPriceValue('NetTotalPrice') + }; + } + + /** + * Determines the formatter for an attribute based on data type + * @param {string} dataType - The data type of the attribute + * @returns {string} - The formatter to use ('currency', 'percent', or undefined) + */ + _getAttributeFormatter(dataType) { + if (!dataType) { + return undefined; + } + + const upperDataType = dataType.toUpperCase(); + switch (upperDataType) { + case 'CURRENCY': + return 'currency'; + case 'PERCENT': + return 'percent'; + default: + return undefined; + } + } + + _getAttributeStep(dataType) { + if (!dataType) { + return undefined; + } + + const upperDataType = dataType.toUpperCase(); + switch (upperDataType) { + case 'CURRENCY': + return '0.01'; + case 'PERCENT': + return '0.01'; + case 'NUMBER': + return 'any'; + default: + return undefined; + } + } + + attributeInputType(dataType, displayTypeOverride) { + // If displayTypeOverride is specified, use it (normalized to lowercase) + if (displayTypeOverride) { + const override = displayTypeOverride.toUpperCase(); + // Map display type override values to control types + switch (override) { + case 'TEXT': + return 'text'; + case 'NUMBER': + return 'number'; + case 'DATE': + return 'date'; + case 'DATETIME': + return 'datetime'; + case 'CHECKBOX': + return 'checkbox'; + case 'TOGGLE': + return 'toggle'; + case 'SLIDER': + return 'slider'; + case 'RADIOBUTTON': + return 'radio'; + case 'COMBOBOX': + return 'combobox'; + case 'MULTIVALUECOMBOBOX': + return 'multivalue'; + default: + // If override value is not recognized, fall through to dataType logic + break; + } + } + + // Fall back to default dataType mapping + switch (dataType) { + case 'NUMBER': + case 'CURRENCY': + case 'PERCENT': + return 'number'; + case 'DATE': + return 'date'; + case 'DATETIME': + return 'datetime'; + case 'CHECKBOX': + return 'checkbox'; + case 'PICKLIST': + return 'combobox'; + default: + return 'text'; + } + } + + /** + * Calculates the decoder value display message for multivalue attributes + * @param {Object} attr - The attribute object with valueDecoder and options + * @returns {string} - The formatted decoder message + */ + getDecoderValue(attr) { + if (!attr.valueDecoder) { + return ''; + } + + const parsingregex = /[/#]+/; + let parsedDecoderValue = attr.valueDecoder.trim().split(parsingregex); + parsedDecoderValue = parsedDecoderValue.map(part => part.trim()); + + // Find the delimiter present in the label of first picklist option + DELIMITERS.forEach((delimiter) => { + if (attr.options && attr.options.length > 0 && attr.options[0].label.includes(delimiter)) { + parsedDecoderValue = parsedDecoderValue.join(' ' + delimiter + ' '); + } + }); + + return LABELS.MULTI_VALUE_DECODER_MESSAGE + parsedDecoderValue; + } + + /** Getter for all labels used in HTML template */ + get labels() { + return LABELS; + } + + /** Getter for validation error message */ + get displayValidationMsg() { + return this.validationMsg; + } + + /** + * Gets the display value for an attribute, converting picklist values to labels + * and formatting values based on datatype + * @param {Object} attr - The attribute object + * @returns {string} - The display value + */ + getAttributeDisplayValue(attr) { + if (!attr) { + return ''; + } + + let displayValue = attr.value; + + // Handle null/undefined values + if (displayValue === null || displayValue === undefined) { + return ''; + } + + // For picklists, find the label corresponding to the value + if (attr.dataType === 'PICKLIST' && attr.options) { + const option = attr.options.find(opt => opt.value === attr.value); + if (option) { + displayValue = option.label; + } + return displayValue; + } + + // Format based on dataType + const dataType = attr.dataType?.toUpperCase(); + + switch (dataType) { + case 'CURRENCY': + // Format as currency + try { + const numValue = parseFloat(displayValue); + if (!isNaN(numValue)) { + displayValue = new Intl.NumberFormat(LOCALE, { + style: 'currency', + currency: this.currencyCode || CURRENCY, + currencyDisplay: 'symbol' + }).format(numValue); + } + } catch (e) { + // If formatting fails, return original value + } + break; + + case 'PERCENT': + // Format as percentage + try { + const numValue = parseFloat(displayValue); + if (!isNaN(numValue)) { + displayValue = new Intl.NumberFormat(LOCALE, { + style: 'percent', + minimumFractionDigits: 0, + maximumFractionDigits: 2 + }).format(numValue / 100); + } + } catch (e) { + // If formatting fails, return original value + } + break; + + case 'DATE': + // Format as date + try { + const dateValue = new Date(displayValue); + if (!isNaN(dateValue.getTime())) { + displayValue = new Intl.DateTimeFormat(LOCALE, { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).format(dateValue); + } + } catch (e) { + // If formatting fails, return original value + } + break; + + case 'DATETIME': + // Format as datetime + try { + const dateValue = new Date(displayValue); + if (!isNaN(dateValue.getTime())) { + displayValue = new Intl.DateTimeFormat(LOCALE, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }).format(dateValue); + } + } catch (e) { + // If formatting fails, return original value + } + break; + + default: + // For other types (TEXT, NUMBER, CHECKBOX, etc.), return as-is + break; + } + + return displayValue; + } + + /** Computed property to determine if Update Prices button should be disabled */ + get isUpdatePricesDisabled() { + // Disable when instant pricing is enabled, enable when it's disabled + return this.instantPricing; + } + + /** Computed property to group and sort attributes by category */ + get categorizedAttributes() { + if (!this.selectedNode || !this.selectedNode.attributes) { + return []; + } + + // Filter out hidden attributes based on uiTreatments + const stiId = this.selectedNode.id; + const visibleAttributes = this.selectedNode.attributes.filter(attr => + !this.isAttributeHidden(stiId, attr.id) + ); + + // Helper function to sort attributes by sequence then label + const sortAttributes = (attrs) => { + return attrs.sort((a, b) => { + // First compare by sequence + const aSeq = a.sequence ?? Number.MAX_SAFE_INTEGER; + const bSeq = b.sequence ?? Number.MAX_SAFE_INTEGER; + + if (aSeq !== bSeq) { + return aSeq - bSeq; + } + + // If sequences are equal, compare by label + return (a.label || '').localeCompare(b.label || ''); + }); + }; + + // Group attributes by category + const categoryMap = new Map(); + const uncategorizedAttrs = []; + + visibleAttributes.forEach(attr => { + // Add displayValue and lookup properties to attribute + const attrWithDisplay = { + ...attr, + displayValue: this.getAttributeDisplayValue(attr), + isLookup: attr.dataType === 'lookup', + lookupObjectApiName: attr.additionalFields?.referenceObject || null + }; + + if (attr.categoryName) { + if (!categoryMap.has(attr.categoryName)) { + categoryMap.set(attr.categoryName, []); + } + categoryMap.get(attr.categoryName).push(attrWithDisplay); + } else { + uncategorizedAttrs.push(attrWithDisplay); + } + }); + + // Build result array with categorized groups + const result = []; + + // Add categorized groups (sorted by category name for consistent display) + const sortedCategories = Array.from(categoryMap.keys()).sort(); + sortedCategories.forEach(categoryName => { + const attrs = categoryMap.get(categoryName); + result.push({ + categoryName, + attributes: sortAttributes(attrs), + key: `category-${categoryName}` + }); + }); + + // Add uncategorized group if there are any uncategorized attributes + if (uncategorizedAttrs.length > 0) { + result.push({ + categoryName: 'Uncategorized', + attributes: sortAttributes(uncategorizedAttrs), + key: 'category-uncategorized' + }); + } + + return result; + } + + getOmniDataOutput() { + return { + contextId: this._internalContextId + }; + } + + /** + * Helper method to check if a value is empty + * @param {*} value - The value to check + * @returns {boolean} - True if the value is empty + */ + _isEmptyValue(value) { + // Check for null, undefined, or empty string + if (value === null || value === undefined || value === '') { + return true; + } + // Check for empty array (for multivalue combobox) + if (Array.isArray(value) && value.length === 0) { + return true; + } + return false; + } + + /** + * Validates that all required attributes have values from context JSON. + * This mirrors the omniValidate logic from prodSel. + * @returns {boolean} - True if all required attributes have values, false otherwise + */ + validateRequiredAttributes() { + if (!this.treeItems || this.treeItems.length === 0) { + this.validationMsg = ''; + return true; // No data to validate + } + + let hasAllRequiredValues = true; + + const validateNode = (node) => { + // Check attributes at node level + if (node.attributes) { + node.attributes.forEach(attr => { + if (attr.isRequired && this._isEmptyValue(attr.value)) { + hasAllRequiredValues = false; + } + }); + } + + // Check attributes in coverages + if (node.coverages) { + node.coverages.forEach(coverage => { + // Only validate selected coverages + if (coverage.isSelected && coverage.attributes) { + coverage.attributes.forEach(attr => { + if (attr.isRequired && this._isEmptyValue(attr.value)) { + hasAllRequiredValues = false; + } + }); + } + }); + } + + // Recursively check child items + if (node.items && node.items.length > 0) { + node.items.forEach(childNode => validateNode(childNode)); + } + }; + + // Validate all root nodes + this.treeItems.forEach(rootNode => validateNode(rootNode)); + + // Set validation message if validation fails + if (!hasAllRequiredValues) { + this.validationMsg = LABELS.REQUIRED_ATTRIBUTES_MSG; + } else { + this.validationMsg = ''; + } + + return hasAllRequiredValues; + } + + /** + * Syncs selectedNode changes back to treeItems to ensure latest user values are captured + */ + syncSelectedNodeToTreeItems() { + if (!this.selectedNode) { + return; + } + + const updateNodeInTree = (items, targetName, updatedNode) => { + for (let i = 0; i < items.length; i++) { + if (items[i].name === targetName) { + // Update the node with the latest changes from selectedNode + items[i] = { ...items[i], ...updatedNode }; + return true; + } + if (items[i].items && items[i].items.length > 0) { + if (updateNodeInTree(items[i].items, targetName, updatedNode)) { + return true; + } + } + } + return false; + }; + + updateNodeInTree(this.treeItems, this.selectedNode.name, this.selectedNode); + } + + // Overwrites method from OmniscriptBaseMixin to prevent user from using Next button + @api checkValidity() { + return this.validateRequiredAttributes(); + } + + /** + * Override omniNextStep to track navigation direction + * This allows us to know if the user clicked Next vs Previous + */ + omniNextStep() { + this._navigationDirection = 'next'; + super.omniNextStep(); + } + + /** + * Override omniPrevStep to track navigation direction + * This allows us to know if the user clicked Previous vs Next + */ + omniPrevStep() { + this._navigationDirection = 'previous'; + super.omniPrevStep(); + } + + + // Save state does not work in custom OS environment + disconnectedCallback() { + // Reset navigation direction for next navigation + this._navigationDirection = null; + } + + get showCustomNextPrevButtons() { + // Handle both boolean and string values (OmniScript may pass "true" as string) + return this.clearStateOnPrev === true || this.clearStateOnPrev === 'true'; + } +} \ No newline at end of file diff --git a/osComponents/ProductConfiguration/lwc/productConfiguration/productConfiguration.js-meta.xml b/osComponents/ProductConfiguration/lwc/productConfiguration/productConfiguration.js-meta.xml new file mode 100644 index 0000000..a879fd4 --- /dev/null +++ b/osComponents/ProductConfiguration/lwc/productConfiguration/productConfiguration.js-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + true + \ No newline at end of file diff --git a/osComponents/ProductConfiguration/lwc/productConfigurationMessageItem/productConfigurationMessageItem.html b/osComponents/ProductConfiguration/lwc/productConfigurationMessageItem/productConfigurationMessageItem.html new file mode 100644 index 0000000..8f6e9b6 --- /dev/null +++ b/osComponents/ProductConfiguration/lwc/productConfigurationMessageItem/productConfigurationMessageItem.html @@ -0,0 +1,35 @@ + \ No newline at end of file diff --git a/osComponents/ProductConfiguration/lwc/productConfigurationMessageItem/productConfigurationMessageItem.js b/osComponents/ProductConfiguration/lwc/productConfigurationMessageItem/productConfigurationMessageItem.js new file mode 100644 index 0000000..65c7d57 --- /dev/null +++ b/osComponents/ProductConfiguration/lwc/productConfigurationMessageItem/productConfigurationMessageItem.js @@ -0,0 +1,68 @@ +import { LightningElement, api } from 'lwc'; +import { publish, createMessageContext } from 'lightning/messageService'; +import MessageChannel from '@salesforce/messageChannel/lightning__productConfigurator_notification'; + +export default class MessageItem extends LightningElement { + // ================================================================================ + // PUBLIC PROPERTIES + // ================================================================================ + /** + * Variable to store message information. Properties required in message + * 1. message (text content) + * 2. type (info, error, warning) + * 3. key (optional unique identifier) + * 4. recordId (optional - Salesforce record ID for navigation) + */ + @api message; + + // ================================================================================ + // ACCESSOR METHODS + // ================================================================================ + + get title() { + return this.message.message || this.message.text || ''; + } + + get isInfoMessage() { + return this.message.type === 'info'; + } + + get isErrorMessage() { + return this.message.type === 'error'; + } + + get isWarningMessage() { + return this.message.type === 'warning'; + } + + get hasNavigationPath() { + return !!this.message.recordId; + } + + // ================================================================================ + // EVENT HANDLERS + // ================================================================================ + + handleView(event) { + // Prevent default button behavior + event.preventDefault(); + event.stopPropagation(); + // Publish navigation event via Lightning Message Service + publish(createMessageContext(), MessageChannel, { + action: 'navigate', + type: 'jump', + recordId: this.message.recordId + }); + + // Also dispatch a custom event for parent components that don't use LMS + const navigateEvent = new CustomEvent('navigate', { + detail: { + recordId: this.message.recordId, + message: this.message + }, + bubbles: true, + composed: true + }); + this.dispatchEvent(navigateEvent); + } +} \ No newline at end of file diff --git a/osComponents/ProductConfiguration/lwc/productConfigurationMessageItem/productConfigurationMessageItem.js-meta.xml b/osComponents/ProductConfiguration/lwc/productConfigurationMessageItem/productConfigurationMessageItem.js-meta.xml new file mode 100644 index 0000000..a879fd4 --- /dev/null +++ b/osComponents/ProductConfiguration/lwc/productConfigurationMessageItem/productConfigurationMessageItem.js-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + true + \ No newline at end of file