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) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-20 12:40:58 +05:30
co-authored by Claude Opus 4.7
parent 325228c514
commit 83ea57ccf0
84 changed files with 10281 additions and 1194 deletions
@@ -11,7 +11,7 @@
<div class="hol-header">
<div class="hol-header__left">
<div class="hol-header__icon"><fa-icon icon="calendar-alt"></fa-icon></div>
<span class="hol-header__title">Add Holiday</span>
<span class="hol-header__title">{{ isEditMode ? 'Edit Holiday' : 'Add Holiday' }}</span>
</div>
<button class="hol-header__close" (click)="dialogRef.close()">
<fa-icon icon="times"></fa-icon>
@@ -131,7 +131,7 @@
(click)="submit()"
>
<fa-icon icon="check" class="hol-actions__icon"></fa-icon>
{{ submitting ? 'Saving...' : 'Add Holiday' }}
{{ submitting ? 'Saving...' : isEditMode ? 'Save Changes' : 'Add Holiday' }}
</button>
</mat-dialog-actions>
</div>
@@ -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<AddHolidayDialogComponent>);
public readonly editData: AddHolidayDialogData | null = inject<AddHolidayDialogData | null>(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);
},
@@ -74,6 +74,23 @@
<div class="cal-legend__item"><span class="cal-legend__dot today"></span> Today</div>
</div>
<div class="cal-legend__actions">
<button
class="cal-legend__tmpl-btn"
(click)="downloadImportTemplate()"
matTooltip="Download a pre-formatted .xlsx with sample rows + instructions"
>
<fa-icon icon="file-excel"></fa-icon> Download Template
</button>
<button
class="cal-legend__import-btn"
(click)="triggerImport()"
[disabled]="importing"
matTooltip="Bulk-create holidays from a filled-in template"
>
<fa-icon [icon]="importing ? 'spinner' : 'upload'" [class.fa-spin]="importing"></fa-icon>
{{ importing ? 'Importing…' : 'Import Holidays' }}
</button>
<input #importInput type="file" accept=".xlsx" (change)="onImportFileChosen($event)" hidden />
<button class="cal-legend__print-btn" (click)="printHolidayList()">
<fa-icon icon="print"></fa-icon> Print Holiday List
</button>
@@ -83,6 +100,200 @@
</div>
</div>
<!-- Import result banner -->
@if (importResult) {
<div
class="cal-import-result"
[class.cal-import-result--ok]="importResult.imported > 0 && importResult.skipped.length === 0"
[class.cal-import-result--mixed]="importResult.imported > 0 && importResult.skipped.length > 0"
[class.cal-import-result--fail]="importResult.imported === 0"
>
<div class="cal-import-result__head">
<fa-icon
[icon]="
importResult.imported > 0 && importResult.skipped.length === 0
? 'check-circle'
: importResult.imported === 0
? 'times-circle'
: 'exclamation-triangle'
"
class="cal-import-result__ic"
></fa-icon>
<div>
<div class="cal-import-result__title">
Import — {{ importResult.imported }} of {{ importResult.total }} added
</div>
<div class="cal-import-result__sub">
@if (importResult.skipped.length === 0) {
All rows were imported successfully.
} @else {
{{ importResult.skipped.length }} row{{ importResult.skipped.length === 1 ? '' : 's' }} skipped — see
below.
}
</div>
</div>
<button class="cal-import-result__close" (click)="dismissImportResult()" matTooltip="Dismiss">
<fa-icon icon="times"></fa-icon>
</button>
</div>
@if (importResult.skipped.length > 0) {
<ul class="cal-import-result__list">
@for (s of importResult.skipped; track s.row) {
<li>
<strong>Row {{ s.row }}:</strong> {{ s.reason }}
</li>
}
</ul>
}
</div>
}
<!-- Holiday List Panel (office-wise) -->
<div class="cal-list">
<div class="cal-list__header">
<div class="cal-list__title">
<fa-icon icon="list-alt" class="cal-list__title-ic"></fa-icon>
<span>Holiday List {{ currentYear }}</span>
<span class="cal-list__count">{{ filteredHolidayRows.length }} of {{ holidayRows.length }}</span>
</div>
<div class="cal-list__filters">
<div class="cal-list__search">
<fa-icon icon="search" class="cal-list__search-ic"></fa-icon>
<input type="text" placeholder="Search by name, office, description…" [(ngModel)]="filterSearch" />
</div>
<select [(ngModel)]="filterOfficeId" class="cal-list__select">
<option [ngValue]="''">All Offices</option>
@for (o of offices; track o.id) {
<option [ngValue]="o.id">{{ o.name }}</option>
}
</select>
<select [(ngModel)]="filterType" class="cal-list__select">
<option value="">All Types</option>
<option value="GAZETTED">Gazetted (RBI)</option>
<option value="CUSTOM">Custom</option>
</select>
@if (filterSearch || filterOfficeId !== '' || filterType) {
<button class="cal-list__clear" (click)="clearListFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
</div>
<div class="cal-list__table-wrap">
<table class="cal-list__table">
<thead>
<tr>
<th class="num">#</th>
<th>Date</th>
<th>Day</th>
<th>Holiday / Occasion</th>
<th>Office</th>
<th>Type</th>
<th>Status</th>
<th class="actions">Actions</th>
</tr>
</thead>
<tbody>
@for (row of filteredHolidayRows; track trackHolidayRow($index, row); let i = $index) {
<tr>
<td class="num">{{ i + 1 }}</td>
<td class="mono">{{ formatRowDate(row.fromDate) }}</td>
<td>{{ rowDayName(row.fromDate) }}</td>
<td>
<div class="cal-list__name">{{ row.name }}</div>
@if (row.description && row.description !== row.name) {
<div class="cal-list__desc">{{ row.description }}</div>
}
</td>
<td>
@for (n of row.officeNames; track n) {
<span class="cal-list__office-chip">{{ n }}</span>
}
</td>
<td>
@if (row.isGazetted) {
<span class="cal-list__pill gazetted">Gazetted</span>
} @else {
<span class="cal-list__pill custom">Custom</span>
}
</td>
<td>
<span
class="cal-list__pill status"
[class.active]="row.status === 'Active' || row.status === 'holidayStatusType.active'"
>
{{ row.status === 'holidayStatusType.active' ? 'Active' : row.status }}
</span>
</td>
<td class="actions">
<button
class="cal-list__act edit"
(click)="editHoliday(row)"
[disabled]="row.isGazetted"
[matTooltip]="row.isGazetted ? 'Gazetted holiday — read-only' : 'Edit holiday'"
>
<fa-icon icon="pencil-alt"></fa-icon>
</button>
<button
class="cal-list__act remove"
(click)="removeHoliday(row)"
[disabled]="row.isGazetted"
[matTooltip]="row.isGazetted ? 'Gazetted holiday — cannot remove' : 'Remove holiday'"
>
<fa-icon icon="trash"></fa-icon>
</button>
<button
class="cal-list__act more"
[matMenuTriggerFor]="rowMenu"
matTooltip="More actions"
(click)="$event.stopPropagation()"
>
<fa-icon icon="ellipsis-v"></fa-icon>
</button>
<mat-menu #rowMenu="matMenu" xPosition="before" class="cal-list__menu">
<button
mat-menu-item
(click)="activateHoliday(row)"
[disabled]="row.isGazetted || isHolidayActive(row)"
>
<fa-icon icon="check-circle" class="cal-list__menu-ic ok"></fa-icon>
@if (isHolidayActive(row)) {
Already Active
} @else {
Activate
}
</button>
<button mat-menu-item (click)="editHoliday(row)" [disabled]="row.isGazetted">
<fa-icon icon="pencil-alt" class="cal-list__menu-ic"></fa-icon>
Edit Holiday
</button>
<button mat-menu-item (click)="removeHoliday(row)" [disabled]="row.isGazetted">
<fa-icon icon="trash" class="cal-list__menu-ic warn"></fa-icon>
Remove Holiday
</button>
<button mat-menu-item (click)="copyHolidayId(row)" [disabled]="row.id == null">
<fa-icon icon="copy" class="cal-list__menu-ic"></fa-icon>
Copy Holiday ID
</button>
</mat-menu>
</td>
</tr>
}
@if (filteredHolidayRows.length === 0) {
<tr>
<td colspan="8" class="cal-list__empty">
@if (holidayListLoading) {
<fa-icon icon="spinner" class="fa-spin"></fa-icon> Loading holidays…
} @else {
No holidays match the current filters.
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Monthly Grids -->
<div class="cal-months">
@for (month of months; track month.month) {
@@ -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;
}
}
}
+646 -16
View File
@@ -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<HTMLInputElement>;
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<number, { month: number; day: number; name: string }[]> = {
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<number, any>();
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<number>(), __officeNames: new Set<string>() });
}
const merged = byId.get(h.id);
merged.__officeIds.add(office.id);
merged.__officeNames.add(office.name);
} else {
unkeyed.push({
...h,
__officeIds: new Set<number>([office.id]),
__officeNames: new Set<string>([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<number>) ?? new Set());
const officeNames: string[] = Array.from((h.__officeNames as Set<string>) ?? 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<void> {
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<void> {
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<void> {
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<void> {
// 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<string, OfficeLite>();
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<void>((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;
}
}