Merge branch 'feature/merge-updates' from cbsnexora into feature/ui-reports-product

Conflict resolutions:
- src/main.scss: kept both new dialog backdrops (CIC/PSL/ALM/CIMS/CKYC/Acct)
  and incoming dialog panel styles (.abd-dialog-panel, .arc-dialog-panel)
- client-general-step.component.html/.ts: kept HEAD's agriculture/non-agri
  income fields, education/degree/religion/social-category dropdowns,
  and inject() refactor matching the incoming pattern
- kyc-management.component.html/.scss/.ts: kept HEAD's Home Visit
  terminology, takeAction() method, hv-* and vkyc-* dialog styles;
  accepted incoming .km-warn-d style addition

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-04-29 19:22:00 +05:30
co-authored by Claude Opus 4.7
470 changed files with 46834 additions and 11014 deletions
+81 -23
View File
@@ -18,7 +18,12 @@
<!-- Cards Grid -->
<div class="acc-grid">
<!-- Frequent Postings -->
<a class="acc-card" [routerLink]="['journal-entries/frequent-postings']" *mifosxHasPermission="'CREATE_JOURNALENTRY'" #frequentPostings>
<a
class="acc-card"
[routerLink]="['journal-entries/frequent-postings']"
*mifosxHasPermission="'CREATE_JOURNALENTRY'"
#frequentPostings
>
<div class="acc-card__icon blue"><i class="fas fa-sync-alt"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Frequent Postings' | translate }}</span>
@@ -27,11 +32,18 @@
</a>
<!-- Create Journal Entries -->
<a class="acc-card" [routerLink]="['journal-entries/create']" *mifosxHasPermission="'CREATE_JOURNALENTRY'" #createJournalEntries>
<a
class="acc-card"
[routerLink]="['journal-entries/create']"
*mifosxHasPermission="'CREATE_JOURNALENTRY'"
#createJournalEntries
>
<div class="acc-card__icon green"><i class="fas fa-plus-circle"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Create Journal Entries' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.Manual journal entry transactions recorded in a journal' | translate }}</span>
<span class="acc-card__desc">{{
'labels.text.Manual journal entry transactions recorded in a journal' | translate
}}</span>
</div>
</a>
@@ -45,20 +57,34 @@
</a>
<!-- Accounts Linked to Financial Activities -->
<a class="acc-card" [routerLink]="['financial-activity-mappings']" *mifosxHasPermission="'READ_FINANCIALACTIVITYACCOUNT'" #accountsLinked>
<a
class="acc-card"
[routerLink]="['financial-activity-mappings']"
*mifosxHasPermission="'READ_FINANCIALACTIVITYACCOUNT'"
#accountsLinked
>
<div class="acc-card__icon teal"><i class="fas fa-link"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Accounts Linked to Financial Activities' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.List of Financial Activities and GL Account Mappings' | translate }}</span>
<span class="acc-card__desc">{{
'labels.text.List of Financial Activities and GL Account Mappings' | translate
}}</span>
</div>
</a>
<!-- Migrate Opening Balances -->
<a class="acc-card" [routerLink]="['migrate-opening-balances']" *mifosxHasPermission="'READ_JOURNALENTRY'" #migrateOpeningBalances>
<a
class="acc-card"
[routerLink]="['migrate-opening-balances']"
*mifosxHasPermission="'READ_JOURNALENTRY'"
#migrateOpeningBalances
>
<div class="acc-card__icon amber"><i class="fas fa-hand-holding-usd"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Migrate Opening Balances (Office-wise)' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.Set or update office-level opening balances for GL accounts' | translate }}</span>
<span class="acc-card__desc">{{
'labels.text.Set or update office-level opening balances for GL accounts' | translate
}}</span>
</div>
</a>
@@ -76,7 +102,9 @@
<div class="acc-card__icon red"><i class="fas fa-archive"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Closing Entries' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.Journal entries made at the end of accounting period' | translate }}</span>
<span class="acc-card__desc">{{
'labels.text.Journal entries made at the end of accounting period' | translate
}}</span>
</div>
</a>
@@ -114,9 +142,15 @@
<h2>{{ 'labels.heading.Chart of Accounts' | translate }}</h2>
<p class="mw300">{{ 'labels.text.The Chart of Accounts - General Ledger Setup' | translate }}</p>
<div class="layout-row align-end gap-2px responsive-column">
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">{{ 'labels.buttons.Close' | translate }}</button>
<button mat-raised-button color="primary" (click)="popover.close(); previousStepChartofAccounts()">{{ 'labels.buttons.Back' | translate }}</button>
<button mat-raised-button color="primary" (click)="popover.close(); nextStepChartofAccounts()">{{ 'labels.buttons.Next' | translate }}</button>
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
{{ 'labels.buttons.Close' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); previousStepChartofAccounts()">
{{ 'labels.buttons.Back' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); nextStepChartofAccounts()">
{{ 'labels.buttons.Next' | translate }}
</button>
</div>
</ng-template>
@@ -124,9 +158,15 @@
<h2>{{ 'labels.heading.Accounts Linked to Financial Activities' | translate }}</h2>
<p class="mw400">{{ 'labels.text.Accounts linked to Financial Activities' | translate }}</p>
<div class="layout-row align-end gap-2px responsive-column">
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">{{ 'labels.buttons.Close' | translate }}</button>
<button mat-raised-button color="primary" (click)="popover.close(); previousStepAccountsLinked()">{{ 'labels.buttons.Back' | translate }}</button>
<button mat-raised-button color="primary" (click)="popover.close(); nextStepAccountsLinked()">{{ 'labels.buttons.Next' | translate }}</button>
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
{{ 'labels.buttons.Close' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); previousStepAccountsLinked()">
{{ 'labels.buttons.Back' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); nextStepAccountsLinked()">
{{ 'labels.buttons.Next' | translate }}
</button>
</div>
</ng-template>
@@ -134,9 +174,15 @@
<h2>{{ 'labels.heading.Migrate Opening Balances (Office-wise)' | translate }}</h2>
<p class="mw400">{{ 'labels.text.GLAccount Balances Application' | translate }}</p>
<div class="layout-row align-end gap-2px responsive-column">
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">{{ 'labels.buttons.Close' | translate }}</button>
<button mat-raised-button color="primary" (click)="popover.close(); previousStepMigrateOpeningBalances()">{{ 'labels.buttons.Back' | translate }}</button>
<button mat-raised-button color="primary" (click)="popover.close(); nextStepMigrateOpeningBalances()">{{ 'labels.buttons.Next' | translate }}</button>
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
{{ 'labels.buttons.Close' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); previousStepMigrateOpeningBalances()">
{{ 'labels.buttons.Back' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); nextStepMigrateOpeningBalances()">
{{ 'labels.buttons.Next' | translate }}
</button>
</div>
</ng-template>
@@ -144,9 +190,15 @@
<h2>{{ 'labels.heading.Closing Entries' | translate }}</h2>
<p class="mw300">{{ 'labels.text.Accounting to journal entries' | translate }}</p>
<div class="layout-row align-end gap-2px responsive-column">
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">{{ 'labels.buttons.Close' | translate }}</button>
<button mat-raised-button color="primary" (click)="popover.close(); previousStepClosingEntries()">{{ 'labels.buttons.Back' | translate }}</button>
<button mat-raised-button color="primary" (click)="popover.close(); nextStepClosingEntries()">{{ 'labels.buttons.Next' | translate }}</button>
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
{{ 'labels.buttons.Close' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); previousStepClosingEntries()">
{{ 'labels.buttons.Back' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); nextStepClosingEntries()">
{{ 'labels.buttons.Next' | translate }}
</button>
</div>
</ng-template>
@@ -154,8 +206,14 @@
<h2>{{ 'labels.heading.Create Journal Entries' | translate }}</h2>
<p class="mw300">{{ 'labels.text.Journal Entries Transactions' | translate }}</p>
<div class="layout-row align-end gap-2px responsive-column">
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">{{ 'labels.buttons.Close' | translate }}</button>
<button mat-raised-button color="primary" (click)="popover.close(); previousStepCreateJournalEntries()">{{ 'labels.buttons.Back' | translate }}</button>
<button mat-raised-button color="primary" (click)="popover.close(); nextStepCreateJournalEntries()">{{ 'labels.buttons.Next' | translate }}</button>
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
{{ 'labels.buttons.Close' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); previousStepCreateJournalEntries()">
{{ 'labels.buttons.Back' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); nextStepCreateJournalEntries()">
{{ 'labels.buttons.Next' | translate }}
</button>
</div>
</ng-template>
+61 -15
View File
@@ -46,10 +46,21 @@ $accent: #0ea5e9;
span {
border-radius: 2px;
&:nth-child(1) { background: $brand-dark; }
&:nth-child(2) { background: $accent; }
&:nth-child(3) { background: #10b981; }
&:nth-child(4) { background: #cbd5e1; }
&:nth-child(1) {
background: $brand-dark;
}
&:nth-child(2) {
background: $accent;
}
&:nth-child(3) {
background: #10b981;
}
&:nth-child(4) {
background: #cbd5e1;
}
}
}
@@ -59,7 +70,9 @@ $accent: #0ea5e9;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
@media (width <= 768px) { grid-template-columns: 1fr; }
@media (width <= 768px) {
grid-template-columns: 1fr;
}
}
.acc-card {
@@ -72,7 +85,9 @@ $accent: #0ea5e9;
border-radius: 8px;
text-decoration: none;
cursor: pointer;
transition: box-shadow 0.15s, transform 0.15s;
transition:
box-shadow 0.15s,
transform 0.15s;
&:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
@@ -89,12 +104,35 @@ $accent: #0ea5e9;
font-size: 16px;
flex-shrink: 0;
&.blue { background: #e0f2fe; color: #0284c7; }
&.green { background: #d1fae5; color: #059669; }
&.purple { background: #ede9fe; color: #7c3aed; }
&.amber { background: #fef3c7; color: #d97706; }
&.teal { background: #ccfbf1; color: #0d9488; }
&.red { background: #fee2e2; color: #dc2626; }
&.blue {
background: #e0f2fe;
color: #0284c7;
}
&.green {
background: #d1fae5;
color: #059669;
}
&.purple {
background: #ede9fe;
color: #7c3aed;
}
&.amber {
background: #fef3c7;
color: #d97706;
}
&.teal {
background: #ccfbf1;
color: #0d9488;
}
&.red {
background: #fee2e2;
color: #dc2626;
}
}
&__info {
@@ -128,7 +166,15 @@ $accent: #0ea5e9;
border-color: rgb(255 255 255 / 6%);
}
.acc-card__title { color: #e0e0e0; }
.acc-card__desc { color: #666; }
.acc-header__title { color: #e0e0e0; }
.acc-card__title {
color: #e0e0e0;
}
.acc-card__desc {
color: #666;
}
.acc-header__title {
color: #e0e0e0;
}
}
@@ -41,7 +41,14 @@
<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" />
<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')) {
@@ -51,7 +58,14 @@
<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" />
<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')) {
@@ -90,7 +104,14 @@
<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" />
<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>
@@ -42,12 +42,27 @@ export class AddHolidayDialogComponent implements OnInit {
ngOnInit() {
this.holidayForm = this.fb.group({
name: ['', Validators.required],
name: [
'',
Validators.required
],
description: [''],
fromDate: ['', Validators.required],
toDate: ['', Validators.required],
reschedulingType: ['', Validators.required],
officeId: ['', Validators.required]
fromDate: [
'',
Validators.required
],
toDate: [
'',
Validators.required
],
reschedulingType: [
'',
Validators.required
],
officeId: [
'',
Validators.required
]
});
this.loadData();
@@ -111,7 +111,16 @@
[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' : '')"
[matTooltip]="
day.holidayName ||
(day.is2ndSaturday
? '2nd Saturday Off'
: day.is4thSaturday
? '4th Saturday Off'
: day.isSunday && day.isCurrentMonth
? 'Sunday'
: '')
"
>
{{ day.isCurrentMonth ? day.date : '' }}
</div>
@@ -6,6 +6,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use 'sass:color';
$navy: #0d1b2a;
$blue: #1e88e5;
$green: #10b981;
@@ -239,7 +241,7 @@ $purple: #7c3aed;
flex-shrink: 0;
&:hover {
background: darken($blue, 8%);
background: color.adjust($blue, $lightness: -8%);
box-shadow: 0 2px 8px rgb(30 136 229 / 35%);
}
}
@@ -56,7 +56,15 @@ export class BankCalendarComponent implements OnInit {
currentYear: number;
months: MonthData[] = [];
weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
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 };
@@ -135,7 +143,14 @@ export class BankCalendarComponent implements OnInit {
private loadWorkingDays() {
// Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off)
this.workingDaysConfig = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
this.workingDaysConfig = [
'MO',
'TU',
'WE',
'TH',
'FR',
'SA'
];
this.mergeGazettedHolidays();
this.buildCalendar();
@@ -157,7 +172,11 @@ export class BankCalendarComponent implements OnInit {
for (const gh of gazetted) {
const exists = this.holidays.some((h: any) => {
if (h.fromDate) {
const [y, m, d] = h.fromDate;
const [
y,
m,
d
] = h.fromDate;
return y === this.currentYear && m === gh.month && d === gh.day;
}
return false;
@@ -165,8 +184,16 @@ export class BankCalendarComponent implements OnInit {
if (!exists) {
this.holidays.push({
name: gh.name,
fromDate: [this.currentYear, gh.month, gh.day],
toDate: [this.currentYear, gh.month, gh.day],
fromDate: [
this.currentYear,
gh.month,
gh.day
],
toDate: [
this.currentYear,
gh.month,
gh.day
],
description: gh.name,
status: { value: 'Active' }
});
@@ -197,8 +224,18 @@ export class BankCalendarComponent implements OnInit {
const today = new Date();
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
const days: CalendarDay[] = [];
@@ -287,7 +324,7 @@ export class BankCalendarComponent implements OnInit {
isSunday: false,
is2ndSaturday: false,
is4thSaturday: false,
dayOfWeek: (days.length) % 7
dayOfWeek: days.length % 7
});
}
@@ -307,7 +344,11 @@ export class BankCalendarComponent implements OnInit {
private getHolidayForDate(year: number, month: number, day: number): any {
return this.holidays.find((h: any) => {
if (h.fromDate) {
const [y, m, d] = h.fromDate;
const [
y,
m,
d
] = h.fromDate;
return y === year && m === month + 1 && d === day;
}
return false;
@@ -337,10 +378,28 @@ export class BankCalendarComponent implements OnInit {
printHolidayList() {
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
const dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday'
];
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
// --- Section 1: Gazetted / API Holidays ---
const gazettedList: { sno: number; date: string; day: string; occasion: string }[] = [];
@@ -352,7 +411,11 @@ export class BankCalendarComponent implements OnInit {
sortedHolidays.forEach((h: any, i: number) => {
if (h.fromDate) {
const [y, m, d] = h.fromDate;
const [
y,
m,
d
] = h.fromDate;
const date = new Date(y, m - 1, d);
gazettedList.push({
sno: i + 1,
@@ -406,7 +469,7 @@ export class BankCalendarComponent implements OnInit {
}
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('');
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>
+25 -4
View File
@@ -25,16 +25,32 @@
<input matInput placeholder="{{ 'labels.text.SearchByExternalID' | translate }}" [formControl]="externalId" />
</mat-form-field>
<mat-checkbox #showClosedCenters labelPosition="after" (change)="changeShowClosedCenters()" class="table-checkbox">
<mat-checkbox
#showClosedCenters
labelPosition="after"
(change)="changeShowClosedCenters()"
class="table-checkbox"
>
{{ 'labels.inputs.Show Closed Centers' | translate }}
</mat-checkbox>
<div class="table-buttons">
<button mat-stroked-button [routerLink]="['../', 'organization', 'bulk-import', 'Centers']" *mifosxHasPermission="'CREATE_CENTER'" class="btn-import">
<button
mat-stroked-button
[routerLink]="['../', 'organization', 'bulk-import', 'Centers']"
*mifosxHasPermission="'CREATE_CENTER'"
class="btn-import"
>
<fa-icon icon="download"></fa-icon>
{{ 'labels.buttons.Import Center' | translate }}
</button>
<button mat-flat-button color="primary" [routerLink]="['create']" *mifosxHasPermission="'CREATE_CENTER'" class="btn-create">
<button
mat-flat-button
color="primary"
[routerLink]="['create']"
*mifosxHasPermission="'CREATE_CENTER'"
class="btn-create"
>
<fa-icon icon="plus"></fa-icon>
{{ 'labels.buttons.Create Center' | translate }}
</button>
@@ -75,7 +91,12 @@
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id, 'general']" class="table-row"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
[routerLink]="[row.id, 'general']"
class="table-row"
></tr>
</table>
<mat-paginator
@@ -120,284 +120,277 @@
<!-- SECTION 1: ZIP Details -->
@if (activeSection === 'zip') {
<div class="cv-section">
<div class="cv-section__head">
<div class="cv-section__title">
<fa-icon icon="file-archive"></fa-icon>
<div>
<h3>ZIP Batch Details</h3>
<p>CERSAI ZIP archives grouped by creation date</p>
</div>
</div>
<span class="cv-badge cv-badge--blue">Phase 2</span>
</div>
@if (loading) {
<div class="cv-state cv-state--loading">
<fa-icon icon="spinner" animation="spin"></fa-icon>
<span>Loading batches…</span>
</div>
}
@if (error && !loading) {
<div class="cv-state cv-state--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ error }}</span>
</div>
}
@if (!loading && !error && groups.length === 0) {
<div class="cv-state cv-state--empty">
<fa-icon icon="file-archive" size="2x"></fa-icon>
<h4>No batches found</h4>
<p>Create a batch from the Record Master to see it here.</p>
</div>
}
@if (!loading && groups.length > 0) {
<div class="cv-groups">
@for (group of groups; track group.dateLabel) {
<div class="cv-group" [class.cv-group--open]="group.expanded">
<button class="cv-group__head" (click)="toggleGroup(group)">
<div class="cv-group__left">
<fa-icon [icon]="group.expanded ? 'chevron-down' : 'chevron-right'" class="cv-group__chev"></fa-icon>
<div class="cv-group__date-pill">
<fa-icon icon="calendar-check"></fa-icon>
<span>{{ group.dateLabel }}</span>
</div>
<div class="cv-group__summary">
<span><strong>{{ group.batchCount }}</strong> batches</span>
<span class="cv-group__sep">·</span>
<span><strong>{{ group.totalRecordCount }}</strong> records</span>
<span class="cv-group__sep">·</span>
<span>{{ formatBytes(group.totalSizeBytes) }}</span>
</div>
</div>
<div class="cv-group__right">
@if (group.pendingCount > 0) {
<span class="cv-pill cv-pill--amber">
<fa-icon icon="hourglass-half"></fa-icon> {{ group.pendingCount }} pending
</span>
}
@if (group.uploadedCount > 0) {
<span class="cv-pill cv-pill--green">
<fa-icon icon="check-double"></fa-icon> {{ group.uploadedCount }} uploaded
</span>
}
</div>
</button>
@if (group.expanded) {
<div class="cv-group__body">
<table class="cv-batches">
<thead>
<tr>
<th>Batch ID</th>
<th>Filename</th>
<th class="cv-batches__num">Records</th>
<th class="cv-batches__num">Size</th>
<th>Status</th>
<th class="cv-batches__actions">Actions</th>
</tr>
</thead>
<tbody>
@for (batch of group.batches; track trackBatch($index, batch)) {
<tr>
<td class="cv-batches__bid">
<fa-icon icon="tag"></fa-icon>
<span>{{ batch.batchId }}</span>
</td>
<td class="cv-batches__file">
<fa-icon icon="file-archive"></fa-icon>
<span>{{ batch.filename }}</span>
</td>
<td class="cv-batches__num">{{ batch.recordCount }}</td>
<td class="cv-batches__num">{{ formatBytes(batch.sizeBytes) }}</td>
<td>
@if (batch.uploadedToCersai) {
<span class="cv-pill cv-pill--green">
<fa-icon icon="check-circle"></fa-icon> Uploaded
</span>
} @else {
<span class="cv-pill cv-pill--amber">
<fa-icon icon="hourglass-half"></fa-icon> Pending
</span>
}
</td>
<td class="cv-batches__actions">
<button
class="cv-action-btn"
(click)="openManifest(batch, $event)"
title="View manifest"
>
<fa-icon icon="eye"></fa-icon>
</button>
@if (batch.documentId) {
<button
class="cv-action-btn cv-action-btn--primary"
(click)="downloadZip(batch, $event)"
title="Download ZIP"
>
<fa-icon icon="download"></fa-icon>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="cv-section">
<div class="cv-section__head">
<div class="cv-section__title">
<fa-icon icon="file-archive"></fa-icon>
<div>
<h3>ZIP Batch Details</h3>
<p>CERSAI ZIP archives grouped by creation date</p>
</div>
}
</div>
<span class="cv-badge cv-badge--blue">Phase 2</span>
</div>
}
</div>
@if (loading) {
<div class="cv-state cv-state--loading">
<fa-icon icon="spinner" animation="spin"></fa-icon>
<span>Loading batches…</span>
</div>
}
@if (error && !loading) {
<div class="cv-state cv-state--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ error }}</span>
</div>
}
@if (!loading && !error && groups.length === 0) {
<div class="cv-state cv-state--empty">
<fa-icon icon="file-archive" size="2x"></fa-icon>
<h4>No batches found</h4>
<p>Create a batch from the Record Master to see it here.</p>
</div>
}
@if (!loading && groups.length > 0) {
<div class="cv-groups">
@for (group of groups; track group.dateLabel) {
<div class="cv-group" [class.cv-group--open]="group.expanded">
<button class="cv-group__head" (click)="toggleGroup(group)">
<div class="cv-group__left">
<fa-icon [icon]="group.expanded ? 'chevron-down' : 'chevron-right'" class="cv-group__chev"></fa-icon>
<div class="cv-group__date-pill">
<fa-icon icon="calendar-check"></fa-icon>
<span>{{ group.dateLabel }}</span>
</div>
<div class="cv-group__summary">
<span
><strong>{{ group.batchCount }}</strong> batches</span
>
<span class="cv-group__sep">·</span>
<span
><strong>{{ group.totalRecordCount }}</strong> records</span
>
<span class="cv-group__sep">·</span>
<span>{{ formatBytes(group.totalSizeBytes) }}</span>
</div>
</div>
<div class="cv-group__right">
@if (group.pendingCount > 0) {
<span class="cv-pill cv-pill--amber">
<fa-icon icon="hourglass-half"></fa-icon> {{ group.pendingCount }} pending
</span>
}
@if (group.uploadedCount > 0) {
<span class="cv-pill cv-pill--green">
<fa-icon icon="check-double"></fa-icon> {{ group.uploadedCount }} uploaded
</span>
}
</div>
</button>
@if (group.expanded) {
<div class="cv-group__body">
<table class="cv-batches">
<thead>
<tr>
<th>Batch ID</th>
<th>Filename</th>
<th class="cv-batches__num">Records</th>
<th class="cv-batches__num">Size</th>
<th>Status</th>
<th class="cv-batches__actions">Actions</th>
</tr>
</thead>
<tbody>
@for (batch of group.batches; track trackBatch($index, batch)) {
<tr>
<td class="cv-batches__bid">
<fa-icon icon="tag"></fa-icon>
<span>{{ batch.batchId }}</span>
</td>
<td class="cv-batches__file">
<fa-icon icon="file-archive"></fa-icon>
<span>{{ batch.filename }}</span>
</td>
<td class="cv-batches__num">{{ batch.recordCount }}</td>
<td class="cv-batches__num">{{ formatBytes(batch.sizeBytes) }}</td>
<td>
@if (batch.uploadedToCersai) {
<span class="cv-pill cv-pill--green">
<fa-icon icon="check-circle"></fa-icon> Uploaded
</span>
} @else {
<span class="cv-pill cv-pill--amber">
<fa-icon icon="hourglass-half"></fa-icon> Pending
</span>
}
</td>
<td class="cv-batches__actions">
<button class="cv-action-btn" (click)="openManifest(batch, $event)" title="View manifest">
<fa-icon icon="eye"></fa-icon>
</button>
@if (batch.documentId) {
<button
class="cv-action-btn cv-action-btn--primary"
(click)="downloadZip(batch, $event)"
title="Download ZIP"
>
<fa-icon icon="download"></fa-icon>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
</div>
}
</div>
}
<!-- SECTION 2: SFTP Upload + API Match -->
@if (activeSection === 'sftp') {
<div class="cv-section">
<div class="cv-section__head">
<div class="cv-section__title">
<fa-icon icon="cloud-upload-alt"></fa-icon>
<div>
<h3>SFTP Upload &amp; ACK · API Match</h3>
<p>CERSAI SFTP transfer history with ACK receipts and per-batch logs</p>
<div class="cv-section">
<div class="cv-section__head">
<div class="cv-section__title">
<fa-icon icon="cloud-upload-alt"></fa-icon>
<div>
<h3>SFTP Upload &amp; ACK · API Match</h3>
<p>CERSAI SFTP transfer history with ACK receipts and per-batch logs</p>
</div>
</div>
<span class="cv-badge cv-badge--amber">Phase 3-4</span>
</div>
<div class="cv-sftp-kpis">
<div class="cv-sftp-kpi cv-sftp-kpi--green">
<div class="cv-sftp-kpi__icon"><fa-icon icon="check-circle"></fa-icon></div>
<div>
<div class="cv-sftp-kpi__value">{{ sftpSuccessCount }}</div>
<div class="cv-sftp-kpi__label">Uploaded successfully</div>
</div>
</div>
<div class="cv-sftp-kpi cv-sftp-kpi--red">
<div class="cv-sftp-kpi__icon"><fa-icon icon="times-circle"></fa-icon></div>
<div>
<div class="cv-sftp-kpi__value">{{ sftpFailedCount }}</div>
<div class="cv-sftp-kpi__label">Failed</div>
</div>
</div>
<div class="cv-sftp-kpi cv-sftp-kpi--amber">
<div class="cv-sftp-kpi__icon"><fa-icon icon="hourglass-half"></fa-icon></div>
<div>
<div class="cv-sftp-kpi__value">{{ sftpPendingCount }}</div>
<div class="cv-sftp-kpi__label">Pending upload</div>
</div>
</div>
</div>
<span class="cv-badge cv-badge--amber">Phase 3-4</span>
</div>
<div class="cv-sftp-kpis">
<div class="cv-sftp-kpi cv-sftp-kpi--green">
<div class="cv-sftp-kpi__icon"><fa-icon icon="check-circle"></fa-icon></div>
<div>
<div class="cv-sftp-kpi__value">{{ sftpSuccessCount }}</div>
<div class="cv-sftp-kpi__label">Uploaded successfully</div>
@if (loading) {
<div class="cv-state cv-state--loading">
<fa-icon icon="spinner" animation="spin"></fa-icon>
<span>Loading logs…</span>
</div>
</div>
<div class="cv-sftp-kpi cv-sftp-kpi--red">
<div class="cv-sftp-kpi__icon"><fa-icon icon="times-circle"></fa-icon></div>
<div>
<div class="cv-sftp-kpi__value">{{ sftpFailedCount }}</div>
<div class="cv-sftp-kpi__label">Failed</div>
}
@if (!loading && sftpLogs.length === 0) {
<div class="cv-state cv-state--empty">
<fa-icon icon="cloud-upload-alt" size="2x"></fa-icon>
<h4>No SFTP upload history yet</h4>
<p>Upload a batch from the Record Master to see CERSAI transfer logs here.</p>
</div>
</div>
<div class="cv-sftp-kpi cv-sftp-kpi--amber">
<div class="cv-sftp-kpi__icon"><fa-icon icon="hourglass-half"></fa-icon></div>
<div>
<div class="cv-sftp-kpi__value">{{ sftpPendingCount }}</div>
<div class="cv-sftp-kpi__label">Pending upload</div>
</div>
</div>
</div>
}
@if (loading) {
<div class="cv-state cv-state--loading">
<fa-icon icon="spinner" animation="spin"></fa-icon>
<span>Loading logs…</span>
</div>
}
@if (!loading && sftpLogs.length === 0) {
<div class="cv-state cv-state--empty">
<fa-icon icon="cloud-upload-alt" size="2x"></fa-icon>
<h4>No SFTP upload history yet</h4>
<p>Upload a batch from the Record Master to see CERSAI transfer logs here.</p>
</div>
}
@if (!loading && sftpLogs.length > 0) {
<div class="cv-sftp-logs">
@for (log of sftpLogs; track log.id) {
<div
class="cv-sftp-log"
[class.cv-sftp-log--success]="isSftpSuccess(log)"
[class.cv-sftp-log--failed]="isSftpFailed(log)"
>
<div class="cv-sftp-log__icon">
@if (isSftpSuccess(log)) {
<fa-icon icon="check-circle"></fa-icon>
} @else if (isSftpFailed(log)) {
<fa-icon icon="times-circle"></fa-icon>
} @else {
<fa-icon icon="hourglass-half"></fa-icon>
}
</div>
<div class="cv-sftp-log__body">
<div class="cv-sftp-log__head">
<span class="cv-sftp-log__bid">
<fa-icon icon="tag"></fa-icon> {{ log.batchId }}
</span>
@if (!loading && sftpLogs.length > 0) {
<div class="cv-sftp-logs">
@for (log of sftpLogs; track log.id) {
<div
class="cv-sftp-log"
[class.cv-sftp-log--success]="isSftpSuccess(log)"
[class.cv-sftp-log--failed]="isSftpFailed(log)"
>
<div class="cv-sftp-log__icon">
@if (isSftpSuccess(log)) {
<span class="cv-pill cv-pill--green">
<fa-icon icon="check-circle"></fa-icon> Upload successful
</span>
<fa-icon icon="check-circle"></fa-icon>
} @else if (isSftpFailed(log)) {
<span class="cv-pill cv-pill--red">
<fa-icon icon="times-circle"></fa-icon> Upload failed
</span>
<fa-icon icon="times-circle"></fa-icon>
} @else {
<span class="cv-pill cv-pill--amber">
<fa-icon icon="hourglass-half"></fa-icon> Pending
</span>
<fa-icon icon="hourglass-half"></fa-icon>
}
<span class="cv-sftp-log__time">
<fa-icon icon="clock"></fa-icon>
{{ log.uploadedOnLabel || log.createdOnLabel || '—' }}
</span>
</div>
<div class="cv-sftp-log__body">
<div class="cv-sftp-log__head">
<span class="cv-sftp-log__bid"> <fa-icon icon="tag"></fa-icon> {{ log.batchId }} </span>
@if (isSftpSuccess(log)) {
<span class="cv-pill cv-pill--green">
<fa-icon icon="check-circle"></fa-icon> Upload successful
</span>
} @else if (isSftpFailed(log)) {
<span class="cv-pill cv-pill--red"> <fa-icon icon="times-circle"></fa-icon> Upload failed </span>
} @else {
<span class="cv-pill cv-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
}
<span class="cv-sftp-log__time">
<fa-icon icon="clock"></fa-icon>
{{ log.uploadedOnLabel || log.createdOnLabel || '—' }}
</span>
</div>
<div class="cv-sftp-log__meta">
<div>
<span class="cv-sftp-log__label">Filename</span>
<span class="cv-sftp-log__value">{{ log.filename }}</span>
</div>
<div>
<span class="cv-sftp-log__label">Records</span>
<span class="cv-sftp-log__value">{{ log.recordCount }}</span>
</div>
<div>
<span class="cv-sftp-log__label">Size</span>
<span class="cv-sftp-log__value">{{ formatBytes(log.sizeBytes) }}</span>
</div>
@if (log.cersaiReference) {
<div class="cv-sftp-log__meta">
<div>
<span class="cv-sftp-log__label">CERSAI Ref</span>
<span class="cv-sftp-log__value cv-sftp-log__value--mono">{{ log.cersaiReference }}</span>
<span class="cv-sftp-log__label">Filename</span>
<span class="cv-sftp-log__value">{{ log.filename }}</span>
</div>
<div>
<span class="cv-sftp-log__label">Records</span>
<span class="cv-sftp-log__value">{{ log.recordCount }}</span>
</div>
<div>
<span class="cv-sftp-log__label">Size</span>
<span class="cv-sftp-log__value">{{ formatBytes(log.sizeBytes) }}</span>
</div>
@if (log.cersaiReference) {
<div>
<span class="cv-sftp-log__label">CERSAI Ref</span>
<span class="cv-sftp-log__value cv-sftp-log__value--mono">{{ log.cersaiReference }}</span>
</div>
}
</div>
@if (log.uploadRemarks) {
<div class="cv-sftp-log__remarks" [class.cv-sftp-log__remarks--err]="isSftpFailed(log)">
<fa-icon [icon]="isSftpFailed(log) ? 'exclamation-triangle' : 'info-circle'"></fa-icon>
<span>{{ log.uploadRemarks }}</span>
</div>
}
</div>
@if (log.uploadRemarks) {
<div class="cv-sftp-log__remarks" [class.cv-sftp-log__remarks--err]="isSftpFailed(log)">
<fa-icon [icon]="isSftpFailed(log) ? 'exclamation-triangle' : 'info-circle'"></fa-icon>
<span>{{ log.uploadRemarks }}</span>
</div>
}
</div>
<div class="cv-sftp-log__actions">
<button class="cv-action-btn" (click)="openManifest(log, $event)" title="View manifest">
<fa-icon icon="eye"></fa-icon>
</button>
@if (log.documentId) {
<button
class="cv-action-btn cv-action-btn--primary"
(click)="downloadZip(log, $event)"
title="Download ZIP"
>
<fa-icon icon="download"></fa-icon>
<div class="cv-sftp-log__actions">
<button class="cv-action-btn" (click)="openManifest(log, $event)" title="View manifest">
<fa-icon icon="eye"></fa-icon>
</button>
}
@if (log.documentId) {
<button
class="cv-action-btn cv-action-btn--primary"
(click)="downloadZip(log, $event)"
title="Download ZIP"
>
<fa-icon icon="download"></fa-icon>
</button>
}
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
}
</div>
}
<!-- Manifest modal -->
@@ -451,13 +444,9 @@
<div>
<span class="cv-kpi__label">Status</span>
@if (manifestBatch.uploadedToCersai) {
<span class="cv-pill cv-pill--green">
<fa-icon icon="check-circle"></fa-icon> Uploaded
</span>
<span class="cv-pill cv-pill--green"> <fa-icon icon="check-circle"></fa-icon> Uploaded </span>
} @else {
<span class="cv-pill cv-pill--amber">
<fa-icon icon="hourglass-half"></fa-icon> Pending
</span>
<span class="cv-pill cv-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
}
</div>
<div class="cv-manifest-head__grid-span">
@@ -526,7 +515,10 @@
class="cv-dot"
[class.cv-dot--ok]="entry.idProof?.packaged"
[class.cv-dot--miss]="!entry.idProof?.packaged"
[title]="(entry.idProof?.type || 'ID') + (entry.idProof?.zipEntry ? ' — ' + entry.idProof.zipEntry : '')"
[title]="
(entry.idProof?.type || 'ID') +
(entry.idProof?.zipEntry ? ' — ' + entry.idProof.zipEntry : '')
"
>
<fa-icon [icon]="entry.idProof?.packaged ? 'check' : 'times'"></fa-icon>
</span>
@@ -536,13 +528,23 @@
class="cv-dot"
[class.cv-dot--ok]="entry.addressProof?.packaged"
[class.cv-dot--miss]="!entry.addressProof?.packaged"
[title]="(entry.addressProof?.type || 'Address') + (entry.addressProof?.zipEntry ? ' — ' + entry.addressProof.zipEntry : '')"
[title]="
(entry.addressProof?.type || 'Address') +
(entry.addressProof?.zipEntry ? ' — ' + entry.addressProof.zipEntry : '')
"
>
<fa-icon [icon]="entry.addressProof?.packaged ? 'check' : 'times'"></fa-icon>
</span>
</td>
<td class="cv-td-r cv-manifest-size">
{{ formatBytes((entry.photo?.bytes || 0) + (entry.signature?.bytes || 0) + (entry.idProof?.bytes || 0) + (entry.addressProof?.bytes || 0)) }}
{{
formatBytes(
(entry.photo?.bytes || 0) +
(entry.signature?.bytes || 0) +
(entry.idProof?.bytes || 0) +
(entry.addressProof?.bytes || 0)
)
}}
</td>
</tr>
}
@@ -554,9 +556,7 @@
</div>
<div class="cv-modal__footer">
<button class="cv-btn cv-btn--ghost" (click)="closeManifest()">
<fa-icon icon="times"></fa-icon> Close
</button>
<button class="cv-btn cv-btn--ghost" (click)="closeManifest()"><fa-icon icon="times"></fa-icon> Close</button>
@if (manifestBatch?.documentId) {
<button class="cv-btn cv-btn--primary" (click)="downloadZip(manifestBatch, $event)">
<fa-icon icon="download"></fa-icon>
@@ -91,14 +91,18 @@ $purple-light: #efeafb;
cursor: pointer;
transition: all 0.15s;
fa-icon { font-size: 12px; }
fa-icon {
font-size: 12px;
}
&:hover:not(:disabled) {
background: $gray-50;
border-color: $gray-500;
}
&--ghost { /* default */ }
&--ghost {
/* default */
}
&--primary {
background: $primary;
@@ -139,13 +143,27 @@ $purple-light: #efeafb;
justify-content: center;
font-size: 15px;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
&--blue { background: linear-gradient(135deg, $primary, #4285f4); }
&--teal { background: linear-gradient(135deg, $teal-700, $green); }
&--amber { background: linear-gradient(135deg, $amber, #d4892f); }
&--green { background: linear-gradient(135deg, $green, #2fb88c); }
&--purple { background: linear-gradient(135deg, $purple, #8a6fd4); }
&--blue {
background: linear-gradient(135deg, $primary, #4285f4);
}
&--teal {
background: linear-gradient(135deg, $teal-700, $green);
}
&--amber {
background: linear-gradient(135deg, $amber, #d4892f);
}
&--green {
background: linear-gradient(135deg, $green, #2fb88c);
}
&--purple {
background: linear-gradient(135deg, $purple, #8a6fd4);
}
}
&__label {
@@ -209,7 +227,10 @@ $purple-light: #efeafb;
align-items: center;
gap: 6px;
fa-icon { color: $primary; font-size: 11px; }
fa-icon {
color: $primary;
font-size: 11px;
}
}
.cv-input {
@@ -251,7 +272,7 @@ select.cv-input {
border: 1px solid $gray-200 !important;
border-radius: 14px !important;
padding: 8px !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03) !important;
box-shadow: 0 1px 3px rgb(0 0 0 / 3%) !important;
}
button.cv-tab,
@@ -283,7 +304,7 @@ button.cv-tab,
color: $gray-500 !important;
}
> span:not(.cv-tab__badge):not(.cv-tab__count) {
> span:not(.cv-tab__badge, .cv-tab__count) {
display: inline-flex !important;
align-items: center !important;
}
@@ -292,7 +313,9 @@ button.cv-tab,
background: $gray-50 !important;
color: $gray-900 !important;
> fa-icon { color: $primary !important; }
> fa-icon {
color: $primary !important;
}
}
&--active {
@@ -301,7 +324,9 @@ button.cv-tab,
color: $primary-dark !important;
box-shadow: 0 2px 8px rgba($primary, 0.12) !important;
> fa-icon { color: $primary !important; }
> fa-icon {
color: $primary !important;
}
}
}
@@ -342,13 +367,20 @@ button.cv-tab,
}
@media (width <= 680px) {
.cv-tabs { flex-direction: column !important; }
.cv-tabs {
flex-direction: column !important;
}
button.cv-tab,
.cv-tabs .cv-tab { justify-content: space-between !important; }
.cv-tabs .cv-tab {
justify-content: space-between !important;
}
}
/* Hide the per-section header badge since the tab strip already carries it */
.cv-section .cv-badge { display: none; }
.cv-section .cv-badge {
display: none;
}
/* ---------- Section card ---------- */
.cv-section {
@@ -412,8 +444,15 @@ button.cv-tab,
text-transform: uppercase;
letter-spacing: 0.3px;
&--blue { background: $primary-light; color: $primary-dark; }
&--amber { background: $amber-light; color: $amber; }
&--blue {
background: $primary-light;
color: $primary-dark;
}
&--amber {
background: $amber-light;
color: $amber;
}
}
/* ---------- State banners ---------- */
@@ -426,7 +465,9 @@ button.cv-tab,
border-radius: 10px;
font-size: 13px;
fa-icon { font-size: 18px; }
fa-icon {
font-size: 18px;
}
&--loading {
background: $primary-light;
@@ -445,9 +486,22 @@ button.cv-tab,
color: $gray-500;
border: 1px dashed $gray-200;
fa-icon { color: $primary; margin-bottom: 4px; }
h4 { margin: 0; font-size: 15px; font-weight: 600; color: $gray-900; }
p { margin: 0; font-size: 12px; }
fa-icon {
color: $primary;
margin-bottom: 4px;
}
h4 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: $gray-900;
}
p {
margin: 0;
font-size: 12px;
}
}
}
@@ -463,9 +517,13 @@ button.cv-tab,
border: 1px solid $gray-200;
border-radius: 10px;
overflow: hidden;
transition: border-color 0.15s, box-shadow 0.15s;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:hover { border-color: rgba($primary, 0.3); }
&:hover {
border-color: rgba($primary, 0.3);
}
&--open {
border-color: $primary-light;
@@ -485,7 +543,9 @@ button.cv-tab,
font-family: inherit;
text-align: left;
&:hover { background: $gray-100; }
&:hover {
background: $gray-100;
}
}
&__left {
@@ -513,7 +573,10 @@ button.cv-tab,
font-size: 12px;
font-weight: 700;
fa-icon { color: $primary; font-size: 11px; }
fa-icon {
color: $primary;
font-size: 11px;
}
}
&__summary {
@@ -523,10 +586,15 @@ button.cv-tab,
gap: 8px;
flex-wrap: wrap;
strong { color: $gray-900; font-weight: 700; }
strong {
color: $gray-900;
font-weight: 700;
}
}
&__sep { color: $gray-200; }
&__sep {
color: $gray-200;
}
&__right {
display: flex;
@@ -550,7 +618,9 @@ button.cv-tab,
font-size: 11px;
font-weight: 700;
fa-icon { font-size: 10px; }
fa-icon {
font-size: 10px;
}
&--green {
background: $green-light;
@@ -617,19 +687,31 @@ button.cv-tab,
&--green {
border-color: rgba($green, 0.3);
background: linear-gradient(180deg, $green-light, $white);
.cv-sftp-kpi__icon { background: $green; color: $white; }
.cv-sftp-kpi__icon {
background: $green;
color: $white;
}
}
&--red {
border-color: rgba($red, 0.3);
background: linear-gradient(180deg, $red-light, $white);
.cv-sftp-kpi__icon { background: $red; color: $white; }
.cv-sftp-kpi__icon {
background: $red;
color: $white;
}
}
&--amber {
border-color: rgba($amber, 0.3);
background: linear-gradient(180deg, $amber-light, $white);
.cv-sftp-kpi__icon { background: $amber; color: $white; }
.cv-sftp-kpi__icon {
background: $amber;
color: $white;
}
}
}
@@ -649,12 +731,22 @@ button.cv-tab,
border-left-width: 4px;
border-radius: 10px;
background: $white;
transition: background 0.15s, box-shadow 0.15s;
transition:
background 0.15s,
box-shadow 0.15s;
&:hover { background: #fafbfc; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); }
&:hover {
background: #fafbfc;
box-shadow: 0 2px 8px rgb(0 0 0 / 4%);
}
&--success { border-left-color: $green; }
&--failed { border-left-color: $red; }
&--success {
border-left-color: $green;
}
&--failed {
border-left-color: $red;
}
&__icon {
width: 36px;
@@ -669,10 +761,20 @@ button.cv-tab,
flex-shrink: 0;
}
&--success .cv-sftp-log__icon { background: $green-light; color: $green; }
&--failed .cv-sftp-log__icon { background: $red-light; color: $red; }
&--success .cv-sftp-log__icon {
background: $green-light;
color: $green;
}
&__body { flex: 1; min-width: 0; }
&--failed .cv-sftp-log__icon {
background: $red-light;
color: $red;
}
&__body {
flex: 1;
min-width: 0;
}
&__head {
display: flex;
@@ -688,7 +790,10 @@ button.cv-tab,
font-weight: 700;
color: $primary;
fa-icon { font-size: 11px; margin-right: 4px; }
fa-icon {
font-size: 11px;
margin-right: 4px;
}
}
&__time {
@@ -696,7 +801,10 @@ button.cv-tab,
color: $gray-500;
margin-left: auto;
fa-icon { font-size: 10px; margin-right: 4px; }
fa-icon {
font-size: 10px;
margin-right: 4px;
}
}
&__meta {
@@ -705,7 +813,12 @@ button.cv-tab,
gap: 8px 16px;
font-size: 12px;
> div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
> div {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
}
&__label {
@@ -739,14 +852,20 @@ button.cv-tab,
align-items: flex-start;
gap: 8px;
fa-icon { font-size: 12px; color: $gray-500; margin-top: 2px; }
fa-icon {
font-size: 12px;
color: $gray-500;
margin-top: 2px;
}
&--err {
background: $red-light;
border-color: rgba($red, 0.3);
color: $red;
fa-icon { color: $red; }
fa-icon {
color: $red;
}
}
}
@@ -763,7 +882,9 @@ button.cv-tab,
border-collapse: collapse;
font-size: 12px;
thead tr { background: $gray-50; }
thead tr {
background: $gray-50;
}
th {
text-align: left;
@@ -783,10 +904,20 @@ button.cv-tab,
vertical-align: middle;
}
tbody tr:hover { background: #f5f8fc; }
tbody tr:hover {
background: #f5f8fc;
}
&__num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
&__actions { text-align: right; width: 100px; }
&__num {
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
&__actions {
text-align: right;
width: 100px;
}
&__bid {
font-family: 'SF Mono', Consolas, monospace;
@@ -795,13 +926,21 @@ button.cv-tab,
color: $primary;
white-space: nowrap;
fa-icon { color: $primary; margin-right: 6px; font-size: 11px; }
fa-icon {
color: $primary;
margin-right: 6px;
font-size: 11px;
}
}
&__file {
color: $gray-700;
fa-icon { color: $primary; margin-right: 6px; font-size: 11px; }
fa-icon {
color: $primary;
margin-right: 6px;
font-size: 11px;
}
span {
word-break: break-all;
@@ -823,7 +962,9 @@ button.cv-tab,
margin-left: 4px;
transition: all 0.15s;
fa-icon { font-size: 12px; }
fa-icon {
font-size: 12px;
}
&:hover {
background: $primary-light;
@@ -900,7 +1041,7 @@ button.cv-tab,
.cv-modal {
position: fixed;
inset: 0;
background: rgba(15, 25, 45, 0.55);
background: rgb(15 25 45 / 55%);
backdrop-filter: blur(3px);
z-index: 100;
display: flex;
@@ -917,7 +1058,7 @@ button.cv-tab,
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25);
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
}
&__header {
@@ -1051,7 +1192,10 @@ button.cv-tab,
align-items: center;
gap: 6px;
fa-icon { color: $primary; font-size: 12px; }
fa-icon {
color: $primary;
font-size: 12px;
}
}
.cv-manifest-table-wrap {
@@ -1091,14 +1235,31 @@ button.cv-tab,
vertical-align: middle;
}
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: #f5f8fc; }
tbody tr:last-child td {
border-bottom: none;
}
tbody tr:hover {
background: #f5f8fc;
}
}
.cv-th-c { text-align: center !important; width: 38px; }
.cv-th-r { text-align: right !important; }
.cv-td-c { text-align: center; }
.cv-td-r { text-align: right; }
.cv-th-c {
text-align: center !important;
width: 38px;
}
.cv-th-r {
text-align: right !important;
}
.cv-td-c {
text-align: center;
}
.cv-td-r {
text-align: right;
}
.cv-manifest-client {
display: flex;
@@ -1117,7 +1278,10 @@ button.cv-tab,
color: $gray-500;
font-family: 'SF Mono', Consolas, monospace;
fa-icon { font-size: 8px; margin-right: 2px; }
fa-icon {
font-size: 8px;
margin-right: 2px;
}
}
.cv-dot {
@@ -1157,11 +1321,21 @@ button.cv-tab,
/* ---------- Responsive ---------- */
@media (width <= 1100px) {
.cv-kpis { grid-template-columns: repeat(3, 1fr); }
.cv-kpis {
grid-template-columns: repeat(3, 1fr);
}
}
@media (width <= 700px) {
.cv-kpis { grid-template-columns: repeat(2, 1fr); }
.cv-filters__grid { grid-template-columns: 1fr; }
.cv-manifest-head__grid { grid-template-columns: 1fr; }
.cv-kpis {
grid-template-columns: repeat(2, 1fr);
}
.cv-filters__grid {
grid-template-columns: 1fr;
}
.cv-manifest-head__grid {
grid-template-columns: 1fr;
}
}
@@ -210,7 +210,11 @@ export class CkycVerificationComponent implements OnInit {
formatJavaDate(value: any): string {
if (!value) return '';
if (Array.isArray(value) && value.length >= 3) {
const [y, m, d] = value;
const [
y,
m,
d
] = value;
return `${String(d).padStart(2, '0')}-${String(m).padStart(2, '0')}-${y}`;
}
if (typeof value === 'string') return value;
@@ -221,7 +225,14 @@ export class CkycVerificationComponent implements OnInit {
if (value == null || value === '') return '';
const pad = (n: any) => String(n ?? 0).padStart(2, '0');
if (Array.isArray(value) && value.length >= 3) {
const [y, mo, d, h = 0, mi = 0, s = 0] = value;
const [
y,
mo,
d,
h = 0,
mi = 0,
s = 0
] = value;
const date = `${pad(d)}-${pad(mo)}-${y}`;
if (value.length >= 4) {
return `${date} ${pad(h)}:${pad(mi)}:${pad(s)}`;
@@ -269,7 +280,14 @@ export class CkycVerificationComponent implements OnInit {
private dateValue(value: any): number {
if (Array.isArray(value) && value.length >= 3) {
const [y, mo, d, h = 0, mi = 0, s = 0] = value;
const [
y,
mo,
d,
h = 0,
mi = 0,
s = 0
] = value;
return new Date(y, (mo || 1) - 1, d || 1, h, mi, s).getTime();
}
if (typeof value === 'string') {
@@ -293,10 +311,7 @@ export class CkycVerificationComponent implements OnInit {
/** Completion check per manifest entry */
isEntryComplete(entry: any): boolean {
return (
entry?.photo?.packaged &&
entry?.signature?.packaged &&
entry?.idProof?.packaged &&
entry?.addressProof?.packaged
entry?.photo?.packaged && entry?.signature?.packaged && entry?.idProof?.packaged && entry?.addressProof?.packaged
);
}
}
+9 -19
View File
@@ -29,9 +29,7 @@
<!-- 5-Phase pipeline banner -->
<div class="ckyc-pipeline">
<div class="ckyc-pipeline__head">
<span class="ckyc-pipeline__heading">
<fa-icon icon="stream"></fa-icon> 5-phase compliance pipeline
</span>
<span class="ckyc-pipeline__heading"> <fa-icon icon="stream"></fa-icon> 5-phase compliance pipeline </span>
<span class="ckyc-pipeline__hint">CBS → CKYC automation flow</span>
</div>
@@ -70,9 +68,7 @@
</div>
<!-- Menu cards -->
<h2 class="ckyc-dash__section-title">
<fa-icon icon="th-large"></fa-icon> Quick actions
</h2>
<h2 class="ckyc-dash__section-title"><fa-icon icon="th-large"></fa-icon> Quick actions</h2>
<div class="ckyc-dash__grid">
<a class="ckyc-card ckyc-card--blue" [routerLink]="['record-master']">
@@ -82,15 +78,15 @@
</div>
<div class="ckyc-card__body">
<div class="ckyc-card__title">Record Master</div>
<p class="ckyc-card__desc">Manage master records, identify clients without CKYC and collect missing artefacts.</p>
<p class="ckyc-card__desc">
Manage master records, identify clients without CKYC and collect missing artefacts.
</p>
<div class="ckyc-card__tags">
<span class="ckyc-tag">Phase 1</span>
<span class="ckyc-tag">Clients</span>
</div>
</div>
<div class="ckyc-card__cta">
Open <fa-icon icon="arrow-right"></fa-icon>
</div>
<div class="ckyc-card__cta">Open <fa-icon icon="arrow-right"></fa-icon></div>
</a>
<a class="ckyc-card ckyc-card--teal" [routerLink]="['ckyc-verification']">
@@ -106,9 +102,7 @@
<span class="ckyc-tag">Batches</span>
</div>
</div>
<div class="ckyc-card__cta">
Open <fa-icon icon="arrow-right"></fa-icon>
</div>
<div class="ckyc-card__cta">Open <fa-icon icon="arrow-right"></fa-icon></div>
</a>
<a class="ckyc-card ckyc-card--amber" [routerLink]="['ckyc-reports']">
@@ -124,9 +118,7 @@
<span class="ckyc-tag">MIS</span>
</div>
</div>
<div class="ckyc-card__cta">
Open <fa-icon icon="arrow-right"></fa-icon>
</div>
<div class="ckyc-card__cta">Open <fa-icon icon="arrow-right"></fa-icon></div>
</a>
<a class="ckyc-card ckyc-card--purple" [routerLink]="['ekyc']">
@@ -142,9 +134,7 @@
<span class="ckyc-tag">Aadhaar</span>
</div>
</div>
<div class="ckyc-card__cta">
Open <fa-icon icon="arrow-right"></fa-icon>
</div>
<div class="ckyc-card__cta">Open <fa-icon icon="arrow-right"></fa-icon></div>
</a>
</div>
</div>
+70 -24
View File
@@ -45,7 +45,7 @@ $purple-bg: #f8f5ff;
margin-bottom: 22px;
position: relative;
overflow: hidden;
box-shadow: 0 6px 24px rgba(26, 115, 232, 0.2);
box-shadow: 0 6px 24px rgb(26 115 232 / 20%);
&::before {
content: '';
@@ -55,7 +55,7 @@ $purple-bg: #f8f5ff;
width: 320px;
height: 320px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.15), transparent 70%);
background: radial-gradient(circle, rgb(255 255 255 / 15%), transparent 70%);
pointer-events: none;
}
@@ -71,8 +71,8 @@ $purple-bg: #f8f5ff;
width: 58px;
height: 58px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.3);
background: rgb(255 255 255 / 18%);
border: 1px solid rgb(255 255 255 / 30%);
display: flex;
align-items: center;
justify-content: center;
@@ -86,8 +86,8 @@ $purple-bg: #f8f5ff;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
background: rgb(255 255 255 / 15%);
border: 1px solid rgb(255 255 255 / 30%);
border-radius: 30px;
font-size: 12px;
font-weight: 600;
@@ -95,7 +95,10 @@ $purple-bg: #f8f5ff;
flex-shrink: 0;
position: relative;
fa-icon { color: #9fe1cb; font-size: 14px; }
fa-icon {
color: #9fe1cb;
font-size: 14px;
}
}
}
@@ -139,7 +142,9 @@ $purple-bg: #f8f5ff;
align-items: center;
gap: 8px;
fa-icon { color: $primary; }
fa-icon {
color: $primary;
}
}
&__hint {
@@ -155,8 +160,14 @@ $purple-bg: #f8f5ff;
overflow-x: auto;
padding: 4px 2px;
&::-webkit-scrollbar { height: 4px; }
&::-webkit-scrollbar-thumb { background: $gray-200; border-radius: 4px; }
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-thumb {
background: $gray-200;
border-radius: 4px;
}
}
}
@@ -171,7 +182,9 @@ $purple-bg: #f8f5ff;
position: relative;
transition: transform 0.15s;
&:hover { transform: translateY(-2px); }
&:hover {
transform: translateY(-2px);
}
&__num {
position: absolute;
@@ -249,7 +262,9 @@ $purple-bg: #f8f5ff;
background: $amber;
}
.ckyc-phase__title { color: #633806; }
.ckyc-phase__title {
color: #633806;
}
}
&--wide {
@@ -270,7 +285,10 @@ $purple-bg: #f8f5ff;
text-transform: uppercase;
letter-spacing: 0.4px;
fa-icon { color: $primary; font-size: 14px; }
fa-icon {
color: $primary;
font-size: 14px;
}
}
/* ---------- Menu grid ---------- */
@@ -289,7 +307,10 @@ $purple-bg: #f8f5ff;
border-radius: 16px;
text-decoration: none;
color: $gray-900;
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
transition:
transform 0.2s,
box-shadow 0.2s,
border-color 0.2s;
cursor: pointer;
position: relative;
overflow: hidden;
@@ -304,12 +325,14 @@ $purple-bg: #f8f5ff;
border-radius: 50%;
opacity: 0.12;
pointer-events: none;
transition: transform 0.4s, opacity 0.3s;
transition:
transform 0.4s,
opacity 0.3s;
}
&:hover {
transform: translateY(-3px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
box-shadow: 0 10px 30px rgb(0 0 0 / 8%);
.ckyc-card__glow {
transform: scale(1.25);
@@ -320,7 +343,9 @@ $purple-bg: #f8f5ff;
background: $primary;
color: $white;
fa-icon { transform: translateX(3px); }
fa-icon {
transform: translateX(3px);
}
}
}
@@ -336,7 +361,7 @@ $purple-bg: #f8f5ff;
margin-bottom: 16px;
position: relative;
z-index: 1;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
box-shadow: 0 6px 18px rgb(0 0 0 / 10%);
}
&__body {
@@ -394,9 +419,11 @@ $purple-bg: #f8f5ff;
.ckyc-card__icon {
background: linear-gradient(135deg, $primary, #4285f4);
}
.ckyc-card__glow {
background: radial-gradient(circle, $primary, transparent 60%);
}
&:hover .ckyc-card__cta {
background: $primary;
border-color: $primary;
@@ -407,9 +434,11 @@ $purple-bg: #f8f5ff;
.ckyc-card__icon {
background: linear-gradient(135deg, $teal, #1d9e75);
}
.ckyc-card__glow {
background: radial-gradient(circle, $teal, transparent 60%);
}
&:hover .ckyc-card__cta {
background: $teal;
border-color: $teal;
@@ -420,9 +449,11 @@ $purple-bg: #f8f5ff;
.ckyc-card__icon {
background: linear-gradient(135deg, $amber, #d4892f);
}
.ckyc-card__glow {
background: radial-gradient(circle, $amber, transparent 60%);
}
&:hover .ckyc-card__cta {
background: $amber;
border-color: $amber;
@@ -433,15 +464,16 @@ $purple-bg: #f8f5ff;
.ckyc-card__icon {
background: linear-gradient(135deg, $purple, #8a6fd4);
}
.ckyc-card__glow {
background: radial-gradient(circle, $purple, transparent 60%);
}
&:hover .ckyc-card__cta {
background: $purple;
border-color: $purple;
}
}
}
.ckyc-tag {
@@ -458,12 +490,26 @@ $purple-bg: #f8f5ff;
/* ---------- Responsive ---------- */
@media (width <= 900px) {
.ckyc-dash { padding: 16px 18px 30px; }
.ckyc-dash__hero { flex-direction: column; align-items: flex-start; }
.ckyc-pipeline__track { flex-direction: row; flex-wrap: nowrap; }
.ckyc-dash {
padding: 16px 18px 30px;
}
.ckyc-dash__hero {
flex-direction: column;
align-items: flex-start;
}
.ckyc-pipeline__track {
flex-flow: row nowrap;
}
}
@media (width <= 600px) {
.ckyc-dash__grid { grid-template-columns: 1fr; }
.ckyc-phase { min-width: 120px; }
.ckyc-dash__grid {
grid-template-columns: 1fr;
}
.ckyc-phase {
min-width: 120px;
}
}
@@ -28,12 +28,7 @@
<!-- 3-tab filter -->
<div class="rm-tabs">
<button
type="button"
class="rm-tab"
[class.rm-tab--active]="activeTab === 'all'"
(click)="setTab('all')"
>
<button type="button" class="rm-tab" [class.rm-tab--active]="activeTab === 'all'" (click)="setTab('all')">
<fa-icon icon="list"></fa-icon>
<span>All clients</span>
<span class="rm-tab__count">{{ tabCounts.all }}</span>
@@ -75,12 +70,7 @@
<div class="rm-field">
<label class="rm-label"><fa-icon icon="cube"></fa-icon> Loan product ID</label>
<input
type="number"
class="rm-input"
formControlName="productId"
placeholder="Optional"
/>
<input type="number" class="rm-input" formControlName="productId" placeholder="Optional" />
</div>
<div class="rm-field">
@@ -186,9 +176,7 @@
<div class="rm-row__avatar">{{ row.fullName?.charAt(0) || '?' }}</div>
<div class="rm-row__ident">
<div class="rm-row__name">{{ row.fullName }}</div>
<div class="rm-row__account">
<fa-icon icon="hashtag"></fa-icon> {{ row.accountNo }}
</div>
<div class="rm-row__account"><fa-icon icon="hashtag"></fa-icon> {{ row.accountNo }}</div>
</div>
</div>
</td>
@@ -208,16 +196,36 @@
</td>
<td class="rm-table__artefacts">
<div class="rm-row__chips">
<span class="rm-chip" [class.rm-chip--ok]="row.hasPhoto" [class.rm-chip--miss]="!row.hasPhoto" title="Photo">
<span
class="rm-chip"
[class.rm-chip--ok]="row.hasPhoto"
[class.rm-chip--miss]="!row.hasPhoto"
title="Photo"
>
<fa-icon [icon]="row.hasPhoto ? 'check' : 'times'"></fa-icon> P
</span>
<span class="rm-chip" [class.rm-chip--ok]="row.hasSignature" [class.rm-chip--miss]="!row.hasSignature" title="Signature">
<span
class="rm-chip"
[class.rm-chip--ok]="row.hasSignature"
[class.rm-chip--miss]="!row.hasSignature"
title="Signature"
>
<fa-icon [icon]="row.hasSignature ? 'check' : 'times'"></fa-icon> S
</span>
<span class="rm-chip" [class.rm-chip--ok]="row.hasIdProof" [class.rm-chip--miss]="!row.hasIdProof" title="ID Proof">
<span
class="rm-chip"
[class.rm-chip--ok]="row.hasIdProof"
[class.rm-chip--miss]="!row.hasIdProof"
title="ID Proof"
>
<fa-icon [icon]="row.hasIdProof ? 'check' : 'times'"></fa-icon> ID
</span>
<span class="rm-chip" [class.rm-chip--ok]="row.hasAddressProof" [class.rm-chip--miss]="!row.hasAddressProof" title="Address Proof">
<span
class="rm-chip"
[class.rm-chip--ok]="row.hasAddressProof"
[class.rm-chip--miss]="!row.hasAddressProof"
title="Address Proof"
>
<fa-icon [icon]="row.hasAddressProof ? 'check' : 'times'"></fa-icon> Ad
</span>
</div>
@@ -233,11 +241,7 @@
</div>
</td>
<td class="rm-table__actions">
<button
class="rm-action-btn"
(click)="openView(row, $event)"
title="View details"
>
<button class="rm-action-btn" (click)="openView(row, $event)" title="View details">
<fa-icon icon="eye"></fa-icon>
</button>
<button
@@ -366,9 +370,7 @@
}
<!-- KYC snapshot -->
<div class="rm-view__section-title">
<fa-icon icon="shield-alt"></fa-icon> KYC Snapshot
</div>
<div class="rm-view__section-title"><fa-icon icon="shield-alt"></fa-icon> KYC Snapshot</div>
<div class="rm-view__kpis">
<div class="rm-kpi">
<div class="rm-kpi__label"><fa-icon icon="check-double"></fa-icon> KYC Status</div>
@@ -376,8 +378,14 @@
<div
class="rm-kpi__value rm-kpi__pill"
[class.rm-kpi__pill--ok]="viewDetails.kycStatusCode === 'clientKycStatus.verified'"
[class.rm-kpi__pill--warn]="viewDetails.kycStatusCode === 'clientKycStatus.pending' || viewDetails.kycStatusCode === 'clientKycStatus.initiated'"
[class.rm-kpi__pill--err]="viewDetails.kycStatusCode === 'clientKycStatus.rejected' || viewDetails.kycStatusCode === 'clientKycStatus.expired'"
[class.rm-kpi__pill--warn]="
viewDetails.kycStatusCode === 'clientKycStatus.pending' ||
viewDetails.kycStatusCode === 'clientKycStatus.initiated'
"
[class.rm-kpi__pill--err]="
viewDetails.kycStatusCode === 'clientKycStatus.rejected' ||
viewDetails.kycStatusCode === 'clientKycStatus.expired'
"
>
{{ viewDetails.kycStatus }}
</div>
@@ -409,14 +417,32 @@
</div>
<!-- 4 documents -->
<div class="rm-view__section-title">
<fa-icon icon="folder-open"></fa-icon> CKYC Documents
</div>
<div class="rm-view__section-title"><fa-icon icon="folder-open"></fa-icon> CKYC Documents</div>
<div class="rm-view__docs">
<ng-container *ngTemplateOutlet="docTile; context: { slot: viewDetails.docs.photo, label: 'Photo / Image of Client', icon: 'user-circle' }"></ng-container>
<ng-container *ngTemplateOutlet="docTile; context: { slot: viewDetails.docs.signature, label: 'Signature of Client', icon: 'file-signature' }"></ng-container>
<ng-container *ngTemplateOutlet="docTile; context: { slot: viewDetails.docs.idProof, label: 'Identity Proof', icon: 'id-card' }"></ng-container>
<ng-container *ngTemplateOutlet="docTile; context: { slot: viewDetails.docs.addressProof, label: 'Address Proof', icon: 'home' }"></ng-container>
<ng-container
*ngTemplateOutlet="
docTile;
context: { slot: viewDetails.docs.photo, label: 'Photo / Image of Client', icon: 'user-circle' }
"
></ng-container>
<ng-container
*ngTemplateOutlet="
docTile;
context: { slot: viewDetails.docs.signature, label: 'Signature of Client', icon: 'file-signature' }
"
></ng-container>
<ng-container
*ngTemplateOutlet="
docTile;
context: { slot: viewDetails.docs.idProof, label: 'Identity Proof', icon: 'id-card' }
"
></ng-container>
<ng-container
*ngTemplateOutlet="
docTile;
context: { slot: viewDetails.docs.addressProof, label: 'Address Proof', icon: 'home' }
"
></ng-container>
</div>
<ng-template #docTile let-slot="slot" let-label="label" let-icon="icon">
@@ -432,10 +458,7 @@
}
</div>
</div>
<fa-icon
class="rm-doc__state"
[icon]="slot.present ? 'check-circle' : 'times-circle'"
></fa-icon>
<fa-icon class="rm-doc__state" [icon]="slot.present ? 'check-circle' : 'times-circle'"></fa-icon>
</div>
@if (slot.files?.length) {
@@ -475,9 +498,7 @@
</div>
<div class="rm-modal__footer">
<button class="rm-btn rm-btn--ghost" (click)="closeView()">
<fa-icon icon="times"></fa-icon> Close
</button>
<button class="rm-btn rm-btn--ghost" (click)="closeView()"><fa-icon icon="times"></fa-icon> Close</button>
<button class="rm-btn rm-btn--primary" (click)="collectCkycFromView()">
<fa-icon icon="cloud-upload-alt"></fa-icon>
<span>Collect CKYC</span>
@@ -554,9 +575,7 @@
}
</div>
<div class="rm-step__content">
<div class="rm-step__label">
<fa-icon [icon]="phase.icon"></fa-icon> {{ phase.label }}
</div>
<div class="rm-step__label"><fa-icon [icon]="phase.icon"></fa-icon> {{ phase.label }}</div>
@if (phase.message) {
<div class="rm-step__msg">{{ phase.message }}</div>
}
@@ -677,7 +696,10 @@
class="rm-manifest__chip"
[class.rm-manifest__chip--ok]="entry.idProof?.packaged"
[class.rm-manifest__chip--miss]="!entry.idProof?.packaged"
[title]="(entry.idProof?.type || 'Identity Proof') + (entry.idProof?.zipEntry ? ' — ' + entry.idProof.zipEntry : '')"
[title]="
(entry.idProof?.type || 'Identity Proof') +
(entry.idProof?.zipEntry ? ' — ' + entry.idProof.zipEntry : '')
"
>
<fa-icon [icon]="entry.idProof?.packaged ? 'check' : 'times'"></fa-icon>
</span>
@@ -687,13 +709,23 @@
class="rm-manifest__chip"
[class.rm-manifest__chip--ok]="entry.addressProof?.packaged"
[class.rm-manifest__chip--miss]="!entry.addressProof?.packaged"
[title]="(entry.addressProof?.type || 'Address Proof') + (entry.addressProof?.zipEntry ? ' — ' + entry.addressProof.zipEntry : '')"
[title]="
(entry.addressProof?.type || 'Address Proof') +
(entry.addressProof?.zipEntry ? ' — ' + entry.addressProof.zipEntry : '')
"
>
<fa-icon [icon]="entry.addressProof?.packaged ? 'check' : 'times'"></fa-icon>
</span>
</td>
<td class="rm-manifest__td-right rm-manifest__size">
{{ formatBytes((entry.photo?.bytes || 0) + (entry.signature?.bytes || 0) + (entry.idProof?.bytes || 0) + (entry.addressProof?.bytes || 0)) }}
{{
formatBytes(
(entry.photo?.bytes || 0) +
(entry.signature?.bytes || 0) +
(entry.idProof?.bytes || 0) +
(entry.addressProof?.bytes || 0)
)
}}
</td>
</tr>
}
@@ -718,14 +750,14 @@
<div class="rm-modal__footer">
<button class="rm-btn rm-btn--ghost" [disabled]="zipRunning" (click)="closeZipDialog()">
@if (zipResult && !zipResult.error) { Close } @else { Cancel }
@if (zipResult && !zipResult.error) {
Close
} @else {
Cancel
}
</button>
@if (!zipResult || zipResult.error) {
<button
class="rm-btn rm-btn--amber"
[disabled]="zipRunning"
(click)="startZipCreation()"
>
<button class="rm-btn rm-btn--amber" [disabled]="zipRunning" (click)="startZipCreation()">
@if (zipRunning) {
<fa-icon icon="spinner" animation="spin"></fa-icon>
<span>Creating…</span>
@@ -762,9 +794,8 @@
<div class="rm-modal__body">
<p class="rm-modal__intro">
Transferring batch to the <strong>CERSAI SFTP</strong> drop server and waiting for
acknowledgement. On success, the returned <strong>CKYC numbers</strong> will be saved
against each client's master record.
Transferring batch to the <strong>CERSAI SFTP</strong> drop server and waiting for acknowledgement. On
success, the returned <strong>CKYC numbers</strong> will be saved against each client's master record.
</p>
@if (uploadResult?.status === 'ALREADY_UPLOADED') {
@@ -798,9 +829,7 @@
}
</div>
<div class="rm-step__content">
<div class="rm-step__label">
<fa-icon [icon]="phase.icon"></fa-icon> {{ phase.label }}
</div>
<div class="rm-step__label"><fa-icon [icon]="phase.icon"></fa-icon> {{ phase.label }}</div>
@if (phase.message) {
<div class="rm-step__msg">{{ phase.message }}</div>
}
@@ -824,7 +853,9 @@
<div class="rm-modal__result-grid">
<div>
<span class="rm-modal__result-label">CERSAI Reference</span>
<span class="rm-modal__result-value rm-modal__result-value--mono">{{ uploadResult.cersaiReference }}</span>
<span class="rm-modal__result-value rm-modal__result-value--mono">{{
uploadResult.cersaiReference
}}</span>
</div>
<div>
<span class="rm-modal__result-label">Batch ID</span>
@@ -860,11 +891,15 @@
</div>
<div class="rm-modal__result-grid-span">
<span class="rm-modal__result-label">Checksum sent</span>
<span class="rm-modal__result-value rm-modal__result-value--mono">{{ uploadResult.checksumSent }}</span>
<span class="rm-modal__result-value rm-modal__result-value--mono">{{
uploadResult.checksumSent
}}</span>
</div>
<div class="rm-modal__result-grid-span">
<span class="rm-modal__result-label">Checksum verified</span>
<span class="rm-modal__result-value rm-modal__result-value--mono">{{ uploadResult.checksumVerified }}</span>
<span class="rm-modal__result-value rm-modal__result-value--mono">{{
uploadResult.checksumVerified
}}</span>
</div>
</div>
</div>
@@ -918,14 +953,14 @@
<div class="rm-modal__footer">
<button class="rm-btn rm-btn--ghost" [disabled]="uploadRunning" (click)="closeUploadDialog()">
@if (uploadResult && !uploadResult.error) { Close } @else { Cancel }
@if (uploadResult && !uploadResult.error) {
Close
} @else {
Cancel
}
</button>
@if (!uploadResult || uploadResult.error) {
<button
class="rm-btn rm-btn--amber"
[disabled]="uploadRunning"
(click)="startSftpUpload()"
>
<button class="rm-btn rm-btn--amber" [disabled]="uploadRunning" (click)="startSftpUpload()">
@if (uploadRunning) {
<fa-icon icon="spinner" animation="spin"></fa-icon>
<span>Uploading…</span>
@@ -24,7 +24,9 @@ $amber: #ba7517;
$amber-light: #faeeda;
$teal-900: #085041;
.rm-container { padding: 20px 24px 40px; }
.rm-container {
padding: 20px 24px 40px;
}
.rm-header {
display: flex;
@@ -51,7 +53,7 @@ $teal-900: #085041;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 3px 10px rgba(26, 115, 232, 0.25);
box-shadow: 0 3px 10px rgb(26 115 232 / 25%);
flex-shrink: 0;
}
@@ -84,9 +86,14 @@ $teal-900: #085041;
align-items: center;
gap: 8px;
fa-icon { font-size: 13px; }
fa-icon {
font-size: 13px;
}
&--ghost { color: $gray-700 !important; border-color: $gray-200 !important; }
&--ghost {
color: $gray-700 !important;
border-color: $gray-200 !important;
}
&--primary {
box-shadow: 0 1px 3px rgba($primary, 0.3) !important;
@@ -102,7 +109,7 @@ $teal-900: #085041;
border: 1px solid $gray-200 !important;
border-radius: 14px !important;
padding: 8px !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03) !important;
box-shadow: 0 1px 3px rgb(0 0 0 / 3%) !important;
}
button.rm-tab,
@@ -147,7 +154,9 @@ button.rm-tab,
background: $gray-50 !important;
color: $gray-900 !important;
fa-icon { color: $primary !important; }
fa-icon {
color: $primary !important;
}
}
}
@@ -175,7 +184,9 @@ button.rm-tab.rm-tab--active,
color: $primary-dark !important;
box-shadow: 0 2px 8px rgba($primary, 0.12) !important;
fa-icon { color: $primary !important; }
fa-icon {
color: $primary !important;
}
.rm-tab__count {
background: $primary !important;
@@ -190,7 +201,9 @@ button.rm-tab--amber.rm-tab--active,
color: #633806 !important;
box-shadow: 0 2px 8px rgba($amber, 0.18) !important;
fa-icon { color: $amber !important; }
fa-icon {
color: $amber !important;
}
.rm-tab__count {
background: $amber !important;
@@ -205,7 +218,9 @@ button.rm-tab--green.rm-tab--active,
color: $teal-900 !important;
box-shadow: 0 2px 8px rgba($green, 0.18) !important;
fa-icon { color: $green !important; }
fa-icon {
color: $green !important;
}
.rm-tab__count {
background: $green !important;
@@ -214,8 +229,13 @@ button.rm-tab--green.rm-tab--active,
}
@media (width <= 680px) {
.rm-tabs { flex-direction: column; }
.rm-tab { justify-content: space-between; }
.rm-tabs {
flex-direction: column;
}
.rm-tab {
justify-content: space-between;
}
}
/* Filters */
@@ -294,7 +314,10 @@ button.rm-tab--green.rm-tab--active,
align-items: center;
gap: 6px;
fa-icon { color: $primary; font-size: 11px; }
fa-icon {
color: $primary;
font-size: 11px;
}
}
.rm-input {
@@ -310,18 +333,20 @@ button.rm-tab--green.rm-tab--active,
box-sizing: border-box;
transition: all 0.15s;
&::placeholder { color: $gray-500; }
&::placeholder {
color: $gray-500;
}
&:focus {
outline: none;
border-color: $primary;
background: $white;
box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1);
box-shadow: 0 0 0 3px rgb(26 115 232 / 10%);
}
&--sm {
width: auto;
padding: 6px 10px 6px 10px;
padding: 6px 10px;
font-size: 12px;
height: 30px;
}
@@ -330,8 +355,6 @@ button.rm-tab--green.rm-tab--active,
/* Native <select> — custom chevron, no overlap */
select.rm-input {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
padding-right: 34px;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='%235f6368' d='M0 0l5 6 5-6z'/></svg>");
background-repeat: no-repeat;
@@ -344,6 +367,7 @@ select.rm-input {
}
/* Native date input — cleaner calendar icon */
/* stylelint-disable-next-line selector-no-qualifying-type */
input[type='date'].rm-input {
padding-right: 10px;
@@ -354,11 +378,23 @@ input[type='date'].rm-input {
transition: opacity 0.15s;
}
&:hover::-webkit-calendar-picker-indicator { opacity: 0.9; }
&:hover::-webkit-calendar-picker-indicator {
opacity: 0.9;
}
&::-webkit-datetime-edit { color: $gray-900; padding-top: 1px; }
&::-webkit-datetime-edit-fields-wrapper { color: $gray-900; }
&::-webkit-datetime-edit-text { color: $gray-500; padding: 0 2px; }
&::-webkit-datetime-edit {
color: $gray-900;
padding-top: 1px;
}
&::-webkit-datetime-edit-fields-wrapper {
color: $gray-900;
}
&::-webkit-datetime-edit-text {
color: $gray-500;
padding: 0 2px;
}
&:not(:focus):placeholder-shown::-webkit-datetime-edit {
color: $gray-500;
@@ -381,7 +417,9 @@ input[type='date'].rm-input {
background: $primary-light;
border: 1px dashed rgba($primary, 0.3);
fa-icon { font-size: 20px; }
fa-icon {
font-size: 20px;
}
}
&--error {
@@ -389,7 +427,9 @@ input[type='date'].rm-input {
background: $red-light;
border: 1px solid rgba($red, 0.3);
fa-icon { font-size: 18px; }
fa-icon {
font-size: 18px;
}
}
&--empty {
@@ -397,7 +437,9 @@ input[type='date'].rm-input {
background: $gray-50;
border: 1px dashed $gray-200;
fa-icon { color: $green; }
fa-icon {
color: $green;
}
h3 {
margin: 4px 0 0;
@@ -406,7 +448,9 @@ input[type='date'].rm-input {
color: $gray-900;
}
p { margin: 0; }
p {
margin: 0;
}
}
}
@@ -433,9 +477,14 @@ input[type='date'].rm-input {
align-items: center;
gap: 6px;
fa-icon { color: $primary; }
fa-icon {
color: $primary;
}
strong { color: $gray-900; font-weight: 700; }
strong {
color: $gray-900;
font-weight: 700;
}
}
&__selected {
@@ -449,7 +498,9 @@ input[type='date'].rm-input {
border-radius: 14px;
border: 1px solid rgba($green, 0.3);
strong { font-weight: 700; }
strong {
font-weight: 700;
}
}
}
@@ -490,12 +541,15 @@ input[type='date'].rm-input {
vertical-align: middle;
}
tbody tr:last-child td { border-bottom: none; }
tbody tr:last-child td {
border-bottom: none;
}
&__check {
width: 42px;
text-align: center;
/* stylelint-disable-next-line selector-no-qualifying-type */
input[type='checkbox'] {
width: 16px;
height: 16px;
@@ -504,21 +558,35 @@ input[type='date'].rm-input {
}
}
&__artefacts { width: 180px; }
&__completion { width: 90px; text-align: center; }
&__actions { width: 90px; text-align: right; }
&__artefacts {
width: 180px;
}
&__completion {
width: 90px;
text-align: center;
}
&__actions {
width: 90px;
text-align: right;
}
}
.rm-row {
cursor: pointer;
transition: background 0.12s;
&:hover { background: #f5f8fc; }
&:hover {
background: #f5f8fc;
}
&--selected {
background: $primary-light !important;
td { border-bottom-color: rgba($primary, 0.15); }
td {
border-bottom-color: rgba($primary, 0.15);
}
}
&__client {
@@ -542,7 +610,9 @@ input[type='date'].rm-input {
flex-shrink: 0;
}
&__ident { min-width: 0; }
&__ident {
min-width: 0;
}
&__name {
font-size: 13px;
@@ -560,7 +630,9 @@ input[type='date'].rm-input {
font-family: 'SF Mono', Consolas, monospace;
margin-top: 1px;
fa-icon { font-size: 9px; }
fa-icon {
font-size: 9px;
}
}
&__line {
@@ -570,9 +642,14 @@ input[type='date'].rm-input {
font-size: 12px;
color: $gray-700;
+ .rm-row__line { margin-top: 3px; }
+ .rm-row__line {
margin-top: 3px;
}
fa-icon { color: $primary; font-size: 10px; }
fa-icon {
color: $primary;
font-size: 10px;
}
span {
white-space: nowrap;
@@ -630,10 +707,21 @@ input[type='date'].rm-input {
font-weight: 700;
border: 1px solid transparent;
fa-icon { font-size: 8px; }
fa-icon {
font-size: 8px;
}
&--ok { background: $green-light; color: $teal-900; border-color: rgba($green, 0.2); }
&--miss { background: $red-light; color: $red; border-color: rgba($red, 0.2); }
&--ok {
background: $green-light;
color: $teal-900;
border-color: rgba($green, 0.2);
}
&--miss {
background: $red-light;
color: $red;
border-color: rgba($red, 0.2);
}
}
.rm-action-btn {
@@ -647,7 +735,9 @@ input[type='date'].rm-input {
margin-left: 4px;
transition: all 0.12s;
fa-icon { font-size: 11px; }
fa-icon {
font-size: 11px;
}
&:hover {
background: $primary-light;
@@ -681,7 +771,7 @@ input[type='date'].rm-input {
background: $gray-900;
color: $white;
border-radius: 14px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
box-shadow: 0 12px 40px rgb(0 0 0 / 25%);
max-width: calc(100vw - 40px);
animation: rm-slide-up 0.25s ease-out;
@@ -697,8 +787,15 @@ input[type='date'].rm-input {
gap: 8px;
font-size: 13px;
fa-icon { color: #9fe1cb; }
strong { color: $white; font-weight: 700; font-size: 15px; }
fa-icon {
color: #9fe1cb;
}
strong {
color: $white;
font-weight: 700;
font-size: 15px;
}
}
&__warn {
@@ -712,7 +809,9 @@ input[type='date'].rm-input {
font-size: 11px;
font-weight: 600;
fa-icon { font-size: 10px; }
fa-icon {
font-size: 10px;
}
}
&__btns {
@@ -723,17 +822,24 @@ input[type='date'].rm-input {
}
@keyframes rm-slide-up {
from { transform: translate(-50%, 20px); opacity: 0; }
to { transform: translate(-50%, 0); opacity: 1; }
from {
transform: translate(-50%, 20px);
opacity: 0;
}
to {
transform: translate(-50%, 0);
opacity: 1;
}
}
.rm-btn--ghost-light {
background: rgba(255, 255, 255, 0.1) !important;
background: rgb(255 255 255 / 10%) !important;
color: $white !important;
border-color: rgba(255, 255, 255, 0.2) !important;
border-color: rgb(255 255 255 / 20%) !important;
&:hover {
background: rgba(255, 255, 255, 0.2) !important;
background: rgb(255 255 255 / 20%) !important;
}
}
@@ -742,14 +848,16 @@ input[type='date'].rm-input {
color: $white !important;
border: none !important;
&:hover { background: #a0651a !important; }
&:hover {
background: #a0651a !important;
}
}
/* ---------- Modal ---------- */
.rm-modal {
position: fixed;
inset: 0;
background: rgba(15, 25, 45, 0.55);
background: rgb(15 25 45 / 55%);
backdrop-filter: blur(3px);
z-index: 1200;
display: flex;
@@ -767,7 +875,7 @@ input[type='date'].rm-input {
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25);
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
animation: rm-zoom 0.25s ease-out;
}
@@ -817,7 +925,10 @@ input[type='date'].rm-input {
background: $red-light;
}
&:disabled { opacity: 0.4; cursor: not-allowed; }
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
&__body {
@@ -832,7 +943,10 @@ input[type='date'].rm-input {
line-height: 1.55;
margin: 0 0 16px;
strong { color: $gray-900; font-weight: 700; }
strong {
color: $gray-900;
font-weight: 700;
}
}
&__warn {
@@ -848,9 +962,18 @@ input[type='date'].rm-input {
color: #633806;
line-height: 1.5;
fa-icon { color: $amber; font-size: 14px; flex-shrink: 0; margin-top: 2px; }
fa-icon {
color: $amber;
font-size: 14px;
flex-shrink: 0;
margin-top: 2px;
}
strong { display: block; font-weight: 700; color: #4d2a05; }
strong {
display: block;
font-weight: 700;
color: #4d2a05;
}
}
&__result {
@@ -870,7 +993,10 @@ input[type='date'].rm-input {
color: $teal-900;
margin-bottom: 12px;
fa-icon { color: $green; font-size: 16px; }
fa-icon {
color: $green;
font-size: 16px;
}
}
&__result-grid {
@@ -920,7 +1046,12 @@ input[type='date'].rm-input {
font-size: 12px;
color: #5a1a0e;
fa-icon { color: $red; font-size: 14px; flex-shrink: 0; margin-top: 2px; }
fa-icon {
color: $red;
font-size: 14px;
flex-shrink: 0;
margin-top: 2px;
}
strong {
display: block;
@@ -953,7 +1084,9 @@ input[type='date'].rm-input {
text-align: left;
transition: background 0.15s;
&:hover { background: $gray-100; }
&:hover {
background: $gray-100;
}
> fa-icon:first-child {
color: $primary;
@@ -965,7 +1098,11 @@ input[type='date'].rm-input {
flex: 1;
min-width: 0;
strong { color: $gray-900; font-weight: 700; margin-right: 4px; }
strong {
color: $gray-900;
font-weight: 700;
margin-right: 4px;
}
}
}
@@ -986,7 +1123,9 @@ input[type='date'].rm-input {
text-transform: uppercase;
letter-spacing: 0.3px;
fa-icon { font-size: 9px; }
fa-icon {
font-size: 9px;
}
&--full {
background: $green-light;
@@ -1037,14 +1176,31 @@ input[type='date'].rm-input {
vertical-align: middle;
}
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: #f5f8fc; }
tbody tr:last-child td {
border-bottom: none;
}
tbody tr:hover {
background: #f5f8fc;
}
}
&__th-center { text-align: center !important; width: 38px; }
&__th-right { text-align: right !important; }
&__td-center { text-align: center; }
&__td-right { text-align: right; }
&__th-center {
text-align: center !important;
width: 38px;
}
&__th-right {
text-align: right !important;
}
&__td-center {
text-align: center;
}
&__td-right {
text-align: right;
}
&__client {
display: flex;
@@ -1063,7 +1219,10 @@ input[type='date'].rm-input {
color: $gray-500;
font-family: 'SF Mono', Consolas, monospace;
fa-icon { font-size: 8px; margin-right: 2px; }
fa-icon {
font-size: 8px;
margin-right: 2px;
}
}
&__chip {
@@ -1165,7 +1324,10 @@ input[type='date'].rm-input {
align-items: center;
gap: 8px;
fa-icon { font-size: 12px; color: $gray-500; }
fa-icon {
font-size: 12px;
color: $gray-500;
}
}
&__msg {
@@ -1185,7 +1347,9 @@ input[type='date'].rm-input {
.rm-step__label {
color: $primary;
fa-icon { color: $primary; }
fa-icon {
color: $primary;
}
}
}
@@ -1196,12 +1360,16 @@ input[type='date'].rm-input {
color: $white;
}
&::after { background: $green; }
&::after {
background: $green;
}
.rm-step__label {
color: $teal-900;
fa-icon { color: $green; }
fa-icon {
color: $green;
}
}
}
@@ -1212,18 +1380,32 @@ input[type='date'].rm-input {
color: $white;
}
.rm-step__label { color: $red; }
.rm-step__label {
color: $red;
}
}
}
@keyframes rm-fade {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes rm-zoom {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
/* ---------- View details modal ---------- */
@@ -1238,7 +1420,7 @@ input[type='date'].rm-input {
.rm-modal__title--teal fa-icon {
background: linear-gradient(135deg, #0f6e56, #1d9e75) !important;
box-shadow: 0 3px 10px rgba(29, 158, 117, 0.3) !important;
box-shadow: 0 3px 10px rgb(29 158 117 / 30%) !important;
}
.rm-modal__warn--blue {
@@ -1247,8 +1429,13 @@ input[type='date'].rm-input {
border-left-color: $primary !important;
color: #0c447c !important;
fa-icon { color: $primary !important; }
strong { color: #0c447c !important; }
fa-icon {
color: $primary !important;
}
strong {
color: #0c447c !important;
}
}
/* CKYC numbers table */
@@ -1272,7 +1459,10 @@ input[type='date'].rm-input {
gap: 6px;
border-bottom: 1px solid rgba($green, 0.2);
fa-icon { color: $green; font-size: 12px; }
fa-icon {
color: $green;
font-size: 12px;
}
}
&__table-wrap {
@@ -1308,7 +1498,9 @@ input[type='date'].rm-input {
color: $gray-900;
}
tbody tr:last-child td { border-bottom: none; }
tbody tr:last-child td {
border-bottom: none;
}
}
&__mono {
@@ -1323,7 +1515,10 @@ input[type='date'].rm-input {
background: rgba($green, 0.06);
}
&__status { text-align: right; white-space: nowrap; }
&__status {
text-align: right;
white-space: nowrap;
}
}
.rm-view {
@@ -1353,7 +1548,10 @@ input[type='date'].rm-input {
box-shadow: 0 3px 10px rgba($primary, 0.25);
}
&__client-info { min-width: 0; flex: 1; }
&__client-info {
min-width: 0;
flex: 1;
}
&__client-name {
font-size: 16px;
@@ -1370,7 +1568,11 @@ input[type='date'].rm-input {
flex-wrap: wrap;
gap: 6px;
fa-icon { color: $primary; font-size: 10px; margin-right: 3px; }
fa-icon {
color: $primary;
font-size: 10px;
margin-right: 3px;
}
}
&__cif {
@@ -1381,7 +1583,10 @@ input[type='date'].rm-input {
}
}
&__sep { color: $gray-200; margin: 0 2px; }
&__sep {
color: $gray-200;
margin: 0 2px;
}
&__loading {
padding: 10px 14px;
@@ -1394,7 +1599,9 @@ input[type='date'].rm-input {
align-items: center;
gap: 8px;
fa-icon { font-size: 14px; }
fa-icon {
font-size: 14px;
}
}
&__section-title {
@@ -1408,9 +1615,14 @@ input[type='date'].rm-input {
align-items: center;
gap: 6px;
fa-icon { color: $primary; font-size: 12px; }
fa-icon {
color: $primary;
font-size: 12px;
}
&:first-of-type { margin-top: 6px; }
&:first-of-type {
margin-top: 6px;
}
}
&__kpis {
@@ -1445,7 +1657,10 @@ input[type='date'].rm-input {
align-items: center;
gap: 5px;
fa-icon { color: $primary; font-size: 10px; }
fa-icon {
color: $primary;
font-size: 10px;
}
}
&__value {
@@ -1454,7 +1669,11 @@ input[type='date'].rm-input {
color: $gray-900;
}
&__missing { color: $gray-500; font-style: italic; font-weight: 500; }
&__missing {
color: $gray-500;
font-style: italic;
font-weight: 500;
}
&__pill {
display: inline-flex;
@@ -1515,7 +1734,10 @@ input[type='date'].rm-input {
flex-shrink: 0;
}
&__body { flex: 1; min-width: 0; }
&__body {
flex: 1;
min-width: 0;
}
&__label {
font-size: 10px;
@@ -1570,7 +1792,10 @@ input[type='date'].rm-input {
color: $gray-500;
font-style: italic;
fa-icon { color: $primary; font-size: 11px; }
fa-icon {
color: $primary;
font-size: 11px;
}
}
&--ok {
@@ -1582,8 +1807,13 @@ input[type='date'].rm-input {
color: $teal-900;
}
.rm-doc__state { color: $green; }
.rm-doc__files { border-top-color: rgba($green, 0.2); }
.rm-doc__state {
color: $green;
}
.rm-doc__files {
border-top-color: rgba($green, 0.2);
}
}
&--miss {
@@ -1595,7 +1825,9 @@ input[type='date'].rm-input {
color: $red;
}
.rm-doc__state { color: $red; }
.rm-doc__state {
color: $red;
}
}
}
@@ -1615,7 +1847,10 @@ input[type='date'].rm-input {
border-color: $primary;
box-shadow: 0 2px 6px rgba($primary, 0.1);
.rm-file__open { opacity: 1; color: $primary; }
.rm-file__open {
opacity: 1;
color: $primary;
}
}
&__thumb {
@@ -1707,7 +1942,7 @@ input[type='date'].rm-input {
&:hover {
border-color: $primary-light;
box-shadow: 0 4px 14px rgba(26, 115, 232, 0.1);
box-shadow: 0 4px 14px rgb(26 115 232 / 10%);
transform: translateY(-1px);
}
@@ -1756,7 +1991,10 @@ input[type='date'].rm-input {
font-family: 'SF Mono', Consolas, monospace;
margin-top: 2px;
fa-icon { font-size: 10px; margin-right: 2px; }
fa-icon {
font-size: 10px;
margin-right: 2px;
}
}
&__completion {
@@ -1835,7 +2073,11 @@ input[type='date'].rm-input {
color: $gray-700;
min-width: 0;
fa-icon { color: $primary; font-size: 11px; flex-shrink: 0; }
fa-icon {
color: $primary;
font-size: 11px;
flex-shrink: 0;
}
span {
white-space: nowrap;
@@ -1844,31 +2086,9 @@ input[type='date'].rm-input {
flex: 1;
}
&--missing { color: $gray-500; font-style: italic; }
}
.rm-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 9px;
border-radius: 14px;
font-size: 11px;
font-weight: 600;
border: 1px solid transparent;
fa-icon { font-size: 10px; }
&--ok {
background: $green-light;
color: $teal-900;
border-color: rgba($green, 0.2);
}
&--miss {
background: $red-light;
color: $red;
border-color: rgba($red, 0.2);
&--missing {
color: $gray-500;
font-style: italic;
}
}
@@ -1910,13 +2130,28 @@ input[type='date'].rm-input {
border-radius: 8px !important;
color: $gray-700 !important;
&:disabled { color: $gray-200 !important; }
&:disabled {
color: $gray-200 !important;
}
}
}
@media (width <= 640px) {
.rm-container { padding: 14px; }
.rm-header { flex-direction: column; align-items: flex-start; }
.rm-cards { grid-template-columns: 1fr; }
.rm-pager { flex-direction: column; gap: 12px; }
.rm-container {
padding: 14px;
}
.rm-header {
flex-direction: column;
align-items: flex-start;
}
.rm-cards {
grid-template-columns: 1fr;
}
.rm-pager {
flex-direction: column;
gap: 12px;
}
}
@@ -221,12 +221,8 @@ export class RecordMasterComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res: any) => {
(res?.idProofs || []).forEach((p: any) =>
this.proofKindMap.set(String(p.id), 'id')
);
(res?.addressProofs || []).forEach((p: any) =>
this.proofKindMap.set(String(p.id), 'address')
);
(res?.idProofs || []).forEach((p: any) => this.proofKindMap.set(String(p.id), 'id'));
(res?.addressProofs || []).forEach((p: any) => this.proofKindMap.set(String(p.id), 'address'));
this.proofKindLoaded = true;
// If records are already loaded, re-verify them now
if (this.rows.length > 0) this.reverifyArtefacts();
@@ -256,9 +252,7 @@ export class RecordMasterComponent implements OnInit {
const docTypeId = String(idf?.documentType?.id ?? '');
const kind = this.proofKindMap.get(docTypeId);
const isActive =
idf?.status === 'clientIdentifierStatusType.active' ||
idf?.status === 'Active' ||
!idf?.status;
idf?.status === 'clientIdentifierStatusType.active' || idf?.status === 'Active' || !idf?.status;
if (!isActive || !kind) continue;
if (kind === 'id') hasId = true;
else if (kind === 'address') hasAddr = true;
@@ -326,9 +320,7 @@ export class RecordMasterComponent implements OnInit {
/** Apply the tab's client-side filter and recompute counts */
private applyTabFilter(): void {
if (this.activeTab === 'ready') {
this.rows = this.allRows.filter(
(r) => r.hasPhoto && r.hasSignature && r.hasIdProof && r.hasAddressProof
);
this.rows = this.allRows.filter((r) => r.hasPhoto && r.hasSignature && r.hasIdProof && r.hasAddressProof);
} else {
this.rows = this.allRows;
}
@@ -338,12 +330,9 @@ export class RecordMasterComponent implements OnInit {
private computeCounts(all: CkycRecordRow[]) {
return {
all: all.length,
pendingCollection: all.filter(
(r) => !r.hasPhoto || !r.hasSignature || !r.hasIdProof || !r.hasAddressProof
).length,
ready: all.filter(
(r) => r.hasPhoto && r.hasSignature && r.hasIdProof && r.hasAddressProof
).length
pendingCollection: all.filter((r) => !r.hasPhoto || !r.hasSignature || !r.hasIdProof || !r.hasAddressProof)
.length,
ready: all.filter((r) => r.hasPhoto && r.hasSignature && r.hasIdProof && r.hasAddressProof).length
};
}
@@ -390,7 +379,11 @@ export class RecordMasterComponent implements OnInit {
}
openClient(row: CkycRecordRow): void {
this.router.navigate(['/clients/', row.clientId, 'general']);
this.router.navigate([
'/clients/',
row.clientId,
'general'
]);
}
openView(row: CkycRecordRow, event?: Event): void {
@@ -402,9 +395,17 @@ export class RecordMasterComponent implements OnInit {
row,
docs: {
photo: { present: row.hasPhoto, name: row.hasPhoto ? 'Profile photo' : 'Not uploaded', files: [] },
signature: { present: row.hasSignature, name: row.hasSignature ? 'Client signature' : 'Not uploaded', files: [] },
signature: {
present: row.hasSignature,
name: row.hasSignature ? 'Client signature' : 'Not uploaded',
files: []
},
idProof: { present: row.hasIdProof, name: row.hasIdProof ? 'Identifier' : 'Not uploaded', files: [] },
addressProof: { present: row.hasAddressProof, name: row.hasAddressProof ? 'Identifier' : 'Not uploaded', files: [] }
addressProof: {
present: row.hasAddressProof,
name: row.hasAddressProof ? 'Identifier' : 'Not uploaded',
files: []
}
}
};
@@ -486,9 +487,7 @@ export class RecordMasterComponent implements OnInit {
const docTypeId = String(idf?.documentType?.id ?? '');
const kind = this.proofKindMap.get(docTypeId);
const isActive =
idf?.status === 'clientIdentifierStatusType.active' ||
idf?.status === 'Active' ||
!idf?.status;
idf?.status === 'clientIdentifierStatusType.active' || idf?.status === 'Active' || !idf?.status;
if (!isActive || !kind) continue;
const proofName = idf?.documentType?.name || 'Identifier';
const rawKey = idf?.documentKey || '';
@@ -580,7 +579,11 @@ export class RecordMasterComponent implements OnInit {
private formatJavaDate(value: any): string {
if (!value) return '';
if (Array.isArray(value) && value.length >= 3) {
const [y, m, d] = value;
const [
y,
m,
d
] = value;
return `${String(d).padStart(2, '0')}-${String(m).padStart(2, '0')}-${y}`;
}
if (typeof value === 'string') return value;
@@ -608,7 +611,11 @@ export class RecordMasterComponent implements OnInit {
collectCkyc(row: CkycRecordRow, event: Event): void {
event.stopPropagation();
this.router.navigate(['/clients/', row.clientId, 'ckyc']);
this.router.navigate([
'/clients/',
row.clientId,
'ckyc'
]);
}
createClient(): void {
@@ -616,7 +623,12 @@ export class RecordMasterComponent implements OnInit {
}
artefactCompletion(row: CkycRecordRow): number {
const items = [row.hasPhoto, row.hasSignature, row.hasIdProof, row.hasAddressProof];
const items = [
row.hasPhoto,
row.hasSignature,
row.hasIdProof,
row.hasAddressProof
];
return items.filter(Boolean).length;
}
@@ -670,7 +682,11 @@ export class RecordMasterComponent implements OnInit {
this.zipRunning = false;
this.zipResult = null;
this.manifestExpanded = false;
this.zipPhases = this.zipPhases.map((p) => ({ ...p, status: 'pending' as const, message: undefined as string | undefined }));
this.zipPhases = this.zipPhases.map((p) => ({
...p,
status: 'pending' as const,
message: undefined as string | undefined
}));
}
closeZipDialog(): void {
@@ -683,7 +699,11 @@ export class RecordMasterComponent implements OnInit {
this.zipRunning = true;
this.zipResult = null;
// Reset phases
this.zipPhases = this.zipPhases.map((p) => ({ ...p, status: 'pending' as const, message: undefined as string | undefined }));
this.zipPhases = this.zipPhases.map((p) => ({
...p,
status: 'pending' as const,
message: undefined as string | undefined
}));
// Kick off visual phase animation for steps 1-4 while the POST is in flight.
// Step 5 (Register batch) is held until the API responds.
@@ -745,15 +765,21 @@ export class RecordMasterComponent implements OnInit {
// Don't override a phase that's already done/failed by the API response
if (this.zipPhases[i].status === 'pending') this.zipPhases[i].status = 'active';
}, i * step);
setTimeout(() => {
if (this.zipPhases[i].status === 'active') this.zipPhases[i].status = 'done';
}, (i + 1) * step);
setTimeout(
() => {
if (this.zipPhases[i].status === 'active') this.zipPhases[i].status = 'done';
},
(i + 1) * step
);
}
// Activate phase 5 after phases 1-4 finish, leave it active until API returns
setTimeout(() => {
const last = this.zipPhases.length - 1;
if (this.zipPhases[last].status === 'pending') this.zipPhases[last].status = 'active';
}, (this.zipPhases.length - 1) * step);
setTimeout(
() => {
const last = this.zipPhases.length - 1;
if (this.zipPhases[last].status === 'pending') this.zipPhases[last].status = 'active';
},
(this.zipPhases.length - 1) * step
);
}
proceedToUpload(): void {
@@ -840,7 +866,14 @@ export class RecordMasterComponent implements OnInit {
if (value == null || value === '') return '';
const pad = (n: any) => String(n ?? 0).padStart(2, '0');
if (Array.isArray(value) && value.length >= 3) {
const [y, mo, d, h = 0, mi = 0, s = 0] = value;
const [
y,
mo,
d,
h = 0,
mi = 0,
s = 0
] = value;
const date = `${pad(d)}-${pad(mo)}-${y}`;
if (value.length >= 4) {
return `${date} ${pad(h)}:${pad(mi)}:${pad(s)}`;
@@ -863,14 +896,20 @@ export class RecordMasterComponent implements OnInit {
setTimeout(() => {
if (this.uploadPhases[i].status === 'pending') this.uploadPhases[i].status = 'active';
}, i * step);
setTimeout(() => {
if (this.uploadPhases[i].status === 'active') this.uploadPhases[i].status = 'done';
}, (i + 1) * step);
setTimeout(
() => {
if (this.uploadPhases[i].status === 'active') this.uploadPhases[i].status = 'done';
},
(i + 1) * step
);
}
setTimeout(() => {
const last = this.uploadPhases.length - 1;
if (this.uploadPhases[last].status === 'pending') this.uploadPhases[last].status = 'active';
}, (this.uploadPhases.length - 1) * step);
setTimeout(
() => {
const last = this.uploadPhases.length - 1;
if (this.uploadPhases[last].status === 'pending') this.uploadPhases[last].status = 'active';
},
(this.uploadPhases.length - 1) * step
);
}
goToVerification(): void {
@@ -91,7 +91,9 @@
@if (isFieldEnabled('stateProvinceId')) {
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.State / Province' | translate }}</span>
<span class="detail-value">{{ getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name }}</span>
<span class="detail-value">{{
getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name
}}</span>
</div>
}
@if (isFieldEnabled('countryId')) {
@@ -1,18 +1,48 @@
/**
* 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/.
*/
/**
* Entity Classification — Scheme & Sector Classification
*/
.ec-page { padding: 0; }
.ec-page {
padding: 0;
}
.step-header {
background: #0f172a;
padding: 16px 24px;
border-radius: 10px 10px 0 0;
margin-bottom: 20px;
&__left { display: flex; align-items: center; gap: 12px; }
&__icon { font-size: 18px; color: #60a5fa; }
&__title { font-size: 16px; font-weight: 600; color: #fff; margin: 0; }
&__sub { font-size: 11px; color: rgb(255 255 255 / 50%); margin: 2px 0 0; }
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 18px;
color: #60a5fa;
}
&__title {
font-size: 16px;
font-weight: 600;
color: #fff;
margin: 0;
}
&__sub {
font-size: 11px;
color: rgb(255 255 255 / 50%);
margin: 2px 0 0;
}
}
.ec-chips-label {
@@ -32,7 +62,11 @@
min-height: 28px;
}
.ec-no-chip { font-size: 12px; color: #94a3b8; padding: 4px 0; }
.ec-no-chip {
font-size: 12px;
color: #94a3b8;
padding: 4px 0;
}
.ec-chip {
padding: 4px 10px;
@@ -50,7 +84,10 @@
height: 14px !important;
cursor: pointer;
opacity: 0.6;
&:hover { opacity: 1; }
&:hover {
opacity: 1;
}
}
.ec-card {
@@ -60,7 +97,10 @@
margin-bottom: 12px;
overflow: hidden;
transition: border-color 0.2s;
&--open { border-color: #93c5fd; }
&--open {
border-color: #93c5fd;
}
}
.ec-card-head {
@@ -71,10 +111,17 @@
cursor: pointer;
user-select: none;
transition: background 0.15s;
&:hover { background: #f8fafc; }
&:hover {
background: #f8fafc;
}
}
.ec-card-head-left { display: flex; align-items: center; gap: 12px; }
.ec-card-head-left {
display: flex;
align-items: center;
gap: 12px;
}
.ec-tag {
padding: 3px 10px;
@@ -85,14 +132,31 @@
white-space: nowrap;
}
.ec-card-title { font-size: 14px; font-weight: 600; color: #1e293b; }
.ec-card-desc { font-size: 12px; color: #64748b; margin-top: 2px; }
.ec-card-toggle { display: flex; align-items: center; gap: 10px; }
.ec-card-title {
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.ec-card-desc {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
.ec-card-toggle {
display: flex;
align-items: center;
gap: 10px;
}
.ec-chevron {
transition: transform 0.25s;
color: #94a3b8;
&.open { transform: rotate(180deg); }
&.open {
transform: rotate(180deg);
}
}
.ec-card-body {
@@ -105,7 +169,10 @@
grid-template-columns: repeat(3, 1fr);
gap: 4px 16px;
margin-bottom: 8px;
mat-form-field { width: 100%; }
mat-form-field {
width: 100%;
}
}
.ec-grid-2 {
@@ -113,7 +180,10 @@
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
margin-bottom: 8px;
mat-form-field { width: 100%; }
mat-form-field {
width: 100%;
}
}
.ec-sub-title {
@@ -125,7 +195,12 @@
margin: 16px 0 12px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
&:first-child { margin-top: 0; padding-top: 0; border-top: none; }
&:first-child {
margin-top: 0;
padding-top: 0;
border-top: none;
}
}
.ec-check-grid {
@@ -139,8 +214,14 @@
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
transition: background 0.12s, border-color 0.12s;
&:hover { background: #f8fafc; border-color: #93c5fd; }
transition:
background 0.12s,
border-color 0.12s;
&:hover {
background: #f8fafc;
border-color: #93c5fd;
}
}
}
@@ -161,8 +242,19 @@
border-radius: 6px;
}
.ec-flag-label { font-size: 13px; font-weight: 500; color: #1e293b; display: block; }
.ec-flag-sub { font-size: 11px; color: #94a3b8; display: block; margin-top: 1px; }
.ec-flag-label {
font-size: 13px;
font-weight: 500;
color: #1e293b;
display: block;
}
.ec-flag-sub {
font-size: 11px;
color: #94a3b8;
display: block;
margin-top: 1px;
}
.ec-reg-note {
margin-top: 16px;
@@ -173,7 +265,10 @@
font-size: 12px;
color: #0c4a6e;
line-height: 1.6;
strong { font-weight: 600; }
strong {
font-weight: 600;
}
}
.ec-card-actions {
@@ -182,7 +277,12 @@
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
button { display: flex; align-items: center; gap: 6px; }
button {
display: flex;
align-items: center;
gap: 6px;
}
}
.ec-info-banner {
@@ -198,11 +298,27 @@
margin-top: 12px;
}
@media (width <= 900px) { .ec-grid-3 { grid-template-columns: repeat(2, 1fr); } }
@media (width <= 900px) {
.ec-grid-3 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (width <= 600px) {
.ec-grid-3, .ec-grid-2 { grid-template-columns: 1fr; }
.ec-check-grid { grid-template-columns: 1fr; }
.ec-card-head { padding: 12px 14px; }
.ec-card-body { padding: 14px; }
.ec-grid-3,
.ec-grid-2 {
grid-template-columns: 1fr;
}
.ec-check-grid {
grid-template-columns: 1fr;
}
.ec-card-head {
padding: 12px 14px;
}
.ec-card-body {
padding: 14px;
}
}
@@ -1,5 +1,9 @@
/**
* 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, Input, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core';
@@ -54,13 +58,69 @@ export class ClientEntityKycStepComponent implements OnInit, OnChanges {
/** Category metadata */
categories: CategoryMeta[] = [
{ id: 'msme', label: 'MSME', tag: 'Udyam / MSME', tagColor: '#0A5C6B', tagBg: '#E6F4F6', desc: 'Micro, Small & Medium Enterprise — Ministry of MSME / Udyam Registration', icon: 'cogs' },
{ id: 'startup', label: 'Startup India Registered', tag: 'DPIIT', tagColor: '#1A5FAB', tagBg: '#EAF2FB', desc: 'DPIIT recognition under Startup India initiative', icon: 'rocket' },
{ id: 'mudra', label: 'Mudra Borrower', tag: 'PMMY', tagColor: '#8A5A00', tagBg: '#FEF3DC', desc: 'Pradhan Mantri Mudra Yojana — SIDBI / Mudra scheme', icon: 'hand-holding-usd' },
{ id: 'eou', label: 'Export Oriented Unit', tag: 'EOU', tagColor: '#1B7A4A', tagBg: '#E8F5EE', desc: 'Customs-registered EOU under DGFT / Ministry of Commerce', icon: 'ship' },
{ id: 'importexport', label: 'Import Export Business', tag: 'DGFT / IEC', tagColor: '#5B3A8A', tagBg: '#F0EBF8', desc: 'Entity with valid DGFT Importer Exporter Code', icon: 'exchange-alt' },
{ id: 'psl', label: 'Priority Sector Borrower', tag: 'RBI PSL', tagColor: '#7B1F1F', tagBg: '#FCEAEA', desc: 'Eligible under RBI Priority Sector Lending guidelines', icon: 'bullseye' },
{ id: 'nps', label: 'Non-Priority Sector', tag: 'General', tagColor: '#444441', tagBg: '#F1EFE8', desc: 'Standard corporate — no special priority sector classification', icon: 'building' }
{
id: 'msme',
label: 'MSME',
tag: 'Udyam / MSME',
tagColor: '#0A5C6B',
tagBg: '#E6F4F6',
desc: 'Micro, Small & Medium Enterprise — Ministry of MSME / Udyam Registration',
icon: 'cogs'
},
{
id: 'startup',
label: 'Startup India Registered',
tag: 'DPIIT',
tagColor: '#1A5FAB',
tagBg: '#EAF2FB',
desc: 'DPIIT recognition under Startup India initiative',
icon: 'rocket'
},
{
id: 'mudra',
label: 'Mudra Borrower',
tag: 'PMMY',
tagColor: '#8A5A00',
tagBg: '#FEF3DC',
desc: 'Pradhan Mantri Mudra Yojana — SIDBI / Mudra scheme',
icon: 'hand-holding-usd'
},
{
id: 'eou',
label: 'Export Oriented Unit',
tag: 'EOU',
tagColor: '#1B7A4A',
tagBg: '#E8F5EE',
desc: 'Customs-registered EOU under DGFT / Ministry of Commerce',
icon: 'ship'
},
{
id: 'importexport',
label: 'Import Export Business',
tag: 'DGFT / IEC',
tagColor: '#5B3A8A',
tagBg: '#F0EBF8',
desc: 'Entity with valid DGFT Importer Exporter Code',
icon: 'exchange-alt'
},
{
id: 'psl',
label: 'Priority Sector Borrower',
tag: 'RBI PSL',
tagColor: '#7B1F1F',
tagBg: '#FCEAEA',
desc: 'Eligible under RBI Priority Sector Lending guidelines',
icon: 'bullseye'
},
{
id: 'nps',
label: 'Non-Priority Sector',
tag: 'General',
tagColor: '#444441',
tagBg: '#F1EFE8',
desc: 'Standard corporate — no special priority sector classification',
icon: 'building'
}
];
/** Toggle & expand state per category */
@@ -89,7 +149,15 @@ export class ClientEntityKycStepComponent implements OnInit, OnChanges {
ngOnChanges(changes: SimpleChanges): void {
if (changes['readOnly']) {
const forms = [this.msmeForm, this.startupForm, this.mudraForm, this.eouForm, this.importexportForm, this.pslForm, this.npsForm];
const forms = [
this.msmeForm,
this.startupForm,
this.mudraForm,
this.eouForm,
this.importexportForm,
this.pslForm,
this.npsForm
];
for (const form of forms) {
if (!form) continue;
if (this.readOnly) {
@@ -213,14 +281,22 @@ export class ClientEntityKycStepComponent implements OnInit, OnChanges {
getForm(categoryId: string): UntypedFormGroup {
switch (categoryId) {
case 'msme': return this.msmeForm;
case 'startup': return this.startupForm;
case 'mudra': return this.mudraForm;
case 'eou': return this.eouForm;
case 'importexport': return this.importexportForm;
case 'psl': return this.pslForm;
case 'nps': return this.npsForm;
default: return this.fb.group({});
case 'msme':
return this.msmeForm;
case 'startup':
return this.startupForm;
case 'mudra':
return this.mudraForm;
case 'eou':
return this.eouForm;
case 'importexport':
return this.importexportForm;
case 'psl':
return this.pslForm;
case 'nps':
return this.npsForm;
default:
return this.fb.group({});
}
}
@@ -236,7 +312,7 @@ export class ClientEntityKycStepComponent implements OnInit, OnChanges {
}
get enabledCategories(): CategoryMeta[] {
return this.categories.filter(c => this.enabled[c.id]);
return this.categories.filter((c) => this.enabled[c.id]);
}
get enabledCount(): number {
@@ -266,7 +342,7 @@ export class ClientEntityKycStepComponent implements OnInit, OnChanges {
next: () => {
this.saving[categoryId] = false;
this.saved[categoryId] = true;
setTimeout(() => this.saved[categoryId] = false, 3000);
setTimeout(() => (this.saved[categoryId] = false), 3000);
},
error: () => {
this.saving[categoryId] = false;
@@ -306,6 +382,10 @@ export class ClientEntityKycStepComponent implements OnInit, OnChanges {
}
// Keep old getter for backward compatibility
get entityKycData(): any { return this.classificationData; }
get hasEntityKycData(): boolean { return this.hasClassificationData; }
get entityKycData(): any {
return this.classificationData;
}
get hasEntityKycData(): boolean {
return this.hasClassificationData;
}
}
@@ -1,3 +1,11 @@
<!--
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/.
-->
<!--
Copyright since 2025 Mifos Initiative
-->
@@ -10,7 +18,6 @@
<mat-dialog-content>
<form [formGroup]="familyMemberForm">
<!-- ═══ VIEW MODE ═══ -->
@if (isViewMode) {
<!-- Linked client card -->
@@ -18,20 +25,31 @@
<div class="fm-linked-card">
<div class="fm-linked-card__left">
@if (linkedProfileImage) {
<img [src]="linkedProfileImage" class="fm-linked-card__photo zoomable" alt="Profile"
(click)="zoomImage(linkedProfileImage, 'Client Photograph')" />
<img
[src]="linkedProfileImage"
class="fm-linked-card__photo zoomable"
alt="Profile"
(click)="zoomImage(linkedProfileImage, 'Client Photograph')"
/>
} @else {
<mat-icon class="fm-linked-card__icon">person</mat-icon>
}
</div>
<div class="fm-linked-card__info">
<span class="fm-linked-card__name">{{ selectedClient.displayName }}</span>
<span class="fm-linked-card__meta">Client ID: {{ selectedClient.id }}{{ selectedClient.accountNumber ? ' · A/C: ' + selectedClient.accountNumber : '' }}</span>
<span class="fm-linked-card__meta"
>Client ID: {{ selectedClient.id
}}{{ selectedClient.accountNumber ? ' · A/C: ' + selectedClient.accountNumber : '' }}</span
>
</div>
@if (linkedSignatureImage) {
<div class="fm-linked-card__sig">
<img [src]="linkedSignatureImage" alt="Signature" class="zoomable"
(click)="zoomImage(linkedSignatureImage, 'Specimen Signature')" />
<img
[src]="linkedSignatureImage"
alt="Signature"
class="zoomable"
(click)="zoomImage(linkedSignatureImage, 'Specimen Signature')"
/>
<span>Signature</span>
</div>
}
@@ -57,7 +75,11 @@
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Date of Birth</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('dateOfBirth')?.value ? (familyMemberForm.get('dateOfBirth')?.value | date:'dd MMM yyyy') : '—' }}</span>
<span class="fm-readonly-value">{{
familyMemberForm.get('dateOfBirth')?.value
? (familyMemberForm.get('dateOfBirth')?.value | date: 'dd MMM yyyy')
: '—'
}}</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Nationality</span>
@@ -89,19 +111,25 @@
<span class="fm-readonly-label">Status</span>
<span class="fm-readonly-value">
{{ familyMemberForm.get('isAlive')?.value !== false ? 'Alive' : 'Deceased' }}
@if (familyMemberForm.get('isDependent')?.value) { · Dependent }
@if (familyMemberForm.get('isDependent')?.value) {
· Dependent
}
</span>
</div>
@if (isSpouse && familyMemberForm.get('marriageDate')?.value) {
<div class="fm-readonly-item">
<span class="fm-readonly-label">Marriage Date</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('marriageDate')?.value | date:'dd MMM yyyy' }}</span>
<span class="fm-readonly-value">{{
familyMemberForm.get('marriageDate')?.value | date: 'dd MMM yyyy'
}}</span>
</div>
}
@if (isSpouse && familyMemberForm.get('marriageAuthority')?.value) {
<div class="fm-readonly-item">
<span class="fm-readonly-label">Issuing Authority</span>
<span class="fm-readonly-value">{{ getMarriageAuthorityLabel(familyMemberForm.get('marriageAuthority')?.value) }}</span>
<span class="fm-readonly-value">{{
getMarriageAuthorityLabel(familyMemberForm.get('marriageAuthority')?.value)
}}</span>
</div>
}
</div>
@@ -136,7 +164,9 @@
</mat-form-field>
@if (searching) {
<div class="fm-search-results">
<div class="fm-search-loading"><mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner> Searching...</div>
<div class="fm-search-loading">
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner> Searching...
</div>
</div>
}
@if (!searching && searchResults.length > 0) {
@@ -146,7 +176,18 @@
<mat-icon class="fm-search-item__icon">person</mat-icon>
<div class="fm-search-item__info">
<span class="fm-search-item__name">{{ r.displayName }}</span>
<span class="fm-search-item__meta">ID: {{ r.id }} · {{ r.officeName }}@if (r.dateOfBirth) { · DOB: {{ r.dateOfBirth | dateFormat }} }@if (r.fatherSpouseName) { · {{ r.fatherSpouseName }} }@if (r.mobileNo) { · {{ r.mobileNo }} }</span>
<span class="fm-search-item__meta"
>ID: {{ r.id }} · {{ r.officeName }}
@if (r.dateOfBirth) {
· DOB: {{ r.dateOfBirth | dateFormat }}
}
@if (r.fatherSpouseName) {
· {{ r.fatherSpouseName }}
}
@if (r.mobileNo) {
· {{ r.mobileNo }}
}
</span>
</div>
</div>
}
@@ -162,24 +203,37 @@
<div class="fm-linked-card">
<div class="fm-linked-card__left">
@if (linkedProfileImage) {
<img [src]="linkedProfileImage" class="fm-linked-card__photo zoomable" alt="Profile"
(click)="zoomImage(linkedProfileImage, 'Client Photograph')" />
<img
[src]="linkedProfileImage"
class="fm-linked-card__photo zoomable"
alt="Profile"
(click)="zoomImage(linkedProfileImage, 'Client Photograph')"
/>
} @else {
<mat-icon class="fm-linked-card__icon">person</mat-icon>
}
</div>
<div class="fm-linked-card__info">
<span class="fm-linked-card__name">{{ selectedClient.displayName }}</span>
<span class="fm-linked-card__meta">Client ID: {{ selectedClient.id }}{{ selectedClient.accountNumber ? ' · A/C: ' + selectedClient.accountNumber : '' }}</span>
<span class="fm-linked-card__meta"
>Client ID: {{ selectedClient.id
}}{{ selectedClient.accountNumber ? ' · A/C: ' + selectedClient.accountNumber : '' }}</span
>
</div>
@if (linkedSignatureImage) {
<div class="fm-linked-card__sig">
<img [src]="linkedSignatureImage" alt="Signature" class="zoomable"
(click)="zoomImage(linkedSignatureImage, 'Specimen Signature')" />
<img
[src]="linkedSignatureImage"
alt="Signature"
class="zoomable"
(click)="zoomImage(linkedSignatureImage, 'Specimen Signature')"
/>
<span>Signature</span>
</div>
}
<button mat-icon-button type="button" (click)="removeLinkedClient()" class="fm-linked-card__remove"><mat-icon>close</mat-icon></button>
<button mat-icon-button type="button" (click)="removeLinkedClient()" class="fm-linked-card__remove">
<mat-icon>close</mat-icon>
</button>
</div>
@if (fetchingLinkedData) {
@@ -198,7 +252,11 @@
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Date of Birth</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('dateOfBirth')?.value ? (familyMemberForm.get('dateOfBirth')?.value | date:'dd MMM yyyy') : '—' }}</span>
<span class="fm-readonly-value">{{
familyMemberForm.get('dateOfBirth')?.value
? (familyMemberForm.get('dateOfBirth')?.value | date: 'dd MMM yyyy')
: '—'
}}</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Aadhaar</span>
@@ -292,8 +350,13 @@
<button type="button" mat-stroked-button class="fm-file-btn" (click)="certInput.click()">
<mat-icon>upload_file</mat-icon> Upload Marriage Certificate
</button>
<input #certInput type="file" hidden accept=".pdf,.jpg,.jpeg,.png"
(change)="onMarriageCertSelected($event)" />
<input
#certInput
type="file"
hidden
accept=".pdf,.jpg,.jpeg,.png"
(change)="onMarriageCertSelected($event)"
/>
<span class="fm-file-hint">PDF, JPG or PNG (max 5 MB)</span>
} @else {
<div class="fm-file-attached">
@@ -313,15 +376,32 @@
<div class="fm-grid">
<mat-form-field appearance="outline">
<mat-label>Aadhaar Number</mat-label>
<input matInput formControlName="aadhaarNumber" maxlength="12" placeholder="12-digit Aadhaar"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
<input
matInput
formControlName="aadhaarNumber"
maxlength="12"
placeholder="12-digit Aadhaar"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>PAN Number</mat-label>
<input matInput formControlName="panNumber" maxlength="10" placeholder="ABCDE1234F" style="text-transform:uppercase"
(input)="familyMemberForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), {emitEvent:false})" />
@if (familyMemberForm.get('panNumber')?.hasError('pattern') && familyMemberForm.get('panNumber')?.touched) {
<input
matInput
formControlName="panNumber"
maxlength="10"
placeholder="ABCDE1234F"
style="text-transform: uppercase"
(input)="
familyMemberForm
.get('panNumber')
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (
familyMemberForm.get('panNumber')?.hasError('pattern') && familyMemberForm.get('panNumber')?.touched
) {
<mat-error>Invalid PAN format</mat-error>
}
</mat-form-field>
@@ -348,9 +428,13 @@
<button mat-flat-button color="primary" mat-dialog-close type="button">Close</button>
} @else {
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
<button mat-flat-button color="primary" type="button"
<button
mat-flat-button
color="primary"
type="button"
[disabled]="familyMemberForm.invalid"
[mat-dialog-close]="{ member: familyMember }">
[mat-dialog-close]="{ member: familyMember }"
>
{{ data.context === 'Edit' ? 'Update' : 'Add Member' }}
</button>
}
@@ -1,3 +1,11 @@
/**
* 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/.
*/
.fm-dialog__header {
display: flex;
align-items: center;
@@ -9,7 +17,9 @@
font-weight: 600;
color: #1e293b;
.fm-dialog__icon { color: #1074b9; }
.fm-dialog__icon {
color: #1074b9;
}
}
.fm-mode-toggle {
@@ -32,7 +42,11 @@
cursor: pointer;
transition: all 0.15s;
mat-icon { font-size: 18px; width: 18px; height: 18px; }
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&.active {
border-color: #1074b9;
@@ -40,11 +54,19 @@
color: #1074b9;
}
&:hover:not(.active) { border-color: #94a3b8; }
&:hover:not(.active) {
border-color: #94a3b8;
}
}
.fm-search-wrapper { position: relative; margin-bottom: 12px; }
.fm-search-field { width: 100%; }
.fm-search-wrapper {
position: relative;
margin-bottom: 12px;
}
.fm-search-field {
width: 100%;
}
.fm-search-results {
position: absolute;
@@ -92,13 +114,34 @@
cursor: pointer;
border-bottom: 1px solid #f1f5f9;
&:last-child { border-bottom: none; }
&:hover { background: #f0f7ff; }
&:last-child {
border-bottom: none;
}
&__icon { color: #1074b9; font-size: 20px; }
&__info { display: flex; flex-direction: column; }
&__name { font-size: 13px; font-weight: 600; color: #1e293b; }
&__meta { font-size: 11px; color: #64748b; }
&:hover {
background: #f0f7ff;
}
&__icon {
color: #1074b9;
font-size: 20px;
}
&__info {
display: flex;
flex-direction: column;
}
&__name {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
&__meta {
font-size: 11px;
color: #64748b;
}
}
.fm-linked-card {
@@ -111,8 +154,13 @@
border-radius: 8px;
margin-bottom: 16px;
&__left { flex-shrink: 0; }
&__icon { color: #1074b9; }
&__left {
flex-shrink: 0;
}
&__icon {
color: #1074b9;
}
&__photo {
width: 40px;
@@ -121,9 +169,24 @@
object-fit: cover;
border: 2px solid #e2e8f0;
}
&__info { flex: 1; display: flex; flex-direction: column; }
&__name { font-size: 14px; font-weight: 600; color: #1e293b; }
&__meta { font-size: 12px; color: #64748b; font-family: 'Roboto Mono', monospace; }
&__info {
flex: 1;
display: flex;
flex-direction: column;
}
&__name {
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
&__meta {
font-size: 12px;
color: #64748b;
font-family: 'Roboto Mono', monospace;
}
&__sig {
display: flex;
@@ -149,7 +212,14 @@
letter-spacing: 0.04em;
}
}
&__remove { color: #94a3b8 !important; &:hover { color: #ef4444 !important; } }
&__remove {
color: #94a3b8 !important;
&:hover {
color: #ef4444 !important;
}
}
}
.fm-fetching-hint {
@@ -204,8 +274,16 @@
padding: 10px 16px;
margin-bottom: 8px;
.fm-readonly-label { display: block; margin-bottom: 4px; }
.fm-readonly-value { font-size: 13px; color: #475569; line-height: 1.5; }
.fm-readonly-label {
display: block;
margin-bottom: 4px;
}
.fm-readonly-value {
font-size: 13px;
color: #475569;
line-height: 1.5;
}
}
.fm-kyc-badge {
@@ -266,7 +344,9 @@
.zoomable {
cursor: zoom-in;
transition: transform 0.15s ease, box-shadow 0.15s ease;
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
&:hover {
transform: scale(1.08);
@@ -275,7 +355,11 @@
}
::ng-deep .image-zoom-panel {
.mat-mdc-dialog-container { padding: 0 !important; border-radius: 10px !important; overflow: hidden; }
.mat-mdc-dialog-container {
padding: 0 !important;
border-radius: 10px !important;
overflow: hidden;
}
}
.fm-file-upload {
@@ -294,7 +378,11 @@
color: #1074b9;
border-color: #cbd5e1;
mat-icon { font-size: 18px; width: 18px; height: 18px; }
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.fm-file-hint {
@@ -312,7 +400,10 @@
border-radius: 6px;
width: 100%;
&__icon { color: #1074b9; font-size: 20px; }
&__icon {
color: #1074b9;
font-size: 20px;
}
&__name {
flex: 1;
@@ -330,7 +421,14 @@
height: 28px !important;
line-height: 28px !important;
mat-icon { font-size: 16px; width: 16px; height: 16px; }
&:hover { color: #ef4444 !important; }
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover {
color: #ef4444 !important;
}
}
}
@@ -87,7 +87,9 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
}
/** Whether dialog is in View-only mode */
get isViewMode(): boolean { return this.data.context === 'View'; }
get isViewMode(): boolean {
return this.data.context === 'View';
}
/** Get relationship name from ID */
get relationshipName(): string {
@@ -109,7 +111,7 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
/** Get marriage authority display label */
getMarriageAuthorityLabel(value: string): string {
return this.marriageAuthorityOptions.find(a => a.value === value)?.label || value || '—';
return this.marriageAuthorityOptions.find((a) => a.value === value)?.label || value || '—';
}
/** Marriage certificate file */
@@ -150,7 +152,10 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
});
if (m.linkedClientId) {
this.entryMode = 'linked';
this.selectedClient = { id: m.linkedClientId, displayName: m.linkedClientName || m.firstName || `Client #${m.linkedClientId}` };
this.selectedClient = {
id: m.linkedClientId,
displayName: m.linkedClientName || m.firstName || `Client #${m.linkedClientId}`
};
// Re-fetch linked client details (profile, signature, KYC status)
this.fetchLinkedClientData(m.linkedClientId.toString());
}
@@ -162,13 +167,22 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
createFamilyMemberForm() {
this.familyMemberForm = this.formBuilder.group({
firstName: ['', Validators.required],
relationshipId: ['', Validators.required],
firstName: [
'',
Validators.required
],
relationshipId: [
'',
Validators.required
],
dateOfBirth: [''],
isDependent: [false],
remarks: [''],
aadhaarNumber: [''],
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)],
panNumber: [
'',
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
],
nationality: ['INDIAN'],
isAlive: [true],
marriageDate: [''],
@@ -208,7 +222,10 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
this.searching = false;
this.clientsService.enrichSearchResults(this.searchResults);
},
error: () => { this.searchResults = []; this.searching = false; }
error: () => {
this.searchResults = [];
this.searching = false;
}
});
}, 400);
}
@@ -242,7 +259,9 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
this.setLinkedFieldsReadonly(true);
this.fetchingLinkedData = false;
},
error: () => { this.fetchingLinkedData = false; }
error: () => {
this.fetchingLinkedData = false;
}
});
// Fetch KYC (PAN, Aadhaar, verification status)
@@ -250,8 +269,12 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
next: (kyc: any) => {
if (kyc) {
const patch: any = {};
if (kyc.panNumber) { patch.panNumber = kyc.panNumber; }
if (kyc.aadhaarNumberMasked) { patch.aadhaarNumber = kyc.aadhaarNumberMasked; }
if (kyc.panNumber) {
patch.panNumber = kyc.panNumber;
}
if (kyc.aadhaarNumberMasked) {
patch.aadhaarNumber = kyc.aadhaarNumberMasked;
}
this.familyMemberForm.patchValue(patch);
this.linkedKycStatus = {
panVerified: kyc.panVerified || false,
@@ -278,8 +301,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
// Fetch signature image
this.clientsService.getClientDocuments(clientId).subscribe({
next: (docs: any[]) => {
const sigDoc = docs?.find((d: any) =>
d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
const sigDoc = docs?.find(
(d: any) => d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
);
if (sigDoc) {
this.clientsService.getClientSignatureImage(clientId, sigDoc.id.toString()).subscribe({
@@ -296,8 +319,13 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
/** Disable/enable fields that are auto-populated from linked client */
private setLinkedFieldsReadonly(readonly: boolean): void {
const fields = ['firstName', 'dateOfBirth', 'aadhaarNumber', 'panNumber'];
fields.forEach(f => {
const fields = [
'firstName',
'dateOfBirth',
'aadhaarNumber',
'panNumber'
];
fields.forEach((f) => {
const ctrl = this.familyMemberForm.get(f);
if (ctrl) {
readonly ? ctrl.disable() : ctrl.enable();
@@ -313,7 +341,11 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
this.linkedKycStatus = null;
this.setLinkedFieldsReadonly(false);
this.familyMemberForm.patchValue({
firstName: '', linkedClientId: '', dateOfBirth: '', aadhaarNumber: '', panNumber: ''
firstName: '',
linkedClientId: '',
dateOfBirth: '',
aadhaarNumber: '',
panNumber: ''
});
}
@@ -385,26 +417,49 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
</div>
</div>
`,
styles: [`
.img-zoom-dialog { display: flex; flex-direction: column; }
.img-zoom-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 16px; background: #f8fafc; border-bottom: 1px solid #e2e8f0;
font-size: 14px; font-weight: 600; color: #1e293b;
}
.img-zoom-body {
display: flex; align-items: center; justify-content: center;
padding: 20px; background: #fff; min-height: 200px;
}
.img-zoom-body img {
max-width: 100%; max-height: 70vh; object-fit: contain;
border-radius: 6px; box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
`],
imports: [MatIcon, STANDALONE_SHARED_IMPORTS]
styles: [
`
.img-zoom-dialog {
display: flex;
flex-direction: column;
}
.img-zoom-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.img-zoom-body {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: #fff;
min-height: 200px;
}
.img-zoom-body img {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 6px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
`
],
imports: [
MatIcon,
STANDALONE_SHARED_IMPORTS
]
})
export class ImageZoomDialogComponent {
dialogRef = inject<MatDialogRef<ImageZoomDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
close() { this.dialogRef.close(); }
close() {
this.dialogRef.close();
}
}
@@ -1,3 +1,11 @@
<!--
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/.
-->
<!--
Copyright since 2025 Mifos Initiative
-->
@@ -75,9 +83,13 @@
{{ member.dateOfBirth ? (member.dateOfBirth | dateFormat) : '—' }}
</span>
<span class="fm-table__col fm-table__col--id fm-mono">
@if (member.panNumber) { {{ member.panNumber }} }
@else if (member.aadhaarNumber) { {{ member.aadhaarNumber }} }
@else { — }
@if (member.panNumber) {
{{ member.panNumber }}
} @else if (member.aadhaarNumber) {
{{ member.aadhaarNumber }}
} @else {
—
}
</span>
<span class="fm-table__col fm-table__col--status">
@if (member.isAlive !== false) {
@@ -87,14 +99,32 @@
}
</span>
<span class="fm-table__col fm-table__col--actions">
<button mat-icon-button type="button" (click)="viewFamilyMember(member)" class="fm-action-btn" matTooltip="View">
<button
mat-icon-button
type="button"
(click)="viewFamilyMember(member)"
class="fm-action-btn"
matTooltip="View"
>
<mat-icon>visibility</mat-icon>
</button>
@if (!readOnly) {
<button mat-icon-button type="button" (click)="editFamilyMember(member, i)" class="fm-action-btn" matTooltip="Edit">
<button
mat-icon-button
type="button"
(click)="editFamilyMember(member, i)"
class="fm-action-btn"
matTooltip="Edit"
>
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button type="button" (click)="deleteFamilyMember(member.firstName, i)" class="fm-action-btn fm-action-btn--delete" matTooltip="Delete">
<button
mat-icon-button
type="button"
(click)="deleteFamilyMember(member.firstName, i)"
class="fm-action-btn fm-action-btn--delete"
matTooltip="Delete"
>
<mat-icon>delete</mat-icon>
</button>
}
@@ -103,5 +133,4 @@
}
</div>
}
</div>
@@ -1,11 +1,29 @@
/**
* 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/.
*/
.zoomable {
cursor: zoom-in;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover { transform: scale(1.08); box-shadow: 0 4px 16px rgb(0 0 0 / 18%); }
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
&:hover {
transform: scale(1.08);
box-shadow: 0 4px 16px rgb(0 0 0 / 18%);
}
}
::ng-deep .image-zoom-panel {
.mat-mdc-dialog-container { padding: 0 !important; border-radius: 10px !important; overflow: hidden; }
.mat-mdc-dialog-container {
padding: 0 !important;
border-radius: 10px !important;
overflow: hidden;
}
}
.search-no-results-hint {
@@ -63,7 +81,10 @@
color: #1e293b;
}
.fm-step__icon { color: #1074b9; font-size: 14px; }
.fm-step__icon {
color: #1074b9;
font-size: 14px;
}
.fm-step__count {
background: #1074b9;
@@ -170,8 +191,13 @@
border-bottom: 1px solid #f1f5f9;
transition: background 0.1s;
&:last-child { border-bottom: none; }
&:hover { background: #f8fafc; }
&:last-child {
border-bottom: none;
}
&:hover {
background: #f8fafc;
}
}
.fm-table__col {
@@ -186,11 +212,31 @@
gap: 8px;
min-width: 0;
}
&--rel { flex: 0 0 130px; }
&--dob { flex: 0 0 120px; color: #64748b; font-size: 12px; }
&--id { flex: 0 0 140px; }
&--status { flex: 0 0 60px; text-align: center; }
&--actions { flex: 0 0 80px; display: flex; gap: 2px; }
&--rel {
flex: 0 0 130px;
}
&--dob {
flex: 0 0 120px;
color: #64748b;
font-size: 12px;
}
&--id {
flex: 0 0 140px;
}
&--status {
flex: 0 0 60px;
text-align: center;
}
&--actions {
flex: 0 0 80px;
display: flex;
gap: 2px;
}
}
.fm-table__avatar {
@@ -232,8 +278,15 @@
padding: 2px 8px;
border-radius: 10px;
&--yes { background: #ecfdf5; color: #059669; }
&--no { background: #fef2f2; color: #dc2626; }
&--yes {
background: #ecfdf5;
color: #059669;
}
&--no {
background: #fef2f2;
color: #dc2626;
}
}
.fm-action-btn {
@@ -248,8 +301,13 @@
color: #64748b;
}
&:hover mat-icon { color: #1074b9; }
&--delete:hover mat-icon { color: #ef4444; }
&:hover mat-icon {
color: #1074b9;
}
&--delete:hover mat-icon {
color: #ef4444;
}
}
@media (width <= 768px) {
@@ -260,7 +318,10 @@
}
.fm-table__col {
&--type, &--id { display: none; }
&--type,
&--id {
display: none;
}
}
}
@@ -284,11 +345,27 @@
gap: 10px;
}
.nom-header__icon { font-size: 16px; color: #1565c0; }
.nom-header__title { font-size: 16px; font-weight: 600; color: #1e293b; display: block; }
.nom-header__sub { font-size: 11px; color: #94a3b8; display: block; }
.nom-header__icon {
font-size: 16px;
color: #1565c0;
}
.nom-add-btn { font-size: 13px; }
.nom-header__title {
font-size: 16px;
font-weight: 600;
color: #1e293b;
display: block;
}
.nom-header__sub {
font-size: 11px;
color: #94a3b8;
display: block;
}
.nom-add-btn {
font-size: 13px;
}
/* Share bar */
.nom-share-bar {
@@ -302,7 +379,11 @@
border-radius: 6px;
}
.nom-share-bar__label { font-size: 12px; color: #64748b; white-space: nowrap; }
.nom-share-bar__label {
font-size: 12px;
color: #64748b;
white-space: nowrap;
}
.nom-share-bar__track {
flex: 1;
@@ -317,8 +398,14 @@
background: #1565c0;
border-radius: 4px;
transition: width 0.3s;
&.complete { background: #059669; }
&.over { background: #dc2626; }
&.complete {
background: #059669;
}
&.over {
background: #dc2626;
}
}
.nom-share-bar__value {
@@ -327,7 +414,10 @@
color: #1565c0;
min-width: 36px;
text-align: right;
&.over { color: #dc2626; }
&.over {
color: #dc2626;
}
}
/* Nominee card */
@@ -346,24 +436,62 @@
}
.nom-card__avatar {
width: 36px; height: 36px;
width: 36px;
height: 36px;
border-radius: 50%;
background: #dbeafe;
display: flex; align-items: center; justify-content: center;
display: flex;
align-items: center;
justify-content: center;
color: #1565c0;
mat-icon { font-size: 20px; }
mat-icon {
font-size: 20px;
}
}
.nom-card__photo { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.nom-card__info { flex: 1; }
.nom-card__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; }
.nom-card__meta { font-size: 12px; color: #64748b; display: block; margin-top: 2px; }
.nom-card__photo {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.nom-card__info {
flex: 1;
}
.nom-card__name {
font-weight: 600;
font-size: 14px;
color: #1e293b;
display: block;
}
.nom-card__meta {
font-size: 12px;
color: #64748b;
display: block;
margin-top: 2px;
}
.nom-card__sig {
flex-shrink: 0;
img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; padding: 2px; }
img {
height: 24px;
max-width: 60px;
object-fit: contain;
border: 1px solid #e2e8f0;
border-radius: 4px;
padding: 2px;
}
}
.nom-card__actions {
display: flex;
gap: 2px;
}
.nom-card__actions { display: flex; gap: 2px; }
.nom-card__edit {
padding: 12px 16px;
@@ -392,8 +520,18 @@
min-width: 50px;
}
.nom-card__share-value { font-size: 18px; font-weight: 700; color: #1565c0; display: block; }
.nom-card__share-label { font-size: 10px; color: #94a3b8; text-transform: uppercase; }
.nom-card__share-value {
font-size: 18px;
font-weight: 700;
color: #1565c0;
display: block;
}
.nom-card__share-label {
font-size: 10px;
color: #94a3b8;
text-transform: uppercase;
}
.nom-card__guardian {
display: flex;
@@ -404,7 +542,13 @@
border-top: 1px solid #e0f2fe;
font-size: 12px;
color: #0c4a6e;
mat-icon { font-size: 16px; width: 16px; height: 16px; color: #0ea5e9; }
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #0ea5e9;
}
}
/* Empty */
@@ -421,8 +565,16 @@
font-size: 14px;
}
.nom-empty__icon { font-size: 36px; width: 36px; height: 36px; color: #cbd5e1; }
.nom-empty__sub { font-size: 12px; }
.nom-empty__icon {
font-size: 36px;
width: 36px;
height: 36px;
color: #cbd5e1;
}
.nom-empty__sub {
font-size: 12px;
}
/* Nominee form */
.nom-form {
@@ -444,20 +596,30 @@
}
/* Search */
.nom-search-wrapper { position: relative; }
.nom-search-field { width: 100%; }
.nom-search-wrapper {
position: relative;
}
.nom-search-field {
width: 100%;
}
.nom-search-prefix {
display: inline-flex;
align-items: center;
padding-right: 8px;
mat-icon { font-size: 20px; color: #94a3b8; }
mat-icon {
font-size: 20px;
color: #94a3b8;
}
}
.nom-search-results {
position: absolute;
top: 100%;
left: 0; right: 0;
left: 0;
right: 0;
z-index: 10;
background: #fff;
border: 1px solid #e2e8f0;
@@ -469,9 +631,12 @@
}
.nom-search-loading {
display: flex; align-items: center; gap: 8px;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
font-size: 13px; color: #64748b;
font-size: 13px;
color: #64748b;
}
.nom-search-item {
@@ -480,14 +645,43 @@
gap: 10px;
padding: 10px 16px;
cursor: pointer;
&:hover { background: #f1f5f9; }
&:hover {
background: #f1f5f9;
}
}
.nom-search-item__icon { color: #94a3b8; font-size: 20px; }
.nom-search-item__info { flex: 1; }
.nom-search-item__name { font-weight: 500; font-size: 13px; color: #1e293b; display: block; }
.nom-search-item__meta { font-size: 11px; color: #94a3b8; display: block; }
.nom-search-item__status { font-size: 11px; font-weight: 600; color: #94a3b8; &.active { color: #22c55e; } }
.nom-search-item__icon {
color: #94a3b8;
font-size: 20px;
}
.nom-search-item__info {
flex: 1;
}
.nom-search-item__name {
font-weight: 500;
font-size: 13px;
color: #1e293b;
display: block;
}
.nom-search-item__meta {
font-size: 11px;
color: #94a3b8;
display: block;
}
.nom-search-item__status {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
&.active {
color: #22c55e;
}
}
/* Selected nominee */
.nom-selected {
@@ -507,31 +701,92 @@
}
.nom-selected__avatar {
width: 40px; height: 40px;
width: 40px;
height: 40px;
border-radius: 50%;
background: #dbeafe;
display: flex; align-items: center; justify-content: center;
display: flex;
align-items: center;
justify-content: center;
color: #1565c0;
overflow: hidden;
}
.nom-selected__photo { width: 100%; height: 100%; object-fit: cover; }
.nom-selected__info { flex: 1; }
.nom-selected__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; }
.nom-selected__meta { font-size: 12px; color: #64748b; display: block; }
.nom-selected__photo {
width: 100%;
height: 100%;
object-fit: cover;
}
.nom-selected__info {
flex: 1;
}
.nom-selected__name {
font-weight: 600;
font-size: 14px;
color: #1e293b;
display: block;
}
.nom-selected__meta {
font-size: 12px;
color: #64748b;
display: block;
}
.nom-selected__sig {
display: flex; flex-direction: column; align-items: center; gap: 2px; flex-shrink: 0;
img { height: 28px; max-width: 70px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; background: #fff; padding: 2px 4px; }
span { font-size: 9px; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.04em; }
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex-shrink: 0;
img {
height: 28px;
max-width: 70px;
object-fit: contain;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
padding: 2px 4px;
}
span {
font-size: 9px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.04em;
}
}
.nom-selected__remove {
color: #94a3b8;
}
.nom-selected__loading {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
font-size: 12px;
color: #64748b;
}
.nom-selected__remove { color: #94a3b8; }
.nom-selected__loading { display: flex; align-items: center; gap: 8px; padding: 12px 16px; font-size: 12px; color: #64748b; }
.nom-kyc-badge {
display: inline-flex; align-items: center; margin-left: 4px;
mat-icon { font-size: 14px; width: 14px; height: 14px; }
&.verified { color: #16a34a; }
display: inline-flex;
align-items: center;
margin-left: 4px;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&.verified {
color: #16a34a;
}
}
.nom-selected__details {
@@ -542,10 +797,29 @@
flex-wrap: wrap;
}
.nom-detail { display: flex; flex-direction: column; gap: 2px; }
.nom-detail__label { font-size: 10px; text-transform: uppercase; color: #94a3b8; letter-spacing: 0.5px; }
.nom-detail__value { font-size: 13px; font-weight: 500; color: #1e293b; }
.nom-detail__value.mono { font-family: 'Roboto Mono', monospace; font-size: 12px; }
.nom-detail {
display: flex;
flex-direction: column;
gap: 2px;
}
.nom-detail__label {
font-size: 10px;
text-transform: uppercase;
color: #94a3b8;
letter-spacing: 0.5px;
}
.nom-detail__value {
font-size: 13px;
font-weight: 500;
color: #1e293b;
}
.nom-detail__value.mono {
font-family: 'Roboto Mono', monospace;
font-size: 12px;
}
/* Form fields */
.nom-form-fields {
@@ -556,8 +830,13 @@
align-items: center;
}
.nom-form-field { width: 100%; }
.nom-minor-toggle { padding-top: 8px; }
.nom-form-field {
width: 100%;
}
.nom-minor-toggle {
padding-top: 8px;
}
/* Guardian */
.nom-guardian-section {
@@ -573,7 +852,11 @@
font-weight: 600;
color: #0c4a6e;
margin-bottom: 10px;
mat-icon { font-size: 18px; color: #0ea5e9; }
mat-icon {
font-size: 18px;
color: #0ea5e9;
}
}
.nom-guardian-card {
@@ -584,13 +867,32 @@
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
mat-icon { color: #0ea5e9; }
mat-icon {
color: #0ea5e9;
}
}
.nom-guardian-card__info { flex: 1; }
.nom-guardian-card__name { font-weight: 600; font-size: 13px; color: #1e293b; display: block; }
.nom-guardian-card__meta { font-size: 11px; color: #64748b; display: block; }
.nom-guardian-rel { width: 160px; }
.nom-guardian-card__info {
flex: 1;
}
.nom-guardian-card__name {
font-weight: 600;
font-size: 13px;
color: #1e293b;
display: block;
}
.nom-guardian-card__meta {
font-size: 11px;
color: #64748b;
display: block;
}
.nom-guardian-rel {
width: 160px;
}
/* Actions */
.nom-form-actions {
@@ -599,11 +901,24 @@
gap: 10px;
padding: 12px 16px;
border-top: 1px solid #f1f5f9;
button { display: flex; align-items: center; gap: 6px; }
button {
display: flex;
align-items: center;
gap: 6px;
}
}
@media (width <= 768px) {
.nom-form-fields { grid-template-columns: 1fr; }
.nom-guardian-card { flex-wrap: wrap; }
.nom-guardian-rel { width: 100%; }
.nom-form-fields {
grid-template-columns: 1fr;
}
.nom-guardian-card {
flex-wrap: wrap;
}
.nom-guardian-rel {
width: 100%;
}
}
@@ -1,5 +1,9 @@
/**
* 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, Input, OnInit, inject } from '@angular/core';
@@ -88,9 +92,20 @@ export class ClientFamilyMembersStepComponent implements OnInit {
/** Relationship options */
relationshipOptions = [
'Father', 'Mother', 'Spouse', 'Son', 'Daughter',
'Brother', 'Sister', 'Uncle', 'Aunt', 'Grandfather',
'Grandmother', 'Friend', 'Business Partner', 'Other'
'Father',
'Mother',
'Spouse',
'Son',
'Daughter',
'Brother',
'Sister',
'Uncle',
'Aunt',
'Grandfather',
'Grandmother',
'Friend',
'Business Partner',
'Other'
];
ngOnInit(): void {
@@ -109,14 +124,18 @@ export class ClientFamilyMembersStepComponent implements OnInit {
this.loaded = true;
this.clientFamilyMembers = Array.isArray(data) ? data : [];
},
error: () => { this.loaded = true; }
error: () => {
this.loaded = true;
}
});
}
addFamilyMember() {
const ref = this.dialog.open(ClientFamilyMemberDialogComponent, {
data: { context: 'Add', options: this.clientTemplate.familyMemberOptions },
width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel'
width: '640px',
maxWidth: '95vw',
panelClass: 'fm-dialog-panel'
});
ref.afterClosed().subscribe((response: any) => {
if (response?.member) {
@@ -128,14 +147,18 @@ export class ClientFamilyMembersStepComponent implements OnInit {
viewFamilyMember(member: any) {
this.dialog.open(ClientFamilyMemberDialogComponent, {
data: { context: 'View', member, options: this.clientTemplate.familyMemberOptions },
width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel'
width: '640px',
maxWidth: '95vw',
panelClass: 'fm-dialog-panel'
});
}
editFamilyMember(member: any, index: number) {
const ref = this.dialog.open(ClientFamilyMemberDialogComponent, {
data: { context: 'Edit', member, options: this.clientTemplate.familyMemberOptions },
width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel'
width: '640px',
maxWidth: '95vw',
panelClass: 'fm-dialog-panel'
});
ref.afterClosed().subscribe((response: any) => {
if (response?.member) {
@@ -175,8 +198,7 @@ export class ClientFamilyMembersStepComponent implements OnInit {
return this.clientFamilyMembers.some((m: any) => {
const rel = m.relationship || m.relationshipId;
const relName = typeof rel === 'string' ? rel : '';
return relName.toLowerCase() === 'spouse' ||
m.firstName?.toLowerCase().includes('spouse');
return relName.toLowerCase() === 'spouse' || m.firstName?.toLowerCase().includes('spouse');
});
}
@@ -191,14 +213,19 @@ export class ClientFamilyMembersStepComponent implements OnInit {
const allMembers = Array.isArray(data) ? data : [];
this.nominees = allMembers.filter((m: any) => m.nominationType && m.nominationType !== '');
},
error: () => { this.nomineesLoaded = true; }
error: () => {
this.nomineesLoaded = true;
}
});
}
onNomineeSearch(text: string): void {
this.nomineeSearchText = text;
clearTimeout(this.nomineeSearchTimer);
if (!text || text.length < 2) { this.nomineeSearchResults = []; return; }
if (!text || text.length < 2) {
this.nomineeSearchResults = [];
return;
}
this.nomineeSearching = true;
this.nomineeSearchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 10).subscribe({
@@ -207,7 +234,10 @@ export class ClientFamilyMembersStepComponent implements OnInit {
this.nomineeSearching = false;
this.clientsService.enrichSearchResults(this.nomineeSearchResults);
},
error: () => { this.nomineeSearchResults = []; this.nomineeSearching = false; }
error: () => {
this.nomineeSearchResults = [];
this.nomineeSearching = false;
}
});
}, 400);
}
@@ -232,7 +262,9 @@ export class ClientFamilyMembersStepComponent implements OnInit {
// Fetch client data for DOB
this.clientsService.getClientData(clientId.toString()).subscribe({
next: (data: any) => {
if (data?.dateOfBirth) { details.dateOfBirth = data.dateOfBirth; }
if (data?.dateOfBirth) {
details.dateOfBirth = data.dateOfBirth;
}
done();
},
error: () => done()
@@ -253,7 +285,9 @@ export class ClientFamilyMembersStepComponent implements OnInit {
// Fetch profile image
this.clientsService.getClientProfileImage(clientId.toString()).subscribe({
next: (img: any) => {
if (img) { details.profileImageUrl = this.sanitizer.bypassSecurityTrustResourceUrl(img); }
if (img) {
details.profileImageUrl = this.sanitizer.bypassSecurityTrustResourceUrl(img);
}
done();
},
error: () => done()
@@ -262,8 +296,8 @@ export class ClientFamilyMembersStepComponent implements OnInit {
// Fetch signature image
this.clientsService.getClientDocuments(clientId.toString()).subscribe({
next: (docs: any[]) => {
const sigDoc = docs?.find((d: any) =>
d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
const sigDoc = docs?.find(
(d: any) => d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
);
if (sigDoc) {
this.clientsService.getClientSignatureImage(clientId.toString(), sigDoc.id.toString()).subscribe({
@@ -273,7 +307,9 @@ export class ClientFamilyMembersStepComponent implements OnInit {
},
error: () => done()
});
} else { done(); }
} else {
done();
}
},
error: () => done()
});
@@ -282,7 +318,10 @@ export class ClientFamilyMembersStepComponent implements OnInit {
onGuardianSearch(text: string): void {
this.guardianSearchText = text;
clearTimeout(this.guardianSearchTimer);
if (!text || text.length < 2) { this.guardianSearchResults = []; return; }
if (!text || text.length < 2) {
this.guardianSearchResults = [];
return;
}
this.guardianSearching = true;
this.guardianSearchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 10).subscribe({
@@ -291,7 +330,10 @@ export class ClientFamilyMembersStepComponent implements OnInit {
this.guardianSearching = false;
this.clientsService.enrichSearchResults(this.guardianSearchResults);
},
error: () => { this.guardianSearchResults = []; this.guardianSearching = false; }
error: () => {
this.guardianSearchResults = [];
this.guardianSearching = false;
}
});
}, 400);
}
@@ -315,7 +357,8 @@ export class ClientFamilyMembersStepComponent implements OnInit {
}
saveNominee(): void {
if (!this.clientId || !this.selectedNomineeClient || !this.nomineeRelationshipId || !this.nomineePercentageShare) return;
if (!this.clientId || !this.selectedNomineeClient || !this.nomineeRelationshipId || !this.nomineePercentageShare)
return;
if (this.totalSharePercentage > 100) return;
this.nomineeSaving = true;
@@ -360,7 +403,9 @@ export class ClientFamilyMembersStepComponent implements OnInit {
});
this.resetNomineeForm();
},
error: () => { this.nomineeSaving = false; }
error: () => {
this.nomineeSaving = false;
}
});
}
@@ -383,13 +428,13 @@ export class ClientFamilyMembersStepComponent implements OnInit {
/** Max share allowed for the nominee being edited (excludes its own share) */
maxShareForEdit(index: number): number {
const otherTotal = this.nominees.reduce((sum, n, i) => i === index ? sum : sum + (n.percentageShare || 0), 0);
const otherTotal = this.nominees.reduce((sum, n, i) => (i === index ? sum : sum + (n.percentageShare || 0)), 0);
return 100 - otherTotal;
}
saveEditNominee(nominee: any, index: number): void {
if (!this.editNomineeRelId || !this.editNomineeShare) return;
const otherTotal = this.nominees.reduce((sum, n, i) => i === index ? sum : sum + (n.percentageShare || 0), 0);
const otherTotal = this.nominees.reduce((sum, n, i) => (i === index ? sum : sum + (n.percentageShare || 0)), 0);
if (otherTotal + this.editNomineeShare > 100) return;
const relOption = this.clientTemplate?.familyMemberOptions?.relationshipIdOptions?.find(
@@ -413,8 +458,12 @@ export class ClientFamilyMembersStepComponent implements OnInit {
dateFormat: 'dd MMMM yyyy',
locale: 'en'
};
if (nominee.guardianName) { payload.guardianName = nominee.guardianName; }
if (nominee.guardianRelationship) { payload.guardianRelationship = nominee.guardianRelationship; }
if (nominee.guardianName) {
payload.guardianName = nominee.guardianName;
}
if (nominee.guardianRelationship) {
payload.guardianRelationship = nominee.guardianRelationship;
}
this.clientsService.editFamilyMember(this.clientId, nominee.id, payload).subscribe({ error: () => {} });
}
@@ -2020,11 +2020,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
export class SignatoryDetailDialogComponent {
private clientService = inject(ClientsService);
private sanitizer = inject(DomSanitizer);
private inputData = inject<any>(MAT_DIALOG_DATA);
data: any;
loading = false;
constructor() {
const inputData = inject<any>(MAT_DIALOG_DATA);
const inputData = this.inputData;
this.data = { ...inputData };
const cid = inputData.id?.toString();
if (!cid) return;
@@ -1,3 +1,11 @@
/**
* 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, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@@ -39,13 +47,23 @@ import { ClientsService } from 'app/clients/clients.service';
}
<div class="din-info-bar">
<fa-icon icon="info-circle" class="info-icon"></fa-icon>
<span>Enter the <strong>Director Identification Number (DIN)</strong> issued by the Ministry of Corporate Affairs (MCA) for this authorized signatory. The system will verify the DIN and match the entity name against the director's associated companies.</span>
<span
>Enter the <strong>Director Identification Number (DIN)</strong> issued by the Ministry of Corporate
Affairs (MCA) for this authorized signatory. The system will verify the DIN and match the entity name
against the director's associated companies.</span
>
</div>
<mat-form-field appearance="outline" class="din-input-field">
<mat-label>DIN Number</mat-label>
<mat-icon matPrefix class="din-prefix-icon">badge</mat-icon>
<input matInput [(ngModel)]="dinNumber" placeholder="e.g. 01234567" maxlength="8"
(keyup.enter)="onVerify()" autocomplete="off" />
<input
matInput
[(ngModel)]="dinNumber"
placeholder="e.g. 01234567"
maxlength="8"
(keyup.enter)="onVerify()"
autocomplete="off"
/>
<mat-hint>8-digit DIN number from MCA</mat-hint>
</mat-form-field>
</div>
@@ -59,14 +77,17 @@ import { ClientsService } from 'app/clients/clients.service';
<div class="progress-steps">
@for (step of progressSteps; track step.label; let i = $index) {
<div class="progress-step" [class.active]="i === currentStep" [class.done]="i < currentStep">
<fa-icon [icon]="i < currentStep ? 'check-circle' : step.icon" [class.done-icon]="i < currentStep"></fa-icon>
<fa-icon
[icon]="i < currentStep ? 'check-circle' : step.icon"
[class.done-icon]="i < currentStep"
></fa-icon>
<span>{{ step.label }}</span>
</div>
}
</div>
<p class="verify-msg">
Verifying DIN <strong>{{ dinNumber }}</strong> for <strong>{{ signatoryName }}</strong>
against <strong>MCA (Ministry of Corporate Affairs)</strong> records.
Verifying DIN <strong>{{ dinNumber }}</strong> for <strong>{{ signatoryName }}</strong> against
<strong>MCA (Ministry of Corporate Affairs)</strong> records.
</p>
</div>
}
@@ -80,9 +101,8 @@ import { ClientsService } from 'app/clients/clients.service';
<div class="result-badge success-badge">VERIFIED</div>
<h3 class="result-title">DIN Verified Successfully</h3>
<p class="result-detail">
DIN <strong class="din-highlight">{{ dinNumber }}</strong> for
<strong>{{ signatoryName }}</strong> has been verified against
<strong>MCA</strong> records.
DIN <strong class="din-highlight">{{ dinNumber }}</strong> for <strong>{{ signatoryName }}</strong> has been
verified against <strong>MCA</strong> records.
</p>
<div class="result-meta">
<div class="meta-row">
@@ -124,7 +144,9 @@ import { ClientsService } from 'app/clients/clients.service';
<fa-icon icon="check-circle" class="match-banner-icon"></fa-icon>
<div>
<strong>Entity Match Found</strong>
<p><strong>{{ entityName }}</strong> matches <strong>{{ matchedCompany }}</strong></p>
<p>
<strong>{{ entityName }}</strong> matches <strong>{{ matchedCompany }}</strong>
</p>
</div>
</div>
} @else {
@@ -132,7 +154,9 @@ import { ClientsService } from 'app/clients/clients.service';
<fa-icon icon="exclamation-triangle" class="match-banner-icon"></fa-icon>
<div>
<strong>No Entity Match</strong>
<p><strong>{{ entityName }}</strong> does not match any of the director's associated companies.</p>
<p>
<strong>{{ entityName }}</strong> does not match any of the director's associated companies.
</p>
</div>
</div>
}
@@ -148,11 +172,21 @@ import { ClientsService } from 'app/clients/clients.service';
<div class="company-info">
<span class="company-name">{{ company.companyName }}</span>
<span class="company-meta">
@if (company.cin) { CIN: {{ company.cin }} }
@if (company.cin && company.designation) { &middot; }
@if (company.designation) { {{ company.designation }} }
@if ((company.cin || company.designation) && company.appointmentDate) { &middot; }
@if (company.appointmentDate) { Since: {{ company.appointmentDate }} }
@if (company.cin) {
CIN: {{ company.cin }}
}
@if (company.cin && company.designation) {
&middot;
}
@if (company.designation) {
{{ company.designation }}
}
@if ((company.cin || company.designation) && company.appointmentDate) {
&middot;
}
@if (company.appointmentDate) {
Since: {{ company.appointmentDate }}
}
</span>
</div>
</div>
@@ -201,15 +235,11 @@ import { ClientsService } from 'app/clients/clients.service';
</button>
}
@if (!verifying && verified) {
<button mat-raised-button color="primary" (click)="onClose(true)">
<fa-icon icon="check"></fa-icon> Done
</button>
<button mat-raised-button color="primary" (click)="onClose(true)"><fa-icon icon="check"></fa-icon> Done</button>
}
@if (!verifying && failed) {
<button mat-stroked-button (click)="onRetry()">Retry</button>
<button mat-raised-button color="warn" (click)="onClose(false)">
<fa-icon icon="times"></fa-icon> Close
</button>
<button mat-raised-button color="warn" (click)="onClose(false)"><fa-icon icon="times"></fa-icon> Close</button>
}
@if (!verifying && errorOccurred) {
<button mat-stroked-button (click)="onRetry()">Retry</button>
@@ -235,7 +265,9 @@ import { ClientsService } from 'app/clients/clients.service';
color: #1074b9;
}
.dialog-title-icon { font-size: 20px; }
.dialog-title-icon {
font-size: 20px;
}
mat-dialog-content {
padding: 16px 24px;
@@ -255,10 +287,24 @@ import { ClientsService } from 'app/clients/clients.service';
border: 1px solid #e2e8f0;
}
.sig-icon { font-size: 22px; color: #1074b9; }
.sig-detail { display: flex; flex-direction: column; }
.sig-name { font-size: 15px; font-weight: 700; color: #1a2332; }
.sig-meta { font-size: 12px; color: #64748b; margin-top: 2px; }
.sig-icon {
font-size: 22px;
color: #1074b9;
}
.sig-detail {
display: flex;
flex-direction: column;
}
.sig-name {
font-size: 15px;
font-weight: 700;
color: #1a2332;
}
.sig-meta {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
/* Entity info */
.entity-info {
@@ -272,10 +318,26 @@ import { ClientsService } from 'app/clients/clients.service';
border: 1px solid #fde68a;
}
.entity-icon { font-size: 18px; color: #b45309; }
.entity-detail { display: flex; flex-direction: column; }
.entity-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #92400e; }
.entity-name { font-size: 14px; font-weight: 700; color: #78350f; }
.entity-icon {
font-size: 18px;
color: #b45309;
}
.entity-detail {
display: flex;
flex-direction: column;
}
.entity-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #92400e;
}
.entity-name {
font-size: 14px;
font-weight: 700;
color: #78350f;
}
/* DIN info bar */
.din-info-bar {
@@ -291,49 +353,180 @@ import { ClientsService } from 'app/clients/clients.service';
line-height: 1.5;
}
.info-icon { font-size: 16px; color: #1074b9; flex-shrink: 0; margin-top: 2px; }
.info-icon {
font-size: 16px;
color: #1074b9;
flex-shrink: 0;
margin-top: 2px;
}
/* DIN input */
.din-input-field { width: 100%; }
.din-prefix-icon { color: #64748b; margin-right: 4px; }
.din-input-field {
width: 100%;
}
.din-prefix-icon {
color: #64748b;
margin-right: 4px;
}
/* Progress */
.progress-section { display: flex; flex-direction: column; align-items: center; gap: 12px; }
.progress-percent { font-size: 22px; font-weight: 700; color: #1074b9; margin-top: 4px; }
.progress-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.progress-percent {
font-size: 22px;
font-weight: 700;
color: #1074b9;
margin-top: 4px;
}
.progress-steps { display: flex; flex-direction: column; gap: 10px; width: 100%; padding: 8px 16px; }
.progress-step { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #9ca3af; transition: color 0.3s ease; }
.progress-step.active { color: #1074b9; font-weight: 600; }
.progress-step.done { color: #388e3c; }
.done-icon { color: #388e3c; }
.progress-steps {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
padding: 8px 16px;
}
.progress-step {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #9ca3af;
transition: color 0.3s ease;
}
.progress-step.active {
color: #1074b9;
font-weight: 600;
}
.progress-step.done {
color: #388e3c;
}
.done-icon {
color: #388e3c;
}
.verify-msg { font-size: 14px; color: #555; line-height: 1.6; text-align: center; margin: 4px 0 0; }
.verify-msg {
font-size: 14px;
color: #555;
line-height: 1.6;
text-align: center;
margin: 4px 0 0;
}
/* Result cards */
.result-card { display: flex; flex-direction: column; align-items: center; padding: 20px 16px; gap: 12px; }
.result-icon-ring { width: 64px; height: 64px; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.success-ring { background: linear-gradient(135deg, #ecfdf5, #d1fae5); border: 3px solid #a7f3d0; }
.fail-ring { background: linear-gradient(135deg, #fef2f2, #fecaca); border: 3px solid #fca5a5; }
.result-shield { font-size: 28px; }
.success-shield { color: #059669; }
.fail-shield { color: #dc2626; }
.result-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 16px;
gap: 12px;
}
.result-icon-ring {
width: 64px;
height: 64px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.success-ring {
background: linear-gradient(135deg, #ecfdf5, #d1fae5);
border: 3px solid #a7f3d0;
}
.fail-ring {
background: linear-gradient(135deg, #fef2f2, #fecaca);
border: 3px solid #fca5a5;
}
.result-shield {
font-size: 28px;
}
.success-shield {
color: #059669;
}
.fail-shield {
color: #dc2626;
}
.result-badge { display: inline-block; font-size: 11px; font-weight: 800; letter-spacing: 1.5px; padding: 3px 14px; border-radius: 20px; }
.success-badge { color: #059669; background: #ecfdf5; border: 1px solid #a7f3d0; }
.fail-badge { color: #dc2626; background: #fef2f2; border: 1px solid #fecaca; }
.result-badge {
display: inline-block;
font-size: 11px;
font-weight: 800;
letter-spacing: 1.5px;
padding: 3px 14px;
border-radius: 20px;
}
.success-badge {
color: #059669;
background: #ecfdf5;
border: 1px solid #a7f3d0;
}
.fail-badge {
color: #dc2626;
background: #fef2f2;
border: 1px solid #fecaca;
}
.result-title { font-size: 17px; font-weight: 700; color: #111827; margin: 0; }
.result-detail { font-size: 13px; color: #6b7280; text-align: center; line-height: 1.6; margin: 0; max-width: 400px; }
.din-highlight { color: #059669; font-family: 'JetBrains Mono', 'Fira Code', monospace; }
.din-highlight-fail { color: #dc2626; font-family: 'JetBrains Mono', 'Fira Code', monospace; }
.result-title {
font-size: 17px;
font-weight: 700;
color: #111827;
margin: 0;
}
.result-detail {
font-size: 13px;
color: #6b7280;
text-align: center;
line-height: 1.6;
margin: 0;
max-width: 400px;
}
.din-highlight {
color: #059669;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.din-highlight-fail {
color: #dc2626;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.result-meta { width: 100%; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px 16px; margin-top: 4px; }
.meta-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f3f4f6; }
.meta-row:last-child { border-bottom: none; }
.meta-label { font-size: 12px; color: #9ca3af; font-weight: 500; }
.meta-value { font-size: 12px; color: #374151; font-weight: 600; }
.meta-mono { font-family: 'JetBrains Mono', 'Fira Code', monospace; letter-spacing: 0.5px; }
.meta-success { color: #059669; }
.result-meta {
width: 100%;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px 16px;
margin-top: 4px;
}
.meta-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid #f3f4f6;
}
.meta-row:last-child {
border-bottom: none;
}
.meta-label {
font-size: 12px;
color: #9ca3af;
font-weight: 500;
}
.meta-value {
font-size: 12px;
color: #374151;
font-weight: 600;
}
.meta-mono {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
letter-spacing: 0.5px;
}
.meta-success {
color: #059669;
}
/* ═══ Company Match Section ═══ */
.company-match-section {
@@ -351,7 +544,10 @@ import { ClientsService } from 'app/clients/clients.service';
margin: 0 0 10px;
}
.company-title-icon { font-size: 14px; color: #64748b; }
.company-title-icon {
font-size: 14px;
color: #64748b;
}
/* Match banner */
.match-banner {
@@ -365,7 +561,10 @@ import { ClientsService } from 'app/clients/clients.service';
line-height: 1.5;
}
.match-banner p { margin: 2px 0 0; font-size: 12px; }
.match-banner p {
margin: 2px 0 0;
font-size: 12px;
}
.match-success {
background: #ecfdf5;
@@ -373,7 +572,12 @@ import { ClientsService } from 'app/clients/clients.service';
color: #065f46;
}
.match-success .match-banner-icon { color: #059669; font-size: 18px; margin-top: 1px; flex-shrink: 0; }
.match-success .match-banner-icon {
color: #059669;
font-size: 18px;
margin-top: 1px;
flex-shrink: 0;
}
.match-warning {
background: #fffbeb;
@@ -381,7 +585,12 @@ import { ClientsService } from 'app/clients/clients.service';
color: #92400e;
}
.match-warning .match-banner-icon { color: #d97706; font-size: 18px; margin-top: 1px; flex-shrink: 0; }
.match-warning .match-banner-icon {
color: #d97706;
font-size: 18px;
margin-top: 1px;
flex-shrink: 0;
}
/* Company list */
.company-list {
@@ -399,9 +608,13 @@ import { ClientsService } from 'app/clients/clients.service';
transition: background 0.15s;
}
.company-item:last-child { border-bottom: none; }
.company-item:last-child {
border-bottom: none;
}
.company-item:hover { background: #f8fafc; }
.company-item:hover {
background: #f8fafc;
}
.company-matched {
background: #f0fdf4 !important;
@@ -415,28 +628,78 @@ import { ClientsService } from 'app/clients/clients.service';
flex-shrink: 0;
}
.company-match-icon.match { color: #22c55e; }
.company-match-icon.no-match { color: #d1d5db; }
.company-match-icon.match {
color: #22c55e;
}
.company-match-icon.no-match {
color: #d1d5db;
}
.company-info { display: flex; flex-direction: column; min-width: 0; }
.company-name { font-size: 13px; font-weight: 600; color: #1e293b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.company-meta { font-size: 11px; color: #94a3b8; margin-top: 2px; }
.company-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.company-name {
font-size: 13px;
font-weight: 600;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.company-meta {
font-size: 11px;
color: #94a3b8;
margin-top: 2px;
}
/* Fail reason */
.fail-reason {
display: flex; align-items: center; gap: 10px; width: 100%;
background: #fef2f2; border: 1px solid #fecaca; padding: 10px 14px;
border-radius: 6px; font-size: 13px; color: #991b1b; line-height: 1.5;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
background: #fef2f2;
border: 1px solid #fecaca;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
color: #991b1b;
line-height: 1.5;
}
.fail-reason-icon {
font-size: 16px;
color: #dc2626;
flex-shrink: 0;
}
.fail-reason-icon { font-size: 16px; color: #dc2626; flex-shrink: 0; }
/* Error */
.error-icon-large { font-size: 48px; color: #f57c00; }
.error-msg { font-size: 16px; font-weight: 600; color: #f57c00; margin: 0; text-align: center; }
.error-detail { font-size: 14px; color: #555; text-align: center; }
.error-icon-large {
font-size: 48px;
color: #f57c00;
}
.error-msg {
font-size: 16px;
font-weight: 600;
color: #f57c00;
margin: 0;
text-align: center;
}
.error-detail {
font-size: 14px;
color: #555;
text-align: center;
}
mat-dialog-actions { padding: 8px 24px 16px; }
mat-dialog-actions button { display: flex; align-items: center; gap: 6px; }
mat-dialog-actions {
padding: 8px 24px 16px;
}
mat-dialog-actions button {
display: flex;
align-items: center;
gap: 6px;
}
`
],
imports: [
@@ -561,7 +824,11 @@ export class DinVerifyDialogComponent implements OnInit, OnDestroy {
if (this.apiError) {
this.errorOccurred = true;
this.errorMessage = this.apiError?.error?.defaultUserMessage || this.apiError?.error?.message || this.apiError?.message || 'Unable to verify DIN. Please try again.';
this.errorMessage =
this.apiError?.error?.defaultUserMessage ||
this.apiError?.error?.message ||
this.apiError?.message ||
'Unable to verify DIN. Please try again.';
return;
}
@@ -590,7 +857,11 @@ export class DinVerifyDialogComponent implements OnInit, OnDestroy {
this.matchCompanyName();
} else {
this.failed = true;
this.failReason = changes?.dinFailReason || res?.failReason || res?.defaultUserMessage || 'DIN could not be verified against MCA records.';
this.failReason =
changes?.dinFailReason ||
res?.failReason ||
res?.defaultUserMessage ||
'DIN could not be verified against MCA records.';
}
}
@@ -1,3 +1,11 @@
/**
* 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, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatDialogRef, MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
@@ -21,7 +29,10 @@ import { ClientsService } from 'app/clients/clients.service';
<div class="progress-steps">
@for (step of progressSteps; track step.label; let i = $index) {
<div class="progress-step" [class.active]="i === currentStep" [class.done]="i < currentStep">
<fa-icon [icon]="i < currentStep ? 'check-circle' : step.icon" [class.done-icon]="i < currentStep"></fa-icon>
<fa-icon
[icon]="i < currentStep ? 'check-circle' : step.icon"
[class.done-icon]="i < currentStep"
></fa-icon>
<span>{{ step.label }}</span>
</div>
}
@@ -42,8 +53,8 @@ import { ClientsService } from 'app/clients/clients.service';
<h3 class="approve-title">No Sanctions Match Found</h3>
<p class="approve-detail">
<strong>{{ clientName }}</strong> has been screened against the
<strong>UN Security Council Consolidated List</strong> and
<strong>FIU-IND</strong> records. No confirmed match was found.
<strong>UN Security Council Consolidated List</strong> and <strong>FIU-IND</strong> records. No confirmed
match was found.
</p>
<div class="approve-meta">
<div class="meta-row">
@@ -550,7 +561,12 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
}
private runProgress() {
const stepDurations = [1200, 1500, 1800, 1000]; // ms per step
const stepDurations = [
1200,
1500,
1800,
1000
]; // ms per step
const totalDuration = stepDurations.reduce((a, b) => a + b, 0);
let elapsed = 0;
const interval = 50;
@@ -602,7 +618,10 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
if (this.apiError) {
this.errorOccurred = true;
this.errorMessage = this.apiError?.error?.message || this.apiError?.message || 'Unable to connect to sanctions screening service.';
this.errorMessage =
this.apiError?.error?.message ||
this.apiError?.message ||
'Unable to connect to sanctions screening service.';
} else {
this.screeningLogId = this.apiResult?.screeningLogId || null;
if (this.apiResult?.matchFound) {
@@ -20,15 +20,18 @@
<mat-tab-group class="addr-tabs" [(selectedIndex)]="activeTab" animationDuration="150ms">
<!-- ═══ TAB 1: PERMANENT ═══ -->
<mat-tab>
<ng-template mat-tab-label>
<span class="addr-tab-num">1</span> Permanent Address
</ng-template>
<ng-template mat-tab-label> <span class="addr-tab-num">1</span> Permanent Address </ng-template>
<div class="addr-panel" [formGroup]="permanentForm">
<div class="addr-grid addr-grid--1">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Address Line 1</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>home</mat-icon></span>
<input matInput required formControlName="addressLine1" placeholder="House / Flat No., Floor, Building Name" />
<input
matInput
required
formControlName="addressLine1"
placeholder="House / Flat No., Floor, Building Name"
/>
</mat-form-field>
</div>
<div class="addr-grid addr-grid--1">
@@ -45,9 +48,15 @@
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>PIN Code</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>pin_drop</mat-icon></span>
<input matInput required formControlName="pinCode" maxlength="6" placeholder="6-digit PIN"
<input
matInput
required
formControlName="pinCode"
maxlength="6"
placeholder="6-digit PIN"
(input)="onPinInput('permanent')"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
@if (permanentForm.get('pinCode')?.hasError('pattern') && permanentForm.get('pinCode')?.touched) {
<mat-error>Enter a valid 6-digit PIN code</mat-error>
}
@@ -126,9 +135,7 @@
<!-- ═══ TAB 2: CURRENT / RESIDENTIAL ═══ -->
<mat-tab>
<ng-template mat-tab-label>
<span class="addr-tab-num">2</span> Current / Residential
</ng-template>
<ng-template mat-tab-label> <span class="addr-tab-num">2</span> Current / Residential </ng-template>
<div class="addr-panel" [formGroup]="currentForm">
<div class="addr-same-row">
<mat-checkbox [checked]="sameAsPermanent" (change)="toggleSameAsPermanent($event.checked)">
@@ -140,7 +147,12 @@
<div class="addr-grid addr-grid--1">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Address Line 1</mat-label>
<input matInput required formControlName="addressLine1" placeholder="House / Flat No., Floor, Building Name" />
<input
matInput
required
formControlName="addressLine1"
placeholder="House / Flat No., Floor, Building Name"
/>
</mat-form-field>
</div>
<div class="addr-grid addr-grid--1">
@@ -155,9 +167,15 @@
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>PIN Code</mat-label>
<input matInput required formControlName="pinCode" maxlength="6" placeholder="6-digit PIN"
<input
matInput
required
formControlName="pinCode"
maxlength="6"
placeholder="6-digit PIN"
(input)="onPinInput('current')"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
@if (currentForm.get('pinCode')?.hasError('pattern') && currentForm.get('pinCode')?.touched) {
<mat-error>Enter a valid 6-digit PIN code</mat-error>
}
@@ -230,9 +248,7 @@
<!-- ═══ TAB 3: OFFICE / WORK ═══ -->
<mat-tab>
<ng-template mat-tab-label>
<span class="addr-tab-num">3</span> Office / Work
</ng-template>
<ng-template mat-tab-label> <span class="addr-tab-num">3</span> Office / Work </ng-template>
<div class="addr-panel" [formGroup]="officeForm">
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
@@ -268,9 +284,14 @@
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>PIN Code</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>pin_drop</mat-icon></span>
<input matInput formControlName="pinCode" maxlength="6" placeholder="6-digit PIN"
<input
matInput
formControlName="pinCode"
maxlength="6"
placeholder="6-digit PIN"
(input)="onPinInput('office')"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
@if (officeForm.get('pinCode')?.hasError('pattern') && officeForm.get('pinCode')?.touched) {
<mat-error>Enter a valid 6-digit PIN code</mat-error>
}
@@ -348,13 +369,13 @@
<!-- ═══ TAB 4: CORRESPONDENCE ═══ -->
<mat-tab>
<ng-template mat-tab-label>
<span class="addr-tab-num">4</span> Correspondence
</ng-template>
<ng-template mat-tab-label> <span class="addr-tab-num">4</span> Correspondence </ng-template>
<div class="addr-panel" [formGroup]="correspondenceForm">
<div class="addr-copy-row">
<span class="addr-copy-label">Copy address from:</span>
<button mat-stroked-button type="button" class="addr-copy-btn" (click)="copyFrom('permanent')">Permanent</button>
<button mat-stroked-button type="button" class="addr-copy-btn" (click)="copyFrom('permanent')">
Permanent
</button>
<button mat-stroked-button type="button" class="addr-copy-btn" (click)="copyFrom('current')">Current</button>
<button mat-stroked-button type="button" class="addr-copy-btn" (click)="copyFrom('office')">Office</button>
</div>
@@ -363,7 +384,12 @@
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Address Line 1</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>home</mat-icon></span>
<input matInput required formControlName="addressLine1" placeholder="House / Flat No., Floor, Building Name" />
<input
matInput
required
formControlName="addressLine1"
placeholder="House / Flat No., Floor, Building Name"
/>
</mat-form-field>
</div>
<div class="addr-grid addr-grid--1">
@@ -380,9 +406,15 @@
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>PIN Code</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>pin_drop</mat-icon></span>
<input matInput required formControlName="pinCode" maxlength="6" placeholder="6-digit PIN"
<input
matInput
required
formControlName="pinCode"
maxlength="6"
placeholder="6-digit PIN"
(input)="onPinInput('correspondence')"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
@if (correspondenceForm.get('pinCode')?.hasError('pattern') && correspondenceForm.get('pinCode')?.touched) {
<mat-error>Enter a valid 6-digit PIN code</mat-error>
}
@@ -1,3 +1,11 @@
/**
* 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/.
*/
/**
* Copyright since 2025 Mifos Initiative
*/
@@ -76,9 +84,17 @@ $muted: #64748b;
gap: 4px 16px;
margin-bottom: 4px;
&--1 { grid-template-columns: 1fr; }
&--2 { grid-template-columns: 1fr 1fr; }
&--3 { grid-template-columns: 1fr 1fr 1fr; }
&--1 {
grid-template-columns: 1fr;
}
&--2 {
grid-template-columns: 1fr 1fr;
}
&--3 {
grid-template-columns: 1fr 1fr 1fr;
}
}
.addr-ff {
@@ -101,7 +117,8 @@ $muted: #64748b;
white-space: nowrap;
}
&::before, &::after {
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
@@ -174,7 +191,8 @@ $muted: #64748b;
// Responsive
@media (width <= 768px) {
.addr-grid--2, .addr-grid--3 {
.addr-grid--2,
.addr-grid--3 {
grid-template-columns: 1fr;
}
@@ -42,27 +42,60 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
sameAsPermanent = false;
permanentForm: UntypedFormGroup = this.fb.group({
addressLine1: ['', Validators.required],
addressLine1: [
'',
Validators.required
],
addressLine2: [''],
pinCode: ['', [Validators.required, Validators.pattern(/^\d{6}$/)]],
locality: ['', Validators.required],
pinCode: [
'',
[
Validators.required,
Validators.pattern(/^\d{6}$/)
]
],
locality: [
'',
Validators.required
],
city: [''],
district: [''],
state: ['', Validators.required],
state: [
'',
Validators.required
],
country: [{ value: 'India', disabled: true }],
landmark: [''],
proofType: ['', Validators.required],
proofType: [
'',
Validators.required
],
proofDocNumber: ['']
});
currentForm: UntypedFormGroup = this.fb.group({
addressLine1: ['', Validators.required],
addressLine1: [
'',
Validators.required
],
addressLine2: [''],
pinCode: ['', [Validators.required, Validators.pattern(/^\d{6}$/)]],
locality: ['', Validators.required],
pinCode: [
'',
[
Validators.required,
Validators.pattern(/^\d{6}$/)
]
],
locality: [
'',
Validators.required
],
city: [''],
district: [''],
state: ['', Validators.required],
state: [
'',
Validators.required
],
country: [{ value: 'India', disabled: true }],
landmark: [''],
residenceType: [''],
@@ -74,7 +107,10 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
department: [''],
addressLine1: [''],
addressLine2: [''],
pinCode: ['', Validators.pattern(/^\d{6}$/)],
pinCode: [
'',
Validators.pattern(/^\d{6}$/)
],
locality: [''],
city: [''],
district: [''],
@@ -87,13 +123,28 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
});
correspondenceForm: UntypedFormGroup = this.fb.group({
addressLine1: ['', Validators.required],
addressLine1: [
'',
Validators.required
],
addressLine2: [''],
pinCode: ['', [Validators.required, Validators.pattern(/^\d{6}$/)]],
locality: ['', Validators.required],
pinCode: [
'',
[
Validators.required,
Validators.pattern(/^\d{6}$/)
]
],
locality: [
'',
Validators.required
],
city: [''],
district: [''],
state: ['', Validators.required],
state: [
'',
Validators.required
],
country: [{ value: 'India', disabled: true }],
landmark: [''],
commMode: [''],
@@ -116,18 +167,36 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
};
proofTypes = [
'Aadhaar Card', 'Passport', 'Voter ID', 'Driving Licence',
'Utility Bill (not older than 3 months)', 'Bank Statement'
'Aadhaar Card',
'Passport',
'Voter ID',
'Driving Licence',
'Utility Bill (not older than 3 months)',
'Bank Statement'
];
residenceTypes = ['Owned', 'Rented', 'Leased', 'Company Provided', 'Parental / Family Owned'];
residenceTypes = [
'Owned',
'Rented',
'Leased',
'Company Provided',
'Parental / Family Owned'
];
employmentTypes = [
'Salaried (Private)', 'Salaried (Government / PSU)',
'Self-Employed Professional', 'Business Owner', 'Retired'
'Salaried (Private)',
'Salaried (Government / PSU)',
'Self-Employed Professional',
'Business Owner',
'Retired'
];
commModes = ['Post / Courier', 'Email', 'SMS', 'WhatsApp'];
commModes = [
'Post / Courier',
'Email',
'SMS',
'WhatsApp'
];
private pinTimers: Record<string, any> = {};
private pinCache: Record<string, any[]> = {};
@@ -144,7 +213,12 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
ngOnChanges(changes: SimpleChanges): void {
if (changes['readOnly']) {
const forms = [this.permanentForm, this.currentForm, this.officeForm, this.correspondenceForm];
const forms = [
this.permanentForm,
this.currentForm,
this.officeForm,
this.correspondenceForm
];
for (const form of forms) {
if (this.readOnly) {
form.disable({ emitEvent: false });
@@ -168,10 +242,19 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
// Build type-to-tab mapping from address type names
const tabMap: Record<string, string> = {
permanent: 'permanent', 'permanent address': 'permanent',
residential: 'current', current: 'current', 'current / residential': 'current', communication: 'current',
office: 'office', 'office / work': 'office', business: 'office', work: 'office',
correspondence: 'correspondence', mailing: 'correspondence', postal: 'correspondence'
permanent: 'permanent',
'permanent address': 'permanent',
residential: 'current',
current: 'current',
'current / residential': 'current',
communication: 'current',
office: 'office',
'office / work': 'office',
business: 'office',
work: 'office',
correspondence: 'correspondence',
mailing: 'correspondence',
postal: 'correspondence'
};
for (const addr of addresses) {
@@ -192,7 +275,9 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
});
}
},
error: () => { this.addressLoaded = true; }
error: () => {
this.addressLoaded = true;
}
});
}
@@ -235,11 +320,13 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
private applyPinData(tab: string, offices: any[]) {
const seen = new Set<string>();
this.localityOptions[tab] = offices.filter(o => {
if (seen.has(o.Name)) return false;
seen.add(o.Name);
return true;
}).map(o => o.Name);
this.localityOptions[tab] = offices
.filter((o) => {
if (seen.has(o.Name)) return false;
seen.add(o.Name);
return true;
})
.map((o) => o.Name);
const form = this.getForm(tab);
const f = offices[0];
@@ -259,11 +346,16 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
getForm(tab: string): UntypedFormGroup {
switch (tab) {
case 'permanent': return this.permanentForm;
case 'current': return this.currentForm;
case 'office': return this.officeForm;
case 'correspondence': return this.correspondenceForm;
default: return this.permanentForm;
case 'permanent':
return this.permanentForm;
case 'current':
return this.currentForm;
case 'office':
return this.officeForm;
case 'correspondence':
return this.correspondenceForm;
default:
return this.permanentForm;
}
}
@@ -307,7 +399,9 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
get addressData(): any {
return {
permanent: this.permanentForm.getRawValue(),
current: this.sameAsPermanent ? { ...this.permanentForm.getRawValue(), sameAsPermanent: true } : this.currentForm.getRawValue(),
current: this.sameAsPermanent
? { ...this.permanentForm.getRawValue(), sameAsPermanent: true }
: this.currentForm.getRawValue(),
office: this.officeForm.getRawValue(),
correspondence: this.correspondenceForm.getRawValue()
};
@@ -832,7 +832,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
} else {
this.gstinVerifyStatus = 'FAILED';
this.showPanErrorAlert(
changes?.gstinVerificationMessage || 'GSTIN verification failed. Please check the GSTIN number and try again.'
changes?.gstinVerificationMessage ||
'GSTIN verification failed. Please check the GSTIN number and try again.'
);
}
},
@@ -840,7 +841,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.gstinVerifying = false;
this.gstinVerifyStatus = 'FAILED';
this.showPanErrorAlert(
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'GSTIN verification failed.'
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
'GSTIN verification failed.'
);
}
});
@@ -1,3 +1,11 @@
<!--
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/.
-->
<!--
Copyright since 2025 Mifos Initiative
-->
@@ -20,11 +28,13 @@
<div class="crop-workspace" [class.hidden]="!imageLoaded">
<div class="crop-source">
<canvas #sourceCanvas
<canvas
#sourceCanvas
(mousedown)="onMouseDown($event)"
(mousemove)="onMouseMove($event)"
(mouseup)="onMouseUp()"
(mouseleave)="onMouseUp()">
(mouseleave)="onMouseUp()"
>
</canvas>
<div class="crop-hint">Drag to move. Drag corner to resize.</div>
</div>
@@ -1,3 +1,11 @@
/**
* 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/.
*/
/**
* Copyright since 2025 Mifos Initiative
*/
@@ -1,8 +1,12 @@
/**
* 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, Inject, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
import { Component, inject, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@@ -59,10 +63,8 @@ export class ImageCropDialogComponent implements AfterViewInit {
zoom = 1;
constructor(
public dialogRef: MatDialogRef<ImageCropDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: ImageCropData
) {}
dialogRef = inject(MatDialogRef<ImageCropDialogComponent>);
data = inject<ImageCropData>(MAT_DIALOG_DATA);
ngAfterViewInit() {
const reader = new FileReader();
@@ -127,9 +129,14 @@ export class ImageCropDialogComponent implements AfterViewInit {
ctx.clearRect(this.cropX, this.cropY, this.cropW, this.cropH);
ctx.drawImage(
this.img,
this.cropX * this.scaleX, this.cropY * this.scaleY,
this.cropW * this.scaleX, this.cropH * this.scaleY,
this.cropX, this.cropY, this.cropW, this.cropH
this.cropX * this.scaleX,
this.cropY * this.scaleY,
this.cropW * this.scaleX,
this.cropH * this.scaleY,
this.cropX,
this.cropY,
this.cropW,
this.cropH
);
// Crop border
@@ -141,14 +148,31 @@ export class ImageCropDialogComponent implements AfterViewInit {
const hs = 8;
ctx.fillStyle = '#1353a0';
const corners = [
[this.cropX, this.cropY],
[this.cropX + this.cropW, this.cropY],
[this.cropX, this.cropY + this.cropH],
[this.cropX + this.cropW, this.cropY + this.cropH]
[
this.cropX,
this.cropY
],
[
this.cropX + this.cropW,
this.cropY
],
[
this.cropX,
this.cropY + this.cropH
],
[
this.cropX + this.cropW,
this.cropY + this.cropH
]
];
corners.forEach(([x, y]) => {
ctx.fillRect(x - hs / 2, y - hs / 2, hs, hs);
});
corners.forEach(
([
x,
y
]) => {
ctx.fillRect(x - hs / 2, y - hs / 2, hs, hs);
}
);
}
private updatePreview() {
@@ -163,9 +187,14 @@ export class ImageCropDialogComponent implements AfterViewInit {
pCtx.clearRect(0, 0, pw, ph);
pCtx.drawImage(
this.img,
this.cropX * this.scaleX, this.cropY * this.scaleY,
this.cropW * this.scaleX, this.cropH * this.scaleY,
0, 0, pw, ph
this.cropX * this.scaleX,
this.cropY * this.scaleY,
this.cropW * this.scaleX,
this.cropH * this.scaleY,
0,
0,
pw,
ph
);
}
@@ -229,9 +258,14 @@ export class ImageCropDialogComponent implements AfterViewInit {
const ctx = offscreen.getContext('2d')!;
ctx.drawImage(
this.img,
this.cropX * this.scaleX, this.cropY * this.scaleY,
this.cropW * this.scaleX, this.cropH * this.scaleY,
0, 0, offscreen.width, offscreen.height
this.cropX * this.scaleX,
this.cropY * this.scaleY,
this.cropW * this.scaleX,
this.cropH * this.scaleY,
0,
0,
offscreen.width,
offscreen.height
);
offscreen.toBlob((blob) => {
@@ -405,8 +405,13 @@
}
.preview-field {
&:nth-child(3n) { border-right: 1px solid rgb(0 0 0 / 4%); }
&:nth-child(2n) { border-right: none; }
&:nth-child(3n) {
border-right: 1px solid rgb(0 0 0 / 4%);
}
&:nth-child(2n) {
border-right: none;
}
}
}
@@ -486,8 +491,13 @@
border-bottom: 1px solid #f1f5f9;
transition: background 0.1s;
&:last-child { border-bottom: none; }
&:hover { background: #f8fafc; }
&:last-child {
border-bottom: none;
}
&:hover {
background: #f8fafc;
}
}
.fm-preview-row__sno {
@@ -34,12 +34,18 @@
<mat-panel-title class="addr-panel__title">
<i class="fa fa-home addr-panel__type-icon"></i>
<span class="addr-panel__type">{{ address.addressType }}</span>
<span class="addr-panel__status" [class.addr-panel__status--active]="address.isActive" [class.addr-panel__status--inactive]="!address.isActive">
<span
class="addr-panel__status"
[class.addr-panel__status--active]="address.isActive"
[class.addr-panel__status--inactive]="!address.isActive"
>
{{ address.isActive ? 'Active' : 'Inactive' }}
</span>
</mat-panel-title>
<mat-panel-description class="addr-panel__desc">
{{ address.city || '' }}{{ address.city && (address.countyDistrict || address.addressLine3) ? ', ' : '' }}{{ address.countyDistrict || address.addressLine3 || '' }}{{ address.postalCode ? ' - ' + address.postalCode : '' }}
{{ address.city || '' }}{{ address.city && (address.countyDistrict || address.addressLine3) ? ', ' : ''
}}{{ address.countyDistrict || address.addressLine3 || ''
}}{{ address.postalCode ? ' - ' + address.postalCode : '' }}
</mat-panel-description>
</mat-expansion-panel-header>
@@ -101,12 +107,16 @@
@if (address.stateProvinceId) {
<div class="addr-field">
<div class="addr-field__label">State</div>
<div class="addr-field__value">{{ getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name || '-' }}</div>
<div class="addr-field__value">
{{ getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name || '-' }}
</div>
</div>
}
<div class="addr-field">
<div class="addr-field__label">Country</div>
<div class="addr-field__value">{{ getSelectedValue('countryIdOptions', address.countryId)?.name || 'India' }}</div>
<div class="addr-field__value">
{{ getSelectedValue('countryIdOptions', address.countryId)?.name || 'India' }}
</div>
</div>
</div>
</mat-expansion-panel>
@@ -80,23 +80,17 @@
<!-- Client basic info -->
<div class="ckyc-info-grid">
<div class="ckyc-info-item">
<span class="ckyc-info-label">
<fa-icon icon="hashtag"></fa-icon> CIF Number
</span>
<span class="ckyc-info-label"> <fa-icon icon="hashtag"></fa-icon> CIF Number </span>
<span class="ckyc-info-value ckyc-info-value--mono">{{ cifNumber || clientId }}</span>
</div>
<div class="ckyc-info-item">
<span class="ckyc-info-label">
<fa-icon icon="user"></fa-icon> Full Name
</span>
<span class="ckyc-info-label"> <fa-icon icon="user"></fa-icon> Full Name </span>
<span class="ckyc-info-value">{{ clientName || '—' }}</span>
</div>
<div class="ckyc-info-item">
<span class="ckyc-info-label">
<fa-icon icon="envelope"></fa-icon> Email
</span>
<span class="ckyc-info-label"> <fa-icon icon="envelope"></fa-icon> Email </span>
@if (clientEmail) {
<span class="ckyc-info-value">{{ clientEmail }}</span>
} @else {
@@ -105,9 +99,7 @@
</div>
<div class="ckyc-info-item">
<span class="ckyc-info-label">
<fa-icon icon="phone"></fa-icon> Mobile
</span>
<span class="ckyc-info-label"> <fa-icon icon="phone"></fa-icon> Mobile </span>
@if (clientMobile) {
<span class="ckyc-info-value">{{ clientMobile }}</span>
} @else {
@@ -152,7 +144,8 @@
}
@if (existing.ADDRESS_PROOF.fileCount && existing.ADDRESS_PROOF.fileCount > 0) {
<span class="ckyc-existing__count">
{{ existing.ADDRESS_PROOF.fileCount }} {{ existing.ADDRESS_PROOF.fileCount === 1 ? 'file' : 'files' }}
{{ existing.ADDRESS_PROOF.fileCount }}
{{ existing.ADDRESS_PROOF.fileCount === 1 ? 'file' : 'files' }}
</span>
}
</div>
@@ -169,65 +162,65 @@
}
@if (!existing.ADDRESS_PROOF.present || existing.ADDRESS_PROOF.expanded) {
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
<div class="ckyc-file-row">
<input
type="file"
#addressProofInput
accept=".png,.jpeg,.jpg,.pdf"
(change)="onFileSelect($event, 'ADDRESS_PROOF')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
<div class="ckyc-file-row">
<input
matInput
[readonly]="true"
[value]="addressProofFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
type="file"
#addressProofInput
accept=".png,.jpeg,.jpg,.pdf"
(change)="onFileSelect($event, 'ADDRESS_PROOF')"
[style.display]="'none'"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="addressProofInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="addressProofFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="addressProofInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!addressProofFile || uploading['ADDRESS_PROOF']"
(click)="uploadDocument('ADDRESS_PROOF')"
>
@if (uploading['ADDRESS_PROOF']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!addressProofFile || uploading['ADDRESS_PROOF']"
(click)="uploadDocument('ADDRESS_PROOF')"
>
@if (uploading['ADDRESS_PROOF']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
@if (uploadErrors['ADDRESS_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['ADDRESS_PROOF'] }}</span>
</div>
}
@if (uploadResponses['ADDRESS_PROOF']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['ADDRESS_PROOF'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['ADDRESS_PROOF'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['ADDRESS_PROOF'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['ADDRESS_PROOF'].processingStatus }}
</p>
</div>
}
</button>
@if (uploadErrors['ADDRESS_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['ADDRESS_PROOF'] }}</span>
</div>
}
@if (uploadResponses['ADDRESS_PROOF']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['ADDRESS_PROOF'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['ADDRESS_PROOF'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['ADDRESS_PROOF'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['ADDRESS_PROOF'].processingStatus }}
</p>
</div>
}
}
</mat-card-content>
</mat-card>
@@ -274,65 +267,65 @@
}
@if (!existing.PHOTO_PROOF.present || existing.PHOTO_PROOF.expanded) {
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
<div class="ckyc-file-row">
<input
type="file"
#photoProofInput
accept=".png,.jpeg,.jpg,.pdf"
(change)="onFileSelect($event, 'PHOTO_PROOF')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
<div class="ckyc-file-row">
<input
matInput
[readonly]="true"
[value]="photoProofFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
type="file"
#photoProofInput
accept=".png,.jpeg,.jpg,.pdf"
(change)="onFileSelect($event, 'PHOTO_PROOF')"
[style.display]="'none'"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="photoProofInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="photoProofFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="photoProofInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!photoProofFile || uploading['PHOTO_PROOF']"
(click)="uploadDocument('PHOTO_PROOF')"
>
@if (uploading['PHOTO_PROOF']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!photoProofFile || uploading['PHOTO_PROOF']"
(click)="uploadDocument('PHOTO_PROOF')"
>
@if (uploading['PHOTO_PROOF']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
@if (uploadErrors['PHOTO_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['PHOTO_PROOF'] }}</span>
</div>
}
@if (uploadResponses['PHOTO_PROOF']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['PHOTO_PROOF'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['PHOTO_PROOF'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['PHOTO_PROOF'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['PHOTO_PROOF'].processingStatus }}
</p>
</div>
}
</button>
@if (uploadErrors['PHOTO_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['PHOTO_PROOF'] }}</span>
</div>
}
@if (uploadResponses['PHOTO_PROOF']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['PHOTO_PROOF'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['PHOTO_PROOF'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['PHOTO_PROOF'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['PHOTO_PROOF'].processingStatus }}
</p>
</div>
}
}
</mat-card-content>
</mat-card>
@@ -370,65 +363,65 @@
}
@if (!existing.CLIENT_IMAGE.present || existing.CLIENT_IMAGE.expanded) {
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p>
<div class="ckyc-file-row">
<input
type="file"
#clientImageInput
accept=".png,.jpeg,.jpg"
(change)="onFileSelect($event, 'CLIENT_IMAGE')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p>
<div class="ckyc-file-row">
<input
matInput
[readonly]="true"
[value]="clientImageFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
type="file"
#clientImageInput
accept=".png,.jpeg,.jpg"
(change)="onFileSelect($event, 'CLIENT_IMAGE')"
[style.display]="'none'"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="clientImageInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="clientImageFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="clientImageInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!clientImageFile || uploading['CLIENT_IMAGE']"
(click)="uploadDocument('CLIENT_IMAGE')"
>
@if (uploading['CLIENT_IMAGE']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!clientImageFile || uploading['CLIENT_IMAGE']"
(click)="uploadDocument('CLIENT_IMAGE')"
>
@if (uploading['CLIENT_IMAGE']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
@if (uploadErrors['CLIENT_IMAGE']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['CLIENT_IMAGE'] }}</span>
</div>
}
@if (uploadResponses['CLIENT_IMAGE']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['CLIENT_IMAGE'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['CLIENT_IMAGE'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['CLIENT_IMAGE'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['CLIENT_IMAGE'].processingStatus }}
</p>
</div>
}
</button>
@if (uploadErrors['CLIENT_IMAGE']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['CLIENT_IMAGE'] }}</span>
</div>
}
@if (uploadResponses['CLIENT_IMAGE']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['CLIENT_IMAGE'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['CLIENT_IMAGE'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['CLIENT_IMAGE'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['CLIENT_IMAGE'].processingStatus }}
</p>
</div>
}
}
</mat-card-content>
</mat-card>
@@ -451,7 +444,11 @@
</div>
</div>
@if (existing.CLIENT_SIGNATURE.previewUrl) {
<img [src]="existing.CLIENT_SIGNATURE.previewUrl" alt="Client signature" class="ckyc-existing__thumb ckyc-existing__thumb--wide" />
<img
[src]="existing.CLIENT_SIGNATURE.previewUrl"
alt="Client signature"
class="ckyc-existing__thumb ckyc-existing__thumb--wide"
/>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-signature"></fa-icon>
@@ -470,65 +467,66 @@
}
@if (!existing.CLIENT_SIGNATURE.present || existing.CLIENT_SIGNATURE.expanded) {
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p>
<div class="ckyc-file-row">
<input
type="file"
#clientSignatureInput
accept=".png,.jpeg,.jpg"
(change)="onFileSelect($event, 'CLIENT_SIGNATURE')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p>
<div class="ckyc-file-row">
<input
matInput
[readonly]="true"
[value]="clientSignatureFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
type="file"
#clientSignatureInput
accept=".png,.jpeg,.jpg"
(change)="onFileSelect($event, 'CLIENT_SIGNATURE')"
[style.display]="'none'"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="clientSignatureInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="clientSignatureFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="clientSignatureInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!clientSignatureFile || uploading['CLIENT_SIGNATURE']"
(click)="uploadDocument('CLIENT_SIGNATURE')"
>
@if (uploading['CLIENT_SIGNATURE']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!clientSignatureFile || uploading['CLIENT_SIGNATURE']"
(click)="uploadDocument('CLIENT_SIGNATURE')"
>
@if (uploading['CLIENT_SIGNATURE']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
@if (uploadErrors['CLIENT_SIGNATURE']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['CLIENT_SIGNATURE'] }}</span>
</div>
}
@if (uploadResponses['CLIENT_SIGNATURE']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['CLIENT_SIGNATURE'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b>
{{ uploadResponses['CLIENT_SIGNATURE'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['CLIENT_SIGNATURE'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['CLIENT_SIGNATURE'].processingStatus }}
</p>
</div>
}
</button>
@if (uploadErrors['CLIENT_SIGNATURE']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['CLIENT_SIGNATURE'] }}</span>
</div>
}
@if (uploadResponses['CLIENT_SIGNATURE']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['CLIENT_SIGNATURE'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['CLIENT_SIGNATURE'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['CLIENT_SIGNATURE'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['CLIENT_SIGNATURE'].processingStatus }}
</p>
</div>
}
}
</mat-card-content>
</mat-card>
@@ -61,7 +61,7 @@
overflow: hidden;
margin-bottom: 18px;
color: #fff;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.1);
box-shadow: 0 6px 22px rgb(0 0 0 / 10%);
&::before {
content: '';
@@ -71,7 +71,7 @@
width: 320px;
height: 320px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.18), transparent 70%);
background: radial-gradient(circle, rgb(255 255 255 / 18%), transparent 70%);
pointer-events: none;
}
@@ -96,8 +96,8 @@
width: 60px;
height: 60px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.35);
background: rgb(255 255 255 / 20%);
border: 1px solid rgb(255 255 255 / 35%);
display: flex;
align-items: center;
justify-content: center;
@@ -115,8 +115,8 @@
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.22);
border: 1px solid rgba(255, 255, 255, 0.4);
background: rgb(255 255 255 / 22%);
border: 1px solid rgb(255 255 255 / 40%);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.8px;
@@ -150,11 +150,20 @@
opacity: 0.95;
flex-wrap: wrap;
fa-icon { font-size: 11px; opacity: 0.9; margin-right: 3px; }
strong { font-weight: 700; }
fa-icon {
font-size: 11px;
opacity: 0.9;
margin-right: 3px;
}
strong {
font-weight: 700;
}
}
&__sep { opacity: 0.5; }
&__sep {
opacity: 0.5;
}
&__hint {
font-size: 12px;
@@ -169,8 +178,8 @@
align-items: center;
gap: 8px;
padding: 7px 14px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.35);
background: rgb(255 255 255 / 20%);
border: 1px solid rgb(255 255 255 / 35%);
border-radius: 20px;
font-size: 11px;
font-weight: 700;
@@ -186,15 +195,23 @@
height: 8px;
border-radius: 50%;
background: #9fe1cb;
box-shadow: 0 0 0 0 rgba(159, 225, 203, 0.7);
box-shadow: 0 0 0 0 rgb(159 225 203 / 70%);
animation: cersai-pulse 2s infinite;
}
}
@keyframes cersai-pulse {
0% { box-shadow: 0 0 0 0 rgba(159, 225, 203, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(159, 225, 203, 0); }
100% { box-shadow: 0 0 0 0 rgba(159, 225, 203, 0); }
0% {
box-shadow: 0 0 0 0 rgb(159 225 203 / 70%);
}
70% {
box-shadow: 0 0 0 10px rgb(159 225 203 / 0%);
}
100% {
box-shadow: 0 0 0 0 rgb(159 225 203 / 0%);
}
}
/* ===================== Client info grid ===================== */
@@ -218,7 +235,7 @@
&:hover {
border-color: #c6dafc;
box-shadow: 0 2px 8px rgba(26, 115, 232, 0.06);
box-shadow: 0 2px 8px rgb(26 115 232 / 6%);
}
}
@@ -231,9 +248,19 @@
flex-direction: column;
align-items: flex-start;
&__left { width: 100%; }
&__icon { width: 52px; height: 52px; font-size: 22px; }
&__ckyc-number { font-size: 20px; }
&__left {
width: 100%;
}
&__icon {
width: 52px;
height: 52px;
font-size: 22px;
}
&__ckyc-number {
font-size: 20px;
}
}
}
@@ -253,7 +280,10 @@
align-items: center;
gap: 6px;
fa-icon { color: #1a73e8; font-size: 11px; }
fa-icon {
color: #1a73e8;
font-size: 11px;
}
}
.ckyc-info-value {
@@ -277,7 +307,9 @@
}
}
.ckyc-info-hint { display: none; }
.ckyc-info-hint {
display: none;
}
.ckyc-section-title {
margin: 24px 0 12px;
@@ -296,14 +328,16 @@
border-radius: 12px !important;
border: 1px solid #e8eaed;
box-shadow: none !important;
transition: border-color 0.15s, box-shadow 0.15s;
transition:
border-color 0.15s,
box-shadow 0.15s;
display: flex;
flex-direction: column;
min-height: 240px;
&:hover {
border-color: #c6dafc;
box-shadow: 0 2px 10px rgba(26, 115, 232, 0.06) !important;
box-shadow: 0 2px 10px rgb(26 115 232 / 6%) !important;
}
.mat-mdc-card-content {
@@ -336,7 +370,7 @@
display: flex !important;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(26, 115, 232, 0.25);
box-shadow: 0 2px 6px rgb(26 115 232 / 25%);
flex-shrink: 0;
}
@@ -426,7 +460,9 @@
font-size: 12px;
margin-bottom: 14px;
mat-progress-spinner { display: inline-block; }
mat-progress-spinner {
display: inline-block;
}
}
.ckyc-existing {
@@ -494,7 +530,10 @@
font-weight: 600;
}
fa-icon { color: #1d9e75; font-size: 12px; }
fa-icon {
color: #1d9e75;
font-size: 12px;
}
}
&__key {
@@ -528,7 +567,7 @@
background: #fff;
object-fit: cover;
align-self: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
box-shadow: 0 2px 8px rgb(0 0 0 / 6%);
&--wide {
width: 200px;
@@ -550,7 +589,10 @@
padding: 0 12px !important;
letter-spacing: 0.2px;
fa-icon { margin-right: 6px; font-size: 11px; }
fa-icon {
margin-right: 6px;
font-size: 11px;
}
&:hover {
background: #e1f5ee !important;
@@ -176,7 +176,11 @@ export class CkycTabComponent implements OnInit, OnDestroy {
private formatJavaDate(value: any): string {
if (!value) return '';
if (Array.isArray(value) && value.length >= 3) {
const [y, m, d] = value;
const [
y,
m,
d
] = value;
return `${String(d).padStart(2, '0')}-${String(m).padStart(2, '0')}-${y}`;
}
if (typeof value === 'string') return value;
@@ -236,13 +240,9 @@ export class CkycTabComponent implements OnInit, OnDestroy {
// 3 & 4. ID proof + Address proof — client identifiers classified via proof-types
const idProofIds = new Set<string>((proofTypes?.idProofs || []).map((p: any) => String(p.id)));
const addressProofIds = new Set<string>(
(proofTypes?.addressProofs || []).map((p: any) => String(p.id))
);
const addressProofIds = new Set<string>((proofTypes?.addressProofs || []).map((p: any) => String(p.id)));
const idList = Array.isArray(identifiers)
? identifiers
: (identifiers as any)?.pageItems || [];
const idList = Array.isArray(identifiers) ? identifiers : (identifiers as any)?.pageItems || [];
for (const identity of idList) {
const docTypeId = String(identity?.documentType?.id ?? '');
@@ -10,29 +10,36 @@
<div class="cv-top">
<!-- Avatar -->
<div class="cv-avatar">
<img class="cv-avatar__img"
[src]="clientImage ? clientImage : 'assets/images/user_placeholder.png'" />
<img class="cv-avatar__img" [src]="clientImage ? clientImage : 'assets/images/user_placeholder.png'" />
<div class="cv-avatar__actions">
<button class="cv-avatar__btn"
<button
class="cv-avatar__btn"
(click)="doAction('Upload Image')"
matTooltip="{{ 'tooltips.Upload Image' | translate }}"
*mifosxHasPermission="'CREATE_CLIENTIMAGE'">
*mifosxHasPermission="'CREATE_CLIENTIMAGE'"
>
<i class="fa fa-upload"></i>
</button>
<button class="cv-avatar__btn"
<button
class="cv-avatar__btn"
(click)="doAction('Capture Image')"
matTooltip="{{ 'tooltips.Capture Image' | translate }}"
*mifosxHasPermission="'CREATE_CLIENTIMAGE'">
*mifosxHasPermission="'CREATE_CLIENTIMAGE'"
>
<i class="fa fa-camera"></i>
</button>
<button class="cv-avatar__btn cv-avatar__btn--danger"
<button
class="cv-avatar__btn cv-avatar__btn--danger"
(click)="doAction('Delete Image')"
matTooltip="{{ 'tooltips.Delete Image' | translate }}"
*mifosxHasPermission="'DELETE_CLIENTIMAGE'">
*mifosxHasPermission="'DELETE_CLIENTIMAGE'"
>
<i class="fa fa-trash"></i>
</button>
</div>
<span class="cv-avatar__sig" (click)="doAction('View Signature')">{{ 'labels.inputs.View Signature' | translate }}</span>
<span class="cv-avatar__sig" (click)="doAction('View Signature')">{{
'labels.inputs.View Signature' | translate
}}</span>
</div>
<!-- Info -->
@@ -40,12 +47,19 @@
<!-- Name + Status + Menu -->
<div class="cv-name-row">
<div class="cv-name">
<i class="fa fa-stop cv-name__dot" [ngClass]="clientViewData.status.code | statusLookup"
matTooltip="{{ 'labels.status.' + clientViewData.status.value | translate }}"></i>
<i
class="fa fa-stop cv-name__dot"
[ngClass]="clientViewData.status.code | statusLookup"
matTooltip="{{ 'labels.status.' + clientViewData.status.value | translate }}"
></i>
<span class="cv-name__text">
<mifosx-entity-name
[entityName]="complianceHideClientData ? maskName(clientViewData.displayName) : clientViewData.displayName"
[display]="'right'" [hideCopy]="complianceHideClientData"></mifosx-entity-name>
[entityName]="
complianceHideClientData ? maskName(clientViewData.displayName) : clientViewData.displayName
"
[display]="'right'"
[hideCopy]="complianceHideClientData"
></mifosx-entity-name>
</span>
<span class="cv-name__status">
{{ 'labels.status.' + clientViewData.status.value | translate }}
@@ -77,8 +91,12 @@
<span class="cv-id__label">{{ 'labels.inputs.External Id' | translate }}</span>
<span class="cv-id__val">
<mifosx-external-identifier
[externalId]="complianceHideClientData ? maskValue(clientViewData.externalId) : clientViewData.externalId"
[completed]="true" [hideCopy]="complianceHideClientData"></mifosx-external-identifier>
[externalId]="
complianceHideClientData ? maskValue(clientViewData.externalId) : clientViewData.externalId
"
[completed]="true"
[hideCopy]="complianceHideClientData"
></mifosx-external-identifier>
</span>
</div>
}
@@ -118,7 +136,9 @@
<div class="cv-cell__value">
<mifosx-external-identifier
[externalId]="complianceHideClientData ? maskValue(clientViewData.mobileNo) : clientViewData.mobileNo"
[completed]="true" [hideCopy]="complianceHideClientData"></mifosx-external-identifier>
[completed]="true"
[hideCopy]="complianceHideClientData"
></mifosx-external-identifier>
</div>
</div>
}
@@ -127,8 +147,12 @@
<div class="cv-cell__label">{{ 'labels.inputs.Email' | translate }}</div>
<div class="cv-cell__value">
<mifosx-external-identifier
[externalId]="complianceHideClientData ? maskEmail(clientViewData.emailAddress) : clientViewData.emailAddress"
[completed]="true" [hideCopy]="complianceHideClientData"></mifosx-external-identifier>
[externalId]="
complianceHideClientData ? maskEmail(clientViewData.emailAddress) : clientViewData.emailAddress
"
[completed]="true"
[hideCopy]="complianceHideClientData"
></mifosx-external-identifier>
</div>
</div>
}
@@ -148,10 +172,26 @@
<div class="cv-cell">
<div class="cv-cell__label">DIP Status</div>
<div class="cv-cell__value">
<span class="cv-badge"
[class.cv-badge--green]="clientViewData.dipVerified === true || clientViewData.dipVerified === 'true' || clientViewData.dipVerified === 1"
[class.cv-badge--yellow]="clientViewData.dipVerified !== true && clientViewData.dipVerified !== 'true' && clientViewData.dipVerified !== 1">
{{ (clientViewData.dipVerified === true || clientViewData.dipVerified === 'true' || clientViewData.dipVerified === 1) ? 'Verified' : 'Pending' }}
<span
class="cv-badge"
[class.cv-badge--green]="
clientViewData.dipVerified === true ||
clientViewData.dipVerified === 'true' ||
clientViewData.dipVerified === 1
"
[class.cv-badge--yellow]="
clientViewData.dipVerified !== true &&
clientViewData.dipVerified !== 'true' &&
clientViewData.dipVerified !== 1
"
>
{{
clientViewData.dipVerified === true ||
clientViewData.dipVerified === 'true' ||
clientViewData.dipVerified === 1
? 'Verified'
: 'Pending'
}}
</span>
</div>
</div>
@@ -160,10 +200,26 @@
<div class="cv-cell">
<div class="cv-cell__label">Sanctions Screening</div>
<div class="cv-cell__value">
<span class="cv-badge"
[class.cv-badge--green]="clientViewData.nameVerified === true || clientViewData.nameVerified === 'true' || clientViewData.nameVerified === 1"
[class.cv-badge--red]="clientViewData.nameVerified === false || clientViewData.nameVerified === 'false' || clientViewData.nameVerified === 0">
{{ (clientViewData.nameVerified === true || clientViewData.nameVerified === 'true' || clientViewData.nameVerified === 1) ? 'Clear' : 'Pending' }}
<span
class="cv-badge"
[class.cv-badge--green]="
clientViewData.nameVerified === true ||
clientViewData.nameVerified === 'true' ||
clientViewData.nameVerified === 1
"
[class.cv-badge--red]="
clientViewData.nameVerified === false ||
clientViewData.nameVerified === 'false' ||
clientViewData.nameVerified === 0
"
>
{{
clientViewData.nameVerified === true ||
clientViewData.nameVerified === 'true' ||
clientViewData.nameVerified === 1
? 'Clear'
: 'Pending'
}}
</span>
</div>
</div>
@@ -206,244 +262,299 @@
</div>
</div>
<mat-menu #clientMenu="matMenu">
<button mat-menu-item [routerLink]="['edit']" *mifosxHasPermission="'UPDATE_CLIENT'">
<mat-menu #clientMenu="matMenu">
<button mat-menu-item [routerLink]="['edit']" *mifosxHasPermission="'UPDATE_CLIENT'">
<mat-icon matListIcon>
<fa-icon icon="edit" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Edit' | translate }}</span>
</button>
@if (isActive()) {
<button mat-menu-item [matMenuTriggerFor]="accountApplications">
<mat-icon matListIcon>
<fa-icon icon="money-bill-alt" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Applications' | translate }}</span>
</button>
}
<button mat-menu-item [matMenuTriggerFor]="Actions">
<mat-icon matListIcon>
<fa-icon icon="tasks" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Actions' | translate }}</span>
</button>
@if (!clientViewData.staffId) {
<button mat-menu-item (click)="doAction('Assign Staff')">
<mat-icon matListIcon>
<fa-icon icon="user-tie" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Assign Staff' | translate }}</span>
</button>
}
@if (clientViewData.staffId) {
<span>
<button mat-menu-item *mifosxHasPermission="'UNASSIGNSTAFF_CLIENT'" (click)="doAction('Unassign Staff')">
<mat-icon matListIcon>
<fa-icon icon="edit" size="sm"></fa-icon>
<fa-icon icon="user-tie" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Edit' | translate }}</span>
<span>{{ 'labels.buttons.Unassign Staff' | translate }}</span>
</button>
</span>
}
@if (isActive()) {
<button mat-menu-item [matMenuTriggerFor]="accountApplications">
<mat-icon matListIcon>
<fa-icon icon="money-bill-alt" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Applications' | translate }}</span>
</button>
}
<button mat-menu-item [matMenuTriggerFor]="More">{{ 'labels.buttons.More' | translate }}</button>
</mat-menu>
<button mat-menu-item [matMenuTriggerFor]="Actions">
<mat-icon matListIcon>
<fa-icon icon="tasks" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Actions' | translate }}</span>
</button>
<mat-menu #accountApplications="matMenu">
<button mat-menu-item *mifosxHasPermission="'CREATE_LOAN'" [routerLink]="['loans-accounts', 'create']">
{{ 'labels.buttons.New Loan Account' | translate }}
</button>
<button mat-menu-item *mifosxHasPermission="'CREATE_SAVINGSACCOUNT'" [routerLink]="['savings-accounts', 'create']">
{{ 'labels.buttons.New Savings Account' | translate }}
</button>
<button mat-menu-item *mifosxHasPermission="'CREATE_SHAREACCOUNT'" [routerLink]="['shares-accounts', 'create']">
{{ 'labels.buttons.New Share Account' | translate }}
</button>
<button
mat-menu-item
*mifosxHasPermission="'CREATE_RECURRINGDEPOSITACCOUNT'"
[routerLink]="['recurring-deposits-accounts', 'create-recurring-deposits-account']"
>
{{ 'labels.buttons.New Recurring Deposit Account' | translate }}
</button>
<button
mat-menu-item
*mifosxHasPermission="'CREATE_FIXEDDEPOSITACCOUNT'"
[routerLink]="['fixed-deposits-accounts', 'create']"
>
{{ 'labels.buttons.New Fixed Deposits Account' | translate }}
</button>
<button mat-menu-item *mifosxHasPermission="'CREATE_CURRENTACCOUNT'" [routerLink]="['current-accounts', 'create']">
{{ 'labels.buttons.New Current Account' | translate }}
</button>
</mat-menu>
@if (!clientViewData.staffId) {
<button mat-menu-item (click)="doAction('Assign Staff')">
<mat-icon matListIcon>
<fa-icon icon="user-tie" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Assign Staff' | translate }}</span>
</button>
}
<mat-menu #Actions="matMenu">
<button mat-menu-item (click)="doAction('Close')">{{ 'labels.buttons.Close' | translate }}</button>
<button mat-menu-item (click)="doAction('Transfer Client')">
{{ 'labels.buttons.Transfer Client' | translate }}
</button>
@if (clientViewData.status.value === 'Pending') {
<span
><button mat-menu-item (click)="doAction('Activate')">
{{ 'labels.buttons.Activate' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Pending') {
<span
><button mat-menu-item (click)="doAction('Withdraw')">
{{ 'labels.buttons.Withdraw' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Pending') {
<span
><button mat-menu-item (click)="doAction('Reject')">{{ 'labels.buttons.Reject' | translate }}</button></span
>
}
@if (clientViewData.status.value === 'Pending') {
<span
><button mat-menu-item *mifosxHasPermission="'DELETE_CLIENT'" (click)="doAction('Delete')">
{{ 'labels.buttons.Delete' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Closed') {
<span
><button mat-menu-item (click)="doAction('Reactivate')">
{{ 'labels.buttons.Reactivate' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Rejected') {
<span
><button mat-menu-item (click)="doAction('Undo Rejection')">
{{ 'labels.buttons.Undo Rejection' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Transfer in progress') {
<span
><button mat-menu-item (click)="doAction('Undo Transfer')">
{{ 'labels.buttons.Undo Transfer' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Transfer in progress') {
<span
><button mat-menu-item (click)="doAction('Accept Transfer')">
{{ 'labels.buttons.Accept Transfer' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Transfer in progress') {
<span
><button mat-menu-item (click)="doAction('Reject Transfer')">
{{ 'labels.buttons.Reject transfer' | translate }}
</button></span
>
}
</mat-menu>
@if (clientViewData.staffId) {
<span>
<button mat-menu-item *mifosxHasPermission="'UNASSIGNSTAFF_CLIENT'" (click)="doAction('Unassign Staff')">
<mat-icon matListIcon>
<fa-icon icon="user-tie" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Unassign Staff' | translate }}</span>
</button>
</span>
}
<button mat-menu-item [matMenuTriggerFor]="More">{{ 'labels.buttons.More' | translate }}</button>
</mat-menu>
<mat-menu #accountApplications="matMenu">
<button mat-menu-item *mifosxHasPermission="'CREATE_LOAN'" [routerLink]="['loans-accounts', 'create']">
{{ 'labels.buttons.New Loan Account' | translate }}
</button>
<mat-menu #More="matMenu">
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
{{ 'labels.buttons.Add Charge' | translate }}
</button>
<button mat-menu-item (click)="doAction('Create Collateral')">
{{ 'labels.buttons.Create Collateral' | translate }}
</button>
<button mat-menu-item (click)="doAction('Survey')" [disabled]="true">
{{ 'labels.buttons.Survey' | translate }}
</button>
<button
mat-menu-item
(click)="doAction('Update Default Savings')"
*mifosxHasPermission="'UPDATESAVINGSACCOUNT_CLIENT'"
>
{{ 'labels.buttons.Update Default Savings' | translate }}
</button>
<button mat-menu-item (click)="doAction('Upload Signature')" *mifosxHasPermission="'CREATE_CLIENTIMAGE'">
{{ 'labels.buttons.Upload Signature' | translate }}
</button>
<button mat-menu-item (click)="doAction('Delete Signature')" *mifosxHasPermission="'DELETE_CLIENTIMAGE'">
{{ 'labels.buttons.Delete Signature' | translate }}
</button>
<button mat-menu-item (click)="doAction('Client Screen Reports')">
{{ 'labels.buttons.Client Screen Reports' | translate }}
</button>
<button
mat-menu-item
(click)="doAction('Create Standing Instructions')"
*mifosxHasPermission="'CREATE_STANDINGINSTRUCTION'"
>
{{ 'labels.buttons.Create Standing Instructions' | translate }}
</button>
@if (clientViewData.status.value !== 'Transfer on hold') {
<span>
<button
mat-menu-item
*mifosxHasPermission="'CREATE_SAVINGSACCOUNT'"
[routerLink]="['savings-accounts', 'create']"
(click)="doAction('View Standing Instructions')"
*mifosxHasPermission="'READ_STANDINGINSTRUCTION'"
>
{{ 'labels.buttons.New Savings Account' | translate }}
{{ 'labels.buttons.View Standing Instructions' | translate }}
</button>
<button mat-menu-item *mifosxHasPermission="'CREATE_SHAREACCOUNT'" [routerLink]="['shares-accounts', 'create']">
{{ 'labels.buttons.New Share Account' | translate }}
</button>
<button
mat-menu-item
*mifosxHasPermission="'CREATE_RECURRINGDEPOSITACCOUNT'"
[routerLink]="['recurring-deposits-accounts', 'create-recurring-deposits-account']"
>
{{ 'labels.buttons.New Recurring Deposit Account' | translate }}
</button>
<button
mat-menu-item
*mifosxHasPermission="'CREATE_FIXEDDEPOSITACCOUNT'"
[routerLink]="['fixed-deposits-accounts', 'create']"
>
{{ 'labels.buttons.New Fixed Deposits Account' | translate }}
</button>
<button
mat-menu-item
*mifosxHasPermission="'CREATE_CURRENTACCOUNT'"
[routerLink]="['current-accounts', 'create']"
>
{{ 'labels.buttons.New Current Account' | translate }}
</button>
</mat-menu>
<mat-menu #Actions="matMenu">
<button mat-menu-item (click)="doAction('Close')">{{ 'labels.buttons.Close' | translate }}</button>
<button mat-menu-item (click)="doAction('Transfer Client')">
{{ 'labels.buttons.Transfer Client' | translate }}
</button>
@if (clientViewData.status.value === 'Pending') {
<span
><button mat-menu-item (click)="doAction('Activate')">
{{ 'labels.buttons.Activate' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Pending') {
<span
><button mat-menu-item (click)="doAction('Withdraw')">
{{ 'labels.buttons.Withdraw' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Pending') {
<span
><button mat-menu-item (click)="doAction('Reject')">{{ 'labels.buttons.Reject' | translate }}</button></span
>
}
@if (clientViewData.status.value === 'Pending') {
<span
><button mat-menu-item *mifosxHasPermission="'DELETE_CLIENT'" (click)="doAction('Delete')">
{{ 'labels.buttons.Delete' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Closed') {
<span
><button mat-menu-item (click)="doAction('Reactivate')">
{{ 'labels.buttons.Reactivate' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Rejected') {
<span
><button mat-menu-item (click)="doAction('Undo Rejection')">
{{ 'labels.buttons.Undo Rejection' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Transfer in progress') {
<span
><button mat-menu-item (click)="doAction('Undo Transfer')">
{{ 'labels.buttons.Undo Transfer' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Transfer in progress') {
<span
><button mat-menu-item (click)="doAction('Accept Transfer')">
{{ 'labels.buttons.Accept Transfer' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Transfer in progress') {
<span
><button mat-menu-item (click)="doAction('Reject Transfer')">
{{ 'labels.buttons.Reject transfer' | translate }}
</button></span
>
}
</mat-menu>
<mat-menu #More="matMenu">
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
{{ 'labels.buttons.Add Charge' | translate }}
</button>
<button mat-menu-item (click)="doAction('Create Collateral')">
{{ 'labels.buttons.Create Collateral' | translate }}
</button>
<button mat-menu-item (click)="doAction('Survey')" [disabled]="true">
{{ 'labels.buttons.Survey' | translate }}
</button>
<button
mat-menu-item
(click)="doAction('Update Default Savings')"
*mifosxHasPermission="'UPDATESAVINGSACCOUNT_CLIENT'"
>
{{ 'labels.buttons.Update Default Savings' | translate }}
</button>
<button mat-menu-item (click)="doAction('Upload Signature')" *mifosxHasPermission="'CREATE_CLIENTIMAGE'">
{{ 'labels.buttons.Upload Signature' | translate }}
</button>
<button mat-menu-item (click)="doAction('Delete Signature')" *mifosxHasPermission="'DELETE_CLIENTIMAGE'">
{{ 'labels.buttons.Delete Signature' | translate }}
</button>
<button mat-menu-item (click)="doAction('Client Screen Reports')">
{{ 'labels.buttons.Client Screen Reports' | translate }}
</button>
<button
mat-menu-item
(click)="doAction('Create Standing Instructions')"
*mifosxHasPermission="'CREATE_STANDINGINSTRUCTION'"
>
{{ 'labels.buttons.Create Standing Instructions' | translate }}
</button>
@if (clientViewData.status.value !== 'Transfer on hold') {
<span>
<button
mat-menu-item
(click)="doAction('View Standing Instructions')"
*mifosxHasPermission="'READ_STANDINGINSTRUCTION'"
>
{{ 'labels.buttons.View Standing Instructions' | translate }}
</button>
</span>
}
</mat-menu>
</span>
}
</mat-menu>
<div>
<nav mat-tab-nav-bar class="cv-tabs" [tabPanel]="tabPanel">
<a mat-tab-link [routerLink]="['./general']" routerLinkActive #general="routerLinkActive" [active]="general.isActive">
<a
mat-tab-link
[routerLink]="['./general']"
routerLinkActive
#general="routerLinkActive"
[active]="general.isActive"
>
<i class="fa fa-tachometer cv-tab-icon"></i> {{ 'labels.inputs.General' | translate }}
</a>
<a mat-tab-link [routerLink]="['./personal-data']" routerLinkActive #personalData="routerLinkActive" [active]="personalData.isActive">
<a
mat-tab-link
[routerLink]="['./personal-data']"
routerLinkActive
#personalData="routerLinkActive"
[active]="personalData.isActive"
>
<i class="fa fa-user cv-tab-icon"></i> {{ 'labels.inputs.Personal Data' | translate }}
</a>
<a mat-tab-link [routerLink]="['./address']" routerLinkActive #address="routerLinkActive" [active]="address.isActive">
<a
mat-tab-link
[routerLink]="['./address']"
routerLinkActive
#address="routerLinkActive"
[active]="address.isActive"
>
<i class="fa fa-map-marker cv-tab-icon"></i> {{ 'labels.inputs.Address' | translate }}
</a>
@if (clientViewData?.legalForm?.id !== 2) {
<a mat-tab-link [routerLink]="['./family-members']" routerLinkActive #familyMembers="routerLinkActive" [active]="familyMembers.isActive">
<a
mat-tab-link
[routerLink]="['./family-members']"
routerLinkActive
#familyMembers="routerLinkActive"
[active]="familyMembers.isActive"
>
<i class="fa fa-users cv-tab-icon"></i> Family Members
</a>
}
<a mat-tab-link [routerLink]="['./nominees']" routerLinkActive #nomineesTab="routerLinkActive" [active]="nomineesTab.isActive">
<a
mat-tab-link
[routerLink]="['./nominees']"
routerLinkActive
#nomineesTab="routerLinkActive"
[active]="nomineesTab.isActive"
>
<i class="fa fa-shield cv-tab-icon"></i> Nominee
</a>
<a mat-tab-link [routerLink]="['./identities']" routerLinkActive #identities="routerLinkActive" [active]="identities.isActive" *mifosxHasPermission="'READ_CLIENTIDENTIFIER'">
<a
mat-tab-link
[routerLink]="['./identities']"
routerLinkActive
#identities="routerLinkActive"
[active]="identities.isActive"
*mifosxHasPermission="'READ_CLIENTIDENTIFIER'"
>
<i class="fa fa-id-card cv-tab-icon"></i> {{ 'labels.inputs.Identities' | translate }}
</a>
<a mat-tab-link [routerLink]="['./kyc']" routerLinkActive #kyc="routerLinkActive" [active]="kyc.isActive">
<i class="fa fa-check-circle cv-tab-icon"></i> {{ 'labels.inputs.KYC' | translate }}
</a>
@if (clientViewData.legalForm?.id !== 1) {
<a mat-tab-link [routerLink]="['./entity-classification']" routerLinkActive #ecTab="routerLinkActive" [active]="ecTab.isActive">
<a
mat-tab-link
[routerLink]="['./entity-classification']"
routerLinkActive
#ecTab="routerLinkActive"
[active]="ecTab.isActive"
>
<i class="fa fa-building cv-tab-icon"></i> Scheme &amp; Sector
</a>
}
<a mat-tab-link [routerLink]="['./ckyc']" routerLinkActive #ckyc="routerLinkActive" [active]="ckyc.isActive">
<i class="fa fa-shield cv-tab-icon"></i> {{ 'labels.inputs.CKYC' | translate }}
</a>
<a mat-tab-link [routerLink]="['./form60']" routerLinkActive #form60="routerLinkActive" [active]="form60.isActive">
<a
mat-tab-link
[routerLink]="['./form60']"
routerLinkActive
#form60="routerLinkActive"
[active]="form60.isActive"
>
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
</a>
<a mat-tab-link [routerLink]="['./documents']" routerLinkActive #documents="routerLinkActive" [active]="documents.isActive" *mifosxHasPermission="'READ_DOCUMENT'">
<a
mat-tab-link
[routerLink]="['./documents']"
routerLinkActive
#documents="routerLinkActive"
[active]="documents.isActive"
*mifosxHasPermission="'READ_DOCUMENT'"
>
<i class="fa fa-folder-open cv-tab-icon"></i> {{ 'labels.inputs.Documents' | translate }}
</a>
<a mat-tab-link [routerLink]="['./notes']" routerLinkActive #notes="routerLinkActive" [active]="notes.isActive" *mifosxHasPermission="'READ_CLIENTNOTE'">
<a
mat-tab-link
[routerLink]="['./notes']"
routerLinkActive
#notes="routerLinkActive"
[active]="notes.isActive"
*mifosxHasPermission="'READ_CLIENTNOTE'"
>
<i class="fa fa-sticky-note cv-tab-icon"></i> {{ 'labels.inputs.Notes' | translate }}
</a>
@for (clientDatatable of clientDatatables; track clientDatatable) {
@@ -43,10 +43,17 @@ $accent: #0ea5e9;
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(108deg, transparent 0, transparent 80px, rgb(255 255 255 / 2.5%) 80px, rgb(255 255 255 / 2.5%) 160px);
background: repeating-linear-gradient(
108deg,
transparent 0,
transparent 80px,
rgb(255 255 255 / 2.5%) 80px,
rgb(255 255 255 / 2.5%) 160px
);
pointer-events: none;
}
/* stylelint-disable-next-line selector-max-universal */
> * {
position: relative;
z-index: 1;
@@ -90,7 +97,9 @@ $accent: #0ea5e9;
cursor: pointer;
transition: all 0.12s;
i { font-size: 10px; }
i {
font-size: 10px;
}
&:hover {
background: rgb(255 255 255 / 20%) !important;
@@ -109,12 +118,18 @@ $accent: #0ea5e9;
cursor: pointer;
font-weight: 500;
&:hover { color: #fff; text-decoration: underline; }
&:hover {
color: #fff;
text-decoration: underline;
}
}
}
// ── Info column ──
.cv-info { flex: 1; min-width: 0; }
.cv-info {
flex: 1;
min-width: 0;
}
// ── Name row ──
.cv-name-row {
@@ -129,7 +144,9 @@ $accent: #0ea5e9;
align-items: center;
gap: 10px;
&__dot { font-size: 10px; }
&__dot {
font-size: 10px;
}
&__text {
font-size: 20px;
@@ -161,7 +178,10 @@ $accent: #0ea5e9;
border-radius: 5px !important;
line-height: 28px !important;
&:hover { background: rgb(255 255 255 / 10%); color: #fff !important; }
&:hover {
background: rgb(255 255 255 / 10%);
color: #fff !important;
}
.actions-menu {
font-size: 14px !important;
@@ -250,12 +270,25 @@ $accent: #0ea5e9;
border-radius: 10px;
letter-spacing: 0.3px;
&--green { background: rgb(16 185 129 / 20%); color: #6ee7b7; }
&--yellow { background: rgb(245 158 11 / 20%); color: #fcd34d; }
&--red { background: rgb(239 68 68 / 20%); color: #fca5a5; }
&--green {
background: rgb(16 185 129 / 20%);
color: #6ee7b7;
}
&--yellow {
background: rgb(245 158 11 / 20%);
color: #fcd34d;
}
&--red {
background: rgb(239 68 68 / 20%);
color: #fca5a5;
}
}
.actions-menu { color: #fff !important; }
.actions-menu {
color: #fff !important;
}
// ── Tab bar ──
.cv-tabs {
@@ -279,7 +312,8 @@ $accent: #0ea5e9;
}
// Fix copy icons on dark background
.cv-top, .cv-grid {
.cv-top,
.cv-grid {
::ng-deep {
mifosx-external-identifier,
mifosx-account-number {
@@ -350,16 +384,33 @@ $accent: #0ea5e9;
// ── Responsive ──
@media (width <= 1024px) {
.cv-grid { grid-template-columns: repeat(3, 1fr); }
.cv-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (width <= 768px) {
.cv-top { flex-direction: column; align-items: center; text-align: center; }
.cv-name-row { justify-content: center; }
.cv-ids { justify-content: center; }
.cv-grid { grid-template-columns: repeat(2, 1fr); }
.cv-top {
flex-direction: column;
align-items: center;
text-align: center;
}
.cv-name-row {
justify-content: center;
}
.cv-ids {
justify-content: center;
}
.cv-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (width <= 480px) {
.cv-grid { grid-template-columns: 1fr; }
.cv-grid {
grid-template-columns: 1fr;
}
}
@@ -24,5 +24,7 @@
</mat-dialog-content>
<mat-dialog-actions class="icd-actions">
<button mat-button type="button" (click)="cancel()">Cancel</button>
<button mat-flat-button color="primary" type="button" (click)="save()" [disabled]="!croppedBlob">Use cropped image</button>
<button mat-flat-button color="primary" type="button" (click)="save()" [disabled]="!croppedBlob">
Use cropped image
</button>
</mat-dialog-actions>
@@ -38,6 +38,7 @@
max-height: 60vh;
overflow: hidden;
/* stylelint-disable-next-line selector-type-no-unknown */
::ng-deep image-cropper {
display: block;
max-height: 56vh;
@@ -30,17 +30,14 @@
<strong>KYC Document Upload</strong>
<p>
Upload KYC documents as per RBI guidelines. Accepted:
<i class="fa fa-file-image-o"></i> JPG,
<i class="fa fa-file-image-o"></i> PNG,
<i class="fa fa-file-image-o"></i> JPG, <i class="fa fa-file-image-o"></i> PNG,
<i class="fa fa-file-pdf-o"></i> PDF — max 5MB each.
</p>
</div>
</div>
@if (loadingProofs) {
<div class="kyc-loading">
<i class="fa fa-spinner fa-spin"></i> Loading document types…
</div>
<div class="kyc-loading"><i class="fa fa-spinner fa-spin"></i> Loading document types…</div>
}
<form [formGroup]="uploadDocumentForm" class="kyc-form">
@@ -51,12 +48,8 @@
<i class="fa fa-user-circle-o"></i>
</div>
<div class="kyc-section__titles">
<p class="kyc-section__title">
<i class="fa fa-id-badge"></i> Identity proof
</p>
<p class="kyc-section__sub">
Select document type — upload requirements will update automatically
</p>
<p class="kyc-section__title"><i class="fa fa-id-badge"></i> Identity proof</p>
<p class="kyc-section__sub">Select document type — upload requirements will update automatically</p>
</div>
</div>
@@ -73,9 +66,7 @@
}
<div class="kyc-field kyc-mb-sm">
<label class="kyc-label">
<i class="fa fa-file-text-o"></i> Document type
</label>
<label class="kyc-label"> <i class="fa fa-file-text-o"></i> Document type </label>
<div class="kyc-select-wrap">
<i class="fa fa-id-card kyc-select-wrap__icon"></i>
<select
@@ -94,9 +85,7 @@
@if (selectedIdProof) {
<div class="kyc-field kyc-mb-sm">
<label class="kyc-label">
<i class="fa fa-hashtag"></i> {{ selectedIdProof.documentKeyLabel }}
</label>
<label class="kyc-label"> <i class="fa fa-hashtag"></i> {{ selectedIdProof.documentKeyLabel }} </label>
<div class="kyc-input-wrap">
<i class="fa fa-pencil kyc-input-wrap__icon"></i>
<input
@@ -122,11 +111,9 @@
<div class="kyc-upload-label">
<span class="kyc-label">
<i class="fa fa-file-image-o"></i>
{{ selectedIdProof.sides === 2 ? 'Front side' : (selectedIdProof.name + ' (single copy)') }}
</span>
<span class="kyc-tag kyc-tag--req">
<i class="fa fa-asterisk"></i> Required
{{ selectedIdProof.sides === 2 ? 'Front side' : selectedIdProof.name + ' (single copy)' }}
</span>
<span class="kyc-tag kyc-tag--req"> <i class="fa fa-asterisk"></i> Required </span>
</div>
<label
class="kyc-dropzone"
@@ -142,15 +129,12 @@
@if (idFiles.front) {
<i class="fa fa-check-circle kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">File selected</p>
<p class="kyc-dropzone__name">
<i class="fa fa-paperclip"></i> {{ getFileName('id', 'front') }}
</p>
<p class="kyc-dropzone__name"><i class="fa fa-paperclip"></i> {{ getFileName('id', 'front') }}</p>
} @else {
<i class="fa fa-cloud-upload kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">Click to upload</p>
<p class="kyc-dropzone__hint">
<i class="fa fa-file-image-o"></i> JPG ·
<i class="fa fa-file-image-o"></i> PNG ·
<i class="fa fa-file-image-o"></i> JPG · <i class="fa fa-file-image-o"></i> PNG ·
<i class="fa fa-file-pdf-o"></i> PDF · max 5MB
</p>
}
@@ -160,12 +144,8 @@
@if (selectedIdProof.sides === 2) {
<div class="kyc-field">
<div class="kyc-upload-label">
<span class="kyc-label">
<i class="fa fa-file-image-o"></i> Back side
</span>
<span class="kyc-tag kyc-tag--req">
<i class="fa fa-asterisk"></i> Required
</span>
<span class="kyc-label"> <i class="fa fa-file-image-o"></i> Back side </span>
<span class="kyc-tag kyc-tag--req"> <i class="fa fa-asterisk"></i> Required </span>
</div>
<label
class="kyc-dropzone"
@@ -181,15 +161,12 @@
@if (idFiles.back) {
<i class="fa fa-check-circle kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">File selected</p>
<p class="kyc-dropzone__name">
<i class="fa fa-paperclip"></i> {{ getFileName('id', 'back') }}
</p>
<p class="kyc-dropzone__name"><i class="fa fa-paperclip"></i> {{ getFileName('id', 'back') }}</p>
} @else {
<i class="fa fa-cloud-upload kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">Click to upload</p>
<p class="kyc-dropzone__hint">
<i class="fa fa-file-image-o"></i> JPG ·
<i class="fa fa-file-image-o"></i> PNG ·
<i class="fa fa-file-image-o"></i> JPG · <i class="fa fa-file-image-o"></i> PNG ·
<i class="fa fa-file-pdf-o"></i> PDF · max 5MB
</p>
}
@@ -231,12 +208,8 @@
<i class="fa fa-map-marker"></i>
</div>
<div class="kyc-section__titles">
<p class="kyc-section__title">
<i class="fa fa-home"></i> Address proof
</p>
<p class="kyc-section__sub">
Select document type — upload requirements will update automatically
</p>
<p class="kyc-section__title"><i class="fa fa-home"></i> Address proof</p>
<p class="kyc-section__sub">Select document type — upload requirements will update automatically</p>
</div>
</div>
@@ -258,14 +231,10 @@
}
<div class="kyc-field">
<label class="kyc-label">
<i class="fa fa-file-text-o"></i> Document type
</label>
<label class="kyc-label"> <i class="fa fa-file-text-o"></i> Document type </label>
<div class="kyc-chip-wrap">
@for (group of addressGroups; track group.label; let last = $last) {
<div class="kyc-chip-group-label">
<i class="fa fa-folder-open-o"></i> {{ group.label }}
</div>
<div class="kyc-chip-group-label"><i class="fa fa-folder-open-o"></i> {{ group.label }}</div>
<div class="kyc-chip-grid">
@for (p of group.items; track p.id) {
<span
@@ -305,11 +274,9 @@
<div class="kyc-upload-label">
<span class="kyc-label">
<i class="fa fa-file-image-o"></i>
{{ selectedAddrProof.sides === 2 ? 'Front side' : (selectedAddrProof.name + ' (single copy)') }}
</span>
<span class="kyc-tag kyc-tag--req">
<i class="fa fa-asterisk"></i> Required
{{ selectedAddrProof.sides === 2 ? 'Front side' : selectedAddrProof.name + ' (single copy)' }}
</span>
<span class="kyc-tag kyc-tag--req"> <i class="fa fa-asterisk"></i> Required </span>
</div>
<label
class="kyc-dropzone"
@@ -332,8 +299,7 @@
<i class="fa fa-cloud-upload kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">Click to upload</p>
<p class="kyc-dropzone__hint">
<i class="fa fa-file-image-o"></i> JPG ·
<i class="fa fa-file-image-o"></i> PNG ·
<i class="fa fa-file-image-o"></i> JPG · <i class="fa fa-file-image-o"></i> PNG ·
<i class="fa fa-file-pdf-o"></i> PDF · max 5MB
</p>
}
@@ -343,12 +309,8 @@
@if (selectedAddrProof.sides === 2) {
<div class="kyc-field">
<div class="kyc-upload-label">
<span class="kyc-label">
<i class="fa fa-file-image-o"></i> Back side
</span>
<span class="kyc-tag kyc-tag--req">
<i class="fa fa-asterisk"></i> Required
</span>
<span class="kyc-label"> <i class="fa fa-file-image-o"></i> Back side </span>
<span class="kyc-tag kyc-tag--req"> <i class="fa fa-asterisk"></i> Required </span>
</div>
<label
class="kyc-dropzone"
@@ -371,8 +333,7 @@
<i class="fa fa-cloud-upload kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">Click to upload</p>
<p class="kyc-dropzone__hint">
<i class="fa fa-file-image-o"></i> JPG ·
<i class="fa fa-file-image-o"></i> PNG ·
<i class="fa fa-file-image-o"></i> JPG · <i class="fa fa-file-image-o"></i> PNG ·
<i class="fa fa-file-pdf-o"></i> PDF · max 5MB
</p>
}
@@ -388,11 +349,7 @@
</label>
<div class="kyc-input-wrap kyc-input-wrap--date">
<i class="fa fa-calendar-o kyc-input-wrap__icon"></i>
<input
type="date"
class="kyc-input kyc-input--date"
formControlName="issueDate"
/>
<input type="date" class="kyc-input kyc-input--date" formControlName="issueDate" />
</div>
</div>
}
@@ -439,19 +396,21 @@
<div class="kyc-status-bar">
<span>
<span class="kyc-dot" [class.kyc-dot--done]="isIdComplete()">
@if (isIdComplete()) { <i class="fa fa-check"></i> }
@if (isIdComplete()) {
<i class="fa fa-check"></i>
}
</span>
<i class="fa fa-id-badge"></i> Identity proof
</span>
<span>
<span class="kyc-dot" [class.kyc-dot--done]="isAddressComplete()">
@if (isAddressComplete()) { <i class="fa fa-check"></i> }
@if (isAddressComplete()) {
<i class="fa fa-check"></i>
}
</span>
<i class="fa fa-home"></i> Address proof
</span>
<span class="kyc-status-hint">
<i class="fa fa-info-circle"></i> Fill in at least one section
</span>
<span class="kyc-status-hint"> <i class="fa fa-info-circle"></i> Fill in at least one section </span>
</div>
</form>
} @else {
@@ -488,13 +447,7 @@
<i class="fa fa-times fd-btn__icon"></i>
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-flat-button
color="primary"
[disabled]="!canSubmit"
(click)="submit()"
class="fd-btn fd-btn--submit"
>
<button mat-flat-button color="primary" [disabled]="!canSubmit" (click)="submit()" class="fd-btn fd-btn--submit">
<i class="fa fa-check fd-btn__icon"></i>
@if (documentIdentifier) {
{{ 'labels.buttons.Submit' | translate }}
@@ -304,8 +304,13 @@ $red-900: #993c1d;
border-color: $teal-50;
border-left-color: $teal-700;
> i:first-child { color: $teal-700; }
.kyc-prefill__value { color: $teal-900; }
> i:first-child {
color: $teal-700;
}
.kyc-prefill__value {
color: $teal-900;
}
}
}
@@ -363,15 +368,23 @@ $red-900: #993c1d;
transition: opacity 0.15s;
}
&:hover::-webkit-calendar-picker-indicator { opacity: 0.9; }
&:hover::-webkit-calendar-picker-indicator {
opacity: 0.9;
}
&::-webkit-datetime-edit {
color: $gray-900;
padding-top: 1px;
}
&::-webkit-datetime-edit-fields-wrapper { color: $gray-900; }
&::-webkit-datetime-edit-text { color: $gray-500; padding: 0 2px; }
&::-webkit-datetime-edit-fields-wrapper {
color: $gray-900;
}
&::-webkit-datetime-edit-text {
color: $gray-500;
padding: 0 2px;
}
&:invalid::-webkit-datetime-edit,
&:not(:focus):placeholder-shown::-webkit-datetime-edit {
@@ -398,9 +411,13 @@ $red-900: #993c1d;
font-family: inherit;
resize: vertical;
min-height: 60px;
transition: border-color 0.15s, background 0.15s;
transition:
border-color 0.15s,
background 0.15s;
&::placeholder { color: $gray-500; }
&::placeholder {
color: $gray-500;
}
&:focus {
outline: none;
@@ -414,7 +431,10 @@ $red-900: #993c1d;
color: $gray-500;
padding: 8px 0 14px;
i { margin-right: 6px; color: $primary; }
i {
margin-right: 6px;
color: $primary;
}
}
.kyc-form {
@@ -452,7 +472,10 @@ $red-900: #993c1d;
align-items: center;
gap: 8px;
i { color: $primary; font-size: 14px; }
i {
color: $primary;
font-size: 14px;
}
}
&__sub {
@@ -496,7 +519,9 @@ $red-900: #993c1d;
}
}
.kyc-mb-sm { margin-bottom: 12px; }
.kyc-mb-sm {
margin-bottom: 12px;
}
.kyc-field {
display: flex;
@@ -534,9 +559,13 @@ $red-900: #993c1d;
color: $gray-900;
font-size: 13px;
font-family: inherit;
transition: border-color 0.15s, background 0.15s;
transition:
border-color 0.15s,
background 0.15s;
&::placeholder { color: $gray-500; }
&::placeholder {
color: $gray-500;
}
&:focus {
outline: none;
@@ -553,7 +582,6 @@ $red-900: #993c1d;
.kyc-select {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='%235f6368' d='M0 0l5 6 5-6z'/></svg>");
background-repeat: no-repeat;
background-position: right 12px center;
@@ -570,7 +598,11 @@ $red-900: #993c1d;
line-height: 1.5;
margin-bottom: 12px;
i { font-size: 14px; flex-shrink: 0; margin-top: 1px; }
i {
font-size: 14px;
flex-shrink: 0;
margin-top: 1px;
}
&--blue {
background: $blue-50;
@@ -670,8 +702,13 @@ $red-900: #993c1d;
border-style: solid;
background: $teal-50;
.kyc-dropzone__icon { color: $teal-accent; }
.kyc-dropzone__text { color: $teal-900; }
.kyc-dropzone__icon {
color: $teal-accent;
}
.kyc-dropzone__text {
color: $teal-900;
}
}
&--req {
@@ -682,7 +719,9 @@ $red-900: #993c1d;
background: $red-100;
}
.kyc-dropzone__icon { color: $red-700; }
.kyc-dropzone__icon {
color: $red-700;
}
}
}
@@ -704,7 +743,10 @@ $red-900: #993c1d;
align-items: center;
gap: 6px;
i { color: $gray-500; font-size: 11px; }
i {
color: $gray-500;
font-size: 11px;
}
}
.kyc-chip-grid {
@@ -735,13 +777,18 @@ $red-900: #993c1d;
white-space: nowrap;
user-select: none;
i { font-size: 11px; color: $gray-500; }
i {
font-size: 11px;
color: $gray-500;
}
&:hover {
border-color: $teal-700;
color: $teal-900;
i { color: $teal-700; }
i {
color: $teal-700;
}
}
&--sel {
@@ -750,7 +797,9 @@ $red-900: #993c1d;
color: $teal-900;
font-weight: 600;
i { color: $teal-accent; }
i {
color: $teal-accent;
}
}
}
@@ -765,6 +814,15 @@ $red-900: #993c1d;
border-top: 1px solid $gray-200;
flex-wrap: wrap;
gap: 8px;
span i {
color: $gray-500;
margin: 0 2px;
}
.kyc-status-hint i {
color: $primary;
}
}
.kyc-status-hint {
@@ -789,12 +847,6 @@ $red-900: #993c1d;
}
}
.kyc-status-bar {
span i { color: $gray-500; margin: 0 2px; }
.kyc-status-hint i { color: $primary; }
}
.kyc-merged {
margin-top: 14px;
padding: 12px 14px;
@@ -815,7 +867,9 @@ $red-900: #993c1d;
font-weight: 700;
color: #0c4a6e;
i { margin-right: 6px; }
i {
margin-right: 6px;
}
}
&__hint {
@@ -863,7 +917,9 @@ $red-900: #993c1d;
font-family: inherit;
transition: all 0.15s;
i { font-size: 11px; }
i {
font-size: 11px;
}
&:hover {
background: #0284c7;
@@ -246,9 +246,7 @@ export class UploadDocumentDialogComponent implements OnInit {
next: (res: any) => {
const activeFilter = (p: any) => p?.active !== false;
this.idProofs = (res?.idProofs || []).filter(activeFilter).map((p: any) => this.normalizeProof(p));
this.addressProofs = (res?.addressProofs || [])
.filter(activeFilter)
.map((p: any) => this.normalizeProof(p));
this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map((p: any) => this.normalizeProof(p));
this.buildAddressGroups();
this.loadingProofs = false;
},
@@ -281,7 +279,12 @@ export class UploadDocumentDialogComponent implements OnInit {
if (!map.has(key)) map.set(key, []);
map.get(key).push(p);
});
this.addressGroups = Array.from(map.entries()).map(([label, items]) => ({ label, items }));
this.addressGroups = Array.from(map.entries()).map(
([
label,
items
]) => ({ label, items })
);
}
onIdProofChange(proofId: any): void {
@@ -371,40 +374,48 @@ export class UploadDocumentDialogComponent implements OnInit {
return;
}
Promise.all([this.fileToImage(front), this.fileToImage(back)])
.then(([fImg, bImg]) => {
const targetWidth = Math.max(fImg.width, bImg.width);
const fScale = targetWidth / fImg.width;
const bScale = targetWidth / bImg.width;
const fH = fImg.height * fScale;
const bH = bImg.height * bScale;
const gap = 20;
const totalH = Math.round(fH + bH + gap);
Promise.all([
this.fileToImage(front),
this.fileToImage(back)
])
.then(
([
fImg,
bImg
]) => {
const targetWidth = Math.max(fImg.width, bImg.width);
const fScale = targetWidth / fImg.width;
const bScale = targetWidth / bImg.width;
const fH = fImg.height * fScale;
const bH = bImg.height * bScale;
const gap = 20;
const totalH = Math.round(fH + bH + gap);
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = totalH;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = totalH;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(fImg, 0, 0, targetWidth, fH);
ctx.drawImage(bImg, 0, fH + gap, targetWidth, bH);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(fImg, 0, 0, targetWidth, fH);
ctx.drawImage(bImg, 0, fH + gap, targetWidth, bH);
canvas.toBlob((blob) => {
if (!blob) return;
const merged = new File([blob], `${proof.name || 'document'}-combined.png`, { type: 'image/png' });
const preview = canvas.toDataURL('image/png');
if (section === 'id') {
this.idMergedFile = merged;
this.idMergedPreview = preview;
} else {
this.addrMergedFile = merged;
this.addrMergedPreview = preview;
}
}, 'image/png');
})
canvas.toBlob((blob) => {
if (!blob) return;
const merged = new File([blob], `${proof.name || 'document'}-combined.png`, { type: 'image/png' });
const preview = canvas.toDataURL('image/png');
if (section === 'id') {
this.idMergedFile = merged;
this.idMergedPreview = preview;
} else {
this.addrMergedFile = merged;
this.addrMergedPreview = preview;
}
}, 'image/png');
}
)
.catch(() => this.resetMerged(section));
}
@@ -1,61 +1,144 @@
.ec-view { padding: 16px 0; }
/**
* 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/.
*/
.ec-view {
padding: 16px 0;
}
.ec-loading {
display: flex; flex-direction: column; align-items: center; gap: 12px;
padding: 48px; color: #64748b;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px;
color: #64748b;
}
.ec-header {
display: flex; align-items: center; justify-content: space-between;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 { font-size: 16px; font-weight: 600; color: #1e293b; display: flex; align-items: center; gap: 8px;
fa-icon { color: #1565c0; }
h3 {
font-size: 16px;
font-weight: 600;
color: #1e293b;
display: flex;
align-items: center;
gap: 8px;
fa-icon {
color: #1565c0;
}
}
}
.ec-count {
font-size: 12px; font-weight: 600; color: #059669;
background: #ecfdf5; padding: 3px 10px; border-radius: 12px;
font-size: 12px;
font-weight: 600;
color: #059669;
background: #ecfdf5;
padding: 3px 10px;
border-radius: 12px;
}
.ec-empty {
display: flex; flex-direction: column; align-items: center; gap: 8px;
padding: 40px; color: #94a3b8; font-size: 14px;
background: #f8fafc; border: 2px dashed #e2e8f0; border-radius: 10px;
mat-icon { font-size: 36px; width: 36px; height: 36px; }
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 40px;
color: #94a3b8;
font-size: 14px;
background: #f8fafc;
border: 2px dashed #e2e8f0;
border-radius: 10px;
mat-icon {
font-size: 36px;
width: 36px;
height: 36px;
}
}
.ec-card {
border: 1px solid #e2e8f0; border-radius: 10px; margin-bottom: 12px; overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 10px;
margin-bottom: 12px;
overflow: hidden;
}
.ec-card__header {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; background: #f8fafc; border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: #f8fafc;
border-bottom: 1px solid #f1f5f9;
}
.ec-card__tag {
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 12px;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
}
.ec-card__title { font-size: 14px; font-weight: 600; color: #1e293b; flex: 1; }
.ec-card__title {
font-size: 14px;
font-weight: 600;
color: #1e293b;
flex: 1;
}
.ec-card__badge {
font-size: 10px; font-weight: 700; color: #059669;
background: #ecfdf5; padding: 2px 8px; border-radius: 10px; border: 1px solid #a7f3d0;
font-size: 10px;
font-weight: 700;
color: #059669;
background: #ecfdf5;
padding: 2px 8px;
border-radius: 10px;
border: 1px solid #a7f3d0;
}
.ec-card__body { padding: 14px 16px; }
.ec-card__body {
padding: 14px 16px;
}
.ec-card__grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px 16px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px 16px;
}
.ec-card__field { display: flex; flex-direction: column; gap: 2px; }
.ec-card__label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: #94a3b8; font-weight: 500; }
.ec-card__value { font-size: 13px; font-weight: 500; color: #1e293b; }
.ec-card__field {
display: flex;
flex-direction: column;
gap: 2px;
}
.ec-card__label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #94a3b8;
font-weight: 500;
}
.ec-card__value {
font-size: 13px;
font-weight: 500;
color: #1e293b;
}
@media (width <= 768px) {
.ec-card__grid { grid-template-columns: repeat(2, 1fr); }
.ec-card__grid {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -1,3 +1,11 @@
/**
* 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 { ActivatedRoute } from '@angular/router';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -29,19 +37,45 @@ export class EntityClassificationTabComponent implements OnInit {
categories = [
{ key: 'msme', label: 'MSME', tag: 'Udyam / MSME', tagColor: '#0A5C6B', tagBg: '#E6F4F6', icon: 'cogs' },
{ key: 'startup', label: 'Startup India', tag: 'DPIIT', tagColor: '#1A5FAB', tagBg: '#EAF2FB', icon: 'rocket' },
{ key: 'mudra', label: 'Mudra Borrower', tag: 'PMMY', tagColor: '#8A5A00', tagBg: '#FEF3DC', icon: 'hand-holding-usd' },
{
key: 'mudra',
label: 'Mudra Borrower',
tag: 'PMMY',
tagColor: '#8A5A00',
tagBg: '#FEF3DC',
icon: 'hand-holding-usd'
},
{ key: 'eou', label: 'Export Oriented Unit', tag: 'EOU', tagColor: '#1B7A4A', tagBg: '#E8F5EE', icon: 'ship' },
{ key: 'importexport', label: 'Import Export', tag: 'DGFT / IEC', tagColor: '#5B3A8A', tagBg: '#F0EBF8', icon: 'exchange-alt' },
{
key: 'importexport',
label: 'Import Export',
tag: 'DGFT / IEC',
tagColor: '#5B3A8A',
tagBg: '#F0EBF8',
icon: 'exchange-alt'
},
{ key: 'psl', label: 'Priority Sector', tag: 'RBI PSL', tagColor: '#7B1F1F', tagBg: '#FCEAEA', icon: 'bullseye' },
{ key: 'nps', label: 'Non-Priority Sector', tag: 'General', tagColor: '#444441', tagBg: '#F1EFE8', icon: 'building' }
{
key: 'nps',
label: 'Non-Priority Sector',
tag: 'General',
tagColor: '#444441',
tagBg: '#F1EFE8',
icon: 'building'
}
];
ngOnInit() {
this.clientId = this.route.parent?.snapshot.paramMap.get('clientId') || '';
if (this.clientId) {
this.clientsService.getEntityClassifications(this.clientId).subscribe({
next: (res: any) => { this.data = res; this.loading = false; },
error: () => { this.loading = false; }
next: (res: any) => {
this.data = res;
this.loading = false;
},
error: () => {
this.loading = false;
}
});
} else {
this.loading = false;
@@ -50,19 +84,25 @@ export class EntityClassificationTabComponent implements OnInit {
get enabledCategories(): any[] {
if (!this.data) return [];
return this.categories.filter(c => this.data[c.key]?.enabled);
return this.categories.filter((c) => this.data[c.key]?.enabled);
}
getCategoryData(key: string): any {
return this.data?.[key] || {};
}
getFieldPairs(catData: any): { label: string, value: any }[] {
getFieldPairs(catData: any): { label: string; value: any }[] {
if (!catData) return [];
const skip = ['id', 'clientId', 'enabled', 'locale', 'dateFormat'];
const skip = [
'id',
'clientId',
'enabled',
'locale',
'dateFormat'
];
return Object.keys(catData)
.filter(k => !skip.includes(k) && catData[k] != null && catData[k] !== '' && catData[k] !== false)
.map(k => ({
.filter((k) => !skip.includes(k) && catData[k] != null && catData[k] !== '' && catData[k] !== false)
.map((k) => ({
label: this.formatLabel(k),
value: this.formatValue(catData[k])
}));
@@ -71,7 +111,7 @@ export class EntityClassificationTabComponent implements OnInit {
private formatLabel(key: string): string {
return key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, s => s.toUpperCase())
.replace(/^./, (s) => s.toUpperCase())
.replace(/Id$/, '')
.trim();
}
@@ -7,7 +7,6 @@
-->
<div class="nom-container mat-typography">
<!-- ═══ FAMILY MEMBERS SECTION ═══ -->
<div class="nom-header">
<div class="nom-header__left">
@@ -50,7 +49,12 @@
<button mat-stroked-button color="primary" class="nom-actions__edit" (click)="editFamilyMember(member, i)">
<fa-icon icon="edit" class="m-r-10"></fa-icon> Edit
</button>
<button mat-stroked-button color="warn" class="nom-actions__delete" (click)="deleteMember(member, 'Family Member')">
<button
mat-stroked-button
color="warn"
class="nom-actions__delete"
(click)="deleteMember(member, 'Family Member')"
>
<fa-icon icon="trash" class="m-r-10"></fa-icon> Delete
</button>
</div>
@@ -91,5 +95,4 @@
</mat-expansion-panel>
}
</mat-accordion>
</div>
@@ -80,12 +80,12 @@ export class FamilyMembersTabComponent {
/** Family members (non-nominee entries) */
get familyMembers(): any[] {
return this.clientFamilyMembers.filter(m => !m.nominationType || m.nominationType === '');
return this.clientFamilyMembers.filter((m) => !m.nominationType || m.nominationType === '');
}
/** Nominees (entries with nominationType set) */
get nominees(): any[] {
return this.clientFamilyMembers.filter(m => m.nominationType && m.nominationType !== '');
return this.clientFamilyMembers.filter((m) => m.nominationType && m.nominationType !== '');
}
/** Get relationship display name */
@@ -44,7 +44,9 @@ $white: #fff;
height: 32px !important;
line-height: 32px !important;
color: $gray-500;
transition: color 0.15s ease, background 0.15s ease;
transition:
color 0.15s ease,
background 0.15s ease;
&:hover {
color: $gray-900;
@@ -48,8 +48,14 @@ export class NomineeDialogComponent implements OnInit {
const member = this.data.member;
this.nomineeForm = this.formBuilder.group({
nominationType: [member?.nominationType || 'DA1'],
firstName: [member?.firstName || '', Validators.required],
relationshipId: [member?.relationshipId || '', Validators.required],
firstName: [
member?.firstName || '',
Validators.required
],
relationshipId: [
member?.relationshipId || '',
Validators.required
],
dateOfBirth: [member?.dateOfBirth ? this.dateUtils.formatDate(member.dateOfBirth, 'yyyy-MM-dd') : null],
percentageShare: [member?.percentageShare ?? 100],
isDependent: [member?.isDependent || false],
@@ -61,13 +61,17 @@
}
@if (form60Data.dateOfBirth) {
<div class="f60-detail">
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">calendar_today</mat-icon> Date of Birth</span>
<span class="f60-detail__label"
><mat-icon class="f60-detail__icon">calendar_today</mat-icon> Date of Birth</span
>
<span class="f60-detail__value">{{ form60Data.dateOfBirth | dateFormat }}</span>
</div>
}
@if (form60Data.fatherName) {
<div class="f60-detail">
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">family_restroom</mat-icon> Father's Name</span>
<span class="f60-detail__label"
><mat-icon class="f60-detail__icon">family_restroom</mat-icon> Father's Name</span
>
<span class="f60-detail__value">{{ form60Data.fatherName }}</span>
</div>
}
@@ -79,25 +83,33 @@
}
@if (form60Data.transactionType) {
<div class="f60-detail">
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">receipt_long</mat-icon> Transaction Type</span>
<span class="f60-detail__label"
><mat-icon class="f60-detail__icon">receipt_long</mat-icon> Transaction Type</span
>
<span class="f60-detail__value">{{ form60Data.transactionType }}</span>
</div>
}
@if (form60Data.transactionAmount) {
<div class="f60-detail">
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">currency_rupee</mat-icon> Transaction Amount</span>
<span class="f60-detail__label"
><mat-icon class="f60-detail__icon">currency_rupee</mat-icon> Transaction Amount</span
>
<span class="f60-detail__value">&#8377; {{ form60Data.transactionAmount }}</span>
</div>
}
@if (form60Data.estimatedTotalIncome) {
<div class="f60-detail">
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">account_balance_wallet</mat-icon> Estimated Total Income</span>
<span class="f60-detail__label"
><mat-icon class="f60-detail__icon">account_balance_wallet</mat-icon> Estimated Total Income</span
>
<span class="f60-detail__value">&#8377; {{ form60Data.estimatedTotalIncome }}</span>
</div>
}
@if (form60Data.reasonForNoPan) {
<div class="f60-detail">
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">help_outline</mat-icon> Reason for No PAN</span>
<span class="f60-detail__label"
><mat-icon class="f60-detail__icon">help_outline</mat-icon> Reason for No PAN</span
>
<span class="f60-detail__value">{{ form60Data.reasonForNoPan }}</span>
</div>
}
@@ -181,7 +193,8 @@
<!-- Declaration -->
<div class="f60-declaration">
<mat-checkbox formControlName="declaration">
I hereby declare that I do not have a Permanent Account Number (PAN) and the information provided above is true and correct to the best of my knowledge and belief.
I hereby declare that I do not have a Permanent Account Number (PAN) and the information provided above
is true and correct to the best of my knowledge and belief.
</mat-checkbox>
</div>
@@ -6,19 +6,26 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #1E6BB8;
$primary-light: #EBF3FF;
$primary: #1e6bb8;
$primary-light: #ebf3ff;
$green: #16a34a;
$green-light: #dcfce7;
$amber: #f59e0b;
$amber-light: #fef3c7;
$border: #E2E8F0;
$text: #1A2332;
$muted: #64748B;
$bg: #F0F4F8;
$border: #e2e8f0;
$text: #1a2332;
$muted: #64748b;
$bg: #f0f4f8;
.f60-container { padding: 20px 24px; }
.f60-loading { display: flex; justify-content: center; padding: 40px; }
.f60-container {
padding: 20px 24px;
}
.f60-loading {
display: flex;
justify-content: center;
padding: 40px;
}
/* ── PAN OK — Not Required ── */
.f60-pan-ok {
@@ -29,103 +36,260 @@ $bg: #F0F4F8;
border-radius: 12px;
&__icon-wrap {
width: 72px; height: 72px; border-radius: 50%;
background: #fff; display: inline-flex; align-items: center;
justify-content: center; margin-bottom: 16px;
width: 72px;
height: 72px;
border-radius: 50%;
background: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba($green, 0.15);
}
&__icon { font-size: 40px; width: 40px; height: 40px; color: $green; }
&__title { font-size: 20px; font-weight: 700; color: $text; margin: 0 0 8px; }
&__icon {
font-size: 40px;
width: 40px;
height: 40px;
color: $green;
}
&__title {
font-size: 20px;
font-weight: 700;
color: $text;
margin: 0 0 8px;
}
&__desc {
font-size: 15px; color: $text; margin: 0 0 6px;
display: flex; align-items: center; justify-content: center; gap: 6px; flex-wrap: wrap;
strong { font-family: 'JetBrains Mono', monospace; letter-spacing: 0.5px; }
font-size: 15px;
color: $text;
margin: 0 0 6px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
flex-wrap: wrap;
strong {
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.5px;
}
}
&__tick {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
color: $green;
}
&__verified {
font-size: 13px;
font-weight: 600;
color: $green;
}
&__rule {
font-size: 12px;
color: $muted;
margin: 12px 0 0;
}
&__tick { font-size: 18px !important; width: 18px !important; height: 18px !important; color: $green; }
&__verified { font-size: 13px; font-weight: 600; color: $green; }
&__rule { font-size: 12px; color: $muted; margin: 12px 0 0; }
}
/* ── Warning Banner ── */
.f60-warning {
display: flex; align-items: center; gap: 12px;
background: $amber-light; padding: 14px 20px;
border-left: 4px solid $amber; border-radius: 8px;
display: flex;
align-items: center;
gap: 12px;
background: $amber-light;
padding: 14px 20px;
border-left: 4px solid $amber;
border-radius: 8px;
margin-bottom: 20px;
&__icon { color: $amber; font-size: 24px; flex-shrink: 0; }
&__text { font-size: 13px; color: #92400e; line-height: 1.5; strong { font-weight: 700; } }
&__icon {
color: $amber;
font-size: 24px;
flex-shrink: 0;
}
&__text {
font-size: 13px;
color: #92400e;
line-height: 1.5;
strong {
font-weight: 700;
}
}
}
/* ── Card ── */
.f60-card {
background: #fff; border: 1px solid $border; border-radius: 12px; overflow: hidden;
background: #fff;
border: 1px solid $border;
border-radius: 12px;
overflow: hidden;
&__header {
display: flex; align-items: center; gap: 10px;
padding: 14px 20px; background: $bg; border-bottom: 1px solid $border;
h3 { margin: 0; font-size: 15px; font-weight: 600; color: $text; flex: 1; }
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
background: $bg;
border-bottom: 1px solid $border;
h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: $text;
flex: 1;
}
}
&__header-icon {
color: $primary;
font-size: 22px;
}
&__header-icon { color: $primary; font-size: 22px; }
&__status {
font-size: 11px; font-weight: 600; color: $green; background: $green-light;
padding: 3px 10px; border-radius: 12px;
font-size: 11px;
font-weight: 600;
color: $green;
background: $green-light;
padding: 3px 10px;
border-radius: 12px;
}
&__edit {
font-size: 12px !important; height: 30px !important; border-radius: 6px !important;
mat-icon { font-size: 16px; width: 16px; height: 16px; margin-right: 4px; }
font-size: 12px !important;
height: 30px !important;
border-radius: 6px !important;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
margin-right: 4px;
}
}
&__body {
padding: 20px;
}
&__body { padding: 20px; }
}
/* ── Detail Grid (read-only) ── */
.f60-detail-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 16px 24px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 24px;
@media (width <= 600px) { grid-template-columns: 1fr; }
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.f60-detail {
display: flex; flex-direction: column;
&--full { grid-column: 1 / -1; }
display: flex;
flex-direction: column;
&--full {
grid-column: 1 / -1;
}
&__label {
font-size: 11px; font-weight: 600; text-transform: uppercase;
color: $muted; letter-spacing: 0.5px; margin-bottom: 4px;
display: flex; align-items: center; gap: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: $muted;
letter-spacing: 0.5px;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 4px;
}
&__icon {
font-size: 15px !important;
width: 15px !important;
height: 15px !important;
color: $primary;
}
&__value {
font-size: 15px;
font-weight: 600;
color: $text;
}
&__icon { font-size: 15px !important; width: 15px !important; height: 15px !important; color: $primary; }
&__value { font-size: 15px; font-weight: 600; color: $text; }
}
/* ── Form Grid ── */
.f60-form-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 16px;
@media (width <= 600px) { grid-template-columns: 1fr; }
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.f60-ff {
width: 100%;
&--full {
grid-column: 1 / -1;
}
}
.f60-ff { width: 100%; &--full { grid-column: 1 / -1; } }
.f60-prefix {
display: inline-flex; align-items: center;
padding-left: 4px; padding-right: 8px; margin-right: 8px;
border-right: 1.5px solid #cbd5e1; line-height: 1;
mat-icon { font-size: 20px; width: 20px; height: 20px; color: $primary; }
display: inline-flex;
align-items: center;
padding-left: 4px;
padding-right: 8px;
margin-right: 8px;
border-right: 1.5px solid #cbd5e1;
line-height: 1;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: $primary;
}
}
/* ── Declaration ── */
.f60-declaration {
margin: 12px 0 20px; padding: 14px 16px;
background: $bg; border-radius: 8px; border: 1px solid $border;
font-size: 13px; line-height: 1.5; color: $muted;
margin: 12px 0 20px;
padding: 14px 16px;
background: $bg;
border-radius: 8px;
border: 1px solid $border;
font-size: 13px;
line-height: 1.5;
color: $muted;
}
/* ── Actions ── */
.f60-actions {
display: flex; justify-content: flex-end; gap: 10px;
button { border-radius: 8px !important; font-weight: 600 !important; }
mat-icon { font-size: 18px; width: 18px; height: 18px; margin-right: 4px; vertical-align: middle; }
display: flex;
justify-content: flex-end;
gap: 10px;
button {
border-radius: 8px !important;
font-weight: 600 !important;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
margin-right: 4px;
vertical-align: middle;
}
}
@@ -90,33 +90,37 @@ export class Form60TabComponent implements OnInit, OnDestroy {
private loadForm60Purposes(): void {
this.loadingPurposes = true;
this.clientsService.getCodeValuesByName('FORM_60_PURPOSE').pipe(
takeUntil(this.destroy$)
).subscribe((values: any[]) => {
this.transactionTypes = (values || [])
.sort((a: any, b: any) => (a.position || 0) - (b.position || 0))
.map((v: any) => ({ value: v.name, label: v.name }));
this.loadingPurposes = false;
});
this.clientsService
.getCodeValuesByName('FORM_60_PURPOSE')
.pipe(takeUntil(this.destroy$))
.subscribe((values: any[]) => {
this.transactionTypes = (values || [])
.sort((a: any, b: any) => (a.position || 0) - (b.position || 0))
.map((v: any) => ({ value: v.name, label: v.name }));
this.loadingPurposes = false;
});
}
private checkPanStatus(): void {
this.checkingPan = true;
this.clientsService.getClientKyc(this.clientId).pipe(
takeUntil(this.destroy$),
catchError(() => of(null))
).subscribe((kyc: any) => {
this.checkingPan = false;
if (kyc?.panNumber) {
this.hasPan = true;
this.panNumber = kyc.panNumber;
this.panVerified = kyc.panVerified || false;
this.loading = false;
} else {
this.hasPan = false;
this.loadForm60Data();
}
});
this.clientsService
.getClientKyc(this.clientId)
.pipe(
takeUntil(this.destroy$),
catchError(() => of(null))
)
.subscribe((kyc: any) => {
this.checkingPan = false;
if (kyc?.panNumber) {
this.hasPan = true;
this.panNumber = kyc.panNumber;
this.panVerified = kyc.panVerified || false;
this.loading = false;
} else {
this.hasPan = false;
this.loadForm60Data();
}
});
}
private createForm(): void {
@@ -7,7 +7,6 @@
-->
<div class="general-tab-container mat-typography">
<!-- Performance History - KPI Stat Cards -->
<section class="performance-section">
<h3 class="performance-title">{{ 'labels.heading.Performance History' | translate }}</h3>
@@ -164,7 +163,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Active Saving Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Active Saving Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -211,7 +212,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Closed Saving Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Saving Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -233,6 +236,89 @@
}
</section>
<!-- Current Accounts -->
@if (currentAccounts?.length > 0) {
<section class="account-section">
<div class="section-header">
<div class="section-header__left">
<i class="fa fa-credit-card section-header__icon"></i>
<h3 class="section-header__title">Current Accounts</h3>
<span class="section-header__badge">{{ currentAccounts?.length || 0 }}</span>
</div>
</div>
<div class="table-wrapper">
<table mat-table [dataSource]="currentAccounts">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
<th mat-header-cell *matHeaderCellDef>Current Account Product</th>
<td mat-cell *matCellDef="let element">
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Last Active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="Balance">
<th mat-header-cell class="r-amount" *matHeaderCellDef>{{ 'labels.inputs.Balance' | translate }}</th>
<td mat-cell class="r-amount" *matCellDef="let element">
{{ element.accountBalance | currency: element.currency?.code || 'INR' : 'symbol-narrow' : '1.2-2' }}
</td>
</ng-container>
<ng-container matColumnDef="Actions">
<th mat-header-cell class="center" *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell class="center" *matCellDef="let element">
@if (element.status.submittedAndPendingApproval) {
<button
class="account-action-button"
mat-raised-button
color="primary"
(click)="routeEdit($event)"
[routerLink]="['../', 'current-accounts', element.id, 'actions', 'Approve']"
>
<i class="fa fa-check"></i>
</button>
}
<button
class="account-action-button"
mat-raised-button
color="primary"
(click)="routeEdit($event)"
[routerLink]="['../', 'current-accounts', element.id, 'general']"
>
<i class="fa fa-eye"></i>
</button>
</td>
</ng-container>
<ng-container matColumnDef="no-data">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">No Active Current Accounts Available</p>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="openSavingsColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: openSavingsColumns"
[routerLink]="['../', 'current-accounts', row.id, 'general']"
></tr>
<tr mat-footer-row *matFooterRowDef="currentAccounts?.length === 0 ? ['no-data'] : []"></tr>
</table>
</div>
</section>
}
<!-- Upcoming Charges -->
<section class="account-section">
<div class="section-header">
@@ -258,7 +344,8 @@
<ng-container matColumnDef="Name">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.name' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="!(element.isWaived || element.isPaid) | statusLookup"></i> {{ element.name }}
<i class="fa fa-stop" [ngClass]="!(element.isWaived || element.isPaid) | statusLookup"></i>
{{ element.name }}
</td>
</ng-container>
@@ -322,7 +409,11 @@
</ng-container>
<tr mat-header-row *matHeaderRowDef="upcomingChargesColumns"></tr>
<tr mat-row *matRowDef="let row; columns: upcomingChargesColumns" [routerLink]="['../', 'charges', row.id]"></tr>
<tr
mat-row
*matRowDef="let row; columns: upcomingChargesColumns"
[routerLink]="['../', 'charges', row.id]"
></tr>
<tr mat-footer-row *matFooterRowDef="upcomingCharges?.length === 0 ? ['no-data'] : []"></tr>
</table>
</div>
@@ -407,7 +498,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Active Fixed Deposit Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Active Fixed Deposit Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -454,7 +547,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Closed Fixed Deposit Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Fixed Deposit Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -559,7 +654,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Active Recurring Deposit Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Active Recurring Deposit Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -607,7 +704,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Closed Recurring Deposit Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Recurring Deposit Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -775,7 +874,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openLoansColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Active Loan Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Active Loan Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -867,7 +968,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedLoansColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Closed Loan Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Loan Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -924,7 +1027,8 @@
<th mat-header-cell class="r-amount" *matHeaderCellDef>{{ 'labels.inputs.Total Value' | translate }}</th>
<td mat-cell class="r-amount" *matCellDef="let element">
{{
element.basePrice * element.quantity | currency: element.currency?.code || 'USD' : 'symbol-narrow' : '1.2-2'
element.basePrice * element.quantity
| currency: element.currency?.code || 'USD' : 'symbol-narrow' : '1.2-2'
}}
</td>
</ng-container>
@@ -1043,7 +1147,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openSharesColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Active Share Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Active Share Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -1094,7 +1200,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedSharesColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Closed Share Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Share Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -1123,4 +1231,4 @@
<embed [src]="pdfUrl" type="application/pdf" width="100%" height="600px" />
</div>
</div>
</div>
</div>
@@ -58,9 +58,13 @@ $red: #d93025;
background: $white;
border-radius: 10px;
padding: 18px 20px;
box-shadow: 0 1px 3px rgb(0 0 0 / 8%), 0 1px 2px rgb(0 0 0 / 6%);
box-shadow:
0 1px 3px rgb(0 0 0 / 8%),
0 1px 2px rgb(0 0 0 / 6%);
border-left: 4px solid transparent;
transition: box-shadow 0.2s ease, transform 0.2s ease;
transition:
box-shadow 0.2s ease,
transform 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
@@ -219,6 +223,7 @@ $red: #d93025;
}
// ── Mat Table Styling ──
/* stylelint-disable selector-no-qualifying-type */
table[mat-table] {
width: 100%;
border-collapse: collapse;
@@ -312,6 +317,7 @@ table[mat-table] {
font-size: 12px;
}
}
/* stylelint-enable selector-no-qualifying-type */
// Status color classes (for the fa-stop dots)
:host ::ng-deep {
@@ -228,6 +228,8 @@ export class GeneralTabComponent implements OnDestroy {
loanAccounts: any[] = [];
/** Savings Accounts Data */
savingAccounts: any[] = [];
/** Current Accounts Data */
currentAccounts: any[] = [];
/** Shares Accounts Data */
shareAccounts: any[] = [];
/** Upcoming Charges Data */
@@ -273,6 +275,7 @@ export class GeneralTabComponent implements OnDestroy {
(data: { clientAccountsData: any; clientChargesData: any; clientSummary: any; clientCollateralData: any }) => {
this.clientAccountData = data.clientAccountsData;
this.savingAccounts = data.clientAccountsData?.savingsAccounts ?? [];
this.currentAccounts = data.clientAccountsData?.currentAccounts ?? [];
this.loanAccounts = data.clientAccountsData?.loanAccounts ?? [];
this.shareAccounts = data.clientAccountsData?.shareAccounts ?? [];
@@ -14,7 +14,13 @@
<h3 class="idt-header__title">{{ 'labels.heading.Identities' | translate }}</h3>
<span class="idt-header__badge">{{ clientIdentities?.length || 0 }}</span>
</div>
<button mat-flat-button color="primary" class="idt-header__btn" (click)="addIdentifier()" *mifosxHasPermission="'CREATE_CLIENTIDENTIFIER'">
<button
mat-flat-button
color="primary"
class="idt-header__btn"
(click)="addIdentifier()"
*mifosxHasPermission="'CREATE_CLIENTIDENTIFIER'"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@@ -35,11 +41,21 @@
<div class="idt-card__title">
<i class="fa fa-file-text idt-card__type-icon"></i>
<span class="idt-card__type">{{ identity.documentType?.name }}</span>
<span class="idt-card__status" [class.idt-card__status--active]="identity.status === 'clientIdentifierStatusType.active'" [class.idt-card__status--inactive]="identity.status !== 'clientIdentifierStatusType.active'">
<span
class="idt-card__status"
[class.idt-card__status--active]="identity.status === 'clientIdentifierStatusType.active'"
[class.idt-card__status--inactive]="identity.status !== 'clientIdentifierStatusType.active'"
>
{{ identity.status === 'clientIdentifierStatusType.active' ? 'Active' : 'Inactive' }}
</span>
</div>
<button mat-stroked-button color="warn" class="idt-card__delete" (click)="deleteIdentifier(identity.clientId, identity.id, i)" *mifosxHasPermission="'DELETE_CLIENTIDENTIFIER'">
<button
mat-stroked-button
color="warn"
class="idt-card__delete"
(click)="deleteIdentifier(identity.clientId, identity.id, i)"
*mifosxHasPermission="'DELETE_CLIENTIDENTIFIER'"
>
<i class="fa fa-trash"></i>
</button>
</div>
@@ -70,13 +86,22 @@
</div>
<div class="idt-docs__grid">
@for (document of identity.documents; track trackByDocumentId($index, document)) {
<div class="idt-doc" [class.idt-doc--clickable]="isPreviewable(document)"
<div
class="idt-doc"
[class.idt-doc--clickable]="isPreviewable(document)"
(click)="isPreviewable(document) && openDocumentPreview(identity, document)"
(keydown.enter)="isPreviewable(document) && openDocumentPreview(identity, document)"
tabindex="0" role="button"
[attr.aria-label]="('labels.buttons.Preview' | translate) + ': ' + document.name">
tabindex="0"
role="button"
[attr.aria-label]="('labels.buttons.Preview' | translate) + ': ' + document.name"
>
@if (previewThumbnails[document.id]) {
<img [src]="previewThumbnails[document.id]" [alt]="document.name" class="idt-doc__thumb" loading="lazy" />
<img
[src]="previewThumbnails[document.id]"
[alt]="document.name"
class="idt-doc__thumb"
loading="lazy"
/>
} @else {
<div class="idt-doc__placeholder">
<i class="fa fa-file-text-o"></i>
@@ -85,9 +110,7 @@
<div class="idt-doc__info">
<span class="idt-doc__name">{{ document.name }}</span>
@if (document.fileName) {
<span class="idt-doc__file">
<i class="fa fa-paperclip"></i> {{ document.fileName }}
</span>
<span class="idt-doc__file"> <i class="fa fa-paperclip"></i> {{ document.fileName }} </span>
}
</div>
@if (isPreviewable(document)) {
@@ -37,7 +37,10 @@ $red-light: #fce8e6;
gap: 10px;
}
&__icon { font-size: 16px; color: $primary; }
&__icon {
font-size: 16px;
color: $primary;
}
&__title {
margin: 0;
@@ -71,8 +74,17 @@ $red-light: #fce8e6;
border-radius: 10px;
border: 1px dashed $gray-200;
&__icon { font-size: 32px; color: $gray-500; margin-bottom: 8px; }
&__text { color: $gray-500; font-size: 13px; margin: 0; }
&__icon {
font-size: 32px;
color: $gray-500;
margin-bottom: 8px;
}
&__text {
color: $gray-500;
font-size: 13px;
margin: 0;
}
}
// Identity Card
@@ -82,7 +94,9 @@ $red-light: #fce8e6;
border-radius: 10px;
overflow: hidden;
margin-bottom: 14px;
transition: box-shadow 0.15s, border-color 0.15s;
transition:
box-shadow 0.15s,
border-color 0.15s;
position: relative;
&::before {
@@ -96,7 +110,7 @@ $red-light: #fce8e6;
&:hover {
border-color: $primary-light;
box-shadow: 0 2px 8px rgba(26, 115, 232, 0.08);
box-shadow: 0 2px 8px rgb(26 115 232 / 8%);
}
&__header {
@@ -126,7 +140,7 @@ $red-light: #fce8e6;
justify-content: center;
background: linear-gradient(135deg, $primary, #4285f4);
border-radius: 8px;
box-shadow: 0 2px 6px rgba(26, 115, 232, 0.25);
box-shadow: 0 2px 6px rgb(26 115 232 / 25%);
flex-shrink: 0;
}
@@ -162,14 +176,18 @@ $red-light: #fce8e6;
background: $green-light;
color: $green;
&::before { background: $green; }
&::before {
background: $green;
}
}
&--inactive {
background: $red-light;
color: $red;
&::before { background: $red; }
&::before {
background: $red;
}
}
}
@@ -189,7 +207,9 @@ $red-light: #fce8e6;
background: $red-light !important;
}
i { font-size: 13px; }
i {
font-size: 13px;
}
}
&__body {
@@ -214,7 +234,9 @@ $red-light: #fce8e6;
padding: 8px 14px;
border-bottom: 1px dashed $gray-200;
&:last-child { border-bottom: none; }
&:last-child {
border-bottom: none;
}
}
&__icon {
@@ -257,8 +279,13 @@ $red-light: #fce8e6;
}
@media (width <= 600px) {
&__row { flex-wrap: wrap; }
&__label { min-width: 0; }
&__row {
flex-wrap: wrap;
}
&__label {
min-width: 0;
}
}
}
@@ -277,7 +304,9 @@ $red-light: #fce8e6;
align-items: center;
gap: 6px;
i { color: $primary; }
i {
color: $primary;
}
}
&__count {
@@ -314,7 +343,7 @@ $red-light: #fce8e6;
&:hover {
border-color: $primary;
box-shadow: 0 2px 8px rgba(26, 115, 232, 0.1);
box-shadow: 0 2px 8px rgb(26 115 232 / 10%);
transform: translateY(-1px);
}
}
@@ -369,7 +398,9 @@ $red-light: #fce8e6;
align-items: center;
gap: 4px;
i { font-size: 9px; }
i {
font-size: 9px;
}
}
&__preview {
@@ -388,7 +419,11 @@ $red-light: #fce8e6;
flex-shrink: 0;
}
&:hover .idt-doc__preview { opacity: 1; }
&:hover .idt-doc__preview {
opacity: 1;
}
}
.document-lightbox-host { display: none; }
.document-lightbox-host {
display: none;
}
@@ -132,18 +132,15 @@ export class IdentitiesTabComponent implements OnDestroy {
});
// Pull display name from parent route's clientViewData
this.route.parent.data
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data: any) => {
this.clientName = data?.clientViewData?.displayName || '';
});
this.route.parent.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data: any) => {
this.clientName = data?.clientViewData?.displayName || '';
});
// Pull permanent address (falls back to first available address)
this.clientService.getClientAddressData(this.clientId).subscribe({
next: (addresses: any[]) => {
if (!addresses?.length) return;
const permanent =
addresses.find((a) => /permanent/i.test(a?.addressType || '')) || addresses[0];
const permanent = addresses.find((a) => /permanent/i.test(a?.addressType || '')) || addresses[0];
this.clientAddress = this.formatAddress(permanent);
},
error: () => undefined
@@ -214,10 +211,7 @@ export class IdentitiesTabComponent implements OnDestroy {
const identifierData = {
documentTypeId: section.proofId,
status: 'Active',
documentKey:
kind === 'id'
? section.documentKey
: `${section.proof?.name || 'Address Proof'} - ${this.clientId}`,
documentKey: kind === 'id' ? section.documentKey : `${section.proof?.name || 'Address Proof'} - ${this.clientId}`,
description: kind === 'address' && section.address ? section.address : description
};
@@ -65,12 +65,22 @@
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{ kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—' }}</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
<button mat-stroked-button class="kv-action-btn" type="button"
[disabled]="verifying.pan || !kycData.panNumber" (click)="verifyPAN()">
@if (verifying.pan) { <mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> }
@else { Re-Verify }
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="verifying.pan || !kycData.panNumber"
(click)="verifyPAN()"
>
@if (verifying.pan) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Re-Verify
}
</button>
</span>
</div>
@@ -88,11 +98,18 @@
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{ kycData.aadhaarVerifiedOn ? formatDate(kycData.aadhaarVerifiedOn) : '—' }}</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.aadhaarVerifiedOn ? formatDate(kycData.aadhaarVerifiedOn) : '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
<button mat-stroked-button class="kv-action-btn" type="button"
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked" (click)="startAadhaarReVerify()">
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked"
(click)="startAadhaarReVerify()"
>
Re-Verify
</button>
}
@@ -113,12 +130,22 @@
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{ kycData.gstinVerifiedOn ? formatDate(kycData.gstinVerifiedOn) : '—' }}</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.gstinVerifiedOn ? formatDate(kycData.gstinVerifiedOn) : '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
<button mat-stroked-button class="kv-action-btn" type="button"
[disabled]="verifying.gstin || !kycData.gstinNumber" (click)="verifyGSTIN()">
@if (verifying.gstin) { <mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> }
@else { Re-Verify }
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="verifying.gstin || !kycData.gstinNumber"
(click)="verifyGSTIN()"
>
@if (verifying.gstin) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Re-Verify
}
</button>
</span>
</div>
@@ -138,7 +165,9 @@
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{ kycData.enterprisePanVerifiedOn ? formatDate(kycData.enterprisePanVerifiedOn) : '—' }}</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.enterprisePanVerifiedOn ? formatDate(kycData.enterprisePanVerifiedOn) : '—'
}}</span>
<span class="kv-table__col kv-table__col--action"></span>
</div>
}
@@ -202,12 +231,27 @@
<div class="kv-otp-bar">
<mat-form-field appearance="outline" class="kv-otp-input">
<mat-label>Enter 12-digit Aadhaar</mat-label>
<input matInput [(ngModel)]="aadhaarFullNumber" [ngModelOptions]="{standalone: true}" maxlength="12" type="password" />
<input
matInput
[(ngModel)]="aadhaarFullNumber"
[ngModelOptions]="{ standalone: true }"
maxlength="12"
type="password"
/>
</mat-form-field>
<button mat-raised-button color="primary" type="button" class="kv-otp-btn"
[disabled]="!aadhaarFullNumber || aadhaarFullNumber.length !== 12 || verifying.aadhaar" (click)="sendAadhaarOtp()">
@if (verifying.aadhaar) { <mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> }
@else { Send OTP }
<button
mat-raised-button
color="primary"
type="button"
class="kv-otp-btn"
[disabled]="!aadhaarFullNumber || aadhaarFullNumber.length !== 12 || verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
@if (verifying.aadhaar) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Send OTP
}
</button>
</div>
}
@@ -216,32 +260,69 @@
<span class="kv-otp-label"><mat-icon>sms</mat-icon> OTP sent</span>
<mat-form-field appearance="outline" class="kv-otp-input">
<mat-label>Enter OTP</mat-label>
<input matInput [(ngModel)]="aadhaarOtp" [ngModelOptions]="{standalone: true}" maxlength="6" autocomplete="one-time-code" />
<input
matInput
[(ngModel)]="aadhaarOtp"
[ngModelOptions]="{ standalone: true }"
maxlength="6"
autocomplete="one-time-code"
/>
</mat-form-field>
<button mat-raised-button color="primary" type="button" class="kv-otp-btn"
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying" (click)="verifyAadhaarOtp()">
@if (aadhaarOtpVerifying) { <mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> }
@else { Verify }
<button
mat-raised-button
color="primary"
type="button"
class="kv-otp-btn"
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
(click)="verifyAadhaarOtp()"
>
@if (aadhaarOtpVerifying) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Verify
}
</button>
<button
mat-stroked-button
type="button"
class="kv-otp-btn"
[disabled]="verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
Resend
</button>
<button mat-stroked-button type="button" class="kv-otp-btn" [disabled]="verifying.aadhaar" (click)="sendAadhaarOtp()">Resend</button>
</div>
}
@if (verificationError.aadhaar) {
<div class="kyc-error-msg"><mat-icon class="kyc-error-icon">error_outline</mat-icon> {{ verificationError.aadhaar }}</div>
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon> {{ verificationError.aadhaar }}
</div>
}
@if (verificationError.pan) {
<div class="kyc-error-msg"><mat-icon class="kyc-error-icon">error_outline</mat-icon> {{ verificationError.pan }}</div>
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon> {{ verificationError.pan }}
</div>
}
<!-- KYC Meta Grid -->
<div class="kv-meta-grid">
<div class="kv-meta-item">
<span class="kv-meta-label">KYC Type</span>
<span class="kv-meta-value">{{ kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—' }}</span>
<span class="kv-meta-value">{{
kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—'
}}</span>
</div>
<div class="kv-meta-item">
<span class="kv-meta-label">Risk Category</span>
<span class="kv-meta-value">{{ kycData.riskCategory === 1 ? 'Low' : kycData.riskCategory === 2 ? 'Medium' : kycData.riskCategory === 3 ? 'High' : '—' }}</span>
<span class="kv-meta-value">{{
kycData.riskCategory === 1
? 'Low'
: kycData.riskCategory === 2
? 'Medium'
: kycData.riskCategory === 3
? 'High'
: '—'
}}</span>
</div>
<div class="kv-meta-item">
<span class="kv-meta-label">Initiated On</span>
@@ -323,9 +404,14 @@
<div class="rekyc-tl-content">
<div class="rekyc-tl-label">Complete Re-KYC</div>
<div class="rekyc-tl-sub">Updates Last Verified date and sets new Expiry Date as per RBI norms</div>
<button mat-flat-button color="primary" class="rekyc-btn-complete" type="button"
<button
mat-flat-button
color="primary"
class="rekyc-btn-complete"
type="button"
[disabled]="!selectedDeclarationFile || reKycCompleting || !kycId"
(click)="completeReKyc()">
(click)="completeReKyc()"
>
@if (reKycCompleting) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
Completing...
@@ -164,6 +164,24 @@
color: #757575;
border: 1px solid #e0e0e0;
}
&.linked {
background: #ecfdf5;
color: #059669;
border: 1px solid #a7f3d0;
}
&.not-linked {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&.exempt {
background: #eff6ff;
color: #2563eb;
border: 1px solid #bfdbfe;
}
}
.status-chip-icon {
@@ -375,26 +393,6 @@
margin-bottom: 16px;
}
.status-chip {
&.linked {
background: #ecfdf5;
color: #059669;
border: 1px solid #a7f3d0;
}
&.not-linked {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&.exempt {
background: #eff6ff;
color: #2563eb;
border: 1px solid #bfdbfe;
}
}
.pan-aadhaar-detail {
margin: 12px 0;
display: flex;
@@ -468,10 +466,17 @@
padding: 0;
transition: background 0.1s;
&:last-child { border-bottom: none; }
&:hover { background: #f8fafc; }
&:last-child {
border-bottom: none;
}
&--link { background: #fafbfc; }
&:hover {
background: #f8fafc;
}
&--link {
background: #fafbfc;
}
}
.kv-table__col {
@@ -486,10 +491,25 @@
gap: 8px;
font-weight: 600;
}
&--num { flex: 1; }
&--status { flex: 0 0 150px; }
&--date { flex: 0 0 150px; color: #64748b; font-size: 12px; }
&--action { flex: 0 0 110px; text-align: right; }
&--num {
flex: 1;
}
&--status {
flex: 0 0 150px;
}
&--date {
flex: 0 0 150px;
color: #64748b;
font-size: 12px;
}
&--action {
flex: 0 0 110px;
text-align: right;
}
}
.kv-doc-icon {
@@ -520,10 +540,25 @@
height: 14px !important;
}
&--ok { background: #ecfdf5; color: #059669; }
&--pending { background: #f8fafc; color: #94a3b8; }
&--fail { background: #fef2f2; color: #dc2626; }
&--info { background: #eff6ff; color: #2563eb; }
&--ok {
background: #ecfdf5;
color: #059669;
}
&--pending {
background: #f8fafc;
color: #94a3b8;
}
&--fail {
background: #fef2f2;
color: #dc2626;
}
&--info {
background: #eff6ff;
color: #2563eb;
}
}
.kv-action-btn {
@@ -558,14 +593,21 @@
font-weight: 600;
white-space: nowrap;
mat-icon { font-size: 16px !important; width: 16px !important; height: 16px !important; }
mat-icon {
font-size: 16px !important;
width: 16px !important;
height: 16px !important;
}
}
.kv-otp-input {
width: 200px;
flex-shrink: 0;
::ng-deep .mat-mdc-form-field-infix { padding: 6px 0 !important; min-height: 36px !important; }
::ng-deep .mat-mdc-form-field-infix {
padding: 6px 0 !important;
min-height: 36px !important;
}
}
.kv-otp-btn {
@@ -594,8 +636,14 @@
border-right: 1px solid #f1f5f9;
border-bottom: 1px solid #f1f5f9;
&:nth-child(5n) { border-right: none; }
&--full { grid-column: 1 / -1; border-right: none; }
&:nth-child(5n) {
border-right: none;
}
&--full {
grid-column: 1 / -1;
border-right: none;
}
}
.kv-meta-label {
@@ -614,20 +662,51 @@
@media (width <= 960px) {
.kv-table__col {
&--doc { flex: 0 0 120px; }
&--date { display: none; }
&--action { flex: 0 0 90px; }
&--doc {
flex: 0 0 120px;
}
&--date {
display: none;
}
&--action {
flex: 0 0 90px;
}
}
.kv-meta-grid {
grid-template-columns: repeat(3, 1fr);
}
.kv-meta-item:nth-child(5n) {
border-right: 1px solid #f1f5f9;
}
.kv-meta-item:nth-child(3n) {
border-right: none;
}
.kv-meta-grid { grid-template-columns: repeat(3, 1fr); }
.kv-meta-item:nth-child(5n) { border-right: 1px solid #f1f5f9; }
.kv-meta-item:nth-child(3n) { border-right: none; }
}
@media (width <= 640px) {
.kv-table__row { flex-wrap: wrap; }
.kv-table__col { &--status, &--action { flex: 0 0 50%; } }
.kv-meta-grid { grid-template-columns: 1fr 1fr; }
.kv-otp-bar { flex-wrap: wrap; }
.kv-table__row {
flex-wrap: wrap;
}
.kv-table__col {
&--status,
&--action {
flex: 0 0 50%;
}
}
.kv-meta-grid {
grid-template-columns: 1fr 1fr;
}
.kv-otp-bar {
flex-wrap: wrap;
}
}
/* ─── Header Actions ─── */
@@ -645,7 +724,9 @@
border-color: #93c5fd !important;
}
.m-r-6 { margin-right: 6px; }
.m-r-6 {
margin-right: 6px;
}
/* ─── Re-KYC Section ─── */
.rekyc-section {
@@ -741,7 +822,9 @@
}
.rekyc-tl-item--last .rekyc-tl-dot {
.rekyc-tl-line { display: none; }
.rekyc-tl-line {
display: none;
}
}
.rekyc-tl-content {
@@ -881,7 +964,10 @@
border: 1px solid #a7f3d0;
color: #065f46;
.rekyc-result-icon { color: #059669; font-size: 20px; }
.rekyc-result-icon {
color: #059669;
font-size: 20px;
}
}
&--error {
@@ -899,5 +985,7 @@
}
@media (width <= 640px) {
.rekyc-upload-zone { max-width: 100%; }
.rekyc-upload-zone {
max-width: 100%;
}
}
@@ -447,10 +447,14 @@ export class KycTabComponent implements OnInit, OnDestroy {
getPanAadhaarStatusLabel(status: string): string {
switch (status) {
case 'Y': return 'Linked (Seeded)';
case 'N': return 'Not Linked';
case 'E': return 'Exempt';
default: return 'Pending Verification';
case 'Y':
return 'Linked (Seeded)';
case 'N':
return 'Not Linked';
case 'E':
return 'Exempt';
default:
return 'Pending Verification';
}
}
@@ -544,7 +548,14 @@ export class KycTabComponent implements OnInit, OnDestroy {
if (kyc.aadhaarVerifiedOn) y = addLine('Aadhaar Verified On:', this.formatDate(kyc.aadhaarVerifiedOn), y);
// PAN-Aadhaar Link
const seedingStatus = kyc.panAadhaarSeedingStatus === 'Y' ? 'Linked' : kyc.panAadhaarSeedingStatus === 'N' ? 'Not Linked' : kyc.panAadhaarSeedingStatus === 'E' ? 'Exempt' : 'Pending';
const seedingStatus =
kyc.panAadhaarSeedingStatus === 'Y'
? 'Linked'
: kyc.panAadhaarSeedingStatus === 'N'
? 'Not Linked'
: kyc.panAadhaarSeedingStatus === 'E'
? 'Exempt'
: 'Pending';
y = addLine('PAN-Aadhaar Link:', seedingStatus, y);
// GSTIN (Non-Individual)
@@ -573,7 +584,11 @@ export class KycTabComponent implements OnInit, OnDestroy {
y += 8;
y = addLine('KYC Type:', kyc.kycType === 1 ? 'Individual' : kyc.kycType === 2 ? 'Non-Individual' : '—', y);
y = addLine('Risk Category:', kyc.riskCategory === 1 ? 'Low' : kyc.riskCategory === 2 ? 'Medium' : kyc.riskCategory === 3 ? 'High' : '—', y);
y = addLine(
'Risk Category:',
kyc.riskCategory === 1 ? 'Low' : kyc.riskCategory === 2 ? 'Medium' : kyc.riskCategory === 3 ? 'High' : '—',
y
);
y = addLine('Initiated On:', this.formatDate(kyc.initiatedOn), y);
y = addLine('Last Verified:', this.formatDate(kyc.verifiedOn), y);
y = addLine('Expiry Date:', this.formatDate(kyc.expiryDate), y);
@@ -627,7 +642,9 @@ I further confirm that:
const footerY = doc.internal.pageSize.getHeight() - 10;
doc.setFontSize(7);
doc.setTextColor(150, 150, 150);
doc.text('This is a system-generated document. Generated from CBS Nexora — KYC Module.', pageWidth / 2, footerY, { align: 'center' });
doc.text('This is a system-generated document. Generated from CBS Nexora — KYC Module.', pageWidth / 2, footerY, {
align: 'center'
});
// Save
doc.save(`Self_Declaration_${cifNumber}_${clientName.replace(/\s/g, '_')}.pdf`);
@@ -654,7 +671,10 @@ I further confirm that:
const formData = new FormData();
formData.append('file', this.selectedDeclarationFile);
formData.append('name', 'KYC Self Declaration');
formData.append('description', `Signed self-declaration form uploaded on ${new Date().toLocaleDateString('en-IN')}`);
formData.append(
'description',
`Signed self-declaration form uploaded on ${new Date().toLocaleDateString('en-IN')}`
);
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
next: () => {
@@ -668,13 +688,19 @@ I further confirm that:
},
error: (err: any) => {
this.reKycCompleting = false;
this.reKycError = err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Failed to complete re-KYC. Please try again.';
this.reKycError =
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
'Failed to complete re-KYC. Please try again.';
}
});
},
error: (err: any) => {
this.reKycCompleting = false;
this.reKycError = err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Failed to upload declaration. Please try again.';
this.reKycError =
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
'Failed to upload declaration. Please try again.';
}
});
}
@@ -6,7 +6,6 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="nom-container mat-typography">
<!-- Header -->
<div class="nom-page-header">
<div class="nom-page-header__left">
@@ -39,9 +38,12 @@
<div class="nom-type-chips">
@for (group of accountGroups; track group.type) {
@if (group.accounts.length > 0) {
<button type="button" class="nom-type-chip"
<button
type="button"
class="nom-type-chip"
[class.active]="selectedType === group.type"
(click)="selectedType = group.type; onTypeChange()">
(click)="selectedType = group.type; onTypeChange()"
>
<mat-icon>{{ group.icon }}</mat-icon>
{{ group.label }}
<span class="nom-type-chip__count">{{ group.accounts.length }}</span>
@@ -55,13 +57,23 @@
<mat-label>Account</mat-label>
<mat-select [(ngModel)]="selectedAccountId" (ngModelChange)="onAccountChange()">
@for (acc of selectedGroup.accounts; track acc.id) {
<mat-option [value]="acc.id">{{ acc.productName }} — {{ acc.accountNo }}@if (acc.accountBalance != null) { ({{ acc.accountBalance | number:'1.2-2' }}) }</mat-option>
<mat-option [value]="acc.id"
>{{ acc.productName }} — {{ acc.accountNo }}
@if (acc.accountBalance != null) {
({{ acc.accountBalance | number: '1.2-2' }})
}
</mat-option>
}
</mat-select>
</mat-form-field>
@if (selectedAccountId && !showAddForm) {
<button mat-flat-button color="primary" class="nom-add-btn"
[disabled]="totalShare >= 100" (click)="showAddForm = true">
<button
mat-flat-button
color="primary"
class="nom-add-btn"
[disabled]="totalShare >= 100"
(click)="showAddForm = true"
>
<mat-icon>add</mat-icon> Add Nominee
</button>
}
@@ -83,10 +95,12 @@
<div class="nom-share-bar">
<span class="nom-share-bar__label">Share</span>
<div class="nom-share-bar__track">
<div class="nom-share-bar__fill"
<div
class="nom-share-bar__fill"
[style.width.%]="totalShare > 100 ? 100 : totalShare"
[class.over]="totalShare > 100"
[class.complete]="totalShare === 100"></div>
[class.complete]="totalShare === 100"
></div>
</div>
<span class="nom-share-bar__value" [class.over]="totalShare > 100">{{ totalShare }}%</span>
</div>
@@ -97,8 +111,12 @@
<div class="nom-card__main">
<div class="nom-card__avatar">
@if (nominee.profileImageUrl) {
<img [src]="nominee.profileImageUrl" alt="" class="nom-card__photo zoomable"
(click)="zoomImage(nominee.profileImageUrl, 'Nominee Photo')" />
<img
[src]="nominee.profileImageUrl"
alt=""
class="nom-card__photo zoomable"
(click)="zoomImage(nominee.profileImageUrl, 'Nominee Photo')"
/>
} @else {
<mat-icon>person</mat-icon>
}
@@ -107,14 +125,22 @@
<span class="nom-card__name">{{ nominee.nomineeDisplayName || nominee.firstName || '—' }}</span>
<span class="nom-card__meta">
{{ getRelationship(nominee) }}
@if (nominee.nomineeDateOfBirth || nominee.dateOfBirth) { · DOB: {{ (nominee.nomineeDateOfBirth || nominee.dateOfBirth) | dateFormat }} }
@if (nominee.nomineeMobileNo || nominee.mobileNo) { · {{ nominee.nomineeMobileNo || nominee.mobileNo }} }
@if (nominee.nomineeDateOfBirth || nominee.dateOfBirth) {
· DOB: {{ nominee.nomineeDateOfBirth || nominee.dateOfBirth | dateFormat }}
}
@if (nominee.nomineeMobileNo || nominee.mobileNo) {
· {{ nominee.nomineeMobileNo || nominee.mobileNo }}
}
</span>
</div>
@if (nominee.signatureUrl) {
<div class="nom-card__sig">
<img [src]="nominee.signatureUrl" alt="Signature" class="zoomable"
(click)="zoomImage(nominee.signatureUrl, 'Nominee Signature')" />
<img
[src]="nominee.signatureUrl"
alt="Signature"
class="zoomable"
(click)="zoomImage(nominee.signatureUrl, 'Nominee Signature')"
/>
</div>
}
@if (nominee.nomineeIdentifier) {
@@ -124,16 +150,30 @@
<span class="nom-card__share-value">{{ nominee.percentageShare || 0 }}%</span>
</div>
<div class="nom-card__actions">
<button mat-icon-button type="button" matTooltip="View" (click)="viewNominee(nominee)"><mat-icon>visibility</mat-icon></button>
<button mat-icon-button type="button" matTooltip="Edit" (click)="editNominee(nominee, i)"><mat-icon>edit</mat-icon></button>
<button mat-icon-button type="button" matTooltip="Delete" class="nom-card__delete" (click)="deleteNominee(nominee, i)"><mat-icon>delete</mat-icon></button>
<button mat-icon-button type="button" matTooltip="View" (click)="viewNominee(nominee)">
<mat-icon>visibility</mat-icon>
</button>
<button mat-icon-button type="button" matTooltip="Edit" (click)="editNominee(nominee, i)">
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
type="button"
matTooltip="Delete"
class="nom-card__delete"
(click)="deleteNominee(nominee, i)"
>
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
@if (nominee.nomineeIsMinor && (nominee.guardianDisplayName || nominee.guardianName)) {
<div class="nom-card__guardian">
<mat-icon>shield</mat-icon>
Guardian: <strong>{{ nominee.guardianDisplayName || nominee.guardianName }}</strong>
@if (nominee.guardianRelationshipName) { ({{ nominee.guardianRelationshipName }}) }
@if (nominee.guardianRelationshipName) {
({{ nominee.guardianRelationshipName }})
}
</div>
}
<!-- Inline Edit -->
@@ -143,24 +183,39 @@
<mat-form-field appearance="outline" class="nom-edit-field">
<mat-label>Type</mat-label>
<mat-select [(ngModel)]="editNomType">
@for (opt of nominationTypeOptions; track opt.value) { <mat-option [value]="opt.value">{{ opt.label }}</mat-option> }
@for (opt of nominationTypeOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="nom-edit-field">
<mat-label>Relationship</mat-label>
<mat-select [(ngModel)]="editRelId">
@for (rel of relationshipOptions; track rel) { <mat-option [value]="rel.id">{{ rel.name }}</mat-option> }
@for (rel of relationshipOptions; track rel) {
<mat-option [value]="rel.id">{{ rel.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="nom-edit-field nom-edit-field--sm">
<mat-label>Share %</mat-label>
<input matInput type="number" [(ngModel)]="editShare" min="1" [max]="maxShareForEdit(i)" />
@if (editShare && editShare > maxShareForEdit(i)) { <mat-error>Max {{ maxShareForEdit(i) }}%</mat-error> }
@if (editShare && editShare > maxShareForEdit(i)) {
<mat-error>Max {{ maxShareForEdit(i) }}%</mat-error>
}
</mat-form-field>
<button mat-flat-button color="primary" type="button" class="nom-edit-save"
<button
mat-flat-button
color="primary"
type="button"
class="nom-edit-save"
[disabled]="!editRelId || !editShare || editShare > maxShareForEdit(i)"
(click)="saveEdit(nominee, i)"><mat-icon>check</mat-icon></button>
<button mat-stroked-button type="button" class="nom-edit-cancel" (click)="cancelEdit()"><mat-icon>close</mat-icon></button>
(click)="saveEdit(nominee, i)"
>
<mat-icon>check</mat-icon>
</button>
<button mat-stroked-button type="button" class="nom-edit-cancel" (click)="cancelEdit()">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
}
@@ -185,17 +240,39 @@
<mat-form-field appearance="outline" class="nom-search-field">
<mat-label>Search by Name, Client ID or Mobile</mat-label>
<span matPrefix class="nom-search-prefix"><mat-icon>search</mat-icon></span>
<input matInput [value]="searchText" (input)="onSearch($any($event.target).value)" (blur)="dismissSearch()" placeholder="Type at least 2 characters..." autocomplete="off" />
<input
matInput
[value]="searchText"
(input)="onSearch($any($event.target).value)"
(blur)="dismissSearch()"
placeholder="Type at least 2 characters..."
autocomplete="off"
/>
</mat-form-field>
@if (searchResults.length > 0 || searching) {
<div class="nom-search-results">
@if (searching) { <div class="nom-search-loading"><mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Searching...</div> }
@if (searching) {
<div class="nom-search-loading">
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Searching...
</div>
}
@for (client of searchResults; track client.id) {
<div class="nom-search-item" (mousedown)="selectClient(client); $event.preventDefault()">
<mat-icon class="nom-search-item__icon">person</mat-icon>
<div class="nom-search-item__info">
<span class="nom-search-item__name">{{ client.displayName }}</span>
<span class="nom-search-item__meta">ID: {{ client.id }} · {{ client.officeName }}@if (client.dateOfBirth) { · DOB: {{ client.dateOfBirth | dateFormat }} }@if (client.fatherSpouseName) { · {{ client.fatherSpouseName }} }@if (client.mobileNo) { · {{ client.mobileNo }} }</span>
<span class="nom-search-item__meta"
>ID: {{ client.id }} · {{ client.officeName }}
@if (client.dateOfBirth) {
· DOB: {{ client.dateOfBirth | dateFormat }}
}
@if (client.fatherSpouseName) {
· {{ client.fatherSpouseName }}
}
@if (client.mobileNo) {
· {{ client.mobileNo }}
}
</span>
</div>
</div>
}
@@ -212,32 +289,68 @@
<div class="nom-linked-card">
<div class="nom-linked-card__left">
@if (linkedProfileImage) {
<img [src]="linkedProfileImage" class="nom-linked-card__photo zoomable" (click)="zoomImage(linkedProfileImage, 'Photo')" />
} @else { <mat-icon class="nom-linked-card__icon">person</mat-icon> }
<img
[src]="linkedProfileImage"
class="nom-linked-card__photo zoomable"
(click)="zoomImage(linkedProfileImage, 'Photo')"
/>
} @else {
<mat-icon class="nom-linked-card__icon">person</mat-icon>
}
</div>
<div class="nom-linked-card__info">
<span class="nom-linked-card__name">{{ selectedClient.displayName }}</span>
<span class="nom-linked-card__meta">ID: {{ selectedClient.id }}@if (selectedClient.mobileNo) { · {{ selectedClient.mobileNo }} }</span>
<span class="nom-linked-card__meta"
>ID: {{ selectedClient.id }}
@if (selectedClient.mobileNo) {
· {{ selectedClient.mobileNo }}
}
</span>
</div>
@if (linkedSignatureImage) {
<div class="nom-linked-card__sig">
<img [src]="linkedSignatureImage" alt="Signature" class="zoomable" (click)="zoomImage(linkedSignatureImage, 'Signature')" />
<img
[src]="linkedSignatureImage"
alt="Signature"
class="zoomable"
(click)="zoomImage(linkedSignatureImage, 'Signature')"
/>
</div>
}
<button mat-icon-button type="button" (click)="removeSelectedClient()"><mat-icon>close</mat-icon></button>
</div>
@if (fetchingDetails) {
<div class="nom-fetching-hint"><mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> Fetching details...</div>
<div class="nom-fetching-hint">
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> Fetching details...
</div>
}
@if (!fetchingDetails) {
<!-- KYC details -->
@if (selectedClient.dateOfBirth || selectedClient.panNumber || selectedClient.aadhaarMasked) {
<div class="nom-readonly-row">
@if (selectedClient.dateOfBirth) { <span class="nom-ro-item"><span class="nom-ro-label">DOB</span> {{ selectedClient.dateOfBirth | dateFormat }}</span> }
@if (selectedClient.panNumber) { <span class="nom-ro-item"><span class="nom-ro-label">PAN</span> {{ selectedClient.panNumber }} @if (linkedKycStatus?.panVerified) { <mat-icon class="nom-ro-ok">check_circle</mat-icon> }</span> }
@if (selectedClient.aadhaarMasked) { <span class="nom-ro-item"><span class="nom-ro-label">Aadhaar</span> {{ selectedClient.aadhaarMasked }} @if (linkedKycStatus?.aadhaarVerified) { <mat-icon class="nom-ro-ok">check_circle</mat-icon> }</span> }
@if (selectedClient.dateOfBirth) {
<span class="nom-ro-item"
><span class="nom-ro-label">DOB</span> {{ selectedClient.dateOfBirth | dateFormat }}</span
>
}
@if (selectedClient.panNumber) {
<span class="nom-ro-item"
><span class="nom-ro-label">PAN</span> {{ selectedClient.panNumber }}
@if (linkedKycStatus?.panVerified) {
<mat-icon class="nom-ro-ok">check_circle</mat-icon>
}
</span>
}
@if (selectedClient.aadhaarMasked) {
<span class="nom-ro-item"
><span class="nom-ro-label">Aadhaar</span> {{ selectedClient.aadhaarMasked }}
@if (linkedKycStatus?.aadhaarVerified) {
<mat-icon class="nom-ro-ok">check_circle</mat-icon>
}
</span>
}
</div>
}
@@ -246,19 +359,25 @@
<mat-form-field appearance="outline" class="nom-field-item">
<mat-label>Nomination Type</mat-label>
<mat-select [(ngModel)]="nomineeNomType">
@for (opt of nominationTypeOptions; track opt.value) { <mat-option [value]="opt.value">{{ opt.label }}</mat-option> }
@for (opt of nominationTypeOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="nom-field-item">
<mat-label>Relationship</mat-label>
<mat-select [(ngModel)]="nomineeRelationshipId">
@for (rel of relationshipOptions; track rel) { <mat-option [value]="rel.id">{{ rel.name }}</mat-option> }
@for (rel of relationshipOptions; track rel) {
<mat-option [value]="rel.id">{{ rel.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="nom-field-item nom-field-item--sm">
<mat-label>Share %</mat-label>
<input matInput type="number" [(ngModel)]="nomineeShare" min="1" [max]="maxAllowedShare" />
@if (nomineeShare && nomineeShare > maxAllowedShare) { <mat-error>Max {{ maxAllowedShare }}%</mat-error> }
@if (nomineeShare && nomineeShare > maxAllowedShare) {
<mat-error>Max {{ maxAllowedShare }}%</mat-error>
}
</mat-form-field>
<div class="nom-field-item nom-field-item--toggle">
<mat-slide-toggle [(ngModel)]="nomineeIsMinor">Minor</mat-slide-toggle>
@@ -270,11 +389,21 @@
<span class="nom-guardian-label"><mat-icon>shield</mat-icon> Guardian</span>
@if (!selectedGuardian) {
<mat-form-field appearance="outline" class="nom-search-field">
<input matInput [ngModel]="guardianSearchText" (ngModelChange)="onGuardianSearch($event)" placeholder="Search guardian..." autocomplete="off" />
<input
matInput
[ngModel]="guardianSearchText"
(ngModelChange)="onGuardianSearch($event)"
placeholder="Search guardian..."
autocomplete="off"
/>
</mat-form-field>
@if (guardianSearchResults.length > 0 || guardianSearching) {
<div class="nom-search-results nom-search-results--inline">
@if (guardianSearching) { <div class="nom-search-loading"><mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner></div> }
@if (guardianSearching) {
<div class="nom-search-loading">
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
</div>
}
@for (client of guardianSearchResults; track client.id) {
<div class="nom-search-item" (click)="selectGuardian(client)">
<mat-icon class="nom-search-item__icon">person</mat-icon>
@@ -284,7 +413,12 @@
</div>
}
} @else {
<span class="nom-guardian-selected">{{ selectedGuardian.displayName }} <button mat-icon-button (click)="selectedGuardian = null"><mat-icon>close</mat-icon></button></span>
<span class="nom-guardian-selected"
>{{ selectedGuardian.displayName }}
<button mat-icon-button (click)="selectedGuardian = null">
<mat-icon>close</mat-icon>
</button></span
>
}
</div>
}
@@ -296,11 +430,24 @@
<div class="nom-form-actions">
<button mat-stroked-button type="button" (click)="resetAddForm()">Cancel</button>
<button mat-raised-button color="primary" type="button"
[disabled]="!nomineeRelationshipId || !nomineeShare || nomineeSaving || (nomineeShare > maxAllowedShare) || (nomineeIsMinor && !selectedGuardian)"
(click)="saveNominee()">
@if (nomineeSaving) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> }
@else { <mat-icon>check</mat-icon> Save }
<button
mat-raised-button
color="primary"
type="button"
[disabled]="
!nomineeRelationshipId ||
!nomineeShare ||
nomineeSaving ||
nomineeShare > maxAllowedShare ||
(nomineeIsMinor && !selectedGuardian)
"
(click)="saveNominee()"
>
@if (nomineeSaving) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<mat-icon>check</mat-icon> Save
}
</button>
</div>
}
@@ -8,189 +8,708 @@
$primary: #1074b9;
$primary-light: #e8f0fe;
.nom-container { padding: 16px 24px; }
.nom-container {
padding: 16px 24px;
}
/* Header */
.nom-page-header {
background: #0f172a; padding: 14px 20px; border-radius: 10px; margin-bottom: 16px;
&__left { display: flex; align-items: center; gap: 10px; }
&__icon { color: #60a5fa; }
&__title { font-size: 15px; font-weight: 600; color: #fff; margin: 0; }
&__sub { font-size: 10px; color: rgb(255 255 255 / 45%); margin: 1px 0 0; }
background: #0f172a;
padding: 14px 20px;
border-radius: 10px;
margin-bottom: 16px;
&__left {
display: flex;
align-items: center;
gap: 10px;
}
&__icon {
color: #60a5fa;
}
&__title {
font-size: 15px;
font-weight: 600;
color: #fff;
margin: 0;
}
&__sub {
font-size: 10px;
color: rgb(255 255 255 / 45%);
margin: 1px 0 0;
}
}
.nom-loading { display: flex; align-items: center; gap: 8px; padding: 24px 0; justify-content: center; font-size: 13px; color: #64748b;
&--sm { padding: 12px 0; }
.nom-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 24px 0;
justify-content: center;
font-size: 13px;
color: #64748b;
&--sm {
padding: 12px 0;
}
}
.nom-no-accounts {
text-align: center; padding: 36px 20px; background: #f8fafc; border-radius: 8px; border: 1px dashed #e2e8f0;
&__icon { font-size: 40px; width: 40px; height: 40px; color: #94a3b8; margin-bottom: 8px; }
p { color: #64748b; font-size: 13px; margin: 2px 0; }
&__sub { font-size: 11px; color: #94a3b8; }
text-align: center;
padding: 36px 20px;
background: #f8fafc;
border-radius: 8px;
border: 1px dashed #e2e8f0;
&__icon {
font-size: 40px;
width: 40px;
height: 40px;
color: #94a3b8;
margin-bottom: 8px;
}
p {
color: #64748b;
font-size: 13px;
margin: 2px 0;
}
&__sub {
font-size: 11px;
color: #94a3b8;
}
}
/* Toolbar: chips on top, account + add below */
.nom-toolbar {
display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px;
&__right { display: flex; align-items: center; gap: 10px; }
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
&__right {
display: flex;
align-items: center;
gap: 10px;
}
}
.nom-type-chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.nom-type-chips { display: flex; gap: 6px; flex-wrap: wrap; }
.nom-type-chip {
display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px;
border: 1.5px solid #e2e8f0; border-radius: 6px; background: #fff;
font-size: 12px; font-weight: 500; color: #64748b; cursor: pointer; transition: all 0.15s;
mat-icon { font-size: 16px; width: 16px; height: 16px; }
&__count { font-size: 9px; font-weight: 700; background: #f1f5f9; color: #64748b; padding: 0 5px; border-radius: 6px; }
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border: 1.5px solid #e2e8f0;
border-radius: 6px;
background: #fff;
font-size: 12px;
font-weight: 500;
color: #64748b;
cursor: pointer;
transition: all 0.15s;
&.active { border-color: $primary; background: $primary-light; color: $primary;
.nom-type-chip__count { background: $primary; color: #fff; }
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&__count {
font-size: 9px;
font-weight: 700;
background: #f1f5f9;
color: #64748b;
padding: 0 5px;
border-radius: 6px;
}
&.active {
border-color: $primary;
background: $primary-light;
color: $primary;
.nom-type-chip__count {
background: $primary;
color: #fff;
}
}
&:hover:not(.active) {
border-color: #94a3b8;
}
&:hover:not(.active) { border-color: #94a3b8; }
}
.nom-account-select { flex: 1; min-width: 280px; }
.nom-add-btn { border-radius: 6px !important; font-size: 12px !important; height: 36px !important; white-space: nowrap; }
.nom-account-select {
flex: 1;
min-width: 280px;
}
.nom-add-btn {
border-radius: 6px !important;
font-size: 12px !important;
height: 36px !important;
white-space: nowrap;
}
/* Share bar */
.nom-share-bar {
display: flex; align-items: center; gap: 8px; padding: 6px 12px;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 8px;
&__label { font-size: 11px; font-weight: 600; color: #64748b; white-space: nowrap; }
&__track { flex: 1; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; }
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 8px;
&__label {
font-size: 11px;
font-weight: 600;
color: #64748b;
white-space: nowrap;
}
&__track {
flex: 1;
height: 6px;
background: #e2e8f0;
border-radius: 3px;
overflow: hidden;
}
&__fill {
height: 100%; border-radius: 3px; transition: width 0.3s;
height: 100%;
border-radius: 3px;
transition: width 0.3s;
background: linear-gradient(90deg, #3b82f6, #10b981);
&.over { background: #ef4444; } &.complete { background: linear-gradient(90deg, #10b981, #059669); }
&.over {
background: #ef4444;
}
&.complete {
background: linear-gradient(90deg, #10b981, #059669);
}
}
&__value {
font-size: 13px;
font-weight: 700;
color: #10b981;
min-width: 35px;
text-align: right;
&.over {
color: #ef4444;
}
}
&__value { font-size: 13px; font-weight: 700; color: #10b981; min-width: 35px; text-align: right; &.over { color: #ef4444; } }
}
/* Nominee card — compact */
.nom-card { border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 6px; overflow: hidden; }
.nom-card__main { display: flex; align-items: center; gap: 10px; padding: 8px 12px; }
.nom-card {
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 6px;
overflow: hidden;
}
.nom-card__main {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
}
.nom-card__avatar {
width: 32px; height: 32px; border-radius: 50%; background: #dbeafe;
display: flex; align-items: center; justify-content: center; color: #1565c0; flex-shrink: 0;
mat-icon { font-size: 18px; }
width: 32px;
height: 32px;
border-radius: 50%;
background: #dbeafe;
display: flex;
align-items: center;
justify-content: center;
color: #1565c0;
flex-shrink: 0;
mat-icon {
font-size: 18px;
}
}
.nom-card__photo {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.nom-card__photo { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.nom-card__sig {
flex-shrink: 0;
img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 3px; background: #fff; padding: 1px 3px; }
}
.nom-card__info { flex: 1; min-width: 0; }
.nom-card__name { font-weight: 600; font-size: 13px; color: #1e293b; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nom-card__meta { font-size: 11px; color: #64748b; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
img {
height: 24px;
max-width: 60px;
object-fit: contain;
border: 1px solid #e2e8f0;
border-radius: 3px;
background: #fff;
padding: 1px 3px;
}
}
.nom-card__info {
flex: 1;
min-width: 0;
}
.nom-card__name {
font-weight: 600;
font-size: 13px;
color: #1e293b;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nom-card__meta {
font-size: 11px;
color: #64748b;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nom-card__id {
font-size: 10px; font-weight: 600; color: #1d4ed8; background: #eff6ff;
padding: 2px 8px; border-radius: 4px; white-space: nowrap;
font-family: 'Roboto Mono', monospace; letter-spacing: 0.3px; flex-shrink: 0;
font-size: 10px;
font-weight: 600;
color: #1d4ed8;
background: #eff6ff;
padding: 2px 8px;
border-radius: 4px;
white-space: nowrap;
font-family: 'Roboto Mono', monospace;
letter-spacing: 0.3px;
flex-shrink: 0;
}
.nom-card__share {
text-align: center;
min-width: 40px;
flex-shrink: 0;
}
.nom-card__share-value {
font-size: 15px;
font-weight: 700;
color: #1565c0;
}
.nom-card__actions {
display: flex;
gap: 0;
flex-shrink: 0;
button {
width: 32px !important;
height: 32px !important;
line-height: 32px !important;
mat-icon {
font-size: 18px;
}
}
}
.nom-card__share { text-align: center; min-width: 40px; flex-shrink: 0; }
.nom-card__share-value { font-size: 15px; font-weight: 700; color: #1565c0; }
.nom-card__actions { display: flex; gap: 0; flex-shrink: 0;
.nom-card__delete {
color: #94a3b8 !important;
button { width: 32px !important; height: 32px !important; line-height: 32px !important;
mat-icon { font-size: 18px; }
&:hover {
color: #ef4444 !important;
}
}
.nom-card__delete { color: #94a3b8 !important; &:hover { color: #ef4444 !important; } }
.nom-card__guardian {
display: flex; align-items: center; gap: 5px; padding: 5px 12px; background: #fff7ed;
font-size: 11px; color: #92400e; border-top: 1px solid #fde68a;
mat-icon { font-size: 14px; width: 14px; height: 14px; }
display: flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
background: #fff7ed;
font-size: 11px;
color: #92400e;
border-top: 1px solid #fde68a;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
/* Inline edit */
.nom-card__edit { padding: 8px 12px; border-top: 1px solid #e2e8f0; background: #f8fafc; }
.nom-edit-row { display: flex; align-items: center; gap: 8px; }
.nom-edit-field { flex: 1; &--sm { max-width: 100px; } }
.nom-edit-save, .nom-edit-cancel { min-width: 36px !important; height: 36px !important; padding: 0 !important; }
.nom-card__edit {
padding: 8px 12px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.nom-edit-row {
display: flex;
align-items: center;
gap: 8px;
}
.nom-edit-field {
flex: 1;
&--sm {
max-width: 100px;
}
}
.nom-edit-save,
.nom-edit-cancel {
min-width: 36px !important;
height: 36px !important;
padding: 0 !important;
}
/* Empty */
.nom-empty {
text-align: center; padding: 24px; background: #f8fafc; border-radius: 6px; border: 1px dashed #e2e8f0;
mat-icon { font-size: 28px; width: 28px; height: 28px; color: #94a3b8; margin-bottom: 4px; display: block; margin: 0 auto 4px; }
span { font-size: 12px; color: #94a3b8; }
text-align: center;
padding: 24px;
background: #f8fafc;
border-radius: 6px;
border: 1px dashed #e2e8f0;
mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
color: #94a3b8;
display: block;
margin: 0 auto 4px;
}
span {
font-size: 12px;
color: #94a3b8;
}
}
/* ═══ Add Form ═══ */
.nom-add-form {
border: 1px solid #e2e8f0; border-radius: 8px; padding: 14px; margin-top: 8px; background: #fafbfc;
&__title { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: #1e293b; margin-bottom: 12px; }
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px;
margin-top: 8px;
background: #fafbfc;
&__title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #1e293b;
margin-bottom: 12px;
}
}
/* Search */
.nom-search-wrapper { position: relative; margin-bottom: 8px; }
.nom-search-field { width: 100%; }
.nom-search-prefix { color: #94a3b8; }
.nom-search-wrapper {
position: relative;
margin-bottom: 8px;
}
.nom-search-field {
width: 100%;
}
.nom-search-prefix {
color: #94a3b8;
}
.nom-search-results {
position: absolute; top: 56px; left: 0; right: 0; z-index: 100;
background: #fff; border: 1px solid #e2e8f0; border-radius: 6px;
box-shadow: 0 6px 20px rgb(0 0 0 / 10%); max-height: 220px; overflow-y: auto;
&--inline { position: relative; top: 0; margin-top: 4px; }
position: absolute;
top: 56px;
left: 0;
right: 0;
z-index: 100;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
box-shadow: 0 6px 20px rgb(0 0 0 / 10%);
max-height: 220px;
overflow-y: auto;
&--inline {
position: relative;
top: 0;
margin-top: 4px;
}
}
.nom-search-loading {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px;
font-size: 12px;
color: #64748b;
}
.nom-search-loading { display: flex; align-items: center; gap: 6px; padding: 10px 12px; font-size: 12px; color: #64748b; }
.nom-search-item {
display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #f1f5f9;
&:last-child { border-bottom: none; } &:hover { background: #f0f7ff; }
&__icon { color: $primary; font-size: 18px; }
&__info { display: flex; flex-direction: column; flex: 1; }
&__name { font-size: 13px; font-weight: 600; color: #1e293b; }
&__meta { font-size: 10px; color: #64748b; }
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
&:hover {
background: #f0f7ff;
}
&__icon {
color: $primary;
font-size: 18px;
}
&__info {
display: flex;
flex-direction: column;
flex: 1;
}
&__name {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
&__meta {
font-size: 10px;
color: #64748b;
}
}
.search-no-results-hint {
font-size: 11px;
color: #94a3b8;
text-align: center;
padding: 4px 0;
}
.search-no-results-hint { font-size: 11px; color: #94a3b8; text-align: center; padding: 4px 0; }
/* Linked card */
.nom-linked-card {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 8px;
&__left { flex-shrink: 0; }
&__icon { color: $primary; }
&__photo { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; border: 2px solid #e2e8f0; }
&__info { flex: 1; display: flex; flex-direction: column; }
&__name { font-size: 13px; font-weight: 600; color: #1e293b; }
&__meta { font-size: 11px; color: #64748b; font-family: 'Roboto Mono', monospace; }
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 8px;
&__left {
flex-shrink: 0;
}
&__icon {
color: $primary;
}
&__photo {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e2e8f0;
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
}
&__name {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
&__meta {
font-size: 11px;
color: #64748b;
font-family: 'Roboto Mono', monospace;
}
&__sig {
flex-shrink: 0;
img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 3px; background: #fff; padding: 1px 3px; }
img {
height: 24px;
max-width: 60px;
object-fit: contain;
border: 1px solid #e2e8f0;
border-radius: 3px;
background: #fff;
padding: 1px 3px;
}
}
}
.nom-fetching-hint { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #64748b; margin: -4px 0 8px; }
.nom-fetching-hint {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #64748b;
margin: -4px 0 8px;
}
/* Read-only KYC row */
.nom-readonly-row {
display: flex; flex-wrap: wrap; gap: 16px; padding: 8px 12px;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 10px;
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 10px;
}
.nom-ro-item {
font-size: 12px;
color: #1e293b;
display: inline-flex;
align-items: center;
gap: 4px;
}
.nom-ro-label {
font-size: 10px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
margin-right: 2px;
}
.nom-ro-ok {
font-size: 14px !important;
width: 14px !important;
height: 14px !important;
color: #16a34a;
}
.nom-ro-item { font-size: 12px; color: #1e293b; display: inline-flex; align-items: center; gap: 4px; }
.nom-ro-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; margin-right: 2px; }
.nom-ro-ok { font-size: 14px !important; width: 14px !important; height: 14px !important; color: #16a34a; }
/* Nominee fields */
.nom-fields-row { display: flex; flex-wrap: wrap; gap: 0 12px; align-items: center; margin-bottom: 4px; }
.nom-field-item { flex: 1; min-width: 160px; &--sm { max-width: 100px; min-width: 80px; } &--toggle { padding-top: 8px; } }
.nom-remarks-field { width: 100%; }
.nom-form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
.nom-fields-row {
display: flex;
flex-wrap: wrap;
gap: 0 12px;
align-items: center;
margin-bottom: 4px;
}
.nom-field-item {
flex: 1;
min-width: 160px;
&--sm {
max-width: 100px;
min-width: 80px;
}
&--toggle {
padding-top: 8px;
}
}
.nom-remarks-field {
width: 100%;
}
.nom-form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
/* Guardian */
.nom-guardian-section { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.nom-guardian-label { display: flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 600; color: #92400e; mat-icon { font-size: 16px; } }
.nom-guardian-section {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.nom-guardian-selected { display: flex; align-items: center; gap: 4px; font-size: 13px; font-weight: 500; color: #1e293b;
button { width: 24px !important; height: 24px !important; line-height: 24px !important; mat-icon { font-size: 14px; } }
.nom-guardian-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
color: #92400e;
mat-icon {
font-size: 16px;
}
}
.nom-guardian-selected {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 500;
color: #1e293b;
button {
width: 24px !important;
height: 24px !important;
line-height: 24px !important;
mat-icon {
font-size: 14px;
}
}
}
/* Zoom */
.zoomable { cursor: zoom-in; transition: transform 0.15s; &:hover { transform: scale(1.08); box-shadow: 0 4px 12px rgb(0 0 0 / 15%); } }
::ng-deep .image-zoom-panel { .mat-mdc-dialog-container { padding: 0 !important; border-radius: 8px !important; overflow: hidden; } }
.zoomable {
cursor: zoom-in;
transition: transform 0.15s;
&:hover {
transform: scale(1.08);
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
}
::ng-deep .image-zoom-panel {
.mat-mdc-dialog-container {
padding: 0 !important;
border-radius: 8px !important;
overflow: hidden;
}
}
@@ -109,11 +109,11 @@ export class NomineeTabComponent implements OnInit {
editNomType: string = 'DA1';
get hasAccounts(): boolean {
return this.accountGroups.some(g => g.accounts.length > 0);
return this.accountGroups.some((g) => g.accounts.length > 0);
}
get selectedGroup(): AccountGroup | undefined {
return this.accountGroups.find(g => g.type === this.selectedType);
return this.accountGroups.find((g) => g.type === this.selectedType);
}
get selectedAccount(): any {
@@ -154,13 +154,43 @@ export class NomineeTabComponent implements OnInit {
next: (data: any) => {
this.loading = false;
this.accountGroups = [
{ type: 'savings', apiType: 'SB', label: 'Savings Account', icon: 'savings', accounts: this.filterActive(data.savingsAccounts) },
{ type: 'current', apiType: 'CB', label: 'Current Account', icon: 'account_balance', accounts: this.filterActive(data.currentAccounts) },
{ type: 'fd', apiType: 'FD', label: 'Fixed Deposit', icon: 'lock', accounts: this.filterActive(data.fixedDepositAccounts) },
{ type: 'rd', apiType: 'RD', label: 'Recurring Deposit', icon: 'autorenew', accounts: this.filterActive(data.recurringDepositAccounts) },
{ type: 'loan', apiType: 'LA', label: 'Loan Account', icon: 'credit_score', accounts: this.filterActive(data.loanAccounts) }
{
type: 'savings',
apiType: 'SB',
label: 'Savings Account',
icon: 'savings',
accounts: this.filterActive(data.savingsAccounts)
},
{
type: 'current',
apiType: 'CB',
label: 'Current Account',
icon: 'account_balance',
accounts: this.filterActive(data.currentAccounts)
},
{
type: 'fd',
apiType: 'FD',
label: 'Fixed Deposit',
icon: 'lock',
accounts: this.filterActive(data.fixedDepositAccounts)
},
{
type: 'rd',
apiType: 'RD',
label: 'Recurring Deposit',
icon: 'autorenew',
accounts: this.filterActive(data.recurringDepositAccounts)
},
{
type: 'loan',
apiType: 'LA',
label: 'Loan Account',
icon: 'credit_score',
accounts: this.filterActive(data.loanAccounts)
}
];
const firstWithAccounts = this.accountGroups.find(g => g.accounts.length > 0);
const firstWithAccounts = this.accountGroups.find((g) => g.accounts.length > 0);
if (firstWithAccounts) {
this.selectedType = firstWithAccounts.type;
// Auto-select if only 1 account in the group
@@ -170,18 +200,16 @@ export class NomineeTabComponent implements OnInit {
}
}
},
error: () => { this.loading = false; }
error: () => {
this.loading = false;
}
});
}
private filterActive(accounts: any[]): any[] {
if (!accounts) return [];
// Include all non-closed accounts (active, disbursed, pending, etc.)
return accounts.filter((a: any) =>
a.status?.active ||
a.status?.value === 'Active' ||
!a.status?.closed
);
return accounts.filter((a: any) => a.status?.active || a.status?.value === 'Active' || !a.status?.closed);
}
onTypeChange(): void {
@@ -198,7 +226,9 @@ export class NomineeTabComponent implements OnInit {
onAccountChange(): void {
this.nominees = [];
this.resetAddForm();
if (this.selectedAccountId) { this.loadNominees(); }
if (this.selectedAccountId) {
this.loadNominees();
}
}
private loadNominees(): void {
@@ -210,9 +240,12 @@ export class NomineeTabComponent implements OnInit {
this.nomineesLoading = false;
this.nominees = Array.isArray(data) ? data : [];
// Enrich each nominee with profile image & signature
this.nominees.forEach(n => this.enrichNomineeImages(n));
this.nominees.forEach((n) => this.enrichNomineeImages(n));
},
error: () => { this.nomineesLoading = false; this.nominees = []; }
error: () => {
this.nomineesLoading = false;
this.nominees = [];
}
});
}
@@ -224,7 +257,9 @@ export class NomineeTabComponent implements OnInit {
// Profile image
this.clientsService.getClientProfileImage(cid).subscribe({
next: (img: any) => {
if (img) { nominee.profileImageUrl = this.sanitizer.bypassSecurityTrustResourceUrl(img); }
if (img) {
nominee.profileImageUrl = this.sanitizer.bypassSecurityTrustResourceUrl(img);
}
},
error: () => {}
});
@@ -232,8 +267,8 @@ export class NomineeTabComponent implements OnInit {
// Signature
this.clientsService.getClientDocuments(cid).subscribe({
next: (docs: any[]) => {
const sigDoc = docs?.find((d: any) =>
d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
const sigDoc = docs?.find(
(d: any) => d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
);
if (sigDoc) {
this.clientsService.getClientSignatureImage(cid, sigDoc.id.toString()).subscribe({
@@ -253,7 +288,10 @@ export class NomineeTabComponent implements OnInit {
onSearch(text: string): void {
this.searchText = text;
clearTimeout(this.searchTimer);
if (!text || text.length < 2) { this.searchResults = []; return; }
if (!text || text.length < 2) {
this.searchResults = [];
return;
}
this.searching = true;
this.searchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 8).subscribe({
@@ -262,13 +300,19 @@ export class NomineeTabComponent implements OnInit {
this.searching = false;
this.clientsService.enrichSearchResults(this.searchResults);
},
error: () => { this.searchResults = []; this.searching = false; }
error: () => {
this.searchResults = [];
this.searching = false;
}
});
}, 400);
}
dismissSearch(): void {
setTimeout(() => { this.searchResults = []; this.searching = false; }, 200);
setTimeout(() => {
this.searchResults = [];
this.searching = false;
}, 200);
}
selectClient(client: any): void {
@@ -291,11 +335,18 @@ export class NomineeTabComponent implements OnInit {
this.linkedSignatureImage = null;
this.linkedKycStatus = null;
let pending = 4;
const done = () => { pending--; if (pending <= 0) { this.fetchingDetails = false; } };
const done = () => {
pending--;
if (pending <= 0) {
this.fetchingDetails = false;
}
};
this.clientsService.getClientData(clientId.toString()).subscribe({
next: (data: any) => {
if (data?.dateOfBirth) { this.selectedClient = { ...this.selectedClient, dateOfBirth: data.dateOfBirth }; }
if (data?.dateOfBirth) {
this.selectedClient = { ...this.selectedClient, dateOfBirth: data.dateOfBirth };
}
done();
},
error: () => done()
@@ -304,8 +355,15 @@ export class NomineeTabComponent implements OnInit {
this.clientsService.getClientKyc(clientId.toString()).subscribe({
next: (kyc: any) => {
if (kyc) {
this.selectedClient = { ...this.selectedClient, panNumber: kyc.panNumber, aadhaarMasked: kyc.aadhaarNumberMasked };
this.linkedKycStatus = { panVerified: kyc.panVerified || false, aadhaarVerified: kyc.aadhaarVerified || false };
this.selectedClient = {
...this.selectedClient,
panNumber: kyc.panNumber,
aadhaarMasked: kyc.aadhaarNumberMasked
};
this.linkedKycStatus = {
panVerified: kyc.panVerified || false,
aadhaarVerified: kyc.aadhaarVerified || false
};
}
done();
},
@@ -313,19 +371,31 @@ export class NomineeTabComponent implements OnInit {
});
this.clientsService.getClientProfileImage(clientId.toString()).subscribe({
next: (img: any) => { if (img) { this.linkedProfileImage = this.sanitizer.bypassSecurityTrustResourceUrl(img); } done(); },
next: (img: any) => {
if (img) {
this.linkedProfileImage = this.sanitizer.bypassSecurityTrustResourceUrl(img);
}
done();
},
error: () => done()
});
this.clientsService.getClientDocuments(clientId.toString()).subscribe({
next: (docs: any[]) => {
const sigDoc = docs?.find((d: any) => d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature'));
const sigDoc = docs?.find(
(d: any) => d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
);
if (sigDoc) {
this.clientsService.getClientSignatureImage(clientId.toString(), sigDoc.id.toString()).subscribe({
next: (blob: Blob) => { this.linkedSignatureImage = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob)); done(); },
next: (blob: Blob) => {
this.linkedSignatureImage = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
done();
},
error: () => done()
});
} else { done(); }
} else {
done();
}
},
error: () => done()
});
@@ -336,12 +406,22 @@ export class NomineeTabComponent implements OnInit {
onGuardianSearch(text: string): void {
this.guardianSearchText = text;
clearTimeout(this.guardianSearchTimer);
if (!text || text.length < 2) { this.guardianSearchResults = []; return; }
if (!text || text.length < 2) {
this.guardianSearchResults = [];
return;
}
this.guardianSearching = true;
this.guardianSearchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 8).subscribe({
next: (data: any) => { this.guardianSearchResults = data?.content || []; this.guardianSearching = false; this.clientsService.enrichSearchResults(this.guardianSearchResults); },
error: () => { this.guardianSearchResults = []; this.guardianSearching = false; }
next: (data: any) => {
this.guardianSearchResults = data?.content || [];
this.guardianSearching = false;
this.clientsService.enrichSearchResults(this.guardianSearchResults);
},
error: () => {
this.guardianSearchResults = [];
this.guardianSearching = false;
}
});
}, 400);
}
@@ -385,7 +465,9 @@ export class NomineeTabComponent implements OnInit {
this.loadNominees();
this.resetAddForm();
},
error: () => { this.nomineeSaving = false; }
error: () => {
this.nomineeSaving = false;
}
});
}
@@ -395,26 +477,30 @@ export class NomineeTabComponent implements OnInit {
// Resolve the linked client ID — could be nomineeClientId or nested
const clientId = nominee.nomineeClientId || nominee.linkedClientId || nominee.clientId;
const displayName = nominee.nomineeDisplayName || nominee.firstName || '';
import('../../client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component').then(m => {
this.dialog.open(m.ClientFamilyMemberDialogComponent, {
data: {
context: 'View',
member: {
firstName: displayName,
relationshipId: nominee.relationshipId,
dateOfBirth: nominee.nomineeDateOfBirth || nominee.dateOfBirth,
linkedClientId: clientId,
linkedClientName: displayName,
isAlive: true,
isDependent: nominee.nomineeIsMinor,
panNumber: nominee.panNumber || '',
aadhaarNumber: nominee.aadhaarMasked || ''
import('../../client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component').then(
(m) => {
this.dialog.open(m.ClientFamilyMemberDialogComponent, {
data: {
context: 'View',
member: {
firstName: displayName,
relationshipId: nominee.relationshipId,
dateOfBirth: nominee.nomineeDateOfBirth || nominee.dateOfBirth,
linkedClientId: clientId,
linkedClientName: displayName,
isAlive: true,
isDependent: nominee.nomineeIsMinor,
panNumber: nominee.panNumber || '',
aadhaarNumber: nominee.aadhaarMasked || ''
},
options: { relationshipIdOptions: this.relationshipOptions }
},
options: { relationshipIdOptions: this.relationshipOptions }
},
width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel'
});
});
width: '640px',
maxWidth: '95vw',
panelClass: 'fm-dialog-panel'
});
}
);
}
editNominee(nominee: any, index: number): void {
@@ -424,10 +510,12 @@ export class NomineeTabComponent implements OnInit {
this.editNomType = nominee.nominationType || 'DA1';
}
cancelEdit(): void { this.editingIndex = null; }
cancelEdit(): void {
this.editingIndex = null;
}
maxShareForEdit(index: number): number {
return 100 - this.nominees.reduce((sum, n, i) => i === index ? sum : sum + (n.percentageShare || 0), 0);
return 100 - this.nominees.reduce((sum, n, i) => (i === index ? sum : sum + (n.percentageShare || 0)), 0);
}
saveEdit(nominee: any, index: number): void {
@@ -468,7 +556,8 @@ export class NomineeTabComponent implements OnInit {
this.dialog.open(ImageZoomDialogComponent, {
data: { imageSrc, title },
panelClass: 'image-zoom-panel',
maxWidth: '90vw', maxHeight: '90vh'
maxWidth: '90vw',
maxHeight: '90vh'
});
}
@@ -6,6 +6,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use 'sass:color';
$primary: #1a73e8;
$primary-light: #e8f0fe;
$gray-50: #f8f9fa;
@@ -165,7 +167,7 @@ $teal: #00897b;
&.status-active {
background: rgba($green, 0.1);
color: darken($green, 10%);
color: color.adjust($green, $lightness: -10%);
&::before {
background: $green;
@@ -183,7 +185,7 @@ $teal: #00897b;
&.status-pending {
background: rgba($orange, 0.1);
color: darken($orange, 15%);
color: color.adjust($orange, $lightness: -15%);
&::before {
background: $orange;
@@ -230,7 +232,9 @@ $teal: #00897b;
padding: 3px 12px;
border-radius: 12px;
i { font-size: 12px; }
i {
font-size: 12px;
}
&--verified {
background: #ecfdf5;
+37 -23
View File
@@ -329,21 +329,28 @@ export class ClientsService {
* Phase 1 – Record Master: fetch clients missing CKYC registration
* or artefacts. Backed by GET /ckyc/record-master.
*/
getCkycRecordMaster(filters: {
officeId?: number | string;
productId?: number | string;
fromDate?: string;
toDate?: string;
missing?: 'photo' | 'signature' | 'id' | 'address' | 'any';
limit?: number;
offset?: number;
} = {}): Observable<any> {
getCkycRecordMaster(
filters: {
officeId?: number | string;
productId?: number | string;
fromDate?: string;
toDate?: string;
missing?: 'photo' | 'signature' | 'id' | 'address' | 'any';
limit?: number;
offset?: number;
} = {}
): Observable<any> {
let params = new HttpParams();
Object.entries(filters).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== '') {
params = params.set(key, String(value));
Object.entries(filters).forEach(
([
key,
value
]) => {
if (value !== null && value !== undefined && value !== '') {
params = params.set(key, String(value));
}
}
});
);
return this.http.get(`/ckyc/record-master`, { params });
}
@@ -359,18 +366,25 @@ export class ClientsService {
* Phase 2/3 – List CKYC batches. Supports filters and an optional
* groupByDate=true to bucket results by created_on for the UI.
*/
getCkycBatches(filters: {
fromDate?: string;
toDate?: string;
uploadedToCersai?: boolean;
groupByDate?: boolean;
} = {}): Observable<any> {
getCkycBatches(
filters: {
fromDate?: string;
toDate?: string;
uploadedToCersai?: boolean;
groupByDate?: boolean;
} = {}
): Observable<any> {
let params = new HttpParams();
Object.entries(filters).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== '') {
params = params.set(key, String(value));
Object.entries(filters).forEach(
([
key,
value
]) => {
if (value !== null && value !== undefined && value !== '') {
params = params.set(key, String(value));
}
}
});
);
return this.http.get(`/ckyc/batches`, { params });
}
@@ -223,7 +223,9 @@
flex-shrink: 0;
}
span { flex: 1; }
span {
flex: 1;
}
button {
flex-shrink: 0;
+121 -28
View File
@@ -16,14 +16,27 @@
<!-- Meta Chips -->
<div class="t-meta">
<div class="t-chip"><div class="t-live"></div>&nbsp;LIVE</div>
<div class="t-chip">
<div class="t-live"></div>
&nbsp;LIVE
</div>
<div class="t-chip">Branch:&nbsp;<strong>HO</strong></div>
<div class="t-chip">Date:&nbsp;<strong>{{ today | date:'dd MMM yyyy' }}</strong></div>
<div class="t-chip">Last login:&nbsp;<strong>{{ lastLoginTime }}</strong></div>
<div class="t-chip">
Date:&nbsp;<strong>{{ today | date: 'dd MMM yyyy' }}</strong>
</div>
<div class="t-chip">
Last login:&nbsp;<strong>{{ lastLoginTime }}</strong>
</div>
<div class="t-sep"></div>
<div class="t-chip t-chip--secure"><i class="fas fa-lock t-chip-icon t-chip-icon--green"></i>SSL/TLS&nbsp;<strong>256-bit</strong></div>
<div class="t-chip t-chip--secure"><i class="fas fa-certificate t-chip-icon t-chip-icon--blue"></i>ISO 27001&nbsp;<strong>Certified</strong></div>
<div class="t-chip t-chip--timer"><i class="fas fa-clock t-chip-icon t-chip-icon--amber"></i>Session&nbsp;<strong>{{ sessionTimer }}</strong></div>
<div class="t-chip t-chip--secure">
<i class="fas fa-lock t-chip-icon t-chip-icon--green"></i>SSL/TLS&nbsp;<strong>256-bit</strong>
</div>
<div class="t-chip t-chip--secure">
<i class="fas fa-certificate t-chip-icon t-chip-icon--blue"></i>ISO 27001&nbsp;<strong>Certified</strong>
</div>
<div class="t-chip t-chip--timer">
<i class="fas fa-clock t-chip-icon t-chip-icon--amber"></i>Session&nbsp;<strong>{{ sessionTimer }}</strong>
</div>
</div>
<!-- Right Actions -->
@@ -43,7 +56,11 @@
<div class="t-divider"></div>
<div #appMenu class="t-action-btn t-user-btn" [matMenuTriggerFor]="applicationMenu" matTooltip="Account">
<img src="assets/images/user_placeholder.png" [alt]="'labels.headings.User Profile' | translate" class="t-user-img" />
<img
src="assets/images/user_placeholder.png"
[alt]="'labels.headings.User Profile' | translate"
class="t-user-img"
/>
</div>
</div>
</div>
@@ -66,7 +83,11 @@
<!-- Transactions -->
<div class="subledger-group">
<div class="subledger-group-title">Transactions</div>
<a class="subledger-link" [class.hidden]="!matchesSearch('Cash / Clearing / Transfer')" (click)="subLedgerOpen = false">
<a
class="subledger-link"
[class.hidden]="!matchesSearch('Cash / Clearing / Transfer')"
(click)="subLedgerOpen = false"
>
<fa-icon icon="money-bill" class="subledger-icon"></fa-icon> Cash / Clearing / Transfer
</a>
<a class="subledger-link" [class.hidden]="!matchesSearch('Batch Transaction')" (click)="subLedgerOpen = false">
@@ -114,7 +135,12 @@
<!-- Customer -->
<div class="subledger-group">
<div class="subledger-group-title">Customer</div>
<a class="subledger-link" [class.hidden]="!matchesSearch('Client Master')" routerLink="/clients" (click)="subLedgerOpen = false">
<a
class="subledger-link"
[class.hidden]="!matchesSearch('Client Master')"
routerLink="/clients"
(click)="subLedgerOpen = false"
>
<fa-icon icon="user" class="subledger-icon"></fa-icon> Client Master
</a>
<a class="subledger-link" [class.hidden]="!matchesSearch('Account Master')" (click)="subLedgerOpen = false">
@@ -131,10 +157,18 @@
<!-- Account Status -->
<div class="subledger-group">
<div class="subledger-group-title">Account Status</div>
<a class="subledger-link" [class.hidden]="!matchesSearch('Inoperative to Operative')" (click)="subLedgerOpen = false">
<a
class="subledger-link"
[class.hidden]="!matchesSearch('Inoperative to Operative')"
(click)="subLedgerOpen = false"
>
<fa-icon icon="lock-open" class="subledger-icon"></fa-icon> Inoperative &rarr; Operative
</a>
<a class="subledger-link" [class.hidden]="!matchesSearch('Account Status Master')" (click)="subLedgerOpen = false">
<a
class="subledger-link"
[class.hidden]="!matchesSearch('Account Status Master')"
(click)="subLedgerOpen = false"
>
<fa-icon icon="cog" class="subledger-icon"></fa-icon> Account Status Master
</a>
<a class="subledger-link" [class.hidden]="!matchesSearch('Restrict Account')" (click)="subLedgerOpen = false">
@@ -160,7 +194,11 @@
<a class="subledger-link" [class.hidden]="!matchesSearch('Holiday Marking')" (click)="subLedgerOpen = false">
<fa-icon icon="calendar" class="subledger-icon"></fa-icon> Holiday Marking
</a>
<a class="subledger-link" [class.hidden]="!matchesSearch('Open Closed Register')" (click)="subLedgerOpen = false">
<a
class="subledger-link"
[class.hidden]="!matchesSearch('Open Closed Register')"
(click)="subLedgerOpen = false"
>
<fa-icon icon="book-open" class="subledger-icon"></fa-icon> Open Closed Register
</a>
</div>
@@ -185,27 +223,66 @@
<div class="subledger-columns">
<div class="subledger-group">
<div class="subledger-group-title">Financial Statements</div>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Trial Balance')" (click)="glMenuOpen = false"><fa-icon icon="balance-scale" class="subledger-icon"></fa-icon> Trial Balance</a>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Profit and Loss')" (click)="glMenuOpen = false"><fa-icon icon="chart-line" class="subledger-icon"></fa-icon> Profit and Loss</a>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')" (click)="glMenuOpen = false"><fa-icon icon="file-alt" class="subledger-icon"></fa-icon> Consolidate Balance Sheet</a>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Trial Balance')" (click)="glMenuOpen = false"
><fa-icon icon="balance-scale" class="subledger-icon"></fa-icon> Trial Balance</a
>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Profit and Loss')" (click)="glMenuOpen = false"
><fa-icon icon="chart-line" class="subledger-icon"></fa-icon> Profit and Loss</a
>
<a
class="subledger-link"
[class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')"
(click)="glMenuOpen = false"
><fa-icon icon="file-alt" class="subledger-icon"></fa-icon> Consolidate Balance Sheet</a
>
</div>
<div class="subledger-group">
<div class="subledger-group-title">GL Reports</div>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('GL Average Balance')" (click)="glMenuOpen = false"><fa-icon icon="calculator" class="subledger-icon"></fa-icon> GL Average Balance</a>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('GL Mapping for Report')" (click)="glMenuOpen = false"><fa-icon icon="project-diagram" class="subledger-icon"></fa-icon> GL Mapping for Report</a>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Account wise Balance List')" (click)="glMenuOpen = false"><fa-icon icon="list" class="subledger-icon"></fa-icon> Account wise Balance List</a>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Distribution of Balance')" (click)="glMenuOpen = false"><fa-icon icon="chart-pie" class="subledger-icon"></fa-icon> Distribution of Balance</a>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('GL Average Balance')" (click)="glMenuOpen = false"
><fa-icon icon="calculator" class="subledger-icon"></fa-icon> GL Average Balance</a
>
<a
class="subledger-link"
[class.hidden]="!matchesGlSearch('GL Mapping for Report')"
(click)="glMenuOpen = false"
><fa-icon icon="project-diagram" class="subledger-icon"></fa-icon> GL Mapping for Report</a
>
<a
class="subledger-link"
[class.hidden]="!matchesGlSearch('Account wise Balance List')"
(click)="glMenuOpen = false"
><fa-icon icon="list" class="subledger-icon"></fa-icon> Account wise Balance List</a
>
<a
class="subledger-link"
[class.hidden]="!matchesGlSearch('Distribution of Balance')"
(click)="glMenuOpen = false"
><fa-icon icon="chart-pie" class="subledger-icon"></fa-icon> Distribution of Balance</a
>
</div>
<div class="subledger-group">
<div class="subledger-group-title">Account Reports</div>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Client wise Balance Report')" (click)="glMenuOpen = false"><fa-icon icon="users" class="subledger-icon"></fa-icon> Client wise Balance Report</a>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Contingent Expense')" (click)="glMenuOpen = false"><fa-icon icon="exclamation-circle" class="subledger-icon"></fa-icon> Contingent Expense</a>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false"><fa-icon icon="university" class="subledger-icon"></fa-icon> Banker Head Report</a>
<a
class="subledger-link"
[class.hidden]="!matchesGlSearch('Client wise Balance Report')"
(click)="glMenuOpen = false"
><fa-icon icon="users" class="subledger-icon"></fa-icon> Client wise Balance Report</a
>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Contingent Expense')" (click)="glMenuOpen = false"
><fa-icon icon="exclamation-circle" class="subledger-icon"></fa-icon> Contingent Expense</a
>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false"
><fa-icon icon="university" class="subledger-icon"></fa-icon> Banker Head Report</a
>
</div>
<div class="subledger-group">
<div class="subledger-group-title">Registers</div>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Sundry Register')" (click)="glMenuOpen = false"><fa-icon icon="clipboard-list" class="subledger-icon"></fa-icon> Sundry Register</a>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Voucher Register')" (click)="glMenuOpen = false"><fa-icon icon="receipt" class="subledger-icon"></fa-icon> Voucher Register</a>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Sundry Register')" (click)="glMenuOpen = false"
><fa-icon icon="clipboard-list" class="subledger-icon"></fa-icon> Sundry Register</a
>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Voucher Register')" (click)="glMenuOpen = false"
><fa-icon icon="receipt" class="subledger-icon"></fa-icon> Voucher Register</a
>
</div>
</div>
</div>
@@ -348,7 +425,11 @@
<button mat-raised-button color="primary" (click)="popover.close(); showPopover(templateAdmin, admin)">
{{ 'labels.buttons.Back' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); showPopover(templateConfigWizard, configWizard)">
<button
mat-raised-button
color="primary"
(click)="popover.close(); showPopover(templateConfigWizard, configWizard)"
>
{{ 'labels.buttons.Next' | translate }}
</button>
</div>
@@ -364,7 +445,11 @@
<button mat-raised-button color="primary" (click)="popover.close(); showPopover(templateSelfService, selfService)">
{{ 'labels.buttons.Back' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); showPopover(templateNotifications, notifications)">
<button
mat-raised-button
color="primary"
(click)="popover.close(); showPopover(templateNotifications, notifications)"
>
{{ 'labels.buttons.Next' | translate }}
</button>
</div>
@@ -377,7 +462,11 @@
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
{{ 'labels.buttons.Close' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); showPopover(templateConfigWizard, configWizard)">
<button
mat-raised-button
color="primary"
(click)="popover.close(); showPopover(templateConfigWizard, configWizard)"
>
{{ 'labels.buttons.Back' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); showPopover(templateThemePicker, themeToggle)">
@@ -393,7 +482,11 @@
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
{{ 'labels.buttons.Close' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); showPopover(templateNotifications, notifications)">
<button
mat-raised-button
color="primary"
(click)="popover.close(); showPopover(templateNotifications, notifications)"
>
{{ 'labels.buttons.Back' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); showPopover(templateAppMenu, appMenu)">
@@ -26,8 +26,11 @@ $top-h: 32px;
position: absolute;
inset: 0;
background: repeating-linear-gradient(
108deg, transparent 0, transparent 80px,
rgb(255 255 255 / 2.2%) 80px, rgb(255 255 255 / 2.2%) 160px
108deg,
transparent 0,
transparent 80px,
rgb(255 255 255 / 2.2%) 80px,
rgb(255 255 255 / 2.2%) 160px
);
pointer-events: none;
}
@@ -54,10 +57,21 @@ $top-h: 32px;
span {
border-radius: 1.5px;
&:nth-child(1) { background: #fff; }
&:nth-child(2) { background: $accent; }
&:nth-child(3) { background: $accent-2; }
&:nth-child(4) { background: rgb(255 255 255 / 30%); }
&:nth-child(1) {
background: #fff;
}
&:nth-child(2) {
background: $accent;
}
&:nth-child(3) {
background: $accent-2;
}
&:nth-child(4) {
background: rgb(255 255 255 / 30%);
}
}
}
@@ -112,9 +126,17 @@ $top-h: 32px;
.t-chip-icon {
font-size: 9px;
&--green { color: #34d399; }
&--blue { color: #60a5fa; }
&--amber { color: #fbbf24; }
&--green {
color: #34d399;
}
&--blue {
color: #60a5fa;
}
&--amber {
color: #fbbf24;
}
}
.t-chip--secure {
@@ -124,7 +146,10 @@ $top-h: 32px;
.t-chip--timer {
color: rgb(255 255 255 / 55%);
strong { color: #fbbf24 !important; font-weight: 600; }
strong {
color: #fbbf24 !important;
font-weight: 600;
}
}
.t-live {
@@ -285,7 +310,7 @@ $icon-hover-bg: rgb(255 255 255 / 10%);
border-radius: 0 0 10px 10px;
z-index: 1000;
border-top: 3px solid #0d47a1;
animation: slideDown 0.13s ease;
animation: slide-down 0.13s ease;
}
.subledger-mega-menu-header {
@@ -309,9 +334,16 @@ $icon-hover-bg: rgb(255 255 255 / 10%);
color: #0d47a1;
}
@keyframes slideDown {
from { opacity: 0; transform: translateX(-50%) translateY(-4px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
@keyframes slide-down {
from {
opacity: 0;
transform: translateX(-50%) translateY(-4px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.subledger-search {
@@ -358,7 +390,9 @@ $icon-hover-bg: rgb(255 255 255 / 10%);
font-size: 13px;
cursor: pointer;
border-radius: 5px;
transition: background 0.15s, color 0.15s;
transition:
background 0.15s,
color 0.15s;
&:hover {
background: #e3f2fd;
+2 -2
View File
@@ -87,9 +87,9 @@ export class Accounting {
} else if (accountingRuleId === 2) {
return { code: 'CASH', id: accountingRuleId, value: 'Cash' };
} else if (accountingRuleId === 3) {
return { code: 'ACCRUAL_PERIODIC', id: accountingRuleId, value: 'Accural (periodic)' };
return { code: 'ACCRUAL_PERIODIC', id: accountingRuleId, value: 'Accrual (periodic)' };
} else if (accountingRuleId === 4) {
return { code: 'ACCRUAL_UPFRONT', id: accountingRuleId, value: 'Accural (upfront)' };
return { code: 'ACCRUAL_UPFRONT', id: accountingRuleId, value: 'Accrual (upfront)' };
}
return { code: 'INVALID', id: accountingRuleId, value: 'Invalid' };
}
@@ -34,7 +34,8 @@
<fa-icon icon="eye" size="sm"></fa-icon>
</ng-template>
<mat-step [stepControl]="currentAccountDetailsForm">
<!-- Step 1: Details -->
<mat-step>
<ng-template matStepLabel>{{ 'labels.inputs.DETAILS' | translate }}</ng-template>
<mifosx-current-account-details-step
@@ -44,33 +45,60 @@
</mifosx-current-account-details-step>
</mat-step>
<mat-step [stepControl]="currentAccountTermsForm">
<ng-template matStepLabel>{{ 'labels.inputs.TERMS' | translate }}</ng-template>
@if (currentAccountProductTemplate) {
<!-- Step 2: Currency -->
<mat-step>
<ng-template matStepLabel>CURRENCY</ng-template>
<mifosx-current-account-terms-step
[currentAccountProductTemplate]="currentAccountProductTemplate"
></mifosx-current-account-terms-step>
</mat-step>
<mifosx-current-account-currency-step
[currentAccountProductTemplate]="currentAccountProductTemplate"
></mifosx-current-account-currency-step>
<mat-step>
<ng-template matStepLabel>{{ 'labels.inputs.CHARGES' | translate }}</ng-template>
<div class="layout-row align-center gap-2percent margin-t responsive-column">
<button mat-raised-button matStepperPrevious>
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<button mat-raised-button matStepperNext>
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</mat-step>
<mifosx-current-account-charges-step
[currentAccountProductTemplate]="currentAccountProductTemplate"
[currentAccountTemplate]="currentAccountTemplate"
[currencyCode]="currentAccountTermsForm.get('currencyCode')"
>
</mifosx-current-account-charges-step>
</mat-step>
<!-- Step 3: Digital -->
<mat-step>
<ng-template matStepLabel>DIGITAL</ng-template>
@if (currentAccountFormValid) {
<mifosx-current-account-digital-step
[currentAccountProductTemplate]="currentAccountProductTemplate"
></mifosx-current-account-digital-step>
<div class="layout-row align-center gap-2percent margin-t responsive-column">
<button mat-raised-button matStepperPrevious>
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<button mat-raised-button matStepperNext>
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</mat-step>
<!-- Step 4: Preview -->
<mat-step completed>
<ng-template matStepLabel>{{ 'labels.inputs.PREVIEW' | translate }}</ng-template>
<mifosx-current-account-preview-step
[currentAccountProductTemplate]="currentAccountProductTemplate"
[currentAccountTemplate]="currentAccountTemplate"
[currentAccountTermForm]="currentAccountTermForm"
[currentAccountSettingsForm]="currentAccountSettingsForm"
[currentAccountTermsForm]="currentAccountTermsForm"
[currentAccount]="currentAccount"
[currentAccountDigital]="currentAccountDigital"
[currentAccountNominee]="currentAccountNominee"
[currentAccountCurrencyFlags]="currentAccountCurrencyFlags"
(submitEvent)="submit()"
>
</mifosx-current-account-preview-step>
@@ -27,11 +27,14 @@
.mat-step-content {
padding: 1.5rem;
margin: 1rem 0;
overflow: visible !important;
}
}
// Form field spacing within steps
:host ::ng-deep mat-step-content {
overflow: visible !important;
mat-form-field {
margin-bottom: 1rem;
width: 100%;
@@ -12,17 +12,25 @@ import { Router, ActivatedRoute } from '@angular/router';
/** Custom Components */
import { CurrentAccountDetailsStepComponent } from '../current-account-stepper/current-account-details-step/current-account-details-step.component';
import { CurrentAccountTermsStepComponent } from '../current-account-stepper/current-account-terms-step/current-account-terms-step.component';
import { CurrentAccountChargesStepComponent } from '../current-account-stepper/current-account-charges-step/current-account-charges-step.component';
import { CurrentAccountCurrencyStepComponent } from '../current-account-stepper/current-account-currency-step/current-account-currency-step.component';
import { CurrentAccountDigitalStepComponent } from '../current-account-stepper/current-account-digital-step/current-account-digital-step.component';
/** Custom Services */
import { CurrentAccountsService } from '../current-accounts.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { MatStepper, MatStepperIcon, MatStep, MatStepLabel } from '@angular/material/stepper';
import {
MatStepper,
MatStepperIcon,
MatStep,
MatStepLabel,
MatStepperNext,
MatStepperPrevious
} from '@angular/material/stepper';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { CurrentAccountPreviewStepComponent } from '../current-account-stepper/current-account-preview-step/current-account-preview-step.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormGroup } from '@angular/forms';
/**
* Create Current Account Component
@@ -35,12 +43,14 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
...STANDALONE_SHARED_IMPORTS,
MatStepper,
MatStepperIcon,
MatStepperNext,
MatStepperPrevious,
FaIconComponent,
MatStep,
MatStepLabel,
CurrentAccountDetailsStepComponent,
CurrentAccountTermsStepComponent,
CurrentAccountChargesStepComponent,
CurrentAccountCurrencyStepComponent,
CurrentAccountDigitalStepComponent,
CurrentAccountPreviewStepComponent
]
})
@@ -56,24 +66,15 @@ export class CreateCurrentAccountComponent {
/** Current Account Product Template */
currentAccountProductTemplate: any;
/** Current Account Details Step */
@ViewChild(CurrentAccountDetailsStepComponent, { static: true })
currentAccountDetailsStep: CurrentAccountDetailsStepComponent;
/** Current Account Terms Step */
@ViewChild(CurrentAccountTermsStepComponent, { static: true })
currentAccountTermsStep: CurrentAccountTermsStepComponent;
/** Current Account Charges Step */
@ViewChild(CurrentAccountChargesStepComponent, { static: true })
currentAccountChargesStep: CurrentAccountChargesStepComponent;
/**
* Fetches current account template from `resolve`
* @param {ActivatedRoute} route Activated Route
* @param {Router} router Router
* @param {Dates} dateUtils Date Utils
* @param {CurrentAccountsService} currentAccountsService Current Accounts Service
* @param {SettingsService} settingsService Settings Service
*/
@ViewChild(CurrentAccountCurrencyStepComponent)
currentAccountCurrencyStep: CurrentAccountCurrencyStepComponent;
@ViewChild(CurrentAccountDigitalStepComponent)
currentAccountDigitalStep: CurrentAccountDigitalStepComponent;
constructor() {
this.route.data.subscribe((data: { currentAccountTemplate: any }) => {
this.currentAccountTemplate = data.currentAccountTemplate;
@@ -82,7 +83,6 @@ export class CreateCurrentAccountComponent {
/**
* Sets current account product template.
* @param {any} $event API response
*/
setTemplate($event: any) {
this.currentAccountProductTemplate = $event;
@@ -95,60 +95,77 @@ export class CreateCurrentAccountComponent {
return this.currentAccountDetailsStep.currentAccountDetailsForm;
}
/**
* Retrieves current account terms form.
*/
get currentAccountTermsForm() {
return this.currentAccountTermsStep.currentAccountTermsForm;
/** Stub forms needed by preview step inputs */
get currentAccountTermForm(): FormGroup {
return new FormGroup({});
}
/**
* Checks validity of overall current account form.
*/
get currentAccountFormValid() {
return this.currentAccountDetailsForm.valid && this.currentAccountTermsForm.valid;
get currentAccountSettingsForm(): FormGroup {
return new FormGroup({});
}
get currentAccountTermsForm(): FormGroup {
return new FormGroup({});
}
/**
* Retrieves current account object.
*/
get currentAccount() {
return { ...this.currentAccountDetailsStep.currentAccountDetails };
}
get currentAccountDigital() {
return this.currentAccountDigitalStep?.currentAccountDigital ?? null;
}
get currentAccountNominee() {
return this.currentAccountCurrencyStep?.nomineeData ?? null;
}
get currentAccountCurrencyFlags() {
return {
...this.currentAccountDetailsStep.currentAccountDetails,
...this.currentAccountTermsStep.currentAccountTerms,
...this.currentAccountChargesStep.currentAccountCharges
balanceSwapOption: this.currentAccountCurrencyStep?.balanceSwapOption ?? false,
msme: this.currentAccountCurrencyStep?.msme ?? false,
autoDebitFacility: this.currentAccountCurrencyStep?.autoDebitFacility ?? false
};
}
/**
* Creates a new share account.
* Creates a new current account.
*/
submit() {
// TODO: Update once language and date settings are setup
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const monthDayFormat = 'dd MMMM';
const currentAccount = {
...this.currentAccount,
charges: this.currentAccount.charges.map((charge: any) => ({
chargeId: charge.id,
amount: charge.amount,
dueDate: charge.dueDate ? this.dateUtils.formatDate(charge.dueDate, dateFormat) : charge.dueDate,
feeOnMonthDay: charge.feeOnMonthDay
? this.dateUtils.formatDate(charge.feeOnMonthDay, monthDayFormat)
: charge.feeOnMonthDay,
feeInterval: charge.feeInterval
})),
submittedOnDate: this.dateUtils.formatDate(this.currentAccount.submittedOnDate, dateFormat),
const details = this.currentAccount;
const currentAccount: any = {
...details,
...this.currentAccountCurrencyStep.currencyStepData,
...(this.currentAccountDigitalStep?.currentAccountDigital ?? {}),
charges: [],
submittedOnDate: this.dateUtils.formatDate(details.submittedOnDate, dateFormat),
dateFormat,
monthDayFormat,
locale
};
if (details.establishmentDate) {
currentAccount.establishmentDate = this.dateUtils.formatDate(details.establishmentDate, dateFormat);
}
if (details.startOperationDate) {
currentAccount.startOperationDate = this.dateUtils.formatDate(details.startOperationDate, dateFormat);
}
// strip UI-only flags not needed by backend
delete currentAccount.isNonIndividualClient;
if (this.currentAccountTemplate.clientId) {
currentAccount.clientId = this.currentAccountTemplate.clientId;
} else {
currentAccount.groupId = this.currentAccountTemplate.groupId;
}
console.log('Current Account Payload:', JSON.stringify(currentAccount, null, 2));
this.currentAccountsService.createCurrentAccount(currentAccount).subscribe((response: any) => {
this.router.navigate(
[
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
import { Component, OnInit, OnChanges, Input, inject } from '@angular/core';
import { Component, OnInit, Input, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { UntypedFormControl } from '@angular/forms';
@@ -69,13 +69,27 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FormatNumberPipe
]
})
export class CurrentAccountChargesStepComponent implements OnInit, OnChanges {
export class CurrentAccountChargesStepComponent implements OnInit {
private dialog = inject(MatDialog);
private dateUtils = inject(Dates);
private translateService = inject(TranslateService);
/** Current Account Product Template */
@Input() currentAccountProductTemplate: any;
private _currentAccountProductTemplate: any;
@Input()
set currentAccountProductTemplate(value: any) {
this._currentAccountProductTemplate = value;
if (value) {
this.chargeData = value.chargeOptions || [];
// Only pre-populate from product template for new accounts
if (!this.currentAccountTemplate?.id) {
this.chargesDataSource = (value.charges || []).map((charge: any) => ({ ...charge, id: charge.chargeId }));
}
}
}
get currentAccountProductTemplate(): any {
return this._currentAccountProductTemplate;
}
/** Current Account Template */
@Input() currentAccountTemplate: any;
/** Currency Code */
@@ -88,8 +102,6 @@ export class CurrentAccountChargesStepComponent implements OnInit, OnChanges {
chargesDataSource: {}[] = [];
/** Component is pristine if there has been no changes by user interaction */
pristine = true;
/** For Edit Current Account Form */
isChargesPatched = false;
/** Display columns for charges table */
displayedColumns: string[] = [
'name',
@@ -114,22 +126,10 @@ export class CurrentAccountChargesStepComponent implements OnInit, OnChanges {
];
ngOnInit() {
if (this.currentAccountTemplate) {
if (!this.isChargesPatched && this.currentAccountTemplate.charges) {
this.chargesDataSource =
this.currentAccountProductTemplate.charges.map((charge: any) => ({ ...charge, id: charge.chargeId })) || [];
this.isChargesPatched = true;
} else {
this.chargesDataSource = [];
}
}
}
ngOnChanges() {
if (this.currentAccountProductTemplate) {
this.chargeData = this.currentAccountProductTemplate.chargeOptions;
if (this.currentAccountTemplate?.id && this.currentAccountTemplate?.charges) {
// Edit mode: load saved charges from existing account
this.chargesDataSource =
this.currentAccountProductTemplate.charges.map((charge: any) => ({ ...charge, id: charge.chargeId })) || [];
this.currentAccountTemplate.charges.map((charge: any) => ({ ...charge, id: charge.chargeId })) || [];
}
}
@@ -0,0 +1,79 @@
<!--
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="sac-section">
<div class="sac-section__header">
<i class="fas fa-coins"></i>
<span>Currency &amp; Account Status Configuration</span>
</div>
<div class="sac-body">
<div class="sac-grid">
<mat-form-field appearance="outline" class="sac-field">
<mat-label>Currency</mat-label>
<input matInput [value]="currentAccountProductTemplate?.currency?.name || ''" readonly />
<mat-hint>Inherited from selected product</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="sac-field">
<mat-label>Decimal Places</mat-label>
<input matInput [value]="currentAccountProductTemplate?.currency?.decimalPlaces || ''" readonly />
<mat-hint>Inherited from selected product</mat-hint>
</mat-form-field>
</div>
<div class="sac-alert sac-alert--editable">
<div class="sac-alert__content">
<h4>Balance Swap Option</h4>
<p>Allows balance to be swapped or transferred between linked accounts as per product configuration.</p>
</div>
<mat-slide-toggle [(ngModel)]="balanceSwapOption" color="primary"></mat-slide-toggle>
</div>
<div class="sac-alert sac-alert--editable">
<div class="sac-alert__content">
<h4>MSME</h4>
<p>Account is categorized under the Micro, Small and Medium Enterprises segment.</p>
</div>
<mat-slide-toggle [(ngModel)]="msme" color="primary"></mat-slide-toggle>
</div>
<div class="sac-alert sac-alert--editable">
<div class="sac-alert__content">
<h4>Auto Debit Facility</h4>
<p>Enable automatic debit instructions for recurring payments linked to this account.</p>
</div>
<mat-slide-toggle [(ngModel)]="autoDebitFacility" color="primary"></mat-slide-toggle>
</div>
<div class="sac-alert">
<div class="sac-alert__content">
<h4>Dormancy Alert</h4>
<p>Enable automated notifications when an account becomes inactive based on product rules.</p>
</div>
<span class="sac-badge" [class.sac-badge--yes]="currentAccountProductTemplate?.dormancyAlert">
{{ currentAccountProductTemplate?.dormancyAlert ? 'Yes' : 'No' }}
</span>
</div>
@if (currentAccountProductTemplate?.dormancyAlert) {
<mifosx-dormancy-alert-panel [productTemplate]="currentAccountProductTemplate"></mifosx-dormancy-alert-panel>
}
<div class="sac-alert">
<div class="sac-alert__content">
<h4>Nomination Facility</h4>
<p>Allow account holders to designate individuals to receive funds in the event of account holder's death.</p>
</div>
<span class="sac-badge" [class.sac-badge--yes]="currentAccountProductTemplate?.nominationFacility">
{{ currentAccountProductTemplate?.nominationFacility ? 'Yes' : 'No' }}
</span>
</div>
@if (currentAccountProductTemplate?.nominationFacility) {
<mifosx-nominee-form (nomineeChange)="onNomineeChange($event)"></mifosx-nominee-form>
}
</div>
</div>
@@ -0,0 +1,130 @@
/**
* 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/.
*/
$accent: #0ea5e9;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-900: #202124;
.sac-section {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
background: #fff;
& + & {
margin-top: 16px;
}
&__header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
background: $gray-50;
border-bottom: 1px solid $gray-200;
i {
font-size: 14px;
color: $accent;
}
span {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
}
}
.sac-body {
background: #fff;
}
.sac-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
padding: 16px 24px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.sac-field {
width: 100%;
}
.sac-alert {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 18px 24px;
border-top: 1px solid $border;
background: #f8fafc;
@media (width <= 760px) {
flex-direction: column;
align-items: flex-start;
}
&__content {
h4 {
margin: 0 0 6px;
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
p {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: #64748b;
max-width: 540px;
}
}
}
.sac-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 48px;
padding: 4px 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: #fee2e2;
color: #b91c1c;
border: 1px solid #fecaca;
&--yes {
background: #dcfce7;
color: #15803d;
border-color: #bbf7d0;
}
}
.sac-alert--editable {
border-color: #bae6fd;
background: #f0f9ff;
&:hover {
border-color: #7dd3fc;
box-shadow: 0 2px 8px rgb(2 132 199 / 10%);
}
h4 {
color: #0369a1;
}
}
@@ -0,0 +1,60 @@
/**
* 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, Input, OnChanges, SimpleChanges } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { NomineeFormComponent } from 'app/shared/nominee-form/nominee-form.component';
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'mifosx-current-account-currency-step',
templateUrl: './current-account-currency-step.component.html',
styleUrls: ['./current-account-currency-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
NomineeFormComponent,
DormancyAlertPanelComponent,
MatSlideToggle,
FormsModule
]
})
export class CurrentAccountCurrencyStepComponent implements OnChanges {
@Input() currentAccountProductTemplate: any;
nomineeData: any = null;
// Editable feature toggles (pre-filled from product, overridable by user)
balanceSwapOption = false;
msme = false;
autoDebitFacility = false;
ngOnChanges(changes: SimpleChanges): void {
if (changes['currentAccountProductTemplate']?.currentValue) {
const pt = changes['currentAccountProductTemplate'].currentValue;
this.balanceSwapOption = !!pt.balanceSwapOption;
this.msme = !!pt.msme;
this.autoDebitFacility = !!pt.autoDebitFacility;
}
}
onNomineeChange(event: any): void {
this.nomineeData = event;
}
/** Called by parent to collect currency step values */
get currencyStepData() {
return {
balanceSwapOption: this.balanceSwapOption,
msme: this.msme,
autoDebitFacility: this.autoDebitFacility,
...(this.nomineeData ? { nominee: this.nomineeData } : {})
};
}
}
@@ -0,0 +1,190 @@
<!--
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/.
-->
<!-- Header -->
<div class="abd-header">
<div class="abd-header__icon">
<mat-icon>person_pin</mat-icon>
</div>
<div class="abd-header__text">
<h2 class="abd-header__title">Add Beneficiary / Nominee</h2>
<p class="abd-header__sub">Register a nominee for this account as per Indian banking norms</p>
</div>
<button mat-icon-button class="abd-header__close" type="button" (click)="cancel()">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content>
<form [formGroup]="form" class="abd-form">
<!-- Mode Toggle -->
<div class="abd-mode-toggle">
<button
type="button"
class="abd-mode-btn"
[class.abd-mode-btn--active]="mode === 'search'"
(click)="switchMode('search')"
>
<mat-icon>manage_search</mat-icon> Linked Client
</button>
<button
type="button"
class="abd-mode-btn"
[class.abd-mode-btn--active]="mode === 'manual'"
(click)="switchMode('manual')"
>
<mat-icon>edit_note</mat-icon> Manual Entry
</button>
</div>
<!-- Search Mode -->
@if (mode === 'search') {
<div class="abd-search-section">
<mat-form-field appearance="outline" class="abd-full-width">
<mat-label>Search Client by Name / CIF</mat-label>
<mat-icon matPrefix class="abd-prefix-icon">search</mat-icon>
<input
matInput
formControlName="searchText"
(input)="onSearchInput($event.target.value)"
autocomplete="off"
placeholder="Type at least 2 characters..."
/>
@if (isSearching) {
<mat-progress-spinner matSuffix diameter="18" mode="indeterminate"></mat-progress-spinner>
}
@if (selectedClient) {
<button matSuffix mat-icon-button type="button" (click)="clearSelection()">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
@if (searchResults.length > 0) {
<div class="abd-search-results">
@for (client of searchResults; track client) {
<div class="abd-result-item" (click)="selectClient(client)">
<div class="abd-result-avatar">{{ (client.displayName || '?')[0].toUpperCase() }}</div>
<div class="abd-result-body">
<span class="abd-result-name">{{ client.displayName }}</span>
<span class="abd-result-meta">
@if (client.cifNumber) {
<span class="abd-chip">{{ client.cifNumber }}</span>
}
@if (client.officeName) {
<span class="abd-chip">{{ client.officeName }}</span>
}
</span>
</div>
<mat-icon class="abd-result-arrow">chevron_right</mat-icon>
</div>
}
</div>
}
@if (selectedClient) {
<div class="abd-selected-card">
<div class="abd-selected-avatar">{{ (selectedClient.displayName || '?')[0].toUpperCase() }}</div>
<div class="abd-selected-info">
<span class="abd-selected-name">{{ selectedClient.displayName }}</span>
<span class="abd-selected-sub">{{ selectedClient.cifNumber || selectedClient.accountNumber }}</span>
</div>
<span class="abd-badge abd-badge--linked">Linked</span>
</div>
}
</div>
}
<!-- Name field (manual or auto-filled) -->
<mat-form-field appearance="outline" class="abd-full-width abd-mt">
<mat-label>Nominee / Beneficiary Name <span class="abd-required">*</span></mat-label>
<input matInput formControlName="name" [readonly]="mode === 'search' && !!selectedClient" />
<mat-error>Name is required</mat-error>
</mat-form-field>
<!-- Row: Relationship + DOB -->
<div class="abd-row">
<mat-form-field appearance="outline" class="abd-flex">
<mat-label>Relationship <span class="abd-required">*</span></mat-label>
<mat-select formControlName="relationship">
@for (r of relationships; track r) {
<mat-option [value]="r">{{ r }}</mat-option>
}
</mat-select>
<mat-error>Relationship is required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="abd-flex" (click)="dobPicker.open()">
<mat-label>Date of Birth</mat-label>
<input matInput [matDatepicker]="dobPicker" formControlName="dateOfBirth" readonly />
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
<mat-datepicker #dobPicker></mat-datepicker>
</mat-form-field>
</div>
<!-- Share % -->
<div class="abd-share-row">
<mat-form-field appearance="outline" class="abd-share-field">
<mat-label>Share % <span class="abd-required">*</span></mat-label>
<input matInput type="number" formControlName="sharePercentage" min="1" max="100" />
<span matSuffix class="abd-pct">%</span>
<mat-error>Enter 1–100</mat-error>
</mat-form-field>
<div class="abd-quick-pct">
@for (p of [25, 33, 50, 100]; track p) {
<button type="button" class="abd-pct-chip" (click)="form.get('sharePercentage').setValue(p)">{{ p }}%</button>
}
</div>
</div>
<!-- Minor Toggle -->
<div class="abd-minor-toggle">
<mat-slide-toggle formControlName="isMinor" color="primary">
Nominee is a Minor (below 18 years)
</mat-slide-toggle>
</div>
<!-- Guardian fields (if minor) -->
@if (isMinor) {
<div class="abd-guardian-box">
<div class="abd-guardian-label">
<mat-icon>supervisor_account</mat-icon>
Guardian Details
</div>
<div class="abd-row">
<mat-form-field appearance="outline" class="abd-flex">
<mat-label>Guardian Name</mat-label>
<input matInput formControlName="guardianName" />
</mat-form-field>
<mat-form-field appearance="outline" class="abd-flex">
<mat-label>Guardian Relationship</mat-label>
<mat-select formControlName="guardianRelationship">
@for (r of guardianRelationships; track r) {
<mat-option [value]="r">{{ r }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
}
</form>
</mat-dialog-content>
<mat-dialog-actions class="abd-actions">
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button
mat-flat-button
color="primary"
type="button"
class="abd-btn-add"
[disabled]="form.get('name').invalid || form.get('relationship').invalid || form.get('sharePercentage').invalid"
(click)="confirm()"
>
<mat-icon>check</mat-icon> Add Nominee
</button>
</mat-dialog-actions>
@@ -0,0 +1,415 @@
/**
* 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/.
*/
$primary: #1a56db;
$primary-light: #eff6ff;
$primary-border: #bfdbfe;
$success: #057a55;
$success-light: #f3faf7;
$success-border: #84e1bc;
$orange: #d03801;
$orange-light: #fff8f1;
$orange-border: #fcd9bd;
$border: #e5e7eb;
$text: #111827;
$muted: #6b7280;
$radius: 10px;
mifosx-add-beneficiary-dialog {
display: block;
}
// Header
.abd-header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 18px 20px 14px;
border-bottom: 1px solid $border;
background: linear-gradient(135deg, #f0f7ff 0%, #fff 60%);
&__icon {
width: 42px;
height: 42px;
border-radius: 10px;
background: $primary;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
mat-icon {
color: #fff;
font-size: 22px;
width: 22px;
height: 22px;
}
}
&__text {
flex: 1;
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: $text;
}
&__sub {
margin: 3px 0 0;
font-size: 11.5px;
color: $muted;
}
&__close {
color: $muted;
margin: -4px -8px 0 0;
}
}
// Form
.abd-form {
display: flex;
flex-direction: column;
gap: 0;
width: 500px;
max-width: 100%;
padding: 4px 0;
}
.abd-full-width {
width: 100%;
}
.abd-mt {
margin-top: 8px;
}
// Mode toggle
.abd-mode-toggle {
display: flex;
gap: 8px;
margin: 14px 0 10px;
background: #f9fafb;
border: 1px solid $border;
border-radius: 10px;
padding: 5px;
}
.abd-mode-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1.5px solid transparent;
background: transparent;
font-size: 13px;
font-weight: 600;
color: $muted;
cursor: pointer;
transition: all 0.15s;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&--active {
background: #fff;
border-color: $primary-border;
color: $primary;
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
}
}
// Search
.abd-search-section {
position: relative;
}
.abd-prefix-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: $muted;
margin-right: 4px;
}
.abd-search-results {
position: absolute;
z-index: 200;
top: calc(100% - 8px);
left: 0;
right: 0;
background: #fff;
border: 1px solid $border;
border-radius: $radius;
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
max-height: 220px;
overflow-y: auto;
}
.abd-result-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid #f3f4f6;
transition: background 0.1s;
&:last-child {
border-bottom: none;
}
&:hover {
background: $primary-light;
}
}
.abd-result-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, $primary);
color: #fff;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.abd-result-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.abd-result-name {
font-size: 13px;
font-weight: 600;
color: $text;
}
.abd-result-meta {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.abd-chip {
font-size: 10px;
padding: 1px 7px;
border-radius: 999px;
background: #f3f4f6;
color: $muted;
font-weight: 500;
}
.abd-result-arrow {
font-size: 18px;
width: 18px;
height: 18px;
color: $primary;
opacity: 0.4;
transition: all 0.1s;
.abd-result-item:hover & {
opacity: 1;
transform: translateX(2px);
}
}
// Selected card
.abd-selected-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: $success-light;
border: 1.5px solid $success-border;
border-radius: $radius;
margin-top: 6px;
}
.abd-selected-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #10b981, $success);
color: #fff;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.abd-selected-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.abd-selected-name {
font-size: 13px;
font-weight: 700;
color: #065f46;
}
.abd-selected-sub {
font-size: 11px;
color: #047857;
}
.abd-badge {
font-size: 10px;
font-weight: 700;
padding: 3px 10px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
&--linked {
background: $success;
color: #fff;
}
}
// Row layout
.abd-row {
display: flex;
gap: 12px;
margin-top: 8px;
@media (width <= 500px) {
flex-direction: column;
gap: 0;
}
}
.abd-flex {
flex: 1;
}
// Share %
.abd-share-row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-top: 8px;
}
.abd-share-field {
flex: 0 0 150px;
}
.abd-pct {
font-size: 15px;
font-weight: 600;
color: $muted;
margin-right: 4px;
}
.abd-quick-pct {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding-top: 10px;
}
.abd-pct-chip {
padding: 5px 12px;
border-radius: 8px;
border: 1.5px solid $border;
background: #fff;
font-size: 12px;
font-weight: 600;
color: $muted;
cursor: pointer;
transition: all 0.12s;
&:hover {
border-color: $primary;
background: $primary-light;
color: $primary;
}
}
// Required star
.abd-required {
color: #e02424;
}
// Minor toggle
.abd-minor-toggle {
margin-top: 14px;
padding: 12px 14px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 8px;
}
// Guardian box
.abd-guardian-box {
margin-top: 10px;
padding: 14px;
background: $orange-light;
border: 1.5px solid $orange-border;
border-radius: $radius;
}
.abd-guardian-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: $orange;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 10px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: $orange;
}
}
// Actions
.abd-actions {
padding: 12px 20px 16px !important;
border-top: 1px solid $border;
gap: 10px;
justify-content: flex-end !important;
}
.abd-btn-add {
border-radius: 8px !important;
padding: 0 18px !important;
gap: 4px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
@@ -0,0 +1,167 @@
/**
* 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, inject, OnDestroy, ViewEncapsulation } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatDialogRef, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatIcon } from '@angular/material/icon';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { ClientsService } from 'app/clients/clients.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-add-beneficiary-dialog',
templateUrl: './add-beneficiary-dialog.component.html',
styleUrl: './add-beneficiary-dialog.component.scss',
encapsulation: ViewEncapsulation.None,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogContent,
MatDialogActions,
MatProgressSpinner,
MatIcon,
MatSlideToggle,
ReactiveFormsModule
]
})
export class AddBeneficiaryDialogComponent implements OnDestroy {
private fb = inject(UntypedFormBuilder);
private clientsService = inject(ClientsService);
dialogRef = inject(MatDialogRef<AddBeneficiaryDialogComponent>);
/** Toggle between linked client and manual entry */
mode: 'search' | 'manual' = 'search';
form: UntypedFormGroup = this.fb.group({
searchText: [''],
name: [
'',
Validators.required
],
relationship: [
'',
Validators.required
],
dateOfBirth: [null],
sharePercentage: [
null,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
isMinor: [false],
guardianName: [''],
guardianRelationship: ['']
});
searchResults: any[] = [];
selectedClient: any = null;
isSearching = false;
private searchTimer: any = null;
readonly relationships = [
'Spouse',
'Son',
'Daughter',
'Father',
'Mother',
'Brother',
'Sister',
'Grandson',
'Granddaughter',
'Father-in-law',
'Mother-in-law',
'Other'
];
readonly guardianRelationships = [
'Father',
'Mother',
'Guardian',
'Natural Guardian'
];
get isMinor(): boolean {
return this.form.get('isMinor').value;
}
switchMode(m: 'search' | 'manual'): void {
this.mode = m;
this.selectedClient = null;
this.searchResults = [];
this.form.patchValue({ searchText: '', name: '' });
}
onSearchInput(value: string): void {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.selectedClient = null;
if (!value || value.trim().length < 2) {
this.searchResults = [];
return;
}
this.isSearching = true;
this.searchTimer = setTimeout(() => {
this.clientsService.searchByText(value.trim(), 0, 10).subscribe({
next: (res: any) => {
this.searchResults = res?.content || [];
this.isSearching = false;
},
error: () => {
this.searchResults = [];
this.isSearching = false;
}
});
}, 400);
}
selectClient(client: any): void {
this.selectedClient = client;
this.form.patchValue({
searchText: client.displayName,
name: client.displayName
});
this.searchResults = [];
}
clearSelection(): void {
this.selectedClient = null;
this.form.patchValue({ searchText: '', name: '' });
this.searchResults = [];
}
confirm(): void {
if (
this.form.get('name').invalid ||
this.form.get('relationship').invalid ||
this.form.get('sharePercentage').invalid
)
return;
const v = this.form.getRawValue();
this.dialogRef.close({
clientId: this.selectedClient?.id || null,
name: v.name,
relationship: v.relationship,
dateOfBirth: v.dateOfBirth,
sharePercentage: v.sharePercentage,
isMinor: v.isMinor,
guardianName: v.isMinor ? v.guardianName : null,
guardianRelationship: v.isMinor ? v.guardianRelationship : null
});
}
cancel(): void {
this.dialogRef.close(null);
}
ngOnDestroy(): void {
if (this.searchTimer) clearTimeout(this.searchTimer);
}
}
@@ -0,0 +1,135 @@
<!--
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/.
-->
<!-- Dialog Header -->
<div class="arc-header">
<div class="arc-header__icon">
<mat-icon>person_add</mat-icon>
</div>
<div class="arc-header__text">
<h2 class="arc-header__title">Add Related Client / Partner</h2>
<p class="arc-header__sub">Search and link an existing client with their ownership share</p>
</div>
<button mat-icon-button class="arc-header__close" (click)="cancel()">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content>
<form [formGroup]="form" class="arc-dialog-form">
<!-- Step 1: Client Search -->
<div class="arc-step-label">
<span class="arc-step-badge">1</span>
<span>Select Client</span>
</div>
<div class="arc-search-section">
<mat-form-field appearance="outline" class="arc-full-width">
<mat-label>Search by Name / CIF / Account No</mat-label>
<mat-icon matPrefix class="arc-search-icon">search</mat-icon>
<input
matInput
formControlName="searchText"
(input)="onSearchInput($event.target.value)"
autocomplete="off"
placeholder="Type at least 2 characters..."
/>
@if (isSearching) {
<mat-progress-spinner matSuffix diameter="18" mode="indeterminate"></mat-progress-spinner>
}
@if (selectedClient) {
<button matSuffix mat-icon-button type="button" (click)="clearSelection()">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
<!-- Search Results -->
@if (searchResults.length > 0) {
<div class="arc-search-results">
@for (client of searchResults; track client) {
<div class="arc-search-result-item" (click)="selectClient(client)">
<div class="arc-result-avatar">
{{ (client.displayName || '?')[0].toUpperCase() }}
</div>
<div class="arc-result-body">
<span class="arc-result-name">{{ client.displayName }}</span>
<span class="arc-result-meta">
@if (client.cifNumber) {
<span class="arc-chip">{{ client.cifNumber }}</span>
}
@if (client.officeName) {
<span class="arc-chip">{{ client.officeName }}</span>
}
@if (client.status?.value) {
<span class="arc-chip arc-chip--status" [class.arc-chip--active]="client.status?.id === 300">
{{ client.status.value }}
</span>
}
</span>
</div>
<mat-icon class="arc-result-arrow">chevron_right</mat-icon>
</div>
}
</div>
}
</div>
<!-- Selected Client Card -->
@if (selectedClient) {
<div class="arc-selected-client">
<div class="arc-selected-avatar">
{{ (selectedClient.displayName || '?')[0].toUpperCase() }}
</div>
<div class="arc-client-info">
<span class="arc-client-name">{{ selectedClient.displayName }}</span>
<span class="arc-client-sub">{{ selectedClient.cifNumber || selectedClient.accountNumber || 'N/A' }}</span>
</div>
<span class="arc-selected-badge">Selected</span>
</div>
}
<!-- Step 2: Share Percentage -->
<div class="arc-step-label arc-step-label--mt">
<span class="arc-step-badge">2</span>
<span>Ownership Share</span>
</div>
<div class="arc-share-row">
<mat-form-field appearance="outline" class="arc-share-field">
<mat-label>Share Percentage</mat-label>
<input matInput type="number" formControlName="sharePercentage" min="1" max="100" />
<span matSuffix class="arc-percent-suffix">%</span>
<mat-error>Enter a value between 1 and 100</mat-error>
</mat-form-field>
<div class="arc-share-hints">
@for (hint of [25, 33, 50, 100]; track hint) {
<button type="button" class="arc-hint-chip" (click)="form.get('sharePercentage').setValue(hint)">
{{ hint }}%
</button>
}
</div>
</div>
</form>
</mat-dialog-content>
<mat-dialog-actions class="arc-actions">
<button mat-stroked-button type="button" (click)="cancel()" class="arc-btn-cancel">Cancel</button>
<button
mat-flat-button
color="primary"
type="button"
class="arc-btn-add"
[disabled]="!selectedClient || form.get('sharePercentage').invalid"
(click)="confirm()"
>
<mat-icon>add</mat-icon>
Add Partner
</button>
</mat-dialog-actions>
@@ -0,0 +1,359 @@
/**
* 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/.
*/
// ── Tokens ───────────────────────────────────────────────────────────────────
$primary: #2563eb;
$primary-light: #eff6ff;
$primary-border: #bfdbfe;
$success: #16a34a;
$success-light: #f0fdf4;
$success-border: #bbf7d0;
$border: #e5e7eb;
$text: #111827;
$text-muted: #6b7280;
$radius: 10px;
// ── Dialog wrapper ────────────────────────────────────────────────────────────
mifosx-add-related-client-dialog {
display: block;
}
// ── Header ────────────────────────────────────────────────────────────────────
.arc-header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 20px 20px 14px;
border-bottom: 1px solid $border;
&__icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: $primary-light;
border: 1px solid $primary-border;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
mat-icon {
color: $primary;
font-size: 20px;
width: 20px;
height: 20px;
}
}
&__text {
flex: 1;
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: $text;
line-height: 1.3;
}
&__sub {
margin: 3px 0 0;
font-size: 12px;
color: $text-muted;
}
&__close {
margin-top: -4px;
margin-right: -8px;
color: $text-muted;
}
}
// ── Dialog content ────────────────────────────────────────────────────────────
.arc-dialog-form {
display: flex;
flex-direction: column;
gap: 0;
width: 440px;
max-width: 100%;
padding-top: 4px;
}
// ── Step labels ───────────────────────────────────────────────────────────────
.arc-step-label {
display: flex;
align-items: center;
gap: 8px;
margin: 16px 0 10px;
font-size: 12px;
font-weight: 700;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.04em;
&--mt {
margin-top: 20px;
}
}
.arc-step-badge {
width: 20px;
height: 20px;
border-radius: 50%;
background: $primary;
color: #fff;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
// ── Search ────────────────────────────────────────────────────────────────────
.arc-full-width {
width: 100%;
}
.arc-search-section {
position: relative;
}
.arc-search-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: $text-muted;
margin-right: 4px;
}
.arc-search-results {
position: absolute;
z-index: 200;
top: calc(100% - 8px);
left: 0;
right: 0;
background: #fff;
border: 1px solid $border;
border-radius: $radius;
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
max-height: 240px;
overflow-y: auto;
}
.arc-search-result-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid #f3f4f6;
transition: background 0.12s;
&:last-child {
border-bottom: none;
}
&:hover {
background: $primary-light;
.arc-result-arrow {
opacity: 1;
transform: translateX(2px);
}
}
}
.arc-result-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #2563eb);
color: #fff;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.arc-result-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
}
.arc-result-name {
font-size: 13px;
font-weight: 600;
color: $text;
}
.arc-result-meta {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.arc-chip {
font-size: 10px;
padding: 1px 7px;
border-radius: 999px;
background: #f3f4f6;
color: $text-muted;
font-weight: 500;
&--status {
background: #fef3c7;
color: #92400e;
}
&--active {
background: #dcfce7;
color: #166534;
}
}
.arc-result-arrow {
font-size: 18px;
width: 18px;
height: 18px;
color: $primary;
opacity: 0;
transition:
opacity 0.12s,
transform 0.12s;
}
// ── Selected client ───────────────────────────────────────────────────────────
.arc-selected-client {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: $success-light;
border: 1.5px solid $success-border;
border-radius: $radius;
margin-top: 6px;
}
.arc-selected-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
background: linear-gradient(135deg, #22c55e, #16a34a);
color: #fff;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.arc-client-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.arc-client-name {
font-size: 13px;
font-weight: 700;
color: #166534;
}
.arc-client-sub {
font-size: 11px;
color: #15803d;
}
.arc-selected-badge {
font-size: 10px;
font-weight: 700;
padding: 3px 10px;
border-radius: 999px;
background: $success;
color: #fff;
text-transform: uppercase;
letter-spacing: 0.05em;
}
// ── Share percentage ──────────────────────────────────────────────────────────
.arc-share-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.arc-share-field {
flex: 0 0 160px;
}
.arc-percent-suffix {
font-size: 15px;
font-weight: 600;
color: $text-muted;
margin-right: 4px;
}
.arc-share-hints {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding-top: 10px;
}
.arc-hint-chip {
padding: 5px 12px;
border-radius: 8px;
border: 1.5px solid $border;
background: #fff;
font-size: 12px;
font-weight: 600;
color: $text-muted;
cursor: pointer;
transition:
border-color 0.12s,
background 0.12s,
color 0.12s;
&:hover {
border-color: $primary;
background: $primary-light;
color: $primary;
}
}
// ── Actions ───────────────────────────────────────────────────────────────────
.arc-actions {
padding: 12px 20px 16px !important;
border-top: 1px solid $border;
gap: 10px;
justify-content: flex-end !important;
}
.arc-btn-cancel {
border-radius: 8px !important;
}
.arc-btn-add {
border-radius: 8px !important;
padding: 0 18px !important;
gap: 4px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
@@ -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/.
*/
import { Component, inject, OnDestroy, ViewEncapsulation } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatDialogRef, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatIcon } from '@angular/material/icon';
import { ClientsService } from 'app/clients/clients.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-add-related-client-dialog',
templateUrl: './add-related-client-dialog.component.html',
styleUrl: './add-related-client-dialog.component.scss',
encapsulation: ViewEncapsulation.None,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogContent,
MatDialogActions,
MatProgressSpinner,
MatIcon,
ReactiveFormsModule
]
})
export class AddRelatedClientDialogComponent implements OnDestroy {
private fb = inject(UntypedFormBuilder);
private clientsService = inject(ClientsService);
dialogRef = inject(MatDialogRef<AddRelatedClientDialogComponent>);
form: UntypedFormGroup = this.fb.group({
searchText: [''],
sharePercentage: [
null,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
signingAuthority: [null],
role: [null]
});
readonly signingAuthorityOptions = [
{ value: 'SINGLE', label: 'Single' },
{ value: 'JOINTLY', label: 'Jointly' },
{ value: 'EITHER_OR_SURVIVOR', label: 'Either or Survivor' }
];
readonly roleOptions = [
{ value: 'PRIMARY', label: 'Primary' },
{ value: 'JOINT', label: 'Joint' },
{ value: 'NOMINEE', label: 'Nominee' }
];
searchResults: any[] = [];
selectedClient: any = null;
isSearching = false;
private searchTimer: any = null;
onSearchInput(value: string): void {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.selectedClient = null;
if (!value || value.trim().length < 2) {
this.searchResults = [];
return;
}
this.isSearching = true;
this.searchTimer = setTimeout(() => {
this.clientsService.searchByText(value.trim(), 0, 10).subscribe({
next: (res: any) => {
this.searchResults = res?.content || [];
this.isSearching = false;
},
error: () => {
this.searchResults = [];
this.isSearching = false;
}
});
}, 400);
}
selectClient(client: any): void {
this.selectedClient = client;
this.form.patchValue({ searchText: client.displayName });
this.searchResults = [];
}
clearSelection(): void {
this.selectedClient = null;
this.form.patchValue({ searchText: '' });
this.searchResults = [];
}
confirm(): void {
if (!this.selectedClient || this.form.get('sharePercentage').invalid) return;
this.dialogRef.close({
clientId: this.selectedClient.id,
clientName: this.selectedClient.displayName,
clientCif: this.selectedClient.cifNumber || this.selectedClient.cifNo || '',
sharePercentage: this.form.get('sharePercentage').value,
signingAuthority: this.form.get('signingAuthority').value || null,
role: this.form.get('role').value || null
});
}
cancel(): void {
this.dialogRef.close(null);
}
ngOnDestroy(): void {
if (this.searchTimer) clearTimeout(this.searchTimer);
}
}
@@ -7,63 +7,910 @@
-->
<form [formGroup]="currentAccountDetailsForm">
<div class="layout-row-wrap gap-2px responsive-column">
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
<mat-select formControlName="productId" required>
@for (product of productData; track product) {
<mat-option [value]="product.id">
{{ product.name }}
</mat-option>
}
</mat-select>
<mat-error>
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
@if (currentAccountProductSelected) {
<mat-form-field class="flex-48" (click)="submittedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Submitted On' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="submittedOnDatePicker"
formControlName="submittedOnDate"
required
/>
<mat-datepicker-toggle matSuffix [for]="submittedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #submittedOnDatePicker></mat-datepicker>
<!-- ══ Account Details ══════════════════════════════════════════ -->
<div class="sad-section">
<div class="sad-section__header">
<i class="fas fa-info-circle"></i>
<h3>Account Details</h3>
</div>
<div class="sad-grid">
<mat-form-field appearance="outline" class="sad-field">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
<mat-select formControlName="productId" required>
@for (product of productData; track product) {
<mat-option [value]="product.id">{{ product.name }}</mat-option>
}
</mat-select>
<mat-error>
{{ 'labels.inputs.Submitted On' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
}
@if (currentAccountProductSelected) {
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Field Officer' | translate }}</mat-label>
<mat-select formControlName="fieldOfficerId">
@for (officer of fieldOfficerData; track officer) {
<mat-option [value]="officer.id">
{{ officer.displayName }}
</mat-option>
}
</mat-select>
</mat-form-field>
}
@if (currentAccountProductSelected) {
<mat-form-field appearance="outline" class="sad-field" (click)="submittedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Submitted On' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="submittedOnDatePicker"
formControlName="submittedOnDate"
required
/>
<mat-datepicker-toggle matSuffix [for]="submittedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #submittedOnDatePicker></mat-datepicker>
<mat-error>
{{ 'labels.inputs.Submitted On' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
@if (currentAccountProductSelected) {
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.External ID' | translate }}</mat-label>
<input matInput formControlName="externalId" />
</mat-form-field>
}
<mat-form-field appearance="outline" class="sad-field">
<mat-label>{{ 'labels.inputs.Field Officer' | translate }}</mat-label>
<mat-select formControlName="fieldOfficerId">
@for (officer of fieldOfficerData; track officer) {
<mat-option [value]="officer.id">{{ officer.displayName }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="sad-field">
<mat-label>{{ 'labels.inputs.External ID' | translate }}</mat-label>
<input matInput formControlName="externalId" />
</mat-form-field>
<mat-form-field appearance="outline" class="sad-field" (click)="establishmentDatePicker.open()">
<mat-label>Establishment Date</mat-label>
<input matInput [matDatepicker]="establishmentDatePicker" formControlName="establishmentDate" />
<mat-datepicker-toggle matSuffix [for]="establishmentDatePicker"></mat-datepicker-toggle>
<mat-datepicker #establishmentDatePicker></mat-datepicker>
<mat-hint>Date of entity / business establishment</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="sad-field" (click)="startOperationDatePicker.open()">
<mat-label>Start of Operation Date</mat-label>
<input matInput [matDatepicker]="startOperationDatePicker" formControlName="startOperationDate" />
<mat-datepicker-toggle matSuffix [for]="startOperationDatePicker"></mat-datepicker-toggle>
<mat-datepicker #startOperationDatePicker></mat-datepicker>
<mat-hint>Date when operations commenced</mat-hint>
</mat-form-field>
}
</div>
</div>
@if (currentAccountProductSelected && productTemplate) {
<!-- ══ Product Classification (read-only) ══════════════════════ -->
<div class="sad-section sad-section--mt">
<div class="sad-section__header">
<i class="fas fa-tags"></i>
<h3>Product Classification</h3>
<span class="sad-badge">From Product</span>
</div>
<div class="sad-grid sad-grid--classification">
<mat-form-field appearance="outline" class="sad-field">
<mat-label>Account Type</mat-label>
<mat-select [(ngModel)]="selectedAccountType" [ngModelOptions]="{ standalone: true }">
@for (opt of accountTypeOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="sad-field">
<mat-label>Customer Segment</mat-label>
<mat-select [(ngModel)]="selectedCustomerSegment" [ngModelOptions]="{ standalone: true }">
@for (opt of customerSegmentOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="sad-field">
<mat-label>Account No</mat-label>
<input
matInput
[value]="productTemplate?.previewAccountNo || currentAccountTemplate?.accountNo || 'Auto-generated'"
readonly
/>
</mat-form-field>
@if (productTemplate.effectiveDate) {
<mat-form-field appearance="outline" class="sad-field">
<mat-label>Effective Date</mat-label>
<input matInput [value]="formatTemplateDate(productTemplate.effectiveDate)" readonly />
</mat-form-field>
}
@if (productTemplate.effectiveTo) {
<mat-form-field appearance="outline" class="sad-field">
<mat-label>Effective To</mat-label>
<input matInput [value]="formatTemplateDate(productTemplate.effectiveTo)" readonly />
</mat-form-field>
}
@if (productTemplate.endDate) {
<mat-form-field appearance="outline" class="sad-field">
<mat-label>End Date</mat-label>
<input matInput [value]="formatTemplateDate(productTemplate.endDate)" readonly />
</mat-form-field>
}
<mat-form-field appearance="outline" class="sad-field">
<mat-label>Introducer Required</mat-label>
<input matInput [value]="productTemplate.introducerRequired ? 'Yes' : 'No'" readonly />
</mat-form-field>
<div class="sad-field sad-info-row">
<span class="sad-info-label">Share Linking Enabled</span>
<span class="sad-badge" [class.sad-badge--yes]="productTemplate.shareLinkingEnabled">
{{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }}
</span>
</div>
</div>
</div>
@if (productTemplate?.introducerRequired) {
<mifosx-introducer-search (introducerChange)="onIntroducerChange($event)"></mifosx-introducer-search>
}
}
<!-- ══ Status of Account (RBI) — Individual Clients Only ════════ -->
@if (currentAccountProductSelected) {
<div class="soa-card">
<div class="soa-card__header">
<div class="soa-card__header-left">
<div class="soa-card__icon">
<i class="fas fa-id-card"></i>
</div>
<div>
<h3 class="soa-card__title">Status of Account</h3>
<span class="soa-card__sub">As per RBI guidelines — select account holder type</span>
</div>
</div>
<span class="soa-pill">RBI Compliant</span>
</div>
<!-- Auto-detected client info bar -->
@if (clientDob) {
<div class="soa-client-info-bar">
<div class="soa-client-info-bar__item">
<mat-icon>cake</mat-icon>
<span
>Date of Birth: <strong>{{ formatTemplateDate(clientDob) }}</strong></span
>
</div>
<div class="soa-client-info-bar__item">
<mat-icon>person_pin</mat-icon>
<span
>Age: <strong>{{ clientAge }} years</strong></span
>
</div>
@if (clientAge !== null && clientAge < 18) {
<span class="soa-auto-pill soa-auto-pill--minor">
<mat-icon>child_care</mat-icon> Auto-detected: Minor
</span>
} @else if (clientAge !== null && clientAge >= 60) {
<span class="soa-auto-pill soa-auto-pill--senior">
<mat-icon>elderly</mat-icon> Auto-detected: Senior Citizen
</span>
} @else {
<span class="soa-auto-pill soa-auto-pill--ok"><mat-icon>check_circle</mat-icon> Age Verified</span>
}
</div>
}
<!-- Status selector tiles -->
<div class="soa-tiles">
@for (opt of accountStatusOptions; track opt.value) {
<button
type="button"
class="soa-tile"
[class.soa-tile--selected]="selectedAccountStatus === opt.value"
[class.soa-tile--disabled]="isTileDisabled(opt.value)"
[disabled]="isTileDisabled(opt.value)"
(click)="!isTileDisabled(opt.value) && (selectedAccountStatus = opt.value)"
[title]="getTileTooltip(opt.value)"
>
<mat-icon class="soa-tile__icon" [style.color]="isTileDisabled(opt.value) ? '#cbd5e1' : opt.color">
{{ opt.icon }}
</mat-icon>
<span class="soa-tile__label">{{ opt.label }}</span>
@if (isTileDisabled(opt.value)) {
<span class="soa-tile__lock"><mat-icon>lock</mat-icon></span>
}
@if (selectedAccountStatus === opt.value) {
<mat-icon class="soa-tile__check">check_circle</mat-icon>
}
</button>
}
</div>
<!-- ── JOINT ACCOUNT ─────────────────────────────────────────── -->
@if (selectedAccountStatus === 'JOINT') {
<div class="soa-block">
<div class="soa-block__head">
<div class="soa-block__head-left">
<span class="soa-block__letter" style="background: #8b5cf6">A</span>
<div>
<span class="soa-block__title">Joint Account Holders</span>
<span class="soa-block__hint">Add all co-applicants linked to this joint current account</span>
</div>
</div>
<button
mat-flat-button
color="primary"
type="button"
class="soa-add-btn"
(click)="openAddJointHolderDialog()"
>
<mat-icon>person_add</mat-icon> Add Holder
</button>
</div>
<div class="soa-table-wrap">
<table class="soa-tbl">
<thead>
<tr>
<th class="soa-th soa-th--sr">Sr.</th>
<th class="soa-th">Holder Name</th>
<th class="soa-th">CIF No.</th>
<th class="soa-th soa-th--center">Ownership %</th>
<th class="soa-th soa-th--center">Signing Authority</th>
<th class="soa-th soa-th--center">Role</th>
<th class="soa-th soa-th--center">Action</th>
</tr>
</thead>
<tbody>
<!-- Primary account holder row (non-removable) -->
<tr class="soa-tr soa-tr--primary">
<td class="soa-td soa-td--sr">1</td>
<td class="soa-td">
<div class="soa-client-cell">
<div class="soa-avatar soa-avatar--primary">
{{ primaryClientName ? primaryClientName[0].toUpperCase() : '?' }}
</div>
<div>
<span class="soa-client-name">{{ primaryClientName || 'Primary Holder' }}</span>
<span class="soa-primary-tag">Primary Account Holder</span>
</div>
</div>
</td>
<td class="soa-td">
<span class="soa-mono">{{ primaryClientCif || '—' }}</span>
</td>
<td class="soa-td soa-td--center">
<input
class="soa-pct-input"
type="number"
min="0"
max="100"
[(ngModel)]="primaryHolderShare"
[ngModelOptions]="{ standalone: true }"
placeholder="0"
/>
<span class="soa-pct-unit">%</span>
</td>
<td class="soa-td soa-td--center">
<span class="soa-sign-toggle soa-sign-toggle--mandatory">
<mat-icon>lock</mat-icon> Mandatory
</span>
</td>
<td class="soa-td soa-td--center">Primary Account Holder</td>
<td class="soa-td soa-td--center"><span class="soa-no-action">—</span></td>
</tr>
<!-- Co-holders -->
@for (h of jointHolders; track h; let i = $index) {
<tr class="soa-tr" [class.soa-tr--mandatory]="h.isMandatory">
<td class="soa-td soa-td--sr">{{ i + 2 }}</td>
<td class="soa-td">
<div class="soa-client-cell">
<div class="soa-avatar" [class.soa-avatar--mandatory]="h.isMandatory">
{{ h.clientName[0].toUpperCase() }}
</div>
<div>
<span class="soa-client-name">{{ h.clientName }}</span>
@if (h.isMandatory) {
<span class="soa-mandatory-tag">Mandatory Signatory</span>
}
</div>
</div>
</td>
<td class="soa-td">
<span class="soa-mono">{{ h.clientCif || '—' }}</span>
</td>
<td class="soa-td soa-td--center">
<span class="soa-pct-badge" [class.soa-pct-badge--full]="h.sharePercentage === 100">
{{ h.sharePercentage }}%
</span>
</td>
<td class="soa-td soa-td--center">
<button
type="button"
class="soa-sign-toggle"
[class.soa-sign-toggle--mandatory]="h.isMandatory"
[class.soa-sign-toggle--optional]="!h.isMandatory"
(click)="toggleJointHolderMandatory(h)"
>
<mat-icon>{{ h.isMandatory ? 'lock' : 'lock_open' }}</mat-icon>
{{ h.isMandatory ? 'Mandatory' : 'Optional' }}
</button>
</td>
<td class="soa-td soa-td--center">Co-Holder</td>
<td class="soa-td soa-td--center">
<button mat-icon-button color="warn" type="button" (click)="removeJointHolder(i)">
<mat-icon>delete_outline</mat-icon>
</button>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="soa-tr-total">
<td colspan="3" class="soa-td soa-td--total-label">Total Ownership</td>
<td class="soa-td soa-td--center">
<span
class="soa-pct-badge"
[class.soa-pct-badge--ok]="totalJointSharePercentage === 100"
[class.soa-pct-badge--warn]="totalJointSharePercentage !== 100"
>
{{ totalJointSharePercentage }}%
</span>
</td>
<td class="soa-td soa-td--center">
<span class="soa-mandatory-count">
<mat-icon>lock</mat-icon> {{ mandatoryJointCount + 1 }} Mandatory
</span>
</td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
@if (jointHolders.length === 0) {
<div class="soa-empty" style="margin-top: 8px">
<mat-icon>group_add</mat-icon>
<span>Click <strong>Add Holder</strong> to add joint account holders.</span>
</div>
}
@if (totalJointSharePercentage !== 100) {
<div class="soa-alert soa-alert--warn" style="margin-top: 8px">
<mat-icon>warning_amber</mat-icon>
Total ownership is {{ totalJointSharePercentage }}%. It must equal 100%.
</div>
} @else {
<div class="soa-alert soa-alert--ok" style="margin-top: 8px">
<mat-icon>check_circle</mat-icon>
Ownership allocation is complete (100%).
</div>
}
</div>
<!-- Mode of Operation -->
<div class="soa-block soa-block--sep">
<div class="soa-block__head">
<div class="soa-block__head-left">
<span class="soa-block__letter" style="background: #7c3aed">B</span>
<div>
<span class="soa-block__title">Mode of Operation</span>
<span class="soa-block__hint">Auto-set from mandatory holders — override manually if required</span>
</div>
</div>
@if (modeOfOperation) {
<span class="soa-auto-badge"><mat-icon>auto_awesome</mat-icon> Auto</span>
}
</div>
@if (jointHolders.length === 0) {
<div class="soa-empty">
<mat-icon>how_to_vote</mat-icon>
<span>Add joint holders in Section A first.</span>
</div>
} @else {
<div class="soa-mode-grid">
@for (opt of modeOfOperationOptions; track opt.value) {
<button
type="button"
class="soa-mode-card"
[class.soa-mode-card--selected]="modeOfOperation === opt.value"
(click)="modeOfOperation = opt.value"
>
<mat-icon class="soa-mode-card__icon">{{ opt.icon }}</mat-icon>
<div class="soa-mode-card__code">{{ opt.label }}</div>
<div class="soa-mode-card__desc">{{ opt.sublabel }}</div>
@if (modeOfOperation === opt.value) {
<mat-icon class="soa-mode-card__check">check_circle</mat-icon>
}
</button>
}
</div>
<div class="soa-mode-summary" [class.soa-mode-summary--set]="modeOfOperation">
@if (modeOfOperation) {
<mat-icon>how_to_vote</mat-icon>
<span
>Mode of Operation: <strong>{{ selectedModeLabel }}</strong></span
>
<span class="soa-mode-summary__from">
({{ mandatoryJointCount }} Mandatory + {{ jointHolders.length - mandatoryJointCount }} Optional)
</span>
} @else {
<mat-icon>info</mat-icon>
<span>Set Mandatory/Optional for holders above to auto-select mode.</span>
}
</div>
}
</div>
}
<!-- ── MINOR ACCOUNT ─────────────────────────────────────────── -->
@if (selectedAccountStatus === 'MINOR') {
<div class="soa-block">
<div class="soa-block__head">
<div class="soa-block__head-left">
<span class="soa-block__letter" style="background: #f59e0b">
<mat-icon style="font-size: 14px; height: 14px; width: 14px">child_care</mat-icon>
</span>
<div>
<span class="soa-block__title">Minor Account Details</span>
<span class="soa-block__hint">As per RBI — guardian must be an existing registered client</span>
</div>
</div>
<span class="soa-pill soa-pill--minor">Guardian Required</span>
</div>
<div class="soa-minor-grid">
<mat-form-field appearance="outline" class="soa-field" (click)="minorDobPicker.open()">
<mat-label>Minor's Date of Birth</mat-label>
<input
matInput
[matDatepicker]="minorDobPicker"
[max]="maxDate"
[(ngModel)]="minorDob"
[ngModelOptions]="{ standalone: true }"
/>
<mat-datepicker-toggle matSuffix [for]="minorDobPicker"></mat-datepicker-toggle>
<mat-datepicker #minorDobPicker></mat-datepicker>
@if (clientDob) {
<mat-hint>Auto-filled from client profile</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="soa-field">
<mat-label>Age</mat-label>
<input matInput [value]="clientAge !== null ? clientAge + ' years' : '—'" readonly />
<mat-hint>Must be below 18 years</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="soa-field">
<mat-label>Date of Majority (Auto)</mat-label>
<input matInput [value]="majorityDate" readonly />
<mat-hint>Auto-calculated: DOB + 18 years</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="soa-field">
<mat-label>Guardian Relationship</mat-label>
<mat-select [(ngModel)]="guardianRelationship" [ngModelOptions]="{ standalone: true }">
@for (rel of guardianRelationshipOptions; track rel) {
<mat-option [value]="rel">{{ rel }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="soa-guardian-search">
<div class="soa-guardian-search__label">
<mat-icon>manage_accounts</mat-icon>
<span>Search &amp; Link Guardian (existing client)</span>
</div>
<mifosx-introducer-search (introducerChange)="onGuardianChange($event)"></mifosx-introducer-search>
@if (guardianName) {
<div class="soa-guardian-selected">
<mat-icon class="soa-guardian-selected__icon">verified_user</mat-icon>
<div>
<div class="soa-guardian-selected__name">{{ guardianName }}</div>
<div class="soa-guardian-selected__cif">CIF: {{ guardianCif || 'N/A' }}</div>
</div>
<span class="soa-pill soa-pill--ok">Guardian Linked</span>
</div>
}
</div>
<div class="soa-info-banner soa-info-banner--minor">
<mat-icon>info</mat-icon>
<ul>
<li>Minor below 10 years — Guardian must operate the account.</li>
<li>Minor above 10 years — Can operate independently (bank discretion).</li>
<li>Account must be converted to regular savings on attaining majority.</li>
<li>Fresh KYC of the minor must be done on majority.</li>
</ul>
</div>
</div>
}
<!-- ── SENIOR CITIZEN ────────────────────────────────────────── -->
@if (selectedAccountStatus === 'SENIOR_CITIZEN') {
<div class="soa-block">
<div class="soa-block__head">
<div class="soa-block__head-left">
<span class="soa-block__letter" style="background: #10b981">
<mat-icon style="font-size: 14px; height: 14px; width: 14px">elderly</mat-icon>
</span>
<div>
<span class="soa-block__title">Senior Citizen Account</span>
<span class="soa-block__hint">Age ≥ 60 years — eligible for preferential banking services</span>
</div>
</div>
<span class="soa-pill soa-pill--senior">Age ≥ 60</span>
</div>
<div class="soa-minor-grid" style="margin-bottom: 16px">
<mat-form-field appearance="outline" class="soa-field">
<mat-label>Date of Birth</mat-label>
<input matInput [value]="formatTemplateDate(clientDob)" readonly />
<mat-hint>From client profile</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="soa-field">
<mat-label>Age</mat-label>
<input matInput [value]="clientAge !== null ? clientAge + ' years' : '—'" readonly />
<mat-hint>Age ≥ 60 required for Senior Citizen</mat-hint>
</mat-form-field>
</div>
<div class="soa-sc-benefits">
<div class="soa-sc-benefit">
<mat-icon>priority_high</mat-icon>
<div>
<strong>Priority Service</strong>
<span>Dedicated counters / assisted banking at branches</span>
</div>
</div>
<div class="soa-sc-benefit">
<mat-icon>percent</mat-icon>
<div>
<strong>Preferential Interest Rate</strong>
<span>Additional 0.25%–0.50% on deposits (as per product)</span>
</div>
</div>
<div class="soa-sc-benefit">
<mat-icon>health_and_safety</mat-icon>
<div>
<strong>Doorstep Banking</strong>
<span>Eligible for home banking services (RBI circular)</span>
</div>
</div>
<div class="soa-sc-benefit">
<mat-icon>gavel</mat-icon>
<div>
<strong>Nomination Mandatory</strong>
<span>RBI mandate — ensure nomination is registered</span>
</div>
</div>
</div>
<div class="soa-info-banner soa-info-banner--senior">
<mat-icon>info</mat-icon>
<ul>
<li>Age proof (DOB document) mandatory at time of account opening.</li>
<li>Nomination registration is compulsory (Form DA-1).</li>
<li>Bank must offer dedicated helpline and priority grievance redressal.</li>
<li>Door-step banking services must be offered (RBI Master Direction 2023).</li>
</ul>
</div>
</div>
}
<!-- ── INDIVIDUAL MALE ───────────────────────────────────────── -->
@if (selectedAccountStatus === 'INDIVIDUAL_MALE') {
<div class="soa-block">
<div class="soa-block__head">
<div class="soa-block__head-left">
<span class="soa-block__letter" style="background: #3b82f6">
<mat-icon style="font-size: 14px; height: 14px; width: 14px">person</mat-icon>
</span>
<div>
<span class="soa-block__title">Individual Account (Male)</span>
<span class="soa-block__hint"
>Select special category if applicable — governs applicable schemes &amp; benefits</span
>
</div>
</div>
<span class="soa-pill" style="background: #dbeafe; color: #1d4ed8; border-color: #bfdbfe"
>Special Category</span
>
</div>
<div class="soa-category-grid">
@for (opt of specialCategoryOptions; track opt.value) {
<button
type="button"
class="soa-cat-tile"
[class.soa-cat-tile--selected]="selectedSpecialCategory === opt.value"
[class.soa-cat-tile--male]="true"
(click)="selectedSpecialCategory = opt.value"
>
<mat-icon class="soa-cat-tile__icon">{{ opt.icon }}</mat-icon>
<span class="soa-cat-tile__label">{{ opt.label }}</span>
@if (selectedSpecialCategory === opt.value) {
<mat-icon class="soa-cat-tile__check">check_circle</mat-icon>
}
</button>
}
</div>
<div class="soa-info-banner soa-info-banner--male">
<mat-icon>info</mat-icon>
<ul>
<li>Valid KYC documents required (Aadhaar / PAN / Passport).</li>
<li>Nomination registration is mandatory (Form DA-1).</li>
<li>Defence / Ex-Serviceman — eligible for special savings schemes (DSSP).</li>
<li>NRI — FCNR / NRE / NRO account rules apply as per FEMA guidelines.</li>
</ul>
</div>
</div>
}
<!-- ── INDIVIDUAL FEMALE ──────────────────────────────────────── -->
@if (selectedAccountStatus === 'INDIVIDUAL_FEMALE') {
<div class="soa-block">
<div class="soa-block__head">
<div class="soa-block__head-left">
<span class="soa-block__letter" style="background: #ec4899">
<mat-icon style="font-size: 14px; height: 14px; width: 14px">person_outline</mat-icon>
</span>
<div>
<span class="soa-block__title">Individual Account (Female)</span>
<span class="soa-block__hint"
>Select weaker section category if applicable — governs PMJDY &amp; priority schemes</span
>
</div>
</div>
<span class="soa-pill" style="background: #fce7f3; color: #be185d; border-color: #f9a8d4"
>Weaker Section</span
>
</div>
<div class="soa-category-grid">
@for (opt of weakerSectionOptions; track opt.value) {
<button
type="button"
class="soa-cat-tile"
[class.soa-cat-tile--selected]="selectedWeakerSection === opt.value"
[class.soa-cat-tile--female]="true"
(click)="selectedWeakerSection = opt.value"
>
<mat-icon class="soa-cat-tile__icon">{{ opt.icon }}</mat-icon>
<span class="soa-cat-tile__label">{{ opt.label }}</span>
@if (selectedWeakerSection === opt.value) {
<mat-icon class="soa-cat-tile__check">check_circle</mat-icon>
}
</button>
}
</div>
<div class="soa-info-banner soa-info-banner--female">
<mat-icon>info</mat-icon>
<ul>
<li>Valid KYC documents required (Aadhaar / PAN / Passport).</li>
<li>Nomination registration is mandatory (Form DA-1).</li>
<li>BPL / SC-ST / Minority — eligible for zero-balance accounts (RBI Priority Sector norms).</li>
<li>PMJDY beneficiary — ₹10,000 overdraft facility &amp; accident insurance cover applicable.</li>
<li>SHG members — group linkage lending &amp; special deposit rates applicable.</li>
</ul>
</div>
</div>
}
</div>
}
<!-- ══ Non-Individual Account Details (Indian Banking Style) ═════ -->
@if (isNonIndividualClient && currentAccountProductSelected) {
<div class="ni-card">
<!-- Card Header -->
<div class="ni-card__header">
<div class="ni-card__header-left">
<div class="ni-card__header-icon">
<i class="fas fa-building"></i>
</div>
<div>
<h3 class="ni-card__title">Non-Individual Account Details</h3>
<span class="ni-card__sub">As per RBI / Indian banking norms for non-individual entities</span>
</div>
</div>
<span class="ni-pill ni-pill--entity">Entity Account</span>
</div>
<!-- ── A. Partners / Members ─────────────────────────────── -->
<div class="ni-block">
<div class="ni-block__head">
<div class="ni-block__head-left">
<span class="ni-block__letter">A</span>
<div>
<span class="ni-block__title">Partners / Members</span>
<span class="ni-block__hint">Authorised persons linked to this entity account</span>
</div>
</div>
<button
mat-flat-button
color="primary"
type="button"
class="ni-add-btn"
(click)="openAddRelatedClientDialog()"
>
<mat-icon>person_add</mat-icon> Add Partner
</button>
</div>
@if (relatedClients.length > 0) {
<div class="ni-table-wrap">
<table class="ni-tbl">
<thead>
<tr>
<th class="ni-th ni-th--sr">Sr.</th>
<th class="ni-th">Partner Name</th>
<th class="ni-th">CIF No.</th>
<th class="ni-th ni-th--center">Ownership %</th>
<th class="ni-th ni-th--center">Signing Authority</th>
<th class="ni-th ni-th--center">Action</th>
</tr>
</thead>
<tbody>
@for (c of relatedClients; track c; let i = $index) {
<tr class="ni-tr" [class.ni-tr--mandatory]="c.isMandatory">
<td class="ni-td ni-td--sr">{{ i + 1 }}</td>
<td class="ni-td">
<div class="ni-client-cell">
<div class="ni-avatar" [class.ni-avatar--mandatory]="c.isMandatory">
{{ c.clientName[0].toUpperCase() }}
</div>
<div>
<span class="ni-client-name">{{ c.clientName }}</span>
@if (c.isMandatory) {
<span class="ni-mandatory-tag">Mandatory Signatory</span>
}
</div>
</div>
</td>
<td class="ni-td">
<span class="ni-mono">{{ c.clientCif || '—' }}</span>
</td>
<td class="ni-td ni-td--center">
<span class="ni-pct-badge" [class.ni-pct-badge--full]="c.sharePercentage === 100">
{{ c.sharePercentage }}%
</span>
</td>
<td class="ni-td ni-td--center">
<button
type="button"
class="ni-sign-toggle"
[class.ni-sign-toggle--mandatory]="c.isMandatory"
[class.ni-sign-toggle--optional]="!c.isMandatory"
(click)="toggleMandatory(c)"
[title]="c.isMandatory ? 'Click to set Optional' : 'Click to set Mandatory'"
>
<mat-icon>{{ c.isMandatory ? 'lock' : 'lock_open' }}</mat-icon>
{{ c.isMandatory ? 'Mandatory' : 'Optional' }}
</button>
</td>
<td class="ni-td ni-td--center">
<button
mat-icon-button
color="warn"
type="button"
(click)="removeRelatedClient(i)"
class="ni-del-btn"
>
<mat-icon>delete_outline</mat-icon>
</button>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="ni-tr-total">
<td colspan="3" class="ni-td ni-td--total-label">Total Ownership</td>
<td class="ni-td ni-td--center">
<span
class="ni-pct-badge"
[class.ni-pct-badge--ok]="totalSharePercentage === 100"
[class.ni-pct-badge--warn]="totalSharePercentage !== 100"
>
{{ totalSharePercentage }}%
</span>
</td>
<td class="ni-td ni-td--center">
<span class="ni-mandatory-count-badge">
<mat-icon>lock</mat-icon> {{ mandatoryCount }} Mandatory
</span>
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
@if (totalSharePercentage !== 100) {
<div class="ni-alert ni-alert--warn">
<mat-icon>warning_amber</mat-icon>
Total ownership is {{ totalSharePercentage }}%. It must equal 100%.
</div>
} @else {
<div class="ni-alert ni-alert--ok">
<mat-icon>check_circle</mat-icon>
Ownership allocation is complete (100%).
</div>
}
} @else {
<div class="ni-empty-state">
<mat-icon>group_add</mat-icon>
<span>No partners added yet. Click <strong>Add Partner</strong> to begin.</span>
</div>
}
</div>
<!-- ── B. Mode of Operation / Signatory Rights (auto from partners) ── -->
<div class="ni-block ni-block--sep">
<div class="ni-block__head">
<div class="ni-block__head-left">
<span class="ni-block__letter ni-block__letter--b">B</span>
<div>
<span class="ni-block__title">Mode of Operation / Signatory Rights</span>
<span class="ni-block__hint">Auto-computed from Mandatory partners — you can override manually</span>
</div>
</div>
@if (signatoryRights) {
<span class="ni-auto-badge"> <mat-icon>auto_awesome</mat-icon> Auto </span>
}
</div>
@if (relatedClients.length === 0) {
<div class="ni-empty-state">
<mat-icon>how_to_vote</mat-icon>
<span>Add partners in Section A and set Mandatory/Optional to auto-compute the mode.</span>
</div>
} @else {
<div class="ni-signatory-grid">
@for (opt of signatoryRightsOptions; track opt.value) {
<button
type="button"
class="ni-sig-card"
[class.ni-sig-card--selected]="signatoryRights === opt.value"
(click)="signatoryRights = opt.value"
>
<mat-icon class="ni-sig-card__icon">{{ opt.icon }}</mat-icon>
<div class="ni-sig-card__code">{{ opt.label }}</div>
<div class="ni-sig-card__desc">{{ opt.sublabel }}</div>
@if (signatoryRights === opt.value) {
<mat-icon class="ni-sig-card__check">check_circle</mat-icon>
}
</button>
}
</div>
<div class="ni-mode-summary" [class.ni-mode-summary--set]="signatoryRights">
@if (signatoryRights) {
<mat-icon>how_to_vote</mat-icon>
<span
>Mode of Operation: <strong>{{ selectedSignatoryLabel }}</strong></span
>
<span class="ni-mode-summary__from">
({{ mandatoryCount }} Mandatory + {{ relatedClients.length - mandatoryCount }} Optional partner(s))
</span>
} @else {
<mat-icon>info</mat-icon>
<span>Set Mandatory/Optional for partners above to auto-select mode.</span>
}
</div>
}
</div>
</div>
}
<div class="layout-row align-center gap-2percent margin-t responsive-column">
<button mat-raised-button matStepperPrevious disabled>
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
@@ -7,15 +7,21 @@
*/
/** Angular Imports */
import { Component, OnInit, Input, Output, EventEmitter, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { Component, OnInit, Input, Output, EventEmitter, inject, ChangeDetectorRef } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule, FormsModule } from '@angular/forms';
import { Dates } from 'app/core/utils/dates';
/** Custom Services */
import { CurrentAccountsService } from 'app/current-accounts/current-accounts.service';
import { SettingsService } from 'app/settings/settings.service';
import { ClientsService } from 'app/clients/clients.service';
import { MatDialog } from '@angular/material/dialog';
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { IntroducerSearchComponent } from 'app/shared/introducer-search/introducer-search.component';
import { AddRelatedClientDialogComponent } from './add-related-client-dialog/add-related-client-dialog.component';
/**
* Current Account Details Step
@@ -28,41 +34,140 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
...STANDALONE_SHARED_IMPORTS,
MatStepperPrevious,
FaIconComponent,
MatStepperNext
MatStepperNext,
IntroducerSearchComponent,
MatIcon,
FormsModule
]
})
export class CurrentAccountDetailsStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private currentAccountsService = inject(CurrentAccountsService);
private settingsService = inject(SettingsService);
private clientsService = inject(ClientsService);
private dialog = inject(MatDialog);
private dateUtils = inject(Dates);
private cdr = inject(ChangeDetectorRef);
/** Current Account Template */
@Input() currentAccountTemplate: any;
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
maxDate = new Date();
/** Product Data */
productData: any;
/** Field Officer Data */
fieldOfficerData: any;
/** For edit current account form */
isFieldOfficerPatched = false;
/** Current Account Details Form */
currentAccountDetailsForm: UntypedFormGroup;
currentAccountProductSelected = false;
introducerId: number | null = null;
introducerName: string | null = null;
introducerCif: string | null = null;
// ── Non-Individual (existing) ──────────────────────────────────────────
isNonIndividualClient = false;
relatedClients: Array<{
clientId: number;
clientName: string;
clientCif: string;
sharePercentage: number;
isMandatory: boolean;
}> = [];
signatoryRights = '';
readonly signatoryRightsOptions = [
{ value: 'ANYONE', label: 'E or S', sublabel: 'Either or Survivor', icon: 'how_to_vote' },
{ value: 'ONE_MANDATORY', label: 'J/One', sublabel: 'Joint – One Mandatory', icon: 'group' },
{ value: 'TWO_MANDATORY', label: 'J/Two', sublabel: 'Joint – Two Mandatory', icon: 'groups' },
{ value: 'ONLY_ONE', label: 'Single', sublabel: 'Only 1 Person', icon: 'person' }
];
/** Current Account Template with product data */
@Output() currentAccountProductTemplate = new EventEmitter();
/**
* Sets share account details form.
* @param {FormBuilder} formBuilder Form Builder.
* @param {CurrentAccountsService} currentAccountsService Current Accounts Service.
* @param {SettingsService} settingsService Setting service
*/
productTemplate: any = null;
accountTypeOptions: string[] = [];
customerSegmentOptions: string[] = [];
selectedAccountType: string | null = null;
selectedCustomerSegment: string | null = null;
// ── Client info (auto-fetched) ─────────────────────────────────────────
clientDob: Date | null = null;
clientAge: number | null = null;
clientGender: string | null = null;
primaryClientName = '';
primaryClientCif = '';
primaryClientId: number | null = null;
primaryHolderShare = 0;
// ── Status of Account (RBI) ────────────────────────────────────────────
readonly accountStatusOptions = [
{ value: 'INDIVIDUAL_MALE', label: 'Individual (Male)', icon: 'person', color: '#3b82f6' },
{ value: 'INDIVIDUAL_FEMALE', label: 'Individual (Female)', icon: 'person_outline', color: '#ec4899' },
{ value: 'SINGLE', label: 'Single Account', icon: 'account_circle', color: '#0891b2' },
{ value: 'JOINT', label: 'Joint Account', icon: 'group', color: '#8b5cf6' },
{ value: 'MINOR', label: 'Minor Account', icon: 'child_care', color: '#f59e0b' },
{ value: 'SENIOR_CITIZEN', label: 'Senior Citizen', icon: 'elderly', color: '#10b981' }
];
selectedAccountStatus = '';
// ── Joint Account (Status of Account) ─────────────────────────────────
jointHolders: Array<{
clientId: number;
clientName: string;
clientCif: string;
sharePercentage: number;
isMandatory: boolean;
signingAuthority?: string;
role?: string;
}> = [];
modeOfOperation = '';
readonly modeOfOperationOptions = [
{ value: 'EITHER_OR_SURVIVOR', label: 'E or S', sublabel: 'Either or Survivor', icon: 'how_to_vote' },
{ value: 'FORMER_OR_SURVIVOR', label: 'F or S', sublabel: 'Former or Survivor', icon: 'person' },
{ value: 'JOINTLY', label: 'Jointly', sublabel: 'All Must Sign', icon: 'groups' },
{ value: 'ANYONE_OR_SURVIVOR', label: 'Anyone', sublabel: 'Anyone or Survivor', icon: 'group' }
];
// ── Individual Male — Special Category ────────────────────────────────
selectedSpecialCategory = '';
readonly specialCategoryOptions = [
{ value: 'DEFENCE', label: 'Defence Personnel', icon: 'military_tech' },
{ value: 'EX_SERVICEMAN', label: 'Ex-Serviceman', icon: 'shield' },
{ value: 'GOVT_EMPLOYEE', label: 'Government Employee', icon: 'account_balance' },
{ value: 'PHYSICALLY_CHALLENGED', label: 'Physically Challenged', icon: 'accessible' },
{ value: 'NRI', label: 'NRI (Non-Resident Indian)', icon: 'flight' },
{ value: 'NONE', label: 'None / General', icon: 'person' }
];
// ── Individual Female — Weaker Section ────────────────────────────────
selectedWeakerSection = '';
readonly weakerSectionOptions = [
{ value: 'BPL', label: 'BPL (Below Poverty Line)', icon: 'volunteer_activism' },
{ value: 'SC_ST', label: 'SC / ST Category', icon: 'groups' },
{ value: 'MINORITY', label: 'Minority Community', icon: 'diversity_3' },
{ value: 'SHG', label: 'Self Help Group Member', icon: 'handshake' },
{ value: 'PMJDY', label: 'PMJDY Beneficiary', icon: 'savings' },
{ value: 'NONE', label: 'None / General', icon: 'person_outline' }
];
// ── Minor Account ──────────────────────────────────────────────────────
guardianClientId: number | null = null;
guardianName = '';
guardianCif = '';
guardianRelationship = '';
minorDob: Date | null = null;
readonly guardianRelationshipOptions = [
'Father',
'Mother',
'Legal Guardian',
'Court Appointed Guardian'
];
constructor() {
this.createCurrentAccountDetailsForm();
}
@@ -70,6 +175,27 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.buildDependencies();
const clientId = this.currentAccountTemplate?.clientId;
if (clientId) {
this.primaryClientId = clientId;
this.clientsService.getClientData(String(clientId)).subscribe((client: any) => {
this.isNonIndividualClient = client?.legalForm?.id === 2;
this.primaryClientName = client?.displayName ?? client?.fullname ?? '';
this.primaryClientCif = client?.cifNumber ?? client?.cifNo ?? '';
if (client?.dateOfBirth) {
const dob = this.parseClientDob(client.dateOfBirth);
if (dob) {
this.clientDob = dob;
this.clientAge = this.calculateAge(dob);
this.clientGender = client.gender?.name?.toLowerCase() ?? null;
this.autoDetectAccountStatus();
}
}
this.cdr.detectChanges();
});
}
if (this.currentAccountTemplate) {
this.productData = this.currentAccountTemplate.productOptions;
if (this.currentAccountTemplate.currentAccountProductId) {
@@ -82,15 +208,12 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
});
} else {
this.currentAccountDetailsForm.patchValue({
submittedOnDate: new Date()
submittedOnDate: this.settingsService.businessDate
});
}
}
}
/**
* Creates current account details form.
*/
createCurrentAccountDetailsForm() {
this.currentAccountDetailsForm = this.formBuilder.group({
productId: [
@@ -102,22 +225,45 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
Validators.required
],
fieldOfficerId: [''],
externalId: ['']
externalId: [''],
balanceSwapOption: [false],
msme: [false],
autoDebitFacility: [false],
establishmentDate: [''],
startOperationDate: ['']
});
}
/**
* Fetches current account product template on productId value changes
*/
buildDependencies() {
const entityId = this.currentAccountTemplate.groupId || this.currentAccountTemplate.clientId;
this.currentAccountDetailsForm.get('productId').valueChanges.subscribe((productId: string) => {
this.currentAccountsService
.getCurrentAccountTemplate(entityId, productId, this.currentAccountTemplate.groupId ? true : false)
.subscribe((response: any) => {
if (!response.currency) {
const selectedProduct = this.currentAccountTemplate.productOptions?.find(
(product: any) => product.id == productId
);
if (selectedProduct?.currency) {
response.currency = selectedProduct.currency;
} else if (this.currentAccountTemplate.currency) {
response.currency = this.currentAccountTemplate.currency;
}
}
this.productTemplate = response;
this.accountTypeOptions = this.extractOptions(response.accountTypeOptions, response.accountType);
this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment);
this.selectedAccountType = response.accountType ?? null;
this.selectedCustomerSegment = response.customerSegment ?? null;
this.currentAccountProductTemplate.emit(response);
this.fieldOfficerData = response.fieldOfficerOptions;
this.currentAccountProductSelected = true;
this.currentAccountDetailsForm.patchValue({
balanceSwapOption: !!response.balanceSwapOption,
msme: !!response.msme,
autoDebitFacility: !!response.autoDebitFacility
});
this.cdr.detectChanges();
if (!this.isFieldOfficerPatched && this.currentAccountTemplate.fieldOfficerId) {
this.currentAccountDetailsForm.get('fieldOfficerId').patchValue(this.currentAccountTemplate.fieldOfficerId);
this.isFieldOfficerPatched = true;
@@ -128,10 +274,266 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
});
}
/**
* Returns current account form value.
*/
private extractOptions(options: any[], fallback?: string): string[] {
if (Array.isArray(options) && options.length > 0) {
return options.map((o: any) => o.value ?? o.name ?? o);
}
return fallback ? [fallback] : [];
}
formatTemplateDate(value: any): string {
if (!value) return '';
let dateValue: Date | null = null;
if (value instanceof Date) {
dateValue = value;
} else if (Array.isArray(value) && value.length >= 3) {
dateValue = new Date(value[0], value[1] - 1, value[2]);
} else {
dateValue = new Date(value);
}
if (!dateValue || Number.isNaN(dateValue.getTime())) {
return Array.isArray(value) ? value.join('/') : String(value);
}
return this.dateUtils.formatDate(dateValue, 'M/d/yyyy') || '';
}
// ── Client DOB helpers ─────────────────────────────────────────────────
private parseClientDob(dob: any): Date | null {
if (!dob) return null;
if (Array.isArray(dob) && dob.length >= 3) return new Date(dob[0], dob[1] - 1, dob[2]);
const d = new Date(dob);
return isNaN(d.getTime()) ? null : d;
}
private calculateAge(dob: Date): number {
const today = this.settingsService.businessDate ?? new Date();
let age = today.getFullYear() - dob.getFullYear();
const m = today.getMonth() - dob.getMonth();
if (m < 0 || (m === 0 && today.getDate() < dob.getDate())) age--;
return age;
}
private autoDetectAccountStatus(): void {
if (this.clientAge === null) return;
if (this.clientAge < 18) {
this.selectedAccountStatus = 'MINOR';
this.minorDob = this.clientDob;
} else if (this.clientAge >= 60) {
this.selectedAccountStatus = 'SENIOR_CITIZEN';
} else if (this.clientGender === 'female') {
this.selectedAccountStatus = 'INDIVIDUAL_FEMALE';
} else {
this.selectedAccountStatus = 'INDIVIDUAL_MALE';
}
}
isTileDisabled(value: string): boolean {
if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18;
if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60;
return false;
}
getTileTooltip(value: string): string {
if (value === 'MINOR' && this.clientAge !== null && this.clientAge >= 18)
return `Age ${this.clientAge} yrs — Minor only allowed below 18 years`;
if (value === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60)
return `Age ${this.clientAge} yrs — Senior Citizen only allowed at 60+ years`;
if (value === 'MINOR' && this.clientAge === null) return 'DOB not available — cannot verify age';
if (value === 'SENIOR_CITIZEN' && this.clientAge === null) return 'DOB not available — cannot verify age';
return '';
}
// ── Introducer ─────────────────────────────────────────────────────────
onIntroducerChange(event: { clientId: number; displayName: string; cifNumber?: string } | null): void {
this.introducerId = event?.clientId ?? null;
this.introducerName = event?.displayName ?? null;
this.introducerCif = event?.cifNumber ?? null;
}
onGuardianChange(event: { clientId: number; displayName: string; cifNumber?: string } | null): void {
this.guardianClientId = event?.clientId ?? null;
this.guardianName = event?.displayName ?? '';
this.guardianCif = event?.cifNumber ?? '';
}
// ── Joint Account (Status of Account) ─────────────────────────────────
openAddJointHolderDialog(): void {
const ref = this.dialog.open(AddRelatedClientDialogComponent, {
width: '500px',
panelClass: 'arc-dialog-panel',
disableClose: true
});
ref.afterClosed().subscribe((result) => {
if (result) {
const duplicate = this.jointHolders.some((h) => h.clientCif && h.clientCif === result.clientCif);
if (duplicate) {
alert(`Account holder with CIF ${result.clientCif} is already added.`);
return;
}
this.jointHolders = [
...this.jointHolders,
{ ...result, isMandatory: false }];
this.autoSetModeOfOperation();
}
});
}
removeJointHolder(index: number): void {
this.jointHolders = this.jointHolders.filter((_, i) => i !== index);
this.autoSetModeOfOperation();
}
toggleJointHolderMandatory(holder: any): void {
holder.isMandatory = !holder.isMandatory;
this.autoSetModeOfOperation();
}
autoSetModeOfOperation(): void {
const mandatory = this.jointHolders.filter((h) => h.isMandatory).length;
if (mandatory === 0) this.modeOfOperation = 'EITHER_OR_SURVIVOR';
else if (mandatory === 1) this.modeOfOperation = 'FORMER_OR_SURVIVOR';
else this.modeOfOperation = 'JOINTLY';
}
get totalJointSharePercentage(): number {
return (this.primaryHolderShare || 0) + this.jointHolders.reduce((sum, h) => sum + (h.sharePercentage || 0), 0);
}
get mandatoryJointCount(): number {
return this.jointHolders.filter((h) => h.isMandatory).length;
}
get selectedModeLabel(): string {
return this.modeOfOperationOptions.find((o) => o.value === this.modeOfOperation)?.sublabel ?? '';
}
get majorityDate(): string {
const dob = this.minorDob ?? this.clientDob;
if (!dob) return '';
const d = new Date(dob);
d.setFullYear(d.getFullYear() + 18);
return this.dateUtils.formatDate(d, 'M/d/yyyy') || '';
}
// ── Non-Individual (existing) helpers ──────────────────────────────────
openAddRelatedClientDialog(): void {
const ref = this.dialog.open(AddRelatedClientDialogComponent, {
width: '500px',
panelClass: 'arc-dialog-panel',
disableClose: true
});
ref.afterClosed().subscribe((result) => {
if (result) {
const duplicate = this.relatedClients.some((c) => c.clientCif && c.clientCif === result.clientCif);
if (duplicate) {
alert(`Partner with CIF ${result.clientCif} is already added.`);
return;
}
this.relatedClients = [
...this.relatedClients,
{ ...result, isMandatory: false }];
this.autoSetSignatoryRights();
}
});
}
removeRelatedClient(index: number): void {
this.relatedClients = this.relatedClients.filter((_, i) => i !== index);
this.autoSetSignatoryRights();
}
toggleMandatory(client: any): void {
client.isMandatory = !client.isMandatory;
this.autoSetSignatoryRights();
}
autoSetSignatoryRights(): void {
const mandatory = this.relatedClients.filter((c) => c.isMandatory).length;
if (mandatory === 0) this.signatoryRights = 'ANYONE';
else if (mandatory === 1) this.signatoryRights = 'ONE_MANDATORY';
else this.signatoryRights = 'TWO_MANDATORY';
}
get totalSharePercentage(): number {
return this.relatedClients.reduce((sum, c) => sum + (c.sharePercentage || 0), 0);
}
get mandatoryCount(): number {
return this.relatedClients.filter((c) => c.isMandatory).length;
}
get selectedSignatoryLabel(): string {
const opt = this.signatoryRightsOptions.find((x) => x.value === this.signatoryRights);
return opt ? opt.sublabel : '';
}
get currentAccountDetails() {
return this.currentAccountDetailsForm.getRawValue();
const value = this.currentAccountDetailsForm.getRawValue();
if (this.introducerId) {
value.introducerId = this.introducerId;
value.introducerName = this.introducerName;
value.introducerCifNumber = this.introducerCif;
}
value.isNonIndividualClient = this.isNonIndividualClient;
// Product classification fields
if (this.selectedAccountType) {
value.accountType = this.selectedAccountType;
}
if (this.selectedCustomerSegment) {
value.customerSegment = this.selectedCustomerSegment;
}
// accountStatus applies to all client types
value.accountStatus = this.selectedAccountStatus || null;
if (this.selectedAccountStatus === 'INDIVIDUAL_MALE' && this.selectedSpecialCategory)
value.specialCategory = this.selectedSpecialCategory;
if (this.selectedAccountStatus === 'INDIVIDUAL_FEMALE' && this.selectedWeakerSection)
value.weakerSection = this.selectedWeakerSection;
if (this.selectedAccountStatus === 'JOINT') {
value.modeOfOperation = this.modeOfOperation || null;
value.jointHolders = [
{
clientId: this.primaryClientId,
displayName: this.primaryClientName,
cifNumber: this.primaryClientCif,
sharePercentage: this.primaryHolderShare,
isMandatory: true,
isPrimary: true,
signingAuthority: 'Mandatory',
role: 'Primary Account Holder'
},
...this.jointHolders.map((h) => ({
clientId: h.clientId,
displayName: h.clientName,
cifNumber: h.clientCif,
sharePercentage: h.sharePercentage,
isMandatory: h.isMandatory,
isPrimary: false,
signingAuthority: h.signingAuthority || (h.isMandatory ? 'Mandatory' : 'Optional'),
role: h.role || 'Co-Holder'
}))
];
}
if (this.selectedAccountStatus === 'MINOR') {
value.guardianClientId = this.guardianClientId;
value.guardianName = this.guardianName;
value.guardianCifNumber = this.guardianCif;
value.guardianRelationship = this.guardianRelationship;
value.minorDateOfBirth = this.minorDob ? this.dateUtils.formatDate(this.minorDob, 'yyyy-MM-dd') : null;
}
// non-individual: partners are a separate dataset
if (this.isNonIndividualClient) {
value.signatoryRights = this.signatoryRights || null;
value.partners = this.relatedClients.map((c) => ({
clientId: c.clientId,
clientName: c.clientName,
clientCif: c.clientCif,
sharePercentage: c.sharePercentage,
isMandatory: c.isMandatory
}));
}
return value;
}
}
@@ -0,0 +1,214 @@
<!--
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="dig-wrapper">
<div class="dig-header">
<div class="dig-header__icon">
<i class="fas fa-mobile-alt"></i>
</div>
<div class="dig-header__text">
<h3 class="dig-header__title">Digital &amp; Account Features</h3>
<p class="dig-header__sub">Toggle the facilities available for this account (as per RBI guidelines)</p>
</div>
<span class="dig-header__badge">FROM PRODUCT</span>
</div>
<div class="dig-grid">
<div class="dig-card" [class.dig-card--on]="facilityConfig.atmFacility">
<div class="dig-card__icon"><i class="fas fa-credit-card"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">ATM Facility</span>
<span class="dig-card__status">{{ facilityConfig.atmFacility ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.atmFacility" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.atmWithdrawalFacility">
<div class="dig-card__icon"><i class="fas fa-wallet"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">ATM Withdrawal</span>
<span class="dig-card__status">{{ facilityConfig.atmWithdrawalFacility ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.atmWithdrawalFacility" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.upiFacility">
<div class="dig-card__icon"><i class="fas fa-mobile-alt"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">UPI Facility</span>
<span class="dig-card__status">{{ facilityConfig.upiFacility ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.upiFacility" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.posTransactionsFacility">
<div class="dig-card__icon"><i class="fas fa-exchange-alt"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">POS Transactions</span>
<span class="dig-card__status">{{ facilityConfig.posTransactionsFacility ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.posTransactionsFacility" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.chequeBook">
<div class="dig-card__icon"><i class="fas fa-book"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">Cheque Book</span>
<span class="dig-card__status">{{ facilityConfig.chequeBook ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.chequeBook" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.impsFacility">
<div class="dig-card__icon"><i class="fas fa-bolt"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">IMPS Facility</span>
<span class="dig-card__status">{{ facilityConfig.impsFacility ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.impsFacility" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.smsAlerts">
<div class="dig-card__icon"><i class="fas fa-sms"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">SMS Alerts</span>
<span class="dig-card__status">{{ facilityConfig.smsAlerts ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.smsAlerts" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.bbpsFacility">
<div class="dig-card__icon"><i class="fas fa-bell"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">BBPS Facility</span>
<span class="dig-card__status">{{ facilityConfig.bbpsFacility ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.bbpsFacility" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.insuranceCover">
<div class="dig-card__icon"><i class="fas fa-shield-alt"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">Insurance Cover</span>
<span class="dig-card__status">{{ facilityConfig.insuranceCover ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.insuranceCover" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.bepgFacility">
<div class="dig-card__icon"><i class="fas fa-plug"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">BEPG Facility</span>
<span class="dig-card__status">{{ facilityConfig.bepgFacility ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.bepgFacility" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.neftFacility">
<div class="dig-card__icon"><i class="fas fa-money-check-alt"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">NEFT Facility</span>
<span class="dig-card__status">{{ facilityConfig.neftFacility ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.neftFacility" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.rtgsFacility">
<div class="dig-card__icon"><i class="fas fa-money-check"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">RTGS Facility</span>
<span class="dig-card__status">{{ facilityConfig.rtgsFacility ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.rtgsFacility" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.interBankTotalCapFacility">
<div class="dig-card__icon"><i class="fas fa-balance-scale"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">Inter-Bank Total Cap</span>
<span class="dig-card__status">{{ facilityConfig.interBankTotalCapFacility ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.interBankTotalCapFacility" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.withdrawalFacility">
<div class="dig-card__icon"><i class="fas fa-arrow-right-arrow-left"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">Withdrawal Facility</span>
<span class="dig-card__status">{{ facilityConfig.withdrawalFacility ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.withdrawalFacility" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.depositFacilityKyc">
<div class="dig-card__icon"><i class="fas fa-id-card"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">Deposit Facility (KYC)</span>
<span class="dig-card__status">{{ facilityConfig.depositFacilityKyc ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.depositFacilityKyc" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.virtualDebitCard">
<div class="dig-card__icon"><i class="fas fa-credit-card"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">Virtual Debit Card</span>
<span class="dig-card__status">{{ facilityConfig.virtualDebitCard ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.virtualDebitCard" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
</div>
</div>
@@ -0,0 +1,210 @@
/**
* 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: #2563eb;
$blue-light: #eff6ff;
$green: #16a34a;
$green-light: #f0fdf4;
$green-border: #bbf7d0;
$border: #e2e8f0;
$text: #1e293b;
$muted: #64748b;
$gray-bg: #f8fafc;
// ── Wrapper ──────────────────────────────────────────────
.dig-wrapper {
border: 1px solid $border;
border-radius: 14px;
overflow: hidden;
background: #fff;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
}
// ── Header ───────────────────────────────────────────────
.dig-header {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 24px;
background: $gray-bg;
border-bottom: 1px solid $border;
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
background: $blue-light;
color: $blue;
font-size: 16px;
flex: 0 0 auto;
}
&__text {
flex: 1;
}
&__title {
margin: 0 0 2px;
font-size: 15px;
font-weight: 700;
color: $text;
}
&__sub {
margin: 0;
font-size: 12px;
color: $muted;
}
&__badge {
flex: 0 0 auto;
padding: 4px 12px;
border-radius: 999px;
background: #dbeafe;
color: $blue;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
}
}
// ── Grid ─────────────────────────────────────────────────
.dig-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
@media (width <= 960px) {
grid-template-columns: repeat(2, 1fr);
}
@media (width <= 580px) {
grid-template-columns: 1fr;
}
}
// ── Card ─────────────────────────────────────────────────
.dig-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-right: 1px solid $border;
border-bottom: 1px solid $border;
background: #fff;
transition: background 0.18s ease;
cursor: pointer;
&:nth-child(3n) {
border-right: none;
}
&--on {
background: $green-light;
.dig-card__icon {
background: $green-border;
color: $green;
}
.dig-card__status {
color: $green;
font-weight: 600;
}
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 9px;
background: $blue-light;
color: $blue;
font-size: 14px;
flex: 0 0 auto;
transition:
background 0.18s,
color 0.18s;
}
&__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
&__label {
font-size: 13px;
font-weight: 600;
color: $text;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__status {
font-size: 11px;
color: $muted;
transition: color 0.18s;
}
}
// ── Toggle Switch ─────────────────────────────────────────
.dig-toggle {
position: relative;
flex: 0 0 auto;
cursor: pointer;
input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
&:checked + .dig-toggle__track {
background: $green;
border-color: $green;
}
&:checked + .dig-toggle__track .dig-toggle__thumb {
transform: translateX(18px);
}
}
&__track {
display: block;
width: 40px;
height: 22px;
border-radius: 999px;
background: #cbd5e1;
border: 1.5px solid #94a3b8;
position: relative;
transition:
background 0.2s,
border-color 0.2s;
}
&__thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgb(0 0 0 / 20%);
transition: transform 0.2s ease;
}
}
@@ -0,0 +1,54 @@
/**
* 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, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
const DEFAULT_FACILITY_CONFIG = {
atmFacility: false,
atmWithdrawalFacility: false,
upiFacility: false,
posTransactionsFacility: false,
chequeBook: false,
impsFacility: false,
smsAlerts: false,
bbpsFacility: false,
insuranceCover: false,
bepgFacility: false,
neftFacility: false,
rtgsFacility: false,
interBankTotalCapFacility: false,
withdrawalFacility: false,
depositFacilityKyc: false,
virtualDebitCard: false
};
@Component({
selector: 'mifosx-current-account-digital-step',
templateUrl: './current-account-digital-step.component.html',
styleUrls: ['./current-account-digital-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class CurrentAccountDigitalStepComponent {
facilityConfig = { ...DEFAULT_FACILITY_CONFIG };
@Input()
set currentAccountProductTemplate(value: any) {
if (value?.accountFacilityConfig) {
this.facilityConfig = { ...DEFAULT_FACILITY_CONFIG, ...value.accountFacilityConfig };
}
}
get currentAccountDigital() {
return { accountFacilityConfig: { ...this.facilityConfig } };
}
}
@@ -6,251 +6,720 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="responsive-column">
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Details' | translate }}</h3>
<mat-divider class="flex-fill"></mat-divider>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Product' | translate }}</span>
<span class="flex-60">{{
currentAccount.productId | find: currentAccountTemplate.productOptions : 'id' : 'name'
}}</span>
<div class="preview-wrapper">
<!-- ══ 1. Account Details ════════════════════════════════════════ -->
<div class="preview-section">
<div class="preview-section__header">
<i class="fas fa-info-circle preview-section__icon"></i>
<span class="preview-section__title">Account Details</span>
</div>
<div class="preview-body">
<div class="preview-grid preview-grid--three">
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Product' | translate }}</span>
<span class="preview-value">{{
currentAccount?.productId | find: currentAccountTemplate?.productOptions : 'id' : 'name'
}}</span>
</div>
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Submitted On' | translate }}</span>
<span class="preview-value">{{ currentAccount?.submittedOnDate | dateFormat }}</span>
</div>
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Field Officer' | translate }}</span>
<span class="preview-value">{{
currentAccount?.fieldOfficerId
| find: currentAccountProductTemplate?.fieldOfficerOptions : 'id' : 'displayName'
}}</span>
</div>
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.External Id' | translate }}</span>
@if (currentAccount?.externalId) {
<span class="preview-value">
<mifosx-external-identifier externalId="{{ currentAccount.externalId }}"></mifosx-external-identifier>
</span>
} @else {
<span class="preview-value preview-muted">Unassigned</span>
}
</div>
@if (currentAccount?.establishmentDate) {
<div class="preview-field">
<span class="preview-label">Establishment Date</span>
<span class="preview-value">{{ currentAccount.establishmentDate | dateFormat }}</span>
</div>
}
@if (currentAccount?.startOperationDate) {
<div class="preview-field">
<span class="preview-label">Start of Operation Date</span>
<span class="preview-value">{{ currentAccount.startOperationDate | dateFormat }}</span>
</div>
}
</div>
</div>
</div>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Submitted On' | translate }}</span>
<span class="flex-60">{{ currentAccount.submittedOnDate | dateFormat }}</span>
</div>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.External Id' | translate }}</span>
@if (currentAccount.externalId) {
<span class="flex-60">
<mifosx-external-identifier externalId="{{ currentAccount.externalId }}"></mifosx-external-identifier>
</span>
}
@if (!currentAccount.externalId) {
<span class="flex-60">
{{ 'labels.inputs.Unassigned' | translate }}
</span>
}
</div>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Field Officer' | translate }}</span>
<span class="flex-60">{{
currentAccount.fieldOfficerId | find: currentAccountProductTemplate.fieldOfficerOptions : 'id' : 'displayName'
}}</span>
</div>
@if (activeClientMembers) {
<h3 class="mat-h3 margin-t flex-fill">{{ 'labels.heading.Client Members' | translate }}</h3>
<mat-divider class="flex-fill"></mat-divider>
<div class="flex-60">
<table mat-table #membersTable [dataSource]="dataSource">
<!-- ID Column -->
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Client ID' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.id }}</td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Client Name' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.displayName }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="membersDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: membersDisplayedColumns"></tr>
</table>
<!-- ══ 2. Product Classification ════════════════════════════════ -->
@if (
currentAccountProductTemplate?.accountType ||
currentAccountProductTemplate?.customerSegment ||
currentAccountProductTemplate?.productCode ||
currentAccountProductTemplate?.previewAccountNo ||
currentAccountProductTemplate?.effectiveDate ||
currentAccountProductTemplate?.effectiveTo ||
currentAccountProductTemplate?.endDate
) {
<div class="preview-section">
<div class="preview-section__header">
<i class="fas fa-tags preview-section__icon"></i>
<span class="preview-section__title">Product Classification</span>
<span class="preview-section__badge">From Product</span>
</div>
<div class="preview-body">
<div class="preview-grid preview-grid--three">
@if (currentAccountProductTemplate?.accountType) {
<div class="preview-field">
<span class="preview-label">Account Type</span>
<span class="preview-value">{{ currentAccountProductTemplate?.accountType }}</span>
</div>
}
@if (currentAccountProductTemplate?.customerSegment) {
<div class="preview-field">
<span class="preview-label">Customer Segment</span>
<span class="preview-value">{{ currentAccountProductTemplate?.customerSegment }}</span>
</div>
}
@if (currentAccountProductTemplate?.productCode) {
<div class="preview-field">
<span class="preview-label">Product Code</span>
<span class="preview-value preview-mono">{{ currentAccountProductTemplate?.productCode }}</span>
</div>
}
<div class="preview-field">
<span class="preview-label">Account No</span>
<span class="preview-value preview-mono">{{
currentAccountProductTemplate?.previewAccountNo || currentAccountTemplate?.accountNo || 'Auto-generated'
}}</span>
</div>
@if (currentAccountProductTemplate?.effectiveDate) {
<div class="preview-field">
<span class="preview-label">Effective Date</span>
<span class="preview-value">{{ currentAccountProductTemplate?.effectiveDate | dateFormat }}</span>
</div>
}
@if (currentAccountProductTemplate?.effectiveTo) {
<div class="preview-field">
<span class="preview-label">Effective To</span>
<span class="preview-value">{{ currentAccountProductTemplate?.effectiveTo | dateFormat }}</span>
</div>
}
@if (currentAccountProductTemplate?.endDate) {
<div class="preview-field">
<span class="preview-label">End Date</span>
<span class="preview-value">{{ currentAccountProductTemplate?.endDate | dateFormat }}</span>
</div>
}
<div class="preview-field">
<span class="preview-label">Introducer Required</span>
<span
class="preview-value preview-badge"
[class.preview-badge--yes]="currentAccountProductTemplate?.introducerRequired"
>
{{ currentAccountProductTemplate?.introducerRequired | yesNo }}
</span>
</div>
<div class="preview-field">
<span class="preview-label">Share Linking Enabled</span>
<span
class="preview-value preview-badge"
[class.preview-badge--yes]="currentAccountProductTemplate?.shareLinkingEnabled"
>
{{ currentAccountProductTemplate?.shareLinkingEnabled | yesNo }}
</span>
</div>
</div>
</div>
</div>
}
<h3 class="mat-h3 margin-t flex-fill">{{ 'labels.heading.Terms' | translate }}</h3>
<mat-divider class="flex-fill"></mat-divider>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Currency' | translate }}</span>
<span class="flex-60">{{ currentAccountTermsForm.get('currencyCode').value }}</span>
</div>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Decimal Places' | translate }}</span>
<span class="flex-60">{{ currentAccountTermsForm.get('decimal').value }}</span>
</div>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Nominal Annual Interest' | translate }}:</span>
<span class="flex-60">{{ currentAccount.nominalAnnualInterestRate | formatNumber }} %</span>
</div>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Interest Compounding Period' | translate }}:</span>
<span class="flex-60">{{
getCatalogTranslation(
currentAccount.interestCompoundingPeriodType
| find: currentAccountProductTemplate.interestCompoundingPeriodTypeOptions : 'id' : 'value'
)
}}</span>
</div>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Interest Posting Period' | translate }}:</span>
<span class="flex-60">{{
getCatalogTranslation(
currentAccount.interestPostingPeriodType
| find: currentAccountProductTemplate.interestPostingPeriodTypeOptions : 'id' : 'value'
)
}}</span>
</div>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Interest Calculated using' | translate }}:</span>
<span class="flex-60">{{
getCatalogTranslation(
currentAccount.interestCalculationType
| find: currentAccountProductTemplate.interestCalculationTypeOptions : 'id' : 'value'
)
}}</span>
</div>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Days in Year' | translate }}:</span>
<span class="flex-60">{{
getCatalogTranslation(
currentAccount.interestCalculationDaysInYearType
| find: currentAccountProductTemplate.interestCalculationDaysInYearTypeOptions : 'id' : 'value'
)
}}</span>
</div>
@if (currentAccount.minRequiredOpeningBalance) {
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Minimum Opening Balance' | translate }}:</span>
<span class="flex-60">{{ currentAccount.minRequiredOpeningBalance | formatNumber }}</span>
<!-- ══ 3. Introducer Details (if selected) ══════════════════════ -->
@if (currentAccount?.introducerId) {
<div class="preview-section">
<div class="preview-section__header">
<i class="fas fa-user-tie preview-section__icon"></i>
<span class="preview-section__title">Introducer Details</span>
</div>
<div class="preview-body">
<div class="preview-grid preview-grid--three">
<div class="preview-field">
<span class="preview-label">Name</span>
<span class="preview-value">{{ currentAccount?.introducerName || '—' }}</span>
</div>
@if (currentAccount?.introducerCif) {
<div class="preview-field">
<span class="preview-label">CIF No</span>
<span class="preview-value preview-mono">{{ currentAccount.introducerCif }}</span>
</div>
}
<div class="preview-field">
<span class="preview-label">Client ID</span>
<span class="preview-value preview-mono">{{ currentAccount.introducerId }}</span>
</div>
</div>
</div>
</div>
}
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}:</span>
<span class="flex-60">{{ currentAccount.withdrawalFeeForTransfers | yesNo }}</span>
</div>
<!-- ══ 4. Status of Account ══════════════════════════════════════ -->
@if (currentAccount?.accountStatus) {
<div class="preview-section">
<div class="preview-section__header">
<i class="fas fa-id-card preview-section__icon"></i>
<span class="preview-section__title">Status of Account</span>
<span class="preview-section__badge">RBI Guidelines</span>
</div>
<div class="preview-body">
<!-- Status Tile -->
<div class="preview-soa-status-row">
<span class="preview-soa-tile">
@switch (currentAccount.accountStatus) {
@case ('INDIVIDUAL_MALE') {
<i class="fas fa-male"></i> Individual (Male)
}
@case ('INDIVIDUAL_FEMALE') {
<i class="fas fa-female"></i> Individual (Female)
}
@case ('SINGLE') {
<i class="fas fa-user-circle"></i> Single Account
}
@case ('JOINT') {
<i class="fas fa-users"></i> Joint Account
}
@case ('MINOR') {
<i class="fas fa-child"></i> Minor Account
}
@case ('SENIOR_CITIZEN') {
<i class="fas fa-user-clock"></i> Senior Citizen
}
@default {
{{ currentAccount.accountStatus }}
}
}
</span>
</div>
@if (currentAccount.lockinPeriodFrequency) {
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Lock-in Period' | translate }}:</span>
<span class="flex-60">{{
getCatalogTranslation(
currentAccount.lockinPeriodFrequency +
' ' +
(currentAccount.lockinPeriodFrequencyType
| find: currentAccountProductTemplate.lockinPeriodFrequencyTypeOptions : 'id' : 'value')
)
}}</span>
</div>
}
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Is Overdraft Allowed' | translate }}:</span>
<span class="flex-60">{{ currentAccount.allowOverdraft | yesNo }}</span>
</div>
@if (currentAccount.minOverdraftForInterestCalculation) {
<div class="flex-fill layout-row">
<span class="flex-40"
>{{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}:</span
>
<span class="flex-60">{{ currentAccount.minOverdraftForInterestCalculation | formatNumber }}</span>
</div>
}
@if (currentAccount.nominalAnnualInterestRateOverdraft) {
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}:</span>
<span class="flex-60">{{ currentAccount.nominalAnnualInterestRateOverdraft | formatNumber }} %</span>
</div>
}
@if (currentAccount.overdraftLimit) {
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}:</span>
<span class="flex-60">{{ currentAccount.overdraftLimit | formatNumber }}</span>
</div>
}
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Enforce Minimum Balance' | translate }}:</span>
<span class="flex-60">{{ currentAccount.enforceMinRequiredBalance | yesNo }}</span>
</div>
@if (currentAccount.minRequiredBalance) {
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Minimum Balance' | translate }}:</span>
<span class="flex-60">{{ currentAccount.minRequiredBalance }}</span>
</div>
}
@if (currentAccount.charges.length) {
<div class="layout-row-wrap responsive-column flex-fill">
<h3 class="mat-h3 flex-fill">{{ 'labels.inputs.Charges' | translate }}</h3>
<mat-divider class="flex-fill"></mat-divider>
<table class="flex-fill mat-elevation-z1" mat-table [dataSource]="currentAccount.charges">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Name' | translate }}</th>
<td mat-cell *matCellDef="let charge">
{{ charge.name + ', ' + charge.currency.displaySymbol }}
</td>
</ng-container>
<ng-container matColumnDef="chargeCalculationType">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Type' | translate }}</th>
<td mat-cell *matCellDef="let charge">
{{ charge.chargeCalculationType.value | translateKey: 'catalogs' }}
</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Amount' | translate }}</th>
<td mat-cell *matCellDef="let charge">
{{ charge.amount | formatNumber }}
</td>
</ng-container>
<ng-container matColumnDef="date">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Date' | translate }}</th>
<td mat-cell *matCellDef="let charge">
@if (charge.chargeTimeType.value === 'Specified due date' || charge.chargeTimeType.value === 'Weekly Fee') {
<span>
{{ (charge.dueDate | dateFormat) || 'Unassigned' }}
<!-- JOINT: Mode of Operation + Holders Table -->
@if (currentAccount.accountStatus === 'JOINT') {
<div class="preview-grid preview-grid--three" style="margin-top: 12px">
<div class="preview-field">
<span class="preview-label">Mode of Operation</span>
<span class="preview-value">
@switch (currentAccount.modeOfOperation) {
@case ('EITHER_OR_SURVIVOR') {
Either or Survivor
}
@case ('FORMER_OR_SURVIVOR') {
Former or Survivor
}
@case ('JOINTLY') {
Jointly
}
@case ('ANYONE_OR_SURVIVOR') {
Anyone or Survivor
}
@default {
{{ currentAccount.modeOfOperation || '—' }}
}
}
</span>
}
@if (charge.chargeTimeType.value === 'Monthly Fee' || charge.chargeTimeType.value === 'Annual Fee') {
<span>
{{ (charge.feeOnMonthDay | dateFormat) || 'Unassigned' }}
</div>
</div>
@if (currentAccount.jointHolders?.length) {
<div class="preview-soa-table-wrap">
<table class="preview-soa-tbl">
<thead>
<tr>
<th class="preview-soa-th">Sr.</th>
<th class="preview-soa-th">Holder Name</th>
<th class="preview-soa-th">CIF No.</th>
<th class="preview-soa-th">Ownership %</th>
<th class="preview-soa-th">Signing</th>
<th class="preview-soa-th">Role</th>
</tr>
</thead>
<tbody>
@for (holder of currentAccount.jointHolders; track holder.clientId; let i = $index) {
<tr [class.preview-soa-tr--primary]="holder.isPrimary">
<td class="preview-soa-td">{{ i + 1 }}</td>
<td class="preview-soa-td">
{{ holder.clientName || holder.displayName || '—' }}
@if (holder.isPrimary) {
<span class="preview-soa-primary-tag">Primary</span>
}
</td>
<td class="preview-soa-td preview-mono">{{ holder.clientCif || holder.cifNumber || '—' }}</td>
<td class="preview-soa-td">{{ holder.sharePercentage ?? '—' }}%</td>
<td class="preview-soa-td">
<span class="preview-badge" [class.preview-badge--yes]="holder.isMandatory">
{{ holder.signingAuthority || (holder.isMandatory ? 'Mandatory' : 'Optional') }}
</span>
</td>
<td class="preview-soa-td">
{{ holder.role || (holder.isPrimary ? 'Primary Account Holder' : 'Co-Holder') }}
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
<!-- INDIVIDUAL_MALE: Special Category -->
@if (currentAccount.accountStatus === 'INDIVIDUAL_MALE' && currentAccount.specialCategory) {
<div class="preview-grid preview-grid--three" style="margin-top: 12px">
<div class="preview-field">
<span class="preview-label">Special Category</span>
<span class="preview-value">
@switch (currentAccount.specialCategory) {
@case ('DEFENCE') {
Defence Personnel
}
@case ('EX_SERVICEMAN') {
Ex-Serviceman
}
@case ('GOVT_EMPLOYEE') {
Government Employee
}
@case ('PHYSICALLY_CHALLENGED') {
Physically Challenged
}
@case ('NRI') {
NRI (Non-Resident Indian)
}
@case ('NONE') {
None / General
}
@default {
{{ currentAccount.specialCategory }}
}
}
</span>
</div>
</div>
}
<!-- INDIVIDUAL_FEMALE: Weaker Section -->
@if (currentAccount.accountStatus === 'INDIVIDUAL_FEMALE' && currentAccount.weakerSection) {
<div class="preview-grid preview-grid--three" style="margin-top: 12px">
<div class="preview-field">
<span class="preview-label">Weaker Section</span>
<span class="preview-value">
@switch (currentAccount.weakerSection) {
@case ('BPL') {
BPL (Below Poverty Line)
}
@case ('SC_ST') {
SC / ST Category
}
@case ('MINORITY') {
Minority Community
}
@case ('SHG') {
Self Help Group Member
}
@case ('PMJDY') {
PMJDY Beneficiary
}
@case ('NONE') {
None / General
}
@default {
{{ currentAccount.weakerSection }}
}
}
</span>
</div>
</div>
}
<!-- MINOR: Guardian Details -->
@if (currentAccount.accountStatus === 'MINOR') {
<div class="preview-grid preview-grid--three" style="margin-top: 12px">
@if (currentAccount.guardianName) {
<div class="preview-field">
<span class="preview-label">Guardian Name</span>
<span class="preview-value">{{ currentAccount.guardianName }}</span>
</div>
}
@if (
!(
charge.chargeTimeType.value === 'Monthly Fee' ||
charge.chargeTimeType.value === 'Annual Fee' ||
charge.chargeTimeType.value === 'Specified due date' ||
charge.chargeTimeType.value === 'Weekly Fee'
)
) {
<span> N/A </span>
@if (currentAccount.guardianCif) {
<div class="preview-field">
<span class="preview-label">Guardian CIF</span>
<span class="preview-value preview-mono">{{ currentAccount.guardianCif }}</span>
</div>
}
</td>
</ng-container>
<ng-container matColumnDef="repaymentsEvery">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Repayments Every' | translate }}</th>
<td mat-cell *matCellDef="let charge">
{{ charge.feeInterval || 'Not Provided' }}
</td>
</ng-container>
<ng-container matColumnDef="chargeTimeType">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Collected On' | translate }}</th>
<td mat-cell *matCellDef="let charge">
{{ charge.chargeTimeType.value }}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="chargesDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: chargesDisplayedColumns"></tr>
</table>
@if (currentAccount.guardianRelationship) {
<div class="preview-field">
<span class="preview-label">Relationship</span>
<span class="preview-value">{{ currentAccount.guardianRelationship }}</span>
</div>
}
@if (currentAccount.minorDob) {
<div class="preview-field">
<span class="preview-label">Minor DOB</span>
<span class="preview-value">{{ currentAccount.minorDob | dateFormat }}</span>
</div>
}
</div>
}
</div>
</div>
}
<!-- ══ 4b. Partners / Related Clients (Non-Individual only) ═════ -->
@if (currentAccount?.isNonIndividualClient) {
<div class="preview-section">
<div class="preview-section__header">
<i class="fas fa-handshake preview-section__icon"></i>
<span class="preview-section__title">Partners / Related Clients</span>
@if (currentAccount?.signatoryRights) {
<span class="preview-section__badge preview-section__badge--digital">{{
currentAccount.signatoryRights
}}</span>
}
</div>
<div class="preview-body">
@if (currentAccount?.partners?.length) {
<div class="preview-soa-table-wrap">
<table class="preview-soa-tbl">
<thead>
<tr>
<th class="preview-soa-th">Sr.</th>
<th class="preview-soa-th">Partner / Client Name</th>
<th class="preview-soa-th">CIF No.</th>
<th class="preview-soa-th">Ownership %</th>
<th class="preview-soa-th">Signing</th>
</tr>
</thead>
<tbody>
@for (p of currentAccount.partners; track p.clientId; let i = $index) {
<tr>
<td class="preview-soa-td">{{ i + 1 }}</td>
<td class="preview-soa-td">{{ p.clientName || '—' }}</td>
<td class="preview-soa-td preview-mono">{{ p.clientCif || '—' }}</td>
<td class="preview-soa-td">{{ p.sharePercentage ?? '—' }}%</td>
<td class="preview-soa-td">
<span class="preview-badge" [class.preview-badge--yes]="p.isMandatory">
{{ p.isMandatory ? 'Mandatory' : 'Optional' }}
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<p class="preview-muted">No related clients / partners added.</p>
}
</div>
</div>
}
<!-- ══ 5. Currency & Account Flags ══════════════════════════════ -->
<div class="preview-section">
<div class="preview-section__header">
<i class="fas fa-coins preview-section__icon"></i>
<span class="preview-section__title">Currency &amp; Account Flags</span>
</div>
<div class="preview-body">
<div class="preview-grid preview-grid--three">
<div class="preview-field">
<span class="preview-label">Currency</span>
<span class="preview-value">{{ currencyName }}</span>
</div>
<div class="preview-field">
<span class="preview-label">Decimal Places</span>
<span class="preview-value">{{ decimalPlaces }}</span>
</div>
<div class="preview-field">
<span class="preview-label">Currency In Multiples Of</span>
<span class="preview-value">{{ inMultiplesOf }}</span>
</div>
<div class="preview-field">
<span class="preview-label">Balance Swap Option</span>
<span
class="preview-value preview-badge"
[class.preview-badge--yes]="currentAccountCurrencyFlags?.balanceSwapOption"
>
{{ currentAccountCurrencyFlags?.balanceSwapOption | yesNo }}
</span>
</div>
<div class="preview-field">
<span class="preview-label">MSME</span>
<span class="preview-value preview-badge" [class.preview-badge--yes]="currentAccountCurrencyFlags?.msme">
{{ currentAccountCurrencyFlags?.msme | yesNo }}
</span>
</div>
<div class="preview-field">
<span class="preview-label">Auto Debit Facility</span>
<span
class="preview-value preview-badge"
[class.preview-badge--yes]="currentAccountCurrencyFlags?.autoDebitFacility"
>
{{ currentAccountCurrencyFlags?.autoDebitFacility | yesNo }}
</span>
</div>
<div class="preview-field">
<span class="preview-label">Dormancy Alert</span>
<span
class="preview-value preview-badge"
[class.preview-badge--yes]="currentAccountProductTemplate?.dormancyAlert"
>
{{ currentAccountProductTemplate?.dormancyAlert | yesNo }}
</span>
</div>
<div class="preview-field">
<span class="preview-label">Nomination Facility</span>
<span
class="preview-value preview-badge"
[class.preview-badge--yes]="currentAccountProductTemplate?.nominationFacility"
>
{{ currentAccountProductTemplate?.nominationFacility | yesNo }}
</span>
</div>
</div>
</div>
</div>
<!-- ══ 5. Dormancy Monitoring ════════════════════════════════════ -->
@if (currentAccountProductTemplate?.dormancyAlert || currentAccountProductTemplate?.isDormancyTrackingActive) {
<div class="preview-section">
<div class="preview-section__header">
<i class="fas fa-moon preview-section__icon"></i>
<span class="preview-section__title">Dormancy Monitoring</span>
<span
class="preview-section__badge"
[class.preview-section__badge--active]="currentAccountProductTemplate?.isDormancyTrackingActive"
>
{{ currentAccountProductTemplate?.isDormancyTrackingActive ? 'Active' : 'Inactive' }}
</span>
</div>
<div class="preview-body">
<div class="preview-dormancy-timeline">
<div class="preview-dorm-step">
<div class="preview-dorm-step__dot preview-dorm-step__dot--inactive"></div>
<div class="preview-dorm-step__body">
<span class="preview-dorm-step__label">Inactive</span>
<span class="preview-dorm-step__days">
@if (currentAccountProductTemplate?.daysToInactive != null) {
After {{ currentAccountProductTemplate.daysToInactive }} days of no transaction
} @else {
Not configured
}
</span>
</div>
</div>
<div class="preview-dorm-connector"></div>
<div class="preview-dorm-step">
<div class="preview-dorm-step__dot preview-dorm-step__dot--dormant"></div>
<div class="preview-dorm-step__body">
<span class="preview-dorm-step__label">Dormant</span>
<span class="preview-dorm-step__days">
@if (currentAccountProductTemplate?.daysToDormancy != null) {
{{ currentAccountProductTemplate.daysToDormancy }} days after inactive
} @else {
Not configured
}
</span>
</div>
</div>
<div class="preview-dorm-connector"></div>
<div class="preview-dorm-step">
<div class="preview-dorm-step__dot preview-dorm-step__dot--escheat"></div>
<div class="preview-dorm-step__body">
<span class="preview-dorm-step__label">Escheat</span>
<span class="preview-dorm-step__days">
@if (currentAccountProductTemplate?.daysToEscheat != null) {
{{ currentAccountProductTemplate.daysToEscheat }} days after dormant
} @else {
Not configured
}
</span>
</div>
</div>
</div>
<div class="preview-dorm-flags">
<span class="preview-dorm-flag" [class.preview-dorm-flag--on]="currentAccountProductTemplate?.dormancyAlert">
<i class="fas fa-bell"></i> Alert
{{ currentAccountProductTemplate?.dormancyAlert ? 'Enabled' : 'Disabled' }}
</span>
<span
class="preview-dorm-flag"
[class.preview-dorm-flag--on]="currentAccountProductTemplate?.isDormancyTrackingActive"
>
<i class="fas fa-eye"></i> Tracking
{{ currentAccountProductTemplate?.isDormancyTrackingActive ? 'Active' : 'Inactive' }}
</span>
</div>
</div>
</div>
}
<!-- ══ 6. Nominee Details (if filled) ═══════════════════════════ -->
@if (currentAccountNominee) {
<div class="preview-section">
<div class="preview-section__header">
<i class="fas fa-user-shield preview-section__icon"></i>
<span class="preview-section__title">Nominee Details</span>
<span class="preview-section__badge">
{{ currentAccountNominee?.mode === 'linked' ? 'Linked Client' : 'Manual Entry' }}
</span>
</div>
<div class="preview-body">
<div class="preview-grid preview-grid--three">
<div class="preview-field">
<span class="preview-label">Name</span>
<span class="preview-value">{{
currentAccountNominee?.fullName || currentAccountNominee?.displayName || '—'
}}</span>
</div>
@if (currentAccountNominee?.cifNumber) {
<div class="preview-field">
<span class="preview-label">CIF No</span>
<span class="preview-value preview-mono">{{ currentAccountNominee.cifNumber }}</span>
</div>
}
<div class="preview-field">
<span class="preview-label">Relationship</span>
<span class="preview-value">{{ currentAccountNominee?.relationship || '—' }}</span>
</div>
<div class="preview-field">
<span class="preview-label">Share %</span>
<span class="preview-value">{{ currentAccountNominee?.nomineeShare ?? '—' }}%</span>
</div>
@if (currentAccountNominee?.dateOfBirth) {
<div class="preview-field">
<span class="preview-label">Date of Birth</span>
<span class="preview-value">{{ currentAccountNominee?.dateOfBirth | dateFormat }}</span>
</div>
}
@if (currentAccountNominee?.nationality) {
<div class="preview-field">
<span class="preview-label">Nationality</span>
<span class="preview-value">{{ currentAccountNominee?.nationality }}</span>
</div>
}
@if (currentAccountNominee?.mobileNo) {
<div class="preview-field">
<span class="preview-label">Mobile No</span>
<span class="preview-value preview-mono">{{ currentAccountNominee?.mobileNo }}</span>
</div>
}
@if (currentAccountNominee?.aadhaarNumber) {
<div class="preview-field">
<span class="preview-label">Aadhaar No</span>
<span class="preview-value preview-mono">{{ currentAccountNominee?.aadhaarNumber }}</span>
</div>
}
@if (currentAccountNominee?.panNumber) {
<div class="preview-field">
<span class="preview-label">PAN No</span>
<span class="preview-value preview-mono">{{ currentAccountNominee?.panNumber }}</span>
</div>
}
@if (currentAccountNominee?.guardianName) {
<div class="preview-field">
<span class="preview-label">Guardian Name</span>
<span class="preview-value">{{ currentAccountNominee?.guardianName }}</span>
</div>
}
@if (currentAccountNominee?.guardianRelationship) {
<div class="preview-field">
<span class="preview-label">Guardian Relationship</span>
<span class="preview-value">{{ currentAccountNominee?.guardianRelationship }}</span>
</div>
}
@if (currentAccountNominee?.remarks) {
<div class="preview-field">
<span class="preview-label">Remarks</span>
<span class="preview-value">{{ currentAccountNominee?.remarks }}</span>
</div>
}
</div>
</div>
</div>
}
<!-- ══ 7. Non-Individual Details (if applicable) ════════════════ -->
@if (currentAccount?.partners?.length > 0) {
<div class="preview-section">
<div class="preview-section__header">
<i class="fas fa-building preview-section__icon"></i>
<span class="preview-section__title">Non-Individual Account Details</span>
</div>
<div class="preview-body">
<!-- Partners -->
<div class="preview-ni-label">Partners / Members</div>
<div class="preview-ni-table-wrap">
<table class="preview-ni-tbl">
<thead>
<tr>
<th>Partner Name</th>
<th>CIF No</th>
<th>Ownership %</th>
<th>Signing Authority</th>
</tr>
</thead>
<tbody>
@for (p of currentAccount.partners; track p.clientId) {
<tr>
<td>{{ p.clientName || '—' }}</td>
<td class="preview-mono">{{ p.clientCif || '—' }}</td>
<td>{{ p.sharePercentage }}%</td>
<td>
<span class="preview-badge" [class.preview-badge--yes]="p.isMandatory">
{{ p.isMandatory ? 'Mandatory' : 'Optional' }}
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Signatory Rights -->
@if (currentAccount?.signatoryRights) {
<div class="preview-ni-mode">
<i class="fas fa-how_to_vote"></i>
<span class="preview-ni-mode__label">Mode of Operation:</span>
<strong>{{ currentAccount.signatoryRights }}</strong>
</div>
}
</div>
</div>
}
<!-- ══ 8. Digital Facilities ════════════════════════════════════ -->
@if (hasDigitalSection) {
<div class="preview-section">
<div class="preview-section__header">
<i class="fas fa-mobile-alt preview-section__icon"></i>
<span class="preview-section__title">Digital Facilities</span>
<span class="preview-section__badge preview-section__badge--digital">User Configured</span>
</div>
<div class="preview-body">
<div class="preview-digital-grid">
@for (facility of digitalFacilityLabels; track facility.key) {
<div class="preview-digital-card" [class.preview-digital-card--on]="digitalFacilityConfig?.[facility.key]">
<span class="preview-digital-label">{{ facility.label }}</span>
<span
class="preview-digital-status"
[class.preview-digital-status--on]="digitalFacilityConfig?.[facility.key]"
>
{{ digitalFacilityConfig?.[facility.key] ? 'Enabled' : 'Disabled' }}
</span>
</div>
}
</div>
</div>
</div>
}
</div>
@@ -6,29 +6,456 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
table {
width: 100%;
.preview-wrapper {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0;
}
.mat-elevation-z1 {
margin: 1em 0 1.5em;
.preview-section {
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
background: #fff;
box-shadow: 0 1px 4px rgb(0 0 0 / 5%);
&__header {
display: flex;
align-items: center;
gap: 8px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
padding: 0.6rem 1rem;
}
&__icon {
font-size: 13px;
color: #0ea5e9;
width: 16px;
text-align: center;
}
&__title {
font-size: 0.82rem;
font-weight: 700;
color: #1e293b;
text-transform: uppercase;
letter-spacing: 0.06em;
flex: 1;
}
&__badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
background: #dbeafe;
color: #1d4ed8;
border: 1px solid #bfdbfe;
text-transform: uppercase;
&--digital {
background: #fef3c7;
color: #92400e;
border-color: #fde68a;
}
&--active {
background: #dcfce7 !important;
color: #15803d !important;
border-color: #bbf7d0 !important;
}
}
}
h2,
h3,
h4 {
margin: 0;
.preview-body {
padding: 1rem 1.25rem;
}
.preview-grid {
display: grid;
gap: 0.85rem 1.5rem;
&--three {
grid-template-columns: repeat(3, 1fr);
@media (width <= 768px) {
grid-template-columns: repeat(2, 1fr);
}
@media (width <= 480px) {
grid-template-columns: 1fr;
}
}
}
.preview-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.preview-label {
font-size: 0.68rem;
color: #94a3b8;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.preview-value {
font-size: 0.9rem;
color: #1e293b;
font-weight: 500;
}
span {
margin: 0.5em 0;
.preview-mono {
font-family: monospace;
font-size: 0.88rem;
color: #334155;
}
mat-divider {
margin: 0 0 0.5em;
.preview-badge {
display: inline-block;
padding: 0.15rem 0.6rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
background: #fef2f2;
color: #dc2626;
width: fit-content;
&--yes {
background: #f0fdf4;
color: #15803d;
}
&--warn {
background: #fef3c7;
color: #92400e;
}
}
// Digital facilities grid
.preview-digital-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
@media (width <= 900px) {
grid-template-columns: repeat(3, 1fr);
}
@media (width <= 600px) {
grid-template-columns: repeat(2, 1fr);
}
}
.preview-digital-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
&--on {
border-color: #86efac;
background: #f0fdf4;
}
}
.preview-digital-label {
font-size: 11px;
font-weight: 600;
color: #475569;
}
.preview-digital-status {
font-size: 11px;
font-weight: 700;
color: #dc2626;
&--on {
color: #15803d;
}
}
// ── Dormancy Monitoring ───────────────────────────────────────────
.preview-dormancy-timeline {
display: flex;
align-items: flex-start;
margin-bottom: 14px;
}
.preview-dorm-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
flex: 1;
&__dot {
width: 36px;
height: 36px;
border-radius: 50%;
border: 3px solid;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&--inactive {
border-color: #f59e0b;
background: #fffbeb;
&::after {
content: '';
width: 10px;
height: 10px;
border-radius: 50%;
background: #f59e0b;
}
}
&--dormant {
border-color: #ef4444;
background: #fef2f2;
&::after {
content: '';
width: 10px;
height: 10px;
border-radius: 50%;
background: #ef4444;
}
}
&--escheat {
border-color: #7c3aed;
background: #f5f3ff;
&::after {
content: '';
width: 10px;
height: 10px;
border-radius: 50%;
background: #7c3aed;
}
}
}
&__body {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 2px;
}
&__label {
font-size: 12px;
font-weight: 700;
color: #1e293b;
}
&__days {
font-size: 10.5px;
color: #64748b;
line-height: 1.3;
}
}
.preview-dorm-connector {
flex: 0 0 40px;
height: 3px;
background: linear-gradient(90deg, #e2e8f0, #cbd5e1);
margin-top: 16px;
border-radius: 2px;
}
.preview-dorm-flags {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.preview-dorm-flag {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 12px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 600;
background: #f1f5f9;
color: #94a3b8;
border: 1px solid #e2e8f0;
i {
font-size: 11px;
}
&--on {
background: #f0fdf4;
color: #15803d;
border-color: #bbf7d0;
}
}
// Non-Individual section
.preview-ni-label {
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.preview-ni-table-wrap {
overflow-x: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 12px;
}
.preview-ni-tbl {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
padding: 8px 12px;
font-size: 11px;
font-weight: 700;
color: #374151;
text-transform: uppercase;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
text-align: left;
}
td {
padding: 9px 12px;
color: #1e293b;
border-bottom: 1px solid #f3f4f6;
}
}
.preview-ni-mode {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #374151;
padding: 8px 12px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
&__label {
font-weight: 600;
}
}
.preview-table {
width: 100%;
margin-top: 1rem;
}
.margin-t {
margin-top: 1em;
}
// ── Status of Account ─────────────────────────────────────────────
.preview-soa-status-row {
display: flex;
align-items: center;
gap: 10px;
}
.preview-soa-tile {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
border-radius: 999px;
font-size: 14px;
font-weight: 700;
background: #eff6ff;
color: #1d4ed8;
border: 2px solid #bfdbfe;
i {
font-size: 16px;
}
&--entity {
background: #fef3c7;
color: #92400e;
border-color: #fde68a;
}
}
.preview-soa-table-wrap {
margin-top: 14px;
overflow-x: auto;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.preview-soa-tbl {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.preview-soa-th {
background: #f8fafc;
padding: 8px 12px;
text-align: left;
font-weight: 600;
color: #475569;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
.preview-soa-td {
padding: 10px 12px;
border-bottom: 1px solid #f1f5f9;
color: #0f172a;
vertical-align: middle;
}
.preview-soa-tr--primary {
background: #f0f9ff;
.preview-soa-td {
font-weight: 600;
}
}
.preview-soa-primary-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
background: #dbeafe;
color: #1d4ed8;
border: 1px solid #bfdbfe;
vertical-align: middle;
}
.preview-muted {
color: #94a3b8;
font-size: 13px;
font-style: italic;
margin: 0;
}
@@ -36,7 +36,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
* Current account preview step
*/
@Component({
selector: 'mifosx-current-account-account-preview-step',
selector: 'mifosx-current-account-preview-step',
templateUrl: './current-account-preview-step.component.html',
styleUrls: ['./current-account-preview-step.component.scss'],
imports: [
@@ -62,16 +62,36 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
]
})
export class CurrentAccountPreviewStepComponent implements OnChanges {
readonly digitalFacilityLabels = [
{ key: 'atmFacility', label: 'ATM Facility' },
{ key: 'atmWithdrawalFacility', label: 'ATM Withdrawal' },
{ key: 'upiFacility', label: 'UPI Facility' },
{ key: 'posTransactionsFacility', label: 'POS Transactions' },
{ key: 'chequeBook', label: 'Cheque Book' },
{ key: 'impsFacility', label: 'IMPS Facility' },
{ key: 'smsAlerts', label: 'SMS Alerts' },
{ key: 'bbpsFacility', label: 'BBPS Facility' },
{ key: 'insuranceCover', label: 'Insurance Cover' },
{ key: 'bepgFacility', label: 'BEPG Facility' },
{ key: 'neftFacility', label: 'NEFT Facility' },
{ key: 'rtgsFacility', label: 'RTGS Facility' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
];
private translateService = inject(TranslateService);
/** Current Account Product Template */
@Input() currentAccountProductTemplate: any;
/** Current Account Template */
@Input() currentAccountTemplate: any;
/** Current Account Terms Form */
@Input() currentAccountTermsForm: any;
/** Current Account */
@Input() currentAccountTermForm: any;
@Input() currentAccountSettingsForm: any;
@Input() currentAccount: any;
@Input() currentAccountDigital: any;
@Input() currentAccountNominee: any;
@Input() currentAccountCurrencyFlags: any;
@Input() activeClientMembers?: any;
/** Table Data Source */
@@ -104,4 +124,40 @@ export class CurrentAccountPreviewStepComponent implements OnChanges {
getCatalogTranslation(text: string): string {
return this.translateService.instant('labels.catalogs.' + text);
}
get termPreview(): any {
return this.currentAccountTermForm?.getRawValue?.() || this.currentAccount || {};
}
get settingsPreview(): any {
return this.currentAccountSettingsForm?.getRawValue?.() || this.currentAccount || {};
}
get digitalFacilityConfig(): any {
return (
this.currentAccountDigital?.accountFacilityConfig ??
this.currentAccountProductTemplate?.accountFacilityConfig ??
null
);
}
get hasDigitalSection(): boolean {
return !!this.digitalFacilityConfig;
}
get currencyName(): string {
return (
this.currentAccountProductTemplate?.currency?.name ||
this.currentAccountTermsForm?.get?.('currencyCode')?.value ||
'—'
);
}
get decimalPlaces(): string | number {
return this.currentAccountProductTemplate?.currency?.decimalPlaces ?? '—';
}
get inMultiplesOf(): string | number {
return this.currentAccountProductTemplate?.currency?.inMultiplesOf ?? '—';
}
}
@@ -0,0 +1,174 @@
<!--
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/.
-->
<form [formGroup]="currentAccountSettingsForm">
<!-- Section 1: Balance & Minimum Balance -->
<div class="sps-section">
<div class="sps-section__header">
<span class="sps-section__badge"><i class="fas fa-balance-scale"></i></span>
<span>Balance &amp; Minimum Balance</span>
</div>
<div class="sps-body">
<div class="sps-layout sps-layout--balance">
<div class="sps-row sps-row--three">
<mat-form-field appearance="outline" class="sps-field sps-field--compact">
<mat-label>{{ 'labels.inputs.Minimum Opening Balance' | translate }}</mat-label>
<input type="number" matInput formControlName="minRequiredOpeningBalance" min="0" step="0.01" />
</mat-form-field>
<div class="sps-toggle sps-toggle--inline">
<mat-checkbox labelPosition="before" formControlName="enforceMinRequiredBalance">
<span>Enforce Minimum Balance</span>
</mat-checkbox>
</div>
<mat-form-field appearance="outline" class="sps-field sps-field--compact">
<mat-label>Minimum Balance (&#8377;) *</mat-label>
<input type="number" matInput formControlName="minRequiredBalance" min="0" step="0.01" />
<mat-hint>PENAL CHARGE APPLIES ON BREACH</mat-hint>
</mat-form-field>
</div>
<div class="sps-row sps-row--two">
<mat-form-field appearance="outline" class="sps-field sps-field--compact">
<mat-label>Balance Frequency</mat-label>
<mat-select formControlName="balanceFrequency">
@for (freq of periodFrequencyData; track freq.value) {
<mat-option [value]="freq.value">{{ freq.value }}</mat-option>
}
</mat-select>
</mat-form-field>
<div class="sps-toggle sps-toggle--inline sps-toggle--right">
<mat-checkbox labelPosition="before" formControlName="withdrawalFeeForTransfers">
<span>{{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}</span>
</mat-checkbox>
</div>
</div>
</div>
</div>
</div>
<!-- Section 2: Lock-in Period -->
<div class="sps-section">
<div class="sps-section__header">
<span class="sps-section__badge"><i class="fas fa-lock"></i></span>
<span>Lock-in Period</span>
</div>
<div class="sps-body">
<div class="sps-lockin-row">
<span>Enable Lock-in Period</span>
<mat-checkbox formControlName="enableLockinPeriod"></mat-checkbox>
</div>
@if (currentAccountSettingsForm.value.enableLockinPeriod) {
<div class="sps-lockin-fields">
<mat-form-field appearance="outline" class="sps-field sps-field--compact">
<mat-label>{{ 'labels.inputs.Frequency' | translate }} *</mat-label>
<input type="number" matInput formControlName="lockinPeriodFrequency" min="1" step="1" />
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field sps-field--compact">
<mat-label>{{ 'labels.inputs.Type' | translate }} *</mat-label>
<mat-select formControlName="lockinPeriodFrequencyType">
@for (type of lockinPeriodFrequencyTypeData; track type) {
<mat-option [value]="type.id">{{ type.value | translateKey: 'catalogs' }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
</div>
</div>
<!-- Section 3: Additional Rules -->
<div class="sps-section">
<div class="sps-section__header">
<span class="sps-section__badge"><i class="fas fa-cog"></i></span>
<span>Additional Rules</span>
</div>
<div class="sps-body">
<div class="sps-toggle sps-toggle--inline">
<mat-checkbox formControlName="accountClosedZeroBalanceInDayClosing">
<span>Account Closed with Zero Balance in Day Closing</span>
</mat-checkbox>
</div>
</div>
</div>
<!-- Section 4: Overdraft (OD) Facility -->
<div class="sps-section">
<div class="sps-section__header">
<span class="sps-section__badge"><i class="fas fa-hand-holding-usd"></i></span>
<span>Overdraft (OD) Facility</span>
</div>
<div class="sps-body">
<div class="sps-od-layout">
<!-- Left: checkboxes -->
<div class="sps-od-checks">
<div class="sps-od-check-row">
<mat-checkbox formControlName="allowOverdraft">
<span>Is Overdraft Allowed</span>
</mat-checkbox>
</div>
@if (currentAccountSettingsForm.value.allowOverdraft) {
<div class="sps-od-check-row">
<mat-checkbox formControlName="odAccountClosedZeroBalanceInDayClosing">
<span>OD Account Closed with Zero Balance in Day Closing</span>
</mat-checkbox>
<p class="sps-od-check-hint">
Automatically closes OD accounts when balance reaches zero during day-end closing.
</p>
</div>
}
</div>
<!-- Right: OD details panel — only visible when OD is allowed -->
@if (currentAccountSettingsForm.value.allowOverdraft) {
<div class="sps-od-panel">
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">Average Balance Calculation in Period</span>
<mat-radio-group formControlName="avgBalanceCalcPeriod" class="sps-od-radio-choices">
<mat-radio-button value="6M">6M</mat-radio-button>
<mat-radio-button value="1Y">1Y</mat-radio-button>
<mat-radio-button value="3Y">3Y</mat-radio-button>
</mat-radio-group>
</div>
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">Valid for Period</span>
<mat-radio-group formControlName="validForPeriod" class="sps-od-radio-choices">
<mat-radio-button value="3M">3M</mat-radio-button>
<mat-radio-button value="1Y">1Y</mat-radio-button>
<mat-radio-button value="3Y">3Y</mat-radio-button>
</mat-radio-group>
</div>
<div class="sps-od-inputs">
<mat-form-field appearance="outline" class="sps-field">
<mat-label>OD Limit %</mat-label>
<input type="number" matInput formControlName="overdraftLimit" min="0" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>Interest on OD %</mat-label>
<input
type="number"
matInput
formControlName="nominalAnnualInterestRateOverdraft"
min="0"
step="0.01"
/>
</mat-form-field>
</div>
</div>
}
</div>
</div>
</div>
</form>
@@ -0,0 +1,255 @@
/**
* 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/.
*/
$accent: #0ea5e9;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-900: #202124;
.sps-section {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
background: #fff;
& + & {
margin-top: 16px;
}
&__header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
background: $gray-50;
border-bottom: 1px solid $gray-200;
i {
font-size: 14px;
color: $accent;
}
span {
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
}
}
.sps-section__badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: #eff6ff;
font-size: 14px;
}
.sps-body {
background: #fff;
padding: 18px 24px 24px;
}
.sps-field {
width: 100%;
&--full {
grid-column: 1 / -1;
}
&--compact {
width: 100%;
}
}
.sps-layout {
display: grid;
gap: 16px;
}
.sps-layout--balance {
gap: 22px;
}
.sps-layout--additional {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
@media (width <= 1100px) {
grid-template-columns: 1fr;
}
}
.sps-row {
display: grid;
gap: 18px;
align-items: start;
}
.sps-row--three {
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
.sps-field--compact:first-child {
min-width: 0;
}
.sps-toggle--inline {
align-self: center;
min-height: 56px;
}
}
.sps-row--two {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
.sps-field--compact {
min-width: 0;
}
}
.sps-toggle {
display: flex;
align-items: center;
min-height: 56px;
mat-checkbox {
font-size: 14px;
color: $gray-900;
}
&--full {
min-height: auto;
margin-bottom: 8px;
}
&--inline {
padding-top: 0;
}
&--right {
justify-content: flex-start;
}
}
.sps-lockin-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid $gray-200;
font-size: 14px;
color: $gray-900;
}
.sps-lockin-row mat-checkbox {
margin-right: 2px;
}
.sps-lockin-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
@media (width <= 640px) {
grid-template-columns: 1fr;
}
}
// ── Overdraft (OD) Facility ───────────────────────────────────────────────
.sps-od-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
align-items: start;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
.sps-od-checks {
display: flex;
flex-direction: column;
gap: 12px;
}
.sps-od-check-row {
padding: 14px 16px;
border: 1px solid $border;
border-radius: 8px;
background: $gray-50;
mat-checkbox {
font-size: 14px;
font-weight: 500;
color: $gray-900;
}
}
.sps-od-check-hint {
margin: 6px 0 0 28px;
font-size: 11.5px;
color: #64748b;
line-height: 1.5;
}
.sps-od-panel {
background: #f0f7ff;
border: 1px solid #bfdbfe;
border-radius: 10px;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.sps-od-radio-group {
display: flex;
flex-direction: column;
gap: 8px;
padding-bottom: 16px;
border-bottom: 1px solid #dbeafe;
&:last-of-type {
border-bottom: none;
padding-bottom: 0;
}
}
.sps-od-radio-label {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.07em;
color: #3b82f6;
text-transform: uppercase;
}
.sps-od-radio-choices {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.sps-od-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
@media (width <= 640px) {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,163 @@
/**
* 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, Input, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-current-account-settings-step',
templateUrl: './current-account-settings-step.component.html',
styleUrls: ['./current-account-settings-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatRadioGroup,
MatRadioButton,
FormatNumberPipe
]
})
export class CurrentAccountSettingsStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private _currentAccountProductTemplate: any;
@Input()
set currentAccountProductTemplate(value: any) {
this._currentAccountProductTemplate = value;
if (value && this.currentAccountSettingsForm) {
this.applyTemplate(value);
}
}
get currentAccountProductTemplate(): any {
return this._currentAccountProductTemplate;
}
currentAccountSettingsForm: UntypedFormGroup;
lockinPeriodFrequencyTypeData: any[] = [];
periodFrequencyData: any[] = [
{ value: 'Daily' },
{ value: 'Weekly' },
{ value: 'Monthly' },
{ value: 'Quarterly' },
{ value: 'Half Yearly' },
{ value: 'Yearly' }];
constructor() {
this.createForm();
this.buildDependencies();
}
createForm() {
this.currentAccountSettingsForm = this.formBuilder.group({
minRequiredOpeningBalance: [
'',
[Validators.min(0)]
],
withdrawalFeeForTransfers: [false],
enforceMinRequiredBalance: [false],
minRequiredBalance: [
'',
[Validators.min(0)]
],
balanceFrequency: ['Monthly'],
enableLockinPeriod: [false],
lockinPeriodFrequency: [''],
lockinPeriodFrequencyType: [''],
accountClosedZeroBalanceInDayClosing: [false],
allowOverdraft: [false],
odAccountClosedZeroBalanceInDayClosing: [false],
avgBalanceCalcPeriod: ['6M'],
validForPeriod: ['3M'],
overdraftLimit: [
'',
[Validators.min(0)]
],
nominalAnnualInterestRateOverdraft: [
'',
[Validators.min(0)]
]
});
}
ngOnInit() {
if (this._currentAccountProductTemplate) {
this.applyTemplate(this._currentAccountProductTemplate);
}
}
private applyTemplate(t: any) {
this.lockinPeriodFrequencyTypeData = t.lockinPeriodFrequencyTypeOptions || [];
const hasLockin = !!(t.lockinPeriodFrequency && t.lockinPeriodFrequency > 0);
this.currentAccountSettingsForm.patchValue({
minRequiredOpeningBalance: t.minRequiredOpeningBalance,
withdrawalFeeForTransfers: t.withdrawalFeeForTransfers || false,
enforceMinRequiredBalance: t.enforceMinRequiredBalance || false,
minRequiredBalance: t.minRequiredBalance,
balanceFrequency: t.balanceFrequency || 'Monthly',
enableLockinPeriod: hasLockin,
lockinPeriodFrequency: hasLockin ? t.lockinPeriodFrequency : '',
lockinPeriodFrequencyType: hasLockin ? t.lockinPeriodFrequencyType?.id || '' : '',
accountClosedZeroBalanceInDayClosing: t.accountClosedZeroBalanceInDayClosing || false,
allowOverdraft: t.allowOverdraft || false,
odAccountClosedZeroBalanceInDayClosing: t.odAccountClosedZeroBalanceInDayClosing || false,
avgBalanceCalcPeriod: t.avgBalanceCalcPeriod || '6M',
validForPeriod: t.validForPeriod || '3M',
overdraftLimit: t.overdraftLimit || '',
nominalAnnualInterestRateOverdraft: t.nominalAnnualInterestRateOverdraft || ''
});
}
buildDependencies() {
this.currentAccountSettingsForm.get('enableLockinPeriod').valueChanges.subscribe((enabled: boolean) => {
const freq = this.currentAccountSettingsForm.get('lockinPeriodFrequency');
const type = this.currentAccountSettingsForm.get('lockinPeriodFrequencyType');
if (enabled) {
freq.setValidators([
Validators.required,
Validators.min(1)
]);
type.setValidators([Validators.required]);
} else {
freq.setValue('');
type.setValue('');
freq.clearValidators();
type.clearValidators();
}
freq.updateValueAndValidity();
type.updateValueAndValidity();
});
}
get currentAccountSettings() {
const value = { ...this.currentAccountSettingsForm.getRawValue() };
if (!value.enableLockinPeriod) {
value.lockinPeriodFrequency = null;
value.lockinPeriodFrequencyType = null;
}
delete value.enableLockinPeriod;
delete value.balanceFrequency;
if (!value.allowOverdraft) {
delete value.overdraftLimit;
delete value.nominalAnnualInterestRateOverdraft;
delete value.avgBalanceCalcPeriod;
delete value.validForPeriod;
delete value.odAccountClosedZeroBalanceInDayClosing;
}
return value;
}
}

Some files were not shown because too many files have changed in this diff Show More