feat: non-individual entity UI improvements — hide individual-only sections, fix legal form display, send extended fields to API

- Hide Family Members tab/section for non-individual entities in client view, create flow, and preview
- Hide Financial & Classification section for non-individual entities in personal data and preview
- Fix legal form column in clients list — fetch legalForm from client API for correct display
- Add person/business icon before client name in list rows
- Remove unsupportedNpd filter — send authorizedSignatory, additionalInfo, dateOfRegistration to API
- Add registration number format hint (mat-hint) based on constitution type
- Add spacing between verification row and constitution type dropdowns
- Reduce table cell padding and prevent action buttons from being clipped
- Add missing MPL-2.0 license headers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-04-04 15:10:51 +05:30
co-authored by Claude Opus 4.6
parent ab01111424
commit b890d24bb7
522 changed files with 51991 additions and 21885 deletions
@@ -0,0 +1,116 @@
<!--
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/.
-->
<div class="hol-dialog">
<!-- Header -->
<div class="hol-header">
<div class="hol-header__left">
<div class="hol-header__icon"><fa-icon icon="calendar-alt"></fa-icon></div>
<span class="hol-header__title">Add Holiday</span>
</div>
<button class="hol-header__close" (click)="dialogRef.close()">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<!-- Body -->
<mat-dialog-content class="hol-body">
<form [formGroup]="holidayForm">
<div class="hol-row">
<mat-form-field appearance="outline" class="hol-field">
<mat-label>Holiday Name</mat-label>
<input matInput required formControlName="name" placeholder="e.g. Republic Day" />
@if (holidayForm.controls.name.hasError('required')) {
<mat-error>Name is <strong>required</strong></mat-error>
}
</mat-form-field>
</div>
<div class="hol-row">
<mat-form-field appearance="outline" class="hol-field">
<mat-label>Description</mat-label>
<input matInput formControlName="description" placeholder="Optional description" />
</mat-form-field>
</div>
<div class="hol-row hol-row--two">
<mat-form-field appearance="outline" class="hol-field" (click)="fromPicker.open()">
<mat-label>From Date</mat-label>
<input matInput [min]="minDate" [max]="maxDate" [matDatepicker]="fromPicker" required formControlName="fromDate" />
<mat-datepicker-toggle matSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
@if (holidayForm.controls.fromDate.hasError('required')) {
<mat-error>From Date is <strong>required</strong></mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="hol-field" (click)="toPicker.open()">
<mat-label>To Date</mat-label>
<input matInput [min]="minDate" [max]="maxDate" [matDatepicker]="toPicker" required formControlName="toDate" />
<mat-datepicker-toggle matSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
@if (holidayForm.controls.toDate.hasError('required')) {
<mat-error>To Date is <strong>required</strong></mat-error>
}
</mat-form-field>
</div>
<div class="hol-row hol-row--two">
<mat-form-field appearance="outline" class="hol-field">
<mat-label>Repayment Scheduling</mat-label>
<mat-select required formControlName="reschedulingType">
@for (type of repaymentSchedulingTypes; track type) {
<mat-option [value]="type.id">{{ type.value }}</mat-option>
}
</mat-select>
@if (holidayForm.controls.reschedulingType.hasError('required')) {
<mat-error>Required</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="hol-field">
<mat-label>Office</mat-label>
<mat-select required formControlName="officeId">
@for (office of offices; track office) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
@if (holidayForm.controls.officeId.hasError('required')) {
<mat-error>Required</mat-error>
}
</mat-form-field>
</div>
@if (holidayForm.contains('repaymentsRescheduledTo')) {
<div class="hol-row">
<mat-form-field appearance="outline" class="hol-field" (click)="reschPicker.open()">
<mat-label>Repayments Rescheduled To</mat-label>
<input matInput [min]="minDate" [max]="maxDate" [matDatepicker]="reschPicker" required formControlName="repaymentsRescheduledTo" />
<mat-datepicker-toggle matSuffix [for]="reschPicker"></mat-datepicker-toggle>
<mat-datepicker #reschPicker></mat-datepicker>
</mat-form-field>
</div>
}
</form>
</mat-dialog-content>
<!-- Actions -->
<mat-dialog-actions class="hol-actions">
<button mat-stroked-button class="hol-actions__cancel" (click)="dialogRef.close()">Cancel</button>
<button
mat-flat-button
color="primary"
class="hol-actions__submit"
[disabled]="holidayForm.invalid || submitting"
(click)="submit()"
>
<fa-icon icon="check" class="hol-actions__icon"></fa-icon>
{{ submitting ? 'Saving...' : 'Add Holiday' }}
</button>
</mat-dialog-actions>
</div>
@@ -0,0 +1,120 @@
/**
* 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/.
*/
$blue: #1e88e5;
$navy: #0d1b2a;
.hol-dialog {
max-width: 560px;
width: 560px;
}
// Header
.hol-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px 14px;
border-bottom: 1px solid #e8eaed;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
width: 38px;
height: 38px;
border-radius: 10px;
background: #eff6ff;
display: flex;
align-items: center;
justify-content: center;
color: $blue;
font-size: 16px;
}
&__title {
font-size: 17px;
font-weight: 700;
color: $navy;
}
&__close {
width: 32px;
height: 32px;
border: none;
background: #f1f3f4;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #5f6368;
transition: all 0.15s;
&:hover {
background: #e8eaed;
color: $navy;
}
}
}
// Body
.hol-body {
padding: 18px 24px !important;
overflow: visible !important;
max-height: 65vh !important;
}
.hol-row {
margin-bottom: 4px;
&--two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
}
.hol-field {
width: 100%;
}
// Actions
.hol-actions {
padding: 12px 24px 18px !important;
border-top: 1px solid #e8eaed;
margin: 0 !important;
display: flex !important;
justify-content: flex-end !important;
gap: 10px !important;
&__cancel {
border-radius: 8px !important;
font-size: 13px !important;
font-weight: 600 !important;
padding: 0 20px !important;
height: 38px !important;
}
&__submit {
border-radius: 8px !important;
font-size: 13px !important;
font-weight: 600 !important;
padding: 0 20px !important;
height: 38px !important;
box-shadow: 0 1px 3px rgba($blue, 0.3) !important;
}
&__icon {
margin-right: 6px;
font-size: 12px;
}
}
@@ -0,0 +1,134 @@
/**
* 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, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators, UntypedFormControl } from '@angular/forms';
import { MatDialogRef, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
import { OrganizationService } from '../../organization/organization.service';
import { SettingsService } from '../../settings/settings.service';
import { Dates } from '../../core/utils/dates';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@Component({
selector: 'mifosx-add-holiday-dialog',
templateUrl: './add-holiday-dialog.component.html',
styleUrls: ['./add-holiday-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatDialogContent,
MatDialogActions
]
})
export class AddHolidayDialogComponent implements OnInit {
private fb = inject(UntypedFormBuilder);
private organizationService = inject(OrganizationService);
private settings = inject(SettingsService);
private dateUtils = inject(Dates);
public dialogRef = inject(MatDialogRef<AddHolidayDialogComponent>);
holidayForm: UntypedFormGroup;
offices: any[] = [];
repaymentSchedulingTypes: any[] = [];
submitting = false;
minDate = new Date(2000, 0, 1);
maxDate = new Date(2100, 0, 1);
ngOnInit() {
this.holidayForm = this.fb.group({
name: ['', Validators.required],
description: [''],
fromDate: ['', Validators.required],
toDate: ['', Validators.required],
reschedulingType: ['', Validators.required],
officeId: ['', Validators.required]
});
this.loadData();
this.buildDependencies();
}
private loadData() {
this.organizationService.getOffices().subscribe((data: any[]) => {
this.offices = data || [];
if (this.offices.length > 0) {
this.holidayForm.patchValue({ officeId: this.offices[0].id });
}
});
this.organizationService.getHolidayTemplate().subscribe((data: any) => {
this.repaymentSchedulingTypes = data || [];
});
}
private buildDependencies() {
this.holidayForm.get('reschedulingType').valueChanges.subscribe((option: any) => {
if (option === 2) {
this.holidayForm.addControl('repaymentsRescheduledTo', new UntypedFormControl('', Validators.required));
} else {
this.holidayForm.removeControl('repaymentsRescheduledTo');
}
});
}
submit() {
if (this.holidayForm.invalid || this.submitting) return;
this.submitting = true;
const formData = { ...this.holidayForm.value };
const dateFormat = this.settings.dateFormat;
const locale = this.settings.language.code;
const momentFormat = 'DD MMMM YYYY';
const coerceDate = (value: unknown): Date | null => {
if (value instanceof Date) return value;
if (value == null || value === '') return null;
const d = new Date(value as any);
return Number.isNaN(d.getTime()) ? null : d;
};
const fromDate = coerceDate(formData.fromDate);
const toDate = coerceDate(formData.toDate);
if (!fromDate || !toDate) {
this.submitting = false;
return;
}
formData.fromDate = this.dateUtils.formatDateAsString(fromDate, momentFormat);
formData.toDate = this.dateUtils.formatDateAsString(toDate, momentFormat);
if (this.holidayForm.contains('repaymentsRescheduledTo')) {
const reschDate = coerceDate(formData.repaymentsRescheduledTo);
if (!reschDate) {
this.submitting = false;
return;
}
formData.repaymentsRescheduledTo = this.dateUtils.formatDateAsString(reschDate, momentFormat);
}
const officeId = formData.officeId;
delete formData.officeId;
const payload = {
...formData,
dateFormat,
locale,
offices: [{ officeId }]
};
this.organizationService.createHoliday(payload).subscribe({
next: () => {
this.dialogRef.close(true);
},
error: () => {
this.submitting = false;
}
});
}
}
@@ -0,0 +1,34 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { BankCalendarComponent } from './bank-calendar.component';
const routes: Routes = [
Route.withShell([
{
path: 'bank-calendar',
data: { title: 'Bank Calendar', breadcrumb: 'Bank Calendar' },
children: [
{
path: '',
component: BankCalendarComponent
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class BankCalendarRoutingModule {}
@@ -0,0 +1,134 @@
<!--
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/.
-->
<div class="cal-container">
<!-- Header -->
<div class="cal-header">
<div class="cal-header__left">
<fa-icon icon="calendar-alt" class="cal-header__icon"></fa-icon>
<span class="cal-header__title">Bank Calendar</span>
</div>
<div class="cal-header__nav">
<button class="cal-nav-btn" (click)="previousYear()" matTooltip="Previous Year">
<fa-icon icon="chevron-left"></fa-icon>
</button>
<span class="cal-header__year">{{ currentYear }}</span>
<button class="cal-nav-btn" (click)="nextYear()" matTooltip="Next Year">
<fa-icon icon="chevron-right"></fa-icon>
</button>
<button class="cal-today-btn" (click)="goToCurrentYear()">Today</button>
</div>
</div>
<!-- Overall Stats -->
<div class="cal-stats">
<div class="cal-stat working">
<fa-icon icon="briefcase" class="cal-stat__icon"></fa-icon>
<div class="cal-stat__info">
<div class="cal-stat__value">{{ overallStats.working }}</div>
<div class="cal-stat__label">Working Days</div>
</div>
</div>
<div class="cal-stat sundays">
<fa-icon icon="ban" class="cal-stat__icon"></fa-icon>
<div class="cal-stat__info">
<div class="cal-stat__value">{{ overallStats.sundays }}</div>
<div class="cal-stat__label">Sundays</div>
</div>
</div>
<div class="cal-stat sat2nd4th">
<fa-icon icon="calendar-times" class="cal-stat__icon"></fa-icon>
<div class="cal-stat__info">
<div class="cal-stat__value">{{ overallStats.sat2nd4th }}</div>
<div class="cal-stat__label">2nd &amp; 4th Sat</div>
</div>
</div>
<div class="cal-stat holidays">
<fa-icon icon="star" class="cal-stat__icon"></fa-icon>
<div class="cal-stat__info">
<div class="cal-stat__value">{{ overallStats.holidays }}</div>
<div class="cal-stat__label">Holidays</div>
</div>
</div>
<div class="cal-stat total">
<fa-icon icon="calendar" class="cal-stat__icon"></fa-icon>
<div class="cal-stat__info">
<div class="cal-stat__value">{{ overallStats.totalDays }}</div>
<div class="cal-stat__label">Total Days</div>
</div>
</div>
</div>
<!-- Legend -->
<div class="cal-legend">
<div class="cal-legend__items">
<div class="cal-legend__item"><span class="cal-legend__dot working"></span> Working Day</div>
<div class="cal-legend__item"><span class="cal-legend__dot sunday"></span> Sunday</div>
<div class="cal-legend__item"><span class="cal-legend__dot sat2nd4th"></span> 2nd / 4th Saturday</div>
<div class="cal-legend__item"><span class="cal-legend__dot holiday"></span> Holiday</div>
<div class="cal-legend__item"><span class="cal-legend__dot today"></span> Today</div>
</div>
<div class="cal-legend__actions">
<button class="cal-legend__print-btn" (click)="printHolidayList()">
<fa-icon icon="print"></fa-icon> Print Holiday List
</button>
<button class="cal-legend__add-btn" (click)="openAddHolidayDialog()">
<fa-icon icon="plus"></fa-icon> Add Holiday
</button>
</div>
</div>
<!-- Monthly Grids -->
<div class="cal-months">
@for (month of months; track month.month) {
<div class="cal-month-card">
<div class="cal-month-card__header">
<span class="cal-month-card__name">{{ month.name }}</span>
<span class="cal-month-card__counts">
<span class="count-working">{{ month.workingDays }}W</span>
<span class="count-off">{{ month.nonWorkingDays }}Off</span>
</span>
</div>
<div class="cal-grid">
<!-- Week day headers -->
@for (wd of weekDays; track wd) {
<div class="cal-grid__wday" [class.cal-grid__wday--sun]="wd === 'Sun'">{{ wd }}</div>
}
<!-- Day cells -->
@for (day of month.days; track $index) {
<div
class="cal-grid__day"
[class.cal-grid__day--other]="!day.isCurrentMonth"
[class.cal-grid__day--today]="day.isToday"
[class.cal-grid__day--sunday]="day.isSunday && day.isCurrentMonth"
[class.cal-grid__day--sat2nd4th]="(day.is2ndSaturday || day.is4thSaturday) && day.isCurrentMonth"
[class.cal-grid__day--holiday]="day.isHoliday && day.isCurrentMonth"
[class.cal-grid__day--working]="day.isWorking && day.isCurrentMonth"
[matTooltip]="day.holidayName || (day.is2ndSaturday ? '2nd Saturday Off' : day.is4thSaturday ? '4th Saturday Off' : day.isSunday && day.isCurrentMonth ? 'Sunday' : '')"
>
{{ day.isCurrentMonth ? day.date : '' }}
</div>
}
</div>
<!-- Month summary bar -->
<div class="cal-month-card__summary">
@if (month.holidays > 0) {
<span class="summary-tag holiday">{{ month.holidays }} Holiday{{ month.holidays > 1 ? 's' : '' }}</span>
}
<span class="summary-tag sun">{{ month.sundays }} Sun</span>
@if (month.secondFourthSat > 0) {
<span class="summary-tag sat">{{ month.secondFourthSat }} Sat Off</span>
}
</div>
</div>
}
</div>
</div>
@@ -0,0 +1,466 @@
/**
* 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/.
*/
$navy: #0d1b2a;
$blue: #1e88e5;
$green: #10b981;
$orange: #f59e0b;
$red: #ef4444;
$purple: #7c3aed;
// ─── Container ───
.cal-container {
padding: 20px 24px;
max-width: 1440px;
margin: 0 auto;
background: #f0f2f5;
min-height: 100vh;
}
// ─── Header ───
.cal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
&__left {
display: flex;
align-items: center;
gap: 10px;
}
&__icon {
font-size: 22px;
color: $blue;
}
&__title {
font-size: 20px;
font-weight: 700;
color: $navy;
}
&__nav {
display: flex;
align-items: center;
gap: 8px;
}
&__year {
font-size: 20px;
font-weight: 800;
color: $navy;
min-width: 60px;
text-align: center;
}
}
.cal-nav-btn {
width: 34px;
height: 34px;
border: 1px solid #d0d5dd;
background: #fff;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #475569;
transition: all 0.15s;
&:hover {
background: $blue;
color: #fff;
border-color: $blue;
}
}
.cal-today-btn {
height: 34px;
padding: 0 16px;
border: 1px solid $blue;
background: #eff6ff;
color: $blue;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: $blue;
color: #fff;
}
}
// ─── Stats Row ───
.cal-stats {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 14px;
}
.cal-stat {
background: #fff;
border-radius: 10px;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 12px;
border: 1px solid #e8ecf0;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
&__icon {
font-size: 18px;
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__value {
font-size: 22px;
font-weight: 800;
color: $navy;
}
&__label {
font-size: 11px;
color: #64748b;
font-weight: 500;
}
&.working .cal-stat__icon {
background: #ecfdf5;
color: $green;
}
&.sundays .cal-stat__icon {
background: #fef2f2;
color: $red;
}
&.sat2nd4th .cal-stat__icon {
background: #fff7ed;
color: $orange;
}
&.holidays .cal-stat__icon {
background: #f5f3ff;
color: $purple;
}
&.total .cal-stat__icon {
background: #eff6ff;
color: $blue;
}
}
// ─── Legend ───
.cal-legend {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding: 10px 16px;
background: #fff;
border-radius: 8px;
border: 1px solid #e8ecf0;
&__items {
display: flex;
gap: 20px;
align-items: center;
flex-wrap: wrap;
}
&__item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #475569;
font-weight: 500;
}
&__actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
&__print-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 18px;
border: 1px solid #d0d5dd;
background: #fff;
color: #475569;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
&:hover {
background: #f8fafc;
border-color: #94a3b8;
color: $navy;
}
}
&__add-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 18px;
border: none;
background: $blue;
color: #fff;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
flex-shrink: 0;
&:hover {
background: darken($blue, 8%);
box-shadow: 0 2px 8px rgb(30 136 229 / 35%);
}
}
&__dot {
width: 12px;
height: 12px;
border-radius: 3px;
flex-shrink: 0;
&.working {
background: #ecfdf5;
border: 1px solid $green;
}
&.sunday {
background: #fef2f2;
border: 1px solid $red;
}
&.sat2nd4th {
background: #fff7ed;
border: 1px solid $orange;
}
&.holiday {
background: #f5f3ff;
border: 1px solid $purple;
}
&.today {
background: $blue;
border: 1px solid $blue;
}
}
}
// ─── Monthly Grid ───
.cal-months {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.cal-month-card {
background: #fff;
border-radius: 10px;
padding: 14px;
border: 1px solid #e8ecf0;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 10%);
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 2px solid #0d47a1;
}
&__name {
font-size: 14px;
font-weight: 700;
color: #0d47a1;
text-transform: uppercase;
letter-spacing: 0.5px;
}
&__counts {
display: flex;
gap: 6px;
.count-working {
font-size: 11px;
font-weight: 700;
color: $green;
background: #ecfdf5;
padding: 2px 6px;
border-radius: 4px;
}
.count-off {
font-size: 11px;
font-weight: 700;
color: $red;
background: #fef2f2;
padding: 2px 6px;
border-radius: 4px;
}
}
&__summary {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #f1f5f9;
}
}
.summary-tag {
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
&.holiday {
background: #f5f3ff;
color: $purple;
}
&.sun {
background: #fef2f2;
color: $red;
}
&.sat {
background: #fff7ed;
color: $orange;
}
}
// ─── Day Grid ───
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
&__wday {
text-align: center;
font-size: 10px;
font-weight: 700;
color: #64748b;
padding: 4px 0;
text-transform: uppercase;
&--sun {
color: $red;
}
}
&__day {
text-align: center;
font-size: 12px;
font-weight: 500;
padding: 5px 2px;
border-radius: 4px;
cursor: default;
color: #334155;
transition: all 0.1s;
&--other {
color: transparent;
}
&--working {
background: #f0fdf4;
color: #166534;
}
&--sunday {
background: #fef2f2;
color: $red;
font-weight: 700;
}
&--sat2nd4th {
background: #fff7ed;
color: #c2410c;
font-weight: 700;
}
&--holiday {
background: #f5f3ff;
color: $purple;
font-weight: 700;
}
&--today {
background: $blue !important;
color: #fff !important;
font-weight: 800;
box-shadow: 0 2px 6px rgb(30 136 229 / 40%);
}
}
}
// ─── Responsive ───
@media screen and (max-width: 1200px) {
.cal-months {
grid-template-columns: repeat(2, 1fr);
}
.cal-stats {
grid-template-columns: repeat(3, 1fr);
}
}
@media screen and (max-width: 768px) {
.cal-months {
grid-template-columns: 1fr;
}
.cal-stats {
grid-template-columns: repeat(2, 1fr);
}
.cal-legend {
flex-wrap: wrap;
gap: 10px;
}
.cal-header {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
}
@@ -0,0 +1,508 @@
/**
* 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, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
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';
interface CalendarDay {
date: number;
month: number;
year: number;
isCurrentMonth: boolean;
isToday: boolean;
isWorking: boolean;
isHoliday: boolean;
isSunday: boolean;
is2ndSaturday: boolean;
is4thSaturday: boolean;
holidayName?: string;
dayOfWeek: number; // 0=Sun, 6=Sat
}
interface MonthData {
month: number;
year: number;
name: string;
days: CalendarDay[];
workingDays: number;
nonWorkingDays: number;
holidays: number;
sundays: number;
secondFourthSat: number;
}
@Component({
selector: 'mifosx-bank-calendar',
templateUrl: './bank-calendar.component.html',
styleUrls: ['./bank-calendar.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTooltip
]
})
export class BankCalendarComponent implements OnInit {
private organizationService = inject(OrganizationService);
private dialog = inject(MatDialog);
currentYear: number;
months: MonthData[] = [];
weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
workingDaysConfig: string[] = [];
holidays: any[] = [];
overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
/** Indian bank gazetted holidays by year */
private readonly gazettedHolidays: Record<number, { month: number; day: number; name: string }[]> = {
2025: [
{ month: 1, day: 26, name: 'Republic Day' },
{ month: 2, day: 26, name: 'Maha Shivaratri' },
{ month: 3, day: 14, name: 'Holi' },
{ month: 3, day: 31, name: 'Id-ul-Fitr (Ramzan Eid)' },
{ month: 4, day: 6, name: 'Ram Navami' },
{ month: 4, day: 10, name: 'Mahavir Jayanti' },
{ month: 4, day: 14, name: 'Dr. Ambedkar Jayanti' },
{ month: 4, day: 18, name: 'Good Friday' },
{ month: 5, day: 1, name: 'May Day' },
{ month: 5, day: 12, name: 'Buddha Purnima' },
{ month: 6, day: 7, name: 'Id-ul-Zuha (Bakrid)' },
{ month: 7, day: 6, name: 'Muharram' },
{ month: 8, day: 15, name: 'Independence Day' },
{ month: 8, day: 16, name: 'Janmashtami' },
{ month: 9, day: 5, name: 'Milad-un-Nabi (Eid-e-Milad)' },
{ month: 10, day: 2, name: 'Mahatma Gandhi Jayanti' },
{ month: 10, day: 2, name: 'Dussehra (Vijaya Dashami)' },
{ month: 10, day: 20, name: 'Diwali (Deepavali)' },
{ month: 11, day: 5, name: 'Guru Nanak Jayanti' },
{ month: 12, day: 25, name: 'Christmas Day' }
],
2026: [
{ month: 1, day: 26, name: 'Republic Day' },
{ month: 3, day: 3, name: 'Holi' },
{ month: 3, day: 21, name: 'Id-ul-Fitr (Ramzan Eid)' },
{ month: 3, day: 26, name: 'Ram Navami' },
{ month: 3, day: 31, name: 'Mahavir Jayanti' },
{ month: 4, day: 1, name: 'Annual Closing of Bank Accounts' },
{ month: 4, day: 3, name: 'Good Friday' },
{ month: 5, day: 1, name: 'Buddha Purnima / May Day' },
{ month: 5, day: 27, name: 'Id-ul-Zuha (Bakrid)' },
{ month: 6, day: 26, name: 'Muharram' },
{ month: 8, day: 15, name: 'Independence Day' },
{ month: 8, day: 26, name: 'Milad-un-Nabi (Eid-e-Milad)' },
{ month: 9, day: 4, name: 'Janmashtami' },
{ month: 10, day: 2, name: 'Mahatma Gandhi Jayanti' },
{ month: 10, day: 20, name: 'Dussehra (Vijaya Dashami)' },
{ month: 11, day: 8, name: 'Diwali (Deepavali)' },
{ month: 11, day: 24, name: 'Guru Nanak Jayanti' },
{ month: 12, day: 25, name: 'Christmas Day' }
],
2027: [
{ month: 1, day: 26, name: 'Republic Day' },
{ month: 2, day: 20, name: 'Holi' },
{ month: 3, day: 11, name: 'Id-ul-Fitr (Ramzan Eid)' },
{ month: 3, day: 15, name: 'Ram Navami' },
{ month: 3, day: 21, name: 'Mahavir Jayanti' },
{ month: 3, day: 26, name: 'Good Friday' },
{ month: 4, day: 1, name: 'Annual Closing of Bank Accounts' },
{ month: 5, day: 1, name: 'May Day' },
{ month: 5, day: 2, name: 'Buddha Purnima' },
{ month: 5, day: 17, name: 'Id-ul-Zuha (Bakrid)' },
{ month: 6, day: 16, name: 'Muharram' },
{ month: 8, day: 15, name: 'Independence Day' },
{ month: 8, day: 16, name: 'Milad-un-Nabi (Eid-e-Milad)' },
{ month: 8, day: 25, name: 'Janmashtami' },
{ month: 10, day: 2, name: 'Mahatma Gandhi Jayanti' },
{ month: 10, day: 9, name: 'Dussehra (Vijaya Dashami)' },
{ month: 10, day: 28, name: 'Diwali (Deepavali)' },
{ month: 11, day: 14, name: 'Guru Nanak Jayanti' },
{ month: 12, day: 25, name: 'Christmas Day' }
]
};
ngOnInit() {
this.currentYear = new Date().getFullYear();
this.loadWorkingDays();
}
private loadWorkingDays() {
// Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off)
this.workingDaysConfig = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
this.mergeGazettedHolidays();
this.buildCalendar();
// 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();
});
}
});
}
/** Merge static gazetted holidays into the holidays array (API format) */
private mergeGazettedHolidays() {
const gazetted = this.gazettedHolidays[this.currentYear] || [];
for (const gh of gazetted) {
const exists = this.holidays.some((h: any) => {
if (h.fromDate) {
const [y, m, d] = h.fromDate;
return y === this.currentYear && m === gh.month && d === gh.day;
}
return false;
});
if (!exists) {
this.holidays.push({
name: gh.name,
fromDate: [this.currentYear, gh.month, gh.day],
toDate: [this.currentYear, gh.month, gh.day],
description: gh.name,
status: { value: 'Active' }
});
}
}
}
buildCalendar() {
this.months = [];
this.overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
for (let m = 0; m < 12; m++) {
const monthData = this.buildMonth(m, this.currentYear);
this.months.push(monthData);
this.overallStats.working += monthData.workingDays;
this.overallStats.nonWorking += monthData.nonWorkingDays;
this.overallStats.holidays += monthData.holidays;
this.overallStats.sundays += monthData.sundays;
this.overallStats.sat2nd4th += monthData.secondFourthSat;
}
this.overallStats.totalDays = this.overallStats.working + this.overallStats.nonWorking;
}
private buildMonth(month: number, year: number): MonthData {
const firstDay = new Date(year, month, 1);
const daysInMonth = new Date(year, month + 1, 0).getDate();
const startDayOfWeek = firstDay.getDay();
const today = new Date();
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const days: CalendarDay[] = [];
let workingCount = 0;
let nonWorkingCount = 0;
let holidayCount = 0;
let sundayCount = 0;
let sat2nd4thCount = 0;
// Previous month padding
const prevMonthDays = new Date(year, month, 0).getDate();
for (let i = startDayOfWeek - 1; i >= 0; i--) {
days.push({
date: prevMonthDays - i,
month: month - 1,
year,
isCurrentMonth: false,
isToday: false,
isWorking: false,
isHoliday: false,
isSunday: false,
is2ndSaturday: false,
is4thSaturday: false,
dayOfWeek: (startDayOfWeek - i - 1 + 7) % 7
});
}
// Current month days
let saturdayCount = 0;
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(year, month, d);
const dow = date.getDay();
const isSunday = dow === 0;
const isSaturday = dow === 6;
if (isSaturday) saturdayCount++;
const is2ndSat = isSaturday && saturdayCount === 2;
const is4thSat = isSaturday && saturdayCount === 4;
const isToday = date.toDateString() === today.toDateString();
// Check if holiday
const holiday = this.getHolidayForDate(year, month, d);
const isHoliday = !!holiday;
// Non-working: Sunday, 2nd Sat, 4th Sat, or holiday
const isNonWorking = isSunday || is2ndSat || is4thSat || isHoliday;
if (isSunday) sundayCount++;
if (is2ndSat || is4thSat) sat2nd4thCount++;
if (isHoliday) holidayCount++;
if (isNonWorking) {
nonWorkingCount++;
} else {
workingCount++;
}
days.push({
date: d,
month,
year,
isCurrentMonth: true,
isToday,
isWorking: !isNonWorking,
isHoliday,
isSunday,
is2ndSaturday: is2ndSat,
is4thSaturday: is4thSat,
holidayName: holiday?.name,
dayOfWeek: dow
});
}
// Next month padding
const remaining = 42 - days.length;
for (let i = 1; i <= remaining; i++) {
days.push({
date: i,
month: month + 1,
year,
isCurrentMonth: false,
isToday: false,
isWorking: false,
isHoliday: false,
isSunday: false,
is2ndSaturday: false,
is4thSaturday: false,
dayOfWeek: (days.length) % 7
});
}
return {
month,
year,
name: monthNames[month],
days,
workingDays: workingCount,
nonWorkingDays: nonWorkingCount,
holidays: holidayCount,
sundays: sundayCount,
secondFourthSat: sat2nd4thCount
};
}
private getHolidayForDate(year: number, month: number, day: number): any {
return this.holidays.find((h: any) => {
if (h.fromDate) {
const [y, m, d] = h.fromDate;
return y === year && m === month + 1 && d === day;
}
return false;
});
}
previousYear() {
this.currentYear--;
this.holidays = [];
this.mergeGazettedHolidays();
this.buildCalendar();
}
nextYear() {
this.currentYear++;
this.holidays = [];
this.mergeGazettedHolidays();
this.buildCalendar();
}
goToCurrentYear() {
this.currentYear = new Date().getFullYear();
this.holidays = [];
this.mergeGazettedHolidays();
this.buildCalendar();
}
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 &amp; 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 &amp; 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' })} &bull; CBS Nexora &bull; 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) {
// Reload holidays after successful creation
this.loadWorkingDays();
}
});
}
}
@@ -0,0 +1,22 @@
/**
* 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 { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { BankCalendarRoutingModule } from './bank-calendar-routing.module';
import { BankCalendarComponent } from './bank-calendar.component';
@NgModule({
imports: [
SharedModule,
BankCalendarRoutingModule,
BankCalendarComponent
]
})
export class BankCalendarModule {}