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:
co-authored by
Claude Opus 4.7
parent
325228c514
commit
83ea57ccf0
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user