From 83ea57ccf0a66530322948b25e4d1d4bce19e15b Mon Sep 17 00:00:00 2001 From: Aditya Date: Wed, 20 May 2026 12:40:58 +0530 Subject: [PATCH] Client-stepper: pre-fill existing KYC/address proofs in edit, fix identifier saves Edit-mode now shows the previously selected KYC document type + uploaded file thumbnail (loads from /clients/{id}/identifiers + /client_identifiers/{id}/documents). Same for address proof on the India address step. Closes the gap where editing a client showed an empty file picker even when proof had been uploaded on create. Identifier-save hardened across all three callsites (create-client, edit-client, identities-tab.createIdentifierWithDocs) - the KYC chip picker proofId is a CODE string ('AADHAAR'), but backend ClientIdentifierRequest.documentTypeId is Long, which Spring rejects with HttpMessageNotReadableException -> 400. resolveDocumentTypeId helper looks up the code against /kyc/proof-types?type=id and substitutes the numeric CodeValue id before posting. Skips silently for constitution-specific non-individual docs that have no CodeValue mapping. KYC payload normalises panNumber / enterprisePan / gstinNumber / cinNumber via toUpperCase + whitespace strip so user-typed lowercase or trailing spaces don't trip the backend regex (validation.msg.CLIENTKYC.panNumber.does.not.match.regexp). State name match in client-address save now normalises both sides (lowercase, collapse underscores + spaces) so legacy MADHYA_PRADESH resolves to 'Madhya Pradesh' returned by api.postalpincode.in. Backed by tenant migration 0297. Identities-tab resolver flattened - was returning identifiers before the per-row forkJoin(getClientIdentificationDocuments) completed, so the view rendered empty documents arrays. Now uses switchMap + forkJoin so the resolver awaits completion. Also bundles concurrent work-in-progress on letter-mgmt, organization, users, bank-calendar, shell, nominee/notes/identifier dialogs, and theming. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/scheduled_tasks.lock | 1 + .claude/settings.local.json | 16 +- .../add-holiday-dialog.component.html | 4 +- .../add-holiday-dialog.component.ts | 50 +- .../bank-calendar.component.html | 211 ++++++ .../bank-calendar.component.scss | 471 +++++++++++++ .../bank-calendar/bank-calendar.component.ts | 662 +++++++++++++++++- .../_stepper-readonly-overrides.scss | 186 +++++ .../client-address-step.component.scss | 56 ++ .../client-datatable-step.component.scss | 6 + .../client-entity-kyc-step.component.scss | 6 + .../client-family-member-dialog.component.ts | 20 +- .../client-family-members-step.component.scss | 6 + .../client-family-members-step.component.ts | 9 +- .../client-general-step.component.scss | 6 + .../client-india-address-step.component.html | 200 ++++-- .../client-india-address-step.component.scss | 220 ++++++ .../client-india-address-step.component.ts | 224 ++++++ .../client-kyc-step.component.html | 424 ++++++++--- .../client-kyc-step.component.scss | 587 ++++++++++++++++ .../client-kyc-step.component.ts | 218 +++++- .../address-tab/address-tab.component.html | 23 +- .../address-tab/address-tab.component.ts | 137 ++-- .../edit-address-dialog.component.html | 186 +++++ .../edit-address-dialog.component.scss | 274 ++++++++ .../edit-address-dialog.component.ts | 354 ++++++++++ .../edit-notes-dialog.component.html | 19 +- .../edit-notes-dialog.component.ts | 34 +- .../identities-tab.component.ts | 56 +- .../kyc-tab/kyc-tab.component.html | 65 +- .../kyc-tab/kyc-tab.component.scss | 273 ++++++++ .../nominee-tab/nominee-tab.component.html | 13 + .../nominee-tab/nominee-tab.component.scss | 21 + .../nominee-tab/nominee-tab.component.ts | 29 +- src/app/clients/clients.service.ts | 20 + .../client-identities.resolver.ts | 33 +- .../create-client.component.html | 5 +- .../create-client/create-client.component.ts | 154 +++- .../edit-client/edit-client.component.html | 10 +- .../edit-client/edit-client.component.ts | 129 +++- src/app/core/shell/shell.component.html | 188 ++++- src/app/core/shell/shell.component.scss | 12 + src/app/core/shell/shell.component.ts | 2 + .../balance-certificate.component.ts | 12 +- .../certificates/certificate-pdf.ts | 90 ++- .../form-15gh/form-15gh.component.ts | 13 +- .../certificates/form-60/form-60.component.ts | 12 +- .../hypothecation/hypothecation.component.ts | 14 +- .../loan-interest/loan-interest.component.ts | 12 +- .../certificates/noc/noc.component.ts | 12 +- .../share-certificate.component.ts | 12 +- .../dsc-sign-dialog.component.html | 209 ++++++ .../dsc-sign-dialog.component.scss | 609 ++++++++++++++++ .../dsc-sign-dialog.component.ts | 140 ++++ .../dsc-sign-dialog/dsc-sign.service.ts | 74 ++ src/app/letter-mgmt/letter-mgmt.module.ts | 4 +- .../letter-mgmt/notice/notice.component.ts | 23 +- src/app/login/login.component.ts | 8 +- .../offices/offices.component.html | 547 +++++++-------- .../offices/offices.component.scss | 648 ++++++++++++++++- .../organization/offices/offices.component.ts | 453 ++++++------ .../organization-routing.module.ts | 11 +- .../organization/organization.component.html | 4 +- src/app/organization/organization.service.ts | 11 + .../formfield/formfield.component.html | 7 + src/app/shared/icons.module.ts | 8 +- .../entity-notes-tab.component.html | 11 + .../entity-notes-tab.component.ts | 50 +- .../shared/validators/min-words.validator.ts | 35 + .../user-details-dialog.component.html | 149 ++++ .../user-details-dialog.component.scss | 331 +++++++++ .../user-details-dialog.component.ts | 104 +++ .../user-form-dialog.component.html | 186 +++++ .../user-form-dialog.component.scss | 357 ++++++++++ .../user-form-dialog.component.ts | 251 +++++++ src/app/users/users.component.html | 306 ++++---- src/app/users/users.component.scss | 537 +++++++++++++- src/app/users/users.component.ts | 376 ++++++---- src/app/users/users.module.ts | 4 + src/environments/.env.ts | 4 +- src/environments/environment.prod.ts | 2 +- src/environments/environment.ts | 2 +- src/main.scss | 213 ++++++ src/theme/_dark_content.scss | 4 +- 84 files changed, 10281 insertions(+), 1194 deletions(-) create mode 100644 .claude/scheduled_tasks.lock create mode 100644 src/app/clients/client-stepper/_stepper-readonly-overrides.scss create mode 100644 src/app/clients/clients-view/address-tab/edit-address-dialog/edit-address-dialog.component.html create mode 100644 src/app/clients/clients-view/address-tab/edit-address-dialog/edit-address-dialog.component.scss create mode 100644 src/app/clients/clients-view/address-tab/edit-address-dialog/edit-address-dialog.component.ts create mode 100644 src/app/letter-mgmt/dsc-sign-dialog/dsc-sign-dialog.component.html create mode 100644 src/app/letter-mgmt/dsc-sign-dialog/dsc-sign-dialog.component.scss create mode 100644 src/app/letter-mgmt/dsc-sign-dialog/dsc-sign-dialog.component.ts create mode 100644 src/app/letter-mgmt/dsc-sign-dialog/dsc-sign.service.ts create mode 100644 src/app/shared/validators/min-words.validator.ts create mode 100644 src/app/users/user-details-dialog/user-details-dialog.component.html create mode 100644 src/app/users/user-details-dialog/user-details-dialog.component.scss create mode 100644 src/app/users/user-details-dialog/user-details-dialog.component.ts create mode 100644 src/app/users/user-form-dialog/user-form-dialog.component.html create mode 100644 src/app/users/user-form-dialog/user-form-dialog.component.scss create mode 100644 src/app/users/user-form-dialog/user-form-dialog.component.ts diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 000000000..dc8228604 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"67d120b4-ff78-435d-be80-f79482a386b4","pid":19832,"acquiredAt":1778067581856} \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 89e03ff86..703120349 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -52,7 +52,21 @@ "Bash(findstr /I /C:\"error\" /C:\"neft-rtgs\" /C:\"digital\")", "Bash(findstr /V \"warning\")", "Bash(dir \"C:\\\\Users\\\\adity\\\\Music\\\\CBS\\\\UI\\\\web-app\\\\src\\\\app\\\\settings\")", - "Bash(findstr \"merge_code_update\")" + "Bash(findstr \"merge_code_update\")", + "Bash(node -e \"console.log\\(JSON.stringify\\(require\\('./package.json'\\).prettier || 'no config'\\)\\)\")", + "Bash(node node_modules/prettier/bin/prettier.cjs --write --ignore-unknown \"src/**/*.{ts,html,scss,css}\")", + "Bash(node node_modules/prettier/bin/prettier.cjs --check --ignore-unknown \"src/**/*.{ts,html,scss,css}\")", + "Bash(Get-ChildItem -Path \"c:\\\\Users\\\\adity\\\\Music\\\\CBS\\\\UI\\\\web-app\\\\src\\\\app\\\\\" -Directory | Select-Object Name)", + "Bash(Get-ChildItem -Path \"c:\\\\Users\\\\adity\\\\Music\\\\CBS\\\\UI\\\\web-app\\\\src\\\\app\" -Directory)", + "Bash(Select-Object Name)", + "Bash(Get-ChildItem -Path \"c:\\\\Users\\\\adity\\\\Music\\\\CBS\\\\UI\\\\web-app\\\\src\\\\app\\\\loans\" -Recurse -File)", + "Bash(Select-Object FullName)", + "Bash(ConvertTo-Csv -NoTypeInformation)", + "Bash(Out-String)", + "Bash(node node_modules/prettier/bin/prettier.cjs --write \"src/**/*.{ts,html,scss}\")", + "Bash(node node_modules/stylelint/bin/stylelint.mjs --fix \"src/**/*.scss\")", + "Bash(node node_modules/stylelint/bin/stylelint.mjs \"src/**/*.scss\")", + "Bash(node node_modules/eslint/bin/eslint.js --cache \"src/app/users/users.component.ts\")" ] } } 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 index e98d6bc35..4f56c66e8 100644 --- 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 @@ -11,7 +11,7 @@
- Add Holiday + {{ isEditMode ? 'Edit Holiday' : 'Add Holiday' }}
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 index e10d3d65d..9f4e454af 100644 --- 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 @@ -8,13 +8,25 @@ import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup, Validators, UntypedFormControl } from '@angular/forms'; -import { MatDialogRef, MatDialogContent, MatDialogActions } from '@angular/material/dialog'; +import { MatDialogRef, MatDialogContent, MatDialogActions, MAT_DIALOG_DATA } 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'; +export interface AddHolidayDialogData { + mode: 'edit'; + holidayId: number; + name: string; + description?: string; + fromDate: Date; + toDate: Date; + reschedulingType: number; + repaymentsRescheduledTo?: Date; + officeId: number; +} + @Component({ selector: 'mifosx-add-holiday-dialog', templateUrl: './add-holiday-dialog.component.html', @@ -32,6 +44,9 @@ export class AddHolidayDialogComponent implements OnInit { private settings = inject(SettingsService); private dateUtils = inject(Dates); public dialogRef = inject(MatDialogRef); + public readonly editData: AddHolidayDialogData | null = inject(MAT_DIALOG_DATA, { + optional: true + }); holidayForm: UntypedFormGroup; offices: any[] = []; @@ -40,6 +55,10 @@ export class AddHolidayDialogComponent implements OnInit { minDate = new Date(2000, 0, 1); maxDate = new Date(2100, 0, 1); + get isEditMode(): boolean { + return this.editData?.mode === 'edit'; + } + ngOnInit() { this.holidayForm = this.fb.group({ name: [ @@ -67,12 +86,32 @@ export class AddHolidayDialogComponent implements OnInit { this.loadData(); this.buildDependencies(); + + if (this.isEditMode && this.editData) { + const ed = this.editData; + // Patch known fields synchronously; office list arrives async but the + // form's mat-select binds by id so the value still resolves once options render. + this.holidayForm.patchValue({ + name: ed.name, + description: ed.description || '', + fromDate: ed.fromDate, + toDate: ed.toDate, + reschedulingType: ed.reschedulingType, + officeId: ed.officeId + }); + if (ed.reschedulingType === 2 && ed.repaymentsRescheduledTo) { + this.holidayForm.addControl( + 'repaymentsRescheduledTo', + new UntypedFormControl(ed.repaymentsRescheduledTo, Validators.required) + ); + } + } } private loadData() { this.organizationService.getOffices().subscribe((data: any[]) => { this.offices = data || []; - if (this.offices.length > 0) { + if (!this.isEditMode && this.offices.length > 0) { this.holidayForm.patchValue({ officeId: this.offices[0].id }); } }); @@ -137,7 +176,12 @@ export class AddHolidayDialogComponent implements OnInit { offices: [{ officeId }] }; - this.organizationService.createHoliday(payload).subscribe({ + const request$ = + this.isEditMode && this.editData + ? this.organizationService.updateHoliday(this.editData.holidayId, payload) + : this.organizationService.createHoliday(payload); + + request$.subscribe({ next: () => { this.dialogRef.close(true); }, diff --git a/src/app/bank-calendar/bank-calendar.component.html b/src/app/bank-calendar/bank-calendar.component.html index b7f263ac6..b7f912e0f 100644 --- a/src/app/bank-calendar/bank-calendar.component.html +++ b/src/app/bank-calendar/bank-calendar.component.html @@ -74,6 +74,23 @@
Today
+ + + @@ -83,6 +100,200 @@
+ + @if (importResult) { +
+
+ +
+
+ Import — {{ importResult.imported }} of {{ importResult.total }} added +
+
+ @if (importResult.skipped.length === 0) { + All rows were imported successfully. + } @else { + {{ importResult.skipped.length }} row{{ importResult.skipped.length === 1 ? '' : 's' }} skipped — see + below. + } +
+
+ +
+ @if (importResult.skipped.length > 0) { +
    + @for (s of importResult.skipped; track s.row) { +
  • + Row {{ s.row }}: {{ s.reason }} +
  • + } +
+ } +
+ } + + +
+
+
+ + Holiday List {{ currentYear }} + {{ filteredHolidayRows.length }} of {{ holidayRows.length }} +
+
+ + + + @if (filterSearch || filterOfficeId !== '' || filterType) { + + } +
+
+ +
+ + + + + + + + + + + + + + + @for (row of filteredHolidayRows; track trackHolidayRow($index, row); let i = $index) { + + + + + + + + + + + } + @if (filteredHolidayRows.length === 0) { + + + + } + +
#DateDayHoliday / OccasionOfficeTypeStatusActions
{{ i + 1 }}{{ formatRowDate(row.fromDate) }}{{ rowDayName(row.fromDate) }} +
{{ row.name }}
+ @if (row.description && row.description !== row.name) { +
{{ row.description }}
+ } +
+ @for (n of row.officeNames; track n) { + {{ n }} + } + + @if (row.isGazetted) { + Gazetted + } @else { + Custom + } + + + {{ row.status === 'holidayStatusType.active' ? 'Active' : row.status }} + + + + + + + + + + + +
+ @if (holidayListLoading) { + Loading holidays… + } @else { + No holidays match the current filters. + } +
+
+
+
@for (month of months; track month.month) { diff --git a/src/app/bank-calendar/bank-calendar.component.scss b/src/app/bank-calendar/bank-calendar.component.scss index a5ba73b92..60e13c78d 100644 --- a/src/app/bank-calendar/bank-calendar.component.scss +++ b/src/app/bank-calendar/bank-calendar.component.scss @@ -246,6 +246,53 @@ $purple: #7c3aed; } } + &__tmpl-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border: 1px solid #bbf7d0; + background: #ecfdf5; + color: #047857; + border-radius: 8px; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + + &:hover { + background: #d1fae5; + border-color: $green; + } + } + + &__import-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border: 1px solid #c4b5fd; + background: #f5f3ff; + color: $purple; + border-radius: 8px; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + + &:hover:not(:disabled) { + background: #ede9fe; + border-color: $purple; + } + + &:disabled { + opacity: 0.6; + cursor: progress; + } + } + &__dot { width: 12px; height: 12px; @@ -466,3 +513,427 @@ $purple: #7c3aed; align-items: flex-start; } } + +// ─── Holiday List (office-wise) ─── +.cal-list { + background: #fff; + border-radius: 12px; + box-shadow: 0 1px 3px rgb(15 23 42 / 6%); + border: 1px solid #e2e8f0; + margin-bottom: 18px; + overflow: hidden; + + &__header { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid #e2e8f0; + background: linear-gradient(180deg, #f8fafc 0%, #fff 100%); + align-items: center; + justify-content: space-between; + } + + &__title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: $navy; + + &-ic { + color: $blue; + font-size: 15px; + } + } + + &__count { + margin-left: 6px; + padding: 2px 10px; + background: #eff6ff; + color: $blue; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + } + + &__filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + + &__search { + position: relative; + display: inline-flex; + align-items: center; + + input { + width: 240px; + padding: 7px 10px 7px 32px; + border: 1px solid #d0d5dd; + border-radius: 8px; + font-size: 12.5px; + background: #fff; + color: $navy; + transition: all 0.15s; + + &:focus { + outline: none; + border-color: $blue; + box-shadow: 0 0 0 3px rgb(30 136 229 / 12%); + } + } + + &-ic { + position: absolute; + left: 10px; + color: #94a3b8; + font-size: 11px; + pointer-events: none; + } + } + + &__select { + padding: 7px 10px; + border: 1px solid #d0d5dd; + border-radius: 8px; + background: #fff; + color: $navy; + font-size: 12.5px; + cursor: pointer; + transition: all 0.15s; + + &:focus { + outline: none; + border-color: $blue; + box-shadow: 0 0 0 3px rgb(30 136 229 / 12%); + } + } + + &__clear { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + background: #fff; + border: 1px solid #fecaca; + color: $red; + border-radius: 8px; + cursor: pointer; + font-size: 12px; + font-weight: 600; + transition: all 0.15s; + + &:hover { + background: #fef2f2; + border-color: #f87171; + } + } + + &__table-wrap { + overflow: auto; + max-height: 460px; + } + + &__table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + thead th { + position: sticky; + top: 0; + z-index: 1; + background: #f1f5f9; + color: #475569; + text-align: left; + padding: 10px 14px; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid #e2e8f0; + + &.num { + width: 48px; + text-align: center; + } + + &.actions { + width: 140px; + text-align: center; + } + } + + tbody td { + padding: 9px 14px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; + color: $navy; + + &.num { + text-align: center; + color: #94a3b8; + font-weight: 600; + } + + &.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + } + + &.actions { + text-align: center; + white-space: nowrap; + } + } + + tbody tr { + transition: background 0.12s; + + &:hover { + background: #f8fafc; + } + } + } + + &__name { + font-weight: 600; + color: $navy; + } + + &__desc { + font-size: 11px; + color: #64748b; + margin-top: 2px; + } + + &__office-chip { + display: inline-block; + padding: 2px 8px; + margin: 1px 4px 1px 0; + background: #eef2ff; + color: #4338ca; + border-radius: 999px; + font-size: 10.5px; + font-weight: 600; + } + + &__pill { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + + &.gazetted { + background: #f5f3ff; + color: $purple; + } + + &.custom { + background: #ecfdf5; + color: $green; + } + + &.status { + background: #f1f5f9; + color: #64748b; + + &.active { + background: #ecfdf5; + color: $green; + } + } + } + + &__act { + width: 30px; + height: 30px; + margin: 0 2px; + border: 1px solid #e2e8f0; + background: #fff; + border-radius: 7px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + color: #64748b; + font-size: 12px; + transition: all 0.12s; + + &:hover:not(:disabled) { + transform: translateY(-1px); + } + + &.edit:hover:not(:disabled) { + background: #eff6ff; + border-color: #bfdbfe; + color: $blue; + } + + &.remove:hover:not(:disabled) { + background: #fef2f2; + border-color: #fecaca; + color: $red; + } + + &.more:hover:not(:disabled) { + background: #f5f3ff; + border-color: #ddd6fe; + color: $purple; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } + + &__menu-ic { + width: 18px; + margin-right: 10px; + color: #64748b; + font-size: 13px; + text-align: center; + + &.ok { + color: $green; + } + + &.warn { + color: $red; + } + } + + &__empty { + text-align: center; + padding: 30px 16px; + color: #94a3b8; + font-size: 13px; + } +} + +@media (width <= 768px) { + .cal-list { + &__header { + flex-direction: column; + align-items: stretch; + } + + &__filters { + flex-direction: column; + align-items: stretch; + } + + &__search input, + &__select { + width: 100%; + } + } +} + +// ─── Import result banner ─── +.cal-import-result { + margin-bottom: 16px; + border-radius: 10px; + border: 1px solid #e2e8f0; + background: #fff; + overflow: hidden; + box-shadow: 0 1px 3px rgb(15 23 42 / 6%); + + &__head { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + } + + &__ic { + font-size: 22px; + flex-shrink: 0; + } + + &__title { + font-size: 14px; + font-weight: 700; + color: $navy; + line-height: 1.2; + } + + &__sub { + font-size: 12px; + color: #64748b; + margin-top: 2px; + } + + &__close { + margin-left: auto; + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid #e2e8f0; + background: #fff; + color: #94a3b8; + cursor: pointer; + transition: all 0.12s; + + &:hover { + background: #f1f5f9; + color: $navy; + } + } + + &__list { + margin: 0; + padding: 8px 18px 14px 36px; + background: #f8fafc; + border-top: 1px solid #e2e8f0; + max-height: 160px; + overflow-y: auto; + font-size: 12px; + color: #334155; + + li { + padding: 3px 0; + } + + strong { + color: $navy; + margin-right: 4px; + } + } + + &--ok { + border-color: #bbf7d0; + background: linear-gradient(180deg, #ecfdf5 0%, #fff 100%); + + .cal-import-result__ic { + color: $green; + } + } + + &--mixed { + border-color: #fde68a; + background: linear-gradient(180deg, #fefce8 0%, #fff 100%); + + .cal-import-result__ic { + color: $orange; + } + } + + &--fail { + border-color: #fecaca; + background: linear-gradient(180deg, #fef2f2 0%, #fff 100%); + + .cal-import-result__ic { + color: $red; + } + } +} diff --git a/src/app/bank-calendar/bank-calendar.component.ts b/src/app/bank-calendar/bank-calendar.component.ts index 754f956ab..bfcacb185 100644 --- a/src/app/bank-calendar/bank-calendar.component.ts +++ b/src/app/bank-calendar/bank-calendar.component.ts @@ -6,12 +6,51 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, ViewChild, ElementRef, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatMenuModule } from '@angular/material/menu'; +import { FormsModule } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import * as ExcelJS from 'exceljs'; 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'; +import { SettingsService } from '../settings/settings.service'; +import { Dates } from '../core/utils/dates'; +import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; +import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; +import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component'; +import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component'; + +interface ImportResult { + total: number; + imported: number; + skipped: { row: number; reason: string }[]; +} + +interface OfficeLite { + id: number; + name: string; +} + +interface HolidayRow { + id: number | null; + name: string; + fromDate: number[]; // [yyyy, mm, dd] + toDate: number[]; + description?: string; + officeIds: number[]; + officeNames: string[]; + status: string; + reschedulingType?: number; + repaymentsRescheduledTo?: number[]; + isGazetted: boolean; // true = local seed (no API id), false = backend record + raw?: any; +} interface CalendarDay { date: number; @@ -46,13 +85,25 @@ interface MonthData { styleUrls: ['./bank-calendar.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, + FormsModule, FaIconComponent, - MatTooltip + MatTooltip, + MatMenuModule ] }) export class BankCalendarComponent implements OnInit { private organizationService = inject(OrganizationService); private dialog = inject(MatDialog); + private snack = inject(MatSnackBar); + private settings = inject(SettingsService); + private dateUtils = inject(Dates); + private translate = inject(TranslateService); + + @ViewChild('importInput') importInput!: ElementRef; + + importing = false; + /** Last import outcome — kept on screen until user dismisses or imports again. */ + importResult: ImportResult | null = null; currentYear: number; months: MonthData[] = []; @@ -69,6 +120,15 @@ export class BankCalendarComponent implements OnInit { holidays: any[] = []; overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 }; + // ───── Holiday list (office-wise) ───── + offices: OfficeLite[] = []; + holidayRows: HolidayRow[] = []; + holidayListLoading = false; + /** Filter values for the list panel. */ + filterOfficeId: number | '' = ''; + filterSearch = ''; + filterType: '' | 'GAZETTED' | 'CUSTOM' = ''; + /** Indian bank gazetted holidays by year */ private readonly gazettedHolidays: Record = { 2025: [ @@ -153,19 +213,267 @@ export class BankCalendarComponent implements OnInit { ]; this.mergeGazettedHolidays(); this.buildCalendar(); + this.rebuildHolidayRows(); - // 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(); + // Pull offices then fan-out a per-office holiday fetch so each office's + // calendar entries are surfaced in the list panel (calendar grid still + // unions everything for the year-at-a-glance view). + this.holidayListLoading = true; + this.organizationService.getOffices().subscribe({ + next: (offices: any[]) => { + this.offices = (offices || []).map((o: any) => ({ id: o.id, name: o.name })); + if (this.offices.length === 0) { + this.holidayListLoading = false; + return; + } + const requests = this.offices.map((o) => + this.organizationService.getHolidays(o.id.toString()).pipe(catchError(() => of([]))) + ); + forkJoin(requests).subscribe({ + next: (perOffice: any[]) => { + // Tag every holiday with its source office and merge duplicates by id. + const byId = new Map(); + const unkeyed: any[] = []; + perOffice.forEach((rows: any[], idx: number) => { + const office = this.offices[idx]; + (rows || []).forEach((h: any) => { + if (h.id != null) { + if (!byId.has(h.id)) { + byId.set(h.id, { ...h, __officeIds: new Set(), __officeNames: new Set() }); + } + const merged = byId.get(h.id); + merged.__officeIds.add(office.id); + merged.__officeNames.add(office.name); + } else { + unkeyed.push({ + ...h, + __officeIds: new Set([office.id]), + __officeNames: new Set([office.name]) + }); + } + }); + }); + this.holidays = [ + ...Array.from(byId.values()), + ...unkeyed + ]; + this.mergeGazettedHolidays(); + this.buildCalendar(); + this.rebuildHolidayRows(); + this.holidayListLoading = false; + }, + error: () => { + this.holidayListLoading = false; + } }); + }, + error: () => { + this.holidayListLoading = false; } }); } + /** Flatten the holidays array into a presentable list with office labels. */ + private rebuildHolidayRows(): void { + this.holidayRows = this.holidays.map((h: any) => { + const officeIds: number[] = Array.from((h.__officeIds as Set) ?? new Set()); + const officeNames: string[] = Array.from((h.__officeNames as Set) ?? new Set()); + const isGazetted = h.id == null; + return { + id: h.id ?? null, + name: h.name || 'Holiday', + fromDate: h.fromDate, + toDate: h.toDate || h.fromDate, + description: h.description, + officeIds, + officeNames: officeNames.length ? officeNames : isGazetted ? ['All Offices (Gazetted)'] : ['—'], + status: h?.status?.value || h?.status?.code || 'Active', + reschedulingType: h?.reschedulingType?.id || h?.reschedulingType, + repaymentsRescheduledTo: h?.repaymentsRescheduledTo, + isGazetted, + raw: h + }; + }); + // Sort by date asc + this.holidayRows.sort((a, b) => { + const da = (a.fromDate?.[0] ?? 0) * 10000 + (a.fromDate?.[1] ?? 0) * 100 + (a.fromDate?.[2] ?? 0); + const db = (b.fromDate?.[0] ?? 0) * 10000 + (b.fromDate?.[1] ?? 0) * 100 + (b.fromDate?.[2] ?? 0); + return da - db; + }); + } + + // ───── Filters / actions on the holiday list ───── + get filteredHolidayRows(): HolidayRow[] { + const q = this.filterSearch.trim().toLowerCase(); + return this.holidayRows.filter((r) => { + if (this.filterOfficeId !== '' && !r.officeIds.includes(this.filterOfficeId as number)) { + // Gazetted entries have no officeIds — only hide when a specific office is selected. + if (!r.isGazetted) return false; + } + if (this.filterType === 'GAZETTED' && !r.isGazetted) return false; + if (this.filterType === 'CUSTOM' && r.isGazetted) return false; + if (q) { + const haystack = `${r.name} ${r.description ?? ''} ${r.officeNames.join(' ')}`.toLowerCase(); + if (!haystack.includes(q)) return false; + } + return true; + }); + } + + formatRowDate(d?: number[]): string { + if (!d || d.length < 3) return '—'; + const date = new Date(d[0], d[1] - 1, d[2]); + return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }); + } + + rowDayName(d?: number[]): string { + if (!d || d.length < 3) return ''; + const date = new Date(d[0], d[1] - 1, d[2]); + return date.toLocaleDateString('en-IN', { weekday: 'short' }); + } + + clearListFilters(): void { + this.filterOfficeId = ''; + this.filterSearch = ''; + this.filterType = ''; + } + + trackHolidayRow(_idx: number, row: HolidayRow): string { + return row.id != null ? `id-${row.id}` : `g-${row.fromDate?.join('-')}-${row.name}`; + } + + /** Open the dialog pre-populated for the given holiday. */ + async editHoliday(row: HolidayRow): Promise { + if (row.isGazetted || row.id == null) { + this.snack.open('Gazetted RBI holidays are read-only. Add a custom holiday to override.', 'OK', { + duration: 3500 + }); + return; + } + const { AddHolidayDialogComponent } = await import('./add-holiday-dialog/add-holiday-dialog.component'); + const ref = this.dialog.open(AddHolidayDialogComponent, { + width: '600px', + maxWidth: '95vw', + maxHeight: '90vh', + autoFocus: false, + panelClass: 'add-holiday-dialog-panel', + data: { + mode: 'edit', + holidayId: row.id, + name: row.name, + description: row.description, + fromDate: this.toJsDate(row.fromDate), + toDate: this.toJsDate(row.toDate), + reschedulingType: row.reschedulingType, + repaymentsRescheduledTo: this.toJsDate(row.repaymentsRescheduledTo), + officeId: row.officeIds[0] + } + }); + ref.afterClosed().subscribe((updated: boolean) => { + if (updated) { + this.showSuccess('Holiday Updated', `Changes to "${row.name}" have been saved.`); + this.loadWorkingDays(); + } + }); + } + + /** Polished success modal (matches the rest of Nexora — no more bare snackbars). */ + private showSuccess(title: string, message: string): void { + this.dialog.open(SuccessDialogComponent, { + data: { title, message, buttonLabel: 'OK' }, + panelClass: 'sd-panel' + }); + } + + /** Polished error modal — body string is rendered as the only field. */ + private showError(message: string): void { + this.dialog.open(ErrorDialogComponent, { data: message }); + } + + /** True when the holiday is already active and can't be re-activated. */ + isHolidayActive(row: HolidayRow): boolean { + const s = (row.status || '').toLowerCase(); + return s === 'active' || s === 'holidaystatustype.active'; + } + + /** + * Activate the holiday — mirrors the legacy view-holidays "Activate" + * action. Confirmed via the shared ConfirmationDialog and POSTed with + * `command=activate`. + */ + activateHoliday(row: HolidayRow): void { + if (row.isGazetted || row.id == null) { + this.snack.open('Gazetted RBI holidays are already in effect — no activation needed.', 'OK', { duration: 3500 }); + return; + } + if (this.isHolidayActive(row)) { + this.snack.open(`"${row.name}" is already active.`, 'OK', { duration: 2500 }); + return; + } + const ref = this.dialog.open(ConfirmationDialogComponent, { + data: { + heading: this.translate.instant('labels.heading.Holiday'), + dialogContext: + this.translate.instant('labels.dialogContext.Are you sure you want to activate') + + ` ${row.name} ` + + this.translate.instant('labels.dialogContext.holiday') + } + }); + ref.afterClosed().subscribe((response: any) => { + if (!response?.confirm) return; + this.organizationService.activateHoliday(row.id!.toString()).subscribe({ + next: () => { + this.showSuccess('Holiday Activated', `"${row.name}" is now active.`); + this.loadWorkingDays(); + }, + error: () => { + this.showError(`Failed to activate "${row.name}". Please try again.`); + } + }); + }); + } + + /** Copy the holiday's numeric ID to the clipboard (handy for support tickets). */ + copyHolidayId(row: HolidayRow): void { + if (row.id == null) { + this.snack.open('Gazetted holidays have no server ID to copy.', 'OK', { duration: 2500 }); + return; + } + const text = String(row.id); + navigator.clipboard + ?.writeText(text) + .then(() => this.snack.open(`Copied holiday ID ${text} to clipboard.`, 'OK', { duration: 2000 })) + .catch(() => this.snack.open('Could not access clipboard.', 'OK', { duration: 2500 })); + } + + /** Confirm + delete via shared DeleteDialog. */ + removeHoliday(row: HolidayRow): void { + if (row.isGazetted || row.id == null) { + this.snack.open('Gazetted RBI holidays cannot be removed — they are seeded locally.', 'OK', { duration: 3500 }); + return; + } + const ref = this.dialog.open(DeleteDialogComponent, { + data: { deleteContext: `holiday "${row.name}" (${this.formatRowDate(row.fromDate)})` } + }); + ref.afterClosed().subscribe((response: any) => { + if (!response?.delete) return; + this.organizationService.deleteHoliday(row.id!.toString()).subscribe({ + next: () => { + this.showSuccess('Holiday Removed', `"${row.name}" has been deleted.`); + this.loadWorkingDays(); + }, + error: () => { + this.showError(`Failed to remove "${row.name}". Please try again.`); + } + }); + }); + } + + private toJsDate(d?: number[]): Date | undefined { + if (!d || d.length < 3) return undefined; + return new Date(d[0], d[1] - 1, d[2]); + } + /** Merge static gazetted holidays into the holidays array (API format) */ private mergeGazettedHolidays() { const gazetted = this.gazettedHolidays[this.currentYear] || []; @@ -358,22 +666,19 @@ export class BankCalendarComponent implements OnInit { previousYear() { this.currentYear--; this.holidays = []; - this.mergeGazettedHolidays(); - this.buildCalendar(); + this.loadWorkingDays(); } nextYear() { this.currentYear++; this.holidays = []; - this.mergeGazettedHolidays(); - this.buildCalendar(); + this.loadWorkingDays(); } goToCurrentYear() { this.currentYear = new Date().getFullYear(); this.holidays = []; - this.mergeGazettedHolidays(); - this.buildCalendar(); + this.loadWorkingDays(); } printHolidayList() { @@ -563,9 +868,334 @@ export class BankCalendarComponent implements OnInit { dialogRef.afterClosed().subscribe((result: boolean) => { if (result) { - // Reload holidays after successful creation + this.showSuccess('Holiday Added', 'The holiday has been saved and applied to the calendar.'); this.loadWorkingDays(); } }); } + + // ───── Excel import ───── + + /** + * Generate and download the holiday-import template — one header row, two + * sample rows, and a notes block describing each column. Office names must + * match the bank's office list verbatim; reschedulingType is 1 (no repayment + * rescheduling) or 2 (move repayments to a specific date). + */ + async downloadImportTemplate(): Promise { + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'CBS Nexora'; + workbook.created = new Date(); + + const sheet = workbook.addWorksheet('Holidays'); + + const headers = [ + 'Name *', + 'From Date * (YYYY-MM-DD)', + 'To Date * (YYYY-MM-DD)', + 'Office Name *', + 'Description', + 'Rescheduling Type * (1=No Reschedule, 2=Move To Date)', + 'Repayments Rescheduled To (YYYY-MM-DD, only if type=2)' + ]; + + sheet.columns = [ + { width: 28 }, + { width: 24 }, + { width: 24 }, + { width: 24 }, + { width: 30 }, + { width: 36 }, + { width: 32 }]; + + const headerRow = sheet.addRow(headers); + headerRow.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 11 }; + headerRow.alignment = { vertical: 'middle', horizontal: 'left' }; + headerRow.eachCell((cell) => { + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0D47A1' } }; + cell.border = { bottom: { style: 'medium', color: { argb: 'FF0D47A1' } } }; + }); + headerRow.height = 28; + + // Sample rows (a single-day holiday + a multi-day one with rescheduling). + sheet.addRow([ + 'Founder’s Day', + '2026-09-15', + '2026-09-15', + 'Head Office', + 'Annual founder anniversary holiday', + 1, + '' + ]); + sheet.addRow([ + 'Branch Inauguration Week', + '2026-11-02', + '2026-11-04', + 'Mumbai — Andheri (E)', + 'New branch opening', + 2, + '2026-11-05' + ]); + + sheet.getRow(2).getCell(7).note = 'Leave blank when Rescheduling Type = 1'; + + // Notes block + sheet.addRow([]); + const noteHeader = sheet.addRow([ + 'INSTRUCTIONS — read before importing' + ]); + sheet.mergeCells(`A${noteHeader.number}:G${noteHeader.number}`); + noteHeader.font = { bold: true, color: { argb: 'FFB45309' }, size: 11 }; + noteHeader.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFEF3C7' } }; + noteHeader.height = 22; + + const notes = [ + '1. Fields marked * are required. Rows missing required fields will be skipped.', + '2. Office Name must match the bank office name EXACTLY (case-insensitive) — see the office list on the calendar page.', + '3. Dates must be in YYYY-MM-DD format. To Date >= From Date.', + '4. Rescheduling Type: 1 = no repayment reschedule, 2 = move repayments to a target date (column 7 required).', + '5. Do not change the header row. Rows below the header are read top-to-bottom; empty rows end the import.', + '6. Duplicates against existing holidays will be REJECTED by the server — no data is overwritten.' + ]; + notes.forEach((n) => { + const r = sheet.addRow([n]); + sheet.mergeCells(`A${r.number}:G${r.number}`); + r.font = { color: { argb: 'FF334155' }, size: 10.5 }; + }); + + // Freeze the header row so users keep their bearings while scrolling. + sheet.views = [{ state: 'frozen', ySplit: 1 }]; + + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'holiday-import-template.xlsx'; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 0); + + this.snack.open('Template downloaded — fill it in and click "Import Holidays".', 'OK', { duration: 3000 }); + } + + /** Trigger the hidden file input when "Import Holidays" is clicked. */ + triggerImport(): void { + this.importInput?.nativeElement?.click(); + } + + async onImportFileChosen(event: Event): Promise { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + // Always reset so re-selecting the same file fires the change event again. + if (input) input.value = ''; + if (!file) return; + if (!file.name.toLowerCase().endsWith('.xlsx')) { + this.snack.open('Please select an .xlsx file exported from the holiday template.', 'OK', { duration: 3500 }); + return; + } + + this.importing = true; + this.importResult = null; + + try { + const buffer = await file.arrayBuffer(); + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(buffer); + const sheet = workbook.worksheets[0]; + if (!sheet) { + this.snack.open('The workbook has no worksheets.', 'OK', { duration: 3500 }); + this.importing = false; + return; + } + + const rows: { rowNum: number; values: any[] }[] = []; + sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { + if (rowNumber === 1) return; // header + // Treat the "INSTRUCTIONS" notes block as a terminator — first cell + // starts with 'INSTRUCTIONS' or row has only 1 non-empty cell after a blank. + const first = (row.getCell(1).value ?? '').toString(); + if (first.toUpperCase().startsWith('INSTRUCTIONS') || first.match(/^\d+\.\s/)) return; + const values = [ + row.getCell(1).value, + row.getCell(2).value, + row.getCell(3).value, + row.getCell(4).value, + row.getCell(5).value, + row.getCell(6).value, + row.getCell(7).value + ]; + // Skip fully empty rows + if (values.every((v) => v == null || v === '')) return; + rows.push({ rowNum: rowNumber, values }); + }); + + if (rows.length === 0) { + this.snack.open('No data rows found in the workbook.', 'OK', { duration: 3500 }); + this.importing = false; + return; + } + + await this.processImportRows(rows); + } catch { + this.snack.open('Failed to read the Excel file. Make sure it is a valid .xlsx.', 'OK', { duration: 4000 }); + } finally { + this.importing = false; + } + } + + /** Process parsed rows sequentially, collecting per-row outcomes. */ + private async processImportRows(rows: { rowNum: number; values: any[] }[]): Promise { + // Use the EXACT same formatting path as the working AddHolidayDialog — + // dateFormat sent in the payload is the Angular-style pattern (e.g. + // 'dd MMMM yyyy') and the formatted value goes through Dates utility + // which converts it to moment tokens and respects the user's locale. + const dateFormat = this.settings.dateFormat; + const locale = this.settings.language.code; + + const officeByName = new Map(); + this.offices.forEach((o) => officeByName.set(o.name.trim().toLowerCase(), o)); + + const result: ImportResult = { total: rows.length, imported: 0, skipped: [] }; + + for (const { rowNum, values } of rows) { + const [ + nameRaw, + fromRaw, + toRaw, + officeRaw, + descRaw, + typeRaw, + reschRaw + ] = values; + const name = (nameRaw ?? '').toString().trim(); + const officeName = (officeRaw ?? '').toString().trim(); + const description = (descRaw ?? '').toString().trim(); + const typeNum = Number(typeRaw); + const fromDate = this.parseImportDate(fromRaw); + const toDate = this.parseImportDate(toRaw); + + if (!name) { + result.skipped.push({ row: rowNum, reason: 'missing Name' }); + continue; + } + if (!fromDate || !toDate) { + result.skipped.push({ row: rowNum, reason: 'invalid From/To Date' }); + continue; + } + if (toDate.getTime() < fromDate.getTime()) { + result.skipped.push({ row: rowNum, reason: 'To Date is before From Date' }); + continue; + } + const office = officeByName.get(officeName.toLowerCase()); + if (!office) { + result.skipped.push({ row: rowNum, reason: `unknown office "${officeName}"` }); + continue; + } + if (typeNum !== 1 && typeNum !== 2) { + result.skipped.push({ row: rowNum, reason: 'Rescheduling Type must be 1 or 2' }); + continue; + } + let reschDate: Date | null = null; + if (typeNum === 2) { + reschDate = this.parseImportDate(reschRaw); + if (!reschDate) { + result.skipped.push({ row: rowNum, reason: 'type=2 requires Repayments Rescheduled To date' }); + continue; + } + } + + const payload: any = { + name, + description, + fromDate: this.dateUtils.formatDateAsString(fromDate, dateFormat), + toDate: this.dateUtils.formatDateAsString(toDate, dateFormat), + reschedulingType: typeNum, + dateFormat, + locale, + offices: [{ officeId: office.id }] + }; + if (reschDate) { + payload.repaymentsRescheduledTo = this.dateUtils.formatDateAsString(reschDate, dateFormat); + } + + try { + await new Promise((resolve, reject) => { + this.organizationService.createHoliday(payload).subscribe({ + next: () => resolve(), + error: (err) => reject(err) + }); + }); + result.imported++; + } catch (err: any) { + const apiMsg = + err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.developerMessage || err?.message || 'API error'; + result.skipped.push({ row: rowNum, reason: apiMsg }); + } + } + + this.importResult = result; + if (result.imported > 0 && result.skipped.length === 0) { + this.showSuccess( + 'Holidays Imported', + `All ${result.imported} row${result.imported === 1 ? '' : 's'} imported successfully.` + ); + this.loadWorkingDays(); + } else if (result.imported > 0) { + this.showSuccess( + 'Import Partially Complete', + `${result.imported} of ${result.total} holidays imported. ${result.skipped.length} row${result.skipped.length === 1 ? ' was' : 's were'} skipped — see the highlighted panel for details.` + ); + this.loadWorkingDays(); + } else { + this.showError( + `Import finished — 0 of ${result.total} rows could be added. See the highlighted panel for details.` + ); + } + } + + dismissImportResult(): void { + this.importResult = null; + } + + /** + * Parse a cell value into a Date. + * + * ExcelJS returns date-typed cells as JS `Date` objects pinned to UTC midnight + * — e.g. "2026-09-15" comes back as `2026-09-15T00:00:00.000Z`. If we read + * `.getDate()` / `.getMonth()` in a timezone west of UTC, we'd land on the + * previous day. So when the value comes in as a Date, we extract the UTC + * components and rebuild a local-noon Date so downstream formatting (moment) + * always sees the day the user typed. + */ + private parseImportDate(v: any): Date | null { + if (v == null || v === '') return null; + if (v instanceof Date) { + if (Number.isNaN(v.getTime())) return null; + return new Date(v.getUTCFullYear(), v.getUTCMonth(), v.getUTCDate(), 12, 0, 0); + } + // ExcelJS may also surface a date-formatted cell as a number (serial date) + // when the cell type isn't decisively a date — handle that defensively. + if (typeof v === 'number') { + // Excel epoch: 1899-12-30 (accounting for the 1900 leap-year bug). + const ms = Math.round((v - 25569) * 86400 * 1000); + const d = new Date(ms); + if (Number.isNaN(d.getTime())) return null; + return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 12, 0, 0); + } + const s = v.toString().trim(); + // Accept YYYY-MM-DD primarily; fall back to Date.parse for resilience. + const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); + if (iso) { + const d = new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]), 12, 0, 0); + return Number.isNaN(d.getTime()) ? null : d; + } + const parsed = new Date(s); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } } diff --git a/src/app/clients/client-stepper/_stepper-readonly-overrides.scss b/src/app/clients/client-stepper/_stepper-readonly-overrides.scss new file mode 100644 index 000000000..8d6360981 --- /dev/null +++ b/src/app/clients/client-stepper/_stepper-readonly-overrides.scss @@ -0,0 +1,186 @@ +/** + * 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/. + */ + +/** + * Shared mixin for the client stepper steps. + * + * When the per-step `readOnly` flag is on, the host calls `form.disable()` which + * cascades Material's default `~0.38 opacity` onto every outlined input, + * select, checkbox and label. Visually it reads as "blurry". This mixin keeps + * the disabled affordances (no caret, no border highlight on focus, no + * pointer cursor on selects) but restores crisp colour + weight so the saved + * values stay legible. + * + * Usage in a step component's .scss: + * + * @use '../stepper-readonly-overrides' as readonly; + * + * :host { + * @include readonly.readonly-form-overrides; + * } + */ +@mixin readonly-form-overrides { + /* ─── MDC design-token overrides ─── + These CSS custom properties feed Material's disabled-state colors at the + token level — higher in the cascade than any rule selector — so they win + even against Material's own !important declarations baked into MDC styles. */ + --mdc-outlined-text-field-disabled-input-text-color: #0f172a; + --mdc-outlined-text-field-disabled-label-text-color: #64748b; + --mdc-outlined-text-field-disabled-outline-color: #cbd5e1; + --mdc-outlined-text-field-disabled-supporting-text-color: #64748b; + --mdc-outlined-text-field-disabled-leading-icon-color: #475569; + --mdc-outlined-text-field-disabled-trailing-icon-color: #475569; + --mdc-filled-text-field-disabled-input-text-color: #0f172a; + --mdc-filled-text-field-disabled-label-text-color: #64748b; + --mat-select-disabled-trigger-text-color: #0f172a; + --mat-form-field-disabled-input-text-color: #0f172a; + --mat-form-field-disabled-input-text-placeholder-color: #64748b; + --mdc-checkbox-disabled-selected-icon-color: #059669; + --mdc-checkbox-disabled-unselected-icon-color: #94a3b8; + + ::ng-deep { + /* Outlined form fields in disabled state */ + .mat-mdc-form-field.mat-form-field-appearance-outline.mat-form-field-disabled { + opacity: 1; + + /* Input value */ + .mat-mdc-input-element, + input.mat-mdc-input-element, + textarea.mat-mdc-input-element { + color: #0f172a !important; + -webkit-text-fill-color: #0f172a !important; + font-weight: 600; + caret-color: transparent; + } + + /* Floating label */ + .mat-mdc-floating-label, + .mdc-floating-label { + color: #64748b !important; + opacity: 1 !important; + } + + /* Outline border */ + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: #cbd5e1 !important; + border-width: 1px !important; + } + + /* matPrefix / matSuffix icons */ + .field-prefix-icon mat-icon, + .mat-mdc-form-field-icon-prefix mat-icon, + .mat-mdc-form-field-icon-suffix mat-icon { + color: #475569 !important; + opacity: 1; + } + + /* Hint text */ + .mat-mdc-form-field-hint { + color: #64748b !important; + opacity: 1; + } + + /* mat-select value + arrow. + MDC nests the visible text deeper than `.mat-mdc-select-value` — the + actual `` that renders the chosen option is `.mat-mdc-select-min-line`. + Without explicitly forcing it, MDC's disabled-state CSS wins and the + text reads as washed-out grey. */ + .mat-mdc-select-value-text, + .mat-mdc-select-value, + .mat-mdc-select-min-line, + .mat-mdc-select-placeholder { + color: #0f172a !important; + -webkit-text-fill-color: #0f172a !important; + font-weight: 600; + opacity: 1 !important; + } + + .mat-mdc-select-arrow { + color: #94a3b8 !important; + } + + /* The disabled select wrapper itself drops opacity in MDC — undo that. */ + .mat-mdc-select-disabled, + .mat-mdc-select-disabled .mat-mdc-select-trigger { + opacity: 1 !important; + } + + /* The MDC text-field root (parent of input + outline) also gets a + disabled modifier. Strip any opacity it applies. */ + .mdc-text-field--disabled { + opacity: 1 !important; + + .mdc-text-field__input, + input, + textarea { + color: #0f172a !important; + -webkit-text-fill-color: #0f172a !important; + opacity: 1 !important; + } + } + + /* Native suffix icon buttons (e.g. eye-toggle) */ + .toggle-vis-btn, + .mat-mdc-icon-button { + color: #64748b !important; + opacity: 1; + } + } + + /* Disabled checkboxes — keep the check + label crisp */ + .mat-mdc-checkbox-disabled { + opacity: 1 !important; + + .mdc-form-field > label, + .mat-mdc-checkbox-label, + .mdc-label { + color: #0f172a !important; + opacity: 1 !important; + font-weight: 500; + } + + .mdc-checkbox__background { + background-color: #059669 !important; + border-color: #059669 !important; + } + + .mdc-checkbox__checkmark { + color: #fff !important; + } + } + + /* Disabled mat-radio */ + .mat-mdc-radio-button.mat-mdc-radio-disabled { + opacity: 1 !important; + + .mdc-form-field > label, + .mdc-label { + color: #0f172a !important; + opacity: 1 !important; + font-weight: 500; + } + } + + /* Disabled mat-slide-toggle */ + .mat-mdc-slide-toggle.mat-mdc-slide-toggle-disabled { + opacity: 1 !important; + } + + /* Native UA override — Chrome/Safari force their own grey on disabled + form controls via `-webkit-text-fill-color`, which beats `color`. */ + input:disabled, + select:disabled, + textarea:disabled { + color: #0f172a !important; + -webkit-text-fill-color: #0f172a !important; + opacity: 1 !important; + } + } +} diff --git a/src/app/clients/client-stepper/client-address-step/client-address-step.component.scss b/src/app/clients/client-stepper/client-address-step/client-address-step.component.scss index 645a8b51f..b08228dbb 100644 --- a/src/app/clients/client-stepper/client-address-step/client-address-step.component.scss +++ b/src/app/clients/client-stepper/client-address-step/client-address-step.component.scss @@ -105,3 +105,59 @@ grid-template-columns: 1fr; } } + +/* ═══════════════════════════════════════════════════════════════ + Read-only / disabled form-field overrides + ─── Mirrors the India-address step so view-mode addresses stay + legible instead of looking ghosted at 0.38 opacity. + ═══════════════════════════════════════════════════════════════ */ +:host ::ng-deep { + .mat-mdc-form-field.mat-form-field-appearance-outline.mat-form-field-disabled { + opacity: 1; + + .mat-mdc-input-element, + input.mat-mdc-input-element, + textarea.mat-mdc-input-element { + color: #0f172a !important; + -webkit-text-fill-color: #0f172a !important; + font-weight: 600; + caret-color: transparent; + } + + .mat-mdc-floating-label, + .mdc-floating-label { + color: #64748b !important; + opacity: 1 !important; + } + + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: #cbd5e1 !important; + border-width: 1px !important; + } + + .field-prefix-icon mat-icon, + .mat-mdc-form-field-icon-prefix mat-icon, + .mat-mdc-form-field-icon-suffix mat-icon { + color: #475569 !important; + opacity: 1; + } + + .mat-mdc-form-field-hint { + color: #64748b !important; + opacity: 1; + } + + .mat-mdc-select-value-text, + .mat-mdc-select-value { + color: #0f172a !important; + -webkit-text-fill-color: #0f172a !important; + font-weight: 600; + } + + .mat-mdc-select-arrow { + color: #94a3b8 !important; + } + } +} diff --git a/src/app/clients/client-stepper/client-datatable-step/client-datatable-step.component.scss b/src/app/clients/client-stepper/client-datatable-step/client-datatable-step.component.scss index 32a562333..8740e5e6d 100644 --- a/src/app/clients/client-stepper/client-datatable-step/client-datatable-step.component.scss +++ b/src/app/clients/client-stepper/client-datatable-step/client-datatable-step.component.scss @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +@use '../stepper-readonly-overrides' as readonly; + +:host { + @include readonly.readonly-form-overrides; +} + .date-picker { width: 92%; } 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 index 36e7a013d..2c259f1ce 100644 --- 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 @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +@use '../stepper-readonly-overrides' as readonly; + +:host { + @include readonly.readonly-form-overrides; +} + /** * Entity Classification — Scheme & Sector Classification */ 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 e04a33843..552c39daf 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 @@ -259,7 +259,10 @@ export class ClientFamilyMemberDialogComponent implements OnInit { // non-individuals (companies / trusts) have no person-to-person relationship to model here. this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({ next: (data: any) => { - this.searchResults = data?.content || []; + // Filter the host client out of results so they can't even appear as + // a candidate. The dialog also blocks selection defensively in case + // the host slips through (e.g. via a paged search). + this.searchResults = (data?.content || []).filter((c: any) => !this.isSelfClient(c?.id)); this.searching = false; this.clientsService.enrichSearchResults(this.searchResults); }, @@ -278,6 +281,17 @@ export class ClientFamilyMemberDialogComponent implements OnInit { */ linkedSelectError = ''; + /** + * Self-check: prevent a client from being added as their own family member. + * The host client's id is passed in via dialog data — comparing as strings + * because the search API uses numbers but route params are strings. + */ + isSelfClient(clientId: number | string): boolean { + const host = this.data?.hostClientId; + if (host == null || clientId == null) return false; + return String(host) === String(clientId); + } + /** * True if the given client id is already linked as a family member of * this client. The member currently being edited is excluded so its own @@ -298,6 +312,10 @@ export class ClientFamilyMemberDialogComponent implements OnInit { /** Select a client from search results and fetch full details */ selectClient(client: any): void { + if (this.isSelfClient(client?.id)) { + this.linkedSelectError = `You can't add this client to their own family. Please pick a different person.`; + return; + } if (this.isClientAlreadyLinked(client?.id)) { this.linkedSelectError = `${client?.displayName || 'This client'} is already linked as a family member.`; return; 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 0bdf7fb98..a6c5f3354 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 @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +@use '../stepper-readonly-overrides' as readonly; + +:host { + @include readonly.readonly-form-overrides; +} + .zoomable { cursor: zoom-in; transition: 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 61a168804..1f3181844 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 @@ -135,7 +135,8 @@ export class ClientFamilyMembersStepComponent implements OnInit { data: { context: 'Add', options: this.clientTemplate.familyMemberOptions, - existingMembers: this.clientFamilyMembers + existingMembers: this.clientFamilyMembers, + hostClientId: this.clientId }, width: '640px', maxWidth: '95vw', @@ -154,7 +155,8 @@ export class ClientFamilyMembersStepComponent implements OnInit { context: 'View', member, options: this.clientTemplate.familyMemberOptions, - existingMembers: this.clientFamilyMembers + existingMembers: this.clientFamilyMembers, + hostClientId: this.clientId }, width: '640px', maxWidth: '95vw', @@ -168,7 +170,8 @@ export class ClientFamilyMembersStepComponent implements OnInit { context: 'Edit', member, options: this.clientTemplate.familyMemberOptions, - existingMembers: this.clientFamilyMembers + existingMembers: this.clientFamilyMembers, + hostClientId: this.clientId }, width: '640px', maxWidth: '95vw', 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 b762b87ed..9c749bd8a 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 @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +@use '../stepper-readonly-overrides' as readonly; + +:host { + @include readonly.readonly-form-overrides; +} + form { padding: 0; } 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 index c36618643..ebefe625c 100644 --- 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 @@ -123,7 +123,7 @@
-
+
@for (group of addressGroups; track group.label; let last = $last) {
folder_open {{ group.label }}
@@ -131,9 +131,11 @@ @@ -186,59 +188,78 @@ Required
-