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>
1202 lines
42 KiB
TypeScript
1202 lines
42 KiB
TypeScript
/**
|
||
* Copyright since 2025 Mifos Initiative
|
||
*
|
||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||
*/
|
||
|
||
import { Component, OnInit, 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;
|
||
month: number;
|
||
year: number;
|
||
isCurrentMonth: boolean;
|
||
isToday: boolean;
|
||
isWorking: boolean;
|
||
isHoliday: boolean;
|
||
isSunday: boolean;
|
||
is2ndSaturday: boolean;
|
||
is4thSaturday: boolean;
|
||
holidayName?: string;
|
||
dayOfWeek: number; // 0=Sun, 6=Sat
|
||
}
|
||
|
||
interface MonthData {
|
||
month: number;
|
||
year: number;
|
||
name: string;
|
||
days: CalendarDay[];
|
||
workingDays: number;
|
||
nonWorkingDays: number;
|
||
holidays: number;
|
||
sundays: number;
|
||
secondFourthSat: number;
|
||
}
|
||
|
||
@Component({
|
||
selector: 'mifosx-bank-calendar',
|
||
templateUrl: './bank-calendar.component.html',
|
||
styleUrls: ['./bank-calendar.component.scss'],
|
||
imports: [
|
||
...STANDALONE_SHARED_IMPORTS,
|
||
FormsModule,
|
||
FaIconComponent,
|
||
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[] = [];
|
||
weekDays = [
|
||
'Sun',
|
||
'Mon',
|
||
'Tue',
|
||
'Wed',
|
||
'Thu',
|
||
'Fri',
|
||
'Sat'
|
||
];
|
||
workingDaysConfig: string[] = [];
|
||
holidays: any[] = [];
|
||
overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
|
||
|
||
// ───── 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: [
|
||
{ month: 1, day: 26, name: 'Republic Day' },
|
||
{ month: 2, day: 26, name: 'Maha Shivaratri' },
|
||
{ month: 3, day: 14, name: 'Holi' },
|
||
{ month: 3, day: 31, name: 'Id-ul-Fitr (Ramzan Eid)' },
|
||
{ month: 4, day: 6, name: 'Ram Navami' },
|
||
{ month: 4, day: 10, name: 'Mahavir Jayanti' },
|
||
{ month: 4, day: 14, name: 'Dr. Ambedkar Jayanti' },
|
||
{ month: 4, day: 18, name: 'Good Friday' },
|
||
{ month: 5, day: 1, name: 'May Day' },
|
||
{ month: 5, day: 12, name: 'Buddha Purnima' },
|
||
{ month: 6, day: 7, name: 'Id-ul-Zuha (Bakrid)' },
|
||
{ month: 7, day: 6, name: 'Muharram' },
|
||
{ month: 8, day: 15, name: 'Independence Day' },
|
||
{ month: 8, day: 16, name: 'Janmashtami' },
|
||
{ month: 9, day: 5, name: 'Milad-un-Nabi (Eid-e-Milad)' },
|
||
{ month: 10, day: 2, name: 'Mahatma Gandhi Jayanti' },
|
||
{ month: 10, day: 2, name: 'Dussehra (Vijaya Dashami)' },
|
||
{ month: 10, day: 20, name: 'Diwali (Deepavali)' },
|
||
{ month: 11, day: 5, name: 'Guru Nanak Jayanti' },
|
||
{ month: 12, day: 25, name: 'Christmas Day' }
|
||
],
|
||
2026: [
|
||
{ month: 1, day: 26, name: 'Republic Day' },
|
||
{ month: 3, day: 3, name: 'Holi' },
|
||
{ month: 3, day: 21, name: 'Id-ul-Fitr (Ramzan Eid)' },
|
||
{ month: 3, day: 26, name: 'Ram Navami' },
|
||
{ month: 3, day: 31, name: 'Mahavir Jayanti' },
|
||
{ month: 4, day: 1, name: 'Annual Closing of Bank Accounts' },
|
||
{ month: 4, day: 3, name: 'Good Friday' },
|
||
{ month: 5, day: 1, name: 'Buddha Purnima / May Day' },
|
||
{ month: 5, day: 27, name: 'Id-ul-Zuha (Bakrid)' },
|
||
{ month: 6, day: 26, name: 'Muharram' },
|
||
{ month: 8, day: 15, name: 'Independence Day' },
|
||
{ month: 8, day: 26, name: 'Milad-un-Nabi (Eid-e-Milad)' },
|
||
{ month: 9, day: 4, name: 'Janmashtami' },
|
||
{ month: 10, day: 2, name: 'Mahatma Gandhi Jayanti' },
|
||
{ month: 10, day: 20, name: 'Dussehra (Vijaya Dashami)' },
|
||
{ month: 11, day: 8, name: 'Diwali (Deepavali)' },
|
||
{ month: 11, day: 24, name: 'Guru Nanak Jayanti' },
|
||
{ month: 12, day: 25, name: 'Christmas Day' }
|
||
],
|
||
2027: [
|
||
{ month: 1, day: 26, name: 'Republic Day' },
|
||
{ month: 2, day: 20, name: 'Holi' },
|
||
{ month: 3, day: 11, name: 'Id-ul-Fitr (Ramzan Eid)' },
|
||
{ month: 3, day: 15, name: 'Ram Navami' },
|
||
{ month: 3, day: 21, name: 'Mahavir Jayanti' },
|
||
{ month: 3, day: 26, name: 'Good Friday' },
|
||
{ month: 4, day: 1, name: 'Annual Closing of Bank Accounts' },
|
||
{ month: 5, day: 1, name: 'May Day' },
|
||
{ month: 5, day: 2, name: 'Buddha Purnima' },
|
||
{ month: 5, day: 17, name: 'Id-ul-Zuha (Bakrid)' },
|
||
{ month: 6, day: 16, name: 'Muharram' },
|
||
{ month: 8, day: 15, name: 'Independence Day' },
|
||
{ month: 8, day: 16, name: 'Milad-un-Nabi (Eid-e-Milad)' },
|
||
{ month: 8, day: 25, name: 'Janmashtami' },
|
||
{ month: 10, day: 2, name: 'Mahatma Gandhi Jayanti' },
|
||
{ month: 10, day: 9, name: 'Dussehra (Vijaya Dashami)' },
|
||
{ month: 10, day: 28, name: 'Diwali (Deepavali)' },
|
||
{ month: 11, day: 14, name: 'Guru Nanak Jayanti' },
|
||
{ month: 12, day: 25, name: 'Christmas Day' }
|
||
]
|
||
};
|
||
|
||
ngOnInit() {
|
||
this.currentYear = new Date().getFullYear();
|
||
this.loadWorkingDays();
|
||
}
|
||
|
||
private loadWorkingDays() {
|
||
// Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off)
|
||
this.workingDaysConfig = [
|
||
'MO',
|
||
'TU',
|
||
'WE',
|
||
'TH',
|
||
'FR',
|
||
'SA'
|
||
];
|
||
this.mergeGazettedHolidays();
|
||
this.buildCalendar();
|
||
this.rebuildHolidayRows();
|
||
|
||
// 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] || [];
|
||
for (const gh of gazetted) {
|
||
const exists = this.holidays.some((h: any) => {
|
||
if (h.fromDate) {
|
||
const [
|
||
y,
|
||
m,
|
||
d
|
||
] = h.fromDate;
|
||
return y === this.currentYear && m === gh.month && d === gh.day;
|
||
}
|
||
return false;
|
||
});
|
||
if (!exists) {
|
||
this.holidays.push({
|
||
name: gh.name,
|
||
fromDate: [
|
||
this.currentYear,
|
||
gh.month,
|
||
gh.day
|
||
],
|
||
toDate: [
|
||
this.currentYear,
|
||
gh.month,
|
||
gh.day
|
||
],
|
||
description: gh.name,
|
||
status: { value: 'Active' }
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
buildCalendar() {
|
||
this.months = [];
|
||
this.overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
|
||
|
||
for (let m = 0; m < 12; m++) {
|
||
const monthData = this.buildMonth(m, this.currentYear);
|
||
this.months.push(monthData);
|
||
this.overallStats.working += monthData.workingDays;
|
||
this.overallStats.nonWorking += monthData.nonWorkingDays;
|
||
this.overallStats.holidays += monthData.holidays;
|
||
this.overallStats.sundays += monthData.sundays;
|
||
this.overallStats.sat2nd4th += monthData.secondFourthSat;
|
||
}
|
||
this.overallStats.totalDays = this.overallStats.working + this.overallStats.nonWorking;
|
||
}
|
||
|
||
private buildMonth(month: number, year: number): MonthData {
|
||
const firstDay = new Date(year, month, 1);
|
||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||
const startDayOfWeek = firstDay.getDay();
|
||
const today = new Date();
|
||
|
||
const monthNames = [
|
||
'January',
|
||
'February',
|
||
'March',
|
||
'April',
|
||
'May',
|
||
'June',
|
||
'July',
|
||
'August',
|
||
'September',
|
||
'October',
|
||
'November',
|
||
'December'
|
||
];
|
||
|
||
const days: CalendarDay[] = [];
|
||
let workingCount = 0;
|
||
let nonWorkingCount = 0;
|
||
let holidayCount = 0;
|
||
let sundayCount = 0;
|
||
let sat2nd4thCount = 0;
|
||
|
||
// Previous month padding
|
||
const prevMonthDays = new Date(year, month, 0).getDate();
|
||
for (let i = startDayOfWeek - 1; i >= 0; i--) {
|
||
days.push({
|
||
date: prevMonthDays - i,
|
||
month: month - 1,
|
||
year,
|
||
isCurrentMonth: false,
|
||
isToday: false,
|
||
isWorking: false,
|
||
isHoliday: false,
|
||
isSunday: false,
|
||
is2ndSaturday: false,
|
||
is4thSaturday: false,
|
||
dayOfWeek: (startDayOfWeek - i - 1 + 7) % 7
|
||
});
|
||
}
|
||
|
||
// Current month days
|
||
let saturdayCount = 0;
|
||
for (let d = 1; d <= daysInMonth; d++) {
|
||
const date = new Date(year, month, d);
|
||
const dow = date.getDay();
|
||
const isSunday = dow === 0;
|
||
const isSaturday = dow === 6;
|
||
|
||
if (isSaturday) saturdayCount++;
|
||
|
||
const is2ndSat = isSaturday && saturdayCount === 2;
|
||
const is4thSat = isSaturday && saturdayCount === 4;
|
||
const isToday = date.toDateString() === today.toDateString();
|
||
|
||
// Check if holiday
|
||
const holiday = this.getHolidayForDate(year, month, d);
|
||
const isHoliday = !!holiday;
|
||
|
||
// Non-working: Sunday, 2nd Sat, 4th Sat, or holiday
|
||
const isNonWorking = isSunday || is2ndSat || is4thSat || isHoliday;
|
||
|
||
if (isSunday) sundayCount++;
|
||
if (is2ndSat || is4thSat) sat2nd4thCount++;
|
||
if (isHoliday) holidayCount++;
|
||
|
||
if (isNonWorking) {
|
||
nonWorkingCount++;
|
||
} else {
|
||
workingCount++;
|
||
}
|
||
|
||
days.push({
|
||
date: d,
|
||
month,
|
||
year,
|
||
isCurrentMonth: true,
|
||
isToday,
|
||
isWorking: !isNonWorking,
|
||
isHoliday,
|
||
isSunday,
|
||
is2ndSaturday: is2ndSat,
|
||
is4thSaturday: is4thSat,
|
||
holidayName: holiday?.name,
|
||
dayOfWeek: dow
|
||
});
|
||
}
|
||
|
||
// Next month padding
|
||
const remaining = 42 - days.length;
|
||
for (let i = 1; i <= remaining; i++) {
|
||
days.push({
|
||
date: i,
|
||
month: month + 1,
|
||
year,
|
||
isCurrentMonth: false,
|
||
isToday: false,
|
||
isWorking: false,
|
||
isHoliday: false,
|
||
isSunday: false,
|
||
is2ndSaturday: false,
|
||
is4thSaturday: false,
|
||
dayOfWeek: days.length % 7
|
||
});
|
||
}
|
||
|
||
return {
|
||
month,
|
||
year,
|
||
name: monthNames[month],
|
||
days,
|
||
workingDays: workingCount,
|
||
nonWorkingDays: nonWorkingCount,
|
||
holidays: holidayCount,
|
||
sundays: sundayCount,
|
||
secondFourthSat: sat2nd4thCount
|
||
};
|
||
}
|
||
|
||
private getHolidayForDate(year: number, month: number, day: number): any {
|
||
return this.holidays.find((h: any) => {
|
||
if (h.fromDate) {
|
||
const [
|
||
y,
|
||
m,
|
||
d
|
||
] = h.fromDate;
|
||
return y === year && m === month + 1 && d === day;
|
||
}
|
||
return false;
|
||
});
|
||
}
|
||
|
||
previousYear() {
|
||
this.currentYear--;
|
||
this.holidays = [];
|
||
this.loadWorkingDays();
|
||
}
|
||
|
||
nextYear() {
|
||
this.currentYear++;
|
||
this.holidays = [];
|
||
this.loadWorkingDays();
|
||
}
|
||
|
||
goToCurrentYear() {
|
||
this.currentYear = new Date().getFullYear();
|
||
this.holidays = [];
|
||
this.loadWorkingDays();
|
||
}
|
||
|
||
printHolidayList() {
|
||
const monthNames = [
|
||
'January',
|
||
'February',
|
||
'March',
|
||
'April',
|
||
'May',
|
||
'June',
|
||
'July',
|
||
'August',
|
||
'September',
|
||
'October',
|
||
'November',
|
||
'December'
|
||
];
|
||
const dayNames = [
|
||
'Sunday',
|
||
'Monday',
|
||
'Tuesday',
|
||
'Wednesday',
|
||
'Thursday',
|
||
'Friday',
|
||
'Saturday'
|
||
];
|
||
|
||
// --- Section 1: Gazetted / API Holidays ---
|
||
const gazettedList: { sno: number; date: string; day: string; occasion: string }[] = [];
|
||
const sortedHolidays = [...this.holidays].sort((a: any, b: any) => {
|
||
const da = a.fromDate[0] * 10000 + a.fromDate[1] * 100 + a.fromDate[2];
|
||
const db = b.fromDate[0] * 10000 + b.fromDate[1] * 100 + b.fromDate[2];
|
||
return da - db;
|
||
});
|
||
|
||
sortedHolidays.forEach((h: any, i: number) => {
|
||
if (h.fromDate) {
|
||
const [
|
||
y,
|
||
m,
|
||
d
|
||
] = h.fromDate;
|
||
const date = new Date(y, m - 1, d);
|
||
gazettedList.push({
|
||
sno: i + 1,
|
||
date: `${monthNames[m - 1]} ${String(d).padStart(2, '0')}, ${y}`,
|
||
day: dayNames[date.getDay()],
|
||
occasion: h.name || 'Holiday'
|
||
});
|
||
}
|
||
});
|
||
|
||
// --- Section 2: 2nd & 4th Saturdays ---
|
||
const satList: { sno: number; date: string; day: string; occasion: string }[] = [];
|
||
let satSno = 0;
|
||
for (let m = 0; m < 12; m++) {
|
||
const daysInMonth = new Date(this.currentYear, m + 1, 0).getDate();
|
||
let satCount = 0;
|
||
for (let d = 1; d <= daysInMonth; d++) {
|
||
const date = new Date(this.currentYear, m, d);
|
||
if (date.getDay() === 6) {
|
||
satCount++;
|
||
if (satCount === 2 || satCount === 4) {
|
||
satSno++;
|
||
satList.push({
|
||
sno: satSno,
|
||
date: `${monthNames[m]} ${String(d).padStart(2, '0')}, ${this.currentYear}`,
|
||
day: 'Saturday',
|
||
occasion: satCount === 2 ? '2nd Saturday Off' : '4th Saturday Off'
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Section 3: Sundays ---
|
||
const sunList: { sno: number; date: string; day: string; occasion: string }[] = [];
|
||
let sunSno = 0;
|
||
for (let m = 0; m < 12; m++) {
|
||
const daysInMonth = new Date(this.currentYear, m + 1, 0).getDate();
|
||
for (let d = 1; d <= daysInMonth; d++) {
|
||
const date = new Date(this.currentYear, m, d);
|
||
if (date.getDay() === 0) {
|
||
sunSno++;
|
||
sunList.push({
|
||
sno: sunSno,
|
||
date: `${monthNames[m]} ${String(d).padStart(2, '0')}, ${this.currentYear}`,
|
||
day: 'Sunday',
|
||
occasion: 'Weekly Off'
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
const buildRows = (list: typeof gazettedList) =>
|
||
list.map((h) => `<tr><td>${h.sno}</td><td>${h.date}</td><td>${h.day}</td><td>${h.occasion}</td></tr>`).join('');
|
||
|
||
const html = `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>Holiday List ${this.currentYear} — CBS Nexora</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: 'Segoe UI', Arial, sans-serif; padding: 30px 40px; color: #1a1a2e; }
|
||
.header { text-align: center; margin-bottom: 24px; border-bottom: 3px solid #0d47a1; padding-bottom: 16px; }
|
||
.header h1 { font-size: 20px; color: #0d47a1; margin-bottom: 4px; letter-spacing: 0.5px; }
|
||
.header h2 { font-size: 15px; color: #475569; font-weight: 500; }
|
||
.header .year { font-size: 26px; font-weight: 800; color: #0d1b2a; margin-top: 6px; }
|
||
.stats { display: flex; justify-content: center; gap: 24px; margin-bottom: 24px; font-size: 13px; flex-wrap: wrap; }
|
||
.stats span { padding: 5px 14px; border-radius: 6px; font-weight: 600; }
|
||
.stats .gh { background: #f5f3ff; color: #7c3aed; }
|
||
.stats .sat { background: #fff7ed; color: #c2410c; }
|
||
.stats .sun { background: #fef2f2; color: #ef4444; }
|
||
.stats .total { background: #eff6ff; color: #1e88e5; }
|
||
.section-title { font-size: 14px; font-weight: 700; color: #0d47a1; margin: 22px 0 8px; padding: 6px 12px; background: #e3f2fd; border-radius: 6px; border-left: 4px solid #0d47a1; display: flex; align-items: center; gap: 8px; }
|
||
.section-title .count { margin-left: auto; font-size: 12px; background: #0d47a1; color: #fff; padding: 2px 10px; border-radius: 12px; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 12.5px; margin-bottom: 6px; }
|
||
th { background: #0d47a1; color: #fff; padding: 9px 12px; text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||
td { padding: 7px 12px; border-bottom: 1px solid #e2e8f0; }
|
||
tr:nth-child(even) { background: #f8fafc; }
|
||
td:first-child, th:first-child { text-align: center; width: 55px; }
|
||
th:nth-child(2), td:nth-child(2) { width: 200px; }
|
||
th:nth-child(3), td:nth-child(3) { width: 120px; }
|
||
.footer { text-align: center; margin-top: 28px; font-size: 11px; color: #94a3b8; border-top: 1px solid #e2e8f0; padding-top: 12px; }
|
||
@media print {
|
||
body { padding: 15px 20px; }
|
||
.section-title { break-inside: avoid; }
|
||
table { page-break-inside: auto; }
|
||
tr { page-break-inside: avoid; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
<h1>CBS Nexora — Bank Holiday List</h1>
|
||
<h2>Gazetted Holidays, 2nd/4th Saturdays & Sundays</h2>
|
||
<div class="year">${this.currentYear}</div>
|
||
</div>
|
||
<div class="stats">
|
||
<span class="gh">Gazetted Holidays: ${gazettedList.length}</span>
|
||
<span class="sat">2nd/4th Saturday Offs: ${satList.length}</span>
|
||
<span class="sun">Sundays: ${sunList.length}</span>
|
||
<span class="total">Total Non-Working Days: ${gazettedList.length + satList.length + sunList.length}</span>
|
||
</div>
|
||
|
||
<div class="section-title">Gazetted / Bank Holidays <span class="count">${gazettedList.length}</span></div>
|
||
<table>
|
||
<thead><tr><th>Sr. No.</th><th>Date</th><th>Day</th><th>Holiday / Occasion</th></tr></thead>
|
||
<tbody>${buildRows(gazettedList)}</tbody>
|
||
</table>
|
||
|
||
<div class="section-title">2nd & 4th Saturday Offs <span class="count">${satList.length}</span></div>
|
||
<table>
|
||
<thead><tr><th>Sr. No.</th><th>Date</th><th>Day</th><th>Holiday / Occasion</th></tr></thead>
|
||
<tbody>${buildRows(satList)}</tbody>
|
||
</table>
|
||
|
||
<div class="section-title">Sundays (Weekly Off) <span class="count">${sunList.length}</span></div>
|
||
<table>
|
||
<thead><tr><th>Sr. No.</th><th>Date</th><th>Day</th><th>Holiday / Occasion</th></tr></thead>
|
||
<tbody>${buildRows(sunList)}</tbody>
|
||
</table>
|
||
|
||
<div class="footer">Generated on ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'long', year: 'numeric' })} • CBS Nexora • Internal Use Only</div>
|
||
<script>window.onload = function() { window.print(); }</script>
|
||
</body>
|
||
</html>
|
||
`;
|
||
|
||
const printWindow = window.open('', '_blank');
|
||
if (printWindow) {
|
||
printWindow.document.write(html);
|
||
printWindow.document.close();
|
||
}
|
||
}
|
||
|
||
async openAddHolidayDialog() {
|
||
const { AddHolidayDialogComponent } = await import('./add-holiday-dialog/add-holiday-dialog.component');
|
||
const dialogRef = this.dialog.open(AddHolidayDialogComponent, {
|
||
width: '600px',
|
||
maxWidth: '95vw',
|
||
maxHeight: '90vh',
|
||
autoFocus: false,
|
||
panelClass: 'add-holiday-dialog-panel'
|
||
});
|
||
|
||
dialogRef.afterClosed().subscribe((result: boolean) => {
|
||
if (result) {
|
||
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;
|
||
}
|
||
}
|