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