The Bank Calendar never read the Working Days setting. It hard-coded
Mon-Sat as working days and always marked only Sundays and the 2nd and
4th Saturday as off, so unticking Saturday (or any weekday) in
Admin > Working Days changed nothing on the calendar.
- Load the setting from /workingdays and parse its recurrence
(BYDAY=MO,TU,...). Until it answers, or if it cannot be read, the old
Mon-Sat default stays in place.
- A weekday left out of Working Days is off every week. When Saturday
is left out, every Saturday is off; when it is a working day, the RBI
rule still keeps the 2nd and 4th Saturday off. Sunday shows as a
working day if Working Days ticks it.
- Month and year counts, the Saturday stat card label ("2nd & 4th Sat"
or "Saturdays"), the legend, tooltips and the month summary tags
("N Sat Off", "N Weekly Off") follow the same rule.
Verified with prettier, eslint, tsc and ng build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1268 lines
44 KiB
TypeScript
1268 lines
44 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;
|
||
/** 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<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, 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<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), 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<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;
|
||
}
|
||
});
|
||
}
|
||
|
||
/** 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<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,
|
||
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) => `<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;
|
||
}
|
||
}
|