feat(loan-origination): purpose and priority sector at KYC, simpler deposit-loan sanction, account wizard from LOS
- Purpose & Priority Sector section in KYC (priority sector Yes/No against the product's setting with a reason when they differ, category from the PrioritySectorCategory Code, weaker section and its type, supporting documents per category); Complete KYC and the sanction need it. - Deposit loans: Deposit Selection in two steps (deposit, then lien letter); no checkbox lists at sanction; the approver approves without ticking rows; sanction letter released on approval, with the sanctioning authority's name and date; Personal Discussion plan lists what was collected and the product terms; income, EMI and co-borrower hidden at KYC; loan-specific documents can always be added. - Loan account wizard takes purpose, priority sector, weaker section from the LOS application; LOS applications can be picked from a client's view; Limit & Security, Guarantors and SMS steps for advances. - FP-style deposit products are recognised as deposit loans once the product catalog is registered. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,664 @@
|
|||||||
|
/**
|
||||||
|
* 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 { jsPDF } from 'jspdf';
|
||||||
|
import autoTable from 'jspdf-autotable';
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
|
||||||
|
(pdfjsLib as any).GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||||
|
import.meta.url
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
/** One block of the form. The screen works out the rows; this file only draws them. */
|
||||||
|
export type LoanFormSection =
|
||||||
|
| { title: string; type: 'kv'; rows: [string, string][] }
|
||||||
|
| { title: string; type: 'compare'; rows: [string, string, string][]; jointName: string }
|
||||||
|
| { title: string; type: 'table'; head: string[]; rows: string[][]; note?: string; minRows?: number }
|
||||||
|
| { title: string; type: 'text'; text: string };
|
||||||
|
|
||||||
|
export interface LoanApplicationData {
|
||||||
|
bankName: string;
|
||||||
|
branchName: string;
|
||||||
|
applicationNo: string;
|
||||||
|
applicationDate: string;
|
||||||
|
generatedOn: string;
|
||||||
|
amount: string;
|
||||||
|
amountInWords: string;
|
||||||
|
scheme: string;
|
||||||
|
purpose: string;
|
||||||
|
/** The applicant's photograph as a data URL, when the client record has one. */
|
||||||
|
photo: string | null;
|
||||||
|
jointPhoto: boolean;
|
||||||
|
form: LoanFormSection[];
|
||||||
|
declaration: string;
|
||||||
|
/** What the bank fills in. */
|
||||||
|
bankUse: [string, string][];
|
||||||
|
/** After the form: what the sanctioning authority needs to see. */
|
||||||
|
annexure: LoanFormSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file that goes into the pack after the form: the customer's eKYC documents, the lien documents and the rest. */
|
||||||
|
export interface LoanApplicationAttachment {
|
||||||
|
group: string;
|
||||||
|
label: string;
|
||||||
|
fileName: string;
|
||||||
|
blob: Blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARGIN = 12;
|
||||||
|
const NAVY: [number, number, number] = [
|
||||||
|
15,
|
||||||
|
23,
|
||||||
|
42
|
||||||
|
];
|
||||||
|
const BLUE: [number, number, number] = [
|
||||||
|
30,
|
||||||
|
64,
|
||||||
|
175
|
||||||
|
];
|
||||||
|
const SOFT: [number, number, number] = [
|
||||||
|
241,
|
||||||
|
245,
|
||||||
|
249
|
||||||
|
];
|
||||||
|
const LINE: [number, number, number] = [
|
||||||
|
203,
|
||||||
|
213,
|
||||||
|
225
|
||||||
|
];
|
||||||
|
const MUTED: [number, number, number] = [
|
||||||
|
100,
|
||||||
|
116,
|
||||||
|
139
|
||||||
|
];
|
||||||
|
/** A long PDF would make the pack unmanageable: its first pages go in, and the pack says so. */
|
||||||
|
const MAX_PDF_PAGES = 12;
|
||||||
|
|
||||||
|
type Doc = jsPDF & { lastAutoTable?: { finalY: number } };
|
||||||
|
|
||||||
|
const finalY = (doc: Doc): number => doc.lastAutoTable?.finalY ?? 0;
|
||||||
|
|
||||||
|
// ───────────────────────────── helpers ─────────────────────────────
|
||||||
|
|
||||||
|
/** 125000 -> "One Lakh Twenty Five Thousand" (Indian grouping). */
|
||||||
|
export function amountInWords(value: number): string {
|
||||||
|
const ones = [
|
||||||
|
'',
|
||||||
|
'One',
|
||||||
|
'Two',
|
||||||
|
'Three',
|
||||||
|
'Four',
|
||||||
|
'Five',
|
||||||
|
'Six',
|
||||||
|
'Seven',
|
||||||
|
'Eight',
|
||||||
|
'Nine',
|
||||||
|
'Ten',
|
||||||
|
'Eleven',
|
||||||
|
'Twelve',
|
||||||
|
'Thirteen',
|
||||||
|
'Fourteen',
|
||||||
|
'Fifteen',
|
||||||
|
'Sixteen',
|
||||||
|
'Seventeen',
|
||||||
|
'Eighteen',
|
||||||
|
'Nineteen'
|
||||||
|
];
|
||||||
|
const tens = [
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Twenty',
|
||||||
|
'Thirty',
|
||||||
|
'Forty',
|
||||||
|
'Fifty',
|
||||||
|
'Sixty',
|
||||||
|
'Seventy',
|
||||||
|
'Eighty',
|
||||||
|
'Ninety'
|
||||||
|
];
|
||||||
|
const below100 = (n: number): string =>
|
||||||
|
n < 20 ? ones[n] : tens[Math.floor(n / 10)] + (n % 10 ? ' ' + ones[n % 10] : '');
|
||||||
|
const below1000 = (n: number): string =>
|
||||||
|
(n >= 100 ? ones[Math.floor(n / 100)] + ' Hundred' + (n % 100 ? ' ' : '') : '') +
|
||||||
|
(n % 100 ? below100(n % 100) : '');
|
||||||
|
let n = Math.floor(Math.abs(value));
|
||||||
|
if (n === 0) {
|
||||||
|
return 'Zero';
|
||||||
|
}
|
||||||
|
const parts: string[] = [];
|
||||||
|
const crore = Math.floor(n / 10000000);
|
||||||
|
n %= 10000000;
|
||||||
|
const lakh = Math.floor(n / 100000);
|
||||||
|
n %= 100000;
|
||||||
|
const thousand = Math.floor(n / 1000);
|
||||||
|
n %= 1000;
|
||||||
|
if (crore) parts.push(below1000(crore) + ' Crore');
|
||||||
|
if (lakh) parts.push(below100(lakh) + ' Lakh');
|
||||||
|
if (thousand) parts.push(below100(thousand) + ' Thousand');
|
||||||
|
if (n) parts.push(below1000(n));
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAsDataUrl(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result));
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error('image'));
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A numbered bar that opens each block of the form. */
|
||||||
|
function sectionBar(doc: Doc, number: number, title: string, y: number): number {
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
|
doc.setFillColor(...SOFT);
|
||||||
|
doc.roundedRect(MARGIN, y, pageWidth - MARGIN * 2, 7.5, 1.2, 1.2, 'F');
|
||||||
|
doc.setFillColor(...BLUE);
|
||||||
|
doc.roundedRect(MARGIN, y, 7.5, 7.5, 1.2, 1.2, 'F');
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.text(String(number), MARGIN + 3.75, y + 5.2, { align: 'center' });
|
||||||
|
doc.setTextColor(...NAVY);
|
||||||
|
doc.setFontSize(9.5);
|
||||||
|
doc.text(title.toUpperCase(), MARGIN + 11, y + 5.2);
|
||||||
|
return y + 10.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts a new page when what comes next would not fit. */
|
||||||
|
function room(doc: Doc, y: number, needed: number): number {
|
||||||
|
if (y + needed > doc.internal.pageSize.getHeight() - 14) {
|
||||||
|
doc.addPage();
|
||||||
|
return 16;
|
||||||
|
}
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseTable = {
|
||||||
|
theme: 'grid' as const,
|
||||||
|
styles: {
|
||||||
|
fontSize: 8.6,
|
||||||
|
cellPadding: { top: 2, bottom: 2, left: 2.4, right: 2.4 },
|
||||||
|
lineColor: LINE,
|
||||||
|
lineWidth: 0.2,
|
||||||
|
textColor: NAVY,
|
||||||
|
valign: 'middle' as const
|
||||||
|
},
|
||||||
|
headStyles: { fillColor: NAVY, textColor: [
|
||||||
|
255,
|
||||||
|
255,
|
||||||
|
255
|
||||||
|
] as [number, number, number], fontStyle: 'bold' as const, halign: 'left' as const },
|
||||||
|
alternateRowStyles: { fillColor: [
|
||||||
|
250,
|
||||||
|
251,
|
||||||
|
253
|
||||||
|
] as [number, number, number] },
|
||||||
|
margin: { left: MARGIN, right: MARGIN }
|
||||||
|
};
|
||||||
|
|
||||||
|
/** About how tall a block will be, so a block that fits on a page is not split across two. */
|
||||||
|
function estimatedHeight(s: LoanFormSection): number {
|
||||||
|
const rowH = 9;
|
||||||
|
switch (s.type) {
|
||||||
|
case 'kv':
|
||||||
|
return 12 + s.rows.length * rowH;
|
||||||
|
case 'compare':
|
||||||
|
return 14 + s.rows.length * rowH + 14;
|
||||||
|
case 'table':
|
||||||
|
return 14 + Math.max(s.rows.length, s.minRows ?? 1, 1) * rowH;
|
||||||
|
default:
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSection(doc: Doc, n: number, s: LoanFormSection, startY: number): number {
|
||||||
|
// a block that fits on one page starts on a new page rather than being cut in two
|
||||||
|
const usable = doc.internal.pageSize.getHeight() - 30;
|
||||||
|
const needed = Math.min(estimatedHeight(s), usable);
|
||||||
|
let y = room(doc, startY, needed);
|
||||||
|
y = sectionBar(doc, n, s.title, y);
|
||||||
|
if (s.type === 'kv') {
|
||||||
|
autoTable(doc, {
|
||||||
|
...baseTable,
|
||||||
|
startY: y,
|
||||||
|
body: s.rows.map(
|
||||||
|
([
|
||||||
|
k,
|
||||||
|
v
|
||||||
|
]) => [
|
||||||
|
k,
|
||||||
|
v
|
||||||
|
]
|
||||||
|
),
|
||||||
|
columnStyles: { 0: { cellWidth: 62, fontStyle: 'bold', fillColor: SOFT, textColor: [
|
||||||
|
51,
|
||||||
|
65,
|
||||||
|
85
|
||||||
|
] } },
|
||||||
|
bodyStyles: { minCellHeight: 7 }
|
||||||
|
});
|
||||||
|
} else if (s.type === 'compare') {
|
||||||
|
autoTable(doc, {
|
||||||
|
...baseTable,
|
||||||
|
startY: y,
|
||||||
|
head: [[
|
||||||
|
'Particulars',
|
||||||
|
'Applicant',
|
||||||
|
s.jointName || 'Joint applicant'
|
||||||
|
]],
|
||||||
|
body: s.rows.map((r) => [
|
||||||
|
r[0],
|
||||||
|
r[1],
|
||||||
|
r[2]
|
||||||
|
]),
|
||||||
|
columnStyles: {
|
||||||
|
0: { cellWidth: 46, fontStyle: 'bold', fillColor: SOFT, textColor: [
|
||||||
|
51,
|
||||||
|
65,
|
||||||
|
85
|
||||||
|
] },
|
||||||
|
1: { cellWidth: 70 }
|
||||||
|
},
|
||||||
|
bodyStyles: { minCellHeight: 7 }
|
||||||
|
});
|
||||||
|
} else if (s.type === 'table') {
|
||||||
|
const rows = s.rows.length ? s.rows : [];
|
||||||
|
const padded = [...rows];
|
||||||
|
while (padded.length < (s.minRows ?? 0)) {
|
||||||
|
padded.push(s.head.map(() => ''));
|
||||||
|
}
|
||||||
|
if (!padded.length) {
|
||||||
|
padded.push([
|
||||||
|
s.note ?? 'Nil',
|
||||||
|
...s.head.slice(1).map(() => '')
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
autoTable(doc, { ...baseTable, startY: y, head: [s.head], body: padded, bodyStyles: { minCellHeight: 7 } });
|
||||||
|
} else {
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(8.6);
|
||||||
|
doc.setTextColor(...NAVY);
|
||||||
|
const lines = doc.splitTextToSize(s.text, doc.internal.pageSize.getWidth() - MARGIN * 2);
|
||||||
|
doc.text(lines, MARGIN, y + 3);
|
||||||
|
return y + 3 + lines.length * 4 + 4;
|
||||||
|
}
|
||||||
|
return finalY(doc) + 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── attachments ─────────────────────────────
|
||||||
|
|
||||||
|
function attachmentHeader(doc: Doc, index: number, total: number, a: LoanApplicationAttachment, note: string): number {
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
|
doc.setFillColor(...SOFT);
|
||||||
|
doc.rect(0, 0, pageWidth, 16, 'F');
|
||||||
|
doc.setFillColor(...BLUE);
|
||||||
|
doc.rect(0, 0, 3, 16, 'F');
|
||||||
|
doc.setTextColor(...NAVY);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.text(`Attachment ${index} of ${total} — ${a.label}`, MARGIN, 7);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(...MUTED);
|
||||||
|
doc.text(`${a.group} · ${a.fileName}${note ? ' · ' + note : ''}`, MARGIN, 12.5);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
return 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
function placePicture(doc: Doc, dataUrl: string, format: 'JPEG' | 'PNG', w: number, h: number, top: number): void {
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
|
const pageHeight = doc.internal.pageSize.getHeight();
|
||||||
|
const maxW = pageWidth - MARGIN * 2;
|
||||||
|
const maxH = pageHeight - top - 12;
|
||||||
|
const ratio = Math.min(maxW / w, maxH / h);
|
||||||
|
const width = w * ratio;
|
||||||
|
const height = h * ratio;
|
||||||
|
doc.addImage(dataUrl, format, (pageWidth - width) / 2, top, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendImage(doc: Doc, index: number, total: number, a: LoanApplicationAttachment): Promise<void> {
|
||||||
|
const dataUrl = await readAsDataUrl(a.blob);
|
||||||
|
const img = await loadImage(dataUrl);
|
||||||
|
doc.addPage();
|
||||||
|
const top = attachmentHeader(doc, index, total, a, '');
|
||||||
|
placePicture(
|
||||||
|
doc,
|
||||||
|
dataUrl,
|
||||||
|
/png/i.test(a.blob.type) ? 'PNG' : 'JPEG',
|
||||||
|
img.naturalWidth || 1,
|
||||||
|
img.naturalHeight || 1,
|
||||||
|
top
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendPdf(doc: Doc, index: number, total: number, a: LoanApplicationAttachment): Promise<void> {
|
||||||
|
const data = new Uint8Array(await a.blob.arrayBuffer());
|
||||||
|
const pdf = await (pdfjsLib as any).getDocument({ data }).promise;
|
||||||
|
const pages = Math.min(pdf.numPages, MAX_PDF_PAGES);
|
||||||
|
for (let p = 1; p <= pages; p++) {
|
||||||
|
const page = await pdf.getPage(p);
|
||||||
|
const viewport = page.getViewport({ scale: 1.5 });
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.ceil(viewport.width);
|
||||||
|
canvas.height = Math.ceil(viewport.height);
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('canvas');
|
||||||
|
}
|
||||||
|
await page.render({ canvasContext: context, viewport }).promise;
|
||||||
|
doc.addPage();
|
||||||
|
const note =
|
||||||
|
pdf.numPages > 1
|
||||||
|
? `page ${p} of ${pdf.numPages}${pdf.numPages > pages && p === pages ? ' (first ' + pages + ' pages only)' : ''}`
|
||||||
|
: '';
|
||||||
|
const top = attachmentHeader(doc, index, total, a, note);
|
||||||
|
placePicture(doc, canvas.toDataURL('image/jpeg', 0.82), 'JPEG', canvas.width, canvas.height, top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendAttachment(doc: Doc, index: number, total: number, a: LoanApplicationAttachment): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (/pdf/i.test(a.blob.type) || /\.pdf$/i.test(a.fileName)) {
|
||||||
|
await appendPdf(doc, index, total, a);
|
||||||
|
} else if (/^image\//i.test(a.blob.type)) {
|
||||||
|
await appendImage(doc, index, total, a);
|
||||||
|
} else {
|
||||||
|
throw new Error('type');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
doc.addPage();
|
||||||
|
const top = attachmentHeader(doc, index, total, a, '');
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.text(
|
||||||
|
'This file could not be placed in the pack (' +
|
||||||
|
(a.blob.type || 'unknown type') +
|
||||||
|
'). Open it from the application.',
|
||||||
|
MARGIN,
|
||||||
|
top + 6
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── the form ─────────────────────────────
|
||||||
|
|
||||||
|
function drawHeader(doc: Doc, d: LoanApplicationData): number {
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
|
// top band
|
||||||
|
doc.setFillColor(...NAVY);
|
||||||
|
doc.rect(0, 0, pageWidth, 34, 'F');
|
||||||
|
doc.setFillColor(...BLUE);
|
||||||
|
doc.rect(0, 34, pageWidth, 1.6, 'F');
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(17);
|
||||||
|
doc.text(d.bankName.toUpperCase(), MARGIN, 14);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(186, 200, 230);
|
||||||
|
doc.text(d.branchName, MARGIN, 20);
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(14);
|
||||||
|
doc.text('LOAN APPLICATION FORM', MARGIN, 29);
|
||||||
|
// application number card
|
||||||
|
const cardW = 56;
|
||||||
|
doc.setFillColor(255, 255, 255);
|
||||||
|
doc.roundedRect(pageWidth - MARGIN - cardW, 7, cardW, 20, 2, 2, 'F');
|
||||||
|
doc.setTextColor(...MUTED);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(7);
|
||||||
|
doc.text('APPLICATION NO.', pageWidth - MARGIN - cardW + 4, 13);
|
||||||
|
doc.setTextColor(...NAVY);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.text(d.applicationNo, pageWidth - MARGIN - cardW + 4, 19);
|
||||||
|
doc.setTextColor(...MUTED);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.text('Date: ' + d.applicationDate, pageWidth - MARGIN - cardW + 4, 24.5);
|
||||||
|
|
||||||
|
// addressee and photographs
|
||||||
|
let y = 43;
|
||||||
|
doc.setTextColor(...NAVY);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.text('PART I — Basic information common to all schemes', MARGIN, y);
|
||||||
|
y += 8;
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(9.5);
|
||||||
|
doc.text('To,', MARGIN, y);
|
||||||
|
doc.text('THE BRANCH MANAGER', MARGIN, y + 5.5);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(d.bankName + ' — ' + d.branchName, MARGIN, y + 11);
|
||||||
|
|
||||||
|
const boxW = 27;
|
||||||
|
const boxH = 33;
|
||||||
|
const boxTop = 38;
|
||||||
|
const rightX = pageWidth - MARGIN - boxW;
|
||||||
|
const leftX = rightX - boxW - 5;
|
||||||
|
const drawBox = (x: number, label: string, image: string | null) => {
|
||||||
|
doc.setDrawColor(...LINE);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.roundedRect(x, boxTop, boxW, boxH, 1.5, 1.5);
|
||||||
|
if (image) {
|
||||||
|
try {
|
||||||
|
doc.addImage(image, 'JPEG', x + 0.8, boxTop + 0.8, boxW - 1.6, boxH - 1.6);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// an image that cannot be drawn leaves the box empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doc.setTextColor(...MUTED);
|
||||||
|
doc.setFontSize(6.8);
|
||||||
|
doc.text('PHOTOGRAPH', x + boxW / 2, boxTop + 13, { align: 'center' });
|
||||||
|
doc.text('OF', x + boxW / 2, boxTop + 17, { align: 'center' });
|
||||||
|
doc.text(label, x + boxW / 2, boxTop + 21, { align: 'center' });
|
||||||
|
};
|
||||||
|
drawBox(leftX, 'APPLICANT', d.photo);
|
||||||
|
if (d.jointPhoto) {
|
||||||
|
drawBox(rightX, 'JOINT-APPLICANT', null);
|
||||||
|
} else {
|
||||||
|
drawBox(rightX, 'JOINT-APPLICANT', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the request, in a panel
|
||||||
|
y = boxTop + boxH + 6;
|
||||||
|
const panelW = pageWidth - MARGIN * 2;
|
||||||
|
const text =
|
||||||
|
`I/We request for a loan of Rs. ${d.amount} (Rupees ${d.amountInWords} only) under ${d.scheme} Scheme` +
|
||||||
|
(d.purpose ? ` for the purpose of ${d.purpose}.` : '.') +
|
||||||
|
' I/We furnish our particulars as below.';
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(9.2);
|
||||||
|
const lines = doc.splitTextToSize(text, panelW - 10);
|
||||||
|
const panelH = lines.length * 4.6 + 7;
|
||||||
|
doc.setFillColor(239, 246, 255);
|
||||||
|
doc.roundedRect(MARGIN, y, panelW, panelH, 1.8, 1.8, 'F');
|
||||||
|
doc.setFillColor(...BLUE);
|
||||||
|
doc.rect(MARGIN, y, 1.6, panelH, 'F');
|
||||||
|
doc.setTextColor(...NAVY);
|
||||||
|
doc.text(lines, MARGIN + 5, y + 6);
|
||||||
|
return y + panelH + 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSignatures(doc: Doc, y: number, jointName: string): number {
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
|
y = room(doc, y, 36);
|
||||||
|
const colW = (pageWidth - MARGIN * 2 - 8) / 2;
|
||||||
|
const boxes: [string, string][] = [
|
||||||
|
[
|
||||||
|
'Signature of the Applicant',
|
||||||
|
''
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Signature of the Joint-Applicant',
|
||||||
|
jointName
|
||||||
|
]
|
||||||
|
];
|
||||||
|
boxes.forEach(
|
||||||
|
([
|
||||||
|
label,
|
||||||
|
name
|
||||||
|
], i) => {
|
||||||
|
const x = MARGIN + i * (colW + 8);
|
||||||
|
doc.setDrawColor(...LINE);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.roundedRect(x, y, colW, 24, 1.5, 1.5);
|
||||||
|
doc.setDrawColor(...MUTED);
|
||||||
|
doc.line(x + 6, y + 17, x + colW - 6, y + 17);
|
||||||
|
doc.setTextColor(...MUTED);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.text(label, x + colW / 2, y + 21.5, { align: 'center' });
|
||||||
|
if (name) {
|
||||||
|
doc.setTextColor(...NAVY);
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.text(name, x + colW / 2, y + 13, { align: 'center' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
doc.setTextColor(...NAVY);
|
||||||
|
doc.setFontSize(8.6);
|
||||||
|
doc.text('Place: ______________________', MARGIN, y + 30);
|
||||||
|
doc.text('Date: ______________________', MARGIN + colW + 8, y + 30);
|
||||||
|
return y + 36;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBankUse(doc: Doc, y: number, rows: [string, string][]): number {
|
||||||
|
y = room(doc, y, 20 + rows.length * 7);
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
|
doc.setFillColor(...NAVY);
|
||||||
|
doc.roundedRect(MARGIN, y, pageWidth - MARGIN * 2, 7, 1.2, 1.2, 'F');
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(8.8);
|
||||||
|
doc.text('FOR BANK USE ONLY', MARGIN + 4, y + 4.8);
|
||||||
|
autoTable(doc, {
|
||||||
|
...baseTable,
|
||||||
|
startY: y + 8,
|
||||||
|
body: rows,
|
||||||
|
columnStyles: { 0: { cellWidth: 62, fontStyle: 'bold', fillColor: SOFT, textColor: [
|
||||||
|
51,
|
||||||
|
65,
|
||||||
|
85
|
||||||
|
] } },
|
||||||
|
bodyStyles: { minCellHeight: 7 }
|
||||||
|
});
|
||||||
|
return finalY(doc) + 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The loan application form, then the annexure for the sanctioning authority, then every document attached. */
|
||||||
|
export async function buildLoanApplication(
|
||||||
|
d: LoanApplicationData,
|
||||||
|
attachments: LoanApplicationAttachment[]
|
||||||
|
): Promise<jsPDF> {
|
||||||
|
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }) as Doc;
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
|
|
||||||
|
let y = drawHeader(doc, d);
|
||||||
|
let n = 1;
|
||||||
|
for (const section of d.form) {
|
||||||
|
y = drawSection(doc, n++, section, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// declaration
|
||||||
|
y = room(doc, y, 60);
|
||||||
|
y = sectionBar(doc, n++, 'Declaration', y);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(7.8);
|
||||||
|
doc.setTextColor(51, 65, 85);
|
||||||
|
const declaration = doc.splitTextToSize(d.declaration, pageWidth - MARGIN * 2);
|
||||||
|
doc.text(declaration, MARGIN, y + 3);
|
||||||
|
y += 3 + declaration.length * 3.6 + 6;
|
||||||
|
|
||||||
|
const jointName = (d.form.find((s) => s.type === 'compare') as { jointName: string } | undefined)?.jointName ?? '';
|
||||||
|
y = drawSignatures(doc, y, jointName);
|
||||||
|
y = drawBankUse(doc, y, d.bankUse);
|
||||||
|
|
||||||
|
// annexure
|
||||||
|
if (d.annexure.length || attachments.length) {
|
||||||
|
doc.addPage();
|
||||||
|
y = 16;
|
||||||
|
doc.setFillColor(...NAVY);
|
||||||
|
doc.rect(0, 0, pageWidth, 12, 'F');
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.text('ANNEXURE — For the sanctioning authority', MARGIN, 8);
|
||||||
|
y = 20;
|
||||||
|
let a = 1;
|
||||||
|
for (const section of d.annexure) {
|
||||||
|
y = drawSection(doc, a++, section, y);
|
||||||
|
}
|
||||||
|
y = room(doc, y, 30);
|
||||||
|
y = sectionBar(doc, a++, `Documents attached (${attachments.length})`, y);
|
||||||
|
autoTable(doc, {
|
||||||
|
...baseTable,
|
||||||
|
startY: y,
|
||||||
|
head: [[
|
||||||
|
'#',
|
||||||
|
'Group',
|
||||||
|
'Document',
|
||||||
|
'File'
|
||||||
|
]],
|
||||||
|
body: attachments.length
|
||||||
|
? attachments.map((x, i) => [
|
||||||
|
String(i + 1),
|
||||||
|
x.group,
|
||||||
|
x.label,
|
||||||
|
x.fileName
|
||||||
|
])
|
||||||
|
: [[
|
||||||
|
'',
|
||||||
|
'No document is on file for this application.',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
]],
|
||||||
|
columnStyles: { 0: { cellWidth: 8 } },
|
||||||
|
styles: { ...baseTable.styles, fontSize: 8 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < attachments.length; i++) {
|
||||||
|
await appendAttachment(doc, i + 1, attachments.length, attachments[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer on every page
|
||||||
|
const pages = doc.getNumberOfPages();
|
||||||
|
for (let p = 1; p <= pages; p++) {
|
||||||
|
doc.setPage(p);
|
||||||
|
doc.setDrawColor(...LINE);
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
doc.line(
|
||||||
|
MARGIN,
|
||||||
|
doc.internal.pageSize.getHeight() - 10,
|
||||||
|
pageWidth - MARGIN,
|
||||||
|
doc.internal.pageSize.getHeight() - 10
|
||||||
|
);
|
||||||
|
doc.setFontSize(7);
|
||||||
|
doc.setTextColor(150, 150, 150);
|
||||||
|
doc.text(
|
||||||
|
`Loan Application ${d.applicationNo} · ${d.bankName} · generated ${d.generatedOn}`,
|
||||||
|
MARGIN,
|
||||||
|
doc.internal.pageSize.getHeight() - 6
|
||||||
|
);
|
||||||
|
doc.text(`Page ${p} of ${pages}`, pageWidth - MARGIN, doc.internal.pageSize.getHeight() - 6, { align: 'right' });
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
@@ -68,6 +68,8 @@ export async function createRealLoanAccount(
|
|||||||
interestCalculationPeriodType: template.interestCalculationPeriodType?.id,
|
interestCalculationPeriodType: template.interestCalculationPeriodType?.id,
|
||||||
amortizationType: template.amortizationType?.id,
|
amortizationType: template.amortizationType?.id,
|
||||||
transactionProcessingStrategyCode: template.transactionProcessingStrategyCode,
|
transactionProcessingStrategyCode: template.transactionProcessingStrategyCode,
|
||||||
|
// the application number is the loan's external id, so the application can find (and show) this account
|
||||||
|
externalId: app.applicationNo,
|
||||||
submittedOnDate: today,
|
submittedOnDate: today,
|
||||||
expectedDisbursementDate: today,
|
expectedDisbursementDate: today,
|
||||||
dateFormat,
|
dateFormat,
|
||||||
@@ -85,7 +87,7 @@ export async function createRealLoanAccount(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await firstValueFrom(
|
const disbursed: any = await firstValueFrom(
|
||||||
loansService.loanActionButtons(loanId, 'disburse', {
|
loansService.loanActionButtons(loanId, 'disburse', {
|
||||||
actualDisbursementDate: today,
|
actualDisbursementDate: today,
|
||||||
transactionAmount: app.requestedAmount,
|
transactionAmount: app.requestedAmount,
|
||||||
@@ -93,6 +95,16 @@ export async function createRealLoanAccount(
|
|||||||
locale
|
locale
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
if (disbursed?.rollbackTransaction) {
|
||||||
|
// Disbursement needs a checker (maker-checker): nothing is disbursed until it is approved, so the application must not say so.
|
||||||
|
throw new Error(
|
||||||
|
'The disbursement of loan ' +
|
||||||
|
loanId +
|
||||||
|
' was sent for approval (request ' +
|
||||||
|
disbursed.commandId +
|
||||||
|
'). It happens when it is approved in the Approvals inbox.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const account: any = await firstValueFrom(loansService.getLoanAccountResource(String(loanId), ''));
|
const account: any = await firstValueFrom(loansService.getLoanAccountResource(String(loanId), ''));
|
||||||
return {
|
return {
|
||||||
@@ -100,3 +112,55 @@ export async function createRealLoanAccount(
|
|||||||
loanAccountNo: account?.accountNo || String(loanId)
|
loanAccountNo: account?.accountNo || String(loanId)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CoreLienTarget {
|
||||||
|
/** The Fineract deposit account (fixed or recurring) the loan is secured on. */
|
||||||
|
savingsId: number;
|
||||||
|
accountNo: string;
|
||||||
|
/** How much of the loan this deposit secures. */
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts a real lien on the pledged FD / RD accounts once the loan account exists.
|
||||||
|
*
|
||||||
|
* Fineract has no separate lien call; its own way to bind a deposit to a loan is a guarantor funded from that deposit:
|
||||||
|
* the amount is held on the account (it cannot be withdrawn or closed), an RD counts it as a lien, and it is released
|
||||||
|
* in step with the loan's repayments and in full when the loan is repaid. The customer guarantees their own loan with
|
||||||
|
* their own deposit, which Fineract allows when the guarantor is funded from a savings account.
|
||||||
|
*
|
||||||
|
* Returns the accounts it could not lien, with the reason, so the screen can say so rather than leaving the
|
||||||
|
* application showing a lien that does not exist.
|
||||||
|
*/
|
||||||
|
export async function placeCoreLien(
|
||||||
|
loansService: LoansService,
|
||||||
|
dateUtils: Dates,
|
||||||
|
settings: SettingsService,
|
||||||
|
loanId: number,
|
||||||
|
clientId: number,
|
||||||
|
targets: CoreLienTarget[]
|
||||||
|
): Promise<{ placed: CoreLienTarget[]; failed: { target: CoreLienTarget; reason: string }[] }> {
|
||||||
|
const placed: CoreLienTarget[] = [];
|
||||||
|
const failed: { target: CoreLienTarget; reason: string }[] = [];
|
||||||
|
for (const target of targets) {
|
||||||
|
try {
|
||||||
|
await firstValueFrom(
|
||||||
|
loansService.createNewGuarantor(String(loanId), {
|
||||||
|
guarantorTypeId: 1,
|
||||||
|
entityId: clientId,
|
||||||
|
savingsId: target.savingsId,
|
||||||
|
amount: target.amount,
|
||||||
|
locale: settings.language.code,
|
||||||
|
dateFormat: settings.dateFormat
|
||||||
|
})
|
||||||
|
);
|
||||||
|
placed.push(target);
|
||||||
|
} catch (err: any) {
|
||||||
|
failed.push({
|
||||||
|
target,
|
||||||
|
reason: err?.error?.errors?.[0]?.defaultUserMessage || err?.message || 'Could not place the lien.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { placed, failed };
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,14 +77,25 @@ export const STAGE_LABELS: Record<OriginationStage, string> = {
|
|||||||
* credit bureau (CIC) check for it. Everything else keeps the full 12-stage list. */
|
* credit bureau (CIC) check for it. Everything else keeps the full 12-stage list. */
|
||||||
export function stagesFor(productCode: string | undefined | null): OriginationStage[] {
|
export function stagesFor(productCode: string | undefined | null): OriginationStage[] {
|
||||||
if (isDepositLoanCode(productCode)) {
|
if (isDepositLoanCode(productCode)) {
|
||||||
// No credit bureau check, field investigation or security cheque / ACH (recovery is by set-off against the lien); every other stage stays.
|
// No credit bureau check, field investigation or security cheque / ACH (recovery is by set-off against the lien), and no
|
||||||
|
// separate risk or legal approval: the one Sanction step (the committee stage) takes their place. Every other stage stays.
|
||||||
return STAGE_ORDER.filter(
|
return STAGE_ORDER.filter(
|
||||||
(stage) => stage !== 'CIC_CHECK' && stage !== 'FIELD_INVESTIGATION' && stage !== 'CHEQUE_ACH'
|
(stage) =>
|
||||||
|
stage !== 'CIC_CHECK' &&
|
||||||
|
stage !== 'FIELD_INVESTIGATION' &&
|
||||||
|
stage !== 'CHEQUE_ACH' &&
|
||||||
|
stage !== 'RISK_APPROVAL' &&
|
||||||
|
stage !== 'LEGAL_APPROVAL'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return STAGE_ORDER.filter((stage) => stage !== 'DEPOSIT_SELECTION');
|
return STAGE_ORDER.filter((stage) => stage !== 'DEPOSIT_SELECTION');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a stage is called for this product: on a loan against a deposit the committee stage is the single Sanction step. */
|
||||||
|
export function stageLabelFor(stage: OriginationStage, productCode: string | undefined | null): string {
|
||||||
|
return stage === 'CREDIT_COMMITTEE' && isDepositLoanCode(productCode) ? 'Sanction' : STAGE_LABELS[stage];
|
||||||
|
}
|
||||||
|
|
||||||
export function stageIndex(stage: OriginationStage): number {
|
export function stageIndex(stage: OriginationStage): number {
|
||||||
return STAGE_ORDER.indexOf(stage);
|
return STAGE_ORDER.indexOf(stage);
|
||||||
}
|
}
|
||||||
@@ -151,13 +162,14 @@ export const DISCREPANCY_LABELS: Record<DiscrepancyType, string> = {
|
|||||||
FAKE_DECLARATION: 'Fake Declaration Suspected'
|
FAKE_DECLARATION: 'Fake Declaration Suspected'
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApprovalDecision = 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED';
|
export type ApprovalDecision = 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED' | 'REVOKED';
|
||||||
|
|
||||||
export const APPROVAL_LABELS: Record<ApprovalDecision, string> = {
|
export const APPROVAL_LABELS: Record<ApprovalDecision, string> = {
|
||||||
PENDING: 'Pending',
|
PENDING: 'Pending',
|
||||||
APPROVED: 'Approved',
|
APPROVED: 'Approved',
|
||||||
REJECTED: 'Rejected',
|
REJECTED: 'Rejected',
|
||||||
EXCEPTION_APPROVED: 'Approved on Exception'
|
EXCEPTION_APPROVED: 'Approved on Exception',
|
||||||
|
REVOKED: 'Returned for Rectification'
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VerificationStatus = 'PENDING' | 'VERIFIED' | 'FAILED';
|
export type VerificationStatus = 'PENDING' | 'VERIFIED' | 'FAILED';
|
||||||
@@ -195,8 +207,34 @@ export interface StageHistoryEntry {
|
|||||||
remarks: string;
|
remarks: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A LIC policy pledged for a loan against LIC: not a bank account, so the officer records it from the policy documents. */
|
||||||
|
export interface LicPolicy {
|
||||||
|
policyNo: string;
|
||||||
|
insurer: string;
|
||||||
|
policyHolder: string;
|
||||||
|
sumAssured: number;
|
||||||
|
premiumPaid: number;
|
||||||
|
/** As per the insurer's surrender value letter (not older than a month). */
|
||||||
|
surrenderValue: number;
|
||||||
|
/** DD-MM-YYYY */
|
||||||
|
maturityDate: string;
|
||||||
|
/** The loan's interest rate, entered by the officer (a policy has no deposit rate to follow). */
|
||||||
|
loanRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One guarantor named on an application. */
|
||||||
|
export interface GuarantorEntry {
|
||||||
|
clientId: number;
|
||||||
|
name: string;
|
||||||
|
hasShareAccount: boolean;
|
||||||
|
/** The share accounts found for the guarantor when membership was checked. */
|
||||||
|
shareAccountNos?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/** The deposit(s) pledged for a loan against FD / RD / DDS / LIC and what they allow (policy 9.5). */
|
/** The deposit(s) pledged for a loan against FD / RD / DDS / LIC and what they allow (policy 9.5). */
|
||||||
export interface DepositSelection {
|
export interface DepositSelection {
|
||||||
|
/** The LIC policy, for a loan against LIC (then `accountIds` is empty and `accountNos` holds the policy number). */
|
||||||
|
policy?: LicPolicy;
|
||||||
accountIds: number[];
|
accountIds: number[];
|
||||||
accountNos: string[];
|
accountNos: string[];
|
||||||
securityValue: number;
|
securityValue: number;
|
||||||
@@ -220,15 +258,24 @@ export interface LienRecord {
|
|||||||
markedOn: string;
|
markedOn: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
accountNos: string[];
|
accountNos: string[];
|
||||||
|
/** Whether the lien really stands in core banking (funds held on the FD / RD, or the DDS account marked), as opposed to
|
||||||
|
* only being recorded here. */
|
||||||
|
coreStatus?: 'PLACED' | 'FAILED' | 'NOT_APPLICABLE';
|
||||||
|
/** What was done in core banking, or why it could not be. */
|
||||||
|
coreNote?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An approval sent for a decision (maker-checker): the user who sent it cannot be the one who decides. */
|
/** An approval sent for a decision (maker-checker): the user who sent it cannot be the one who decides. */
|
||||||
export interface ApprovalRequest {
|
export interface ApprovalRequest {
|
||||||
status: 'PENDING' | 'APPROVED' | 'EXCEPTION_APPROVED' | 'REJECTED' | 'WITHDRAWN';
|
status: 'PENDING' | 'APPROVED' | 'EXCEPTION_APPROVED' | 'REJECTED' | 'WITHDRAWN' | 'REVOKED';
|
||||||
submittedBy: string;
|
submittedBy: string;
|
||||||
submittedOn: string;
|
submittedOn: string;
|
||||||
requestedDecision: 'APPROVED' | 'EXCEPTION_APPROVED';
|
requestedDecision: 'APPROVED' | 'EXCEPTION_APPROVED';
|
||||||
remarks: string;
|
remarks: string;
|
||||||
|
revokeRemarks?: string;
|
||||||
|
previousRevokeRemarks?: string;
|
||||||
|
previousRevokedBy?: string;
|
||||||
|
previousRevokedOn?: string;
|
||||||
resolutionNo?: string;
|
resolutionNo?: string;
|
||||||
resolutionDate?: string;
|
resolutionDate?: string;
|
||||||
/** What the sender had verified at the moment of sending -- what the approver reviews. */
|
/** What the sender had verified at the moment of sending -- what the approver reviews. */
|
||||||
@@ -379,6 +426,13 @@ export interface LoanApplication {
|
|||||||
guarantorClientId: number | null;
|
guarantorClientId: number | null;
|
||||||
guarantorName: string;
|
guarantorName: string;
|
||||||
guarantorHasShareAccount: boolean;
|
guarantorHasShareAccount: boolean;
|
||||||
|
/** How many guarantors this application has to name, from the product it was raised on. Absent on an application raised
|
||||||
|
* before products carried it, which keeps the officer's own Guarantor Required tick. */
|
||||||
|
guarantorsRequiredCount?: number;
|
||||||
|
/** Whether those guarantors have to hold a share account (be members), from the product. */
|
||||||
|
guarantorMustBeMember?: boolean;
|
||||||
|
/** The guarantors named so far (each a real, already-onboarded bank client). */
|
||||||
|
guarantors?: GuarantorEntry[];
|
||||||
|
|
||||||
// "Reports Required" presented at Loan Committee, and the "Verification Checklist" run at Risk
|
// "Reports Required" presented at Loan Committee, and the "Verification Checklist" run at Risk
|
||||||
// Approval — kept as their own fields since ID/Address/Income/FI-Report are already covered by
|
// Approval — kept as their own fields since ID/Address/Income/FI-Report are already covered by
|
||||||
@@ -398,9 +452,22 @@ export interface LoanApplication {
|
|||||||
* is what actually gets quoted in the sanction letter later. */
|
* is what actually gets quoted in the sanction letter later. */
|
||||||
committeeResolutionNo: string;
|
committeeResolutionNo: string;
|
||||||
committeeResolutionDate: string;
|
committeeResolutionDate: string;
|
||||||
|
/** What the loan is for, asked after KYC: the priority sector and weaker section decisions follow from it. */
|
||||||
|
loanPurpose?: string;
|
||||||
|
/** The officer's priority sector decision ('yes' | 'no'); the product's own setting is only the starting point. */
|
||||||
|
pslClassified?: string;
|
||||||
|
/** Priority sector lending category (RBI Master Directions para 5); one only, set when classified as priority sector. */
|
||||||
|
prioritySectorCategory?: string;
|
||||||
|
/** Why the decision differs from the product's priority sector setting. */
|
||||||
|
pslOverrideReason?: string;
|
||||||
|
/** Weaker section ('yes' | 'no') and its type (RBI Master Directions para 17.1). */
|
||||||
|
weakerSection?: string;
|
||||||
|
weakerSectionType?: string;
|
||||||
riskApproval: ApprovalRecord;
|
riskApproval: ApprovalRecord;
|
||||||
legalApproval: ApprovalRecord;
|
legalApproval: ApprovalRecord;
|
||||||
postCheckApproval: ApprovalRecord;
|
postCheckApproval: ApprovalRecord;
|
||||||
|
/** The approval of the loan account's disbursement (asked at the Disbursement stage; absent until approved). */
|
||||||
|
disbursementApproval?: ApprovalRecord;
|
||||||
|
|
||||||
// Legal / documentation
|
// Legal / documentation
|
||||||
feesAndAccountOpened: boolean;
|
feesAndAccountOpened: boolean;
|
||||||
@@ -526,6 +593,11 @@ interface OriginationProduct {
|
|||||||
interestBasis?: string;
|
interestBasis?: string;
|
||||||
/** The product's own interest rate (used when the rate is not "deposit rate + spread"). */
|
/** The product's own interest rate (used when the rate is not "deposit rate + spread"). */
|
||||||
baseRate?: number;
|
baseRate?: number;
|
||||||
|
/** The deposit product(s) a loan is lent against, as named on the loan product (Product Creation > Security & Lien). */
|
||||||
|
eligibleDepositProducts?: string;
|
||||||
|
/** How many guarantors an application on this product has to name (Product Creation); absent on an older product. */
|
||||||
|
guarantorsRequired?: number;
|
||||||
|
guarantorMustBeMember?: boolean;
|
||||||
allowMultipleDeposits?: boolean;
|
allowMultipleDeposits?: boolean;
|
||||||
allowThirdParty?: boolean;
|
allowThirdParty?: boolean;
|
||||||
/** The loan tenure may not run past the deposit's maturity. */
|
/** The loan tenure may not run past the deposit's maturity. */
|
||||||
@@ -540,7 +612,106 @@ interface OriginationProduct {
|
|||||||
facilityType?: string;
|
facilityType?: string;
|
||||||
preclosure?: string;
|
preclosure?: string;
|
||||||
renewalAllowed?: boolean;
|
renewalAllowed?: boolean;
|
||||||
|
/** The product was created as priority sector lending (Product Creation): the sanction names the category. */
|
||||||
|
prioritySector?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** RBI's priority sector lending categories. */
|
||||||
|
export const PRIORITY_SECTOR_CATEGORIES = [
|
||||||
|
'Agriculture',
|
||||||
|
'Micro, Small and Medium Enterprises',
|
||||||
|
'Export Credit',
|
||||||
|
'Education',
|
||||||
|
'Housing',
|
||||||
|
'Social Infrastructure',
|
||||||
|
'Renewable Energy',
|
||||||
|
'Others'
|
||||||
|
];
|
||||||
|
/** Weaker section types (RBI Master Directions on PSL, para 17.1): the fallback when the Code "WeakerSectionType" cannot be read. */
|
||||||
|
export const WEAKER_SECTION_TYPES = [
|
||||||
|
'Small and Marginal Farmers',
|
||||||
|
'Artisans, village and cottage industries (individual credit limit up to ₹2 lakh)',
|
||||||
|
'Beneficiaries of Government Sponsored Schemes (NRLM, SRMS)',
|
||||||
|
'Scheduled Castes and Scheduled Tribes',
|
||||||
|
'Beneficiaries of Differential Rate of Interest (DRI) scheme',
|
||||||
|
'Self Help Groups / Joint Liability Groups',
|
||||||
|
'Individuals and members of SHGs/JLGs availing microfinance loans',
|
||||||
|
'Individual women beneficiaries (up to ₹2 lakh)',
|
||||||
|
'Distressed farmers indebted to non-institutional lenders',
|
||||||
|
'Distressed persons other than farmers (up to ₹1 lakh)',
|
||||||
|
'Persons with disabilities',
|
||||||
|
'Transgenders',
|
||||||
|
'Minority communities notified by Government of India'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Proof an officer can attach for a priority sector classification, by category. */
|
||||||
|
export const PSL_DOCUMENT_TYPES: Record<string, string[]> = {
|
||||||
|
Agriculture: [
|
||||||
|
'Land record (7/12, Khata, Patta)',
|
||||||
|
'Crop / cultivation details',
|
||||||
|
'Kisan Credit Card',
|
||||||
|
'Tenancy / lease agreement',
|
||||||
|
'Allied activity licence (dairy, fisheries, poultry)',
|
||||||
|
'Project report / quotation'
|
||||||
|
],
|
||||||
|
'Micro, Small and Medium Enterprises': [
|
||||||
|
'Udyam Registration Certificate',
|
||||||
|
'GST registration',
|
||||||
|
'Shop & Establishment / trade licence',
|
||||||
|
'Project report / quotation',
|
||||||
|
'Latest ITR / financial statements'
|
||||||
|
],
|
||||||
|
'Export Credit': [
|
||||||
|
'Export order / Letter of credit',
|
||||||
|
'Import Export Code (IEC)',
|
||||||
|
'Shipping bill / invoice'
|
||||||
|
],
|
||||||
|
Education: [
|
||||||
|
'Admission letter',
|
||||||
|
'Fee structure',
|
||||||
|
'Marksheet of the last exam'
|
||||||
|
],
|
||||||
|
Housing: [
|
||||||
|
'Sale agreement / allotment letter',
|
||||||
|
'Property title documents',
|
||||||
|
'Cost estimate / approved plan',
|
||||||
|
'Property tax receipt (for repairs)'
|
||||||
|
],
|
||||||
|
'Social Infrastructure': [
|
||||||
|
'School / hospital registration',
|
||||||
|
'Project report and approvals',
|
||||||
|
'Land / building documents'
|
||||||
|
],
|
||||||
|
'Renewable Energy': [
|
||||||
|
'Project report',
|
||||||
|
'Vendor quotation',
|
||||||
|
'DISCOM / MNRE approval'
|
||||||
|
],
|
||||||
|
Others: [
|
||||||
|
'Self Help Group registration / resolution',
|
||||||
|
'Start-up recognition (DPIIT)',
|
||||||
|
'Debt statement (distressed person)',
|
||||||
|
'Project report'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Offered for every category and for the weaker section proof. */
|
||||||
|
export const PSL_OTHER_DOCUMENT = 'Other supporting document';
|
||||||
|
|
||||||
|
/** Proof of the weaker section the borrower belongs to. */
|
||||||
|
export const WEAKER_SECTION_DOCUMENT_TYPES = [
|
||||||
|
'Caste certificate (SC/ST)',
|
||||||
|
'Disability certificate / UDID',
|
||||||
|
'Minority community proof',
|
||||||
|
'Landholding record (small / marginal farmer)',
|
||||||
|
'SHG / JLG registration',
|
||||||
|
'Government scheme beneficiary certificate (NRLM, SRMS, DRI)',
|
||||||
|
'Artisan card / village industry registration',
|
||||||
|
'Debt statement (distressed farmer / person)',
|
||||||
|
'Aadhaar (women / transgender beneficiary)',
|
||||||
|
PSL_OTHER_DOCUMENT
|
||||||
|
];
|
||||||
|
|
||||||
/** Short names of the loans against a deposit or policy (FD, RD, DDS, LIC): the deposit is the security, so income
|
/** Short names of the loans against a deposit or policy (FD, RD, DDS, LIC): the deposit is the security, so income
|
||||||
* proof, bank statement, address proof and a co-borrower are optional and there is no credit bureau check. */
|
* proof, bank statement, address proof and a co-borrower are optional and there is no credit bureau check. */
|
||||||
export const DEPOSIT_PRODUCT_CODES = [
|
export const DEPOSIT_PRODUCT_CODES = [
|
||||||
@@ -550,8 +721,33 @@ export const DEPOSIT_PRODUCT_CODES = [
|
|||||||
'LI'
|
'LI'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** A loan against a LIC policy: the security is a policy recorded on the application, not a deposit account. */
|
||||||
|
export function isLicLoanCode(productCode: string | undefined | null): boolean {
|
||||||
|
return productCode === 'LI' || (!!productCode && licProductCodes.has(productCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short names of saved products whose Product Creation sub type is a deposit or policy (fd, rd, dds, lic). Filled by
|
||||||
|
* registerOriginationProduct, so a product made later under any short name (a second FD product) is also a loan
|
||||||
|
* against a deposit; the fixed codes above stay as the answer for the original products.
|
||||||
|
*/
|
||||||
|
const depositSubTypeCodes = new Set<string>();
|
||||||
|
const licProductCodes = new Set<string>();
|
||||||
|
const depositKindBySubType: Record<string, string> = { fd: 'FD', rd: 'RD', dds: 'DS', lic: 'LI' };
|
||||||
|
const depositKindByCode = new Map<string, string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kind of deposit a product is a loan against, as the original short name of that kind: 'FD', 'RD', 'DS' (daily
|
||||||
|
* deposit scheme) or 'LI' (LIC policy). A product made later under another short name answers by its sub type, so
|
||||||
|
* screens that tell the kinds apart treat it like the original; any other product gets its own code back.
|
||||||
|
*/
|
||||||
|
export function depositKindCode(productCode: string | undefined | null): string {
|
||||||
|
const code = productCode ?? '';
|
||||||
|
return DEPOSIT_PRODUCT_CODES.includes(code) ? code : (depositKindByCode.get(code) ?? code);
|
||||||
|
}
|
||||||
|
|
||||||
export function isDepositLoanCode(productCode: string | undefined | null): boolean {
|
export function isDepositLoanCode(productCode: string | undefined | null): boolean {
|
||||||
return !!productCode && DEPOSIT_PRODUCT_CODES.includes(productCode);
|
return !!productCode && (DEPOSIT_PRODUCT_CODES.includes(productCode) || depositSubTypeCodes.has(productCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||||
@@ -792,6 +988,24 @@ export function buildProductDocuments(productCode: string): ProductDocumentEntry
|
|||||||
*/
|
*/
|
||||||
export function registerOriginationProduct(p: any): OriginationProduct {
|
export function registerOriginationProduct(p: any): OriginationProduct {
|
||||||
const ext = p?.advanceExtensions ?? {};
|
const ext = p?.advanceExtensions ?? {};
|
||||||
|
const shortName = String(p.shortName);
|
||||||
|
if ([
|
||||||
|
'fd',
|
||||||
|
'rd',
|
||||||
|
'dds',
|
||||||
|
'lic'
|
||||||
|
].includes(ext.productTypeSubGroup)) {
|
||||||
|
depositSubTypeCodes.add(shortName);
|
||||||
|
depositKindByCode.set(shortName, depositKindBySubType[ext.productTypeSubGroup]);
|
||||||
|
} else {
|
||||||
|
depositSubTypeCodes.delete(shortName);
|
||||||
|
depositKindByCode.delete(shortName);
|
||||||
|
}
|
||||||
|
if (ext.productTypeSubGroup === 'lic') {
|
||||||
|
licProductCodes.add(shortName);
|
||||||
|
} else {
|
||||||
|
licProductCodes.delete(shortName);
|
||||||
|
}
|
||||||
const inMonths = !p?.repaymentFrequencyType?.value || p.repaymentFrequencyType.value === 'Months';
|
const inMonths = !p?.repaymentFrequencyType?.value || p.repaymentFrequencyType.value === 'Months';
|
||||||
const rate = Number(p?.interestRatePerPeriod ?? 0);
|
const rate = Number(p?.interestRatePerPeriod ?? 0);
|
||||||
const entry: OriginationProduct = {
|
const entry: OriginationProduct = {
|
||||||
@@ -813,6 +1027,12 @@ export function registerOriginationProduct(p: any): OriginationProduct {
|
|||||||
eligibleBasis: String(ext.eligibleAmountBasis ?? 'balance_accrued'),
|
eligibleBasis: String(ext.eligibleAmountBasis ?? 'balance_accrued'),
|
||||||
interestBasis: String(ext.interestBasis ?? 'deposit_spread'),
|
interestBasis: String(ext.interestBasis ?? 'deposit_spread'),
|
||||||
baseRate: rate,
|
baseRate: rate,
|
||||||
|
eligibleDepositProducts: ext.eligibleDepositProducts ? String(ext.eligibleDepositProducts) : undefined,
|
||||||
|
guarantorsRequired:
|
||||||
|
ext.guarantorsRequired === undefined || ext.guarantorsRequired === null || ext.guarantorsRequired === ''
|
||||||
|
? undefined
|
||||||
|
: Number(ext.guarantorsRequired),
|
||||||
|
guarantorMustBeMember: ext.guarantorMustBeMember === undefined ? undefined : ext.guarantorMustBeMember === true,
|
||||||
allowMultipleDeposits: ext.lienAllowMultipleDeposits === true,
|
allowMultipleDeposits: ext.lienAllowMultipleDeposits === true,
|
||||||
allowThirdParty: ext.lienAllowThirdParty === true,
|
allowThirdParty: ext.lienAllowThirdParty === true,
|
||||||
tenureWithinMaturity: ext.lienTenureNotExceedMaturity !== false,
|
tenureWithinMaturity: ext.lienTenureNotExceedMaturity !== false,
|
||||||
@@ -824,7 +1044,8 @@ export function registerOriginationProduct(p: any): OriginationProduct {
|
|||||||
interestPostingFrequency: String(ext.interestPostingFrequency ?? 'Monthly'),
|
interestPostingFrequency: String(ext.interestPostingFrequency ?? 'Monthly'),
|
||||||
facilityType: String(ext.facilityType ?? 'demand'),
|
facilityType: String(ext.facilityType ?? 'demand'),
|
||||||
preclosure: String(ext.preclosureAllowed ?? 'anytime'),
|
preclosure: String(ext.preclosureAllowed ?? 'anytime'),
|
||||||
renewalAllowed: ext.renewalAllowed === true
|
renewalAllowed: ext.renewalAllowed === true,
|
||||||
|
prioritySector: String(ext.prioritySector ?? '').toLowerCase() === 'yes'
|
||||||
};
|
};
|
||||||
const at = ORIGINATION_PRODUCTS.findIndex((x) => x.code === entry.code);
|
const at = ORIGINATION_PRODUCTS.findIndex((x) => x.code === entry.code);
|
||||||
if (at >= 0) {
|
if (at >= 0) {
|
||||||
@@ -1001,7 +1222,11 @@ export function progressPct(app: LoanApplication): number {
|
|||||||
if (app.overallStatus === 'DISBURSED') {
|
if (app.overallStatus === 'DISBURSED') {
|
||||||
return 100;
|
return 100;
|
||||||
}
|
}
|
||||||
return round(((stageIndex(app.currentStage) + 1) / STAGE_ORDER.length) * 100);
|
const stages = stagesFor(app.productCode);
|
||||||
|
const at = stages.indexOf(app.currentStage);
|
||||||
|
return at >= 0
|
||||||
|
? round(((at + 1) / stages.length) * 100)
|
||||||
|
: round(((stageIndex(app.currentStage) + 1) / STAGE_ORDER.length) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stageBadgeClass(app: LoanApplication): string {
|
export function stageBadgeClass(app: LoanApplication): string {
|
||||||
@@ -1745,6 +1970,19 @@ export function decideApproval(
|
|||||||
approverRole: string,
|
approverRole: string,
|
||||||
remarks: string
|
remarks: string
|
||||||
): void {
|
): void {
|
||||||
|
if (decision === 'REVOKED') {
|
||||||
|
const req = app.approvalRequests?.[field];
|
||||||
|
if (req) {
|
||||||
|
req.status = 'REVOKED';
|
||||||
|
req.decidedBy = approverName;
|
||||||
|
req.decidedOn = todayDDMMYYYY();
|
||||||
|
req.remarks = remarks;
|
||||||
|
req.revokeRemarks = remarks;
|
||||||
|
}
|
||||||
|
delete app[field];
|
||||||
|
recordStageProgress(app, approverName, 'Returned for Rectification', remarks);
|
||||||
|
return;
|
||||||
|
}
|
||||||
app[field] = { decision, approverName, approverRole, date: todayDDMMYYYY(), remarks };
|
app[field] = { decision, approverName, approverRole, date: todayDDMMYYYY(), remarks };
|
||||||
if (decision === 'REJECTED') {
|
if (decision === 'REJECTED') {
|
||||||
app.overallStatus = 'REJECTED';
|
app.overallStatus = 'REJECTED';
|
||||||
@@ -2120,7 +2358,11 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
|||||||
personalDiscussionDate: '—',
|
personalDiscussionDate: '—',
|
||||||
declarationTaken: false,
|
declarationTaken: false,
|
||||||
declarationDate: '—',
|
declarationDate: '—',
|
||||||
guarantorRequired: false,
|
// What the product asks for; an older product that does not say leaves it to the officer's own tick at Risk Approval.
|
||||||
|
guarantorRequired: (ORIGINATION_PRODUCTS.find((p) => p.code === input.productCode)?.guarantorsRequired ?? 0) > 0,
|
||||||
|
guarantorsRequiredCount: ORIGINATION_PRODUCTS.find((p) => p.code === input.productCode)?.guarantorsRequired,
|
||||||
|
guarantorMustBeMember: ORIGINATION_PRODUCTS.find((p) => p.code === input.productCode)?.guarantorMustBeMember,
|
||||||
|
guarantors: [],
|
||||||
guarantorClientId: null,
|
guarantorClientId: null,
|
||||||
guarantorName: '—',
|
guarantorName: '—',
|
||||||
guarantorHasShareAccount: false,
|
guarantorHasShareAccount: false,
|
||||||
|
|||||||
@@ -430,14 +430,18 @@
|
|||||||
// one long stacked column, so a 6-7 item checklist reads as a glanceable board, not a scroll.
|
// one long stacked column, so a 6-7 item checklist reads as a glanceable board, not a scroll.
|
||||||
.loi-kyc-doc-grid {
|
.loi-kyc-doc-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loi-kyc-card {
|
.loi-kyc-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-self: start;
|
||||||
|
height: auto;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
@@ -1178,6 +1182,7 @@
|
|||||||
.loi-docs-locked {
|
.loi-docs-locked {
|
||||||
label.pg-btn,
|
label.pg-btn,
|
||||||
.loi-legal-doc-thumb-remove,
|
.loi-legal-doc-thumb-remove,
|
||||||
|
.loi-doc-thumb-remove,
|
||||||
.loi-doc-remove,
|
.loi-doc-remove,
|
||||||
.loi-custom-doc-input,
|
.loi-custom-doc-input,
|
||||||
.loan-action-row {
|
.loan-action-row {
|
||||||
|
|||||||
@@ -8,9 +8,33 @@
|
|||||||
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
|
||||||
import { BranchMaster, LoanApplication } from './loan-origination-data';
|
import {
|
||||||
|
BranchMaster,
|
||||||
|
LoanApplication,
|
||||||
|
PRIORITY_SECTOR_CATEGORIES,
|
||||||
|
WEAKER_SECTION_TYPES
|
||||||
|
} from './loan-origination-data';
|
||||||
|
|
||||||
|
/** The core banking loan account an application has been given, with where it stands. */
|
||||||
|
export interface LoanAccountState {
|
||||||
|
id: number;
|
||||||
|
accountNo: string;
|
||||||
|
status: string;
|
||||||
|
pendingApproval: boolean;
|
||||||
|
/** Disbursed and running (the loan has money out). */
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What an opened loan account shows on the stages after signing. */
|
||||||
|
export interface AccountOverview {
|
||||||
|
account: { label: string; value: string }[];
|
||||||
|
approval: { by: string; on: string; amount: string };
|
||||||
|
charges: { name: string; amount: string; when: string; added: boolean }[];
|
||||||
|
glAccounts: { label: string; value: string }[];
|
||||||
|
posting: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** The code a branch carries on an application: the Fineract office it belongs to (office 1 is "OFF1"). */
|
/** The code a branch carries on an application: the Fineract office it belongs to (office 1 is "OFF1"). */
|
||||||
export function officeBranchCode(officeId: number | string): string {
|
export function officeBranchCode(officeId: number | string): string {
|
||||||
@@ -35,11 +59,159 @@ export class LoanOriginationService {
|
|||||||
|
|
||||||
private readonly baseUrl = '/loan-origination/applications';
|
private readonly baseUrl = '/loan-origination/applications';
|
||||||
|
|
||||||
|
private readonly codeLists = new Map<string, Observable<string[]>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active values of a system Code (Admin > Codes), in their order. The built-in list is the fallback only when the
|
||||||
|
* Code cannot be read or has no values.
|
||||||
|
*/
|
||||||
|
private codeNames(codeName: string, fallback: string[]): Observable<string[]> {
|
||||||
|
let list = this.codeLists.get(codeName);
|
||||||
|
if (!list) {
|
||||||
|
list = this.http.get<any[]>('/codes').pipe(
|
||||||
|
switchMap((codes) => {
|
||||||
|
const code = (codes ?? []).find((c) => c.name === codeName);
|
||||||
|
return code ? this.http.get<any[]>(`/codes/${code.id}/codevalues`) : of([] as any[]);
|
||||||
|
}),
|
||||||
|
map((values) =>
|
||||||
|
(values ?? [])
|
||||||
|
.filter((v) => v.active !== false && v.isActive !== false)
|
||||||
|
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
|
||||||
|
.map((v) => String(v.name))
|
||||||
|
),
|
||||||
|
map((names) => (names.length ? names : fallback)),
|
||||||
|
catchError(() => of(fallback)),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
this.codeLists.set(codeName, list);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The priority sector lending categories (Code "PrioritySectorCategory"). */
|
||||||
|
prioritySectorCategories(): Observable<string[]> {
|
||||||
|
return this.codeNames('PrioritySectorCategory', PRIORITY_SECTOR_CATEGORIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The weaker section types (Code "WeakerSectionType"). */
|
||||||
|
weakerSectionTypes(): Observable<string[]> {
|
||||||
|
return this.codeNames('WeakerSectionType', WEAKER_SECTION_TYPES);
|
||||||
|
}
|
||||||
|
|
||||||
/** One fixed or recurring deposit account with its balance, rate and maturity. */
|
/** One fixed or recurring deposit account with its balance, rate and maturity. */
|
||||||
getDepositAccount(kind: 'FD' | 'RD', accountId: number): Observable<any> {
|
getDepositAccount(kind: 'FD' | 'RD', accountId: number): Observable<any> {
|
||||||
return this.http.get(`/${kind === 'FD' ? 'fixeddepositaccounts' : 'recurringdepositaccounts'}/${accountId}`);
|
return this.http.get(`/${kind === 'FD' ? 'fixeddepositaccounts' : 'recurringdepositaccounts'}/${accountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A customer's open Daily Deposit Scheme accounts (the bank's DDS module keeps these, not Fineract's deposit accounts). */
|
||||||
|
listDdsAccounts(clientId: number): Observable<any[]> {
|
||||||
|
return this.http
|
||||||
|
.get<any[]>('/dds/parties')
|
||||||
|
.pipe(map((parties) => (parties ?? []).filter((p) => p.clientId === clientId && p.status !== 'CLOSED')));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The core banking loan account opened for this application, if any. The account screen gives the loan the application
|
||||||
|
* number as its external id, so the two can be matched; null when no account has been created.
|
||||||
|
*/
|
||||||
|
loanForApplication(applicationNo: string): Observable<LoanAccountState | null> {
|
||||||
|
// A search, not /loans/external-id/{id}: that answers 404 when there is no account, which the app shows as an error popup.
|
||||||
|
return this.http.get<any>(`/loans?externalId=${encodeURIComponent(applicationNo)}&limit=1`).pipe(
|
||||||
|
map((result) => result?.pageItems?.[0]),
|
||||||
|
map((loan) =>
|
||||||
|
loan?.id
|
||||||
|
? {
|
||||||
|
id: loan.id,
|
||||||
|
accountNo: loan.accountNo,
|
||||||
|
status: loan.status?.value ?? '—',
|
||||||
|
pendingApproval: !!loan.status?.pendingApproval,
|
||||||
|
active: !!loan.status?.active
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the Post-Disbursement / Disbursement stages show about the account that was opened for an application. */
|
||||||
|
loanAccountOverview(loanId: number): Observable<AccountOverview> {
|
||||||
|
return this.http.get<any>(`/loans/${loanId}?associations=charges`).pipe(
|
||||||
|
switchMap((loan) =>
|
||||||
|
this.http.get<any>(`/loanproducts/${loan.loanProductId}`).pipe(
|
||||||
|
catchError(() => of({})),
|
||||||
|
map((product) => {
|
||||||
|
const date = (v: any) =>
|
||||||
|
Array.isArray(v)
|
||||||
|
? v
|
||||||
|
.slice(0, 3)
|
||||||
|
.reverse()
|
||||||
|
.map((n: number) => String(n).padStart(2, '0'))
|
||||||
|
.join('-')
|
||||||
|
: (v ?? '—');
|
||||||
|
const inr = (v: any) =>
|
||||||
|
v === null || v === undefined || v === '' ? '—' : '₹' + Number(v).toLocaleString('en-IN');
|
||||||
|
const txt = (v: any) => (v === null || v === undefined || v === '' ? '—' : String(v));
|
||||||
|
const productChargeIds = new Set<number>((product.charges ?? []).map((c: any) => c.id));
|
||||||
|
const gl = (account: any) => (account?.glCode ? account.glCode + ' · ' + account.name : '—');
|
||||||
|
const maps = product.accountingMappings ?? {};
|
||||||
|
const cash = product.accountingRule?.id === 2;
|
||||||
|
return {
|
||||||
|
account: [
|
||||||
|
{ label: 'Account no.', value: txt(loan.accountNo) },
|
||||||
|
{ label: 'Status', value: txt(loan.status?.value) },
|
||||||
|
{ label: 'Product', value: txt(loan.loanProductName) },
|
||||||
|
{ label: 'Customer', value: txt(loan.clientName) },
|
||||||
|
{ label: 'Principal', value: inr(loan.principal) },
|
||||||
|
{
|
||||||
|
label: 'Interest rate',
|
||||||
|
value: loan.annualInterestRate !== undefined ? loan.annualInterestRate + '% p.a.' : '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Term',
|
||||||
|
value:
|
||||||
|
loan.termFrequency !== undefined
|
||||||
|
? loan.termFrequency + ' ' + (loan.termPeriodFrequencyType?.value ?? '')
|
||||||
|
: '—'
|
||||||
|
},
|
||||||
|
{ label: 'Purpose', value: txt(loan.loanPurposeName) },
|
||||||
|
{ label: 'Submitted on', value: date(loan.timeline?.submittedOnDate) },
|
||||||
|
{ label: 'Expected disbursement', value: date(loan.timeline?.expectedDisbursementDate) }
|
||||||
|
],
|
||||||
|
approval: {
|
||||||
|
by: txt(loan.timeline?.approvedByUsername),
|
||||||
|
on: date(loan.timeline?.approvedOnDate),
|
||||||
|
amount: inr(loan.approvedPrincipal)
|
||||||
|
},
|
||||||
|
charges: (loan.charges ?? []).map((c: any) => ({
|
||||||
|
name: c.name as string,
|
||||||
|
amount: inr(c.amount),
|
||||||
|
when: (c.chargeTimeType?.value ?? '—') as string,
|
||||||
|
added: !productChargeIds.has(c.chargeId)
|
||||||
|
})),
|
||||||
|
glAccounts: [
|
||||||
|
{ label: 'Fund source (paid out on disbursement)', value: gl(maps.fundSourceAccount) },
|
||||||
|
{ label: "Loan portfolio (the customer's loan account)", value: gl(maps.loanPortfolioAccount) },
|
||||||
|
{ label: 'Interest income', value: gl(maps.interestOnLoanAccount) },
|
||||||
|
{ label: 'Fee income', value: gl(maps.incomeFromFeeAccount) },
|
||||||
|
{ label: 'Penalty income', value: gl(maps.incomeFromPenaltyAccount) },
|
||||||
|
{ label: 'Overpayment liability', value: gl(maps.overpaymentLiabilityAccount) },
|
||||||
|
{ label: 'Write-off', value: gl(maps.writeOffAccount) }
|
||||||
|
],
|
||||||
|
posting: cash
|
||||||
|
? 'On disbursement: Debit ' +
|
||||||
|
gl(maps.loanPortfolioAccount) +
|
||||||
|
' · Credit ' +
|
||||||
|
gl(maps.fundSourceAccount) +
|
||||||
|
' · ' +
|
||||||
|
inr(loan.principal)
|
||||||
|
: null
|
||||||
|
} as AccountOverview;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The bank's real branches: every office of the organisation, in hierarchy order. */
|
/** The bank's real branches: every office of the organisation, in hierarchy order. */
|
||||||
listBranches(): Observable<BranchMaster[]> {
|
listBranches(): Observable<BranchMaster[]> {
|
||||||
return this.http.get<{ id: number; name: string; hierarchy?: string }[]>('/offices').pipe(
|
return this.http.get<{ id: number; name: string; hierarchy?: string }[]>('/offices').pipe(
|
||||||
@@ -145,7 +317,7 @@ export class LoanOriginationService {
|
|||||||
applicationNo: string,
|
applicationNo: string,
|
||||||
field: string,
|
field: string,
|
||||||
body: {
|
body: {
|
||||||
decision: 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED';
|
decision: 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED' | 'REVOKED';
|
||||||
approverName: string;
|
approverName: string;
|
||||||
approverRole: string;
|
approverRole: string;
|
||||||
remarks?: string;
|
remarks?: string;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -109,7 +109,8 @@ type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target' | 'rate
|
|||||||
'../../cashier/cashier-shared.scss',
|
'../../cashier/cashier-shared.scss',
|
||||||
'../../investment/investment-shared.scss',
|
'../../investment/investment-shared.scss',
|
||||||
'../../npa/npa-shared.scss',
|
'../../npa/npa-shared.scss',
|
||||||
'../loan-origination-shared.scss'
|
'../loan-origination-shared.scss',
|
||||||
|
'./loan-pipeline.component.scss'
|
||||||
],
|
],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -6,13 +6,53 @@
|
|||||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="container">
|
<div class="cla-container">
|
||||||
|
<!-- Hero header, same as the deposit account screens -->
|
||||||
|
<div class="cla-header">
|
||||||
|
<fa-icon icon="university" class="cla-header__icon"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<h2 class="cla-header__title">Create Loan Account</h2>
|
||||||
|
<div class="cla-header__sub">
|
||||||
|
{{
|
||||||
|
viewMode
|
||||||
|
? 'The account created for this application, as it was filled (view only).'
|
||||||
|
: 'Pick a product, set terms and review before submit.'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (submitBlocked) {
|
||||||
|
<div class="cla-existing">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Not submitted</strong>
|
||||||
|
<span>{{ submitBlocked }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (existingLosLoan) {
|
||||||
|
<div class="cla-existing">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Loan account {{ existingLosLoan.accountNo }} — {{ existingLosLoan.status }}</strong>
|
||||||
|
<span>{{
|
||||||
|
viewMode
|
||||||
|
? 'You are viewing the account that was created. Nothing here can be changed.'
|
||||||
|
: 'It is already created for this application. A second account cannot be created.'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<mat-stepper
|
<mat-stepper
|
||||||
orientation="vertical"
|
orientation="horizontal"
|
||||||
[linear]="false"
|
[linear]="false"
|
||||||
class="mat-elevation-z8"
|
class="cla-stepper"
|
||||||
labelPosition="bottom"
|
labelPosition="end"
|
||||||
#loansAccountStepper
|
#loansAccountStepper
|
||||||
|
(selectionChange)="refreshLosSections()"
|
||||||
>
|
>
|
||||||
<ng-template matStepperIcon="number">
|
<ng-template matStepperIcon="number">
|
||||||
<fa-icon icon="pencil-alt" size="sm"></fa-icon>
|
<fa-icon icon="pencil-alt" size="sm"></fa-icon>
|
||||||
@@ -35,7 +75,10 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<mat-step [stepControl]="loansAccountDetailsForm">
|
<mat-step [stepControl]="loansAccountDetailsForm">
|
||||||
<ng-template matStepLabel>{{ 'labels.inputs.DETAILS' | translate }}</ng-template>
|
<ng-template matStepLabel
|
||||||
|
><fa-icon icon="check-square" class="cla-tab__icon"></fa-icon
|
||||||
|
>{{ 'labels.inputs.DETAILS' | translate }}</ng-template
|
||||||
|
>
|
||||||
|
|
||||||
<mifosx-loans-account-details-step
|
<mifosx-loans-account-details-step
|
||||||
[loansAccountTemplate]="loansAccountTemplate"
|
[loansAccountTemplate]="loansAccountTemplate"
|
||||||
@@ -46,7 +89,9 @@
|
|||||||
</mat-step>
|
</mat-step>
|
||||||
|
|
||||||
<mat-step [stepControl]="loansAccountTermsForm">
|
<mat-step [stepControl]="loansAccountTermsForm">
|
||||||
<ng-template matStepLabel>{{ 'labels.inputs.TERMS' | translate }}</ng-template>
|
<ng-template matStepLabel
|
||||||
|
><fa-icon icon="file-alt" class="cla-tab__icon"></fa-icon>{{ 'labels.inputs.TERMS' | translate }}</ng-template
|
||||||
|
>
|
||||||
|
|
||||||
<mifosx-loans-account-terms-step
|
<mifosx-loans-account-terms-step
|
||||||
#loanAccountTerms
|
#loanAccountTerms
|
||||||
@@ -55,12 +100,103 @@
|
|||||||
[collateralOptions]="collateralOptions"
|
[collateralOptions]="collateralOptions"
|
||||||
[loansAccountFormValid]="loansAccountFormValid"
|
[loansAccountFormValid]="loansAccountFormValid"
|
||||||
[loanPrincipal]="loanPrincipal"
|
[loanPrincipal]="loanPrincipal"
|
||||||
|
[losApplication]="loansAccountDetailsStep?.losApplication"
|
||||||
|
[losAdvance]="
|
||||||
|
!!loansAccountDetailsStep?.losApplication && loansAccountDetailsStep?.categoryFilter === 'advances'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
</mifosx-loans-account-terms-step>
|
</mifosx-loans-account-terms-step>
|
||||||
</mat-step>
|
</mat-step>
|
||||||
|
|
||||||
|
<!-- Opened from Loan Origination for an entity (not a person): its related persons, the authorised signatories. -->
|
||||||
|
@if (loansAccountDetailsStep?.losIsNonIndividual) {
|
||||||
|
<mat-step>
|
||||||
|
<ng-template matStepLabel
|
||||||
|
><fa-icon icon="university" class="cla-tab__icon"></fa-icon>Related Persons</ng-template
|
||||||
|
>
|
||||||
|
<table class="cla-related">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Client ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Pin</th>
|
||||||
|
<th>Birth date</th>
|
||||||
|
<th>Address</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (person of loansAccountDetailsStep.losRelatedPersons; track $index) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ person.clientId ?? '—' }}</td>
|
||||||
|
<td>{{ person.title || '—' }}</td>
|
||||||
|
<td>{{ person.loading ? 'Loading…' : person.name || '—' }}</td>
|
||||||
|
<td>{{ person.pincode || '—' }}</td>
|
||||||
|
<td>{{ person.birthDate ? (person.birthDate | date: 'dd-MM-yyyy') : '—' }}</td>
|
||||||
|
<td>{{ person.address || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6">No authorised signatories are recorded for this entity.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="layout-row align-center gap-2percent margin-t responsive-column stepper-buttons">
|
||||||
|
<button mat-raised-button matStepperPrevious>
|
||||||
|
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||||
|
{{ 'labels.buttons.Previous' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button matStepperNext>
|
||||||
|
{{ 'labels.buttons.Next' | translate }}
|
||||||
|
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-step>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Advances opened from Loan Origination: the limit, its security and the purpose and renewal date the officer gives. -->
|
||||||
|
@if (loansAccountDetailsStep?.advancesSelected) {
|
||||||
|
<mat-step>
|
||||||
|
<ng-template matStepLabel
|
||||||
|
><fa-icon icon="check-square" class="cla-tab__icon"></fa-icon>Limit & Security</ng-template
|
||||||
|
>
|
||||||
|
<mifosx-loans-account-los-limit-step
|
||||||
|
[losApplication]="loansAccountDetailsStep?.losApplication"
|
||||||
|
[productId]="loansAccountDetailsStep?.loansAccountDetailsForm?.get('productId')?.value"
|
||||||
|
>
|
||||||
|
</mifosx-loans-account-los-limit-step>
|
||||||
|
</mat-step>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Advances opened from Loan Origination: the SMS alerts the customer gets (the account master's SMS Alert page). -->
|
||||||
|
@if (loansAccountDetailsStep?.advancesSelected) {
|
||||||
|
<mat-step>
|
||||||
|
<ng-template matStepLabel><fa-icon icon="bell" class="cla-tab__icon"></fa-icon>SMS Alerts</ng-template>
|
||||||
|
<mifosx-loans-account-los-sms-step [chequeBookOffered]="!!loansAccountTermsStep?.chequeBookOffered">
|
||||||
|
</mifosx-loans-account-los-sms-step>
|
||||||
|
</mat-step>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- The guarantors named at Loan Origination (the account master's guarantor page). -->
|
||||||
|
<!-- Not for a loan on a deposit (FD / RD / DDS / LIC): the deposit is the security, no guarantor is taken. -->
|
||||||
|
@if (
|
||||||
|
loansAccountDetailsStep?.losApplication &&
|
||||||
|
!loansAccountDetailsStep?.losApplication?.depositSelection &&
|
||||||
|
(loansAccountDetailsStep?.losApplication?.guarantorsRequiredCount ||
|
||||||
|
loansAccountDetailsStep?.losApplication?.guarantors?.length)
|
||||||
|
) {
|
||||||
|
<mat-step>
|
||||||
|
<ng-template matStepLabel><fa-icon icon="user-shield" class="cla-tab__icon"></fa-icon>Guarantors</ng-template>
|
||||||
|
<mifosx-loans-account-los-guarantors-step [losApplication]="loansAccountDetailsStep?.losApplication">
|
||||||
|
</mifosx-loans-account-los-guarantors-step>
|
||||||
|
</mat-step>
|
||||||
|
}
|
||||||
|
|
||||||
<mat-step>
|
<mat-step>
|
||||||
<ng-template matStepLabel>{{ 'labels.inputs.CHARGES' | translate }}</ng-template>
|
<ng-template matStepLabel
|
||||||
|
><fa-icon icon="coins" class="cla-tab__icon"></fa-icon>{{ 'labels.inputs.CHARGES' | translate }}</ng-template
|
||||||
|
>
|
||||||
|
|
||||||
<mifosx-loans-account-charges-step
|
<mifosx-loans-account-charges-step
|
||||||
#loanAccountCharges
|
#loanAccountCharges
|
||||||
@@ -68,13 +204,17 @@
|
|||||||
[loansAccountTemplate]="loansAccountTemplate"
|
[loansAccountTemplate]="loansAccountTemplate"
|
||||||
[loansAccountFormValid]="loansAccountFormValid"
|
[loansAccountFormValid]="loansAccountFormValid"
|
||||||
[loansSavingsAccountLinked]="loansSavingsAccountLinked"
|
[loansSavingsAccountLinked]="loansSavingsAccountLinked"
|
||||||
|
[viewOnly]="viewMode"
|
||||||
>
|
>
|
||||||
</mifosx-loans-account-charges-step>
|
</mifosx-loans-account-charges-step>
|
||||||
</mat-step>
|
</mat-step>
|
||||||
|
|
||||||
@if (loansAccountFormValid) {
|
@if (loansAccountFormValid && !losAdvance) {
|
||||||
<mat-step state="repayment" completed>
|
<mat-step state="repayment" completed>
|
||||||
<ng-template matStepLabel>{{ 'labels.inputs.REPAYMENT SCHEDULE' | translate }}</ng-template>
|
<ng-template matStepLabel
|
||||||
|
><fa-icon icon="university" class="cla-tab__icon"></fa-icon
|
||||||
|
>{{ 'labels.inputs.REPAYMENT SCHEDULE' | translate }}</ng-template
|
||||||
|
>
|
||||||
<mifosx-loans-account-schedule-step
|
<mifosx-loans-account-schedule-step
|
||||||
#loanAccountSchedule
|
#loanAccountSchedule
|
||||||
[loansAccountTemplate]="loansAccountTemplate"
|
[loansAccountTemplate]="loansAccountTemplate"
|
||||||
@@ -88,18 +228,25 @@
|
|||||||
|
|
||||||
@for (datatable of datatables; track datatable) {
|
@for (datatable of datatables; track datatable) {
|
||||||
<mat-step>
|
<mat-step>
|
||||||
<ng-template matStepLabel>{{ datatable.registeredTableName }}</ng-template>
|
<ng-template matStepLabel
|
||||||
|
><fa-icon icon="file-alt" class="cla-tab__icon"></fa-icon>{{ datatable.registeredTableName }}</ng-template
|
||||||
|
>
|
||||||
<mifosx-loans-account-datatable-step [datatableData]="datatable" #dtloan></mifosx-loans-account-datatable-step>
|
<mifosx-loans-account-datatable-step [datatableData]="datatable" #dtloan></mifosx-loans-account-datatable-step>
|
||||||
</mat-step>
|
</mat-step>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (loansAccountFormValid) {
|
@if (loansAccountFormValid) {
|
||||||
<mat-step state="preview" completed>
|
<mat-step state="preview" completed>
|
||||||
<ng-template matStepLabel>{{ 'labels.inputs.PREVIEW' | translate }}</ng-template>
|
<ng-template matStepLabel
|
||||||
|
><fa-icon icon="eye" class="cla-tab__icon"></fa-icon>{{ 'labels.inputs.PREVIEW' | translate }}</ng-template
|
||||||
|
>
|
||||||
<mifosx-loans-account-preview-step
|
<mifosx-loans-account-preview-step
|
||||||
[loansAccountTemplate]="loansAccountTemplate"
|
[loansAccountTemplate]="loansAccountTemplate"
|
||||||
[loansAccountProductTemplate]="loansAccountProductTemplate"
|
[loansAccountProductTemplate]="loansAccountProductTemplate"
|
||||||
[loansAccount]="loansAccount"
|
[loansAccount]="loansAccount"
|
||||||
|
[losSections]="losSections"
|
||||||
|
[losMode]="!!loansAccountDetailsStep?.losApplication"
|
||||||
|
[viewOnly]="viewMode"
|
||||||
(submitEvent)="submit()"
|
(submitEvent)="submit()"
|
||||||
>
|
>
|
||||||
</mifosx-loans-account-preview-step>
|
</mifosx-loans-account-preview-step>
|
||||||
|
|||||||
@@ -5,3 +5,190 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* The Nexora account-creation look (same as the deposit account screens): hero header, a tab strip for the steps, a footer
|
||||||
|
with Previous / Next. The steps themselves still work as a stepper. */
|
||||||
|
$navy: #0d1b2a;
|
||||||
|
$blue: #1e88e5;
|
||||||
|
$blue-d: #1e40af;
|
||||||
|
$indigo: #4f46e5;
|
||||||
|
$border: #e2e8f0;
|
||||||
|
|
||||||
|
.cla-container {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cla-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, $blue 0%, $indigo 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $navy;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cla-tab__icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cla-stepper {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
::ng-deep {
|
||||||
|
.mat-horizontal-stepper-header-container {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid $border;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step headers read as tabs: label only, equal width, underline on the open one. */
|
||||||
|
.mat-step-header {
|
||||||
|
flex: 1;
|
||||||
|
height: 50px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-step-header .mat-step-icon,
|
||||||
|
.mat-stepper-horizontal-line {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-step-header .mat-step-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-step-header[aria-selected='true'] {
|
||||||
|
border-bottom-color: $blue-d;
|
||||||
|
|
||||||
|
.mat-step-label {
|
||||||
|
color: $blue-d;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-step-header:hover:not([aria-selected='true']) {
|
||||||
|
background: rgb(30 64 175 / 5%);
|
||||||
|
|
||||||
|
.mat-step-label {
|
||||||
|
color: $navy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-horizontal-content-container {
|
||||||
|
padding: 24px;
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Previous on the left, Next on the right, with a rule above, as on the deposit screens. */
|
||||||
|
.stepper-buttons {
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 24px !important;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
|
||||||
|
/* The shared row class clips what is taller than it; the 40px buttons must show whole. */
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 56px;
|
||||||
|
overflow: visible !important;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
button {
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[matStepperPrevious][disabled] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[matStepperNext] {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Related persons of an entity (its authorised signatories), as on the account master. */
|
||||||
|
.cla-related {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An account is already open for the Loan Origination application. */
|
||||||
|
.cla-existing {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 13.5px;
|
||||||
|
|
||||||
|
div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Angular Imports */
|
/** Angular Imports */
|
||||||
import { Component, QueryList, ViewChild, ViewChildren, inject } from '@angular/core';
|
import { AfterViewChecked, Component, QueryList, ViewChild, ViewChildren, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable, forkJoin, of } from 'rxjs';
|
||||||
|
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||||
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
import { LoansAccountLosLimitStepComponent } from '../loans-account-stepper/loans-account-los-limit-step/loans-account-los-limit-step.component';
|
||||||
|
import { LoansAccountLosGuarantorsStepComponent } from '../loans-account-stepper/loans-account-los-guarantors-step/loans-account-los-guarantors-step.component';
|
||||||
|
import { LoansAccountLosSmsStepComponent } from '../loans-account-stepper/loans-account-los-sms-step/loans-account-los-sms-step.component';
|
||||||
|
import { LosPreviewSection } from '../loans-account-stepper/los-preview-section';
|
||||||
|
import { LoanAccountState, LoanOriginationService } from 'app/loan-origination/loan-origination.service';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
@@ -20,7 +29,14 @@ import { LoansAccountDetailsStepComponent } from '../loans-account-stepper/loans
|
|||||||
import { LoansAccountTermsStepComponent } from '../loans-account-stepper/loans-account-terms-step/loans-account-terms-step.component';
|
import { LoansAccountTermsStepComponent } from '../loans-account-stepper/loans-account-terms-step/loans-account-terms-step.component';
|
||||||
import { LoansAccountChargesStepComponent } from '../loans-account-stepper/loans-account-charges-step/loans-account-charges-step.component';
|
import { LoansAccountChargesStepComponent } from '../loans-account-stepper/loans-account-charges-step/loans-account-charges-step.component';
|
||||||
import { LoansAccountDatatableStepComponent } from '../loans-account-stepper/loans-account-datatable-step/loans-account-datatable-step.component';
|
import { LoansAccountDatatableStepComponent } from '../loans-account-stepper/loans-account-datatable-step/loans-account-datatable-step.component';
|
||||||
import { MatStepper, MatStepperIcon, MatStep, MatStepLabel } from '@angular/material/stepper';
|
import {
|
||||||
|
MatStepper,
|
||||||
|
MatStepperIcon,
|
||||||
|
MatStep,
|
||||||
|
MatStepLabel,
|
||||||
|
MatStepperNext,
|
||||||
|
MatStepperPrevious
|
||||||
|
} from '@angular/material/stepper';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { LoansAccountScheduleStepComponent } from '../loans-account-stepper/loans-account-schedule-step/loans-account-schedule-step.component';
|
import { LoansAccountScheduleStepComponent } from '../loans-account-stepper/loans-account-schedule-step/loans-account-schedule-step.component';
|
||||||
import { LoansAccountPreviewStepComponent } from '../loans-account-stepper/loans-account-preview-step/loans-account-preview-step.component';
|
import { LoansAccountPreviewStepComponent } from '../loans-account-stepper/loans-account-preview-step/loans-account-preview-step.component';
|
||||||
@@ -40,25 +56,90 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
FaIconComponent,
|
FaIconComponent,
|
||||||
MatStep,
|
MatStep,
|
||||||
MatStepLabel,
|
MatStepLabel,
|
||||||
|
MatStepperNext,
|
||||||
|
MatStepperPrevious,
|
||||||
LoansAccountDetailsStepComponent,
|
LoansAccountDetailsStepComponent,
|
||||||
LoansAccountTermsStepComponent,
|
LoansAccountTermsStepComponent,
|
||||||
|
LoansAccountLosLimitStepComponent,
|
||||||
|
LoansAccountLosGuarantorsStepComponent,
|
||||||
|
LoansAccountLosSmsStepComponent,
|
||||||
LoansAccountChargesStepComponent,
|
LoansAccountChargesStepComponent,
|
||||||
LoansAccountScheduleStepComponent,
|
LoansAccountScheduleStepComponent,
|
||||||
LoansAccountDatatableStepComponent,
|
LoansAccountDatatableStepComponent,
|
||||||
LoansAccountPreviewStepComponent
|
LoansAccountPreviewStepComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CreateLoansAccountComponent {
|
export class CreateLoansAccountComponent implements AfterViewChecked {
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private loansService = inject(LoansService);
|
private loansService = inject(LoansService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private clientService = inject(ClientsService);
|
private clientService = inject(ClientsService);
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private dateUtils = inject(Dates);
|
||||||
|
private originationService = inject(LoanOriginationService);
|
||||||
|
|
||||||
/** Imports all the step component */
|
/** Imports all the step component */
|
||||||
@ViewChild(LoansAccountDetailsStepComponent, { static: true })
|
@ViewChild(LoansAccountDetailsStepComponent, { static: true })
|
||||||
loansAccountDetailsStep: LoansAccountDetailsStepComponent;
|
loansAccountDetailsStep: LoansAccountDetailsStepComponent;
|
||||||
@ViewChild(LoansAccountTermsStepComponent, { static: true }) loansAccountTermsStep: LoansAccountTermsStepComponent;
|
@ViewChild(LoansAccountTermsStepComponent, { static: true }) loansAccountTermsStep: LoansAccountTermsStepComponent;
|
||||||
|
/** The Limit & Security step an Advances account opened from Loan Origination has. */
|
||||||
|
@ViewChild(LoansAccountLosLimitStepComponent) loansAccountLosLimitStep: LoansAccountLosLimitStepComponent;
|
||||||
|
@ViewChild(LoansAccountLosGuarantorsStepComponent)
|
||||||
|
loansAccountLosGuarantorsStep: LoansAccountLosGuarantorsStepComponent;
|
||||||
|
/** What the Loan Origination steps filled, listed in the Preview; rebuilt whenever the officer moves to another step. */
|
||||||
|
losSections: LosPreviewSection[] = [];
|
||||||
|
|
||||||
|
/** An Advances account opened from Loan Origination has no repayment schedule. */
|
||||||
|
get losAdvance(): boolean {
|
||||||
|
return (
|
||||||
|
!!this.loansAccountDetailsStep?.losApplication && this.loansAccountDetailsStep?.categoryFilter === 'advances'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshLosSections(): void {
|
||||||
|
const details = this.loansAccountDetailsStep;
|
||||||
|
if (!details?.losApplication) {
|
||||||
|
// an Advances account opened without a Loan Origination application still lists the limit and SMS steps it filled
|
||||||
|
this.losSections = details?.advancesSelected ? [
|
||||||
|
...(this.loansAccountLosLimitStep?.previewSections() ?? []),
|
||||||
|
...(this.loansAccountLosSmsStep?.previewSections() ?? [])
|
||||||
|
] : [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.originationService.loanForApplication(details.losApplication.applicationNo).subscribe((loan) => {
|
||||||
|
this.existingLosLoan = loan;
|
||||||
|
});
|
||||||
|
const persons = details.losIsNonIndividual
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: 'Related persons',
|
||||||
|
rows: (details.losRelatedPersons ?? []).map((p: any, i: number) => ({
|
||||||
|
label: 'Person ' + (i + 1),
|
||||||
|
value: [
|
||||||
|
p.name,
|
||||||
|
p.title,
|
||||||
|
p.pincode,
|
||||||
|
p.address
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
this.losSections = [
|
||||||
|
...details.previewSections(),
|
||||||
|
...persons,
|
||||||
|
...(this.loansAccountTermsStep?.previewSections() ?? []),
|
||||||
|
...(this.loansAccountLosLimitStep?.previewSections() ?? []),
|
||||||
|
...(this.loansAccountLosSmsStep?.previewSections() ?? []),
|
||||||
|
...(this.loansAccountLosGuarantorsStep?.previewSections() ?? [])
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The SMS Alerts step of an account opened from Loan Origination. */
|
||||||
|
@ViewChild(LoansAccountLosSmsStepComponent) loansAccountLosSmsStep: LoansAccountLosSmsStepComponent;
|
||||||
@ViewChild(LoansAccountChargesStepComponent, { static: true })
|
@ViewChild(LoansAccountChargesStepComponent, { static: true })
|
||||||
loansAccountChargesStep: LoansAccountChargesStepComponent;
|
loansAccountChargesStep: LoansAccountChargesStepComponent;
|
||||||
/** Get handle on dtloan tags in the template */
|
/** Get handle on dtloan tags in the template */
|
||||||
@@ -170,6 +251,138 @@ export class CreateLoansAccountComponent {
|
|||||||
* Submits Data to create loan account
|
* Submits Data to create loan account
|
||||||
*/
|
*/
|
||||||
submit() {
|
submit() {
|
||||||
|
// An Advances account needs the Limit & Security step filled (purpose, and the sanction when there is no application).
|
||||||
|
this.submitBlocked = '';
|
||||||
|
const limit = this.loansAccountLosLimitStep;
|
||||||
|
if (this.loansAccountDetailsStep?.advancesSelected && limit?.form.invalid) {
|
||||||
|
limit.form.markAllAsTouched();
|
||||||
|
this.submitBlocked =
|
||||||
|
'Complete the Limit & Security step before submitting: the purpose of the loan and, without a Loan Origination application, the sanction no. and date.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// An application that does not belong to this customer, or is not ready for an account, is never linked.
|
||||||
|
if (this.loansAccountDetailsStep?.losMode && this.loansAccountDetailsStep.losError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const los = this.loansAccountDetailsStep?.losApplication;
|
||||||
|
if (!los) {
|
||||||
|
this.createAccount();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// One account per Loan Origination application: look again just before creating (it may have been opened meanwhile).
|
||||||
|
this.originationService.loanForApplication(los.applicationNo).subscribe((existing) => {
|
||||||
|
this.existingLosLoan = existing;
|
||||||
|
if (!existing) {
|
||||||
|
this.createAccount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why Submit did nothing (shown above the steps); empty when nothing is wrong. */
|
||||||
|
submitBlocked = '';
|
||||||
|
|
||||||
|
/** The account already opened for the Loan Origination application on screen (null: none yet). */
|
||||||
|
existingLosLoan: LoanAccountState | null = null;
|
||||||
|
|
||||||
|
/** Opened with ?view=1 from Loan Origination (View Account): the account that was created, shown read-only. */
|
||||||
|
get viewMode(): boolean {
|
||||||
|
return this.route.snapshot.queryParamMap.get('view') === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
private viewApplied = false;
|
||||||
|
|
||||||
|
ngAfterViewChecked(): void {
|
||||||
|
const details = this.loansAccountDetailsStep;
|
||||||
|
if (
|
||||||
|
!this.viewMode ||
|
||||||
|
this.viewApplied ||
|
||||||
|
!details?.losApplication ||
|
||||||
|
!this.loansAccountLosLimitStep ||
|
||||||
|
!this.loansAccountLosSmsStep ||
|
||||||
|
!this.loansAccountTermsStep
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.viewApplied = true;
|
||||||
|
this.originationService.loanForApplication(details.losApplication.applicationNo).subscribe((loan) => {
|
||||||
|
if (!loan) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.existingLosLoan = loan;
|
||||||
|
forkJoin({
|
||||||
|
full: this.http.get<any>(`/loans/${loan.id}?associations=charges`),
|
||||||
|
services: this.http.get<any[]>(`/datatables/loan_account_services/${loan.id}`).pipe(catchError(() => of([])))
|
||||||
|
}).subscribe(({ full, services }) => this.showCreatedAccount(full, services?.[0] ?? {}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fills every step from what was saved with the account, and locks them. */
|
||||||
|
private showCreatedAccount(loan: any, row: any): void {
|
||||||
|
const yes = (v: any) => v === true || v === 'true';
|
||||||
|
const iso = (v: any) =>
|
||||||
|
Array.isArray(v)
|
||||||
|
? v
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((n: number) => String(n).padStart(2, '0'))
|
||||||
|
.join('-')
|
||||||
|
.replace(/^(\d+)-/, '$1-')
|
||||||
|
: '';
|
||||||
|
const num = (v: any) => (v === null || v === undefined ? null : Number(v));
|
||||||
|
|
||||||
|
const limit = this.loansAccountLosLimitStep;
|
||||||
|
limit.form.patchValue({
|
||||||
|
purpose: row.loan_purpose_text ?? loan.loanPurposeName ?? '',
|
||||||
|
documentRenewalDate: iso(row.document_renewal_date),
|
||||||
|
prioritySectorCategory: row.priority_sector_category ?? ''
|
||||||
|
});
|
||||||
|
limit.form.disable();
|
||||||
|
|
||||||
|
const sms = this.loansAccountLosSmsStep;
|
||||||
|
sms.form.patchValue({
|
||||||
|
smsActive: yes(row.sms_active) ? 'yes' : 'no',
|
||||||
|
smsCharge: yes(row.sms_charge) ? 'yes' : 'no',
|
||||||
|
disbursementAlert: yes(row.sms_disbursement_alert),
|
||||||
|
dueAlert: yes(row.sms_due_alert),
|
||||||
|
overdueAlert: yes(row.sms_overdue_alert),
|
||||||
|
renewAlert: yes(row.sms_renew_alert),
|
||||||
|
closureAlert: yes(row.sms_closure_alert),
|
||||||
|
repaymentAlert: yes(row.sms_credit_clearing),
|
||||||
|
debitAlert: yes(row.sms_debit_clearing),
|
||||||
|
chargeInterest: yes(row.sms_charge_interest),
|
||||||
|
chequeBook: yes(row.sms_cheque_book),
|
||||||
|
debitAbove: num(row.sms_debit_above) ?? 0,
|
||||||
|
creditAbove: num(row.sms_credit_above) ?? 0
|
||||||
|
});
|
||||||
|
sms.form.disable();
|
||||||
|
|
||||||
|
const terms = this.loansAccountTermsStep;
|
||||||
|
terms.losServicesForm.patchValue({
|
||||||
|
chequeBookIssue: yes(row.cheque_book_issue),
|
||||||
|
ppsLimit: num(row.pps_limit),
|
||||||
|
atmActive: yes(row.atm_active),
|
||||||
|
impsActive: yes(row.imps_active),
|
||||||
|
impsPrimary: yes(row.imps_primary_account),
|
||||||
|
mmid: row.mmid ?? '',
|
||||||
|
upiActive: yes(row.upi_active),
|
||||||
|
posActive: yes(row.pos_active),
|
||||||
|
atmPerTxn: num(row.atm_per_txn_limit),
|
||||||
|
atmDay: num(row.atm_day_limit),
|
||||||
|
posPerTxn: num(row.pos_per_txn_limit),
|
||||||
|
posDay: num(row.pos_day_limit),
|
||||||
|
upiPerTxn: num(row.upi_per_txn_limit),
|
||||||
|
upiDay: num(row.upi_day_limit)
|
||||||
|
});
|
||||||
|
terms.losServicesForm.disable();
|
||||||
|
|
||||||
|
// the charges the account actually has
|
||||||
|
this.loansAccountChargesStep.chargesDataSource = (loan.charges ?? []).map((c: any) => ({
|
||||||
|
...c,
|
||||||
|
chargeId: c.chargeId ?? c.id
|
||||||
|
}));
|
||||||
|
this.refreshLosSections();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createAccount() {
|
||||||
const locale = this.settingsService.language.code;
|
const locale = this.settingsService.language.code;
|
||||||
const dateFormat = this.settingsService.dateFormat;
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
const payload = this.loansService.buildLoanRequestPayload(
|
const payload = this.loansService.buildLoanRequestPayload(
|
||||||
@@ -179,6 +392,10 @@ export class CreateLoansAccountComponent {
|
|||||||
locale,
|
locale,
|
||||||
dateFormat
|
dateFormat
|
||||||
);
|
);
|
||||||
|
// The application number is the loan's external id: it is how Loan Origination finds (and shows the status of) this account.
|
||||||
|
if (this.loansAccountDetailsStep?.losApplication) {
|
||||||
|
payload['externalId'] = this.loansAccountDetailsStep.losApplication.applicationNo;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.loansAccountProductTemplate.datatables && this.loansAccountProductTemplate.datatables.length > 0) {
|
if (this.loansAccountProductTemplate.datatables && this.loansAccountProductTemplate.datatables.length > 0) {
|
||||||
const datatables: any[] = [];
|
const datatables: any[] = [];
|
||||||
@@ -188,15 +405,84 @@ export class CreateLoansAccountComponent {
|
|||||||
payload['datatables'] = datatables;
|
payload['datatables'] = datatables;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loansService.createLoansAccount(payload).subscribe((response: any) => {
|
// Opened from Loan Origination for an Advances account: the services the officer filled are saved with the new loan.
|
||||||
this.router.navigate(
|
const losServices =
|
||||||
[
|
this.loansAccountDetailsStep.losMode && this.loansAccountTermsStep.losAdvance
|
||||||
'../',
|
? {
|
||||||
response.resourceId,
|
...this.loansAccountTermsStep.losServices,
|
||||||
'general'
|
// sanction no. and date, secured, security type and the document renewal date, with the services
|
||||||
],
|
...(this.loansAccountLosLimitStep?.datatableValues((iso) =>
|
||||||
{ relativeTo: this.route }
|
this.dateUtils.formatDate(new Date(iso + 'T00:00:00'), dateFormat)
|
||||||
);
|
) ?? {}),
|
||||||
});
|
...(this.loansAccountLosSmsStep?.datatableValues((iso) =>
|
||||||
|
this.dateUtils.formatDate(new Date(iso + 'T00:00:00'), dateFormat)
|
||||||
|
) ?? {})
|
||||||
|
}
|
||||||
|
: this.loansAccountDetailsStep.advancesSelected
|
||||||
|
? {
|
||||||
|
// no application: only what the Limit & Security and SMS Alerts steps took
|
||||||
|
...(this.loansAccountLosLimitStep?.datatableValues((iso) =>
|
||||||
|
this.dateUtils.formatDate(new Date(iso + 'T00:00:00'), dateFormat)
|
||||||
|
) ?? {}),
|
||||||
|
...(this.loansAccountLosSmsStep?.datatableValues((iso) =>
|
||||||
|
this.dateUtils.formatDate(new Date(iso + 'T00:00:00'), dateFormat)
|
||||||
|
) ?? {})
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const create = () =>
|
||||||
|
this.loansService.createLoansAccount(payload).subscribe((response: any) => {
|
||||||
|
if (losServices && response.loanId) {
|
||||||
|
const body = { ...losServices, locale: locale, dateFormat: dateFormat };
|
||||||
|
this.http.post(`/datatables/loan_account_services/${response.loanId}`, body).subscribe();
|
||||||
|
}
|
||||||
|
this.router.navigate(
|
||||||
|
[
|
||||||
|
'../',
|
||||||
|
response.resourceId,
|
||||||
|
'general'
|
||||||
|
],
|
||||||
|
{ relativeTo: this.route }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The purpose is typed, but the loan's own purpose field (shown in the approval list and on the loan) takes a code value:
|
||||||
|
// use the matching one, or add it to the Loan Purpose code.
|
||||||
|
const purpose = this.loansAccountLosLimitStep?.purposeText;
|
||||||
|
if (purpose) {
|
||||||
|
this.purposeCodeValueId(purpose).subscribe((id) => {
|
||||||
|
if (id) {
|
||||||
|
payload['loanPurposeId'] = id;
|
||||||
|
}
|
||||||
|
create();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
create();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Loan Purpose code value for this text: an existing one (by name or description), else a new one. */
|
||||||
|
private purposeCodeValueId(text: string): Observable<number | null> {
|
||||||
|
const wanted = text.trim().toLowerCase();
|
||||||
|
return this.http.get<any[]>('/codes/3/codevalues').pipe(
|
||||||
|
switchMap((values) => {
|
||||||
|
const found = (values ?? []).find(
|
||||||
|
(v) => v.name?.trim().toLowerCase() === wanted || v.description?.trim().toLowerCase() === wanted
|
||||||
|
);
|
||||||
|
if (found) {
|
||||||
|
return of(found.id as number);
|
||||||
|
}
|
||||||
|
const position = (values ?? []).reduce((max, v) => Math.max(max, v.position ?? 0), 0) + 1;
|
||||||
|
return this.http
|
||||||
|
.post<any>('/codes/3/codevalues', {
|
||||||
|
name: text.trim().slice(0, 100),
|
||||||
|
description: text.trim(),
|
||||||
|
isActive: true,
|
||||||
|
position
|
||||||
|
})
|
||||||
|
.pipe(map((r) => r.subResourceId as number));
|
||||||
|
}),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-20
@@ -6,24 +6,26 @@
|
|||||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="layout-row-wrap gap-2px responsive-column">
|
<div class="layout-row-wrap gap-2px responsive-column" [class.view-only]="viewOnly">
|
||||||
<mat-form-field class="flex-48">
|
@if (!viewOnly) {
|
||||||
<mat-label>{{ 'labels.inputs.Charge' | translate }}</mat-label>
|
<mat-form-field class="flex-48">
|
||||||
<mat-select #charge>
|
<mat-label>{{ 'labels.inputs.Charge' | translate }}</mat-label>
|
||||||
@for (charge of chargeData; track charge) {
|
<mat-select #charge>
|
||||||
<mat-option [value]="charge">
|
@for (charge of chargeData; track charge) {
|
||||||
{{ charge.name }}
|
<mat-option [value]="charge">
|
||||||
</mat-option>
|
{{ charge.name }}
|
||||||
}
|
</mat-option>
|
||||||
</mat-select>
|
}
|
||||||
</mat-form-field>
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
<div class="flex-48 align-center">
|
<div class="flex-48 align-center">
|
||||||
<button type="button" mat-raised-button color="primary" (click)="addCharge(charge)" [disabled]="!charge.value">
|
<button type="button" mat-raised-button color="primary" (click)="addCharge(charge)" [disabled]="!charge.value">
|
||||||
<fa-icon icon="plus" class="m-r-10"></fa-icon>
|
<fa-icon icon="plus" class="m-r-10"></fa-icon>
|
||||||
{{ 'labels.buttons.Add' | translate }}
|
{{ 'labels.buttons.Add' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<table
|
<table
|
||||||
class="flex-98 mat-elevation-z1"
|
class="flex-98 mat-elevation-z1"
|
||||||
@@ -102,9 +104,13 @@
|
|||||||
<ng-container matColumnDef="action">
|
<ng-container matColumnDef="action">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let charge">
|
<td mat-cell *matCellDef="let charge">
|
||||||
<button mat-icon-button color="warn" (click)="deleteCharge(charge)">
|
@if (canDelete(charge)) {
|
||||||
<fa-icon icon="trash"></fa-icon>
|
<button mat-icon-button color="warn" (click)="deleteCharge(charge)">
|
||||||
</button>
|
<fa-icon icon="trash"></fa-icon>
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<span class="charge-locked" title="Comes with the product">Product</span>
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
|||||||
+16
@@ -42,3 +42,19 @@ mat-divider {
|
|||||||
.tableName {
|
.tableName {
|
||||||
padding-left: 2%;
|
padding-left: 2%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A charge the product brings: shown, but not removable. */
|
||||||
|
.charge-locked {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An account already created: the charges are shown, not edited. */
|
||||||
|
.view-only [mat-icon-button] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
+20
-2
@@ -147,6 +147,9 @@ export class LoansAccountChargesStepComponent implements OnInit, OnChanges {
|
|||||||
this.loanId = this.route.snapshot.params['loanId'];
|
this.loanId = this.route.snapshot.params['loanId'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shows the charges of an account already created (opened from Loan Origination): nothing can be added, changed or removed. */
|
||||||
|
@Input() viewOnly = false;
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
if (this.loansAccountTemplate && this.loansAccountTemplate.charges) {
|
if (this.loansAccountTemplate && this.loansAccountTemplate.charges) {
|
||||||
this.chargesDataSource =
|
this.chargesDataSource =
|
||||||
@@ -210,9 +213,16 @@ export class LoansAccountChargesStepComponent implements OnInit, OnChanges {
|
|||||||
* Add a charge
|
* Add a charge
|
||||||
*/
|
*/
|
||||||
addCharge(charge: any) {
|
addCharge(charge: any) {
|
||||||
|
const chargeId = charge.value.id || charge.value.chargeId;
|
||||||
|
// A charge the product already brings stays as it is (and cannot be removed); adding it again would replace it.
|
||||||
|
if (this.chargesDataSource.some((c: any) => (c.chargeId ?? c.id) === chargeId)) {
|
||||||
|
charge.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newCharge = {
|
const newCharge = {
|
||||||
...charge.value,
|
...charge.value,
|
||||||
chargeId: charge.value.id || charge.value.chargeId
|
chargeId,
|
||||||
|
addedAtCreation: true
|
||||||
};
|
};
|
||||||
this.chargesDataSource = this.chargesDataSource.concat([newCharge]);
|
this.chargesDataSource = this.chargesDataSource.concat([newCharge]);
|
||||||
charge.value = '';
|
charge.value = '';
|
||||||
@@ -356,12 +366,20 @@ export class LoansAccountChargesStepComponent implements OnInit, OnChanges {
|
|||||||
get loansAccountCharges() {
|
get loansAccountCharges() {
|
||||||
const uniqueCharges = this.getUniqueCharges(this.chargesDataSource);
|
const uniqueCharges = this.getUniqueCharges(this.chargesDataSource);
|
||||||
return {
|
return {
|
||||||
charges: uniqueCharges.map((charge: any) => ({
|
charges: uniqueCharges.map(({ addedAtCreation, ...charge }: any) => ({
|
||||||
...charge,
|
...charge,
|
||||||
chargeId: charge.chargeId ?? charge.id
|
chargeId: charge.chargeId ?? charge.id
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A charge the officer added while opening the account can be removed again; the ones the product brings cannot. (On an
|
||||||
|
* existing account being modified every charge keeps its delete button.)
|
||||||
|
*/
|
||||||
|
canDelete(charge: any): boolean {
|
||||||
|
return !this.viewOnly && (this.loanId != null || !!charge.addedAtCreation);
|
||||||
|
}
|
||||||
private getUniqueCharges<T extends { id?: number | string; chargeId?: number | string }>(charges: T[]): T[] {
|
private getUniqueCharges<T extends { id?: number | string; chargeId?: number | string }>(charges: T[]): T[] {
|
||||||
const uniqueChargesMap = new Map<number | string, T>();
|
const uniqueChargesMap = new Map<number | string, T>();
|
||||||
|
|
||||||
|
|||||||
+208
-48
@@ -7,58 +7,218 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<form [formGroup]="loansAccountDetailsForm">
|
<form [formGroup]="loansAccountDetailsForm">
|
||||||
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
|
@if (losMode) {
|
||||||
<mat-form-field class="flex-48">
|
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
|
||||||
<mat-label>Category</mat-label>
|
<mat-form-field class="flex-48">
|
||||||
<mat-select [value]="categoryFilter" (selectionChange)="categoryFilter = $event.value; onCategoryChange()">
|
<mat-label>Category</mat-label>
|
||||||
<mat-option value="">All categories</mat-option>
|
<input matInput [value]="losCategoryLabel" readonly />
|
||||||
@for (category of categories; track category.id) {
|
</mat-form-field>
|
||||||
<mat-option [value]="category.id">{{ category.label }}</mat-option>
|
<mat-form-field class="flex-48">
|
||||||
}
|
<mat-label>Product type</mat-label>
|
||||||
</mat-select>
|
<input matInput [value]="losTypeLabel" readonly />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field class="flex-48">
|
</div>
|
||||||
<mat-label>Product type</mat-label>
|
<div class="layout-row-wrap gap-2px responsive-column">
|
||||||
<mat-select [value]="typeFilter" (selectionChange)="typeFilter = $event.value; applyProductFilters()">
|
<mat-form-field class="flex-fill">
|
||||||
<mat-option value="">All types</mat-option>
|
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
|
||||||
@for (type of typeOptions; track type.id) {
|
<input matInput [value]="losProductName" readonly />
|
||||||
<mat-option [value]="type.id">{{ type.label }}</mat-option>
|
</mat-form-field>
|
||||||
}
|
</div>
|
||||||
</mat-select>
|
}
|
||||||
</mat-form-field>
|
@if (!losMode) {
|
||||||
</div>
|
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
|
||||||
|
<mat-form-field class="flex-48">
|
||||||
|
<mat-label>Category</mat-label>
|
||||||
|
<mat-select
|
||||||
|
[value]="categoryFilter"
|
||||||
|
[disabled]="losMode"
|
||||||
|
(selectionChange)="categoryFilter = $event.value; onCategoryChange()"
|
||||||
|
>
|
||||||
|
<mat-option value="">All categories</mat-option>
|
||||||
|
@for (category of categories; track category.id) {
|
||||||
|
<mat-option [value]="category.id">{{ category.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field class="flex-48">
|
||||||
|
<mat-label>Product type</mat-label>
|
||||||
|
<mat-select
|
||||||
|
[value]="typeFilter"
|
||||||
|
[disabled]="losMode"
|
||||||
|
(selectionChange)="typeFilter = $event.value; applyProductFilters()"
|
||||||
|
>
|
||||||
|
<mat-option value="">All types</mat-option>
|
||||||
|
@for (type of typeOptions; track type.id) {
|
||||||
|
<mat-option [value]="type.id">{{ type.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="layout-row-wrap gap-2px responsive-column">
|
<div class="layout-row-wrap gap-2px responsive-column">
|
||||||
<mat-form-field class="flex-fill">
|
<mat-form-field class="flex-fill">
|
||||||
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
|
||||||
<mat-select
|
<mat-select
|
||||||
required
|
required
|
||||||
matTooltip="{{ 'tooltips.Name of the loan product' | translate }}"
|
matTooltip="{{ 'tooltips.Name of the loan product' | translate }}"
|
||||||
formControlName="productId"
|
formControlName="productId"
|
||||||
>
|
>
|
||||||
<mat-option>
|
<mat-option>
|
||||||
<ngx-mat-select-search
|
<ngx-mat-select-search
|
||||||
placeholderLabel="{{ placeHolderLabel }}"
|
placeholderLabel="{{ placeHolderLabel }}"
|
||||||
noEntriesFoundLabel="{{ noEntriesFoundLabel }}"
|
noEntriesFoundLabel="{{ noEntriesFoundLabel }}"
|
||||||
[formControl]="filterFormCtrl"
|
[formControl]="filterFormCtrl"
|
||||||
></ngx-mat-select-search>
|
></ngx-mat-select-search>
|
||||||
</mat-option>
|
|
||||||
@for (product of productData | async; track product) {
|
|
||||||
<mat-option [value]="product.id">
|
|
||||||
{{ product.name }}
|
|
||||||
</mat-option>
|
</mat-option>
|
||||||
|
@for (product of productData | async; track product) {
|
||||||
|
<mat-option [value]="product.id">
|
||||||
|
{{ product.name }}
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
<mat-error>
|
||||||
|
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
|
||||||
|
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The customer's Loan Origination applications that are ready for an account, narrowed by the category, type and product above. -->
|
||||||
|
<div class="layout-row-wrap gap-2px responsive-column">
|
||||||
|
<mat-form-field class="flex-fill">
|
||||||
|
<mat-label>Link to a Loan Origination application</mat-label>
|
||||||
|
<mat-select [disabled]="!losChoices.length" (selectionChange)="useLosApplication($event.value)">
|
||||||
|
@for (choice of losChoices; track choice.app.applicationNo) {
|
||||||
|
<mat-option [value]="choice.app.applicationNo">{{ choice.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
<mat-hint>
|
||||||
|
@if (losChoices.length) {
|
||||||
|
Choosing one opens this screen for that application, with its product, customer and terms filled in.
|
||||||
|
} @else if (hasLosCandidates) {
|
||||||
|
No application at Customer Signing matches this category, type and product for this customer.
|
||||||
|
} @else {
|
||||||
|
This customer has no Loan Origination application at Customer Signing waiting for an account.
|
||||||
|
}
|
||||||
|
</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (losMode) {
|
||||||
|
@if (losError) {
|
||||||
|
<p class="los-error">{{ losError }}</p>
|
||||||
|
}
|
||||||
|
@if (losApplication; as app) {
|
||||||
|
<div class="los-card">
|
||||||
|
<div class="los-card__head">
|
||||||
|
<strong>Loan Origination application</strong>
|
||||||
|
<a class="los-card__link" [routerLink]="['/loan-origination/application', app.applicationNo]">{{
|
||||||
|
app.applicationNo
|
||||||
|
}}</a>
|
||||||
|
<span class="los-card__chip">{{ app.currentStage }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="los-card__grid">
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Amount</span><span>₹{{ app.requestedAmount | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Tenure</span><span>{{ app.tenureMonths }} months</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Interest rate</span><span>{{ app.interestRate }}% p.a.</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Secured on</span
|
||||||
|
><span>{{ app.depositSelection?.accountNos?.join(', ') || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (losCustomer; as c) {
|
||||||
|
<div class="los-customer">
|
||||||
|
<h3 class="mat-h3">Customer details</h3>
|
||||||
|
@if (c.missing) {
|
||||||
|
<p class="los-error">This application is not linked to a bank client yet.</p>
|
||||||
|
} @else {
|
||||||
|
<div class="los-customer__grid">
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Client ID</span><span>{{ c.accountNo || c.clientId }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Name</span><span>{{ c.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Client type</span><span>{{ c.clientType || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Father's name</span><span>{{ c.fatherName || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Gender</span><span>{{ c.gender || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Date of birth</span><span>{{ c.dateOfBirth | date: 'dd-MM-yyyy' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Adult / Minor</span><span>{{ c.minor ? 'Minor' : 'Adult' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="los-customer__wide">
|
||||||
|
<span class="los-card__label">Address</span>
|
||||||
|
<span>
|
||||||
|
@if (c.address; as ad) {
|
||||||
|
{{ ad.lines.join(', ') }}{{ ad.city ? ', ' + ad.city : '' }}{{ ad.district ? ', ' + ad.district : ''
|
||||||
|
}}{{ ad.state ? ', ' + ad.state : '' }}{{ ad.pincode ? ' - ' + ad.pincode : '' }}
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Mobile</span><span>{{ c.mobile || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Occupation</span><span>{{ c.occupation || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Nationality</span><span>{{ c.nationality || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Branch</span><span>{{ c.branch || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">PAN</span
|
||||||
|
><span
|
||||||
|
>{{ c.pan || '—' }}
|
||||||
|
@if (c.pan) {
|
||||||
|
({{ c.panVerified ? 'verified' : 'not verified' }})
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Aadhaar</span
|
||||||
|
><span
|
||||||
|
>{{ c.aadhaar || '—' }}
|
||||||
|
@if (c.aadhaar) {
|
||||||
|
({{ c.aadhaarVerified ? 'verified' : 'not verified' }})
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">KYC status</span><span>{{ c.kycStatus || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="los-card__label">Risk</span><span>{{ c.risk || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</mat-select>
|
</div>
|
||||||
<mat-error>
|
}
|
||||||
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
|
} @else {
|
||||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
<mifosx-loan-product-template-card [product]="selectedProduct"></mifosx-loan-product-template-card>
|
||||||
</mat-error>
|
}
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<mifosx-loan-product-template-card [product]="selectedProduct"></mifosx-loan-product-template-card>
|
@if (loanProductSelected && !losMode) {
|
||||||
|
|
||||||
@if (loanProductSelected) {
|
|
||||||
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
|
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
|
||||||
<mat-form-field class="flex-48">
|
<mat-form-field class="flex-48">
|
||||||
<mat-label>{{ 'labels.inputs.External id' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.External id' | translate }}</mat-label>
|
||||||
|
|||||||
+52
@@ -30,3 +30,55 @@ mat-divider {
|
|||||||
.margin-t {
|
.margin-t {
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Opened from Loan Origination: the application card and the customer's details. */
|
||||||
|
.los-card,
|
||||||
|
.los-customer {
|
||||||
|
border: 1px solid #c9d7f2;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f3f7ff;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 8px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.los-card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.los-card__link {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.los-card__chip {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #e3ebff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.los-card__grid,
|
||||||
|
.los-customer__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||||
|
gap: 10px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.los-customer__wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.los-card__label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #5b6b8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.los-error {
|
||||||
|
color: #b3261e;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|||||||
+385
@@ -35,6 +35,12 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
import { LoanProductCatalogService } from '../../services/loan-product-catalog.service';
|
import { LoanProductCatalogService } from '../../services/loan-product-catalog.service';
|
||||||
import { LoanProductTemplateCardComponent } from '../loan-product-template-card/loan-product-template-card.component';
|
import { LoanProductTemplateCardComponent } from '../loan-product-template-card/loan-product-template-card.component';
|
||||||
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from 'app/products/loan-products/loan-product-types';
|
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from 'app/products/loan-products/loan-product-types';
|
||||||
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
|
import { LoanOriginationService } from 'app/loan-origination/loan-origination.service';
|
||||||
|
import { LoanApplication } from 'app/loan-origination/loan-origination-data';
|
||||||
|
import { LosPreviewSection } from '../los-preview-section';
|
||||||
|
import { forkJoin, of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loans Account Details Step
|
* Loans Account Details Step
|
||||||
@@ -64,6 +70,96 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
|
|||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private commons = inject(Commons);
|
private commons = inject(Commons);
|
||||||
private catalog = inject(LoanProductCatalogService);
|
private catalog = inject(LoanProductCatalogService);
|
||||||
|
private clientsService = inject(ClientsService);
|
||||||
|
private originationService = inject(LoanOriginationService);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set when this screen was opened from the Loan Origination pipeline (?los=<application no>): the category, type and product
|
||||||
|
* come from that application and cannot be changed, only what a loan account needs is asked, and the customer's details are
|
||||||
|
* shown from the client record. Opened any other way, the screen is exactly as it always was.
|
||||||
|
*/
|
||||||
|
losApplicationNo: string | null = null;
|
||||||
|
losApplication: LoanApplication | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer's Loan Origination applications an account can be opened for: at Customer Signing, still in progress and with no
|
||||||
|
* account opened yet. They are matched to loan products by name, so the category and type come from the product, not from a list here.
|
||||||
|
*/
|
||||||
|
private losCandidates: { app: LoanApplication; productId: number; group: string; type: string; label: string }[] = [];
|
||||||
|
/** The candidates that fit the category, type and product chosen above; refreshed whenever one of them changes. */
|
||||||
|
losChoices: { app: LoanApplication; productId: number; label: string }[] = [];
|
||||||
|
losError = '';
|
||||||
|
losProductName = '';
|
||||||
|
/** The customer's details for the Customer block (read from the client record, KYC and address). */
|
||||||
|
losCustomer: any = null;
|
||||||
|
|
||||||
|
/** The application and the customer it was raised for, for the Preview. */
|
||||||
|
previewSections(): LosPreviewSection[] {
|
||||||
|
const app = this.losApplication;
|
||||||
|
const c = this.losCustomer;
|
||||||
|
if (!app) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const sections: LosPreviewSection[] = [
|
||||||
|
{
|
||||||
|
title: 'Loan Origination application',
|
||||||
|
rows: [
|
||||||
|
{ label: 'Application no.', value: app.applicationNo },
|
||||||
|
{ label: 'Product', value: app.productName ?? '—' },
|
||||||
|
{ label: 'Branch', value: app.branchName ?? '—' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
if (c && !c.missing) {
|
||||||
|
const a = c.address;
|
||||||
|
sections.push({
|
||||||
|
title: 'Customer',
|
||||||
|
rows: [
|
||||||
|
{ label: 'Client ID', value: String(c.clientId ?? '—') },
|
||||||
|
{ label: 'Name', value: c.name ?? '—' },
|
||||||
|
{ label: 'Client type', value: c.clientType ?? '—' },
|
||||||
|
{ label: "Father's name", value: c.fatherName ?? '—' },
|
||||||
|
{ label: 'Gender', value: c.gender ?? '—' },
|
||||||
|
{
|
||||||
|
label: 'Date of birth',
|
||||||
|
value: Array.isArray(c.dateOfBirth) ? c.dateOfBirth.slice(0, 3).reverse().join('-') : (c.dateOfBirth ?? '—')
|
||||||
|
},
|
||||||
|
{ label: 'Mobile', value: c.mobile ?? '—' },
|
||||||
|
{ label: 'Occupation', value: c.occupation ?? '—' },
|
||||||
|
{ label: 'PAN', value: c.pan ?? '—' },
|
||||||
|
{ label: 'Aadhaar', value: c.aadhaar ?? '—' },
|
||||||
|
{ label: 'KYC status', value: c.kycStatus ?? '—' },
|
||||||
|
{ label: 'Risk', value: c.risk ?? '—' },
|
||||||
|
{
|
||||||
|
label: 'Address',
|
||||||
|
value: a
|
||||||
|
? [
|
||||||
|
...a.lines,
|
||||||
|
a.city,
|
||||||
|
a.district,
|
||||||
|
a.state,
|
||||||
|
a.pincode
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
/** An entity (company, firm, trust) has related persons -- its authorised signatories -- shown on their own step; a person has none. */
|
||||||
|
losIsNonIndividual = false;
|
||||||
|
losRelatedPersons: {
|
||||||
|
clientId: number | null;
|
||||||
|
title: string;
|
||||||
|
name: string;
|
||||||
|
pincode: string;
|
||||||
|
birthDate: any;
|
||||||
|
address: string;
|
||||||
|
loading: boolean;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
//** Defining PlaceHolders for the search bar */
|
//** Defining PlaceHolders for the search bar */
|
||||||
placeHolderLabel = '';
|
placeHolderLabel = '';
|
||||||
@@ -135,7 +231,22 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
|
|||||||
this.createLoansAccountDetailsForm();
|
this.createLoansAccountDetailsForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The category and type of the application's product, named for the read-only fields. */
|
||||||
|
get losCategoryLabel(): string {
|
||||||
|
return PRODUCT_TYPE_GROUPS.find((g) => g.id === this.categoryFilter)?.label ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
get losTypeLabel(): string {
|
||||||
|
return (PRODUCT_SUB_TYPES[this.categoryFilter] ?? []).find((s) => s.id === this.typeFilter)?.label ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the screen was opened from a Loan Origination application. */
|
||||||
|
get losMode(): boolean {
|
||||||
|
return !!this.losApplicationNo;
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
this.losApplicationNo = this.route.snapshot.queryParamMap.get('los');
|
||||||
this.placeHolderLabel = this.translateService.instant('labels.text.Search');
|
this.placeHolderLabel = this.translateService.instant('labels.text.Search');
|
||||||
this.noEntriesFoundLabel = this.translateService.instant('labels.text.No data found');
|
this.noEntriesFoundLabel = this.translateService.instant('labels.text.No data found');
|
||||||
this.maxDate = this.settingsService.maxFutureDate;
|
this.maxDate = this.settingsService.maxFutureDate;
|
||||||
@@ -164,6 +275,279 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
this.productData.next(this.productList.slice());
|
this.productData.next(this.productList.slice());
|
||||||
this.loadActiveProducts();
|
this.loadActiveProducts();
|
||||||
|
if (this.losMode) {
|
||||||
|
this.loadLosApplication();
|
||||||
|
} else {
|
||||||
|
this.loadLosCandidates();
|
||||||
|
this.loansAccountDetailsForm
|
||||||
|
.get('productId')
|
||||||
|
?.valueChanges.pipe(takeUntil(this._onDestroy))
|
||||||
|
.subscribe(() => this.refreshLosChoices());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sameName(a: string | undefined, b: string | undefined): boolean {
|
||||||
|
const clean = (v: string | undefined) => (v ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
return !!clean(a) && clean(a) === clean(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads this customer's applications that are ready for an account and keeps those with no account yet. */
|
||||||
|
private loadLosCandidates(): void {
|
||||||
|
const clientId = Number(this.loansAccountTemplate?.clientId);
|
||||||
|
if (!clientId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
forkJoin({
|
||||||
|
apps: this.originationService.list().pipe(catchError(() => of([] as LoanApplication[]))),
|
||||||
|
catalog: this.catalog.activeProducts().pipe(catchError(() => of([])))
|
||||||
|
}).subscribe(({ apps }) => {
|
||||||
|
const ready = apps.filter(
|
||||||
|
(a) => a.clientId === clientId && a.overallStatus === 'IN_PROGRESS' && a.currentStage === 'CUSTOMER_SIGNING'
|
||||||
|
);
|
||||||
|
if (!ready.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// an application that already has its account cannot be linked a second time
|
||||||
|
forkJoin(ready.map((a) => this.originationService.loanForApplication(a.applicationNo))).subscribe((loans) => {
|
||||||
|
this.losCandidates = ready
|
||||||
|
.filter((_a, i) => !loans[i])
|
||||||
|
.map((app) => {
|
||||||
|
const product = this.allProducts.find((p: any) => this.sameName(p.name, app.productName));
|
||||||
|
const ext = product ? this.catalog.classification(product.id) : {};
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
productId: product ? Number(product.id) : 0,
|
||||||
|
group: ext['productTypeGroup'] ?? '',
|
||||||
|
type: ext['productTypeSubGroup'] ?? '',
|
||||||
|
label:
|
||||||
|
app.applicationNo +
|
||||||
|
' · ' +
|
||||||
|
(app.productName ?? '') +
|
||||||
|
' · ₹' +
|
||||||
|
Number(app.requestedAmount).toLocaleString('en-IN') +
|
||||||
|
' · ' +
|
||||||
|
app.tenureMonths +
|
||||||
|
' months'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((c) => c.productId > 0);
|
||||||
|
this.refreshLosChoices();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The applications that fit what is chosen above: category, product type and product. Nothing chosen shows all of them. */
|
||||||
|
refreshLosChoices(): void {
|
||||||
|
const productId = Number(this.loansAccountDetailsForm.get('productId')?.value) || 0;
|
||||||
|
this.losChoices = this.losCandidates.filter(
|
||||||
|
(c) =>
|
||||||
|
(!this.categoryFilter || c.group === this.categoryFilter) &&
|
||||||
|
(!this.typeFilter || c.type === this.typeFilter) &&
|
||||||
|
(!productId || c.productId === productId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The product picked is an Advances product (by its classification): the Limit & Security and SMS Alerts steps apply to it. */
|
||||||
|
get advancesSelected(): boolean {
|
||||||
|
const id = this.loansAccountDetailsForm?.get('productId')?.value;
|
||||||
|
return !!id && this.catalog.classification(id)['productTypeGroup'] === 'advances';
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasLosCandidates(): boolean {
|
||||||
|
return this.losCandidates.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the screen for the chosen application: the same screen as when it is opened from Loan Origination. */
|
||||||
|
useLosApplication(applicationNo: string): void {
|
||||||
|
const clientId = this.loansAccountTemplate?.clientId;
|
||||||
|
if (!applicationNo || !clientId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.hash = '#/clients/' + clientId + '/loans-accounts/create?los=' + encodeURIComponent(applicationNo);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the application and sets the category, type and product from it; the product is then fixed. */
|
||||||
|
private loadLosApplication(): void {
|
||||||
|
this.originationService.get(this.losApplicationNo as string).subscribe({
|
||||||
|
next: (app) => {
|
||||||
|
this.losApplication = app;
|
||||||
|
// An application can only be linked to its own customer, and only once it is ready for an account.
|
||||||
|
const clientId = Number(this.loansAccountTemplate?.clientId);
|
||||||
|
if (clientId && app.clientId !== clientId) {
|
||||||
|
this.losError =
|
||||||
|
'Application ' + app.applicationNo + ' belongs to another customer and cannot be linked to this account.';
|
||||||
|
this.losApplication = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.route.snapshot.queryParamMap.get('view') !== '1' &&
|
||||||
|
(app.currentStage !== 'CUSTOMER_SIGNING' || app.overallStatus !== 'IN_PROGRESS')
|
||||||
|
) {
|
||||||
|
this.losError =
|
||||||
|
'Application ' +
|
||||||
|
app.applicationNo +
|
||||||
|
' is not at Customer Signing, so an account cannot be opened for it yet.';
|
||||||
|
this.losApplication = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wanted = (app.productName ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
const product = this.allProducts.find(
|
||||||
|
(p: any) => (p.name ?? '').trim().toLowerCase().replace(/\s+/g, ' ') === wanted
|
||||||
|
);
|
||||||
|
if (!product) {
|
||||||
|
this.losError = 'The product "' + app.productName + '" was not found among the loan products.';
|
||||||
|
} else {
|
||||||
|
// The product catalog is read first, so its category and type are known when they are named.
|
||||||
|
this.catalog.activeProducts().subscribe(() => {
|
||||||
|
const ext = this.catalog.classification(product.id);
|
||||||
|
this.categoryFilter = ext['productTypeGroup'] ?? '';
|
||||||
|
this.typeFilter = ext['productTypeSubGroup'] ?? '';
|
||||||
|
this.losProductName = product.name;
|
||||||
|
const today = this.settingsService.businessDate;
|
||||||
|
this.loansAccountDetailsForm.patchValue({
|
||||||
|
productId: product.id,
|
||||||
|
submittedOnDate: today,
|
||||||
|
expectedDisbursementDate: today
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.loadLosCustomer(app);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.losError = 'Could not read the loan origination application ' + this.losApplicationNo + '.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entity's authorised signatories are stored as two comma-separated columns, names and client ids in the same order. Each one
|
||||||
|
* linked to a client is filled in from that client's own record (title, name, birth date, address, pincode).
|
||||||
|
*/
|
||||||
|
private loadLosRelatedPersons(entity: any): void {
|
||||||
|
const split = (v: any): string[] =>
|
||||||
|
String(v ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const names = split(entity?.clientNonPersonDetails?.authorizedSignatory);
|
||||||
|
const ids = split(entity?.clientNonPersonDetails?.authorizedSignatoryClientIds);
|
||||||
|
const count = Math.max(names.length, ids.length);
|
||||||
|
this.losRelatedPersons = Array.from({ length: count }, (_u, i) => {
|
||||||
|
const id = ids[i] && Number.isFinite(Number(ids[i])) ? Number(ids[i]) : null;
|
||||||
|
return {
|
||||||
|
clientId: id,
|
||||||
|
title: '',
|
||||||
|
name: names[i] ?? '',
|
||||||
|
pincode: '',
|
||||||
|
birthDate: null as any,
|
||||||
|
address: '',
|
||||||
|
loading: id !== null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const quiet = catchError(() => of(null));
|
||||||
|
this.losRelatedPersons
|
||||||
|
.filter((r) => r.clientId !== null)
|
||||||
|
.forEach((row) => {
|
||||||
|
forkJoin({
|
||||||
|
person: this.clientsService.getClientData(String(row.clientId), true).pipe(quiet),
|
||||||
|
address: this.clientsService.getClientAddressData(String(row.clientId), true).pipe(quiet)
|
||||||
|
}).subscribe(({ person, address }: any) => {
|
||||||
|
const list: any[] = Array.isArray(address) ? address : [];
|
||||||
|
const addr = list.find((a) => a.isActive) ?? list[0];
|
||||||
|
row.title = person?.salutation?.name ?? '';
|
||||||
|
row.name = person?.displayName ?? row.name;
|
||||||
|
row.birthDate = person?.dateOfBirth ?? null;
|
||||||
|
row.pincode = addr?.postalCode ?? '';
|
||||||
|
row.address = addr
|
||||||
|
? [
|
||||||
|
addr.addressLine1,
|
||||||
|
addr.addressLine2,
|
||||||
|
addr.addressLine3,
|
||||||
|
addr.city,
|
||||||
|
addr.stateName
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
: '';
|
||||||
|
row.loading = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The customer's id, name, address, risk and the rest a loan needs, from the client record (nothing typed again). */
|
||||||
|
private loadLosCustomer(app: LoanApplication): void {
|
||||||
|
const clientId = String(app.clientId ?? this.loansAccountTemplate?.clientId ?? '');
|
||||||
|
if (!clientId) {
|
||||||
|
this.losCustomer = { missing: true };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const quiet = catchError(() => of(null));
|
||||||
|
forkJoin({
|
||||||
|
client: this.clientsService.getClientData(clientId, true).pipe(quiet),
|
||||||
|
kyc: this.clientsService.getClientKyc(clientId, true).pipe(quiet),
|
||||||
|
address: this.clientsService.getClientAddressData(clientId, true).pipe(quiet),
|
||||||
|
family: this.clientsService.getClientFamilyMembers(clientId).pipe(quiet)
|
||||||
|
}).subscribe(({ client, kyc, address, family }: any) => {
|
||||||
|
const addresses: any[] = Array.isArray(address) ? address : [];
|
||||||
|
const addr =
|
||||||
|
addresses.find((a) => a.isActive && /permanent/i.test(a.addressType ?? '')) ??
|
||||||
|
addresses.find((a) => a.isActive) ??
|
||||||
|
addresses[0];
|
||||||
|
const members: any[] = Array.isArray(family) ? family : [];
|
||||||
|
const father = members.find((m) => /father/i.test(m.relationship ?? ''));
|
||||||
|
const riskLabels: Record<number, string> = { 1: 'Low', 2: 'Medium', 3: 'High' };
|
||||||
|
this.losIsNonIndividual = !!client?.legalForm && client.legalForm.id !== 1;
|
||||||
|
if (this.losIsNonIndividual) {
|
||||||
|
this.loadLosRelatedPersons(client);
|
||||||
|
}
|
||||||
|
this.losCustomer = {
|
||||||
|
clientId: client?.id ?? clientId,
|
||||||
|
accountNo: client?.accountNo,
|
||||||
|
name: client?.displayName ?? app.applicantName,
|
||||||
|
// Individual or Non-individual, from the client's legal form (an entity -- firm, company, trust -- is non-individual).
|
||||||
|
clientType: client?.legalForm
|
||||||
|
? /^legalFormType\.individual$/i.test(client.legalForm.code ?? '') || client.legalForm.id === 1
|
||||||
|
? 'Individual'
|
||||||
|
: 'Non-Individual'
|
||||||
|
: null,
|
||||||
|
fatherName: father
|
||||||
|
? [
|
||||||
|
father.firstName,
|
||||||
|
father.middleName,
|
||||||
|
father.lastName
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
: null,
|
||||||
|
gender: client?.gender?.name,
|
||||||
|
dateOfBirth: client?.dateOfBirth,
|
||||||
|
mobile: client?.mobileNo,
|
||||||
|
occupation: client?.occupation?.name ?? client?.occupationCategory?.name,
|
||||||
|
nationality: client?.nationality,
|
||||||
|
branch: client?.officeName ?? app.branchName,
|
||||||
|
minor: client?.isMinor === true,
|
||||||
|
address: addr
|
||||||
|
? {
|
||||||
|
lines: [
|
||||||
|
addr.addressLine1,
|
||||||
|
addr.addressLine2,
|
||||||
|
addr.addressLine3
|
||||||
|
].filter(Boolean),
|
||||||
|
city: addr.city,
|
||||||
|
district: addr.countyDistrict,
|
||||||
|
state: addr.stateName,
|
||||||
|
pincode: addr.postalCode
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
pan: kyc?.panNumber,
|
||||||
|
panVerified: !!kyc?.panVerified,
|
||||||
|
aadhaar: kyc?.aadhaarNumberMasked,
|
||||||
|
aadhaarVerified: !!kyc?.aadhaarVerified,
|
||||||
|
kycStatus: kyc?.kycStatusCode ? String(kyc.kycStatusCode).replace('clientKycStatus.', '') : null,
|
||||||
|
risk: kyc ? (riskLabels[kyc.riskCategory] ?? null) : null
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,6 +574,7 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
/** Rebuilds the product list from the template: active only, then category and type, then the search text. */
|
/** Rebuilds the product list from the template: active only, then category and type, then the search text. */
|
||||||
applyProductFilters(): void {
|
applyProductFilters(): void {
|
||||||
|
this.refreshLosChoices();
|
||||||
this.productList = this.allProducts.filter((product: any) => {
|
this.productList = this.allProducts.filter((product: any) => {
|
||||||
if (this.activeProductIds && !this.activeProductIds.has(Number(product.id))) {
|
if (this.activeProductIds && !this.activeProductIds.has(Number(product.id))) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<h4 class="mat-h4">Guarantor master</h4>
|
||||||
|
<p class="lgs-note">
|
||||||
|
Guarantors required by the product: <strong>{{ required }}</strong> · named at Loan Origination:
|
||||||
|
<strong>{{ rows.length }}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table class="lgs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Client ID</th>
|
||||||
|
<th>Guarantor name</th>
|
||||||
|
<th>Membership (share a/c)</th>
|
||||||
|
<th>Member</th>
|
||||||
|
<th>Address</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of rows; track row.clientId) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.clientId }}</td>
|
||||||
|
<td>{{ row.name }}</td>
|
||||||
|
<td>{{ row.membership }}</td>
|
||||||
|
<td>{{ row.member ? 'Yes' : 'No' }}</td>
|
||||||
|
<td>{{ row.address }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">No guarantor was named for this application.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="layout-row align-center gap-2percent margin-t responsive-column stepper-buttons">
|
||||||
|
<button mat-raised-button matStepperPrevious>
|
||||||
|
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button matStepperNext>
|
||||||
|
Next
|
||||||
|
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.lgs-note {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lgs-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, Input, OnChanges, inject } from '@angular/core';
|
||||||
|
import { MatStepperNext, MatStepperPrevious } from '@angular/material/stepper';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { forkJoin, of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
|
import { LoanApplication } from 'app/loan-origination/loan-origination-data';
|
||||||
|
import { LosPreviewSection } from '../los-preview-section';
|
||||||
|
|
||||||
|
interface GuarantorRow {
|
||||||
|
clientId: number;
|
||||||
|
name: string;
|
||||||
|
membership: string;
|
||||||
|
member: boolean;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarantors: the people named for this loan at Loan Origination (each an onboarded client), with their membership (share
|
||||||
|
* account) and address from the client record. Read-only here.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-loans-account-los-guarantors-step',
|
||||||
|
templateUrl: './loans-account-los-guarantors-step.component.html',
|
||||||
|
styleUrls: ['./loans-account-los-guarantors-step.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatStepperPrevious,
|
||||||
|
MatStepperNext,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class LoansAccountLosGuarantorsStepComponent implements OnChanges {
|
||||||
|
private clientsService = inject(ClientsService);
|
||||||
|
|
||||||
|
@Input() losApplication: LoanApplication | null = null;
|
||||||
|
|
||||||
|
rows: GuarantorRow[] = [];
|
||||||
|
private loadedFor: string | null = null;
|
||||||
|
|
||||||
|
get required(): number {
|
||||||
|
return this.losApplication?.guarantorsRequiredCount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything on this step, for the Preview. */
|
||||||
|
previewSections(): LosPreviewSection[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Guarantors',
|
||||||
|
rows: this.rows.map((r, i) => ({
|
||||||
|
label: 'Guarantor ' + (i + 1),
|
||||||
|
value:
|
||||||
|
r.name +
|
||||||
|
' (client ' +
|
||||||
|
r.clientId +
|
||||||
|
') · member ' +
|
||||||
|
(r.member ? 'yes, ' + r.membership : 'no') +
|
||||||
|
' · ' +
|
||||||
|
r.address
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(): void {
|
||||||
|
const app = this.losApplication;
|
||||||
|
if (!app || this.loadedFor === app.applicationNo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loadedFor = app.applicationNo;
|
||||||
|
const guarantors = app.guarantors ?? [];
|
||||||
|
this.rows = guarantors.map((g) => ({
|
||||||
|
clientId: g.clientId,
|
||||||
|
name: g.name,
|
||||||
|
membership: g.shareAccountNos?.join(', ') || '—',
|
||||||
|
member: g.hasShareAccount,
|
||||||
|
address: '…'
|
||||||
|
}));
|
||||||
|
if (!guarantors.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
forkJoin(
|
||||||
|
guarantors.map((g) =>
|
||||||
|
this.clientsService.getClientAddressData(String(g.clientId), true).pipe(catchError(() => of([])))
|
||||||
|
)
|
||||||
|
).subscribe((all: any[]) => {
|
||||||
|
this.rows = this.rows.map((row, i) => {
|
||||||
|
const list: any[] = Array.isArray(all[i]) ? all[i] : [];
|
||||||
|
const a =
|
||||||
|
list.find((x) => x.isActive && /permanent/i.test(x.addressType ?? '')) ??
|
||||||
|
list.find((x) => x.isActive) ??
|
||||||
|
list[0];
|
||||||
|
const text = a
|
||||||
|
? [
|
||||||
|
a.addressLine1,
|
||||||
|
a.addressLine2,
|
||||||
|
a.addressLine3,
|
||||||
|
a.townVillage,
|
||||||
|
a.city,
|
||||||
|
a.countyDistrict,
|
||||||
|
a.postalCode
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
: '';
|
||||||
|
return { ...row, address: text || '—' };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<form [formGroup]="form">
|
||||||
|
<div class="lls-grid">
|
||||||
|
<div>
|
||||||
|
<span class="lls-label">Nature of limit</span><span>{{ natureOfLimit }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="lls-label">Security type</span><span>{{ securityType }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="lls-label">Secured / Unsecured</span><span>{{ secured ? 'Secured' : 'Unsecured' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="lls-label">Priority sector</span><span>{{ prioritySector ? 'Yes' : 'No' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="lls-label">Sanction date</span><span>{{ sanctionDate }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="lls-label">Sanction no.</span><span>{{ sanctionNo }}</span>
|
||||||
|
</div>
|
||||||
|
@if (!manual) {
|
||||||
|
<div>
|
||||||
|
<span class="lls-label">Sanction limit</span
|
||||||
|
><span>₹{{ losApplication?.requestedAmount | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="lls-label">Period</span><span>{{ losApplication?.tenureMonths }} months</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<span class="lls-label">Margin %</span><span>{{ productExt['margin'] ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (manual) {
|
||||||
|
<p class="lls-note">
|
||||||
|
This account is being opened without a Loan Origination application, so the sanction is entered here. To take it,
|
||||||
|
and the security, from an application, pick one on the Details step.
|
||||||
|
</p>
|
||||||
|
<div class="layout-row-wrap layout-lt-md-column gap-2percent lls-inputs">
|
||||||
|
<mat-form-field class="flex-48">
|
||||||
|
<mat-label>Sanction no.</mat-label>
|
||||||
|
<input matInput formControlName="sanctionNoInput" maxlength="40" required />
|
||||||
|
<mat-error>Sanction no. is <strong>required</strong></mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field class="flex-48">
|
||||||
|
<mat-label>Sanction date</mat-label>
|
||||||
|
<input matInput type="date" formControlName="sanctionDateInput" required />
|
||||||
|
<mat-error>Sanction date is <strong>required</strong></mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="layout-row-wrap layout-lt-md-column gap-2percent lls-inputs">
|
||||||
|
<mat-form-field class="flex-48">
|
||||||
|
<mat-label>Purpose of the loan</mat-label>
|
||||||
|
<input matInput formControlName="purpose" maxlength="200" required />
|
||||||
|
<mat-error>Purpose is <strong>required</strong></mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (prioritySector) {
|
||||||
|
<mat-form-field class="flex-48">
|
||||||
|
<mat-label>Priority sector category</mat-label>
|
||||||
|
<mat-select formControlName="prioritySectorCategory" required>
|
||||||
|
@for (category of prioritySectorCategories; track category) {
|
||||||
|
<mat-option [value]="category">{{ category }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
<mat-error>Priority sector category is <strong>required</strong></mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
@if (showRenewal) {
|
||||||
|
<mat-form-field class="flex-48">
|
||||||
|
<mat-label>Document renewal date</mat-label>
|
||||||
|
<input matInput type="date" formControlName="documentRenewalDate" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="mat-h4">Product terms</h4>
|
||||||
|
<div class="lls-grid lls-grid--plain">
|
||||||
|
@for (row of productRows; track row.label) {
|
||||||
|
<div>
|
||||||
|
<span class="lls-label">{{ row.label }}</span
|
||||||
|
><span>{{ row.value }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="mat-h4">SMA</h4>
|
||||||
|
<div class="lls-grid lls-grid--plain">
|
||||||
|
@for (row of smaRows; track row.label) {
|
||||||
|
<div>
|
||||||
|
<span class="lls-label">{{ row.label }}</span
|
||||||
|
><span>{{ row.value }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="mat-h4">NPA</h4>
|
||||||
|
<div class="lls-grid lls-grid--plain">
|
||||||
|
@for (row of npaRows; track row.label) {
|
||||||
|
<div>
|
||||||
|
<span class="lls-label">{{ row.label }}</span
|
||||||
|
><span>{{ row.value }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!manual) {
|
||||||
|
<h4 class="mat-h4">As sanctioned</h4>
|
||||||
|
<div class="lls-grid lls-grid--plain">
|
||||||
|
@for (row of sanctionRows; track row.label) {
|
||||||
|
<div>
|
||||||
|
<span class="lls-label">{{ row.label }}</span
|
||||||
|
><span>{{ row.value }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="mat-h4">Security details</h4>
|
||||||
|
<table class="lls-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Reference</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
<th>Value</th>
|
||||||
|
<th>Rate</th>
|
||||||
|
<th>Maturity</th>
|
||||||
|
<th>Lien for this loan</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of security; track row.reference) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.reference }}</td>
|
||||||
|
<td>{{ row.kind }}</td>
|
||||||
|
<td>{{ row.owner }}</td>
|
||||||
|
<td>₹{{ row.value | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ row.rate }}</td>
|
||||||
|
<td>{{ row.maturity }}</td>
|
||||||
|
<td>₹{{ row.lien | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7">No security is recorded for this application.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="layout-row align-center gap-2percent margin-t responsive-column stepper-buttons">
|
||||||
|
<button mat-raised-button matStepperPrevious>
|
||||||
|
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button matStepperNext [disabled]="form.invalid">
|
||||||
|
Next
|
||||||
|
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.lls-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||||
|
gap: 12px 16px;
|
||||||
|
border: 1px solid #c9d7f2;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f3f7ff;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lls-grid--plain {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lls-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #5b6b8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lls-inputs {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lls-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lls-note {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
+493
@@ -0,0 +1,493 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, Input, OnChanges, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||||
|
import { MatStepperNext, MatStepperPrevious } from '@angular/material/stepper';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { forkJoin, of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import {
|
||||||
|
LoanApplication,
|
||||||
|
PRIORITY_SECTOR_CATEGORIES,
|
||||||
|
depositKindCode,
|
||||||
|
sanctioningAuthority
|
||||||
|
} from 'app/loan-origination/loan-origination-data';
|
||||||
|
import { LoanOriginationService } from 'app/loan-origination/loan-origination.service';
|
||||||
|
import { LosPreviewSection } from '../los-preview-section';
|
||||||
|
|
||||||
|
/** One pledged deposit or policy, as listed under the limit. */
|
||||||
|
interface SecurityRow {
|
||||||
|
reference: string;
|
||||||
|
kind: string;
|
||||||
|
owner: string;
|
||||||
|
value: number;
|
||||||
|
rate: string;
|
||||||
|
maturity: string;
|
||||||
|
lien: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InfoRow {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limit & Security: what an Advances account is for, taken from the Loan Origination application and the product, with the
|
||||||
|
* security (the pledged FD / RD / DDS or LIC policy) listed below. The officer only gives the purpose and the date the
|
||||||
|
* documents are due for renewal.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-loans-account-los-limit-step',
|
||||||
|
templateUrl: './loans-account-los-limit-step.component.html',
|
||||||
|
styleUrls: ['./loans-account-los-limit-step.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatStepperPrevious,
|
||||||
|
MatStepperNext,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class LoansAccountLosLimitStepComponent implements OnChanges {
|
||||||
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private originationService = inject(LoanOriginationService);
|
||||||
|
|
||||||
|
@Input() losApplication: LoanApplication | null = null;
|
||||||
|
@Input() productId: any = null;
|
||||||
|
|
||||||
|
readonly form: UntypedFormGroup = this.formBuilder.group({
|
||||||
|
purpose: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
documentRenewalDate: [''],
|
||||||
|
prioritySectorCategory: [''],
|
||||||
|
// Asked only when the account is opened without a Loan Origination application (nothing to take them from).
|
||||||
|
sanctionNoInput: [''],
|
||||||
|
sanctionDateInput: ['']
|
||||||
|
});
|
||||||
|
|
||||||
|
/** RBI's priority sector lending categories. */
|
||||||
|
/** From the system Code "PrioritySectorCategory"; the built-in list shows until it is read. */
|
||||||
|
prioritySectorCategories: string[] = PRIORITY_SECTOR_CATEGORIES;
|
||||||
|
|
||||||
|
productExt: Record<string, any> = {};
|
||||||
|
productName = '';
|
||||||
|
security: SecurityRow[] = [];
|
||||||
|
private loadedFor: string | null = null;
|
||||||
|
|
||||||
|
/** True when the account is opened without a Loan Origination application: the sanction is typed in, there is no security list. */
|
||||||
|
get manual(): boolean {
|
||||||
|
return !this.losApplication;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(): void {
|
||||||
|
const app = this.losApplication;
|
||||||
|
if (!this.productId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// the sanction number and date are needed only when there is no application to take them from
|
||||||
|
for (const name of [
|
||||||
|
'sanctionNoInput',
|
||||||
|
'sanctionDateInput'
|
||||||
|
]) {
|
||||||
|
const control = this.form.get(name)!;
|
||||||
|
control.setValidators(app ? null : Validators.required);
|
||||||
|
control.updateValueAndValidity({ emitEvent: false });
|
||||||
|
}
|
||||||
|
const key = (app?.applicationNo ?? 'manual') + '|' + this.productId;
|
||||||
|
if (this.loadedFor === key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loadedFor = key;
|
||||||
|
this.originationService.prioritySectorCategories().subscribe((names) => (this.prioritySectorCategories = names));
|
||||||
|
this.http.get<any>('/loanproducts/' + this.productId).subscribe((product) => {
|
||||||
|
this.productName = product?.name ?? '';
|
||||||
|
this.productExt = product?.advanceExtensions ?? {};
|
||||||
|
const category = this.form.get('prioritySectorCategory')!;
|
||||||
|
category.setValidators(this.prioritySector ? Validators.required : null);
|
||||||
|
// the category was set at Sanction in Loan Origination: it is shown here, not chosen again
|
||||||
|
if (this.prioritySector && app?.prioritySectorCategory) {
|
||||||
|
category.setValue(app.prioritySectorCategory);
|
||||||
|
category.disable();
|
||||||
|
} else {
|
||||||
|
category.enable();
|
||||||
|
}
|
||||||
|
category.updateValueAndValidity();
|
||||||
|
// the purpose was given in Loan Origination after KYC: shown here, not asked again
|
||||||
|
const purpose = this.form.get('purpose')!;
|
||||||
|
if (app?.loanPurpose?.trim()) {
|
||||||
|
purpose.setValue(app.loanPurpose.trim());
|
||||||
|
purpose.disable();
|
||||||
|
} else {
|
||||||
|
purpose.enable();
|
||||||
|
}
|
||||||
|
this.setRenewalDefault();
|
||||||
|
});
|
||||||
|
if (app) {
|
||||||
|
this.loadSecurity(app);
|
||||||
|
} else {
|
||||||
|
this.security = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Priority sector: the officer's decision at Sanction in Loan Origination, else what the product says. */
|
||||||
|
get prioritySector(): boolean {
|
||||||
|
const decided = this.losApplication?.pslClassified;
|
||||||
|
if (decided) {
|
||||||
|
return decided === 'yes';
|
||||||
|
}
|
||||||
|
return String(this.productExt['prioritySector'] ?? '').toLowerCase() === 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weaker section, as decided in Loan Origination. */
|
||||||
|
get weakerSection(): boolean {
|
||||||
|
return this.losApplication?.weakerSection === 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The purpose the officer typed. */
|
||||||
|
get purposeText(): string {
|
||||||
|
return String(this.form.get('purpose')!.value ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A loan on a deposit (FD / RD / DDS / LIC) runs for a fixed tenure; its documents are not renewed. */
|
||||||
|
get showRenewal(): boolean {
|
||||||
|
return this.losApplication ? !this.losApplication.depositSelection : !!this.productExt['renewalAllowed'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nature of the limit: the loan product (Loan against FD, LIC Policy, Cash Credit ...). */
|
||||||
|
get natureOfLimit(): string {
|
||||||
|
return this.losApplication?.productName ?? (this.productName || '—');
|
||||||
|
}
|
||||||
|
|
||||||
|
get securityType(): string {
|
||||||
|
return this.productExt['securityType'] ?? '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Secured when the product is backed by a security; a product whose security type is none is unsecured. */
|
||||||
|
get secured(): boolean {
|
||||||
|
const type = String(this.productExt['securityType'] ?? '').trim();
|
||||||
|
return !!type && !/^none/i.test(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The date the limit was sanctioned: the day the sanctioning authority approved it. */
|
||||||
|
get sanctionDate(): string {
|
||||||
|
const app = this.losApplication;
|
||||||
|
if (!app) {
|
||||||
|
// typed in as yyyy-MM-dd, shown as dd-MM-yyyy
|
||||||
|
const typed = String(this.form.get('sanctionDateInput')!.value ?? '');
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(typed);
|
||||||
|
return m ? m[3] + '-' + m[2] + '-' + m[1] : '—';
|
||||||
|
}
|
||||||
|
return app?.creditCommitteeApproval?.date && app.creditCommitteeApproval.date !== '—'
|
||||||
|
? app.creditCommitteeApproval.date
|
||||||
|
: '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sanction number: the committee's resolution number, else the application number. */
|
||||||
|
get sanctionNo(): string {
|
||||||
|
const app = this.losApplication;
|
||||||
|
if (!app) {
|
||||||
|
return String(this.form.get('sanctionNoInput')!.value ?? '').trim() || '—';
|
||||||
|
}
|
||||||
|
return app?.committeeResolutionNo || app?.applicationNo || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the product was created with (Product Creation) that bears on this limit. */
|
||||||
|
get productRows(): InfoRow[] {
|
||||||
|
const e = this.productExt;
|
||||||
|
const yesNo = (v: any) => (v === true || v === 'true' ? 'Yes' : 'No');
|
||||||
|
const basis: Record<string, string> = {
|
||||||
|
balance_accrued: 'Deposit balance + accrued interest',
|
||||||
|
balance: 'Deposit balance only',
|
||||||
|
surrender: 'Surrender value',
|
||||||
|
gold_value: 'Gold value',
|
||||||
|
commodity_value: 'Commodity value'
|
||||||
|
};
|
||||||
|
const rows: InfoRow[] = [
|
||||||
|
{ label: 'Facility', value: e['facilityType'] ?? '—' },
|
||||||
|
{ label: 'Maximum LTV', value: e['ltv'] !== undefined ? e['ltv'] + '%' : '—' },
|
||||||
|
{ label: 'Eligible amount basis', value: basis[e['eligibleAmountBasis']] ?? e['eligibleAmountBasis'] ?? '—' },
|
||||||
|
{
|
||||||
|
label: 'Lien marked',
|
||||||
|
value: (e['lienMarkingStage'] ?? '—') === 'sanction' ? 'At sanction' : 'At disbursement'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Renewal',
|
||||||
|
value: e['renewalAllowed']
|
||||||
|
? 'Allowed, every ' +
|
||||||
|
(e['renewalPeriodMonths'] ?? '—') +
|
||||||
|
' months (grace ' +
|
||||||
|
(e['renewalGraceDays'] ?? 0) +
|
||||||
|
' days)'
|
||||||
|
: 'Not allowed'
|
||||||
|
},
|
||||||
|
{ label: 'Pre-closure', value: e['preclosureAllowed'] ?? '—' },
|
||||||
|
{ label: 'Lock-in', value: (e['lockInMonths'] ?? 0) + ' months' },
|
||||||
|
{ label: 'Tenure within security maturity', value: yesNo(e['lienTenureNotExceedMaturity']) }
|
||||||
|
];
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When the account is watched as SMA (special mention), from the product. */
|
||||||
|
get smaRows(): InfoRow[] {
|
||||||
|
const sma = Number(this.productExt['smaStartAfterMonths'] ?? 0);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'SMA counting starts',
|
||||||
|
value: sma > 0 ? sma + ' month' + (sma > 1 ? 's' : '') + ' after disbursement' : 'From disbursement'
|
||||||
|
},
|
||||||
|
{ label: 'SMA-0', value: 'Overdue 1 to 30 days' },
|
||||||
|
{ label: 'SMA-1', value: 'Overdue 31 to 60 days' },
|
||||||
|
{ label: 'SMA-2', value: 'Overdue 61 to 90 days' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When the account turns NPA, in words, from the product's NPA settings. */
|
||||||
|
get npaRows(): InfoRow[] {
|
||||||
|
const e = this.productExt;
|
||||||
|
const outOfOrder: Record<string, string> = {
|
||||||
|
LIMIT_EXCEEDED: 'the outstanding stays above the limit / drawing power continuously',
|
||||||
|
NO_CREDITS_90_DAYS: 'there are no credits for 90 days, or credits are less than the interest debited',
|
||||||
|
DP_NOT_RENEWED: 'the drawing power is not reassessed or renewed within 180 days'
|
||||||
|
};
|
||||||
|
const trigger: Record<string, string> = {
|
||||||
|
maturity: 'Dues stay overdue for 90 days (after maturity, or after an instalment falls due)',
|
||||||
|
out_of_order: 'The account is "out of order": ' + (outOfOrder[e['npaOutOfOrderBasis']] ?? '—'),
|
||||||
|
crop_season: 'Dues stay overdue for the crop season(s) (KCC)'
|
||||||
|
};
|
||||||
|
const interest: Record<string, string> = {
|
||||||
|
undebited: 'Moved to the Undebited Interest GL',
|
||||||
|
suspense: 'Moved to the Interest Suspense GL'
|
||||||
|
};
|
||||||
|
const rows: InfoRow[] = [
|
||||||
|
{ label: 'NPA when', value: trigger[e['npaTrigger']] ?? e['npaTrigger'] ?? '—' },
|
||||||
|
{ label: 'Interest after NPA', value: interest[e['interestAfterNpa']] ?? e['interestAfterNpa'] ?? '—' }
|
||||||
|
];
|
||||||
|
// A stock statement gives the drawing power on stock or receivables; a deposit loan has none.
|
||||||
|
if (e['stockStatementValidityDays'] && !this.losApplication?.depositSelection) {
|
||||||
|
rows.push({ label: 'Stock statement valid for', value: e['stockStatementValidityDays'] + ' days' });
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
label: 'Upgrade from NPA',
|
||||||
|
value: e['npaUpgradeOnlyAllArrears'] ? 'Only when all arrears are cleared' : 'As per bank policy'
|
||||||
|
});
|
||||||
|
if (e['npaKeepStandardIfDepositCovers']) {
|
||||||
|
rows.push({ label: 'Deposit-covered loan', value: 'Stays Standard while the deposit covers the dues' });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What was decided on the application at Loan Origination. */
|
||||||
|
get sanctionRows(): InfoRow[] {
|
||||||
|
const app = this.losApplication;
|
||||||
|
if (!app) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const approver = (field: string, label: string): InfoRow => {
|
||||||
|
const ap = (app as any)[field];
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
value: ap?.approverName && ap.approverName !== '—' ? ap.approverName + (ap.date ? ' · ' + ap.date : '') : '—'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const sel = app.depositSelection;
|
||||||
|
const share = sel?.securityValue
|
||||||
|
? ((app.requestedAmount / sel.securityValue) * 100).toFixed(1) + '% of the security'
|
||||||
|
: '—';
|
||||||
|
// A loan against a deposit has the one Sanction step, no committee, risk or legal approval.
|
||||||
|
const deposit = !!sel;
|
||||||
|
return [
|
||||||
|
{ label: 'Application no.', value: app.applicationNo },
|
||||||
|
{ label: 'Sanctioning authority', value: sanctioningAuthority(app.requestedAmount) },
|
||||||
|
{
|
||||||
|
label: deposit ? 'Sanction no.' : 'Resolution',
|
||||||
|
value: app.committeeResolutionNo ? app.committeeResolutionNo + ' dated ' + app.committeeResolutionDate : '—'
|
||||||
|
},
|
||||||
|
...(deposit ? [approver('creditCommitteeApproval', 'Sanctioned by')] : [
|
||||||
|
approver('creditCommitteeApproval', 'Loan Committee'),
|
||||||
|
approver('riskApproval', 'Risk approval'),
|
||||||
|
approver('legalApproval', 'Legal approval')
|
||||||
|
]),
|
||||||
|
approver('postCheckApproval', 'Post-disbursement check'),
|
||||||
|
{ label: 'Loan against the security', value: share },
|
||||||
|
{ label: 'Eligible amount', value: sel ? '₹' + Number(sel.eligibleAmount).toLocaleString('en-IN') : '—' },
|
||||||
|
{
|
||||||
|
label: 'Lien certificate',
|
||||||
|
value: app.lien?.certificateNo
|
||||||
|
? app.lien.certificateNo + (app.lien.markedOn ? ' · ' + app.lien.markedOn : '')
|
||||||
|
: 'Issued at disbursement'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A date written dd-MM-yyyy, as a Date (null when it is not a date). */
|
||||||
|
private parse(text: string | undefined): Date | null {
|
||||||
|
const m = /^(\d{1,2})-(\d{1,2})-(\d{4})$/.exec((text ?? '').trim());
|
||||||
|
return m ? new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1])) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private iso(d: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The documents come up for renewal a renewal period (the product's, usually a year) after the sanction. */
|
||||||
|
private setRenewalDefault(): void {
|
||||||
|
if (!this.showRenewal || this.form.get('documentRenewalDate')!.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sanctioned = this.parse(this.sanctionDate);
|
||||||
|
const months = Number(this.productExt['renewalPeriodMonths'] ?? 12) || 12;
|
||||||
|
if (sanctioned) {
|
||||||
|
sanctioned.setMonth(sanctioned.getMonth() + months);
|
||||||
|
this.form.patchValue({ documentRenewalDate: this.iso(sanctioned) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The deposits or the policy this limit is secured on, with what each is worth now. */
|
||||||
|
private loadSecurity(app: LoanApplication): void {
|
||||||
|
const sel = app.depositSelection;
|
||||||
|
if (!sel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lienShare = (value: number, total: number) =>
|
||||||
|
total > 0 ? Math.round((value / total) * app.requestedAmount * 100) / 100 : app.requestedAmount;
|
||||||
|
if (sel.policy) {
|
||||||
|
this.security = [
|
||||||
|
{
|
||||||
|
reference: sel.policy.policyNo,
|
||||||
|
kind: 'LIC policy (' + (sel.policy.insurer || 'LIC') + ')',
|
||||||
|
owner: sel.policy.policyHolder,
|
||||||
|
value: sel.policy.surrenderValue,
|
||||||
|
rate: '—',
|
||||||
|
maturity: sel.policy.maturityDate,
|
||||||
|
lien: app.requestedAmount
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ids = sel.accountIds ?? [];
|
||||||
|
if (depositKindCode(app.productCode) === 'DS') {
|
||||||
|
this.originationService.listDdsAccounts(app.clientId as number).subscribe((parties) => {
|
||||||
|
const picked = parties.filter((p: any) => ids.includes(p.id));
|
||||||
|
const total = picked.reduce((s: number, p: any) => s + Number(p.currentBalance ?? 0), 0);
|
||||||
|
this.security = picked.map((p: any) => ({
|
||||||
|
reference: p.accountNo,
|
||||||
|
kind: 'DDS',
|
||||||
|
owner: app.applicantName,
|
||||||
|
value: Number(p.currentBalance ?? 0),
|
||||||
|
rate: p.interestRate + '%',
|
||||||
|
maturity: String(p.maturityDate ?? '—'),
|
||||||
|
lien: lienShare(Number(p.currentBalance ?? 0), total)
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const kind = depositKindCode(app.productCode) === 'RD' ? 'RD' : 'FD';
|
||||||
|
forkJoin(
|
||||||
|
ids.map((id: number) => this.originationService.getDepositAccount(kind, id).pipe(catchError(() => of(null))))
|
||||||
|
).subscribe((accounts: any[]) => {
|
||||||
|
const list = accounts.filter((a) => !!a);
|
||||||
|
const total = list.reduce((s, a) => s + Number(a.summary?.accountBalance ?? 0), 0);
|
||||||
|
this.security = list.map((a) => ({
|
||||||
|
reference: a.accountNo,
|
||||||
|
kind: kind === 'RD' ? 'Recurring deposit' : 'Fixed deposit',
|
||||||
|
owner: app.applicantName,
|
||||||
|
value: Number(a.summary?.accountBalance ?? 0),
|
||||||
|
rate: (a.nominalAnnualInterestRate ?? '—') + '%',
|
||||||
|
maturity: Array.isArray(a.maturityDate)
|
||||||
|
? a.maturityDate.slice(0, 3).reverse().join('-')
|
||||||
|
: String(a.maturityDate ?? '—'),
|
||||||
|
lien: lienShare(Number(a.summary?.accountBalance ?? 0), total)
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything on this step, for the Preview. */
|
||||||
|
previewSections(): LosPreviewSection[] {
|
||||||
|
const f = this.form.getRawValue();
|
||||||
|
const limit: InfoRow[] = [
|
||||||
|
{ label: 'Nature of limit', value: this.natureOfLimit },
|
||||||
|
{ label: 'Security type', value: this.securityType },
|
||||||
|
{ label: 'Secured / Unsecured', value: this.secured ? 'Secured' : 'Unsecured' },
|
||||||
|
{ label: 'Priority sector', value: this.prioritySector ? 'Yes' : 'No' },
|
||||||
|
{ label: 'Sanction date', value: this.sanctionDate },
|
||||||
|
{ label: 'Sanction no.', value: this.sanctionNo },
|
||||||
|
{ label: 'Purpose of the loan', value: f.purpose || '—' }
|
||||||
|
];
|
||||||
|
if (this.prioritySector) {
|
||||||
|
limit.push({ label: 'Priority sector category', value: f.prioritySectorCategory || '—' });
|
||||||
|
}
|
||||||
|
if (this.losApplication?.pslOverrideReason) {
|
||||||
|
limit.push({ label: 'Priority sector — differs from product', value: this.losApplication.pslOverrideReason });
|
||||||
|
}
|
||||||
|
if (this.losApplication?.weakerSection) {
|
||||||
|
limit.push({
|
||||||
|
label: 'Weaker section',
|
||||||
|
value: this.weakerSection ? 'Yes — ' + (this.losApplication.weakerSectionType || '—') : 'No'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.showRenewal) {
|
||||||
|
limit.push({ label: 'Document renewal date', value: f.documentRenewalDate || '—' });
|
||||||
|
}
|
||||||
|
const security = this.security.map((s) => ({
|
||||||
|
label: s.reference + ' (' + s.kind + ')',
|
||||||
|
value:
|
||||||
|
'Owner ' +
|
||||||
|
s.owner +
|
||||||
|
' · value ₹' +
|
||||||
|
s.value.toLocaleString('en-IN') +
|
||||||
|
' · maturity ' +
|
||||||
|
s.maturity +
|
||||||
|
' · lien ₹' +
|
||||||
|
s.lien.toLocaleString('en-IN')
|
||||||
|
}));
|
||||||
|
return [
|
||||||
|
{ title: 'Limit', rows: limit },
|
||||||
|
...(this.manual
|
||||||
|
? []
|
||||||
|
: [{ title: 'Security', rows: security.length ? security : [{ label: 'Security', value: '—' }] }]),
|
||||||
|
{ title: 'Product terms', rows: this.productRows },
|
||||||
|
{ title: 'SMA', rows: this.smaRows },
|
||||||
|
{ title: 'NPA', rows: this.npaRows },
|
||||||
|
...(this.manual ? [] : [{ title: 'As sanctioned', rows: this.sanctionRows }])
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What is saved with the loan account (the loan_account_services datatable); dates as the server's date format. */
|
||||||
|
datatableValues(format: (isoDate: string) => string): Record<string, any> {
|
||||||
|
const sanctioned = this.parse(this.sanctionDate);
|
||||||
|
const data: Record<string, any> = {
|
||||||
|
sanction_no: this.sanctionNo === '—' ? undefined : this.sanctionNo,
|
||||||
|
secured: this.secured,
|
||||||
|
security_type: this.productExt['securityType'] || undefined,
|
||||||
|
loan_purpose_text: this.form.get('purpose')!.value?.trim() || undefined,
|
||||||
|
priority_sector: this.prioritySector,
|
||||||
|
priority_sector_category: (this.prioritySector && this.form.get('prioritySectorCategory')!.value) || undefined
|
||||||
|
};
|
||||||
|
if (this.losApplication?.weakerSection) {
|
||||||
|
data['weaker_section'] = this.weakerSection;
|
||||||
|
data['weaker_section_type'] = (this.weakerSection && this.losApplication.weakerSectionType) || undefined;
|
||||||
|
}
|
||||||
|
if (this.losApplication?.pslOverrideReason) {
|
||||||
|
data['psl_override_reason'] = this.losApplication.pslOverrideReason;
|
||||||
|
}
|
||||||
|
if (sanctioned) {
|
||||||
|
data['sanction_date'] = format(this.iso(sanctioned));
|
||||||
|
}
|
||||||
|
const renewal = this.showRenewal ? this.form.get('documentRenewalDate')!.value : '';
|
||||||
|
if (renewal) {
|
||||||
|
data['document_renewal_date'] = format(renewal);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<form [formGroup]="form">
|
||||||
|
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
|
||||||
|
<mat-form-field class="flex-48">
|
||||||
|
<mat-label>SMS active</mat-label>
|
||||||
|
<mat-select formControlName="smsActive">
|
||||||
|
<mat-option value="yes">Yes</mat-option>
|
||||||
|
<mat-option value="no">No</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (active) {
|
||||||
|
<mat-form-field class="flex-48">
|
||||||
|
<mat-label>SMS charge</mat-label>
|
||||||
|
<mat-select formControlName="smsCharge">
|
||||||
|
<mat-option value="yes">Yes</mat-option>
|
||||||
|
<mat-option value="no">No</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (active) {
|
||||||
|
<h4 class="mat-h4">Free alerts</h4>
|
||||||
|
<div class="sms-checks">
|
||||||
|
<mat-checkbox formControlName="disbursementAlert">Account opened / limit sanctioned</mat-checkbox>
|
||||||
|
<mat-checkbox formControlName="dueAlert">Interest due reminder</mat-checkbox>
|
||||||
|
<mat-checkbox formControlName="overdueAlert">Overdue / limit exceeded notice</mat-checkbox>
|
||||||
|
<mat-checkbox formControlName="renewAlert">Limit expiry / renewal reminder</mat-checkbox>
|
||||||
|
<mat-checkbox formControlName="closureAlert">Account closed</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="mat-h4">Paid alerts</h4>
|
||||||
|
<div class="sms-checks">
|
||||||
|
<mat-checkbox formControlName="repaymentAlert">Amount credited (deposit into the account)</mat-checkbox>
|
||||||
|
<mat-checkbox formControlName="debitAlert">Amount debited (drawing / charge)</mat-checkbox>
|
||||||
|
<mat-checkbox formControlName="chargeInterest">Interest and charges applied</mat-checkbox>
|
||||||
|
@if (chequeBookOffered) {
|
||||||
|
<mat-checkbox formControlName="chequeBook">Cheque book issued</mat-checkbox>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layout-row-wrap layout-lt-md-column gap-2percent sms-amounts">
|
||||||
|
<mat-form-field class="flex-48">
|
||||||
|
<mat-label>Debit alert above (₹)</mat-label>
|
||||||
|
<input matInput type="number" min="0" formControlName="debitAbove" />
|
||||||
|
<mat-hint>Alert only for debits greater than this amount</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field class="flex-48">
|
||||||
|
<mat-label>Credit alert above (₹)</mat-label>
|
||||||
|
<input matInput type="number" min="0" formControlName="creditAbove" />
|
||||||
|
<mat-hint>Alert only for credits greater than this amount</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="sms-note">
|
||||||
|
SMS activation date: <strong>{{ activationDate }}</strong>
|
||||||
|
@if (form.getRawValue().smsCharge === 'yes') {
|
||||||
|
· SMS charge date: <strong>{{ activationDate }}</strong>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="layout-row align-center gap-2percent margin-t responsive-column stepper-buttons">
|
||||||
|
<button mat-raised-button matStepperPrevious>
|
||||||
|
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button matStepperNext>
|
||||||
|
Next
|
||||||
|
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.sms-checks {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 28px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-amounts {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-note {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, Input, inject } from '@angular/core';
|
||||||
|
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
|
||||||
|
import { MatStepperNext, MatStepperPrevious } from '@angular/material/stepper';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
|
import { LosPreviewSection } from '../los-preview-section';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMS Alerts: which messages the customer gets for this account. Free alerts (renewal and due reminders) and paid alerts
|
||||||
|
* (debits, credits, clearing ...). The activation date is the day the account is opened.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-loans-account-los-sms-step',
|
||||||
|
templateUrl: './loans-account-los-sms-step.component.html',
|
||||||
|
styleUrls: ['./loans-account-los-sms-step.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatStepperPrevious,
|
||||||
|
MatStepperNext,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class LoansAccountLosSmsStepComponent {
|
||||||
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
|
||||||
|
/** Only a product that has a cheque book offers the cheque book alert. */
|
||||||
|
@Input() chequeBookOffered = false;
|
||||||
|
readonly activationDate: string = this.iso(this.settingsService.businessDate);
|
||||||
|
|
||||||
|
readonly form: UntypedFormGroup = this.formBuilder.group({
|
||||||
|
smsActive: ['yes'],
|
||||||
|
smsCharge: ['yes'],
|
||||||
|
disbursementAlert: [true],
|
||||||
|
dueAlert: [true],
|
||||||
|
overdueAlert: [true],
|
||||||
|
renewAlert: [true],
|
||||||
|
closureAlert: [true],
|
||||||
|
repaymentAlert: [true],
|
||||||
|
debitAlert: [true],
|
||||||
|
chargeInterest: [true],
|
||||||
|
chequeBook: [true],
|
||||||
|
debitAbove: [0],
|
||||||
|
creditAbove: [0]
|
||||||
|
});
|
||||||
|
|
||||||
|
get active(): boolean {
|
||||||
|
return this.form.getRawValue().smsActive === 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
|
private iso(d: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything on this step, for the Preview. */
|
||||||
|
previewSections(): LosPreviewSection[] {
|
||||||
|
const v = this.form.getRawValue();
|
||||||
|
const yn = (b: boolean) => (b ? 'Yes' : 'No');
|
||||||
|
if (!this.active) {
|
||||||
|
return [{ title: 'SMS Alerts', rows: [{ label: 'SMS active', value: 'No' }] }];
|
||||||
|
}
|
||||||
|
const rows = [
|
||||||
|
{ label: 'SMS active', value: 'Yes' },
|
||||||
|
{ label: 'SMS charge', value: v.smsCharge === 'yes' ? 'Yes' : 'No' },
|
||||||
|
{ label: 'Account opened / limit sanctioned', value: yn(v.disbursementAlert) },
|
||||||
|
{ label: 'Interest due reminder', value: yn(v.dueAlert) },
|
||||||
|
{ label: 'Overdue / limit exceeded notice', value: yn(v.overdueAlert) },
|
||||||
|
{ label: 'Limit expiry / renewal reminder', value: yn(v.renewAlert) },
|
||||||
|
{ label: 'Account closed', value: yn(v.closureAlert) },
|
||||||
|
{ label: 'Amount credited', value: yn(v.repaymentAlert) },
|
||||||
|
{ label: 'Amount debited', value: yn(v.debitAlert) },
|
||||||
|
{ label: 'Interest and charges applied', value: yn(v.chargeInterest) }
|
||||||
|
];
|
||||||
|
if (this.chequeBookOffered) {
|
||||||
|
rows.push({ label: 'Cheque book issued', value: yn(v.chequeBook) });
|
||||||
|
}
|
||||||
|
rows.push(
|
||||||
|
{ label: 'Debit alert above', value: '₹' + (Number(v.debitAbove) || 0) },
|
||||||
|
{ label: 'Credit alert above', value: '₹' + (Number(v.creditAbove) || 0) },
|
||||||
|
{ label: 'SMS activation date', value: this.activationDate }
|
||||||
|
);
|
||||||
|
return [{ title: 'SMS Alerts', rows }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What is saved with the loan account (the loan_account_services datatable); dates as the server's date format. */
|
||||||
|
datatableValues(format: (isoDate: string) => string): Record<string, any> {
|
||||||
|
const v = this.form.getRawValue();
|
||||||
|
const on = this.active;
|
||||||
|
const data: Record<string, any> = {
|
||||||
|
sms_active: on,
|
||||||
|
sms_charge: on && v.smsCharge === 'yes'
|
||||||
|
};
|
||||||
|
if (on) {
|
||||||
|
Object.assign(data, {
|
||||||
|
sms_disbursement_alert: !!v.disbursementAlert,
|
||||||
|
sms_due_alert: !!v.dueAlert,
|
||||||
|
sms_overdue_alert: !!v.overdueAlert,
|
||||||
|
sms_renew_alert: !!v.renewAlert,
|
||||||
|
sms_closure_alert: !!v.closureAlert,
|
||||||
|
// an Advances account has no repayments: a credit is money paid into it, a debit is a drawing or a charge
|
||||||
|
sms_credit_clearing: !!v.repaymentAlert,
|
||||||
|
sms_debit_clearing: !!v.debitAlert,
|
||||||
|
sms_charge_interest: !!v.chargeInterest,
|
||||||
|
sms_cheque_book: this.chequeBookOffered && !!v.chequeBook,
|
||||||
|
sms_debit_above: Number(v.debitAbove) || 0,
|
||||||
|
sms_credit_above: Number(v.creditAbove) || 0,
|
||||||
|
sms_activation_date: format(this.activationDate)
|
||||||
|
});
|
||||||
|
if (v.smsCharge === 'yes') {
|
||||||
|
data['sms_charge_date'] = format(this.activationDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
+227
-162
@@ -6,6 +6,63 @@
|
|||||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
@if (losSections?.length) {
|
||||||
|
<div class="lp">
|
||||||
|
@if (losMode) {
|
||||||
|
<div class="lp-hero">
|
||||||
|
<div class="lp-hero__who">
|
||||||
|
<span class="lp-hero__eyebrow">Review before you submit</span>
|
||||||
|
<h2 class="lp-hero__name">{{ losHighlight('Customer', 'Name') }}</h2>
|
||||||
|
<span class="lp-hero__sub">{{ losHighlight('Loan Origination application', 'Product') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-kpis">
|
||||||
|
<div class="lp-kpi">
|
||||||
|
<span class="lp-kpi__label">Sanctioned amount</span>
|
||||||
|
<span class="lp-kpi__value">{{ losHighlight('Loan terms', 'Sanctioned amount') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-kpi">
|
||||||
|
<span class="lp-kpi__label">Tenure</span>
|
||||||
|
<span class="lp-kpi__value">{{ losHighlight('Loan terms', 'Tenure') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-kpi">
|
||||||
|
<span class="lp-kpi__label">Interest</span>
|
||||||
|
<span class="lp-kpi__value">{{ losHighlight('Loan terms', 'Debit interest rate') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-kpi">
|
||||||
|
<span class="lp-kpi__label">Application</span>
|
||||||
|
<span class="lp-kpi__value">{{ losHighlight('Loan Origination application', 'Application no.') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="lp-grid">
|
||||||
|
@for (section of losSections; track section.title) {
|
||||||
|
<section class="lp-card" [class.lp-card--wide]="isWide(section.title)">
|
||||||
|
<header class="lp-card__head">
|
||||||
|
<fa-icon [icon]="sectionIcon(section.title)" class="lp-card__icon"></fa-icon>
|
||||||
|
<h3>{{ section.title }}</h3>
|
||||||
|
</header>
|
||||||
|
<dl class="lp-rows">
|
||||||
|
@for (row of section.rows; track $index) {
|
||||||
|
<div class="lp-row">
|
||||||
|
<dt>{{ row.label }}</dt>
|
||||||
|
<dd>
|
||||||
|
@if (row.value === 'Yes' || row.value === 'No') {
|
||||||
|
<span class="lp-pill" [class.lp-pill--yes]="row.value === 'Yes'">{{ row.value }}</span>
|
||||||
|
} @else {
|
||||||
|
{{ row.value }}
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="content responsive-column">
|
<div class="content responsive-column">
|
||||||
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Details' | translate }}</h3>
|
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Details' | translate }}</h3>
|
||||||
|
|
||||||
@@ -100,204 +157,210 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<h3 class="mat-h3 margin-t flex-fill">{{ 'labels.heading.Terms' | translate }}</h3>
|
<!-- An account opened from Loan Origination lists its terms above (sanctioned amount, tenure, interest); an Advances
|
||||||
|
account has no repayment schedule, so the instalment terms are not repeated here. -->
|
||||||
|
@if (!losMode) {
|
||||||
|
<h3 class="mat-h3 margin-t flex-fill">{{ 'labels.heading.Terms' | translate }}</h3>
|
||||||
|
|
||||||
<mat-divider class="flex-fill"></mat-divider>
|
<mat-divider class="flex-fill"></mat-divider>
|
||||||
<div class="flex-fill">
|
|
||||||
<span class="flex-40">{{ 'labels.inputs.Principal' | translate }}:</span>
|
|
||||||
<span class="flex-60">
|
|
||||||
{{
|
|
||||||
loansAccount.principalAmount | currency: loansAccountProductTemplate.currency.code : 'symbol-narrow' : '1.2-2'
|
|
||||||
}}
|
|
||||||
<span class="m-l-5">{{ loansAccountProductTemplate.currency.code }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-fill">
|
|
||||||
<span class="flex-40">{{ 'labels.inputs.Loan Term' | translate }}:</span>
|
|
||||||
<span class="flex-60"
|
|
||||||
>{{ loansAccount.loanTermFrequency }}
|
|
||||||
{{
|
|
||||||
loansAccount.loanTermFrequencyType | find: loansAccountProductTemplate.termFrequencyTypeOptions : 'id' : 'name'
|
|
||||||
}}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-fill">
|
|
||||||
<span class="flex-40">{{ 'labels.inputs.Number of repayments' | translate }}:</span>
|
|
||||||
<span class="flex-60">{{ loansAccount.numberOfRepayments }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-fill">
|
|
||||||
<span class="flex-40">{{ 'labels.inputs.Repaid every' | translate }}:</span>
|
|
||||||
<span class="flex-60"
|
|
||||||
>{{ loansAccount.repaymentEvery }}
|
|
||||||
{{
|
|
||||||
loansAccount.repaymentFrequencyType | find: loansAccountProductTemplate.termFrequencyTypeOptions : 'id' : 'name'
|
|
||||||
}}
|
|
||||||
{{
|
|
||||||
loansAccount.repaymentFrequencyNthDayType
|
|
||||||
| find: loansAccountProductTemplate.repaymentFrequencyNthDayTypeOptions : 'id' : 'name'
|
|
||||||
}}
|
|
||||||
{{
|
|
||||||
loansAccount.repaymentFrequencyDayOfWeekType
|
|
||||||
| find: loansAccountProductTemplate.repaymentFrequencyDaysOfWeekTypeOptions : 'id' : 'name'
|
|
||||||
}}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (productEnableDownPayment) {
|
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
<span class="flex-40">{{ 'labels.inputs.Enable Down Payments' | translate }}:</span>
|
<span class="flex-40">{{ 'labels.inputs.Principal' | translate }}:</span>
|
||||||
<span class="flex-60">{{ loansAccount.enableDownPayment | yesNo }}</span>
|
<span class="flex-60">
|
||||||
|
{{
|
||||||
|
loansAccount.principalAmount | currency: loansAccountProductTemplate.currency.code : 'symbol-narrow' : '1.2-2'
|
||||||
|
}}
|
||||||
|
<span class="m-l-5">{{ loansAccountProductTemplate.currency.code }}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
@if (loansAccount.repaymentsStartingFromDate) {
|
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
<span class="flex-40">{{ 'labels.inputs.First repayment on' | translate }}:</span>
|
<span class="flex-40">{{ 'labels.inputs.Loan Term' | translate }}:</span>
|
||||||
<span class="flex-60">{{ loansAccount.repaymentsStartingFromDate | dateFormat }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (loansAccount.interestChargedFromDate) {
|
|
||||||
<div class="flex-fill">
|
|
||||||
<span class="flex-40">{{ 'labels.inputs.Interest charged from' | translate }}:</span>
|
|
||||||
<span class="flex-60">{{ loansAccount.interestChargedFromDate | dateFormat }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (loansAccount.interestRatePerPeriod) {
|
|
||||||
<div class="flex-fill">
|
|
||||||
<span class="flex-40">{{ 'labels.inputs.Nominal interest rate' | translate }}:</span>
|
|
||||||
<span class="flex-60"
|
<span class="flex-60"
|
||||||
>{{ loansAccount.interestRatePerPeriod }} {{
|
>{{ loansAccount.loanTermFrequency }}
|
||||||
loansAccountProductTemplate.interestRateFrequencyType.value
|
{{
|
||||||
|
loansAccount.loanTermFrequencyType
|
||||||
|
| find: loansAccountProductTemplate.termFrequencyTypeOptions : 'id' : 'name'
|
||||||
}}</span
|
}}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
@if (loansAccount.interestType) {
|
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
<span class="flex-40">{{ 'labels.inputs.Interest method' | translate }}: </span>
|
<span class="flex-40">{{ 'labels.inputs.Number of repayments' | translate }}:</span>
|
||||||
<span class="flex-60">{{ loansAccount.interestType }}</span>
|
<span class="flex-60">{{ loansAccount.numberOfRepayments }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
@if (loansAccount.isEqualAmortization) {
|
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
<span class="flex-40">{{ 'labels.inputs.Is Equal Amortization' | translate }}:</span>
|
<span class="flex-40">{{ 'labels.inputs.Repaid every' | translate }}:</span>
|
||||||
<span class="flex-60">{{ loansAccount.isEqualAmortization }}</span>
|
<span class="flex-60"
|
||||||
</div>
|
>{{ loansAccount.repaymentEvery }}
|
||||||
}
|
{{
|
||||||
|
loansAccount.repaymentFrequencyType
|
||||||
<div class="flex-fill">
|
| find: loansAccountProductTemplate.termFrequencyTypeOptions : 'id' : 'name'
|
||||||
<span class="flex-40">{{ 'labels.inputs.Amortization' | translate }}</span>
|
}}
|
||||||
<span class="flex-60">{{
|
{{
|
||||||
loansAccount.amortizationType
|
loansAccount.repaymentFrequencyNthDayType
|
||||||
| find: loansAccountProductTemplate.amortizationTypeOptions : 'id' : 'value'
|
| find: loansAccountProductTemplate.repaymentFrequencyNthDayTypeOptions : 'id' : 'name'
|
||||||
| translateKey: 'catalogs'
|
}}
|
||||||
}}</span>
|
{{
|
||||||
</div>
|
loansAccount.repaymentFrequencyDayOfWeekType
|
||||||
|
| find: loansAccountProductTemplate.repaymentFrequencyDaysOfWeekTypeOptions : 'id' : 'name'
|
||||||
<div class="flex-fill">
|
}}</span
|
||||||
<span class="flex-40">{{ 'labels.inputs.Interest calculation period' | translate }}: </span>
|
|
||||||
<span class="flex-60">{{
|
|
||||||
loansAccount.interestCalculationPeriodType
|
|
||||||
| find: loansAccountProductTemplate.interestCalculationPeriodTypeOptions : 'id' : 'value'
|
|
||||||
| translateKey: 'catalogs'
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (loansAccount.allowPartialPeriodInterestCalculation) {
|
|
||||||
<div class="flex-fill">
|
|
||||||
<span class="flex-40"
|
|
||||||
>{{ 'labels.inputs.Calculate interest for exact days in partial period' | translate }}:</span
|
|
||||||
>
|
>
|
||||||
<span class="flex-60">{{ loansAccount.allowPartialPeriodInterestCalculation }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
@if (loansAccount.inArrearsTolerance) {
|
@if (productEnableDownPayment) {
|
||||||
|
<div class="flex-fill">
|
||||||
|
<span class="flex-40">{{ 'labels.inputs.Enable Down Payments' | translate }}:</span>
|
||||||
|
<span class="flex-60">{{ loansAccount.enableDownPayment | yesNo }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loansAccount.repaymentsStartingFromDate) {
|
||||||
|
<div class="flex-fill">
|
||||||
|
<span class="flex-40">{{ 'labels.inputs.First repayment on' | translate }}:</span>
|
||||||
|
<span class="flex-60">{{ loansAccount.repaymentsStartingFromDate | dateFormat }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loansAccount.interestChargedFromDate) {
|
||||||
|
<div class="flex-fill">
|
||||||
|
<span class="flex-40">{{ 'labels.inputs.Interest charged from' | translate }}:</span>
|
||||||
|
<span class="flex-60">{{ loansAccount.interestChargedFromDate | dateFormat }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loansAccount.interestRatePerPeriod) {
|
||||||
|
<div class="flex-fill">
|
||||||
|
<span class="flex-40">{{ 'labels.inputs.Nominal interest rate' | translate }}:</span>
|
||||||
|
<span class="flex-60"
|
||||||
|
>{{ loansAccount.interestRatePerPeriod }} {{
|
||||||
|
loansAccountProductTemplate.interestRateFrequencyType.value
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loansAccount.interestType) {
|
||||||
|
<div class="flex-fill">
|
||||||
|
<span class="flex-40">{{ 'labels.inputs.Interest method' | translate }}: </span>
|
||||||
|
<span class="flex-60">{{ loansAccount.interestType }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loansAccount.isEqualAmortization) {
|
||||||
|
<div class="flex-fill">
|
||||||
|
<span class="flex-40">{{ 'labels.inputs.Is Equal Amortization' | translate }}:</span>
|
||||||
|
<span class="flex-60">{{ loansAccount.isEqualAmortization }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
<span class="flex-40">{{ 'labels.inputs.Arrears tolerance' | translate }}: </span>
|
<span class="flex-40">{{ 'labels.inputs.Amortization' | translate }}</span>
|
||||||
<span class="flex-60">{{ loansAccount.inArrearsTolerance }}</span>
|
<span class="flex-60">{{
|
||||||
|
loansAccount.amortizationType
|
||||||
|
| find: loansAccountProductTemplate.amortizationTypeOptions : 'id' : 'value'
|
||||||
|
| translateKey: 'catalogs'
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
@if (loansAccount.graceOnInterestCharged) {
|
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
<span class="flex-40">{{ 'labels.inputs.Interest free period' | translate }}: </span>
|
<span class="flex-40">{{ 'labels.inputs.Interest calculation period' | translate }}: </span>
|
||||||
<span class="flex-60">{{ loansAccount.graceOnInterestCharged }}</span>
|
<span class="flex-60">{{
|
||||||
|
loansAccount.interestCalculationPeriodType
|
||||||
|
| find: loansAccountProductTemplate.interestCalculationPeriodTypeOptions : 'id' : 'value'
|
||||||
|
| translateKey: 'catalogs'
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
<div class="flex-fill">
|
@if (loansAccount.allowPartialPeriodInterestCalculation) {
|
||||||
<span class="flex-40">{{ 'labels.inputs.Repayment strategy' | translate }}: </span>
|
<div class="flex-fill">
|
||||||
<span class="flex-60">{{
|
<span class="flex-40"
|
||||||
loansAccount.transactionProcessingStrategyCode
|
>{{ 'labels.inputs.Calculate interest for exact days in partial period' | translate }}:</span
|
||||||
| find: loansAccountProductTemplate.transactionProcessingStrategyOptions : 'code' : 'name'
|
>
|
||||||
| translateKey: 'catalogs'
|
<span class="flex-60">{{ loansAccount.allowPartialPeriodInterestCalculation }}</span>
|
||||||
}}</span>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
<div class="flex-fill">
|
@if (loansAccount.inArrearsTolerance) {
|
||||||
<span class="flex-40"> {{ 'labels.inputs.Installment Amount' | translate }} </span>
|
<div class="flex-fill">
|
||||||
<span class="flex-60"> {{ loansAccount.fixedEmiAmount | formatNumber }} </span>
|
<span class="flex-40">{{ 'labels.inputs.Arrears tolerance' | translate }}: </span>
|
||||||
</div>
|
<span class="flex-60">{{ loansAccount.inArrearsTolerance }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="flex-fill">
|
@if (loansAccount.graceOnInterestCharged) {
|
||||||
<span class="flex-40"> {{ 'labels.inputs.Balloon Repayment Amount' | translate }} </span>
|
<div class="flex-fill">
|
||||||
<span class="flex-60"> {{ loansAccount.balloonRepaymentAmount | formatNumber }} </span>
|
<span class="flex-40">{{ 'labels.inputs.Interest free period' | translate }}: </span>
|
||||||
</div>
|
<span class="flex-60">{{ loansAccount.graceOnInterestCharged }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<h3 class="mat-h3 margin-t flex-fill">{{ 'labels.heading.Moratorium' | translate }}</h3>
|
|
||||||
|
|
||||||
<mat-divider class="flex-fill"></mat-divider>
|
|
||||||
@if (loansAccount.graceOnPrincipalPayment) {
|
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
<span class="flex-40">{{ 'labels.inputs.On principal payment' | translate }}: </span>
|
<span class="flex-40">{{ 'labels.inputs.Repayment strategy' | translate }}: </span>
|
||||||
<span class="flex-60">{{ loansAccount.graceOnPrincipalPayment }}</span>
|
<span class="flex-60">{{
|
||||||
|
loansAccount.transactionProcessingStrategyCode
|
||||||
|
| find: loansAccountProductTemplate.transactionProcessingStrategyOptions : 'code' : 'name'
|
||||||
|
| translateKey: 'catalogs'
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
@if (loansAccount.graceOnInterestPayment) {
|
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
<span class="flex-40">{{ 'labels.inputs.On interest payment' | translate }}: </span>
|
<span class="flex-40"> {{ 'labels.inputs.Installment Amount' | translate }} </span>
|
||||||
<span class="flex-60">{{ loansAccount.graceOnInterestPayment }}</span>
|
<span class="flex-60"> {{ loansAccount.fixedEmiAmount | formatNumber }} </span>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
@if (loansAccount.graceOnArrearsAgeing) {
|
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
<span class="flex-40">{{ 'labels.inputs.On Arrears Aging' | translate }}: </span>
|
<span class="flex-40"> {{ 'labels.inputs.Balloon Repayment Amount' | translate }} </span>
|
||||||
<span class="flex-60">{{ loansAccount.graceOnArrearsAgeing }}</span>
|
<span class="flex-60"> {{ loansAccount.balloonRepaymentAmount | formatNumber }} </span>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
<div class="flex-fill">
|
<h3 class="mat-h3 margin-t flex-fill">{{ 'labels.heading.Moratorium' | translate }}</h3>
|
||||||
<span class="flex-40"> {{ 'labels.inputs.Enable installment level Delinquency' | translate }} </span>
|
|
||||||
<span class="flex-60"> {{ loansAccount.enableInstallmentLevelDelinquency | yesNo }} </span>
|
<mat-divider class="flex-fill"></mat-divider>
|
||||||
</div>
|
@if (loansAccount.graceOnPrincipalPayment) {
|
||||||
|
<div class="flex-fill">
|
||||||
|
<span class="flex-40">{{ 'labels.inputs.On principal payment' | translate }}: </span>
|
||||||
|
<span class="flex-60">{{ loansAccount.graceOnPrincipalPayment }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loansAccount.graceOnInterestPayment) {
|
||||||
|
<div class="flex-fill">
|
||||||
|
<span class="flex-40">{{ 'labels.inputs.On interest payment' | translate }}: </span>
|
||||||
|
<span class="flex-60">{{ loansAccount.graceOnInterestPayment }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loansAccount.graceOnArrearsAgeing) {
|
||||||
|
<div class="flex-fill">
|
||||||
|
<span class="flex-40">{{ 'labels.inputs.On Arrears Aging' | translate }}: </span>
|
||||||
|
<span class="flex-60">{{ loansAccount.graceOnArrearsAgeing }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (loansAccount.isTopup) {
|
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
<span class="flex-40">{{ 'labels.inputs.Is Topup Loan' | translate }}? </span>
|
<span class="flex-40"> {{ 'labels.inputs.Enable installment level Delinquency' | translate }} </span>
|
||||||
<span class="flex-60">{{ loansAccount.isTopup }}</span>
|
<span class="flex-60"> {{ loansAccount.enableInstallmentLevelDelinquency | yesNo }} </span>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
<div class="flex-fill">
|
@if (loansAccount.isTopup) {
|
||||||
<span class="flex-40">{{ 'labels.inputs.Recalculate Interest' | translate }}: </span>
|
<div class="flex-fill">
|
||||||
<span class="flex-60">{{ loansAccountProductTemplate?.isInterestRecalculationEnabled | yesNo }}</span>
|
<span class="flex-40">{{ 'labels.inputs.Is Topup Loan' | translate }}? </span>
|
||||||
</div>
|
<span class="flex-60">{{ loansAccount.isTopup }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (loansAccountProductTemplate?.daysInMonthType) {
|
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
<span class="flex-40">{{ 'labels.inputs.Days in month' | translate }}: </span>
|
<span class="flex-40">{{ 'labels.inputs.Recalculate Interest' | translate }}: </span>
|
||||||
<span class="flex-60">{{ loansAccountProductTemplate?.daysInMonthType.value | translateKey: 'catalogs' }}</span>
|
<span class="flex-60">{{ loansAccountProductTemplate?.isInterestRecalculationEnabled | yesNo }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (loansAccountProductTemplate?.daysInMonthType) {
|
||||||
|
<div class="flex-fill">
|
||||||
|
<span class="flex-40">{{ 'labels.inputs.Days in month' | translate }}: </span>
|
||||||
|
<span class="flex-60">{{ loansAccountProductTemplate?.daysInMonthType.value | translateKey: 'catalogs' }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (loansAccount.charges.length) {
|
@if (loansAccount.charges.length) {
|
||||||
@@ -395,10 +458,12 @@
|
|||||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||||
{{ 'labels.buttons.Previous' | translate }}
|
{{ 'labels.buttons.Previous' | translate }}
|
||||||
</button>
|
</button>
|
||||||
<button mat-raised-button [routerLink]="['../..']">
|
@if (!viewOnly) {
|
||||||
{{ 'labels.buttons.Cancel' | translate }}
|
<button mat-raised-button [routerLink]="['../..']">
|
||||||
</button>
|
{{ 'labels.buttons.Cancel' | translate }}
|
||||||
<button mat-raised-button color="primary" (click)="submitEvent.emit()">
|
</button>
|
||||||
{{ 'labels.buttons.Submit' | translate }}
|
<button mat-raised-button color="primary" (click)="submitEvent.emit()">
|
||||||
</button>
|
{{ 'labels.buttons.Submit' | translate }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+179
@@ -32,3 +32,182 @@ mat-divider {
|
|||||||
.margin-t {
|
.margin-t {
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Loan Origination preview: a header strip with the headline numbers, then a card per section. */
|
||||||
|
$navy: #0d1b2a;
|
||||||
|
$blue: #1e88e5;
|
||||||
|
$indigo: #4f46e5;
|
||||||
|
$border: #e2e8f0;
|
||||||
|
$muted: #64748b;
|
||||||
|
|
||||||
|
.lp {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lp-hero {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px 32px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, $navy 0%, #1e3a8a 55%, $indigo 100%);
|
||||||
|
box-shadow: 0 6px 20px rgb(30 58 138 / 22%);
|
||||||
|
|
||||||
|
&__eyebrow {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
margin: 4px 0 2px;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lp-kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1 1 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lp-kpi {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgb(255 255 255 / 12%);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
display: block;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lp-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
@media (width <= 520px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lp-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid $border;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $navy;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $blue;
|
||||||
|
background: rgb(30 136 229 / 12%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lp-rows {
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 18px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lp-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 9px 0;
|
||||||
|
border-bottom: 1px dashed #edf1f6;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
flex: 0 0 42%;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lp-pill {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #475569;
|
||||||
|
background: #e2e8f0;
|
||||||
|
|
||||||
|
&--yes {
|
||||||
|
color: #166534;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+37
@@ -27,6 +27,7 @@ import { ExternalIdentifierComponent } from '../../../shared/external-identifier
|
|||||||
import { MatDivider } from '@angular/material/divider';
|
import { MatDivider } from '@angular/material/divider';
|
||||||
import { MatStepperPrevious } from '@angular/material/stepper';
|
import { MatStepperPrevious } from '@angular/material/stepper';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { IconProp } from '@fortawesome/fontawesome-svg-core';
|
||||||
import { FindPipe } from '../../../pipes/find.pipe';
|
import { FindPipe } from '../../../pipes/find.pipe';
|
||||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||||
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
|
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
|
||||||
@@ -73,6 +74,8 @@ export class LoansAccountPreviewStepComponent implements OnChanges {
|
|||||||
@Input() loansAccountProductTemplate: any;
|
@Input() loansAccountProductTemplate: any;
|
||||||
/** Loans Account Data */
|
/** Loans Account Data */
|
||||||
@Input() loansAccount: any;
|
@Input() loansAccount: any;
|
||||||
|
/** What the Loan Origination steps filled (an account opened from Loan Origination), listed before the loan details. */
|
||||||
|
@Input() losSections: { title: string; rows: { label: string; value: string }[] }[] = [];
|
||||||
/** active Client Members in case of GLIM Account */
|
/** active Client Members in case of GLIM Account */
|
||||||
@Input() activeClientMembers?: any;
|
@Input() activeClientMembers?: any;
|
||||||
|
|
||||||
@@ -111,6 +114,40 @@ export class LoansAccountPreviewStepComponent implements OnChanges {
|
|||||||
|
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
|
/** Opened from a Loan Origination application: the header strip is shown and the instalment terms are not repeated. */
|
||||||
|
@Input() losMode = false;
|
||||||
|
/** An account already created is being viewed: there is nothing to submit. */
|
||||||
|
@Input() viewOnly = false;
|
||||||
|
|
||||||
|
/** One value from a section (the header strip of the Loan Origination preview). */
|
||||||
|
losHighlight(section: string, label: string): string {
|
||||||
|
return this.losSections?.find((s) => s.title === section)?.rows.find((r) => r.label === label)?.value ?? '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sections that list one entry per line (or long text) take the full width. */
|
||||||
|
isWide(title: string): boolean {
|
||||||
|
return /^(Security|Guarantors|Related persons|Customer)$/.test(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionIcon(title: string): IconProp {
|
||||||
|
const icons: Record<string, IconProp> = {
|
||||||
|
'Loan Origination application': 'file-alt',
|
||||||
|
Customer: 'user',
|
||||||
|
'Loan terms': 'coins',
|
||||||
|
'Account services': 'credit-card',
|
||||||
|
Limit: 'check-square',
|
||||||
|
Security: 'user-shield',
|
||||||
|
'Product terms': 'file-alt',
|
||||||
|
SMA: 'exclamation-triangle',
|
||||||
|
NPA: 'exclamation-triangle',
|
||||||
|
'As sanctioned': 'check',
|
||||||
|
'SMS Alerts': 'bell',
|
||||||
|
Guarantors: 'user-shield',
|
||||||
|
'Related persons': 'university'
|
||||||
|
};
|
||||||
|
return icons[title] ?? 'file-alt';
|
||||||
|
}
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
this.productEnableDownPayment = this.loansAccountProductTemplate.product.enableDownPayment;
|
this.productEnableDownPayment = this.loansAccountProductTemplate.product.enableDownPayment;
|
||||||
if (this.activeClientMembers) {
|
if (this.activeClientMembers) {
|
||||||
|
|||||||
+692
-612
File diff suppressed because it is too large
Load Diff
+24
@@ -30,3 +30,27 @@ mat-divider {
|
|||||||
.margin-t {
|
.margin-t {
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Advances opened from Loan Origination: the product's interest, shown and not changed. */
|
||||||
|
.los-readonly {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||||
|
gap: 10px 16px;
|
||||||
|
border: 1px solid #c9d7f2;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f3f7ff;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.los-readonly__label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #5b6b8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.los-readonly__note {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|||||||
+214
@@ -45,6 +45,10 @@ import { FindPipe } from '../../../pipes/find.pipe';
|
|||||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||||
import { YesnoPipe } from '../../../pipes/yesno.pipe';
|
import { YesnoPipe } from '../../../pipes/yesno.pipe';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { LoanApplication } from 'app/loan-origination/loan-origination-data';
|
||||||
|
import { LosPreviewSection } from '../los-preview-section';
|
||||||
|
import { LoanProductCatalogService } from '../../services/loan-product-catalog.service';
|
||||||
|
|
||||||
interface DisbursementData {
|
interface DisbursementData {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -86,6 +90,8 @@ interface DisbursementData {
|
|||||||
})
|
})
|
||||||
export class LoansAccountTermsStepComponent implements OnInit, OnChanges {
|
export class LoansAccountTermsStepComponent implements OnInit, OnChanges {
|
||||||
private formBuilder = inject(UntypedFormBuilder);
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
private catalog = inject(LoanProductCatalogService);
|
||||||
|
private http = inject(HttpClient);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
dialog = inject(MatDialog);
|
dialog = inject(MatDialog);
|
||||||
@@ -106,6 +112,192 @@ export class LoansAccountTermsStepComponent implements OnInit, OnChanges {
|
|||||||
@Input() collateralOptions: any;
|
@Input() collateralOptions: any;
|
||||||
// @Input loanPrincipal: Loan Principle
|
// @Input loanPrincipal: Loan Principle
|
||||||
@Input() loanPrincipal: any;
|
@Input() loanPrincipal: any;
|
||||||
|
/** The Loan Origination application this account is being opened for; its amount, tenure and rate fill the terms. */
|
||||||
|
@Input() losApplication: LoanApplication | null = null;
|
||||||
|
/** An Advances account opened from Loan Origination: no repayment schedule is asked, the interest is the product's, and the
|
||||||
|
* account services (cheque book, ATM / IMPS) are taken instead. */
|
||||||
|
@Input() losAdvance = false;
|
||||||
|
|
||||||
|
/** What the officer fills for the account's services; kept apart from the loan terms and saved with the loan afterwards. */
|
||||||
|
readonly losServicesForm = this.formBuilder.group({
|
||||||
|
chequeBookIssue: [false],
|
||||||
|
ppsLimit: [null],
|
||||||
|
atmActive: [false],
|
||||||
|
impsActive: [false],
|
||||||
|
impsPrimary: [false],
|
||||||
|
mmid: [''],
|
||||||
|
upiActive: [false],
|
||||||
|
posActive: [false],
|
||||||
|
atmPerTxn: [null],
|
||||||
|
atmDay: [null],
|
||||||
|
posPerTxn: [null],
|
||||||
|
posDay: [null],
|
||||||
|
upiPerTxn: [null],
|
||||||
|
upiDay: [null]
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The settings the product was created with (Product Creation), for the read-only interest block and the cheque book. */
|
||||||
|
get productExt(): Record<string, any> {
|
||||||
|
// The full settings are read from the product itself; the catalog list carries only its classification.
|
||||||
|
return this.losProductExt ?? this.catalog.classification(this.losProductId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The id of the product the account is for, whichever way the product template carries it. */
|
||||||
|
private get losProductId(): any {
|
||||||
|
return this.loansAccountProductTemplate?.loanProductId ?? this.loansAccountProductTemplate?.product?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private losProductExt: Record<string, any> | null = null;
|
||||||
|
private losProductExtFor: any = null;
|
||||||
|
|
||||||
|
private loadLosProductExt(): void {
|
||||||
|
const productId = this.losProductId;
|
||||||
|
if (!this.losAdvance || !productId || this.losProductExtFor === productId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.losProductExtFor = productId;
|
||||||
|
this.http.get<any>('/loanproducts/' + productId).subscribe((product) => {
|
||||||
|
this.losProductExt = product?.advanceExtensions ?? null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The interest the product was set up with, shown and not changed. */
|
||||||
|
get losInterestRows(): { label: string; value: string }[] {
|
||||||
|
const e = this.productExt;
|
||||||
|
const app = this.losApplication;
|
||||||
|
const yesNo = (v: any) => (v === true || v === 'true' ? 'Yes' : 'No');
|
||||||
|
const rows: { label: string; value: string }[] = [];
|
||||||
|
rows.push({
|
||||||
|
label: 'Debit interest rate',
|
||||||
|
value:
|
||||||
|
(app ? app.interestRate + '% p.a.' : '—') +
|
||||||
|
(e['interestBasis'] === 'deposit_spread' ? ' (deposit rate + ' + (e['spread'] ?? 0) + '%)' : '')
|
||||||
|
});
|
||||||
|
rows.push({
|
||||||
|
label: 'Interest charged on',
|
||||||
|
value: e['interestChargedOn'] === 'limit' ? 'Sanctioned limit' : 'Amount used'
|
||||||
|
});
|
||||||
|
rows.push({ label: 'Interest calculation', value: e['interestCalcMethod'] ?? '—' });
|
||||||
|
rows.push({
|
||||||
|
label: 'Interest posting',
|
||||||
|
value:
|
||||||
|
[
|
||||||
|
e['interestPostingFrequency'],
|
||||||
|
e['interestPostingDay']
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ') || '—'
|
||||||
|
});
|
||||||
|
rows.push({ label: 'Credit interest on balance', value: yesNo(e['interestOnCreditBalance']) });
|
||||||
|
rows.push({
|
||||||
|
label: 'Penal interest',
|
||||||
|
value: e['penalApplicable']
|
||||||
|
? (e['penalRate'] ?? 0) +
|
||||||
|
'% on ' +
|
||||||
|
(e['penalAppliedOn'] ?? 'overdue') +
|
||||||
|
(e['penalGraceDays'] ? ', grace ' + e['penalGraceDays'] + ' days' : '')
|
||||||
|
: 'Not applicable'
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything on this step, for the Preview (an Advances account opened from Loan Origination). */
|
||||||
|
previewSections(): LosPreviewSection[] {
|
||||||
|
const app = this.losApplication;
|
||||||
|
const v = this.losServicesForm.getRawValue();
|
||||||
|
const yn = (b: any) => (b ? 'Yes' : 'No');
|
||||||
|
const inr = (x: any) => (x === null || x === '' || x === undefined ? '—' : '₹' + Number(x).toLocaleString('en-IN'));
|
||||||
|
const services: { label: string; value: string }[] = [];
|
||||||
|
if (this.chequeBookOffered) {
|
||||||
|
services.push(
|
||||||
|
{ label: 'Cheque book issue', value: yn(v.chequeBookIssue) },
|
||||||
|
{ label: 'Positive Pay limit', value: inr(v.ppsLimit) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
services.push(
|
||||||
|
{
|
||||||
|
label: 'ATM',
|
||||||
|
value: yn(v.atmActive) + (v.atmActive ? ' · per txn ' + inr(v.atmPerTxn) + ' · per day ' + inr(v.atmDay) : '')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'IMPS',
|
||||||
|
value: yn(v.impsActive) + (v.impsActive ? ' · primary ' + yn(v.impsPrimary) + ' · MMID ' + (v.mmid || '—') : '')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'UPI',
|
||||||
|
value: yn(v.upiActive) + (v.upiActive ? ' · per txn ' + inr(v.upiPerTxn) + ' · per day ' + inr(v.upiDay) : '')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'POS',
|
||||||
|
value: yn(v.posActive) + (v.posActive ? ' · per txn ' + inr(v.posPerTxn) + ' · per day ' + inr(v.posDay) : '')
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Loan terms',
|
||||||
|
rows: [
|
||||||
|
{ label: 'Sanctioned amount', value: app ? inr(app.requestedAmount) : '—' },
|
||||||
|
{ label: 'Tenure', value: app ? app.tenureMonths + ' months' : '—' },
|
||||||
|
...this.losInterestRows
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ title: 'Account services', rows: services }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only a product that has a cheque book asks whether this customer gets one. */
|
||||||
|
get chequeBookOffered(): boolean {
|
||||||
|
return this.productExt['chequeBookEnabled'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the product says about Positive Pay on its cheques: the value from which it is asked and from which it is mandatory. */
|
||||||
|
get chequePpsInfo(): string {
|
||||||
|
const e = this.productExt;
|
||||||
|
const money = (x: any) =>
|
||||||
|
x === undefined || x === null || x === '' ? null : '₹' + Number(x).toLocaleString('en-IN');
|
||||||
|
return [
|
||||||
|
money(e['ppsThreshold']) ? 'asked from ' + money(e['ppsThreshold']) : '',
|
||||||
|
money(e['ppsMandatoryAbove']) ? 'mandatory above ' + money(e['ppsMandatoryAbove']) : ''
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
get chequeBookInfo(): string {
|
||||||
|
const e = this.productExt;
|
||||||
|
return [
|
||||||
|
e['chequeType'],
|
||||||
|
e['defaultChequeBookSize'] ? e['defaultChequeBookSize'] + ' leaves' : ''
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The services as the loan_account_services datatable stores them. */
|
||||||
|
get losServices(): Record<string, any> {
|
||||||
|
const v = this.losServicesForm.getRawValue();
|
||||||
|
const num = (x: any) => (x === null || x === '' || x === undefined ? undefined : Number(x));
|
||||||
|
const data: Record<string, any> = {
|
||||||
|
atm_active: !!v.atmActive,
|
||||||
|
imps_active: !!v.impsActive,
|
||||||
|
imps_primary_account: !!v.impsPrimary,
|
||||||
|
upi_active: !!v.upiActive,
|
||||||
|
pos_active: !!v.posActive,
|
||||||
|
mmid: v.mmid || undefined,
|
||||||
|
atm_per_txn_limit: num(v.atmPerTxn),
|
||||||
|
atm_day_limit: num(v.atmDay),
|
||||||
|
pos_per_txn_limit: num(v.posPerTxn),
|
||||||
|
pos_day_limit: num(v.posDay),
|
||||||
|
upi_per_txn_limit: num(v.upiPerTxn),
|
||||||
|
upi_day_limit: num(v.upiDay)
|
||||||
|
};
|
||||||
|
if (this.chequeBookOffered) {
|
||||||
|
data['cheque_book_issue'] = !!v.chequeBookIssue;
|
||||||
|
// Positive Pay applies to cheques, so it is asked only where the product has a cheque book.
|
||||||
|
data['pps_limit'] = num(v.ppsLimit);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
/** Minimum date allowed. */
|
/** Minimum date allowed. */
|
||||||
minDate = new Date(2000, 0, 1);
|
minDate = new Date(2000, 0, 1);
|
||||||
@@ -313,6 +505,28 @@ export class LoansAccountTermsStepComponent implements OnInit, OnChanges {
|
|||||||
new UntypedFormControl(this.loansAccountTermsData?.maxOutstandingLoanBalance ?? null)
|
new UntypedFormControl(this.loansAccountTermsData?.maxOutstandingLoanBalance ?? null)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
this.applyLosTerms();
|
||||||
|
this.loadLosProductExt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opened from Loan Origination: the amount, tenure and interest rate are the ones sanctioned there, not the product's
|
||||||
|
* defaults. Tenure is in months there, so the number of repayments is that over how often the product repays.
|
||||||
|
*/
|
||||||
|
private applyLosTerms(): void {
|
||||||
|
const app = this.losApplication;
|
||||||
|
if (!app || !this.loansAccountTermsData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const repaymentEvery = Number(this.loansAccountTermsForm.get('repaymentEvery')?.value) || 1;
|
||||||
|
this.loansAccountTermsForm.patchValue({
|
||||||
|
principalAmount: app.requestedAmount,
|
||||||
|
numberOfRepayments: Math.max(1, Math.round(app.tenureMonths / repaymentEvery))
|
||||||
|
});
|
||||||
|
const rate = this.loansAccountTermsForm.get('interestRatePerPeriod');
|
||||||
|
if (rate && !rate.disabled) {
|
||||||
|
rate.setValue(app.interestRate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A block of label / value rows each Loan Origination step of the account screen contributes to the Preview. */
|
||||||
|
export interface LosPreviewSection {
|
||||||
|
title: string;
|
||||||
|
rows: { label: string; value: string }[];
|
||||||
|
}
|
||||||
@@ -405,6 +405,8 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
|||||||
securityType: e.securityType,
|
securityType: e.securityType,
|
||||||
eligibleAmountBasis: e.eligibleAmountBasis,
|
eligibleAmountBasis: e.eligibleAmountBasis,
|
||||||
margin: e.margin,
|
margin: e.margin,
|
||||||
|
guarantorsRequired: e.guarantorsRequired ?? 0,
|
||||||
|
guarantorMustBeMember: e.guarantorMustBeMember ?? true,
|
||||||
lienMarkingStage: e.lienMarkingStage,
|
lienMarkingStage: e.lienMarkingStage,
|
||||||
eligibleDepositProducts: split(e.eligibleDepositProducts),
|
eligibleDepositProducts: split(e.eligibleDepositProducts),
|
||||||
tenureNotExceedMaturity: e.lienTenureNotExceedMaturity,
|
tenureNotExceedMaturity: e.lienTenureNotExceedMaturity,
|
||||||
@@ -1194,6 +1196,8 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
|||||||
eligibleAmountBasis: sec.eligibleAmountBasis,
|
eligibleAmountBasis: sec.eligibleAmountBasis,
|
||||||
margin: num(sec.margin),
|
margin: num(sec.margin),
|
||||||
ltv: sec.ltv,
|
ltv: sec.ltv,
|
||||||
|
guarantorsRequired: num(sec.guarantorsRequired) ?? 0,
|
||||||
|
guarantorMustBeMember: sec.guarantorMustBeMember,
|
||||||
dpMethod: 'security_value_x_ltv',
|
dpMethod: 'security_value_x_ltv',
|
||||||
lienRequired: this.loanProductSecurityLienStep.isLienSubType,
|
lienRequired: this.loanProductSecurityLienStep.isLienSubType,
|
||||||
// Lien settings exist only for a deposit / policy sub type; a pledged one (Gold ...) sends none of them.
|
// Lien settings exist only for a deposit / policy sub type; a pledged one (Gold ...) sends none of them.
|
||||||
@@ -1329,6 +1333,9 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True from the moment a new product is sent until the answer comes back. */
|
||||||
|
private creating = false;
|
||||||
|
|
||||||
submit() {
|
submit() {
|
||||||
const loanProduct = this.loanProducts.buildPayload(this.loanProduct, this.itemsByDefault);
|
const loanProduct = this.loanProducts.buildPayload(this.loanProduct, this.itemsByDefault);
|
||||||
if (this.isAdvancedPaymentStrategy) {
|
if (this.isAdvancedPaymentStrategy) {
|
||||||
@@ -1353,7 +1360,15 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a second click while the first request is still on its way would send the same product twice
|
||||||
|
if (this.creating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.creating = true;
|
||||||
this.productsService.createLoanProduct(loanProduct).subscribe({
|
this.productsService.createLoanProduct(loanProduct).subscribe({
|
||||||
|
error: () => {
|
||||||
|
this.creating = false;
|
||||||
|
},
|
||||||
next: (response: any) => {
|
next: (response: any) => {
|
||||||
if (response?.rollbackTransaction) {
|
if (response?.rollbackTransaction) {
|
||||||
this.alertService.alert({
|
this.alertService.alert({
|
||||||
|
|||||||
+2
@@ -91,6 +91,8 @@
|
|||||||
/>
|
/>
|
||||||
@if (loanProductDetailsForm.get('shortName').hasError('pattern')) {
|
@if (loanProductDetailsForm.get('shortName').hasError('pattern')) {
|
||||||
<mat-error>Short Name must be exactly <strong>2 capital letters</strong> (A–Z)</mat-error>
|
<mat-error>Short Name must be exactly <strong>2 capital letters</strong> (A–Z)</mat-error>
|
||||||
|
} @else if (loanProductDetailsForm.get('shortName').hasError('taken')) {
|
||||||
|
<mat-error>This Short Name is already used by another product — pick a different one</mat-error>
|
||||||
} @else {
|
} @else {
|
||||||
<mat-error>
|
<mat-error>
|
||||||
{{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }}
|
{{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }}
|
||||||
|
|||||||
+36
-2
@@ -18,6 +18,7 @@ import { CdkTextareaAutosize } from '@angular/cdk/text-field';
|
|||||||
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
|
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../loan-product-types';
|
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../loan-product-types';
|
||||||
|
import { ProductsService } from 'app/products/products.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-loan-product-details-step',
|
selector: 'mifosx-loan-product-details-step',
|
||||||
@@ -35,6 +36,10 @@ export class LoanProductDetailsStepComponent implements OnInit {
|
|||||||
private formBuilder = inject(UntypedFormBuilder);
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
|
private productsService = inject(ProductsService);
|
||||||
|
|
||||||
|
/** Short names the existing products already use, so a new product is not offered (or allowed) one of them. */
|
||||||
|
private takenShortNames = new Set<string>();
|
||||||
|
|
||||||
@Input() loanProductsTemplate: any;
|
@Input() loanProductsTemplate: any;
|
||||||
|
|
||||||
@@ -73,8 +78,20 @@ export class LoanProductDetailsStepComponent implements OnInit {
|
|||||||
// rule, so an existing short name such as 'PW16' does not stop the product from being edited.
|
// rule, so an existing short name such as 'PW16' does not stop the product from being edited.
|
||||||
this.loanProductDetailsForm.get('shortName').setValidators([
|
this.loanProductDetailsForm.get('shortName').setValidators([
|
||||||
Validators.required,
|
Validators.required,
|
||||||
Validators.pattern(/^[A-Z]{2}$/)
|
Validators.pattern(/^[A-Z]{2}$/),
|
||||||
|
(control) =>
|
||||||
|
this.takenShortNames.has(control.value) ? { taken: true } : null
|
||||||
]);
|
]);
|
||||||
|
this.productsService.getLoanProducts().subscribe((products: any[]) => {
|
||||||
|
this.takenShortNames = new Set((products ?? []).map((p) => p.shortName));
|
||||||
|
// the code filled in before this list arrived may already be in use
|
||||||
|
const control = this.loanProductDetailsForm.get('shortName');
|
||||||
|
if (control.value && this.takenShortNames.has(control.value)) {
|
||||||
|
control.setValue(this.freeShortName(control.value));
|
||||||
|
} else {
|
||||||
|
control.updateValueAndValidity({ emitEvent: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
this.loanProductDetailsForm.get('productTypeGroup').updateValueAndValidity({ emitEvent: false });
|
this.loanProductDetailsForm.get('productTypeGroup').updateValueAndValidity({ emitEvent: false });
|
||||||
this.loanProductDetailsForm.get('productTypeSubGroup').updateValueAndValidity({ emitEvent: false });
|
this.loanProductDetailsForm.get('productTypeSubGroup').updateValueAndValidity({ emitEvent: false });
|
||||||
this.loanProductDetailsForm.get('glAccount').updateValueAndValidity({ emitEvent: false });
|
this.loanProductDetailsForm.get('glAccount').updateValueAndValidity({ emitEvent: false });
|
||||||
@@ -130,6 +147,23 @@ export class LoanProductDetailsStepComponent implements OnInit {
|
|||||||
return typeof saved === 'boolean' ? (saved ? 'YES' : 'NO') : (saved ?? fallback);
|
return typeof saved === 'boolean' ? (saved ? 'YES' : 'NO') : (saved ?? fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sub type's own code when no product uses it yet; otherwise the same first letter with the next free second
|
||||||
|
* letter (FD taken -> FA, then FB ...), so a second product of the same kind still gets a short name of its own.
|
||||||
|
*/
|
||||||
|
private freeShortName(wanted: string): string {
|
||||||
|
if (this.isExistingProduct || !this.takenShortNames.has(wanted)) {
|
||||||
|
return wanted;
|
||||||
|
}
|
||||||
|
for (const second of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
||||||
|
const candidate = wanted.charAt(0) + second;
|
||||||
|
if (!this.takenShortNames.has(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wanted;
|
||||||
|
}
|
||||||
|
|
||||||
/** True when editing a saved product; see ngOnInit. */
|
/** True when editing a saved product; see ngOnInit. */
|
||||||
isExistingProduct = false;
|
isExistingProduct = false;
|
||||||
|
|
||||||
@@ -246,7 +280,7 @@ export class LoanProductDetailsStepComponent implements OnInit {
|
|||||||
this.loanProductDetailsForm.get('productTypeSubGroup')?.valueChanges.subscribe((subGroupId: string) => {
|
this.loanProductDetailsForm.get('productTypeSubGroup')?.valueChanges.subscribe((subGroupId: string) => {
|
||||||
const subGroup = this.availableSubGroups.find((s) => s.id === subGroupId);
|
const subGroup = this.availableSubGroups.find((s) => s.id === subGroupId);
|
||||||
if (subGroup) {
|
if (subGroup) {
|
||||||
this.loanProductDetailsForm.get('shortName').setValue(subGroup.shortName);
|
this.loanProductDetailsForm.get('shortName').setValue(this.freeShortName(subGroup.shortName));
|
||||||
}
|
}
|
||||||
this.applyCategoryForSubType(subGroupId);
|
this.applyCategoryForSubType(subGroupId);
|
||||||
});
|
});
|
||||||
|
|||||||
+2
@@ -182,6 +182,8 @@ export class LoanProductPreviewStepComponent {
|
|||||||
),
|
),
|
||||||
row('Margin', 'margin', pct),
|
row('Margin', 'margin', pct),
|
||||||
row('LTV', 'ltv', pct),
|
row('LTV', 'ltv', pct),
|
||||||
|
row('Guarantors required', 'guarantorsRequired'),
|
||||||
|
row('Guarantors must be bank members', 'guarantorMustBeMember'),
|
||||||
row('Lien required', 'lienRequired'),
|
row('Lien required', 'lienRequired'),
|
||||||
row('Lien marking stage', 'lienMarkingStage'),
|
row('Lien marking stage', 'lienMarkingStage'),
|
||||||
row('Eligible deposit products', 'eligibleDepositProducts'),
|
row('Eligible deposit products', 'eligibleDepositProducts'),
|
||||||
|
|||||||
+18
@@ -15,6 +15,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@if (isAdvance) {
|
@if (isAdvance) {
|
||||||
|
<div class="lsl-section">
|
||||||
|
<div class="lsl-section__header">
|
||||||
|
<i class="fas fa-user-plus"></i>
|
||||||
|
<h3>Guarantors</h3>
|
||||||
|
<span class="lsl-section__hint">Asked on every application of this product</span>
|
||||||
|
</div>
|
||||||
|
<div class="lsl-grid">
|
||||||
|
<mat-form-field appearance="outline" class="lsl-field">
|
||||||
|
<mat-label>Guarantors Required</mat-label>
|
||||||
|
<input matInput type="number" formControlName="guarantorsRequired" min="0" max="5" />
|
||||||
|
<mat-hint>0 = none (a deposit or policy is its own security). Otherwise that many are asked for.</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="lsl-checks">
|
||||||
|
<mat-checkbox formControlName="guarantorMustBeMember">Guarantors must be members of the bank</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="lsl-section">
|
<div class="lsl-section">
|
||||||
<div class="lsl-section__header">
|
<div class="lsl-section__header">
|
||||||
<i class="fas fa-shield-alt"></i>
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
|||||||
+11
@@ -240,6 +240,15 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
|
|||||||
Validators.max(100)
|
Validators.max(100)
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
// How many guarantors an application on this product has to name (0 = none: a deposit is its own security).
|
||||||
|
guarantorsRequired: [
|
||||||
|
0,
|
||||||
|
[
|
||||||
|
Validators.min(0),
|
||||||
|
Validators.max(5)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
guarantorMustBeMember: [true],
|
||||||
lienMarkingStage: [
|
lienMarkingStage: [
|
||||||
'disbursement',
|
'disbursement',
|
||||||
Validators.required
|
Validators.required
|
||||||
@@ -445,6 +454,8 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
|
|||||||
eligibleAmountBasis: value.eligibleAmountBasis,
|
eligibleAmountBasis: value.eligibleAmountBasis,
|
||||||
margin: value.margin,
|
margin: value.margin,
|
||||||
ltv: this.ltv,
|
ltv: this.ltv,
|
||||||
|
guarantorsRequired: value.guarantorsRequired,
|
||||||
|
guarantorMustBeMember: value.guarantorMustBeMember,
|
||||||
lienMarkingStage: value.lienMarkingStage,
|
lienMarkingStage: value.lienMarkingStage,
|
||||||
eligibleDepositProducts: value.eligibleDepositProducts,
|
eligibleDepositProducts: value.eligibleDepositProducts,
|
||||||
lienRules: {
|
lienRules: {
|
||||||
|
|||||||
Reference in New Issue
Block a user