Client-stepper: pre-fill existing KYC/address proofs in edit, fix identifier saves

Edit-mode now shows the previously selected KYC document type + uploaded file thumbnail
(loads from /clients/{id}/identifiers + /client_identifiers/{id}/documents). Same for
address proof on the India address step. Closes the gap where editing a client showed
an empty file picker even when proof had been uploaded on create.

Identifier-save hardened across all three callsites (create-client, edit-client,
identities-tab.createIdentifierWithDocs) - the KYC chip picker proofId is a CODE
string ('AADHAAR'), but backend ClientIdentifierRequest.documentTypeId is Long, which
Spring rejects with HttpMessageNotReadableException -> 400. resolveDocumentTypeId
helper looks up the code against /kyc/proof-types?type=id and substitutes the numeric
CodeValue id before posting. Skips silently for constitution-specific non-individual
docs that have no CodeValue mapping.

KYC payload normalises panNumber / enterprisePan / gstinNumber / cinNumber via
toUpperCase + whitespace strip so user-typed lowercase or trailing spaces don't trip
the backend regex (validation.msg.CLIENTKYC.panNumber.does.not.match.regexp).

State name match in client-address save now normalises both sides (lowercase, collapse
underscores + spaces) so legacy MADHYA_PRADESH resolves to 'Madhya Pradesh' returned
by api.postalpincode.in. Backed by tenant migration 0297.

Identities-tab resolver flattened - was returning identifiers before the per-row
forkJoin(getClientIdentificationDocuments) completed, so the view rendered empty
documents arrays. Now uses switchMap + forkJoin so the resolver awaits completion.

