/** * 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; /** Off every week because Admin > Working Days leaves this weekday out (Sunday aside). */ isWeeklyOff: 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; /** Saturdays off: the 2nd and 4th when Saturday is a working day, every one when it is not. */ secondFourthSat: number; /** Other weekdays off every week because Working Days leaves them out. */ weeklyOff: number; } /** Weekday codes in Date#getDay() order, as used by the /workingdays recurrence (BYDAY=MO,TU,...). */ const DAY_CODES = [ 'SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA' ]; @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; 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, weeklyOff: 0, totalDays: 0 }; // ───── Holiday list (office-wise) ───── offices: OfficeLite[] = []; holidayRows: HolidayRow[] = []; holidayListLoading = false; /** Filter values for the list panel. */ filterOfficeId: number | '' = ''; filterSearch = ''; filterType: '' | 'GAZETTED' | 'CUSTOM' = ''; /** Indian bank gazetted holidays by year */ private readonly gazettedHolidays: Record = { 2025: [ { 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), used until // Admin > Working Days answers, or if it cannot be read. this.workingDaysConfig = [ 'MO', 'TU', 'WE', 'TH', 'FR', 'SA' ]; this.mergeGazettedHolidays(); this.buildCalendar(); this.rebuildHolidayRows(); // The calendar has to agree with Admin > Working Days: a weekday unticked there is off every // week here too. this.organizationService.getWorkingDays().subscribe({ next: (data: any) => { const configured = this.parseWorkingDays(data?.recurrence); if (configured.length > 0) { this.workingDaysConfig = configured; this.buildCalendar(); } }, error: () => {} }); // Pull offices then fan-out a per-office holiday fetch so each office's // calendar entries are surfaced in the list panel (calendar grid still // unions everything for the year-at-a-glance view). this.holidayListLoading = true; this.organizationService.getOffices().subscribe({ next: (offices: any[]) => { this.offices = (offices || []).map((o: any) => ({ id: o.id, name: o.name })); if (this.offices.length === 0) { this.holidayListLoading = false; return; } const requests = this.offices.map((o) => this.organizationService.getHolidays(o.id.toString()).pipe(catchError(() => of([]))) ); forkJoin(requests).subscribe({ next: (perOffice: any[]) => { // Tag every holiday with its source office and merge duplicates by id. const byId = new Map(); const unkeyed: any[] = []; perOffice.forEach((rows: any[], idx: number) => { const office = this.offices[idx]; (rows || []).forEach((h: any) => { if (h.id != null) { if (!byId.has(h.id)) { byId.set(h.id, { ...h, __officeIds: new Set(), __officeNames: new Set() }); } const merged = byId.get(h.id); merged.__officeIds.add(office.id); merged.__officeNames.add(office.name); } else { unkeyed.push({ ...h, __officeIds: new Set([office.id]), __officeNames: new Set([office.name]) }); } }); }); this.holidays = [ ...Array.from(byId.values()), ...unkeyed ]; this.mergeGazettedHolidays(); this.buildCalendar(); this.rebuildHolidayRows(); this.holidayListLoading = false; }, error: () => { this.holidayListLoading = false; } }); }, error: () => { this.holidayListLoading = false; } }); } /** Weekday codes out of a recurrence such as "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR". */ private parseWorkingDays(recurrence: string | undefined): string[] { const byDay = /BYDAY=([^;]*)/.exec(recurrence || ''); if (!byDay) { return []; } return byDay[1] .split(',') .map((d) => d.trim().toUpperCase()) .filter((d) => DAY_CODES.includes(d)); } /** True when Admin > Working Days counts Saturday as a working day (so only the 2nd and 4th are off). */ get saturdayIsWorkingDay(): boolean { return this.workingDaysConfig.includes('SA'); } /** Flatten the holidays array into a presentable list with office labels. */ private rebuildHolidayRows(): void { this.holidayRows = this.holidays.map((h: any) => { const officeIds: number[] = Array.from((h.__officeIds as Set) ?? new Set()); const officeNames: string[] = Array.from((h.__officeNames as Set) ?? new Set()); const isGazetted = h.id == null; return { id: h.id ?? null, name: h.name || 'Holiday', fromDate: h.fromDate, toDate: h.toDate || h.fromDate, description: h.description, officeIds, officeNames: officeNames.length ? officeNames : isGazetted ? ['All Offices (Gazetted)'] : ['—'], status: h?.status?.value || h?.status?.code || 'Active', reschedulingType: h?.reschedulingType?.id || h?.reschedulingType, repaymentsRescheduledTo: h?.repaymentsRescheduledTo, isGazetted, raw: h }; }); // Sort by date asc this.holidayRows.sort((a, b) => { const da = (a.fromDate?.[0] ?? 0) * 10000 + (a.fromDate?.[1] ?? 0) * 100 + (a.fromDate?.[2] ?? 0); const db = (b.fromDate?.[0] ?? 0) * 10000 + (b.fromDate?.[1] ?? 0) * 100 + (b.fromDate?.[2] ?? 0); return da - db; }); } // ───── Filters / actions on the holiday list ───── get filteredHolidayRows(): HolidayRow[] { const q = this.filterSearch.trim().toLowerCase(); return this.holidayRows.filter((r) => { if (this.filterOfficeId !== '' && !r.officeIds.includes(this.filterOfficeId as number)) { // Gazetted entries have no officeIds — only hide when a specific office is selected. if (!r.isGazetted) return false; } if (this.filterType === 'GAZETTED' && !r.isGazetted) return false; if (this.filterType === 'CUSTOM' && r.isGazetted) return false; if (q) { const haystack = `${r.name} ${r.description ?? ''} ${r.officeNames.join(' ')}`.toLowerCase(); if (!haystack.includes(q)) return false; } return true; }); } formatRowDate(d?: number[]): string { if (!d || d.length < 3) return '—'; const date = new Date(d[0], d[1] - 1, d[2]); return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }); } rowDayName(d?: number[]): string { if (!d || d.length < 3) return ''; const date = new Date(d[0], d[1] - 1, d[2]); return date.toLocaleDateString('en-IN', { weekday: 'short' }); } clearListFilters(): void { this.filterOfficeId = ''; this.filterSearch = ''; this.filterType = ''; } trackHolidayRow(_idx: number, row: HolidayRow): string { return row.id != null ? `id-${row.id}` : `g-${row.fromDate?.join('-')}-${row.name}`; } /** Open the dialog pre-populated for the given holiday. */ async editHoliday(row: HolidayRow): Promise { if (row.isGazetted || row.id == null) { this.snack.open('Gazetted RBI holidays are read-only. Add a custom holiday to override.', 'OK', { duration: 3500 }); return; } const { AddHolidayDialogComponent } = await import('./add-holiday-dialog/add-holiday-dialog.component'); const ref = this.dialog.open(AddHolidayDialogComponent, { width: '600px', maxWidth: '95vw', maxHeight: '90vh', autoFocus: false, panelClass: 'add-holiday-dialog-panel', data: { mode: 'edit', holidayId: row.id, name: row.name, description: row.description, fromDate: this.toJsDate(row.fromDate), toDate: this.toJsDate(row.toDate), reschedulingType: row.reschedulingType, repaymentsRescheduledTo: this.toJsDate(row.repaymentsRescheduledTo), officeId: row.officeIds[0] } }); ref.afterClosed().subscribe((updated: boolean) => { if (updated) { this.showSuccess('Holiday Updated', `Changes to "${row.name}" have been saved.`); this.loadWorkingDays(); } }); } /** Polished success modal (matches the rest of Nexora — no more bare snackbars). */ private showSuccess(title: string, message: string): void { this.dialog.open(SuccessDialogComponent, { data: { title, message, buttonLabel: 'OK' }, panelClass: 'sd-panel' }); } /** Polished error modal — body string is rendered as the only field. */ private showError(message: string): void { this.dialog.open(ErrorDialogComponent, { data: message }); } /** True when the holiday is already active and can't be re-activated. */ isHolidayActive(row: HolidayRow): boolean { const s = (row.status || '').toLowerCase(); return s === 'active' || s === 'holidaystatustype.active'; } /** * Activate the holiday — mirrors the legacy view-holidays "Activate" * action. Confirmed via the shared ConfirmationDialog and POSTed with * `command=activate`. */ activateHoliday(row: HolidayRow): void { if (row.isGazetted || row.id == null) { this.snack.open('Gazetted RBI holidays are already in effect — no activation needed.', 'OK', { duration: 3500 }); return; } if (this.isHolidayActive(row)) { this.snack.open(`"${row.name}" is already active.`, 'OK', { duration: 2500 }); return; } const ref = this.dialog.open(ConfirmationDialogComponent, { data: { heading: this.translate.instant('labels.heading.Holiday'), dialogContext: this.translate.instant('labels.dialogContext.Are you sure you want to activate') + ` ${row.name} ` + this.translate.instant('labels.dialogContext.holiday') } }); ref.afterClosed().subscribe((response: any) => { if (!response?.confirm) return; this.organizationService.activateHoliday(row.id!.toString()).subscribe({ next: () => { this.showSuccess('Holiday Activated', `"${row.name}" is now active.`); this.loadWorkingDays(); }, error: () => { this.showError(`Failed to activate "${row.name}". Please try again.`); } }); }); } /** Copy the holiday's numeric ID to the clipboard (handy for support tickets). */ copyHolidayId(row: HolidayRow): void { if (row.id == null) { this.snack.open('Gazetted holidays have no server ID to copy.', 'OK', { duration: 2500 }); return; } const text = String(row.id); navigator.clipboard ?.writeText(text) .then(() => this.snack.open(`Copied holiday ID ${text} to clipboard.`, 'OK', { duration: 2000 })) .catch(() => this.snack.open('Could not access clipboard.', 'OK', { duration: 2500 })); } /** Confirm + delete via shared DeleteDialog. */ removeHoliday(row: HolidayRow): void { if (row.isGazetted || row.id == null) { this.snack.open('Gazetted RBI holidays cannot be removed — they are seeded locally.', 'OK', { duration: 3500 }); return; } const ref = this.dialog.open(DeleteDialogComponent, { data: { deleteContext: `holiday "${row.name}" (${this.formatRowDate(row.fromDate)})` } }); ref.afterClosed().subscribe((response: any) => { if (!response?.delete) return; this.organizationService.deleteHoliday(row.id!.toString()).subscribe({ next: () => { this.showSuccess('Holiday Removed', `"${row.name}" has been deleted.`); this.loadWorkingDays(); }, error: () => { this.showError(`Failed to remove "${row.name}". Please try again.`); } }); }); } private toJsDate(d?: number[]): Date | undefined { if (!d || d.length < 3) return undefined; return new Date(d[0], d[1] - 1, d[2]); } /** Merge static gazetted holidays into the holidays array (API format) */ private mergeGazettedHolidays() { const gazetted = this.gazettedHolidays[this.currentYear] || []; 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, weeklyOff: 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.weeklyOff += monthData.weeklyOff; } 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; let weeklyOffCount = 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, isWeeklyOff: 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 isSaturday = dow === 6; const isWorkingWeekday = this.workingDaysConfig.includes(DAY_CODES[dow]); const isSunday = dow === 0 && !isWorkingWeekday; if (isSaturday) saturdayCount++; // RBI: when Saturday is a working day the 2nd and 4th are still off. When Working Days leaves // Saturday out, every Saturday is a weekly off instead. const is2ndSat = isSaturday && isWorkingWeekday && saturdayCount === 2; const is4thSat = isSaturday && isWorkingWeekday && saturdayCount === 4; const isWeeklyOff = dow !== 0 && !isWorkingWeekday; 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, a weekly off from Working Days, or holiday const isNonWorking = isSunday || is2ndSat || is4thSat || isWeeklyOff || isHoliday; if (isSunday) sundayCount++; if (is2ndSat || is4thSat || (isSaturday && isWeeklyOff)) sat2nd4thCount++; if (isWeeklyOff && !isSaturday) weeklyOffCount++; 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, isWeeklyOff, 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, isWeeklyOff: false, dayOfWeek: days.length % 7 }); } return { month, year, name: monthNames[month], days, workingDays: workingCount, nonWorkingDays: nonWorkingCount, holidays: holidayCount, sundays: sundayCount, secondFourthSat: sat2nd4thCount, weeklyOff: weeklyOffCount }; } 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) => `${h.sno}${h.date}${h.day}${h.occasion}`).join(''); const html = ` Holiday List ${this.currentYear} — CBS Nexora

