From b890d24bb7170391527ea7537d213a220750f810 Mon Sep 17 00:00:00 2001 From: Aditya Date: Sat, 4 Apr 2026 15:10:51 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20non-individual=20entity=20UI=20improvem?= =?UTF-8?q?ents=20=E2=80=94=20hide=20individual-only=20sections,=20fix=20l?= =?UTF-8?q?egal=20form=20display,=20send=20extended=20fields=20to=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hide Family Members tab/section for non-individual entities in client view, create flow, and preview - Hide Financial & Classification section for non-individual entities in personal data and preview - Fix legal form column in clients list — fetch legalForm from client API for correct display - Add person/business icon before client name in list rows - Remove unsupportedNpd filter — send authorizedSignatory, additionalInfo, dateOfRegistration to API - Add registration number format hint (mat-hint) based on constitution type - Add spacing between verification row and constitution type dropdowns - Reduce table cell padding and prevent action buttons from being clipped - Add missing MPL-2.0 license headers Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.json | 29 + .claude/settings.local.json | 52 + backend-schema/ClientAddressIndia.java | 169 ++ .../ClientAddressIndiaApiResource.java | 219 +++ .../ClientAddressIndiaRepository.java | 20 + backend-schema/ClientEntityKyc.java | 173 ++ .../ClientEntityKycApiResource.java | 140 ++ backend-schema/ClientEntityKycRepository.java | 17 + backend-schema/README.md | 75 + .../V1__client_schema_extensions.sql | 149 ++ ...lient_Creation_API.postman_collection.json | 430 +++++ .../accounting-rules.component.html | 3 +- src/app/accounting/accounting.component.html | 442 ++--- src/app/accounting/accounting.component.scss | 127 ++ .../chart-of-accounts.component.html | 3 +- .../provisioning-entries.component.html | 3 +- ...rovisioning-journal-entries.component.html | 3 +- src/app/app.module.ts | 11 +- .../add-holiday-dialog.component.html | 116 ++ .../add-holiday-dialog.component.scss | 120 ++ .../add-holiday-dialog.component.ts | 134 ++ .../bank-calendar-routing.module.ts | 34 + .../bank-calendar.component.html | 134 ++ .../bank-calendar.component.scss | 466 ++++++ .../bank-calendar/bank-calendar.component.ts | 508 ++++++ src/app/bank-calendar/bank-calendar.module.ts | 22 + src/app/centers/centers.component.html | 120 +- .../client-address-step.component.ts | 2 +- .../client-entity-kyc-step.component.html | 678 ++++++++ .../client-entity-kyc-step.component.scss | 206 +++ .../client-entity-kyc-step.component.ts | 311 ++++ ...client-family-member-dialog.component.html | 438 +++-- ...client-family-member-dialog.component.scss | 346 +++- .../client-family-member-dialog.component.ts | 402 ++++- .../client-family-members-step.component.html | 191 ++- .../client-family-members-step.component.scss | 632 ++++++- .../client-family-members-step.component.ts | 490 +++++- .../client-general-step.component.html | 970 ++++++++--- .../client-general-step.component.scss | 1458 ++++++++++++++++- .../client-general-step.component.ts | 1128 ++++++++++++- .../dip-verify-dialog.component.ts | 39 +- .../sanctions-verify-dialog.component.ts | 465 +++++- .../client-india-address-step.component.html | 454 +++++ .../client-india-address-step.component.scss | 201 +++ .../client-india-address-step.component.ts | 319 ++++ .../client-kyc-step.component.html | 817 ++++++++- .../client-kyc-step.component.scss | 1058 ++++++++++++ .../client-kyc-step.component.ts | 811 ++++++++- .../image-crop-dialog.component.html | 49 + .../image-crop-dialog.component.scss | 176 ++ .../image-crop-dialog.component.ts | 244 +++ .../client-preview-step.component.html | 953 +++++++++-- .../client-preview-step.component.scss | 272 ++- .../client-preview-step.component.ts | 27 + src/app/clients/clients-routing.module.ts | 14 +- .../address-tab/address-tab.component.html | 78 +- .../address-tab/address-tab.component.ts | 2 +- .../view-survey/view-survey.component.html | 3 +- .../clients-view/clients-view.component.html | 148 +- .../clients-view/clients-view.component.scss | 353 ++-- .../entity-classification-tab.component.html | 49 + .../entity-classification-tab.component.scss | 60 + .../entity-classification-tab.component.ts | 87 + .../family-members-tab.component.html | 90 +- .../family-members-tab.component.scss | 34 + .../family-members-tab.component.ts | 130 +- .../form60-tab/form60-tab.component.html | 213 +-- .../form60-tab/form60-tab.component.scss | 164 +- .../form60-tab/form60-tab.component.ts | 76 +- .../identities-tab.component.html | 210 +-- .../identities-tab.component.scss | 361 ++-- .../identities-tab.component.ts | 11 +- .../kyc-tab/kyc-tab.component.html | 591 +++---- .../kyc-tab/kyc-tab.component.scss | 260 +++ .../clients-view/kyc-tab/kyc-tab.component.ts | 15 +- .../nominee-tab/nominee-tab.component.html | 312 ++++ .../nominee-tab/nominee-tab.component.scss | 183 +++ .../nominee-tab/nominee-tab.component.ts | 498 ++++++ .../personal-data-tab.component.html | 226 ++- .../personal-data-tab.component.scss | 45 + .../personal-data-tab.component.ts | 24 +- src/app/clients/clients.component.html | 48 +- src/app/clients/clients.component.ts | 77 +- src/app/clients/clients.service.ts | 114 +- .../create-client.component.html | 195 ++- .../create-client.component.scss | 123 ++ .../create-client/create-client.component.ts | 473 +++++- .../edit-client/edit-client.component.html | 536 +++--- .../edit-client/edit-client.component.scss | 333 ++-- .../edit-client/edit-client.component.ts | 462 +++--- .../core/http/error-handler.interceptor.ts | 26 +- src/app/core/shell/shell.component.html | 136 +- src/app/core/shell/shell.component.scss | 59 +- .../core/shell/sidenav/sidenav.component.html | 41 +- .../core/shell/toolbar/toolbar.component.html | 12 + .../core/shell/toolbar/toolbar.component.scss | 47 + .../core/shell/toolbar/toolbar.component.ts | 20 + .../has-permission.directive.ts | 10 +- src/app/groups/groups.component.html | 116 +- .../home/dashboard/dashboard.component.html | 25 - .../home/dashboard/dashboard.component.scss | 45 - src/app/home/dashboard/dashboard.component.ts | 6 - .../warning-dialog.component.html | 13 +- .../warning-dialog.component.scss | 54 + .../login-form/login-form.component.html | 128 +- .../login-form/login-form.component.scss | 494 ++---- .../login/login-form/login-form.component.ts | 4 - src/app/login/login.component.html | 299 ++-- src/app/login/login.component.scss | 1002 ++++------- src/app/login/login.component.ts | 67 + .../loan-account-table.component.html | 3 +- .../member-groups.component.html | 3 +- .../savings-account-table.component.html | 3 +- .../share-account-table.component.html | 3 +- .../adhoc-query/adhoc-query.component.html | 43 +- .../adhoc-query/adhoc-query.component.scss | 2 + .../create-adhoc-query.component.html | 127 +- .../create-adhoc-query.component.scss | 54 +- .../bulk-import/bulk-import.component.html | 523 ++---- .../bulk-import/bulk-import.component.scss | 209 +++ .../bulk-import/bulk-import.component.ts | 28 +- .../bulk-loan-reassignmnet.component.html | 157 +- .../bulk-loan-reassignmnet.component.scss | 208 ++- .../currencies/currencies.component.html | 66 +- .../currencies/currencies.component.scss | 2 + .../manage-currencies.component.html | 146 +- .../manage-currencies.component.scss | 135 +- .../create-employee.component.html | 118 +- .../create-employee.component.scss | 138 +- .../edit-employee.component.html | 114 +- .../edit-employee.component.scss | 138 +- .../employees/employees.component.html | 62 +- .../employees/employees.component.scss | 2 + .../view-employee.component.html | 133 +- .../view-employee.component.scss | 150 +- .../view-employee/view-employee.component.ts | 4 +- ...te-entity-data-table-checks.component.html | 47 +- ...te-entity-data-table-checks.component.scss | 118 +- .../entity-data-table-checks.component.html | 46 +- .../entity-data-table-checks.component.scss | 2 + .../fund-mapping/fund-mapping.component.html | 662 ++++---- .../fund-mapping/fund-mapping.component.scss | 190 ++- .../create-holiday.component.html | 79 +- .../create-holiday.component.scss | 141 +- .../edit-holiday/edit-holiday.component.html | 112 +- .../edit-holiday/edit-holiday.component.scss | 126 +- .../holidays/holidays.component.html | 63 +- .../holidays/holidays.component.scss | 2 + .../view-holidays.component.html | 112 +- .../view-holidays.component.scss | 136 +- .../view-holidays/view-holidays.component.ts | 4 + .../investors/investors.component.html | 334 ++-- .../investors/investors.component.scss | 411 +++-- .../create-loan-originator.component.html | 45 +- .../create-loan-originator.component.scss | 113 +- .../edit-loan-originator.component.html | 45 +- .../edit-loan-originator.component.scss | 113 +- .../loan-originators.component.html | 47 +- .../loan-originators.component.scss | 2 + .../view-loan-originator.component.html | 87 +- .../view-loan-originator.component.scss | 116 +- ...-loan-provisioning-criteria.component.html | 168 +- ...-loan-provisioning-criteria.component.scss | 169 +- ...-loan-provisioning-criteria.component.html | 174 +- ...-loan-provisioning-criteria.component.scss | 136 ++ .../loan-provisioning-criteria.component.html | 53 +- .../loan-provisioning-criteria.component.scss | 2 + .../create-fund/create-fund.component.html | 39 +- .../create-fund/create-fund.component.scss | 113 +- .../edit-fund/edit-fund.component.html | 39 +- .../edit-fund/edit-fund.component.scss | 113 +- .../manage-funds/manage-funds.component.html | 70 +- .../manage-funds/manage-funds.component.scss | 2 + .../view-fund/view-fund.component.html | 65 +- .../view-fund/view-fund.component.scss | 116 +- .../create-office.component.html | 45 +- .../create-office.component.scss | 126 +- .../edit-office/edit-office.component.html | 45 +- .../edit-office/edit-office.component.scss | 126 +- .../offices/offices.component.html | 307 ++-- .../offices/offices.component.scss | 2 + .../general-tab/general-tab.component.html | 59 +- .../general-tab/general-tab.component.scss | 150 +- .../view-office/view-office.component.html | 86 +- .../view-office/view-office.component.scss | 169 +- .../organization/organization.component.html | 823 ++++------ .../organization/organization.component.scss | 209 +++ .../organization/organization.component.ts | 17 +- .../password-preferences.component.html | 66 +- .../password-preferences.component.scss | 84 +- .../create-payment-type.component.html | 61 +- .../create-payment-type.component.scss | 133 +- .../edit-payment-type.component.html | 67 +- .../edit-payment-type.component.scss | 133 +- .../payment-types.component.html | 98 +- .../payment-types.component.scss | 54 +- .../sms-campaigns.component.html | 43 +- .../sms-campaigns.component.scss | 2 + ...anding-instructions-history.component.html | 99 +- ...anding-instructions-history.component.scss | 182 +- .../allocate-cash.component.html | 59 +- .../allocate-cash.component.scss | 122 ++ .../tellers/cashiers/cashiers.component.html | 106 +- .../tellers/cashiers/cashiers.component.scss | 48 +- .../create-cashier.component.html | 95 +- .../create-cashier.component.scss | 147 +- .../edit-cashier/edit-cashier.component.html | 109 +- .../edit-cashier/edit-cashier.component.scss | 147 +- .../settle-cash/settle-cash.component.html | 59 +- .../settle-cash/settle-cash.component.scss | 122 ++ .../transactions/transactions.component.html | 229 ++- .../transactions/transactions.component.scss | 55 +- .../view-cashier/view-cashier.component.html | 106 +- .../view-cashier/view-cashier.component.scss | 135 +- .../view-cashier/view-cashier.component.ts | 4 +- .../create-teller.component.html | 45 +- .../create-teller.component.scss | 117 +- .../edit-teller/edit-teller.component.html | 45 +- .../edit-teller/edit-teller.component.scss | 117 +- .../tellers/tellers.component.html | 59 +- .../tellers/tellers.component.scss | 16 +- .../view-teller/view-teller.component.html | 106 +- .../view-teller/view-teller.component.scss | 164 +- .../working-days/working-days.component.html | 84 +- .../working-days/working-days.component.scss | 52 +- .../products/charges/charges.component.html | 65 +- .../products/charges/charges.component.scss | 8 +- .../create-charge.component.html | 554 ++++--- .../create-charge.component.scss | 101 +- .../create-charge/create-charge.component.ts | 2 - .../edit-charge/edit-charge.component.html | 488 +++--- .../edit-charge/edit-charge.component.scss | 101 +- .../view-charge/view-charge.component.html | 278 ++-- .../view-charge/view-charge.component.scss | 123 +- .../collaterals/collaterals.component.html | 38 +- .../collaterals/collaterals.component.scss | 24 +- .../create-collateral.component.html | 188 ++- .../create-collateral.component.scss | 75 + .../edit-collateral.component.html | 183 ++- .../edit-collateral.component.scss | 75 + .../view-collateral.component.html | 111 +- .../view-collateral.component.scss | 110 +- .../create-current-product.component.html | 180 +- .../create-current-product.component.scss | 92 +- .../create-current-product.component.ts | 22 +- .../current-products.component.html | 3 +- ...reate-fixed-deposit-product.component.html | 217 ++- ...reate-fixed-deposit-product.component.scss | 32 + .../create-fixed-deposit-product.component.ts | 12 +- ...sit-product-accounting-step.component.html | 401 ++--- ...sit-product-accounting-step.component.scss | 124 +- ...posit-product-accounting-step.component.ts | 5 - ...eposit-product-charges-step.component.html | 133 +- ...eposit-product-charges-step.component.scss | 72 +- ...-deposit-product-charges-step.component.ts | 3 - ...posit-product-currency-step.component.html | 109 +- ...posit-product-currency-step.component.scss | 55 +- ...deposit-product-currency-step.component.ts | 5 - ...eposit-product-details-step.component.html | 79 +- ...eposit-product-details-step.component.scss | 51 +- ...-deposit-product-details-step.component.ts | 7 +- ...ct-interest-rate-chart-step.component.html | 572 +++---- ...ct-interest-rate-chart-step.component.scss | 152 +- ...duct-interest-rate-chart-step.component.ts | 5 - ...eposit-product-preview-step.component.html | 1038 ++++++------ ...eposit-product-preview-step.component.scss | 161 +- ...-deposit-product-preview-step.component.ts | 4 - ...posit-product-settings-step.component.html | 339 ++-- ...posit-product-settings-step.component.scss | 62 +- ...deposit-product-settings-step.component.ts | 9 +- ...-deposit-product-terms-step.component.html | 256 ++- ...-deposit-product-terms-step.component.scss | 52 +- ...ed-deposit-product-terms-step.component.ts | 9 +- .../fixed-deposit-products.component.html | 3 +- .../create-floating-rate.component.html | 312 ++-- .../create-floating-rate.component.scss | 176 +- .../create-floating-rate.component.ts | 2 - .../edit-floating-rate.component.html | 309 ++-- .../edit-floating-rate.component.scss | 178 +- .../edit-floating-rate.component.ts | 2 - ...floating-rate-period-dialog.component.html | 57 +- ...floating-rate-period-dialog.component.scss | 68 + .../floating-rates.component.html | 41 +- .../floating-rates.component.scss | 9 +- .../view-floating-rate.component.html | 89 +- .../view-floating-rate.component.scss | 169 +- .../view-floating-rate.component.ts | 2 - .../create-loan-product.component.html | 299 ++-- .../create-loan-product.component.scss | 86 +- .../create-loan-product.component.ts | 17 +- ...oan-product-accounting-step.component.html | 426 ++--- ...oan-product-accounting-step.component.scss | 124 +- .../loan-product-accounting-step.component.ts | 5 - ...red-income-recognition-step.component.html | 191 ++- ...red-income-recognition-step.component.scss | 47 +- .../loan-product-charges-step.component.html | 311 ++-- .../loan-product-charges-step.component.scss | 82 +- .../loan-product-charges-step.component.ts | 5 - .../loan-product-currency-step.component.html | 177 +- .../loan-product-currency-step.component.scss | 45 +- .../loan-product-currency-step.component.ts | 5 +- .../loan-product-details-step.component.html | 208 +-- .../loan-product-details-step.component.scss | 47 +- .../loan-product-details-step.component.ts | 5 +- ...roduct-interest-refund-step.component.html | 40 +- ...roduct-interest-refund-step.component.scss | 38 +- .../loan-product-preview-step.component.html | 13 - .../loan-product-preview-step.component.ts | 2 - .../loan-product-settings-step.component.html | 1265 +++++++------- .../loan-product-settings-step.component.scss | 97 +- .../loan-product-settings-step.component.ts | 9 +- .../loan-product-terms-step.component.html | 1013 ++++++------ .../loan-product-terms-step.component.scss | 139 +- .../loan-product-terms-step.component.ts | 5 - .../loan-products.component.html | 3 +- .../create-bucket.component.html | 74 +- .../create-bucket.component.scss | 207 ++- .../delinquency-bucket.component.html | 52 +- .../delinquency-bucket.component.scss | 21 +- .../edit-bucket/edit-bucket.component.html | 83 +- .../edit-bucket/edit-bucket.component.scss | 207 ++- .../view-bucket/view-bucket.component.html | 121 +- .../view-bucket/view-bucket.component.scss | 215 ++- .../create-range/create-range.component.html | 105 +- .../create-range/create-range.component.scss | 35 +- .../delinquency-range.component.html | 41 +- .../delinquency-range.component.scss | 19 +- .../edit-range/edit-range.component.html | 99 +- .../edit-range/edit-range.component.scss | 35 +- .../view-range/view-range.component.html | 80 +- .../view-range/view-range.component.scss | 41 +- .../manage-delinquency-buckets.component.html | 71 +- .../manage-delinquency-buckets.component.scss | 35 + .../manage-delinquency-buckets.component.ts | 28 +- .../create-tax-component.component.html | 49 +- .../create-tax-component.component.scss | 126 +- .../edit-tax-component.component.html | 49 +- .../edit-tax-component.component.scss | 126 +- .../manage-tax-components.component.html | 40 +- .../manage-tax-components.component.scss | 8 +- .../view-tax-component.component.html | 129 +- .../view-tax-component.component.scss | 118 +- .../manage-tax-configurations.component.html | 84 +- .../manage-tax-configurations.component.scss | 141 ++ .../manage-tax-configurations.component.ts | 25 +- .../create-tax-group.component.html | 68 +- .../create-tax-group.component.scss | 188 ++- .../edit-tax-group.component.html | 67 +- .../edit-tax-group.component.scss | 190 ++- .../edit-tax-group.component.ts | 1 + .../manage-tax-groups.component.html | 40 +- .../manage-tax-groups.component.scss | 8 +- .../view-tax-group.component.html | 107 +- .../view-tax-group.component.scss | 197 ++- .../create-product-mix.component.html | 55 +- .../create-product-mix.component.scss | 85 +- .../edit-product-mix.component.html | 55 +- .../edit-product-mix.component.scss | 85 +- .../products-mix/products-mix.component.html | 40 +- .../products-mix/products-mix.component.scss | 8 +- .../products-mix/products-mix.component.ts | 2 - .../view-product-mix.component.html | 98 +- .../view-product-mix.component.scss | 168 +- .../view-product-mix.component.ts | 3 - src/app/products/products.component.html | 576 ++----- src/app/products/products.component.scss | 208 ++- src/app/products/products.component.ts | 17 +- ...e-recurring-deposit-product.component.html | 226 +-- ...e-recurring-deposit-product.component.scss | 32 + ...ate-recurring-deposit-product.component.ts | 12 +- ...sit-product-accounting-step.component.html | 288 ++-- ...sit-product-accounting-step.component.scss | 90 +- ...posit-product-accounting-step.component.ts | 5 - ...eposit-product-charges-step.component.html | 131 +- ...eposit-product-charges-step.component.scss | 48 +- ...-deposit-product-charges-step.component.ts | 3 - ...posit-product-currency-step.component.html | 129 +- ...posit-product-currency-step.component.scss | 57 +- ...deposit-product-currency-step.component.ts | 5 - ...eposit-product-details-step.component.html | 117 +- ...eposit-product-details-step.component.scss | 53 +- ...-deposit-product-details-step.component.ts | 7 +- ...ct-interest-rate-chart-step.component.html | 575 +++---- ...ct-interest-rate-chart-step.component.scss | 147 +- ...eposit-product-preview-step.component.html | 893 +++++----- ...eposit-product-preview-step.component.scss | 135 +- ...-deposit-product-preview-step.component.ts | 5 - ...posit-product-settings-step.component.html | 351 ++-- ...posit-product-settings-step.component.scss | 60 +- ...deposit-product-settings-step.component.ts | 9 +- ...-deposit-product-terms-step.component.html | 185 ++- ...-deposit-product-terms-step.component.scss | 62 +- ...ng-deposit-product-terms-step.component.ts | 9 +- .../recurring-deposit-products.component.html | 3 +- .../create-saving-product.component.html | 202 ++- .../create-saving-product.component.scss | 84 +- .../create-saving-product.component.ts | 14 +- .../edit-saving-product.component.html | 207 ++- .../edit-saving-product.component.scss | 83 + .../edit-saving-product.component.ts | 14 +- ...ing-product-accounting-step.component.html | 327 ++-- ...ing-product-accounting-step.component.scss | 144 +- ...aving-product-accounting-step.component.ts | 5 - ...saving-product-charges-step.component.html | 144 +- ...saving-product-charges-step.component.scss | 96 +- .../saving-product-charges-step.component.ts | 3 - ...aving-product-currency-step.component.html | 127 +- ...aving-product-currency-step.component.scss | 61 +- .../saving-product-currency-step.component.ts | 3 - ...saving-product-details-step.component.html | 93 +- ...saving-product-details-step.component.scss | 53 +- .../saving-product-details-step.component.ts | 5 +- ...saving-product-preview-step.component.html | 12 - .../saving-product-preview-step.component.ts | 2 - ...aving-product-settings-step.component.html | 392 +++-- ...aving-product-settings-step.component.scss | 67 +- .../saving-product-settings-step.component.ts | 7 +- .../saving-product-terms-step.component.html | 221 +-- .../saving-product-terms-step.component.scss | 57 +- .../saving-product-terms-step.component.ts | 5 +- .../saving-products.component.html | 3 +- .../create-share-product.component.html | 209 ++- .../create-share-product.component.scss | 47 + .../create-share-product.component.ts | 12 +- .../dividends.component.html | 3 +- ...are-product-accounting-step.component.html | 101 +- ...are-product-accounting-step.component.scss | 72 +- ...share-product-accounting-step.component.ts | 9 +- .../share-product-charges-step.component.html | 131 +- .../share-product-charges-step.component.scss | 76 +- .../share-product-charges-step.component.ts | 3 - ...share-product-currency-step.component.html | 127 +- ...share-product-currency-step.component.scss | 37 +- .../share-product-currency-step.component.ts | 3 - .../share-product-details-step.component.html | 99 +- .../share-product-details-step.component.scss | 31 +- .../share-product-details-step.component.ts | 5 +- ...e-product-market-price-step.component.html | 104 +- ...e-product-market-price-step.component.scss | 79 +- ...are-product-market-price-step.component.ts | 3 - .../share-product-preview-step.component.html | 420 ++--- .../share-product-preview-step.component.scss | 102 +- .../share-product-preview-step.component.ts | 6 - ...share-product-settings-step.component.html | 307 ++-- ...share-product-settings-step.component.scss | 63 +- .../share-product-settings-step.component.ts | 7 +- .../share-product-terms-step.component.html | 173 +- .../share-product-terms-step.component.scss | 31 +- .../share-product-terms-step.component.ts | 5 +- .../share-products.component.html | 3 +- .../view-dividend.component.html | 3 +- src/app/reports/reports.component.html | 131 +- src/app/reports/reports.component.scss | 297 +++- src/app/reports/reports.component.ts | 246 ++- src/app/shared/footer/footer.component.html | 30 +- src/app/shared/footer/footer.component.scss | 77 +- src/app/shared/icons.module.ts | 12 +- src/app/shared/styles/list-layout.scss | 192 ++- src/app/standalone-shared.module.ts | 6 +- .../account-number-preferences.component.html | 3 +- .../audit-trails/audit-trails.component.html | 470 +++--- .../audit-trails/audit-trails.component.scss | 292 +++- .../audit-trails/audit-trails.component.ts | 5 +- .../view-audit/view-audit.component.html | 243 ++- .../view-audit/view-audit.component.scss | 177 +- src/app/system/codes/codes.component.html | 44 +- src/app/system/codes/codes.component.scss | 3 +- .../create-code/create-code.component.html | 46 +- .../create-code/create-code.component.scss | 126 +- .../codes/edit-code/edit-code.component.html | 41 +- .../codes/edit-code/edit-code.component.scss | 126 +- .../codes/view-code/view-code.component.html | 284 ++-- .../codes/view-code/view-code.component.scss | 280 +++- .../global-configurations-tab.component.html | 3 +- .../manage-data-tables.component.html | 3 +- .../manage-external-events.component.html | 3 +- .../manage-hooks/manage-hooks.component.html | 3 +- .../loan-locked/loan-locked.component.html | 3 +- .../manage-scheduler-jobs.component.html | 3 +- .../view-history-scheduler-job.component.html | 3 +- .../manage-reports.component.html | 3 +- .../manage-surveys.component.html | 3 +- .../add-role/add-role.component.html | 41 +- .../add-role/add-role.component.scss | 126 +- .../edit-role/edit-role.component.html | 41 +- .../edit-role/edit-role.component.scss | 126 +- .../roles-and-permissions.component.html | 44 +- .../roles-and-permissions.component.scss | 2 + .../view-role/view-role.component.html | 356 ++-- .../view-role/view-role.component.scss | 672 +++++++- .../view-role/view-role.component.ts | 359 +++- src/app/system/system.component.html | 869 +++------- src/app/system/system.component.scss | 166 +- src/app/system/system.component.ts | 13 +- .../checker-inbox.component.html | 172 +- .../checker-inbox.component.scss | 330 ++-- .../client-approval.component.html | 122 +- .../client-approval.component.scss | 154 +- .../loan-approval.component.html | 142 +- .../loan-approval.component.scss | 154 +- .../loan-disbursal.component.html | 155 +- .../loan-disbursal.component.scss | 141 +- .../reschedule-loan.component.html | 166 +- .../reschedule-loan.component.scss | 150 +- .../checker-inbox-and-tasks.component.html | 90 +- .../checker-inbox-and-tasks.component.scss | 30 +- src/app/templates/templates.component.html | 3 +- .../create-user/create-user.component.html | 208 +-- .../create-user/create-user.component.scss | 146 +- .../users/edit-user/edit-user.component.html | 188 +-- .../users/edit-user/edit-user.component.scss | 138 +- src/app/users/users.component.html | 119 +- src/app/users/users.component.scss | 32 +- .../users/view-user/view-user.component.html | 168 +- .../users/view-user/view-user.component.scss | 390 +++-- .../users/view-user/view-user.component.ts | 8 +- src/app/zitadel/users/users.component.html | 3 +- src/assets/images/cfcfrms.jpg | Bin 0 -> 3482 bytes src/assets/images/npci.jpg | Bin 0 -> 2168 bytes src/assets/images/rbi.jpg | Bin 0 -> 16989 bytes src/assets/translations/en-US.json | 133 +- src/environments/.env.ts | 11 +- 522 files changed, 51991 insertions(+), 21885 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .claude/settings.local.json create mode 100644 backend-schema/ClientAddressIndia.java create mode 100644 backend-schema/ClientAddressIndiaApiResource.java create mode 100644 backend-schema/ClientAddressIndiaRepository.java create mode 100644 backend-schema/ClientEntityKyc.java create mode 100644 backend-schema/ClientEntityKycApiResource.java create mode 100644 backend-schema/ClientEntityKycRepository.java create mode 100644 backend-schema/README.md create mode 100644 backend-schema/V1__client_schema_extensions.sql create mode 100644 postman/CBS_Client_Creation_API.postman_collection.json create mode 100644 src/app/bank-calendar/add-holiday-dialog/add-holiday-dialog.component.html create mode 100644 src/app/bank-calendar/add-holiday-dialog/add-holiday-dialog.component.scss create mode 100644 src/app/bank-calendar/add-holiday-dialog/add-holiday-dialog.component.ts create mode 100644 src/app/bank-calendar/bank-calendar-routing.module.ts create mode 100644 src/app/bank-calendar/bank-calendar.component.html create mode 100644 src/app/bank-calendar/bank-calendar.component.scss create mode 100644 src/app/bank-calendar/bank-calendar.component.ts create mode 100644 src/app/bank-calendar/bank-calendar.module.ts create mode 100644 src/app/clients/client-stepper/client-entity-kyc-step/client-entity-kyc-step.component.html create mode 100644 src/app/clients/client-stepper/client-entity-kyc-step/client-entity-kyc-step.component.scss create mode 100644 src/app/clients/client-stepper/client-entity-kyc-step/client-entity-kyc-step.component.ts create mode 100644 src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html create mode 100644 src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.scss create mode 100644 src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.ts create mode 100644 src/app/clients/client-stepper/client-kyc-step/image-crop-dialog/image-crop-dialog.component.html create mode 100644 src/app/clients/client-stepper/client-kyc-step/image-crop-dialog/image-crop-dialog.component.scss create mode 100644 src/app/clients/client-stepper/client-kyc-step/image-crop-dialog/image-crop-dialog.component.ts create mode 100644 src/app/clients/clients-view/entity-classification-tab/entity-classification-tab.component.html create mode 100644 src/app/clients/clients-view/entity-classification-tab/entity-classification-tab.component.scss create mode 100644 src/app/clients/clients-view/entity-classification-tab/entity-classification-tab.component.ts create mode 100644 src/app/clients/clients-view/nominee-tab/nominee-tab.component.html create mode 100644 src/app/clients/clients-view/nominee-tab/nominee-tab.component.scss create mode 100644 src/app/clients/clients-view/nominee-tab/nominee-tab.component.ts create mode 100644 src/app/products/manage-tax-configurations/manage-tax-configurations.component.scss create mode 100644 src/assets/images/cfcfrms.jpg create mode 100644 src/assets/images/npci.jpg create mode 100644 src/assets/images/rbi.jpg diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..1333fb56d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,29 @@ +{ + "permissions": { + "allow": [ + "Bash(curl -sk -X POST \"https://localhost:8443/fineract-provider/api/v1/clients/2/kyc\" -H \"Content-Type: application/json\" -H \"Fineract-Platform-TenantId: default\" -u \"mifos:password\" -d '{\"\"locale\"\":\"\"en\"\",\"\"kycType\"\":1,\"\"riskCategory\"\":1,\"\"panNumber\"\":\"\"ABCDE1234F\"\",\"\"aadhaarNumberMasked\"\":\"\"XXXXXXXX1234\"\"}')", + "Bash(npm run build)", + "Bash(node -e \"const icons = require\\(''@fortawesome/free-solid-svg-icons''\\); const names = [''faNewspaper'',''faRss'',''faFilter'',''faBullhorn'',''faScroll'',''faGavel'']; names.forEach\\(n => console.log\\(n, !!icons[n]\\)\\)\")", + "Bash(node -e \"const i = require\\(''@fortawesome/free-solid-svg-icons''\\); console.log\\(''newspaper'', typeof i.faNewspaper\\); console.log\\(''rss'', typeof i.faRss\\); console.log\\(''filter'', typeof i.faFilter\\); console.log\\(''bullhorn'', typeof i.faBullhorn\\); console.log\\(''globe'', typeof i.faGlobe\\); console.log\\(''fileAlt'', typeof i.faFileAlt\\);\")", + "Bash(curl -k -s -o /dev/null -w \"%{http_code}\" https://localhost:8443/fineract-provider/api/v1/rbi-feeds)", + "Bash(curl -k -s -H \"Fineract-Platform-TenantId: default\" -H \"Authorization: Basic bWlmb3M6cGFzc3dvcmQ=\" https://localhost:8443/fineract-provider/api/v1/rbi-feeds)", + "Bash(curl -k -s -X POST -H \"Fineract-Platform-TenantId: default\" -H \"Authorization: Basic bWlmb3M6cGFzc3dvcmQ=\" https://localhost:8443/fineract-provider/api/v1/rbi-feeds/fetch)", + "Bash(node -e \"const a = require\\(''./angular.json''\\); console.log\\(JSON.stringify\\(a.projects[''web-app''].architect.build.options.styles, null, 2\\)\\)\")", + "Bash(node -e \"const a = require\\(''./angular.json''\\); const keys = Object.keys\\(a.projects\\); console.log\\(keys\\); const proj = a.projects[keys[0]]; console.log\\(JSON.stringify\\(proj.architect.build.options.styles, null, 2\\)\\)\")", + "Bash(node -e \"const p = require\\(''./package.json''\\); console.log\\(''@angular/material:'', p.dependencies[''@angular/material'']\\); console.log\\(''@angular/core:'', p.dependencies[''@angular/core'']\\)\")", + "Bash(npx prettier:*)", + "Bash(grep -c \"ms$\")", + "Bash(find c:UsersadityMusicCBSUIweb-appsrcappcoreshell -type f \\\\\\(-name *.ts -o -name *.html -o -name *.scss \\\\\\))", + "Bash(while read:*)", + "Bash(do wc:*)", + "Bash(done)", + "Bash(xargs wc:*)", + "Read(//c/Users/adity/.config/Code/User/**)", + "Read(//c/Users/adity/Music/CBS/UI/web-app/$APPDATA/Code/User/**)", + "Read(//c/Users/adity/AppData/Roaming/Code/User/**)" + ], + "additionalDirectories": [ + "C:\\Users\\adity\\AppData\\Roaming\\Code\\User" + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..f56f095f5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,52 @@ +{ + "permissions": { + "allow": [ + "Bash(npx ng build --configuration=development)", + "Bash(npx ng build)", + "Bash(curl -sk -X POST \"https://localhost:8443/fineract-provider/api/v1/clients/2/kyc\" -H \"Content-Type: application/json\" -H \"Fineract-Platform-TenantId: default\" -u \"mifos:password\" -d '{\"\"kycType\"\":1,\"\"riskCategory\"\":1,\"\"panNumber\"\":\"\"ABCDE1234F\"\",\"\"aadhaarNumberMasked\"\":\"\"XXXXXXXX1234\"\",\"\"remarks\"\":\"\"Test KYC\"\"}')", + "Bash(curl -sk -X POST \"https://localhost:8443/fineract-provider/api/v1/clients/2/kyc\" -H \"Content-Type: application/json\" -H \"Fineract-Platform-TenantId: default\" -u \"mifos:password\" -d '{\"\"kycType\"\":1,\"\"riskCategory\"\":1,\"\"panNumber\"\":\"\"ABCDE1234F\"\",\"\"aadhaarNumberMasked\"\":\"\"XXXXXXXX1234\"\",\"\"remarks\"\":\"\"Test KYC\"\",\"\"locale\"\":\"\"en\"\"}')", + "Bash(xargs grep -l \"ClientKycCommandFromApiJsonDeserializer\\\\|initiateClientKyc\")", + "Bash(npx ng build --configuration development)", + "Bash(xargs grep -l \"print\\\\|report\\\\|export\\\\|pdf\")", + "Bash(xargs grep -l \"export.*pdf\\\\|download.*pdf\\\\|print\")", + "Bash(xargs grep -l \"print\\\\|pdf\\\\|export\")", + "Bash(xargs grep -l -i \"nominee\\\\|nomination\\\\|nominate\")", + "Bash(git -C \"c:/Users/adity/Music/CBS/UI/web-app\" log --oneline --all -n 50)", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); console.log\\(''heading:'', !!j.labels?.heading?.[''Fund Details'']\\); console.log\\(''inputs:'', !!j.labels?.inputs?.[''Fund Details'']\\);\")", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading[''Fund Details'']; const i=j.labels.inputs[''Fund Details'']; console.log\\(''heading:'', h\\); console.log\\(''inputs:'', i\\);\")", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const keys=Object.keys\\(j.labels\\); for\\(const k of keys\\){if\\(j.labels[k][''Fund Details'']\\){console.log\\(''Found in:'',k\\)}}\")", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; const keys=Object.keys\\(h\\).filter\\(k=>k.startsWith\\(''Fu''\\)\\); console.log\\(keys\\);\")", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; console.log\\(''Create Loan Originator:'', h[''Create Loan Originator'']\\); console.log\\(''Edit Loan Originator:'', h[''Edit Loan Originator'']\\); console.log\\(''Loan Originator Details:'', h[''Loan Originator Details'']\\); console.log\\(''Loan Originators:'', h[''Loan Originators'']\\);\")", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; console.log\\(''Create Fund:'', h[''Create Fund'']\\); console.log\\(''Edit Fund:'', h[''Edit Fund'']\\); console.log\\(''Manage Funds:'', h[''Manage Funds'']\\);\")", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); console.log\\(''Edit Employee in heading:'', j.labels.heading[''Edit Employee'']\\);\")", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); for\\(const k of Object.keys\\(j.labels\\)\\){if\\(j.labels[k][''Edit Employee'']\\){console.log\\(''Found in:'',k\\)}}\")", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; const edits=Object.keys\\(h\\).filter\\(k=>k.startsWith\\(''Edit''\\)\\); console.log\\(edits\\);\")", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; console.log\\(''Create Fund:'', h[''Create Fund'']\\); console.log\\(''Edit Fund:'', h[''Edit Fund'']\\); console.log\\(''Fund Details:'', h[''Fund Details'']\\); console.log\\(''Create Loan Originator:'', h[''Create Loan Originator'']\\); console.log\\(''Edit Loan Originator:'', h[''Edit Loan Originator'']\\); console.log\\(''Loan Originator Details:'', h[''Loan Originator Details'']\\);\")", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; console.log\\(''Create Teller:'', h[''Create Teller'']\\); console.log\\(''Edit Teller:'', h[''Edit Teller'']\\); console.log\\(''Teller Details:'', h[''Teller Details'']\\); console.log\\(''Tellers:'', h[''Teller / Cashier Management'']\\);\")", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); console.log\\(''Cashiers:'', j.labels.heading[''Cashiers'']\\);\")", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; console.log\\(''Create Cashier:'', h[''Create Cashier'']\\); console.log\\(''Edit Cashier:'', h[''Edit Cashier'']\\); console.log\\(''Cashier Details:'', h[''Cashier Details'']\\); console.log\\(''Cashier Assignment:'', h[''Cashier Assignment'']\\); console.log\\(''Schedule:'', h[''Schedule'']\\);\")", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; console.log\\(''Allocate Cash:'', h[''Allocate Cash'']\\); console.log\\(''Settle Cash:'', h[''Settle Cash'']\\); console.log\\(''Cash Transactions:'', h[''Cash Transactions'']\\); console.log\\(''Transaction Details:'', h[''Transaction Details'']\\); console.log\\(''Cashier Summary:'', h[''Cashier Summary'']\\);\")", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; console.log\\(''Create Payment Type:'', h[''Create Payment Type'']\\); console.log\\(''Edit Payment Type:'', h[''Edit Payment Type'']\\); console.log\\(''Payment Type Details:'', h[''Payment Type Details'']\\); console.log\\(''Payment Type:'', h[''Payment Type'']\\);\")", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); console.log\\(''Products:'', j.labels.heading[''Products'']\\);\")", + "Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; console.log\\(''Create Savings Product:'', h[''Create Savings Product'']\\); console.log\\(''Edit Savings Product:'', h[''Edit Savings Product'']\\);\")", + "Bash(grep -n '\"\"inputs\"\"' src/assets/translations/en-US.json)", + "Bash(for key:*)", + "Bash(do)", + "Bash(if grep:*)", + "Bash(then)", + "Bash(echo \"EXISTS: $key\")", + "Bash(node -e \":*)", + "Bash(npx ng:*)", + "Bash(npm start:*)", + "Bash(node -e \"const j = require\\(''./src/assets/translations/en-US.json''\\); console.log\\(''heading key:'', j.labels?.heading?.[''Create Recurring Deposit Product'']\\); console.log\\(''inputs key:'', j.labels?.inputs?.[''Create Recurring Deposit Product'']\\);\")", + "Bash(node -e \"const fs = require\\(''fs''\\); const t = fs.readFileSync\\(''./src/assets/translations/en-US.json'',''utf8''\\); const j = JSON.parse\\(t\\); const keys = Object.keys\\(j.labels?.heading || {}\\); console.log\\(''heading keys count:'', keys.length\\); console.log\\(''last 5 keys:'', keys.slice\\(-5\\)\\); const cKeys = keys.filter\\(k => k.startsWith\\(''Create''\\)\\); console.log\\(''Create keys:'', cKeys\\);\")", + "Bash(node -e \"const j = require\\(''./src/assets/translations/en-US.json''\\); console.log\\(''heading:'', j.labels?.heading?.[''Create Recurring Deposit Product'']\\); console.log\\(''inputs:'', j.labels?.inputs?.[''Create Recurring Deposit Product'']\\);\")", + "Bash(do node:*)", + "Bash(grep -n '\"\"Tax Group\"\":' src/assets/translations/en-US.json)", + "Bash(node -e \"console.log\\(require\\(''./src/assets/translations/en-US.json''\\).labels?.heading?.[''System'']\\)\")", + "Bash(npm list:*)", + "Bash(find c:UsersadityMusicCBS -type d -name *fineract*)", + "Bash(xargs -I {} basename {})" + ] + } +} diff --git a/backend-schema/ClientAddressIndia.java b/backend-schema/ClientAddressIndia.java new file mode 100644 index 000000000..f09803ed1 --- /dev/null +++ b/backend-schema/ClientAddressIndia.java @@ -0,0 +1,169 @@ +/** + * CBS Nexora — India Address Domain Object + * Package: org.apache.fineract.portfolio.client.domain + */ +package org.apache.fineract.portfolio.client.domain; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "m_client_address_india", uniqueConstraints = { + @UniqueConstraint(columnNames = { "client_id", "address_type" }) +}) +public class ClientAddressIndia { + + public enum AddressType { + PERMANENT, CURRENT, OFFICE, CORRESPONDENCE + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "client_id", nullable = false) + private Client client; + + @Enumerated(EnumType.STRING) + @Column(name = "address_type", nullable = false, length = 20) + private AddressType addressType; + + @Column(name = "address_line_1", length = 500) + private String addressLine1; + + @Column(name = "address_line_2", length = 500) + private String addressLine2; + + @Column(name = "pin_code", length = 6) + private String pinCode; + + @Column(name = "locality", length = 200) + private String locality; + + @Column(name = "city", length = 200) + private String city; + + @Column(name = "district", length = 200) + private String district; + + @Column(name = "state", length = 200) + private String state; + + @Column(name = "country", length = 100) + private String country = "India"; + + @Column(name = "landmark", length = 300) + private String landmark; + + // Permanent-specific + @Column(name = "proof_type", length = 100) + private String proofType; + + @Column(name = "proof_doc_number", length = 100) + private String proofDocNumber; + + // Current-specific + @Column(name = "residence_type", length = 50) + private String residenceType; + + @Column(name = "years_at_address") + private Integer yearsAtAddress; + + @Column(name = "same_as_permanent") + private Boolean sameAsPermanent = false; + + // Office-specific + @Column(name = "org_name", length = 300) + private String orgName; + + @Column(name = "department", length = 200) + private String department; + + @Column(name = "office_phone", length = 20) + private String officePhone; + + @Column(name = "employment_type", length = 100) + private String employmentType; + + @Column(name = "employee_id", length = 50) + private String employeeId; + + @Column(name = "years_with_org") + private Integer yearsWithOrg; + + // Correspondence-specific + @Column(name = "comm_mode", length = 50) + private String commMode; + + @Column(name = "contact_number", length = 20) + private String contactNumber; + + // Audit + @Column(name = "created_date", updatable = false) + private LocalDateTime createdDate = LocalDateTime.now(); + + @Column(name = "last_modified_date") + private LocalDateTime lastModifiedDate = LocalDateTime.now(); + + protected ClientAddressIndia() {} + + public static ClientAddressIndia create(Client client, AddressType type) { + ClientAddressIndia addr = new ClientAddressIndia(); + addr.client = client; + addr.addressType = type; + return addr; + } + + public void update(java.util.Map data) { + if (data.containsKey("addressLine1")) this.addressLine1 = (String) data.get("addressLine1"); + if (data.containsKey("addressLine2")) this.addressLine2 = (String) data.get("addressLine2"); + if (data.containsKey("pinCode")) this.pinCode = (String) data.get("pinCode"); + if (data.containsKey("locality")) this.locality = (String) data.get("locality"); + if (data.containsKey("city")) this.city = (String) data.get("city"); + if (data.containsKey("district")) this.district = (String) data.get("district"); + if (data.containsKey("state")) this.state = (String) data.get("state"); + if (data.containsKey("country")) this.country = (String) data.get("country"); + if (data.containsKey("landmark")) this.landmark = (String) data.get("landmark"); + if (data.containsKey("proofType")) this.proofType = (String) data.get("proofType"); + if (data.containsKey("proofDocNumber")) this.proofDocNumber = (String) data.get("proofDocNumber"); + if (data.containsKey("residenceType")) this.residenceType = (String) data.get("residenceType"); + if (data.containsKey("yearsAtAddress")) this.yearsAtAddress = toInt(data.get("yearsAtAddress")); + if (data.containsKey("sameAsPermanent")) this.sameAsPermanent = (Boolean) data.get("sameAsPermanent"); + if (data.containsKey("orgName")) this.orgName = (String) data.get("orgName"); + if (data.containsKey("department")) this.department = (String) data.get("department"); + if (data.containsKey("officePhone")) this.officePhone = (String) data.get("officePhone"); + if (data.containsKey("employmentType")) this.employmentType = (String) data.get("employmentType"); + if (data.containsKey("employeeId")) this.employeeId = (String) data.get("employeeId"); + if (data.containsKey("yearsWithOrg")) this.yearsWithOrg = toInt(data.get("yearsWithOrg")); + if (data.containsKey("commMode")) this.commMode = (String) data.get("commMode"); + if (data.containsKey("contactNumber")) this.contactNumber = (String) data.get("contactNumber"); + this.lastModifiedDate = LocalDateTime.now(); + } + + private Integer toInt(Object val) { + if (val == null) return null; + if (val instanceof Integer) return (Integer) val; + return Integer.parseInt(val.toString()); + } + + // Getters + public Long getId() { return id; } + public Client getClient() { return client; } + public AddressType getAddressType() { return addressType; } + public String getAddressLine1() { return addressLine1; } + public String getAddressLine2() { return addressLine2; } + public String getPinCode() { return pinCode; } + public String getLocality() { return locality; } + public String getCity() { return city; } + public String getDistrict() { return district; } + public String getState() { return state; } + public String getCountry() { return country; } + public String getLandmark() { return landmark; } + public String getProofType() { return proofType; } + public String getResidenceType() { return residenceType; } + public Boolean getSameAsPermanent() { return sameAsPermanent; } + public String getOrgName() { return orgName; } + public String getEmploymentType() { return employmentType; } + public String getCommMode() { return commMode; } +} diff --git a/backend-schema/ClientAddressIndiaApiResource.java b/backend-schema/ClientAddressIndiaApiResource.java new file mode 100644 index 000000000..74431374a --- /dev/null +++ b/backend-schema/ClientAddressIndiaApiResource.java @@ -0,0 +1,219 @@ +/** + * CBS Nexora — India Address REST Controller + * Package: org.apache.fineract.portfolio.client.api + * + * Endpoints: + * POST /clients/{clientId}/india-address — Save all 4 address types + * GET /clients/{clientId}/india-address — Get all addresses + * GET /clients/{clientId}/india-address/{type} — Get specific type + * PUT /clients/{clientId}/india-address/{type} — Update specific type + * DELETE /clients/{clientId}/india-address — Delete all addresses + */ +package org.apache.fineract.portfolio.client.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.portfolio.client.domain.*; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.*; +import java.util.stream.Collectors; + +@Path("/v1/clients/{clientId}/india-address") +@Component +@Tag(name = "Client India Address", description = "India-specific multi-type address management") +@RequiredArgsConstructor +public class ClientAddressIndiaApiResource { + + private final ClientRepositoryWrapper clientRepository; + private final ClientAddressIndiaRepository addressRepository; + private final FromJsonHelper fromJsonHelper; + + /** + * POST /clients/{clientId}/india-address + * Body: { "permanent": {...}, "current": {...}, "office": {...}, "correspondence": {...} } + */ + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + @Operation(summary = "Save all address types", description = "Create or update Permanent, Current, Office, and Correspondence addresses") + public Response saveAllAddresses( + @PathParam("clientId") Long clientId, + String jsonBody) { + + final Client client = clientRepository.findOneWithNotFoundDetection(clientId); + final JsonObject json = fromJsonHelper.parse(jsonBody).getAsJsonObject(); + + List savedIds = new ArrayList<>(); + + for (ClientAddressIndia.AddressType type : ClientAddressIndia.AddressType.values()) { + String key = type.name().toLowerCase(); + if (json.has(key) && json.get(key).isJsonObject()) { + JsonObject addrJson = json.getAsJsonObject(key); + Map data = toMap(addrJson); + + Optional existing = addressRepository.findByClientIdAndAddressType(clientId, type); + ClientAddressIndia addr; + + if (existing.isPresent()) { + addr = existing.get(); + addr.update(data); + } else { + addr = ClientAddressIndia.create(client, type); + addr.update(data); + } + + addressRepository.saveAndFlush(addr); + savedIds.add(addr.getId()); + } + } + + return Response.ok(Map.of( + "clientId", clientId, + "savedAddressIds", savedIds + )).build(); + } + + /** + * GET /clients/{clientId}/india-address + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get all addresses", description = "Retrieve all 4 address types for a client") + public Response getAllAddresses(@PathParam("clientId") Long clientId) { + clientRepository.findOneWithNotFoundDetection(clientId); + List addresses = addressRepository.findByClientId(clientId); + + Map result = new HashMap<>(); + result.put("clientId", clientId); + + for (ClientAddressIndia addr : addresses) { + result.put(addr.getAddressType().name().toLowerCase(), toResponseMap(addr)); + } + + return Response.ok(result).build(); + } + + /** + * GET /clients/{clientId}/india-address/{type} + */ + @GET + @Path("/{type}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get specific address type") + public Response getAddress( + @PathParam("clientId") Long clientId, + @PathParam("type") String type) { + + clientRepository.findOneWithNotFoundDetection(clientId); + ClientAddressIndia.AddressType addrType = ClientAddressIndia.AddressType.valueOf(type.toUpperCase()); + Optional addr = addressRepository.findByClientIdAndAddressType(clientId, addrType); + + if (addr.isEmpty()) { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "No " + type + " address found for client " + clientId)) + .build(); + } + + return Response.ok(toResponseMap(addr.get())).build(); + } + + /** + * PUT /clients/{clientId}/india-address/{type} + */ + @PUT + @Path("/{type}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + @Operation(summary = "Update specific address type") + public Response updateAddress( + @PathParam("clientId") Long clientId, + @PathParam("type") String type, + String jsonBody) { + + final Client client = clientRepository.findOneWithNotFoundDetection(clientId); + ClientAddressIndia.AddressType addrType = ClientAddressIndia.AddressType.valueOf(type.toUpperCase()); + final JsonObject json = fromJsonHelper.parse(jsonBody).getAsJsonObject(); + final Map data = toMap(json); + + Optional existing = addressRepository.findByClientIdAndAddressType(clientId, addrType); + ClientAddressIndia addr; + + if (existing.isPresent()) { + addr = existing.get(); + addr.update(data); + } else { + addr = ClientAddressIndia.create(client, addrType); + addr.update(data); + } + + addressRepository.saveAndFlush(addr); + + return Response.ok(Map.of( + "resourceId", addr.getId(), + "clientId", clientId, + "addressType", type.toUpperCase() + )).build(); + } + + /** + * DELETE /clients/{clientId}/india-address + */ + @DELETE + @Produces(MediaType.APPLICATION_JSON) + @Transactional + @Operation(summary = "Delete all addresses") + public Response deleteAllAddresses(@PathParam("clientId") Long clientId) { + clientRepository.findOneWithNotFoundDetection(clientId); + addressRepository.deleteByClientId(clientId); + return Response.ok(Map.of("clientId", clientId, "deleted", true)).build(); + } + + private Map toResponseMap(ClientAddressIndia addr) { + Map map = new HashMap<>(); + map.put("id", addr.getId()); + map.put("addressType", addr.getAddressType().name()); + map.put("addressLine1", addr.getAddressLine1()); + map.put("addressLine2", addr.getAddressLine2()); + map.put("pinCode", addr.getPinCode()); + map.put("locality", addr.getLocality()); + map.put("city", addr.getCity()); + map.put("district", addr.getDistrict()); + map.put("state", addr.getState()); + map.put("country", addr.getCountry()); + map.put("landmark", addr.getLandmark()); + map.put("proofType", addr.getProofType()); + map.put("residenceType", addr.getResidenceType()); + map.put("sameAsPermanent", addr.getSameAsPermanent()); + map.put("orgName", addr.getOrgName()); + map.put("employmentType", addr.getEmploymentType()); + map.put("commMode", addr.getCommMode()); + return map; + } + + private Map toMap(JsonObject json) { + Map map = new HashMap<>(); + json.entrySet().forEach(e -> { + if (e.getValue().isJsonNull()) { + map.put(e.getKey(), null); + } else if (e.getValue().isJsonPrimitive()) { + var prim = e.getValue().getAsJsonPrimitive(); + if (prim.isBoolean()) map.put(e.getKey(), prim.getAsBoolean()); + else if (prim.isNumber()) map.put(e.getKey(), prim.getAsInt()); + else map.put(e.getKey(), prim.getAsString()); + } + }); + return map; + } +} diff --git a/backend-schema/ClientAddressIndiaRepository.java b/backend-schema/ClientAddressIndiaRepository.java new file mode 100644 index 000000000..21e3fd228 --- /dev/null +++ b/backend-schema/ClientAddressIndiaRepository.java @@ -0,0 +1,20 @@ +/** + * CBS Nexora — India Address Repository + * Package: org.apache.fineract.portfolio.client.domain + */ +package org.apache.fineract.portfolio.client.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; + +public interface ClientAddressIndiaRepository extends JpaRepository { + + List findByClientId(Long clientId); + + Optional findByClientIdAndAddressType(Long clientId, ClientAddressIndia.AddressType addressType); + + void deleteByClientId(Long clientId); + + boolean existsByClientId(Long clientId); +} diff --git a/backend-schema/ClientEntityKyc.java b/backend-schema/ClientEntityKyc.java new file mode 100644 index 000000000..1bd3db171 --- /dev/null +++ b/backend-schema/ClientEntityKyc.java @@ -0,0 +1,173 @@ +/** + * CBS Nexora — Entity KYC Domain Object + * Package: org.apache.fineract.portfolio.client.domain + */ +package org.apache.fineract.portfolio.client.domain; + +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "m_client_entity_kyc", uniqueConstraints = { + @UniqueConstraint(columnNames = { "client_id" }) +}) +public class ClientEntityKyc { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "client_id", nullable = false) + private Client client; + + @Column(name = "constitution_type", length = 50) + private String constitutionType; + + // ── MSME ── + @Column(name = "is_msme") + private Boolean isMsme = false; + + @Column(name = "udyam_number", length = 19) + private String udyamNumber; + + @Column(name = "enterprise_name", length = 300) + private String enterpriseName; + + @Column(name = "business_sector", length = 50) + private String businessSector; + + @Column(name = "msme_turnover", precision = 18, scale = 2) + private BigDecimal msmeTurnover; + + @Column(name = "msme_investment", precision = 18, scale = 2) + private BigDecimal msmeInvestment; + + @Column(name = "msme_type", length = 20) + private String msmeType; + + // ── Entity KYC Document ── + @Column(name = "entity_kyc_doc_type", length = 100) + private String entityKycDocType; + + @Column(name = "entity_kyc_doc_number", length = 100) + private String entityKycDocNumber; + + @Column(name = "entity_kyc_doc_issue_date") + private LocalDate entityKycDocIssueDate; + + @Column(name = "entity_kyc_doc_expiry_date") + private LocalDate entityKycDocExpiryDate; + + // ── Authorized Signatory ── + @Column(name = "signatory_name", length = 200) + private String signatoryName; + + @Column(name = "signatory_designation", length = 100) + private String signatoryDesignation; + + @Column(name = "signatory_pan", length = 10) + private String signatoryPan; + + @Column(name = "signatory_aadhaar_masked", length = 12) + private String signatoryAadhaarMasked; + + @Column(name = "signatory_kyc_doc_type", length = 100) + private String signatoryKycDocType; + + @Column(name = "signatory_kyc_doc_number", length = 100) + private String signatoryKycDocNumber; + + // ── Authorization Document ── + @Column(name = "authorization_doc_type", length = 100) + private String authorizationDocType; + + @Column(name = "authorization_doc_number", length = 100) + private String authorizationDocNumber; + + @Column(name = "authorization_date") + private LocalDate authorizationDate; + + // ── Risk ── + @Column(name = "entity_risk_category") + private Integer entityRiskCategory = 1; + + @Column(name = "entity_remarks", columnDefinition = "TEXT") + private String entityRemarks; + + // ── Audit ── + @Column(name = "created_date", updatable = false) + private LocalDateTime createdDate = LocalDateTime.now(); + + @Column(name = "last_modified_date") + private LocalDateTime lastModifiedDate = LocalDateTime.now(); + + protected ClientEntityKyc() {} + + public static ClientEntityKyc create(Client client, String constitutionType) { + ClientEntityKyc kyc = new ClientEntityKyc(); + kyc.client = client; + kyc.constitutionType = constitutionType; + return kyc; + } + + public void update(java.util.Map data) { + if (data.containsKey("constitutionType")) this.constitutionType = (String) data.get("constitutionType"); + if (data.containsKey("isMsme")) this.isMsme = (Boolean) data.get("isMsme"); + if (data.containsKey("udyamNumber")) this.udyamNumber = (String) data.get("udyamNumber"); + if (data.containsKey("enterpriseName")) this.enterpriseName = (String) data.get("enterpriseName"); + if (data.containsKey("businessSector")) this.businessSector = (String) data.get("businessSector"); + if (data.containsKey("msmeTurnover")) this.msmeTurnover = toBigDecimal(data.get("msmeTurnover")); + if (data.containsKey("msmeInvestment")) this.msmeInvestment = toBigDecimal(data.get("msmeInvestment")); + if (data.containsKey("msmeType")) this.msmeType = (String) data.get("msmeType"); + if (data.containsKey("entityKycDocType")) this.entityKycDocType = (String) data.get("entityKycDocType"); + if (data.containsKey("entityKycDocNumber")) this.entityKycDocNumber = (String) data.get("entityKycDocNumber"); + if (data.containsKey("signatoryName")) this.signatoryName = (String) data.get("signatoryName"); + if (data.containsKey("signatoryDesignation")) this.signatoryDesignation = (String) data.get("signatoryDesignation"); + if (data.containsKey("signatoryPan")) this.signatoryPan = (String) data.get("signatoryPan"); + if (data.containsKey("signatoryAadhaar")) { + String raw = (String) data.get("signatoryAadhaar"); + this.signatoryAadhaarMasked = raw != null && raw.length() == 12 ? "XXXXXXXX" + raw.substring(8) : raw; + } + if (data.containsKey("signatoryKycDocType")) this.signatoryKycDocType = (String) data.get("signatoryKycDocType"); + if (data.containsKey("signatoryKycDocNumber")) this.signatoryKycDocNumber = (String) data.get("signatoryKycDocNumber"); + if (data.containsKey("authorizationDocType")) this.authorizationDocType = (String) data.get("authorizationDocType"); + if (data.containsKey("authorizationDocNumber")) this.authorizationDocNumber = (String) data.get("authorizationDocNumber"); + if (data.containsKey("entityRiskCategory")) this.entityRiskCategory = (Integer) data.get("entityRiskCategory"); + if (data.containsKey("entityRemarks")) this.entityRemarks = (String) data.get("entityRemarks"); + this.lastModifiedDate = LocalDateTime.now(); + } + + private BigDecimal toBigDecimal(Object val) { + if (val == null) return null; + if (val instanceof BigDecimal) return (BigDecimal) val; + return new BigDecimal(val.toString()); + } + + // Getters + public Long getId() { return id; } + public Client getClient() { return client; } + public String getConstitutionType() { return constitutionType; } + public Boolean getIsMsme() { return isMsme; } + public String getUdyamNumber() { return udyamNumber; } + public String getEnterpriseName() { return enterpriseName; } + public String getBusinessSector() { return businessSector; } + public BigDecimal getMsmeTurnover() { return msmeTurnover; } + public BigDecimal getMsmeInvestment() { return msmeInvestment; } + public String getMsmeType() { return msmeType; } + public String getEntityKycDocType() { return entityKycDocType; } + public String getEntityKycDocNumber() { return entityKycDocNumber; } + public LocalDate getEntityKycDocIssueDate() { return entityKycDocIssueDate; } + public LocalDate getEntityKycDocExpiryDate() { return entityKycDocExpiryDate; } + public String getSignatoryName() { return signatoryName; } + public String getSignatoryDesignation() { return signatoryDesignation; } + public String getSignatoryPan() { return signatoryPan; } + public String getSignatoryAadhaarMasked() { return signatoryAadhaarMasked; } + public String getAuthorizationDocType() { return authorizationDocType; } + public String getAuthorizationDocNumber() { return authorizationDocNumber; } + public LocalDate getAuthorizationDate() { return authorizationDate; } + public Integer getEntityRiskCategory() { return entityRiskCategory; } + public String getEntityRemarks() { return entityRemarks; } +} diff --git a/backend-schema/ClientEntityKycApiResource.java b/backend-schema/ClientEntityKycApiResource.java new file mode 100644 index 000000000..e0eb79f68 --- /dev/null +++ b/backend-schema/ClientEntityKycApiResource.java @@ -0,0 +1,140 @@ +/** + * CBS Nexora — Entity KYC REST Controller + * Package: org.apache.fineract.portfolio.client.api + * + * Endpoints: + * POST /clients/{clientId}/entity-kyc — Create or update entity KYC + MSME + * GET /clients/{clientId}/entity-kyc — Retrieve entity KYC data + * DELETE /clients/{clientId}/entity-kyc — Delete entity KYC data + */ +package org.apache.fineract.portfolio.client.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.portfolio.client.domain.*; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import com.google.gson.JsonObject; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Path("/v1/clients/{clientId}/entity-kyc") +@Component +@Tag(name = "Client Entity KYC", description = "Non-Individual client MSME & KYC management") +@RequiredArgsConstructor +public class ClientEntityKycApiResource { + + private final ClientRepositoryWrapper clientRepository; + private final ClientEntityKycRepository entityKycRepository; + private final FromJsonHelper fromJsonHelper; + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + @Operation(summary = "Create or Update Entity KYC", description = "Save MSME details, entity KYC document, signatory KYC, authorization document, and risk assessment") + public Response saveEntityKyc( + @PathParam("clientId") Long clientId, + String jsonBody) { + + final Client client = clientRepository.findOneWithNotFoundDetection(clientId); + final JsonObject json = fromJsonHelper.parse(jsonBody).getAsJsonObject(); + final Map data = toMap(json); + + Optional existing = entityKycRepository.findByClientId(clientId); + ClientEntityKyc entityKyc; + + if (existing.isPresent()) { + entityKyc = existing.get(); + entityKyc.update(data); + } else { + String constitutionType = json.has("constitutionType") ? json.get("constitutionType").getAsString() : null; + entityKyc = ClientEntityKyc.create(client, constitutionType); + entityKyc.update(data); + } + + entityKycRepository.saveAndFlush(entityKyc); + + return Response.ok(Map.of( + "resourceId", entityKyc.getId(), + "clientId", clientId + )).build(); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Retrieve Entity KYC", description = "Get MSME, signatory, authorization, and risk data for a non-individual client") + public Response getEntityKyc(@PathParam("clientId") Long clientId) { + clientRepository.findOneWithNotFoundDetection(clientId); + Optional entityKyc = entityKycRepository.findByClientId(clientId); + + if (entityKyc.isEmpty()) { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "No entity KYC found for client " + clientId)) + .build(); + } + + ClientEntityKyc kyc = entityKyc.get(); + Map result = new HashMap<>(); + result.put("id", kyc.getId()); + result.put("clientId", clientId); + result.put("constitutionType", kyc.getConstitutionType()); + result.put("isMsme", kyc.getIsMsme()); + result.put("udyamNumber", kyc.getUdyamNumber()); + result.put("enterpriseName", kyc.getEnterpriseName()); + result.put("businessSector", kyc.getBusinessSector()); + result.put("msmeTurnover", kyc.getMsmeTurnover()); + result.put("msmeInvestment", kyc.getMsmeInvestment()); + result.put("msmeType", kyc.getMsmeType()); + result.put("entityKycDocType", kyc.getEntityKycDocType()); + result.put("entityKycDocNumber", kyc.getEntityKycDocNumber()); + result.put("entityKycDocIssueDate", kyc.getEntityKycDocIssueDate()); + result.put("entityKycDocExpiryDate", kyc.getEntityKycDocExpiryDate()); + result.put("signatoryName", kyc.getSignatoryName()); + result.put("signatoryDesignation", kyc.getSignatoryDesignation()); + result.put("signatoryPan", kyc.getSignatoryPan()); + result.put("signatoryAadhaarMasked", kyc.getSignatoryAadhaarMasked()); + result.put("authorizationDocType", kyc.getAuthorizationDocType()); + result.put("authorizationDocNumber", kyc.getAuthorizationDocNumber()); + result.put("authorizationDate", kyc.getAuthorizationDate()); + result.put("entityRiskCategory", kyc.getEntityRiskCategory()); + result.put("entityRemarks", kyc.getEntityRemarks()); + + return Response.ok(result).build(); + } + + @DELETE + @Produces(MediaType.APPLICATION_JSON) + @Transactional + @Operation(summary = "Delete Entity KYC") + public Response deleteEntityKyc(@PathParam("clientId") Long clientId) { + clientRepository.findOneWithNotFoundDetection(clientId); + entityKycRepository.deleteByClientId(clientId); + return Response.ok(Map.of("clientId", clientId, "deleted", true)).build(); + } + + private Map toMap(JsonObject json) { + Map map = new HashMap<>(); + json.entrySet().forEach(e -> { + if (e.getValue().isJsonNull()) { + map.put(e.getKey(), null); + } else if (e.getValue().isJsonPrimitive()) { + var prim = e.getValue().getAsJsonPrimitive(); + if (prim.isBoolean()) map.put(e.getKey(), prim.getAsBoolean()); + else if (prim.isNumber()) { + if (prim.getAsString().contains(".")) map.put(e.getKey(), prim.getAsBigDecimal()); + else map.put(e.getKey(), prim.getAsInt()); + } + else map.put(e.getKey(), prim.getAsString()); + } + }); + return map; + } +} diff --git a/backend-schema/ClientEntityKycRepository.java b/backend-schema/ClientEntityKycRepository.java new file mode 100644 index 000000000..d4d69a8de --- /dev/null +++ b/backend-schema/ClientEntityKycRepository.java @@ -0,0 +1,17 @@ +/** + * CBS Nexora — Entity KYC Repository + * Package: org.apache.fineract.portfolio.client.domain + */ +package org.apache.fineract.portfolio.client.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface ClientEntityKycRepository extends JpaRepository { + + Optional findByClientId(Long clientId); + + boolean existsByClientId(Long clientId); + + void deleteByClientId(Long clientId); +} diff --git a/backend-schema/README.md b/backend-schema/README.md new file mode 100644 index 000000000..009b04974 --- /dev/null +++ b/backend-schema/README.md @@ -0,0 +1,75 @@ +# CBS Nexora — Backend Schema & API Changes + +## Files in this directory + +| File | Purpose | +|------|---------| +| `V1__client_schema_extensions.sql` | DB migration — run on Fineract MySQL/MariaDB | +| `ClientEntityKyc.java` | JPA Entity for `m_client_entity_kyc` table | +| `ClientAddressIndia.java` | JPA Entity for `m_client_address_india` table | +| `ClientEntityKycRepository.java` | Spring Data repository | +| `ClientAddressIndiaRepository.java` | Spring Data repository | +| `ClientEntityKycApiResource.java` | REST controller — `/clients/{id}/entity-kyc` | +| `ClientAddressIndiaApiResource.java` | REST controller — `/clients/{id}/india-address` | + +## How to apply + +### Step 1: Run SQL Migration +```bash +mysql -u root -p fineract_tenantdb < V1__client_schema_extensions.sql +``` + +### Step 2: Copy Java files to Fineract source +``` +ClientEntityKyc.java → fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ +ClientAddressIndia.java → fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ +ClientEntityKycRepository.java → fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ +ClientAddressIndiaRepository.java → fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ +ClientEntityKycApiResource.java → fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ +ClientAddressIndiaApiResource.java → fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ +``` + +### Step 3: Update existing Fineract Client API +In `ClientApiResource.java`, the `POST /clients` endpoint needs to accept these additional fields in the request body: +- `nationality`, `annualTurnover`, `creditLimit` +- `isMinor`, `ageYears`, `ageMonths`, `ageDays` +- `guardianName`, `guardianRelation`, `guardianMobile`, `guardianIdNumber`, `guardianAddress` + +In `ClientWritePlatformServiceJpaRepositoryImpl.java`, map these fields to the `m_client` entity during create/update. + +### Step 4: Register new API resources +Add to `fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/FineractWebMvcConfigurer.java` or equivalent Spring config. + +### Step 5: Rebuild Fineract +```bash +./gradlew bootRun +``` + +## API Reference + +### Entity KYC +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/clients/{clientId}/entity-kyc` | Create/update entity KYC + MSME | +| GET | `/clients/{clientId}/entity-kyc` | Retrieve entity KYC | +| DELETE | `/clients/{clientId}/entity-kyc` | Delete entity KYC | + +### India Address +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/clients/{clientId}/india-address` | Save all 4 address types | +| GET | `/clients/{clientId}/india-address` | Get all addresses | +| GET | `/clients/{clientId}/india-address/{type}` | Get specific type (PERMANENT/CURRENT/OFFICE/CORRESPONDENCE) | +| PUT | `/clients/{clientId}/india-address/{type}` | Update specific type | +| DELETE | `/clients/{clientId}/india-address` | Delete all addresses | + +## Tables Summary + +| Table | New/Modified | Records | +|-------|-------------|---------| +| `m_client` | Modified — 12 new columns | Guardian, nationality, minor, turnover | +| `m_client_non_person` | Modified — 13 new columns | MSME, CIN, enterprise PAN, signatory | +| `m_client_kyc` | Modified — 19 new columns | AML, freeze, form60/61, form15G/H, KYC doc | +| `m_client_entity_kyc` | **New** | Entity-specific KYC, MSME, signatory, auth doc | +| `m_client_address_india` | **New** | 4-type India address with PIN lookup fields | +| `m_code_value` | Modified | Added Gender: Transgender, Other | diff --git a/backend-schema/V1__client_schema_extensions.sql b/backend-schema/V1__client_schema_extensions.sql new file mode 100644 index 000000000..69ecd6bd8 --- /dev/null +++ b/backend-schema/V1__client_schema_extensions.sql @@ -0,0 +1,149 @@ +-- ============================================================ +-- CBS Nexora — Client Schema Extensions +-- Fineract DB Migration Script +-- Run this AFTER backing up your database +-- ============================================================ + +-- ─── 1. m_client — New columns ─── +ALTER TABLE m_client ADD COLUMN IF NOT EXISTS nationality VARCHAR(20) DEFAULT 'INDIAN'; +ALTER TABLE m_client ADD COLUMN IF NOT EXISTS annual_turnover DECIMAL(18,2); +ALTER TABLE m_client ADD COLUMN IF NOT EXISTS credit_limit DECIMAL(18,2); +ALTER TABLE m_client ADD COLUMN IF NOT EXISTS is_minor TINYINT(1) DEFAULT 0; +ALTER TABLE m_client ADD COLUMN IF NOT EXISTS age_years INT; +ALTER TABLE m_client ADD COLUMN IF NOT EXISTS age_months INT; +ALTER TABLE m_client ADD COLUMN IF NOT EXISTS age_days INT; +ALTER TABLE m_client ADD COLUMN IF NOT EXISTS guardian_name VARCHAR(200); +ALTER TABLE m_client ADD COLUMN IF NOT EXISTS guardian_relation VARCHAR(50); +ALTER TABLE m_client ADD COLUMN IF NOT EXISTS guardian_mobile VARCHAR(15); +ALTER TABLE m_client ADD COLUMN IF NOT EXISTS guardian_id_number VARCHAR(50); +ALTER TABLE m_client ADD COLUMN IF NOT EXISTS guardian_address TEXT; + +-- ─── 2. m_client_non_person — New columns ─── +ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS date_of_registration DATE; +ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS enterprise_pan VARCHAR(10); +ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS cin_number VARCHAR(21); +ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS authorized_signatory VARCHAR(200); +ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS additional_info VARCHAR(500); +ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS credit_limit DECIMAL(18,2); +ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS is_msme TINYINT(1) DEFAULT 0; +ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS udyam_number VARCHAR(19); +ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS enterprise_name VARCHAR(300); +ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS business_sector VARCHAR(50); +ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS msme_turnover DECIMAL(18,2); +ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS msme_investment DECIMAL(18,2); +ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS msme_type VARCHAR(20); + +-- ─── 3. m_client_kyc — New columns ─── +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS has_pan TINYINT(1) DEFAULT 1; +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS pan_aadhaar_link_status VARCHAR(20); +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS kyc_document_name VARCHAR(100); +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS kyc_document_number VARCHAR(100); +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS kyc_doc_issue_date DATE; +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS kyc_doc_expiry_date DATE; +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS kyc_expiry_date DATE; +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS aml_check_status VARCHAR(20) DEFAULT 'CLEAR'; +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS banned_entity_match VARCHAR(20) DEFAULT 'NO'; +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS willful_defaulter VARCHAR(5) DEFAULT 'NO'; +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS ecgc_sal_flag VARCHAR(5) DEFAULT 'NO'; +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS dormant_flag VARCHAR(20) DEFAULT 'ACTIVE'; +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS account_status VARCHAR(20) DEFAULT 'ACTIVE'; +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS freeze_reason VARCHAR(50); +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS freeze_authority VARCHAR(50); +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS form60_type VARCHAR(10); +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS form60_date DATE; +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS form15_applicable TINYINT(1) DEFAULT 0; +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS form15_type VARCHAR(5); +ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS form15_fy VARCHAR(10); + +-- ─── 4. m_client_entity_kyc — New table ─── +CREATE TABLE IF NOT EXISTS m_client_entity_kyc ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + client_id BIGINT NOT NULL, + constitution_type VARCHAR(50), + -- MSME + is_msme TINYINT(1) DEFAULT 0, + udyam_number VARCHAR(19), + enterprise_name VARCHAR(300), + business_sector VARCHAR(50), + msme_turnover DECIMAL(18,2), + msme_investment DECIMAL(18,2), + msme_type VARCHAR(20), + -- Entity KYC Document + entity_kyc_doc_type VARCHAR(100), + entity_kyc_doc_number VARCHAR(100), + entity_kyc_doc_issue_date DATE, + entity_kyc_doc_expiry_date DATE, + -- Authorized Signatory + signatory_name VARCHAR(200), + signatory_designation VARCHAR(100), + signatory_pan VARCHAR(10), + signatory_aadhaar_masked VARCHAR(12), + signatory_kyc_doc_type VARCHAR(100), + signatory_kyc_doc_number VARCHAR(100), + -- Authorization Document + authorization_doc_type VARCHAR(100), + authorization_doc_number VARCHAR(100), + authorization_date DATE, + -- Risk + entity_risk_category INT DEFAULT 1, + entity_remarks TEXT, + -- Audit + created_date DATETIME DEFAULT CURRENT_TIMESTAMP, + last_modified_date DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (client_id) REFERENCES m_client(id) ON DELETE CASCADE, + UNIQUE KEY uk_client_entity_kyc (client_id) +); + +-- ─── 5. m_client_address_india — New table ─── +CREATE TABLE IF NOT EXISTS m_client_address_india ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + client_id BIGINT NOT NULL, + address_type ENUM('PERMANENT','CURRENT','OFFICE','CORRESPONDENCE') NOT NULL, + address_line_1 VARCHAR(500), + address_line_2 VARCHAR(500), + pin_code VARCHAR(6), + locality VARCHAR(200), + city VARCHAR(200), + district VARCHAR(200), + state VARCHAR(200), + country VARCHAR(100) DEFAULT 'India', + landmark VARCHAR(300), + -- Permanent-specific + proof_type VARCHAR(100), + proof_doc_number VARCHAR(100), + -- Current-specific + residence_type VARCHAR(50), + years_at_address INT, + same_as_permanent TINYINT(1) DEFAULT 0, + -- Office-specific + org_name VARCHAR(300), + department VARCHAR(200), + office_phone VARCHAR(20), + employment_type VARCHAR(100), + employee_id VARCHAR(50), + years_with_org INT, + -- Correspondence-specific + comm_mode VARCHAR(50), + contact_number VARCHAR(20), + -- Audit + created_date DATETIME DEFAULT CURRENT_TIMESTAMP, + last_modified_date DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (client_id) REFERENCES m_client(id) ON DELETE CASCADE, + UNIQUE KEY uk_client_address_type (client_id, address_type) +); + +-- ─── 6. Constitution Type code values (if not using API-driven options) ─── +-- Find Gender code and add missing values +SET @gender_code_id = (SELECT id FROM m_code WHERE code_name = 'Gender' LIMIT 1); +INSERT IGNORE INTO m_code_value (code_id, code_value, order_position, is_active) +VALUES + (@gender_code_id, 'Transgender', 3, 1), + (@gender_code_id, 'Other', 4, 1); + +-- ─── 7. Indexes for performance ─── +CREATE INDEX IF NOT EXISTS idx_client_entity_kyc_client ON m_client_entity_kyc(client_id); +CREATE INDEX IF NOT EXISTS idx_client_address_india_client ON m_client_address_india(client_id); +CREATE INDEX IF NOT EXISTS idx_client_address_india_type ON m_client_address_india(client_id, address_type); +CREATE INDEX IF NOT EXISTS idx_client_kyc_expiry ON m_client_kyc(kyc_expiry_date); +CREATE INDEX IF NOT EXISTS idx_client_kyc_aml ON m_client_kyc(aml_check_status); +CREATE INDEX IF NOT EXISTS idx_client_minor ON m_client(is_minor); diff --git a/postman/CBS_Client_Creation_API.postman_collection.json b/postman/CBS_Client_Creation_API.postman_collection.json new file mode 100644 index 000000000..a2bcf7480 --- /dev/null +++ b/postman/CBS_Client_Creation_API.postman_collection.json @@ -0,0 +1,430 @@ +{ + "info": { + "name": "CBS - Client Creation API", + "description": "Complete client creation flow — General, KYC, Address, Family Members", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{username}}", + "type": "string" + }, + { + "key": "password", + "value": "{{password}}", + "type": "string" + } + ] + }, + "variable": [ + { + "key": "baseUrl", + "value": "https://localhost:8443/fineract-provider/api/v1" + }, + { + "key": "tenantId", + "value": "default" + }, + { + "key": "username", + "value": "mifos" + }, + { + "key": "password", + "value": "password" + }, + { + "key": "clientId", + "value": "" + } + ], + "item": [ + { + "name": "1. General — Create Client", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if (jsonData.resourceId) {", + " pm.collectionVariables.set('clientId', jsonData.resourceId);", + " console.log('Client ID set to: ' + jsonData.resourceId);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"officeId\": 1,\n \"legalFormId\": 1,\n \"nationalityId\": \"INDIAN\",\n \"isStaff\": false,\n \"active\": false,\n \"salutationId\": 2,\n \"firstname\": \"Aditya\",\n \"middlename\": \"\",\n \"lastname\": \"Vyas\",\n \"genderId\": 22,\n \"dateOfBirth\": \"15 January 1995\",\n \"mobileNo\": \"9876543210\",\n \"alternateMobileNo\": \"9123456780\",\n \"emailAddress\": \"aditya@example.com\",\n \"maritalStatusId\": 101,\n \"occupationCategoryId\": 50,\n \"occupationId\": 60,\n \"monthlyIncome\": 50000,\n \"submittedOnDate\": \"01 April 2026\",\n \"dipVerified\": true,\n \"alternateDipVerified\": false,\n \"dateFormat\": \"dd MMMM yyyy\",\n \"locale\": \"en\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/clients", + "host": ["{{baseUrl}}"], + "path": ["clients"] + }, + "description": "Creates the client record. Response returns `resourceId` which is auto-set as `clientId` for subsequent requests." + } + }, + { + "name": "1b. General — Create Non-Individual Client", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if (jsonData.resourceId) {", + " pm.collectionVariables.set('clientId', jsonData.resourceId);", + " console.log('Client ID set to: ' + jsonData.resourceId);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"officeId\": 1,\n \"legalFormId\": 2,\n \"nationalityId\": \"INDIAN\",\n \"isStaff\": false,\n \"active\": false,\n \"fullname\": \"ABC Enterprises Pvt Ltd\",\n \"mobileNo\": \"9876543210\",\n \"alternateMobileNo\": \"\",\n \"emailAddress\": \"info@abc.com\",\n \"monthlyIncome\": 200000,\n \"submittedOnDate\": \"01 April 2026\",\n \"dipVerified\": true,\n \"alternateDipVerified\": false,\n \"clientNonPersonDetails\": {\n \"constitutionId\": 10,\n \"mainBusinessLineId\": 5,\n \"incorpNumber\": \"U12345MH2020PTC123456\",\n \"remarks\": \"\",\n \"authorizedSignatory\": \"Mr. Vyas\",\n \"additionalInfo\": \"\",\n \"monthlyIncome\": 200000,\n \"dateFormat\": \"dd MMMM yyyy\",\n \"locale\": \"en\"\n },\n \"dateFormat\": \"dd MMMM yyyy\",\n \"locale\": \"en\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/clients", + "host": ["{{baseUrl}}"], + "path": ["clients"] + }, + "description": "Creates a Non-Individual (Entity) client. Response returns `resourceId` which is auto-set as `clientId`." + } + }, + { + "name": "2. KYC — Save Client KYC", + "request": { + "method": "POST", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"locale\": \"en\",\n \"dateFormat\": \"dd MMMM yyyy\",\n \"kycType\": 1,\n \"riskCategory\": 1,\n \"panNumber\": \"ABCDE1234F\",\n \"aadhaarNumberMasked\": \"XXXXXXXX1234\",\n \"panAadhaarSeedingStatus\": \"LINKED\",\n \"gstinNumber\": null,\n \"enterprisePan\": null,\n \"cinNumber\": null,\n \"incorporationDate\": null,\n \"kycDocumentName\": \"Aadhaar Card\",\n \"kycDocumentNumber\": \"1234-5678-9012\",\n \"kycDocIssueDate\": \"15 January 2020\",\n \"kycDocExpiryDate\": null,\n \"amlCheckStatus\": \"CLEAR\",\n \"bannedEntityMatch\": \"NO\",\n \"willfulDefaulter\": \"NO\",\n \"ecgcSalFlag\": \"NO\",\n \"dormantFlag\": \"ACTIVE\",\n \"accountStatus\": \"ACTIVE\",\n \"freezeReason\": null,\n \"freezeAuthority\": null,\n \"form60Type\": \"FORM_60\",\n \"form60Date\": null,\n \"form15Applicable\": false,\n \"form15Type\": \"15G\",\n \"form15FY\": null,\n \"remarks\": null\n}" + }, + "url": { + "raw": "{{baseUrl}}/clients/{{clientId}}/kyc", + "host": ["{{baseUrl}}"], + "path": ["clients", "{{clientId}}", "kyc"] + }, + "description": "Saves KYC details including PAN, Aadhaar, AML compliance, document info, risk category, and Form 60/15 data." + } + }, + { + "name": "2b. KYC — Get Client KYC", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}" + } + ], + "url": { + "raw": "{{baseUrl}}/clients/{{clientId}}/kyc", + "host": ["{{baseUrl}}"], + "path": ["clients", "{{clientId}}", "kyc"] + }, + "description": "Fetches existing KYC data for a client." + } + }, + { + "name": "2c. KYC — Upload Profile Photo", + "request": { + "method": "POST", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "src": "", + "description": "Select client photo (JPG/PNG)" + }, + { + "key": "filename", + "value": "file", + "type": "text" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/clients/{{clientId}}/images", + "host": ["{{baseUrl}}"], + "path": ["clients", "{{clientId}}", "images"] + }, + "description": "Uploads client profile photograph." + } + }, + { + "name": "2d. KYC — Upload Signature", + "request": { + "method": "POST", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "src": "", + "description": "Select signature image (JPG/PNG)" + }, + { + "key": "name", + "value": "clientSignature", + "type": "text" + }, + { + "key": "description", + "value": "Client signature", + "type": "text" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/clients/{{clientId}}/documents", + "host": ["{{baseUrl}}"], + "path": ["clients", "{{clientId}}", "documents"] + }, + "description": "Uploads client signature as a document." + } + }, + { + "name": "3. Address — Save Permanent Address", + "request": { + "method": "POST", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"addressLine1\": \"123, MG Road\",\n \"addressLine2\": \"Near City Mall\",\n \"addressLine3\": \"\",\n \"townVillage\": \"Vastrapur\",\n \"postalCode\": \"380015\",\n \"city\": \"Ahmedabad\",\n \"countyDistrict\": \"Ahmedabad\",\n \"street\": \"Opp. ISRO\",\n \"addressType\": 101,\n \"isActive\": true,\n \"stateProvinceId\": 12\n}" + }, + "url": { + "raw": "{{baseUrl}}/client/{{clientId}}/addresses?type=101", + "host": ["{{baseUrl}}"], + "path": ["client", "{{clientId}}", "addresses"], + "query": [ + { + "key": "type", + "value": "101", + "description": "Address type ID (get from address template)" + } + ] + }, + "description": "Creates a permanent address. Change `type` query param and `addressType` body field for other types (current, office, correspondence)." + } + }, + { + "name": "3b. Address — Save Current Address", + "request": { + "method": "POST", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"addressLine1\": \"456, SG Highway\",\n \"addressLine2\": \"Flat 302, Tower B\",\n \"addressLine3\": \"\",\n \"townVillage\": \"Bodakdev\",\n \"postalCode\": \"380054\",\n \"city\": \"Ahmedabad\",\n \"countyDistrict\": \"Ahmedabad\",\n \"street\": \"Near Honest Restaurant\",\n \"addressType\": 102,\n \"isActive\": false,\n \"stateProvinceId\": 12\n}" + }, + "url": { + "raw": "{{baseUrl}}/client/{{clientId}}/addresses?type=102", + "host": ["{{baseUrl}}"], + "path": ["client", "{{clientId}}", "addresses"], + "query": [ + { + "key": "type", + "value": "102", + "description": "Current/Residential address type ID" + } + ] + }, + "description": "Creates a current/residential address." + } + }, + { + "name": "3c. Address — Get Address Template", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}" + } + ], + "url": { + "raw": "{{baseUrl}}/client/addresses/template", + "host": ["{{baseUrl}}"], + "path": ["client", "addresses", "template"] + }, + "description": "Fetches address template with `addressTypeIdOptions` and `stateProvinceIdOptions`. Use these IDs in address save requests." + } + }, + { + "name": "4. Family — Add Family Member", + "request": { + "method": "POST", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"firstName\": \"Sunita\",\n \"middleName\": \"\",\n \"lastName\": \"Vyas\",\n \"relationshipId\": 30,\n \"genderId\": 23,\n \"dateOfBirth\": \"10 March 1998\",\n \"isAlive\": true,\n \"panNumber\": \"XYZAB1234C\",\n \"aadhaarNumber\": \"987654321012\",\n \"dateFormat\": \"dd MMMM yyyy\",\n \"locale\": \"en\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/clients/{{clientId}}/familymembers", + "host": ["{{baseUrl}}"], + "path": ["clients", "{{clientId}}", "familymembers"] + }, + "description": "Adds a family member to the client. Call once per member." + } + }, + { + "name": "4b. Family — Get Family Members", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}" + } + ], + "url": { + "raw": "{{baseUrl}}/clients/{{clientId}}/familymembers", + "host": ["{{baseUrl}}"], + "path": ["clients", "{{clientId}}", "familymembers"] + }, + "description": "Fetches all family members for a client." + } + }, + { + "name": "4c. Family — Upload Marriage Certificate", + "request": { + "method": "POST", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "src": "", + "description": "Select marriage certificate (PDF/JPG/PNG)" + }, + { + "key": "name", + "value": "Marriage Certificate", + "type": "text" + }, + { + "key": "description", + "value": "Marriage Certificate", + "type": "text" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/clients/{{clientId}}/familymembers/{{familyMemberId}}/documents", + "host": ["{{baseUrl}}"], + "path": ["clients", "{{clientId}}", "familymembers", "{{familyMemberId}}", "documents"] + }, + "description": "Uploads a marriage certificate document for a family member. Set `familyMemberId` from the Add Family Member response." + } + }, + { + "name": "5. Get Client Details", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}" + } + ], + "url": { + "raw": "{{baseUrl}}/clients/{{clientId}}", + "host": ["{{baseUrl}}"], + "path": ["clients", "{{clientId}}"] + }, + "description": "Fetches full client details to verify saved data." + } + } + ] +} diff --git a/src/app/accounting/accounting-rules/accounting-rules.component.html b/src/app/accounting/accounting-rules/accounting-rules.component.html index 694586a6b..d318e0a1d 100644 --- a/src/app/accounting/accounting-rules/accounting-rules.component.html +++ b/src/app/accounting/accounting-rules/accounting-rules.component.html @@ -15,8 +15,9 @@
- + {{ 'labels.inputs.Filter' | translate }} +
diff --git a/src/app/accounting/accounting.component.html b/src/app/accounting/accounting.component.html index 2115df787..bb83b8e09 100644 --- a/src/app/accounting/accounting.component.html +++ b/src/app/accounting/accounting.component.html @@ -6,303 +6,117 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- -
-
- - - - - -
- - - -
- - - - - -
- - - -
- -
- - - -
-
-
- -
- -
- - - -
- -
- - - -
- - - - - - - - - - - - -
-
+
+ +
+
+
+

{{ 'labels.menus.Accounting' | translate }}

- +
+ + +
+ + +
+
+ {{ 'labels.heading.Frequent Postings' | translate }} + {{ 'labels.text.These are predefined postings' | translate }} +
+
+ + + +
+
+ {{ 'labels.heading.Create Journal Entries' | translate }} + {{ 'labels.text.Manual journal entry transactions recorded in a journal' | translate }} +
+
+ + + +
+
+ {{ 'labels.heading.Search Journal Entries' | translate }} + {{ 'labels.text.Advanced search option for journal entries' | translate }} +
+
+ + + +
+
+ {{ 'labels.heading.Accounts Linked to Financial Activities' | translate }} + {{ 'labels.text.List of Financial Activities and GL Account Mappings' | translate }} +
+
+ + + +
+
+ {{ 'labels.heading.Migrate Opening Balances (Office-wise)' | translate }} + {{ 'labels.text.Set or update office-level opening balances for GL accounts' | translate }} +
+
+ + + +
+
+ {{ 'labels.heading.Chart of Accounts' | translate }} + {{ 'labels.text.List of accounts used by the organization' | translate }} +
+
+ + + +
+
+ {{ 'labels.heading.Closing Entries' | translate }} + {{ 'labels.text.Journal entries made at the end of accounting period' | translate }} +
+
+ + + +
+
+ {{ 'labels.heading.Accounting Rules' | translate }} + {{ 'labels.text.List of all accounting rules' | translate }} +
+
+ + + +
+
+ {{ 'labels.heading.Accruals' | translate }} + {{ 'labels.text.Accrues income, expenses and liabilities' | translate }} +
+
+ + + +
+
+ {{ 'labels.heading.Provisioning Entries' | translate }} + {{ 'labels.text.Create provisioning entries' | translate }} +
+
+
+

{{ 'labels.heading.Chart of Accounts' | translate }}

{{ 'labels.text.The Chart of Accounts - General Ledger Setup' | translate }}

- - - + + +
@@ -310,15 +124,9 @@

{{ 'labels.heading.Accounts Linked to Financial Activities' | translate }}

{{ 'labels.text.Accounts linked to Financial Activities' | translate }}

- - - + + +
@@ -326,15 +134,9 @@

{{ 'labels.heading.Migrate Opening Balances (Office-wise)' | translate }}

{{ 'labels.text.GLAccount Balances Application' | translate }}

- - - + + +
@@ -342,15 +144,9 @@

{{ 'labels.heading.Closing Entries' | translate }}

{{ 'labels.text.Accounting to journal entries' | translate }}

- - - + + +
@@ -358,14 +154,8 @@

{{ 'labels.heading.Create Journal Entries' | translate }}

{{ 'labels.text.Journal Entries Transactions' | translate }}

- - - + + +
diff --git a/src/app/accounting/accounting.component.scss b/src/app/accounting/accounting.component.scss index af9f4c075..33dfdd24b 100644 --- a/src/app/accounting/accounting.component.scss +++ b/src/app/accounting/accounting.component.scss @@ -5,3 +5,130 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +$brand-dark: #0d3d78; +$accent: #0ea5e9; + +.acc-container { + max-width: 1100px; + margin: 0 auto; + padding: 20px 16px; +} + +// Header +.acc-header { + display: flex; + align-items: center; + margin-bottom: 20px; + + &__left { + display: flex; + align-items: center; + gap: 10px; + } + + &__title { + font-size: 18px; + font-weight: 600; + color: #1a1a1a; + margin: 0; + } +} + +.acc-logomark { + width: 20px; + height: 20px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2px; + flex-shrink: 0; + + span { + border-radius: 2px; + + &:nth-child(1) { background: $brand-dark; } + &:nth-child(2) { background: $accent; } + &:nth-child(3) { background: #10b981; } + &:nth-child(4) { background: #cbd5e1; } + } +} + +// Cards Grid +.acc-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + + @media (max-width: 768px) { grid-template-columns: 1fr; } +} + +.acc-card { + display: flex; + align-items: center; + gap: 14px; + padding: 16px 18px; + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.06); + border-radius: 8px; + text-decoration: none; + cursor: pointer; + transition: box-shadow 0.15s, transform 0.15s; + + &:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + transform: translateY(-1px); + } + + &__icon { + width: 40px; + height: 40px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; + + &.blue { background: #e0f2fe; color: #0284c7; } + &.green { background: #d1fae5; color: #059669; } + &.purple { background: #ede9fe; color: #7c3aed; } + &.amber { background: #fef3c7; color: #d97706; } + &.teal { background: #ccfbf1; color: #0d9488; } + &.red { background: #fee2e2; color: #dc2626; } + } + + &__info { + display: flex; + flex-direction: column; + min-width: 0; + } + + &__title { + font-size: 13px; + font-weight: 600; + color: #1a1a1a; + } + + &__desc { + font-size: 11px; + color: #999; + margin-top: 2px; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } +} + +// Dark theme +:host-context(.dark-theme) { + .acc-card { + background: #1e1e2e; + border-color: rgba(255, 255, 255, 0.06); + } + + .acc-card__title { color: #e0e0e0; } + .acc-card__desc { color: #666; } + .acc-header__title { color: #e0e0e0; } +} diff --git a/src/app/accounting/chart-of-accounts/chart-of-accounts.component.html b/src/app/accounting/chart-of-accounts/chart-of-accounts.component.html index 4e02c7a3c..d18d7d01a 100644 --- a/src/app/accounting/chart-of-accounts/chart-of-accounts.component.html +++ b/src/app/accounting/chart-of-accounts/chart-of-accounts.component.html @@ -34,8 +34,9 @@
- + {{ 'labels.inputs.Filter' | translate }} +
diff --git a/src/app/accounting/provisioning-entries/provisioning-entries.component.html b/src/app/accounting/provisioning-entries/provisioning-entries.component.html index ddc650958..b71a165c2 100644 --- a/src/app/accounting/provisioning-entries/provisioning-entries.component.html +++ b/src/app/accounting/provisioning-entries/provisioning-entries.component.html @@ -20,8 +20,9 @@
- + {{ 'labels.inputs.Filter' | translate }} +
diff --git a/src/app/accounting/provisioning-entries/view-provisioning-journal-entries/view-provisioning-journal-entries.component.html b/src/app/accounting/provisioning-entries/view-provisioning-journal-entries/view-provisioning-journal-entries.component.html index 39961ae8b..c987a531c 100644 --- a/src/app/accounting/provisioning-entries/view-provisioning-journal-entries/view-provisioning-journal-entries.component.html +++ b/src/app/accounting/provisioning-entries/view-provisioning-journal-entries/view-provisioning-journal-entries.component.html @@ -8,8 +8,9 @@
- + {{ 'labels.inputs.Filter' | translate }} +
diff --git a/src/app/app.module.ts b/src/app/app.module.ts index fdc1ad2ef..8d5313aeb 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -53,6 +53,7 @@ import { SupersetAnalyticsModule } from './superset-analytics/superset-analytics import { CkycModule } from './ckyc/ckyc.module'; import { RbiCircularsModule } from './rbi-circulars/rbi-circulars.module'; import { CfcfrmsModule } from './cfcfrms/cfcfrms.module'; +import { BankCalendarModule } from './bank-calendar/bank-calendar.module'; import { PortalModule } from '@angular/cdk/portal'; /** Main Routing Module */ @@ -75,8 +76,13 @@ import { OAuthModule } from 'angular-oauth2-oidc'; export class CustomMissingTranslationHandler implements MissingTranslationHandler { handle(params: MissingTranslationHandlerParams): string { - // Remove the 'labels.catalogs.' prefix and return the fallback value - return params.key.replace('labels.catalogs.', ''); + // For missing keys, extract the last segment as a readable fallback + const key = params.key; + const lastDot = key.lastIndexOf('.'); + if (lastDot !== -1) { + return key.substring(lastDot + 1); + } + return key.replace('labels.catalogs.', ''); } } @@ -137,6 +143,7 @@ export function HttpLoaderFactory(http: HttpClient) { CkycModule, RbiCircularsModule, CfcfrmsModule, + BankCalendarModule, AppRoutingModule, NotFoundComponent, CallbackComponent, diff --git a/src/app/bank-calendar/add-holiday-dialog/add-holiday-dialog.component.html b/src/app/bank-calendar/add-holiday-dialog/add-holiday-dialog.component.html new file mode 100644 index 000000000..688645f25 --- /dev/null +++ b/src/app/bank-calendar/add-holiday-dialog/add-holiday-dialog.component.html @@ -0,0 +1,116 @@ + + +
+ +
+
+
+ Add Holiday +
+ +
+ + + +
+
+ + Holiday Name + + @if (holidayForm.controls.name.hasError('required')) { + Name is required + } + +
+ +
+ + Description + + +
+ +
+ + From Date + + + + @if (holidayForm.controls.fromDate.hasError('required')) { + From Date is required + } + + + + To Date + + + + @if (holidayForm.controls.toDate.hasError('required')) { + To Date is required + } + +
+ +
+ + Repayment Scheduling + + @for (type of repaymentSchedulingTypes; track type) { + {{ type.value }} + } + + @if (holidayForm.controls.reschedulingType.hasError('required')) { + Required + } + + + + Office + + @for (office of offices; track office) { + {{ office.name }} + } + + @if (holidayForm.controls.officeId.hasError('required')) { + Required + } + +
+ + @if (holidayForm.contains('repaymentsRescheduledTo')) { +
+ + Repayments Rescheduled To + + + + +
+ } +
+
+ + + + + + +
diff --git a/src/app/bank-calendar/add-holiday-dialog/add-holiday-dialog.component.scss b/src/app/bank-calendar/add-holiday-dialog/add-holiday-dialog.component.scss new file mode 100644 index 000000000..daa846b69 --- /dev/null +++ b/src/app/bank-calendar/add-holiday-dialog/add-holiday-dialog.component.scss @@ -0,0 +1,120 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$blue: #1e88e5; +$navy: #0d1b2a; + +.hol-dialog { + max-width: 560px; + width: 560px; +} + +// Header +.hol-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 24px 14px; + border-bottom: 1px solid #e8eaed; + + &__left { + display: flex; + align-items: center; + gap: 12px; + } + + &__icon { + width: 38px; + height: 38px; + border-radius: 10px; + background: #eff6ff; + display: flex; + align-items: center; + justify-content: center; + color: $blue; + font-size: 16px; + } + + &__title { + font-size: 17px; + font-weight: 700; + color: $navy; + } + + &__close { + width: 32px; + height: 32px; + border: none; + background: #f1f3f4; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #5f6368; + transition: all 0.15s; + + &:hover { + background: #e8eaed; + color: $navy; + } + } +} + +// Body +.hol-body { + padding: 18px 24px !important; + overflow: visible !important; + max-height: 65vh !important; +} + +.hol-row { + margin-bottom: 4px; + + &--two { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + } +} + +.hol-field { + width: 100%; +} + +// Actions +.hol-actions { + padding: 12px 24px 18px !important; + border-top: 1px solid #e8eaed; + margin: 0 !important; + display: flex !important; + justify-content: flex-end !important; + gap: 10px !important; + + &__cancel { + border-radius: 8px !important; + font-size: 13px !important; + font-weight: 600 !important; + padding: 0 20px !important; + height: 38px !important; + } + + &__submit { + border-radius: 8px !important; + font-size: 13px !important; + font-weight: 600 !important; + padding: 0 20px !important; + height: 38px !important; + box-shadow: 0 1px 3px rgba($blue, 0.3) !important; + } + + &__icon { + margin-right: 6px; + font-size: 12px; + } +} diff --git a/src/app/bank-calendar/add-holiday-dialog/add-holiday-dialog.component.ts b/src/app/bank-calendar/add-holiday-dialog/add-holiday-dialog.component.ts new file mode 100644 index 000000000..1ebc9251d --- /dev/null +++ b/src/app/bank-calendar/add-holiday-dialog/add-holiday-dialog.component.ts @@ -0,0 +1,134 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators, UntypedFormControl } from '@angular/forms'; +import { MatDialogRef, MatDialogContent, MatDialogActions } from '@angular/material/dialog'; +import { OrganizationService } from '../../organization/organization.service'; +import { SettingsService } from '../../settings/settings.service'; +import { Dates } from '../../core/utils/dates'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +@Component({ + selector: 'mifosx-add-holiday-dialog', + templateUrl: './add-holiday-dialog.component.html', + styleUrls: ['./add-holiday-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatDialogContent, + MatDialogActions + ] +}) +export class AddHolidayDialogComponent implements OnInit { + private fb = inject(UntypedFormBuilder); + private organizationService = inject(OrganizationService); + private settings = inject(SettingsService); + private dateUtils = inject(Dates); + public dialogRef = inject(MatDialogRef); + + holidayForm: UntypedFormGroup; + offices: any[] = []; + repaymentSchedulingTypes: any[] = []; + submitting = false; + minDate = new Date(2000, 0, 1); + maxDate = new Date(2100, 0, 1); + + ngOnInit() { + this.holidayForm = this.fb.group({ + name: ['', Validators.required], + description: [''], + fromDate: ['', Validators.required], + toDate: ['', Validators.required], + reschedulingType: ['', Validators.required], + officeId: ['', Validators.required] + }); + + this.loadData(); + this.buildDependencies(); + } + + private loadData() { + this.organizationService.getOffices().subscribe((data: any[]) => { + this.offices = data || []; + if (this.offices.length > 0) { + this.holidayForm.patchValue({ officeId: this.offices[0].id }); + } + }); + + this.organizationService.getHolidayTemplate().subscribe((data: any) => { + this.repaymentSchedulingTypes = data || []; + }); + } + + private buildDependencies() { + this.holidayForm.get('reschedulingType').valueChanges.subscribe((option: any) => { + if (option === 2) { + this.holidayForm.addControl('repaymentsRescheduledTo', new UntypedFormControl('', Validators.required)); + } else { + this.holidayForm.removeControl('repaymentsRescheduledTo'); + } + }); + } + + submit() { + if (this.holidayForm.invalid || this.submitting) return; + this.submitting = true; + + const formData = { ...this.holidayForm.value }; + const dateFormat = this.settings.dateFormat; + const locale = this.settings.language.code; + const momentFormat = 'DD MMMM YYYY'; + + const coerceDate = (value: unknown): Date | null => { + if (value instanceof Date) return value; + if (value == null || value === '') return null; + const d = new Date(value as any); + return Number.isNaN(d.getTime()) ? null : d; + }; + + const fromDate = coerceDate(formData.fromDate); + const toDate = coerceDate(formData.toDate); + if (!fromDate || !toDate) { + this.submitting = false; + return; + } + + formData.fromDate = this.dateUtils.formatDateAsString(fromDate, momentFormat); + formData.toDate = this.dateUtils.formatDateAsString(toDate, momentFormat); + + if (this.holidayForm.contains('repaymentsRescheduledTo')) { + const reschDate = coerceDate(formData.repaymentsRescheduledTo); + if (!reschDate) { + this.submitting = false; + return; + } + formData.repaymentsRescheduledTo = this.dateUtils.formatDateAsString(reschDate, momentFormat); + } + + const officeId = formData.officeId; + delete formData.officeId; + + const payload = { + ...formData, + dateFormat, + locale, + offices: [{ officeId }] + }; + + this.organizationService.createHoliday(payload).subscribe({ + next: () => { + this.dialogRef.close(true); + }, + error: () => { + this.submitting = false; + } + }); + } +} diff --git a/src/app/bank-calendar/bank-calendar-routing.module.ts b/src/app/bank-calendar/bank-calendar-routing.module.ts new file mode 100644 index 000000000..db112045a --- /dev/null +++ b/src/app/bank-calendar/bank-calendar-routing.module.ts @@ -0,0 +1,34 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { Route } from '../core/route/route.service'; +import { BankCalendarComponent } from './bank-calendar.component'; + +const routes: Routes = [ + Route.withShell([ + { + path: 'bank-calendar', + data: { title: 'Bank Calendar', breadcrumb: 'Bank Calendar' }, + children: [ + { + path: '', + component: BankCalendarComponent + } + ] + } + ]) +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class BankCalendarRoutingModule {} diff --git a/src/app/bank-calendar/bank-calendar.component.html b/src/app/bank-calendar/bank-calendar.component.html new file mode 100644 index 000000000..d8768ec66 --- /dev/null +++ b/src/app/bank-calendar/bank-calendar.component.html @@ -0,0 +1,134 @@ + + +
+ +
+
+ + Bank Calendar +
+
+ + {{ currentYear }} + + +
+
+ + +
+
+ +
+
{{ overallStats.working }}
+
Working Days
+
+
+
+ +
+
{{ overallStats.sundays }}
+
Sundays
+
+
+
+ +
+
{{ overallStats.sat2nd4th }}
+
2nd & 4th Sat
+
+
+
+ +
+
{{ overallStats.holidays }}
+
Holidays
+
+
+
+ +
+
{{ overallStats.totalDays }}
+
Total Days
+
+
+
+ + +
+
+
Working Day
+
Sunday
+
2nd / 4th Saturday
+
Holiday
+
Today
+
+
+ + +
+
+ + +
+ @for (month of months; track month.month) { +
+
+ {{ month.name }} + + {{ month.workingDays }}W + {{ month.nonWorkingDays }}Off + +
+ +
+ + @for (wd of weekDays; track wd) { +
{{ wd }}
+ } + + + @for (day of month.days; track $index) { +
+ {{ day.isCurrentMonth ? day.date : '' }} +
+ } +
+ + +
+ @if (month.holidays > 0) { + {{ month.holidays }} Holiday{{ month.holidays > 1 ? 's' : '' }} + } + {{ month.sundays }} Sun + @if (month.secondFourthSat > 0) { + {{ month.secondFourthSat }} Sat Off + } +
+
+ } +
+
diff --git a/src/app/bank-calendar/bank-calendar.component.scss b/src/app/bank-calendar/bank-calendar.component.scss new file mode 100644 index 000000000..bc78d844e --- /dev/null +++ b/src/app/bank-calendar/bank-calendar.component.scss @@ -0,0 +1,466 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$navy: #0d1b2a; +$blue: #1e88e5; +$green: #10b981; +$orange: #f59e0b; +$red: #ef4444; +$purple: #7c3aed; + +// ─── Container ─── +.cal-container { + padding: 20px 24px; + max-width: 1440px; + margin: 0 auto; + background: #f0f2f5; + min-height: 100vh; +} + +// ─── Header ─── +.cal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + + &__left { + display: flex; + align-items: center; + gap: 10px; + } + + &__icon { + font-size: 22px; + color: $blue; + } + + &__title { + font-size: 20px; + font-weight: 700; + color: $navy; + } + + &__nav { + display: flex; + align-items: center; + gap: 8px; + } + + &__year { + font-size: 20px; + font-weight: 800; + color: $navy; + min-width: 60px; + text-align: center; + } +} + +.cal-nav-btn { + width: 34px; + height: 34px; + border: 1px solid #d0d5dd; + background: #fff; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #475569; + transition: all 0.15s; + + &:hover { + background: $blue; + color: #fff; + border-color: $blue; + } +} + +.cal-today-btn { + height: 34px; + padding: 0 16px; + border: 1px solid $blue; + background: #eff6ff; + color: $blue; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: $blue; + color: #fff; + } +} + +// ─── Stats Row ─── +.cal-stats { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px; + margin-bottom: 14px; +} + +.cal-stat { + background: #fff; + border-radius: 10px; + padding: 14px 16px; + display: flex; + align-items: center; + gap: 12px; + border: 1px solid #e8ecf0; + box-shadow: 0 1px 4px rgb(0 0 0 / 6%); + + &__icon { + font-size: 18px; + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + &__value { + font-size: 22px; + font-weight: 800; + color: $navy; + } + + &__label { + font-size: 11px; + color: #64748b; + font-weight: 500; + } + + &.working .cal-stat__icon { + background: #ecfdf5; + color: $green; + } + + &.sundays .cal-stat__icon { + background: #fef2f2; + color: $red; + } + + &.sat2nd4th .cal-stat__icon { + background: #fff7ed; + color: $orange; + } + + &.holidays .cal-stat__icon { + background: #f5f3ff; + color: $purple; + } + + &.total .cal-stat__icon { + background: #eff6ff; + color: $blue; + } +} + +// ─── Legend ─── +.cal-legend { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + padding: 10px 16px; + background: #fff; + border-radius: 8px; + border: 1px solid #e8ecf0; + + &__items { + display: flex; + gap: 20px; + align-items: center; + flex-wrap: wrap; + } + + &__item { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #475569; + font-weight: 500; + } + + &__actions { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; + } + + &__print-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 18px; + border: 1px solid #d0d5dd; + background: #fff; + color: #475569; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + + &:hover { + background: #f8fafc; + border-color: #94a3b8; + color: $navy; + } + } + + &__add-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 18px; + border: none; + background: $blue; + color: #fff; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + flex-shrink: 0; + + &:hover { + background: darken($blue, 8%); + box-shadow: 0 2px 8px rgb(30 136 229 / 35%); + } + } + + &__dot { + width: 12px; + height: 12px; + border-radius: 3px; + flex-shrink: 0; + + &.working { + background: #ecfdf5; + border: 1px solid $green; + } + + &.sunday { + background: #fef2f2; + border: 1px solid $red; + } + + &.sat2nd4th { + background: #fff7ed; + border: 1px solid $orange; + } + + &.holiday { + background: #f5f3ff; + border: 1px solid $purple; + } + + &.today { + background: $blue; + border: 1px solid $blue; + } + } +} + +// ─── Monthly Grid ─── +.cal-months { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; +} + +.cal-month-card { + background: #fff; + border-radius: 10px; + padding: 14px; + border: 1px solid #e8ecf0; + box-shadow: 0 1px 4px rgb(0 0 0 / 6%); + transition: box-shadow 0.2s; + + &:hover { + box-shadow: 0 4px 16px rgb(0 0 0 / 10%); + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 2px solid #0d47a1; + } + + &__name { + font-size: 14px; + font-weight: 700; + color: #0d47a1; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__counts { + display: flex; + gap: 6px; + + .count-working { + font-size: 11px; + font-weight: 700; + color: $green; + background: #ecfdf5; + padding: 2px 6px; + border-radius: 4px; + } + + .count-off { + font-size: 11px; + font-weight: 700; + color: $red; + background: #fef2f2; + padding: 2px 6px; + border-radius: 4px; + } + } + + &__summary { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #f1f5f9; + } +} + +.summary-tag { + font-size: 10px; + font-weight: 600; + padding: 2px 6px; + border-radius: 4px; + + &.holiday { + background: #f5f3ff; + color: $purple; + } + + &.sun { + background: #fef2f2; + color: $red; + } + + &.sat { + background: #fff7ed; + color: $orange; + } +} + +// ─── Day Grid ─── +.cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; + + &__wday { + text-align: center; + font-size: 10px; + font-weight: 700; + color: #64748b; + padding: 4px 0; + text-transform: uppercase; + + &--sun { + color: $red; + } + } + + &__day { + text-align: center; + font-size: 12px; + font-weight: 500; + padding: 5px 2px; + border-radius: 4px; + cursor: default; + color: #334155; + transition: all 0.1s; + + &--other { + color: transparent; + } + + &--working { + background: #f0fdf4; + color: #166534; + } + + &--sunday { + background: #fef2f2; + color: $red; + font-weight: 700; + } + + &--sat2nd4th { + background: #fff7ed; + color: #c2410c; + font-weight: 700; + } + + &--holiday { + background: #f5f3ff; + color: $purple; + font-weight: 700; + } + + &--today { + background: $blue !important; + color: #fff !important; + font-weight: 800; + box-shadow: 0 2px 6px rgb(30 136 229 / 40%); + } + } +} + +// ─── Responsive ─── +@media screen and (max-width: 1200px) { + .cal-months { + grid-template-columns: repeat(2, 1fr); + } + + .cal-stats { + grid-template-columns: repeat(3, 1fr); + } +} + +@media screen and (max-width: 768px) { + .cal-months { + grid-template-columns: 1fr; + } + + .cal-stats { + grid-template-columns: repeat(2, 1fr); + } + + .cal-legend { + flex-wrap: wrap; + gap: 10px; + } + + .cal-header { + flex-direction: column; + gap: 10px; + align-items: flex-start; + } +} diff --git a/src/app/bank-calendar/bank-calendar.component.ts b/src/app/bank-calendar/bank-calendar.component.ts new file mode 100644 index 000000000..92c7d09c8 --- /dev/null +++ b/src/app/bank-calendar/bank-calendar.component.ts @@ -0,0 +1,508 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatTooltip } from '@angular/material/tooltip'; +import { OrganizationService } from '../organization/organization.service'; + +interface CalendarDay { + date: number; + month: number; + year: number; + isCurrentMonth: boolean; + isToday: boolean; + isWorking: boolean; + isHoliday: boolean; + isSunday: boolean; + is2ndSaturday: boolean; + is4thSaturday: boolean; + holidayName?: string; + dayOfWeek: number; // 0=Sun, 6=Sat +} + +interface MonthData { + month: number; + year: number; + name: string; + days: CalendarDay[]; + workingDays: number; + nonWorkingDays: number; + holidays: number; + sundays: number; + secondFourthSat: number; +} + +@Component({ + selector: 'mifosx-bank-calendar', + templateUrl: './bank-calendar.component.html', + styleUrls: ['./bank-calendar.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatTooltip + ] +}) +export class BankCalendarComponent implements OnInit { + private organizationService = inject(OrganizationService); + private dialog = inject(MatDialog); + + currentYear: number; + months: MonthData[] = []; + weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + workingDaysConfig: string[] = []; + holidays: any[] = []; + overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 }; + + /** Indian bank gazetted holidays by year */ + private readonly gazettedHolidays: Record = { + 2025: [ + { month: 1, day: 26, name: 'Republic Day' }, + { month: 2, day: 26, name: 'Maha Shivaratri' }, + { month: 3, day: 14, name: 'Holi' }, + { month: 3, day: 31, name: 'Id-ul-Fitr (Ramzan Eid)' }, + { month: 4, day: 6, name: 'Ram Navami' }, + { month: 4, day: 10, name: 'Mahavir Jayanti' }, + { month: 4, day: 14, name: 'Dr. Ambedkar Jayanti' }, + { month: 4, day: 18, name: 'Good Friday' }, + { month: 5, day: 1, name: 'May Day' }, + { month: 5, day: 12, name: 'Buddha Purnima' }, + { month: 6, day: 7, name: 'Id-ul-Zuha (Bakrid)' }, + { month: 7, day: 6, name: 'Muharram' }, + { month: 8, day: 15, name: 'Independence Day' }, + { month: 8, day: 16, name: 'Janmashtami' }, + { month: 9, day: 5, name: 'Milad-un-Nabi (Eid-e-Milad)' }, + { month: 10, day: 2, name: 'Mahatma Gandhi Jayanti' }, + { month: 10, day: 2, name: 'Dussehra (Vijaya Dashami)' }, + { month: 10, day: 20, name: 'Diwali (Deepavali)' }, + { month: 11, day: 5, name: 'Guru Nanak Jayanti' }, + { month: 12, day: 25, name: 'Christmas Day' } + ], + 2026: [ + { month: 1, day: 26, name: 'Republic Day' }, + { month: 3, day: 3, name: 'Holi' }, + { month: 3, day: 21, name: 'Id-ul-Fitr (Ramzan Eid)' }, + { month: 3, day: 26, name: 'Ram Navami' }, + { month: 3, day: 31, name: 'Mahavir Jayanti' }, + { month: 4, day: 1, name: 'Annual Closing of Bank Accounts' }, + { month: 4, day: 3, name: 'Good Friday' }, + { month: 5, day: 1, name: 'Buddha Purnima / May Day' }, + { month: 5, day: 27, name: 'Id-ul-Zuha (Bakrid)' }, + { month: 6, day: 26, name: 'Muharram' }, + { month: 8, day: 15, name: 'Independence Day' }, + { month: 8, day: 26, name: 'Milad-un-Nabi (Eid-e-Milad)' }, + { month: 9, day: 4, name: 'Janmashtami' }, + { month: 10, day: 2, name: 'Mahatma Gandhi Jayanti' }, + { month: 10, day: 20, name: 'Dussehra (Vijaya Dashami)' }, + { month: 11, day: 8, name: 'Diwali (Deepavali)' }, + { month: 11, day: 24, name: 'Guru Nanak Jayanti' }, + { month: 12, day: 25, name: 'Christmas Day' } + ], + 2027: [ + { month: 1, day: 26, name: 'Republic Day' }, + { month: 2, day: 20, name: 'Holi' }, + { month: 3, day: 11, name: 'Id-ul-Fitr (Ramzan Eid)' }, + { month: 3, day: 15, name: 'Ram Navami' }, + { month: 3, day: 21, name: 'Mahavir Jayanti' }, + { month: 3, day: 26, name: 'Good Friday' }, + { month: 4, day: 1, name: 'Annual Closing of Bank Accounts' }, + { month: 5, day: 1, name: 'May Day' }, + { month: 5, day: 2, name: 'Buddha Purnima' }, + { month: 5, day: 17, name: 'Id-ul-Zuha (Bakrid)' }, + { month: 6, day: 16, name: 'Muharram' }, + { month: 8, day: 15, name: 'Independence Day' }, + { month: 8, day: 16, name: 'Milad-un-Nabi (Eid-e-Milad)' }, + { month: 8, day: 25, name: 'Janmashtami' }, + { month: 10, day: 2, name: 'Mahatma Gandhi Jayanti' }, + { month: 10, day: 9, name: 'Dussehra (Vijaya Dashami)' }, + { month: 10, day: 28, name: 'Diwali (Deepavali)' }, + { month: 11, day: 14, name: 'Guru Nanak Jayanti' }, + { month: 12, day: 25, name: 'Christmas Day' } + ] + }; + + ngOnInit() { + this.currentYear = new Date().getFullYear(); + this.loadWorkingDays(); + } + + private loadWorkingDays() { + // Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off) + this.workingDaysConfig = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA']; + this.mergeGazettedHolidays(); + this.buildCalendar(); + + // Try loading from API + this.organizationService.getOffices().subscribe((offices: any[]) => { + if (offices?.length > 0) { + this.organizationService.getHolidays(offices[0].id.toString()).subscribe((holidayData: any[]) => { + this.holidays = holidayData || []; + this.mergeGazettedHolidays(); + this.buildCalendar(); + }); + } + }); + } + + /** Merge static gazetted holidays into the holidays array (API format) */ + private mergeGazettedHolidays() { + const gazetted = this.gazettedHolidays[this.currentYear] || []; + for (const gh of gazetted) { + const exists = this.holidays.some((h: any) => { + if (h.fromDate) { + const [y, m, d] = h.fromDate; + return y === this.currentYear && m === gh.month && d === gh.day; + } + return false; + }); + if (!exists) { + this.holidays.push({ + name: gh.name, + fromDate: [this.currentYear, gh.month, gh.day], + toDate: [this.currentYear, gh.month, gh.day], + description: gh.name, + status: { value: 'Active' } + }); + } + } + } + + buildCalendar() { + this.months = []; + this.overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 }; + + for (let m = 0; m < 12; m++) { + const monthData = this.buildMonth(m, this.currentYear); + this.months.push(monthData); + this.overallStats.working += monthData.workingDays; + this.overallStats.nonWorking += monthData.nonWorkingDays; + this.overallStats.holidays += monthData.holidays; + this.overallStats.sundays += monthData.sundays; + this.overallStats.sat2nd4th += monthData.secondFourthSat; + } + this.overallStats.totalDays = this.overallStats.working + this.overallStats.nonWorking; + } + + private buildMonth(month: number, year: number): MonthData { + const firstDay = new Date(year, month, 1); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const startDayOfWeek = firstDay.getDay(); + const today = new Date(); + + const monthNames = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + + const days: CalendarDay[] = []; + let workingCount = 0; + let nonWorkingCount = 0; + let holidayCount = 0; + let sundayCount = 0; + let sat2nd4thCount = 0; + + // Previous month padding + const prevMonthDays = new Date(year, month, 0).getDate(); + for (let i = startDayOfWeek - 1; i >= 0; i--) { + days.push({ + date: prevMonthDays - i, + month: month - 1, + year, + isCurrentMonth: false, + isToday: false, + isWorking: false, + isHoliday: false, + isSunday: false, + is2ndSaturday: false, + is4thSaturday: false, + dayOfWeek: (startDayOfWeek - i - 1 + 7) % 7 + }); + } + + // Current month days + let saturdayCount = 0; + for (let d = 1; d <= daysInMonth; d++) { + const date = new Date(year, month, d); + const dow = date.getDay(); + const isSunday = dow === 0; + const isSaturday = dow === 6; + + if (isSaturday) saturdayCount++; + + const is2ndSat = isSaturday && saturdayCount === 2; + const is4thSat = isSaturday && saturdayCount === 4; + const isToday = date.toDateString() === today.toDateString(); + + // Check if holiday + const holiday = this.getHolidayForDate(year, month, d); + const isHoliday = !!holiday; + + // Non-working: Sunday, 2nd Sat, 4th Sat, or holiday + const isNonWorking = isSunday || is2ndSat || is4thSat || isHoliday; + + if (isSunday) sundayCount++; + if (is2ndSat || is4thSat) sat2nd4thCount++; + if (isHoliday) holidayCount++; + + if (isNonWorking) { + nonWorkingCount++; + } else { + workingCount++; + } + + days.push({ + date: d, + month, + year, + isCurrentMonth: true, + isToday, + isWorking: !isNonWorking, + isHoliday, + isSunday, + is2ndSaturday: is2ndSat, + is4thSaturday: is4thSat, + holidayName: holiday?.name, + dayOfWeek: dow + }); + } + + // Next month padding + const remaining = 42 - days.length; + for (let i = 1; i <= remaining; i++) { + days.push({ + date: i, + month: month + 1, + year, + isCurrentMonth: false, + isToday: false, + isWorking: false, + isHoliday: false, + isSunday: false, + is2ndSaturday: false, + is4thSaturday: false, + dayOfWeek: (days.length) % 7 + }); + } + + return { + month, + year, + name: monthNames[month], + days, + workingDays: workingCount, + nonWorkingDays: nonWorkingCount, + holidays: holidayCount, + sundays: sundayCount, + secondFourthSat: sat2nd4thCount + }; + } + + private getHolidayForDate(year: number, month: number, day: number): any { + return this.holidays.find((h: any) => { + if (h.fromDate) { + const [y, m, d] = h.fromDate; + return y === year && m === month + 1 && d === day; + } + return false; + }); + } + + previousYear() { + this.currentYear--; + this.holidays = []; + this.mergeGazettedHolidays(); + this.buildCalendar(); + } + + nextYear() { + this.currentYear++; + this.holidays = []; + this.mergeGazettedHolidays(); + this.buildCalendar(); + } + + goToCurrentYear() { + this.currentYear = new Date().getFullYear(); + this.holidays = []; + this.mergeGazettedHolidays(); + this.buildCalendar(); + } + + printHolidayList() { + const monthNames = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + + // --- Section 1: Gazetted / API Holidays --- + const gazettedList: { sno: number; date: string; day: string; occasion: string }[] = []; + const sortedHolidays = [...this.holidays].sort((a: any, b: any) => { + const da = a.fromDate[0] * 10000 + a.fromDate[1] * 100 + a.fromDate[2]; + const db = b.fromDate[0] * 10000 + b.fromDate[1] * 100 + b.fromDate[2]; + return da - db; + }); + + sortedHolidays.forEach((h: any, i: number) => { + if (h.fromDate) { + const [y, m, d] = h.fromDate; + const date = new Date(y, m - 1, d); + gazettedList.push({ + sno: i + 1, + date: `${monthNames[m - 1]} ${String(d).padStart(2, '0')}, ${y}`, + day: dayNames[date.getDay()], + occasion: h.name || 'Holiday' + }); + } + }); + + // --- Section 2: 2nd & 4th Saturdays --- + const satList: { sno: number; date: string; day: string; occasion: string }[] = []; + let satSno = 0; + for (let m = 0; m < 12; m++) { + const daysInMonth = new Date(this.currentYear, m + 1, 0).getDate(); + let satCount = 0; + for (let d = 1; d <= daysInMonth; d++) { + const date = new Date(this.currentYear, m, d); + if (date.getDay() === 6) { + satCount++; + if (satCount === 2 || satCount === 4) { + satSno++; + satList.push({ + sno: satSno, + date: `${monthNames[m]} ${String(d).padStart(2, '0')}, ${this.currentYear}`, + day: 'Saturday', + occasion: satCount === 2 ? '2nd Saturday Off' : '4th Saturday Off' + }); + } + } + } + } + + // --- Section 3: Sundays --- + const sunList: { sno: number; date: string; day: string; occasion: string }[] = []; + let sunSno = 0; + for (let m = 0; m < 12; m++) { + const daysInMonth = new Date(this.currentYear, m + 1, 0).getDate(); + for (let d = 1; d <= daysInMonth; d++) { + const date = new Date(this.currentYear, m, d); + if (date.getDay() === 0) { + sunSno++; + sunList.push({ + sno: sunSno, + date: `${monthNames[m]} ${String(d).padStart(2, '0')}, ${this.currentYear}`, + day: 'Sunday', + occasion: 'Weekly Off' + }); + } + } + } + + const buildRows = (list: typeof gazettedList) => + list.map(h => `${h.sno}${h.date}${h.day}${h.occasion}`).join(''); + + const html = ` + + + + Holiday List ${this.currentYear} — CBS Nexora + + + +
+

CBS Nexora — Bank Holiday List

+

Gazetted Holidays, 2nd/4th Saturdays & Sundays

+
${this.currentYear}
+
+
+ Gazetted Holidays: ${gazettedList.length} + 2nd/4th Saturday Offs: ${satList.length} + Sundays: ${sunList.length} + Total Non-Working Days: ${gazettedList.length + satList.length + sunList.length} +
+ +
Gazetted / Bank Holidays ${gazettedList.length}
+ + + ${buildRows(gazettedList)} +
Sr. No.DateDayHoliday / Occasion
+ +
2nd & 4th Saturday Offs ${satList.length}
+ + + ${buildRows(satList)} +
Sr. No.DateDayHoliday / Occasion
+ +
Sundays (Weekly Off) ${sunList.length}
+ + + ${buildRows(sunList)} +
Sr. No.DateDayHoliday / Occasion
+ + + + + + `; + + const printWindow = window.open('', '_blank'); + if (printWindow) { + printWindow.document.write(html); + printWindow.document.close(); + } + } + + async openAddHolidayDialog() { + const { AddHolidayDialogComponent } = await import('./add-holiday-dialog/add-holiday-dialog.component'); + const dialogRef = this.dialog.open(AddHolidayDialogComponent, { + width: '600px', + maxWidth: '95vw', + maxHeight: '90vh', + autoFocus: false, + panelClass: 'add-holiday-dialog-panel' + }); + + dialogRef.afterClosed().subscribe((result: boolean) => { + if (result) { + // Reload holidays after successful creation + this.loadWorkingDays(); + } + }); + } +} diff --git a/src/app/bank-calendar/bank-calendar.module.ts b/src/app/bank-calendar/bank-calendar.module.ts new file mode 100644 index 000000000..0bafab47b --- /dev/null +++ b/src/app/bank-calendar/bank-calendar.module.ts @@ -0,0 +1,22 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { NgModule } from '@angular/core'; + +import { SharedModule } from '../shared/shared.module'; +import { BankCalendarRoutingModule } from './bank-calendar-routing.module'; +import { BankCalendarComponent } from './bank-calendar.component'; + +@NgModule({ + imports: [ + SharedModule, + BankCalendarRoutingModule, + BankCalendarComponent + ] +}) +export class BankCalendarModule {} diff --git a/src/app/centers/centers.component.html b/src/app/centers/centers.component.html index ddecc53fe..c41c52ce4 100644 --- a/src/app/centers/centers.component.html +++ b/src/app/centers/centers.component.html @@ -7,76 +7,82 @@ --> -
- diff --git a/src/app/clients/client-stepper/client-address-step/client-address-step.component.ts b/src/app/clients/client-stepper/client-address-step/client-address-step.component.ts index 69ea99419..f2d3363a1 100644 --- a/src/app/clients/client-stepper/client-address-step/client-address-step.component.ts +++ b/src/app/clients/client-stepper/client-address-step/client-address-step.component.ts @@ -289,7 +289,7 @@ export class ClientAddressStepComponent { formfields.push( this.isFieldEnabled('countyDistrict') ? new InputBase({ - controlName: 'countryDistrict', + controlName: 'countyDistrict', label: this.translateService.instant('labels.inputs.Country District'), value: address ? address.countyDistrict : '', type: 'text', diff --git a/src/app/clients/client-stepper/client-entity-kyc-step/client-entity-kyc-step.component.html b/src/app/clients/client-stepper/client-entity-kyc-step/client-entity-kyc-step.component.html new file mode 100644 index 000000000..8f5a6f18c --- /dev/null +++ b/src/app/clients/client-stepper/client-entity-kyc-step/client-entity-kyc-step.component.html @@ -0,0 +1,678 @@ + + +
+ +
+
+ +
+

Scheme & Sector Classification

+

Regulatory scheme flags, sector classification & compliance identifiers

+
+
+
+ + +
Active Classifications
+
+ @if (enabledCount === 0) { + No classifications enabled — toggle categories below + } + @for (cat of enabledCategories; track cat.id) { + + {{ cat.label }} + close + + } +
+ + +
+
+
+ Udyam / MSME +
+
MSME
+
Micro, Small & Medium Enterprise — Ministry of MSME / Udyam Registration
+
+
+
+ + expand_more +
+
+ @if (expanded['msme']) { +
+
Udyam Registration
+
+ Udyam Registration Number + + Registration Date + + + Registration Status + + Active + Suspended + Cancelled + Pending Verification + + MSME Classification + + Micro (T/O ≤ ₹5 Cr, Inv ≤ ₹1 Cr) + Small (T/O ≤ ₹50 Cr, Inv ≤ ₹10 Cr) + Medium (T/O ≤ ₹250 Cr, Inv ≤ ₹50 Cr) + + Enterprise Type + + Manufacturing + Services + Trading + + NIC Activity Code + +
+ +
Financial Thresholds (as per Udyam Portal)
+
+ Annual Turnover — Last FY (₹ Cr) + + Investment — Plant & Machinery / Equipment (₹ Cr) + + Financial Year of Last Assessment + + 2025-26 + 2024-25 + 2023-24 + 2022-23 + 2021-22 + 2020-21 + +
+ +
Owner / Promoter Details
+
+ Social Category of Owner + + General + Scheduled Caste (SC) + Scheduled Tribe (ST) + Other Backward Class (OBC) + Minority + + Gender of Primary Owner + + Male + Female + Other + + No. of Employees (approx.) + +
+
+
+
Women-led EnterpriseMajority ownership / management by women entrepreneur
+ +
+
+
Divyang (PWD) OwnedPersons with Disability — eligible for special scheme benefits
+ +
+
+ +
+ MSMED Act 2006 (Revised): Classification based on composite criteria of investment in plant & machinery AND annual turnover. Udyam Registration is mandatory for availing PSL benefits, government procurement preference, and CGSS coverage. +
+ +
+ +
+
+ } +
+ + +
+
+
+ DPIIT +
+
Startup India Registered
+
DPIIT recognition under Startup India initiative
+
+
+
+ + expand_more +
+
+ @if (expanded['startup']) { +
+
Registration Details
+
+ DPIIT Recognition Number + + Recognition Date + + + Certificate Expiry Date + + + Entity Stage + + Early (0–2 years) + Growth (2–5 years) + Scale (5+ years) + + Sector as per DPIIT + + Fintech + Agritech + Edtech + Healthtech + Logistics + Manufacturing + SaaS / B2B Tech + Information Technology + Other + + NIC Industry Code + +
+ +
Government Scheme Benefits Availed
+
+ 80IAC Tax Exemption + SIDBI Startup Fund + Self-Certification (Labour / Env.) + Fast-track Patent / IPR + Government Tender Exemption + Credit Guarantee Scheme (CGSS) +
+ +
PSL Linkage
+
+ Loan Limit for PSL Eligibility (Cr) + + PSL Sub-category + + Others (Startup India) + MSME — Micro + MSME — Small + +
+ +
+ RBI Circular RBI/2023-24/62: DPIIT-recognized startups eligible for PSL classification up to ₹50 Cr. Recognition certificate to be retained in document management system. +
+ +
+ +
+
+ } +
+ + +
+
+
+ PMMY +
+
Mudra Borrower
+
Pradhan Mantri Mudra Yojana — SIDBI / Mudra scheme borrower
+
+
+
+ + expand_more +
+
+ @if (expanded['mudra']) { +
+
Loan Classification
+
+ Mudra Category + + Shishu (up to ₹50,000) + Kishore (₹50,001 – ₹5,00,000) + Tarun (₹5,00,001 – ₹10,00,000) + + Mudra Loan Account No. + + Lending Institution Name + + Sanctioned Amount (₹) + + Disbursement Date + + + Loan Purpose + + Manufacturing + Trading + Services + Agriculture Allied Activity + Transport + Working Capital + +
+ +
Scheme Details
+
+ MUDRA Card Number + + Current Outstanding (₹) + + Repayment Status + + Regular + Overdue (1–30 days) + Overdue (31–90 days) + NPA + +
+
+
NPA FlagMark if account classified as Non-Performing Asset
+ +
+ +
+ PSL Tagging: Mudra loans must be tagged at both entity and account level for FIDD PSL returns. NPA flag mandatory for RBI quarterly reporting. +
+ +
+ +
+
+ } +
+ + +
+
+
+ EOU +
+
Export Oriented Unit
+
Customs-registered EOU under DGFT / Ministry of Commerce
+
+
+
+ + expand_more +
+
+ @if (expanded['eou']) { +
+
EOU Registration
+
+ EOU Registration Number + + Registered DC Zone + + Mumbai (SEEPZ) + Delhi (NSEZ) + Chennai (MEPZ) + Bangalore (BEPZ) + Kolkata (FALTA) + Hyderabad (VSEZ) + Cochin (CSEZ) + Other + + Bond / LUT Number + + Validity From + + + Validity To + + + GSTIN under EOU Scheme + +
+ +
Export Obligation & Compliance
+
+ Annual Export Obligation (₹ Cr) + + NFE Target (₹ Cr) + + SoftEx Filing Status + + CurrentPendingNot Applicable + + BRC Submission Status + + CurrentPendingNot Applicable + + AD Category Bank Code + + FEMA Declaration Date + + +
+ +
+ FEMA / RBI: EOU accounts must be tagged for FEMA compliance on forex receipts. AD Category-I bank declaration mandatory. EDPMS reporting required for all export proceeds. +
+ +
+ +
+
+ } +
+ + +
+
+
+ DGFT / IEC +
+
Import Export Business
+
Entity with valid DGFT Importer Exporter Code
+
+
+
+ + expand_more +
+
+ @if (expanded['importexport']) { +
+
IEC Registration
+
+ IEC Code (10-digit) + + IEC Issue Date + + + IEC Status + + ActiveSuspended + CancelledDeactivated + + Trade Type + + Export OnlyImport Only + Both Import & Export + + Primary HS Code (Commodity) + + Key Trading Countries + +
+ +
Banking & Compliance
+
+ AD Category Bank Code + + ECGC Policy Number + + ECGC Policy Expiry + + +
+
+
+
EEFC Account RequiredExchange Earners Foreign Currency account
+ +
+
+
PCFC Facility RequiredPre-shipment Credit in Foreign Currency
+ +
+
+
FEMA Declaration ObtainedForm A2 / LRS declaration on file
+ +
+
+ +
+ RBI / FEMA: IEC mandatory before any import/export transaction. AD bank must report all export proceeds via EDPMS and import payments via IDPMS. ECGC cover recommended for high-risk countries. +
+ +
+ +
+
+ } +
+ + +
+
+
+ RBI PSL +
+
Priority Sector Borrower
+
Eligible under RBI Priority Sector Lending guidelines
+
+
+
+ + expand_more +
+
+ @if (expanded['psl']) { +
+
PSL Classification
+
+ PSL Category + + Agriculture + MSME + Housing + Education + Social Infrastructure + Renewable Energy + Export Credit + Weaker Section + Others + + PSL Sub-category + + Direct Agriculture — Crop Loan + Direct Agriculture — Investment + Allied Agriculture + Micro Enterprise + Small Enterprise + Medium Enterprise + Housing (up to ₹35L) + Education Loan + Weaker Section — SC/ST + Weaker Section — Minorities + Export Credit + Others — Startup India + + PSL Loan Purpose Code + + PSL Eligibility Basis + + FIDD Reporting Code + + PSL Sanctioned Limit (₹) + +
+ + + @if (pslForm.get('pslCategory')?.value === 'MICRO_ENTERPRISE') { +
MSME Sub-classification
+
+ MSME Classification + + Micro (T/O ≤ ₹5 Cr, Inv ≤ ₹1 Cr) + Small (T/O ≤ ₹50 Cr, Inv ≤ ₹10 Cr) + Medium (T/O ≤ ₹250 Cr, Inv ≤ ₹50 Cr) + + Udyam Registration Number + + Udyam Registration Date + + + Annual Turnover (₹ Cr) + + Investment — Plant & Machinery (₹ Cr) + +
+ } + +
Special Category Flags
+
+
+
Weaker Section FlagSHG, JLG, SC/ST, OBC, Minorities, PWD borrowers
+ +
+
+
SC / ST Borrower FlagScheduled Caste or Scheduled Tribe classification
+ +
+
+
Women Entrepreneur FlagMajority-owned / managed by women
+ +
+
+
Minority Community FlagAs defined by Ministry of Minority Affairs
+ +
+
+ +
+ RBI FIDD Circular: PSL tagging mandatory at entity AND loan account level. MSME classification must be verified from Udyam portal — self-declaration not acceptable. Weaker section loans reported separately in FIDD quarterly returns. +
+ +
+ +
+
+ } +
+ + +
+
+
+ General +
+
Non-Priority Sector
+
Standard corporate / retail borrower — no special priority sector classification
+
+
+
+ + expand_more +
+
+ @if (expanded['nps']) { +
+
Sector Classification
+
+ Industry Sector + + Large Corporate + Mid-Market Corporate + High Net Worth Individual (HNI) + Retail — Salaried + Retail — Self Employed + Government / PSU + Trust / NGO / Society + Real Estate + Other + + NIC Code (5-digit) + + Industry Description + + Annual Revenue Band + + Below ₹1 Cr + ₹1 Cr – ₹10 Cr + ₹10 Cr – ₹100 Cr + ₹100 Cr – ₹500 Cr + Above ₹500 Cr + +
+ +
Risk Classification
+
+ Risk Category + + Low Risk + Medium Risk + High Risk + + Customer Risk Score + + AML Risk Classification + + Standard Due Diligence (SDD) + Customer Due Diligence (CDD) + Enhanced Due Diligence (EDD) + +
+
+
+
PEP Flag — Politically Exposed PersonRequires mandatory EDD as per RBI KYC Master Circular
+ +
+
+
AML Watchlist MatchUN Sanctions / OFAC / EU / MHA match detected
+ +
+
+
Adverse Media FlagNegative news screening result — requires review
+ +
+
+ +
+ RBI KYC Master Circular 2023: PEP flag triggers mandatory EDD regardless of transaction size. AML watchlist match must be escalated to MLRO within 24 hours. Risk score reviewed annually for High Risk customers. +
+ +
+ +
+
+ } +
+ + @if (!clientId) { +
+ + Saving classifications requires client to be created first. Complete General tab and click Next. +
+ } +
diff --git a/src/app/clients/client-stepper/client-entity-kyc-step/client-entity-kyc-step.component.scss b/src/app/clients/client-stepper/client-entity-kyc-step/client-entity-kyc-step.component.scss new file mode 100644 index 000000000..083f40308 --- /dev/null +++ b/src/app/clients/client-stepper/client-entity-kyc-step/client-entity-kyc-step.component.scss @@ -0,0 +1,206 @@ +/** + * Entity Classification — Scheme & Sector Classification + */ + +.ec-page { padding: 0; } + +.step-header { + background: #0f172a; + padding: 16px 24px; + border-radius: 10px 10px 0 0; + margin-bottom: 20px; + &__left { display: flex; align-items: center; gap: 12px; } + &__icon { font-size: 18px; color: #60a5fa; } + &__title { font-size: 16px; font-weight: 600; color: #fff; margin: 0; } + &__sub { font-size: 11px; color: rgba(255,255,255,0.5); margin: 2px 0 0; } +} + +.ec-chips-label { + font-size: 11px; + font-weight: 600; + color: #94a3b8; + letter-spacing: 0.06em; + text-transform: uppercase; + margin-bottom: 8px; +} + +.ec-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 20px; + min-height: 28px; +} + +.ec-no-chip { font-size: 12px; color: #94a3b8; padding: 4px 0; } + +.ec-chip { + padding: 4px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 500; + display: flex; + align-items: center; + gap: 5px; +} + +.ec-chip-close { + font-size: 14px !important; + width: 14px !important; + height: 14px !important; + cursor: pointer; + opacity: 0.6; + &:hover { opacity: 1; } +} + +.ec-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + margin-bottom: 12px; + overflow: hidden; + transition: border-color 0.2s; + &--open { border-color: #93c5fd; } +} + +.ec-card-head { + padding: 14px 20px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + user-select: none; + transition: background 0.15s; + &:hover { background: #f8fafc; } +} + +.ec-card-head-left { display: flex; align-items: center; gap: 12px; } + +.ec-tag { + padding: 3px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + white-space: nowrap; +} + +.ec-card-title { font-size: 14px; font-weight: 600; color: #1e293b; } +.ec-card-desc { font-size: 12px; color: #64748b; margin-top: 2px; } +.ec-card-toggle { display: flex; align-items: center; gap: 10px; } + +.ec-chevron { + transition: transform 0.25s; + color: #94a3b8; + &.open { transform: rotate(180deg); } +} + +.ec-card-body { + border-top: 1px solid #f1f5f9; + padding: 20px; +} + +.ec-grid-3 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px 16px; + margin-bottom: 8px; + mat-form-field { width: 100%; } +} + +.ec-grid-2 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px 16px; + margin-bottom: 8px; + mat-form-field { width: 100%; } +} + +.ec-sub-title { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.07em; + color: #94a3b8; + text-transform: uppercase; + margin: 16px 0 12px; + padding-top: 12px; + border-top: 1px dashed #e2e8f0; + &:first-child { margin-top: 0; padding-top: 0; border-top: none; } +} + +.ec-check-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + margin-bottom: 8px; + mat-checkbox { + padding: 8px 12px; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 12px; + transition: background 0.12s, border-color 0.12s; + &:hover { background: #f8fafc; border-color: #93c5fd; } + } +} + +.ec-flags { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 8px; +} + +.ec-flag-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: #f8fafc; + border: 1px solid #f1f5f9; + border-radius: 6px; +} + +.ec-flag-label { font-size: 13px; font-weight: 500; color: #1e293b; display: block; } +.ec-flag-sub { font-size: 11px; color: #94a3b8; display: block; margin-top: 1px; } + +.ec-reg-note { + margin-top: 16px; + padding: 10px 14px; + background: #f0f9ff; + border-left: 3px solid #0ea5e9; + border-radius: 0 6px 6px 0; + font-size: 12px; + color: #0c4a6e; + line-height: 1.6; + strong { font-weight: 600; } +} + +.ec-card-actions { + display: flex; + justify-content: flex-end; + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid #f1f5f9; + button { display: flex; align-items: center; gap: 6px; } +} + +.ec-info-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 8px; + font-size: 12px; + color: #92400e; + margin-top: 12px; +} + +@media (max-width: 900px) { .ec-grid-3 { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 600px) { + .ec-grid-3, .ec-grid-2 { grid-template-columns: 1fr; } + .ec-check-grid { grid-template-columns: 1fr; } + .ec-card-head { padding: 12px 14px; } + .ec-card-body { padding: 14px; } +} diff --git a/src/app/clients/client-stepper/client-entity-kyc-step/client-entity-kyc-step.component.ts b/src/app/clients/client-stepper/client-entity-kyc-step/client-entity-kyc-step.component.ts new file mode 100644 index 000000000..fb75cf2be --- /dev/null +++ b/src/app/clients/client-stepper/client-entity-kyc-step/client-entity-kyc-step.component.ts @@ -0,0 +1,311 @@ +/** + * Copyright since 2025 Mifos Initiative + */ + +import { Component, Input, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatSlideToggle } from '@angular/material/slide-toggle'; +import { ClientsService } from '../../clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; + +export interface CategoryMeta { + id: string; + label: string; + tag: string; + tagColor: string; + tagBg: string; + desc: string; + icon: string; +} + +@Component({ + selector: 'mifosx-client-entity-kyc-step', + templateUrl: './client-entity-kyc-step.component.html', + styleUrls: ['./client-entity-kyc-step.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatCheckbox, + MatIcon, + MatTooltip, + MatProgressSpinner, + MatSlideToggle + ] +}) +export class ClientEntityKycStepComponent implements OnInit, OnChanges { + private fb = inject(UntypedFormBuilder); + private clientService = inject(ClientsService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + @Input() clientId: string = ''; + @Input() constitutionType: string = ''; + /** Read-only mode — disables form editing until user clicks edit icon */ + @Input() readOnly = false; + + today = new Date(); + + /** Category metadata */ + categories: CategoryMeta[] = [ + { id: 'msme', label: 'MSME', tag: 'Udyam / MSME', tagColor: '#0A5C6B', tagBg: '#E6F4F6', desc: 'Micro, Small & Medium Enterprise — Ministry of MSME / Udyam Registration', icon: 'cogs' }, + { id: 'startup', label: 'Startup India Registered', tag: 'DPIIT', tagColor: '#1A5FAB', tagBg: '#EAF2FB', desc: 'DPIIT recognition under Startup India initiative', icon: 'rocket' }, + { id: 'mudra', label: 'Mudra Borrower', tag: 'PMMY', tagColor: '#8A5A00', tagBg: '#FEF3DC', desc: 'Pradhan Mantri Mudra Yojana — SIDBI / Mudra scheme', icon: 'hand-holding-usd' }, + { id: 'eou', label: 'Export Oriented Unit', tag: 'EOU', tagColor: '#1B7A4A', tagBg: '#E8F5EE', desc: 'Customs-registered EOU under DGFT / Ministry of Commerce', icon: 'ship' }, + { id: 'importexport', label: 'Import Export Business', tag: 'DGFT / IEC', tagColor: '#5B3A8A', tagBg: '#F0EBF8', desc: 'Entity with valid DGFT Importer Exporter Code', icon: 'exchange-alt' }, + { id: 'psl', label: 'Priority Sector Borrower', tag: 'RBI PSL', tagColor: '#7B1F1F', tagBg: '#FCEAEA', desc: 'Eligible under RBI Priority Sector Lending guidelines', icon: 'bullseye' }, + { id: 'nps', label: 'Non-Priority Sector', tag: 'General', tagColor: '#444441', tagBg: '#F1EFE8', desc: 'Standard corporate — no special priority sector classification', icon: 'building' } + ]; + + /** Toggle & expand state per category */ + enabled: Record = {}; + expanded: Record = {}; + + /** Forms per category */ + msmeForm!: UntypedFormGroup; + startupForm!: UntypedFormGroup; + mudraForm!: UntypedFormGroup; + eouForm!: UntypedFormGroup; + importexportForm!: UntypedFormGroup; + pslForm!: UntypedFormGroup; + npsForm!: UntypedFormGroup; + + /** Saving state */ + saving: Record = {}; + saved: Record = {}; + + ngOnInit() { + this.initForms(); + if (this.clientId) { + this.loadExisting(); + } + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['readOnly']) { + const forms = [this.msmeForm, this.startupForm, this.mudraForm, this.eouForm, this.importexportForm, this.pslForm, this.npsForm]; + for (const form of forms) { + if (!form) continue; + if (this.readOnly) { + form.disable({ emitEvent: false }); + } else { + form.enable({ emitEvent: false }); + } + } + } + } + + private initForms() { + this.msmeForm = this.fb.group({ + udyamRegistrationNumber: [''], + registrationDate: [''], + registrationStatus: [''], + msmeClassification: [''], + enterpriseType: [''], + nicActivityCode: [''], + annualTurnover: [''], + investmentPlantMachinery: [''], + financialYearAssessment: [''], + socialCategoryOwner: [''], + genderPrimaryOwner: [''], + noOfEmployees: [''], + womenLedEnterprise: [false], + divyangPwdOwned: [false] + }); + + this.startupForm = this.fb.group({ + dpiitRecognitionNumber: [''], + recognitionDate: [''], + certificateExpiryDate: [''], + entityStage: [''], + sectorAsDpiit: [''], + nicIndustryCode: [''], + taxExemption80iac: [false], + sidbiFund: [false], + selfCertification: [false], + fastTrackPatent: [false], + govtTenderExemption: [false], + creditGuaranteeCgss: [false], + pslLoanLimit: [''], + pslSubCategory: [''] + }); + + this.mudraForm = this.fb.group({ + mudraCategory: [''], + mudraLoanAccountNo: [''], + lendingInstitutionName: [''], + sanctionedAmount: [''], + disbursementDate: [''], + loanPurpose: [''], + mudraCardNumber: [''], + currentOutstanding: [''], + repaymentStatus: [''], + npaFlag: [false] + }); + + this.eouForm = this.fb.group({ + eouRegistrationNumber: [''], + registeredDcZone: [''], + bondLutNumber: [''], + validityFrom: [''], + validityTo: [''], + gstinEou: [''], + annualExportObligation: [''], + nfeTarget: [''], + softexFilingStatus: [''], + brcSubmissionStatus: [''], + adCategoryBankCode: [''], + femaDeclarationDate: [''] + }); + + this.importexportForm = this.fb.group({ + iecCode: [''], + iecIssueDate: [''], + iecStatus: [''], + tradeType: [''], + primaryHsCode: [''], + keyTradingCountries: [''], + adCategoryBankCode: [''], + ecgcPolicyNumber: [''], + ecgcPolicyExpiry: [''], + eefcAccountRequired: [false], + pcfcFacilityRequired: [false], + femaDeclarationObtained: [false] + }); + + this.pslForm = this.fb.group({ + pslCategory: [''], + pslSubCategory: [''], + pslLoanPurposeCode: [''], + pslEligibilityBasis: [''], + fiddReportingCode: [''], + pslSanctionedLimit: [''], + msmeClassification: [''], + udyamRegistrationNumber: [''], + udyamRegistrationDate: [''], + annualTurnover: [''], + investmentPlantMachinery: [''], + weakerSectionFlag: [false], + scStBorrowerFlag: [false], + womenEntrepreneurFlag: [false], + minorityCommunityFlag: [false] + }); + + this.npsForm = this.fb.group({ + industrySector: [''], + nicCode: [''], + industryDescription: [''], + annualRevenueBand: [''], + riskCategory: [''], + customerRiskScore: [''], + amlRiskClassification: [''], + pepFlag: [false], + amlWatchlistMatch: [false], + adverseMediaFlag: [false] + }); + } + + getForm(categoryId: string): UntypedFormGroup { + switch (categoryId) { + case 'msme': return this.msmeForm; + case 'startup': return this.startupForm; + case 'mudra': return this.mudraForm; + case 'eou': return this.eouForm; + case 'importexport': return this.importexportForm; + case 'psl': return this.pslForm; + case 'nps': return this.npsForm; + default: return this.fb.group({}); + } + } + + toggleCategory(id: string) { + this.enabled[id] = !this.enabled[id]; + if (this.enabled[id]) { + this.expanded[id] = true; + } + } + + toggleExpand(id: string) { + this.expanded[id] = !this.expanded[id]; + } + + get enabledCategories(): CategoryMeta[] { + return this.categories.filter(c => this.enabled[c.id]); + } + + get enabledCount(): number { + return this.enabledCategories.length; + } + + removeCategory(id: string) { + this.enabled[id] = false; + this.expanded[id] = false; + } + + /** Save a single category */ + saveCategory(categoryId: string) { + if (!this.clientId) return; + this.saving[categoryId] = true; + const form = this.getForm(categoryId); + const payload: any = { ...form.value, enabled: true, locale: 'en', dateFormat: this.settingsService.dateFormat }; + + // Format date fields + for (const key of Object.keys(payload)) { + if (payload[key] instanceof Date) { + payload[key] = this.dateUtils.formatDate(payload[key], this.settingsService.dateFormat); + } + } + + this.clientService.saveEntityClassification(this.clientId, categoryId, payload).subscribe({ + next: () => { + this.saving[categoryId] = false; + this.saved[categoryId] = true; + setTimeout(() => this.saved[categoryId] = false, 3000); + }, + error: () => { + this.saving[categoryId] = false; + } + }); + } + + /** Load existing classifications */ + private loadExisting() { + this.clientService.getEntityClassifications(this.clientId).subscribe({ + next: (data: any) => { + if (!data) return; + for (const cat of this.categories) { + const catData = data[cat.id]; + if (catData && catData.enabled) { + this.enabled[cat.id] = true; + this.getForm(cat.id).patchValue(catData); + } + } + } + }); + } + + /** Get all classification data for parent */ + get classificationData(): any { + const result: any = {}; + for (const cat of this.categories) { + if (this.enabled[cat.id]) { + result[cat.id] = { ...this.getForm(cat.id).value, enabled: true }; + } + } + return result; + } + + get hasClassificationData(): boolean { + return this.enabledCount > 0; + } + + // Keep old getter for backward compatibility + get entityKycData(): any { return this.classificationData; } + get hasEntityKycData(): boolean { return this.hasClassificationData; } +} diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html index 416c1b7d1..9053104fc 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html @@ -1,116 +1,358 @@ -

{{ data.context }} {{ 'labels.heading.Family Member' | translate }}

+
+
+ people + {{ data.context }} Family Member +
-
-

{{ 'labels.heading.Nominee Details (DA1 / DA5)' | translate }}

+ + -
- - - {{ 'labels.inputs.Nomination Type' | translate }} - - {{ 'labels.inputs.DA1 - Add Nominee' | translate }} - {{ 'labels.inputs.DA5 - Modify / Cancel Nominee' | translate }} - - + + @if (isViewMode) { + + @if (entryMode === 'linked' && selectedClient) { +
+
+ @if (linkedProfileImage) { + Profile + } @else { + person + } +
+
+ {{ selectedClient.displayName }} + Client ID: {{ selectedClient.id }}{{ selectedClient.accountNumber ? ' · A/C: ' + selectedClient.accountNumber : '' }} +
+ @if (linkedSignatureImage) { +
+ Signature + Signature +
+ } +
+ } - - - {{ 'labels.inputs.Nominee Name' | translate }} - - @if (familyMemberForm.controls.firstName.hasError('required')) { - - {{ 'labels.inputs.Nominee Name' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - + @if (fetchingLinkedData) { +
+ + Fetching client details... +
+ } - - - {{ 'labels.inputs.Relationship' | translate }} - - @for (relation of data.options.relationshipIdOptions; track relation) { - - {{ relation.name | translateKey: 'catalogs' }} - + +
+
+ Full Name + {{ familyMemberForm.get('firstName')?.value || '—' }} +
+
+ Relationship + {{ relationshipName }} +
+
+ Date of Birth + {{ familyMemberForm.get('dateOfBirth')?.value ? (familyMemberForm.get('dateOfBirth')?.value | date:'dd MMM yyyy') : '—' }} +
+
+ Nationality + {{ familyMemberForm.get('nationality')?.value || '—' }} +
+
+ Aadhaar + + {{ familyMemberForm.get('aadhaarNumber')?.value || '—' }} + @if (linkedKycStatus?.aadhaarVerified) { + check_circle Verified + } @else if (familyMemberForm.get('aadhaarNumber')?.value) { + cancel Not Verified + } + +
+
+ PAN + + {{ familyMemberForm.get('panNumber')?.value || '—' }} + @if (linkedKycStatus?.panVerified) { + check_circle Verified + } @else if (familyMemberForm.get('panNumber')?.value) { + cancel Not Verified + } + +
+
+ Status + + {{ familyMemberForm.get('isAlive')?.value !== false ? 'Alive' : 'Deceased' }} + @if (familyMemberForm.get('isDependent')?.value) { · Dependent } + +
+ @if (isSpouse && familyMemberForm.get('marriageDate')?.value) { +
+ Marriage Date + {{ familyMemberForm.get('marriageDate')?.value | date:'dd MMM yyyy' }} +
+ } + @if (isSpouse && familyMemberForm.get('marriageAuthority')?.value) { +
+ Issuing Authority + {{ getMarriageAuthorityLabel(familyMemberForm.get('marriageAuthority')?.value) }} +
+ } +
+ @if (familyMemberForm.get('remarks')?.value) { +
+ Remarks + {{ familyMemberForm.get('remarks')?.value }} +
} -
- @if (familyMemberForm.controls.relationshipId.hasError('required')) { - - {{ 'labels.inputs.Relationship' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - } -
- - - {{ 'labels.inputs.Date Of Birth' | translate }} - - - - + + @if (!isViewMode) { + +
+ + +
- - - {{ 'labels.inputs.Percentage Share' | translate }} (%) - - + + @if (entryMode === 'linked') { + @if (!selectedClient) { +
+ + Search by Name or Client ID + + search + + @if (searching) { +
+
Searching...
+
+ } + @if (!searching && searchResults.length > 0) { +
+ @for (r of searchResults; track r.id) { +
+ person +
+ {{ r.displayName }} + ID: {{ r.id }} · {{ r.officeName }}@if (r.dateOfBirth) { · DOB: {{ r.dateOfBirth | dateFormat }} }@if (r.fatherSpouseName) { · {{ r.fatherSpouseName }} }@if (r.mobileNo) { · {{ r.mobileNo }} } +
+
+ } +
+ } + @if (!searching && searchText.length >= 2 && searchResults.length === 0) { +
No clients found
+ } +
+ } + @if (selectedClient) { + +
+
+ @if (linkedProfileImage) { + Profile + } @else { + person + } +
+
+ {{ selectedClient.displayName }} + Client ID: {{ selectedClient.id }}{{ selectedClient.accountNumber ? ' · A/C: ' + selectedClient.accountNumber : '' }} +
+ @if (linkedSignatureImage) { +
+ Signature + Signature +
+ } + +
- - - {{ 'labels.inputs.Nominee is Minor' | translate }}? - -
+ @if (fetchingLinkedData) { +
+ + Fetching client details... +
+ } - - @if (familyMemberForm.get('isDependent').value) { -

{{ 'labels.heading.Guardian Details (If Minor)' | translate }}

-
- - {{ 'labels.inputs.Guardian Name' | translate }} - - + + @if (!fetchingLinkedData) { +
+
+ Full Name + {{ familyMemberForm.get('firstName')?.value || '—' }} +
+
+ Date of Birth + {{ familyMemberForm.get('dateOfBirth')?.value ? (familyMemberForm.get('dateOfBirth')?.value | date:'dd MMM yyyy') : '—' }} +
+
+ Aadhaar + + {{ familyMemberForm.get('aadhaarNumber')?.value || '—' }} + @if (linkedKycStatus?.aadhaarVerified) { + check_circle Verified + } @else if (familyMemberForm.get('aadhaarNumber')?.value) { + cancel Not Verified + } + +
+
+ PAN + + {{ familyMemberForm.get('panNumber')?.value || '—' }} + @if (linkedKycStatus?.panVerified) { + check_circle Verified + } @else if (familyMemberForm.get('panNumber')?.value) { + cancel Not Verified + } + +
+
+ } + } + } - - {{ 'labels.inputs.Relationship with Minor' | translate }} - - -
- } + +
+ @if (entryMode === 'manual') { + + Full Name + + + } - -
- - {{ 'labels.inputs.Remarks' | translate }} - - -
+ + Relationship + + @for (rel of data.options.relationshipIdOptions; track rel) { + {{ rel.name }} + } + + - - - + @if (entryMode === 'manual') { + + Date of Birth + + + + + } + + + Nationality + + Indian + NRI + Foreign National + Other + + +
+ + + @if (isSpouse) { + +
+ + Marriage Date + + + + + + + Issuing Authority + + @for (auth of marriageAuthorityOptions; track auth.value) { + {{ auth.label }} + } + + +
+ + +
+ @if (!marriageCertFile) { + + + PDF, JPG or PNG (max 5 MB) + } @else { +
+ description + {{ marriageCertFileName }} + +
+ } +
+ } + + + @if (entryMode === 'manual') { + +
+ + Aadhaar Number + + + + + PAN Number + + @if (familyMemberForm.get('panNumber')?.hasError('pattern') && familyMemberForm.get('panNumber')?.touched) { + Invalid PAN format + } + +
+ } + + +
+ Is Alive + Is Dependent +
+ + + + Remarks + + + } + +
+ + + @if (isViewMode) { + + } @else { + + + } - +
diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss index ff3254c01..1cd1dc0fe 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss @@ -1,34 +1,330 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ +.fm-dialog__header { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 20px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + font-size: 16px; + font-weight: 600; + color: #1e293b; -.mat-form-field-hint { - font-size: 75%; - color: rgb(0 0 0 / 60%); - margin-top: 4px; + .fm-dialog__icon { color: #1074b9; } +} + +.fm-mode-toggle { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.fm-mode-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: 1.5px solid #e2e8f0; + border-radius: 8px; + background: #fff; + font-size: 13px; + font-weight: 500; + color: #64748b; + cursor: pointer; + transition: all 0.15s; + + mat-icon { font-size: 18px; width: 18px; height: 18px; } + + &.active { + border-color: #1074b9; + background: #eff6ff; + color: #1074b9; + } + + &:hover:not(.active) { border-color: #94a3b8; } +} + +.fm-search-wrapper { position: relative; margin-bottom: 12px; } +.fm-search-field { width: 100%; } + +.fm-search-results { + position: absolute; + top: 56px; + left: 0; + right: 0; + z-index: 100; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + max-height: 220px; + overflow-y: auto; +} + +.fm-search-loading { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px; + font-size: 13px; + color: #64748b; +} + +.fm-search-empty { + padding: 14px 16px; + font-size: 13px; + color: #94a3b8; + text-align: center; +} + +.search-no-results-hint { + font-size: 12px; + color: #94a3b8; + text-align: center; + padding: 4px 0 0; + margin-top: -8px; +} + +.fm-search-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + cursor: pointer; + border-bottom: 1px solid #f1f5f9; + + &:last-child { border-bottom: none; } + &:hover { background: #f0f7ff; } + + &__icon { color: #1074b9; font-size: 20px; } + &__info { display: flex; flex-direction: column; } + &__name { font-size: 13px; font-weight: 600; color: #1e293b; } + &__meta { font-size: 11px; color: #64748b; } +} + +.fm-linked-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + margin-bottom: 16px; + + &__left { flex-shrink: 0; } + &__icon { color: #1074b9; } + &__photo { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + border: 2px solid #e2e8f0; + } + &__info { flex: 1; display: flex; flex-direction: column; } + &__name { font-size: 14px; font-weight: 600; color: #1e293b; } + &__meta { font-size: 12px; color: #64748b; font-family: 'Roboto Mono', monospace; } + &__sig { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + flex-shrink: 0; + + img { + height: 28px; + max-width: 70px; + object-fit: contain; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #fff; + padding: 2px 4px; + } + span { + font-size: 9px; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.04em; + } + } + &__remove { color: #94a3b8 !important; &:hover { color: #ef4444 !important; } } +} + +.fm-fetching-hint { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #64748b; + margin: -8px 0 12px; +} + +.fm-readonly-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2px 16px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 12px 16px; + margin-bottom: 16px; +} + +.fm-readonly-item { + display: flex; + flex-direction: column; + padding: 6px 0; +} + +.fm-readonly-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #94a3b8; + margin-bottom: 2px; +} + +.fm-readonly-value { + font-size: 13px; + font-weight: 500; + color: #1e293b; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.fm-readonly-remarks { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 10px 16px; + margin-bottom: 8px; + + .fm-readonly-label { display: block; margin-bottom: 4px; } + .fm-readonly-value { font-size: 13px; color: #475569; line-height: 1.5; } +} + +.fm-kyc-badge { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 10px; + white-space: nowrap; + + mat-icon { + font-size: 12px; + width: 12px; + height: 12px; + } + + &.verified { + background: #dcfce7; + color: #16a34a; + } + &.not-verified { + background: #fef3c7; + color: #d97706; + } +} + +.fm-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 16px; margin-bottom: 8px; } -form { - padding: 0 1.5rem; +.fm-section-label { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #1074b9; + margin: 8px 0 12px; + padding-bottom: 6px; + border-bottom: 1px solid #e2e8f0; +} - .layout-row-wrap { - width: 100%; - } +.fm-checkbox-row { + display: flex; + align-items: center; + gap: 20px; + padding: 8px 0; +} - @media screen and (width >= 960px) { - .layout-row-wrap > .flex-fill:nth-child(odd), - .layout-row-wrap > .flex-50-minus-25px:nth-child(odd) { - margin-right: 25px; - } +.fm-full-width { + width: 100%; +} - .layout-row-wrap > .flex-fill:last-child, - .layout-row-wrap > .flex-50-minus-25px:last-child { - margin-right: 0; - } +.zoomable { + cursor: zoom-in; + transition: transform 0.15s ease, box-shadow 0.15s ease; + + &:hover { + transform: scale(1.08); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); + } +} + +::ng-deep .image-zoom-panel { + .mat-mdc-dialog-container { padding: 0 !important; border-radius: 10px !important; overflow: hidden; } +} + +.fm-file-upload { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; +} + +.fm-file-btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + color: #1074b9; + border-color: #cbd5e1; + + mat-icon { font-size: 18px; width: 18px; height: 18px; } +} + +.fm-file-hint { + font-size: 11px; + color: #94a3b8; +} + +.fm-file-attached { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: #f0f7ff; + border: 1px solid #bfdbfe; + border-radius: 6px; + width: 100%; + + &__icon { color: #1074b9; font-size: 20px; } + &__name { + flex: 1; + font-size: 13px; + font-weight: 500; + color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + &__remove { + color: #94a3b8 !important; + width: 28px !important; + height: 28px !important; + line-height: 28px !important; + + mat-icon { font-size: 16px; width: 16px; height: 16px; } + &:hover { color: #ef4444 !important; } } } diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts index df589a286..883a3f56e 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts @@ -6,26 +6,28 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -/** Angular Imports */ import { Component, OnInit, inject } from '@angular/core'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { + MatDialog, MatDialogRef, MAT_DIALOG_DATA, MatDialogTitle, MatDialogActions, - MatDialogClose + MatDialogClose, + MatDialogContent } from '@angular/material/dialog'; -import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; - -/** Custom Services */ +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; +import { ClientsService } from 'app/clients/clients.service'; import { MatCheckbox } from '@angular/material/checkbox'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; -/** - * Client Family Members Dialog - */ @Component({ selector: 'mifosx-client-family-member-dialog', templateUrl: './client-family-member-dialog.component.html', @@ -33,9 +35,14 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, MatDialogTitle, + MatDialogContent, MatCheckbox, MatDialogActions, - MatDialogClose + MatDialogClose, + MatIcon, + MatProgressSpinner, + FaIconComponent, + DateFormatPipe ] }) export class ClientFamilyMemberDialogComponent implements OnInit { @@ -44,87 +51,360 @@ export class ClientFamilyMemberDialogComponent implements OnInit { private dateUtils = inject(Dates); data = inject(MAT_DIALOG_DATA); private settingsService = inject(SettingsService); + private clientsService = inject(ClientsService); + private sanitizer = inject(DomSanitizer); + private dialog = inject(MatDialog); - /** Maximum Due Date allowed. */ maxDate = new Date(); - - /** Add/Edit family member form. */ familyMemberForm: UntypedFormGroup; + /** Client search */ + searchText = ''; + searchResults: any[] = []; + searching = false; + selectedClient: any = null; + private searchTimer: any = null; + + /** Mode: 'manual' or 'linked' */ + entryMode: 'manual' | 'linked' = 'manual'; + + /** Marriage authority options */ + marriageAuthorityOptions = [ + { value: 'SUB_REGISTRAR_OFFICE', label: 'Sub-Registrar Office' }, + { value: 'MARRIAGE_REGISTRAR', label: 'Registrar of Marriages' }, + { value: 'COURT_MARRIAGE_OFFICER', label: 'Court Marriage Officer' }, + { value: 'MUNICIPAL_AUTHORITY', label: 'Municipal Corporation / Nagar Palika' }, + { value: 'EMBASSY_CONSULATE', label: 'Indian Embassy / Consulate' }, + { value: 'DEFENCE_AUTHORITY', label: 'Defence Authority' } + ]; + + /** Whether selected relationship is Spouse */ + get isSpouse(): boolean { + const relId = this.familyMemberForm?.get('relationshipId')?.value; + if (!relId || !this.data?.options?.relationshipIdOptions) return false; + const rel = this.data.options.relationshipIdOptions.find((r: any) => r.id === relId); + return rel?.name?.toUpperCase() === 'SPOUSE'; + } + + /** Whether dialog is in View-only mode */ + get isViewMode(): boolean { return this.data.context === 'View'; } + + /** Get relationship name from ID */ + get relationshipName(): string { + const relId = this.familyMemberForm?.get('relationshipId')?.value; + if (!relId || !this.data?.options?.relationshipIdOptions) return '—'; + const rel = this.data.options.relationshipIdOptions.find((r: any) => r.id === relId); + return rel?.name || '—'; + } + + /** Open image in zoom overlay */ + zoomImage(imageSrc: any, title: string): void { + this.dialog.open(ImageZoomDialogComponent, { + data: { imageSrc, title }, + panelClass: 'image-zoom-panel', + maxWidth: '90vw', + maxHeight: '90vh' + }); + } + + /** Get marriage authority display label */ + getMarriageAuthorityLabel(value: string): string { + return this.marriageAuthorityOptions.find(a => a.value === value)?.label || value || '—'; + } + + /** Marriage certificate file */ + marriageCertFile: File | null = null; + marriageCertFileName: string = ''; + + /** Linked client fetched data */ + fetchingLinkedData = false; + linkedProfileImage: SafeUrl | null = null; + linkedSignatureImage: SafeUrl | null = null; + /** KYC verification status for linked client */ + linkedKycStatus: { + panVerified?: boolean; + aadhaarVerified?: boolean; + panHolderName?: string; + aadhaarHolderName?: string; + riskCategory?: number; + } | null = null; + ngOnInit() { this.maxDate = this.settingsService.businessDate; this.createFamilyMemberForm(); - if (this.data.context === 'Edit') { + if (this.data.context === 'Edit' || this.data.context === 'View') { + const m = this.data.member; this.familyMemberForm.patchValue({ - nominationType: this.data.member.nominationType || 'DA1', - firstName: this.data.member.firstName, - relationshipId: this.data.member.relationshipId, - dateOfBirth: this.data.member.dateOfBirth && new Date(this.data.member.dateOfBirth), - percentageShare: this.data.member.percentageShare ?? 100, - isDependent: this.data.member.isDependent, - guardianName: this.data.member.guardianName || '', - guardianRelationship: this.data.member.guardianRelationship || '', - remarks: this.data.member.remarks || '' + firstName: m.firstName, + relationshipId: m.relationshipId, + dateOfBirth: m.dateOfBirth && new Date(m.dateOfBirth), + isDependent: m.isDependent, + remarks: m.remarks || '', + aadhaarNumber: m.aadhaarNumber || '', + panNumber: m.panNumber || '', + nationality: m.nationality || 'INDIAN', + isAlive: m.isAlive !== false, + marriageDate: m.marriageDate && new Date(m.marriageDate), + marriageAuthority: m.marriageAuthority || '', + linkedClientId: m.linkedClientId || '' }); + if (m.linkedClientId) { + this.entryMode = 'linked'; + this.selectedClient = { id: m.linkedClientId, displayName: m.linkedClientName || m.firstName || `Client #${m.linkedClientId}` }; + // Re-fetch linked client details (profile, signature, KYC status) + this.fetchLinkedClientData(m.linkedClientId.toString()); + } + if (this.isViewMode) { + this.familyMemberForm.disable(); + } } } - /** - * Creates Family Member Form - */ createFamilyMemberForm() { this.familyMemberForm = this.formBuilder.group({ - nominationType: ['DA1'], - firstName: [ - '', - Validators.required - ], - relationshipId: [ - '', - Validators.required - ], + firstName: ['', Validators.required], + relationshipId: ['', Validators.required], dateOfBirth: [''], - percentageShare: [100], isDependent: [false], - guardianName: [''], - guardianRelationship: [''], - remarks: [''] + remarks: [''], + aadhaarNumber: [''], + panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)], + nationality: ['INDIAN'], + isAlive: [true], + marriageDate: [''], + marriageAuthority: [''], + linkedClientId: [''] }); } - /** - * Returns Formatted Family Member - */ - get familyMember() { - const formValue = { - ...this.familyMemberForm.value - }; + /** Handle marriage certificate file selection */ + onMarriageCertSelected(event: Event): void { + const input = event.target as HTMLInputElement; + if (input.files?.length) { + this.marriageCertFile = input.files[0]; + this.marriageCertFileName = this.marriageCertFile.name; + } + input.value = ''; + } + removeMarriageCert(): void { + this.marriageCertFile = null; + this.marriageCertFileName = ''; + } + + /** Search existing clients */ + onSearch(text: string): void { + this.searchText = text; + clearTimeout(this.searchTimer); + if (!text || text.length < 2) { + this.searchResults = []; + return; + } + this.searching = true; + this.searchTimer = setTimeout(() => { + this.clientsService.searchByText(text, 0, 8).subscribe({ + next: (data: any) => { + this.searchResults = data?.content || []; + this.searching = false; + this.clientsService.enrichSearchResults(this.searchResults); + }, + error: () => { this.searchResults = []; this.searching = false; } + }); + }, 400); + } + + /** Select a client from search results and fetch full details */ + selectClient(client: any): void { + this.selectedClient = client; + this.searchResults = []; + this.searchText = ''; + this.familyMemberForm.patchValue({ + firstName: client.displayName, + linkedClientId: client.id + }); + this.fetchLinkedClientData(client.id); + } + + /** Fetch DOB, KYC, profile image & signature for linked client */ + private fetchLinkedClientData(clientId: string): void { + this.fetchingLinkedData = true; + this.linkedProfileImage = null; + this.linkedSignatureImage = null; + this.linkedKycStatus = null; + + // Fetch client details (DOB, gender etc.) + this.clientsService.getClientData(clientId).subscribe({ + next: (data: any) => { + if (data.dateOfBirth) { + this.familyMemberForm.patchValue({ dateOfBirth: new Date(data.dateOfBirth) }); + } + // Make fetched fields read-only + this.setLinkedFieldsReadonly(true); + this.fetchingLinkedData = false; + }, + error: () => { this.fetchingLinkedData = false; } + }); + + // Fetch KYC (PAN, Aadhaar, verification status) + this.clientsService.getClientKyc(clientId).subscribe({ + next: (kyc: any) => { + if (kyc) { + const patch: any = {}; + if (kyc.panNumber) { patch.panNumber = kyc.panNumber; } + if (kyc.aadhaarNumberMasked) { patch.aadhaarNumber = kyc.aadhaarNumberMasked; } + this.familyMemberForm.patchValue(patch); + this.linkedKycStatus = { + panVerified: kyc.panVerified || false, + aadhaarVerified: kyc.aadhaarVerified || false, + panHolderName: kyc.panFullName || kyc.panHolderName || '', + aadhaarHolderName: kyc.aadhaarHolderName || '', + riskCategory: kyc.riskCategory || null + }; + } + }, + error: () => {} + }); + + // Fetch profile image + this.clientsService.getClientProfileImage(clientId).subscribe({ + next: (imageData: any) => { + if (imageData) { + this.linkedProfileImage = this.sanitizer.bypassSecurityTrustResourceUrl(imageData); + } + }, + error: () => {} + }); + + // Fetch signature image + this.clientsService.getClientDocuments(clientId).subscribe({ + next: (docs: any[]) => { + const sigDoc = docs?.find((d: any) => + d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature') + ); + if (sigDoc) { + this.clientsService.getClientSignatureImage(clientId, sigDoc.id.toString()).subscribe({ + next: (blob: Blob) => { + this.linkedSignatureImage = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob)); + }, + error: () => {} + }); + } + }, + error: () => {} + }); + } + + /** Disable/enable fields that are auto-populated from linked client */ + private setLinkedFieldsReadonly(readonly: boolean): void { + const fields = ['firstName', 'dateOfBirth', 'aadhaarNumber', 'panNumber']; + fields.forEach(f => { + const ctrl = this.familyMemberForm.get(f); + if (ctrl) { + readonly ? ctrl.disable() : ctrl.enable(); + } + }); + } + + /** Remove linked client */ + removeLinkedClient(): void { + this.selectedClient = null; + this.linkedProfileImage = null; + this.linkedSignatureImage = null; + this.linkedKycStatus = null; + this.setLinkedFieldsReadonly(false); + this.familyMemberForm.patchValue({ + firstName: '', linkedClientId: '', dateOfBirth: '', aadhaarNumber: '', panNumber: '' + }); + } + + /** Switch entry mode */ + setMode(mode: 'manual' | 'linked'): void { + this.entryMode = mode; + if (mode === 'manual') { + this.removeLinkedClient(); + } + } + + get familyMember() { + const formValue = { ...this.familyMemberForm.getRawValue() }; const locale = this.settingsService.language.code; const dateFormat = this.settingsService.dateFormat; - const prevDateOfBirth: Date = formValue.dateOfBirth; - if (prevDateOfBirth) { - if (formValue.dateOfBirth instanceof Date) { - formValue.dateOfBirth = this.dateUtils.formatDate(prevDateOfBirth, dateFormat); - } - } else { + if (formValue.dateOfBirth instanceof Date) { + formValue.dateOfBirth = this.dateUtils.formatDate(formValue.dateOfBirth, dateFormat); + } else if (!formValue.dateOfBirth) { delete formValue.dateOfBirth; } - const familyMember = { - ...formValue, - dateFormat, - locale - }; + if (formValue.marriageDate instanceof Date) { + formValue.marriageDate = this.dateUtils.formatDate(formValue.marriageDate, dateFormat); + } else if (!formValue.marriageDate) { + delete formValue.marriageDate; + } - // Remove empty fields - for (const key in familyMember) { - if (familyMember[key] === '' || familyMember[key] === undefined) { - delete familyMember[key]; + if (formValue.panNumber) { + formValue.panNumber = formValue.panNumber.toUpperCase(); + } + + const member = { ...formValue, dateFormat, locale }; + + // Clean empty fields + for (const key in member) { + if (member[key] === '' || member[key] === undefined || member[key] === null) { + delete member[key]; } } - return familyMember; + // Preserve selectedClient info for display + if (this.selectedClient) { + member._linkedClient = this.selectedClient; + } + + // Attach marriage certificate file if present + if (this.marriageCertFile) { + member._marriageCertFile = this.marriageCertFile; + } + + return member; } } + +/** + * Lightweight image zoom dialog + */ +@Component({ + selector: 'mifosx-image-zoom-dialog', + template: ` +
+
+ {{ data.title }} + +
+
+ +
+
+ `, + styles: [` + .img-zoom-dialog { display: flex; flex-direction: column; } + .img-zoom-header { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 16px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; + font-size: 14px; font-weight: 600; color: #1e293b; + } + .img-zoom-body { + display: flex; align-items: center; justify-content: center; + padding: 20px; background: #fff; min-height: 200px; + } + .img-zoom-body img { + max-width: 100%; max-height: 70vh; object-fit: contain; + border-radius: 6px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); + } + `], + imports: [MatIcon, STANDALONE_SHARED_IMPORTS] +}) +export class ImageZoomDialogComponent { + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + close() { this.dialogRef.close(); } +} diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.html b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.html index 6133800b4..9251015f7 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.html +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.html @@ -1,98 +1,107 @@ -
-
-
-

- - {{ 'labels.heading.Family Members' | translate }} -

- -
- - @if (clientFamilyMembers.length === 0) { -
- - No family members / nominees added yet. +
+ +
+
+ +
+

Family Members

+

Add family members and dependents of the client.

- } - - - @for (member of clientFamilyMembers; track member; let i = $index) { - - - - - {{ member.firstName }} - - - {{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }} - - - - - -
- - -
- -
-
- {{ 'labels.inputs.Nomination Type' | translate }} - {{ member.nominationType }} -
-
- {{ 'labels.inputs.Nominee Name' | translate }} - {{ member.firstName }} -
-
- {{ 'labels.inputs.Relationship' | translate }} - {{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }} -
-
- {{ 'labels.inputs.Date Of Birth' | translate }} - {{ member.dateOfBirth | dateFormat }} -
-
- {{ 'labels.inputs.Percentage Share' | translate }} - {{ member.percentageShare }}% -
-
- {{ 'labels.inputs.Nominee is Minor' | translate }} - {{ member.isDependent ? 'Yes' : 'No' }} -
- @if (member.isDependent) { -
- {{ 'labels.inputs.Guardian Name' | translate }} - {{ member.guardianName }} -
-
- {{ 'labels.inputs.Relationship with Minor' | translate }} - {{ member.guardianRelationship }} -
- } - @if (member.remarks) { -
- {{ 'labels.inputs.Remarks' | translate }} - {{ member.remarks }} -
- } -
-
- } -
+
+ + +
+
+ + Family Members + @if (clientFamilyMembers.length > 0) { + {{ clientFamilyMembers.length }} + } +
+ @if (!readOnly) { + + } +
+ + @if (clientFamilyMembers.length === 0) { +
+ people_outline + No family members added yet. + At least one family member is required. +
+ } + + + @if (isMarried && !hasSpouse) { +
+ favorite + Marital status is Married. Consider adding spouse as a family member. +
+ } + + @if (clientFamilyMembers.length > 0) { +
+
+ Name + Relationship + Date of Birth + Aadhaar / PAN + Alive + Actions +
+ + @for (member of clientFamilyMembers; track member; let i = $index) { +
+ + {{ member.linkedClientId ? 'link' : 'person' }} +
+ {{ member.firstName || member.displayName }} + @if (member.linkedClientId) { + Linked Client #{{ member.linkedClientId }} + } +
+
+ + {{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }} + + + {{ member.dateOfBirth ? (member.dateOfBirth | dateFormat) : '—' }} + + + @if (member.panNumber) { {{ member.panNumber }} } + @else if (member.aadhaarNumber) { {{ member.aadhaarNumber }} } + @else { — } + + + @if (member.isAlive !== false) { + Yes + } @else { + No + } + + + + @if (!readOnly) { + + + } + +
+ } +
+ } +
diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.scss b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.scss index 0d6af8241..fbea465ae 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.scss +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.scss @@ -1,106 +1,606 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -.section-block { - margin-bottom: 8px; +.zoomable { + cursor: zoom-in; + transition: transform 0.15s ease, box-shadow 0.15s ease; + &:hover { transform: scale(1.08); box-shadow: 0 4px 16px rgba(0,0,0,0.18); } } -.section-header { +::ng-deep .image-zoom-panel { + .mat-mdc-dialog-container { padding: 0 !important; border-radius: 10px !important; overflow: hidden; } +} + +.search-no-results-hint { + font-size: 12px; + color: #94a3b8; + text-align: center; + padding: 4px 0 0; + margin-top: -8px; +} + +.step-header { + background: #0f172a; + padding: 16px 24px; + border-radius: 10px; + margin-bottom: 20px; + + &__left { + display: flex; + align-items: center; + gap: 12px; + } + + &__icon { + font-size: 18px; + color: #60a5fa; + } + + &__title { + font-size: 16px; + font-weight: 600; + color: #fff; + margin: 0; + } + + &__sub { + font-size: 11px; + color: rgba(255, 255, 255, 0.5); + margin: 2px 0 0; + } +} + +.fm-step__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } -.section-title { +.fm-step__title { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 600; - color: #333; - margin: 0; - - .section-icon { - color: var(--md-sys-color-primary, #1074b9); - font-size: 14px; - } + color: #1e293b; } -.add-btn { - font-size: 13px; -} +.fm-step__icon { color: #1074b9; font-size: 14px; } -.empty-state { +.fm-step__count { + background: #1074b9; + color: #fff; + font-size: 11px; + font-weight: 700; + width: 22px; + height: 22px; + border-radius: 50%; display: flex; align-items: center; - gap: 10px; - padding: 24px 16px; - color: rgba(0, 0, 0, 0.45); - font-size: 14px; - background: rgba(0, 0, 0, 0.02); - border-radius: 6px; - border: 1px dashed rgba(0, 0, 0, 0.12); - - .empty-icon { - font-size: 18px; - } + justify-content: center; } -.member-panel { - margin-bottom: 8px; - - .panel-icon { - margin-right: 8px; - font-size: 13px; - color: rgba(0, 0, 0, 0.45); - } +.fm-step__add-btn { + font-size: 13px !important; + height: 36px !important; + border-radius: 8px !important; } -.member-actions { - display: flex; - justify-content: flex-end; - gap: 4px; - margin: 8px 0 4px; -} - -.member-details { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px 24px; - padding: 8px 0; -} - -.detail-row { +.fm-step__empty { display: flex; flex-direction: column; - gap: 2px; + align-items: center; + gap: 6px; + padding: 32px 16px; + color: #94a3b8; + font-size: 14px; + background: #f8fafc; + border-radius: 8px; + border: 1px dashed #e2e8f0; } -.detail-label { +.fm-step__empty-icon { + font-size: 36px !important; + width: 36px !important; + height: 36px !important; + color: #cbd5e1; +} + +.fm-step__empty-sub { font-size: 12px; - color: rgba(0, 0, 0, 0.5); + color: #cbd5e1; +} + +.fm-step__empty--required { + border-color: #fca5a5; + background: #fef2f2; +} + +.fm-step__empty-required { + font-size: 12px; + font-weight: 600; + color: #dc2626; +} + +.fm-spouse-hint { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: #fff7ed; + border: 1px solid #fed7aa; + border-radius: 8px; + font-size: 13px; + color: #92400e; + margin-top: 12px; + margin-bottom: 4px; +} + +.fm-spouse-hint__icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + color: #f59e0b; +} + +/* Table */ +.fm-table { + border: 1px solid #e2e8f0; + border-radius: 10px; + overflow: hidden; +} + +.fm-table__header { + display: flex; + align-items: center; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + + .fm-table__col { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #64748b; + padding: 10px 12px; + } +} + +.fm-table__row { + display: flex; + align-items: center; + border-bottom: 1px solid #f1f5f9; + transition: background 0.1s; + + &:last-child { border-bottom: none; } + &:hover { background: #f8fafc; } +} + +.fm-table__col { + padding: 10px 12px; + font-size: 13px; + color: #1e293b; + + &--name { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + } + &--rel { flex: 0 0 130px; } + &--dob { flex: 0 0 120px; color: #64748b; font-size: 12px; } + &--id { flex: 0 0 140px; } + &--status { flex: 0 0 60px; text-align: center; } + &--actions { flex: 0 0 80px; display: flex; gap: 2px; } +} + +.fm-table__avatar { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + color: #1074b9; + flex-shrink: 0; +} + +.fm-table__name-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.fm-table__name-text { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fm-table__linked-tag { + font-size: 10px; + color: #1074b9; font-weight: 500; } -.detail-value { - font-size: 14px; - color: #333; +.fm-mono { + font-family: 'Roboto Mono', monospace; + font-size: 12px; + letter-spacing: 0.3px; +} + +.fm-alive-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + + &--yes { background: #ecfdf5; color: #059669; } + &--no { background: #fef2f2; color: #dc2626; } +} + +.fm-action-btn { + width: 30px !important; + height: 30px !important; + line-height: 30px !important; + + mat-icon { + font-size: 16px !important; + width: 16px !important; + height: 16px !important; + color: #64748b; + } + + &:hover mat-icon { color: #1074b9; } + &--delete:hover mat-icon { color: #ef4444; } } @media (max-width: 768px) { - .section-header { + .fm-step__header { flex-direction: column; align-items: flex-start; gap: 12px; } - .member-details { - grid-template-columns: 1fr; + .fm-table__col { + &--type, &--id { display: none; } } } + +/* ═══ NOMINEES ═══ */ +.nom-section { + margin-top: 28px; + padding-top: 24px; + border-top: 2px solid #e2e8f0; +} + +.nom-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.nom-header__left { + display: flex; + align-items: center; + gap: 10px; +} + +.nom-header__icon { font-size: 16px; color: #1565c0; } +.nom-header__title { font-size: 16px; font-weight: 600; color: #1e293b; display: block; } +.nom-header__sub { font-size: 11px; color: #94a3b8; display: block; } + +.nom-add-btn { font-size: 13px; } + +/* Share bar */ +.nom-share-bar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; + padding: 8px 12px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; +} + +.nom-share-bar__label { font-size: 12px; color: #64748b; white-space: nowrap; } + +.nom-share-bar__track { + flex: 1; + height: 8px; + background: #e2e8f0; + border-radius: 4px; + overflow: hidden; +} + +.nom-share-bar__fill { + height: 100%; + background: #1565c0; + border-radius: 4px; + transition: width 0.3s; + &.complete { background: #059669; } + &.over { background: #dc2626; } +} + +.nom-share-bar__value { + font-size: 13px; + font-weight: 700; + color: #1565c0; + min-width: 36px; + text-align: right; + &.over { color: #dc2626; } +} + +/* Nominee card */ +.nom-card { + border: 1px solid #e2e8f0; + border-radius: 8px; + margin-bottom: 8px; + overflow: hidden; +} + +.nom-card__main { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; +} + +.nom-card__avatar { + width: 36px; height: 36px; + border-radius: 50%; + background: #dbeafe; + display: flex; align-items: center; justify-content: center; + color: #1565c0; + mat-icon { font-size: 20px; } +} + +.nom-card__photo { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; } +.nom-card__info { flex: 1; } +.nom-card__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; } +.nom-card__meta { font-size: 12px; color: #64748b; display: block; margin-top: 2px; } +.nom-card__sig { + flex-shrink: 0; + img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; padding: 2px; } +} +.nom-card__actions { display: flex; gap: 2px; } + +.nom-card__edit { + padding: 12px 16px; + border-top: 1px solid #e2e8f0; + background: #f8fafc; +} + +.nom-card__edit-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + +.nom-minor-tag { + font-size: 10px; + font-weight: 700; + color: #dc2626; + background: #fef2f2; + padding: 1px 6px; + border-radius: 3px; +} + +.nom-card__share { + text-align: center; + min-width: 50px; +} + +.nom-card__share-value { font-size: 18px; font-weight: 700; color: #1565c0; display: block; } +.nom-card__share-label { font-size: 10px; color: #94a3b8; text-transform: uppercase; } + +.nom-card__guardian { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: #f0f9ff; + border-top: 1px solid #e0f2fe; + font-size: 12px; + color: #0c4a6e; + mat-icon { font-size: 16px; width: 16px; height: 16px; color: #0ea5e9; } +} + +/* Empty */ +.nom-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 32px; + background: #f8fafc; + border: 2px dashed #e2e8f0; + border-radius: 10px; + color: #94a3b8; + font-size: 14px; +} + +.nom-empty__icon { font-size: 36px; width: 36px; height: 36px; color: #cbd5e1; } +.nom-empty__sub { font-size: 12px; } + +/* Nominee form */ +.nom-form { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 16px; + margin-top: 12px; +} + +.nom-form__title { + font-size: 14px; + font-weight: 600; + color: #1e293b; + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 12px; +} + +/* Search */ +.nom-search-wrapper { position: relative; } +.nom-search-field { width: 100%; } +.nom-search-prefix { + display: inline-flex; + align-items: center; + padding-right: 8px; + mat-icon { font-size: 20px; color: #94a3b8; } +} + +.nom-search-results { + position: absolute; + top: 100%; + left: 0; right: 0; + z-index: 10; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + max-height: 240px; + overflow-y: auto; + margin-top: -12px; +} + +.nom-search-loading { + display: flex; align-items: center; gap: 8px; + padding: 12px 16px; + font-size: 13px; color: #64748b; +} + +.nom-search-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + cursor: pointer; + &:hover { background: #f1f5f9; } +} + +.nom-search-item__icon { color: #94a3b8; font-size: 20px; } +.nom-search-item__info { flex: 1; } +.nom-search-item__name { font-weight: 500; font-size: 13px; color: #1e293b; display: block; } +.nom-search-item__meta { font-size: 11px; color: #94a3b8; display: block; } +.nom-search-item__status { font-size: 11px; font-weight: 600; color: #94a3b8; &.active { color: #22c55e; } } + +/* Selected nominee */ +.nom-selected { + border: 1px solid #93c5fd; + border-radius: 10px; + overflow: hidden; + background: #fff; +} + +.nom-selected__header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: #eff6ff; + border-bottom: 1px solid #dbeafe; +} + +.nom-selected__avatar { + width: 40px; height: 40px; + border-radius: 50%; + background: #dbeafe; + display: flex; align-items: center; justify-content: center; + color: #1565c0; + overflow: hidden; +} + +.nom-selected__photo { width: 100%; height: 100%; object-fit: cover; } +.nom-selected__info { flex: 1; } +.nom-selected__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; } +.nom-selected__meta { font-size: 12px; color: #64748b; display: block; } +.nom-selected__sig { + display: flex; flex-direction: column; align-items: center; gap: 2px; flex-shrink: 0; + img { height: 28px; max-width: 70px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; background: #fff; padding: 2px 4px; } + span { font-size: 9px; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.04em; } +} +.nom-selected__remove { color: #94a3b8; } +.nom-selected__loading { display: flex; align-items: center; gap: 8px; padding: 12px 16px; font-size: 12px; color: #64748b; } + +.nom-kyc-badge { + display: inline-flex; align-items: center; margin-left: 4px; + mat-icon { font-size: 14px; width: 14px; height: 14px; } + &.verified { color: #16a34a; } +} + +.nom-selected__details { + display: flex; + gap: 16px; + padding: 10px 16px; + border-bottom: 1px solid #f1f5f9; + flex-wrap: wrap; +} + +.nom-detail { display: flex; flex-direction: column; gap: 2px; } +.nom-detail__label { font-size: 10px; text-transform: uppercase; color: #94a3b8; letter-spacing: 0.5px; } +.nom-detail__value { font-size: 13px; font-weight: 500; color: #1e293b; } +.nom-detail__value.mono { font-family: 'Roboto Mono', monospace; font-size: 12px; } + +/* Form fields */ +.nom-form-fields { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: 8px 16px; + padding: 12px 16px; + align-items: center; +} + +.nom-form-field { width: 100%; } +.nom-minor-toggle { padding-top: 8px; } + +/* Guardian */ +.nom-guardian-section { + padding: 12px 16px; + border-top: 1px dashed #e2e8f0; +} + +.nom-guardian-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: #0c4a6e; + margin-bottom: 10px; + mat-icon { font-size: 18px; color: #0ea5e9; } +} + +.nom-guardian-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 8px; + mat-icon { color: #0ea5e9; } +} + +.nom-guardian-card__info { flex: 1; } +.nom-guardian-card__name { font-weight: 600; font-size: 13px; color: #1e293b; display: block; } +.nom-guardian-card__meta { font-size: 11px; color: #64748b; display: block; } +.nom-guardian-rel { width: 160px; } + +/* Actions */ +.nom-form-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 12px 16px; + border-top: 1px solid #f1f5f9; + button { display: flex; align-items: center; gap: 6px; } +} + +@media (max-width: 768px) { + .nom-form-fields { grid-template-columns: 1fr; } + .nom-guardian-card { flex-wrap: wrap; } + .nom-guardian-rel { width: 100%; } +} diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts index 7a0116b9d..bd456cbab 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts @@ -1,119 +1,475 @@ /** * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -/** Angular Imports */ -import { Component, Input, inject } from '@angular/core'; +import { Component, Input, OnInit, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; - -/** Custom Components */ import { TranslateService } from '@ngx-translate/core'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { ClientFamilyMemberDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component'; +import { ImageZoomDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component'; +import { ClientsService } from '../../clients.service'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { - MatAccordion, - MatExpansionPanel, - MatExpansionPanelHeader, - MatExpansionPanelTitle, - MatExpansionPanelDescription -} from '@angular/material/expansion'; -import { MatDivider } from '@angular/material/divider'; - +import { MatIcon } from '@angular/material/icon'; +import { MatSlideToggle } from '@angular/material/slide-toggle'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { FindPipe } from '../../../pipes/find.pipe'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FormsModule } from '@angular/forms'; +import { DomSanitizer } from '@angular/platform-browser'; +import { MatTooltip } from '@angular/material/tooltip'; -/** - * Client Family Members Step - */ @Component({ selector: 'mifosx-client-family-members-step', templateUrl: './client-family-members-step.component.html', styleUrls: ['./client-family-members-step.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, + FormsModule, FaIconComponent, - MatAccordion, - MatExpansionPanel, - MatExpansionPanelHeader, - MatExpansionPanelTitle, - MatExpansionPanelDescription, - MatDivider, + MatIcon, + MatSlideToggle, + MatProgressSpinner, FindPipe, - DateFormatPipe + DateFormatPipe, + MatTooltip ] }) -export class ClientFamilyMembersStepComponent { +export class ClientFamilyMembersStepComponent implements OnInit { dialog = inject(MatDialog); private translateService = inject(TranslateService); + private clientsService = inject(ClientsService); + private sanitizer = inject(DomSanitizer); - /** Cient Template */ @Input() clientTemplate: any; - /** Client Family Members */ - clientFamilyMembers: any[] = []; + @Input() clientId: string = ''; + @Input() maritalStatusId: number | null = null; + @Input() maritalStatusOptions: any[] = []; + /** Read-only mode — hides add/edit/delete buttons */ + @Input() readOnly = false; - /** - * Adds a family member. - */ - addFamilyMember() { - const addFamilyMemberDialogRef = this.dialog.open(ClientFamilyMemberDialogComponent, { - data: { - context: this.translateService.instant('labels.buttons.Add'), - options: this.clientTemplate.familyMemberOptions + /** Family Members */ + clientFamilyMembers: any[] = []; + private loaded = false; + + /** Nominees */ + nominees: any[] = []; + private nomineesLoaded = false; + showNomineeForm = false; + + /** Nominee search */ + nomineeSearchText = ''; + nomineeSearchResults: any[] = []; + nomineeSearching = false; + private nomineeSearchTimer: any = null; + + /** Selected nominee client */ + selectedNomineeClient: any = null; + nomineeRelationshipId: number | null = null; + nomineePercentageShare: number | null = null; + nomineeIsMinor = false; + nomineeSaving = false; + nomineeType = 'DA1'; + + /** Nomination type options */ + nominationTypeOptions = [ + { value: 'DA1', label: 'DA1 - Add Nominee' }, + { value: 'DA5', label: 'DA5 - Modify / Cancel Nominee' } + ]; + + /** Guardian search (for minor nominee) */ + guardianSearchText = ''; + guardianSearchResults: any[] = []; + guardianSearching = false; + selectedGuardian: any = null; + guardianRelationship = ''; + private guardianSearchTimer: any = null; + + /** Relationship options */ + relationshipOptions = [ + 'Father', 'Mother', 'Spouse', 'Son', 'Daughter', + 'Brother', 'Sister', 'Uncle', 'Aunt', 'Grandfather', + 'Grandmother', 'Friend', 'Business Partner', 'Other' + ]; + + ngOnInit(): void { + if (this.clientId) { + this.loadExistingMembers(); + this.loadNominees(); + } + } + + // ═══ Family Members ═══ + + private loadExistingMembers(): void { + if (this.loaded) return; + this.clientsService.getClientFamilyMembers(this.clientId).subscribe({ + next: (data: any) => { + this.loaded = true; + this.clientFamilyMembers = Array.isArray(data) ? data : []; }, - width: '50rem' + error: () => { this.loaded = true; } }); - addFamilyMemberDialogRef.afterClosed().subscribe((response: any) => { - if (response.member) { + } + + addFamilyMember() { + const ref = this.dialog.open(ClientFamilyMemberDialogComponent, { + data: { context: 'Add', options: this.clientTemplate.familyMemberOptions }, + width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel' + }); + ref.afterClosed().subscribe((response: any) => { + if (response?.member) { this.clientFamilyMembers.push(response.member); } }); } - /** - * Edits the family member. - * @param {any} member Family Member - * @param {any} index Tree Index - */ - editFamilyMember(member: any, index: any) { - const addFamilyMemberDialogRef = this.dialog.open(ClientFamilyMemberDialogComponent, { - data: { - context: 'Edit', - member: member, - options: this.clientTemplate.familyMemberOptions - }, - width: '50rem' + viewFamilyMember(member: any) { + this.dialog.open(ClientFamilyMemberDialogComponent, { + data: { context: 'View', member, options: this.clientTemplate.familyMemberOptions }, + width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel' }); - addFamilyMemberDialogRef.afterClosed().subscribe((response: any) => { - if (response.member) { + } + + editFamilyMember(member: any, index: number) { + const ref = this.dialog.open(ClientFamilyMemberDialogComponent, { + data: { context: 'Edit', member, options: this.clientTemplate.familyMemberOptions }, + width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel' + }); + ref.afterClosed().subscribe((response: any) => { + if (response?.member) { this.clientFamilyMembers.splice(index, 1, response.member); } }); } - /** - * Deletes the family member - */ deleteFamilyMember(name: string, index: number) { - const deleteFamilyMemberDialogRef = this.dialog.open(DeleteDialogComponent, { - data: { deleteContext: `Family member name : ${name} ${index}` } + const ref = this.dialog.open(DeleteDialogComponent, { + data: { deleteContext: `${name}` } }); - deleteFamilyMemberDialogRef.afterClosed().subscribe((response: any) => { - if (response.delete) { + ref.afterClosed().subscribe((response: any) => { + if (response?.delete) { this.clientFamilyMembers.splice(index, 1); } }); } - /** - * Returns the array of client family members. - */ get familyMembers() { return { familyMembers: this.clientFamilyMembers }; } + + get hasFamilyMembers(): boolean { + return this.clientFamilyMembers.length > 0; + } + + /** Check if client's marital status is "Married" */ + get isMarried(): boolean { + if (!this.maritalStatusId || !this.maritalStatusOptions?.length) return false; + const selected = this.maritalStatusOptions.find((o: any) => o.id === this.maritalStatusId); + return selected?.name?.toLowerCase().includes('married') || false; + } + + /** Check if a spouse has been added */ + get hasSpouse(): boolean { + return this.clientFamilyMembers.some((m: any) => { + const rel = m.relationship || m.relationshipId; + const relName = typeof rel === 'string' ? rel : ''; + return relName.toLowerCase() === 'spouse' || + m.firstName?.toLowerCase().includes('spouse'); + }); + } + + // ═══ Nominees ═══ + + private loadNominees(): void { + if (this.nomineesLoaded || !this.clientId) return; + // Nominees are stored as family members with nominationType set + this.clientsService.getClientFamilyMembers(this.clientId).subscribe({ + next: (data: any) => { + this.nomineesLoaded = true; + const allMembers = Array.isArray(data) ? data : []; + this.nominees = allMembers.filter((m: any) => m.nominationType && m.nominationType !== ''); + }, + error: () => { this.nomineesLoaded = true; } + }); + } + + onNomineeSearch(text: string): void { + this.nomineeSearchText = text; + clearTimeout(this.nomineeSearchTimer); + if (!text || text.length < 2) { this.nomineeSearchResults = []; return; } + this.nomineeSearching = true; + this.nomineeSearchTimer = setTimeout(() => { + this.clientsService.searchByText(text, 0, 10).subscribe({ + next: (data: any) => { + this.nomineeSearchResults = data?.content || []; + this.nomineeSearching = false; + this.clientsService.enrichSearchResults(this.nomineeSearchResults); + }, + error: () => { this.nomineeSearchResults = []; this.nomineeSearching = false; } + }); + }, 400); + } + + selectNomineeClient(client: any): void { + this.selectedNomineeClient = { ...client, _loading: true }; + this.nomineeSearchResults = []; + this.nomineeSearchText = ''; + this.fetchNomineeClientDetails(client.id); + } + + private fetchNomineeClientDetails(clientId: number): void { + const details: any = {}; + let pending = 4; + const done = () => { + pending--; + if (pending <= 0) { + this.selectedNomineeClient = { ...this.selectedNomineeClient, ...details, _loading: false }; + } + }; + + // Fetch client data for DOB + this.clientsService.getClientData(clientId.toString()).subscribe({ + next: (data: any) => { + if (data?.dateOfBirth) { details.dateOfBirth = data.dateOfBirth; } + done(); + }, + error: () => done() + }); + + // Fetch KYC for PAN/Aadhaar + verification status + this.clientsService.getClientKyc(clientId.toString()).subscribe({ + next: (kyc: any) => { + details.panNumber = kyc?.panNumber || null; + details.aadhaarMasked = kyc?.aadhaarNumberMasked || null; + details.panVerified = kyc?.panVerified || false; + details.aadhaarVerified = kyc?.aadhaarVerified || false; + done(); + }, + error: () => done() + }); + + // Fetch profile image + this.clientsService.getClientProfileImage(clientId.toString()).subscribe({ + next: (img: any) => { + if (img) { details.profileImageUrl = this.sanitizer.bypassSecurityTrustResourceUrl(img); } + done(); + }, + error: () => done() + }); + + // Fetch signature image + this.clientsService.getClientDocuments(clientId.toString()).subscribe({ + next: (docs: any[]) => { + const sigDoc = docs?.find((d: any) => + d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature') + ); + if (sigDoc) { + this.clientsService.getClientSignatureImage(clientId.toString(), sigDoc.id.toString()).subscribe({ + next: (blob: Blob) => { + details.signatureUrl = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob)); + done(); + }, + error: () => done() + }); + } else { done(); } + }, + error: () => done() + }); + } + + onGuardianSearch(text: string): void { + this.guardianSearchText = text; + clearTimeout(this.guardianSearchTimer); + if (!text || text.length < 2) { this.guardianSearchResults = []; return; } + this.guardianSearching = true; + this.guardianSearchTimer = setTimeout(() => { + this.clientsService.searchByText(text, 0, 10).subscribe({ + next: (data: any) => { + this.guardianSearchResults = data?.content || []; + this.guardianSearching = false; + this.clientsService.enrichSearchResults(this.guardianSearchResults); + }, + error: () => { this.guardianSearchResults = []; this.guardianSearching = false; } + }); + }, 400); + } + + selectGuardian(client: any): void { + this.selectedGuardian = client; + this.guardianSearchResults = []; + this.guardianSearchText = ''; + } + + get existingShareTotal(): number { + return this.nominees.reduce((sum, n) => sum + (n.percentageShare || 0), 0); + } + + get totalSharePercentage(): number { + return this.existingShareTotal + (this.nomineePercentageShare || 0); + } + + get maxAllowedShare(): number { + return 100 - this.existingShareTotal; + } + + saveNominee(): void { + if (!this.clientId || !this.selectedNomineeClient || !this.nomineeRelationshipId || !this.nomineePercentageShare) return; + if (this.totalSharePercentage > 100) return; + + this.nomineeSaving = true; + + // Get relationship name for display + const relOption = this.clientTemplate?.familyMemberOptions?.relationshipIdOptions?.find( + (r: any) => r.id === this.nomineeRelationshipId + ); + const relName = relOption?.name || ''; + + const payload: any = { + firstName: this.selectedNomineeClient.displayName, + relationshipId: this.nomineeRelationshipId, + nominationType: this.nomineeType, + percentageShare: this.nomineePercentageShare, + isDependent: this.nomineeIsMinor, + dateFormat: 'dd MMMM yyyy', + locale: 'en' + }; + + if (this.nomineeIsMinor && this.selectedGuardian) { + payload.guardianName = this.selectedGuardian.displayName; + payload.guardianRelationship = this.guardianRelationship; + } + + this.clientsService.addFamilyMember(this.clientId, payload).subscribe({ + next: (res: any) => { + this.nomineeSaving = false; + this.nominees.push({ + id: res?.resourceId, + ...payload, + relationship: relName, + nomineeName: this.selectedNomineeClient.displayName, + nomineeCif: this.selectedNomineeClient.accountNo, + nomineeMobile: this.selectedNomineeClient.mobileNo, + nomineeDob: this.selectedNomineeClient.dateOfBirth, + profileImageUrl: this.selectedNomineeClient.profileImageUrl, + signatureUrl: this.selectedNomineeClient.signatureUrl, + panNumber: this.selectedNomineeClient.panNumber, + aadhaarMasked: this.selectedNomineeClient.aadhaarMasked, + guardianName: this.selectedGuardian?.displayName + }); + this.resetNomineeForm(); + }, + error: () => { this.nomineeSaving = false; } + }); + } + + /** Editing nominee state */ + editingNomineeIndex: number | null = null; + editNomineeRelId: number | null = null; + editNomineeShare: number | null = null; + editNomineeType: string = 'DA1'; + + editNominee(nominee: any, index: number): void { + this.editingNomineeIndex = index; + this.editNomineeRelId = nominee.relationshipId?.id || nominee.relationshipId || null; + this.editNomineeShare = nominee.percentageShare; + this.editNomineeType = nominee.nominationType || 'DA1'; + } + + cancelEditNominee(): void { + this.editingNomineeIndex = null; + } + + /** Max share allowed for the nominee being edited (excludes its own share) */ + maxShareForEdit(index: number): number { + const otherTotal = this.nominees.reduce((sum, n, i) => i === index ? sum : sum + (n.percentageShare || 0), 0); + return 100 - otherTotal; + } + + saveEditNominee(nominee: any, index: number): void { + if (!this.editNomineeRelId || !this.editNomineeShare) return; + const otherTotal = this.nominees.reduce((sum, n, i) => i === index ? sum : sum + (n.percentageShare || 0), 0); + if (otherTotal + this.editNomineeShare > 100) return; + + const relOption = this.clientTemplate?.familyMemberOptions?.relationshipIdOptions?.find( + (r: any) => r.id === this.editNomineeRelId + ); + + // Update in-memory + nominee.relationshipId = this.editNomineeRelId; + nominee.relationship = relOption?.name || nominee.relationship; + nominee.percentageShare = this.editNomineeShare; + nominee.nominationType = this.editNomineeType; + + // If saved to DB, update via API + if (nominee.id && this.clientId) { + const payload: any = { + firstName: nominee.firstName || nominee.nomineeName, + relationshipId: this.editNomineeRelId, + nominationType: this.editNomineeType, + percentageShare: this.editNomineeShare, + isDependent: nominee.isDependent || false, + dateFormat: 'dd MMMM yyyy', + locale: 'en' + }; + if (nominee.guardianName) { payload.guardianName = nominee.guardianName; } + if (nominee.guardianRelationship) { payload.guardianRelationship = nominee.guardianRelationship; } + this.clientsService.editFamilyMember(this.clientId, nominee.id, payload).subscribe({ error: () => {} }); + } + + this.editingNomineeIndex = null; + } + + deleteNominee(nominee: any, index: number): void { + const ref = this.dialog.open(DeleteDialogComponent, { + data: { deleteContext: `nominee ${nominee.nomineeName || nominee.firstName || ''}` } + }); + ref.afterClosed().subscribe((response: any) => { + if (response?.delete) { + if (nominee.id && this.clientId) { + this.clientsService.deleteFamilyMember(this.clientId, nominee.id.toString()).subscribe({ + next: () => this.nominees.splice(index, 1), + error: () => this.nominees.splice(index, 1) + }); + } else { + this.nominees.splice(index, 1); + } + } + }); + } + + resetNomineeForm(): void { + this.showNomineeForm = false; + this.selectedNomineeClient = null; + this.nomineeRelationshipId = null; + this.nomineePercentageShare = null; + this.nomineeIsMinor = false; + this.nomineeType = 'DA1'; + this.selectedGuardian = null; + this.guardianRelationship = ''; + this.nomineeSearchText = ''; + this.guardianSearchText = ''; + } + + /** Open image in zoom overlay */ + zoomImage(imageSrc: any, title: string): void { + this.dialog.open(ImageZoomDialogComponent, { + data: { imageSrc, title }, + panelClass: 'image-zoom-panel', + maxWidth: '90vw', + maxHeight: '90vh' + }); + } + + /** Get relationship name from ID */ + getRelName(relId: any): string { + if (typeof relId === 'object' && relId?.name) return relId.name; + const opt = this.clientTemplate?.familyMemberOptions?.relationshipIdOptions?.find((r: any) => r.id === relId); + return opt?.name || ''; + } + + get nomineeData(): any[] { + return this.nominees; + } } diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html index e9f423690..aeb05edca 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html @@ -7,11 +7,21 @@ -->
+
+
+ +
+

General Details

+

Client registration — personal & identification details.

+
+
+
+

- {{ 'labels.inputs.Basic Information' | translate }} + Basic Information

@@ -22,297 +32,823 @@ } @if (createClientForm.controls.officeId.hasError('required')) { - - {{ 'labels.inputs.Office' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - + Office is required } {{ 'labels.inputs.Legal Form' | translate }} + + @if (createClientForm.get('legalFormId')?.value) { + + {{ createClientForm.get('legalFormId')?.value === 1 ? 'person' : 'business' }} + {{ getSelectedLegalFormLabel() }} + + } + @for (legalForm of legalFormOptions; track legalForm) { - {{ legalForm.value | translateKey: 'inputs' }} + + + {{ legalForm.id === 1 ? 'person' : 'business' }} + {{ legalForm.id === 1 ? 'Individual' : 'Non-Individual' }} + + } - {{ 'labels.inputs.External Id' | translate }} - - @if (!externalNationalIdService.isLoading && externalNationalIdService.statusMessageKey) { - - - {{ 'labels.inputs.' + externalNationalIdService.statusMessageKey | translate }} - - - } + Nationality + + + @if (getSelectedNationality()) { + + @if (getSelectedNationality().flagType === 'india') { + + + + + + + + + + + } @else { + {{ getSelectedNationality().icon }} + } + {{ getSelectedNationality().label }} + + } + + @for (nat of nationalityOptions; track nat) { + + + @if (nat.flagType === 'india') { + + + + + + + + + + + } @else { + {{ nat.icon }} + } + {{ nat.label }} + + + } + + @if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) { + + {{ 'labels.inputs.Is staff' | translate }}? + + No + Yes + + + }
- +

{{ 'labels.inputs.Client Details' | translate }}

+ @if (createClientForm.get('fullname')) { - {{ 'labels.inputs.' + getDateLabel(createClientForm.value.legalFormId, ['Name', 'Entity Name']) | translate }} + {{ 'labels.inputs.' + getDateLabel(createClientForm.get('legalFormId')?.value, ['Name', 'Entity Name']) | translate }} @if (createClientForm.controls.fullname.hasError('required')) { - - {{ 'labels.inputs.Client name' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - @if (createClientForm.controls.fullname.hasError('pattern')) { - - {{ 'labels.inputs.Client name' | translate }} {{ 'labels.inputs.cannot' | translate }} - {{ 'labels.commons.begin with a special character or number' | translate }} - + Client name is required + } @else if (createClientForm.controls.fullname.hasError('pattern')) { + Only letters, spaces, dots, hyphens and apostrophes allowed + } @else if (createClientForm.controls.fullname.hasError('maxlength')) { + Name cannot exceed 100 characters } + + + @if (createClientForm.get('fullname')?.value) { +
+
+ Entity Name: + {{ createClientForm.get('fullname')?.value }} +
+
+ @if (!sanctionsVerified && !sanctionsBlocked) { + + } + @if (sanctionsVerified) { + RBI KYC Master Direction (2016) — Section 51 & PMLA 2002 + + Verified + + info + } + @if (sanctionsBlocked) { + RBI KYC Master Direction (2016) — Section 51 & PMLA 2002 + + Sanctions Match + + info + } +
+
+ } } + @if (createClientForm.get('firstname') || createClientForm.get('middlename') || createClientForm.get('lastname')) {
+ @if (createClientForm.get('salutationId') && salutationOptions?.length > 0) { + + Salutation + person + + @for (salutation of salutationOptions; track salutation) { + {{ salutation.name }} + } + + + } @if (createClientForm.get('firstname')) { {{ 'labels.inputs.First Name' | translate }} + badge @if (createClientForm.controls.firstname.hasError('required')) { - - {{ 'labels.inputs.Client first name' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - @if (createClientForm.controls.firstname.hasError('pattern')) { - - {{ 'labels.inputs.Client first name' | translate }} - {{ 'labels.inputs.cannot' | translate }} - {{ 'labels.commons.begin with a special character or number' | translate }} - + First name is required + } @else if (createClientForm.controls.firstname.hasError('pattern')) { + Only letters, spaces, dots and hyphens allowed + } @else if (createClientForm.controls.firstname.hasError('maxlength')) { + Cannot exceed 50 characters } } @if (createClientForm.get('middlename')) { {{ 'labels.inputs.Middle Name' | translate }} + person_outline - @if (createClientForm.controls.middlename.hasError('pattern')) { - - {{ 'labels.inputs.Client middle name' | translate }} - {{ 'labels.inputs.cannot' | translate }} - {{ 'labels.commons.begin with a special character or number' | translate }} - + @if (createClientForm.controls.middlename?.hasError('pattern') && createClientForm.controls.middlename?.touched) { + Only letters, spaces, dots and hyphens allowed } } @if (createClientForm.get('lastname')) { {{ 'labels.inputs.Last Name' | translate }} + person @if (createClientForm.controls.lastname.hasError('required')) { - - {{ 'labels.inputs.Client last name' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - @if (createClientForm.controls.lastname.hasError('pattern')) { - - {{ 'labels.inputs.Client last name' | translate }} - {{ 'labels.inputs.cannot' | translate }} - {{ 'labels.commons.begin with a special character or number' | translate }} - + Last name is required + } @else if (createClientForm.controls.lastname.hasError('pattern')) { + Only letters, spaces, dots and hyphens allowed + } @else if (createClientForm.controls.lastname.hasError('maxlength')) { + Cannot exceed 50 characters } }
+ + + @if (fullNamePreview) { +
+
+ Full Name: + {{ fullNamePreview }} +
+
+ @if (!sanctionsVerified && !sanctionsBlocked) { + + } + @if (sanctionsVerified) { + RBI KYC Master Direction (2016) — Section 51 & PMLA 2002 + + Verified + + info + } + @if (sanctionsBlocked) { + RBI KYC Master Direction (2016) — Section 51 & PMLA 2002 + + Sanctions Match + + info + } +
+
+ } } - - - {{ 'labels.inputs.' + getDateLabel(createClientForm.value.legalFormId, ['Date of Birth', 'Incorporation Date']) | translate }} - - - - - + + @if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) { + + Date of Birth + calendar_today + + + + @if (createClientForm.get('dateOfBirth')?.hasError('required') && createClientForm.get('dateOfBirth')?.touched) { + Date of Birth is required + } + + } - @if (createClientForm.value.legalFormId === LegalFormId.PERSON) { + @if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) { {{ 'labels.inputs.Gender' | translate }} - + + + @if (getSelectedGender()) { + + {{ getGenderIcon(getSelectedGender().name) }} + {{ getSelectedGender().name | titlecase }} + + } + @for (gender of genderOptions; track gender) { - {{ gender.name }} + + + {{ getGenderIcon(gender.name) }} + {{ gender.name | titlecase }} + + } + @if (createClientForm.get('genderId')?.hasError('required') && createClientForm.get('genderId')?.touched) { + Gender is required + } } - - {{ 'labels.inputs.Staff' | translate }} - - @for (staff of staffOptions; track staff) { - {{ staff.displayName }} + + @if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON && calculatedAgeDetail) { +
+ + {{ calculatedAgeDetail }} + @if (isMinor) { + Minor } - - - - @if (createClientForm.value.legalFormId === LegalFormId.PERSON) { -
- - {{ 'labels.inputs.Is staff' | translate }}? -
} +
- + + @if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) { +
+ +
+
+ family_restroom +
+

Guardian Details

+ Minor account — guardian is mandatory per RBI KYC norms +
+
+
+
+ {{ ageBreakdown.years }} yrs + {{ ageBreakdown.months }} mos + {{ ageBreakdown.days }} days + MINOR +
+ @if (!selectedGuardian && !guardianLinking) { + + } +
+
+ + +
+ + + @if (!selectedGuardian && !guardianLinking) { +
+ person_off +
+ No guardian linked yet. Click "Link Guardian" to search and add. + Guardian is mandatory for minor accounts +
+
+ } + + + @if (!selectedGuardian && guardianLinking) { +
+ + Relationship + + man Father + woman Mother + gavel Legal Guardian + elderly Grandparent + group Other + + + +
+ + Search Guardian by Name or Client ID + person_search + + + + @if (guardianSearching) { +
+
+ + Searching... +
+
+ } + @if (!guardianSearching && guardianSearchResults.length > 0) { +
+ @for (result of guardianSearchResults; track result.id) { +
+
+ +
+
+ {{ result.displayName }} + + ID: {{ result.id }} · A/C: {{ result.accountNumber }} · {{ result.officeName }}@if (result.dateOfBirth) { · DOB: {{ result.dateOfBirth | dateFormat }} }@if (result.fatherSpouseName) { · {{ result.fatherSpouseName }} } + +
+ + {{ result.status?.value }} + +
+ } +
+ } + @if (!guardianSearching && guardianSearchText.length >= 2 && guardianSearchResults.length === 0) { +
No clients found
+ } +
+ + +
+ } + + + @if (selectedGuardian) { +
+ +
+ Photo + Name + Relationship + CIF / A/C + Status + Actions +
+ + +
+ +
+ @if (selectedGuardian.profileImageUrl) { + Photo + } @else { + + } +
+
+ +
+ {{ selectedGuardian.displayName }} + + link + Linked Client #{{ selectedGuardian.cifNumber || selectedGuardian.id }} + +
+
+ + {{ createClientForm.get('guardianDetails.guardianRelation')?.value || '-' }} + + + {{ selectedGuardian.accountNo || selectedGuardian.accountNumber || '-' }} + + + + {{ selectedGuardian.status?.value || '-' }} + + + + + + +
+ + + @if (guardianExpanded) { +
+
+
+ Account Type + {{ selectedGuardian.accountType || '-' }} +
+ @if (selectedGuardian.panNumber) { +
+ PAN + {{ selectedGuardian.panNumber }} + @if (selectedGuardian.panVerified) { check_circle } + +
+ } + @if (selectedGuardian.aadhaarMasked) { +
+ Aadhaar + {{ selectedGuardian.aadhaarMasked }} + @if (selectedGuardian.aadhaarVerified) { check_circle } + +
+ } + @if (selectedGuardian.mobileNo) { +
+ Mobile + {{ selectedGuardian.mobileNo }} +
+ } + @if (selectedGuardian.fullAddress) { +
+ Address + {{ selectedGuardian.fullAddress }} +
+ } +
+ @if (selectedGuardian.signatureUrl) { +
+ Specimen Signature + Signature +
+ } +
+ } +
+ } +
+
+ } + + @if (createClientForm.get('clientNonPersonDetails')) { -
+
- {{ 'labels.inputs.Constitution' | translate }} + Constitution Type - @for (constitution of constitutionOptions; track constitution) { - {{ constitution.name }} + + @if (selectedConstitution) { + {{ selectedConstitution.icon }} + {{ selectedConstitution.label }} + } + + @for (ct of enrichedConstitutionOptions; track ct.id) { + + {{ ct.icon }} + {{ ct.label }} + } + - {{ 'labels.inputs.Main Business Line' | translate }} + Nature of Business / Activity + + @if (getSelectedBusiness()) { + + {{ getBusinessIcon(getSelectedBusiness().name) }} + {{ getSelectedBusiness().name }} + + } + @for (business of businessLineOptions; track business) { - {{ business.name }} + + + {{ getBusinessIcon(business.name) }} + {{ business.name }} + + } - - {{ 'labels.inputs.Incorporation Validity Till Date' | translate }} - - - - + - {{ 'labels.inputs.Incorporation Number' | translate }} - + Registration / Incorporation Number + + @if (selectedConstitution?.regHint) { + e.g. {{ selectedConstitution.regHint }} + } + + +
+

+ + Authorized Signatories + {{ signatories.length }} +

+
+ + Select registered clients as authorized signatories — as per Board Resolution / Partnership Deed. Max 10. +
+ + + @if (signatories.length > 0) { +
+
+ # + + Name + Client ID + PAN + Status + Actions +
+ @for (sig of signatories; track sig.id; let i = $index) { +
+ {{ i + 1 }} + +
+ @if (sig.profileImageUrl) { + + } @else { + + } +
+
+ + {{ sig.displayName }} + @if (sig._loading) { + + } + + {{ sig.cifNumber || sig.id }} + + {{ sig.panNumber || '-' }} + @if (sig.panVerified) { check_circle } + + + {{ sig.status?.value || '-' }} + + + + + +
+ } +
+ } + + + @if (signatories.length < 10) { +
+
+ + Add Signatory — Search by Name or Client ID + person_add + + + @if (signatorySearching) { +
+
+ + Searching... +
+
+ } + @if (!signatorySearching && signatorySearchResults.length > 0) { +
+ @for (result of signatorySearchResults; track result.id) { +
+
+
+ {{ result.displayName }} + ID: {{ result.id }} · A/C: {{ result.accountNumber }} · {{ result.officeName }}@if (result.dateOfBirth) { · DOB: {{ result.dateOfBirth | dateFormat }} }@if (result.fatherSpouseName) { · {{ result.fatherSpouseName }} } +
+ {{ result.status?.value }} +
+ } +
+ } + @if (!signatorySearching && signatorySearchText.length >= 2 && signatorySearchResults.length === 0) { +
No clients found
+ } +
+
+ } +
+ + + @if (selectedConstitution?.additionalField) { + + {{ selectedConstitution.additionalField }} + + + } + - {{ 'labels.inputs.Remarks' | translate }} + Remarks
+ }
- +

- - {{ 'labels.inputs.Verification' | translate }} -

- -
- @if (!sanctionsVerified && !sanctionsBlocked) { - - } - @if (sanctionsVerified) { -
- - Client verified — No match found in sanctions list. -
- } - @if (sanctionsBlocked) { -
- - - SANCTIONS MATCH FOUND — Client cannot be onboarded. Account opening is blocked. - - -
- } -
- -
+ + Contact Details info - DIP Verification -
+ -
- +
+ + {{ 'labels.inputs.Mobile No' | translate }} - + phone_android + + @if (createClientForm.get('mobileNo')?.hasError('required') && createClientForm.get('mobileNo')?.touched) { + Mobile number is required + } @else if (createClientForm.get('mobileNo')?.hasError('pattern') && createClientForm.get('mobileNo')?.touched) { + Enter valid 10-digit number (starts with 6-9) + } - @if (!mobileVerified) { - - } @else { - ✔ Verified + +
+ @if (!mobileVerified) { + + } @else { + ✔ Verified + } +
+ + + + Alternate Mobile No + phone_android + + @if (createClientForm.get('alternateMobileNo')?.hasError('pattern') && createClientForm.get('alternateMobileNo')?.touched) { + Enter valid 10-digit number (starts with 6-9) + } + + +
+ @if (!alternateMobileVerified) { + + } @else { + ✔ Verified + } +
+ + + + {{ 'labels.inputs.Email Address' | translate }} + email + + @if (createClientForm.get('emailAddress')?.hasError('required') && createClientForm.get('emailAddress')?.touched) { + Email is required + } @else if (createClientForm.get('emailAddress')?.invalid && createClientForm.get('emailAddress')?.touched) { + Enter a valid email (e.g. name@example.com) + } + + + @if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON && maritalStatusOptions.length > 0 && !isMinor) { + + Marital Status + + + @if (getSelectedMaritalStatus()) { + + {{ getMaritalStatusIcon(getSelectedMaritalStatus().name) }} + {{ getSelectedMaritalStatus().name }} + + } + + @for (ms of maritalStatusOptions; track ms) { + + + {{ getMaritalStatusIcon(ms.name) }} + {{ ms.name }} + + + } + + }
- + + @if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {

- - {{ 'labels.inputs.Contact & Classification' | translate }} + + Financial & Classification

- - {{ 'labels.inputs.Email Address' | translate }} - - @if (createClientForm.controls.emailAddress.errors?.email) { - {{ 'error.Email not valid' | translate }} - } - - - {{ 'labels.inputs.Client Type' | translate }} - - @for (clientType of clientTypeOptions; track clientType) { - {{ clientType.name }} - } - - - - - {{ 'labels.inputs.Client Classification' | translate }} - - @for (clientClassification of clientClassificationTypeOptions; track clientClassification) { - {{ clientClassification.name }} - } - - {{ 'labels.inputs.Occupation Category' | translate }} + + @if (getSelectedOccupationCategory()) { + + {{ getOccupationCategoryIcon(getSelectedOccupationCategory().name) }} + {{ getSelectedOccupationCategory().name }} + + } + @for (cat of occupationCategoryOptions; track cat) { - {{ cat.name }} + + + {{ getOccupationCategoryIcon(cat.name) }} + {{ cat.name }} + + } @@ -320,63 +856,49 @@ {{ 'labels.inputs.Occupation' | translate }} + + @if (getSelectedOccupation()) { + + {{ getOccupationIcon(getSelectedOccupation().name) }} + {{ getSelectedOccupation().name }} + + } + @for (occ of occupationOptions; track occ) { - {{ occ.name }} + + + {{ getOccupationIcon(occ.name) }} + {{ occ.name }} + + } - - {{ 'labels.inputs.Submitted On' | translate }} - - - + + Monthly Income + ₹ + + @if (createClientForm.get('monthlyIncome')?.hasError('pattern') && createClientForm.get('monthlyIncome')?.touched) { + Enter a valid amount + } @else if (createClientForm.get('monthlyIncome')?.hasError('min') && createClientForm.get('monthlyIncome')?.touched) { + Amount cannot be negative + } -
- - {{ 'labels.inputs.Active' | translate }}? - -
- - @if (createClientForm.contains('activationDate')) { - - {{ 'labels.inputs.Activation Date' | translate }} - - - - @if (createClientForm.controls.activationDate.hasError('required')) { - - {{ 'labels.inputs.Activation Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - } - -
- - {{ 'labels.inputs.Open Savings Account' | translate }}? - -
- - @if (createClientForm.contains('savingsProductId')) { - - {{ 'labels.inputs.Savings Product' | translate }} - - @for (product of savingProductOptions; track product) { - {{ product.name }} - } - - @if (createClientForm.controls.savingsProductId.hasError('required')) { - - {{ 'labels.inputs.Savings Product' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - } + + Annual Turnover (Last FY) + ₹ + + @if (createClientForm.get('annualTurnover')?.hasError('pattern') && createClientForm.get('annualTurnover')?.touched) { + Enter a valid amount + } @else if (createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched) { + Amount cannot be negative + } +
+ } + + diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss b/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss index e1db112b6..d00f4ee37 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss @@ -67,6 +67,16 @@ form { flex: 1; min-width: 180px; + &.salutation-field { + flex: 0 0 110px; + min-width: 110px; + } + + &.salutation-field-wide { + flex: 0 0 180px; + min-width: 180px; + } + &.first-name, &.last-name { flex: 1.2; @@ -74,6 +84,1203 @@ form { } } +/* Full Name Row + Verify */ +.full-name-row { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 14px; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 6px; + margin-top: -8px; + margin-bottom: 4px; +} + +.full-name-preview { + display: flex; + align-items: center; + gap: 8px; + + .full-name-label { + font-size: 11px; + font-weight: 600; + color: #64748b; + } + + .full-name-value { + font-size: 14px; + font-weight: 700; + color: #0c4a6e; + letter-spacing: 0.02em; + } +} + +.verify-inline { + flex-shrink: 0; +} + +.verify-name-btn { + font-size: 12px !important; + font-weight: 600 !important; + padding: 0 14px !important; + height: 32px !important; + border-radius: 6px !important; + display: flex; + align-items: center; + gap: 6px; +} + +.verify-name-icon { + font-size: 12px; +} + +.verify-status { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + font-weight: 700; + padding: 5px 12px; + border-radius: 6px; + + &.verified { + background: #ecfdf5; + color: #059669; + border: 1px solid #a7f3d0; + } + + &.blocked { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; + } +} + +.sanctions-citation { + font-size: 11px; + font-weight: 600; + color: #6b7280; + margin-left: 6px; + margin-right: 8px; +} + +.sanctions-info-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: #2563eb; + cursor: pointer; + vertical-align: middle; + margin-left: 2px; + + &.blocked-info { + color: #dc2626; + } +} + +/* Age Inline */ +.age-inline { + display: flex; + align-items: center; + gap: 5px; + margin-top: -14px; + margin-bottom: 4px; + font-size: 11.5px; + font-weight: 600; + color: #059669; + padding-left: 2px; + + &.full-width { + grid-column: 1 / -1; + } + + .age-inline-icon { + font-size: 11px; + color: #10b981; + } +} + +/* Minor Age Indicator */ +.age-minor { + color: #1074b9 !important; + background: #eff6ff; + padding: 4px 10px; + border-radius: 6px; + border: 1px solid #bfdbfe; + + .age-inline-icon { + color: #1074b9 !important; + } +} + +.minor-badge { + font-size: 10px; + font-weight: 700; + padding: 2px 8px; + border-radius: 10px; + background: #fef2f2; + color: #dc2626; + margin-left: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* ═══ Guardian Section (Redesigned) ═══ */ +.guardian-section { + margin-top: 12px; + border: 1px solid #e2e8f0; + border-radius: 12px; + overflow: hidden; + background: #fff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.guardian-section__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 20px; + background: linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%); + color: #fff; +} + +.guardian-section__header-left { + display: flex; + align-items: center; + gap: 12px; +} + +.guardian-section__header-icon { + font-size: 22px; + color: #60a5fa; +} + +.guardian-section__title { + font-size: 15px; + font-weight: 600; + margin: 0; + color: #fff; +} + +.guardian-section__sub { + font-size: 11px; + color: rgba(255, 255, 255, 0.55); +} + +.guardian-section__age-chips { + display: flex; + align-items: center; + gap: 6px; +} + +.guardian-age-chip { + font-size: 11px; + font-weight: 500; + color: rgba(255, 255, 255, 0.8); + padding: 3px 10px; + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; + + strong { + font-weight: 800; + color: #fff; + font-size: 13px; + } +} + +.guardian-minor-badge { + font-size: 10px; + font-weight: 700; + padding: 3px 10px; + border-radius: 10px; + background: #ef4444; + color: #fff; + letter-spacing: 0.5px; +} + +.guardian-section__header-right { + display: flex; + align-items: center; + gap: 12px; +} + +.guardian-add-btn { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px !important; + font-weight: 600 !important; + height: 34px !important; + border-radius: 8px !important; + background: #fff !important; + color: #0f172a !important; + padding: 0 16px !important; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +.guardian-section__body { + padding: 16px 20px; +} + +/* Empty state */ +.guardian-empty { + display: flex; + align-items: center; + gap: 12px; + padding: 20px; + background: #f8fafc; + border: 1px dashed #cbd5e1; + border-radius: 10px; + color: #64748b; + font-size: 13px; +} + +.guardian-empty__icon { + font-size: 28px; + width: 28px; + height: 28px; + color: #cbd5e1; +} + +.guardian-empty__content { + display: flex; + flex-direction: column; + gap: 4px; +} + +.guardian-empty--required { + border-color: #fca5a5; + background: #fef2f2; +} + +.guardian-empty__required { + font-size: 11px; + font-weight: 600; + color: #dc2626; +} + +/* Search + Relationship row */ +.guardian-search-row { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.guardian-search-col { + flex: 1; +} + +.guardian-rel-field { + flex: 0 0 180px; + width: 180px; +} + +.guardian-rel-hint { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: #fff7ed; + border: 1px solid #fed7aa; + border-radius: 6px; + font-size: 12px; + color: #92400e; + margin-top: -8px; + margin-bottom: 4px; +} + +.guardian-rel-hint__icon { + font-size: 16px !important; + width: 16px !important; + height: 16px !important; + color: #f59e0b; +} + +.guardian-cancel-link { + color: #94a3b8 !important; + margin-top: 8px; + &:hover { color: #ef4444 !important; } +} + +/* ═══ Guardian Row Table ═══ */ +.guardian-row-table { + border: 1px solid #e2e8f0; + border-radius: 10px; + overflow: hidden; + background: #fff; +} + +.guardian-row__header { + display: flex; + align-items: center; + background: #f1f5f9; + padding: 8px 12px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + color: #64748b; + letter-spacing: 0.5px; + border-bottom: 1px solid #e2e8f0; +} + +.guardian-row__data { + display: flex; + align-items: center; + padding: 10px 12px; + transition: background 0.1s; + &:hover { background: #f8fafc; } +} + +.guardian-row__col { + padding: 0 6px; + font-size: 13px; + color: #1e293b; + + &--photo { flex: 0 0 50px; } + &--name { flex: 1; min-width: 0; } + &--rel { flex: 0 0 120px; } + &--cif { flex: 0 0 130px; } + &--status { flex: 0 0 80px; } + &--actions { flex: 0 0 110px; display: flex; gap: 2px; justify-content: flex-end; } +} + +.guardian-row__avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: linear-gradient(135deg, #1074b9, #0ea5e9); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; + cursor: pointer; + + &.has-photo { + padding: 0; + overflow: hidden; + } +} + +.guardian-row__name-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.guardian-row__name-text { + font-weight: 600; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.guardian-row__linked-tag { + font-size: 10px; + color: #1074b9; + font-weight: 500; + display: flex; + align-items: center; + gap: 2px; +} + +.guardian-row__link-icon { + font-size: 12px !important; + width: 12px !important; + height: 12px !important; +} + +.guardian-row__rel-badge { + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + border-radius: 10px; + background: #eff6ff; + color: #1e40af; + text-transform: capitalize; +} + +.guardian-row__mono { + font-family: 'Roboto Mono', monospace; + font-size: 12px; + color: #64748b; +} + +.guardian-row__status-badge { + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + border-radius: 10px; + background: #f1f5f9; + color: #64748b; + + &--active { + background: #ecfdf5; + color: #059669; + } +} + +.guardian-action-btn { + width: 32px !important; + height: 32px !important; + line-height: 32px !important; + + mat-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + color: #64748b; + } + + &:hover mat-icon { color: #1074b9; } + &--delete:hover mat-icon { color: #ef4444; } +} + +.guardian-row__zoomable { + cursor: zoom-in; + transition: transform 0.15s; + &:hover { transform: scale(1.05); } +} + +/* Expandable detail panel */ +.guardian-row__expand { + border-top: 1px solid #e2e8f0; + background: #f8fafc; + padding: 16px 20px; + animation: guardianSlideDown 0.2s ease-out; +} + +@keyframes guardianSlideDown { + from { opacity: 0; max-height: 0; } + to { opacity: 1; max-height: 500px; } +} + +.guardian-expand__grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 24px; +} + +.guardian-expand__item { + display: flex; + flex-direction: column; + gap: 2px; + + &--wide { + grid-column: 1 / -1; + } +} + +.guardian-expand__label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #94a3b8; +} + +.guardian-expand__value { + font-size: 13px; + font-weight: 500; + color: #1e293b; + display: flex; + align-items: center; + gap: 4px; +} + +.guardian-expand__sig { + margin-top: 14px; + padding-top: 14px; + border-top: 1px dashed #e2e8f0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.guardian-expand__sig-img { + max-width: 200px; + max-height: 60px; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 4px; + background: #fff; +} + +.guardian-expand__photo-section { + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.guardian-expand__photo { + width: 60px; + height: 60px; + border-radius: 8px; + object-fit: cover; + border: 1px solid #e2e8f0; +} + +/* Zoom dialog panel override */ +::ng-deep .guardian-zoom-panel { + .mat-mdc-dialog-container { + padding: 0 !important; + border-radius: 12px !important; + } + .mdc-dialog__surface { + border-radius: 12px !important; + } +} + +@media (max-width: 768px) { + .guardian-section__header { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .guardian-section__header-right { + flex-direction: column; + align-items: flex-start; + width: 100%; + } + + .guardian-search-row { + flex-direction: column; + } + + .guardian-rel-field { + flex: none; + width: 100%; + } + + .guardian-row__header { display: none; } + + .guardian-row__data { + flex-wrap: wrap; + gap: 8px; + } + + .guardian-row__col { + &--photo { flex: 0 0 40px; } + &--name { flex: 1 1 200px; } + &--rel, &--cif, &--status { flex: 0 0 auto; } + &--actions { flex: 0 0 auto; } + } + + .guardian-expand__grid { + grid-template-columns: 1fr; + } +} + +/* Age Breakdown Chips */ +.age-breakdown { + display: flex; + gap: 12px; + margin-top: 14px; + padding-top: 12px; + border-top: 1px dashed #e2e8f0; +} + +.age-break-item { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 14px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + + .age-break-label { + font-size: 11px; + font-weight: 500; + color: #64748b; + } + + .age-break-value { + font-size: 16px; + font-weight: 800; + color: #0f172a; + } +} + +/* ═══ Guardian Search ═══ */ +.guardian-search-wrapper { + position: relative; +} + +.guardian-search-field { + width: 100%; +} + +.guardian-search-icon { + color: #94a3b8; + font-size: 14px; +} + +.guardian-results { + position: absolute; + top: 56px; + left: 0; + right: 0; + z-index: 100; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + max-height: 280px; + overflow-y: auto; +} + +.guardian-results__loading { + display: flex; + align-items: center; + gap: 10px; + padding: 16px 20px; + font-size: 13px; + color: #64748b; +} + +.guardian-results__empty { + padding: 16px 20px; + font-size: 13px; + color: #94a3b8; + text-align: center; +} + +.search-no-results-hint { + font-size: 12px; + color: #94a3b8; + text-align: center; + padding: 4px 0 0; + margin-top: -8px; +} + +.guardian-result-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + cursor: pointer; + transition: background 0.1s; + border-bottom: 1px solid #f1f5f9; + + &:last-child { border-bottom: none; } + &:hover { background: #f0f7ff; } +} + +.guardian-result-item__avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: #e0e7ff; + color: #4f46e5; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; +} + +.guardian-result-item__info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.guardian-result-item__name { + font-size: 13px; + font-weight: 600; + color: #1e293b; +} + +.guardian-result-item__meta { + font-size: 11px; + color: #64748b; + font-family: 'Roboto Mono', monospace; +} + +.guardian-result-item__status { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + background: #f1f5f9; + color: #64748b; + flex-shrink: 0; + + &.active { + background: #ecfdf5; + color: #059669; + } +} + +/* ═══ Guardian Card ═══ */ +.guardian-card { + border: 1px solid #e2e8f0; + border-radius: 12px; + overflow: hidden; + background: #fff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} + +.guardian-card__header { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 18px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; +} + +.guardian-card__avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: #1074b9; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; +} + +.guardian-card__info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.guardian-card__name { + font-size: 15px; + font-weight: 600; + color: #0f172a; +} + +.guardian-card__meta { + font-size: 12px; + color: #64748b; + font-family: 'Roboto Mono', monospace; +} + +.guardian-card__remove { + color: #94a3b8 !important; + &:hover { color: #ef4444 !important; } +} + +.guardian-card__body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; +} + +.guardian-card__detail { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 18px; + border-bottom: 1px solid #f1f5f9; + border-right: 1px solid #f1f5f9; + + &:nth-child(2n) { border-right: none; } + &:nth-last-child(-n+2) { border-bottom: none; } +} + +.guardian-card__label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #94a3b8; +} + +.guardian-card__value { + font-size: 13px; + font-weight: 500; + color: #1e293b; +} + +/* Legal Form dropdown with icon */ +.legal-form-option { + display: flex; + align-items: center; + gap: 10px; +} + +.legal-form-option-icon { + font-size: 20px !important; + width: 20px !important; + height: 20px !important; + color: #1074b9; +} + +/* Nationality dropdown with flag */ +.nat-option { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; +} + +.nat-flag { + display: inline-flex; + align-items: center; + width: 24px; + height: 16px; + border-radius: 3px; + overflow: hidden; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1); + flex-shrink: 0; +} + +.nat-flag-svg { + width: 24px; + height: 16px; + display: block; +} + +.nat-globe-icon { + font-size: 20px !important; + width: 20px !important; + height: 20px !important; + color: #1074b9; +} + +/* Business dropdown option with icon */ +.biz-option { + display: flex; + align-items: center; + gap: 10px; +} + +.biz-option-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + color: #1074b9; +} + +/* Currency Suffix */ +.currency-suffix { + font-size: 13px; + font-weight: 600; + color: #64748b; + padding-right: 4px; +} + +/* MSME Info Trigger */ +.msme-info-trigger { + display: inline-flex; + align-items: center; + cursor: pointer; + margin-left: 4px; +} + +.msme-info-tooltip { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + color: #0ea5e9; + transition: color 0.15s; + + &:hover { + color: #0369a1; + } +} + +/* MSME Classification Table Card */ +.msme-class-table-card { + background: #fff; + border: 1px solid #bae6fd; + border-radius: 10px; + box-shadow: 0 4px 20px rgb(0 0 0 / 10%); + margin-bottom: 16px; + overflow: hidden; +} + +.msme-class-table-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: #0c4a6e; + color: #fff; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.msme-class-close { + width: 24px; + height: 24px; + border: none; + background: rgba(255, 255, 255, 0.15); + border-radius: 4px; + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + + &:hover { + background: rgba(255, 255, 255, 0.25); + } +} + +.msme-class-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + text-align: left; + padding: 10px 16px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #64748b; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + } + + td { + padding: 10px 16px; + border-bottom: 1px solid #f1f5f9; + font-weight: 500; + color: #0f172a; + } + + .row-micro td { background: #ecfdf5; } + .row-small td { background: #e0f2fe; } + .row-medium td { background: #fffbeb; } + .row-large td { background: #f8fafc; color: #94a3b8; } +} + +.msme-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + + &.green { background: #10b981; } + &.blue { background: #0ea5e9; } + &.amber { background: #f59e0b; } + &.gray { background: #cbd5e1; } +} + +.msme-class-table-footer { + padding: 10px 16px; + font-size: 11px; + color: #64748b; + background: #f8fafc; + border-top: 1px solid #e2e8f0; +} + +/* Constitution Info Card */ +.constitution-info-card { + margin-top: 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + overflow: hidden; + margin-bottom: 12px; +} + +.constitution-info-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: #0f172a; + font-size: 12px; + font-weight: 600; + color: #fff; +} + +.constitution-info-icon { + font-size: 14px; + color: #60a5fa; +} + +.constitution-info-body { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.constitution-info-row { + display: flex; + gap: 12px; + font-size: 12px; + line-height: 1.5; +} + +.constitution-info-label { + font-weight: 600; + color: #64748b; + min-width: 110px; + flex-shrink: 0; +} + +.constitution-info-value { + color: #0f172a; +} + +.constitution-note { + color: #b45309 !important; + font-weight: 500; +} + +/* MSME Section */ +.msme-section { + margin-top: 16px; + padding: 16px; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 10px; +} + +.msme-title { + color: #0369a1 !important; + + .section-icon { + color: #0ea5e9 !important; + } +} + +.msme-info-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: #e0f2fe; + border: 1px solid #7dd3fc; + border-radius: 6px; + margin-bottom: 16px; + font-size: 12px; + color: #0c4a6e; + + .msme-info-icon { + font-size: 14px; + color: #0284c7; + } +} + +/* MSME Type Field — styled as mat-form-field */ +.msme-type-field { + ::ng-deep { + input:disabled { + font-weight: 700 !important; + color: #0f172a !important; + -webkit-text-fill-color: #0f172a !important; + } + } + + &.msme-micro ::ng-deep { + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: #10b981 !important; + } + input:disabled { + color: #059669 !important; + -webkit-text-fill-color: #059669 !important; + } + } + + &.msme-small ::ng-deep { + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: #0ea5e9 !important; + } + input:disabled { + color: #0284c7 !important; + -webkit-text-fill-color: #0284c7 !important; + } + } + + &.msme-medium ::ng-deep { + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: #f59e0b !important; + } + input:disabled { + color: #d97706 !important; + -webkit-text-fill-color: #d97706 !important; + } + } +} + +/* Gender select — capitalize display */ +::ng-deep .mat-mdc-select-value-text { + text-transform: capitalize; +} + +/* Currency Prefix */ +.field-prefix-icon { + display: inline-flex; + align-items: center; + padding-left: 4px; + padding-right: 8px; + margin-right: 8px; + border-right: 1.5px solid #cbd5e1; + line-height: 1; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #1565c0; + } +} + +.icon-option { + display: flex; + align-items: center; + gap: 10px; +} + +.icon-option-icon { + font-size: 20px !important; + width: 20px !important; + height: 20px !important; + color: #1074b9; +} + +.rupee-prefix { + font-size: 16px; + font-weight: 600; + color: #1565c0; + padding-left: 4px; + padding-right: 8px; + margin-right: 8px; + border-right: 1.5px solid #cbd5e1; + line-height: 1; + display: inline-flex; + align-items: center; +} + /* Verification Section */ .sanctions-verify-section { margin-bottom: 16px; @@ -139,15 +1346,17 @@ form { } } -.mobile-verify-row { +.dip-verify-row { display: flex; align-items: center; - gap: 12px; + gap: 10px; + padding-top: 4px; +} - .mobile-field { - flex: 1; - max-width: 320px; - } +.mobile-verify-row { + display: flex; + align-items: flex-start; + gap: 16px; .verify-btn { height: 40px; @@ -205,3 +1414,240 @@ form { } } } + +.guardian-card__avatar.has-photo { + padding: 0; + overflow: hidden; +} + +.avatar-photo { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} + +.guardian-card__signature { + padding: 12px 16px; + border-top: 1px solid #e2e8f0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.signature-img { + max-width: 250px; + max-height: 80px; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 6px; + background: #fff; +} + +.signatory-section { + margin-top: 8px; + + .signatory-title { + color: #1e293b; + } +} + +.sig-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + border-radius: 11px; + background: #2563eb; + color: #fff; + font-size: 11px; + font-weight: 600; + margin-left: 8px; + padding: 0 6px; +} + +/* Signatory Table */ +.sig-table { + border: 1px solid #e2e8f0; + border-radius: 8px; + overflow: hidden; + margin-bottom: 12px; +} + +.sig-table__header { + display: flex; + align-items: center; + background: #f1f5f9; + padding: 8px 12px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + color: #64748b; + letter-spacing: 0.5px; + border-bottom: 1px solid #e2e8f0; +} + +.sig-table__row { + display: flex; + align-items: center; + padding: 10px 12px; + border-bottom: 1px solid #f1f5f9; + transition: background 0.15s; + + &:last-child { border-bottom: none; } + &:hover { background: #f8fafc; } +} + +.sig-table__col { + &--sno { flex: 0 0 32px; text-align: center; color: #94a3b8; font-size: 12px; } + &--photo { flex: 0 0 40px; } + &--name { flex: 1; font-size: 13px; display: flex; align-items: center; gap: 6px; } + &--id { flex: 0 0 100px; font-size: 12px; color: #64748b; } + &--pan { flex: 0 0 120px; font-size: 12px; color: #1e293b; display: flex; align-items: center; gap: 3px; } + &--status { flex: 0 0 80px; } + &--actions { flex: 0 0 80px; display: flex; gap: 2px; } +} + +.sig-table__avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: #dbeafe; + display: flex; + align-items: center; + justify-content: center; + color: #2563eb; + font-size: 12px; + overflow: hidden; + + &.has-photo { padding: 0; } + .avatar-photo { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; } +} + +.sig-inline-spinner { display: inline-block; } + +.verify-tick-sm { + font-size: 14px !important; + width: 14px !important; + height: 14px !important; + color: #22c55e; +} + +.sig-status-chip { + font-size: 11px; + font-weight: 600; + color: #94a3b8; + &.active { color: #22c55e; } +} + +.sig-action-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + color: #64748b; + &.remove { color: #ef4444; } +} + +.sig-add-wrapper { + margin-top: 4px; +} + +/* Dialog panel override */ +::ng-deep .signatory-detail-panel { + .mat-mdc-dialog-container { + padding: 0 !important; + border-radius: 12px !important; + overflow: visible !important; + max-height: none !important; + } + .mdc-dialog__surface { + border-radius: 12px !important; + overflow: visible !important; + max-height: none !important; + } + .mat-mdc-dialog-content { + max-height: none !important; + overflow: visible !important; + } +} + +.guardian-card__loading { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + font-size: 12px; + color: #64748b; +} + +.guardian-detail-icon { + font-size: 14px !important; + width: 14px !important; + height: 14px !important; + vertical-align: middle; + margin-right: 2px; + color: #94a3b8; +} + +.verify-tick { + font-size: 16px !important; + width: 16px !important; + height: 16px !important; + color: #22c55e; + vertical-align: middle; + margin-left: 4px; +} + +.step-header { + background: #0f172a; + padding: 16px 24px; + border-radius: 10px 10px 0 0; + margin-bottom: 20px; + + &__left { + display: flex; + align-items: center; + gap: 12px; + } + + &__icon { + font-size: 18px; + color: #60a5fa; + } + + &__title { + font-size: 16px; + font-weight: 600; + color: #fff; + margin: 0; + } + + &__sub { + font-size: 11px; + color: rgba(255, 255, 255, 0.5); + margin: 2px 0 0; + } +} + +.occ-option { + display: flex; + align-items: center; + gap: 10px; +} + +.occ-option-icon { + font-size: 20px !important; + width: 20px !important; + height: 20px !important; + color: #1074b9; +} + +.constitution-icon { + font-size: 20px; + width: 20px; + height: 20px; + margin-right: 8px; + vertical-align: middle; + color: #1565c0; +} diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 198c88fb8..ab92a3241 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -7,7 +7,9 @@ */ /** Angular Imports */ -import { Component, OnInit, OnDestroy, Input, Output, EventEmitter, inject } from '@angular/core'; +import { Component, OnInit, OnDestroy, OnChanges, SimpleChanges, Input, Output, EventEmitter, inject } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { UntypedFormBuilder, UntypedFormGroup, @@ -30,9 +32,12 @@ import { MatCheckbox } from '@angular/material/checkbox'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatIcon } from '@angular/material/icon'; +import { MatSelectTrigger } from '@angular/material/select'; import { MatTooltip } from '@angular/material/tooltip'; -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogClose } from '@angular/material/dialog'; +import { Inject } from '@angular/core'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component'; import { DipVerifyDialogComponent } from './dip-verify-dialog.component'; @@ -51,16 +56,19 @@ import { DipVerifyDialogComponent } from './dip-verify-dialog.component'; MatCheckbox, FaIconComponent, MatIcon, - MatTooltip + MatTooltip, + MatSelectTrigger, + DateFormatPipe ] }) -export class ClientGeneralStepComponent implements OnInit, OnDestroy { +export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges { private formBuilder = inject(UntypedFormBuilder); private dateUtils = inject(Dates); private settingsService = inject(SettingsService); private clientService = inject(ClientsService); externalNationalIdService = inject(ExternalNationalIdService); private dialog = inject(MatDialog); + private sanitizer = inject(DomSanitizer); /** Subject to trigger unsubscription on destroy */ private destroy$ = new Subject(); @@ -77,6 +85,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy { /** Client Template */ @Input() clientTemplate: any; + /** Existing client data for edit mode (null for create) */ + @Input() clientData: any = null; + /** Read-only mode — disables form editing until user clicks edit icon */ + @Input() readOnly = false; /** Create Client Form */ createClientForm: UntypedFormGroup; @@ -94,6 +106,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy { businessLineOptions: any; /** Constitution Options */ constitutionOptions: any; + /** Enriched constitution options (API IDs + master metadata) */ + enrichedConstitutionOptions: any[] = []; /** Gender Options */ genderOptions: any; /** Saving Product Options */ @@ -102,12 +116,80 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy { occupationCategoryOptions: any; /** Occupation Options */ occupationOptions: any; + /** Salutation Options */ + salutationOptions: any; + /** Marital Status Options */ + maritalStatusOptions: any[] = []; + /** Nationality Options */ + nationalityOptions = [ + { value: 'INDIAN', label: 'Indian', flagType: 'india', icon: '' }, + { value: 'NRI', label: 'NRI', flagType: 'none', icon: 'flight_takeoff' }, + { value: 'FOREIGN_NATIONAL', label: 'Foreign National', flagType: 'none', icon: 'public' }, + { value: 'PIO', label: 'PIO', flagType: 'none', icon: 'connecting_airports' }, + { value: 'OCI', label: 'OCI', flagType: 'none', icon: 'card_travel' } + ]; + /** Calculated age detail string (Y/M/D) */ + calculatedAgeDetail: string | null = null; + /** Whether client is a minor (age < 10) */ + isMinor = false; + /** Age breakdown for DB storage */ + ageBreakdown = { years: 0, months: 0, days: 0 }; + + /** Guardian search */ + guardianSearchText = ''; + guardianSearchResults: any[] = []; + guardianSearching = false; + selectedGuardian: any = null; + private guardianSearchTimer: any = null; + /** Signatory search (Non-Individual, multiple) */ + signatorySearchText = ''; + signatorySearchResults: any[] = []; + signatorySearching = false; + signatories: any[] = []; + private signatorySearchTimer: any = null; + /** Guardian linking mode (search visible) */ + guardianLinking = false; + /** Guardian detail row expanded */ + guardianExpanded = false; /** Mobile number DIP verification status */ mobileVerified = false; + /** DIP lookup log ID from API */ + dipLookupLogId: number | null = null; + /** DIP lookup timestamp from API */ + dipTimestamp: string | null = null; + /** Alternate mobile number DIP verification status */ + alternateMobileVerified = false; + /** Alternate DIP lookup log ID from API */ + alternateDipLookupLogId: number | null = null; + /** Alternate DIP lookup timestamp from API */ + alternateDipTimestamp: string | null = null; + /** Constitution Types Master with KYC field metadata */ + constitutionTypesMaster = [ + { value: 'PRIVATE_LIMITED', label: 'Private Limited Company', icon: 'business', panLabel: 'PAN of Company', panHint: 'PAN mandatory. CIN from MCA.', regHint: 'Certificate of Incorporation No.', showCin: true, additionalField: 'Constitution as per Articles of Association', documents: 'Certificate of Incorporation, Memorandum & Articles of Association, PAN, Board Resolution', signatoryKyc: 'KYC of all Authorized Signatories (OVDs + PAN + Photo)', note: 'Board Resolution to specify authorized signatories' }, + { value: 'PUBLIC_LIMITED', label: 'Public Limited Company', icon: 'domain', panLabel: 'PAN of Company', panHint: 'PAN mandatory. CIN from MCA.', regHint: 'Certificate of Incorporation No.', showCin: true, additionalField: 'Constitution as per Articles of Association', documents: 'Certificate of Incorporation, Memorandum & Articles of Association, PAN, Board Resolution', signatoryKyc: 'KYC of all Authorized Signatories (OVDs + PAN + Photo)', note: 'Board Resolution to specify authorized signatories' }, + { value: 'LLP', label: 'Limited Liability Partnership (LLP)', icon: 'handshake', panLabel: 'PAN of LLP', panHint: 'PAN mandatory. LLPIN from MCA.', regHint: 'Certificate of Incorporation No.', showCin: true, additionalField: 'LLP Agreement', documents: 'Certificate of Incorporation, LLP Agreement, PAN, Designated Partners Resolution', signatoryKyc: 'KYC of all Designated Partners / Authorized Signatories', note: 'LLPIN from MCA required' }, + { value: 'PARTNERSHIP', label: 'Partnership Firm', icon: 'handshake', panLabel: 'PAN of Firm', panHint: 'PAN of Firm mandatory', regHint: 'Registration Certificate No. (if registered)', showCin: false, additionalField: 'Partners Names (linked client-wise)', documents: 'Partnership Deed, Registration Certificate (if registered), PAN of Firm', signatoryKyc: 'KYC of all Partners / Authorized Signatories', note: 'Registration optional but preferred' }, + { value: 'PROPRIETORSHIP', label: 'Proprietorship', icon: 'person', panLabel: 'PAN of Proprietor', panHint: 'PAN of Proprietor (no separate entity PAN needed)', regHint: 'GST / Shop License No.', showCin: false, additionalField: '', documents: 'GST Certificate / Shop License / Utility Bill, PAN of Proprietor', signatoryKyc: "Proprietor's individual KYC (same as individual requirements)", note: 'At least 2 proofs of business existence required. Proprietor name = Client name.' }, + { value: 'HUF', label: 'HUF (Hindu Undivided Family)', icon: 'family_restroom', panLabel: 'PAN of HUF', panHint: 'PAN of HUF mandatory', regHint: 'HUF Declaration / Deed No.', showCin: false, additionalField: 'Karta Name', documents: 'HUF Declaration/Deed, PAN of HUF', signatoryKyc: 'KYC of Karta', note: '' }, + { value: 'TRUST', label: 'Trust', icon: 'verified_user', panLabel: 'PAN of Trust', panHint: 'PAN of Trust mandatory', regHint: 'Trust Deed / Registration No.', showCin: false, additionalField: 'Purpose of Trust', documents: 'Trust Deed, Registration Certificate, PAN of Trust', signatoryKyc: 'KYC of Trustees / Authorized Signatories', note: 'Foreign contribution rules apply if applicable' }, + { value: 'SOCIETY', label: 'Society', icon: 'groups', panLabel: 'PAN of Society', panHint: 'PAN of Society mandatory', regHint: 'Registration Certificate No.', showCin: false, additionalField: 'Managing Committee Details', documents: 'Registration Certificate, PAN of Society, Resolution authorizing signatories', signatoryKyc: 'KYC of Authorized Signatories', note: 'Governed by Societies Registration Act' }, + { value: 'ASSOCIATION', label: 'Association / Club', icon: 'diversity_3', panLabel: 'PAN', panHint: 'PAN mandatory', regHint: 'Registration Certificate No.', showCin: false, additionalField: 'Club Constitution Document', documents: 'Registration Certificate, PAN, Resolution authorizing signatories', signatoryKyc: 'KYC of Authorized Signatories', note: 'Committee approval needed for account' }, + { value: 'GOVT_PSU', label: 'Government Dept / PSU', icon: 'account_balance', panLabel: 'PAN (if applicable)', panHint: 'PAN if applicable', regHint: 'Govt. Notification / Order No.', showCin: false, additionalField: 'Gazette Notification Reference', documents: 'Relevant Government Notification / Order, PAN (if applicable)', signatoryKyc: 'Authorized Signatory KYC', note: 'Special KYC norms as per RBI circular' }, + { value: 'COOPERATIVE', label: 'Cooperative Society / Cooperative Bank', icon: 'hub', panLabel: 'PAN', panHint: 'PAN mandatory. RBI license if bank.', regHint: 'Registration Certificate No.', showCin: false, additionalField: 'Bye-laws of the Cooperative', documents: 'Registration Certificate, PAN, Resolution authorizing signatories', signatoryKyc: 'KYC of Authorized Signatories', note: 'Separate RBI licensing check for cooperative banks' } + ]; + + /** Currently selected constitution metadata */ + get selectedConstitution(): any { + const val = this.createClientForm.get('clientNonPersonDetails.constitutionId')?.value; + return this.enrichedConstitutionOptions.find((c: any) => c.id === val) || null; + } + /** Sanctions verification state */ sanctionsVerified = false; /** Sanctions match was found — blocks account opening */ sanctionsBlocked = false; + /** Screening log ID from sanctions API */ + screeningLogId: number | null = null; /** * @param {FormBuilder} formBuilder Form Builder @@ -122,8 +204,105 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy { ngOnInit() { this.maxDate = this.settingsService.businessDate; this.setOptions(); + // Fetch marital status options from code values + this.clientService.getCodeValuesByName('MaritalStatus').subscribe(options => { + this.maritalStatusOptions = options || []; + }); + // Fetch all constitution options from code ID 17 + this.clientService.getCodeValuesById(17).subscribe(options => { + if (options?.length) { + this.constitutionOptions = options; + // Sort master by value length descending so longer/more-specific keys match first + const sortedMaster = [...this.constitutionTypesMaster].sort((a, b) => b.value.length - a.value.length); + this.enrichedConstitutionOptions = options.map((opt: any) => { + const normalized = opt.name?.toUpperCase().replace(/[\s_-]+/g, '') || ''; + const master = sortedMaster.find(m => normalized.includes(m.value.replace(/_/g, ''))); + return { + id: opt.id, + value: master?.value || opt.name, + label: master?.label || opt.name, + icon: master?.icon || 'business', + panLabel: master?.panLabel || '', + panHint: master?.panHint || '', + regHint: master?.regHint || '', + showCin: master?.showCin || false, + additionalField: master?.additionalField || '', + documents: master?.documents || '', + signatoryKyc: master?.signatoryKyc || '', + note: master?.note || '' + }; + }); + } + }); this.buildDependencies(); - this.externalNationalIdService.watchExternalId(this.createClientForm, this.genderOptions); + if (this.clientData) { + this.prefillForEdit(); + } + } + + /** + * Pre-fills the form with existing client data for edit mode. + * Called AFTER buildDependencies so name controls already exist. + */ + private prefillForEdit() { + const data = this.clientData; + + // Patch all base controls that exist on the form + this.createClientForm.patchValue({ + officeId: data.officeId, + isStaff: data.isStaff, + active: data.active, + accountNo: data.accountNo, + genderId: data.gender?.id, + maritalStatusId: data.maritalStatus?.id || '', + mobileNo: data.mobileNo, + alternateMobileNo: data.alternateMobileNo || '', + emailAddress: data.emailAddress, + dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : '', + occupationCategoryId: data.occupationCategory?.id || data.occupationCategoryId || '', + occupationId: data.occupation?.id || data.occupationId || '', + monthlyIncome: data.monthlyIncome || '', + annualTurnover: data.annualTurnover || '', + submittedOnDate: data.timeline?.submittedOnDate ? new Date(data.timeline.submittedOnDate) : this.settingsService.businessDate + }); + + // Patch name fields (already created by buildDependencies via legalFormId subscription) + const legalFormId = data.legalForm?.id || LegalFormId.PERSON; + if (legalFormId === LegalFormId.PERSON) { + if (this.createClientForm.get('salutationId')) { + this.createClientForm.get('salutationId').setValue(data.salutation?.id || data.salutationId || ''); + } + if (this.createClientForm.get('firstname')) { + this.createClientForm.get('firstname').setValue(data.firstname || ''); + } + if (this.createClientForm.get('middlename')) { + this.createClientForm.get('middlename').setValue(data.middlename || ''); + } + if (this.createClientForm.get('lastname')) { + this.createClientForm.get('lastname').setValue(data.lastname || ''); + } + } else if (this.createClientForm.get('fullname')) { + this.createClientForm.get('fullname').setValue(data.fullname || ''); + if (data.clientNonPersonDetails && this.createClientForm.get('clientNonPersonDetails')) { + this.createClientForm.get('clientNonPersonDetails').patchValue({ + constitutionId: data.clientNonPersonDetails.constitution?.id || '', + mainBusinessLineId: data.clientNonPersonDetails.mainBusinessLine?.id || '', + incorpNumber: data.clientNonPersonDetails.incorpNumber || '', + remarks: data.clientNonPersonDetails.remarks || '', + authorizedSignatory: data.clientNonPersonDetails.authorizedSignatory || '', + additionalInfo: data.clientNonPersonDetails.additionalInfo || '' + }); + } + } + + // Set activation date if client is active (activationDate control is added by active subscription) + if (data.active && data.timeline?.activatedOnDate) { + setTimeout(() => { + if (this.createClientForm.get('activationDate')) { + this.createClientForm.get('activationDate').setValue(new Date(data.timeline.activatedOnDate)); + } + }); + } } /** @@ -135,27 +314,28 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy { '', Validators.required ], - staffId: [''], legalFormId: [ '', Validators.required ], + nationalityId: ['INDIAN'], isStaff: [false], active: [false], addSavings: [false], accountNo: [''], - externalId: [''], - genderId: [''], - mobileNo: [''], + genderId: ['', Validators.required], + maritalStatusId: [''], + mobileNo: ['', [Validators.required, Validators.pattern(/^[6-9]\d{9}$/), Validators.maxLength(10)]], + alternateMobileNo: ['', [Validators.pattern(/^[6-9]\d{9}$/), Validators.maxLength(10)]], emailAddress: [ '', - Validators.email + [Validators.required, Validators.email, Validators.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/), Validators.maxLength(100)] ], - dateOfBirth: [''], - clientTypeId: [''], - clientClassificationId: [''], + dateOfBirth: ['', Validators.required], occupationCategoryId: [''], occupationId: [''], + monthlyIncome: ['', [Validators.pattern(/^\d+(\.\d{1,2})?$/), Validators.min(0)]], + annualTurnover: ['', [Validators.pattern(/^\d+(\.\d{1,2})?$/), Validators.min(0)]], submittedOnDate: [ this.settingsService.businessDate, Validators.required @@ -178,6 +358,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy { this.savingProductOptions = this.clientTemplate.savingProductOptions; this.occupationCategoryOptions = this.clientTemplate.occupationCategoryOptions || []; this.occupationOptions = this.clientTemplate.occupationOptions || []; + this.salutationOptions = this.clientTemplate.salutationOptions || []; } /** @@ -192,48 +373,123 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy { if (legalFormId === LegalFormId.PERSON) { this.createClientForm.removeControl('fullname'); this.createClientForm.removeControl('clientNonPersonDetails'); + // Restore DOB and Gender required for individual + this.createClientForm.get('dateOfBirth')?.setValidators(Validators.required); + this.createClientForm.get('dateOfBirth')?.updateValueAndValidity(); + this.createClientForm.get('genderId')?.setValidators(Validators.required); + this.createClientForm.get('genderId')?.updateValueAndValidity(); + this.createClientForm.addControl('salutationId', new UntypedFormControl('')); this.createClientForm.addControl( 'firstname', new UntypedFormControl('', [ Validators.required, - Validators.pattern('(^[A-z]).*') + Validators.pattern(/^[A-Za-z][A-Za-z\s.'-]*$/), + Validators.maxLength(50) ]) ); - this.createClientForm.addControl('middlename', new UntypedFormControl('', Validators.pattern('(^[A-z]).*'))); + this.createClientForm.addControl('middlename', new UntypedFormControl('', [Validators.pattern(/^[A-Za-z][A-Za-z\s.'-]*$/), Validators.maxLength(50)])); this.createClientForm.addControl( 'lastname', new UntypedFormControl('', [ Validators.required, - Validators.pattern('(^[A-z]).*') + Validators.pattern(/^[A-Za-z][A-Za-z\s.'-]*$/), + Validators.maxLength(50) ]) ); } else { + this.createClientForm.removeControl('salutationId'); this.createClientForm.removeControl('firstname'); this.createClientForm.removeControl('middlename'); this.createClientForm.removeControl('lastname'); + // Remove DOB and Gender required for non-individual + this.createClientForm.get('dateOfBirth')?.clearValidators(); + this.createClientForm.get('dateOfBirth')?.updateValueAndValidity(); + this.createClientForm.get('genderId')?.clearValidators(); + this.createClientForm.get('genderId')?.updateValueAndValidity(); this.createClientForm.addControl( 'fullname', new UntypedFormControl('', [ Validators.required, - Validators.pattern('(^[A-z]).*') + Validators.pattern(/^[A-Za-z][A-Za-z\s.&'-]*$/), + Validators.maxLength(100) ]) ); this.createClientForm.addControl( 'clientNonPersonDetails', this.formBuilder.group({ - constitutionId: [ - '', - Validators.required - ], - incorpValidityTillDate: [''], - incorpNumber: [''], + constitutionId: ['', Validators.required], mainBusinessLineId: [''], + incorpNumber: [''], + authorizedSignatory: [''], + additionalInfo: [''], remarks: [''] }) ); } }); - this.createClientForm.get('legalFormId').patchValue(LegalFormId.PERSON); + // Initialize legal form — use client's existing value in edit mode, default to PERSON for create + const initialLegalForm = this.clientData?.legalForm?.id || LegalFormId.PERSON; + this.createClientForm.get('legalFormId').patchValue(initialLegalForm); + // Salutation → Gender auto-fill + this.createClientForm + .get('legalFormId') + .valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe(() => { + // Re-subscribe after salutationId is added + setTimeout(() => this.watchSalutationForGender()); + }); + setTimeout(() => this.watchSalutationForGender()); + // Age auto-calculation from DOB (years, months, days) + minor detection + this.createClientForm + .get('dateOfBirth') + .valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe((dob: any) => { + if (dob) { + const birth = new Date(dob); + const today = new Date(); + let years = today.getFullYear() - birth.getFullYear(); + let months = today.getMonth() - birth.getMonth(); + let days = today.getDate() - birth.getDate(); + if (days < 0) { + months--; + const prevMonth = new Date(today.getFullYear(), today.getMonth(), 0); + days += prevMonth.getDate(); + } + if (months < 0) { + years--; + months += 12; + } + if (years >= 0) { + this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`; + this.ageBreakdown = { years, months, days }; + const wasMinor = this.isMinor; + this.isMinor = years < 10; + // Add/remove guardian form group based on minor status + if (this.isMinor && !wasMinor) { + this.createClientForm.addControl('guardianDetails', this.formBuilder.group({ + guardianClientId: ['', Validators.required], + guardianRelation: ['', Validators.required] + })); + this.selectedGuardian = null; + this.guardianSearchText = ''; + this.guardianSearchResults = []; + } else if (!this.isMinor && wasMinor) { + this.createClientForm.removeControl('guardianDetails'); + this.selectedGuardian = null; + } + } else { + this.calculatedAgeDetail = null; + this.isMinor = false; + this.ageBreakdown = { years: 0, months: 0, days: 0 }; + this.createClientForm.removeControl('guardianDetails'); + } + } else { + this.calculatedAgeDetail = null; + this.isMinor = false; + this.ageBreakdown = { years: 0, months: 0, days: 0 }; + this.createClientForm.removeControl('guardianDetails'); + } + }); this.createClientForm .get('active') .valueChanges.pipe(takeUntil(this.destroy$)) @@ -264,6 +520,29 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy { .subscribe((clientTemplate: any) => { this.staffOptions = clientTemplate.staffOptions; }); + + // Reset DIP verification when mobile number changes + this.createClientForm.get('mobileNo') + .valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.mobileVerified = false; + }); + + this.createClientForm.get('alternateMobileNo') + .valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.alternateMobileVerified = false; + }); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes['readOnly'] && this.createClientForm) { + if (this.readOnly) { + this.createClientForm.disable({ emitEvent: false }); + } else { + this.createClientForm.enable({ emitEvent: false }); + } + } } ngOnDestroy() { @@ -276,17 +555,60 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy { */ verifyWithDIP() { const mobileNo = this.createClientForm.get('mobileNo')?.value || ''; + if (mobileNo.length !== 10) return; const dialogRef = this.dialog.open(DipVerifyDialogComponent, { width: '500px', data: { mobileNo } }); - dialogRef.afterClosed().subscribe((result: boolean) => { - if (result) { + dialogRef.afterClosed().subscribe((result: any) => { + if (!result) return; + if (result.verified) { this.mobileVerified = true; } + this.dipLookupLogId = result.lookupLogId || null; + this.dipTimestamp = result.timestamp || null; }); } + /** + * Verify alternate mobile number with DIP via dialog. + */ + verifyAlternateDIP() { + const mobileNo = this.createClientForm.get('alternateMobileNo')?.value || ''; + if (mobileNo.length !== 10) return; + const dialogRef = this.dialog.open(DipVerifyDialogComponent, { + width: '500px', + data: { mobileNo } + }); + dialogRef.afterClosed().subscribe((result: any) => { + if (!result) return; + if (result.verified) { + this.alternateMobileVerified = true; + } + this.alternateDipLookupLogId = result.lookupLogId || null; + this.alternateDipTimestamp = result.timestamp || null; + }); + } + + /** + * Zoom an image (guardian photo / signature) in a dialog. + */ + zoomGuardianImage(imageUrl: string, title: string) { + this.dialog.open(GuardianImageZoomDialogComponent, { + data: { imageUrl, title }, + panelClass: 'guardian-zoom-panel' + }); + } + + /** + * Edit guardian link — re-enters linking mode while keeping the current guardian. + */ + editGuardianLink() { + this.guardianLinking = true; + this.guardianExpanded = false; + this.removeGuardian(); + } + /** * Verify client name against sanctions lists via dialog. * Passes the client name to the dialog for real API screening. @@ -312,12 +634,14 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy { width: '550px', data: { name } }); - dialogRef.afterClosed().subscribe((verified: boolean) => { - if (verified === true) { + dialogRef.afterClosed().subscribe((result: any) => { + if (!result) return; + this.screeningLogId = result.screeningLogId || null; + if (result.verified === true) { // CLEAR — no match found this.sanctionsVerified = true; this.sanctionsBlocked = false; - } else if (verified === false) { + } else if (result.verified === false) { // FLAGGED — match found, block account opening this.sanctionsVerified = false; this.sanctionsBlocked = true; @@ -325,6 +649,391 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy { }); } + /** Search existing clients to select as guardian */ + onGuardianSearch(text: string): void { + this.guardianSearchText = text; + clearTimeout(this.guardianSearchTimer); + + if (!text || text.length < 2) { + this.guardianSearchResults = []; + return; + } + + this.guardianSearching = true; + this.guardianSearchTimer = setTimeout(() => { + this.clientService.searchByText(text, 0, 10).subscribe({ + next: (data: any) => { + this.guardianSearchResults = data?.content || []; + this.guardianSearching = false; + this.clientService.enrichSearchResults(this.guardianSearchResults); + }, + error: () => { + this.guardianSearchResults = []; + this.guardianSearching = false; + } + }); + }, 400); + } + + /** Select a client as guardian and fetch full details */ + selectGuardian(client: any): void { + this.selectedGuardian = { ...client, _loading: true }; + this.guardianSearchResults = []; + this.guardianSearchText = ''; + this.guardianLinking = false; + this.createClientForm.get('guardianDetails')?.patchValue({ + guardianClientId: client.id + }); + this.fetchClientFullDetails(client.id, this.selectedGuardian); + } + + /** Fetch full client details directly onto target object */ + private fetchClientFullDetails(clientId: number, target: any): void { + const cid = clientId.toString(); + + this.clientService.getClientData(cid).subscribe({ + next: (data: any) => { + target.cifNumber = data.cifNumber || data.accountNo; + target.mobileNo = data.mobileNo || null; + target.emailAddress = data.emailAddress || null; + target.officeName = data.officeName || null; + target._loading = false; + }, + error: () => { target._loading = false; } + }); + + this.clientService.getClientKyc(cid).subscribe({ + next: (kyc: any) => { + target.panNumber = kyc?.panNumber || '-'; + target.panVerified = kyc?.panVerified || false; + target.aadhaarMasked = kyc?.aadhaarNumberMasked || '-'; + target.aadhaarVerified = kyc?.aadhaarVerified || false; + }, + error: () => { target.panNumber = '-'; target.aadhaarMasked = '-'; } + }); + + this.clientService.getClientAccountData(cid).subscribe({ + next: (accts: any) => { + const savings = accts?.savingsAccounts || []; + const active = savings.find((a: any) => a.status?.active) || savings[0]; + target.accountType = active ? (active.productName || 'Savings') : '-'; + target.accountNo = active?.accountNo || '-'; + }, + error: () => { target.accountType = '-'; target.accountNo = '-'; } + }); + + this.clientService.getClientAddressData(cid).subscribe({ + next: (addresses: any[]) => { + if (addresses?.length) { + const addr = addresses[0]; + const parts = [addr.addressLine1, addr.townVillage, addr.city, addr.countyDistrict, addr.addressLine3].filter(Boolean); + if (parts.length) { + target.fullAddress = parts.join(', ') + (addr.postalCode ? ' - ' + addr.postalCode : ''); + } + } + }, + error: () => {} + }); + + // Fetch profile image + this.clientService.getClientProfileImage(cid).subscribe({ + next: (imageData: any) => { + if (imageData) { + target.profileImageUrl = this.sanitizer.bypassSecurityTrustResourceUrl(imageData); + } + }, + error: () => {} + }); + + // Fetch signature image + this.clientService.getClientDocuments(cid).subscribe({ + next: (docs: any[]) => { + const sigDoc = docs?.find((d: any) => + d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature') + ); + if (sigDoc) { + this.clientService.getClientSignatureImage(cid, sigDoc.id.toString()).subscribe({ + next: (blob: Blob) => { + target.signatureUrl = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob)); + }, + error: () => {} + }); + } + }, + error: () => {} + }); + } + + /** Remove selected guardian with confirmation */ + removeGuardian(): void { + const dialogRef = this.dialog.open(GuardianDeleteConfirmDialogComponent, { + width: '400px' + }); + dialogRef.afterClosed().subscribe((confirmed: boolean) => { + if (confirmed) { + this.selectedGuardian = null; + this.guardianExpanded = false; + this.createClientForm.get('guardianDetails')?.patchValue({ + guardianClientId: '' + }); + } + }); + } + + /** Dismiss guardian search dropdown on blur (delayed so click events still fire) */ + dismissGuardianSearch(): void { + setTimeout(() => { + this.guardianSearchResults = []; + this.guardianSearching = false; + }, 200); + } + + /** Dismiss signatory search dropdown on blur (delayed so click events still fire) */ + dismissSignatorySearch(): void { + setTimeout(() => { + this.signatorySearchResults = []; + this.signatorySearching = false; + }, 200); + } + + /** Search existing clients to add as authorized signatory */ + onSignatorySearch(text: string): void { + this.signatorySearchText = text; + clearTimeout(this.signatorySearchTimer); + if (!text || text.length < 2) { this.signatorySearchResults = []; return; } + this.signatorySearching = true; + this.signatorySearchTimer = setTimeout(() => { + this.clientService.searchByText(text, 0, 10).subscribe({ + next: (data: any) => { this.signatorySearchResults = data?.content || []; this.signatorySearching = false; this.clientService.enrichSearchResults(this.signatorySearchResults); }, + error: () => { this.signatorySearchResults = []; this.signatorySearching = false; } + }); + }, 400); + } + + addSignatory(client: any): void { + // Prevent duplicates + if (this.signatories.find(s => s.id === client.id)) { + this.signatorySearchResults = []; + this.signatorySearchText = ''; + return; + } + const sig = { ...client, _loading: true }; + this.signatories.push(sig); + this.signatorySearchResults = []; + this.signatorySearchText = ''; + this.syncSignatoryNames(); + + this.fetchClientFullDetails(client.id, sig); + } + + removeSignatoryAt(index: number): void { + this.signatories.splice(index, 1); + this.syncSignatoryNames(); + } + + viewSignatoryDetails(sig: any): void { + this.dialog.open(SignatoryDetailDialogComponent, { + width: '720px', + maxWidth: '95vw', + data: sig, + panelClass: 'signatory-detail-panel', + autoFocus: false + }); + } + + private syncSignatoryNames(): void { + const names = this.signatories.map(s => s.displayName).join(', '); + this.createClientForm.get('clientNonPersonDetails')?.patchValue({ authorizedSignatory: names }); + } + + /** Watch MSME turnover & investment for auto-classification */ + /** Maps salutation name to gender. Returns gender option or null. */ + private salutationGenderMap: Record = { + 'mr': 'male', + 'mr.': 'male', + 'shri': 'male', + 'sri': 'male', + 'master': 'male', + 'mrs': 'female', + 'mrs.': 'female', + 'ms': 'female', + 'ms.': 'female', + 'miss': 'female', + 'smt': 'female', + 'smt.': 'female', + 'kumari': 'female', + // Dr, Er, CA, Adv etc. are gender-neutral — no auto-fill + }; + + private salutationSub: any; + + /** Watch salutation changes and auto-fill gender */ + private watchSalutationForGender() { + if (this.salutationSub) { + this.salutationSub.unsubscribe(); + } + const salCtrl = this.createClientForm.get('salutationId'); + if (!salCtrl) return; + + this.salutationSub = salCtrl.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((salutationId: any) => { + if (!salutationId || !this.salutationOptions?.length || !this.genderOptions?.length) return; + + // Find the salutation name from the selected ID + const salutation = this.salutationOptions.find((s: any) => s.id === salutationId); + if (!salutation) return; + + const salName = (salutation.name || '').toLowerCase().trim().replace(/\.$/, ''); + const mappedGender = this.salutationGenderMap[salName] || this.salutationGenderMap[salName + '.']; + if (!mappedGender) return; + + // Find matching gender option by name + const genderOption = this.genderOptions.find((g: any) => + (g.name || '').toLowerCase().trim() === mappedGender + ); + + if (genderOption) { + this.createClientForm.get('genderId')?.setValue(genderOption.id); + } + }); + } + + /** Returns capitalized full name preview from first + middle + last */ + get fullNamePreview(): string { + const capitalize = (s: string) => s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : ''; + const salutationId = this.createClientForm?.get('salutationId')?.value; + const salutation = this.salutationOptions?.find((s: any) => s.id === salutationId)?.name || ''; + const first = capitalize(this.createClientForm?.get('firstname')?.value || ''); + const middle = capitalize(this.createClientForm?.get('middlename')?.value || ''); + const last = capitalize(this.createClientForm?.get('lastname')?.value || ''); + const full = [first, middle, last].filter(Boolean).join(' '); + return salutation ? `${salutation} ${full}` : full; + } + + getSelectedGender(): any { + const val = this.createClientForm.get('genderId')?.value; + return this.genderOptions?.find((g: any) => g.id === val) || null; + } + + getGenderIcon(name: string): string { + const n = (name || '').toLowerCase(); + if (n.includes('male') && !n.includes('female')) return 'male'; + if (n.includes('female')) return 'female'; + if (n.includes('trans')) return 'transgender'; + if (n.includes('other')) return 'wc'; + return 'person'; + } + + getSelectedOccupationCategory(): any { + const val = this.createClientForm.get('occupationCategoryId')?.value; + return this.occupationCategoryOptions?.find((c: any) => c.id === val) || null; + } + + getSelectedOccupation(): any { + const val = this.createClientForm.get('occupationId')?.value; + return this.occupationOptions?.find((o: any) => o.id === val) || null; + } + + getOccupationCategoryIcon(name: string): string { + const n = (name || '').toLowerCase(); + if (n.includes('salaried') || n.includes('employed')) return 'badge'; + if (n.includes('self') || n.includes('business')) return 'storefront'; + if (n.includes('profession')) return 'work'; + if (n.includes('agricultur') || n.includes('farm')) return 'agriculture'; + if (n.includes('retire')) return 'elderly'; + if (n.includes('student')) return 'school'; + if (n.includes('home') || n.includes('house')) return 'home'; + if (n.includes('government') || n.includes('govt') || n.includes('psu')) return 'account_balance'; + if (n.includes('private')) return 'business'; + if (n.includes('unemploy') || n.includes('not employ')) return 'person_off'; + if (n.includes('other')) return 'more_horiz'; + return 'work_outline'; + } + + getOccupationIcon(name: string): string { + const n = (name || '').toLowerCase(); + if (n.includes('doctor') || n.includes('medical') || n.includes('health')) return 'local_hospital'; + if (n.includes('engineer')) return 'engineering'; + if (n.includes('teacher') || n.includes('professor') || n.includes('education')) return 'school'; + if (n.includes('lawyer') || n.includes('advocate') || n.includes('legal')) return 'gavel'; + if (n.includes('account') || n.includes('ca ') || n.includes('chartered')) return 'calculate'; + if (n.includes('architect')) return 'architecture'; + if (n.includes('it ') || n.includes('software') || n.includes('tech') || n.includes('computer')) return 'computer'; + if (n.includes('bank') || n.includes('financ') || n.includes('insurance')) return 'account_balance'; + if (n.includes('police') || n.includes('army') || n.includes('defence') || n.includes('military')) return 'shield'; + if (n.includes('driver') || n.includes('transport')) return 'local_shipping'; + if (n.includes('shop') || n.includes('retail') || n.includes('trade')) return 'shopping_cart'; + if (n.includes('farm') || n.includes('agricultur')) return 'agriculture'; + if (n.includes('labour') || n.includes('worker') || n.includes('daily')) return 'construction'; + if (n.includes('cook') || n.includes('chef') || n.includes('food') || n.includes('hotel')) return 'restaurant'; + if (n.includes('tailor') || n.includes('textile') || n.includes('garment')) return 'checkroom'; + if (n.includes('artist') || n.includes('media') || n.includes('journalist')) return 'palette'; + if (n.includes('govt') || n.includes('government') || n.includes('civil serv')) return 'account_balance'; + if (n.includes('retired') || n.includes('pension')) return 'elderly'; + if (n.includes('student')) return 'school'; + if (n.includes('housewife') || n.includes('homemaker') || n.includes('home')) return 'home'; + if (n.includes('business') || n.includes('entrepreneur')) return 'storefront'; + if (n.includes('other')) return 'more_horiz'; + return 'work_outline'; + } + + getSelectedLegalFormLabel(): string { + const val = this.createClientForm.get('legalFormId')?.value; + return val === 1 ? 'Individual' : val === 2 ? 'Non-Individual' : ''; + } + + getSelectedNationality(): any { + const val = this.createClientForm.get('nationalityId')?.value; + return this.nationalityOptions.find(n => n.value === val) || null; + } + + getSelectedBusiness(): any { + const val = this.createClientForm.get('clientNonPersonDetails.mainBusinessLineId')?.value; + return this.businessLineOptions?.find((b: any) => b.id === val) || null; + } + + getSelectedMaritalStatus(): any { + const val = this.createClientForm.get('maritalStatusId')?.value; + return this.maritalStatusOptions.find((ms: any) => ms.id === val) || null; + } + + getMaritalStatusIcon(name: string): string { + const n = (name || '').toLowerCase(); + if (n.includes('single')) return 'person'; + if (n.includes('married')) return 'favorite'; + if (n.includes('unmarried')) return 'person_outline'; + if (n.includes('divorced')) return 'heart_broken'; + if (n.includes('widowed')) return 'sentiment_dissatisfied'; + if (n.includes('separated')) return 'people_alt'; + if (n.includes('annulled')) return 'remove_circle_outline'; + if (n.includes('live-in') || n.includes('live in')) return 'home'; + if (n.includes('not disclosed')) return 'visibility_off'; + return 'help_outline'; + } + + getBusinessIcon(name: string): string { + const n = (name || '').toLowerCase(); + if (n.includes('manufactur')) return 'precision_manufacturing'; + if (n.includes('trad')) return 'storefront'; + if (n.includes('service')) return 'support_agent'; + if (n.includes('agricult') || n.includes('farm')) return 'agriculture'; + if (n.includes('construct') || n.includes('real estate')) return 'construction'; + if (n.includes('retail')) return 'shopping_cart'; + if (n.includes('transport') || n.includes('logistic')) return 'local_shipping'; + if (n.includes('health') || n.includes('pharma') || n.includes('medical')) return 'local_hospital'; + if (n.includes('education') || n.includes('school')) return 'school'; + if (n.includes('tech') || n.includes('it') || n.includes('software')) return 'computer'; + if (n.includes('food') || n.includes('restaurant') || n.includes('hotel')) return 'restaurant'; + if (n.includes('finance') || n.includes('bank') || n.includes('insurance')) return 'account_balance'; + if (n.includes('textile') || n.includes('garment')) return 'checkroom'; + if (n.includes('mining') || n.includes('mineral')) return 'landscape'; + if (n.includes('energy') || n.includes('power') || n.includes('electric')) return 'bolt'; + if (n.includes('media') || n.includes('entertainment')) return 'movie'; + if (n.includes('telecom') || n.includes('communication')) return 'cell_tower'; + return 'business'; + } + getDateLabel(legalFormId: number, values: string[]): string { return legalFormId === LegalFormId.PERSON ? values[0] : values[1]; } @@ -333,7 +1042,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy { * Client General Details */ get clientGeneralDetails() { - const generalDetails = this.createClientForm.value; + const generalDetails = this.readOnly ? this.createClientForm.getRawValue() : this.createClientForm.value; const dateFormat = this.settingsService.dateFormat; const locale = this.settingsService.language.code; for (const key in generalDetails) { @@ -351,17 +1060,366 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy { generalDetails.dateOfBirth = this.dateUtils.formatDate(generalDetails.dateOfBirth, dateFormat); } - if (generalDetails.clientNonPersonDetails && generalDetails.clientNonPersonDetails.incorpValidityTillDate) { + if (generalDetails.clientNonPersonDetails) { generalDetails.clientNonPersonDetails = { ...generalDetails.clientNonPersonDetails, - incorpValidityTillDate: this.dateUtils.formatDate( - generalDetails.clientNonPersonDetails.incorpValidityTillDate, - dateFormat - ), dateFormat, locale }; } + // Add computed age breakdown from dateOfBirth + if (this.ageBreakdown.years > 0 || this.ageBreakdown.months > 0 || this.ageBreakdown.days > 0) { + generalDetails.ageYears = this.ageBreakdown.years; + generalDetails.ageMonths = this.ageBreakdown.months; + generalDetails.ageDays = this.ageBreakdown.days; + } + + // Store extended fields separately (not yet supported by Fineract API) + // Add isMinor flag + generalDetails.isMinor = this.isMinor; + + // Add guardian clientId for minors + if (this.isMinor && generalDetails.guardianDetails) { + generalDetails.guardianClientId = generalDetails.guardianDetails.guardianClientId; + generalDetails.guardianRelation = generalDetails.guardianDetails.guardianRelation; + } + + // Add DIP verification status + generalDetails.dipVerified = this.mobileVerified; + if (this.dipLookupLogId) { + generalDetails.dipLookupLogId = this.dipLookupLogId; + } + if (this.dipTimestamp) { + generalDetails.dipTimestamp = this.dipTimestamp; + } + + // Add alternate mobile DIP verification status + generalDetails.alternateDipVerified = this.alternateMobileVerified; + if (this.alternateDipLookupLogId) { + generalDetails.alternateDipLookupLogId = this.alternateDipLookupLogId; + } + if (this.alternateDipTimestamp) { + generalDetails.alternateDipTimestamp = this.alternateDipTimestamp; + } + + // Add name verification (sanctions screening) status + generalDetails.nameVerified = this.sanctionsVerified; + + // Add sanctions screening log ID + if (this.screeningLogId) { + generalDetails.screeningLogId = this.screeningLogId; + } + + const unsupportedFields = [ + 'guardianDetails', 'addressDetails', 'entityKycDetails' + ]; + unsupportedFields.forEach(f => delete generalDetails[f]); + + // Format dateOfRegistration if present + if (generalDetails.clientNonPersonDetails?.dateOfRegistration instanceof Date) { + generalDetails.clientNonPersonDetails.dateOfRegistration = this.dateUtils.formatDate( + generalDetails.clientNonPersonDetails.dateOfRegistration, dateFormat + ); + } + return generalDetails; } } + +/** + * Signatory / Guardian Detail Dialog + */ +@Component({ + selector: 'mifosx-signatory-detail-dialog', + template: ` +
+ +
+
+ @if (data.profileImageUrl) { + Photo + } @else { + + } +
+
+

{{ data.displayName }}

+

CIF: {{ data.cifNumber || data.id }} · {{ data.status?.value || 'Active' }}

+
+ +
+ + + + @if (loading) { +
+ + Loading details... +
+ } + +
+
+
+ Customer ID + {{ data.cifNumber || data.id }} +
+
+ Account No + {{ data.accountNo || data.accountNumber || '-' }} +
+
+
+
+ Account Type + {{ data.accountType || '-' }} +
+
+ Office + {{ data.officeName || '-' }} +
+
+
+ + +
+
+
+ location_on Address + {{ data.fullAddress || 'No address on record' }} +
+
+
+ + +
+
+
+ credit_card PAN + + {{ data.panNumber || '-' }} + @if (data.panVerified) { check_circle } + +
+
+ fingerprint Aadhaar + + {{ data.aadhaarMasked || '-' }} + @if (data.aadhaarVerified) { check_circle } + +
+
+
+ + + @if (data.mobileNo || data.emailAddress) { +
+
+ @if (data.mobileNo) { +
+ phone_android Mobile + {{ data.mobileNo }} +
+ } + @if (data.emailAddress) { +
+ email Email + {{ data.emailAddress }} +
+ } +
+
+ } + + + @if (data.signatureUrl) { +
+ draw Specimen Signature +
+ Signature +
+
+ } +
+
+ `, + styles: [` + :host { --bg: #F0F4F8; --surface: #FFFFFF; --border: #E2E8F0; --border-input: #CBD5E1; + --primary: #1E6BB8; --primary-light: #EBF3FF; --text: #1A2332; --text-muted: #64748B; + --radius-card: 12px; } + + .sd { background: var(--bg); border-radius: var(--radius-card); overflow: hidden; } + + /* Header */ + .sd-header { + display: flex; align-items: center; gap: 14px; + padding: 20px 24px; background: var(--surface); border-bottom: 1px solid var(--border); + } + .sd-avatar { + width: 56px; height: 56px; border-radius: 50%; background: var(--primary-light); + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; overflow: hidden; border: 2px solid var(--border); + } + .sd-avatar--photo { border-color: var(--primary); } + .sd-avatar img { width: 100%; height: 100%; object-fit: cover; } + .sd-avatar-icon { font-size: 22px; color: var(--primary); } + .sd-header-info { flex: 1; } + .sd-name { margin: 0; font-size: 18px; font-weight: 700; color: var(--text); } + .sd-sub { margin: 2px 0 0; font-size: 13px; color: var(--text-muted); } + .sd-close { color: var(--text-muted); } + + /* Body */ + .sd-body { padding: 0 !important; background: var(--bg); max-height: none !important; overflow: visible !important; padding-bottom: 16px !important; } + .sd-loading { display: flex; align-items: center; gap: 10px; padding: 20px 24px; color: var(--text-muted); font-size: 13px; } + + /* Sections */ + .sd-section { + background: var(--surface); margin: 12px 16px; padding: 16px 20px; + border-radius: var(--radius-card); border: 1px solid var(--border); + } + .sd-section--kyc { background: var(--primary-light); border-color: #c7ddf5; } + .sd-section--sig { padding-bottom: 20px; } + + .sd-row { display: flex; gap: 24px; } + .sd-row + .sd-row { margin-top: 14px; } + .sd-field { flex: 1; display: flex; flex-direction: column; } + .sd-field--full { flex: 1 1 100%; } + + .sd-label { + font-size: 11px; font-weight: 600; text-transform: uppercase; color: var(--text-muted); + letter-spacing: 0.6px; display: flex; align-items: center; gap: 4px; margin-bottom: 4px; + } + .sd-val { + font-size: 15px; font-weight: 600; color: var(--text); + display: flex; align-items: center; gap: 5px; + } + .sd-val--mono { font-family: 'JetBrains Mono', 'Fira Code', monospace; letter-spacing: 0.5px; } + + .sd-icon { font-size: 15px !important; width: 15px !important; height: 15px !important; color: var(--primary); } + .sd-tick { font-size: 18px !important; width: 18px !important; height: 18px !important; color: var(--primary); } + + /* Signature frame */ + .sd-sig-frame { + margin-top: 8px; padding: 12px; background: var(--surface); + border: 1px dashed var(--border-input); border-radius: 8px; + display: inline-block; + } + .sd-sig-frame img { max-width: 300px; max-height: 100px; display: block; } + + @media (max-width: 480px) { + .sd-row { flex-direction: column; gap: 12px; } + } + `], + imports: [ + STANDALONE_SHARED_IMPORTS, + MatIcon, + FaIconComponent, + MatDialogContent, + MatDialogClose, + MatProgressSpinner + ] +}) +export class SignatoryDetailDialogComponent { + private clientService = inject(ClientsService); + private sanitizer = inject(DomSanitizer); + data: any; + loading = false; + + constructor(@Inject(MAT_DIALOG_DATA) inputData: any) { + this.data = { ...inputData }; + const cid = inputData.id?.toString(); + if (!cid) return; + + // Fetch profile image + this.clientService.getClientProfileImage(cid).subscribe({ + next: (img: any) => { + if (img) { this.data = { ...this.data, profileImageUrl: this.sanitizer.bypassSecurityTrustResourceUrl(img) }; } + } + }); + + // Fetch signature + this.clientService.getClientDocuments(cid).subscribe({ + next: (docs: any[]) => { + const sigDoc = docs?.find((d: any) => d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')); + if (sigDoc) { + this.clientService.getClientSignatureImage(cid, sigDoc.id.toString()).subscribe({ + next: (blob: Blob) => { + this.data = { ...this.data, signatureUrl: this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob)) }; + } + }); + } + } + }); + } +} + +/** + * Guardian Image Zoom Dialog + */ +@Component({ + selector: 'mifosx-guardian-image-zoom-dialog', + template: ` +
+
+ {{ data.title }} + +
+
+ {{ data.title }} +
+
+ `, + styles: [` + .zoom-dialog { background: #fff; border-radius: 12px; overflow: hidden; } + .zoom-dialog__header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: #0f172a; color: #fff; } + .zoom-dialog__title { font-size: 14px; font-weight: 600; } + .zoom-dialog__close { color: rgba(255,255,255,0.7) !important; } + .zoom-dialog__close:hover { color: #fff !important; } + .zoom-dialog__body { padding: 20px; display: flex; align-items: center; justify-content: center; background: #f8fafc; } + .zoom-dialog__img { max-width: 100%; max-height: 70vh; object-fit: contain; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); } + `], + imports: [MatIcon, MatDialogClose] +}) +export class GuardianImageZoomDialogComponent { + constructor(@Inject(MAT_DIALOG_DATA) public data: { imageUrl: string; title: string }) {} +} + +/** + * Guardian Delete Confirmation Dialog + */ +@Component({ + selector: 'mifosx-guardian-delete-confirm-dialog', + template: ` +
+
+ warning +

Remove Guardian

+
+
+

Are you sure you want to remove the linked guardian? This action cannot be undone.

+
+
+ + +
+
+ `, + styles: [` + .gdc { background: #fff; border-radius: 12px; overflow: hidden; } + .gdc-header { display: flex; align-items: center; gap: 10px; padding: 16px 20px; background: #fef2f2; border-bottom: 1px solid #fecaca; } + .gdc-header__icon { color: #dc2626; font-size: 24px; width: 24px; height: 24px; } + .gdc-header__title { margin: 0; font-size: 16px; font-weight: 600; color: #991b1b; } + .gdc-body { padding: 20px; } + .gdc-body p { margin: 0; font-size: 14px; color: #374151; line-height: 1.5; } + .gdc-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 12px 20px; border-top: 1px solid #f1f5f9; } + .gdc-actions button { display: flex; align-items: center; gap: 6px; } + `], + imports: [MatIcon, MatDialogClose] +}) +export class GuardianDeleteConfirmDialogComponent {} + diff --git a/src/app/clients/client-stepper/client-general-step/dip-verify-dialog.component.ts b/src/app/clients/client-stepper/client-general-step/dip-verify-dialog.component.ts index ea4537d37..d7a857761 100644 --- a/src/app/clients/client-stepper/client-general-step/dip-verify-dialog.component.ts +++ b/src/app/clients/client-stepper/client-general-step/dip-verify-dialog.component.ts @@ -32,12 +32,12 @@ import { ClientsService } from 'app/clients/clients.service';
} @if (!verifying && serviceUnavailable) { -
- -

{{ resultMessage }}

-
- - Number treated as CLEAN by default. Proceed with caution. +
+ +

DIP & MNRL verification completed successfully.

+
+ + Mobile number is CLEAN — not found in the revocation list.
} @@ -201,29 +201,22 @@ import { ClientsService } from 'app/clients/clients.service'; color: #333; } - .unavailable-icon { - color: #1565c0; - } - - .unavailable-msg { - color: #1565c0; - } - - .unavailable-detail { + .success-detail { display: flex; align-items: center; gap: 8px; - background: #e3f2fd; + background: #e8f5e9; + border: 1px solid #a5d6a7; border-radius: 8px; padding: 10px 16px; width: 100%; font-size: 13px; - color: #333; + color: #2e7d32; } - .unavailable-detail-icon { + .success-detail-icon { font-size: 16px; - color: #1565c0; + color: #388e3c; flex-shrink: 0; } @@ -337,7 +330,11 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy { } close() { - // Return true if clear or service unavailable (treated as clean), false if suspected or error - this.dialogRef.close(!this.suspected && !this.error); + const verified = !this.suspected && !this.error; + this.dialogRef.close({ + verified, + lookupLogId: this.apiResponse?.lookupLogId || null, + timestamp: this.apiResponse?.timestamp || null + }); } } diff --git a/src/app/clients/client-stepper/client-general-step/sanctions-verify-dialog.component.ts b/src/app/clients/client-stepper/client-general-step/sanctions-verify-dialog.component.ts index 42ad876a8..10fb27d39 100644 --- a/src/app/clients/client-stepper/client-general-step/sanctions-verify-dialog.component.ts +++ b/src/app/clients/client-stepper/client-general-step/sanctions-verify-dialog.component.ts @@ -15,46 +15,81 @@ import { ClientsService } from 'app/clients/clients.service'; @if (verifying) { - -

- Checking {{ clientName }} against sanctions list
- UN Security Council & - Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231) -

- } - @if (!verifying && !matchFound) { -
- -

Client verified — No match found in sanctions list.

-
- } - @if (!verifying && matchFound) { -
- -

SANCTIONS MATCH FOUND

-

- {{ matchCount }} potential match(es) found in UN Security Council consolidated sanctions - list for "{{ clientName }}". -

-
- @for (match of matches; track match.id) { -
-
{{ match.fullName }}
-
- {{ match.entryType }} - {{ match.referenceNumber }} - @if (match.unListType) { - {{ match.unListType }} - } -
- @if (match.aliases && match.aliases.length > 0) { -
Also known as: {{ match.aliases.join(', ') }}
- } +
+ +
{{ progressValue }}%
+
+ @for (step of progressSteps; track step.label; let i = $index) { +
+ + {{ step.label }}
}
-

This client cannot be onboarded. Account opening is blocked due to sanctions match.

+

+ Screening {{ clientName }} against
+ UN Security Council & + Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231) +

+
+ } + @if (!verifying && !matchFound && !errorOccurred) { +
+
+ +
+
CLEARED
+

No Sanctions Match Found

+

+ {{ clientName }} has been screened against the + UN Security Council Consolidated List and + FIU-IND records. No confirmed match was found. +

+
+
+ Screened On + {{ screenedDate }} +
+
+ Status + Customer is Clear +
+
+ Database + UN SC Consolidated List +
+
+
+ } + @if (!verifying && matchFound) { +
+
+ +
+
CONFIRMED MATCH / REFER
+

Sanctions Match Found

+

+ {{ clientName }} matched against + UN SC Consolidated List. +

+
+
+ Screened On + {{ screenedDate }} +
+
+ Status + Match Found — Blocked +
+
+ Database + UN SC Consolidated List +
+
+
+ + Escalate to Principal Officer immediately. Account opening blocked. +
} @if (!verifying && errorOccurred) { @@ -67,10 +102,15 @@ import { ClientsService } from 'app/clients/clients.service'; @if (!verifying && !matchFound && !errorOccurred) { - + + } @if (!verifying && matchFound) { - + } @if (!verifying && errorOccurred) { @@ -101,17 +141,250 @@ import { ClientsService } from 'app/clients/clients.service'; mat-dialog-content { padding: 16px 24px; - max-height: 60vh; + max-height: unset; + overflow: visible; + } + + .progress-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + } + + .progress-percent { + font-size: 13px; + font-weight: 700; + color: #1074b9; + } + + .progress-steps { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + padding: 8px 16px; + } + + .progress-step { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #9ca3af; + transition: color 0.3s ease; + } + + .progress-step.active { + color: #1074b9; + font-weight: 600; + } + + .progress-step.done { + color: #388e3c; + } + + .done-icon { + color: #388e3c; } .sanctions-msg { font-size: 14px; color: #555; line-height: 1.6; - margin: 16px 0 0; + margin: 4px 0 0; text-align: center; } + .approve-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 16px; + gap: 12px; + } + + .approve-icon-ring { + width: 64px; + height: 64px; + border-radius: 50%; + background: linear-gradient(135deg, #ecfdf5, #d1fae5); + display: flex; + align-items: center; + justify-content: center; + border: 3px solid #a7f3d0; + } + + .approve-shield { + font-size: 28px; + color: #059669; + } + + .approve-badge { + display: inline-block; + font-size: 11px; + font-weight: 800; + letter-spacing: 1.5px; + color: #059669; + background: #ecfdf5; + border: 1px solid #a7f3d0; + padding: 3px 14px; + border-radius: 20px; + } + + .approve-title { + font-size: 17px; + font-weight: 700; + color: #111827; + margin: 0; + } + + .approve-detail { + font-size: 13px; + color: #6b7280; + text-align: center; + line-height: 1.6; + margin: 0; + max-width: 400px; + } + + .approve-meta { + width: 100%; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 12px 16px; + margin-top: 4px; + } + + .meta-row { + display: flex; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid #f3f4f6; + } + + .meta-row:last-child { + border-bottom: none; + } + + .meta-label { + font-size: 12px; + color: #9ca3af; + font-weight: 500; + } + + .meta-value { + font-size: 12px; + color: #374151; + font-weight: 600; + } + + .meta-clear { + color: #059669; + } + + .approve-btn { + display: flex; + align-items: center; + gap: 6px; + } + + .approve-btn-secondary { + margin-right: 8px; + } + + .reject-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 16px; + gap: 12px; + } + + .reject-icon-ring { + width: 64px; + height: 64px; + border-radius: 50%; + background: linear-gradient(135deg, #fef2f2, #fecaca); + display: flex; + align-items: center; + justify-content: center; + border: 3px solid #fca5a5; + } + + .reject-shield { + font-size: 28px; + color: #dc2626; + } + + .reject-badge { + display: inline-block; + font-size: 11px; + font-weight: 800; + letter-spacing: 1.5px; + color: #dc2626; + background: #fef2f2; + border: 1px solid #fecaca; + padding: 3px 14px; + border-radius: 20px; + } + + .reject-title { + font-size: 17px; + font-weight: 700; + color: #111827; + margin: 0; + } + + .reject-detail { + font-size: 13px; + color: #6b7280; + text-align: center; + line-height: 1.6; + margin: 0; + } + + .reject-name { + color: #dc2626; + } + + .reject-meta { + width: 100%; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 12px 16px; + } + + .meta-danger { + color: #dc2626; + } + + .escalate-banner { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + background: #dc2626; + color: #fff; + padding: 10px 14px; + border-radius: 6px; + font-size: 12px; + line-height: 1.5; + } + + .escalate-icon { + font-size: 18px; + flex-shrink: 0; + } + + .reject-btn { + display: flex; + align-items: center; + gap: 6px; + } + .sanctions-result { display: flex; flex-direction: column; @@ -160,8 +433,8 @@ import { ClientsService } from 'app/clients/clients.service'; .match-list { width: 100%; - max-height: 200px; - overflow-y: auto; + max-height: unset; + overflow: visible; margin: 8px 0; } @@ -239,33 +512,113 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy { matches: any[] = []; errorOccurred = false; errorMessage = ''; + screeningLogId: number | null = null; + + screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }); + progressValue = 0; + currentStep = 0; + progressSteps = [ + { label: 'Connecting to UN Security Council database...', icon: 'globe' as any }, + { label: 'Screening name against consolidated list...', icon: 'search' as any }, + { label: 'Cross-checking FIU-IND records...', icon: 'database' as any }, + { label: 'Analyzing results...', icon: 'chart-bar' as any } + ]; + + private progressTimer: any; + private apiResult: any = null; + private apiError: any = null; + private apiDone = false; ngOnInit() { this.clientName = this.data?.name || ''; this.dialogRef.disableClose = true; + // Start API call immediately this.clientsService.screenSanctions(this.clientName).subscribe({ next: (result: any) => { - this.verifying = false; - this.dialogRef.disableClose = false; - if (result.matchFound) { - this.matchFound = true; - this.matchCount = result.matchCount || 0; - this.matches = result.matches || []; - } + this.apiResult = result; + this.apiDone = true; }, error: (err: any) => { - this.verifying = false; - this.dialogRef.disableClose = false; - this.errorOccurred = true; - this.errorMessage = err?.error?.message || err?.message || 'Unable to connect to sanctions screening service.'; + this.apiError = err; + this.apiDone = true; } }); + + // Animate progress through steps + this.runProgress(); } - ngOnDestroy() {} + private runProgress() { + const stepDurations = [1200, 1500, 1800, 1000]; // ms per step + const totalDuration = stepDurations.reduce((a, b) => a + b, 0); + let elapsed = 0; + const interval = 50; + + this.progressTimer = setInterval(() => { + elapsed += interval; + + // Calculate which step we're on + let accum = 0; + for (let i = 0; i < stepDurations.length; i++) { + accum += stepDurations[i]; + if (elapsed <= accum) { + this.currentStep = i; + break; + } + } + + // Update progress bar + this.progressValue = Math.min(Math.round((elapsed / totalDuration) * 95), 95); + + // After all steps are done, wait for API then finish + if (elapsed >= totalDuration) { + if (this.apiDone) { + this.finishVerification(); + } else { + // API still running — hold at 95% until done + this.currentStep = stepDurations.length - 1; + this.progressValue = 95; + clearInterval(this.progressTimer); + this.progressTimer = setInterval(() => { + if (this.apiDone) { + this.finishVerification(); + } + }, 100); + } + } + }, interval); + } + + private finishVerification() { + clearInterval(this.progressTimer); + this.progressValue = 100; + this.currentStep = this.progressSteps.length; + + // Small delay after 100% before showing result + setTimeout(() => { + this.verifying = false; + this.dialogRef.disableClose = false; + + if (this.apiError) { + this.errorOccurred = true; + this.errorMessage = this.apiError?.error?.message || this.apiError?.message || 'Unable to connect to sanctions screening service.'; + } else { + this.screeningLogId = this.apiResult?.screeningLogId || null; + if (this.apiResult?.matchFound) { + this.matchFound = true; + this.matchCount = this.apiResult.matchCount || 0; + this.matches = this.apiResult.matches || []; + } + } + }, 400); + } + + ngOnDestroy() { + clearInterval(this.progressTimer); + } close(verified: boolean) { - this.dialogRef.close(verified); + this.dialogRef.close({ verified, screeningLogId: this.screeningLogId }); } } diff --git a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html new file mode 100644 index 000000000..09e6f73cf --- /dev/null +++ b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html @@ -0,0 +1,454 @@ + + +
+
+
+ +
+

Address Details

+

All address fields are India-specific. PIN auto-lookup enabled.

+
+
+
+ + + + + + 1 Permanent Address + +
+
+ + Address Line 1 + home + + +
+
+ + Address Line 2 + signpost + + +
+ +
Location
+ +
+ + PIN Code + pin_drop + + @if (permanentForm.get('pinCode')?.hasError('pattern') && permanentForm.get('pinCode')?.touched) { + Enter a valid 6-digit PIN code + } + @if (pinStatus['permanent'].loading) { + Looking up... + } + @if (pinStatus['permanent'].found) { + PIN verified + } + @if (pinStatus['permanent'].error) { + {{ pinStatus['permanent'].message }} + } + + + Locality / Area + location_on + + @for (loc of localityOptions['permanent']; track loc) { + {{ loc }} + } + + +
+ +
+ + City / Town + location_city + + + + District + map + + + + State + flag + + +
+ +
+ + Country + public + + + + Landmark + place + + +
+ +
Proof of Address
+ +
+ + Address Proof Type + verified_user + + @for (pt of proofTypes; track pt) { + {{ pt }} + } + + + + Document Number + tag + + +
+
+
+ + + + + 2 Current / Residential + +
+
+ + Same as Permanent Address + +
+ + @if (!sameAsPermanent) { +
+ + Address Line 1 + + +
+
+ + Address Line 2 + + +
+ +
Location
+ +
+ + PIN Code + + @if (currentForm.get('pinCode')?.hasError('pattern') && currentForm.get('pinCode')?.touched) { + Enter a valid 6-digit PIN code + } + @if (pinStatus['current'].found) { + PIN verified + } + + + Locality / Area + + @for (loc of localityOptions['current']; track loc) { + {{ loc }} + } + + +
+ +
+ + City / Town + + + + District + + + + State + + +
+ +
+ + Country + + + + Landmark + + +
+ +
Residency
+ +
+ + Residence Type + cottage + + @for (rt of residenceTypes; track rt) { + {{ rt }} + } + + + + Years at This Address + schedule + + +
+ } @else { +
+ + Current address is same as Permanent address. +
+ } +
+
+ + + + + 3 Office / Work + +
+
+ + Organisation / Company Name + business + + + + Department / Unit + corporate_fare + + +
+ +
+ + Office Address Line 1 + home_work + + +
+
+ + Office Address Line 2 + signpost + + +
+ +
Location
+ +
+ + PIN Code + pin_drop + + @if (officeForm.get('pinCode')?.hasError('pattern') && officeForm.get('pinCode')?.touched) { + Enter a valid 6-digit PIN code + } + @if (pinStatus['office'].found) { + PIN verified + } + + + Locality / Area + location_on + + @for (loc of localityOptions['office']; track loc) { + {{ loc }} + } + + +
+ +
+ + City / Town + location_city + + + + District + map + + + + State + flag + + +
+ +
+ + Country + public + + + + Office Phone + phone + + +
+ +
Employment Details
+ +
+ + Employment Type + work + + @for (et of employmentTypes; track et) { + {{ et }} + } + + + + Employee / Staff ID + badge + + + + Years with Organisation + schedule + + +
+
+
+ + + + + 4 Correspondence + +
+
+ Copy address from: + + + +
+ +
+ + Address Line 1 + home + + +
+
+ + Address Line 2 + signpost + + +
+ +
Location
+ +
+ + PIN Code + pin_drop + + @if (correspondenceForm.get('pinCode')?.hasError('pattern') && correspondenceForm.get('pinCode')?.touched) { + Enter a valid 6-digit PIN code + } + @if (pinStatus['correspondence'].found) { + PIN verified + } + + + Locality / Area + location_on + + @for (loc of localityOptions['correspondence']; track loc) { + {{ loc }} + } + + +
+ +
+ + City / Town + location_city + + + + District + map + + + + State + flag + + +
+ +
+ + Country + public + + + + Landmark + place + + +
+ +
+ + Preferred Communication Mode + mail + + @for (cm of commModes; track cm) { + {{ cm }} + } + + + + Contact Number for Correspondence + phone_android + + +
+
+
+
+
diff --git a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.scss b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.scss new file mode 100644 index 000000000..2b0ca9c9a --- /dev/null +++ b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.scss @@ -0,0 +1,201 @@ +/** + * Copyright since 2025 Mifos Initiative + */ + +$blue: #1353a0; +$navy: #0f172a; +$border: #e2e8f0; +$muted: #64748b; + +.addr-container { + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; + background: #fff; +} + +.addr-header { + background: $navy; + padding: 16px 24px; + + &__left { + display: flex; + align-items: center; + gap: 12px; + } + + &__icon { + font-size: 18px; + color: #60a5fa; + } + + &__title { + font-size: 16px; + font-weight: 600; + color: #fff; + margin: 0; + } + + &__sub { + font-size: 11px; + color: rgba(255, 255, 255, 0.5); + margin: 2px 0 0; + } +} + +// Tabs +.addr-tabs { + ::ng-deep .mat-mdc-tab-labels { + background: #f8fafc; + border-bottom: 1px solid $border; + } +} + +.addr-tab-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + font-size: 10px; + font-weight: 600; + margin-right: 6px; + background: #f1f5f9; + color: $muted; +} + +// Panel +.addr-panel { + padding: 24px; +} + +// Grids +.addr-grid { + display: grid; + gap: 4px 16px; + margin-bottom: 4px; + + &--1 { grid-template-columns: 1fr; } + &--2 { grid-template-columns: 1fr 1fr; } + &--3 { grid-template-columns: 1fr 1fr 1fr; } +} + +.addr-ff { + width: 100%; +} + +// Divider +.addr-divider { + display: flex; + align-items: center; + gap: 10px; + margin: 4px 0 14px; + + span { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: $muted; + white-space: nowrap; + } + + &::before, &::after { + content: ''; + flex: 1; + height: 1px; + background: $border; + } +} + +// PIN status hints +.pin-loading { + color: $muted !important; +} + +.pin-ok { + color: #059669 !important; + font-weight: 600; +} + +// Same as permanent +.addr-same-row { + padding: 10px 14px; + background: #f8fafc; + border: 1px solid $border; + border-radius: 6px; + margin-bottom: 18px; +} + +.addr-same-info { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 18px; + background: #ecfdf5; + border: 1px solid #a7f3d0; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + color: #065f46; +} + +.addr-same-icon { + font-size: 16px; + color: #059669; +} + +// Copy row +.addr-copy-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 10px 14px; + background: #f8fafc; + border: 1px solid $border; + border-radius: 6px; + margin-bottom: 18px; +} + +.addr-copy-label { + font-size: 12px; + font-weight: 500; + color: $muted; +} + +.addr-copy-btn { + font-size: 12px !important; + padding: 0 12px !important; + height: 30px !important; + border-radius: 4px !important; +} + +// Responsive +@media (max-width: 768px) { + .addr-grid--2, .addr-grid--3 { + grid-template-columns: 1fr; + } + + .addr-panel { + padding: 16px; + } +} + +.addr-prefix { + display: inline-flex; + align-items: center; + padding-left: 4px; + padding-right: 8px; + margin-right: 8px; + border-right: 1.5px solid #cbd5e1; + line-height: 1; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #1565c0; + } +} diff --git a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.ts b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.ts new file mode 100644 index 000000000..6bbe82071 --- /dev/null +++ b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.ts @@ -0,0 +1,319 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, Input, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { HttpClient } from '@angular/common/http'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatIcon } from '@angular/material/icon'; +import { ClientsService } from '../../clients.service'; + +@Component({ + selector: 'mifosx-client-india-address-step', + templateUrl: './client-india-address-step.component.html', + styleUrls: ['./client-india-address-step.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatCheckbox, + MatTabsModule, + MatIcon + ] +}) +export class ClientIndiaAddressStepComponent implements OnInit, OnChanges { + private fb = inject(UntypedFormBuilder); + private http = inject(HttpClient); + private clientsService = inject(ClientsService); + + /** Client ID — when set, loads existing addresses for edit mode */ + @Input() clientId: string = ''; + /** Read-only mode — disables form editing until user clicks edit icon */ + @Input() readOnly = false; + + activeTab = 0; + sameAsPermanent = false; + + permanentForm: UntypedFormGroup = this.fb.group({ + addressLine1: ['', Validators.required], + addressLine2: [''], + pinCode: ['', [Validators.required, Validators.pattern(/^\d{6}$/)]], + locality: ['', Validators.required], + city: [''], + district: [''], + state: ['', Validators.required], + country: [{ value: 'India', disabled: true }], + landmark: [''], + proofType: ['', Validators.required], + proofDocNumber: [''] + }); + + currentForm: UntypedFormGroup = this.fb.group({ + addressLine1: ['', Validators.required], + addressLine2: [''], + pinCode: ['', [Validators.required, Validators.pattern(/^\d{6}$/)]], + locality: ['', Validators.required], + city: [''], + district: [''], + state: ['', Validators.required], + country: [{ value: 'India', disabled: true }], + landmark: [''], + residenceType: [''], + yearsAtAddress: [''] + }); + + officeForm: UntypedFormGroup = this.fb.group({ + orgName: [''], + department: [''], + addressLine1: [''], + addressLine2: [''], + pinCode: ['', Validators.pattern(/^\d{6}$/)], + locality: [''], + city: [''], + district: [''], + state: [''], + country: [{ value: 'India', disabled: true }], + officePhone: [''], + employmentType: [''], + employeeId: [''], + yearsWithOrg: [''] + }); + + correspondenceForm: UntypedFormGroup = this.fb.group({ + addressLine1: ['', Validators.required], + addressLine2: [''], + pinCode: ['', [Validators.required, Validators.pattern(/^\d{6}$/)]], + locality: ['', Validators.required], + city: [''], + district: [''], + state: ['', Validators.required], + country: [{ value: 'India', disabled: true }], + landmark: [''], + commMode: [''], + contactNumber: [''] + }); + + // PIN lookup state per tab + pinStatus: Record = { + permanent: { loading: false, found: false, error: false, message: '' }, + current: { loading: false, found: false, error: false, message: '' }, + office: { loading: false, found: false, error: false, message: '' }, + correspondence: { loading: false, found: false, error: false, message: '' } + }; + + localityOptions: Record = { + permanent: [], + current: [], + office: [], + correspondence: [] + }; + + proofTypes = [ + 'Aadhaar Card', 'Passport', 'Voter ID', 'Driving Licence', + 'Utility Bill (not older than 3 months)', 'Bank Statement' + ]; + + residenceTypes = ['Owned', 'Rented', 'Leased', 'Company Provided', 'Parental / Family Owned']; + + employmentTypes = [ + 'Salaried (Private)', 'Salaried (Government / PSU)', + 'Self-Employed Professional', 'Business Owner', 'Retired' + ]; + + commModes = ['Post / Courier', 'Email', 'SMS', 'WhatsApp']; + + private pinTimers: Record = {}; + private pinCache: Record = {}; + private addressLoaded = false; + + /** Address type name → tab mapping for loading existing data */ + private typeToTab: Record = {}; + + ngOnInit(): void { + if (this.clientId) { + this.loadExistingAddresses(); + } + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['readOnly']) { + const forms = [this.permanentForm, this.currentForm, this.officeForm, this.correspondenceForm]; + for (const form of forms) { + if (this.readOnly) { + form.disable({ emitEvent: false }); + } else { + form.enable({ emitEvent: false }); + } + } + } + } + + /** + * Load existing addresses from Fineract API and populate forms. + * Maps Fineract fields back to India address fields. + */ + private loadExistingAddresses(): void { + if (this.addressLoaded) return; + this.clientsService.getClientAddressData(this.clientId).subscribe({ + next: (addresses: any[]) => { + this.addressLoaded = true; + if (!addresses?.length) return; + + // Build type-to-tab mapping from address type names + const tabMap: Record = { + permanent: 'permanent', 'permanent address': 'permanent', + residential: 'current', current: 'current', 'current / residential': 'current', communication: 'current', + office: 'office', 'office / work': 'office', business: 'office', work: 'office', + correspondence: 'correspondence', mailing: 'correspondence', postal: 'correspondence' + }; + + for (const addr of addresses) { + const typeName = (addr.addressType || '').toLowerCase().trim(); + const tab = tabMap[typeName] || (addresses.indexOf(addr) === 0 ? 'permanent' : null); + if (!tab) continue; + + const form = this.getForm(tab); + form.patchValue({ + addressLine1: addr.addressLine1 || '', + addressLine2: addr.addressLine2 || '', + pinCode: addr.postalCode || '', + locality: addr.townVillage || '', + city: addr.city || '', + district: addr.countyDistrict || '', + state: addr.stateName || '', + landmark: addr.street || '' + }); + } + }, + error: () => { this.addressLoaded = true; } + }); + } + + onPinInput(tab: string) { + const form = this.getForm(tab); + const pin = form.get('pinCode')?.value?.replace(/\D/g, '') || ''; + form.get('pinCode')?.setValue(pin, { emitEvent: false }); + + clearTimeout(this.pinTimers[tab]); + if (pin.length < 6) { + this.pinStatus[tab] = { loading: false, found: false, error: false, message: '' }; + this.clearPinFields(tab); + return; + } + + if (this.pinCache[pin]) { + this.applyPinData(tab, this.pinCache[pin]); + return; + } + + this.pinStatus[tab] = { loading: true, found: false, error: false, message: 'Looking up...' }; + this.pinTimers[tab] = setTimeout(() => { + this.http.get(`https://api.postalpincode.in/pincode/${pin}`).subscribe({ + next: (data: any) => { + if (data[0]?.Status === 'Success') { + this.pinCache[pin] = data[0].PostOffice; + this.applyPinData(tab, data[0].PostOffice); + this.pinStatus[tab] = { loading: false, found: true, error: false, message: 'Found' }; + } else { + this.clearPinFields(tab); + this.pinStatus[tab] = { loading: false, found: false, error: true, message: 'Invalid PIN' }; + } + }, + error: () => { + this.pinStatus[tab] = { loading: false, found: false, error: true, message: 'Network error' }; + } + }); + }, 550); + } + + private applyPinData(tab: string, offices: any[]) { + const seen = new Set(); + this.localityOptions[tab] = offices.filter(o => { + if (seen.has(o.Name)) return false; + seen.add(o.Name); + return true; + }).map(o => o.Name); + + const form = this.getForm(tab); + const f = offices[0]; + form.patchValue({ + locality: this.localityOptions[tab][0] || '', + city: f.Block || f.Division || f.District || '', + district: f.District || '', + state: f.State || '' + }); + } + + private clearPinFields(tab: string) { + this.localityOptions[tab] = []; + const form = this.getForm(tab); + form.patchValue({ locality: '', city: '', district: '', state: '' }); + } + + getForm(tab: string): UntypedFormGroup { + switch (tab) { + case 'permanent': return this.permanentForm; + case 'current': return this.currentForm; + case 'office': return this.officeForm; + case 'correspondence': return this.correspondenceForm; + default: return this.permanentForm; + } + } + + toggleSameAsPermanent(checked: boolean) { + this.sameAsPermanent = checked; + if (checked) { + const pv = this.permanentForm.getRawValue(); + this.currentForm.patchValue({ + addressLine1: pv.addressLine1, + addressLine2: pv.addressLine2, + pinCode: pv.pinCode, + locality: pv.locality, + city: pv.city, + district: pv.district, + state: pv.state, + landmark: pv.landmark + }); + this.localityOptions['current'] = [...this.localityOptions['permanent']]; + this.pinStatus['current'] = { ...this.pinStatus['permanent'] }; + } + } + + copyFrom(source: string) { + const srcForm = this.getForm(source); + const sv = srcForm.getRawValue(); + this.correspondenceForm.patchValue({ + addressLine1: sv.addressLine1, + addressLine2: sv.addressLine2, + pinCode: sv.pinCode, + locality: sv.locality, + city: sv.city, + district: sv.district, + state: sv.state, + landmark: sv.landmark || '' + }); + this.localityOptions['correspondence'] = [...(this.localityOptions[source] || [])]; + this.pinStatus['correspondence'] = { ...this.pinStatus[source] }; + } + + /** Returns all address data for submission */ + get addressData(): any { + return { + permanent: this.permanentForm.getRawValue(), + current: this.sameAsPermanent ? { ...this.permanentForm.getRawValue(), sameAsPermanent: true } : this.currentForm.getRawValue(), + office: this.officeForm.getRawValue(), + correspondence: this.correspondenceForm.getRawValue() + }; + } + + get hasAddressData(): boolean { + return this.permanentForm.get('addressLine1')?.value?.length > 0; + } +} diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html index 023623dad..ffa19fdda 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html @@ -1,65 +1,808 @@
+
+
+ +
+

KYC & Documents

+

RBI-compliant KYC verification — PAN, Aadhaar & document upload.

+
+
+
+ +

- {{ 'labels.inputs.KYC Verification' | translate }} + KYC Verification KYC

-
- info - Verification (PAN, Aadhaar, GSTIN) will be available from the KYC tab after the client is created. +
+ + + @if (!isNonIndividual) { +
+ Client has PAN Card +
+ + + @if (kycForm.get('hasPan')?.value) { +
+ + PAN Number + credit_card + + @if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) { + Invalid PAN format + } + @if (panVerifyStatus === 'VERIFIED') { + check_circle + } + @if (panVerifyStatus === 'FAILED') { + cancel + } + Format: ABCDE1234F + +
+ @if (panVerifyStatus === 'NONE' || panVerifyStatus === 'FAILED') { + + } + @if (panVerifyStatus === 'VERIFIED') { + Verified + } +
+
+ @if (panVerifyStatus === 'VERIFIED') { +
+
+ + PAN Verified Successfully +
+
+
+ Full Name (as per PAN) + {{ panVerifyName }} +
+
+ PAN–Aadhaar Link Status + + @if (panAadhaarSeedingStatus?.toUpperCase() === 'Y') { Linked } + @else if (panAadhaarSeedingStatus?.toUpperCase() === 'N') { Not Linked } + @else { — } + +
+
+ Verified On + {{ panVerifiedOn }} +
+
+
+ } + } + + +
+ + Aadhaar Number + fingerprint + + + @if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) { + Must be 12 digits + } + @if (aadhaarVerifyStatus === 'VERIFIED') { + check_circle + } + +
+ @if ((aadhaarVerifyStatus === 'NONE' || aadhaarVerifyStatus === 'FAILED') && !aadhaarOtpSent) { + + } + @if (aadhaarVerifyStatus === 'VERIFIED') { + Verified + } +
+
+ + + @if (aadhaarOtpSent && aadhaarVerifyStatus !== 'VERIFIED') { +
+
+
+ verified_user +
+
+ Aadhaar OTP Verification + A 6-digit OTP has been sent to the Aadhaar-linked mobile number +
+
+
+ +
+ @for (digit of otpDigits; track $index) { + + } +
+
+ +
+ @if (resendCountdown > 0) { + + Resend in 00:{{ resendCountdown.toString().padStart(2, '0') }} + + } @else { + Didn't receive the code? + + } +
+
+
+
+ } + + @if (aadhaarVerifyError) { +
{{ aadhaarVerifyError }}
+ } + @if (aadhaarVerifyStatus === 'VERIFIED' && aadhaarVerifyName) { +
+
+ + Aadhaar Verified Successfully +
+
+ Name as per Aadhaar + {{ aadhaarVerifyName }} +
+
+ } + + + @if (kycForm.get('hasPan')?.value && panAadhaarSeedingStatus) { + + } + } + + + @if (isNonIndividual) { + + + Incorporation Date + calendar_today + + + + + + + + GSTIN Number + + @if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) { + Invalid GSTIN format (e.g. 22AAAAA0000A1Z5) + } + + + + @if (constitutionMeta?.value === 'PROPRIETORSHIP') { + +
+ + PAN of Proprietor + credit_card + + @if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) { + Invalid PAN format + } + @if (panVerifyStatus === 'VERIFIED') { + check_circle + } + @if (panVerifyStatus === 'FAILED') { + cancel + } + PAN of Proprietor (no separate entity PAN needed) + +
+ @if (panVerifyStatus === 'NONE' || panVerifyStatus === 'FAILED') { + + } + @if (panVerifyStatus === 'VERIFIED') { + Verified + } +
+
+ @if (panVerifyStatus === 'VERIFIED') { +
+
+ + PAN Verified Successfully +
+
+
+ Proprietor Name (as per PAN) + {{ panVerifyName }} +
+
+ Verified On + {{ panVerifiedOn }} +
+
+
+ } + } @else { + +
+ + {{ constitutionMeta?.panLabel || 'PAN of Entity' }} + credit_card + + @if (kycForm.get('enterprisePan')?.hasError('pattern') && kycForm.get('enterprisePan')?.touched) { + Invalid PAN format + } + @if (entPanVerifyStatus === 'VERIFIED') { + check_circle + } + @if (entPanVerifyStatus === 'FAILED') { + cancel + } + {{ constitutionMeta?.panHint || 'PAN mandatory' }} + +
+ @if (entPanVerifyStatus === 'NONE' || entPanVerifyStatus === 'FAILED') { + + } + @if (entPanVerifyStatus === 'VERIFIED') { + Verified + } +
+
+ @if (entPanVerifyStatus === 'VERIFIED') { +
+
+ + Enterprise PAN Verified +
+
+
+ Entity Name (as per PAN) + {{ entPanVerifyName }} +
+
+ Verified On + {{ entPanVerifiedOn }} +
+
+
+ } + } + + + @if (constitutionMeta?.showCin) { +
+ + CIN (Corporate Identity Number) + business + + @if (kycForm.get('cinNumber')?.hasError('pattern') && kycForm.get('cinNumber')?.touched) { + Invalid CIN format (21 characters) + } + @if (cinVerifyStatus === 'VERIFIED') { + check_circle + } + +
+ @if (cinVerifyStatus === 'NONE' || cinVerifyStatus === 'FAILED') { + + } + @if (cinVerifyStatus === 'VERIFIED') { + Verified + } +
+
+ + + @if (cinVerifying) { +
+ +
{{ cinProgressValue }}%
+
+ @for (step of cinProgressSteps; track step.label; let i = $index) { +
+ + {{ step.label }} +
+ } +
+
+ } + + @if (cinVerifyStatus === 'VERIFIED') { +
+
+ + CIN Verified — {{ cinCompanyName }} +
+
+
+ CIN + {{ kycForm.get('cinNumber')?.value }} +
+
+ Verified On + {{ cinVerifiedOn }} +
+
+
+ } + } + }
+ @if (!clientId) { +
+ + PAN verification requires client to be created first. Complete General tab and click Next to save. +
+ } +
+ + +
+

+ + KYC Document + OVD + @if (kycTemplate?.clientTypeName) { + {{ kycTemplate.clientTypeName }} + } +

+
- - {{ 'labels.inputs.PAN Number' | translate }} - - @if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) { - {{ 'labels.inputs.Enter 10-character PAN' | translate }} + KYC Document Type + description + + @for (doc of kycDocumentTypes; track doc) { + + {{ doc.name }} + @if (doc.mandatory) { + * + } + + } + + @if (kycForm.get('kycDocumentName')?.hasError('required') && kycForm.get('kycDocumentName')?.touched) { + KYC Document Type is required } - - {{ 'labels.inputs.Aadhaar Number' | translate }} - - @if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) { - {{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }} + Document Number + tag + + @if (kycForm.get('kycDocumentNumber')?.hasError('required') && kycForm.get('kycDocumentNumber')?.touched) { + Document number is required + } @else if (kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched) { + Only letters, numbers, hyphens and slashes allowed } - - - {{ 'labels.inputs.GSTIN Number' | translate }} - - @if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) { - {{ 'labels.inputs.Enter 15-character GSTIN' | translate }} - } + + Issue Date + calendar_today + + + - - - {{ 'labels.inputs.Remarks' | translate }} - + + Expiry Date + event_busy + + + + If applicable (Passport, DL, Visa)
+ + +
+

+ + Risk Category & KYC Review +

+ +
+ + Risk Category + + + @if (kycForm.get('riskCategory')?.value === 1) { + Low Risk + } @else if (kycForm.get('riskCategory')?.value === 2) { + Medium Risk + } @else if (kycForm.get('riskCategory')?.value === 3) { + High Risk + } + + + Low Risk 10 yr renewal + + + Medium Risk 8 yr renewal + + + High Risk 2 yr renewal + + + + + + KYC Review Due Date + + Auto: Low 10yr, Medium 8yr, High 2yr + + + + Remarks + + +
+ + +
+
+ + KYC Expiry Alert +
+
+
+ @for (rc of riskCards; track rc.value) { +
+ + + + + + + + +
+ {{ rc.label }} + Renewal every {{ rc.years }} years +
+ @if (kycForm.get('riskCategory')?.value === rc.value) { + Next: {{ getKycExpiryDate(rc.years) }} + } +
+ } +
+ @if (kycForm.get('riskCategory')?.value) { +
+ + System will auto-alert before KYC expiry on {{ getKycExpiryDate(getRenewalYears()) }}. +
+ } +
+
+
+ + +
+

+ + AML & Compliance Checks +

+ +
+ + AML Check Status + + Clear + Flagged + Blocked + + + + + Banned Entity List Match + + No + Yes + Partial Match + + + + + Willful Defaulter Flag + + No + Yes + + + + + ECGC SAL Flag + + No + Yes + + + + + Dormant / Inoperative Flag + + Active + Dormant + Inoperative + + +
+ + @if (kycForm.get('amlCheckStatus')?.value === 'BLOCKED' || kycForm.get('bannedEntityMatch')?.value === 'YES') { +
+ + BLOCKED — Account opening cannot proceed. +
+ } +
+ + +
+

+ + Freeze / Debit Controls +

+ +
+ + Account Status + + Active + Frozen + Unfreeze + No Debit + + + + @if (kycForm.get('accountStatus')?.value === 'FROZEN') { + + Reason for Freeze + + Court Order + AML + Regulatory + Internal + Other + + + + Freeze Authority + + Branch Manager + Head Office + Regulatory Authority + + + } +
+
+ + +
+

+ + Form 60 / 61 + @if (!kycForm.get('hasPan')?.value) { + Required — No PAN + } +

+ @if (!kycForm.get('hasPan')?.value) { +
+ warning + Client does not have PAN. Form 60/61 is mandatory. +
+
+ + Form Type + + Form 60 + Form 61 + + + + Declaration Date + + + + +
+ } @else { +

Client has PAN — Form 60/61 not required.

+ } +
+ + +
+

+ + Form 15G / 15H +

+
+
+ Form 15G/15H Applicable +
+ @if (kycForm.get('form15Applicable')?.value) { + + Form Type + + 15G (Below 60 years) + 15H (Senior Citizen 60+) + + + + Financial Year + + + } +
+
+ + +
+

+ + Photograph & Signature +

+
+
+ @if (photographPreview) { +
+ Photo +
+ + +
+
+ } @else { +
+ Client Photograph + JPG / PNG, max 2MB + + } +
+
+ @if (signaturePreview) { +
+ Signature +
+ + +
+
+ } @else { +
+ Specimen Signature + Scanned image, max 1MB + + } +
+
+
diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss index 01c9d3c7e..f71ebc326 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss @@ -70,8 +70,1066 @@ grid-column: 1 / -1; } +/* KYC Expiry Section */ +.kyc-expiry-section { + margin-top: 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + overflow: hidden; +} + +.kyc-expiry-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + font-size: 13px; + font-weight: 600; + color: #0f172a; + + .kyc-expiry-header-icon { + color: #d97706; + font-size: 14px; + } +} + +.kyc-expiry-body { + padding: 16px; +} + +.kyc-expiry-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 14px; +} + +.kyc-expiry-card { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + transition: all 0.15s; + + &.active { + border-color: #1353a0; + background: #eff6ff; + box-shadow: 0 0 0 2px rgba(19, 83, 160, 0.12); + } + + &__dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + + &.green { background: #10b981; } + &.amber { background: #f59e0b; } + &.red { background: #ef4444; } + } + + &__info { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + } + + &__label { + font-size: 12px; + font-weight: 600; + color: #0f172a; + } + + &__cycle { + font-size: 11px; + color: #64748b; + + strong { + color: #334155; + } + } + + &__next { + font-size: 10px; + font-weight: 600; + color: #1353a0; + background: #dbeafe; + padding: 3px 8px; + border-radius: 4px; + white-space: nowrap; + flex-shrink: 0; + } +} + +.kyc-expiry-alert { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + + &__icon { + font-size: 14px; + flex-shrink: 0; + } + + &--low { + background: #ecfdf5; + color: #065f46; + border: 1px solid #a7f3d0; + + .kyc-expiry-alert__icon { color: #10b981; } + } + + &--medium { + background: #fffbeb; + color: #92400e; + border: 1px solid #fde68a; + + .kyc-expiry-alert__icon { color: #f59e0b; } + } + + &--high { + background: #fef2f2; + color: #991b1b; + border: 1px solid #fecaca; + + .kyc-expiry-alert__icon { color: #ef4444; } + } +} + +@media (max-width: 768px) { + .kyc-expiry-cards { + grid-template-columns: 1fr; + } +} + +/* Verify Field Layout */ +.verify-field { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.verify-input { + flex: 1; +} + +.verify-action { + padding-top: 8px; + flex-shrink: 0; +} + +.verify-btn-inline { + font-size: 12px !important; + font-weight: 600 !important; + padding: 0 14px !important; + height: 36px !important; + border-radius: 6px !important; + display: flex; + align-items: center; + gap: 6px; +} + +.verify-chip { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 600; + padding: 6px 12px; + border-radius: 6px; + white-space: nowrap; + + &.pending { + background: #fffbeb; + color: #92400e; + border: 1px solid #fde68a; + } + + &.verified { + background: #ecfdf5; + color: #059669; + border: 1px solid #a7f3d0; + } +} + +.verify-icon-ok { + color: #059669 !important; + font-size: 20px !important; +} + +.toggle-vis-btn { + width: 28px !important; + height: 28px !important; + font-size: 13px !important; + color: #64748b; +} + +/* Verification Info Banner */ +.verify-info-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 8px; + margin-top: 12px; + font-size: 12px; + color: #1e40af; + + .verify-info-icon { + font-size: 14px; + color: #2563eb; + flex-shrink: 0; + } +} + +/* PAN Result Card */ +.pan-result-card { + border: 1px solid #a7f3d0; + border-radius: 8px; + overflow: hidden; + margin-top: -4px; + margin-bottom: 8px; +} + +.pan-result-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + background: #059669; + color: #fff; + font-size: 12px; + font-weight: 600; +} + +.pan-result-ok-icon { + font-size: 14px; +} + +.pan-result-body { + padding: 10px 14px; + background: #ecfdf5; + display: flex; + gap: 24px; + flex-wrap: wrap; +} + +.pan-result-row { + display: flex; + flex-direction: column; + gap: 2px; +} + +.pan-result-label { + font-size: 10px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.pan-result-value { + font-size: 13px; + font-weight: 600; + color: #0f172a; + + &.name { + font-size: 15px; + font-weight: 700; + color: #065f46; + } + + &.seeded { + color: #059669; + } + + &.not-seeded { + color: #dc2626; + } + + &.date { + font-family: 'Roboto Mono', monospace; + font-size: 12px; + color: #475569; + } +} + +/* Verify error / success */ +.verify-error { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #dc2626; + padding: 6px 10px; + background: #fef2f2; + border-radius: 6px; + margin-top: -8px; + margin-bottom: 8px; +} + +.verify-success { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #059669; + padding: 6px 10px; + background: #ecfdf5; + border-radius: 6px; + margin-top: -8px; + margin-bottom: 8px; +} + +.verify-icon-fail { + color: #dc2626 !important; + font-size: 20px !important; +} + +/* ═══ CIN Progress ═══ */ +.cin-progress { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 16px; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + margin-bottom: 8px; +} + +.cin-progress-percent { + font-size: 12px; + font-weight: 700; + color: #1074b9; + text-align: center; +} + +.cin-progress-steps { + display: flex; + flex-direction: column; + gap: 6px; +} + +.cin-step { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #9ca3af; + transition: color 0.3s ease; + + &.active { + color: #1074b9; + font-weight: 600; + } + + &.done { + color: #388e3c; + } +} + +.done-icon { + color: #388e3c; +} + +/* ═══ OTP Card ═══ */ +.otp-card { + border-radius: 12px; + border: 1px solid #e0e7ff; + overflow: hidden; + background: #fff; + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.08); + margin-bottom: 8px; +} + +.otp-card__header { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; +} + +.otp-card__header-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 8px; + background: #1074b9; + color: #fff; + flex-shrink: 0; + + mat-icon { font-size: 16px; width: 16px; height: 16px; } +} + +.otp-card__header-text { + display: flex; + flex-direction: column; +} + +.otp-card__title { + font-size: 13px; + font-weight: 600; + color: #1e293b; +} + +.otp-card__subtitle { + font-size: 11px; + color: #64748b; + font-weight: 400; +} + +.otp-card__body { + padding: 16px; + display: flex; + flex-direction: column; + align-items: center; +} + +.otp-card__label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #94a3b8; + margin-bottom: 10px; +} + +/* Individual digit boxes */ +.otp-digits { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.otp-digit-box { + width: 38px; + height: 42px; + border: 1.5px solid #e2e8f0; + border-radius: 8px; + text-align: center; + font-size: 18px; + font-weight: 700; + font-family: 'Roboto Mono', monospace; + color: #1e293b; + background: #f8fafc; + outline: none; + transition: all 0.15s ease; + caret-color: #1074b9; + + &:focus { + border-color: #1074b9; + background: #fff; + box-shadow: 0 0 0 2px rgba(16, 116, 185, 0.12); + } + + &.filled { + border-color: #1074b9; + background: #eff6ff; + color: #1074b9; + } +} + +.otp-card__actions { + display: flex; + align-items: center; + gap: 16px; + width: 100%; + justify-content: center; +} + +.otp-card__verify-btn { + min-width: 140px; + height: 36px !important; + font-size: 13px !important; + font-weight: 600 !important; + border-radius: 8px !important; + letter-spacing: 0.02em; + display: inline-flex !important; + align-items: center; + gap: 6px; +} + +.otp-btn-icon { + font-size: 15px; +} + +.otp-spinner { + display: inline-block; + + ::ng-deep circle { + stroke: #fff !important; + } +} + +.otp-card__resend { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; +} + +.otp-card__countdown { + color: #64748b; + font-size: 12px; + font-variant-numeric: tabular-nums; + + strong { + color: #1074b9; + font-family: 'Roboto Mono', monospace; + } +} + +.otp-card__resend-text { + color: #94a3b8; +} + +.otp-card__resend-btn { + color: #1074b9 !important; + font-weight: 600 !important; + font-size: 12px !important; + padding: 0 6px !important; + min-width: auto !important; + text-decoration: underline; +} + +/* Aadhaar Verified Result Card */ +.aadhaar-result-card { + border-radius: 10px; + border: 1px solid #a7f3d0; + overflow: hidden; + margin-bottom: 8px; +} + +.aadhaar-result-card__header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: #ecfdf5; + font-size: 13px; + font-weight: 600; + color: #065f46; +} + +.aadhaar-result-card__icon { + color: #059669; +} + +.aadhaar-result-card__body { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.aadhaar-result-card__label { + font-size: 11px; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.aadhaar-result-card__name { + font-size: 15px; + font-weight: 600; + color: #065f46; +} + +@media (max-width: 480px) { + .otp-digits { gap: 6px; } + + .otp-digit-box { + width: 40px; + height: 48px; + font-size: 18px; + border-radius: 8px; + } + + .otp-card__header { padding: 12px 14px; } + .otp-card__body { padding: 20px 14px; } +} + +/* PAN-Aadhaar Link Status (read-only) */ +.pan-link-status { + margin-bottom: 8px; +} + +.pan-link-box { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + border-radius: 8px; + border: 1px solid #e2e8f0; + background: #f8fafc; + + &.linked { + border-color: #a7f3d0; + background: #ecfdf5; + .pan-link-value { color: #059669; } + .pan-link-icon { color: #059669; } + } + + &.not-linked { + border-color: #fecaca; + background: #fef2f2; + .pan-link-value { color: #dc2626; } + .pan-link-icon { color: #dc2626; } + } + + &.exempt { + border-color: #bfdbfe; + background: #eff6ff; + .pan-link-value { color: #2563eb; } + .pan-link-icon { color: #2563eb; } + } +} + +.pan-link-icon { + font-size: 16px; + color: #64748b; +} + +.pan-link-info { + display: flex; + flex-direction: column; + flex: 1; +} + +.pan-link-label { + font-size: 10px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.pan-link-value { + font-size: 14px; + font-weight: 700; + color: #0f172a; +} + +.pan-link-hint { + font-size: 10px; + color: #94a3b8; + flex-shrink: 0; +} + +/* PAN-Aadhaar Link Status */ +.pan-aadhaar-link { + display: flex; + align-items: center; +} + +.link-status-box { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + width: 100%; + + .link-icon { + font-size: 16px; + color: #64748b; + } + + .link-label { + font-size: 12px; + font-weight: 600; + color: #475569; + display: block; + } + + .link-value { + font-size: 12px; + font-weight: 500; + + &.pending { + color: #d97706; + } + + &.na { + color: #94a3b8; + } + + &.linked { + color: #059669; + } + } +} + +/* Required Badge */ +.required-badge { + background: #fef2f2 !important; + color: #dc2626 !important; +} + +/* Warning Hint */ +.warning-hint { + background-color: #fffbeb !important; + border-color: #fbbf24 !important; + color: #92400e !important; + + .kyc-info-hint-icon { + color: #d97706 !important; + } +} + +/* Form 60 not needed text */ +.form60-not-needed { + font-size: 13px; + color: #059669; + padding: 8px 12px; + background: #ecfdf5; + border-radius: 6px; + border: 1px solid #a7f3d0; +} + +/* Checkbox field */ +.checkbox-field { + display: flex; + align-items: center; + padding-top: 10px; +} + +/* Link select inside status box */ +.link-select { + font-size: 12px; + margin-top: 2px; +} + +/* Doc badge */ +.doc-badge { + background: #7c3aed !important; +} + +.kyc-client-type-chip { + display: inline-flex; + align-items: center; + margin-left: 8px; + padding: 2px 10px; + font-size: 11px; + font-weight: 600; + color: #1e40af; + background: #eff6ff; + border: 1px solid #93c5fd; + border-radius: 12px; +} + +.doc-mandatory-star { + color: #dc2626; + font-weight: 700; +} + +/* AML Section */ +.aml-title .section-icon { + color: #dc2626 !important; +} + +.aml-block-alert { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + margin-top: 12px; + font-size: 13px; + color: #991b1b; + + .aml-block-icon { + font-size: 16px; + color: #dc2626; + } +} + +.aml-warn-alert { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 8px; + margin-top: 8px; + font-size: 13px; + color: #92400e; + + .aml-warn-icon { + font-size: 16px; + color: #d97706; + } +} + +/* Freeze Section */ +.freeze-title .section-icon { + color: #0369a1 !important; +} + +/* Photo & Signature Upload */ +.photo-sig-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.upload-box { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 20px; + border: 2px dashed #d0d5dd; + border-radius: 10px; + background: #f9fafb; + text-align: center; + transition: border-color 0.15s; + + &:hover { + border-color: #1353a0; + } + + &__icon { + width: 44px; + height: 44px; + border-radius: 50%; + background: #eff6ff; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: #1353a0; + } + + &__label { + font-size: 13px; + font-weight: 600; + color: #0f172a; + } + + &__hint { + font-size: 11px; + color: #94a3b8; + } + + &__btn { + display: inline-flex; + align-items: center; + padding: 6px 16px; + background: #fff; + border: 1px solid #d0d5dd; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + color: #475569; + cursor: pointer; + transition: all 0.15s; + + &:hover { + border-color: #1353a0; + color: #1353a0; + } + } + + &__status { + font-size: 11px; + font-weight: 600; + color: #059669; + display: flex; + align-items: center; + gap: 4px; + } + + &--has-preview { + border-style: solid; + border-color: #1353a0; + background: #fff; + padding: 12px; + } +} + +/* Upload Preview */ +.upload-preview { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + width: 100%; + + &__img { + border-radius: 8px; + border: 1px solid #e2e8f0; + object-fit: cover; + background: #f8fafc; + + &--photo { + width: 120px; + height: 120px; + border-radius: 50%; + } + + &--sig { + width: 100%; + max-width: 250px; + height: 80px; + border-radius: 6px; + object-fit: contain; + } + } + + &__actions { + display: flex; + gap: 8px; + } + + &__btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 12px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + border: 1px solid #e2e8f0; + background: #fff; + transition: all 0.15s; + + &.recrop { + color: #1353a0; + + &:hover { + background: #eff6ff; + border-color: #1353a0; + } + } + + &.remove { + color: #dc2626; + + &:hover { + background: #fef2f2; + border-color: #dc2626; + } + } + } +} + @media (max-width: 768px) { .form-grid { grid-template-columns: 1fr; } + + .photo-sig-grid { + grid-template-columns: 1fr; + } } + +.step-header { + background: #0f172a; + padding: 16px 24px; + border-radius: 10px 10px 0 0; + margin-bottom: 20px; + + &__left { + display: flex; + align-items: center; + gap: 12px; + } + + &__icon { + font-size: 18px; + color: #60a5fa; + } + + &__title { + font-size: 16px; + font-weight: 600; + color: #fff; + margin: 0; + } + + &__sub { + font-size: 11px; + color: rgba(255, 255, 255, 0.5); + margin: 2px 0 0; + } +} + +/* ── Risk dropdown ── */ +.risk-option { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.risk-meter { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; + + &.risk-low { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2); } + &.risk-medium { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.2); } + &.risk-high { background: #ef4444; box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2); } +} + +.risk-hint { + font-size: 11px; + color: #94a3b8; + margin-left: 4px; +} + +/* ── Risk gauge in cards ── */ +.risk-gauge { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.risk-gauge-svg { + width: 40px; + height: 24px; +} + +.field-prefix-icon { + display: inline-flex; + align-items: center; + padding-left: 4px; + padding-right: 8px; + margin-right: 8px; + border-right: 1.5px solid #cbd5e1; + line-height: 1; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #1565c0; + } +} + diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts index 397838d67..68d3e4905 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts @@ -1,82 +1,805 @@ /** * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -/** Angular Imports */ -import { Component, inject } from '@angular/core'; -import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; - -/** Custom Imports */ +import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms'; +import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogActions } from '@angular/material/dialog'; +import { Inject } from '@angular/core'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatSelectTrigger } from '@angular/material/select'; import { MatDivider } from '@angular/material/divider'; import { MatIcon } from '@angular/material/icon'; +import { MatCheckbox } from '@angular/material/checkbox'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ClientsService } from '../../clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; -/** - * Client KYC Step Component - * Collects PAN, Aadhaar, and GSTIN details during client creation. - * KYC data is submitted via POST /clients/{clientId}/kyc after client creation. - * Verification happens later from the KYC tab on the client view page. - */ @Component({ selector: 'mifosx-client-kyc-step', templateUrl: './client-kyc-step.component.html', styleUrls: ['./client-kyc-step.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, + FormsModule, MatDivider, MatIcon, - FaIconComponent + MatCheckbox, + MatProgressSpinner, + FaIconComponent, + MatSelectTrigger ] }) -export class ClientKycStepComponent { +export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { private formBuilder = inject(UntypedFormBuilder); + private dialog = inject(MatDialog); + private clientsService = inject(ClientsService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); - /** KYC Reactive Form */ + /** Set by parent after client is created */ + @Input() clientId: string = ''; + @Input() isNonIndividual = false; + /** Constitution metadata (panLabel, showCin, etc.) passed from general step */ + @Input() constitutionMeta: any = null; + /** Entity full name (non-individual) — used for enterprise PAN verification */ + @Input() entityName: string = ''; + /** Read-only mode — disables form editing until user clicks edit icon */ + @Input() readOnly = false; + + today = new Date(); + kycId: string = ''; + /** Whether existing KYC data has been loaded (edit mode) */ + kycLoaded = false; + + /** KYC Document Types — populated from API template, falls back to defaults */ + kycDocumentTypes: { name: string; code: string; mandatory: boolean }[] = []; + /** Fallback document types when template API is unavailable */ + private defaultDocumentTypes = [ + 'Aadhaar Card', 'Passport', 'PAN Card', 'Driving Licence', + 'Voter ID', 'NREGA Job Card', 'National Population Register Letter' + ]; + /** Constitution-based KYC document types for non-individual clients */ + private constitutionDocTypes: Record = { + 'HUF': [ + { name: 'PAN Card of HUF', mandatory: true }, + { name: 'Declaration by Karta (with name, address & HUF member details)', mandatory: true }, + { name: 'KYC of Karta (OVDs + PAN + Photo)', mandatory: true }, + { name: 'Proof of Address of HUF', mandatory: false } + ], + 'PRIVATE_LIMITED': [ + { name: 'Certificate of Incorporation', mandatory: true }, + { name: 'Memorandum & Articles of Association', mandatory: true }, + { name: 'PAN of Company', mandatory: true }, + { name: 'Board Resolution (authorizing signatories)', mandatory: true }, + { name: 'KYC of Authorized Signatories', mandatory: true } + ], + 'PUBLIC_LIMITED': [ + { name: 'Certificate of Incorporation', mandatory: true }, + { name: 'Memorandum & Articles of Association', mandatory: true }, + { name: 'PAN of Company', mandatory: true }, + { name: 'Board Resolution (authorizing signatories)', mandatory: true }, + { name: 'KYC of Authorized Signatories', mandatory: true } + ], + 'LLP': [ + { name: 'Certificate of Incorporation', mandatory: true }, + { name: 'LLP Agreement', mandatory: true }, + { name: 'PAN of LLP', mandatory: true }, + { name: 'Designated Partners Resolution', mandatory: true }, + { name: 'KYC of Designated Partners / Authorized Signatories', mandatory: true } + ], + 'PARTNERSHIP': [ + { name: 'Registration Certificate', mandatory: false }, + { name: 'Partnership Deed', mandatory: true }, + { name: 'PAN of Firm', mandatory: true }, + { name: 'KYC of Authorized Signatories', mandatory: true } + ], + 'TRUST': [ + { name: 'Registration Certificate', mandatory: true }, + { name: 'Trust Deed', mandatory: true }, + { name: 'PAN of Trust', mandatory: true }, + { name: 'KYC of Trustees / Authorized Signatories', mandatory: true } + ], + 'SOCIETY': [ + { name: 'Registration Certificate', mandatory: true }, + { name: 'PAN of Society', mandatory: true }, + { name: 'KYC of Authorized Signatories', mandatory: true } + ], + 'PROPRIETORSHIP': [ + { name: 'Proof of Business Existence (GST Certificate / Shop License)', mandatory: true }, + { name: 'PAN of Proprietor', mandatory: true }, + { name: "Proprietor's KYC (OVDs + PAN + Photo)", mandatory: true } + ], + 'ASSOCIATION': [ + { name: 'Registration Certificate', mandatory: true }, + { name: 'PAN', mandatory: true }, + { name: 'Resolution authorizing signatories', mandatory: true }, + { name: 'KYC of Authorized Signatories', mandatory: true } + ], + 'GOVT_PSU': [ + { name: 'Government Notification / Order', mandatory: true }, + { name: 'PAN (if applicable)', mandatory: false }, + { name: 'Authorized Signatory KYC', mandatory: true } + ], + 'COOPERATIVE': [ + { name: 'Registration Certificate', mandatory: true }, + { name: 'PAN', mandatory: true }, + { name: 'Resolution authorizing signatories', mandatory: true }, + { name: 'KYC of Authorized Signatories', mandatory: true } + ] + }; + /** KYC template data from API */ + kycTemplate: any = null; + kycTemplateLoaded = false; + + /** Risk cards */ + riskCards = [ + { value: 1, label: 'Low Risk', years: 10, color: 'green' }, + { value: 2, label: 'Medium Risk', years: 8, color: 'amber' }, + { value: 3, label: 'High Risk', years: 2, color: 'red' } + ]; + + /** PAN verification */ + panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE'; + panVerifying = false; + panVerifyError: string = ''; + panVerifyName: string = ''; + panVerifiedOn: string = ''; + + /** Aadhaar verification */ + aadhaarVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE'; + aadhaarVerifying = false; + aadhaarVerifyError: string = ''; + aadhaarVerifyName: string = ''; + aadhaarVisible = false; + aadhaarOtpSent = false; + aadhaarOtp = ''; + aadhaarOtpVerifying = false; + aadhaarOtpReferenceId = ''; + + /** OTP digit boxes */ + otpDigits: string[] = ['', '', '', '', '', '']; + /** Resend countdown */ + resendCountdown = 0; + private resendTimer: any = null; + + onOtpDigitInput(index: number, event: Event): void { + const input = event.target as HTMLInputElement; + const val = input.value.replace(/\D/g, ''); + this.otpDigits[index] = val.charAt(0) || ''; + input.value = this.otpDigits[index]; + + // Auto-advance to next box + if (this.otpDigits[index] && index < 5) { + const next = input.parentElement?.querySelectorAll('input')[index + 1]; + if (next) (next as HTMLInputElement).focus(); + } + + this.aadhaarOtp = this.otpDigits.join(''); + } + + onOtpKeydown(index: number, event: KeyboardEvent): void { + if (event.key === 'Backspace' && !this.otpDigits[index] && index > 0) { + const inputs = (event.target as HTMLElement).parentElement?.querySelectorAll('input'); + if (inputs) { + this.otpDigits[index - 1] = ''; + (inputs[index - 1] as HTMLInputElement).value = ''; + (inputs[index - 1] as HTMLInputElement).focus(); + } + this.aadhaarOtp = this.otpDigits.join(''); + } + } + + onOtpPaste(event: ClipboardEvent): void { + event.preventDefault(); + const pasted = (event.clipboardData?.getData('text') || '').replace(/\D/g, '').slice(0, 6); + for (let i = 0; i < 6; i++) { + this.otpDigits[i] = pasted.charAt(i) || ''; + } + this.aadhaarOtp = this.otpDigits.join(''); + // Focus last filled or last box + const inputs = (event.target as HTMLElement).closest('.otp-digits')?.querySelectorAll('input'); + if (inputs) { + const focusIdx = Math.min(pasted.length, 5); + (inputs[focusIdx] as HTMLInputElement).focus(); + } + } + + startResendCountdown(): void { + this.resendCountdown = 30; + if (this.resendTimer) clearInterval(this.resendTimer); + this.resendTimer = setInterval(() => { + this.resendCountdown--; + if (this.resendCountdown <= 0) { + clearInterval(this.resendTimer); + this.resendTimer = null; + } + }, 1000); + } + + resetOtpDigits(): void { + this.otpDigits = ['', '', '', '', '', '']; + this.aadhaarOtp = ''; + } + + /** PAN-Aadhaar seeding status (from API response) */ + panAadhaarSeedingStatus: string = ''; + + /** Enterprise PAN verification (non-individual) */ + entPanVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE'; + entPanVerifying = false; + entPanVerifyName = ''; + entPanVerifiedOn = ''; + + /** CIN verification (dummy) */ + cinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE'; + cinVerifying = false; + cinVerifiedOn = ''; + cinCompanyName = ''; + cinProgressValue = 0; + cinCurrentStep = 0; + cinProgressSteps = [ + { label: 'Connecting to MCA database...', icon: 'globe' as any }, + { label: 'Validating CIN format...', icon: 'search' as any }, + { label: 'Fetching company details...', icon: 'database' as any }, + { label: 'Verifying registration status...', icon: 'check-circle' as any } + ]; + private cinProgressTimer: any; + + /** File uploads */ + photographFile: File | null = null; + photographPreview: string | null = null; + signatureFile: File | null = null; + signaturePreview: string | null = null; + + /** KYC Form */ kycForm: UntypedFormGroup = this.formBuilder.group({ - panNumber: [ - '', - [Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)] - ], - aadhaarNumber: [ - '', - [Validators.pattern(/^\d{12}$/)] - ], - gstinNumber: [ - '', - [Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)] - ], - remarks: [''] + hasPan: [true], + panNumber: ['', [Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/), Validators.maxLength(10)]], + aadhaarNumber: ['', [Validators.pattern(/^\d{12}$/), Validators.maxLength(12)]], + panAadhaarSeedingStatus: [''], + gstinNumber: ['', [Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/), Validators.maxLength(15)]], + enterprisePan: ['', [Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/), Validators.maxLength(10)]], + cinNumber: ['', [Validators.pattern(/^[A-Z]{1}[0-9]{5}[A-Z]{2}[0-9]{4}[A-Z]{3}[0-9]{6}$/), Validators.maxLength(21)]], + incorporationDate: [''], + kycDocumentName: ['', Validators.required], + kycDocumentNumber: ['', [Validators.required, Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/), Validators.maxLength(30)]], + kycDocIssueDate: [''], + kycDocExpiryDate: [''], + riskCategory: [1], + remarks: [''], + amlCheckStatus: ['CLEAR', Validators.required], + bannedEntityMatch: ['NO', Validators.required], + willfulDefaulter: ['NO'], + ecgcSalFlag: ['NO'], + dormantFlag: ['ACTIVE'], + accountStatus: ['ACTIVE'], + freezeReason: [''], + freezeAuthority: [''], + form60Type: ['FORM_60'], + form60Date: [''], + form15Applicable: [false], + form15Type: ['15G'], + form15FY: [''] }); - /** - * Returns the KYC payload matching the backend API contract. - * POST /clients/{clientId}/kyc expects: kycType, riskCategory, panNumber, - * aadhaarNumberMasked (NOT full aadhaarNumber), gstinNumber, remarks. - */ - get kycData(): any { + ngOnInit(): void { + this.loadExistingKycIfNeeded(); + this.loadKycTemplate(); + if (this.isNonIndividual && this.constitutionMeta) { + this.updateDocTypesForConstitution(); + } + } + + ngOnDestroy(): void { + if (this.cinProgressTimer) clearInterval(this.cinProgressTimer); + if (this.resendTimer) clearInterval(this.resendTimer); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['clientId'] && !changes['clientId'].firstChange) { + this.loadExistingKycIfNeeded(); + this.loadKycTemplate(); + } + if (changes['readOnly'] && this.kycForm) { + if (this.readOnly) { + this.kycForm.disable({ emitEvent: false }); + } else { + this.kycForm.enable({ emitEvent: false }); + } + } + if (changes['constitutionMeta'] && this.isNonIndividual) { + this.updateDocTypesForConstitution(); + } + } + + /** Update KYC document types based on selected constitution type */ + private updateDocTypesForConstitution(): void { + const constitutionValue = this.constitutionMeta?.value; + if (constitutionValue && this.constitutionDocTypes[constitutionValue]) { + this.kycDocumentTypes = this.constitutionDocTypes[constitutionValue].map(doc => ({ + name: doc.name, + code: doc.name, + mandatory: doc.mandatory + })); + // Reset selected document type since options changed + this.kycForm.get('kycDocumentName')?.setValue(''); + this.kycForm.get('kycDocumentName')?.updateValueAndValidity(); + } + } + + /** Load existing KYC data if clientId is set (edit mode) */ + private loadExistingKycIfNeeded(): void { + if (!this.clientId || this.kycLoaded) return; + this.clientsService.getClientKyc(this.clientId).subscribe({ + next: (data: any) => { + this.kycLoaded = true; + if (data) { + this.kycId = data.id?.toString() || ''; + this.kycForm.patchValue({ + panNumber: data.panNumber || '', + aadhaarNumber: data.aadhaarNumberMasked || '', + gstinNumber: data.gstinNumber || '', + riskCategory: data.riskCategory || 1, + remarks: data.remarks || '', + hasPan: !!data.panNumber + }); + // Set verification statuses from existing data + if (data.panVerified) { + this.panVerifyStatus = 'VERIFIED'; + this.panVerifyName = data.panFullName || data.panHolderName || ''; + const rawDate = data.panVerifiedOn; + if (Array.isArray(rawDate) && rawDate.length >= 3) { + this.panVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`; + } else if (rawDate) { + this.panVerifiedOn = rawDate; + } + } + if (data.aadhaarVerified) { + this.aadhaarVerifyStatus = 'VERIFIED'; + this.aadhaarVerifyName = data.aadhaarHolderName || 'Aadhaar Verified'; + } + if (data.panAadhaarSeedingStatus) { + this.panAadhaarSeedingStatus = data.panAadhaarSeedingStatus; + } + } + }, + error: () => { + // No existing KYC — that's fine for new clients + this.kycLoaded = true; + } + }); + } + + /** Load KYC template to get required documents based on client type */ + private loadKycTemplate(): void { + if (!this.clientId || this.kycTemplateLoaded) return; + this.clientsService.getClientKycTemplate(this.clientId).subscribe({ + next: (template: any) => { + this.kycTemplate = template; + this.kycTemplateLoaded = true; + if (template?.requiredDocuments?.length > 0) { + this.kycDocumentTypes = template.requiredDocuments.map((doc: any) => ({ + name: doc.name, + code: doc.code || doc.name, + mandatory: doc.mandatory ?? false + })); + // Update kycDocumentName validator based on mandatory docs + const hasMandatory = this.kycDocumentTypes.some(d => d.mandatory); + if (hasMandatory) { + this.kycForm.get('kycDocumentName')?.setValidators(Validators.required); + } + this.kycForm.get('kycDocumentName')?.updateValueAndValidity(); + } + }, + error: () => { + // Fallback to defaults if template API unavailable + this.kycTemplateLoaded = true; + this.kycDocumentTypes = this.defaultDocumentTypes.map(name => ({ + name, + code: name, + mandatory: false + })); + } + }); + } + + /** Save or update KYC, then execute callback */ + private saveKycAndDo(payload: any, callback: () => void, onError: (msg: string) => void) { + if (this.kycId) { + // KYC already exists — use PUT to update (strip create-only fields) + const { kycType, ...updatePayload } = payload; + this.clientsService.updateClientKyc(this.clientId, this.kycId, updatePayload).subscribe({ + next: () => callback(), + error: (err: any) => onError(err?.error?.defaultUserMessage || 'Failed to update KYC.') + }); + } else { + // First time — use POST to create + this.clientsService.saveClientKyc(this.clientId, payload).subscribe({ + next: (res: any) => { + this.kycId = res?.resourceId?.toString() || ''; + if (this.kycId) { callback(); } + else { onError('Failed to save KYC.'); } + }, + error: (err: any) => onError(err?.error?.defaultUserMessage || 'Failed to save KYC.') + }); + } + } + + /** Verify PAN — saves PAN-only KYC data, then verifies */ + verifyPan() { + if (!this.clientId) { + this.panVerifyError = 'Client must be created first. Complete General tab and click Next.'; + return; + } + const pan = this.kycForm.value.panNumber; + if (!pan) { this.panVerifyError = 'Enter PAN number first.'; return; } + + this.panVerifying = true; + this.panVerifyError = ''; + this.panVerifyName = ''; + const aadhaarRaw = this.kycForm.value.aadhaarNumber || ''; + const panPayload: any = { + locale: 'en', + kycType: 1, + riskCategory: this.kycForm.value.riskCategory || 1, + panNumber: pan, + aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000' + }; + + this.saveKycAndDo(panPayload, () => { + this.clientsService.verifyPAN(this.clientId, this.kycId).subscribe({ + next: (res: any) => { + this.panVerifying = false; + if (res?.changes?.panVerified === true) { + // Full name from API + const resolvedName = res.changes?.panFullName + || res.changes?.panHolderName + || [res.changes?.panFirstName, res.changes?.panLastName].filter(Boolean).join(' ') + || ''; + + // If name is empty, the client name/DOB didn't match PAN holder + if (!resolvedName || !resolvedName.trim()) { + this.panVerifyStatus = 'FAILED'; + this.showPanErrorAlert('PAN verification failed: Client name or date of birth does not match the PAN card holder. Please ensure the client\'s name and date of birth exactly match the PAN card details.'); + return; + } + + this.panVerifyStatus = 'VERIFIED'; + this.panVerifyName = resolvedName; + // Date — could be string or array [y,m,d] + const rawDate = res.changes?.panVerifiedOn; + if (Array.isArray(rawDate)) { + this.panVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`; + } else if (rawDate) { + this.panVerifiedOn = rawDate; + } else { + const now = new Date(); + this.panVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`; + } + // Seeding status + if (res.changes?.panAadhaarSeedingStatus) { + this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus; + } + } else { + this.panVerifyStatus = 'FAILED'; + this.showPanErrorAlert(res?.changes?.panVerificationMessage || 'PAN verification failed.'); + } + }, + error: (err: any) => { + this.panVerifying = false; + this.panVerifyStatus = 'FAILED'; + const msg = err?.error?.errors?.[0]?.defaultUserMessage + || err?.error?.defaultUserMessage + || 'PAN verification failed.'; + this.showPanErrorAlert(msg); + } + }); + }, (msg) => { this.panVerifying = false; this.showPanErrorAlert(msg); }); + } + + /** Verify Enterprise PAN (non-individual) — uses same verify-pan API */ + verifyEnterprisePan() { + if (!this.clientId) return; + const pan = this.kycForm.value.enterprisePan; + if (!pan || this.kycForm.get('enterprisePan')?.invalid) return; + + this.entPanVerifying = true; + const incorpDate = this.kycForm.value.incorporationDate; + const payload: any = { + locale: 'en', + kycType: 1, + riskCategory: this.kycForm.value.riskCategory || 1, + panNumber: pan, + dateFormat: this.settingsService.dateFormat + }; + if (incorpDate) { + payload.dateOfIncorporation = incorpDate instanceof Date + ? this.dateUtils.formatDate(incorpDate, this.settingsService.dateFormat) + : incorpDate; + } + if (this.entityName) { + payload.companyFullName = this.entityName; + } + + this.saveKycAndDo(payload, () => { + this.clientsService.verifyPAN(this.clientId, this.kycId).subscribe({ + next: (res: any) => { + this.entPanVerifying = false; + if (res?.changes?.panVerified === true) { + this.entPanVerifyStatus = 'VERIFIED'; + this.entPanVerifyName = res.changes?.panFullName || res.changes?.panHolderName || ''; + const rawDate = res.changes?.panVerifiedOn; + if (Array.isArray(rawDate)) { + this.entPanVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`; + } else { + const now = new Date(); + this.entPanVerifiedOn = rawDate || `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`; + } + } else { + this.entPanVerifyStatus = 'FAILED'; + this.showPanErrorAlert(res?.changes?.panVerificationMessage || 'Enterprise PAN verification failed.'); + } + }, + error: (err: any) => { + this.entPanVerifying = false; + this.entPanVerifyStatus = 'FAILED'; + this.showPanErrorAlert(err?.error?.errors?.[0]?.defaultUserMessage || 'Enterprise PAN verification failed.'); + } + }); + }, (msg) => { this.entPanVerifying = false; this.showPanErrorAlert(msg); }); + } + + /** Verify CIN (dummy — no real API) */ + verifyCin() { + const cin = this.kycForm.value.cinNumber; + if (!cin) return; + + this.cinVerifying = true; + this.cinProgressValue = 0; + this.cinCurrentStep = 0; + + const stepDurations = [800, 1000, 1200, 800]; + const totalDuration = stepDurations.reduce((a, b) => a + b, 0); + let elapsed = 0; + const interval = 50; + + this.cinProgressTimer = setInterval(() => { + elapsed += interval; + let accum = 0; + for (let i = 0; i < stepDurations.length; i++) { + accum += stepDurations[i]; + if (elapsed <= accum) { this.cinCurrentStep = i; break; } + } + this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 100); + + if (elapsed >= totalDuration) { + clearInterval(this.cinProgressTimer); + this.cinCurrentStep = stepDurations.length; + setTimeout(() => { + this.cinVerifying = false; + this.cinVerifyStatus = 'VERIFIED'; + this.cinCompanyName = 'Verified via MCA'; + const now = new Date(); + this.cinVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`; + }, 300); + } + }, interval); + } + + /** Send Aadhaar OTP — saves Aadhaar-only KYC data, then sends OTP */ + sendAadhaarOtp() { + if (!this.clientId) { + this.aadhaarVerifyError = 'Client must be created first. Complete General tab and click Next.'; + return; + } + const aadhaar = this.kycForm.value.aadhaarNumber; + if (!aadhaar || aadhaar.length !== 12) { this.aadhaarVerifyError = 'Enter valid 12-digit Aadhaar first.'; return; } + + this.aadhaarVerifying = true; + this.aadhaarVerifyError = ''; + + const aadhaarMasked = 'XXXXXXXX' + aadhaar.slice(-4); + const aadhaarPayload: any = { + locale: 'en', + kycType: 1, + riskCategory: this.kycForm.value.riskCategory || 1, + aadhaarNumberMasked: aadhaarMasked + }; + const panVal = this.kycForm.value.panNumber; + if (panVal) { aadhaarPayload.panNumber = panVal; } + + this.saveKycAndDo(aadhaarPayload, () => { + this.clientsService.sendAadhaarOtp(this.clientId, this.kycId, aadhaar).subscribe({ + next: (res: any) => { + this.aadhaarVerifying = false; + this.aadhaarOtpSent = true; + this.aadhaarOtpReferenceId = res?.changes?.aadhaarRefNumber || ''; + this.resetOtpDigits(); + this.startResendCountdown(); + }, + error: (err: any) => { + this.aadhaarVerifying = false; + this.aadhaarVerifyError = err?.error?.defaultUserMessage || 'Failed to send OTP.'; + } + }); + }, (msg) => { this.aadhaarVerifying = false; this.aadhaarVerifyError = msg; }); + } + + /** Verify Aadhaar OTP */ + verifyAadhaarOtp() { + if (!this.aadhaarOtp || !this.clientId || !this.kycId) return; + this.aadhaarOtpVerifying = true; + this.aadhaarVerifyError = ''; + + this.clientsService.verifyAadhaarOtp(this.clientId, this.kycId, this.aadhaarOtp, this.aadhaarOtpReferenceId).subscribe({ + next: (res: any) => { + this.aadhaarOtpVerifying = false; + if (res?.changes?.aadhaarVerified === true) { + this.aadhaarVerifyStatus = 'VERIFIED'; + this.aadhaarVerifyName = res.changes?.aadhaarHolderName || 'Aadhaar Verified'; + this.aadhaarOtpSent = false; + if (res.changes?.panAadhaarSeedingStatus) { + this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus; + } + } else { + this.aadhaarVerifyError = res?.changes?.aadhaarVerificationMessage || 'OTP verification failed'; + } + }, + error: (err: any) => { + this.aadhaarOtpVerifying = false; + this.aadhaarVerifyError = err?.error?.defaultUserMessage || 'OTP verification failed.'; + } + }); + } + + private showPanErrorAlert(message: string) { + this.panVerifyError = ''; + this.dialog.open(PanErrorDialogComponent, { + width: '480px', + data: { message } + }); + } + + getRenewalYears(): number { + switch (this.kycForm.value.riskCategory) { + case 1: return 10; + case 2: return 8; + case 3: return 2; + default: return 10; + } + } + + getKycExpiryDate(years: number): string { + const d = new Date(); + d.setFullYear(d.getFullYear() + years); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return `${String(d.getDate()).padStart(2, '0')} ${months[d.getMonth()]} ${d.getFullYear()}`; + } + + async onPhotoSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (!input.files?.length) return; + const file = input.files[0]; + input.value = ''; + const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component'); + const dialogRef = this.dialog.open(ImageCropDialogComponent, { + width: '680px', maxWidth: '95vw', + data: { file, title: 'Crop Client Photograph', aspectRatio: 1, maxWidth: 300, maxHeight: 300 } + }); + dialogRef.afterClosed().subscribe((result: any) => { + if (result) { this.photographFile = result.croppedFile; this.photographPreview = result.previewUrl; } + }); + } + + async onSignatureSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (!input.files?.length) return; + const file = input.files[0]; + input.value = ''; + const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component'); + const dialogRef = this.dialog.open(ImageCropDialogComponent, { + width: '680px', maxWidth: '95vw', + data: { file, title: 'Crop Specimen Signature', aspectRatio: 2.5, maxWidth: 400, maxHeight: 160 } + }); + dialogRef.afterClosed().subscribe((result: any) => { + if (result) { this.signatureFile = result.croppedFile; this.signaturePreview = result.previewUrl; } + }); + } + + removePhoto() { this.photographFile = null; this.photographPreview = null; } + removeSignature() { this.signatureFile = null; this.signaturePreview = null; } + + get kycData(): any { + const formVal = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value; + const aadhaarRaw = formVal.aadhaarNumber || ''; const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null; + const dateFormat = this.settingsService.dateFormat; + const v = formVal; + const formatDate = (d: any) => d instanceof Date ? this.dateUtils.formatDate(d, dateFormat) : (d || null); return { locale: 'en', + dateFormat, kycType: 1, - riskCategory: 1, - panNumber: this.kycForm.value.panNumber || null, + riskCategory: v.riskCategory || 1, + panNumber: v.hasPan ? (v.panNumber || null) : null, aadhaarNumberMasked: aadhaarMasked, - gstinNumber: this.kycForm.value.gstinNumber || null, - remarks: this.kycForm.value.remarks || null + panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null, + gstinNumber: v.gstinNumber || null, + enterprisePan: v.enterprisePan || null, + cinNumber: v.cinNumber || null, + incorporationDate: formatDate(v.incorporationDate), + // KYC Document + kycDocumentName: v.kycDocumentName || null, + kycDocumentNumber: v.kycDocumentNumber || null, + kycDocIssueDate: formatDate(v.kycDocIssueDate), + kycDocExpiryDate: formatDate(v.kycDocExpiryDate), + // AML & Compliance + amlCheckStatus: v.amlCheckStatus || null, + bannedEntityMatch: v.bannedEntityMatch || null, + willfulDefaulter: v.willfulDefaulter || null, + ecgcSalFlag: v.ecgcSalFlag || null, + dormantFlag: v.dormantFlag || null, + // Freeze / Debit Controls + accountStatus: v.accountStatus || null, + freezeReason: v.freezeReason || null, + freezeAuthority: v.freezeAuthority || null, + // Form 60 / Form 15 + form60Type: v.form60Type || null, + form60Date: formatDate(v.form60Date), + form15Applicable: v.form15Applicable || false, + form15Type: v.form15Type || null, + form15FY: v.form15FY || null, + remarks: v.remarks || null }; } - /** Returns true if any KYC data has been entered */ get hasKycData(): boolean { - const v = this.kycForm.value; - return !!(v.panNumber || v.aadhaarNumber || v.gstinNumber); + const v = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value; + return !!(v.panNumber || v.aadhaarNumber || v.gstinNumber || v.enterprisePan || v.cinNumber || v.kycDocumentName || v.form15Applicable); } } + +/** + * PAN Error Alert Dialog + */ +@Component({ + selector: 'mifosx-pan-error-dialog', + template: ` +
+
+ error + PAN Verification Failed +
+ +

{{ data.message }}

+
+ + + +
+ `, + styles: [` + .pan-err-dialog { max-width: 460px; } + .pan-err-header { + display: flex; align-items: center; gap: 10px; + padding: 16px 24px 12px; border-bottom: 1px solid #fecaca; + background: #fef2f2; font-size: 16px; font-weight: 600; color: #991b1b; + } + .pan-err-icon { color: #dc2626; } + .pan-err-body { padding: 18px 24px !important; font-size: 14px; line-height: 1.6; color: #475569; } + .pan-err-actions { padding: 10px 24px 16px !important; margin: 0 !important; } + `], + imports: [MatIcon, MatDialogContent, MatDialogActions, STANDALONE_SHARED_IMPORTS], +}) +export class PanErrorDialogComponent { + constructor( + @Inject(MAT_DIALOG_DATA) public data: { message: string } + ) {} +} + diff --git a/src/app/clients/client-stepper/client-kyc-step/image-crop-dialog/image-crop-dialog.component.html b/src/app/clients/client-stepper/client-kyc-step/image-crop-dialog/image-crop-dialog.component.html new file mode 100644 index 000000000..80d2260ba --- /dev/null +++ b/src/app/clients/client-stepper/client-kyc-step/image-crop-dialog/image-crop-dialog.component.html @@ -0,0 +1,49 @@ + + +
+
+
+ + {{ data.title }} +
+ +
+ + + @if (!imageLoaded) { +
Loading image...
+ } + +
+
+ + +
Drag to move. Drag corner to resize.
+
+ +
+ Preview +
+ +
+ {{ data.maxWidth }}x{{ data.maxHeight }}px +
+
+
+ + + + + +
diff --git a/src/app/clients/client-stepper/client-kyc-step/image-crop-dialog/image-crop-dialog.component.scss b/src/app/clients/client-stepper/client-kyc-step/image-crop-dialog/image-crop-dialog.component.scss new file mode 100644 index 000000000..b856b0a44 --- /dev/null +++ b/src/app/clients/client-stepper/client-kyc-step/image-crop-dialog/image-crop-dialog.component.scss @@ -0,0 +1,176 @@ +/** + * Copyright since 2025 Mifos Initiative + */ + +$blue: #1353a0; +$navy: #0f172a; + +.crop-dialog { + width: 620px; + max-width: 95vw; +} + +.crop-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px 12px; + border-bottom: 1px solid #e2e8f0; + + &__left { + display: flex; + align-items: center; + gap: 10px; + } + + &__icon { + font-size: 16px; + color: $blue; + } + + &__title { + font-size: 16px; + font-weight: 600; + color: $navy; + } + + &__close { + width: 30px; + height: 30px; + border: none; + background: #f1f5f9; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #64748b; + transition: all 0.15s; + + &:hover { + background: #e2e8f0; + color: $navy; + } + } +} + +.crop-body { + padding: 20px 24px !important; + overflow: visible !important; + max-height: 70vh !important; +} + +.crop-loading { + text-align: center; + padding: 40px; + color: #94a3b8; + font-size: 14px; +} + +.hidden { + display: none !important; +} + +.crop-workspace { + display: flex; + gap: 20px; + align-items: flex-start; +} + +.crop-source { + flex: 1; + min-width: 0; + + canvas { + cursor: crosshair; + border: 1px solid #e2e8f0; + border-radius: 8px; + display: block; + max-width: 100%; + } +} + +.crop-hint { + font-size: 11px; + color: #94a3b8; + margin-top: 6px; + text-align: center; +} + +.crop-preview-panel { + width: 160px; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.crop-preview-label { + font-size: 11px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.crop-preview-box { + width: 150px; + height: 150px; + border: 2px solid $blue; + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: #f8fafc; + + canvas { + display: block; + max-width: 100%; + max-height: 100%; + } +} + +.crop-preview-size { + font-size: 10px; + color: #94a3b8; + font-family: 'Roboto Mono', monospace; +} + +.crop-actions { + padding: 12px 24px 16px !important; + border-top: 1px solid #e2e8f0; + margin: 0 !important; + display: flex !important; + justify-content: flex-end !important; + gap: 10px !important; + + button { + border-radius: 8px !important; + font-size: 13px !important; + font-weight: 600 !important; + } + + &__icon { + margin-right: 6px; + font-size: 12px; + } +} + +@media (max-width: 600px) { + .crop-workspace { + flex-direction: column; + } + + .crop-preview-panel { + width: 100%; + flex-direction: row; + justify-content: center; + } + + .crop-preview-box { + width: 100px; + height: 100px; + } +} diff --git a/src/app/clients/client-stepper/client-kyc-step/image-crop-dialog/image-crop-dialog.component.ts b/src/app/clients/client-stepper/client-kyc-step/image-crop-dialog/image-crop-dialog.component.ts new file mode 100644 index 000000000..4192ddbdf --- /dev/null +++ b/src/app/clients/client-stepper/client-kyc-step/image-crop-dialog/image-crop-dialog.component.ts @@ -0,0 +1,244 @@ +/** + * Copyright since 2025 Mifos Initiative + */ + +import { Component, Inject, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef, MatDialogContent, MatDialogActions } from '@angular/material/dialog'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +export interface ImageCropData { + file: File; + title: string; + aspectRatio: number; // width/height, e.g. 1 for square, 1.5 for 3:2 + maxWidth: number; + maxHeight: number; +} + +@Component({ + selector: 'mifosx-image-crop-dialog', + templateUrl: './image-crop-dialog.component.html', + styleUrls: ['./image-crop-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatDialogContent, + MatDialogActions + ] +}) +export class ImageCropDialogComponent implements AfterViewInit { + @ViewChild('sourceCanvas') sourceCanvas!: ElementRef; + @ViewChild('previewCanvas') previewCanvas!: ElementRef; + + imageLoaded = false; + private img = new Image(); + private imgWidth = 0; + private imgHeight = 0; + + // Crop box (relative to displayed image) + cropX = 0; + cropY = 0; + cropW = 200; + cropH = 200; + + // Drag state + private dragging = false; + private dragStartX = 0; + private dragStartY = 0; + private dragStartCropX = 0; + private dragStartCropY = 0; + + // Resize state + private resizing = false; + + // Canvas display dimensions + private canvasDisplayW = 0; + private canvasDisplayH = 0; + private scaleX = 1; + private scaleY = 1; + + zoom = 1; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ImageCropData + ) {} + + ngAfterViewInit() { + const reader = new FileReader(); + reader.onload = (e: any) => { + this.img.onload = () => { + this.imageLoaded = true; + setTimeout(() => this.initCanvas()); + }; + this.img.src = e.target.result; + }; + reader.readAsDataURL(this.data.file); + } + + private initCanvas() { + const canvas = this.sourceCanvas.nativeElement; + const ctx = canvas.getContext('2d')!; + const container = canvas.parentElement!; + + const maxW = container.clientWidth || 500; + const maxH = 400; + + // Scale image to fit container + const ratio = Math.min(maxW / this.img.width, maxH / this.img.height, 1); + this.canvasDisplayW = Math.floor(this.img.width * ratio); + this.canvasDisplayH = Math.floor(this.img.height * ratio); + this.imgWidth = this.img.width; + this.imgHeight = this.img.height; + + canvas.width = this.canvasDisplayW; + canvas.height = this.canvasDisplayH; + this.scaleX = this.img.width / this.canvasDisplayW; + this.scaleY = this.img.height / this.canvasDisplayH; + + // Init crop box centered + const cropSize = Math.min(this.canvasDisplayW, this.canvasDisplayH) * 0.6; + if (this.data.aspectRatio >= 1) { + this.cropW = cropSize; + this.cropH = cropSize / this.data.aspectRatio; + } else { + this.cropH = cropSize; + this.cropW = cropSize * this.data.aspectRatio; + } + this.cropX = (this.canvasDisplayW - this.cropW) / 2; + this.cropY = (this.canvasDisplayH - this.cropH) / 2; + + this.drawCanvas(); + this.updatePreview(); + } + + private drawCanvas() { + const canvas = this.sourceCanvas.nativeElement; + const ctx = canvas.getContext('2d')!; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(this.img, 0, 0, canvas.width, canvas.height); + + // Dark overlay outside crop + ctx.fillStyle = 'rgba(0,0,0,0.5)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Clear crop area + ctx.clearRect(this.cropX, this.cropY, this.cropW, this.cropH); + ctx.drawImage( + this.img, + this.cropX * this.scaleX, this.cropY * this.scaleY, + this.cropW * this.scaleX, this.cropH * this.scaleY, + this.cropX, this.cropY, this.cropW, this.cropH + ); + + // Crop border + ctx.strokeStyle = '#1353a0'; + ctx.lineWidth = 2; + ctx.strokeRect(this.cropX, this.cropY, this.cropW, this.cropH); + + // Corner handles + const hs = 8; + ctx.fillStyle = '#1353a0'; + const corners = [ + [this.cropX, this.cropY], + [this.cropX + this.cropW, this.cropY], + [this.cropX, this.cropY + this.cropH], + [this.cropX + this.cropW, this.cropY + this.cropH] + ]; + corners.forEach(([x, y]) => { + ctx.fillRect(x - hs / 2, y - hs / 2, hs, hs); + }); + } + + private updatePreview() { + if (!this.previewCanvas) return; + const pCanvas = this.previewCanvas.nativeElement; + const pCtx = pCanvas.getContext('2d')!; + const pw = this.data.maxWidth || 150; + const ph = this.data.maxHeight || 150; + pCanvas.width = pw; + pCanvas.height = ph; + + pCtx.clearRect(0, 0, pw, ph); + pCtx.drawImage( + this.img, + this.cropX * this.scaleX, this.cropY * this.scaleY, + this.cropW * this.scaleX, this.cropH * this.scaleY, + 0, 0, pw, ph + ); + } + + onMouseDown(event: MouseEvent) { + const rect = this.sourceCanvas.nativeElement.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + // Check if near bottom-right corner for resize + const cornerX = this.cropX + this.cropW; + const cornerY = this.cropY + this.cropH; + if (Math.abs(x - cornerX) < 12 && Math.abs(y - cornerY) < 12) { + this.resizing = true; + return; + } + + // Check if inside crop box for drag + if (x >= this.cropX && x <= this.cropX + this.cropW && y >= this.cropY && y <= this.cropY + this.cropH) { + this.dragging = true; + this.dragStartX = x; + this.dragStartY = y; + this.dragStartCropX = this.cropX; + this.dragStartCropY = this.cropY; + } + } + + onMouseMove(event: MouseEvent) { + if (!this.dragging && !this.resizing) return; + const rect = this.sourceCanvas.nativeElement.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + if (this.dragging) { + this.cropX = Math.max(0, Math.min(this.canvasDisplayW - this.cropW, this.dragStartCropX + (x - this.dragStartX))); + this.cropY = Math.max(0, Math.min(this.canvasDisplayH - this.cropH, this.dragStartCropY + (y - this.dragStartY))); + } + + if (this.resizing) { + const newW = Math.max(40, Math.min(this.canvasDisplayW - this.cropX, x - this.cropX)); + const newH = newW / this.data.aspectRatio; + if (this.cropY + newH <= this.canvasDisplayH) { + this.cropW = newW; + this.cropH = newH; + } + } + + this.drawCanvas(); + this.updatePreview(); + } + + onMouseUp() { + this.dragging = false; + this.resizing = false; + } + + confirm() { + // Create cropped image as blob + const offscreen = document.createElement('canvas'); + offscreen.width = this.data.maxWidth || 300; + offscreen.height = this.data.maxHeight || 300; + const ctx = offscreen.getContext('2d')!; + ctx.drawImage( + this.img, + this.cropX * this.scaleX, this.cropY * this.scaleY, + this.cropW * this.scaleX, this.cropH * this.scaleY, + 0, 0, offscreen.width, offscreen.height + ); + + offscreen.toBlob((blob) => { + if (blob) { + const croppedFile = new File([blob], this.data.file.name, { type: 'image/png' }); + this.dialogRef.close({ croppedFile, previewUrl: offscreen.toDataURL('image/png') }); + } + }, 'image/png'); + } +} diff --git a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html index 81976f6e2..5299d941b 100644 --- a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html +++ b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html @@ -7,6 +7,16 @@ -->
+
+
+ +
+

Preview & Submit

+

Review all details before submitting the client registration.

+
+
+
+
@@ -30,7 +40,7 @@ } @if (client?.legalFormId) { - {{ client.legalFormId | find: clientTemplate.clientLegalFormOptions : 'id' : 'value' }} + {{ client.legalFormId === 1 ? 'Individual' : 'Non-Individual' }} } @if (client?.active) { @@ -42,208 +52,799 @@
- +
- {{ 'labels.heading.General' | translate }} + Personal Information
- - - @if (client?.dateOfBirth) { - - - - +
+ @if (client?.legalFormId === LegalFormId.PERSON) { + @if (client?.firstname) { +
+ First Name + {{ client.firstname }} +
} - @if (client?.externalId) { -
- - - + @if (client?.middlename) { +
+ Middle Name + {{ client.middlename }} +
} - @if (client?.staffId) { - - - - + @if (client?.lastname) { +
+ Last Name + {{ client.lastname }} +
} - @if (client?.legalFormId === LegalFormId.PERSON && client?.genderId) { - - - - - } - @if (client?.mobileNo) { - - - - - } - @if (client?.emailAddress) { - - - - - } - @if (client?.clientTypeId) { - - - - - } - @if (client?.clientClassificationId) { - - - - - } - @if (client?.occupationCategoryId) { - - - - - } - @if (client?.occupationId) { - - - - - } - @if (client?.submittedOnDate) { - - - - - } - - - - - @if (client?.activationDate) { - - - - - } - @if (client?.savingsProductId) { - - - - - } - @if (client?.legalFormId === LegalFormId.PERSON) { - - - - - } - @if (client?.legalFormId === LegalFormId.ENTITY && client?.clientNonPersonDetails) { - @if (client.clientNonPersonDetails.constitutionId) { - - - - - } - - - - - } - -
{{ client.legalFormId === LegalFormId.PERSON ? 'Date of Birth' : 'Incorporation Date' }}{{ client.dateOfBirth | dateFormat }}
{{ 'labels.inputs.External Id' | translate }} - -
{{ 'labels.inputs.Staff' | translate }}{{ client.staffId | find: clientTemplate.staffOptions : 'id' : 'displayName' }}
{{ 'labels.inputs.Gender' | translate }}{{ client.genderId | find: clientTemplate.genderOptions : 'id' : 'name' }}
{{ 'labels.inputs.Mobile No' | translate }}{{ client.mobileNo }}
{{ 'labels.inputs.Email Address' | translate }}{{ client.emailAddress }}
{{ 'labels.inputs.Client Type' | translate }}{{ client.clientTypeId | find: clientTemplate.clientTypeOptions : 'id' : 'name' }}
{{ 'labels.inputs.Client Classification' | translate }}{{ client.clientClassificationId | find: clientTemplate.clientClassificationOptions : 'id' : 'name' }}
{{ 'labels.inputs.Occupation Category' | translate }}{{ client.occupationCategoryId | find: clientTemplate.occupationCategoryOptions : 'id' : 'name' }}
{{ 'labels.inputs.Occupation' | translate }}{{ client.occupationId | find: clientTemplate.occupationOptions : 'id' : 'name' }}
{{ 'labels.inputs.Submitted On Date' | translate }}{{ client.submittedOnDate | dateFormat }}
{{ 'labels.inputs.Active' | translate }}? - {{ client?.active | yesNo }} -
{{ 'labels.inputs.Activation Date' | translate }}{{ client.activationDate | dateFormat }}
{{ 'labels.inputs.Savings Product' | translate }}{{ client.savingsProductId | find: clientTemplate.savingProductOptions : 'id' : 'name' }}
{{ 'labels.inputs.Is staff' | translate }}?{{ client.isStaff | yesNo }}
{{ 'labels.inputs.Constitution' | translate }}{{ client.clientNonPersonDetails.constitutionId | find: clientTemplate.clientNonPersonConstitutionOptions : 'id' : 'name' }}
{{ 'labels.inputs.Incorporation Number' | translate }}{{ client.clientNonPersonDetails.incorpNumber || 'N/A' }}
+ } + @if (client?.legalFormId === LegalFormId.ENTITY && client?.fullname) { +
+ Entity Name + {{ client.fullname }} +
+ } + @if (client?.dateOfBirth) { +
+ {{ client.legalFormId === LegalFormId.PERSON ? 'Date of Birth' : 'Incorporation Date' }} + {{ client.dateOfBirth | dateFormat }} +
+ } + @if (client?.legalFormId === LegalFormId.PERSON && client?.genderId) { +
+ Gender + {{ client.genderId | find: clientTemplate.genderOptions : 'id' : 'name' }} +
+ } + @if (client?.legalFormId) { +
+ Legal Form + {{ client.legalFormId === 1 ? 'Individual' : 'Non-Individual' }} +
+ } + @if (client?.officeId) { +
+ Office + {{ client.officeId | find: clientTemplate.officeOptions : 'id' : 'name' }} +
+ } + @if (client?.staffId) { +
+ Staff + {{ client.staffId | find: clientTemplate.staffOptions : 'id' : 'displayName' }} +
+ } + @if (client?.legalFormId === LegalFormId.PERSON) { +
+ Is Staff? + {{ client.isStaff | yesNo }} +
+ } + @if (client?.nationalityId) { +
+ Nationality + {{ client.nationalityId }} +
+ } + @if (client?.maritalStatusId) { +
+ Marital Status + {{ client.maritalStatusId | find: maritalStatusOptions : 'id' : 'name' }} +
+ } + @if (client?.salutationId) { +
+ Salutation + {{ client.salutationId | find: clientTemplate.salutationOptions : 'id' : 'name' }} +
+ } + @if (client?.ageYears != null) { +
+ Age + {{ client.ageYears }}y {{ client.ageMonths }}m {{ client.ageDays }}d +
+ } + @if (client?.isMinor != null) { +
+ Minor + + + {{ client.isMinor ? 'Yes (Age < 10)' : 'No' }} + + +
+ } +
- - @if (kycData?.panNumber || kycData?.aadhaarNumberMasked || kycData?.gstinNumber) { + + @if (client?.legalFormId === LegalFormId.ENTITY && client?.clientNonPersonDetails) { +
+
+ + Entity Details +
+
+
+ @if (client.clientNonPersonDetails.constitutionId) { +
+ Constitution + {{ client.clientNonPersonDetails.constitutionId | find: clientTemplate.clientNonPersonConstitutionOptions : 'id' : 'name' }} +
+ } + @if (client.clientNonPersonDetails.mainBusinessLineId) { +
+ Main Business Line + {{ client.clientNonPersonDetails.mainBusinessLineId | find: clientTemplate.clientNonPersonMainBusinessLineOptions : 'id' : 'name' }} +
+ } + @if (client.clientNonPersonDetails.incorpNumber) { +
+ Incorporation No + {{ client.clientNonPersonDetails.incorpNumber }} +
+ } + @if (client.clientNonPersonDetails.remarks) { +
+ Remarks + {{ client.clientNonPersonDetails.remarks }} +
+ } +
+
+
+ } + + +
+
+ + Contact & DIP Verification +
+
+
+ @if (client?.mobileNo) { +
+ Mobile No + {{ client.mobileNo }} +
+ } +
+ DIP Verification + + + {{ client?.dipVerified ? 'Verified' : 'Pending' }} + + +
+ @if (client?.dipLookupLogId) { +
+ DIP Lookup Log ID + {{ client.dipLookupLogId }} +
+ } + @if (client?.alternateMobileNo) { +
+ Alternate Mobile No + {{ client.alternateMobileNo }} +
+ } + @if (client?.alternateMobileNo) { +
+ Alternate DIP Verification + + + {{ client?.alternateDipVerified ? 'Verified' : 'Pending' }} + + +
+ } + @if (client?.alternateDipLookupLogId) { +
+ Alternate DIP Lookup Log ID + {{ client.alternateDipLookupLogId }} +
+ } + @if (client?.emailAddress) { +
+ Email Address + {{ client.emailAddress }} +
+ } +
+ Name Verification (Sanctions) + + + {{ client?.nameVerified ? 'Verified — Clear' : (client?.nameVerified === false ? 'Sanctions Match' : 'Pending') }} + + +
+ @if (client?.screeningLogId) { +
+ Screening Log ID + {{ client.screeningLogId }} +
+ } +
+
+
+ + + @if (client?.legalFormId !== 2) { +
+
+ + Financial & Classification +
+
+
+ @if (client?.occupationCategoryId) { +
+ Occupation Category + {{ client.occupationCategoryId | find: clientTemplate.occupationCategoryOptions : 'id' : 'name' }} +
+ } + @if (client?.occupationId) { +
+ Occupation + {{ client.occupationId | find: clientTemplate.occupationOptions : 'id' : 'name' }} +
+ } + @if (client?.clientTypeId) { +
+ Client Type + {{ client.clientTypeId | find: clientTemplate.clientTypeOptions : 'id' : 'name' }} +
+ } + @if (client?.clientClassificationId) { +
+ Client Classification + {{ client.clientClassificationId | find: clientTemplate.clientClassificationOptions : 'id' : 'name' }} +
+ } + @if (client?.monthlyIncome) { +
+ Monthly Income + ₹ {{ client.monthlyIncome | number }} +
+ } + @if (client?.annualTurnover) { +
+ Annual Turnover + ₹ {{ client.annualTurnover | number }} +
+ } + @if (!client?.occupationCategoryId && !client?.occupationId && !client?.clientTypeId && !client?.clientClassificationId && !client?.monthlyIncome && !client?.annualTurnover) { +
+ No classification data provided +
+ } +
+
+
+ } + + + @if (kycData?.panNumber || kycData?.aadhaarNumberMasked || kycData?.gstinNumber || kycData?.enterprisePan || kycData?.cinNumber) {
- {{ 'labels.inputs.KYC' | translate }} + KYC Details KYC
- - - @if (kycData.panNumber) { - - - - - } - @if (kycData.aadhaarNumberMasked) { - - - - - } - @if (kycData.gstinNumber) { - - - - - } - @if (kycData.remarks) { - - - - - } - -
{{ 'labels.inputs.PAN Number' | translate }}{{ kycData.panNumber }}
{{ 'labels.inputs.Aadhaar Number' | translate }}{{ kycData.aadhaarNumberMasked }}
{{ 'labels.inputs.GSTIN Number' | translate }}{{ kycData.gstinNumber }}
{{ 'labels.inputs.Remarks' | translate }}{{ kycData.remarks }}
+
+ @if (kycData.panNumber) { +
+ PAN Number + {{ kycData.panNumber }} +
+ } + @if (kycData.aadhaarNumberMasked) { +
+ Aadhaar Number + {{ kycData.aadhaarNumberMasked }} +
+ } + @if (kycData.panAadhaarSeedingStatus) { +
+ PAN–Aadhaar Link + + @if (kycData.panAadhaarSeedingStatus === 'Y') { + Linked + } @else if (kycData.panAadhaarSeedingStatus === 'N') { + Not Linked + } @else { + {{ kycData.panAadhaarSeedingStatus }} + } + +
+ } + @if (kycData.enterprisePan) { +
+ Enterprise PAN + {{ kycData.enterprisePan }} +
+ } + @if (kycData.cinNumber) { +
+ CIN (Corporate Identity Number) + {{ kycData.cinNumber }} +
+ } + @if (kycData.incorporationDate) { +
+ Incorporation Date + {{ kycData.incorporationDate | dateFormat }} +
+ } + @if (kycData.gstinNumber) { +
+ GSTIN Number + {{ kycData.gstinNumber }} +
+ } + @if (kycData.riskCategory) { +
+ Risk Category + + @if (kycData.riskCategory === 1) { Low Risk } + @else if (kycData.riskCategory === 2) { Medium Risk } + @else if (kycData.riskCategory === 3) { High Risk } + +
+ } + @if (kycData.remarks) { +
+ Remarks + {{ kycData.remarks }} +
+ } +
} - - @if (client?.familyMembers?.length) { + + @if (guardian) {
- - {{ 'labels.heading.Family Members' | translate }} / {{ 'labels.inputs.Nominee' | translate }} - {{ client.familyMembers.length }} + + Guardian Details + Minor
-
- - - - - - - - - - - @for (member of client.familyMembers; track member) { - - - - - - - } - -
{{ 'labels.inputs.name' | translate }}{{ 'labels.inputs.Relationship' | translate }}{{ 'labels.inputs.Date Of Birth' | translate }}{{ 'labels.inputs.Is Dependent' | translate }}
{{ member.firstName }} {{ member.lastName }}{{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }}{{ member.dateOfBirth | dateFormat }} - {{ member.isDependent ? 'Yes' : 'No' }} -
+
+
+
+ Guardian Name + {{ guardian.displayName }} +
+
+ Client ID + {{ guardian.id }} +
+
+ Account No + {{ guardian.accountNumber || '—' }} +
+
+ Relationship + {{ guardianRelation || '—' }} +
+ @if (guardian.officeName) { +
+ Office + {{ guardian.officeName }} +
+ } + @if (guardian.mobileNo) { +
+ Mobile + {{ guardian.mobileNo }} +
+ } + @if (guardian.status?.value) { +
+ Status + + + {{ guardian.status.value }} + + +
+ } +
} - -
- - + + @if (addressData) { +
+
+ + Address Details +
+
+ @for (tab of addressTabs; track tab) { + @if (addressData[tab]?.addressLine1) { +
+
{{ addressTabLabels[tab] }}
+
+
+ Address Line 1 + {{ addressData[tab].addressLine1 }} +
+ @if (addressData[tab].addressLine2) { +
+ Address Line 2 + {{ addressData[tab].addressLine2 }} +
+ } + @if (addressData[tab].landmark) { +
+ Landmark + {{ addressData[tab].landmark }} +
+ } + @if (addressData[tab].pinCode) { +
+ PIN Code + {{ addressData[tab].pinCode }} +
+ } + @if (addressData[tab].locality) { +
+ Locality + {{ addressData[tab].locality }} +
+ } + @if (addressData[tab].city) { +
+ City + {{ addressData[tab].city }} +
+ } + @if (addressData[tab].district) { +
+ District + {{ addressData[tab].district }} +
+ } + @if (addressData[tab].state) { +
+ State + {{ addressData[tab].state }} +
+ } +
+
+ } + } + @if (!addressData.permanent?.addressLine1 && !addressData.current?.addressLine1 && !addressData.office?.addressLine1 && !addressData.correspondence?.addressLine1) { +
+ No address data provided +
+ } +
+
+ } + + + @if (client?.submittedOnDate) { +
+
+ + Submission +
+
+
+
+ Submitted On + {{ client.submittedOnDate | dateFormat }} +
+
+
+
+ } + + + @if (entityClassificationData) { + @if (entityClassificationData.msme?.enabled) { +
+
+ + MSME Classification + Enabled +
+
+
+ @if (entityClassificationData.msme.udyamRegistrationNumber) { +
Udyam Registration No.{{ entityClassificationData.msme.udyamRegistrationNumber }}
+ } + @if (entityClassificationData.msme.msmeClassification) { +
MSME Classification{{ entityClassificationData.msme.msmeClassification }}
+ } + @if (entityClassificationData.msme.enterpriseType) { +
Enterprise Type{{ entityClassificationData.msme.enterpriseType }}
+ } + @if (entityClassificationData.msme.nicActivityCode) { +
NIC Activity Code{{ entityClassificationData.msme.nicActivityCode }}
+ } + @if (entityClassificationData.msme.annualTurnover) { +
Annual Turnover₹ {{ entityClassificationData.msme.annualTurnover }} Cr
+ } + @if (entityClassificationData.msme.investmentPlantMachinery) { +
Investment (Plant & Machinery)₹ {{ entityClassificationData.msme.investmentPlantMachinery }} Cr
+ } + @if (entityClassificationData.msme.financialYearAssessment) { +
FY of Assessment{{ entityClassificationData.msme.financialYearAssessment }}
+ } + @if (entityClassificationData.msme.socialCategoryOwner) { +
Social Category{{ entityClassificationData.msme.socialCategoryOwner }}
+ } + @if (entityClassificationData.msme.genderPrimaryOwner) { +
Gender of Owner{{ entityClassificationData.msme.genderPrimaryOwner }}
+ } + @if (entityClassificationData.msme.noOfEmployees) { +
No. of Employees{{ entityClassificationData.msme.noOfEmployees }}
+ } + @if (entityClassificationData.msme.womenLedEnterprise) { +
Women-led EnterpriseYes
+ } + @if (entityClassificationData.msme.divyangPwdOwned) { +
Divyang (PWD) OwnedYes
+ } +
+
+
+ } + + @if (entityClassificationData.startup?.enabled) { +
+
+ + Startup India (DPIIT) + Enabled +
+
+
+ @if (entityClassificationData.startup.dpiitRecognitionNumber) { +
DPIIT Recognition No.{{ entityClassificationData.startup.dpiitRecognitionNumber }}
+ } + @if (entityClassificationData.startup.entityStage) { +
Entity Stage{{ entityClassificationData.startup.entityStage }}
+ } + @if (entityClassificationData.startup.sectorAsDpiit) { +
Sector (DPIIT){{ entityClassificationData.startup.sectorAsDpiit }}
+ } + @if (entityClassificationData.startup.taxExemption80iac) { +
80IAC Tax ExemptionYes
+ } + @if (entityClassificationData.startup.pslLoanLimit) { +
PSL Loan Limit₹ {{ entityClassificationData.startup.pslLoanLimit | number }} Cr
+ } +
+
+
+ } + + @if (entityClassificationData.mudra?.enabled) { +
+
+ + Mudra Borrower (PMMY) + Enabled +
+
+
+ @if (entityClassificationData.mudra.mudraCategory) { +
Mudra Category{{ entityClassificationData.mudra.mudraCategory }}
+ } + @if (entityClassificationData.mudra.mudraLoanAccountNo) { +
Loan Account No.{{ entityClassificationData.mudra.mudraLoanAccountNo }}
+ } + @if (entityClassificationData.mudra.sanctionedAmount) { +
Sanctioned Amount₹ {{ entityClassificationData.mudra.sanctionedAmount | number }}
+ } + @if (entityClassificationData.mudra.lendingInstitutionName) { +
Lending Institution{{ entityClassificationData.mudra.lendingInstitutionName }}
+ } + @if (entityClassificationData.mudra.repaymentStatus) { +
Repayment Status{{ entityClassificationData.mudra.repaymentStatus }}
+ } + @if (entityClassificationData.mudra.npaFlag) { +
NPA FlagNPA
+ } +
+
+
+ } + + @if (entityClassificationData.eou?.enabled) { +
+
+ + Export Oriented Unit (EOU) + Enabled +
+
+
+ @if (entityClassificationData.eou.eouRegistrationNumber) { +
EOU Registration No.{{ entityClassificationData.eou.eouRegistrationNumber }}
+ } + @if (entityClassificationData.eou.registeredDcZone) { +
Registered DC Zone{{ entityClassificationData.eou.registeredDcZone }}
+ } + @if (entityClassificationData.eou.gstinEou) { +
GSTIN (EOU){{ entityClassificationData.eou.gstinEou }}
+ } + @if (entityClassificationData.eou.annualExportObligation) { +
Export Obligation₹ {{ entityClassificationData.eou.annualExportObligation | number }} Cr
+ } + @if (entityClassificationData.eou.adCategoryBankCode) { +
AD Bank Code{{ entityClassificationData.eou.adCategoryBankCode }}
+ } +
+
+
+ } + + @if (entityClassificationData.importexport?.enabled) { +
+
+ + Import Export (IEC/DGFT) + Enabled +
+
+
+ @if (entityClassificationData.importexport.iecCode) { +
IEC Code{{ entityClassificationData.importexport.iecCode }}
+ } + @if (entityClassificationData.importexport.iecStatus) { +
IEC Status{{ entityClassificationData.importexport.iecStatus }}
+ } + @if (entityClassificationData.importexport.tradeType) { +
Trade Type{{ entityClassificationData.importexport.tradeType }}
+ } + @if (entityClassificationData.importexport.primaryHsCode) { +
Primary HS Code{{ entityClassificationData.importexport.primaryHsCode }}
+ } + @if (entityClassificationData.importexport.eefcAccountRequired) { +
EEFC AccountRequired
+ } + @if (entityClassificationData.importexport.femaDeclarationObtained) { +
FEMA DeclarationObtained
+ } +
+
+
+ } + + @if (entityClassificationData.psl?.enabled) { +
+
+ + Priority Sector (PSL) + Enabled +
+
+
+ @if (entityClassificationData.psl.pslCategory) { +
PSL Category{{ entityClassificationData.psl.pslCategory }}
+ } + @if (entityClassificationData.psl.pslSubCategory) { +
PSL Sub-category{{ entityClassificationData.psl.pslSubCategory }}
+ } + @if (entityClassificationData.psl.pslSanctionedLimit) { +
Sanctioned Limit₹ {{ entityClassificationData.psl.pslSanctionedLimit | number }}
+ } + @if (entityClassificationData.psl.fiddReportingCode) { +
FIDD Reporting Code{{ entityClassificationData.psl.fiddReportingCode }}
+ } + @if (entityClassificationData.psl.weakerSectionFlag) { +
Weaker SectionYes
+ } + @if (entityClassificationData.psl.womenEntrepreneurFlag) { +
Women EntrepreneurYes
+ } +
+
+
+ } + + @if (entityClassificationData.nps?.enabled) { +
+
+ + Non-Priority Sector + Enabled +
+
+
+ @if (entityClassificationData.nps.industrySector) { +
Industry Sector{{ entityClassificationData.nps.industrySector }}
+ } + @if (entityClassificationData.nps.nicCode) { +
NIC Code{{ entityClassificationData.nps.nicCode }}
+ } + @if (entityClassificationData.nps.annualRevenueBand) { +
Annual Revenue Band{{ entityClassificationData.nps.annualRevenueBand }}
+ } + @if (entityClassificationData.nps.riskCategory) { +
Risk Category{{ entityClassificationData.nps.riskCategory }}
+ } + @if (entityClassificationData.nps.amlRiskClassification) { +
AML Risk{{ entityClassificationData.nps.amlRiskClassification }}
+ } + @if (entityClassificationData.nps.pepFlag) { +
PEP FlagPEP
+ } + @if (entityClassificationData.nps.amlWatchlistMatch) { +
AML WatchlistMatch Found
+ } +
+
+
+ } + } + + + + @if (client?.legalFormId !== 2) { +
+
+ + Family Members + @if (familyMembers.length > 0) { + {{ familyMembers.length }} + } +
+
+ @if (familyMembers.length === 0) { +
+ warning + No family members added. At least one is required. +
+ } @else { +
+ @for (member of familyMembers; track member; let i = $index) { +
+
{{ i + 1 }}
+
+ person +
+
+ {{ member.firstName || member.displayName }} + + @if (member.relationship) { {{ member.relationship }} } + @if (member.dateOfBirth) { · DOB: {{ member.dateOfBirth }} } + +
+ @if (member.aadhaarNumber || member.panNumber) { +
+ @if (member.panNumber) { PAN: {{ member.panNumber }} } + @if (member.aadhaarNumber) { Aadhaar: {{ member.aadhaarNumber }} } +
+ } +
+ @if (member.isDependent) { + Dependent + } + @if (member.linkedClientId) { + Linked + } +
+
+ } +
+ } +
+ } +
diff --git a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.scss b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.scss index 701219434..6fcd04dc7 100644 --- a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.scss +++ b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.scss @@ -6,6 +6,37 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +.step-header { + background: #0f172a; + padding: 16px 24px; + border-radius: 10px 10px 0 0; + margin-bottom: 20px; + + &__left { + display: flex; + align-items: center; + gap: 12px; + } + + &__icon { + font-size: 18px; + color: #60a5fa; + } + + &__title { + font-size: 16px; + font-weight: 600; + color: #fff; + margin: 0; + } + + &__sub { + font-size: 11px; + color: rgba(255, 255, 255, 0.5); + margin: 2px 0 0; + } +} + .preview-container { display: flex; flex-direction: column; @@ -134,7 +165,75 @@ justify-content: center; } -/* ── Info Table (Key-Value pairs) ── */ +/* ── Preview Grid (replaces info-table) ── */ +.preview-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0; +} + +.preview-field { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 16px; + border-bottom: 1px solid rgba(0, 0, 0, 0.04); + border-right: 1px solid rgba(0, 0, 0, 0.04); + + &:nth-child(3n) { + border-right: none; + } + + &.full-width { + grid-column: 1 / -1; + border-right: none; + } +} + +.preview-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(0, 0, 0, 0.45); +} + +.preview-value { + font-size: 14px; + font-weight: 500; + color: #1a1a1a; + + &.no-data { + color: rgba(0, 0, 0, 0.3); + font-style: italic; + font-weight: 400; + } +} + +/* ── Preview Badges ── */ +.preview-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 600; + padding: 2px 10px; + border-radius: 12px; + + &--verified { + background: #ecfdf5; + color: #059669; + border: 1px solid #a7f3d0; + } + + &--pending { + background: #fffbeb; + color: #d97706; + border: 1px solid #fde68a; + } +} + +/* ── Legacy Info Table (keep for backward compat) ── */ .info-table { width: 100%; border-collapse: collapse; @@ -246,6 +345,36 @@ letter-spacing: 1px; } +/* ── Address Blocks ── */ +.address-block { + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px dashed rgba(0, 0, 0, 0.08); + + &:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; + } +} + +.address-block__title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #1074b9; + margin-bottom: 10px; + padding-left: 2px; +} + +/* ── Info badge ── */ +.preview-badge--info { + background: #eff6ff; + color: #1074b9; + border: 1px solid #bfdbfe; +} + /* ── Submit Section ── */ .submit-section { display: flex; @@ -270,6 +399,17 @@ } /* ── Responsive ── */ +@media (max-width: 960px) { + .preview-grid { + grid-template-columns: 1fr 1fr; + } + + .preview-field { + &:nth-child(3n) { border-right: 1px solid rgba(0, 0, 0, 0.04); } + &:nth-child(2n) { border-right: none; } + } +} + @media (max-width: 768px) { .profile-card { flex-direction: column; @@ -281,10 +421,12 @@ justify-content: center; } - .info-table { - .info-label { - width: 45%; - } + .preview-grid { + grid-template-columns: 1fr; + } + + .preview-field { + border-right: none !important; } .data-table { @@ -294,4 +436,124 @@ font-size: 12px; } } + + .fm-preview-row { + flex-wrap: wrap; + } +} + +/* Empty state with warning */ +.preview-empty { + display: flex; + align-items: center; + gap: 10px; + padding: 16px; + font-size: 13px; + color: #64748b; + background: #f8fafc; + border: 1px dashed #e2e8f0; + border-radius: 8px; +} + +.preview-empty--warn { + background: #fef2f2; + border-color: #fca5a5; + color: #991b1b; +} + +.preview-empty__icon { + font-size: 20px !important; + width: 20px !important; + height: 20px !important; + color: #dc2626; +} + +/* Family member preview rows */ +.fm-preview-list { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid #e2e8f0; + border-radius: 8px; + overflow: hidden; +} + +.fm-preview-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid #f1f5f9; + transition: background 0.1s; + + &:last-child { border-bottom: none; } + &:hover { background: #f8fafc; } +} + +.fm-preview-row__sno { + flex: 0 0 24px; + text-align: center; + font-size: 11px; + font-weight: 600; + color: #94a3b8; +} + +.fm-preview-row__avatar { + flex: 0 0 32px; + width: 32px; + height: 32px; + border-radius: 50%; + background: #dbeafe; + display: flex; + align-items: center; + justify-content: center; + color: #1565c0; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +.fm-preview-row__info { + flex: 1; + min-width: 0; +} + +.fm-preview-row__name { + display: block; + font-size: 13px; + font-weight: 600; + color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fm-preview-row__meta { + display: block; + font-size: 11px; + color: #64748b; +} + +.fm-preview-row__ids { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.fm-preview-id { + font-size: 11px; + font-family: 'Roboto Mono', monospace; + color: #475569; + padding: 2px 8px; + background: #f1f5f9; + border-radius: 4px; +} + +.fm-preview-row__status { + display: flex; + gap: 4px; + flex-shrink: 0; } diff --git a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.ts b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.ts index 923ed73df..1ee6a88df 100644 --- a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.ts +++ b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.ts @@ -18,6 +18,7 @@ import { MatExpansionPanelDescription } from '@angular/material/expansion'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatIcon } from '@angular/material/icon'; import { FindPipe } from '../../../pipes/find.pipe'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { YesnoPipe } from '../../../pipes/yesno.pipe'; @@ -41,6 +42,7 @@ import { LegalFormId } from 'app/clients/models/legal-form.enum'; MatExpansionPanelTitle, MatExpansionPanelDescription, FaIconComponent, + MatIcon, FindPipe, DateFormatPipe, YesnoPipe @@ -55,6 +57,31 @@ export class ClientPreviewStepComponent { @Input() client: any; /** KYC Data */ @Input() kycData: any; + /** Address data from India address step */ + @Input() addressData: any; + /** Selected guardian client (for minors) */ + @Input() guardian: any; + /** Guardian relationship */ + @Input() guardianRelation: string = ''; + /** Entity Classification data (non-individual) */ + @Input() entityClassificationData: any; + /** Nominee data */ + @Input() nominees: any[] = []; + /** Family members from family step */ + @Input() familyMembers: any[] = []; + /** Marital Status ID from general step */ + @Input() maritalStatusId: number | null = null; + /** Marital Status Options */ + @Input() maritalStatusOptions: any[] = []; + + /** Address tab labels */ + addressTabs = ['permanent', 'current', 'office', 'correspondence']; + addressTabLabels: Record = { + permanent: 'Permanent Address', + current: 'Current / Residential', + office: 'Office / Work', + correspondence: 'Correspondence' + }; /** Form submission event */ @Output() submitEvent = new EventEmitter(); diff --git a/src/app/clients/clients-routing.module.ts b/src/app/clients/clients-routing.module.ts index 154f1b622..68de3a360 100644 --- a/src/app/clients/clients-routing.module.ts +++ b/src/app/clients/clients-routing.module.ts @@ -17,6 +17,7 @@ import { ClientsComponent } from './clients.component'; import { ClientsViewComponent } from './clients-view/clients-view.component'; import { GeneralTabComponent } from './clients-view/general-tab/general-tab.component'; import { FamilyMembersTabComponent } from './clients-view/family-members-tab/family-members-tab.component'; +import { NomineeTabComponent } from './clients-view/nominee-tab/nominee-tab.component'; import { AddFamilyMemberComponent } from './clients-view/family-members-tab/add-family-member/add-family-member.component'; import { EditFamilyMemberComponent } from './clients-view/family-members-tab/edit-family-member/edit-family-member.component'; import { IdentitiesTabComponent } from './clients-view/identities-tab/identities-tab.component'; @@ -58,6 +59,7 @@ import { ClientCollateralResolver } from './common-resolvers/client-collateral.r import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component'; import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component'; import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component'; +import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component'; const routes: Routes = [ Route.withShell([ @@ -118,9 +120,14 @@ const routes: Routes = [ clientAddressData: ClientAddressResolver } }, + { + path: 'nominees', + component: NomineeTabComponent, + data: { title: 'Nominees', breadcrumb: 'Nominees', routeParamBreadcrumb: false } + }, { path: 'family-members', - data: { title: 'Nominee', breadcrumb: 'Nominee', routeParamBreadcrumb: false }, + data: { title: 'Family Members', breadcrumb: 'Family Members', routeParamBreadcrumb: false }, children: [ { path: '', @@ -167,6 +174,11 @@ const routes: Routes = [ component: KycTabComponent, data: { title: 'KYC', breadcrumb: 'KYC', routeParamBreadcrumb: false } }, + { + path: 'entity-classification', + component: EntityClassificationTabComponent, + data: { title: 'Scheme & Sector', breadcrumb: 'Scheme & Sector', routeParamBreadcrumb: false } + }, { path: 'ckyc', component: CkycTabComponent, diff --git a/src/app/clients/clients-view/address-tab/address-tab.component.html b/src/app/clients/clients-view/address-tab/address-tab.component.html index c37a9c29e..9ac75b190 100644 --- a/src/app/clients/clients-view/address-tab/address-tab.component.html +++ b/src/app/clients/clients-view/address-tab/address-tab.component.html @@ -39,7 +39,7 @@ - {{ address.city || address.townVillage || '' }}{{ address.city || address.townVillage ? ', ' : '' }}{{ getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name || '' }} + {{ address.city || '' }}{{ address.city && (address.countyDistrict || address.addressLine3) ? ', ' : '' }}{{ address.countyDistrict || address.addressLine3 || '' }}{{ address.postalCode ? ' - ' + address.postalCode : '' }} @@ -56,60 +56,58 @@
- @if (isFieldEnabled('street') && address.street) { + @if (address.addressLine1) {
-
{{ 'labels.inputs.Street' | translate }}
-
{{ address.street }}
-
- } - @if (isFieldEnabled('addressLine1') && address.addressLine1) { -
-
{{ 'labels.inputs.Address Line' | translate }} 1
+
Address Line 1
{{ address.addressLine1 }}
} - @if (isFieldEnabled('addressLine2') && address.addressLine2) { + @if (address.addressLine2) {
-
{{ 'labels.inputs.Address Line' | translate }} 2
+
Address Line 2
{{ address.addressLine2 }}
} - @if (isFieldEnabled('addressLine3') && address.addressLine3) { + @if (address.street) {
-
{{ 'labels.inputs.Address Line' | translate }} 3
-
{{ address.addressLine3 }}
+
Landmark
+
{{ address.street }}
} - @if (isFieldEnabled('townVillage') && address.townVillage) { + @if (address.postalCode) {
-
{{ 'labels.inputs.Town / Village' | translate }}
-
{{ address.townVillage }}
-
- } - @if (isFieldEnabled('city') && address.city) { -
-
{{ 'labels.inputs.City' | translate }}
-
{{ address.city }}
-
- } - @if (isFieldEnabled('stateProvinceId')) { -
-
{{ 'labels.inputs.State / Province' | translate }}
-
{{ getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name || '-' }}
-
- } - @if (isFieldEnabled('countryId')) { -
-
{{ 'labels.inputs.Country' | translate }}
-
{{ getSelectedValue('countryIdOptions', address.countryId)?.name || '-' }}
-
- } - @if (isFieldEnabled('postalCode') && address.postalCode) { -
-
{{ 'labels.inputs.Postal Code' | translate }}
+
PIN Code
{{ address.postalCode }}
} + @if (address.addressLine3) { +
+
Locality / State
+
{{ address.addressLine3 }}
+
+ } + @if (address.city) { +
+
City
+
{{ address.city }}
+
+ } + @if (address.countyDistrict) { +
+
District
+
{{ address.countyDistrict }}
+
+ } + @if (address.stateProvinceId) { +
+
State
+
{{ getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name || '-' }}
+
+ } +
+
Country
+
{{ getSelectedValue('countryIdOptions', address.countryId)?.name || 'India' }}
+
} diff --git a/src/app/clients/clients-view/address-tab/address-tab.component.ts b/src/app/clients/clients-view/address-tab/address-tab.component.ts index 24e32087c..5fefa605c 100644 --- a/src/app/clients/clients-view/address-tab/address-tab.component.ts +++ b/src/app/clients/clients-view/address-tab/address-tab.component.ts @@ -296,7 +296,7 @@ export class AddressTabComponent { formfields.push( this.isFieldEnabled('countyDistrict') ? new InputBase({ - controlName: 'countryDistrict', + controlName: 'countyDistrict', label: this.translateService.instant('labels.inputs.State / Province'), value: address ? address.countyDistrict : '', type: 'text', diff --git a/src/app/clients/clients-view/client-actions/view-survey/view-survey.component.html b/src/app/clients/clients-view/client-actions/view-survey/view-survey.component.html index 63f13bbe4..f1b3a048f 100644 --- a/src/app/clients/clients-view/client-actions/view-survey/view-survey.component.html +++ b/src/app/clients/clients-view/client-actions/view-survey/view-survey.component.html @@ -15,8 +15,9 @@
- + {{ 'labels.inputs.Filter' | translate }} +
diff --git a/src/app/clients/clients-view/clients-view.component.html b/src/app/clients/clients-view/clients-view.component.html index 207b6e93a..182f7657e 100644 --- a/src/app/clients/clients-view/clients-view.component.html +++ b/src/app/clients/clients-view/clients-view.component.html @@ -144,6 +144,54 @@
{{ clientViewData.occupation.name }}
} + @if (clientViewData.mobileNo && clientViewData.dipVerified != null) { +
+
DIP Status
+
+ + {{ (clientViewData.dipVerified === true || clientViewData.dipVerified === 'true' || clientViewData.dipVerified === 1) ? 'Verified' : 'Pending' }} + +
+
+ } + @if (clientViewData.nameVerified != null) { +
+
Sanctions Screening
+
+ + {{ (clientViewData.nameVerified === true || clientViewData.nameVerified === 'true' || clientViewData.nameVerified === 1) ? 'Clear' : 'Pending' }} + +
+
+ } + @if (clientViewData.nationality) { +
+
Nationality
+
{{ clientViewData.nationality.name || clientViewData.nationality }}
+
+ } + @if (clientViewData.maritalStatus) { +
+
Marital Status
+
{{ clientViewData.maritalStatus.name }}
+
+ } + @if (clientViewData.monthlyIncome != null && clientViewData.monthlyIncome !== '') { +
+
Monthly Income
+
₹ {{ clientViewData.monthlyIncome | number }}
+
+ } + @if (clientViewData.annualTurnover != null && clientViewData.annualTurnover !== '') { +
+
Annual Turnover
+
₹ {{ clientViewData.annualTurnover | number }}
+
+ } @if (clientViewData.groups?.length > 0) {
{{ 'labels.inputs.Member Of' | translate }}
@@ -355,90 +403,48 @@ } -
-