Also bundles concurrent work-in-progress on letter-mgmt, organization, users,
bank-calendar, shell, nominee/notes/identifier dialogs, and theming.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-20 12:40:58 +05:30
co-authored by Claude Opus 4.7
parent 325228c514
commit 83ea57ccf0
84 changed files with 10281 additions and 1194 deletions
+646 -16
View File
@@ -6,12 +6,51 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component, OnInit, ViewChild, ElementRef, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatMenuModule } from '@angular/material/menu';
import { FormsModule } from '@angular/forms';
import { TranslateService } from '@ngx-translate/core';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import * as ExcelJS from 'exceljs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTooltip } from '@angular/material/tooltip';
import { OrganizationService } from '../organization/organization.service';
import { SettingsService } from '../settings/settings.service';
import { Dates } from '../core/utils/dates';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component';
interface ImportResult {
total: number;
imported: number;
skipped: { row: number; reason: string }[];
}
interface OfficeLite {
id: number;
name: string;
}
interface HolidayRow {
id: number | null;
name: string;
fromDate: number[]; // [yyyy, mm, dd]
toDate: number[];
description?: string;
officeIds: number[];
officeNames: string[];
status: string;
reschedulingType?: number;
repaymentsRescheduledTo?: number[];
isGazetted: boolean; // true = local seed (no API id), false = backend record
raw?: any;
}
interface CalendarDay {
date: number;
@@ -46,13 +85,25 @@ interface MonthData {
styleUrls: ['./bank-calendar.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatTooltip
MatTooltip,
MatMenuModule
]
})
export class BankCalendarComponent implements OnInit {
private organizationService = inject(OrganizationService);
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
private settings = inject(SettingsService);
private dateUtils = inject(Dates);
private translate = inject(TranslateService);
@ViewChild('importInput') importInput!: ElementRef<HTMLInputElement>;
importing = false;
/** Last import outcome — kept on screen until user dismisses or imports again. */
importResult: ImportResult | null = null;
currentYear: number;
months: MonthData[] = [];
@@ -69,6 +120,15 @@ export class BankCalendarComponent implements OnInit {
holidays: any[] = [];
overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
// ───── Holiday list (office-wise) ─────
offices: OfficeLite[] = [];
holidayRows: HolidayRow[] = [];
holidayListLoading = false;
/** Filter values for the list panel. */
filterOfficeId: number | '' = '';
filterSearch = '';
filterType: '' | 'GAZETTED' | 'CUSTOM' = '';
/** Indian bank gazetted holidays by year */
private readonly gazettedHolidays: Record<number, { month: number; day: number; name: string }[]> = {
2025: [
@@ -153,19 +213,267 @@ export class BankCalendarComponent implements OnInit {
];
this.mergeGazettedHolidays();
this.buildCalendar();
this.rebuildHolidayRows();
// Try loading from API
this.organizationService.getOffices().subscribe((offices: any[]) => {
if (offices?.length > 0) {
this.organizationService.getHolidays(offices[0].id.toString()).subscribe((holidayData: any[]) => {
this.holidays = holidayData || [];
this.mergeGazettedHolidays();
this.buildCalendar();
// Pull offices then fan-out a per-office holiday fetch so each office's
// calendar entries are surfaced in the list panel (calendar grid still
// unions everything for the year-at-a-glance view).
this.holidayListLoading = true;
this.organizationService.getOffices().subscribe({
next: (offices: any[]) => {
this.offices = (offices || []).map((o: any) => ({ id: o.id, name: o.name }));
if (this.offices.length === 0) {
this.holidayListLoading = false;
return;
}
const requests = this.offices.map((o) =>
this.organizationService.getHolidays(o.id.toString()).pipe(catchError(() => of([])))
);
forkJoin(requests).subscribe({
next: (perOffice: any[]) => {
// Tag every holiday with its source office and merge duplicates by id.
const byId = new Map<number, any>();
const unkeyed: any[] = [];
perOffice.forEach((rows: any[], idx: number) => {
const office = this.offices[idx];
(rows || []).forEach((h: any) => {
if (h.id != null) {
if (!byId.has(h.id)) {
byId.set(h.id, { ...h, __officeIds: new Set<number>(), __officeNames: new Set<string>() });
}
const merged = byId.get(h.id);
merged.__officeIds.add(office.id);
merged.__officeNames.add(office.name);
} else {
unkeyed.push({
...h,
__officeIds: new Set<number>([office.id]),
__officeNames: new Set<string>([office.name])
});
}
});
});
this.holidays = [
...Array.from(byId.values()),
...unkeyed
];
this.mergeGazettedHolidays();
this.buildCalendar();
this.rebuildHolidayRows();
this.holidayListLoading = false;
},
error: () => {
this.holidayListLoading = false;
}
});
},
error: () => {
this.holidayListLoading = false;
}
});
}
/** Flatten the holidays array into a presentable list with office labels. */
private rebuildHolidayRows(): void {
this.holidayRows = this.holidays.map((h: any) => {
const officeIds: number[] = Array.from((h.__officeIds as Set<number>) ?? new Set());
const officeNames: string[] = Array.from((h.__officeNames as Set<string>) ?? new Set());
const isGazetted = h.id == null;
return {
id: h.id ?? null,
name: h.name || 'Holiday',
fromDate: h.fromDate,
toDate: h.toDate || h.fromDate,
description: h.description,
officeIds,
officeNames: officeNames.length ? officeNames : isGazetted ? ['All Offices (Gazetted)'] : ['—'],
status: h?.status?.value || h?.status?.code || 'Active',
reschedulingType: h?.reschedulingType?.id || h?.reschedulingType,
repaymentsRescheduledTo: h?.repaymentsRescheduledTo,
isGazetted,
raw: h
};
});
// Sort by date asc
this.holidayRows.sort((a, b) => {
const da = (a.fromDate?.[0] ?? 0) * 10000 + (a.fromDate?.[1] ?? 0) * 100 + (a.fromDate?.[2] ?? 0);
const db = (b.fromDate?.[0] ?? 0) * 10000 + (b.fromDate?.[1] ?? 0) * 100 + (b.fromDate?.[2] ?? 0);
return da - db;
});
}
// ───── Filters / actions on the holiday list ─────
get filteredHolidayRows(): HolidayRow[] {
const q = this.filterSearch.trim().toLowerCase();
return this.holidayRows.filter((r) => {
if (this.filterOfficeId !== '' && !r.officeIds.includes(this.filterOfficeId as number)) {
// Gazetted entries have no officeIds — only hide when a specific office is selected.
if (!r.isGazetted) return false;
}
if (this.filterType === 'GAZETTED' && !r.isGazetted) return false;
if (this.filterType === 'CUSTOM' && r.isGazetted) return false;
if (q) {
const haystack = `${r.name} ${r.description ?? ''} ${r.officeNames.join(' ')}`.toLowerCase();
if (!haystack.includes(q)) return false;
}
return true;
});
}
formatRowDate(d?: number[]): string {
if (!d || d.length < 3) return '—';
const date = new Date(d[0], d[1] - 1, d[2]);
return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
rowDayName(d?: number[]): string {
if (!d || d.length < 3) return '';
const date = new Date(d[0], d[1] - 1, d[2]);
return date.toLocaleDateString('en-IN', { weekday: 'short' });
}
clearListFilters(): void {
this.filterOfficeId = '';
this.filterSearch = '';
this.filterType = '';
}
trackHolidayRow(_idx: number, row: HolidayRow): string {
return row.id != null ? `id-${row.id}` : `g-${row.fromDate?.join('-')}-${row.name}`;
}
/** Open the dialog pre-populated for the given holiday. */
async editHoliday(row: HolidayRow): Promise<void> {
if (row.isGazetted || row.id == null) {
this.snack.open('Gazetted RBI holidays are read-only. Add a custom holiday to override.', 'OK', {
duration: 3500
});
return;
}
const { AddHolidayDialogComponent } = await import('./add-holiday-dialog/add-holiday-dialog.component');
const ref = this.dialog.open(AddHolidayDialogComponent, {
width: '600px',
maxWidth: '95vw',
maxHeight: '90vh',
autoFocus: false,
panelClass: 'add-holiday-dialog-panel',
data: {
mode: 'edit',
holidayId: row.id,
name: row.name,
description: row.description,
fromDate: this.toJsDate(row.fromDate),
toDate: this.toJsDate(row.toDate),
reschedulingType: row.reschedulingType,
repaymentsRescheduledTo: this.toJsDate(row.repaymentsRescheduledTo),
officeId: row.officeIds[0]
}
});
ref.afterClosed().subscribe((updated: boolean) => {
if (updated) {
this.showSuccess('Holiday Updated', `Changes to "${row.name}" have been saved.`);
this.loadWorkingDays();
}
});
}
/** Polished success modal (matches the rest of Nexora — no more bare snackbars). */
private showSuccess(title: string, message: string): void {
this.dialog.open(SuccessDialogComponent, {
data: { title, message, buttonLabel: 'OK' },
panelClass: 'sd-panel'
});
}
/** Polished error modal — body string is rendered as the only field. */
private showError(message: string): void {
this.dialog.open(ErrorDialogComponent, { data: message });
}
/** True when the holiday is already active and can't be re-activated. */
isHolidayActive(row: HolidayRow): boolean {
const s = (row.status || '').toLowerCase();
return s === 'active' || s === 'holidaystatustype.active';
}
/**
* Activate the holiday — mirrors the legacy view-holidays "Activate"
* action. Confirmed via the shared ConfirmationDialog and POSTed with
* `command=activate`.
*/
activateHoliday(row: HolidayRow): void {
if (row.isGazetted || row.id == null) {
this.snack.open('Gazetted RBI holidays are already in effect — no activation needed.', 'OK', { duration: 3500 });
return;
}
if (this.isHolidayActive(row)) {
this.snack.open(`"${row.name}" is already active.`, 'OK', { duration: 2500 });
return;
}
const ref = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: this.translate.instant('labels.heading.Holiday'),
dialogContext:
this.translate.instant('labels.dialogContext.Are you sure you want to activate') +
` ${row.name} ` +
this.translate.instant('labels.dialogContext.holiday')
}
});
ref.afterClosed().subscribe((response: any) => {
if (!response?.confirm) return;
this.organizationService.activateHoliday(row.id!.toString()).subscribe({
next: () => {
this.showSuccess('Holiday Activated', `"${row.name}" is now active.`);
this.loadWorkingDays();
},
error: () => {
this.showError(`Failed to activate "${row.name}". Please try again.`);
}
});
});
}
/** Copy the holiday's numeric ID to the clipboard (handy for support tickets). */
copyHolidayId(row: HolidayRow): void {
if (row.id == null) {
this.snack.open('Gazetted holidays have no server ID to copy.', 'OK', { duration: 2500 });
return;
}
const text = String(row.id);
navigator.clipboard
?.writeText(text)
.then(() => this.snack.open(`Copied holiday ID ${text} to clipboard.`, 'OK', { duration: 2000 }))
.catch(() => this.snack.open('Could not access clipboard.', 'OK', { duration: 2500 }));
}
/** Confirm + delete via shared DeleteDialog. */
removeHoliday(row: HolidayRow): void {
if (row.isGazetted || row.id == null) {
this.snack.open('Gazetted RBI holidays cannot be removed — they are seeded locally.', 'OK', { duration: 3500 });
return;
}
const ref = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `holiday "${row.name}" (${this.formatRowDate(row.fromDate)})` }
});
ref.afterClosed().subscribe((response: any) => {
if (!response?.delete) return;
this.organizationService.deleteHoliday(row.id!.toString()).subscribe({
next: () => {
this.showSuccess('Holiday Removed', `"${row.name}" has been deleted.`);
this.loadWorkingDays();
},
error: () => {
this.showError(`Failed to remove "${row.name}". Please try again.`);
}
});
});
}
private toJsDate(d?: number[]): Date | undefined {
if (!d || d.length < 3) return undefined;
return new Date(d[0], d[1] - 1, d[2]);
}
/** Merge static gazetted holidays into the holidays array (API format) */
private mergeGazettedHolidays() {
const gazetted = this.gazettedHolidays[this.currentYear] || [];
@@ -358,22 +666,19 @@ export class BankCalendarComponent implements OnInit {
previousYear() {
this.currentYear--;
this.holidays = [];
this.mergeGazettedHolidays();
this.buildCalendar();
this.loadWorkingDays();
}
nextYear() {
this.currentYear++;
this.holidays = [];
this.mergeGazettedHolidays();
this.buildCalendar();
this.loadWorkingDays();
}
goToCurrentYear() {
this.currentYear = new Date().getFullYear();
this.holidays = [];
this.mergeGazettedHolidays();
this.buildCalendar();
this.loadWorkingDays();
}
printHolidayList() {
@@ -563,9 +868,334 @@ export class BankCalendarComponent implements OnInit {
dialogRef.afterClosed().subscribe((result: boolean) => {
if (result) {
// Reload holidays after successful creation
this.showSuccess('Holiday Added', 'The holiday has been saved and applied to the calendar.');
this.loadWorkingDays();
}
});
}
// ───── Excel import ─────
/**
* Generate and download the holiday-import template — one header row, two
* sample rows, and a notes block describing each column. Office names must
* match the bank's office list verbatim; reschedulingType is 1 (no repayment
* rescheduling) or 2 (move repayments to a specific date).
*/
async downloadImportTemplate(): Promise<void> {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'CBS Nexora';
workbook.created = new Date();
const sheet = workbook.addWorksheet('Holidays');
const headers = [
'Name *',
'From Date * (YYYY-MM-DD)',
'To Date * (YYYY-MM-DD)',
'Office Name *',
'Description',
'Rescheduling Type * (1=No Reschedule, 2=Move To Date)',
'Repayments Rescheduled To (YYYY-MM-DD, only if type=2)'
];
sheet.columns = [
{ width: 28 },
{ width: 24 },
{ width: 24 },
{ width: 24 },
{ width: 30 },
{ width: 36 },
{ width: 32 }];
const headerRow = sheet.addRow(headers);
headerRow.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 11 };
headerRow.alignment = { vertical: 'middle', horizontal: 'left' };
headerRow.eachCell((cell) => {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0D47A1' } };
cell.border = { bottom: { style: 'medium', color: { argb: 'FF0D47A1' } } };
});
headerRow.height = 28;
// Sample rows (a single-day holiday + a multi-day one with rescheduling).
sheet.addRow([
'Founder’s Day',
'2026-09-15',
'2026-09-15',
'Head Office',
'Annual founder anniversary holiday',
1,
''
]);
sheet.addRow([
'Branch Inauguration Week',
'2026-11-02',
'2026-11-04',
'Mumbai — Andheri (E)',
'New branch opening',
2,
'2026-11-05'
]);
sheet.getRow(2).getCell(7).note = 'Leave blank when Rescheduling Type = 1';
// Notes block
sheet.addRow([]);
const noteHeader = sheet.addRow([
'INSTRUCTIONS — read before importing'
]);
sheet.mergeCells(`A${noteHeader.number}:G${noteHeader.number}`);
noteHeader.font = { bold: true, color: { argb: 'FFB45309' }, size: 11 };
noteHeader.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFEF3C7' } };
noteHeader.height = 22;
const notes = [
'1. Fields marked * are required. Rows missing required fields will be skipped.',
'2. Office Name must match the bank office name EXACTLY (case-insensitive) — see the office list on the calendar page.',
'3. Dates must be in YYYY-MM-DD format. To Date >= From Date.',
'4. Rescheduling Type: 1 = no repayment reschedule, 2 = move repayments to a target date (column 7 required).',
'5. Do not change the header row. Rows below the header are read top-to-bottom; empty rows end the import.',
'6. Duplicates against existing holidays will be REJECTED by the server — no data is overwritten.'
];
notes.forEach((n) => {
const r = sheet.addRow([n]);
sheet.mergeCells(`A${r.number}:G${r.number}`);
r.font = { color: { argb: 'FF334155' }, size: 10.5 };
});
// Freeze the header row so users keep their bearings while scrolling.
sheet.views = [{ state: 'frozen', ySplit: 1 }];
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'holiday-import-template.xlsx';
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 0);
this.snack.open('Template downloaded — fill it in and click "Import Holidays".', 'OK', { duration: 3000 });
}
/** Trigger the hidden file input when "Import Holidays" is clicked. */
triggerImport(): void {
this.importInput?.nativeElement?.click();
}
async onImportFileChosen(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
// Always reset so re-selecting the same file fires the change event again.
if (input) input.value = '';
if (!file) return;
if (!file.name.toLowerCase().endsWith('.xlsx')) {
this.snack.open('Please select an .xlsx file exported from the holiday template.', 'OK', { duration: 3500 });
return;
}
this.importing = true;
this.importResult = null;
try {
const buffer = await file.arrayBuffer();
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer);
const sheet = workbook.worksheets[0];
if (!sheet) {
this.snack.open('The workbook has no worksheets.', 'OK', { duration: 3500 });
this.importing = false;
return;
}
const rows: { rowNum: number; values: any[] }[] = [];
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
if (rowNumber === 1) return; // header
// Treat the "INSTRUCTIONS" notes block as a terminator — first cell
// starts with 'INSTRUCTIONS' or row has only 1 non-empty cell after a blank.
const first = (row.getCell(1).value ?? '').toString();
if (first.toUpperCase().startsWith('INSTRUCTIONS') || first.match(/^\d+\.\s/)) return;
const values = [
row.getCell(1).value,
row.getCell(2).value,
row.getCell(3).value,
row.getCell(4).value,
row.getCell(5).value,
row.getCell(6).value,
row.getCell(7).value
];
// Skip fully empty rows
if (values.every((v) => v == null || v === '')) return;
rows.push({ rowNum: rowNumber, values });
});
if (rows.length === 0) {
this.snack.open('No data rows found in the workbook.', 'OK', { duration: 3500 });
this.importing = false;
return;
}
await this.processImportRows(rows);
} catch {
this.snack.open('Failed to read the Excel file. Make sure it is a valid .xlsx.', 'OK', { duration: 4000 });
} finally {
this.importing = false;
}
}
/** Process parsed rows sequentially, collecting per-row outcomes. */
private async processImportRows(rows: { rowNum: number; values: any[] }[]): Promise<void> {
// Use the EXACT same formatting path as the working AddHolidayDialog —
// dateFormat sent in the payload is the Angular-style pattern (e.g.
// 'dd MMMM yyyy') and the formatted value goes through Dates utility
// which converts it to moment tokens and respects the user's locale.
const dateFormat = this.settings.dateFormat;
const locale = this.settings.language.code;
const officeByName = new Map<string, OfficeLite>();
this.offices.forEach((o) => officeByName.set(o.name.trim().toLowerCase(), o));
const result: ImportResult = { total: rows.length, imported: 0, skipped: [] };
for (const { rowNum, values } of rows) {
const [
nameRaw,
fromRaw,
toRaw,
officeRaw,
descRaw,
typeRaw,
reschRaw
] = values;
const name = (nameRaw ?? '').toString().trim();
const officeName = (officeRaw ?? '').toString().trim();
const description = (descRaw ?? '').toString().trim();
const typeNum = Number(typeRaw);
const fromDate = this.parseImportDate(fromRaw);
const toDate = this.parseImportDate(toRaw);
if (!name) {
result.skipped.push({ row: rowNum, reason: 'missing Name' });
continue;
}
if (!fromDate || !toDate) {
result.skipped.push({ row: rowNum, reason: 'invalid From/To Date' });
continue;
}
if (toDate.getTime() < fromDate.getTime()) {
result.skipped.push({ row: rowNum, reason: 'To Date is before From Date' });
continue;
}
const office = officeByName.get(officeName.toLowerCase());
if (!office) {
result.skipped.push({ row: rowNum, reason: `unknown office "${officeName}"` });
continue;
}
if (typeNum !== 1 && typeNum !== 2) {
result.skipped.push({ row: rowNum, reason: 'Rescheduling Type must be 1 or 2' });
continue;
}
let reschDate: Date | null = null;
if (typeNum === 2) {
reschDate = this.parseImportDate(reschRaw);
if (!reschDate) {
result.skipped.push({ row: rowNum, reason: 'type=2 requires Repayments Rescheduled To date' });
continue;
}
}
const payload: any = {
name,
description,
fromDate: this.dateUtils.formatDateAsString(fromDate, dateFormat),
toDate: this.dateUtils.formatDateAsString(toDate, dateFormat),
reschedulingType: typeNum,
dateFormat,
locale,
offices: [{ officeId: office.id }]
};
if (reschDate) {
payload.repaymentsRescheduledTo = this.dateUtils.formatDateAsString(reschDate, dateFormat);
}
try {
await new Promise<void>((resolve, reject) => {
this.organizationService.createHoliday(payload).subscribe({
next: () => resolve(),
error: (err) => reject(err)
});
});
result.imported++;
} catch (err: any) {
const apiMsg =
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.developerMessage || err?.message || 'API error';
result.skipped.push({ row: rowNum, reason: apiMsg });
}
}
this.importResult = result;
if (result.imported > 0 && result.skipped.length === 0) {
this.showSuccess(
'Holidays Imported',
`All ${result.imported} row${result.imported === 1 ? '' : 's'} imported successfully.`
);
this.loadWorkingDays();
} else if (result.imported > 0) {
this.showSuccess(
'Import Partially Complete',
`${result.imported} of ${result.total} holidays imported. ${result.skipped.length} row${result.skipped.length === 1 ? ' was' : 's were'} skipped — see the highlighted panel for details.`
);
this.loadWorkingDays();
} else {
this.showError(
`Import finished — 0 of ${result.total} rows could be added. See the highlighted panel for details.`
);
}
}
dismissImportResult(): void {
this.importResult = null;
}
/**
* Parse a cell value into a Date.
*
* ExcelJS returns date-typed cells as JS `Date` objects pinned to UTC midnight
* — e.g. "2026-09-15" comes back as `2026-09-15T00:00:00.000Z`. If we read
* `.getDate()` / `.getMonth()` in a timezone west of UTC, we'd land on the
* previous day. So when the value comes in as a Date, we extract the UTC
* components and rebuild a local-noon Date so downstream formatting (moment)
* always sees the day the user typed.
*/
private parseImportDate(v: any): Date | null {
if (v == null || v === '') return null;
if (v instanceof Date) {
if (Number.isNaN(v.getTime())) return null;
return new Date(v.getUTCFullYear(), v.getUTCMonth(), v.getUTCDate(), 12, 0, 0);
}
// ExcelJS may also surface a date-formatted cell as a number (serial date)
// when the cell type isn't decisively a date — handle that defensively.
if (typeof v === 'number') {
// Excel epoch: 1899-12-30 (accounting for the 1900 leap-year bug).
const ms = Math.round((v - 25569) * 86400 * 1000);
const d = new Date(ms);
if (Number.isNaN(d.getTime())) return null;
return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 12, 0, 0);
}
const s = v.toString().trim();
// Accept YYYY-MM-DD primarily; fall back to Date.parse for resilience.
const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (iso) {
const d = new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]), 12, 0, 0);
return Number.isNaN(d.getTime()) ? null : d;
}
const parsed = new Date(s);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
}