CBS Nexora — Bank Holiday List

Gazetted Holidays, 2nd/4th Saturdays & Sundays

${this.currentYear}
Gazetted Holidays: ${gazettedList.length} 2nd/4th Saturday Offs: ${satList.length} Sundays: ${sunList.length} Total Non-Working Days: ${gazettedList.length + satList.length + sunList.length}
Gazetted / Bank Holidays ${gazettedList.length}
${buildRows(gazettedList)}
Sr. No.DateDayHoliday / Occasion
2nd & 4th Saturday Offs ${satList.length}
${buildRows(satList)}
Sr. No.DateDayHoliday / Occasion
Sundays (Weekly Off) ${sunList.length}
${buildRows(sunList)}
Sr. No.DateDayHoliday / Occasion
`; const printWindow = window.open('', '_blank'); if (printWindow) { printWindow.document.write(html); printWindow.document.close(); } } async openAddHolidayDialog() { const { AddHolidayDialogComponent } = await import('./add-holiday-dialog/add-holiday-dialog.component'); const dialogRef = this.dialog.open(AddHolidayDialogComponent, { width: '600px', maxWidth: '95vw', maxHeight: '90vh', autoFocus: false, panelClass: 'add-holiday-dialog-panel' }); dialogRef.afterClosed().subscribe((result: boolean) => { if (result) { this.showSuccess('Holiday Added', 'The holiday has been saved and applied to the calendar.'); this.loadWorkingDays(); } }); } // ───── Excel import ───── /** * Generate and download the holiday-import template — one header row, two * sample rows, and a notes block describing each column. Office names must * match the bank's office list verbatim; reschedulingType is 1 (no repayment * rescheduling) or 2 (move repayments to a specific date). */ async downloadImportTemplate(): Promise { const workbook = new ExcelJS.Workbook(); workbook.creator = 'CBS Nexora'; workbook.created = new Date(); const sheet = workbook.addWorksheet('Holidays'); const headers = [ 'Name *', 'From Date * (YYYY-MM-DD)', 'To Date * (YYYY-MM-DD)', 'Office Name *', 'Description', 'Rescheduling Type * (1=No Reschedule, 2=Move To Date)', 'Repayments Rescheduled To (YYYY-MM-DD, only if type=2)' ]; sheet.columns = [ { width: 28 }, { width: 24 }, { width: 24 }, { width: 24 }, { width: 30 }, { width: 36 }, { width: 32 }]; const headerRow = sheet.addRow(headers); headerRow.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 11 }; headerRow.alignment = { vertical: 'middle', horizontal: 'left' }; headerRow.eachCell((cell) => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0D47A1' } }; cell.border = { bottom: { style: 'medium', color: { argb: 'FF0D47A1' } } }; }); headerRow.height = 28; // Sample rows (a single-day holiday + a multi-day one with rescheduling). sheet.addRow([ 'Founder’s Day', '2026-09-15', '2026-09-15', 'Head Office', 'Annual founder anniversary holiday', 1, '' ]); sheet.addRow([ 'Branch Inauguration Week', '2026-11-02', '2026-11-04', 'Mumbai — Andheri (E)', 'New branch opening', 2, '2026-11-05' ]); sheet.getRow(2).getCell(7).note = 'Leave blank when Rescheduling Type = 1'; // Notes block sheet.addRow([]); const noteHeader = sheet.addRow([ 'INSTRUCTIONS — read before importing' ]); sheet.mergeCells(`A${noteHeader.number}:G${noteHeader.number}`); noteHeader.font = { bold: true, color: { argb: 'FFB45309' }, size: 11 }; noteHeader.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFEF3C7' } }; noteHeader.height = 22; const notes = [ '1. Fields marked * are required. Rows missing required fields will be skipped.', '2. Office Name must match the bank office name EXACTLY (case-insensitive) — see the office list on the calendar page.', '3. Dates must be in YYYY-MM-DD format. To Date >= From Date.', '4. Rescheduling Type: 1 = no repayment reschedule, 2 = move repayments to a target date (column 7 required).', '5. Do not change the header row. Rows below the header are read top-to-bottom; empty rows end the import.', '6. Duplicates against existing holidays will be REJECTED by the server — no data is overwritten.' ]; notes.forEach((n) => { const r = sheet.addRow([n]); sheet.mergeCells(`A${r.number}:G${r.number}`); r.font = { color: { argb: 'FF334155' }, size: 10.5 }; }); // Freeze the header row so users keep their bearings while scrolling. sheet.views = [{ state: 'frozen', ySplit: 1 }]; const buffer = await workbook.xlsx.writeBuffer(); const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'holiday-import-template.xlsx'; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 0); this.snack.open('Template downloaded — fill it in and click "Import Holidays".', 'OK', { duration: 3000 }); } /** Trigger the hidden file input when "Import Holidays" is clicked. */ triggerImport(): void { this.importInput?.nativeElement?.click(); } async onImportFileChosen(event: Event): Promise { const input = event.target as HTMLInputElement; const file = input.files?.[0]; // Always reset so re-selecting the same file fires the change event again. if (input) input.value = ''; if (!file) return; if (!file.name.toLowerCase().endsWith('.xlsx')) { this.snack.open('Please select an .xlsx file exported from the holiday template.', 'OK', { duration: 3500 }); return; } this.importing = true; this.importResult = null; try { const buffer = await file.arrayBuffer(); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(buffer); const sheet = workbook.worksheets[0]; if (!sheet) { this.snack.open('The workbook has no worksheets.', 'OK', { duration: 3500 }); this.importing = false; return; } const rows: { rowNum: number; values: any[] }[] = []; sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { if (rowNumber === 1) return; // header // Treat the "INSTRUCTIONS" notes block as a terminator — first cell // starts with 'INSTRUCTIONS' or row has only 1 non-empty cell after a blank. const first = (row.getCell(1).value ?? '').toString(); if (first.toUpperCase().startsWith('INSTRUCTIONS') || first.match(/^\d+\.\s/)) return; const values = [ row.getCell(1).value, row.getCell(2).value, row.getCell(3).value, row.getCell(4).value, row.getCell(5).value, row.getCell(6).value, row.getCell(7).value ]; // Skip fully empty rows if (values.every((v) => v == null || v === '')) return; rows.push({ rowNum: rowNumber, values }); }); if (rows.length === 0) { this.snack.open('No data rows found in the workbook.', 'OK', { duration: 3500 }); this.importing = false; return; } await this.processImportRows(rows); } catch { this.snack.open('Failed to read the Excel file. Make sure it is a valid .xlsx.', 'OK', { duration: 4000 }); } finally { this.importing = false; } } /** Process parsed rows sequentially, collecting per-row outcomes. */ private async processImportRows(rows: { rowNum: number; values: any[] }[]): Promise { // Use the EXACT same formatting path as the working AddHolidayDialog — // dateFormat sent in the payload is the Angular-style pattern (e.g. // 'dd MMMM yyyy') and the formatted value goes through Dates utility // which converts it to moment tokens and respects the user's locale. const dateFormat = this.settings.dateFormat; const locale = this.settings.language.code; const officeByName = new Map(); this.offices.forEach((o) => officeByName.set(o.name.trim().toLowerCase(), o)); const result: ImportResult = { total: rows.length, imported: 0, skipped: [] }; for (const { rowNum, values } of rows) { const [ nameRaw, fromRaw, toRaw, officeRaw, descRaw, typeRaw, reschRaw ] = values; const name = (nameRaw ?? '').toString().trim(); const officeName = (officeRaw ?? '').toString().trim(); const description = (descRaw ?? '').toString().trim(); const typeNum = Number(typeRaw); const fromDate = this.parseImportDate(fromRaw); const toDate = this.parseImportDate(toRaw); if (!name) { result.skipped.push({ row: rowNum, reason: 'missing Name' }); continue; } if (!fromDate || !toDate) { result.skipped.push({ row: rowNum, reason: 'invalid From/To Date' }); continue; } if (toDate.getTime() < fromDate.getTime()) { result.skipped.push({ row: rowNum, reason: 'To Date is before From Date' }); continue; } const office = officeByName.get(officeName.toLowerCase()); if (!office) { result.skipped.push({ row: rowNum, reason: `unknown office "${officeName}"` }); continue; } if (typeNum !== 1 && typeNum !== 2) { result.skipped.push({ row: rowNum, reason: 'Rescheduling Type must be 1 or 2' }); continue; } let reschDate: Date | null = null; if (typeNum === 2) { reschDate = this.parseImportDate(reschRaw); if (!reschDate) { result.skipped.push({ row: rowNum, reason: 'type=2 requires Repayments Rescheduled To date' }); continue; } } const payload: any = { name, description, fromDate: this.dateUtils.formatDateAsString(fromDate, dateFormat), toDate: this.dateUtils.formatDateAsString(toDate, dateFormat), reschedulingType: typeNum, dateFormat, locale, offices: [{ officeId: office.id }] }; if (reschDate) { payload.repaymentsRescheduledTo = this.dateUtils.formatDateAsString(reschDate, dateFormat); } try { await new Promise((resolve, reject) => { this.organizationService.createHoliday(payload).subscribe({ next: () => resolve(), error: (err) => reject(err) }); }); result.imported++; } catch (err: any) { const apiMsg = err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.developerMessage || err?.message || 'API error'; result.skipped.push({ row: rowNum, reason: apiMsg }); } } this.importResult = result; if (result.imported > 0 && result.skipped.length === 0) { this.showSuccess( 'Holidays Imported', `All ${result.imported} row${result.imported === 1 ? '' : 's'} imported successfully.` ); this.loadWorkingDays(); } else if (result.imported > 0) { this.showSuccess( 'Import Partially Complete', `${result.imported} of ${result.total} holidays imported. ${result.skipped.length} row${result.skipped.length === 1 ? ' was' : 's were'} skipped — see the highlighted panel for details.` ); this.loadWorkingDays(); } else { this.showError( `Import finished — 0 of ${result.total} rows could be added. See the highlighted panel for details.` ); } } dismissImportResult(): void { this.importResult = null; } /** * Parse a cell value into a Date. * * ExcelJS returns date-typed cells as JS `Date` objects pinned to UTC midnight * — e.g. "2026-09-15" comes back as `2026-09-15T00:00:00.000Z`. If we read * `.getDate()` / `.getMonth()` in a timezone west of UTC, we'd land on the * previous day. So when the value comes in as a Date, we extract the UTC * components and rebuild a local-noon Date so downstream formatting (moment) * always sees the day the user typed. */ private parseImportDate(v: any): Date | null { if (v == null || v === '') return null; if (v instanceof Date) { if (Number.isNaN(v.getTime())) return null; return new Date(v.getUTCFullYear(), v.getUTCMonth(), v.getUTCDate(), 12, 0, 0); } // ExcelJS may also surface a date-formatted cell as a number (serial date) // when the cell type isn't decisively a date — handle that defensively. if (typeof v === 'number') { // Excel epoch: 1899-12-30 (accounting for the 1900 leap-year bug). const ms = Math.round((v - 25569) * 86400 * 1000); const d = new Date(ms); if (Number.isNaN(d.getTime())) return null; return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 12, 0, 0); } const s = v.toString().trim(); // Accept YYYY-MM-DD primarily; fall back to Date.parse for resilience. const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); if (iso) { const d = new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]), 12, 0, 0); return Number.isNaN(d.getTime()) ? null : d; } const parsed = new Date(s); return Number.isNaN(parsed.getTime()) ? null : parsed; } }