Client-stepper: pre-fill existing KYC/address proofs in edit, fix identifier saves
Edit-mode now shows the previously selected KYC document type + uploaded file thumbnail
(loads from /clients/{id}/identifiers + /client_identifiers/{id}/documents). Same for
address proof on the India address step. Closes the gap where editing a client showed
an empty file picker even when proof had been uploaded on create.
Identifier-save hardened across all three callsites (create-client, edit-client,
identities-tab.createIdentifierWithDocs) - the KYC chip picker proofId is a CODE
string ('AADHAAR'), but backend ClientIdentifierRequest.documentTypeId is Long, which
Spring rejects with HttpMessageNotReadableException -> 400. resolveDocumentTypeId
helper looks up the code against /kyc/proof-types?type=id and substitutes the numeric
CodeValue id before posting. Skips silently for constitution-specific non-individual
docs that have no CodeValue mapping.
KYC payload normalises panNumber / enterprisePan / gstinNumber / cinNumber via
toUpperCase + whitespace strip so user-typed lowercase or trailing spaces don't trip
the backend regex (validation.msg.CLIENTKYC.panNumber.does.not.match.regexp).
State name match in client-address save now normalises both sides (lowercase, collapse
underscores + spaces) so legacy MADHYA_PRADESH resolves to 'Madhya Pradesh' returned
by api.postalpincode.in. Backed by tenant migration 0297.
Identities-tab resolver flattened - was returning identifiers before the per-row
forkJoin(getClientIdentificationDocuments) completed, so the view rendered empty
documents arrays. Now uses switchMap + forkJoin so the resolver awaits completion.
Also bundles concurrent work-in-progress on letter-mgmt, organization, users,
bank-calendar, shell, nominee/notes/identifier dialogs, and theming.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
325228c514
commit
83ea57ccf0
@@ -0,0 +1 @@
|
||||
{"sessionId":"67d120b4-ff78-435d-be80-f79482a386b4","pid":19832,"acquiredAt":1778067581856}
|
||||
@@ -52,7 +52,21 @@
|
||||
"Bash(findstr /I /C:\"error\" /C:\"neft-rtgs\" /C:\"digital\")",
|
||||
"Bash(findstr /V \"warning\")",
|
||||
"Bash(dir \"C:\\\\Users\\\\adity\\\\Music\\\\CBS\\\\UI\\\\web-app\\\\src\\\\app\\\\settings\")",
|
||||
"Bash(findstr \"merge_code_update\")"
|
||||
"Bash(findstr \"merge_code_update\")",
|
||||
"Bash(node -e \"console.log\\(JSON.stringify\\(require\\('./package.json'\\).prettier || 'no config'\\)\\)\")",
|
||||
"Bash(node node_modules/prettier/bin/prettier.cjs --write --ignore-unknown \"src/**/*.{ts,html,scss,css}\")",
|
||||
"Bash(node node_modules/prettier/bin/prettier.cjs --check --ignore-unknown \"src/**/*.{ts,html,scss,css}\")",
|
||||
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\adity\\\\Music\\\\CBS\\\\UI\\\\web-app\\\\src\\\\app\\\\\" -Directory | Select-Object Name)",
|
||||
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\adity\\\\Music\\\\CBS\\\\UI\\\\web-app\\\\src\\\\app\" -Directory)",
|
||||
"Bash(Select-Object Name)",
|
||||
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\adity\\\\Music\\\\CBS\\\\UI\\\\web-app\\\\src\\\\app\\\\loans\" -Recurse -File)",
|
||||
"Bash(Select-Object FullName)",
|
||||
"Bash(ConvertTo-Csv -NoTypeInformation)",
|
||||
"Bash(Out-String)",
|
||||
"Bash(node node_modules/prettier/bin/prettier.cjs --write \"src/**/*.{ts,html,scss}\")",
|
||||
"Bash(node node_modules/stylelint/bin/stylelint.mjs --fix \"src/**/*.scss\")",
|
||||
"Bash(node node_modules/stylelint/bin/stylelint.mjs \"src/**/*.scss\")",
|
||||
"Bash(node node_modules/eslint/bin/eslint.js --cache \"src/app/users/users.component.ts\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="hol-header">
|
||||
<div class="hol-header__left">
|
||||
<div class="hol-header__icon"><fa-icon icon="calendar-alt"></fa-icon></div>
|
||||
<span class="hol-header__title">Add Holiday</span>
|
||||
<span class="hol-header__title">{{ isEditMode ? 'Edit Holiday' : 'Add Holiday' }}</span>
|
||||
</div>
|
||||
<button class="hol-header__close" (click)="dialogRef.close()">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
@@ -131,7 +131,7 @@
|
||||
(click)="submit()"
|
||||
>
|
||||
<fa-icon icon="check" class="hol-actions__icon"></fa-icon>
|
||||
{{ submitting ? 'Saving...' : 'Add Holiday' }}
|
||||
{{ submitting ? 'Saving...' : isEditMode ? 'Save Changes' : 'Add Holiday' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
|
||||
@@ -8,13 +8,25 @@
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators, UntypedFormControl } from '@angular/forms';
|
||||
import { MatDialogRef, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
|
||||
import { MatDialogRef, MatDialogContent, MatDialogActions, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { OrganizationService } from '../../organization/organization.service';
|
||||
import { SettingsService } from '../../settings/settings.service';
|
||||
import { Dates } from '../../core/utils/dates';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
export interface AddHolidayDialogData {
|
||||
mode: 'edit';
|
||||
holidayId: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
fromDate: Date;
|
||||
toDate: Date;
|
||||
reschedulingType: number;
|
||||
repaymentsRescheduledTo?: Date;
|
||||
officeId: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-add-holiday-dialog',
|
||||
templateUrl: './add-holiday-dialog.component.html',
|
||||
@@ -32,6 +44,9 @@ export class AddHolidayDialogComponent implements OnInit {
|
||||
private settings = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
public dialogRef = inject(MatDialogRef<AddHolidayDialogComponent>);
|
||||
public readonly editData: AddHolidayDialogData | null = inject<AddHolidayDialogData | null>(MAT_DIALOG_DATA, {
|
||||
optional: true
|
||||
});
|
||||
|
||||
holidayForm: UntypedFormGroup;
|
||||
offices: any[] = [];
|
||||
@@ -40,6 +55,10 @@ export class AddHolidayDialogComponent implements OnInit {
|
||||
minDate = new Date(2000, 0, 1);
|
||||
maxDate = new Date(2100, 0, 1);
|
||||
|
||||
get isEditMode(): boolean {
|
||||
return this.editData?.mode === 'edit';
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.holidayForm = this.fb.group({
|
||||
name: [
|
||||
@@ -67,12 +86,32 @@ export class AddHolidayDialogComponent implements OnInit {
|
||||
|
||||
this.loadData();
|
||||
this.buildDependencies();
|
||||
|
||||
if (this.isEditMode && this.editData) {
|
||||
const ed = this.editData;
|
||||
// Patch known fields synchronously; office list arrives async but the
|
||||
// form's mat-select binds by id so the value still resolves once options render.
|
||||
this.holidayForm.patchValue({
|
||||
name: ed.name,
|
||||
description: ed.description || '',
|
||||
fromDate: ed.fromDate,
|
||||
toDate: ed.toDate,
|
||||
reschedulingType: ed.reschedulingType,
|
||||
officeId: ed.officeId
|
||||
});
|
||||
if (ed.reschedulingType === 2 && ed.repaymentsRescheduledTo) {
|
||||
this.holidayForm.addControl(
|
||||
'repaymentsRescheduledTo',
|
||||
new UntypedFormControl(ed.repaymentsRescheduledTo, Validators.required)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private loadData() {
|
||||
this.organizationService.getOffices().subscribe((data: any[]) => {
|
||||
this.offices = data || [];
|
||||
if (this.offices.length > 0) {
|
||||
if (!this.isEditMode && this.offices.length > 0) {
|
||||
this.holidayForm.patchValue({ officeId: this.offices[0].id });
|
||||
}
|
||||
});
|
||||
@@ -137,7 +176,12 @@ export class AddHolidayDialogComponent implements OnInit {
|
||||
offices: [{ officeId }]
|
||||
};
|
||||
|
||||
this.organizationService.createHoliday(payload).subscribe({
|
||||
const request$ =
|
||||
this.isEditMode && this.editData
|
||||
? this.organizationService.updateHoliday(this.editData.holidayId, payload)
|
||||
: this.organizationService.createHoliday(payload);
|
||||
|
||||
request$.subscribe({
|
||||
next: () => {
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
|
||||
@@ -74,6 +74,23 @@
|
||||
<div class="cal-legend__item"><span class="cal-legend__dot today"></span> Today</div>
|
||||
</div>
|
||||
<div class="cal-legend__actions">
|
||||
<button
|
||||
class="cal-legend__tmpl-btn"
|
||||
(click)="downloadImportTemplate()"
|
||||
matTooltip="Download a pre-formatted .xlsx with sample rows + instructions"
|
||||
>
|
||||
<fa-icon icon="file-excel"></fa-icon> Download Template
|
||||
</button>
|
||||
<button
|
||||
class="cal-legend__import-btn"
|
||||
(click)="triggerImport()"
|
||||
[disabled]="importing"
|
||||
matTooltip="Bulk-create holidays from a filled-in template"
|
||||
>
|
||||
<fa-icon [icon]="importing ? 'spinner' : 'upload'" [class.fa-spin]="importing"></fa-icon>
|
||||
{{ importing ? 'Importing…' : 'Import Holidays' }}
|
||||
</button>
|
||||
<input #importInput type="file" accept=".xlsx" (change)="onImportFileChosen($event)" hidden />
|
||||
<button class="cal-legend__print-btn" (click)="printHolidayList()">
|
||||
<fa-icon icon="print"></fa-icon> Print Holiday List
|
||||
</button>
|
||||
@@ -83,6 +100,200 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import result banner -->
|
||||
@if (importResult) {
|
||||
<div
|
||||
class="cal-import-result"
|
||||
[class.cal-import-result--ok]="importResult.imported > 0 && importResult.skipped.length === 0"
|
||||
[class.cal-import-result--mixed]="importResult.imported > 0 && importResult.skipped.length > 0"
|
||||
[class.cal-import-result--fail]="importResult.imported === 0"
|
||||
>
|
||||
<div class="cal-import-result__head">
|
||||
<fa-icon
|
||||
[icon]="
|
||||
importResult.imported > 0 && importResult.skipped.length === 0
|
||||
? 'check-circle'
|
||||
: importResult.imported === 0
|
||||
? 'times-circle'
|
||||
: 'exclamation-triangle'
|
||||
"
|
||||
class="cal-import-result__ic"
|
||||
></fa-icon>
|
||||
<div>
|
||||
<div class="cal-import-result__title">
|
||||
Import — {{ importResult.imported }} of {{ importResult.total }} added
|
||||
</div>
|
||||
<div class="cal-import-result__sub">
|
||||
@if (importResult.skipped.length === 0) {
|
||||
All rows were imported successfully.
|
||||
} @else {
|
||||
{{ importResult.skipped.length }} row{{ importResult.skipped.length === 1 ? '' : 's' }} skipped — see
|
||||
below.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<button class="cal-import-result__close" (click)="dismissImportResult()" matTooltip="Dismiss">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
@if (importResult.skipped.length > 0) {
|
||||
<ul class="cal-import-result__list">
|
||||
@for (s of importResult.skipped; track s.row) {
|
||||
<li>
|
||||
<strong>Row {{ s.row }}:</strong> {{ s.reason }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Holiday List Panel (office-wise) -->
|
||||
<div class="cal-list">
|
||||
<div class="cal-list__header">
|
||||
<div class="cal-list__title">
|
||||
<fa-icon icon="list-alt" class="cal-list__title-ic"></fa-icon>
|
||||
<span>Holiday List {{ currentYear }}</span>
|
||||
<span class="cal-list__count">{{ filteredHolidayRows.length }} of {{ holidayRows.length }}</span>
|
||||
</div>
|
||||
<div class="cal-list__filters">
|
||||
<div class="cal-list__search">
|
||||
<fa-icon icon="search" class="cal-list__search-ic"></fa-icon>
|
||||
<input type="text" placeholder="Search by name, office, description…" [(ngModel)]="filterSearch" />
|
||||
</div>
|
||||
<select [(ngModel)]="filterOfficeId" class="cal-list__select">
|
||||
<option [ngValue]="''">All Offices</option>
|
||||
@for (o of offices; track o.id) {
|
||||
<option [ngValue]="o.id">{{ o.name }}</option>
|
||||
}
|
||||
</select>
|
||||
<select [(ngModel)]="filterType" class="cal-list__select">
|
||||
<option value="">All Types</option>
|
||||
<option value="GAZETTED">Gazetted (RBI)</option>
|
||||
<option value="CUSTOM">Custom</option>
|
||||
</select>
|
||||
@if (filterSearch || filterOfficeId !== '' || filterType) {
|
||||
<button class="cal-list__clear" (click)="clearListFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cal-list__table-wrap">
|
||||
<table class="cal-list__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="num">#</th>
|
||||
<th>Date</th>
|
||||
<th>Day</th>
|
||||
<th>Holiday / Occasion</th>
|
||||
<th>Office</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th class="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredHolidayRows; track trackHolidayRow($index, row); let i = $index) {
|
||||
<tr>
|
||||
<td class="num">{{ i + 1 }}</td>
|
||||
<td class="mono">{{ formatRowDate(row.fromDate) }}</td>
|
||||
<td>{{ rowDayName(row.fromDate) }}</td>
|
||||
<td>
|
||||
<div class="cal-list__name">{{ row.name }}</div>
|
||||
@if (row.description && row.description !== row.name) {
|
||||
<div class="cal-list__desc">{{ row.description }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@for (n of row.officeNames; track n) {
|
||||
<span class="cal-list__office-chip">{{ n }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (row.isGazetted) {
|
||||
<span class="cal-list__pill gazetted">Gazetted</span>
|
||||
} @else {
|
||||
<span class="cal-list__pill custom">Custom</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="cal-list__pill status"
|
||||
[class.active]="row.status === 'Active' || row.status === 'holidayStatusType.active'"
|
||||
>
|
||||
{{ row.status === 'holidayStatusType.active' ? 'Active' : row.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="cal-list__act edit"
|
||||
(click)="editHoliday(row)"
|
||||
[disabled]="row.isGazetted"
|
||||
[matTooltip]="row.isGazetted ? 'Gazetted holiday — read-only' : 'Edit holiday'"
|
||||
>
|
||||
<fa-icon icon="pencil-alt"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
class="cal-list__act remove"
|
||||
(click)="removeHoliday(row)"
|
||||
[disabled]="row.isGazetted"
|
||||
[matTooltip]="row.isGazetted ? 'Gazetted holiday — cannot remove' : 'Remove holiday'"
|
||||
>
|
||||
<fa-icon icon="trash"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
class="cal-list__act more"
|
||||
[matMenuTriggerFor]="rowMenu"
|
||||
matTooltip="More actions"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<fa-icon icon="ellipsis-v"></fa-icon>
|
||||
</button>
|
||||
<mat-menu #rowMenu="matMenu" xPosition="before" class="cal-list__menu">
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="activateHoliday(row)"
|
||||
[disabled]="row.isGazetted || isHolidayActive(row)"
|
||||
>
|
||||
<fa-icon icon="check-circle" class="cal-list__menu-ic ok"></fa-icon>
|
||||
@if (isHolidayActive(row)) {
|
||||
Already Active
|
||||
} @else {
|
||||
Activate
|
||||
}
|
||||
</button>
|
||||
<button mat-menu-item (click)="editHoliday(row)" [disabled]="row.isGazetted">
|
||||
<fa-icon icon="pencil-alt" class="cal-list__menu-ic"></fa-icon>
|
||||
Edit Holiday
|
||||
</button>
|
||||
<button mat-menu-item (click)="removeHoliday(row)" [disabled]="row.isGazetted">
|
||||
<fa-icon icon="trash" class="cal-list__menu-ic warn"></fa-icon>
|
||||
Remove Holiday
|
||||
</button>
|
||||
<button mat-menu-item (click)="copyHolidayId(row)" [disabled]="row.id == null">
|
||||
<fa-icon icon="copy" class="cal-list__menu-ic"></fa-icon>
|
||||
Copy Holiday ID
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (filteredHolidayRows.length === 0) {
|
||||
<tr>
|
||||
<td colspan="8" class="cal-list__empty">
|
||||
@if (holidayListLoading) {
|
||||
<fa-icon icon="spinner" class="fa-spin"></fa-icon> Loading holidays…
|
||||
} @else {
|
||||
No holidays match the current filters.
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Grids -->
|
||||
<div class="cal-months">
|
||||
@for (month of months; track month.month) {
|
||||
|
||||
@@ -246,6 +246,53 @@ $purple: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
&__tmpl-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid #bbf7d0;
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: #d1fae5;
|
||||
border-color: $green;
|
||||
}
|
||||
}
|
||||
|
||||
&__import-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid #c4b5fd;
|
||||
background: #f5f3ff;
|
||||
color: $purple;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #ede9fe;
|
||||
border-color: $purple;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
@@ -466,3 +513,427 @@ $purple: #7c3aed;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Holiday List (office-wise) ───
|
||||
.cal-list {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 6%);
|
||||
border: 1px solid #e2e8f0;
|
||||
margin-bottom: 18px;
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $navy;
|
||||
|
||||
&-ic {
|
||||
color: $blue;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
&__count {
|
||||
margin-left: 6px;
|
||||
padding: 2px 10px;
|
||||
background: #eff6ff;
|
||||
color: $blue;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
&__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__search {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
input {
|
||||
width: 240px;
|
||||
padding: 7px 10px 7px 32px;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
background: #fff;
|
||||
color: $navy;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $blue;
|
||||
box-shadow: 0 0 0 3px rgb(30 136 229 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
&-ic {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__select {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: $navy;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $blue;
|
||||
box-shadow: 0 0 0 3px rgb(30 136 229 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
&__clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #fecaca;
|
||||
color: $red;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
border-color: #f87171;
|
||||
}
|
||||
}
|
||||
|
||||
&__table-wrap {
|
||||
overflow: auto;
|
||||
max-height: 460px;
|
||||
}
|
||||
|
||||
&__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
&.num {
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.actions {
|
||||
width: 140px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
color: $navy;
|
||||
|
||||
&.num {
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&.actions {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
transition: background 0.12s;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__office-chip {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
margin: 1px 4px 1px 0;
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__pill {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.gazetted {
|
||||
background: #f5f3ff;
|
||||
color: $purple;
|
||||
}
|
||||
|
||||
&.custom {
|
||||
background: #ecfdf5;
|
||||
color: $green;
|
||||
}
|
||||
|
||||
&.status {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
|
||||
&.active {
|
||||
background: #ecfdf5;
|
||||
color: $green;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__act {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 0 2px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
transition: all 0.12s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.edit:hover:not(:disabled) {
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
&.remove:hover:not(:disabled) {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: $red;
|
||||
}
|
||||
|
||||
&.more:hover:not(:disabled) {
|
||||
background: #f5f3ff;
|
||||
border-color: #ddd6fe;
|
||||
color: $purple;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__menu-ic {
|
||||
width: 18px;
|
||||
margin-right: 10px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
|
||||
&.ok {
|
||||
color: $green;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
color: $red;
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
text-align: center;
|
||||
padding: 30px 16px;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.cal-list {
|
||||
&__header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
&__filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
&__search input,
|
||||
&__select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Import result banner ───
|
||||
.cal-import-result {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 6%);
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
&__ic {
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: $navy;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__close {
|
||||
margin-left: auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
color: $navy;
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
margin: 0;
|
||||
padding: 8px 18px 14px 36px;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
|
||||
li {
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: $navy;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&--ok {
|
||||
border-color: #bbf7d0;
|
||||
background: linear-gradient(180deg, #ecfdf5 0%, #fff 100%);
|
||||
|
||||
.cal-import-result__ic {
|
||||
color: $green;
|
||||
}
|
||||
}
|
||||
|
||||
&--mixed {
|
||||
border-color: #fde68a;
|
||||
background: linear-gradient(180deg, #fefce8 0%, #fff 100%);
|
||||
|
||||
.cal-import-result__ic {
|
||||
color: $orange;
|
||||
}
|
||||
}
|
||||
|
||||
&--fail {
|
||||
border-color: #fecaca;
|
||||
background: linear-gradient(180deg, #fef2f2 0%, #fff 100%);
|
||||
|
||||
.cal-import-result__ic {
|
||||
color: $red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,51 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { Component, OnInit, ViewChild, ElementRef, inject } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { OrganizationService } from '../organization/organization.service';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { Dates } from '../core/utils/dates';
|
||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
|
||||
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
|
||||
import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component';
|
||||
|
||||
interface ImportResult {
|
||||
total: number;
|
||||
imported: number;
|
||||
skipped: { row: number; reason: string }[];
|
||||
}
|
||||
|
||||
interface OfficeLite {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface HolidayRow {
|
||||
id: number | null;
|
||||
name: string;
|
||||
fromDate: number[]; // [yyyy, mm, dd]
|
||||
toDate: number[];
|
||||
description?: string;
|
||||
officeIds: number[];
|
||||
officeNames: string[];
|
||||
status: string;
|
||||
reschedulingType?: number;
|
||||
repaymentsRescheduledTo?: number[];
|
||||
isGazetted: boolean; // true = local seed (no API id), false = backend record
|
||||
raw?: any;
|
||||
}
|
||||
|
||||
interface CalendarDay {
|
||||
date: number;
|
||||
@@ -46,13 +85,25 @@ interface MonthData {
|
||||
styleUrls: ['./bank-calendar.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
MatTooltip,
|
||||
MatMenuModule
|
||||
]
|
||||
})
|
||||
export class BankCalendarComponent implements OnInit {
|
||||
private organizationService = inject(OrganizationService);
|
||||
private dialog = inject(MatDialog);
|
||||
private snack = inject(MatSnackBar);
|
||||
private settings = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
private translate = inject(TranslateService);
|
||||
|
||||
@ViewChild('importInput') importInput!: ElementRef<HTMLInputElement>;
|
||||
|
||||
importing = false;
|
||||
/** Last import outcome — kept on screen until user dismisses or imports again. */
|
||||
importResult: ImportResult | null = null;
|
||||
|
||||
currentYear: number;
|
||||
months: MonthData[] = [];
|
||||
@@ -69,6 +120,15 @@ export class BankCalendarComponent implements OnInit {
|
||||
holidays: any[] = [];
|
||||
overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
|
||||
|
||||
// ───── Holiday list (office-wise) ─────
|
||||
offices: OfficeLite[] = [];
|
||||
holidayRows: HolidayRow[] = [];
|
||||
holidayListLoading = false;
|
||||
/** Filter values for the list panel. */
|
||||
filterOfficeId: number | '' = '';
|
||||
filterSearch = '';
|
||||
filterType: '' | 'GAZETTED' | 'CUSTOM' = '';
|
||||
|
||||
/** Indian bank gazetted holidays by year */
|
||||
private readonly gazettedHolidays: Record<number, { month: number; day: number; name: string }[]> = {
|
||||
2025: [
|
||||
@@ -153,19 +213,267 @@ export class BankCalendarComponent implements OnInit {
|
||||
];
|
||||
this.mergeGazettedHolidays();
|
||||
this.buildCalendar();
|
||||
this.rebuildHolidayRows();
|
||||
|
||||
// Try loading from API
|
||||
this.organizationService.getOffices().subscribe((offices: any[]) => {
|
||||
if (offices?.length > 0) {
|
||||
this.organizationService.getHolidays(offices[0].id.toString()).subscribe((holidayData: any[]) => {
|
||||
this.holidays = holidayData || [];
|
||||
this.mergeGazettedHolidays();
|
||||
this.buildCalendar();
|
||||
// Pull offices then fan-out a per-office holiday fetch so each office's
|
||||
// calendar entries are surfaced in the list panel (calendar grid still
|
||||
// unions everything for the year-at-a-glance view).
|
||||
this.holidayListLoading = true;
|
||||
this.organizationService.getOffices().subscribe({
|
||||
next: (offices: any[]) => {
|
||||
this.offices = (offices || []).map((o: any) => ({ id: o.id, name: o.name }));
|
||||
if (this.offices.length === 0) {
|
||||
this.holidayListLoading = false;
|
||||
return;
|
||||
}
|
||||
const requests = this.offices.map((o) =>
|
||||
this.organizationService.getHolidays(o.id.toString()).pipe(catchError(() => of([])))
|
||||
);
|
||||
forkJoin(requests).subscribe({
|
||||
next: (perOffice: any[]) => {
|
||||
// Tag every holiday with its source office and merge duplicates by id.
|
||||
const byId = new Map<number, any>();
|
||||
const unkeyed: any[] = [];
|
||||
perOffice.forEach((rows: any[], idx: number) => {
|
||||
const office = this.offices[idx];
|
||||
(rows || []).forEach((h: any) => {
|
||||
if (h.id != null) {
|
||||
if (!byId.has(h.id)) {
|
||||
byId.set(h.id, { ...h, __officeIds: new Set<number>(), __officeNames: new Set<string>() });
|
||||
}
|
||||
const merged = byId.get(h.id);
|
||||
merged.__officeIds.add(office.id);
|
||||
merged.__officeNames.add(office.name);
|
||||
} else {
|
||||
unkeyed.push({
|
||||
...h,
|
||||
__officeIds: new Set<number>([office.id]),
|
||||
__officeNames: new Set<string>([office.name])
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
this.holidays = [
|
||||
...Array.from(byId.values()),
|
||||
...unkeyed
|
||||
];
|
||||
this.mergeGazettedHolidays();
|
||||
this.buildCalendar();
|
||||
this.rebuildHolidayRows();
|
||||
this.holidayListLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.holidayListLoading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.holidayListLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Flatten the holidays array into a presentable list with office labels. */
|
||||
private rebuildHolidayRows(): void {
|
||||
this.holidayRows = this.holidays.map((h: any) => {
|
||||
const officeIds: number[] = Array.from((h.__officeIds as Set<number>) ?? new Set());
|
||||
const officeNames: string[] = Array.from((h.__officeNames as Set<string>) ?? new Set());
|
||||
const isGazetted = h.id == null;
|
||||
return {
|
||||
id: h.id ?? null,
|
||||
name: h.name || 'Holiday',
|
||||
fromDate: h.fromDate,
|
||||
toDate: h.toDate || h.fromDate,
|
||||
description: h.description,
|
||||
officeIds,
|
||||
officeNames: officeNames.length ? officeNames : isGazetted ? ['All Offices (Gazetted)'] : ['—'],
|
||||
status: h?.status?.value || h?.status?.code || 'Active',
|
||||
reschedulingType: h?.reschedulingType?.id || h?.reschedulingType,
|
||||
repaymentsRescheduledTo: h?.repaymentsRescheduledTo,
|
||||
isGazetted,
|
||||
raw: h
|
||||
};
|
||||
});
|
||||
// Sort by date asc
|
||||
this.holidayRows.sort((a, b) => {
|
||||
const da = (a.fromDate?.[0] ?? 0) * 10000 + (a.fromDate?.[1] ?? 0) * 100 + (a.fromDate?.[2] ?? 0);
|
||||
const db = (b.fromDate?.[0] ?? 0) * 10000 + (b.fromDate?.[1] ?? 0) * 100 + (b.fromDate?.[2] ?? 0);
|
||||
return da - db;
|
||||
});
|
||||
}
|
||||
|
||||
// ───── Filters / actions on the holiday list ─────
|
||||
get filteredHolidayRows(): HolidayRow[] {
|
||||
const q = this.filterSearch.trim().toLowerCase();
|
||||
return this.holidayRows.filter((r) => {
|
||||
if (this.filterOfficeId !== '' && !r.officeIds.includes(this.filterOfficeId as number)) {
|
||||
// Gazetted entries have no officeIds — only hide when a specific office is selected.
|
||||
if (!r.isGazetted) return false;
|
||||
}
|
||||
if (this.filterType === 'GAZETTED' && !r.isGazetted) return false;
|
||||
if (this.filterType === 'CUSTOM' && r.isGazetted) return false;
|
||||
if (q) {
|
||||
const haystack = `${r.name} ${r.description ?? ''} ${r.officeNames.join(' ')}`.toLowerCase();
|
||||
if (!haystack.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
formatRowDate(d?: number[]): string {
|
||||
if (!d || d.length < 3) return '—';
|
||||
const date = new Date(d[0], d[1] - 1, d[2]);
|
||||
return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
rowDayName(d?: number[]): string {
|
||||
if (!d || d.length < 3) return '';
|
||||
const date = new Date(d[0], d[1] - 1, d[2]);
|
||||
return date.toLocaleDateString('en-IN', { weekday: 'short' });
|
||||
}
|
||||
|
||||
clearListFilters(): void {
|
||||
this.filterOfficeId = '';
|
||||
this.filterSearch = '';
|
||||
this.filterType = '';
|
||||
}
|
||||
|
||||
trackHolidayRow(_idx: number, row: HolidayRow): string {
|
||||
return row.id != null ? `id-${row.id}` : `g-${row.fromDate?.join('-')}-${row.name}`;
|
||||
}
|
||||
|
||||
/** Open the dialog pre-populated for the given holiday. */
|
||||
async editHoliday(row: HolidayRow): Promise<void> {
|
||||
if (row.isGazetted || row.id == null) {
|
||||
this.snack.open('Gazetted RBI holidays are read-only. Add a custom holiday to override.', 'OK', {
|
||||
duration: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { AddHolidayDialogComponent } = await import('./add-holiday-dialog/add-holiday-dialog.component');
|
||||
const ref = this.dialog.open(AddHolidayDialogComponent, {
|
||||
width: '600px',
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '90vh',
|
||||
autoFocus: false,
|
||||
panelClass: 'add-holiday-dialog-panel',
|
||||
data: {
|
||||
mode: 'edit',
|
||||
holidayId: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
fromDate: this.toJsDate(row.fromDate),
|
||||
toDate: this.toJsDate(row.toDate),
|
||||
reschedulingType: row.reschedulingType,
|
||||
repaymentsRescheduledTo: this.toJsDate(row.repaymentsRescheduledTo),
|
||||
officeId: row.officeIds[0]
|
||||
}
|
||||
});
|
||||
ref.afterClosed().subscribe((updated: boolean) => {
|
||||
if (updated) {
|
||||
this.showSuccess('Holiday Updated', `Changes to "${row.name}" have been saved.`);
|
||||
this.loadWorkingDays();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Polished success modal (matches the rest of Nexora — no more bare snackbars). */
|
||||
private showSuccess(title: string, message: string): void {
|
||||
this.dialog.open(SuccessDialogComponent, {
|
||||
data: { title, message, buttonLabel: 'OK' },
|
||||
panelClass: 'sd-panel'
|
||||
});
|
||||
}
|
||||
|
||||
/** Polished error modal — body string is rendered as the only field. */
|
||||
private showError(message: string): void {
|
||||
this.dialog.open(ErrorDialogComponent, { data: message });
|
||||
}
|
||||
|
||||
/** True when the holiday is already active and can't be re-activated. */
|
||||
isHolidayActive(row: HolidayRow): boolean {
|
||||
const s = (row.status || '').toLowerCase();
|
||||
return s === 'active' || s === 'holidaystatustype.active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the holiday — mirrors the legacy view-holidays "Activate"
|
||||
* action. Confirmed via the shared ConfirmationDialog and POSTed with
|
||||
* `command=activate`.
|
||||
*/
|
||||
activateHoliday(row: HolidayRow): void {
|
||||
if (row.isGazetted || row.id == null) {
|
||||
this.snack.open('Gazetted RBI holidays are already in effect — no activation needed.', 'OK', { duration: 3500 });
|
||||
return;
|
||||
}
|
||||
if (this.isHolidayActive(row)) {
|
||||
this.snack.open(`"${row.name}" is already active.`, 'OK', { duration: 2500 });
|
||||
return;
|
||||
}
|
||||
const ref = this.dialog.open(ConfirmationDialogComponent, {
|
||||
data: {
|
||||
heading: this.translate.instant('labels.heading.Holiday'),
|
||||
dialogContext:
|
||||
this.translate.instant('labels.dialogContext.Are you sure you want to activate') +
|
||||
` ${row.name} ` +
|
||||
this.translate.instant('labels.dialogContext.holiday')
|
||||
}
|
||||
});
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (!response?.confirm) return;
|
||||
this.organizationService.activateHoliday(row.id!.toString()).subscribe({
|
||||
next: () => {
|
||||
this.showSuccess('Holiday Activated', `"${row.name}" is now active.`);
|
||||
this.loadWorkingDays();
|
||||
},
|
||||
error: () => {
|
||||
this.showError(`Failed to activate "${row.name}". Please try again.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Copy the holiday's numeric ID to the clipboard (handy for support tickets). */
|
||||
copyHolidayId(row: HolidayRow): void {
|
||||
if (row.id == null) {
|
||||
this.snack.open('Gazetted holidays have no server ID to copy.', 'OK', { duration: 2500 });
|
||||
return;
|
||||
}
|
||||
const text = String(row.id);
|
||||
navigator.clipboard
|
||||
?.writeText(text)
|
||||
.then(() => this.snack.open(`Copied holiday ID ${text} to clipboard.`, 'OK', { duration: 2000 }))
|
||||
.catch(() => this.snack.open('Could not access clipboard.', 'OK', { duration: 2500 }));
|
||||
}
|
||||
|
||||
/** Confirm + delete via shared DeleteDialog. */
|
||||
removeHoliday(row: HolidayRow): void {
|
||||
if (row.isGazetted || row.id == null) {
|
||||
this.snack.open('Gazetted RBI holidays cannot be removed — they are seeded locally.', 'OK', { duration: 3500 });
|
||||
return;
|
||||
}
|
||||
const ref = this.dialog.open(DeleteDialogComponent, {
|
||||
data: { deleteContext: `holiday "${row.name}" (${this.formatRowDate(row.fromDate)})` }
|
||||
});
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (!response?.delete) return;
|
||||
this.organizationService.deleteHoliday(row.id!.toString()).subscribe({
|
||||
next: () => {
|
||||
this.showSuccess('Holiday Removed', `"${row.name}" has been deleted.`);
|
||||
this.loadWorkingDays();
|
||||
},
|
||||
error: () => {
|
||||
this.showError(`Failed to remove "${row.name}". Please try again.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private toJsDate(d?: number[]): Date | undefined {
|
||||
if (!d || d.length < 3) return undefined;
|
||||
return new Date(d[0], d[1] - 1, d[2]);
|
||||
}
|
||||
|
||||
/** Merge static gazetted holidays into the holidays array (API format) */
|
||||
private mergeGazettedHolidays() {
|
||||
const gazetted = this.gazettedHolidays[this.currentYear] || [];
|
||||
@@ -358,22 +666,19 @@ export class BankCalendarComponent implements OnInit {
|
||||
previousYear() {
|
||||
this.currentYear--;
|
||||
this.holidays = [];
|
||||
this.mergeGazettedHolidays();
|
||||
this.buildCalendar();
|
||||
this.loadWorkingDays();
|
||||
}
|
||||
|
||||
nextYear() {
|
||||
this.currentYear++;
|
||||
this.holidays = [];
|
||||
this.mergeGazettedHolidays();
|
||||
this.buildCalendar();
|
||||
this.loadWorkingDays();
|
||||
}
|
||||
|
||||
goToCurrentYear() {
|
||||
this.currentYear = new Date().getFullYear();
|
||||
this.holidays = [];
|
||||
this.mergeGazettedHolidays();
|
||||
this.buildCalendar();
|
||||
this.loadWorkingDays();
|
||||
}
|
||||
|
||||
printHolidayList() {
|
||||
@@ -563,9 +868,334 @@ export class BankCalendarComponent implements OnInit {
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: boolean) => {
|
||||
if (result) {
|
||||
// Reload holidays after successful creation
|
||||
this.showSuccess('Holiday Added', 'The holiday has been saved and applied to the calendar.');
|
||||
this.loadWorkingDays();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ───── Excel import ─────
|
||||
|
||||
/**
|
||||
* Generate and download the holiday-import template — one header row, two
|
||||
* sample rows, and a notes block describing each column. Office names must
|
||||
* match the bank's office list verbatim; reschedulingType is 1 (no repayment
|
||||
* rescheduling) or 2 (move repayments to a specific date).
|
||||
*/
|
||||
async downloadImportTemplate(): Promise<void> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'CBS Nexora';
|
||||
workbook.created = new Date();
|
||||
|
||||
const sheet = workbook.addWorksheet('Holidays');
|
||||
|
||||
const headers = [
|
||||
'Name *',
|
||||
'From Date * (YYYY-MM-DD)',
|
||||
'To Date * (YYYY-MM-DD)',
|
||||
'Office Name *',
|
||||
'Description',
|
||||
'Rescheduling Type * (1=No Reschedule, 2=Move To Date)',
|
||||
'Repayments Rescheduled To (YYYY-MM-DD, only if type=2)'
|
||||
];
|
||||
|
||||
sheet.columns = [
|
||||
{ width: 28 },
|
||||
{ width: 24 },
|
||||
{ width: 24 },
|
||||
{ width: 24 },
|
||||
{ width: 30 },
|
||||
{ width: 36 },
|
||||
{ width: 32 }];
|
||||
|
||||
const headerRow = sheet.addRow(headers);
|
||||
headerRow.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 11 };
|
||||
headerRow.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||
headerRow.eachCell((cell) => {
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0D47A1' } };
|
||||
cell.border = { bottom: { style: 'medium', color: { argb: 'FF0D47A1' } } };
|
||||
});
|
||||
headerRow.height = 28;
|
||||
|
||||
// Sample rows (a single-day holiday + a multi-day one with rescheduling).
|
||||
sheet.addRow([
|
||||
'Founder’s Day',
|
||||
'2026-09-15',
|
||||
'2026-09-15',
|
||||
'Head Office',
|
||||
'Annual founder anniversary holiday',
|
||||
1,
|
||||
''
|
||||
]);
|
||||
sheet.addRow([
|
||||
'Branch Inauguration Week',
|
||||
'2026-11-02',
|
||||
'2026-11-04',
|
||||
'Mumbai — Andheri (E)',
|
||||
'New branch opening',
|
||||
2,
|
||||
'2026-11-05'
|
||||
]);
|
||||
|
||||
sheet.getRow(2).getCell(7).note = 'Leave blank when Rescheduling Type = 1';
|
||||
|
||||
// Notes block
|
||||
sheet.addRow([]);
|
||||
const noteHeader = sheet.addRow([
|
||||
'INSTRUCTIONS — read before importing'
|
||||
]);
|
||||
sheet.mergeCells(`A${noteHeader.number}:G${noteHeader.number}`);
|
||||
noteHeader.font = { bold: true, color: { argb: 'FFB45309' }, size: 11 };
|
||||
noteHeader.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFEF3C7' } };
|
||||
noteHeader.height = 22;
|
||||
|
||||
const notes = [
|
||||
'1. Fields marked * are required. Rows missing required fields will be skipped.',
|
||||
'2. Office Name must match the bank office name EXACTLY (case-insensitive) — see the office list on the calendar page.',
|
||||
'3. Dates must be in YYYY-MM-DD format. To Date >= From Date.',
|
||||
'4. Rescheduling Type: 1 = no repayment reschedule, 2 = move repayments to a target date (column 7 required).',
|
||||
'5. Do not change the header row. Rows below the header are read top-to-bottom; empty rows end the import.',
|
||||
'6. Duplicates against existing holidays will be REJECTED by the server — no data is overwritten.'
|
||||
];
|
||||
notes.forEach((n) => {
|
||||
const r = sheet.addRow([n]);
|
||||
sheet.mergeCells(`A${r.number}:G${r.number}`);
|
||||
r.font = { color: { argb: 'FF334155' }, size: 10.5 };
|
||||
});
|
||||
|
||||
// Freeze the header row so users keep their bearings while scrolling.
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1 }];
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'holiday-import-template.xlsx';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 0);
|
||||
|
||||
this.snack.open('Template downloaded — fill it in and click "Import Holidays".', 'OK', { duration: 3000 });
|
||||
}
|
||||
|
||||
/** Trigger the hidden file input when "Import Holidays" is clicked. */
|
||||
triggerImport(): void {
|
||||
this.importInput?.nativeElement?.click();
|
||||
}
|
||||
|
||||
async onImportFileChosen(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
// Always reset so re-selecting the same file fires the change event again.
|
||||
if (input) input.value = '';
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith('.xlsx')) {
|
||||
this.snack.open('Please select an .xlsx file exported from the holiday template.', 'OK', { duration: 3500 });
|
||||
return;
|
||||
}
|
||||
|
||||
this.importing = true;
|
||||
this.importResult = null;
|
||||
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer);
|
||||
const sheet = workbook.worksheets[0];
|
||||
if (!sheet) {
|
||||
this.snack.open('The workbook has no worksheets.', 'OK', { duration: 3500 });
|
||||
this.importing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows: { rowNum: number; values: any[] }[] = [];
|
||||
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
||||
if (rowNumber === 1) return; // header
|
||||
// Treat the "INSTRUCTIONS" notes block as a terminator — first cell
|
||||
// starts with 'INSTRUCTIONS' or row has only 1 non-empty cell after a blank.
|
||||
const first = (row.getCell(1).value ?? '').toString();
|
||||
if (first.toUpperCase().startsWith('INSTRUCTIONS') || first.match(/^\d+\.\s/)) return;
|
||||
const values = [
|
||||
row.getCell(1).value,
|
||||
row.getCell(2).value,
|
||||
row.getCell(3).value,
|
||||
row.getCell(4).value,
|
||||
row.getCell(5).value,
|
||||
row.getCell(6).value,
|
||||
row.getCell(7).value
|
||||
];
|
||||
// Skip fully empty rows
|
||||
if (values.every((v) => v == null || v === '')) return;
|
||||
rows.push({ rowNum: rowNumber, values });
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
this.snack.open('No data rows found in the workbook.', 'OK', { duration: 3500 });
|
||||
this.importing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.processImportRows(rows);
|
||||
} catch {
|
||||
this.snack.open('Failed to read the Excel file. Make sure it is a valid .xlsx.', 'OK', { duration: 4000 });
|
||||
} finally {
|
||||
this.importing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Process parsed rows sequentially, collecting per-row outcomes. */
|
||||
private async processImportRows(rows: { rowNum: number; values: any[] }[]): Promise<void> {
|
||||
// Use the EXACT same formatting path as the working AddHolidayDialog —
|
||||
// dateFormat sent in the payload is the Angular-style pattern (e.g.
|
||||
// 'dd MMMM yyyy') and the formatted value goes through Dates utility
|
||||
// which converts it to moment tokens and respects the user's locale.
|
||||
const dateFormat = this.settings.dateFormat;
|
||||
const locale = this.settings.language.code;
|
||||
|
||||
const officeByName = new Map<string, OfficeLite>();
|
||||
this.offices.forEach((o) => officeByName.set(o.name.trim().toLowerCase(), o));
|
||||
|
||||
const result: ImportResult = { total: rows.length, imported: 0, skipped: [] };
|
||||
|
||||
for (const { rowNum, values } of rows) {
|
||||
const [
|
||||
nameRaw,
|
||||
fromRaw,
|
||||
toRaw,
|
||||
officeRaw,
|
||||
descRaw,
|
||||
typeRaw,
|
||||
reschRaw
|
||||
] = values;
|
||||
const name = (nameRaw ?? '').toString().trim();
|
||||
const officeName = (officeRaw ?? '').toString().trim();
|
||||
const description = (descRaw ?? '').toString().trim();
|
||||
const typeNum = Number(typeRaw);
|
||||
const fromDate = this.parseImportDate(fromRaw);
|
||||
const toDate = this.parseImportDate(toRaw);
|
||||
|
||||
if (!name) {
|
||||
result.skipped.push({ row: rowNum, reason: 'missing Name' });
|
||||
continue;
|
||||
}
|
||||
if (!fromDate || !toDate) {
|
||||
result.skipped.push({ row: rowNum, reason: 'invalid From/To Date' });
|
||||
continue;
|
||||
}
|
||||
if (toDate.getTime() < fromDate.getTime()) {
|
||||
result.skipped.push({ row: rowNum, reason: 'To Date is before From Date' });
|
||||
continue;
|
||||
}
|
||||
const office = officeByName.get(officeName.toLowerCase());
|
||||
if (!office) {
|
||||
result.skipped.push({ row: rowNum, reason: `unknown office "${officeName}"` });
|
||||
continue;
|
||||
}
|
||||
if (typeNum !== 1 && typeNum !== 2) {
|
||||
result.skipped.push({ row: rowNum, reason: 'Rescheduling Type must be 1 or 2' });
|
||||
continue;
|
||||
}
|
||||
let reschDate: Date | null = null;
|
||||
if (typeNum === 2) {
|
||||
reschDate = this.parseImportDate(reschRaw);
|
||||
if (!reschDate) {
|
||||
result.skipped.push({ row: rowNum, reason: 'type=2 requires Repayments Rescheduled To date' });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
name,
|
||||
description,
|
||||
fromDate: this.dateUtils.formatDateAsString(fromDate, dateFormat),
|
||||
toDate: this.dateUtils.formatDateAsString(toDate, dateFormat),
|
||||
reschedulingType: typeNum,
|
||||
dateFormat,
|
||||
locale,
|
||||
offices: [{ officeId: office.id }]
|
||||
};
|
||||
if (reschDate) {
|
||||
payload.repaymentsRescheduledTo = this.dateUtils.formatDateAsString(reschDate, dateFormat);
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.organizationService.createHoliday(payload).subscribe({
|
||||
next: () => resolve(),
|
||||
error: (err) => reject(err)
|
||||
});
|
||||
});
|
||||
result.imported++;
|
||||
} catch (err: any) {
|
||||
const apiMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.developerMessage || err?.message || 'API error';
|
||||
result.skipped.push({ row: rowNum, reason: apiMsg });
|
||||
}
|
||||
}
|
||||
|
||||
this.importResult = result;
|
||||
if (result.imported > 0 && result.skipped.length === 0) {
|
||||
this.showSuccess(
|
||||
'Holidays Imported',
|
||||
`All ${result.imported} row${result.imported === 1 ? '' : 's'} imported successfully.`
|
||||
);
|
||||
this.loadWorkingDays();
|
||||
} else if (result.imported > 0) {
|
||||
this.showSuccess(
|
||||
'Import Partially Complete',
|
||||
`${result.imported} of ${result.total} holidays imported. ${result.skipped.length} row${result.skipped.length === 1 ? ' was' : 's were'} skipped — see the highlighted panel for details.`
|
||||
);
|
||||
this.loadWorkingDays();
|
||||
} else {
|
||||
this.showError(
|
||||
`Import finished — 0 of ${result.total} rows could be added. See the highlighted panel for details.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dismissImportResult(): void {
|
||||
this.importResult = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a cell value into a Date.
|
||||
*
|
||||
* ExcelJS returns date-typed cells as JS `Date` objects pinned to UTC midnight
|
||||
* — e.g. "2026-09-15" comes back as `2026-09-15T00:00:00.000Z`. If we read
|
||||
* `.getDate()` / `.getMonth()` in a timezone west of UTC, we'd land on the
|
||||
* previous day. So when the value comes in as a Date, we extract the UTC
|
||||
* components and rebuild a local-noon Date so downstream formatting (moment)
|
||||
* always sees the day the user typed.
|
||||
*/
|
||||
private parseImportDate(v: any): Date | null {
|
||||
if (v == null || v === '') return null;
|
||||
if (v instanceof Date) {
|
||||
if (Number.isNaN(v.getTime())) return null;
|
||||
return new Date(v.getUTCFullYear(), v.getUTCMonth(), v.getUTCDate(), 12, 0, 0);
|
||||
}
|
||||
// ExcelJS may also surface a date-formatted cell as a number (serial date)
|
||||
// when the cell type isn't decisively a date — handle that defensively.
|
||||
if (typeof v === 'number') {
|
||||
// Excel epoch: 1899-12-30 (accounting for the 1900 leap-year bug).
|
||||
const ms = Math.round((v - 25569) * 86400 * 1000);
|
||||
const d = new Date(ms);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 12, 0, 0);
|
||||
}
|
||||
const s = v.toString().trim();
|
||||
// Accept YYYY-MM-DD primarily; fall back to Date.parse for resilience.
|
||||
const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
||||
if (iso) {
|
||||
const d = new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]), 12, 0, 0);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
const parsed = new Date(s);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shared mixin for the client stepper steps.
|
||||
*
|
||||
* When the per-step `readOnly` flag is on, the host calls `form.disable()` which
|
||||
* cascades Material's default `~0.38 opacity` onto every outlined input,
|
||||
* select, checkbox and label. Visually it reads as "blurry". This mixin keeps
|
||||
* the disabled affordances (no caret, no border highlight on focus, no
|
||||
* pointer cursor on selects) but restores crisp colour + weight so the saved
|
||||
* values stay legible.
|
||||
*
|
||||
* Usage in a step component's .scss:
|
||||
*
|
||||
* @use '../stepper-readonly-overrides' as readonly;
|
||||
*
|
||||
* :host {
|
||||
* @include readonly.readonly-form-overrides;
|
||||
* }
|
||||
*/
|
||||
@mixin readonly-form-overrides {
|
||||
/* ─── MDC design-token overrides ───
|
||||
These CSS custom properties feed Material's disabled-state colors at the
|
||||
token level — higher in the cascade than any rule selector — so they win
|
||||
even against Material's own !important declarations baked into MDC styles. */
|
||||
--mdc-outlined-text-field-disabled-input-text-color: #0f172a;
|
||||
--mdc-outlined-text-field-disabled-label-text-color: #64748b;
|
||||
--mdc-outlined-text-field-disabled-outline-color: #cbd5e1;
|
||||
--mdc-outlined-text-field-disabled-supporting-text-color: #64748b;
|
||||
--mdc-outlined-text-field-disabled-leading-icon-color: #475569;
|
||||
--mdc-outlined-text-field-disabled-trailing-icon-color: #475569;
|
||||
--mdc-filled-text-field-disabled-input-text-color: #0f172a;
|
||||
--mdc-filled-text-field-disabled-label-text-color: #64748b;
|
||||
--mat-select-disabled-trigger-text-color: #0f172a;
|
||||
--mat-form-field-disabled-input-text-color: #0f172a;
|
||||
--mat-form-field-disabled-input-text-placeholder-color: #64748b;
|
||||
--mdc-checkbox-disabled-selected-icon-color: #059669;
|
||||
--mdc-checkbox-disabled-unselected-icon-color: #94a3b8;
|
||||
|
||||
::ng-deep {
|
||||
/* Outlined form fields in disabled state */
|
||||
.mat-mdc-form-field.mat-form-field-appearance-outline.mat-form-field-disabled {
|
||||
opacity: 1;
|
||||
|
||||
/* Input value */
|
||||
.mat-mdc-input-element,
|
||||
input.mat-mdc-input-element,
|
||||
textarea.mat-mdc-input-element {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important;
|
||||
font-weight: 600;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
/* Floating label */
|
||||
.mat-mdc-floating-label,
|
||||
.mdc-floating-label {
|
||||
color: #64748b !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Outline border */
|
||||
.mdc-notched-outline__leading,
|
||||
.mdc-notched-outline__notch,
|
||||
.mdc-notched-outline__trailing {
|
||||
border-color: #cbd5e1 !important;
|
||||
border-width: 1px !important;
|
||||
}
|
||||
|
||||
/* matPrefix / matSuffix icons */
|
||||
.field-prefix-icon mat-icon,
|
||||
.mat-mdc-form-field-icon-prefix mat-icon,
|
||||
.mat-mdc-form-field-icon-suffix mat-icon {
|
||||
color: #475569 !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Hint text */
|
||||
.mat-mdc-form-field-hint {
|
||||
color: #64748b !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* mat-select value + arrow.
|
||||
MDC nests the visible text deeper than `.mat-mdc-select-value` — the
|
||||
actual `<span>` that renders the chosen option is `.mat-mdc-select-min-line`.
|
||||
Without explicitly forcing it, MDC's disabled-state CSS wins and the
|
||||
text reads as washed-out grey. */
|
||||
.mat-mdc-select-value-text,
|
||||
.mat-mdc-select-value,
|
||||
.mat-mdc-select-min-line,
|
||||
.mat-mdc-select-placeholder {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important;
|
||||
font-weight: 600;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.mat-mdc-select-arrow {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
/* The disabled select wrapper itself drops opacity in MDC — undo that. */
|
||||
.mat-mdc-select-disabled,
|
||||
.mat-mdc-select-disabled .mat-mdc-select-trigger {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* The MDC text-field root (parent of input + outline) also gets a
|
||||
disabled modifier. Strip any opacity it applies. */
|
||||
.mdc-text-field--disabled {
|
||||
opacity: 1 !important;
|
||||
|
||||
.mdc-text-field__input,
|
||||
input,
|
||||
textarea {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Native suffix icon buttons (e.g. eye-toggle) */
|
||||
.toggle-vis-btn,
|
||||
.mat-mdc-icon-button {
|
||||
color: #64748b !important;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Disabled checkboxes — keep the check + label crisp */
|
||||
.mat-mdc-checkbox-disabled {
|
||||
opacity: 1 !important;
|
||||
|
||||
.mdc-form-field > label,
|
||||
.mat-mdc-checkbox-label,
|
||||
.mdc-label {
|
||||
color: #0f172a !important;
|
||||
opacity: 1 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mdc-checkbox__background {
|
||||
background-color: #059669 !important;
|
||||
border-color: #059669 !important;
|
||||
}
|
||||
|
||||
.mdc-checkbox__checkmark {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Disabled mat-radio */
|
||||
.mat-mdc-radio-button.mat-mdc-radio-disabled {
|
||||
opacity: 1 !important;
|
||||
|
||||
.mdc-form-field > label,
|
||||
.mdc-label {
|
||||
color: #0f172a !important;
|
||||
opacity: 1 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
/* Disabled mat-slide-toggle */
|
||||
.mat-mdc-slide-toggle.mat-mdc-slide-toggle-disabled {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Native UA override — Chrome/Safari force their own grey on disabled
|
||||
form controls via `-webkit-text-fill-color`, which beats `color`. */
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,3 +105,59 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Read-only / disabled form-field overrides
|
||||
─── Mirrors the India-address step so view-mode addresses stay
|
||||
legible instead of looking ghosted at 0.38 opacity.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
:host ::ng-deep {
|
||||
.mat-mdc-form-field.mat-form-field-appearance-outline.mat-form-field-disabled {
|
||||
opacity: 1;
|
||||
|
||||
.mat-mdc-input-element,
|
||||
input.mat-mdc-input-element,
|
||||
textarea.mat-mdc-input-element {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important;
|
||||
font-weight: 600;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.mat-mdc-floating-label,
|
||||
.mdc-floating-label {
|
||||
color: #64748b !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.mdc-notched-outline__leading,
|
||||
.mdc-notched-outline__notch,
|
||||
.mdc-notched-outline__trailing {
|
||||
border-color: #cbd5e1 !important;
|
||||
border-width: 1px !important;
|
||||
}
|
||||
|
||||
.field-prefix-icon mat-icon,
|
||||
.mat-mdc-form-field-icon-prefix mat-icon,
|
||||
.mat-mdc-form-field-icon-suffix mat-icon {
|
||||
color: #475569 !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-hint {
|
||||
color: #64748b !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mat-mdc-select-value-text,
|
||||
.mat-mdc-select-value {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mat-mdc-select-arrow {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -6,6 +6,12 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@use '../stepper-readonly-overrides' as readonly;
|
||||
|
||||
:host {
|
||||
@include readonly.readonly-form-overrides;
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
width: 92%;
|
||||
}
|
||||
|
||||
+6
@@ -6,6 +6,12 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@use '../stepper-readonly-overrides' as readonly;
|
||||
|
||||
:host {
|
||||
@include readonly.readonly-form-overrides;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity Classification — Scheme & Sector Classification
|
||||
*/
|
||||
|
||||
+19
-1
@@ -259,7 +259,10 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
// non-individuals (companies / trusts) have no person-to-person relationship to model here.
|
||||
this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
|
||||
next: (data: any) => {
|
||||
this.searchResults = data?.content || [];
|
||||
// Filter the host client out of results so they can't even appear as
|
||||
// a candidate. The dialog also blocks selection defensively in case
|
||||
// the host slips through (e.g. via a paged search).
|
||||
this.searchResults = (data?.content || []).filter((c: any) => !this.isSelfClient(c?.id));
|
||||
this.searching = false;
|
||||
this.clientsService.enrichSearchResults(this.searchResults);
|
||||
},
|
||||
@@ -278,6 +281,17 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
*/
|
||||
linkedSelectError = '';
|
||||
|
||||
/**
|
||||
* Self-check: prevent a client from being added as their own family member.
|
||||
* The host client's id is passed in via dialog data — comparing as strings
|
||||
* because the search API uses numbers but route params are strings.
|
||||
*/
|
||||
isSelfClient(clientId: number | string): boolean {
|
||||
const host = this.data?.hostClientId;
|
||||
if (host == null || clientId == null) return false;
|
||||
return String(host) === String(clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the given client id is already linked as a family member of
|
||||
* this client. The member currently being edited is excluded so its own
|
||||
@@ -298,6 +312,10 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
|
||||
/** Select a client from search results and fetch full details */
|
||||
selectClient(client: any): void {
|
||||
if (this.isSelfClient(client?.id)) {
|
||||
this.linkedSelectError = `You can't add this client to their own family. Please pick a different person.`;
|
||||
return;
|
||||
}
|
||||
if (this.isClientAlreadyLinked(client?.id)) {
|
||||
this.linkedSelectError = `${client?.displayName || 'This client'} is already linked as a family member.`;
|
||||
return;
|
||||
|
||||
+6
@@ -6,6 +6,12 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@use '../stepper-readonly-overrides' as readonly;
|
||||
|
||||
:host {
|
||||
@include readonly.readonly-form-overrides;
|
||||
}
|
||||
|
||||
.zoomable {
|
||||
cursor: zoom-in;
|
||||
transition:
|
||||
|
||||
+6
-3
@@ -135,7 +135,8 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
||||
data: {
|
||||
context: 'Add',
|
||||
options: this.clientTemplate.familyMemberOptions,
|
||||
existingMembers: this.clientFamilyMembers
|
||||
existingMembers: this.clientFamilyMembers,
|
||||
hostClientId: this.clientId
|
||||
},
|
||||
width: '640px',
|
||||
maxWidth: '95vw',
|
||||
@@ -154,7 +155,8 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
||||
context: 'View',
|
||||
member,
|
||||
options: this.clientTemplate.familyMemberOptions,
|
||||
existingMembers: this.clientFamilyMembers
|
||||
existingMembers: this.clientFamilyMembers,
|
||||
hostClientId: this.clientId
|
||||
},
|
||||
width: '640px',
|
||||
maxWidth: '95vw',
|
||||
@@ -168,7 +170,8 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
||||
context: 'Edit',
|
||||
member,
|
||||
options: this.clientTemplate.familyMemberOptions,
|
||||
existingMembers: this.clientFamilyMembers
|
||||
existingMembers: this.clientFamilyMembers,
|
||||
hostClientId: this.clientId
|
||||
},
|
||||
width: '640px',
|
||||
maxWidth: '95vw',
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@use '../stepper-readonly-overrides' as readonly;
|
||||
|
||||
:host {
|
||||
@include readonly.readonly-form-overrides;
|
||||
}
|
||||
|
||||
form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
+157
-43
@@ -123,7 +123,7 @@
|
||||
|
||||
<div class="addr-proof-field">
|
||||
<label class="addr-proof-label"><mat-icon>description</mat-icon> Document type</label>
|
||||
<div class="addr-chip-wrap">
|
||||
<div class="addr-chip-wrap" [class.addr-chip-wrap--locked]="readOnly">
|
||||
@for (group of addressGroups; track group.label; let last = $last) {
|
||||
<div class="addr-chip-group-label"><mat-icon>folder_open</mat-icon> {{ group.label }}</div>
|
||||
<div class="addr-chip-grid">
|
||||
@@ -131,9 +131,11 @@
|
||||
<span
|
||||
class="addr-chip"
|
||||
[class.addr-chip--sel]="isAddrProofSelected(p)"
|
||||
(click)="selectAddrProof(p)"
|
||||
(keydown.enter)="selectAddrProof(p)"
|
||||
tabindex="0"
|
||||
[class.addr-chip--locked]="readOnly && !isAddrProofSelected(p)"
|
||||
[attr.aria-disabled]="readOnly ? 'true' : null"
|
||||
(click)="readOnly ? null : selectAddrProof(p)"
|
||||
(keydown.enter)="readOnly ? null : selectAddrProof(p)"
|
||||
[attr.tabindex]="readOnly ? -1 : 0"
|
||||
role="button"
|
||||
>
|
||||
<mat-icon class="addr-chip__icon">
|
||||
@@ -186,59 +188,78 @@
|
||||
</span>
|
||||
<span class="addr-upload-req">Required</span>
|
||||
</div>
|
||||
<label
|
||||
class="addr-dropzone"
|
||||
[class.addr-dropzone--has]="addrFiles.front"
|
||||
[class.addr-dropzone--req]="!addrFiles.front"
|
||||
>
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onAddrFile($event, 'front')" />
|
||||
@if (addrFiles.front) {
|
||||
<div class="addr-dropzone__file">
|
||||
<mat-icon>insert_drive_file</mat-icon>
|
||||
<span class="addr-dropzone__name">{{ addrFiles.front.name }}</span>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="addr-dropzone__remove"
|
||||
(click)="$event.preventDefault(); removeAddrFile('front')"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
<!-- Existing saved document — locked by default. Replace flow
|
||||
unlocks automatically when the section's top-level Edit
|
||||
button toggles `readOnly` off. -->
|
||||
@if (existingAddressProofDocuments.length > 0 && !addrFiles.front && readOnly) {
|
||||
<div class="addr-dropzone addr-dropzone--has addr-dropzone--locked">
|
||||
<div class="addr-dropzone__existing">
|
||||
@if (addrMergedPreview) {
|
||||
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
|
||||
} @else {
|
||||
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="addr-dropzone__existing-meta">
|
||||
<span class="addr-dropzone__existing-name">
|
||||
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
|
||||
</span>
|
||||
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
Click here to view
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="addr-dropzone__hint">
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
|
||||
</div>
|
||||
}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (selectedAddrProof.sides === 2) {
|
||||
<div class="addr-upload-slot">
|
||||
<div class="addr-upload-head">
|
||||
<span class="addr-upload-title"> <mat-icon>image</mat-icon> Back side </span>
|
||||
<span class="addr-upload-req">Required</span>
|
||||
</div>
|
||||
} @else {
|
||||
<label
|
||||
class="addr-dropzone"
|
||||
[class.addr-dropzone--has]="addrFiles.back"
|
||||
[class.addr-dropzone--req]="!addrFiles.back"
|
||||
[class.addr-dropzone--has]="addrFiles.front || existingAddressProofDocuments.length > 0"
|
||||
[class.addr-dropzone--req]="!addrFiles.front && existingAddressProofDocuments.length === 0"
|
||||
>
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onAddrFile($event, 'back')" />
|
||||
@if (addrFiles.back) {
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onAddrFile($event, 'front')" />
|
||||
@if (addrFiles.front) {
|
||||
<div class="addr-dropzone__file">
|
||||
<mat-icon>insert_drive_file</mat-icon>
|
||||
<span class="addr-dropzone__name">{{ addrFiles.back.name }}</span>
|
||||
<span class="addr-dropzone__name">{{ addrFiles.front.name }}</span>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="addr-dropzone__remove"
|
||||
(click)="$event.preventDefault(); removeAddrFile('back')"
|
||||
(click)="$event.preventDefault(); removeAddrFile('front')"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
} @else if (existingAddressProofDocuments.length > 0) {
|
||||
<!-- Edit mode WITH existing doc — let the user view it OR
|
||||
click anywhere to replace. -->
|
||||
<div class="addr-dropzone__existing">
|
||||
@if (addrMergedPreview) {
|
||||
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
|
||||
} @else {
|
||||
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="addr-dropzone__existing-meta">
|
||||
<span class="addr-dropzone__existing-name">
|
||||
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
|
||||
</span>
|
||||
<div class="addr-dropzone__existing-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="addr-dropzone__view"
|
||||
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingAddressProof()"
|
||||
>
|
||||
<mat-icon>visibility</mat-icon>
|
||||
View
|
||||
</button>
|
||||
<span class="addr-dropzone__sep">·</span>
|
||||
<span class="addr-dropzone__existing-hint">
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
Click here to replace
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="addr-dropzone__hint">
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
@@ -246,11 +267,104 @@
|
||||
</div>
|
||||
}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedAddrProof.sides === 2) {
|
||||
<div class="addr-upload-slot">
|
||||
<div class="addr-upload-head">
|
||||
<span class="addr-upload-title"> <mat-icon>image</mat-icon> Back side </span>
|
||||
<!-- "Required" pill is hidden when a saved combined doc covers both sides -->
|
||||
@if (!(existingAddressProofDocuments.length > 0 && !addrFiles.back)) {
|
||||
<span class="addr-upload-req">Required</span>
|
||||
}
|
||||
</div>
|
||||
<!-- Locked view: saved combined doc covers the back side too. -->
|
||||
@if (existingAddressProofDocuments.length > 0 && !addrFiles.back && readOnly) {
|
||||
<div class="addr-dropzone addr-dropzone--has addr-dropzone--locked">
|
||||
<div class="addr-dropzone__existing">
|
||||
@if (addrMergedPreview) {
|
||||
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
|
||||
} @else {
|
||||
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="addr-dropzone__existing-meta">
|
||||
<span class="addr-dropzone__existing-name">
|
||||
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
|
||||
</span>
|
||||
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
Click here to view
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<label
|
||||
class="addr-dropzone"
|
||||
[class.addr-dropzone--has]="addrFiles.back || existingAddressProofDocuments.length > 0"
|
||||
[class.addr-dropzone--req]="!addrFiles.back && existingAddressProofDocuments.length === 0"
|
||||
>
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onAddrFile($event, 'back')" />
|
||||
@if (addrFiles.back) {
|
||||
<div class="addr-dropzone__file">
|
||||
<mat-icon>insert_drive_file</mat-icon>
|
||||
<span class="addr-dropzone__name">{{ addrFiles.back.name }}</span>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="addr-dropzone__remove"
|
||||
(click)="$event.preventDefault(); removeAddrFile('back')"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
} @else if (existingAddressProofDocuments.length > 0) {
|
||||
<!-- Edit mode WITH existing combined doc — same view + replace
|
||||
pattern as the front slot, anchored to the saved file. -->
|
||||
<div class="addr-dropzone__existing">
|
||||
@if (addrMergedPreview) {
|
||||
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
|
||||
} @else {
|
||||
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="addr-dropzone__existing-meta">
|
||||
<span class="addr-dropzone__existing-name">
|
||||
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
|
||||
</span>
|
||||
<div class="addr-dropzone__existing-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="addr-dropzone__view"
|
||||
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingAddressProof()"
|
||||
>
|
||||
<mat-icon>visibility</mat-icon>
|
||||
View
|
||||
</button>
|
||||
<span class="addr-dropzone__sep">·</span>
|
||||
<span class="addr-dropzone__existing-hint">
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
Click here to replace
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="addr-dropzone__hint">
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
|
||||
</div>
|
||||
}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedAddrProof.sides === 2 && addrMergedPreview) {
|
||||
<!-- Combined preview + re-crop buttons are an edit-mode tool; hide in read-only.
|
||||
Also hide when the saved combined PNG is already visible as the thumbnail
|
||||
inside both slot tiles (no value in repeating the same image below). -->
|
||||
@if (selectedAddrProof.sides === 2 && addrMergedPreview && !readOnly && (addrFiles.front || addrFiles.back)) {
|
||||
<div class="addr-merged">
|
||||
<div class="addr-merged__head">
|
||||
<span class="addr-merged__title">
|
||||
|
||||
+220
@@ -333,12 +333,35 @@ $muted: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
/* Non-selected chip in read-only mode — visible but visibly unclickable.
|
||||
The selected chip keeps its full highlight (no `--locked` class). */
|
||||
&--locked {
|
||||
cursor: default;
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
|
||||
.addr-chip__icon {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #1976d2;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* When the wrap is locked, the selected chip shouldn't show a pointer cursor either. */
|
||||
.addr-chip-wrap--locked .addr-chip--sel {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.addr-proof-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -559,4 +582,201 @@ $muted: #64748b;
|
||||
&__remove {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Locked variant — used when an existing saved doc is shown but the user
|
||||
hasn't clicked "Edit" yet. Removes the hand-cursor and the green hover. */
|
||||
&--locked {
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: #f0fdf4;
|
||||
border-color: #86efac;
|
||||
}
|
||||
}
|
||||
|
||||
/* Existing saved document (edit mode) — thumbnail + filename + actions. */
|
||||
&__existing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: #e2e8f0;
|
||||
border: 1px solid #cbd5e1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__doc-ic {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 32px;
|
||||
color: #1e88e5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__existing-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__existing-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__existing-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__view,
|
||||
&__edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.12s;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&__view {
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
border-color: #bfdbfe;
|
||||
|
||||
&:hover {
|
||||
background: #dbeafe;
|
||||
border-color: #1e40af;
|
||||
}
|
||||
}
|
||||
|
||||
&__edit {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-color: #fde68a;
|
||||
|
||||
&:hover {
|
||||
background: #fde68a;
|
||||
border-color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
&__existing-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11.5px;
|
||||
color: #1e40af;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Read-only / disabled form-field overrides
|
||||
─── When the stepper is in view mode the form is .disable()'d.
|
||||
Material drops opacity to ~0.38 which reads as "blurry". Restore
|
||||
proper contrast so disabled values are still clearly legible.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
:host ::ng-deep {
|
||||
.mat-mdc-form-field.mat-form-field-appearance-outline.mat-form-field-disabled {
|
||||
opacity: 1;
|
||||
|
||||
.mat-mdc-input-element,
|
||||
input.mat-mdc-input-element,
|
||||
textarea.mat-mdc-input-element {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important;
|
||||
font-weight: 600;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.mat-mdc-floating-label,
|
||||
.mdc-floating-label {
|
||||
color: #64748b !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.mdc-notched-outline__leading,
|
||||
.mdc-notched-outline__notch,
|
||||
.mdc-notched-outline__trailing {
|
||||
border-color: #cbd5e1 !important;
|
||||
border-width: 1px !important;
|
||||
}
|
||||
|
||||
.field-prefix-icon mat-icon,
|
||||
.mat-mdc-form-field-icon-prefix mat-icon,
|
||||
.mat-mdc-form-field-icon-suffix mat-icon {
|
||||
color: #475569 !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-hint {
|
||||
color: #64748b !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mat-mdc-select-value-text,
|
||||
.mat-mdc-select-value {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mat-mdc-select-arrow {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Disabled checkbox — keep visuals crisp */
|
||||
.mat-mdc-checkbox-disabled {
|
||||
opacity: 1 !important;
|
||||
|
||||
.mdc-form-field > label,
|
||||
.mat-mdc-checkbox-label,
|
||||
.mdc-label {
|
||||
color: #0f172a !important;
|
||||
opacity: 1 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mdc-checkbox__background {
|
||||
background-color: #059669 !important;
|
||||
border-color: #059669 !important;
|
||||
}
|
||||
|
||||
.mdc-checkbox__checkmark {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+224
@@ -183,6 +183,21 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
addrMergedFile: File | null = null;
|
||||
addrMergedPreview: string | null = null;
|
||||
|
||||
/**
|
||||
* Identifier row + attached documents that back the address proof on the server (edit
|
||||
* mode). Populated by loadExistingAddressProof() so the UI can show the file the user
|
||||
* uploaded last time instead of an empty picker.
|
||||
*/
|
||||
existingAddressProofIdentifierId: string | null = null;
|
||||
existingAddressProofDocuments: {
|
||||
id: string;
|
||||
name: string;
|
||||
fileName: string;
|
||||
type: string;
|
||||
size: number;
|
||||
previewUrl: string | null;
|
||||
}[] = [];
|
||||
|
||||
residenceTypes = [
|
||||
'Owned',
|
||||
'Rented',
|
||||
@@ -386,7 +401,18 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
state: addr.stateName || '',
|
||||
landmark: addr.street || ''
|
||||
});
|
||||
|
||||
// Saved address payloads don't include the PIN's full locality list
|
||||
// or the "verified" status — so the dropdown shows only the saved
|
||||
// value and the green "PIN verified" chip never appears. Re-run the
|
||||
// lookup silently to repopulate options and confirm the PIN, without
|
||||
// overwriting what the user already had on record.
|
||||
if ((addr.postalCode || '').toString().length === 6) {
|
||||
this.silentlyHydratePin(tab);
|
||||
}
|
||||
}
|
||||
// Surface the address proof file the user uploaded previously.
|
||||
this.loadExistingAddressProof();
|
||||
},
|
||||
error: () => {
|
||||
this.addressLoaded = true;
|
||||
@@ -394,6 +420,95 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Address proof on this UI is persisted as a client_identifier row whose documentType
|
||||
* is one of the address-proof types (see create-client.saveAddressProof). To restore
|
||||
* the picker state and show the existing file in edit mode we:
|
||||
* 1. List the client's identifiers
|
||||
* 2. Find the one whose documentType matches an entry in `addressProofs`
|
||||
* 3. Patch the permanent-form proofId / documentKey from it
|
||||
* 4. Fetch its attached documents and render the first image as a preview
|
||||
*/
|
||||
private loadExistingAddressProof(): void {
|
||||
if (!this.clientId) return;
|
||||
// Wait briefly for proof types to load if they're still in flight — they're needed
|
||||
// to filter identifiers down to "address proofs" only.
|
||||
if (this.loadingProofs || this.addressProofs.length === 0) {
|
||||
setTimeout(() => this.loadExistingAddressProof(), 200);
|
||||
return;
|
||||
}
|
||||
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
|
||||
next: (identifiers: any[]) => {
|
||||
if (!identifiers || identifiers.length === 0) return;
|
||||
const proofNames = new Set(this.addressProofs.map((p) => p.name?.toLowerCase()).filter(Boolean));
|
||||
const identifier = identifiers.find((i: any) => {
|
||||
const n = i?.documentType?.name?.toLowerCase();
|
||||
return n && proofNames.has(n);
|
||||
});
|
||||
if (!identifier?.id) return;
|
||||
|
||||
this.existingAddressProofIdentifierId = identifier.id.toString();
|
||||
// Restore selected chip + form values
|
||||
const matchedProof = this.addressProofs.find(
|
||||
(p) => p.name?.toLowerCase() === identifier.documentType?.name?.toLowerCase()
|
||||
);
|
||||
if (matchedProof) {
|
||||
this.selectedAddrProof = matchedProof;
|
||||
this.permanentForm.patchValue({
|
||||
proofId: matchedProof.id,
|
||||
documentKey: identifier.documentKey || ''
|
||||
});
|
||||
// The description carries `Issue date: dd/mm/yyyy` when set on create (see
|
||||
// create-client.saveAddressProof) — pull it out so the form's issueDate field
|
||||
// shows what was previously entered.
|
||||
const desc: string = identifier.description || '';
|
||||
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
|
||||
if (issueMatch) {
|
||||
this.permanentForm.patchValue({ issueDate: issueMatch[1].trim() });
|
||||
}
|
||||
}
|
||||
|
||||
this.clientsService.getClientIdentificationDocuments(this.existingAddressProofIdentifierId).subscribe({
|
||||
next: (docs: any[]) => {
|
||||
if (!docs || docs.length === 0) return;
|
||||
this.existingAddressProofDocuments = docs.map((d: any) => ({
|
||||
id: d.id?.toString() || '',
|
||||
name: d.name || '',
|
||||
fileName: d.fileName || '',
|
||||
type: d.type || '',
|
||||
size: d.size || 0,
|
||||
previewUrl: null as string | null
|
||||
}));
|
||||
const firstImage = this.existingAddressProofDocuments.find((d) =>
|
||||
(d.type || '').toLowerCase().startsWith('image/')
|
||||
);
|
||||
if (firstImage) {
|
||||
this.clientsService
|
||||
.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId!, firstImage.id)
|
||||
.subscribe({
|
||||
next: async (blob: any) => {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
firstImage.previewUrl = 'data:' + blob.type + ';base64,' + btoa(binary);
|
||||
if (!this.addrMergedPreview) {
|
||||
this.addrMergedPreview = firstImage.previewUrl;
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
onPinInput(tab: string) {
|
||||
const form = this.getForm(tab);
|
||||
const pin = form.get('pinCode')?.value?.replace(/\D/g, '') || '';
|
||||
@@ -451,12 +566,121 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the saved address proof in a new browser tab.
|
||||
*
|
||||
* For images we already hold a base64 data URL in `previewUrl` from
|
||||
* `loadExistingAddressProof`, so we can hand it straight to `window.open`.
|
||||
* For PDFs / other types we need to fetch the binary blob and create a
|
||||
* temporary object URL so the browser's PDF viewer can render it.
|
||||
*/
|
||||
viewExistingAddressProof(): void {
|
||||
if (!this.existingAddressProofIdentifierId || this.existingAddressProofDocuments.length === 0) {
|
||||
return;
|
||||
}
|
||||
const doc = this.existingAddressProofDocuments[0];
|
||||
if (doc.previewUrl) {
|
||||
window.open(doc.previewUrl, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId, doc.id).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const w = window.open(url, '_blank', 'noopener');
|
||||
// Revoke after the tab has had time to load — browsers vary, so
|
||||
// err on the side of a generous wait.
|
||||
if (w) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// Silent fail — UI shows the saved name so user knows it exists.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private clearPinFields(tab: string) {
|
||||
this.localityOptions[tab] = [];
|
||||
const form = this.getForm(tab);
|
||||
form.patchValue({ locality: '', city: '', district: '', state: '' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-hydrate the PIN lookup for an already-loaded address.
|
||||
*
|
||||
* Used when an existing client opens in edit mode — the form values are
|
||||
* patched from the server, but the locality dropdown options and the
|
||||
* "PIN verified" status are derived state that only `onPinInput` produces.
|
||||
* This variant populates that derived state WITHOUT clobbering the saved
|
||||
* locality / city / district / state the user originally chose.
|
||||
*/
|
||||
private silentlyHydratePin(tab: string): void {
|
||||
const form = this.getForm(tab);
|
||||
const pin = (form.get('pinCode')?.value || '').toString().replace(/\D/g, '');
|
||||
if (pin.length !== 6) return;
|
||||
|
||||
const applyOptions = (offices: any[]) => {
|
||||
const seen = new Set<string>();
|
||||
const opts = (offices || [])
|
||||
.filter((o) => {
|
||||
if (!o?.Name || seen.has(o.Name)) return false;
|
||||
seen.add(o.Name);
|
||||
return true;
|
||||
})
|
||||
.map((o) => o.Name);
|
||||
this.localityOptions[tab] = opts;
|
||||
this.pinStatus[tab] = { loading: false, found: true, error: false, message: 'Verified' };
|
||||
|
||||
// If the saved locality isn't part of the options (rare — e.g. spelling
|
||||
// mismatch), prepend it so the bound `<mat-option>` still resolves.
|
||||
const savedLocality = form.get('locality')?.value;
|
||||
if (savedLocality && !opts.includes(savedLocality)) {
|
||||
this.localityOptions[tab] = [
|
||||
savedLocality,
|
||||
...opts
|
||||
];
|
||||
}
|
||||
|
||||
// Fineract's address payload returns `stateProvinceId` (numeric) rather
|
||||
// than `stateName`, so the State field arrives empty after load.
|
||||
// Fill state / city / district from the PIN lookup ONLY when they're
|
||||
// currently empty — preserves anything the user has actually saved.
|
||||
const first = (offices || [])[0];
|
||||
if (first) {
|
||||
const patch: Record<string, string> = {};
|
||||
if (!form.get('state')?.value && first.State) patch['state'] = first.State;
|
||||
if (!form.get('city')?.value && (first.Block || first.Division || first.District)) {
|
||||
patch['city'] = first.Block || first.Division || first.District;
|
||||
}
|
||||
if (!form.get('district')?.value && first.District) patch['district'] = first.District;
|
||||
if (Object.keys(patch).length > 0) {
|
||||
form.patchValue(patch);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (this.pinCache[pin]) {
|
||||
applyOptions(this.pinCache[pin]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pinStatus[tab] = { loading: true, found: false, error: false, message: 'Looking up...' };
|
||||
this.http.get<any[]>(`https://api.postalpincode.in/pincode/${pin}`).subscribe({
|
||||
next: (data: any) => {
|
||||
if (data?.[0]?.Status === 'Success') {
|
||||
this.pinCache[pin] = data[0].PostOffice;
|
||||
applyOptions(data[0].PostOffice);
|
||||
} else {
|
||||
// Couldn't confirm — keep the saved value visible but flag it.
|
||||
this.pinStatus[tab] = { loading: false, found: false, error: false, message: '' };
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.pinStatus[tab] = { loading: false, found: false, error: false, message: '' };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getForm(tab: string): UntypedFormGroup {
|
||||
switch (tab) {
|
||||
case 'permanent':
|
||||
|
||||
@@ -28,9 +28,30 @@
|
||||
<div class="form-grid">
|
||||
<!-- ══ INDIVIDUAL KYC ══ -->
|
||||
@if (!isNonIndividual) {
|
||||
<div class="form-field checkbox-field full-width">
|
||||
<mat-checkbox formControlName="hasPan">Client has PAN Card</mat-checkbox>
|
||||
</div>
|
||||
@if (readOnly) {
|
||||
<!-- In view mode the mat-checkbox renders as a faded grey square
|
||||
that reads as "blurry". Show a clean Yes/No pill instead. -->
|
||||
<div class="form-field checkbox-field full-width">
|
||||
<div class="ro-flag">
|
||||
<span class="ro-flag__label">Client has PAN Card</span>
|
||||
@if (kycForm.get('hasPan')?.value) {
|
||||
<span class="ro-flag__chip ro-flag__chip--yes">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Yes
|
||||
</span>
|
||||
} @else {
|
||||
<span class="ro-flag__chip ro-flag__chip--no">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
No
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="form-field checkbox-field full-width">
|
||||
<mat-checkbox formControlName="hasPan">Client has PAN Card</mat-checkbox>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Individual PAN with inline verify -->
|
||||
@if (kycForm.get('hasPan')?.value) {
|
||||
@@ -83,40 +104,6 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<div class="pan-result-card full-width">
|
||||
<div class="pan-result-header">
|
||||
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
|
||||
<span>PAN Verified Successfully</span>
|
||||
</div>
|
||||
<div class="pan-result-body">
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Full Name (as per PAN)</span>
|
||||
<span class="pan-result-value name">{{ panVerifyName }}</span>
|
||||
</div>
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">PAN–Aadhaar Link Status</span>
|
||||
<span
|
||||
class="pan-result-value"
|
||||
[class.seeded]="panAadhaarSeedingStatus?.toUpperCase() === 'Y'"
|
||||
[class.not-seeded]="panAadhaarSeedingStatus?.toUpperCase() === 'N'"
|
||||
>
|
||||
@if (panAadhaarSeedingStatus?.toUpperCase() === 'Y') {
|
||||
Linked
|
||||
} @else if (panAadhaarSeedingStatus?.toUpperCase() === 'N') {
|
||||
Not Linked
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Verified On</span>
|
||||
<span class="pan-result-value date">{{ panVerifiedOn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Aadhaar with independent verify (Individual only) -->
|
||||
@@ -251,45 +238,140 @@
|
||||
<fa-icon icon="exclamation-circle"></fa-icon> {{ aadhaarVerifyError }}
|
||||
</div>
|
||||
}
|
||||
@if (aadhaarVerifyStatus === 'VERIFIED' && aadhaarVerifyName) {
|
||||
<div class="aadhaar-result-card full-width">
|
||||
<div class="aadhaar-result-card__header">
|
||||
<fa-icon icon="check-circle" class="aadhaar-result-card__icon"></fa-icon>
|
||||
<span>Aadhaar Verified Successfully</span>
|
||||
</div>
|
||||
<div class="aadhaar-result-card__body">
|
||||
<span class="aadhaar-result-card__label">Name as per Aadhaar</span>
|
||||
<span class="aadhaar-result-card__name">{{ aadhaarVerifyName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- PAN-Aadhaar Seeding Status -->
|
||||
@if (kycForm.get('hasPan')?.value && panAadhaarSeedingStatus) {
|
||||
<div class="form-field pan-link-status full-width">
|
||||
<div
|
||||
class="pan-link-box"
|
||||
[class.linked]="panAadhaarSeedingStatus === 'Y'"
|
||||
[class.not-linked]="panAadhaarSeedingStatus === 'N'"
|
||||
[class.exempt]="panAadhaarSeedingStatus === 'E'"
|
||||
>
|
||||
<fa-icon icon="link" class="pan-link-icon"></fa-icon>
|
||||
<div class="pan-link-info">
|
||||
<span class="pan-link-label">PAN–Aadhaar Link Status</span>
|
||||
<span class="pan-link-value">
|
||||
@if (panAadhaarSeedingStatus === 'Y') {
|
||||
Linked (Seeded)
|
||||
<!-- ─── Unified Identity Verification Summary ─── -->
|
||||
<!-- One polished card that consolidates PAN result, Aadhaar result, and -->
|
||||
<!-- the PAN–Aadhaar link status into a single, scannable panel. -->
|
||||
@if (panVerifyStatus === 'VERIFIED' || aadhaarVerifyStatus === 'VERIFIED') {
|
||||
<div
|
||||
class="idv-summary full-width"
|
||||
[class.both]="panVerifyStatus === 'VERIFIED' && aadhaarVerifyStatus === 'VERIFIED'"
|
||||
[class.partial]="
|
||||
!(panVerifyStatus === 'VERIFIED' && aadhaarVerifyStatus === 'VERIFIED') &&
|
||||
(panVerifyStatus === 'VERIFIED' || aadhaarVerifyStatus === 'VERIFIED')
|
||||
"
|
||||
>
|
||||
<!-- Hero strip -->
|
||||
<div class="idv-hero">
|
||||
<div class="idv-hero__ic"><fa-icon icon="shield-alt"></fa-icon></div>
|
||||
<div class="idv-hero__txt">
|
||||
<div class="idv-hero__title">
|
||||
@if (panVerifyStatus === 'VERIFIED' && aadhaarVerifyStatus === 'VERIFIED') {
|
||||
Identity Verified
|
||||
} @else if (panVerifyStatus === 'VERIFIED') {
|
||||
PAN Verified
|
||||
} @else {
|
||||
Aadhaar Verified
|
||||
}
|
||||
@if (panAadhaarSeedingStatus === 'N') {
|
||||
Not Linked
|
||||
</div>
|
||||
<div class="idv-hero__sub">
|
||||
@if (panVerifyStatus === 'VERIFIED' && aadhaarVerifyStatus === 'VERIFIED') {
|
||||
Both PAN & Aadhaar confirmed against NSDL & UIDAI records.
|
||||
} @else if (panVerifyStatus === 'VERIFIED') {
|
||||
PAN confirmed against NSDL records. Aadhaar verification pending.
|
||||
} @else {
|
||||
Aadhaar confirmed via UIDAI OTP. PAN verification pending.
|
||||
}
|
||||
@if (panAadhaarSeedingStatus === 'E') {
|
||||
Exempt
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="idv-hero__count">
|
||||
<span class="idv-hero__count-num">
|
||||
{{ (panVerifyStatus === 'VERIFIED' ? 1 : 0) + (aadhaarVerifyStatus === 'VERIFIED' ? 1 : 0) }}
|
||||
</span>
|
||||
<span class="idv-hero__count-of">/ 2</span>
|
||||
</div>
|
||||
<span class="pan-link-hint">As per Income Tax Portal — Section 139AA</span>
|
||||
</div>
|
||||
|
||||
<!-- Two-column body: PAN | Aadhaar -->
|
||||
<div class="idv-body">
|
||||
<!-- PAN column -->
|
||||
<div class="idv-col" [class.muted]="panVerifyStatus !== 'VERIFIED'">
|
||||
<div class="idv-col__head">
|
||||
<fa-icon icon="id-card" class="idv-col__ic"></fa-icon>
|
||||
<span class="idv-col__label">PAN</span>
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<span class="idv-chip ok"> <fa-icon icon="check-circle"></fa-icon> Verified </span>
|
||||
} @else {
|
||||
<span class="idv-chip pending">Pending</span>
|
||||
}
|
||||
</div>
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<div class="idv-col__rows">
|
||||
<div class="idv-row">
|
||||
<span class="idv-row__k">Name on PAN</span>
|
||||
<span class="idv-row__v name">{{ panVerifyName }}</span>
|
||||
</div>
|
||||
<div class="idv-row">
|
||||
<span class="idv-row__k">PAN Number</span>
|
||||
<span class="idv-row__v mono">{{ kycForm.get('panNumber')?.value }}</span>
|
||||
</div>
|
||||
<div class="idv-row">
|
||||
<span class="idv-row__k">Verified On</span>
|
||||
<span class="idv-row__v">{{ panVerifiedOn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="idv-col__empty">Enter PAN above and click <strong>Verify PAN</strong>.</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Aadhaar column -->
|
||||
<div class="idv-col" [class.muted]="aadhaarVerifyStatus !== 'VERIFIED'">
|
||||
<div class="idv-col__head">
|
||||
<fa-icon icon="fingerprint" class="idv-col__ic"></fa-icon>
|
||||
<span class="idv-col__label">Aadhaar</span>
|
||||
@if (aadhaarVerifyStatus === 'VERIFIED') {
|
||||
<span class="idv-chip ok"> <fa-icon icon="check-circle"></fa-icon> Verified </span>
|
||||
} @else {
|
||||
<span class="idv-chip pending">Pending</span>
|
||||
}
|
||||
</div>
|
||||
@if (aadhaarVerifyStatus === 'VERIFIED') {
|
||||
<div class="idv-col__rows">
|
||||
<div class="idv-row">
|
||||
<span class="idv-row__k">Name on Aadhaar</span>
|
||||
<span class="idv-row__v name">{{ aadhaarVerifyName }}</span>
|
||||
</div>
|
||||
<div class="idv-row">
|
||||
<span class="idv-row__k">Aadhaar Number</span>
|
||||
<span class="idv-row__v mono"
|
||||
>XXXX XXXX {{ (kycForm.get('aadhaarNumber')?.value || '').slice(-4) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="idv-row">
|
||||
<span class="idv-row__k">Channel</span>
|
||||
<span class="idv-row__v">UIDAI OTP</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="idv-col__empty">Enter Aadhaar above and click <strong>Verify Aadhaar</strong>.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PAN ↔ Aadhaar link footer -->
|
||||
@if (panAadhaarSeedingStatus) {
|
||||
<div
|
||||
class="idv-link"
|
||||
[class.linked]="panAadhaarSeedingStatus === 'Y'"
|
||||
[class.not-linked]="panAadhaarSeedingStatus === 'N'"
|
||||
[class.exempt]="panAadhaarSeedingStatus === 'E'"
|
||||
>
|
||||
<fa-icon icon="link" class="idv-link__ic"></fa-icon>
|
||||
<div class="idv-link__txt">
|
||||
<span class="idv-link__label">PAN — Aadhaar Link</span>
|
||||
<span class="idv-link__value">
|
||||
@if (panAadhaarSeedingStatus === 'Y') {
|
||||
Linked (Seeded)
|
||||
} @else if (panAadhaarSeedingStatus === 'N') {
|
||||
Not Linked
|
||||
} @else if (panAadhaarSeedingStatus === 'E') {
|
||||
Exempt
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<span class="idv-link__hint">Income Tax Portal — Section 139AA</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -652,7 +734,7 @@
|
||||
|
||||
<div class="kyc-doc-field">
|
||||
<label class="kyc-doc-label"><mat-icon>description</mat-icon> Document type</label>
|
||||
<div class="kyc-doc-chip-wrap">
|
||||
<div class="kyc-doc-chip-wrap" [class.kyc-doc-chip-wrap--locked]="readOnly">
|
||||
@for (group of kycDocGroups; track group.label; let last = $last) {
|
||||
<div class="kyc-doc-chip-group-label"><mat-icon>folder_open</mat-icon> {{ group.label }}</div>
|
||||
<div class="kyc-doc-chip-grid">
|
||||
@@ -660,9 +742,11 @@
|
||||
<span
|
||||
class="kyc-doc-chip"
|
||||
[class.kyc-doc-chip--sel]="isKycDocSelected(p)"
|
||||
(click)="selectKycDoc(p)"
|
||||
(keydown.enter)="selectKycDoc(p)"
|
||||
tabindex="0"
|
||||
[class.kyc-doc-chip--locked]="readOnly && !isKycDocSelected(p)"
|
||||
[attr.aria-disabled]="readOnly ? 'true' : null"
|
||||
(click)="readOnly ? null : selectKycDoc(p)"
|
||||
(keydown.enter)="readOnly ? null : selectKycDoc(p)"
|
||||
[attr.tabindex]="readOnly ? -1 : 0"
|
||||
role="button"
|
||||
>
|
||||
<mat-icon class="kyc-doc-chip__icon">
|
||||
@@ -699,59 +783,78 @@
|
||||
</span>
|
||||
<span class="kyc-doc-upload-req">Required</span>
|
||||
</div>
|
||||
<label
|
||||
class="kyc-doc-dropzone"
|
||||
[class.kyc-doc-dropzone--has]="kycDocFiles.front"
|
||||
[class.kyc-doc-dropzone--req]="!kycDocFiles.front"
|
||||
>
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'front')" />
|
||||
@if (kycDocFiles.front) {
|
||||
<div class="kyc-doc-dropzone__file">
|
||||
<mat-icon>insert_drive_file</mat-icon>
|
||||
<span class="kyc-doc-dropzone__name">{{ kycDocFiles.front.name }}</span>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="kyc-doc-dropzone__remove"
|
||||
(click)="$event.preventDefault(); removeKycDocFile('front')"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
<!-- Existing saved ID-proof doc — locked by default. Replace flow
|
||||
unlocks automatically when the section-level Edit button toggles
|
||||
`readOnly` off. -->
|
||||
@if (existingKycDocuments.length > 0 && !kycDocFiles.front && readOnly) {
|
||||
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
|
||||
<div class="kyc-doc-dropzone__existing">
|
||||
@if (kycDocMergedPreview) {
|
||||
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
|
||||
} @else {
|
||||
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="kyc-doc-dropzone__existing-meta">
|
||||
<span class="kyc-doc-dropzone__existing-name">
|
||||
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
|
||||
</span>
|
||||
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc()">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
Click here to view
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="kyc-doc-dropzone__hint">
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
|
||||
</div>
|
||||
}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (selectedKycDoc.sides === 2) {
|
||||
<div class="kyc-doc-upload-slot">
|
||||
<div class="kyc-doc-upload-head">
|
||||
<span class="kyc-doc-upload-title"> <mat-icon>image</mat-icon> Back side </span>
|
||||
<span class="kyc-doc-upload-req">Required</span>
|
||||
</div>
|
||||
} @else {
|
||||
<label
|
||||
class="kyc-doc-dropzone"
|
||||
[class.kyc-doc-dropzone--has]="kycDocFiles.back"
|
||||
[class.kyc-doc-dropzone--req]="!kycDocFiles.back"
|
||||
[class.kyc-doc-dropzone--has]="kycDocFiles.front || existingKycDocuments.length > 0"
|
||||
[class.kyc-doc-dropzone--req]="!kycDocFiles.front && existingKycDocuments.length === 0"
|
||||
>
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'back')" />
|
||||
@if (kycDocFiles.back) {
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'front')" />
|
||||
@if (kycDocFiles.front) {
|
||||
<div class="kyc-doc-dropzone__file">
|
||||
<mat-icon>insert_drive_file</mat-icon>
|
||||
<span class="kyc-doc-dropzone__name">{{ kycDocFiles.back.name }}</span>
|
||||
<span class="kyc-doc-dropzone__name">{{ kycDocFiles.front.name }}</span>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="kyc-doc-dropzone__remove"
|
||||
(click)="$event.preventDefault(); removeKycDocFile('back')"
|
||||
(click)="$event.preventDefault(); removeKycDocFile('front')"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
} @else if (existingKycDocuments.length > 0) {
|
||||
<!-- Edit mode WITH existing doc — let the user view it OR
|
||||
click anywhere to replace. -->
|
||||
<div class="kyc-doc-dropzone__existing">
|
||||
@if (kycDocMergedPreview) {
|
||||
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
|
||||
} @else {
|
||||
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="kyc-doc-dropzone__existing-meta">
|
||||
<span class="kyc-doc-dropzone__existing-name">
|
||||
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
|
||||
</span>
|
||||
<div class="kyc-doc-dropzone__existing-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="kyc-doc-dropzone__view"
|
||||
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc()"
|
||||
>
|
||||
<mat-icon>visibility</mat-icon>
|
||||
View
|
||||
</button>
|
||||
<span class="kyc-doc-dropzone__sep">·</span>
|
||||
<span class="kyc-doc-dropzone__existing-hint">
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
Click here to replace
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="kyc-doc-dropzone__hint">
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
@@ -759,11 +862,104 @@
|
||||
</div>
|
||||
}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedKycDoc.sides === 2) {
|
||||
<div class="kyc-doc-upload-slot">
|
||||
<div class="kyc-doc-upload-head">
|
||||
<span class="kyc-doc-upload-title"> <mat-icon>image</mat-icon> Back side </span>
|
||||
<!-- Hide Required pill when saved combined doc covers both sides -->
|
||||
@if (!(existingKycDocuments.length > 0 && !kycDocFiles.back)) {
|
||||
<span class="kyc-doc-upload-req">Required</span>
|
||||
}
|
||||
</div>
|
||||
<!-- Locked view: saved combined doc covers the back side too. -->
|
||||
@if (existingKycDocuments.length > 0 && !kycDocFiles.back && readOnly) {
|
||||
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
|
||||
<div class="kyc-doc-dropzone__existing">
|
||||
@if (kycDocMergedPreview) {
|
||||
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
|
||||
} @else {
|
||||
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="kyc-doc-dropzone__existing-meta">
|
||||
<span class="kyc-doc-dropzone__existing-name">
|
||||
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
|
||||
</span>
|
||||
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc()">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
Click here to view
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<label
|
||||
class="kyc-doc-dropzone"
|
||||
[class.kyc-doc-dropzone--has]="kycDocFiles.back || existingKycDocuments.length > 0"
|
||||
[class.kyc-doc-dropzone--req]="!kycDocFiles.back && existingKycDocuments.length === 0"
|
||||
>
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'back')" />
|
||||
@if (kycDocFiles.back) {
|
||||
<div class="kyc-doc-dropzone__file">
|
||||
<mat-icon>insert_drive_file</mat-icon>
|
||||
<span class="kyc-doc-dropzone__name">{{ kycDocFiles.back.name }}</span>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="kyc-doc-dropzone__remove"
|
||||
(click)="$event.preventDefault(); removeKycDocFile('back')"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
} @else if (existingKycDocuments.length > 0) {
|
||||
<!-- Edit mode WITH existing combined doc — view + replace. -->
|
||||
<div class="kyc-doc-dropzone__existing">
|
||||
@if (kycDocMergedPreview) {
|
||||
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
|
||||
} @else {
|
||||
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="kyc-doc-dropzone__existing-meta">
|
||||
<span class="kyc-doc-dropzone__existing-name">
|
||||
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
|
||||
</span>
|
||||
<div class="kyc-doc-dropzone__existing-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="kyc-doc-dropzone__view"
|
||||
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc()"
|
||||
>
|
||||
<mat-icon>visibility</mat-icon>
|
||||
View
|
||||
</button>
|
||||
<span class="kyc-doc-dropzone__sep">·</span>
|
||||
<span class="kyc-doc-dropzone__existing-hint">
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
Click here to replace
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="kyc-doc-dropzone__hint">
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
|
||||
</div>
|
||||
}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedKycDoc.sides === 2 && kycDocMergedPreview) {
|
||||
<!-- Combined preview + re-crop is an edit-mode tool — hide in read-only,
|
||||
and don't duplicate the thumbnail when nothing's been picked yet. -->
|
||||
@if (
|
||||
selectedKycDoc.sides === 2 && kycDocMergedPreview && !readOnly && (kycDocFiles.front || kycDocFiles.back)
|
||||
) {
|
||||
<div class="kyc-merged">
|
||||
<div class="kyc-merged__head">
|
||||
<span class="kyc-merged__title">
|
||||
|
||||
@@ -869,6 +869,49 @@
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
/* Read-only Yes/No flag — replaces the disabled mat-checkbox in view mode
|
||||
so we don't fight Material's greyed-out disabled checkbox styling. */
|
||||
.ro-flag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
&__label {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
&--yes {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
&--no {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Link select inside status box */
|
||||
.link-select {
|
||||
font-size: 12px;
|
||||
@@ -1402,12 +1445,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Non-selected chip in read-only mode — visible but visibly unclickable.
|
||||
The selected chip stays fully highlighted (no `--locked` class on it). */
|
||||
&--locked {
|
||||
cursor: default;
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
|
||||
.kyc-doc-chip__icon {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #1976d2;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* When the whole wrap is locked, prevent the selected chip's cursor from
|
||||
suggesting interactivity either. */
|
||||
.kyc-doc-chip-wrap--locked .kyc-doc-chip--sel {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.kyc-doc-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1640,4 +1707,524 @@
|
||||
&__remove {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Locked variant — used when an existing saved doc is shown but the
|
||||
section's top-level Edit hasn't been clicked yet. */
|
||||
&--locked {
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: #f0fdf4;
|
||||
border-color: #86efac;
|
||||
}
|
||||
}
|
||||
|
||||
/* Existing saved document — thumbnail + filename + actions. */
|
||||
&__existing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: #e2e8f0;
|
||||
border: 1px solid #cbd5e1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__doc-ic {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 32px;
|
||||
color: #1e88e5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__existing-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__existing-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__existing-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__view {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
border: 1px solid #bfdbfe;
|
||||
transition: all 0.12s;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #dbeafe;
|
||||
border-color: #1e40af;
|
||||
}
|
||||
}
|
||||
|
||||
&__sep {
|
||||
color: #cbd5e1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__existing-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11.5px;
|
||||
color: #1e40af;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Read-only / disabled form-field overrides
|
||||
─── When the form is .disable()'d (view mode after KYC is locked),
|
||||
Material drops opacity and greys everything. That reads as
|
||||
"blurry" to users. Restore proper contrast: dark text, crisp
|
||||
outline, normal-weight label — just NOT editable.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
:host ::ng-deep {
|
||||
/* Outlined form fields in disabled state */
|
||||
.mat-mdc-form-field.mat-form-field-appearance-outline.mat-form-field-disabled {
|
||||
opacity: 1;
|
||||
|
||||
/* Input text */
|
||||
.mat-mdc-input-element,
|
||||
input.mat-mdc-input-element {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important; /* Chrome/Safari override */
|
||||
font-weight: 600;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
/* Floating label */
|
||||
.mat-mdc-floating-label,
|
||||
.mdc-floating-label {
|
||||
color: #64748b !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* The outline lines */
|
||||
.mdc-notched-outline__leading,
|
||||
.mdc-notched-outline__notch,
|
||||
.mdc-notched-outline__trailing {
|
||||
border-color: #cbd5e1 !important;
|
||||
border-width: 1px !important;
|
||||
}
|
||||
|
||||
/* matPrefix icons (the credit-card / fingerprint glyphs) */
|
||||
.field-prefix-icon mat-icon,
|
||||
.mat-mdc-form-field-icon-prefix mat-icon {
|
||||
color: #475569 !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* matSuffix verify-ok icon — keep its green */
|
||||
.verify-icon-ok {
|
||||
color: #059669 !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Hint text */
|
||||
.mat-mdc-form-field-hint {
|
||||
color: #64748b !important;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Disabled checkbox — keep the check + label crisp */
|
||||
.mat-mdc-checkbox-disabled {
|
||||
opacity: 1 !important;
|
||||
|
||||
.mdc-checkbox__background {
|
||||
background-color: #059669 !important;
|
||||
border-color: #059669 !important;
|
||||
}
|
||||
|
||||
.mdc-checkbox__checkmark {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.mdc-form-field > label,
|
||||
.mat-mdc-checkbox-label,
|
||||
.mdc-label {
|
||||
color: #0f172a !important;
|
||||
opacity: 1 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
/* Disabled mat-select — same treatment as inputs */
|
||||
.mat-mdc-form-field-disabled .mat-mdc-select-value-text,
|
||||
.mat-mdc-form-field-disabled .mat-mdc-select-value {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Disabled "toggle visibility" eye icon button */
|
||||
.mat-mdc-form-field-disabled .toggle-vis-btn {
|
||||
color: #64748b !important;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Unified Identity Verification Summary (PAN + Aadhaar + Link)
|
||||
─── replaces the legacy pan-result-card / aadhaar-result-card /
|
||||
pan-link-box trio with a single polished panel.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.idv-summary {
|
||||
margin: 4px 0 12px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #d1fae5;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
||||
overflow: hidden;
|
||||
|
||||
&.partial {
|
||||
border-color: #fde68a;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hero strip — gradient green when both verified, amber when partial. */
|
||||
.idv-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 18px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #047857 0%, #059669 60%, #10b981 100%);
|
||||
|
||||
.idv-summary.partial & {
|
||||
background: linear-gradient(135deg, #b45309 0%, #d97706 60%, #f59e0b 100%);
|
||||
}
|
||||
|
||||
&__ic {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: rgb(255 255 255 / 18%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
&__txt {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin-top: 3px;
|
||||
font-size: 11.5px;
|
||||
opacity: 0.92;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&__count {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 22%);
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
border: 1px solid rgb(255 255 255 / 30%);
|
||||
|
||||
&-num {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&-of {
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Two-column body */
|
||||
.idv-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
background: #ecfdf5;
|
||||
|
||||
.idv-summary.partial & {
|
||||
background: #fef3c7;
|
||||
}
|
||||
}
|
||||
|
||||
.idv-col {
|
||||
padding: 14px 18px;
|
||||
border-right: 1px dashed rgb(5 150 105 / 25%);
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&.muted {
|
||||
background: rgb(255 255 255 / 50%);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__ic {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: #047857;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 8%);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 800;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
&__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
background: rgb(255 255 255 / 70%);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
|
||||
strong {
|
||||
color: #334155;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.idv-chip {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
fa-icon {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
&.ok {
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
.idv-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
|
||||
&__k {
|
||||
flex-shrink: 0;
|
||||
width: 110px;
|
||||
font-size: 10.5px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
&__v {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
|
||||
&.name {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
&.mono {
|
||||
font-family: ui-monospace, 'Roboto Mono', SFMono-Regular, monospace;
|
||||
font-size: 12px;
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer — PAN ↔ Aadhaar link status */
|
||||
.idv-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 18px;
|
||||
background: #f0fdf4;
|
||||
border-top: 1px solid #bbf7d0;
|
||||
|
||||
&__ic {
|
||||
color: #047857;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__txt {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.not-linked {
|
||||
background: #fef2f2;
|
||||
border-top-color: #fecaca;
|
||||
|
||||
.idv-link__ic,
|
||||
.idv-link__value {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
&.exempt {
|
||||
background: #eff6ff;
|
||||
border-top-color: #bfdbfe;
|
||||
|
||||
.idv-link__ic,
|
||||
.idv-link__value {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive — stack columns on narrow screens */
|
||||
@media (width <= 640px) {
|
||||
.idv-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.idv-col {
|
||||
border-right: none;
|
||||
border-bottom: 1px dashed rgb(5 150 105 / 25%);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.idv-hero {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.idv-link {
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__hint {
|
||||
width: 100%;
|
||||
padding-left: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.idv-row__k {
|
||||
width: 95px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
kycDocMergedFile: File | null = null;
|
||||
kycDocMergedPreview: string | null = null;
|
||||
|
||||
/**
|
||||
* Documents already on the server for the linked client_identifier row (edit mode).
|
||||
* Populated in loadExistingKycIdentifier() so the form can show the user what proof
|
||||
* they uploaded previously instead of presenting an empty file picker.
|
||||
*
|
||||
* Each entry: { id, name, fileName, type, size, previewUrl (base64 data URL for images) }
|
||||
*/
|
||||
existingKycIdentifierId: string | null = null;
|
||||
existingKycDocuments: {
|
||||
id: string;
|
||||
name: string;
|
||||
fileName: string;
|
||||
type: string;
|
||||
size: number;
|
||||
previewUrl: string | null;
|
||||
}[] = [];
|
||||
|
||||
/** Set true after a save attempt so we can highlight missing photo/signature boxes */
|
||||
photoSigTouched = false;
|
||||
|
||||
@@ -522,6 +539,36 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.kycDocMergedPreview = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the saved KYC ID-proof document in a new browser tab.
|
||||
*
|
||||
* Same flow as the address-step variant: images use the already-loaded
|
||||
* base64 data URL; PDFs (or anything without a cached preview) are fetched
|
||||
* fresh and rendered via a temporary blob URL.
|
||||
*/
|
||||
viewExistingKycDoc(): void {
|
||||
if (!this.existingKycIdentifierId || this.existingKycDocuments.length === 0) {
|
||||
return;
|
||||
}
|
||||
const doc = this.existingKycDocuments[0];
|
||||
if (doc.previewUrl) {
|
||||
window.open(doc.previewUrl, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, doc.id).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const w = window.open(url, '_blank', 'noopener');
|
||||
if (w) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
/* silent — UI still shows the saved filename */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshKycDocMerged(): Promise<void> {
|
||||
const proof = this.selectedKycDoc;
|
||||
if (!proof || proof.sides !== 2 || !this.kycDocFiles.front || !this.kycDocFiles.back) {
|
||||
@@ -662,6 +709,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
// Load existing photo & signature
|
||||
this.loadExistingPhoto();
|
||||
this.loadExistingSignature();
|
||||
// Show the proof file the user uploaded previously, by matching the KYC document
|
||||
// name against the client_identifier rows on the server.
|
||||
this.loadExistingKycIdentifier(data?.kycDocumentName);
|
||||
},
|
||||
error: () => {
|
||||
// No existing KYC — that's fine for new clients
|
||||
@@ -670,6 +720,86 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the client_identifier row that backs the KYC proof and any documents attached to
|
||||
* it. Address proof + KYC proof both end up as client_identifier rows (see
|
||||
* create-client.saveKycDocProof + saveAddressProof) — so we filter against `kycDocProofs`
|
||||
* before falling back, otherwise the KYC tab could mistakenly show the address proof's file.
|
||||
*
|
||||
* @param kycDocumentName the document type captured on the KYC record — used to pick
|
||||
* the right identifier when a client has more than one
|
||||
*/
|
||||
private loadExistingKycIdentifier(kycDocumentName: string | undefined): void {
|
||||
if (!this.clientId) return;
|
||||
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
|
||||
next: (identifiers: any[]) => {
|
||||
if (!identifiers || identifiers.length === 0) return;
|
||||
const isKycType = (i: any) => {
|
||||
const n = i?.documentType?.name?.toLowerCase();
|
||||
return !!n && this.kycDocProofs.some((p) => p.name?.toLowerCase() === n);
|
||||
};
|
||||
// Pick by document-type name match first; otherwise the first identifier whose
|
||||
// type lives in the KYC proof list (avoid bleeding the address proof in here).
|
||||
const match = kycDocumentName
|
||||
? identifiers.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase())
|
||||
: null;
|
||||
const identifier = match || identifiers.find(isKycType);
|
||||
if (!identifier?.id) return;
|
||||
|
||||
this.existingKycIdentifierId = identifier.id.toString();
|
||||
// Backfill document type + number on the form in case the KYC record didn't have
|
||||
// them (older clients were created via the identifier flow only).
|
||||
if (!this.kycForm.get('kycDocumentName')?.value && identifier?.documentType?.name) {
|
||||
this.kycForm.patchValue({
|
||||
kycDocumentName: identifier.documentType.name,
|
||||
kycDocumentNumber: identifier.documentKey || ''
|
||||
});
|
||||
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === identifier.documentType.name) || null;
|
||||
}
|
||||
|
||||
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
|
||||
next: (docs: any[]) => {
|
||||
if (!docs || docs.length === 0) return;
|
||||
this.existingKycDocuments = docs.map((d: any) => ({
|
||||
id: d.id?.toString() || '',
|
||||
name: d.name || '',
|
||||
fileName: d.fileName || '',
|
||||
type: d.type || '',
|
||||
size: d.size || 0,
|
||||
previewUrl: null as string | null
|
||||
}));
|
||||
// Build a preview for the first image-typed document so the UI can show
|
||||
// exactly what the user uploaded last time.
|
||||
const firstImage = this.existingKycDocuments.find((d) => (d.type || '').toLowerCase().startsWith('image/'));
|
||||
if (firstImage) {
|
||||
this.clientsService
|
||||
.downloadClientIdentificationDocument(this.existingKycIdentifierId!, firstImage.id)
|
||||
.subscribe({
|
||||
next: async (blob: any) => {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
firstImage.previewUrl = 'data:' + blob.type + ';base64,' + btoa(binary);
|
||||
// Mirror onto the merged-preview slot so the existing chip-picker
|
||||
// visualisation lights up without template changes.
|
||||
if (!this.kycDocMergedPreview) {
|
||||
this.kycDocMergedPreview = firstImage.previewUrl;
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch existing client profile photo */
|
||||
private loadExistingPhoto(): void {
|
||||
if (!this.clientId) return;
|
||||
@@ -801,9 +931,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.panVerifyError = 'Client must be created first. Complete General tab and click Next.';
|
||||
return;
|
||||
}
|
||||
const pan = this.kycForm.value.panNumber;
|
||||
const pan = (this.kycForm.value.panNumber || '').toUpperCase().trim();
|
||||
if (!pan) {
|
||||
this.panVerifyError = 'Enter PAN number first.';
|
||||
this.kycForm.get('panNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
// Block API call when the PAN format is invalid — Fineract would accept
|
||||
// the raw value otherwise and persist a junk record.
|
||||
if (!/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(pan)) {
|
||||
this.panVerifyError = 'Invalid PAN format. Expected pattern: ABCDE1234F (5 letters, 4 digits, 1 letter).';
|
||||
this.kycForm.get('panNumber')?.setErrors({ pattern: true });
|
||||
this.kycForm.get('panNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -893,8 +1032,20 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
*/
|
||||
verifyEnterprisePan() {
|
||||
if (!this.clientId) return;
|
||||
const pan = this.kycForm.value.enterprisePan;
|
||||
if (!pan || this.kycForm.get('enterprisePan')?.invalid) return;
|
||||
const pan = (this.kycForm.value.enterprisePan || '').toUpperCase().trim();
|
||||
if (!pan) {
|
||||
this.showPanErrorAlert('Enter Enterprise PAN number first.');
|
||||
this.kycForm.get('enterprisePan')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(pan)) {
|
||||
this.showPanErrorAlert(
|
||||
'Invalid Enterprise PAN format. Expected pattern: ABCDE1234F (5 letters, 4 digits, 1 letter).'
|
||||
);
|
||||
this.kycForm.get('enterprisePan')?.setErrors({ pattern: true });
|
||||
this.kycForm.get('enterprisePan')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.entPanVerifying = true;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
@@ -955,8 +1106,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
/** Verify GSTIN via API */
|
||||
verifyGstin() {
|
||||
if (!this.clientId) return;
|
||||
const gstin = this.kycForm.value.gstinNumber;
|
||||
if (!gstin || this.kycForm.get('gstinNumber')?.invalid) return;
|
||||
const gstin = (this.kycForm.value.gstinNumber || '').toUpperCase().trim();
|
||||
if (!gstin) {
|
||||
this.showPanErrorAlert('Enter GSTIN number first.');
|
||||
this.kycForm.get('gstinNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
if (!/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/.test(gstin)) {
|
||||
this.showPanErrorAlert('Invalid GSTIN format. Expected pattern: 22AAAAA0000A1Z5 (15 characters).');
|
||||
this.kycForm.get('gstinNumber')?.setErrors({ pattern: true });
|
||||
this.kycForm.get('gstinNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.gstinVerifying = true;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
@@ -1019,8 +1180,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
/** Verify CIN (dummy — no real API) */
|
||||
verifyCin() {
|
||||
const cin = this.kycForm.value.cinNumber;
|
||||
if (!cin) return;
|
||||
const cin = (this.kycForm.value.cinNumber || '').toUpperCase().trim();
|
||||
if (!cin) {
|
||||
this.showPanErrorAlert('Enter CIN number first.');
|
||||
this.kycForm.get('cinNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Z]{1}[0-9]{5}[A-Z]{2}[0-9]{4}[A-Z]{3}[0-9]{6}$/.test(cin)) {
|
||||
this.showPanErrorAlert('Invalid CIN format. Expected pattern: L17110MH1973PLC019786 (21 characters).');
|
||||
this.kycForm.get('cinNumber')?.setErrors({ pattern: true });
|
||||
this.kycForm.get('cinNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.cinVerifying = true;
|
||||
this.cinProgressValue = 0;
|
||||
@@ -1068,9 +1239,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.aadhaarVerifyError = 'Client must be created first. Complete General tab and click Next.';
|
||||
return;
|
||||
}
|
||||
const aadhaar = this.kycForm.value.aadhaarNumber;
|
||||
if (!aadhaar || aadhaar.length !== 12) {
|
||||
this.aadhaarVerifyError = 'Enter valid 12-digit Aadhaar first.';
|
||||
const aadhaar = (this.kycForm.value.aadhaarNumber || '').trim();
|
||||
if (!aadhaar) {
|
||||
this.aadhaarVerifyError = 'Enter Aadhaar number first.';
|
||||
this.kycForm.get('aadhaarNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
if (!/^\d{12}$/.test(aadhaar)) {
|
||||
this.aadhaarVerifyError = 'Invalid Aadhaar format. Aadhaar must be exactly 12 digits.';
|
||||
this.kycForm.get('aadhaarNumber')?.setErrors({ pattern: true });
|
||||
this.kycForm.get('aadhaarNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1287,6 +1465,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const v = formVal;
|
||||
const formatDate = (d: any) => (d instanceof Date ? this.dateUtils.formatDate(d, dateFormat) : d || null);
|
||||
// Backend regex on these identifiers is upper-case only; tolerate user-typed lowercase
|
||||
// / whitespace here so the save doesn't 400 on
|
||||
// "validation.msg.CLIENTKYC.panNumber.does.not.match.regexp"
|
||||
// (or the equivalent for gstin / cin / enterprisePan).
|
||||
const norm = (s: any): string | null => {
|
||||
const out = (s || '').toString().toUpperCase().replace(/\s+/g, '');
|
||||
return out.length ? out : null;
|
||||
};
|
||||
const panNorm = norm(v.panNumber);
|
||||
const enterprisePanNorm = norm(v.enterprisePan);
|
||||
const gstinNorm = norm(v.gstinNumber);
|
||||
const cinNorm = norm(v.cinNumber);
|
||||
|
||||
return {
|
||||
locale: 'en',
|
||||
@@ -1294,12 +1484,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
kycType: 1,
|
||||
riskCategory: v.riskCategory || 1,
|
||||
// For non-individual: Company PAN is used as panNumber for verification
|
||||
panNumber: this.isNonIndividual ? v.enterprisePan || null : v.hasPan ? v.panNumber || null : null,
|
||||
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
|
||||
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
|
||||
panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null,
|
||||
gstinNumber: v.gstinNumber || null,
|
||||
enterprisePan: v.enterprisePan || null,
|
||||
cinNumber: v.cinNumber || null,
|
||||
gstinNumber: gstinNorm,
|
||||
enterprisePan: enterprisePanNorm,
|
||||
cinNumber: cinNorm,
|
||||
incorporationDate: formatDate(v.incorporationDate),
|
||||
// KYC Document
|
||||
kycDocumentName: v.kycDocumentName || null,
|
||||
|
||||
@@ -104,14 +104,23 @@
|
||||
<div class="addr-field__value">{{ address.countyDistrict }}</div>
|
||||
</div>
|
||||
}
|
||||
@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>
|
||||
<!-- State — always render. The address payload may carry the value
|
||||
under `stateProvinceId` (numeric → resolve via template options),
|
||||
`stateName` / `stateProvinceName` (string), or be missing
|
||||
entirely. Render whatever we can find, otherwise show "—". -->
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">State</div>
|
||||
<div class="addr-field__value">
|
||||
{{
|
||||
(address.stateProvinceId
|
||||
? getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name
|
||||
: null) ||
|
||||
address.stateName ||
|
||||
address.stateProvinceName ||
|
||||
'—'
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">Country</div>
|
||||
<div class="addr-field__value">
|
||||
|
||||
@@ -16,6 +16,9 @@ import { SelectBase } from 'app/shared/form-dialog/formfield/model/select-base';
|
||||
|
||||
/** Custom Components */
|
||||
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
|
||||
import { EditAddressDialogComponent } from './edit-address-dialog/edit-address-dialog.component';
|
||||
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
|
||||
import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component';
|
||||
|
||||
/** Custom Services */
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
@@ -87,29 +90,42 @@ export class AddressTabComponent {
|
||||
* Adds a client address.
|
||||
*/
|
||||
addAddress() {
|
||||
const data = {
|
||||
title:
|
||||
this.translateService.instant('labels.buttons.Add') +
|
||||
' ' +
|
||||
this.translateService.instant('labels.catalogs.Client') +
|
||||
' ' +
|
||||
this.translateService.instant('labels.heading.Address'),
|
||||
formfields: this.getAddressFormFields('add'),
|
||||
layout: { columns: 2, columnWidth: 350 }
|
||||
};
|
||||
const addAddressDialogRef = this.dialog.open(FormDialogComponent, { data });
|
||||
addAddressDialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response.data) {
|
||||
this.clientService
|
||||
.createClientAddress(this.clientId, response.data.value.addressType, response.data.value)
|
||||
.subscribe((res: any) => {
|
||||
const addressData = response.data.value;
|
||||
addressData.addressId = res.resourceId;
|
||||
addressData.addressType = this.getSelectedValue('addressTypeIdOptions', addressData.addressType).name;
|
||||
addressData.isActive = false;
|
||||
this.clientAddressData.push(addressData);
|
||||
});
|
||||
}
|
||||
// Use the Nexora dialog with PIN lookup + "Copy from existing address" picker.
|
||||
const ref = this.dialog.open(EditAddressDialogComponent, {
|
||||
data: {
|
||||
mode: 'add',
|
||||
title:
|
||||
this.translateService.instant('labels.buttons.Add') +
|
||||
' ' +
|
||||
this.translateService.instant('labels.catalogs.Client') +
|
||||
' ' +
|
||||
this.translateService.instant('labels.heading.Address'),
|
||||
address: null,
|
||||
template: this.clientAddressTemplate,
|
||||
fieldConfig: this.clientAddressFieldConfig,
|
||||
existingAddresses: this.clientAddressData || []
|
||||
},
|
||||
panelClass: 'ead-panel',
|
||||
autoFocus: false
|
||||
});
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (!response?.value) return;
|
||||
const payload = { ...response.value };
|
||||
const typeName = this.getSelectedValue('addressTypeIdOptions', payload.addressType)?.name;
|
||||
this.clientService.createClientAddress(this.clientId, payload.addressType, payload).subscribe({
|
||||
next: (res: any) => {
|
||||
payload.addressId = res.resourceId;
|
||||
payload.addressType = typeName;
|
||||
payload.isActive = false;
|
||||
this.clientAddressData.push(payload);
|
||||
this.showSuccess('Address Added', `${typeName || 'Address'} has been added to this client's profile.`);
|
||||
},
|
||||
error: (err: any) => {
|
||||
const apiMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Please try again.';
|
||||
this.showError(`Failed to add address. ${apiMsg}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,31 +135,54 @@ export class AddressTabComponent {
|
||||
* @param {number} index address index
|
||||
*/
|
||||
editAddress(address: any, index: number) {
|
||||
const data = {
|
||||
title:
|
||||
this.translateService.instant('labels.buttons.Edit') +
|
||||
' ' +
|
||||
this.translateService.instant('labels.catalogs.Client') +
|
||||
' ' +
|
||||
this.translateService.instant('labels.heading.Address'),
|
||||
formfields: this.getAddressFormFields('edit', address),
|
||||
layout: { columns: 2, columnWidth: 350, addButtonText: 'Edit' }
|
||||
};
|
||||
const editAddressDialogRef = this.dialog.open(FormDialogComponent, { data });
|
||||
editAddressDialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response.data) {
|
||||
const addressData = response.data.value;
|
||||
addressData.addressId = address.addressId;
|
||||
addressData.isActive = address.isActive;
|
||||
this.clientService
|
||||
.editClientAddress(this.clientId, address.addressTypeId, addressData)
|
||||
.subscribe((res: any) => {
|
||||
addressData.addressTypeId = address.addressTypeId;
|
||||
addressData.addressType = address.addressType;
|
||||
this.clientAddressData[index] = addressData;
|
||||
});
|
||||
}
|
||||
// Open the Nexora edit dialog with PIN lookup (mirrors the stepper).
|
||||
const ref = this.dialog.open(EditAddressDialogComponent, {
|
||||
data: {
|
||||
title:
|
||||
this.translateService.instant('labels.buttons.Edit') +
|
||||
' ' +
|
||||
this.translateService.instant('labels.catalogs.Client') +
|
||||
' ' +
|
||||
this.translateService.instant('labels.heading.Address'),
|
||||
address,
|
||||
template: this.clientAddressTemplate,
|
||||
fieldConfig: this.clientAddressFieldConfig
|
||||
},
|
||||
panelClass: 'ead-panel',
|
||||
autoFocus: false
|
||||
});
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (!response?.value) return;
|
||||
const addressData = { ...response.value };
|
||||
addressData.addressId = address.addressId;
|
||||
addressData.isActive = address.isActive;
|
||||
this.clientService.editClientAddress(this.clientId, address.addressTypeId, addressData).subscribe({
|
||||
next: () => {
|
||||
addressData.addressTypeId = address.addressTypeId;
|
||||
addressData.addressType = address.addressType;
|
||||
this.clientAddressData[index] = addressData;
|
||||
this.showSuccess('Address Updated', `${address.addressType || 'Address'} has been updated successfully.`);
|
||||
},
|
||||
error: (err: any) => {
|
||||
const apiMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Please try again.';
|
||||
this.showError(`Failed to update address. ${apiMsg}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Polished success modal — matches the rest of the Nexora UI. */
|
||||
private showSuccess(title: string, message: string): void {
|
||||
this.dialog.open(SuccessDialogComponent, {
|
||||
data: { title, message, buttonLabel: 'OK' },
|
||||
panelClass: 'sd-panel'
|
||||
});
|
||||
}
|
||||
|
||||
/** Polished error modal — single string body. */
|
||||
private showError(message: string): void {
|
||||
this.dialog.open(ErrorDialogComponent, { data: message });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,7 +336,9 @@ export class AddressTabComponent {
|
||||
this.isFieldEnabled('countyDistrict')
|
||||
? new InputBase({
|
||||
controlName: 'countyDistrict',
|
||||
label: this.translateService.instant('labels.inputs.State / Province'),
|
||||
// Was mistakenly labeled "State / Province" — that label belongs to
|
||||
// the stateProvinceId select above. This control holds the District.
|
||||
label: this.translateService.instant('labels.inputs.County / District'),
|
||||
value: address ? address.countyDistrict : '',
|
||||
type: 'text',
|
||||
order: 11
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
<!--
|
||||
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="ead-root">
|
||||
<div class="ead-head">
|
||||
<div class="ead-head__left">
|
||||
<fa-icon icon="map-marker-alt" class="ead-head__ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="ead-head__title">{{ data.title || (isAdd ? 'Add Client Address' : 'Edit Client Address') }}</h2>
|
||||
<div class="ead-head__sub">
|
||||
@if (isAdd) {
|
||||
Select address type and enter PIN to auto-fill city, district & state.
|
||||
} @else {
|
||||
Update postal details. Enter PIN to auto-fill city, district & state.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ead-x" (click)="cancel()" matTooltip="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="ead-body" [formGroup]="form" (ngSubmit)="submit()">
|
||||
<!-- Address Type + Copy-from (Add mode only) -->
|
||||
@if (isAdd) {
|
||||
<div class="ead-grid">
|
||||
<div class="ead-field">
|
||||
<label>Address Type *</label>
|
||||
<select formControlName="addressType">
|
||||
<option [ngValue]="''">— Select address type —</option>
|
||||
@for (t of addressTypeOptions; track t.id) {
|
||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (form.get('addressType')?.touched && form.get('addressType')?.hasError('required')) {
|
||||
<div class="ead-err">Address type is required.</div>
|
||||
}
|
||||
</div>
|
||||
@if (copySourceOptions.length > 0) {
|
||||
<div class="ead-field">
|
||||
<label>Copy from existing address (optional)</label>
|
||||
<select
|
||||
[(ngModel)]="copyFromAddressId"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(change)="onCopyFromAddress()"
|
||||
>
|
||||
<option [ngValue]="''">— Don't copy —</option>
|
||||
@for (a of copySourceOptions; track a.addressId) {
|
||||
<option [ngValue]="a.addressId">
|
||||
{{ a.addressType || 'Address' }} · {{ a.city || a.countyDistrict || '' }}
|
||||
@if (a.postalCode) {
|
||||
· {{ a.postalCode }}
|
||||
}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- PIN + Street -->
|
||||
<div class="ead-grid">
|
||||
<div class="ead-field">
|
||||
<label>Postal Code *</label>
|
||||
<div class="ead-input-wrap">
|
||||
<fa-icon icon="map-pin" class="ead-input-ic"></fa-icon>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
formControlName="postalCode"
|
||||
placeholder="6-digit PIN"
|
||||
(input)="onPinInput()"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
|
||||
/>
|
||||
@if (pinStatus.loading) {
|
||||
<span class="ead-pin-status loading"
|
||||
><fa-icon icon="spinner" class="fa-spin"></fa-icon> Looking up…</span
|
||||
>
|
||||
} @else if (pinStatus.found) {
|
||||
<span class="ead-pin-status ok"><fa-icon icon="check-circle"></fa-icon> PIN verified</span>
|
||||
} @else if (pinStatus.error) {
|
||||
<span class="ead-pin-status err"
|
||||
><fa-icon icon="exclamation-triangle"></fa-icon> {{ pinStatus.message }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
@if (form.get('postalCode')?.touched && form.get('postalCode')?.hasError('pattern')) {
|
||||
<div class="ead-err">Enter a valid 6-digit PIN.</div>
|
||||
}
|
||||
</div>
|
||||
@if (isFieldEnabled('street')) {
|
||||
<div class="ead-field">
|
||||
<label>Street</label>
|
||||
<input type="text" formControlName="street" placeholder="Street, Road, Lane" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Address lines -->
|
||||
<div class="ead-grid">
|
||||
@if (isFieldEnabled('addressLine1')) {
|
||||
<div class="ead-field">
|
||||
<label>Address Line 1</label>
|
||||
<input type="text" formControlName="addressLine1" placeholder="House / Building No." />
|
||||
</div>
|
||||
}
|
||||
@if (isFieldEnabled('addressLine2')) {
|
||||
<div class="ead-field">
|
||||
<label>Address Line 2</label>
|
||||
<input type="text" formControlName="addressLine2" placeholder="Apartment / Block" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="ead-grid">
|
||||
@if (isFieldEnabled('addressLine3')) {
|
||||
<div class="ead-field">
|
||||
<label>Address Line 3</label>
|
||||
<input type="text" formControlName="addressLine3" placeholder="Locality / Sector" />
|
||||
</div>
|
||||
}
|
||||
@if (isFieldEnabled('townVillage')) {
|
||||
<div class="ead-field">
|
||||
<label>Town / Village (Locality)</label>
|
||||
<select formControlName="townVillage">
|
||||
<option [ngValue]="''">— Select —</option>
|
||||
@for (loc of effectiveLocalityOptions; track loc) {
|
||||
<option [ngValue]="loc">{{ loc }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- City / District / State -->
|
||||
<div class="ead-grid ead-grid--three">
|
||||
@if (isFieldEnabled('city')) {
|
||||
<div class="ead-field">
|
||||
<label>City</label>
|
||||
<input type="text" formControlName="city" placeholder="City" />
|
||||
</div>
|
||||
}
|
||||
@if (isFieldEnabled('countyDistrict')) {
|
||||
<div class="ead-field">
|
||||
<label>County / District</label>
|
||||
<input type="text" formControlName="countyDistrict" placeholder="District" />
|
||||
</div>
|
||||
}
|
||||
@if (isFieldEnabled('stateProvinceId')) {
|
||||
<div class="ead-field">
|
||||
<label>State / Province</label>
|
||||
<!-- Plain text — populated by the PIN service, mirrors create wizard.
|
||||
On submit we resolve to the matching stateProvinceId for the API. -->
|
||||
<input type="text" formControlName="state" placeholder="State / Province" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Country -->
|
||||
@if (isFieldEnabled('countryId')) {
|
||||
<div class="ead-grid">
|
||||
<div class="ead-field">
|
||||
<label>Country</label>
|
||||
<input type="text" formControlName="country" placeholder="Country" />
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="ead-actions">
|
||||
<button type="button" class="ead-btn ghost" (click)="cancel()">Cancel</button>
|
||||
<button type="submit" class="ead-btn primary" [disabled]="form.invalid">
|
||||
<fa-icon [icon]="isAdd ? 'plus' : 'check'"></fa-icon>
|
||||
{{ isAdd ? 'Add Address' : 'Save Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$navy: #0d1b2a;
|
||||
$blue: #1e88e5;
|
||||
$blue-d: #1e40af;
|
||||
$green: #10b981;
|
||||
$red: #ef4444;
|
||||
$amber: #f59e0b;
|
||||
$purple: #7c3aed;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ead-root {
|
||||
background: #fff;
|
||||
width: 760px;
|
||||
max-width: 96vw;
|
||||
max-height: 92vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ead-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__ic {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, $blue 0%, $purple 100%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 6px -2px rgb(29 78 216 / 35%);
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin-top: 2px;
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.ead-x {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
color: $red;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
.ead-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ead-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 14px;
|
||||
|
||||
&--three {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.ead-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: $navy;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.12s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $blue;
|
||||
box-shadow: 0 0 0 3px rgb(30 136 229 / 14%);
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, #64748b 50%), linear-gradient(135deg, #64748b 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 14px) 50%,
|
||||
calc(100% - 9px) 50%;
|
||||
background-size:
|
||||
5px 5px,
|
||||
5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.ead-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding-left: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.ead-input-ic {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: $blue;
|
||||
font-size: 13px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ead-pin-status {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
|
||||
fa-icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&.ok {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
background: #eff6ff;
|
||||
color: $blue-d;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
&.err {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
.ead-err {
|
||||
font-size: 10.5px;
|
||||
color: $red;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ead-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding-top: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ead-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 0 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid transparent;
|
||||
|
||||
fa-icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&.ghost {
|
||||
background: #fff;
|
||||
border-color: #d0d5dd;
|
||||
color: #475569;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #94a3b8;
|
||||
color: $navy;
|
||||
}
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
|
||||
border-color: $blue-d;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px -2px rgb(30 64 175 / 35%);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 12px -3px rgb(30 64 175 / 45%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.ead-grid,
|
||||
.ead-grid--three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* 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 { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
export interface EditAddressDialogData {
|
||||
/** 'add' shows Address Type select + Copy-from picker; 'edit' hides them. */
|
||||
mode?: 'add' | 'edit';
|
||||
title: string;
|
||||
/** Existing address payload (edit) or null (add). */
|
||||
address: any;
|
||||
template: {
|
||||
addressTypeIdOptions?: { id: number; name: string }[];
|
||||
stateProvinceIdOptions?: { id: number; name: string }[];
|
||||
countryIdOptions?: { id: number; name: string }[];
|
||||
[k: string]: any;
|
||||
};
|
||||
/** Field-config rows from `clientAddressFieldConfig`; used to honour
|
||||
* the org's address-field visibility / enabling settings. */
|
||||
fieldConfig: { field: string; isEnabled: boolean }[];
|
||||
/** All currently-saved addresses for this client. In 'add' mode the user can
|
||||
* pick one as the source for "Copy from existing address". */
|
||||
existingAddresses?: any[];
|
||||
}
|
||||
|
||||
interface PinPostOffice {
|
||||
Name: string;
|
||||
Block?: string;
|
||||
Division?: string;
|
||||
District?: string;
|
||||
State?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit Client Address dialog (post-create, in client view).
|
||||
*
|
||||
* Mirrors the create-stepper's address experience:
|
||||
* • Postal Code field auto-triggers a PIN lookup
|
||||
* • Locality / Area becomes a dropdown of post offices for that PIN
|
||||
* • City / District / State auto-fill from the lookup (only when empty)
|
||||
* • State resolves to the matching `stateProvinceId` from the template
|
||||
*
|
||||
* On submit, returns the form value via `dialogRef.close({ value })`.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-edit-address-dialog',
|
||||
templateUrl: './edit-address-dialog.component.html',
|
||||
styleUrls: ['./edit-address-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class EditAddressDialogComponent implements OnInit {
|
||||
private fb = inject(UntypedFormBuilder);
|
||||
private http = inject(HttpClient);
|
||||
public dialogRef = inject(MatDialogRef<EditAddressDialogComponent>);
|
||||
public data = inject<EditAddressDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
form!: UntypedFormGroup;
|
||||
localityOptions: string[] = [];
|
||||
pinStatus: { loading: boolean; found: boolean; error: boolean; message: string } = {
|
||||
loading: false,
|
||||
found: false,
|
||||
error: false,
|
||||
message: ''
|
||||
};
|
||||
|
||||
/** Currently-selected source for "Copy from existing address" (id). */
|
||||
copyFromAddressId: number | '' = '';
|
||||
|
||||
private pinTimer: any = null;
|
||||
private pinCache: Record<string, PinPostOffice[]> = {};
|
||||
|
||||
get isAdd(): boolean {
|
||||
return this.data.mode === 'add';
|
||||
}
|
||||
|
||||
get stateOptions() {
|
||||
return this.data.template?.stateProvinceIdOptions || [];
|
||||
}
|
||||
get countryOptions() {
|
||||
return this.data.template?.countryIdOptions || [];
|
||||
}
|
||||
get addressTypeOptions() {
|
||||
return this.data.template?.addressTypeIdOptions || [];
|
||||
}
|
||||
/** Existing addresses that can be a "Copy from" source (excludes empties). */
|
||||
get copySourceOptions(): any[] {
|
||||
return (this.data.existingAddresses || []).filter((a) => a && (a.addressLine1 || a.postalCode));
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const a = this.data.address || {};
|
||||
// Pre-resolve the saved stateProvinceId (numeric, from Fineract) to its
|
||||
// display name so the text field shows something meaningful on load.
|
||||
const savedStateName =
|
||||
a.stateName ||
|
||||
a.stateProvinceName ||
|
||||
(a.stateProvinceId ? (this.stateOptions.find((s) => s.id === a.stateProvinceId)?.name ?? '') : '');
|
||||
const savedCountryName =
|
||||
a.countryName || (a.countryId ? (this.countryOptions.find((c) => c.id === a.countryId)?.name ?? '') : '');
|
||||
|
||||
this.form = this.fb.group({
|
||||
...(this.isAdd
|
||||
? {
|
||||
addressType: [
|
||||
a.addressType || '',
|
||||
Validators.required
|
||||
]
|
||||
}
|
||||
: {}),
|
||||
postalCode: [
|
||||
a.postalCode || '',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.pattern(/^\d{6}$/)
|
||||
]
|
||||
],
|
||||
street: [a.street || ''],
|
||||
addressLine1: [a.addressLine1 || ''],
|
||||
addressLine2: [a.addressLine2 || ''],
|
||||
addressLine3: [a.addressLine3 || ''],
|
||||
townVillage: [a.townVillage || ''],
|
||||
city: [a.city || ''],
|
||||
countyDistrict: [a.countyDistrict || ''],
|
||||
// State + Country are plain TEXT now (matching the create-stepper). On
|
||||
// submit we resolve them to the matching template id when possible —
|
||||
// see `submit()`. We also keep the raw original ids around so we don't
|
||||
// lose them if the user doesn't touch the fields.
|
||||
state: [savedStateName],
|
||||
country: [savedCountryName || 'India'],
|
||||
// Hidden originals — used on submit when text wasn't changed.
|
||||
_origStateProvinceId: [a.stateProvinceId || ''],
|
||||
_origCountryId: [a.countryId || '']
|
||||
});
|
||||
|
||||
// If we already have a PIN on load, re-run the lookup so the locality
|
||||
// dropdown is populated and "PIN verified" badge shows in edit mode.
|
||||
if ((a.postalCode || '').toString().length === 6) {
|
||||
this.runPinLookup(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy address fields from an existing address (selected via the dropdown).
|
||||
* The Address Type is preserved — the user's current selection wins, since
|
||||
* the whole point of "Add" is creating a NEW address type.
|
||||
*/
|
||||
onCopyFromAddress(): void {
|
||||
const src = this.copySourceOptions.find((a) => String(a.addressId) === String(this.copyFromAddressId));
|
||||
if (!src) return;
|
||||
const srcStateName =
|
||||
src.stateName ||
|
||||
src.stateProvinceName ||
|
||||
(src.stateProvinceId ? (this.stateOptions.find((s) => s.id === src.stateProvinceId)?.name ?? '') : '');
|
||||
const srcCountryName =
|
||||
src.countryName ||
|
||||
(src.countryId ? (this.countryOptions.find((c) => c.id === src.countryId)?.name ?? 'India') : 'India');
|
||||
|
||||
const patch: Record<string, any> = {
|
||||
postalCode: src.postalCode || '',
|
||||
street: src.street || '',
|
||||
addressLine1: src.addressLine1 || '',
|
||||
addressLine2: src.addressLine2 || '',
|
||||
addressLine3: src.addressLine3 || '',
|
||||
townVillage: src.townVillage || '',
|
||||
city: src.city || '',
|
||||
countyDistrict: src.countyDistrict || '',
|
||||
state: srcStateName,
|
||||
country: srcCountryName,
|
||||
_origStateProvinceId: src.stateProvinceId || '',
|
||||
_origCountryId: src.countryId || ''
|
||||
};
|
||||
this.form.patchValue(patch);
|
||||
// Re-run the PIN lookup so the locality dropdown options + "PIN verified"
|
||||
// chip light up for the copied PIN.
|
||||
if ((patch['postalCode'] || '').toString().length === 6) {
|
||||
this.runPinLookup(true);
|
||||
}
|
||||
}
|
||||
|
||||
isFieldEnabled(field: string): boolean {
|
||||
const cfg = (this.data.fieldConfig || []).find((c) => c.field === field);
|
||||
return cfg ? !!cfg.isEnabled : true;
|
||||
}
|
||||
|
||||
/** PIN input: debounce + lookup. */
|
||||
onPinInput(): void {
|
||||
clearTimeout(this.pinTimer);
|
||||
const pin = (this.form.get('postalCode')?.value || '').toString().replace(/\D/g, '');
|
||||
this.form.get('postalCode')?.setValue(pin, { emitEvent: false });
|
||||
if (pin.length < 6) {
|
||||
this.pinStatus = { loading: false, found: false, error: false, message: '' };
|
||||
this.localityOptions = [];
|
||||
return;
|
||||
}
|
||||
this.pinTimer = setTimeout(() => this.runPinLookup(false), 550);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the post-office list for the current PIN and apply it.
|
||||
* @param preserve when true, don't overwrite existing values for
|
||||
* locality / city / district / state (used on load).
|
||||
*/
|
||||
private runPinLookup(preserve: boolean): void {
|
||||
const pin = (this.form.get('postalCode')?.value || '').toString();
|
||||
if (pin.length !== 6) return;
|
||||
if (this.pinCache[pin]) {
|
||||
this.applyPinData(this.pinCache[pin], preserve);
|
||||
this.pinStatus = { loading: false, found: true, error: false, message: 'Verified' };
|
||||
return;
|
||||
}
|
||||
this.pinStatus = { loading: true, found: false, error: false, message: 'Looking up...' };
|
||||
this.http.get<any[]>(`https://api.postalpincode.in/pincode/${pin}`).subscribe({
|
||||
next: (data: any) => {
|
||||
if (data?.[0]?.Status === 'Success') {
|
||||
this.pinCache[pin] = data[0].PostOffice;
|
||||
this.applyPinData(data[0].PostOffice, preserve);
|
||||
this.pinStatus = { loading: false, found: true, error: false, message: 'Verified' };
|
||||
} else {
|
||||
this.localityOptions = [];
|
||||
this.pinStatus = { loading: false, found: false, error: true, message: 'Invalid PIN' };
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.pinStatus = { loading: false, found: false, error: true, message: 'Network error' };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private applyPinData(offices: PinPostOffice[], preserve: boolean): void {
|
||||
const seen = new Set<string>();
|
||||
this.localityOptions = (offices || [])
|
||||
.filter((o) => {
|
||||
if (!o?.Name || seen.has(o.Name)) return false;
|
||||
seen.add(o.Name);
|
||||
return true;
|
||||
})
|
||||
.map((o) => o.Name);
|
||||
|
||||
const first = offices?.[0];
|
||||
if (!first) return;
|
||||
|
||||
const patch: Record<string, any> = {};
|
||||
|
||||
// Town / Village — pick the first locality from the lookup. Skip only when
|
||||
// there's already a saved value AND we're in preserve mode.
|
||||
if (!preserve || !this.form.get('townVillage')?.value) {
|
||||
if (this.localityOptions[0]) patch['townVillage'] = this.localityOptions[0];
|
||||
}
|
||||
|
||||
// City — Block / Division / District fallback chain
|
||||
if (!preserve || !this.form.get('city')?.value) {
|
||||
const cityValue = first.Block || first.Division || first.District || '';
|
||||
if (cityValue) patch['city'] = cityValue;
|
||||
}
|
||||
|
||||
// District
|
||||
if (!preserve || !this.form.get('countyDistrict')?.value) {
|
||||
if (first.District) patch['countyDistrict'] = first.District;
|
||||
}
|
||||
|
||||
// State — stored as plain TEXT in the form (same as the create wizard).
|
||||
// We display whatever the PIN service returns ("Madhya Pradesh", "Kerala",
|
||||
// etc.) without forcing it through the small Fineract dropdown which
|
||||
// sometimes only carries a handful of states.
|
||||
if (!preserve || !this.form.get('state')?.value) {
|
||||
if (first.State) patch['state'] = first.State;
|
||||
}
|
||||
|
||||
// Country — postalpincode.in only serves India. Default to "India" text.
|
||||
if (!preserve || !this.form.get('country')?.value) {
|
||||
patch['country'] = 'India';
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
this.form.patchValue(patch);
|
||||
}
|
||||
}
|
||||
|
||||
/** Lowercase, collapse internal whitespace, trim — for fuzzy name matching. */
|
||||
private normalize(s: string | undefined | null): string {
|
||||
return (s || '').toString().toLowerCase().replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** Preserve the saved locality even if it's not in the lookup results. */
|
||||
get effectiveLocalityOptions(): string[] {
|
||||
const saved = this.form?.get('townVillage')?.value;
|
||||
if (saved && !this.localityOptions.includes(saved)) {
|
||||
return [
|
||||
saved,
|
||||
...this.localityOptions
|
||||
];
|
||||
}
|
||||
return this.localityOptions;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the API payload — Fineract expects `stateProvinceId` (numeric id)
|
||||
// and `countryId`, not the text names we've been displaying. Resolve via
|
||||
// the template options; fall back to the original ids if the user didn't
|
||||
// change the field.
|
||||
const raw = this.form.value;
|
||||
const payload: Record<string, any> = {
|
||||
postalCode: raw.postalCode,
|
||||
street: raw.street,
|
||||
addressLine1: raw.addressLine1,
|
||||
addressLine2: raw.addressLine2,
|
||||
addressLine3: raw.addressLine3,
|
||||
townVillage: raw.townVillage,
|
||||
city: raw.city,
|
||||
countyDistrict: raw.countyDistrict
|
||||
};
|
||||
if (raw.addressType) payload['addressType'] = raw.addressType;
|
||||
|
||||
const stateMatch = raw.state
|
||||
? this.stateOptions.find((s) => this.normalize(s.name) === this.normalize(raw.state))
|
||||
: null;
|
||||
payload['stateProvinceId'] = stateMatch ? stateMatch.id : raw._origStateProvinceId || '';
|
||||
|
||||
const countryMatch = raw.country
|
||||
? this.countryOptions.find((c) => this.normalize(c.name) === this.normalize(raw.country))
|
||||
: null;
|
||||
payload['countryId'] = countryMatch ? countryMatch.id : raw._origCountryId || '';
|
||||
|
||||
this.dialogRef.close({ value: payload });
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
+17
-2
@@ -10,14 +10,29 @@
|
||||
<div>
|
||||
<form #formRef="ngForm" [formGroup]="noteForm">
|
||||
<mat-form-field>
|
||||
<textarea formControlName="note" matInput placeholder="{{ 'labels.text.Write a note' | translate }}"></textarea>
|
||||
<textarea
|
||||
formControlName="note"
|
||||
matInput
|
||||
placeholder="{{ 'labels.text.Write a note' | translate }}"
|
||||
matTooltip="At least two words required — e.g. 'Hello User', 'Updated mobile', 'Re-KYC done'."
|
||||
matTooltipPosition="above"
|
||||
matTooltipShowDelay="300"
|
||||
(keydown)="onNoteKeyDown($event)"
|
||||
(input)="onNoteInput($event)"
|
||||
></textarea>
|
||||
@if (noteForm.controls['note'].touched && noteForm.controls['note'].hasError('required')) {
|
||||
<mat-error>Note cannot be empty.</mat-error>
|
||||
}
|
||||
@if (noteForm.controls['note'].touched && noteForm.controls['note'].hasError('minWords')) {
|
||||
<mat-error>Note must contain at least 2 words (whitespace doesn't count).</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
[disabled]="noteForm.pristine"
|
||||
[disabled]="noteForm.pristine || noteForm.invalid"
|
||||
[mat-dialog-close]="{ editForm: noteForm }"
|
||||
>
|
||||
{{ 'labels.buttons.Confirm' | translate }}
|
||||
|
||||
+32
-2
@@ -15,8 +15,10 @@ import {
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { minWordsValidator } from 'app/shared/validators/min-words.validator';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-edit-notes-dialog',
|
||||
@@ -26,7 +28,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogTitle,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
MatDialogClose,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class EditNotesDialogComponent implements OnInit {
|
||||
@@ -42,10 +45,37 @@ export class EditNotesDialogComponent implements OnInit {
|
||||
|
||||
createNoteForm() {
|
||||
this.noteForm = this.formBuilder.group({
|
||||
// Same rule as the entity-notes Add form — reject whitespace / single-word.
|
||||
note: [
|
||||
this.data.noteContent,
|
||||
Validators.required
|
||||
[
|
||||
Validators.required,
|
||||
minWordsValidator(2)
|
||||
]
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/** Block whitespace at position 0 of an otherwise-empty textarea. */
|
||||
onNoteKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key !== ' ' && event.key !== 'Tab' && event.key !== 'Enter') return;
|
||||
const el = event.target as HTMLTextAreaElement;
|
||||
if (el.selectionStart === 0 && el.value.trim() === '') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip leading whitespace + collapse internal runs of spaces/tabs. */
|
||||
onNoteInput(event: Event): void {
|
||||
const el = event.target as HTMLTextAreaElement;
|
||||
const before = el.value;
|
||||
const after = before.replace(/^[ \t]+/, '').replace(/[ \t]{2,}/g, ' ');
|
||||
if (before !== after) {
|
||||
const cursorDelta = before.length - after.length;
|
||||
const pos = Math.max(0, (el.selectionStart ?? after.length) - cursorDelta);
|
||||
el.value = after;
|
||||
this.noteForm.get('note')!.setValue(after, { emitEvent: false });
|
||||
el.setSelectionRange(pos, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* Identities Tab Component
|
||||
@@ -206,15 +208,57 @@ export class IdentitiesTabComponent implements OnDestroy {
|
||||
/**
|
||||
* Create one identifier from a KYC section payload, then upload each file
|
||||
* as a document attached to that identifier.
|
||||
*
|
||||
* `section.proofId` may arrive either as a numeric CodeValue id (when sourced from
|
||||
* /kyc/proof-types — the address-proof chip-picker is built from this list and works
|
||||
* straight away) or as a string CODE like "AADHAAR" / "PAN" (when sourced from the
|
||||
* KYC step's constitution-driven document list — that list isn't backed by
|
||||
* CodeValues). The backend's ClientIdentifierRequest.documentTypeId is a Long, so a
|
||||
* string proofId triggers Spring's HttpMessageNotReadableException → generic 400
|
||||
* "String instead of Number". Route through resolveDocumentTypeId so we always post
|
||||
* a numeric documentTypeId.
|
||||
*/
|
||||
private createIdentifierWithDocs(section: any, description: string, kind: 'id' | 'address'): void {
|
||||
const identifierData = {
|
||||
documentTypeId: section.proofId,
|
||||
status: 'Active',
|
||||
documentKey: kind === 'id' ? section.documentKey : `${section.proof?.name || 'Address Proof'} - ${this.clientId}`,
|
||||
description: kind === 'address' && section.address ? section.address : description
|
||||
};
|
||||
const proofName = section.proof?.name || (kind === 'address' ? 'Address Proof' : 'KYC Document');
|
||||
this.resolveDocumentTypeId(section.proofId, proofName).subscribe((documentTypeId: number | null) => {
|
||||
if (documentTypeId == null) {
|
||||
// No CodeValue match (e.g. a constitution-specific non-individual doc like
|
||||
// "Trust Deed" that hasn't been seeded). Skip silently rather than firing the
|
||||
// 400 — the parent already captured the file separately if needed.
|
||||
return;
|
||||
}
|
||||
const identifierData = {
|
||||
documentTypeId,
|
||||
status: 'Active',
|
||||
documentKey: kind === 'id' ? section.documentKey : `${proofName} - ${this.clientId}`,
|
||||
description: kind === 'address' && section.address ? section.address : description
|
||||
};
|
||||
|
||||
this.postIdentifierAndDocs(identifierData, section, proofName, description);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass-through when proofId is already numeric (address proof, identities tab address
|
||||
* picker). Otherwise resolve the string code/name against the ID-proof CodeValues so
|
||||
* the backend gets a proper Long for documentTypeId.
|
||||
*/
|
||||
private resolveDocumentTypeId(proofId: any, proofName: string): Observable<number | null> {
|
||||
if (typeof proofId === 'number' && Number.isFinite(proofId)) {
|
||||
return of(proofId);
|
||||
}
|
||||
return this.clientService.getKycProofTypes('id').pipe(
|
||||
map((res: any) => {
|
||||
const list: any[] = res?.idProofs || [];
|
||||
const target = (proofName || '').trim().toLowerCase();
|
||||
const match = list.find((p: any) => (p?.name || '').trim().toLowerCase() === target);
|
||||
return (match?.id ?? null) as number | null;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Extracted post-and-upload-files step so the resolver wrapper stays readable. */
|
||||
private postIdentifierAndDocs(identifierData: any, section: any, proofName: string, description: string): void {
|
||||
this.clientService.addClientIdentifier(this.clientId, identifierData).subscribe({
|
||||
next: (res: any) => {
|
||||
const newIdentifierId = res.resourceId;
|
||||
|
||||
@@ -203,27 +203,54 @@
|
||||
<span class="kv-table__col kv-table__col--action"></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- PAN-Aadhaar Link Row -->
|
||||
<div class="kv-table__row kv-table__row--link">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">link</mat-icon> PAN–Aadhaar Link
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num">Sec 139AA</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Linked</span>
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
|
||||
<span class="kv-status kv-status--fail"><mat-icon>cancel</mat-icon> Not Linked</span>
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
|
||||
<span class="kv-status kv-status--info"><mat-icon>info</mat-icon> Exempt</span>
|
||||
} @else {
|
||||
<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">—</span>
|
||||
<span class="kv-table__col kv-table__col--action"></span>
|
||||
<!-- ─── PAN ↔ Aadhaar Link — premium status card (replaces the legacy
|
||||
table row). Dark gradient base with state-driven accent so the
|
||||
linking state is the first thing a reviewer sees. -->
|
||||
<div
|
||||
class="pal-card"
|
||||
[class.pal-card--ok]="kycData?.panAadhaarSeedingStatus === 'Y'"
|
||||
[class.pal-card--fail]="kycData?.panAadhaarSeedingStatus === 'N'"
|
||||
[class.pal-card--info]="kycData?.panAadhaarSeedingStatus === 'E'"
|
||||
[class.pal-card--pending]="
|
||||
kycData?.panAadhaarSeedingStatus !== 'Y' &&
|
||||
kycData?.panAadhaarSeedingStatus !== 'N' &&
|
||||
kycData?.panAadhaarSeedingStatus !== 'E'
|
||||
"
|
||||
>
|
||||
<div class="pal-card__left">
|
||||
<div class="pal-card__icon">
|
||||
<mat-icon>credit_card</mat-icon>
|
||||
</div>
|
||||
<div class="pal-card__txt">
|
||||
<div class="pal-card__small">Verification Status</div>
|
||||
<div class="pal-card__title">PAN ↔ Aadhaar</div>
|
||||
<div class="pal-card__status">
|
||||
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
||||
<mat-icon>check_circle</mat-icon> Linked (Seeded)
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
|
||||
<mat-icon>cancel</mat-icon> Not Linked
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
|
||||
<mat-icon>info</mat-icon> Exempt
|
||||
} @else {
|
||||
<mat-icon>radio_button_unchecked</mat-icon> Pending
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pal-card__badge">
|
||||
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
||||
<mat-icon>check</mat-icon>
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
|
||||
<mat-icon>close</mat-icon>
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
|
||||
<mat-icon>info</mat-icon>
|
||||
} @else {
|
||||
<mat-icon>schedule</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="pal-card__hint">As per Income Tax Portal — Section 139AA</div>
|
||||
</div>
|
||||
|
||||
<!-- Aadhaar OTP inline (shown below table when re-verifying) -->
|
||||
|
||||
@@ -989,3 +989,276 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
PAN ↔ Aadhaar premium status card
|
||||
─── Replaces the legacy table-row treatment. Dark gradient with
|
||||
offset glow circles, large gradient icon, and a state-driven
|
||||
accent (green / red / blue / amber).
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.pal-card {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
padding: 14px 18px 30px;
|
||||
border-radius: 18px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #111827, #0f172a);
|
||||
border: 1px solid rgb(255 255 255 / 8%);
|
||||
box-shadow:
|
||||
0 12px 32px rgb(0 0 0 / 35%),
|
||||
inset 0 1px 1px rgb(255 255 255 / 5%);
|
||||
font-family: Inter, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
/* Decorative glows — colors swap with the state variant */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
top: -110px;
|
||||
right: -70px;
|
||||
opacity: 0.18;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
bottom: -70px;
|
||||
left: -50px;
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(145deg, #2563eb, #06b6d4);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
position: relative;
|
||||
box-shadow: 0 10px 20px rgb(37 99 235 / 35%);
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgb(255 255 255 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
&__txt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__small {
|
||||
font-size: 10.5px;
|
||||
color: #94a3b8;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 4px 11px;
|
||||
border-radius: 50px;
|
||||
background: rgb(34 197 94 / 12%);
|
||||
border: 1px solid rgb(74 222 128 / 25%);
|
||||
color: #4ade80;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(5px);
|
||||
width: fit-content;
|
||||
|
||||
mat-icon {
|
||||
font-size: 13px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
z-index: 2;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 18px rgb(34 197 94 / 45%);
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 16px;
|
||||
font-size: 10px;
|
||||
color: rgb(255 255 255 / 35%);
|
||||
font-style: italic;
|
||||
letter-spacing: 0.02em;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ─── State variants ─── */
|
||||
&--ok {
|
||||
&::before {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
&--fail {
|
||||
border-color: rgb(239 68 68 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #ef4444;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #f87171;
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(239 68 68 / 14%);
|
||||
border-color: rgb(248 113 113 / 32%);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
||||
box-shadow: 0 0 18px rgb(239 68 68 / 45%);
|
||||
}
|
||||
|
||||
.pal-card__icon {
|
||||
background: linear-gradient(145deg, #ef4444, #f97316);
|
||||
box-shadow: 0 10px 20px rgb(239 68 68 / 35%);
|
||||
}
|
||||
}
|
||||
|
||||
&--info {
|
||||
border-color: rgb(59 130 246 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #3b82f6;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #60a5fa;
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(59 130 246 / 14%);
|
||||
border-color: rgb(96 165 250 / 32%);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
box-shadow: 0 0 18px rgb(59 130 246 / 45%);
|
||||
}
|
||||
}
|
||||
|
||||
&--pending {
|
||||
border-color: rgb(245 158 11 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #f59e0b;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #fbbf24;
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(245 158 11 / 14%);
|
||||
border-color: rgb(251 191 36 / 32%);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #f59e0b, #b45309);
|
||||
box-shadow: 0 0 18px rgb(245 158 11 / 45%);
|
||||
}
|
||||
|
||||
.pal-card__icon {
|
||||
background: linear-gradient(145deg, #f59e0b, #d97706);
|
||||
box-shadow: 0 10px 20px rgb(245 158 11 / 35%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 540px) {
|
||||
.pal-card {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding-bottom: 36px;
|
||||
|
||||
&__hint {
|
||||
position: static;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +284,13 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selfSelectError) {
|
||||
<div class="search-self-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
{{ selfSelectError }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedClient) {
|
||||
<!-- Linked Client Card -->
|
||||
<div class="nom-linked-card">
|
||||
@@ -431,6 +438,12 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (guardianSelfSelectError) {
|
||||
<div class="search-self-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
{{ guardianSelfSelectError }}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<span class="nom-guardian-selected"
|
||||
>{{ selectedGuardian.displayName }}
|
||||
|
||||
@@ -516,6 +516,27 @@ $primary-light: #e8f0fe;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Shown when the user tries to pick themselves as nominee / guardian. */
|
||||
.search-self-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
padding: 6px 10px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
color: #b91c1c;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Linked card */
|
||||
.nom-linked-card {
|
||||
display: flex;
|
||||
|
||||
@@ -295,9 +295,11 @@ export class NomineeTabComponent implements OnInit {
|
||||
this.searching = true;
|
||||
this.searchTimer = setTimeout(() => {
|
||||
// Nominee picker (post-create tab): nominees must be natural persons.
|
||||
// The host client is excluded from results so they can't be picked as
|
||||
// their own nominee.
|
||||
this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
|
||||
next: (data: any) => {
|
||||
this.searchResults = data?.content || [];
|
||||
this.searchResults = (data?.content || []).filter((c: any) => !this.isSelfClient(c?.id));
|
||||
this.searching = false;
|
||||
this.clientsService.enrichSearchResults(this.searchResults);
|
||||
},
|
||||
@@ -316,7 +318,21 @@ export class NomineeTabComponent implements OnInit {
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/** Self-check: prevent a client from being added as their own nominee/guardian. */
|
||||
isSelfClient(clientId: number | string | null | undefined): boolean {
|
||||
if (this.clientId == null || clientId == null) return false;
|
||||
return String(this.clientId) === String(clientId);
|
||||
}
|
||||
|
||||
/** Inline error shown above the picker when the user tries to pick themselves. */
|
||||
selfSelectError = '';
|
||||
|
||||
selectClient(client: any): void {
|
||||
if (this.isSelfClient(client?.id)) {
|
||||
this.selfSelectError = `You can't link this client as their own nominee. Please pick a different person.`;
|
||||
return;
|
||||
}
|
||||
this.selfSelectError = '';
|
||||
this.selectedClient = client;
|
||||
this.searchResults = [];
|
||||
this.searchText = '';
|
||||
@@ -414,9 +430,10 @@ export class NomineeTabComponent implements OnInit {
|
||||
this.guardianSearching = true;
|
||||
this.guardianSearchTimer = setTimeout(() => {
|
||||
// Guardian picker (post-create tab): guardians must be natural persons.
|
||||
// The host client is filtered out so they can't be picked as their own guardian.
|
||||
this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
|
||||
next: (data: any) => {
|
||||
this.guardianSearchResults = data?.content || [];
|
||||
this.guardianSearchResults = (data?.content || []).filter((c: any) => !this.isSelfClient(c?.id));
|
||||
this.guardianSearching = false;
|
||||
this.clientsService.enrichSearchResults(this.guardianSearchResults);
|
||||
},
|
||||
@@ -428,7 +445,15 @@ export class NomineeTabComponent implements OnInit {
|
||||
}, 400);
|
||||
}
|
||||
|
||||
/** Inline error shown above the guardian picker when self-select is attempted. */
|
||||
guardianSelfSelectError = '';
|
||||
|
||||
selectGuardian(client: any): void {
|
||||
if (this.isSelfClient(client?.id)) {
|
||||
this.guardianSelfSelectError = `You can't link this client as their own guardian. Please pick a different person.`;
|
||||
return;
|
||||
}
|
||||
this.guardianSelfSelectError = '';
|
||||
this.selectedGuardian = client;
|
||||
this.guardianSearchResults = [];
|
||||
this.guardianSearchText = '';
|
||||
|
||||
@@ -474,6 +474,26 @@ export class ClientsService {
|
||||
return this.http.get(`/client/${clientId}/addresses`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents attached to an m_client_address row. The generic document API takes
|
||||
* `client_addresses` as the parent-entity type — see DocumentApiResource.
|
||||
* Used by the address-step component to render a thumbnail of the proof file the user
|
||||
* previously uploaded so it isn't invisible on the edit screen.
|
||||
*/
|
||||
getClientAddressDocuments(clientAddressId: string) {
|
||||
return this.http.get(`/client_addresses/${clientAddressId}/documents`);
|
||||
}
|
||||
|
||||
downloadClientAddressDocument(clientAddressId: string, documentId: string) {
|
||||
return this.http.get(`/client_addresses/${clientAddressId}/documents/${documentId}/attachment`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
}
|
||||
|
||||
uploadClientAddressDocument(clientAddressId: string, documentData: any) {
|
||||
return this.http.post(`/client_addresses/${clientAddressId}/documents`, documentData);
|
||||
}
|
||||
|
||||
getClientAddressTemplate() {
|
||||
return this.http.get(`/client/addresses/template`);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import { Injectable, inject } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot } from '@angular/router';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { Observable, forkJoin, from } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../clients.service';
|
||||
@@ -25,25 +25,24 @@ export class ClientIdentitiesResolver {
|
||||
private clientsService = inject(ClientsService);
|
||||
|
||||
/**
|
||||
* Returns the Client Identities data.
|
||||
* @returns {Observable<any>}
|
||||
* Returns the Client Identities data with each identifier's attached documents
|
||||
* already populated. We previously fired the per-identifier document fetches inside a
|
||||
* non-blocking `subscribe`, so the resolver returned BEFORE the documents arrived and
|
||||
* the view rendered with empty `documents` arrays.
|
||||
*/
|
||||
resolve(route: ActivatedRouteSnapshot): Observable<any> {
|
||||
const clientId = route.parent.paramMap.get('clientId');
|
||||
let identitiesData: any;
|
||||
return this.clientsService.getClientIdentifiers(clientId).pipe(
|
||||
map((identities: any) => {
|
||||
identitiesData = identities;
|
||||
const docObservable: Observable<any>[] = [];
|
||||
identities.forEach((identity: any) => {
|
||||
docObservable.push(this.clientsService.getClientIdentificationDocuments(identity.id));
|
||||
});
|
||||
forkJoin(docObservable).subscribe((documents) => {
|
||||
documents.forEach((document, index) => {
|
||||
identitiesData[index].documents = document;
|
||||
});
|
||||
});
|
||||
return identitiesData;
|
||||
switchMap((identities: any[]) => {
|
||||
if (!identities || identities.length === 0) {
|
||||
return of(identities || []);
|
||||
}
|
||||
const enriched$ = identities.map((identity: any) =>
|
||||
this.clientsService
|
||||
.getClientIdentificationDocuments(identity.id)
|
||||
.pipe(map((documents: any) => ({ ...identity, documents })))
|
||||
);
|
||||
return forkJoin(enriched$);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,7 +245,10 @@
|
||||
color="primary"
|
||||
class="save-next-btn"
|
||||
(click)="onNext()"
|
||||
[disabled]="savingClient || (selectedTabIndex === 0 && clientGeneralStep?.sanctionsBlocked)"
|
||||
[disabled]="!currentStepValid"
|
||||
[matTooltip]="currentStepBlockerMessage"
|
||||
[matTooltipDisabled]="currentStepValid"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
@if (savingClient) {
|
||||
<mat-progress-spinner
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
/** Angular Imports */
|
||||
import { Component, QueryList, ViewChild, ViewChildren, inject } from '@angular/core';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../clients.service';
|
||||
@@ -133,6 +135,76 @@ export class CreateClientComponent {
|
||||
return 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tab form-validity gate for the Save & Next button.
|
||||
* Returns false when the current tab has unfilled / invalid required fields
|
||||
* so the user can't move forward (and the button is visibly disabled).
|
||||
* The mapping follows the tab order in create-client.component.html:
|
||||
* 0 General
|
||||
* 1 KYC & Documents
|
||||
* 2 (non-individual) Scheme & Sector | (individual) Address
|
||||
* 3 (non-individual) Address | (individual) Family
|
||||
* 4 Application Summary (handled by Submit, not Save & Next)
|
||||
*/
|
||||
get currentStepValid(): boolean {
|
||||
if (this.savingClient) return false;
|
||||
const idx = this.selectedTabIndex;
|
||||
|
||||
if (idx === 0) {
|
||||
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
||||
if (this.clientGeneralStep.sanctionsBlocked) return false;
|
||||
return this.clientGeneralForm.valid;
|
||||
}
|
||||
if (idx === 1) {
|
||||
// KYC tab — block when the kyc form has unfilled required fields
|
||||
// (kycDocumentName / kycDocumentNumber / pattern errors on PAN / Aadhaar etc.)
|
||||
const kf = this.clientKycStep?.kycForm;
|
||||
return !kf || kf.valid;
|
||||
}
|
||||
if (this.legalFormType === 2) {
|
||||
if (idx === 2) {
|
||||
// Scheme & Sector (entity KYC) — categories are opt-in, so always allow.
|
||||
return true;
|
||||
}
|
||||
if (idx === 3) {
|
||||
// Address tab for non-individuals
|
||||
return this.isAddressStepValid();
|
||||
}
|
||||
} else {
|
||||
if (idx === 2) {
|
||||
// Address tab for individuals
|
||||
return this.isAddressStepValid();
|
||||
}
|
||||
if (idx === 3) {
|
||||
// Family tab — relations are optional, always allow.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Permanent address is the only mandatory address; current/office/correspondence are optional. */
|
||||
private isAddressStepValid(): boolean {
|
||||
const step = this.clientIndiaAddressStep;
|
||||
if (!step) return true; // step not yet mounted — don't block
|
||||
const perm = step.permanentForm;
|
||||
return !perm || perm.valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable explanation of why the Save & Next button is disabled.
|
||||
* Surfaced via matTooltip so the user knows what to fix.
|
||||
*/
|
||||
get currentStepBlockerMessage(): string {
|
||||
if (this.savingClient) return 'Saving — please wait…';
|
||||
if (this.currentStepValid) return '';
|
||||
const idx = this.selectedTabIndex;
|
||||
if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
||||
return 'Client failed sanctions screening — cannot proceed.';
|
||||
}
|
||||
return 'Please fill all mandatory fields on this tab to continue.';
|
||||
}
|
||||
|
||||
/** Get constitution type from general step for entity KYC */
|
||||
getConstitutionType(): string {
|
||||
return this.clientGeneralStep?.createClientForm?.get('clientNonPersonDetails.constitutionId')?.value || '';
|
||||
@@ -521,11 +593,17 @@ export class CreateClientComponent {
|
||||
isActive: tab === 'permanent'
|
||||
};
|
||||
|
||||
// Try to resolve state to stateProvinceId
|
||||
// Try to resolve state to stateProvinceId. We normalise both sides (lowercase,
|
||||
// collapse underscores + whitespace) so legacy UPPER_SNAKE rows still match the
|
||||
// postal-PIN API's "Madhya Pradesh" form and vice versa. See migration 0297.
|
||||
if ((addr as any).state && template?.stateProvinceIdOptions?.length) {
|
||||
const stateMatch = template.stateProvinceIdOptions.find(
|
||||
(s: any) => s.name?.toLowerCase() === (addr as any).state?.toLowerCase()
|
||||
);
|
||||
const norm = (s: string) =>
|
||||
(s || '')
|
||||
.toLowerCase()
|
||||
.replace(/[_\s]+/g, ' ')
|
||||
.trim();
|
||||
const target = norm((addr as any).state);
|
||||
const stateMatch = template.stateProvinceIdOptions.find((s: any) => norm(s.name) === target);
|
||||
if (stateMatch) payload.stateProvinceId = stateMatch.id;
|
||||
}
|
||||
|
||||
@@ -580,28 +658,60 @@ export class CreateClientComponent {
|
||||
if (!payload?.proofId || !payload?.files?.length) return;
|
||||
|
||||
const proofName = payload.proof?.name || 'KYC Document';
|
||||
const identifierData: any = {
|
||||
documentTypeId: payload.proofId,
|
||||
status: 'Active',
|
||||
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
||||
description: 'KYC document'
|
||||
};
|
||||
// The KYC step's chip-picker carries `id` as a doc-type CODE string (e.g. "AADHAAR")
|
||||
// because its list is constitution-driven, not CodeValue-backed. The backend
|
||||
// ClientIdentifierRequest.documentTypeId is a Long, so a string proofId trips Jackson
|
||||
// with HttpMessageNotReadableException → 400. Resolve to the numeric CodeValue id by
|
||||
// matching on name against /kyc/proof-types?type=id before posting.
|
||||
this.resolveDocumentTypeId(payload.proofId, proofName).subscribe((documentTypeId: number | null) => {
|
||||
if (documentTypeId == null) {
|
||||
// No CodeValue match (e.g. non-individual constitution-specific docs). Skip the
|
||||
// identifier save rather than firing a 400; the form's other KYC fields still get
|
||||
// persisted via the KYC entity.
|
||||
return;
|
||||
}
|
||||
const identifierData: any = {
|
||||
documentTypeId,
|
||||
status: 'Active',
|
||||
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
||||
description: 'KYC document'
|
||||
};
|
||||
|
||||
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
formData.append('file', entry.file);
|
||||
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
formData.append('file', entry.file);
|
||||
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass-through when proofId is already numeric (address proof, identities tab). Otherwise
|
||||
* resolve the string code/name against the ID-proof CodeValues so the backend gets a
|
||||
* proper Long for documentTypeId.
|
||||
*/
|
||||
private resolveDocumentTypeId(proofId: any, proofName: string): Observable<number | null> {
|
||||
if (typeof proofId === 'number' && Number.isFinite(proofId)) {
|
||||
return of(proofId);
|
||||
}
|
||||
return this.clientsService.getKycProofTypes('id').pipe(
|
||||
map((res: any) => {
|
||||
const list: any[] = res?.idProofs || [];
|
||||
const targetName = (proofName || '').trim().toLowerCase();
|
||||
const match = list.find((p: any) => (p?.name || '').trim().toLowerCase() === targetName);
|
||||
return (match?.id ?? null) as number | null;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save family members via POST /clients/{id}/familymembers
|
||||
*/
|
||||
|
||||
@@ -207,7 +207,15 @@
|
||||
</button>
|
||||
}
|
||||
@if (selectedTabIndex < totalTabs - 1) {
|
||||
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient">
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="onNext()"
|
||||
[disabled]="!currentStepValid"
|
||||
[matTooltip]="currentStepBlockerMessage"
|
||||
[matTooltipDisabled]="currentStepValid"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
@if (savingClient) {
|
||||
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
|
||||
Saving...
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import { Component, QueryList, ViewChild, ViewChildren, inject } from '@angular/core';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../clients.service';
|
||||
@@ -145,6 +147,57 @@ export class EditClientComponent {
|
||||
return areValids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tab form-validity gate for the Save & Next button. Mirrors the create
|
||||
* flow — blocks navigation when the current tab has unfilled / invalid
|
||||
* required fields. Tab order:
|
||||
* 0 General
|
||||
* 1 KYC & Documents
|
||||
* 2 (non-individual) Scheme & Sector | (individual) Address
|
||||
* 3 (non-individual) Address | (individual) Family
|
||||
* 4 (non-individual) Family | Preview / Submit
|
||||
*/
|
||||
get currentStepValid(): boolean {
|
||||
if (this.savingClient) return false;
|
||||
const idx = this.selectedTabIndex;
|
||||
|
||||
if (idx === 0) {
|
||||
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
||||
if (this.clientGeneralStep.sanctionsBlocked) return false;
|
||||
return this.clientGeneralForm.valid;
|
||||
}
|
||||
if (idx === 1) {
|
||||
const kf = this.clientKycStep?.kycForm;
|
||||
return !kf || kf.valid;
|
||||
}
|
||||
if (this.legalFormType === 2) {
|
||||
if (idx === 2) return true; // Scheme & Sector — opt-in
|
||||
if (idx === 3) return this.isAddressStepValid();
|
||||
if (idx === 4) return true; // Family — optional
|
||||
} else {
|
||||
if (idx === 2) return this.isAddressStepValid();
|
||||
if (idx === 3) return true; // Family — optional
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private isAddressStepValid(): boolean {
|
||||
const step = this.clientIndiaAddressStep;
|
||||
if (!step) return true;
|
||||
const perm = step.permanentForm;
|
||||
return !perm || perm.valid;
|
||||
}
|
||||
|
||||
/** Human-readable explanation surfaced via matTooltip on the disabled button. */
|
||||
get currentStepBlockerMessage(): string {
|
||||
if (this.savingClient) return 'Saving — please wait…';
|
||||
if (this.currentStepValid) return '';
|
||||
if (this.selectedTabIndex === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
||||
return 'Client failed sanctions screening — cannot proceed.';
|
||||
}
|
||||
return 'Please fill all mandatory fields on this tab to continue.';
|
||||
}
|
||||
|
||||
/** Whether the current tab has unsaved edits */
|
||||
isCurrentTabEdited(): boolean {
|
||||
return this.tabEditMode[this.selectedTabIndex] === true;
|
||||
@@ -637,10 +690,17 @@ export class EditClientComponent {
|
||||
isActive: tab === 'permanent'
|
||||
};
|
||||
|
||||
// Resolve state name to stateProvinceId. Normalise both sides (lowercase,
|
||||
// collapse underscores + whitespace) so legacy UPPER_SNAKE rows still match
|
||||
// the postal-PIN API's "Madhya Pradesh" form and vice versa. See migration 0297.
|
||||
if ((addr as any).state && template?.stateProvinceIdOptions?.length) {
|
||||
const stateMatch = template.stateProvinceIdOptions.find(
|
||||
(s: any) => s.name?.toLowerCase() === (addr as any).state?.toLowerCase()
|
||||
);
|
||||
const norm = (s: string) =>
|
||||
(s || '')
|
||||
.toLowerCase()
|
||||
.replace(/[_\s]+/g, ' ')
|
||||
.trim();
|
||||
const target = norm((addr as any).state);
|
||||
const stateMatch = template.stateProvinceIdOptions.find((s: any) => norm(s.name) === target);
|
||||
if (stateMatch) payload.stateProvinceId = stateMatch.id;
|
||||
}
|
||||
|
||||
@@ -695,28 +755,55 @@ export class EditClientComponent {
|
||||
if (!payload?.proofId || !payload?.files?.length) return;
|
||||
|
||||
const proofName = payload.proof?.name || 'KYC Document';
|
||||
const identifierData: any = {
|
||||
documentTypeId: payload.proofId,
|
||||
status: 'Active',
|
||||
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
||||
description: 'KYC document'
|
||||
};
|
||||
// The KYC step's chip-picker carries `id` as a doc-type CODE string (e.g. "AADHAAR").
|
||||
// The backend ClientIdentifierRequest.documentTypeId is a Long, so a string proofId
|
||||
// trips Jackson → HttpMessageNotReadableException → 400. Resolve to numeric CodeValue
|
||||
// id by matching on name against /kyc/proof-types?type=id before posting.
|
||||
this.resolveDocumentTypeId(payload.proofId, proofName).subscribe((documentTypeId: number | null) => {
|
||||
if (documentTypeId == null) {
|
||||
return;
|
||||
}
|
||||
const identifierData: any = {
|
||||
documentTypeId,
|
||||
status: 'Active',
|
||||
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
||||
description: 'KYC document'
|
||||
};
|
||||
|
||||
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
formData.append('file', entry.file);
|
||||
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
formData.append('file', entry.file);
|
||||
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass-through when proofId is already numeric (address proof). Otherwise resolve the
|
||||
* string code/name against the ID-proof CodeValues so the backend gets a proper Long.
|
||||
*/
|
||||
private resolveDocumentTypeId(proofId: any, proofName: string): Observable<number | null> {
|
||||
if (typeof proofId === 'number' && Number.isFinite(proofId)) {
|
||||
return of(proofId);
|
||||
}
|
||||
return this.clientsService.getKycProofTypes('id').pipe(
|
||||
map((res: any) => {
|
||||
const list: any[] = res?.idProofs || [];
|
||||
const targetName = (proofName || '').trim().toLowerCase();
|
||||
const match = list.find((p: any) => (p?.name || '').trim().toLowerCase() === targetName);
|
||||
return (match?.id ?? null) as number | null;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private saveClientFamilyMembers(clientId: string) {
|
||||
const members = this.clientFamilyMembersStep.clientFamilyMembers;
|
||||
for (const member of members) {
|
||||
|
||||
@@ -190,8 +190,9 @@
|
||||
<fa-icon icon="mobile-alt" class="ni-ico"></fa-icon>Digital
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" (click)="openConfigWizard()" *mifosxHasPermission="'MENU_CONFIG_WIZARD'">
|
||||
<fa-icon icon="cog" class="ni-ico"></fa-icon>Config Wizard
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="reconciliationMenu" *mifosxHasPermission="'MENU_CONFIG_WIZARD'">
|
||||
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon>Reconciliation
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="ckycMenu" *mifosxHasPermission="'MENU_CKYC_MGMT'">
|
||||
<fa-icon icon="fingerprint" class="ni-ico"></fa-icon>CKYC Mgmt
|
||||
@@ -270,6 +271,16 @@
|
||||
<button mat-menu-item routerLink="/otp-audit-logs" tabindex="0">
|
||||
<i class="fa fa-key menu-item-icon"></i> OTP Audit Logs
|
||||
</button>
|
||||
<mat-divider></mat-divider>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="building" class="menu-item-icon" style="color: #7c3aed"></fa-icon> Bank Profile
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="user-tie" class="menu-item-icon" style="color: #7c3aed"></fa-icon> Statutory Auditor Appointment
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="clipboard-check" class="menu-item-icon" style="color: #7c3aed"></fa-icon> Internal Audit Report
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<!-- Sub Ledger Mega Menu -->
|
||||
@@ -745,6 +756,9 @@
|
||||
<!-- General -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">General</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="user-slash" class="menu-item-icon" style="color: #c2410c"></fa-icon> Non-Customer Entry
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> SLR and Non SLR Investment Report
|
||||
@@ -853,6 +867,108 @@
|
||||
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('CERSAI')">
|
||||
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> CERSAI Report
|
||||
</button>
|
||||
<mat-divider></mat-divider>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="database" class="menu-item-icon" style="color: #be185d"></fa-icon> Dump KYC & Loan Data
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<!-- Reconciliation Mega Menu -->
|
||||
<mat-menu #reconciliationMenu="matMenu" class="reconciliation-mega-panel" [overlapTrigger]="false">
|
||||
<div class="deposit-menu-grid reconciliation-grid" (click)="$event.stopPropagation()">
|
||||
<!-- Core / Books -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Core & Books</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="receipt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Voucher Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Sundry Dr / Cr Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
P&L Impact Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
GL Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Fixed Assets Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Inter-Company Reconciliation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bank & Clearing -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Bank & Clearing</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="university" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Bank Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
API Based Bank Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
NEFT / RTGS Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Clearing Reconciliation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Digital / Payments -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Digital & Payments</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="mobile-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
UDIR (IMPS / UPI / ATM)
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Three-way Digital Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
NACH Reconciliation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Instruments & Members -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Instruments & Members</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
DD / Pay Order Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Share Member Reconciliation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Compliance & Financials -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Compliance & Financials</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Invoice Payment
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="percentage" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Tax Reconciliation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #advancesMenu="matMenu" class="advances-mega-panel" [overlapTrigger]="false">
|
||||
@@ -885,6 +1001,9 @@
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Loan Sanction
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="shield-alt" class="menu-item-icon"></fa-icon> Security Wise Report
|
||||
</button>
|
||||
</div>
|
||||
<!-- Recovery & Cash Flow -->
|
||||
<div class="deposit-menu-group">
|
||||
@@ -922,14 +1041,25 @@
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Printing</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="print" class="menu-item-icon"></fa-icon> FD Printing / Renewal
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="book" class="menu-item-icon"></fa-icon> Passbook</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Statement
|
||||
<fa-icon icon="receipt" class="menu-item-icon" style="color: #15803d"></fa-icon> Voucher
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Voucher Printing
|
||||
<fa-icon icon="book" class="menu-item-icon" style="color: #15803d"></fa-icon> Passbook
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="file-alt" class="menu-item-icon" style="color: #15803d"></fa-icon> Statement
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="print" class="menu-item-icon" style="color: #15803d"></fa-icon> FD / Renewal
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="coins" class="menu-item-icon" style="color: #15803d"></fa-icon> Share Member
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #15803d"></fa-icon> Demand Draft (DD)
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="envelope" class="menu-item-icon" style="color: #15803d"></fa-icon> Letter
|
||||
</button>
|
||||
</div>
|
||||
<!-- Certificates -->
|
||||
@@ -942,6 +1072,9 @@
|
||||
<button mat-menu-item routerLink="/letter-mgmt/form-60" tabindex="0">
|
||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 60
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 61
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
|
||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 15G and H
|
||||
</button>
|
||||
@@ -956,10 +1089,6 @@
|
||||
<button mat-menu-item routerLink="/letter-mgmt/noc" tabindex="0">
|
||||
<fa-icon icon="id-card" class="menu-item-icon" style="color: #15803d"></fa-icon> NOC
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/kyc-management" tabindex="0">
|
||||
<fa-icon icon="fingerprint" class="menu-item-icon" style="color: #15803d"></fa-icon>
|
||||
KYC & ReKYC Management
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> Digital Transaction UTR
|
||||
</button>
|
||||
@@ -1078,6 +1207,41 @@
|
||||
Returns)
|
||||
</a>
|
||||
</div>
|
||||
<!-- Income Tax & Ratios -->
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Income Tax & Ratios</div>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Form 24Q 26Q TDS')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="file-signature" class="gl-icon" style="color: #1d4ed8"></fa-icon> Form 24Q and 26Q
|
||||
</a>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('MCPR Reporting')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="clipboard-check" class="gl-icon" style="color: #1d4ed8"></fa-icon> MCPR Reporting
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('FSWM Financially Sound')"
|
||||
(click)="rbiMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="shield-alt" class="gl-icon" style="color: #1d4ed8"></fa-icon> FSWM
|
||||
</a>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('CD Ratio')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="percentage" class="gl-icon" style="color: #1d4ed8"></fa-icon> CD Ratio
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('Capital Tier 1 Tier 2 CRAR')"
|
||||
(click)="rbiMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="layer-group" class="gl-icon" style="color: #1d4ed8"></fa-icon> Capital Tier 1 / Tier 2
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('Deposit Portion Advance Ratio')"
|
||||
(click)="rbiMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="balance-scale" class="gl-icon" style="color: #1d4ed8"></fa-icon> Deposit Portion / Advance
|
||||
Ratio
|
||||
</a>
|
||||
</div>
|
||||
<!-- Statement Reports -->
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Statement Reports</div>
|
||||
|
||||
@@ -537,6 +537,18 @@ $gray-700: #334155;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reconciliation Mega Panel — 5 columns, teal accent to match the icon palette */
|
||||
.reconciliation-grid {
|
||||
grid-template-columns: repeat(5, minmax(190px, 1fr));
|
||||
min-width: 1040px;
|
||||
max-width: 96vw;
|
||||
|
||||
.deposit-group-title {
|
||||
color: #0d9488;
|
||||
border-bottom-color: #0d9488;
|
||||
}
|
||||
}
|
||||
|
||||
/* General Ledger Mega Menu */
|
||||
.gl-backdrop {
|
||||
position: fixed;
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ContentComponent } from './content/content.component';
|
||||
import { FooterComponent } from '../../shared/footer/footer.component';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@@ -50,6 +51,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
AsyncPipe,
|
||||
FaIconComponent,
|
||||
MatMenuModule,
|
||||
MatDividerModule,
|
||||
MatTooltipModule
|
||||
]
|
||||
})
|
||||
|
||||
+10
-2
@@ -20,6 +20,7 @@ import {
|
||||
CertificatePreviewDialogData
|
||||
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
|
||||
import { downloadBalanceCertificatePdf } from '../certificate-pdf';
|
||||
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
|
||||
|
||||
export type BalanceAccountType = 'SAVINGS' | 'CURRENT' | 'FIXED_DEPOSIT' | 'RD' | 'NRE' | 'NRO';
|
||||
|
||||
@@ -60,6 +61,7 @@ export interface BalanceCertificateRecord {
|
||||
export class BalanceCertificateComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
private snack = inject(MatSnackBar);
|
||||
private dscSign = inject(DscSignService);
|
||||
|
||||
// ── Filters ──
|
||||
search = '';
|
||||
@@ -126,8 +128,14 @@ export class BalanceCertificateComponent implements OnInit {
|
||||
}
|
||||
|
||||
downloadCertificate(r: BalanceCertificateRecord): void {
|
||||
downloadBalanceCertificatePdf(r, this.asOnDate);
|
||||
this.snack.open(`Balance Certificate (${r.accountNo}) downloaded.`, 'OK', { duration: 2400 });
|
||||
this.dscSign.promptThenDownload(
|
||||
{
|
||||
title: 'Balance Certificate',
|
||||
reference: `${r.accountNo} · ${r.customerName} · as on ${this.asOnDate}`,
|
||||
filename: `Balance-Certificate-${r.accountNo}-${this.asOnDate}.pdf`
|
||||
},
|
||||
(dsc) => downloadBalanceCertificatePdf(r, this.asOnDate, dsc)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
|
||||
import { jsPDF } from 'jspdf';
|
||||
|
||||
import { BankUserDsc } from 'app/users/dsc/dsc.model';
|
||||
|
||||
import { FyInterestBreakup, LoanInterestRecord } from './loan-interest/loan-interest.component';
|
||||
import { ShareCertificateRecord } from './share-certificate/share-certificate.component';
|
||||
import { BalanceCertificateRecord } from './balance-certificate/balance-certificate.component';
|
||||
@@ -394,6 +396,73 @@ function newCtx(accent: [number, number, number]): PdfCtx {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint a "Digitally Signed" stamp + auditor footer line on the final page of
|
||||
* the given doc. Stamp = green-bordered rounded box on the bottom-right with
|
||||
* a ✓ icon, the signer's common name, masked DSC serial, and signing time.
|
||||
* Audit line = thin teal strip under the existing footer with the same info
|
||||
* in monospace, so the certificate also reads cleanly when printed in B/W.
|
||||
*
|
||||
* Call this just before `doc.save(...)` — it doesn't add a new page.
|
||||
*/
|
||||
export function paintDscSignatureStamp(doc: jsPDF, dsc: BankUserDsc): void {
|
||||
const pageW = doc.internal.pageSize.getWidth();
|
||||
const pageH = doc.internal.pageSize.getHeight();
|
||||
const margin = 20;
|
||||
const signedAt = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const ts = `${signedAt.getFullYear()}-${pad(signedAt.getMonth() + 1)}-${pad(signedAt.getDate())} ${pad(signedAt.getHours())}:${pad(signedAt.getMinutes())}:${pad(signedAt.getSeconds())}`;
|
||||
|
||||
// ── Bottom-right rounded green stamp ──
|
||||
const sW = 68;
|
||||
const sH = 22;
|
||||
const sX = pageW - margin - sW;
|
||||
const sY = pageH - margin - sH - 4;
|
||||
|
||||
// Tint background + green border (slight rounding gives it a "stamped" feel)
|
||||
doc.setFillColor(220, 252, 231); // green-100
|
||||
doc.setDrawColor(21, 128, 61); // green-700
|
||||
doc.setLineWidth(0.6);
|
||||
doc.roundedRect(sX, sY, sW, sH, 2, 2, 'FD');
|
||||
|
||||
// ✓ icon + heading
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(21, 128, 61);
|
||||
doc.text('✓ DIGITALLY SIGNED', sX + 3, sY + 5);
|
||||
|
||||
// Signer name (Common Name)
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(8.5);
|
||||
doc.setTextColor(15, 23, 42);
|
||||
doc.text(dsc.commonName, sX + 3, sY + 10);
|
||||
|
||||
// DSC serial (masked) + type
|
||||
doc.setFont('courier', 'normal');
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(71, 85, 105);
|
||||
doc.text(`DSC ${dsc.dscSerialNoMasked}`, sX + 3, sY + 14);
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(6.5);
|
||||
doc.setTextColor(100, 116, 139);
|
||||
doc.text(dsc.dscType, sX + 3, sY + 17.5);
|
||||
doc.text(`Signed at ${ts}`, sX + 3, sY + 20.5);
|
||||
|
||||
// ── Audit footer line (thin teal strip just below the existing footer) ──
|
||||
const fY = pageH - margin + 2;
|
||||
doc.setFillColor(204, 251, 241); // teal-100
|
||||
doc.rect(margin, fY, pageW - margin * 2, 4, 'F');
|
||||
doc.setFont('courier', 'normal');
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(13, 148, 136); // teal-600
|
||||
doc.text(
|
||||
`[SIGNED] ${dsc.commonName} · DSC ${dsc.dscSerialNoMasked} · Issued by ${dsc.issuedBy.slice(0, 38)} · ${ts}`,
|
||||
margin + 2,
|
||||
fY + 2.8
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────── Loan Interest Certificate ─────────────────────
|
||||
export function buildLoanInterestPdf(record: LoanInterestRecord, fy: FyInterestBreakup): jsPDF {
|
||||
const c = newCtx(ACCENT_BLUE);
|
||||
@@ -467,8 +536,9 @@ export function buildLoanInterestPdf(record: LoanInterestRecord, fy: FyInterestB
|
||||
return c.doc;
|
||||
}
|
||||
|
||||
export function downloadLoanInterestPdf(record: LoanInterestRecord, fy: FyInterestBreakup): void {
|
||||
export function downloadLoanInterestPdf(record: LoanInterestRecord, fy: FyInterestBreakup, dsc?: BankUserDsc): void {
|
||||
const doc = buildLoanInterestPdf(record, fy);
|
||||
if (dsc) paintDscSignatureStamp(doc, dsc);
|
||||
doc.save(`Interest-Certificate-${record.accountNo}-${fy.fy.replace(/\s+/g, '')}.pdf`);
|
||||
}
|
||||
|
||||
@@ -552,8 +622,9 @@ export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
|
||||
return c.doc;
|
||||
}
|
||||
|
||||
export function downloadShareCertificatePdf(r: ShareCertificateRecord): void {
|
||||
export function downloadShareCertificatePdf(r: ShareCertificateRecord, dsc?: BankUserDsc): void {
|
||||
const doc = buildShareCertificatePdf(r);
|
||||
if (dsc) paintDscSignatureStamp(doc, dsc);
|
||||
doc.save(`Share-Certificate-${r.certificateNo}.pdf`);
|
||||
}
|
||||
|
||||
@@ -645,8 +716,9 @@ export function buildBalanceCertificatePdf(r: BalanceCertificateRecord, asOnDate
|
||||
return c.doc;
|
||||
}
|
||||
|
||||
export function downloadBalanceCertificatePdf(r: BalanceCertificateRecord, asOnDate: string): void {
|
||||
export function downloadBalanceCertificatePdf(r: BalanceCertificateRecord, asOnDate: string, dsc?: BankUserDsc): void {
|
||||
const doc = buildBalanceCertificatePdf(r, asOnDate);
|
||||
if (dsc) paintDscSignatureStamp(doc, dsc);
|
||||
doc.save(`Balance-Certificate-${r.accountNo}-${asOnDate}.pdf`);
|
||||
}
|
||||
|
||||
@@ -736,8 +808,9 @@ export function buildForm60Pdf(r: Form60Record): jsPDF {
|
||||
return c.doc;
|
||||
}
|
||||
|
||||
export function downloadForm60Pdf(r: Form60Record): void {
|
||||
export function downloadForm60Pdf(r: Form60Record, dsc?: BankUserDsc): void {
|
||||
const doc = buildForm60Pdf(r);
|
||||
if (dsc) paintDscSignatureStamp(doc, dsc);
|
||||
doc.save(`Form-60-${r.customerId}.pdf`);
|
||||
}
|
||||
|
||||
@@ -830,8 +903,9 @@ export function buildForm15ghPdf(r: Form15ghRecord, kind: Form15Kind): jsPDF {
|
||||
return c.doc;
|
||||
}
|
||||
|
||||
export function downloadForm15ghPdf(r: Form15ghRecord, kind: Form15Kind): void {
|
||||
export function downloadForm15ghPdf(r: Form15ghRecord, kind: Form15Kind, dsc?: BankUserDsc): void {
|
||||
const doc = buildForm15ghPdf(r, kind);
|
||||
if (dsc) paintDscSignatureStamp(doc, dsc);
|
||||
doc.save(`Form-${kind}-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`);
|
||||
}
|
||||
|
||||
@@ -922,8 +996,9 @@ export function buildHypothecationPdf(r: HypothecationRecord): jsPDF {
|
||||
return c.doc;
|
||||
}
|
||||
|
||||
export function downloadHypothecationPdf(r: HypothecationRecord): void {
|
||||
export function downloadHypothecationPdf(r: HypothecationRecord, dsc?: BankUserDsc): void {
|
||||
const doc = buildHypothecationPdf(r);
|
||||
if (dsc) paintDscSignatureStamp(doc, dsc);
|
||||
doc.save(`Hypothecation-${r.certificateNo}.pdf`);
|
||||
}
|
||||
|
||||
@@ -1052,7 +1127,8 @@ export function buildNocPdf(r: NocRecord): jsPDF {
|
||||
return c.doc;
|
||||
}
|
||||
|
||||
export function downloadNocPdf(r: NocRecord): void {
|
||||
export function downloadNocPdf(r: NocRecord, dsc?: BankUserDsc): void {
|
||||
const doc = buildNocPdf(r);
|
||||
if (dsc) paintDscSignatureStamp(doc, dsc);
|
||||
doc.save(`NOC-${r.nocNo}.pdf`);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CertificatePreviewDialogData
|
||||
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
|
||||
import { downloadForm15ghPdf } from '../certificate-pdf';
|
||||
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
|
||||
|
||||
export type Form15Kind = '15G' | '15H';
|
||||
export type Form15Status = 'FILED' | 'PENDING';
|
||||
@@ -60,6 +61,7 @@ export interface Form15ghRecord {
|
||||
export class Form15ghComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
private snack = inject(MatSnackBar);
|
||||
private dscSign = inject(DscSignService);
|
||||
|
||||
// ── Filters ──
|
||||
search = '';
|
||||
@@ -141,8 +143,15 @@ export class Form15ghComponent implements OnInit {
|
||||
}
|
||||
|
||||
downloadCertificate(r: Form15ghRecord): void {
|
||||
downloadForm15ghPdf(r, this.formKind(r));
|
||||
this.snack.open(`Form ${this.formKind(r)} for ${r.customerName} downloaded.`, 'OK', { duration: 2400 });
|
||||
const kind = this.formKind(r);
|
||||
this.dscSign.promptThenDownload(
|
||||
{
|
||||
title: `Form ${kind} Declaration`,
|
||||
reference: `${r.customerId} · ${r.customerName} · ${r.fy}`,
|
||||
filename: `Form-${kind}-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`
|
||||
},
|
||||
(dsc) => downloadForm15ghPdf(r, kind, dsc)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CertificatePreviewDialogData
|
||||
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
|
||||
import { downloadForm60Pdf } from '../certificate-pdf';
|
||||
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
|
||||
|
||||
export type Form60IdProofType = 'AADHAAR' | 'VOTER_ID' | 'DRIVING_LICENSE' | 'PASSPORT' | 'RATION';
|
||||
|
||||
@@ -59,6 +60,7 @@ export interface Form60Record {
|
||||
export class Form60Component implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
private snack = inject(MatSnackBar);
|
||||
private dscSign = inject(DscSignService);
|
||||
|
||||
// ── Filters ──
|
||||
search = '';
|
||||
@@ -142,8 +144,14 @@ export class Form60Component implements OnInit {
|
||||
});
|
||||
return;
|
||||
}
|
||||
downloadForm60Pdf(r);
|
||||
this.snack.open(`Form 60 for ${r.customerName} downloaded.`, 'OK', { duration: 2400 });
|
||||
this.dscSign.promptThenDownload(
|
||||
{
|
||||
title: 'Form 60 Declaration',
|
||||
reference: `${r.customerId} · ${r.customerName}`,
|
||||
filename: `Form60-${r.customerId}.pdf`
|
||||
},
|
||||
(dsc) => downloadForm60Pdf(r, dsc)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CertificatePreviewDialogData
|
||||
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
|
||||
import { downloadHypothecationPdf } from '../certificate-pdf';
|
||||
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
|
||||
|
||||
export type HypoAssetType = 'VEHICLE' | 'PLANT_MACHINERY' | 'STOCK_DEBTORS' | 'GOLD' | 'EQUIPMENT';
|
||||
export type HypoStatus = 'ACTIVE' | 'CLOSED' | 'NOC_ISSUED';
|
||||
@@ -63,6 +64,7 @@ export interface HypothecationRecord {
|
||||
export class HypothecationCertificateComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
private snack = inject(MatSnackBar);
|
||||
private dscSign = inject(DscSignService);
|
||||
|
||||
search = '';
|
||||
assetFilter: '' | HypoAssetType = '';
|
||||
@@ -133,10 +135,14 @@ export class HypothecationCertificateComponent implements OnInit {
|
||||
}
|
||||
|
||||
downloadCertificate(r: HypothecationRecord): void {
|
||||
downloadHypothecationPdf(r);
|
||||
this.snack.open(`Hypothecation Certificate ${r.certificateNo} downloaded.`, 'OK', {
|
||||
duration: 2400
|
||||
});
|
||||
this.dscSign.promptThenDownload(
|
||||
{
|
||||
title: 'Hypothecation Certificate',
|
||||
reference: `${r.certificateNo} · ${r.accountNo} · ${r.customerName}`,
|
||||
filename: `Hypothecation-Certificate-${r.certificateNo}.pdf`
|
||||
},
|
||||
(dsc) => downloadHypothecationPdf(r, dsc)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CertificatePreviewDialogData
|
||||
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
|
||||
import { downloadLoanInterestPdf } from '../certificate-pdf';
|
||||
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
|
||||
|
||||
export type LoanProductType = 'HOUSING' | 'PERSONAL' | 'AUTO' | 'EDUCATION' | 'GOLD' | 'BUSINESS';
|
||||
|
||||
@@ -62,6 +63,7 @@ export interface LoanInterestRecord {
|
||||
export class LoanInterestCertificateComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
private snack = inject(MatSnackBar);
|
||||
private dscSign = inject(DscSignService);
|
||||
|
||||
// ── Filters ──
|
||||
search = '';
|
||||
@@ -161,8 +163,14 @@ export class LoanInterestCertificateComponent implements OnInit {
|
||||
this.snack.open('No interest data on file for the selected financial year.', 'OK', { duration: 2500 });
|
||||
return;
|
||||
}
|
||||
downloadLoanInterestPdf(l, fy);
|
||||
this.snack.open(`Interest Certificate (${fy.fy}) downloaded.`, 'OK', { duration: 2400 });
|
||||
this.dscSign.promptThenDownload(
|
||||
{
|
||||
title: 'Interest Certificate',
|
||||
reference: `${l.accountNo} · ${l.customerName} · ${fy.fy}`,
|
||||
filename: `Interest-Certificate-${l.accountNo}-${fy.fy.replace(/\s+/g, '')}.pdf`
|
||||
},
|
||||
(dsc) => downloadLoanInterestPdf(l, fy, dsc)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CertificatePreviewDialogData
|
||||
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
|
||||
import { downloadNocPdf } from '../certificate-pdf';
|
||||
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
|
||||
|
||||
export type NocLoanType = 'AUTO' | 'HOUSING' | 'PERSONAL' | 'EDUCATION' | 'GOLD' | 'BUSINESS' | 'CC' | 'OD';
|
||||
|
||||
@@ -67,6 +68,7 @@ export interface NocRecord {
|
||||
export class NocCertificateComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
private snack = inject(MatSnackBar);
|
||||
private dscSign = inject(DscSignService);
|
||||
|
||||
// ── Filters ──
|
||||
search = '';
|
||||
@@ -158,8 +160,14 @@ export class NocCertificateComponent implements OnInit {
|
||||
);
|
||||
return;
|
||||
}
|
||||
downloadNocPdf(r);
|
||||
this.snack.open(`NOC ${r.nocNo} downloaded.`, 'OK', { duration: 2400 });
|
||||
this.dscSign.promptThenDownload(
|
||||
{
|
||||
title: 'No-Objection Certificate (NOC)',
|
||||
reference: `${r.nocNo} · ${r.accountNo} · ${r.customerName}`,
|
||||
filename: `NOC-${r.nocNo}.pdf`
|
||||
},
|
||||
(dsc) => downloadNocPdf(r, dsc)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CertificatePreviewDialogData
|
||||
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
|
||||
import { downloadShareCertificatePdf } from '../certificate-pdf';
|
||||
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
|
||||
|
||||
export type ShareClass = 'ORDINARY' | 'PREFERENCE';
|
||||
|
||||
@@ -58,6 +59,7 @@ export interface ShareCertificateRecord {
|
||||
export class ShareCertificateComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
private snack = inject(MatSnackBar);
|
||||
private dscSign = inject(DscSignService);
|
||||
|
||||
// ── Filters ──
|
||||
search = '';
|
||||
@@ -132,8 +134,14 @@ export class ShareCertificateComponent implements OnInit {
|
||||
}
|
||||
|
||||
downloadCertificate(r: ShareCertificateRecord): void {
|
||||
downloadShareCertificatePdf(r);
|
||||
this.snack.open(`Share Certificate ${r.certificateNo} downloaded.`, 'OK', { duration: 2400 });
|
||||
this.dscSign.promptThenDownload(
|
||||
{
|
||||
title: 'Share Certificate',
|
||||
reference: `${r.certificateNo} · Folio ${r.folioNo} · ${r.memberName}`,
|
||||
filename: `Share-Certificate-${r.certificateNo}.pdf`
|
||||
},
|
||||
(dsc) => downloadShareCertificatePdf(r, dsc)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<!--
|
||||
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="dsd-root">
|
||||
<!-- HEADER -->
|
||||
<div class="dsd-head">
|
||||
<div class="dsd-head-l">
|
||||
<fa-icon icon="file-signature" class="dsd-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="dsd-head-t">Sign & Download</h2>
|
||||
<div class="dsd-head-s">{{ data.title }} · {{ data.reference }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dsd-head-r">
|
||||
<div class="dsd-who" matTooltip="Signed-in operator">
|
||||
<fa-icon icon="user-shield" class="dsd-who-ic"></fa-icon>
|
||||
<div class="dsd-who-txt">
|
||||
<span class="dsd-who-name">{{ displayName }}</span>
|
||||
<span class="dsd-who-uname mono">@{{ username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="dsd-x" (click)="cancel()" matTooltip="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LOADING — fetching /v1/dsc/me -->
|
||||
@if (loading) {
|
||||
<div class="dsd-body dsd-body--loading">
|
||||
<div class="dsd-status-row">
|
||||
<span class="dsd-status-pill dsd-status-pill--muted">
|
||||
<fa-icon icon="spinner" class="fa-spin"></fa-icon>
|
||||
Checking DSC mapping…
|
||||
</span>
|
||||
<span class="dsd-status-meta">Looking up the certificate mapped to {{ username }}.</span>
|
||||
</div>
|
||||
<div class="dsd-skeleton">
|
||||
<div class="dsd-skel dsd-skel--h"></div>
|
||||
<div class="dsd-skel-grid">
|
||||
<div class="dsd-skel"></div>
|
||||
<div class="dsd-skel"></div>
|
||||
<div class="dsd-skel"></div>
|
||||
<div class="dsd-skel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- MAPPED — DSC DETAILS + SIGN -->
|
||||
@if (!loading && dscMapped && dsc) {
|
||||
<div class="dsd-body">
|
||||
<div class="dsd-status-row">
|
||||
<span class="dsd-status-pill dsd-status-pill--ok">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
DSC Mapped
|
||||
</span>
|
||||
<span class="dsd-status-meta">Your DSC is ready to digitally sign this document.</span>
|
||||
</div>
|
||||
|
||||
<!-- DSC details card -->
|
||||
<div class="dsd-card">
|
||||
<div class="dsd-card-h">
|
||||
<fa-icon icon="id-card" class="dsd-card-h-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="dsd-card-name">{{ dsc.commonName }}</div>
|
||||
<div class="dsd-card-type">{{ dsc.dscType }}</div>
|
||||
</div>
|
||||
<span
|
||||
class="dsd-cert-pill"
|
||||
[class.dsd-cert-pill--ok]="dsc.certStatus === 'ACTIVE'"
|
||||
[class.dsd-cert-pill--warn]="dsc.certStatus === 'EXPIRED'"
|
||||
[class.dsd-cert-pill--bad]="dsc.certStatus === 'REVOKED'"
|
||||
>
|
||||
{{ dsc.certStatus }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="dsd-card-grid">
|
||||
<div>
|
||||
<span class="dsd-card-l">Issued To</span>
|
||||
<span class="dsd-card-v">{{ dsc.issuedTo }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="dsd-card-l">Issued By</span>
|
||||
<span class="dsd-card-v">{{ dsc.issuedBy }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="dsd-card-l">Serial No.</span>
|
||||
<span class="dsd-card-v mono">{{ dsc.dscSerialNoMasked }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="dsd-card-l">Mapped On</span>
|
||||
<span class="dsd-card-v">{{ dsc.dateOfSubmission | date: 'd MMM y' }}</span>
|
||||
</div>
|
||||
<div class="dsd-span-2">
|
||||
<span class="dsd-card-l">Expires On</span>
|
||||
<span class="dsd-card-v">
|
||||
{{ dsc.dscExpiryDate | date: 'd MMM y' }}
|
||||
@if (daysToExpiry > 0) {
|
||||
<span
|
||||
class="dsd-expiry-chip"
|
||||
[class.dsd-expiry-chip--warn]="isExpiringSoon"
|
||||
[class.dsd-expiry-chip--ok]="!isExpiringSoon"
|
||||
>
|
||||
{{ daysToExpiry }} days left
|
||||
</span>
|
||||
} @else {
|
||||
<span class="dsd-expiry-chip dsd-expiry-chip--bad">Expired</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File preview chip -->
|
||||
<div class="dsd-file-chip">
|
||||
<fa-icon icon="file-invoice" class="dsd-file-ic"></fa-icon>
|
||||
<div class="dsd-file-info">
|
||||
<div class="dsd-file-l">File to sign & download</div>
|
||||
<div class="dsd-file-v mono">{{ data.filename }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Primary actions -->
|
||||
<div class="dsd-actions">
|
||||
<button type="button" class="dsd-btn dsd-btn--ghost" (click)="skipSign()">
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
Download unsigned
|
||||
</button>
|
||||
<button type="button" class="dsd-btn dsd-btn--primary" (click)="signAndDownload()">
|
||||
<fa-icon icon="file-signature"></fa-icon>
|
||||
Sign & Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- NOT MAPPED — MAP DSC FLOW -->
|
||||
@if (!loading && !dscMapped) {
|
||||
<div class="dsd-body">
|
||||
<div class="dsd-status-row">
|
||||
<span class="dsd-status-pill dsd-status-pill--warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
DSC Not Mapped
|
||||
</span>
|
||||
<span class="dsd-status-meta">
|
||||
No active Digital Signature Certificate is mapped to <strong>{{ displayName }}</strong> (<span class="mono"
|
||||
>@{{ username }}</span
|
||||
>).
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Operator card — surfaces who the missing DSC belongs to -->
|
||||
<div class="dsd-op-card">
|
||||
<div class="dsd-op-avatar">
|
||||
<fa-icon icon="user-shield"></fa-icon>
|
||||
</div>
|
||||
<div class="dsd-op-body">
|
||||
<div class="dsd-op-name">{{ displayName }}</div>
|
||||
<div class="dsd-op-meta">
|
||||
<span class="mono">@{{ username }}</span>
|
||||
<span class="dsd-op-sep">·</span>
|
||||
<span>Signed-in operator</span>
|
||||
</div>
|
||||
@if (inactiveDsc) {
|
||||
<div class="dsd-op-prev">
|
||||
<fa-icon icon="history"></fa-icon>
|
||||
Last DSC on file: <strong>{{ inactiveDsc.commonName }}</strong>
|
||||
<span class="mono">({{ inactiveDsc.dscSerialNoMasked }})</span>
|
||||
— <em>{{ inactiveDsc.certStatus }}</em>
|
||||
@if (inactiveDsc.dscExpiryDate) {
|
||||
on {{ inactiveDsc.dscExpiryDate | date: 'd MMM y' }}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dsd-empty">
|
||||
<fa-icon icon="id-card" class="dsd-empty-ic"></fa-icon>
|
||||
<div>
|
||||
<strong>Why map a DSC?</strong>
|
||||
<p>
|
||||
A digitally signed PDF carries cryptographic proof of authorship and tamper-evidence for regulators,
|
||||
auditors, and customers. Map your Class 3 Individual DSC once — then sign every certificate / notice
|
||||
you issue from this terminal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dsd-actions">
|
||||
<button type="button" class="dsd-btn dsd-btn--ghost" (click)="skipSign()">
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
Download unsigned for now
|
||||
</button>
|
||||
<button type="button" class="dsd-btn dsd-btn--primary" (click)="mapDsc()">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
Map DSC for {{ username }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--dsd-blue: #1d4ed8;
|
||||
--dsd-blue-d: #1e40af;
|
||||
--dsd-blue-l: #eff6ff;
|
||||
--dsd-blue-tint: #dbeafe;
|
||||
--dsd-green: #15803d;
|
||||
--dsd-green-l: #dcfce7;
|
||||
--dsd-amber: #d97706;
|
||||
--dsd-amber-l: #fef3c7;
|
||||
--dsd-red: #b91c1c;
|
||||
--dsd-red-l: #fee2e2;
|
||||
--dsd-red-tint: #fecaca;
|
||||
--dsd-purple: #6d28d9;
|
||||
--dsd-purple-l: #ede9fe;
|
||||
--dsd-g50: #f8fafc;
|
||||
--dsd-g100: #f1f5f9;
|
||||
--dsd-g200: #e2e8f0;
|
||||
--dsd-g300: #cbd5e1;
|
||||
--dsd-g500: #64748b;
|
||||
--dsd-g700: #334155;
|
||||
--dsd-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dsd-root {
|
||||
background: #fff;
|
||||
padding: 16px 20px 18px;
|
||||
width: 100%;
|
||||
max-width: 580px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* ───────── Header ───────── */
|
||||
.dsd-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--dsd-g200);
|
||||
}
|
||||
|
||||
.dsd-head-l {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dsd-head-ic {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: var(--dsd-purple);
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.dsd-head-t {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--dsd-g900);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dsd-head-s {
|
||||
margin-top: 3px;
|
||||
font-size: 11.5px;
|
||||
color: var(--dsd-g500);
|
||||
}
|
||||
|
||||
.dsd-head-r {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dsd-who {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px 5px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--dsd-blue-l);
|
||||
border: 1px solid var(--dsd-blue-tint);
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.dsd-who-ic {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--dsd-blue-d);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dsd-who-txt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dsd-who-name {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: var(--dsd-g900);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dsd-who-uname {
|
||||
font-size: 10px;
|
||||
color: var(--dsd-blue-d);
|
||||
margin-top: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dsd-x {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--dsd-g200);
|
||||
background: #fff;
|
||||
color: var(--dsd-g500);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--dsd-red-l);
|
||||
color: var(--dsd-red);
|
||||
border-color: var(--dsd-red-tint);
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Body ───────── */
|
||||
.dsd-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
/* Status row at the top of body */
|
||||
.dsd-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dsd-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
|
||||
fa-icon {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
background: var(--dsd-green-l);
|
||||
color: var(--dsd-green);
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: var(--dsd-amber-l);
|
||||
color: var(--dsd-amber);
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&--muted {
|
||||
background: var(--dsd-blue-l);
|
||||
color: var(--dsd-blue-d);
|
||||
border: 1px solid var(--dsd-blue-tint);
|
||||
}
|
||||
}
|
||||
|
||||
.dsd-status-meta {
|
||||
font-size: 12px;
|
||||
color: var(--dsd-g500);
|
||||
}
|
||||
|
||||
/* ───────── DSC details card ───────── */
|
||||
.dsd-card {
|
||||
border: 1px solid var(--dsd-g200);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #fafbff 0%, #fff 60%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dsd-card-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--dsd-g200);
|
||||
background: var(--dsd-purple-l);
|
||||
}
|
||||
|
||||
.dsd-card-h-ic {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9px;
|
||||
background: var(--dsd-purple);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dsd-card-name {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: var(--dsd-g900);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dsd-card-type {
|
||||
font-size: 11px;
|
||||
color: var(--dsd-g500);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.dsd-cert-pill {
|
||||
margin-left: auto;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
background: var(--dsd-g100);
|
||||
color: var(--dsd-g700);
|
||||
|
||||
&--ok {
|
||||
background: var(--dsd-green-l);
|
||||
color: var(--dsd-green);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: var(--dsd-amber-l);
|
||||
color: var(--dsd-amber);
|
||||
}
|
||||
|
||||
&--bad {
|
||||
background: var(--dsd-red-l);
|
||||
color: var(--dsd-red);
|
||||
}
|
||||
}
|
||||
|
||||
.dsd-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 14px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.dsd-span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.dsd-card-l {
|
||||
display: block;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--dsd-g500);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.dsd-card-v {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--dsd-g900);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dsd-expiry-chip {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
|
||||
&--ok {
|
||||
background: var(--dsd-green-l);
|
||||
color: var(--dsd-green);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: var(--dsd-amber-l);
|
||||
color: var(--dsd-amber);
|
||||
}
|
||||
|
||||
&--bad {
|
||||
background: var(--dsd-red-l);
|
||||
color: var(--dsd-red);
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── File chip ───────── */
|
||||
.dsd-file-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--dsd-blue-l);
|
||||
border: 1px solid var(--dsd-blue-tint);
|
||||
}
|
||||
|
||||
.dsd-file-ic {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9px;
|
||||
background: var(--dsd-blue-d);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dsd-file-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dsd-file-l {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--dsd-blue-d);
|
||||
}
|
||||
|
||||
.dsd-file-v {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--dsd-g900);
|
||||
word-break: break-all;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ───────── Empty (not-mapped) state ───────── */
|
||||
.dsd-empty {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px dashed var(--dsd-g300);
|
||||
border-radius: 10px;
|
||||
background: var(--dsd-g50);
|
||||
color: var(--dsd-g700);
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--dsd-g500);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.dsd-empty-ic {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--dsd-purple-l);
|
||||
color: var(--dsd-purple);
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ───────── Footer actions ───────── */
|
||||
.dsd-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.dsd-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 0 18px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--dsd-g200);
|
||||
background: #fff;
|
||||
color: var(--dsd-g700);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
|
||||
fa-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&--ghost {
|
||||
background: #fff;
|
||||
color: var(--dsd-g700);
|
||||
border-color: var(--dsd-g200);
|
||||
|
||||
fa-icon {
|
||||
color: var(--dsd-g500);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--dsd-g50);
|
||||
border-color: var(--dsd-g300);
|
||||
color: var(--dsd-g900);
|
||||
}
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: linear-gradient(135deg, var(--dsd-purple) 0%, var(--dsd-blue-d) 100%);
|
||||
color: #fff;
|
||||
border-color: var(--dsd-blue-d);
|
||||
box-shadow: 0 2px 6px -2px rgb(29 78 216 / 35%);
|
||||
|
||||
fa-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #5b21b6 0%, #1e3a8a 100%);
|
||||
box-shadow: 0 4px 12px -3px rgb(29 78 216 / 45%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Loading skeleton ───────── */
|
||||
.dsd-body--loading {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.dsd-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.dsd-skel {
|
||||
height: 14px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, var(--dsd-g100) 0%, var(--dsd-g200) 50%, var(--dsd-g100) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: dsd-shimmer 1.4s linear infinite;
|
||||
|
||||
&--h {
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
.dsd-skel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px 14px;
|
||||
|
||||
.dsd-skel {
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dsd-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Operator card (not-mapped state) ───────── */
|
||||
.dsd-op-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #eef2ff 0%, var(--dsd-blue-l) 100%);
|
||||
border: 1px solid var(--dsd-blue-tint);
|
||||
}
|
||||
|
||||
.dsd-op-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--dsd-blue-d) 0%, var(--dsd-purple) 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 6px -2px rgb(29 78 216 / 40%);
|
||||
}
|
||||
|
||||
.dsd-op-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dsd-op-name {
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
color: var(--dsd-g900);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dsd-op-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--dsd-g500);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dsd-op-sep {
|
||||
color: var(--dsd-g300);
|
||||
}
|
||||
|
||||
.dsd-op-prev {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--dsd-blue-tint);
|
||||
font-size: 11.5px;
|
||||
color: var(--dsd-g700);
|
||||
|
||||
fa-icon {
|
||||
color: var(--dsd-amber);
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--dsd-g900);
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
color: var(--dsd-amber);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
import { DscService } from 'app/users/dsc/dsc.service';
|
||||
import { BankUserDsc } from 'app/users/dsc/dsc.model';
|
||||
|
||||
export interface DscSignDialogData {
|
||||
/** Short title shown in the dialog header, e.g. "Interest Certificate". */
|
||||
title: string;
|
||||
/** Reference line shown under the title, e.g. account no / customer ID. */
|
||||
reference: string;
|
||||
/** Filename that will be downloaded — shown so the user knows what they're getting. */
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export type DscSignDialogAction = 'sign' | 'skip' | 'map' | 'cancel';
|
||||
|
||||
export interface DscSignDialogResult {
|
||||
action: DscSignDialogAction;
|
||||
/** Populated when action === 'sign' — the DSC used for signing. */
|
||||
dsc?: BankUserDsc;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-dsc-sign-dialog',
|
||||
templateUrl: './dsc-sign-dialog.component.html',
|
||||
styleUrls: ['./dsc-sign-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class DscSignDialogComponent implements OnInit {
|
||||
private dialogRef = inject(MatDialogRef<DscSignDialogComponent, DscSignDialogResult>);
|
||||
private snack = inject(MatSnackBar);
|
||||
private router = inject(Router);
|
||||
private authService = inject(AuthenticationService);
|
||||
private dscService = inject(DscService);
|
||||
data: DscSignDialogData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
/** Logged-in operator's username — shown on the "not mapped" state. */
|
||||
username = '';
|
||||
|
||||
/** Operator's full name from credentials, if available. */
|
||||
displayName = '';
|
||||
|
||||
/** Loading flag while we hit /v1/dsc/me. */
|
||||
loading = true;
|
||||
|
||||
/** Active DSC on file for the current user — null if none / API error. */
|
||||
dsc: BankUserDsc | null = null;
|
||||
|
||||
/** Most recent DSC if mapped but expired/revoked — shown as a hint. */
|
||||
inactiveDsc: BankUserDsc | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
const creds = this.authService.getCredentials();
|
||||
this.username = creds?.username || 'unknown user';
|
||||
// Prefer the staff display name (e.g. "Aditya Sharma") over the bare username,
|
||||
// falling back to the username when no staff record is linked to the user.
|
||||
this.displayName = creds?.staffDisplayName?.trim() || this.username;
|
||||
|
||||
this.dscService.listMyDscs().subscribe({
|
||||
next: (rows) => {
|
||||
const active = (rows || []).find((r) => r.certStatus === 'ACTIVE');
|
||||
const fallback = (rows || []).find((r) => !!r);
|
||||
this.dsc = active || null;
|
||||
this.inactiveDsc = active ? null : fallback || null;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
// Endpoint missing / 404 / network — treat as "not mapped" so the user
|
||||
// can still choose to download unsigned or kick off the map flow.
|
||||
this.dsc = null;
|
||||
this.inactiveDsc = null;
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** True once we know the answer AND there's an active DSC on file. */
|
||||
get dscMapped(): boolean {
|
||||
return !this.loading && this.dsc !== null;
|
||||
}
|
||||
|
||||
/** Days remaining until the DSC expires (negative = already expired). */
|
||||
get daysToExpiry(): number {
|
||||
if (!this.dsc) return 0;
|
||||
const exp = new Date(this.dsc.dscExpiryDate);
|
||||
const today = new Date();
|
||||
return Math.floor((exp.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
get isExpiringSoon(): boolean {
|
||||
return this.daysToExpiry > 0 && this.daysToExpiry < 60;
|
||||
}
|
||||
|
||||
// ── Actions ──
|
||||
signAndDownload(): void {
|
||||
if (!this.dsc) return;
|
||||
this.dialogRef.close({ action: 'sign', dsc: this.dsc });
|
||||
}
|
||||
|
||||
skipSign(): void {
|
||||
this.dialogRef.close({ action: 'skip' });
|
||||
}
|
||||
|
||||
mapDsc(): void {
|
||||
this.snack.open('Opening DSC enrollment — please connect your DSC token.', 'OK', { duration: 3000 });
|
||||
this.dialogRef.close({ action: 'map' });
|
||||
// The DSC enrollment dialog lives under the user-profile / access-control flow.
|
||||
// Best-effort navigation; silently ignore if the route isn't registered.
|
||||
this.router.navigate(['/access-control']).catch(() => {
|
||||
this.router.navigate(['/users']).catch(() => {
|
||||
/* silent */
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close({ action: 'cancel' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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 { inject, Injectable } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
|
||||
import { BankUserDsc } from 'app/users/dsc/dsc.model';
|
||||
|
||||
import { DscSignDialogComponent, DscSignDialogData, DscSignDialogResult } from './dsc-sign-dialog.component';
|
||||
|
||||
/**
|
||||
* Bridges every Letter-Mgmt download button → DSC sign-or-skip dialog → PDF
|
||||
* download. Components pass the file context and a single callback that knows
|
||||
* how to build the PDF (with the dsc passed through to the PDF builder if the
|
||||
* user chose to sign).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DscSignService {
|
||||
private dialog = inject(MatDialog);
|
||||
private snack = inject(MatSnackBar);
|
||||
|
||||
/**
|
||||
* Opens the DSC sign dialog. The callback fires with `{ dsc }` when the
|
||||
* user signs, with `{ dsc: undefined }` when they skip, and is NEVER called
|
||||
* for cancel / map (we route map to the enrollment screen separately).
|
||||
*
|
||||
* Components call it like:
|
||||
* this.dscSign.promptThenDownload(
|
||||
* { title: 'Interest Certificate', reference: 'LN10029384 · FY 2024-25',
|
||||
* filename: 'Interest-Certificate-LN10029384-FY2024-25.pdf' },
|
||||
* (dsc) => downloadLoanInterestPdf(record, fy, dsc)
|
||||
* );
|
||||
*/
|
||||
promptThenDownload(ctx: DscSignDialogData, doDownload: (dsc?: BankUserDsc) => void): void {
|
||||
const ref = this.dialog.open<DscSignDialogComponent, DscSignDialogData, DscSignDialogResult>(
|
||||
DscSignDialogComponent,
|
||||
{
|
||||
width: '620px',
|
||||
maxWidth: '94vw',
|
||||
maxHeight: '90vh',
|
||||
autoFocus: false,
|
||||
panelClass: 'dsd-dialog',
|
||||
data: ctx
|
||||
}
|
||||
);
|
||||
|
||||
ref.afterClosed().subscribe((result) => {
|
||||
if (!result) return;
|
||||
switch (result.action) {
|
||||
case 'sign':
|
||||
doDownload(result.dsc);
|
||||
this.snack.open(`Signed & downloaded · ${result.dsc?.commonName ?? 'DSC'} · ${ctx.filename}`, 'OK', {
|
||||
duration: 3000
|
||||
});
|
||||
break;
|
||||
case 'skip':
|
||||
doDownload(undefined);
|
||||
this.snack.open(`Downloaded unsigned · ${ctx.filename}`, 'OK', { duration: 2400 });
|
||||
break;
|
||||
case 'map':
|
||||
case 'cancel':
|
||||
default:
|
||||
// Map handled inside the dialog; cancel = no-op.
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component'
|
||||
import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
|
||||
import { NocCertificateComponent } from './certificates/noc/noc.component';
|
||||
import { CertificatePreviewDialogComponent } from './certificates/certificate-preview-dialog/certificate-preview-dialog.component';
|
||||
import { DscSignDialogComponent } from './dsc-sign-dialog/dsc-sign-dialog.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -40,7 +41,8 @@ import { CertificatePreviewDialogComponent } from './certificates/certificate-pr
|
||||
Form15ghComponent,
|
||||
HypothecationCertificateComponent,
|
||||
NocCertificateComponent,
|
||||
CertificatePreviewDialogComponent
|
||||
CertificatePreviewDialogComponent,
|
||||
DscSignDialogComponent
|
||||
]
|
||||
})
|
||||
export class LetterMgmtModule {}
|
||||
|
||||
@@ -15,8 +15,11 @@ import { MatMenu, MatMenuContent, MatMenuItem, MatMenuTrigger } from '@angular/m
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import { BankUserDsc } from 'app/users/dsc/dsc.model';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import { paintDscSignatureStamp } from '../certificates/certificate-pdf';
|
||||
|
||||
import {
|
||||
NoticeHistoryDialogComponent,
|
||||
NoticeHistoryDialogData
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
NoticePreviewDialogData
|
||||
} from './notice-preview-dialog/notice-preview-dialog.component';
|
||||
import { CallContactDialogComponent, CallContactDialogData } from './call-contact-dialog/call-contact-dialog.component';
|
||||
import { DscSignService } from '../dsc-sign-dialog/dsc-sign.service';
|
||||
import {
|
||||
SpeedPostDialogComponent,
|
||||
SpeedPostDialogData,
|
||||
@@ -140,6 +144,7 @@ export interface OverdueLoan {
|
||||
export class NoticeComponent implements OnInit {
|
||||
private snack = inject(MatSnackBar);
|
||||
private dialog = inject(MatDialog);
|
||||
private dscSign = inject(DscSignService);
|
||||
|
||||
// ─── Filters ───
|
||||
search = '';
|
||||
@@ -450,10 +455,16 @@ export class NoticeComponent implements OnInit {
|
||||
this.downloadNoticeFile(loan, latest);
|
||||
}
|
||||
|
||||
/** Generates and downloads the notice PDF using jsPDF. */
|
||||
/** Prompts for DSC signing, then generates and downloads the notice PDF. */
|
||||
downloadNoticeFile(loan: OverdueLoan, notice: NoticeEvent): void {
|
||||
downloadNoticePdf(loan, notice);
|
||||
this.snack.open(`Notice ${notice.id} downloaded as PDF.`, 'OK', { duration: 2200 });
|
||||
this.dscSign.promptThenDownload(
|
||||
{
|
||||
title: `${this.stageLabel(notice.stage)} Notice`,
|
||||
reference: `${loan.accountNo} · ${loan.customerName}`,
|
||||
filename: `${notice.id}-${loan.accountNo}-Notice.pdf`
|
||||
},
|
||||
(dsc) => downloadNoticePdf(loan, notice, dsc)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1083,9 +1094,11 @@ function formatLongDate(iso: string): string {
|
||||
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
/** Convenience: build the PDF and trigger a download with a sensible filename. */
|
||||
export function downloadNoticePdf(loan: OverdueLoan, notice: NoticeEvent): void {
|
||||
/** Convenience: build the PDF and trigger a download with a sensible filename.
|
||||
Pass a `dsc` to stamp the document with a DSC signature block. */
|
||||
export function downloadNoticePdf(loan: OverdueLoan, notice: NoticeEvent, dsc?: BankUserDsc): void {
|
||||
const doc = buildNoticePdf(loan, notice);
|
||||
if (dsc) paintDscSignatureStamp(doc, dsc);
|
||||
doc.save(`${notice.id}-${loan.accountNo}-Notice.pdf`);
|
||||
}
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
/** Subscription to alerts. */
|
||||
alert$: Subscription;
|
||||
logoPath = 'assets/images/cbsnexora-logo.png';
|
||||
logoPathDark = 'assets/images/white-mifos.png';
|
||||
logoPathDark = 'assets/images/cbsnexora-logo.png';
|
||||
/** Subscription to theme changes. */
|
||||
theme$: Subscription;
|
||||
|
||||
@@ -421,7 +421,9 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
if (environment.tenantLogoUrlDark && environment.tenantLogoUrlDark.trim() !== '') {
|
||||
this.logoPathDark = environment.tenantLogoUrlDark;
|
||||
} else {
|
||||
this.logoPathDark = isTenantSpecific ? `assets/images/${tenant}_home_dark.png` : 'assets/images/white-mifos.png';
|
||||
this.logoPathDark = isTenantSpecific
|
||||
? `assets/images/${tenant}_home_dark.png`
|
||||
: 'assets/images/cbsnexora-logo.png';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,6 +432,6 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
onLogoErrorDark(): void {
|
||||
this.logoPathDark = 'assets/images/white-mifos.png';
|
||||
this.logoPathDark = 'assets/images/cbsnexora-logo.png';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,302 +6,273 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="table-header">
|
||||
<div class="table-title-group">
|
||||
<h2 class="table-title">
|
||||
<i class="fas fa-building title-icon"></i>
|
||||
{{ 'labels.heading.Manage Offices' | translate }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<div class="table-search">
|
||||
<mat-form-field appearance="outline">
|
||||
<i matPrefix class="fas fa-search"></i>
|
||||
<mat-label>{{ 'Filter' | translate }}</mat-label>
|
||||
<input #filter matInput (keyup)="applyFilter($event.target.value)" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<mat-button-toggle-group #buttonTreeView [formControl]="viewGroup">
|
||||
<mat-button-toggle value="listView">
|
||||
<fa-icon icon="list"></fa-icon>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle value="treeView">
|
||||
<fa-icon icon="sitemap"></fa-icon>
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
<div class="table-buttons">
|
||||
<div #buttonImportOffices>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="btn-import"
|
||||
*mifosxHasPermission="'CREATE_OFFICE'"
|
||||
[routerLink]="['/organization', 'bulk-import', 'Offices']"
|
||||
>
|
||||
<fa-icon icon="upload" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Import Offices' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<div #buttonCreateOffice>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
class="btn-create"
|
||||
[routerLink]="['create']"
|
||||
*mifosxHasPermission="'CREATE_OFFICE'"
|
||||
>
|
||||
<fa-icon icon="plus" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Create Office' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="off-container">
|
||||
<!-- Hero header -->
|
||||
<div class="off-header">
|
||||
<div class="off-header__left">
|
||||
<fa-icon icon="building" class="off-header__icon"></fa-icon>
|
||||
<div>
|
||||
<h2 class="off-header__title">Manage Offices</h2>
|
||||
<div class="off-header__sub">Define and organise your branch hierarchy</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div [hidden]="viewGroup.value !== 'listView'">
|
||||
<div #tableOffices class="table-container">
|
||||
<table mat-table [dataSource]="dataSource" matSort class="modern-table">
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ ' Office Name ' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let offices">{{ offices.name }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="externalId">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ ' External ID ' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let offices">{{ offices.externalId }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="parentName">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ ' Parent Office ' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let offices">{{ offices.parentName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="openingDate">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ ' Opened On ' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let offices">{{ offices.openingDate | dateFormat }}</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="table-row"
|
||||
[routerLink]="[row.id, 'general']"
|
||||
></tr>
|
||||
</table>
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tree View -->
|
||||
<div [hidden]="viewGroup.value !== 'treeView'">
|
||||
<div class="m-b-20 layout-row align-start gap-20px">
|
||||
<button mat-raised-button (click)="toggleExpandCollapse()">
|
||||
{{ (isTreeExpanded ? 'labels.buttons.Collapse All' : 'labels.buttons.Expand All') | translate }}
|
||||
<div class="off-header__right">
|
||||
<button class="off-btn ghost" (click)="importOffices()" *mifosxHasPermission="'CREATE_OFFICE'">
|
||||
<fa-icon icon="upload"></fa-icon>
|
||||
Import Offices
|
||||
</button>
|
||||
<button class="off-btn primary" (click)="createOffice()" *mifosxHasPermission="'CREATE_OFFICE'">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
Create Office
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout-row gap-4percent responsive-column">
|
||||
<div class="flex-fill">
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
<mat-tree [dataSource]="nestedTreeDataSource" [treeControl]="nestedTreeControl" class="office-tree">
|
||||
<mat-tree-node *matTreeNodeDef="let node" matTreeNodeToggle>
|
||||
<li class="mat-tree-node">
|
||||
<button mat-icon-button disabled></button>
|
||||
<span (click)="viewOfficeNode(node)" class="m-r-10">
|
||||
{{ node.name }}
|
||||
</span>
|
||||
</li>
|
||||
</mat-tree-node>
|
||||
|
||||
<mat-nested-tree-node *matTreeNodeDef="let node; when: hasNestedChild">
|
||||
<li>
|
||||
<div class="mat-tree-node">
|
||||
<button mat-icon-button matTreeNodeToggle [attr.aria-label]="'toggle ' + node.name">
|
||||
<fa-icon
|
||||
class="mat-icon-rtl-mirror"
|
||||
icon="{{ nestedTreeControl.isExpanded(node) ? 'chevron-down' : 'chevron-right' }}"
|
||||
></fa-icon>
|
||||
</button>
|
||||
<span (click)="viewOfficeNode(node)" class="m-r-10">
|
||||
{{ node.name }}
|
||||
</span>
|
||||
</div>
|
||||
<ul [class.office-tree-invisible]="!nestedTreeControl.isExpanded(node)">
|
||||
<ng-container matTreeNodeOutlet></ng-container>
|
||||
</ul>
|
||||
</li>
|
||||
</mat-nested-tree-node>
|
||||
</mat-tree>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<!-- KPI strip -->
|
||||
<div class="off-stats">
|
||||
<div class="off-stat total">
|
||||
<fa-icon icon="building" class="off-stat__ic"></fa-icon>
|
||||
<div class="off-stat__body">
|
||||
<div class="off-stat__val">{{ totalOffices }}</div>
|
||||
<div class="off-stat__lbl">Total Offices</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="off-stat head">
|
||||
<fa-icon icon="university" class="off-stat__ic"></fa-icon>
|
||||
<div class="off-stat__body">
|
||||
<div class="off-stat__val">{{ headOfficeCount }}</div>
|
||||
<div class="off-stat__lbl">Head Office</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="off-stat regional">
|
||||
<fa-icon icon="sitemap" class="off-stat__ic"></fa-icon>
|
||||
<div class="off-stat__body">
|
||||
<div class="off-stat__val">{{ regionalCount }}</div>
|
||||
<div class="off-stat__lbl">Regional / Zonal</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="off-stat branch">
|
||||
<fa-icon icon="map-marker-alt" class="off-stat__ic"></fa-icon>
|
||||
<div class="off-stat__body">
|
||||
<div class="off-stat__val">{{ branchCount }}</div>
|
||||
<div class="off-stat__lbl">Branches</div>
|
||||
</div>
|
||||
|
||||
@if (office) {
|
||||
<div class="flex-48">
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
<div class="layout-row">
|
||||
<mat-card-title>
|
||||
{{ office.name }}
|
||||
</mat-card-title>
|
||||
<div class="flex align-end">
|
||||
<button mat-icon-button (click)="closeOffice()">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<mat-tab-group>
|
||||
<mat-tab label="General">
|
||||
<div class="layout-row-wrap">
|
||||
@if (office.parentId) {
|
||||
<div class="flex-50 mat-body-strong">
|
||||
{{ 'Parent Office' | translate }}
|
||||
</div>
|
||||
}
|
||||
@if (office.parentId) {
|
||||
<div class="flex-50">
|
||||
{{ office.parentName }}
|
||||
</div>
|
||||
}
|
||||
<div class="flex-50 mat-body-strong">
|
||||
{{ ' Opened On' | translate }}
|
||||
</div>
|
||||
<div class="flex-50">
|
||||
{{ office.openingDate | date }}
|
||||
</div>
|
||||
@if (office.externalId) {
|
||||
<div class="flex-50 mat-body-strong">
|
||||
{{ 'External Id' | translate }}
|
||||
</div>
|
||||
}
|
||||
@if (office.externalId) {
|
||||
<div class="flex-50">
|
||||
<mifosx-external-identifier externalId="{{ office.externalId }}"></mifosx-external-identifier>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-tab>
|
||||
@for (dataTable of dataTablesData; track dataTable) {
|
||||
<mat-tab label="{{ dataTable.registeredTableName }}"></mat-tab>
|
||||
}
|
||||
</mat-tab-group>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar (search + view toggle) -->
|
||||
<div class="off-toolbar">
|
||||
<div class="off-toolbar__search">
|
||||
<fa-icon icon="search" class="off-toolbar__search-ic"></fa-icon>
|
||||
<input type="text" placeholder="Search by name, external ID, parent office…" [(ngModel)]="search" />
|
||||
@if (search) {
|
||||
<button class="off-toolbar__clear" (click)="clearSearch()" matTooltip="Clear">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="off-view-toggle">
|
||||
<button
|
||||
class="off-view-toggle__btn"
|
||||
[class.active]="viewMode === 'list'"
|
||||
(click)="viewMode = 'list'"
|
||||
matTooltip="List View"
|
||||
>
|
||||
<fa-icon icon="list"></fa-icon>
|
||||
<span>List</span>
|
||||
</button>
|
||||
<button
|
||||
class="off-view-toggle__btn"
|
||||
[class.active]="viewMode === 'tree'"
|
||||
(click)="viewMode = 'tree'"
|
||||
matTooltip="Hierarchy / Tree View"
|
||||
>
|
||||
<fa-icon icon="sitemap"></fa-icon>
|
||||
<span>Tree</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ───── List View ───── -->
|
||||
@if (viewMode === 'list') {
|
||||
<div class="off-card">
|
||||
<div class="off-card__head">
|
||||
<div class="off-card__title">
|
||||
<fa-icon icon="list" class="off-card__title-ic"></fa-icon>
|
||||
Offices
|
||||
<span class="off-card__count">{{ filteredRows.length }} of {{ rows.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="off-table-wrap">
|
||||
<table class="off-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="num">#</th>
|
||||
<th>Office Name</th>
|
||||
<th>External ID</th>
|
||||
<th>Parent Office</th>
|
||||
<th>Opened On</th>
|
||||
<th>Sub-Offices</th>
|
||||
<th class="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track trackRow($index, row); let i = $index) {
|
||||
<tr>
|
||||
<td class="num">{{ i + 1 }}</td>
|
||||
<td>
|
||||
<div class="off-name">
|
||||
@if (!row.parentId) {
|
||||
<span class="off-pill head">HO</span>
|
||||
} @else if (row.childCount > 0) {
|
||||
<span class="off-pill regional">Reg</span>
|
||||
} @else {
|
||||
<span class="off-pill branch">Br</span>
|
||||
}
|
||||
<span>{{ row.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono">{{ row.externalId || '—' }}</td>
|
||||
<td>{{ row.parentName || '—' }}</td>
|
||||
<td>{{ row.openingDate || '—' }}</td>
|
||||
<td class="num">
|
||||
@if (row.childCount > 0) {
|
||||
<span class="off-chip">{{ row.childCount }}</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="off-act view" (click)="viewOffice(row)" matTooltip="View office">
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
class="off-act edit"
|
||||
(click)="editOffice(row)"
|
||||
matTooltip="Edit office"
|
||||
*mifosxHasPermission="'UPDATE_OFFICE'"
|
||||
>
|
||||
<fa-icon icon="pencil-alt"></fa-icon>
|
||||
</button>
|
||||
<button class="off-act more" [matMenuTriggerFor]="rowMenu" matTooltip="More actions">
|
||||
<fa-icon icon="ellipsis-v"></fa-icon>
|
||||
</button>
|
||||
<mat-menu #rowMenu="matMenu" xPosition="before" class="off-menu">
|
||||
<button mat-menu-item (click)="viewOffice(row)">
|
||||
<fa-icon icon="eye" class="off-menu__ic"></fa-icon>
|
||||
View Details
|
||||
</button>
|
||||
<button mat-menu-item (click)="editOffice(row)" *mifosxHasPermission="'UPDATE_OFFICE'">
|
||||
<fa-icon icon="pencil-alt" class="off-menu__ic"></fa-icon>
|
||||
Edit Office
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="deleteOffice(row)"
|
||||
[disabled]="row.childCount > 0"
|
||||
*mifosxHasPermission="'DELETE_OFFICE'"
|
||||
>
|
||||
<fa-icon icon="trash" class="off-menu__ic warn"></fa-icon>
|
||||
Delete Office
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (filteredRows.length === 0) {
|
||||
<tr>
|
||||
<td colspan="7" class="off-empty">No offices match the current filter.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ───── Tree View ───── -->
|
||||
@if (viewMode === 'tree') {
|
||||
<div class="off-card">
|
||||
<div class="off-card__head">
|
||||
<div class="off-card__title">
|
||||
<fa-icon icon="sitemap" class="off-card__title-ic"></fa-icon>
|
||||
Hierarchy
|
||||
</div>
|
||||
<button class="off-btn ghost small" (click)="toggleExpandCollapse()">
|
||||
<fa-icon [icon]="isTreeExpanded ? 'compress-alt' : 'expand-alt'"></fa-icon>
|
||||
{{ isTreeExpanded ? 'Collapse All' : 'Expand All' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="off-tree-wrap">
|
||||
<mat-tree [dataSource]="nestedTreeDataSource" [treeControl]="nestedTreeControl" class="off-tree">
|
||||
<mat-tree-node *matTreeNodeDef="let node" matTreeNodeToggle>
|
||||
<div class="off-tree-row leaf">
|
||||
<span class="off-tree-row__indent"></span>
|
||||
<fa-icon icon="map-marker-alt" class="off-tree-row__ic branch"></fa-icon>
|
||||
<span class="off-tree-row__name">{{ node.name }}</span>
|
||||
<ng-container *ngTemplateOutlet="rowActions; context: { node }"></ng-container>
|
||||
</div>
|
||||
</mat-tree-node>
|
||||
|
||||
<mat-nested-tree-node *matTreeNodeDef="let node; when: hasNestedChild">
|
||||
<div class="off-tree-row">
|
||||
<button class="off-tree-row__toggle" matTreeNodeToggle [attr.aria-label]="'toggle ' + node.name">
|
||||
<fa-icon [icon]="nestedTreeControl.isExpanded(node) ? 'chevron-down' : 'chevron-right'"></fa-icon>
|
||||
</button>
|
||||
<fa-icon
|
||||
[icon]="node.parentId ? 'sitemap' : 'university'"
|
||||
class="off-tree-row__ic"
|
||||
[class.head]="!node.parentId"
|
||||
[class.regional]="!!node.parentId"
|
||||
></fa-icon>
|
||||
<span class="off-tree-row__name">{{ node.name }}</span>
|
||||
@if (rowForNode(node); as r) {
|
||||
<span class="off-chip tree">{{ r.childCount }} sub</span>
|
||||
}
|
||||
<ng-container *ngTemplateOutlet="rowActions; context: { node }"></ng-container>
|
||||
</div>
|
||||
<ul [class.off-tree__hidden]="!nestedTreeControl.isExpanded(node)">
|
||||
<ng-container matTreeNodeOutlet></ng-container>
|
||||
</ul>
|
||||
</mat-nested-tree-node>
|
||||
</mat-tree>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<ng-template #templateButtonTreeView let-popover="popover">
|
||||
<h2>{{ 'labels.heading.Tree View' | translate }}</h2>
|
||||
<p class="mw300">{{ 'labels.text.Launch an alternative view for office hierarchy' | 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(); previousStep()">
|
||||
{{ 'labels.buttons.Back' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="popover.close(); showPopover(templateButtonCreateOffice, buttonCreateOffice, 'bottom', true)"
|
||||
>
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #templateButtonCreateOffice let-popover="popover">
|
||||
<h2>{{ 'labels.heading.Create Office' | translate }}</h2>
|
||||
<p class="mw300">{{ 'labels.text.Create new offices' | 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(); showPopover(templateButtonTreeView, buttonTreeView, 'bottom', true)"
|
||||
>
|
||||
{{ 'labels.buttons.Back' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="popover.close(); showPopover(templateButtonImportOffices, buttonImportOffices, 'bottom', true)"
|
||||
>
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #templateButtonImportOffices let-popover="popover">
|
||||
<h2>{{ 'labels.heading.Import Offices' | translate }}</h2>
|
||||
<p class="mw300">{{ 'labels.text.Launch the Bulk import/Offices' | 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(); showPopover(templateButtonCreateOffice, buttonCreateOffice, 'bottom', true)"
|
||||
>
|
||||
{{ 'labels.buttons.Back' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="popover.close(); showPopover(templateFilter, filter, 'bottom', true)"
|
||||
>
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #templateFilter let-popover="popover">
|
||||
<h4>{{ 'labels.heading.Search bar to filter offices.' | translate }}</h4>
|
||||
<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(); showPopover(templateButtonImportOffices, buttonImportOffices, 'bottom', true)"
|
||||
>
|
||||
{{ 'labels.buttons.Back' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="popover.close(); showPopover(templateTableOffices, tableOffices, 'top', true)"
|
||||
>
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #templateTableOffices let-popover="popover">
|
||||
<h4>{{ 'labels.heading.List of offices in the organization.' | translate }}</h4>
|
||||
<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(); showPopover(templateFilter, filter, 'bottom', true)"
|
||||
>
|
||||
{{ 'labels.buttons.Back' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); nextStep()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Per-node action cluster — reused across leaf and nested nodes -->
|
||||
<ng-template #rowActions let-node="node">
|
||||
@if (rowForNode(node); as r) {
|
||||
<div class="off-tree-row__acts">
|
||||
<button class="off-act view" (click)="viewOffice(r)" matTooltip="View">
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
</button>
|
||||
<button class="off-act edit" (click)="editOffice(r)" matTooltip="Edit" *mifosxHasPermission="'UPDATE_OFFICE'">
|
||||
<fa-icon icon="pencil-alt"></fa-icon>
|
||||
</button>
|
||||
<button class="off-act more" [matMenuTriggerFor]="nodeMenu" matTooltip="More">
|
||||
<fa-icon icon="ellipsis-v"></fa-icon>
|
||||
</button>
|
||||
<mat-menu #nodeMenu="matMenu" xPosition="before" class="off-menu">
|
||||
<button mat-menu-item (click)="viewOffice(r)">
|
||||
<fa-icon icon="eye" class="off-menu__ic"></fa-icon>
|
||||
View Details
|
||||
</button>
|
||||
<button mat-menu-item (click)="editOffice(r)" *mifosxHasPermission="'UPDATE_OFFICE'">
|
||||
<fa-icon icon="pencil-alt" class="off-menu__ic"></fa-icon>
|
||||
Edit Office
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="deleteOffice(r)"
|
||||
[disabled]="r.childCount > 0"
|
||||
*mifosxHasPermission="'DELETE_OFFICE'"
|
||||
>
|
||||
<fa-icon icon="trash" class="off-menu__ic warn"></fa-icon>
|
||||
Delete Office
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@@ -6,38 +6,644 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@use '../../shared/styles/list-layout' as *;
|
||||
@use 'sass:color';
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
$navy: #0d1b2a;
|
||||
$blue: #1e88e5;
|
||||
$blue-d: #1e40af;
|
||||
$green: #10b981;
|
||||
$amber: #f59e0b;
|
||||
$red: #ef4444;
|
||||
$purple: #7c3aed;
|
||||
|
||||
.select-row:hover {
|
||||
cursor: pointer;
|
||||
.off-container {
|
||||
padding: 20px 24px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
background: #f0f2f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
// ─── Header ───
|
||||
.off-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, $blue 0%, $purple 100%);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
div {
|
||||
margin: 1rem 0;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
.off-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
|
||||
.office-tree {
|
||||
ul,
|
||||
li {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
list-style-type: none;
|
||||
fa-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
li {
|
||||
span {
|
||||
cursor: pointer;
|
||||
&.ghost {
|
||||
background: #fff;
|
||||
border-color: #d0d5dd;
|
||||
color: #475569;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #94a3b8;
|
||||
color: $navy;
|
||||
}
|
||||
}
|
||||
|
||||
.office-tree-invisible {
|
||||
&.primary {
|
||||
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
|
||||
border-color: $blue-d;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px -2px rgb(30 64 175 / 35%);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px -3px rgb(30 64 175 / 45%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── KPI strip ───
|
||||
.off-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.off-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
||||
|
||||
&__ic {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__val {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__lbl {
|
||||
margin-top: 4px;
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.total .off-stat__ic {
|
||||
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
|
||||
}
|
||||
|
||||
&.head .off-stat__ic {
|
||||
background: linear-gradient(135deg, $purple 0%, #4338ca 100%);
|
||||
}
|
||||
|
||||
&.regional .off-stat__ic {
|
||||
background: linear-gradient(135deg, $amber 0%, #c2410c 100%);
|
||||
}
|
||||
|
||||
&.branch .off-stat__ic {
|
||||
background: linear-gradient(135deg, $green 0%, #047857 100%);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Toolbar ───
|
||||
.off-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&__search {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
max-width: 480px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 9px 36px;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
color: $navy;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $blue;
|
||||
box-shadow: 0 0 0 3px rgb(30 136 229 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
&-ic {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__clear {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #94a3b8;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
color: $red;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.off-view-toggle {
|
||||
display: inline-flex;
|
||||
background: #fff;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
|
||||
&__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
fa-icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
background: #f1f5f9;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 3px rgb(30 64 175 / 25%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Card wrapper ───
|
||||
.off-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 6%);
|
||||
overflow: hidden;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $navy;
|
||||
|
||||
&-ic {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
&__count {
|
||||
margin-left: 8px;
|
||||
padding: 2px 10px;
|
||||
background: #eff6ff;
|
||||
color: $blue-d;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── List view table ───
|
||||
.off-table-wrap {
|
||||
overflow: auto;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.off-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
text-align: left;
|
||||
padding: 11px 14px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
&.num {
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.actions {
|
||||
width: 150px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
color: $navy;
|
||||
|
||||
&.num {
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&.actions {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
transition: background 0.12s;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.off-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.off-pill {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&.head {
|
||||
background: #ede9fe;
|
||||
color: $purple;
|
||||
}
|
||||
|
||||
&.regional {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&.branch {
|
||||
background: #ecfdf5;
|
||||
color: $green;
|
||||
}
|
||||
}
|
||||
|
||||
.off-chip {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
background: #eff6ff;
|
||||
color: $blue-d;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
|
||||
&.tree {
|
||||
margin-left: 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.off-act {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 0 2px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
transition: all 0.12s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.view:hover:not(:disabled) {
|
||||
background: #f0f9ff;
|
||||
border-color: #bae6fd;
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
&.edit:hover:not(:disabled) {
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
color: $blue-d;
|
||||
}
|
||||
|
||||
&.more:hover:not(:disabled) {
|
||||
background: #f5f3ff;
|
||||
border-color: #ddd6fe;
|
||||
color: $purple;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.off-menu__ic {
|
||||
width: 18px;
|
||||
margin-right: 10px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
|
||||
&.warn {
|
||||
color: $red;
|
||||
}
|
||||
}
|
||||
|
||||
.off-empty {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
// ─── Tree view ───
|
||||
.off-tree-wrap {
|
||||
padding: 14px 18px 18px;
|
||||
}
|
||||
|
||||
.off-tree {
|
||||
ul {
|
||||
list-style: none;
|
||||
padding-left: 26px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.off-tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
margin: 4px 0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.12s;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
transition: all 0.12s;
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
color: $navy;
|
||||
}
|
||||
}
|
||||
|
||||
&__indent {
|
||||
width: 26px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&__ic {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.head {
|
||||
background: $purple;
|
||||
}
|
||||
|
||||
&.regional {
|
||||
background: $amber;
|
||||
}
|
||||
|
||||
&.branch {
|
||||
background: $green;
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
color: $navy;
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__acts {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.leaf {
|
||||
background: #fafbff;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Responsive ───
|
||||
@media (width <= 900px) {
|
||||
.off-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.off-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.off-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
&__search {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.off-view-toggle {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.off-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.off-table thead th {
|
||||
font-size: 9.5px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.off-table tbody td {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,33 +6,12 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, TemplateRef, ElementRef, ViewChild, AfterViewInit, inject } from '@angular/core';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
||||
import {
|
||||
MatTableDataSource,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { of } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { PopoverService } from '../../configuration-wizard/popover/popover.service';
|
||||
import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service';
|
||||
import { UntypedFormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { OfficeNode } from './office-node.model';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule, UntypedFormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { NestedTreeControl } from '@angular/cdk/tree';
|
||||
import {
|
||||
MatTreeNestedDataSource,
|
||||
@@ -43,20 +22,35 @@ import {
|
||||
MatNestedTreeNode,
|
||||
MatTreeNodeOutlet
|
||||
} from '@angular/material/tree';
|
||||
import { OfficeTreeService } from './office-tree-service.service';
|
||||
import { TreeControlService } from 'app/shared/common-logic/tree-control.service';
|
||||
import { MatButtonToggleGroup, MatButtonToggle } from '@angular/material/button-toggle';
|
||||
import { of } from 'rxjs';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { MatCard, MatCardContent, MatCardTitle } from '@angular/material/card';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { MatTabGroup, MatTab } from '@angular/material/tabs';
|
||||
import { ExternalIdentifierComponent } from '../../shared/external-identifier/external-identifier.component';
|
||||
import { DateFormatPipe } from '../../pipes/date-format.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import { OfficeNode } from './office-node.model';
|
||||
import { OfficeTreeService } from './office-tree-service.service';
|
||||
import { TreeControlService } from 'app/shared/common-logic/tree-control.service';
|
||||
import { OrganizationService } from '../organization.service';
|
||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
|
||||
import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component';
|
||||
|
||||
interface OfficeRow {
|
||||
id: string;
|
||||
name: string;
|
||||
externalId: string;
|
||||
parentName: string;
|
||||
parentId: string;
|
||||
openingDate: string;
|
||||
hierarchy: string;
|
||||
childCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offices component.
|
||||
* Manage Offices — Nexora redesign.
|
||||
*
|
||||
* Replaces the legacy mat-table / mat-tree layout with a hero header, KPI
|
||||
* strip, and per-row / per-node edit + delete actions. Delete is guarded by a
|
||||
* staff-count check so a branch with employees can't be removed.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-offices',
|
||||
@@ -64,218 +58,261 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
styleUrls: ['./offices.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatButtonToggleGroup,
|
||||
MatButtonToggle,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
FaIconComponent,
|
||||
MatTable,
|
||||
MatSort,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatSortHeader,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
MatPaginator,
|
||||
MatTooltip,
|
||||
MatMenuModule,
|
||||
MatTree,
|
||||
MatTreeNodeDef,
|
||||
MatTreeNode,
|
||||
MatTreeNodeToggle,
|
||||
MatIconButton,
|
||||
MatNestedTreeNode,
|
||||
MatTreeNodeOutlet,
|
||||
MatCardTitle,
|
||||
MatTabGroup,
|
||||
MatTab,
|
||||
ExternalIdentifierComponent,
|
||||
DatePipe,
|
||||
DateFormatPipe
|
||||
MatTreeNodeOutlet
|
||||
]
|
||||
})
|
||||
export class OfficesComponent implements OnInit, AfterViewInit {
|
||||
export class OfficesComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private dialog = inject(MatDialog);
|
||||
private organizationService = inject(OrganizationService);
|
||||
private officeTreeService = inject(OfficeTreeService);
|
||||
private treeControlService = inject(TreeControlService);
|
||||
private configurationWizardService = inject(ConfigurationWizardService);
|
||||
private popoverService = inject(PopoverService);
|
||||
|
||||
/** Button toggle group form control for type of view. (list/tree) */
|
||||
/** 'list' | 'tree' view toggle. */
|
||||
viewMode: 'list' | 'tree' = 'list';
|
||||
|
||||
/** Search box value (filters both list + tree). */
|
||||
search = '';
|
||||
|
||||
/** Form control kept around for tree-control compatibility. */
|
||||
viewGroup = new UntypedFormControl('listView');
|
||||
|
||||
/** Offices data. */
|
||||
officesData: any;
|
||||
/** Columns to be displayed in offices table. */
|
||||
displayedColumns: string[] = [
|
||||
'name',
|
||||
'externalId',
|
||||
'parentName',
|
||||
'openingDate'
|
||||
];
|
||||
/** Data source for offices table. */
|
||||
dataSource: MatTableDataSource<any>;
|
||||
/** Nested tree control for offices tree. */
|
||||
/** Raw offices payload from the resolver. */
|
||||
officesData: any[] = [];
|
||||
|
||||
/** Flattened rows used in the list view. */
|
||||
rows: OfficeRow[] = [];
|
||||
|
||||
/** Tree state. */
|
||||
nestedTreeControl: NestedTreeControl<OfficeNode>;
|
||||
/** Nested tree data source for offices tree. */
|
||||
nestedTreeDataSource: MatTreeNestedDataSource<OfficeNode>;
|
||||
/** Selected Office. */
|
||||
office: OfficeNode;
|
||||
/** Office data tables. */
|
||||
dataTablesData: any;
|
||||
/** Flag to check if tree is expanded or collapsed. */
|
||||
isTreeExpanded = true;
|
||||
|
||||
/** Paginator for offices table. */
|
||||
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||
/** Sorter for offices table. */
|
||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||
/** Per-office staff count (lazy-fetched on first delete attempt). */
|
||||
private staffCountCache = new Map<string, number>();
|
||||
|
||||
/* Reference of tree view button */
|
||||
@ViewChild('buttonTreeView') buttonTreeView: ElementRef<any>;
|
||||
/* Template for popover on tree view button */
|
||||
@ViewChild('templateButtonTreeView') templateButtonTreeView: TemplateRef<any>;
|
||||
/* Reference of offices table */
|
||||
@ViewChild('tableOffices') tableOffices: ElementRef<any>;
|
||||
/* Template for popover on offices table */
|
||||
@ViewChild('templateTableOffices') templateTableOffices: TemplateRef<any>;
|
||||
|
||||
/**
|
||||
* Retrieves the offices data from `resolve`.
|
||||
* @param {ActivatedRoute} route Activated Route.
|
||||
* @param {Router} router Router.
|
||||
* @param {OfficeTreeService} officeTreeService office tree service.
|
||||
* @param {ConfigurationWizardService} configurationWizardService ConfigurationWizard Service.
|
||||
* @param {PopoverService} popoverService PopoverService.
|
||||
*/
|
||||
constructor() {
|
||||
const officeTreeService = this.officeTreeService;
|
||||
|
||||
this.route.data.subscribe((data: { offices: any; officeDataTables: any }) => {
|
||||
this.officesData = data.offices;
|
||||
officeTreeService.initialize(this.officesData);
|
||||
this.dataTablesData = data.officeDataTables;
|
||||
this.route.data.subscribe((data: { offices: any }) => {
|
||||
this.officesData = data.offices || [];
|
||||
this.officeTreeService.initialize(this.officesData);
|
||||
this.buildRows();
|
||||
});
|
||||
this.nestedTreeControl = new NestedTreeControl<OfficeNode>(this.getChildren);
|
||||
this.nestedTreeControl = new NestedTreeControl<OfficeNode>((node: OfficeNode) => of(node.children));
|
||||
this.nestedTreeDataSource = new MatTreeNestedDataSource<OfficeNode>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters data in offices table based on passed value.
|
||||
* @param {string} filterValue Value to filter data.
|
||||
*/
|
||||
applyFilter(filterValue: string) {
|
||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the offices table.
|
||||
*/
|
||||
ngOnInit() {
|
||||
this.setOffices();
|
||||
this.officeTreeService.treeDataChange.subscribe((officeTreeData: OfficeNode[]) => {
|
||||
this.nestedTreeDataSource.data = officeTreeData;
|
||||
this.nestedTreeControl.expand(this.nestedTreeDataSource.data[0]);
|
||||
this.nestedTreeControl.dataNodes = officeTreeData;
|
||||
ngOnInit(): void {
|
||||
this.officeTreeService.treeDataChange.subscribe((tree: OfficeNode[]) => {
|
||||
this.nestedTreeDataSource.data = tree;
|
||||
if (tree[0]) this.nestedTreeControl.expand(tree[0]);
|
||||
this.nestedTreeControl.dataNodes = tree;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the data source, paginator and sorter for offices table.
|
||||
*/
|
||||
setOffices() {
|
||||
this.dataSource = new MatTableDataSource(this.officesData);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
}
|
||||
|
||||
/**
|
||||
* View selected office.
|
||||
* @param {OfficeNode} office Office to be viewed.
|
||||
*/
|
||||
viewOfficeNode(office: OfficeNode) {
|
||||
if (office.id) {
|
||||
this.office = office;
|
||||
} else {
|
||||
delete this.office;
|
||||
/** Build the flat row list with cached child counts (used to gate deletes). */
|
||||
private buildRows(): void {
|
||||
const childByParent = new Map<string, number>();
|
||||
for (const o of this.officesData) {
|
||||
if (o.parentId != null) {
|
||||
const k = String(o.parentId);
|
||||
childByParent.set(k, (childByParent.get(k) || 0) + 1);
|
||||
}
|
||||
}
|
||||
this.rows = this.officesData.map((o) => ({
|
||||
id: String(o.id),
|
||||
name: o.name || '',
|
||||
externalId: o.externalId || '',
|
||||
parentName: o.parentName || '',
|
||||
parentId: o.parentId != null ? String(o.parentId) : '',
|
||||
openingDate: this.formatDate(o.openingDate),
|
||||
hierarchy: o.hierarchy || '',
|
||||
childCount: childByParent.get(String(o.id)) || 0
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the current view for office
|
||||
*/
|
||||
closeOffice() {
|
||||
delete this.office;
|
||||
// ───── KPI helpers ─────
|
||||
get totalOffices(): number {
|
||||
return this.rows.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if selected node in tree has children.
|
||||
*/
|
||||
hasNestedChild = (_: number, node: OfficeNode) => node.children.length;
|
||||
|
||||
/**
|
||||
* Gets the children of selected node in tree.
|
||||
*/
|
||||
private getChildren = (node: OfficeNode) => of(node.children);
|
||||
|
||||
/**
|
||||
* Popover function
|
||||
* @param template TemplateRef<any>.
|
||||
* @param target HTMLElement | ElementRef<any>.
|
||||
* @param position String.
|
||||
* @param backdrop Boolean.
|
||||
*/
|
||||
showPopover(
|
||||
template: TemplateRef<any>,
|
||||
target: HTMLElement | ElementRef<any>,
|
||||
position: string,
|
||||
backdrop: boolean
|
||||
): void {
|
||||
setTimeout(() => this.popoverService.open(template, target, position, backdrop, {}), 200);
|
||||
/** Branch = office with no children. */
|
||||
get branchCount(): number {
|
||||
return this.rows.filter((r) => r.childCount === 0).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* To show popover.
|
||||
*/
|
||||
ngAfterViewInit() {
|
||||
if (this.configurationWizardService.showOfficeList) {
|
||||
setTimeout(() => {
|
||||
this.showPopover(this.templateButtonTreeView, this.buttonTreeView.nativeElement, 'bottom', true);
|
||||
});
|
||||
}
|
||||
if (this.configurationWizardService.showOfficeTable) {
|
||||
setTimeout(() => {
|
||||
this.showPopover(this.templateTableOffices, this.tableOffices.nativeElement, 'top', true);
|
||||
});
|
||||
}
|
||||
/** Regional = office with children but a parent. */
|
||||
get regionalCount(): number {
|
||||
return this.rows.filter((r) => r.childCount > 0 && !!r.parentId).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Next Step (Create Office Form) Configuration Wizard.
|
||||
*/
|
||||
nextStep() {
|
||||
this.configurationWizardService.showOfficeList = false;
|
||||
this.configurationWizardService.showOfficeTable = false;
|
||||
this.configurationWizardService.showOfficeForm = true;
|
||||
this.router.navigate(['/organization/offices/create']);
|
||||
get headOfficeCount(): number {
|
||||
return this.rows.filter((r) => !r.parentId).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous Step ( Manage Offices Page) Configuration Wizard.
|
||||
*/
|
||||
previousStep() {
|
||||
this.configurationWizardService.showOfficeList = false;
|
||||
this.configurationWizardService.showOfficeTable = false;
|
||||
this.configurationWizardService.showCreateOffice = true;
|
||||
this.router.navigate(['/organization']);
|
||||
// ───── Filter ─────
|
||||
get filteredRows(): OfficeRow[] {
|
||||
const q = this.search.trim().toLowerCase();
|
||||
if (!q) return this.rows;
|
||||
return this.rows.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
r.externalId.toLowerCase().includes(q) ||
|
||||
r.parentName.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand and Collapse the tree
|
||||
*/
|
||||
toggleExpandCollapse() {
|
||||
clearSearch(): void {
|
||||
this.search = '';
|
||||
}
|
||||
|
||||
// ───── Tree helpers ─────
|
||||
hasNestedChild = (_: number, node: OfficeNode) => node.children.length > 0;
|
||||
|
||||
toggleExpandCollapse(): void {
|
||||
this.isTreeExpanded = this.treeControlService.toggleExpandCollapse(this.nestedTreeControl, this.isTreeExpanded);
|
||||
}
|
||||
|
||||
// ───── Row / node actions ─────
|
||||
viewOffice(row: { id: string }): void {
|
||||
this.router.navigate(
|
||||
[
|
||||
row.id,
|
||||
'general'
|
||||
],
|
||||
{ relativeTo: this.route }
|
||||
);
|
||||
}
|
||||
|
||||
editOffice(row: { id: string }): void {
|
||||
this.router.navigate(
|
||||
[
|
||||
row.id,
|
||||
'edit'
|
||||
],
|
||||
{ relativeTo: this.route }
|
||||
);
|
||||
}
|
||||
|
||||
createOffice(): void {
|
||||
this.router.navigate(['create'], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
importOffices(): void {
|
||||
this.router.navigate([
|
||||
'/organization',
|
||||
'bulk-import',
|
||||
'Offices'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-checks then deletes the office.
|
||||
*
|
||||
* Blocks early when:
|
||||
* - the office has child offices (cascading delete unsafe),
|
||||
* - the office has any staff assigned (per requirement).
|
||||
*/
|
||||
deleteOffice(row: OfficeRow): void {
|
||||
if (row.childCount > 0) {
|
||||
this.showError(
|
||||
`"${row.name}" has ${row.childCount} sub-office${row.childCount === 1 ? '' : 's'} attached. Please move or remove its sub-offices before deleting.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Staff check goes to the server (cached per office for the session).
|
||||
if (this.staffCountCache.has(row.id)) {
|
||||
this.confirmAndDelete(row, this.staffCountCache.get(row.id) as number);
|
||||
return;
|
||||
}
|
||||
this.organizationService.getStaff(row.id).subscribe({
|
||||
next: (staff: any[]) => {
|
||||
const count = (staff || []).length;
|
||||
this.staffCountCache.set(row.id, count);
|
||||
this.confirmAndDelete(row, count);
|
||||
},
|
||||
error: () => {
|
||||
// If we can't reach the staff endpoint, still let the server enforce
|
||||
// referential integrity — show the standard delete dialog.
|
||||
this.confirmAndDelete(row, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private confirmAndDelete(row: OfficeRow, staffCount: number): void {
|
||||
if (staffCount > 0) {
|
||||
this.showError(
|
||||
`"${row.name}" has ${staffCount} employee${staffCount === 1 ? '' : 's'} assigned. Reassign them before deleting this office.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const ref = this.dialog.open(DeleteDialogComponent, {
|
||||
data: { deleteContext: `office "${row.name}"` }
|
||||
});
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (!response?.delete) return;
|
||||
this.organizationService.deleteOffice(row.id).subscribe({
|
||||
next: () => {
|
||||
this.showSuccess('Office Removed', `"${row.name}" has been deleted.`);
|
||||
this.refreshOffices();
|
||||
},
|
||||
error: (err: any) => {
|
||||
const apiMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.developerMessage || 'Please try again.';
|
||||
this.showError(`Failed to remove "${row.name}". ${apiMsg}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private refreshOffices(): void {
|
||||
this.organizationService.getOffices().subscribe((offices: any[]) => {
|
||||
this.officesData = offices || [];
|
||||
this.officeTreeService.initialize(this.officesData);
|
||||
this.buildRows();
|
||||
this.staffCountCache.clear();
|
||||
});
|
||||
}
|
||||
|
||||
// ───── Dialog helpers (Nexora standard) ─────
|
||||
private showSuccess(title: string, message: string): void {
|
||||
this.dialog.open(SuccessDialogComponent, {
|
||||
data: { title, message, buttonLabel: 'OK' },
|
||||
panelClass: 'sd-panel'
|
||||
});
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.dialog.open(ErrorDialogComponent, { data: message });
|
||||
}
|
||||
|
||||
// ───── Misc ─────
|
||||
trackRow = (_i: number, row: OfficeRow) => row.id;
|
||||
|
||||
/** Look up an OfficeRow for a tree node so menu items can call the same handlers. */
|
||||
rowForNode(node: OfficeNode): OfficeRow | undefined {
|
||||
return this.rows.find((r) => r.id === String(node.id));
|
||||
}
|
||||
|
||||
private formatDate(d: any): string {
|
||||
if (!d) return '';
|
||||
if (Array.isArray(d) && d.length >= 3) {
|
||||
const dt = new Date(d[0], d[1] - 1, d[2]);
|
||||
return dt.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
const dt = new Date(d);
|
||||
return Number.isNaN(dt.getTime())
|
||||
? String(d)
|
||||
: dt.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,8 +707,17 @@ const routes: Routes = [
|
||||
]
|
||||
},
|
||||
{
|
||||
// Manage Holidays consolidated into the unified Bank Calendar UI.
|
||||
// Flat redirect on the parent so /organization/holidays AND any
|
||||
// legacy sub-path are caught and forwarded reliably (a nested
|
||||
// path: '' redirect doesn't always fire under hash-strategy routing).
|
||||
path: 'holidays',
|
||||
data: { title: 'Manage Holidays', breadcrumb: 'Manage Holidays' },
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/bank-calendar'
|
||||
},
|
||||
{
|
||||
path: 'holidays-legacy',
|
||||
data: { title: 'Manage Holidays (Legacy)', breadcrumb: 'Manage Holidays' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
|
||||
@@ -48,9 +48,9 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Manage Holidays -->
|
||||
<!-- Manage Holidays (opens unified Bank Calendar UI) -->
|
||||
<div #holidays>
|
||||
<a class="org-card" [routerLink]="['holidays']" *mifosxHasPermission="'READ_HOLIDAY'">
|
||||
<a class="org-card" [routerLink]="['/bank-calendar']" *mifosxHasPermission="'READ_HOLIDAY'">
|
||||
<div class="org-card__icon org-card__icon--green">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
</div>
|
||||
|
||||
@@ -139,6 +139,17 @@ export class OrganizationService {
|
||||
return this.http.put(`/offices/${officeId}`, office);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an office. Fineract enforces referential integrity server-side
|
||||
* (rejects when staff / sub-offices / linked clients exist) — the UI also
|
||||
* pre-checks staff count for a better experience.
|
||||
* @param {string} officeId Office Id to remove.
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
deleteOffice(officeId: string): Observable<any> {
|
||||
return this.http.delete(`/offices/${officeId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
<strong>{{ 'labels.commons.Minimum Value must be' | translate }} {{ formfield.min }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
@if (form.controls[formfield.controlName].hasError('minWords')) {
|
||||
<mat-error>
|
||||
Must contain at least
|
||||
<strong> {{ form.controls[formfield.controlName].errors?.['minWords']?.requiredWords || 2 }} words </strong>
|
||||
(whitespace doesn't count).
|
||||
</mat-error>
|
||||
}
|
||||
@if (formfield.controlType === 'select') {
|
||||
<mat-select [formControlName]="formfield.controlName" [required]="formfield.required">
|
||||
@for (option of formfield.options.data; track option) {
|
||||
|
||||
@@ -201,7 +201,9 @@ import {
|
||||
faQrcode,
|
||||
faRedo,
|
||||
faSignature,
|
||||
faPercentage
|
||||
faPercentage,
|
||||
faCompressAlt,
|
||||
faExpandAlt
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
/**
|
||||
@@ -409,7 +411,9 @@ export class IconsModule {
|
||||
faQrcode,
|
||||
faRedo,
|
||||
faSignature,
|
||||
faPercentage
|
||||
faPercentage,
|
||||
faCompressAlt,
|
||||
faExpandAlt
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,18 @@
|
||||
placeholder="{{ 'labels.text.Write a note' | translate }} ..."
|
||||
cdkTextareaAutosize
|
||||
cdkAutosizeMinRows="2"
|
||||
matTooltip="At least two words required — e.g. 'Hello User', 'Updated mobile', 'Re-KYC done'."
|
||||
matTooltipPosition="above"
|
||||
matTooltipShowDelay="300"
|
||||
(keydown)="onNoteKeyDown($event)"
|
||||
(input)="onNoteInput($event)"
|
||||
></textarea>
|
||||
@if (noteForm.controls['note'].touched && noteForm.controls['note'].hasError('required')) {
|
||||
<mat-error>Note cannot be empty.</mat-error>
|
||||
}
|
||||
@if (noteForm.controls['note'].touched && noteForm.controls['note'].hasError('minWords')) {
|
||||
<mat-error>Note must contain at least 2 words (whitespace doesn't count).</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<button type="submit" mat-raised-button class="flex-1" color="primary" [disabled]="!noteForm.valid">
|
||||
<fa-icon icon="plus" class="m-r-10" aria-hidden="true"></fa-icon>{{ 'labels.buttons.Add' | translate }}
|
||||
|
||||
@@ -15,8 +15,10 @@ import { LoansService } from 'app/loans/loans.service';
|
||||
import { SavingsService } from 'app/savings/savings.service';
|
||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
|
||||
import { minWordsValidator } from 'app/shared/validators/min-words.validator';
|
||||
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
@@ -29,6 +31,7 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CdkTextareaAutosize,
|
||||
FaIconComponent,
|
||||
MatTooltip,
|
||||
DateFormatPipe
|
||||
]
|
||||
})
|
||||
@@ -58,9 +61,14 @@ export class EntityNotesTabComponent implements OnInit {
|
||||
|
||||
createNoteForm() {
|
||||
this.noteForm = this.formBuilder.group({
|
||||
// Reject whitespace-only or single-word notes — at least two words so the
|
||||
// audit trail is meaningful (no "ok" / " " entries landing in the DB).
|
||||
note: [
|
||||
'',
|
||||
Validators.required
|
||||
[
|
||||
Validators.required,
|
||||
minWordsValidator(2)
|
||||
]
|
||||
]
|
||||
});
|
||||
}
|
||||
@@ -70,6 +78,42 @@ export class EntityNotesTabComponent implements OnInit {
|
||||
this.formRef.resetForm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Block whitespace as the very first character so the textarea never starts
|
||||
* with blanks. Catches Space/Tab pressed against an empty (or only-whitespace)
|
||||
* value before the OS even inserts the character.
|
||||
*/
|
||||
onNoteKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key !== ' ' && event.key !== 'Tab' && event.key !== 'Enter') return;
|
||||
const el = event.target as HTMLTextAreaElement;
|
||||
// Only intervene when the caret is at the start AND the existing value is
|
||||
// empty / whitespace-only — otherwise users typing legitimate spaces in the
|
||||
// middle of text are unaffected.
|
||||
if (el.selectionStart === 0 && el.value.trim() === '') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On every input change (typing or paste), strip leading whitespace and
|
||||
* collapse internal runs of spaces / tabs to a single space. Newlines are
|
||||
* preserved so multi-line notes still work.
|
||||
*/
|
||||
onNoteInput(event: Event): void {
|
||||
const el = event.target as HTMLTextAreaElement;
|
||||
const before = el.value;
|
||||
const after = before.replace(/^[ \t]+/, '').replace(/[ \t]{2,}/g, ' ');
|
||||
if (before !== after) {
|
||||
const cursorDelta = before.length - after.length;
|
||||
const pos = Math.max(0, (el.selectionStart ?? after.length) - cursorDelta);
|
||||
el.value = after;
|
||||
this.noteForm.get('note')!.setValue(after, { emitEvent: false });
|
||||
// Restore the caret position so the cursor doesn't jump to the end on
|
||||
// every keystroke (otherwise typing feels broken).
|
||||
el.setSelectionRange(pos, pos);
|
||||
}
|
||||
}
|
||||
|
||||
editNote(noteId: string, noteContent: string, index: number) {
|
||||
const editNoteDialogRef = this.dialog.open(FormDialogComponent, {
|
||||
data: {
|
||||
@@ -79,7 +123,9 @@ export class EntityNotesTabComponent implements OnInit {
|
||||
required: true,
|
||||
value: noteContent,
|
||||
controlType: 'input',
|
||||
label: this.translateService.instant('labels.inputs.Note')
|
||||
label: this.translateService.instant('labels.inputs.Note'),
|
||||
// Same rule as the inline Add Note form — at least 2 words.
|
||||
validators: [minWordsValidator(2)]
|
||||
}
|
||||
],
|
||||
layout: {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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 { AbstractControl, ValidatorFn, ValidationErrors } from '@angular/forms';
|
||||
|
||||
/**
|
||||
* Reject whitespace-only or single-word input.
|
||||
*
|
||||
* Counts contiguous non-whitespace runs as "words". Empty/whitespace-only
|
||||
* input returns the `minWords` error so an empty string is treated as
|
||||
* insufficient (callers can still chain `Validators.required` if they want a
|
||||
* separate "required" error key).
|
||||
*
|
||||
* @param min minimum number of words required (default: 2)
|
||||
*/
|
||||
export function minWordsValidator(min = 2): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const raw = control.value;
|
||||
if (raw == null) return null;
|
||||
const text = String(raw).trim();
|
||||
if (!text) {
|
||||
return { minWords: { requiredWords: min, actual: 0 } };
|
||||
}
|
||||
const words = text.split(/\s+/).filter((w) => w.length > 0);
|
||||
if (words.length < min) {
|
||||
return { minWords: { requiredWords: min, actual: words.length } };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<!--
|
||||
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="udd-root">
|
||||
<div class="udd-head">
|
||||
<div class="udd-head__left">
|
||||
<fa-icon icon="user-circle" class="udd-head__ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="udd-head__title">User Details</h2>
|
||||
<div class="udd-head__sub">Read-only summary of this user's profile</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="udd-x" (click)="close()" matTooltip="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="udd-loading">
|
||||
<fa-icon icon="spinner" class="fa-spin"></fa-icon>
|
||||
Loading…
|
||||
</div>
|
||||
} @else if (error) {
|
||||
<div class="udd-error">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
{{ error }}
|
||||
</div>
|
||||
} @else if (user) {
|
||||
<div class="udd-body">
|
||||
<!-- Identity card -->
|
||||
<div class="udd-id-card" [class.self]="user.isSelfServiceUser">
|
||||
<span class="udd-avatar">{{ initials }}</span>
|
||||
<div class="udd-id-card__txt">
|
||||
<div class="udd-id-card__name">{{ fullName }}</div>
|
||||
<div class="udd-id-card__meta">
|
||||
<span class="mono">@{{ user.username }}</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{{ user.officeName }}</span>
|
||||
@if (user.isSelfServiceUser) {
|
||||
<span class="udd-pill self">Self-Service</span>
|
||||
} @else {
|
||||
<span class="udd-pill staff">Staff</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact -->
|
||||
<div class="udd-section">
|
||||
<div class="udd-section__title">Contact</div>
|
||||
<div class="udd-grid">
|
||||
<div>
|
||||
<div class="udd-key">Email</div>
|
||||
<div class="udd-val mono">{{ user.email || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="udd-key">Mobile</div>
|
||||
<div class="udd-val mono">
|
||||
@if (user.mobileNo) {
|
||||
+91 {{ user.mobileNo }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Office & Staff -->
|
||||
<div class="udd-section">
|
||||
<div class="udd-section__title">Office & Staff</div>
|
||||
<div class="udd-grid">
|
||||
<div>
|
||||
<div class="udd-key">Office</div>
|
||||
<div class="udd-val">{{ user.officeName || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="udd-key">Linked Staff</div>
|
||||
<div class="udd-val">{{ user.staff?.displayName || user.staffDisplayName || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles -->
|
||||
<div class="udd-section">
|
||||
<div class="udd-section__title">Roles ({{ roleNames.length }})</div>
|
||||
@if (roleNames.length) {
|
||||
<div class="udd-roles">
|
||||
@for (r of roleNames; track r) {
|
||||
<span class="udd-role">{{ r }}</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="udd-val muted">No roles assigned.</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Security -->
|
||||
<div class="udd-section">
|
||||
<div class="udd-section__title">Security</div>
|
||||
<div class="udd-grid">
|
||||
<div>
|
||||
<div class="udd-key">Password Policy</div>
|
||||
<div class="udd-val">
|
||||
@if (user.passwordNeverExpires) {
|
||||
<fa-icon icon="check-circle" class="udd-ic ok"></fa-icon>
|
||||
Never expires
|
||||
} @else {
|
||||
<fa-icon icon="clock" class="udd-ic warn"></fa-icon>
|
||||
Expires per policy
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="udd-key">Last Password Change</div>
|
||||
<div class="udd-val">
|
||||
@if (lastPasswordUpdate) {
|
||||
{{ lastPasswordUpdate | date: 'd MMM y' }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="udd-actions">
|
||||
<button class="udd-btn ghost" (click)="close()">Close</button>
|
||||
<button class="udd-btn ghost warn" (click)="close('delete')" *mifosxHasPermission="'DELETE_USER'">
|
||||
<fa-icon icon="trash"></fa-icon>
|
||||
Delete
|
||||
</button>
|
||||
<button class="udd-btn ghost" (click)="close('change-password')">
|
||||
<fa-icon icon="key"></fa-icon>
|
||||
Change Password
|
||||
</button>
|
||||
<button class="udd-btn primary" (click)="close('edit')" *mifosxHasPermission="'UPDATE_USER'">
|
||||
<fa-icon icon="pencil-alt"></fa-icon>
|
||||
Edit User
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$navy: #0d1b2a;
|
||||
$blue: #1e88e5;
|
||||
$blue-d: #1e40af;
|
||||
$green: #10b981;
|
||||
$red: #ef4444;
|
||||
$amber: #f59e0b;
|
||||
$purple: #7c3aed;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.udd-root {
|
||||
background: #fff;
|
||||
width: 640px;
|
||||
max-width: 96vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.udd-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__ic {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, $blue 0%, $purple 100%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 6px -2px rgb(29 78 216 / 35%);
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin-top: 2px;
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.udd-x {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
color: $red;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
.udd-loading,
|
||||
.udd-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 32px 24px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.udd-error {
|
||||
color: $red;
|
||||
}
|
||||
|
||||
.udd-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.udd-id-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #fff 100%);
|
||||
border: 1px solid #bfdbfe;
|
||||
|
||||
&.self {
|
||||
background: linear-gradient(135deg, #ecfdf5 0%, #fff 100%);
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin-top: 3px;
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.udd-avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, $blue 0%, $purple 100%);
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 3px 6px -2px rgb(29 78 216 / 35%);
|
||||
}
|
||||
|
||||
.udd-pill {
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&.staff {
|
||||
background: #ede9fe;
|
||||
color: $purple;
|
||||
}
|
||||
|
||||
&.self {
|
||||
background: #ecfdf5;
|
||||
color: $green;
|
||||
}
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.dot {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.udd-section {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
|
||||
&__title {
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 700;
|
||||
color: $blue-d;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.udd-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.udd-key {
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.udd-val {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $navy;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
word-break: break-word;
|
||||
|
||||
&.muted {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.udd-ic {
|
||||
font-size: 11px;
|
||||
|
||||
&.ok {
|
||||
color: $green;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
color: $amber;
|
||||
}
|
||||
}
|
||||
|
||||
.udd-roles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.udd-role {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%);
|
||||
color: #5b21b6;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.udd-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.udd-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid transparent;
|
||||
|
||||
fa-icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&.ghost {
|
||||
background: #fff;
|
||||
border-color: #d0d5dd;
|
||||
color: #475569;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #94a3b8;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
color: $red;
|
||||
border-color: #fecaca;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
border-color: $red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
|
||||
border-color: $blue-d;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px -2px rgb(30 64 175 / 35%);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px -3px rgb(30 64 175 / 45%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.udd-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.udd-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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 { CommonModule, DatePipe } from '@angular/common';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { UsersService } from '../users.service';
|
||||
|
||||
export interface UserDetailsDialogData {
|
||||
userId: string | number;
|
||||
}
|
||||
|
||||
export type UserDetailsDialogResult = 'edit' | 'delete' | 'change-password' | undefined;
|
||||
|
||||
/**
|
||||
* Read-only "View User" dialog.
|
||||
*
|
||||
* Loads the full user record by id and renders the standard identity / office
|
||||
* / roles / security fields with a footer that lets the caller fast-track to
|
||||
* Edit / Delete / Change Password without leaving the list view.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-user-details-dialog',
|
||||
templateUrl: './user-details-dialog.component.html',
|
||||
styleUrls: ['./user-details-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
DatePipe,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class UserDetailsDialogComponent implements OnInit {
|
||||
private usersService = inject(UsersService);
|
||||
public dialogRef = inject(MatDialogRef<UserDetailsDialogComponent, UserDetailsDialogResult>);
|
||||
public data = inject<UserDetailsDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
user: any = null;
|
||||
loading = true;
|
||||
error: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.usersService.getUser(String(this.data.userId)).subscribe({
|
||||
next: (u: any) => {
|
||||
this.user = u;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
this.error = 'Could not load this user.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get fullName(): string {
|
||||
const fn = (this.user?.firstname || '').trim();
|
||||
const ln = (this.user?.lastname || '').trim();
|
||||
return (
|
||||
[
|
||||
fn,
|
||||
ln
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') ||
|
||||
this.user?.username ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
get initials(): string {
|
||||
if (!this.user) return '?';
|
||||
const a = (this.user.firstname || this.user.username || '').charAt(0);
|
||||
const b = (this.user.lastname || '').charAt(0);
|
||||
return `${a}${b}`.toUpperCase() || '?';
|
||||
}
|
||||
|
||||
get roleNames(): string[] {
|
||||
return (this.user?.selectedRoles || []).map((r: any) => r.name);
|
||||
}
|
||||
|
||||
/** Last-password-update can come as ISO string or backend array. Normalise. */
|
||||
get lastPasswordUpdate(): string | null {
|
||||
const raw = this.user?.lastTimePasswordUpdated;
|
||||
if (!raw) return null;
|
||||
if (Array.isArray(raw) && raw.length >= 3) {
|
||||
const dt = new Date(raw[0], raw[1] - 1, raw[2]);
|
||||
return dt.toISOString();
|
||||
}
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
close(result?: UserDetailsDialogResult): void {
|
||||
this.dialogRef.close(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<!--
|
||||
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="ufd-root">
|
||||
<div class="ufd-head">
|
||||
<div class="ufd-head__left">
|
||||
<fa-icon [icon]="isEdit ? 'pencil-alt' : 'user-plus'" class="ufd-head__ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="ufd-head__title">{{ isEdit ? 'Edit User' : 'Create User' }}</h2>
|
||||
<div class="ufd-head__sub">
|
||||
{{ isEdit ? 'Update the details for this user.' : 'Add a new operator or self-service user.' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ufd-x" (click)="cancel()" matTooltip="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loadingTemplate || loadingUser) {
|
||||
<div class="ufd-loading">
|
||||
<fa-icon icon="spinner" class="fa-spin"></fa-icon>
|
||||
Loading…
|
||||
</div>
|
||||
} @else {
|
||||
<form class="ufd-body" [formGroup]="form" (ngSubmit)="submit()">
|
||||
<!-- Identity -->
|
||||
<div class="ufd-section">
|
||||
<div class="ufd-section__title">Identity</div>
|
||||
<div class="ufd-grid">
|
||||
<div class="ufd-field">
|
||||
<label>Login Name *</label>
|
||||
<input type="text" formControlName="username" autocomplete="username" placeholder="e.g. arao" />
|
||||
@if (form.get('username')?.touched && form.get('username')?.errors?.['required']) {
|
||||
<div class="ufd-err">Login name is required.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="ufd-field">
|
||||
<label>Email *</label>
|
||||
<input type="email" formControlName="email" placeholder="user@bank.com" />
|
||||
@if (form.get('email')?.touched && form.get('email')?.errors) {
|
||||
<div class="ufd-err">A valid email is required.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="ufd-field">
|
||||
<label>First Name *</label>
|
||||
<input type="text" formControlName="firstname" placeholder="First name" />
|
||||
@if (form.get('firstname')?.touched && form.get('firstname')?.errors) {
|
||||
<div class="ufd-err">First name must start with a letter.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="ufd-field">
|
||||
<label>Last Name *</label>
|
||||
<input type="text" formControlName="lastname" placeholder="Last name" />
|
||||
@if (form.get('lastname')?.touched && form.get('lastname')?.errors) {
|
||||
<div class="ufd-err">Last name must start with a letter.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="ufd-field">
|
||||
<label>Mobile *</label>
|
||||
<div class="ufd-input-prefix">
|
||||
<span class="ufd-prefix">+91</span>
|
||||
<input type="tel" maxlength="10" formControlName="mobileNo" placeholder="10-digit mobile" />
|
||||
</div>
|
||||
@if (form.get('mobileNo')?.touched && form.get('mobileNo')?.errors) {
|
||||
<div class="ufd-err">Mobile must be 10 digits.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Office mapping -->
|
||||
<div class="ufd-section">
|
||||
<div class="ufd-section__title">Office & Staff</div>
|
||||
<div class="ufd-grid">
|
||||
<div class="ufd-field">
|
||||
<label>Office *</label>
|
||||
<select formControlName="officeId">
|
||||
<option [ngValue]="''">Select an office</option>
|
||||
@for (o of offices; track o.id) {
|
||||
<option [ngValue]="o.id">{{ o.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (form.get('officeId')?.touched && form.get('officeId')?.errors?.['required']) {
|
||||
<div class="ufd-err">Office is required.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="ufd-field">
|
||||
<label>Staff (optional)</label>
|
||||
<select formControlName="staffId">
|
||||
<option [ngValue]="''">— Not linked —</option>
|
||||
@for (s of staff; track s.id) {
|
||||
<option [ngValue]="s.id">{{ s.displayName }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles -->
|
||||
<div class="ufd-section">
|
||||
<div class="ufd-section__title">Roles *</div>
|
||||
<div class="ufd-roles">
|
||||
@for (r of roles; track r.id) {
|
||||
<button type="button" class="ufd-role" [class.active]="isRoleSelected(r.id)" (click)="toggleRole(r.id)">
|
||||
<fa-icon [icon]="isRoleSelected(r.id) ? 'check' : 'plus'"></fa-icon>
|
||||
{{ r.name }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (form.get('roles')?.touched && form.get('roles')?.errors?.['required']) {
|
||||
<div class="ufd-err">Pick at least one role.</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Security (create-only) -->
|
||||
@if (!isEdit) {
|
||||
<div class="ufd-section">
|
||||
<div class="ufd-section__title">Security</div>
|
||||
<label class="ufd-check">
|
||||
<input type="checkbox" formControlName="sendPasswordToEmail" />
|
||||
<span>Send the auto-generated password to the user's email</span>
|
||||
</label>
|
||||
<label class="ufd-check">
|
||||
<input type="checkbox" formControlName="passwordNeverExpires" />
|
||||
<span>Password never expires</span>
|
||||
</label>
|
||||
|
||||
@if (!form.get('sendPasswordToEmail')?.value) {
|
||||
<div class="ufd-grid">
|
||||
<div class="ufd-field">
|
||||
<label>Password *</label>
|
||||
<input type="password" formControlName="password" autocomplete="new-password" />
|
||||
@if (form.get('password')?.touched && form.get('password')?.invalid) {
|
||||
<div class="ufd-err">Min 8 chars · 1 upper · 1 lower · 1 digit · 1 special.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="ufd-field">
|
||||
<label>Confirm Password *</label>
|
||||
<input type="password" formControlName="repeatPassword" autocomplete="new-password" />
|
||||
@if (form.get('repeatPassword')?.touched && form.get('repeatPassword')?.invalid) {
|
||||
<div class="ufd-err">Passwords don't match.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ufd-section">
|
||||
<div class="ufd-section__title">Security</div>
|
||||
<label class="ufd-check">
|
||||
<input type="checkbox" formControlName="passwordNeverExpires" />
|
||||
<span>Password never expires</span>
|
||||
</label>
|
||||
<div class="ufd-hint">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
To change the password, open the user from the list and use "Change Password".
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (serverError) {
|
||||
<div class="ufd-server-err">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
{{ serverError }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="ufd-actions">
|
||||
<button type="button" class="ufd-btn ghost" (click)="cancel()" [disabled]="submitting">Cancel</button>
|
||||
<button type="submit" class="ufd-btn primary" [disabled]="form.invalid || submitting">
|
||||
<fa-icon
|
||||
[icon]="submitting ? 'spinner' : isEdit ? 'check' : 'user-plus'"
|
||||
[class.fa-spin]="submitting"
|
||||
></fa-icon>
|
||||
{{ submitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create User' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$navy: #0d1b2a;
|
||||
$blue: #1e88e5;
|
||||
$blue-d: #1e40af;
|
||||
$green: #10b981;
|
||||
$red: #ef4444;
|
||||
$amber: #f59e0b;
|
||||
$purple: #7c3aed;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ufd-root {
|
||||
background: #fff;
|
||||
width: 720px;
|
||||
max-width: 96vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ufd-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__ic {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, $blue 0%, $purple 100%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 6px -2px rgb(29 78 216 / 35%);
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin-top: 2px;
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.ufd-x {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
color: $red;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
.ufd-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 40px 24px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ufd-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ufd-section {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
|
||||
&__title {
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 700;
|
||||
color: $blue-d;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.ufd-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.ufd-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: $navy;
|
||||
font-size: 13px;
|
||||
transition: all 0.12s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $blue;
|
||||
box-shadow: 0 0 0 3px rgb(30 136 229 / 14%);
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, #64748b 50%), linear-gradient(135deg, #64748b 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 14px) 50%,
|
||||
calc(100% - 9px) 50%;
|
||||
background-size:
|
||||
5px 5px,
|
||||
5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.ufd-input-prefix {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
height: 36px;
|
||||
|
||||
&:focus-within {
|
||||
border-color: $blue;
|
||||
box-shadow: 0 0 0 3px rgb(30 136 229 / 14%);
|
||||
}
|
||||
|
||||
.ufd-prefix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-right: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
color: $navy;
|
||||
}
|
||||
}
|
||||
|
||||
.ufd-err {
|
||||
font-size: 10.5px;
|
||||
color: $red;
|
||||
font-weight: 600;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.ufd-roles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.ufd-role {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
|
||||
fa-icon {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #94a3b8;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
|
||||
border-color: $blue-d;
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 4px -1px rgb(30 64 175 / 35%);
|
||||
}
|
||||
}
|
||||
|
||||
.ufd-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
color: $navy;
|
||||
margin: 4px 0;
|
||||
cursor: pointer;
|
||||
|
||||
// stylelint-disable-next-line selector-no-qualifying-type
|
||||
input[type='checkbox'] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
.ufd-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
|
||||
fa-icon {
|
||||
color: $amber;
|
||||
}
|
||||
}
|
||||
|
||||
.ufd-server-err {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
border-radius: 8px;
|
||||
color: $red;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ufd-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
margin-top: 4px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.ufd-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 0 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid transparent;
|
||||
|
||||
fa-icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&.ghost {
|
||||
background: #fff;
|
||||
border-color: #d0d5dd;
|
||||
color: #475569;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f8fafc;
|
||||
border-color: #94a3b8;
|
||||
color: $navy;
|
||||
}
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
|
||||
border-color: $blue-d;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px -2px rgb(30 64 175 / 35%);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 12px -3px rgb(30 64 175 / 45%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.ufd-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 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 { CommonModule } from '@angular/common';
|
||||
import {
|
||||
UntypedFormBuilder,
|
||||
UntypedFormControl,
|
||||
UntypedFormGroup,
|
||||
ReactiveFormsModule,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { UsersService } from '../users.service';
|
||||
import { PasswordsUtility } from 'app/core/utils/passwords-utility';
|
||||
|
||||
export interface UserFormDialogData {
|
||||
mode: 'create' | 'edit';
|
||||
userId?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* User form dialog — drives BOTH create and edit through a single dialog.
|
||||
*
|
||||
* The dialog loads the users template (offices + roles) on init and, for edit
|
||||
* mode, fetches the full user record so all fields pre-fill correctly. On
|
||||
* submit it calls createUser / editUser and closes with `true` on success.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-user-form-dialog',
|
||||
templateUrl: './user-form-dialog.component.html',
|
||||
styleUrls: ['./user-form-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class UserFormDialogComponent implements OnInit {
|
||||
private fb = inject(UntypedFormBuilder);
|
||||
private usersService = inject(UsersService);
|
||||
private passwordsUtility = inject(PasswordsUtility);
|
||||
public dialogRef = inject(MatDialogRef<UserFormDialogComponent>);
|
||||
public data = inject<UserFormDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
form!: UntypedFormGroup;
|
||||
offices: any[] = [];
|
||||
roles: any[] = [];
|
||||
staff: any[] = [];
|
||||
|
||||
loadingTemplate = true;
|
||||
loadingUser = false;
|
||||
submitting = false;
|
||||
/** Server-side error to surface in the dialog when the API rejects submit. */
|
||||
serverError: string | null = null;
|
||||
/** Cached existing user payload (edit mode only). */
|
||||
private existing: any = null;
|
||||
|
||||
get isEdit(): boolean {
|
||||
return this.data.mode === 'edit';
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.buildEmptyForm();
|
||||
|
||||
this.usersService.getUsersTemplate().subscribe({
|
||||
next: (tpl: any) => {
|
||||
this.offices = tpl?.allowedOffices || [];
|
||||
this.roles = tpl?.availableRoles || [];
|
||||
this.loadingTemplate = false;
|
||||
// After template arrives, edit-mode user loading is what triggers the
|
||||
// pre-fill — see loadExistingUser().
|
||||
if (this.isEdit) this.loadExistingUser();
|
||||
},
|
||||
error: () => {
|
||||
this.loadingTemplate = false;
|
||||
this.serverError = 'Could not load office / role list. Please retry.';
|
||||
}
|
||||
});
|
||||
|
||||
this.form.get('officeId')!.valueChanges.subscribe((officeId: any) => {
|
||||
this.staff = [];
|
||||
if (officeId == null || officeId === '') return;
|
||||
this.usersService.getStaff(officeId).subscribe((s: any) => (this.staff = s || []));
|
||||
});
|
||||
|
||||
// Toggle password fields based on "Send password to email" checkbox.
|
||||
// Edit mode hides these entirely (password is changed separately).
|
||||
if (!this.isEdit) {
|
||||
this.form.get('sendPasswordToEmail')!.valueChanges.subscribe((send: boolean) => {
|
||||
if (send) {
|
||||
this.form.removeControl('password');
|
||||
this.form.removeControl('repeatPassword');
|
||||
} else {
|
||||
this.form.addControl('password', new UntypedFormControl('', this.passwordsUtility.getPasswordValidators()));
|
||||
this.form.addControl(
|
||||
'repeatPassword',
|
||||
new UntypedFormControl('', [
|
||||
Validators.required,
|
||||
this.passwordsUtility.confirmPassword('password')
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private buildEmptyForm(): void {
|
||||
this.form = this.fb.group({
|
||||
username: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
firstname: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.pattern('(^[A-z]).*')
|
||||
]
|
||||
],
|
||||
lastname: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.pattern('(^[A-z]).*')
|
||||
]
|
||||
],
|
||||
email: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.email,
|
||||
Validators.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)
|
||||
]
|
||||
],
|
||||
mobileNo: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.pattern(/^\d{10}$/)
|
||||
]
|
||||
],
|
||||
officeId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
staffId: [''],
|
||||
roles: [
|
||||
[] as number[],
|
||||
Validators.required
|
||||
],
|
||||
passwordNeverExpires: [false],
|
||||
sendPasswordToEmail: [true]
|
||||
});
|
||||
}
|
||||
|
||||
private loadExistingUser(): void {
|
||||
if (!this.data.userId) return;
|
||||
this.loadingUser = true;
|
||||
this.usersService.getUser(String(this.data.userId)).subscribe({
|
||||
next: (u: any) => {
|
||||
this.existing = u;
|
||||
const staffId = u?.staff?.id ?? null;
|
||||
const selectedRoleIds = (u?.selectedRoles || []).map((r: any) => r.id);
|
||||
// Edit mode never asks about password — strip those controls.
|
||||
this.form.removeControl('sendPasswordToEmail');
|
||||
this.form.patchValue({
|
||||
username: u.username,
|
||||
firstname: u.firstname,
|
||||
lastname: u.lastname,
|
||||
email: u.email,
|
||||
mobileNo: u.mobileNo || '',
|
||||
officeId: u.officeId,
|
||||
staffId,
|
||||
roles: selectedRoleIds,
|
||||
passwordNeverExpires: !!u.passwordNeverExpires
|
||||
});
|
||||
this.loadingUser = false;
|
||||
},
|
||||
error: () => {
|
||||
this.loadingUser = false;
|
||||
this.serverError = 'Could not load this user. Please retry.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle a role chip on/off in the multi-select chip group. */
|
||||
toggleRole(roleId: number): void {
|
||||
const ctrl = this.form.get('roles')!;
|
||||
const current: number[] = ctrl.value || [];
|
||||
const next = current.includes(roleId) ? current.filter((id) => id !== roleId) : [
|
||||
...current,
|
||||
roleId
|
||||
];
|
||||
ctrl.setValue(next);
|
||||
ctrl.markAsTouched();
|
||||
}
|
||||
|
||||
isRoleSelected(roleId: number): boolean {
|
||||
return (this.form.get('roles')?.value || []).includes(roleId);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid || this.submitting) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.serverError = null;
|
||||
|
||||
const payload: any = { ...this.form.value };
|
||||
if (payload.staffId == null || payload.staffId === '') delete payload.staffId;
|
||||
|
||||
const req$ = this.isEdit
|
||||
? this.usersService.editUser(String(this.existing?.id ?? this.data.userId), payload)
|
||||
: this.usersService.createUser(payload);
|
||||
|
||||
req$.subscribe({
|
||||
next: (response: any) => {
|
||||
this.submitting = false;
|
||||
this.dialogRef.close({
|
||||
ok: true,
|
||||
userId: response?.resourceId ?? this.existing?.id ?? this.data.userId,
|
||||
username: payload.username
|
||||
});
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.submitting = false;
|
||||
this.serverError =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
(this.isEdit ? 'Failed to update user.' : 'Failed to create user.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
+175
-131
@@ -6,143 +6,187 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<mat-card class="container">
|
||||
<!-- Header Bar -->
|
||||
<div class="table-header">
|
||||
<div class="table-title-group">
|
||||
<h2 class="table-title">
|
||||
<div class="t-logomark"><span></span><span></span><span></span><span></span></div>
|
||||
{{ 'labels.inputs.Users' | translate }}
|
||||
</h2>
|
||||
<span class="table-count" *ngIf="dataSource?.data?.length">{{ dataSource.data.length }} users</span>
|
||||
<div class="usr-container">
|
||||
<!-- Hero header -->
|
||||
<div class="usr-header">
|
||||
<div class="usr-header__left">
|
||||
<fa-icon icon="users" class="usr-header__icon"></fa-icon>
|
||||
<div>
|
||||
<h2 class="usr-header__title">Users</h2>
|
||||
<div class="usr-header__sub">Manage system users, their roles and office mapping</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usr-header__right">
|
||||
<button class="usr-btn primary" (click)="createUser()" *mifosxHasPermission="'CREATE_USER'">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-actions">
|
||||
<mat-form-field class="table-search" appearance="outline">
|
||||
<i matPrefix class="fas fa-search"></i>
|
||||
<input
|
||||
matInput
|
||||
#filter
|
||||
placeholder="{{ 'labels.inputs.Filter' | translate }}"
|
||||
(keyup)="applyFilter($event.target.value)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="table-buttons">
|
||||
<button mat-flat-button color="primary" [routerLink]="['create']" class="btn-create" #buttonCreateUser>
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
{{ 'labels.buttons.Create User' | translate }}
|
||||
</button>
|
||||
<!-- KPI strip -->
|
||||
<div class="usr-stats">
|
||||
<div class="usr-stat total">
|
||||
<fa-icon icon="users" class="usr-stat__ic"></fa-icon>
|
||||
<div class="usr-stat__body">
|
||||
<div class="usr-stat__val">{{ totalUsers }}</div>
|
||||
<div class="usr-stat__lbl">Total Users</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usr-stat staff">
|
||||
<fa-icon icon="user-tie" class="usr-stat__ic"></fa-icon>
|
||||
<div class="usr-stat__body">
|
||||
<div class="usr-stat__val">{{ staffUsersCount }}</div>
|
||||
<div class="usr-stat__lbl">Staff Users</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usr-stat self">
|
||||
<fa-icon icon="user-circle" class="usr-stat__ic"></fa-icon>
|
||||
<div class="usr-stat__body">
|
||||
<div class="usr-stat__val">{{ selfServiceCount }}</div>
|
||||
<div class="usr-stat__lbl">Self-Service</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usr-stat office">
|
||||
<fa-icon icon="building" class="usr-stat__ic"></fa-icon>
|
||||
<div class="usr-stat__body">
|
||||
<div class="usr-stat__val">{{ distinctOfficesCount }}</div>
|
||||
<div class="usr-stat__lbl">Offices Covered</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-container" #usersTable>
|
||||
<table mat-table [dataSource]="dataSource" matSort class="modern-table">
|
||||
<ng-container matColumnDef="username">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Login Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let user" class="cell-link">{{ user.username }}</td>
|
||||
</ng-container>
|
||||
<!-- Toolbar -->
|
||||
<div class="usr-toolbar">
|
||||
<div class="usr-toolbar__search">
|
||||
<fa-icon icon="search" class="usr-toolbar__search-ic"></fa-icon>
|
||||
<input type="text" placeholder="Search by username, name, email, mobile, office, role…" [(ngModel)]="search" />
|
||||
@if (search) {
|
||||
<button class="usr-toolbar__clear" (click)="search = ''" matTooltip="Clear">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="usr-toolbar__filters">
|
||||
<select [(ngModel)]="officeFilter" class="usr-select">
|
||||
<option [ngValue]="''">All Offices</option>
|
||||
@for (o of officeOptions; track o) {
|
||||
<option [ngValue]="o">{{ o }}</option>
|
||||
}
|
||||
</select>
|
||||
<select [(ngModel)]="roleFilter" class="usr-select">
|
||||
<option [ngValue]="''">All Roles</option>
|
||||
<option [ngValue]="'staff'">Staff</option>
|
||||
<option [ngValue]="'selfservice'">Self-Service</option>
|
||||
</select>
|
||||
@if (search || officeFilter || roleFilter) {
|
||||
<button class="usr-btn ghost small" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container matColumnDef="firstname">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.First Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let user">{{ user.firstname }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="lastname">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Last Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let user">{{ user.lastname }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="email">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Email' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let user" class="cell-mono">{{ user.email }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="mobileNo">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Mobile</th>
|
||||
<td mat-cell *matCellDef="let user" class="cell-mono">
|
||||
@if (user.mobileNo) {
|
||||
+91 {{ user.mobileNo }}
|
||||
} @else {
|
||||
<span class="cell-empty">—</span>
|
||||
<!-- Users table -->
|
||||
<div class="usr-card">
|
||||
<div class="usr-card__head">
|
||||
<div class="usr-card__title">
|
||||
<fa-icon icon="list" class="usr-card__title-ic"></fa-icon>
|
||||
Users
|
||||
<span class="usr-card__count">{{ filteredRows.length }} of {{ rows.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usr-table-wrap">
|
||||
<table class="usr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="num">#</th>
|
||||
<th>User</th>
|
||||
<th>Login</th>
|
||||
<th>Email</th>
|
||||
<th>Mobile</th>
|
||||
<th>Office</th>
|
||||
<th>Type</th>
|
||||
<th class="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track trackRow($index, row); let i = $index) {
|
||||
<tr>
|
||||
<td class="num">{{ i + 1 }}</td>
|
||||
<td>
|
||||
<div class="usr-id">
|
||||
<span class="usr-avatar" [class.self]="row.isSelfServiceUser">{{ initials(row) }}</span>
|
||||
<div class="usr-id__txt">
|
||||
<div class="usr-id__name">{{ row.fullName }}</div>
|
||||
@if (row.staffDisplayName && row.staffDisplayName !== row.fullName) {
|
||||
<div class="usr-id__staff">{{ row.staffDisplayName }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono">{{ row.username }}</td>
|
||||
<td class="mono">{{ row.email || '—' }}</td>
|
||||
<td class="mono">
|
||||
@if (row.mobileNo) {
|
||||
+91 {{ row.mobileNo }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td>{{ row.officeName || '—' }}</td>
|
||||
<td>
|
||||
@if (row.isSelfServiceUser) {
|
||||
<span class="usr-pill self">Self-Service</span>
|
||||
} @else {
|
||||
<span class="usr-pill staff">Staff</span>
|
||||
}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="usr-act view" (click)="viewUser(row)" matTooltip="View user">
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
class="usr-act edit"
|
||||
(click)="editUser(row)"
|
||||
matTooltip="Edit user"
|
||||
*mifosxHasPermission="'UPDATE_USER'"
|
||||
>
|
||||
<fa-icon icon="pencil-alt"></fa-icon>
|
||||
</button>
|
||||
<button class="usr-act more" [matMenuTriggerFor]="rowMenu" matTooltip="More actions">
|
||||
<fa-icon icon="ellipsis-v"></fa-icon>
|
||||
</button>
|
||||
<mat-menu #rowMenu="matMenu" xPosition="before" class="usr-menu">
|
||||
<button mat-menu-item (click)="viewUser(row)">
|
||||
<fa-icon icon="eye" class="usr-menu__ic"></fa-icon>
|
||||
View Details
|
||||
</button>
|
||||
<button mat-menu-item (click)="editUser(row)" *mifosxHasPermission="'UPDATE_USER'">
|
||||
<fa-icon icon="pencil-alt" class="usr-menu__ic"></fa-icon>
|
||||
Edit User
|
||||
</button>
|
||||
<button mat-menu-item (click)="changePassword(row)">
|
||||
<fa-icon icon="key" class="usr-menu__ic"></fa-icon>
|
||||
Change Password
|
||||
</button>
|
||||
<button mat-menu-item (click)="copyUsername(row)">
|
||||
<fa-icon icon="copy" class="usr-menu__ic"></fa-icon>
|
||||
Copy Login Name
|
||||
</button>
|
||||
<button mat-menu-item (click)="deleteUser(row)" *mifosxHasPermission="'DELETE_USER'">
|
||||
<fa-icon icon="trash" class="usr-menu__ic warn"></fa-icon>
|
||||
Delete User
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="officeName">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Office' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let user">{{ user.officeName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="table-row"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
|
||||
@if (filteredRows.length === 0) {
|
||||
<tr>
|
||||
<td colspan="8" class="usr-empty">No users match the current filters.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Config Wizard Templates (keep as-is) -->
|
||||
<ng-template #templateButtonCreateUser let-popover="popover">
|
||||
<h2>{{ 'labels.buttons.Create User' | translate }}</h2>
|
||||
<p class="mw300">{{ 'labels.text.This option allows you to create new users in your organization' | 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(); previousStep()">
|
||||
{{ 'labels.buttons.Back' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="popover.close(); showPopover(templateFilter, filter, 'bottom', true)"
|
||||
>
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #templateFilter let-popover="popover">
|
||||
<h4>{{ 'labels.heading.Search bar to filter users' | translate }}.</h4>
|
||||
<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(); showPopover(templateButtonCreateUser, buttonCreateUser, 'bottom', true)"
|
||||
>
|
||||
{{ 'labels.buttons.Back' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="popover.close(); showPopover(templateUsersTable, usersTable, 'top', true)"
|
||||
>
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #templateUsersTable let-popover="popover">
|
||||
<h4>{{ 'labels.heading.List of Users with their details' | translate }}.</h4>
|
||||
<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(); showPopover(templateFilter, filter, 'bottom', true)"
|
||||
>
|
||||
{{ 'labels.buttons.Back' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); nextStep()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
@@ -6,22 +6,537 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@use '../shared/styles/list-layout' as *;
|
||||
@use 'sass:color';
|
||||
|
||||
.status-active {
|
||||
background: #ecfdf5 !important;
|
||||
color: #059669 !important;
|
||||
$navy: #0d1b2a;
|
||||
$blue: #1e88e5;
|
||||
$blue-d: #1e40af;
|
||||
$green: #10b981;
|
||||
$amber: #f59e0b;
|
||||
$red: #ef4444;
|
||||
$purple: #7c3aed;
|
||||
|
||||
&::before {
|
||||
background: #059669;
|
||||
.usr-container {
|
||||
padding: 20px 24px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
background: #f0f2f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
// ─── Header ───
|
||||
.usr-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, $blue 0%, $purple 100%);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background: #fef2f2 !important;
|
||||
color: #dc2626 !important;
|
||||
.usr-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&::before {
|
||||
background: #dc2626;
|
||||
fa-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&.ghost {
|
||||
background: #fff;
|
||||
border-color: #d0d5dd;
|
||||
color: #475569;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #94a3b8;
|
||||
color: $navy;
|
||||
}
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
|
||||
border-color: $blue-d;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px -2px rgb(30 64 175 / 35%);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px -3px rgb(30 64 175 / 45%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── KPI strip ───
|
||||
.usr-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.usr-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
||||
|
||||
&__ic {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__val {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__lbl {
|
||||
margin-top: 4px;
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.total .usr-stat__ic {
|
||||
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
|
||||
}
|
||||
|
||||
&.staff .usr-stat__ic {
|
||||
background: linear-gradient(135deg, $purple 0%, #4338ca 100%);
|
||||
}
|
||||
|
||||
&.self .usr-stat__ic {
|
||||
background: linear-gradient(135deg, $green 0%, #047857 100%);
|
||||
}
|
||||
|
||||
&.office .usr-stat__ic {
|
||||
background: linear-gradient(135deg, $amber 0%, #c2410c 100%);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Toolbar ───
|
||||
.usr-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&__search {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 260px;
|
||||
max-width: 520px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 9px 36px;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
color: $navy;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $blue;
|
||||
box-shadow: 0 0 0 3px rgb(30 136 229 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
&-ic {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__clear {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #94a3b8;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
color: $red;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
&__filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.usr-select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: $navy;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $blue;
|
||||
box-shadow: 0 0 0 3px rgb(30 136 229 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Card wrapper ───
|
||||
.usr-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 6%);
|
||||
overflow: hidden;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $navy;
|
||||
|
||||
&-ic {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
&__count {
|
||||
margin-left: 8px;
|
||||
padding: 2px 10px;
|
||||
background: #eff6ff;
|
||||
color: $blue-d;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Table ───
|
||||
.usr-table-wrap {
|
||||
overflow: auto;
|
||||
max-height: 620px;
|
||||
}
|
||||
|
||||
.usr-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
text-align: left;
|
||||
padding: 11px 14px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
&.num {
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.actions {
|
||||
width: 150px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
color: $navy;
|
||||
|
||||
&.num {
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&.actions {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
transition: background 0.12s;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.usr-id {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
&__txt {
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
&__staff {
|
||||
font-size: 10.5px;
|
||||
color: #64748b;
|
||||
margin-top: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.usr-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, $blue 0%, $purple 100%);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 4px -1px rgb(29 78 216 / 30%);
|
||||
|
||||
&.self {
|
||||
background: linear-gradient(135deg, $green 0%, #047857 100%);
|
||||
box-shadow: 0 2px 4px -1px rgb(16 185 129 / 30%);
|
||||
}
|
||||
}
|
||||
|
||||
.usr-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&.staff {
|
||||
background: #ede9fe;
|
||||
color: $purple;
|
||||
}
|
||||
|
||||
&.self {
|
||||
background: #ecfdf5;
|
||||
color: $green;
|
||||
}
|
||||
}
|
||||
|
||||
.usr-act {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 0 2px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
transition: all 0.12s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.view:hover:not(:disabled) {
|
||||
background: #f0f9ff;
|
||||
border-color: #bae6fd;
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
&.edit:hover:not(:disabled) {
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
color: $blue-d;
|
||||
}
|
||||
|
||||
&.more:hover:not(:disabled) {
|
||||
background: #f5f3ff;
|
||||
border-color: #ddd6fe;
|
||||
color: $purple;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.usr-menu__ic {
|
||||
width: 18px;
|
||||
margin-right: 10px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
|
||||
&.warn {
|
||||
color: $red;
|
||||
}
|
||||
}
|
||||
|
||||
.usr-empty {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
// ─── Responsive ───
|
||||
@media (width <= 900px) {
|
||||
.usr-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.usr-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.usr-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
&__search {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.usr-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.usr-table thead th {
|
||||
font-size: 9.5px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.usr-table tbody td {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
+243
-133
@@ -6,36 +6,47 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, TemplateRef, ElementRef, ViewChild, AfterViewInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
||||
import {
|
||||
MatTableDataSource,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { of } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { PopoverService } from '../configuration-wizard/popover/popover.service';
|
||||
import { ConfigurationWizardService } from '../configuration-wizard/configuration-wizard.service';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import { UsersService } from './users.service';
|
||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
|
||||
import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component';
|
||||
import { ChangePasswordDialogComponent } from 'app/shared/change-password-dialog/change-password-dialog.component';
|
||||
import { UserFormDialogComponent, UserFormDialogData } from './user-form-dialog/user-form-dialog.component';
|
||||
import {
|
||||
UserDetailsDialogComponent,
|
||||
UserDetailsDialogData,
|
||||
UserDetailsDialogResult
|
||||
} from './user-details-dialog/user-details-dialog.component';
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
username: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
mobileNo: string;
|
||||
officeName: string;
|
||||
staffDisplayName: string;
|
||||
roles: string[];
|
||||
isSelfServiceUser: boolean;
|
||||
raw: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Users component.
|
||||
* Users — Nexora redesign.
|
||||
*
|
||||
* Hero header + KPI strip + searchable Nexora table with per-row
|
||||
* View / Edit / Delete actions (with kebab menu for less-common ops).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-users',
|
||||
@@ -43,143 +54,242 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
styleUrls: ['./users.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTable,
|
||||
MatSort,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatSortHeader,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
MatPaginator
|
||||
MatTooltip,
|
||||
MatMenuModule
|
||||
]
|
||||
})
|
||||
export class UsersComponent implements OnInit, AfterViewInit {
|
||||
export class UsersComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private configurationWizardService = inject(ConfigurationWizardService);
|
||||
private popoverService = inject(PopoverService);
|
||||
private dialog = inject(MatDialog);
|
||||
private usersService = inject(UsersService);
|
||||
|
||||
/** Users data. */
|
||||
usersData: any;
|
||||
/** Columns to be displayed in users table. */
|
||||
displayedColumns: string[] = [
|
||||
'username',
|
||||
'firstname',
|
||||
'lastname',
|
||||
'email',
|
||||
'mobileNo',
|
||||
'officeName'
|
||||
];
|
||||
/** Data source for users table. */
|
||||
dataSource: MatTableDataSource<any>;
|
||||
/** All users normalised to UserRow for the table. */
|
||||
rows: UserRow[] = [];
|
||||
|
||||
/** Paginator for users table. */
|
||||
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||
/** Sorter for users table. */
|
||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||
/** Search text. */
|
||||
search = '';
|
||||
|
||||
/* Reference of create user button */
|
||||
@ViewChild('buttonCreateUser') buttonCreateUser: ElementRef<any>;
|
||||
/* Template for popover create user button */
|
||||
@ViewChild('templateButtonCreateUser') templateButtonCreateUser: TemplateRef<any>;
|
||||
/* Reference of users table */
|
||||
@ViewChild('usersTable') usersTable: ElementRef<any>;
|
||||
/* Template for popover on users table */
|
||||
@ViewChild('templateUsersTable') templateUsersTable: TemplateRef<any>;
|
||||
/** Office filter — empty = all. */
|
||||
officeFilter = '';
|
||||
|
||||
/** 'all' | 'staff' | 'selfservice' role filter. */
|
||||
roleFilter: '' | 'staff' | 'selfservice' = '';
|
||||
|
||||
/**
|
||||
* Retrieves the users data from `resolve`.
|
||||
* @param {ActivatedRoute} route Activated Route.
|
||||
* @param {Router} router Router for navigation.
|
||||
* @param {ConfigurationWizardService} configurationWizardService ConfigurationWizard Service.
|
||||
* @param {PopoverService} popoverService PopoverService.
|
||||
*/
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { users: any }) => {
|
||||
this.usersData = data.users;
|
||||
this.buildRows(data.users || []);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters data in users table based on passed value.
|
||||
* @param {string} filterValue Value to filter data.
|
||||
*/
|
||||
applyFilter(filterValue: string) {
|
||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||
private buildRows(users: any[]): void {
|
||||
this.rows = users.map((u: any) => {
|
||||
const fn = (u.firstname || '').trim();
|
||||
const ln = (u.lastname || '').trim();
|
||||
const fullName =
|
||||
[
|
||||
fn,
|
||||
ln
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') || u.username;
|
||||
const roles: string[] = (u.selectedRoles || u.availableRoles || []).map((r: any) => r?.name).filter(Boolean);
|
||||
return {
|
||||
id: String(u.id),
|
||||
username: u.username || '',
|
||||
firstname: fn,
|
||||
lastname: ln,
|
||||
fullName,
|
||||
email: u.email || '',
|
||||
mobileNo: u.mobileNo || '',
|
||||
officeName: u.officeName || '',
|
||||
staffDisplayName: u.staffDisplayName || '',
|
||||
roles,
|
||||
isSelfServiceUser: !!u.isSelfServiceUser,
|
||||
raw: u
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the users table.
|
||||
*/
|
||||
ngOnInit() {
|
||||
this.setUsers();
|
||||
// ───── KPIs ─────
|
||||
get totalUsers(): number {
|
||||
return this.rows.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the data source, paginator and sorter for users table.
|
||||
*/
|
||||
setUsers() {
|
||||
this.dataSource = new MatTableDataSource(this.usersData);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
get selfServiceCount(): number {
|
||||
return this.rows.filter((r) => r.isSelfServiceUser).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover function
|
||||
* @param template TemplateRef<any>.
|
||||
* @param target HTMLElement | ElementRef<any>.
|
||||
* @param position String.
|
||||
* @param backdrop Boolean.
|
||||
*/
|
||||
showPopover(
|
||||
template: TemplateRef<any>,
|
||||
target: HTMLElement | ElementRef<any>,
|
||||
position: string,
|
||||
backdrop: boolean
|
||||
): void {
|
||||
setTimeout(() => this.popoverService.open(template, target, position, backdrop, {}), 200);
|
||||
get staffUsersCount(): number {
|
||||
return this.rows.filter((r) => !r.isSelfServiceUser).length;
|
||||
}
|
||||
|
||||
/** Distinct offices represented in the user list. */
|
||||
get distinctOfficesCount(): number {
|
||||
return new Set(this.rows.map((r) => r.officeName).filter(Boolean)).size;
|
||||
}
|
||||
|
||||
/** Office names for the office filter dropdown. */
|
||||
get officeOptions(): string[] {
|
||||
return Array.from(new Set(this.rows.map((r) => r.officeName).filter(Boolean))).sort();
|
||||
}
|
||||
|
||||
// ───── Filtering ─────
|
||||
get filteredRows(): UserRow[] {
|
||||
const q = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (this.officeFilter && r.officeName !== this.officeFilter) return false;
|
||||
if (this.roleFilter === 'staff' && r.isSelfServiceUser) return false;
|
||||
if (this.roleFilter === 'selfservice' && !r.isSelfServiceUser) return false;
|
||||
if (!q) return true;
|
||||
const hay =
|
||||
`${r.username} ${r.fullName} ${r.email} ${r.mobileNo} ${r.officeName} ${r.roles.join(' ')}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.officeFilter = '';
|
||||
this.roleFilter = '';
|
||||
}
|
||||
|
||||
// ───── Row actions (all popup-driven now) ─────
|
||||
/**
|
||||
* To show popover.
|
||||
* Open the read-only details popup. The footer of the details dialog can
|
||||
* chain into edit / delete / change-password without leaving the list.
|
||||
*/
|
||||
ngAfterViewInit() {
|
||||
if (this.configurationWizardService.showUsers) {
|
||||
setTimeout(() => {
|
||||
this.showPopover(this.templateButtonCreateUser, this.buttonCreateUser.nativeElement, 'bottom', true);
|
||||
viewUser(row: { id: string; username: string }): void {
|
||||
const ref = this.dialog.open<UserDetailsDialogComponent, UserDetailsDialogData, UserDetailsDialogResult>(
|
||||
UserDetailsDialogComponent,
|
||||
{
|
||||
data: { userId: row.id },
|
||||
panelClass: 'udd-panel',
|
||||
autoFocus: false
|
||||
}
|
||||
);
|
||||
ref.afterClosed().subscribe((action) => {
|
||||
if (action === 'edit') this.editUser(row);
|
||||
else if (action === 'delete') this.deleteUser(row as UserRow);
|
||||
else if (action === 'change-password') this.changePassword(row);
|
||||
});
|
||||
}
|
||||
|
||||
/** Open the user form popup in edit mode. */
|
||||
editUser(row: { id: string }): void {
|
||||
const ref = this.dialog.open<UserFormDialogComponent, UserFormDialogData, any>(UserFormDialogComponent, {
|
||||
data: { mode: 'edit', userId: row.id },
|
||||
panelClass: 'ufd-panel',
|
||||
autoFocus: false
|
||||
});
|
||||
ref.afterClosed().subscribe((result) => {
|
||||
if (result?.ok) {
|
||||
this.showSuccess('User Updated', `Changes to "${result.username}" have been saved.`);
|
||||
this.refreshUsers();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Open the user form popup in create mode. */
|
||||
createUser(): void {
|
||||
const ref = this.dialog.open<UserFormDialogComponent, UserFormDialogData, any>(UserFormDialogComponent, {
|
||||
data: { mode: 'create' },
|
||||
panelClass: 'ufd-panel',
|
||||
autoFocus: false
|
||||
});
|
||||
ref.afterClosed().subscribe((result) => {
|
||||
if (result?.ok) {
|
||||
this.showSuccess('User Created', `"${result.username}" has been added.`);
|
||||
this.refreshUsers();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Change-password popup — same dialog the legacy view-user uses. */
|
||||
changePassword(row: { id: string; username?: string }): void {
|
||||
const ref = this.dialog.open(ChangePasswordDialogComponent, { width: '440px' });
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (!response?.password || !response?.repeatPassword) return;
|
||||
// Backend expects `firstname` in the payload (Fineract quirk) — pull from
|
||||
// the row's cached payload when we have it, otherwise fetch fresh.
|
||||
const cached = this.rows.find((r) => r.id === row.id);
|
||||
const firstname = cached?.firstname || cached?.fullName || row.username || '';
|
||||
const body = {
|
||||
password: response.password,
|
||||
repeatPassword: response.repeatPassword,
|
||||
firstname
|
||||
};
|
||||
this.usersService.changePassword(row.id, body).subscribe({
|
||||
next: () => this.showSuccess('Password Updated', `New password set for "${row.username || row.id}".`),
|
||||
error: (err: any) => {
|
||||
const apiMsg = err?.error?.errors?.[0]?.defaultUserMessage || 'Please try again.';
|
||||
this.showError(`Failed to update password. ${apiMsg}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.configurationWizardService.showUsersList) {
|
||||
setTimeout(() => {
|
||||
this.showPopover(this.templateUsersTable, this.usersTable.nativeElement, 'top', true);
|
||||
/** Delete user with confirm + polished outcome dialog. */
|
||||
deleteUser(row: UserRow): void {
|
||||
const ref = this.dialog.open(DeleteDialogComponent, {
|
||||
data: { deleteContext: `user "${row.username}"` }
|
||||
});
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (!response?.delete) return;
|
||||
this.usersService.deleteUser(row.id).subscribe({
|
||||
next: () => {
|
||||
this.showSuccess('User Removed', `"${row.username}" has been deleted.`);
|
||||
this.refreshUsers();
|
||||
},
|
||||
error: (err: any) => {
|
||||
const apiMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.developerMessage || 'Please try again.';
|
||||
this.showError(`Failed to remove "${row.username}". ${apiMsg}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Next Step (Create User Page) Configuration Wizard.
|
||||
*/
|
||||
nextStep() {
|
||||
this.configurationWizardService.showUsers = false;
|
||||
this.configurationWizardService.showUsersList = false;
|
||||
this.configurationWizardService.showUsersForm = true;
|
||||
this.router.navigate(['/appusers/create']);
|
||||
/** Copy the user's login name. */
|
||||
copyUsername(row: UserRow): void {
|
||||
navigator.clipboard
|
||||
?.writeText(row.username)
|
||||
.then(() => this.showSnackSuccess(`Copied "${row.username}" to clipboard.`))
|
||||
.catch(() => this.showError('Clipboard access was blocked by the browser.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous Step (Roles and Permission Page) Configuration Wizard.
|
||||
*/
|
||||
previousStep() {
|
||||
this.configurationWizardService.showUsers = false;
|
||||
this.configurationWizardService.showUsersList = false;
|
||||
this.configurationWizardService.showRolesandPermissionList = true;
|
||||
this.router.navigate(['/system/roles-and-permissions']);
|
||||
private refreshUsers(): void {
|
||||
this.usersService.getUsers().subscribe((users: any[]) => this.buildRows(users || []));
|
||||
}
|
||||
|
||||
// ───── Dialog helpers ─────
|
||||
private showSuccess(title: string, message: string): void {
|
||||
this.dialog.open(SuccessDialogComponent, {
|
||||
data: { title, message, buttonLabel: 'OK' },
|
||||
panelClass: 'sd-panel'
|
||||
});
|
||||
}
|
||||
|
||||
/** Used for lightweight confirmations like "copied to clipboard". */
|
||||
private showSnackSuccess(message: string): void {
|
||||
this.dialog.open(SuccessDialogComponent, {
|
||||
data: { title: 'Copied', message, buttonLabel: 'OK' },
|
||||
panelClass: 'sd-panel'
|
||||
});
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.dialog.open(ErrorDialogComponent, { data: message });
|
||||
}
|
||||
|
||||
// ───── Misc ─────
|
||||
trackRow = (_i: number, r: UserRow) => r.id;
|
||||
|
||||
initials(row: UserRow): string {
|
||||
const a = (row.firstname || row.username).charAt(0);
|
||||
const b = (row.lastname || '').charAt(0);
|
||||
return `${a}${b}`.toUpperCase() || '?';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { UsersComponent } from './users.component';
|
||||
import { CreateUserComponent } from './create-user/create-user.component';
|
||||
import { ViewUserComponent } from './view-user/view-user.component';
|
||||
import { EditUserComponent } from './edit-user/edit-user.component';
|
||||
import { UserFormDialogComponent } from './user-form-dialog/user-form-dialog.component';
|
||||
import { UserDetailsDialogComponent } from './user-details-dialog/user-details-dialog.component';
|
||||
|
||||
/** DSC Components */
|
||||
import { DscListComponent } from './dsc/dsc-list/dsc-list.component';
|
||||
@@ -43,6 +45,8 @@ import { UsersComponent as UserZitadelComponent } from '../zitadel/users/users.c
|
||||
CreateUserComponent,
|
||||
ViewUserComponent,
|
||||
EditUserComponent,
|
||||
UserFormDialogComponent,
|
||||
UserDetailsDialogComponent,
|
||||
DscListComponent,
|
||||
EnrollDscDialogComponent,
|
||||
RevokeDscDialogComponent,
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260518',
|
||||
'hash': 'g4322f37c6'
|
||||
'version': '260520',
|
||||
'hash': 'g325228c51'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
@@ -66,7 +66,7 @@ export const environment = {
|
||||
/** Production mode - when true, shows minimal hero with only branding at bottom */
|
||||
productionMode: loadedEnv['productionMode'] === 'true' || loadedEnv['productionMode'] === true || false,
|
||||
tenantLogoUrl: loadedEnv['tenantLogoUrl'] || 'assets/images/cbsnexora-logo.png',
|
||||
tenantLogoUrlDark: loadedEnv['tenantLogoUrlDark'] || 'assets/images/white-mifos.png',
|
||||
tenantLogoUrlDark: loadedEnv['tenantLogoUrlDark'] || 'assets/images/cbsnexora-logo.png',
|
||||
documentationBaseUrl: loadedEnv['documentationBaseUrl'] || 'https://mifosforge.jira.com/wiki',
|
||||
// Time in seconds, default 60 seconds
|
||||
waitTimeForNotifications: loadedEnv['waitTimeForNotifications'] || 60,
|
||||
|
||||
@@ -68,7 +68,7 @@ export const environment = {
|
||||
/** Production mode - when true, shows minimal hero with only branding at bottom */
|
||||
productionMode: loadedEnv.productionMode === 'true' || loadedEnv.productionMode === true || false,
|
||||
tenantLogoUrl: loadedEnv.tenantLogoUrl || 'assets/images/cbsnexora-logo.png',
|
||||
tenantLogoUrlDark: loadedEnv.tenantLogoUrlDark || 'assets/images/white-mifos.png',
|
||||
tenantLogoUrlDark: loadedEnv.tenantLogoUrlDark || 'assets/images/cbsnexora-logo.png',
|
||||
documentationBaseUrl: loadedEnv.documentationBaseUrl || 'https://mifosforge.jira.com/wiki',
|
||||
// Time in seconds, default 60 seconds
|
||||
waitTimeForNotifications: loadedEnv.waitTimeForNotifications || 60,
|
||||
|
||||
+213
@@ -71,6 +71,15 @@
|
||||
grid-template-columns: repeat(4, 1fr) !important;
|
||||
}
|
||||
|
||||
.reconciliation-mega-panel {
|
||||
max-width: 1100px !important;
|
||||
}
|
||||
|
||||
.reconciliation-grid {
|
||||
grid-template-columns: repeat(5, minmax(190px, 1fr)) !important;
|
||||
min-width: 1040px;
|
||||
}
|
||||
|
||||
// ===== Material Select Panel Styles (Material Design 3) =====
|
||||
.mat-mdc-select-panel {
|
||||
background: var(--md-sys-color-surface-container, #fff);
|
||||
@@ -1105,3 +1114,207 @@ mat-card {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Client stepper — disabled (read-only) form-field overrides
|
||||
─── Applied GLOBALLY so they bypass component view-encapsulation.
|
||||
`::ng-deep` inside a component SCSS sometimes loses specificity
|
||||
battles with Material 3's MDC token cascade — a global rule scoped
|
||||
to the stepper's component selectors avoids that entirely.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
mifosx-client-general-step,
|
||||
mifosx-client-india-address-step,
|
||||
mifosx-client-address-step,
|
||||
mifosx-client-kyc-step,
|
||||
mifosx-client-entity-kyc-step,
|
||||
mifosx-client-family-members-step,
|
||||
mifosx-client-datatable-step {
|
||||
/* Token-level overrides — these are the colours MDC reads when computing
|
||||
disabled state. Forcing them at the host avoids fighting MDC rule by rule. */
|
||||
--mdc-outlined-text-field-disabled-input-text-color: #0f172a;
|
||||
--mdc-outlined-text-field-disabled-label-text-color: #64748b;
|
||||
--mdc-outlined-text-field-disabled-outline-color: #cbd5e1;
|
||||
--mdc-outlined-text-field-disabled-supporting-text-color: #64748b;
|
||||
--mdc-outlined-text-field-disabled-leading-icon-color: #475569;
|
||||
--mdc-outlined-text-field-disabled-trailing-icon-color: #475569;
|
||||
--mdc-filled-text-field-disabled-input-text-color: #0f172a;
|
||||
--mdc-filled-text-field-disabled-label-text-color: #64748b;
|
||||
--mat-select-disabled-trigger-text-color: #0f172a;
|
||||
--mat-form-field-disabled-input-text-color: #0f172a;
|
||||
--mat-form-field-disabled-input-text-placeholder-color: #64748b;
|
||||
|
||||
.mat-mdc-form-field.mat-form-field-disabled,
|
||||
.mat-mdc-form-field-disabled {
|
||||
opacity: 1 !important;
|
||||
|
||||
.mat-mdc-input-element,
|
||||
input.mat-mdc-input-element,
|
||||
textarea.mat-mdc-input-element {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important;
|
||||
font-weight: 600;
|
||||
caret-color: transparent;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.mat-mdc-floating-label,
|
||||
.mdc-floating-label,
|
||||
.mat-mdc-form-field-required-marker {
|
||||
color: #64748b !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.mdc-notched-outline__leading,
|
||||
.mdc-notched-outline__notch,
|
||||
.mdc-notched-outline__trailing {
|
||||
border-color: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
.mat-mdc-select-value,
|
||||
.mat-mdc-select-value-text,
|
||||
.mat-mdc-select-min-line,
|
||||
.mat-mdc-select-placeholder {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important;
|
||||
font-weight: 600;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.mat-mdc-select-arrow {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-icon-prefix,
|
||||
.mat-mdc-form-field-icon-suffix,
|
||||
.field-prefix-icon mat-icon,
|
||||
.mat-mdc-form-field-icon-prefix mat-icon,
|
||||
.mat-mdc-form-field-icon-suffix mat-icon {
|
||||
color: #475569 !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-hint {
|
||||
color: #64748b !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* The disabled mat-select element itself (sibling of mat-form-field-disabled
|
||||
in some Material 3 builds) — drop the opacity drop. */
|
||||
.mat-mdc-select-disabled,
|
||||
.mat-mdc-select-disabled .mat-mdc-select-trigger,
|
||||
.mat-mdc-select-disabled .mat-mdc-select-value,
|
||||
.mat-mdc-select-disabled .mat-mdc-select-value-text,
|
||||
.mat-mdc-select-disabled .mat-mdc-select-min-line {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important;
|
||||
font-weight: 600;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* MDC text-field root with its own disabled modifier */
|
||||
.mdc-text-field--disabled {
|
||||
opacity: 1 !important;
|
||||
|
||||
.mdc-text-field__input,
|
||||
input,
|
||||
textarea,
|
||||
.mat-mdc-select-min-line {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Native UA stylesheet — Chrome/Safari force `-webkit-text-fill-color: rgb(84,84,84)`
|
||||
on any disabled control. This beats `color`, so reset both. */
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Disabled checkboxes — Material 3's MDC checkbox uses `border-color` to
|
||||
paint the filled box (not `background-color`), so we force BOTH and also
|
||||
target the SVG path's stroke for the tick. Specificity is bumped via
|
||||
chained selectors to beat MDC's own rules. */
|
||||
|
||||
/* Checked + disabled — solid green box, white tick */
|
||||
mat-checkbox.mat-mdc-checkbox.mat-mdc-checkbox-checked.mat-mdc-checkbox-disabled
|
||||
.mdc-checkbox
|
||||
.mdc-checkbox__background,
|
||||
.mat-mdc-checkbox.mat-mdc-checkbox-checked.mat-mdc-checkbox-disabled .mdc-checkbox__background,
|
||||
.mat-mdc-checkbox.mat-mdc-checkbox-disabled.mat-mdc-checkbox-checked .mdc-checkbox__background,
|
||||
.mat-mdc-checkbox-disabled .mdc-checkbox__native-control:checked ~ .mdc-checkbox__background,
|
||||
.mdc-checkbox--selected.mdc-checkbox--disabled .mdc-checkbox__background {
|
||||
background-color: #059669 !important;
|
||||
border-color: #059669 !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Tick (white SVG path) */
|
||||
mat-checkbox.mat-mdc-checkbox-checked.mat-mdc-checkbox-disabled .mdc-checkbox__checkmark,
|
||||
mat-checkbox.mat-mdc-checkbox-checked.mat-mdc-checkbox-disabled .mdc-checkbox__checkmark-path,
|
||||
.mat-mdc-checkbox-disabled.mat-mdc-checkbox-checked .mdc-checkbox__checkmark-path {
|
||||
color: #fff !important;
|
||||
stroke: #fff !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Unchecked + disabled — outlined slate box (visible but empty) */
|
||||
.mat-mdc-checkbox-disabled:not(.mat-mdc-checkbox-checked) .mdc-checkbox__background {
|
||||
background-color: transparent !important;
|
||||
border-color: #94a3b8 !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Full opacity at every layer of MDC's checkbox tree */
|
||||
mat-checkbox.mat-mdc-checkbox-disabled,
|
||||
.mat-mdc-checkbox-disabled,
|
||||
.mat-mdc-checkbox-disabled .mdc-checkbox,
|
||||
.mat-mdc-checkbox-disabled .mdc-checkbox__ripple,
|
||||
.mat-mdc-checkbox-disabled .mdc-form-field {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Label */
|
||||
.mat-mdc-checkbox-disabled .mdc-form-field > label,
|
||||
.mat-mdc-checkbox-disabled .mat-mdc-checkbox-label,
|
||||
.mat-mdc-checkbox-disabled .mdc-label {
|
||||
color: #0f172a !important;
|
||||
opacity: 1 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
/* Token-level overrides at :root so they cascade EVERYWHERE under the stepper
|
||||
(MDC sometimes reads tokens at the component root, which lives outside our
|
||||
`mifosx-client-*-step` host element). Limiting to a class scope to avoid
|
||||
bleeding into other parts of the app. Descendant variants intentionally
|
||||
use `*` so the CSS variables are picked up by Material's deeply nested
|
||||
host elements. */
|
||||
/* stylelint-disable selector-max-universal */
|
||||
mifosx-client-general-step,
|
||||
mifosx-client-india-address-step,
|
||||
mifosx-client-address-step,
|
||||
mifosx-client-kyc-step,
|
||||
mifosx-client-entity-kyc-step,
|
||||
mifosx-client-family-members-step,
|
||||
mifosx-client-datatable-step,
|
||||
mifosx-client-general-step *,
|
||||
mifosx-client-india-address-step *,
|
||||
mifosx-client-address-step *,
|
||||
mifosx-client-kyc-step *,
|
||||
mifosx-client-entity-kyc-step *,
|
||||
mifosx-client-family-members-step *,
|
||||
mifosx-client-datatable-step * {
|
||||
--mdc-checkbox-disabled-selected-icon-color: #059669;
|
||||
--mdc-checkbox-disabled-unselected-icon-color: #94a3b8;
|
||||
--mdc-checkbox-selected-icon-color: #059669;
|
||||
--mdc-checkbox-selected-focus-icon-color: #059669;
|
||||
--mdc-checkbox-selected-hover-icon-color: #059669;
|
||||
--mdc-checkbox-selected-pressed-icon-color: #059669;
|
||||
}
|
||||
/* stylelint-enable selector-max-universal */
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
max-width: 20rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
content: url('../assets/images/white-mifos.png');
|
||||
content: url('../assets/images/cbsnexora-logo.png');
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
@@ -281,7 +281,7 @@ body.dark-theme {
|
||||
mifosx-home {
|
||||
[src*='mifos_lg-logo.jpg'],
|
||||
[src$='_home.png'] {
|
||||
content: url('../assets/images/white-mifos.png');
|
||||
content: url('../assets/images/cbsnexora-logo.png');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user