feat: letter-mgmt module + digital UPI/IMPS/ATM-POS reports + header redesign
letter-mgmt (new module): - Notice page: overdue loan list with filters, KPI strip, bulk actions, call disposition logging (Promise-to-Pay, Not Reachable, etc.), Speed Post dispatch dialog with consignment + post-office tracking, PDF preview + download, call-contact dialog with primary / alt / family / nominee numbers and per-contact disposition history. - Certificates: Loan Interest, Share, Balance, Form 60 (no-PAN flow), Form 15G/H (auto age-based), Hypothecation, NOC - each with shared preview dialog (six tone variants) and jsPDF generator using a common letterhead / summary-grid / signature-seal helper. digital: - IMPS module: report (P2A/P2P/Aadhaar tabs) + entry form with MMID/mobile lookup, NPCI switch resolve-to-A/C+IFSC BANL flow, sender autocomplete, hide sender field for P2P/Aadhaar, charges + 18% GST + waive checkbox, per-sender today-only history dialog. - UPI report with app-mix tab and full transaction filtering. - ATM/POS/ECOM hub with three ticket cards routing to today/status/cycle. - NEFT/RTGS entry: sender-account autocomplete, charges + GST + waive, field reorder (sender before amount), NEFT cap / RTGS floor validation, History button opening per-sender today's transactions. - NEFT/RTGS report: horizontal-bar trend leaderboard replacing tall bars. shell + theme: - Header redesigned with IBM Plex Sans 12/500 typography, 11-column grid per row, top-accent hover indicator, bordered cells via grid-gap. - Live KPI ticker below menu (CASH, TXN TODAY, CLEARING IN/OUT, NPA, CASA, CD RATIO, RBI CIRC, REPO) with marquee scroll, pause-on-hover, brand-blue gradient, amber bottom strip, pulsing red LIVE chip. - Sidebar 'KYC Management' -> 'KYC & ReKYC Management'. - Letter Mgmt / certificate menu items wired with green icon hints for items that have a working page. access-control: - MFA card layout compacted; method header inlines back button + title. icons: - Register faTruck/TruckFast, faEllipsisV/H, faCommentDots. - Add IBM Plex Sans Google Fonts import to index.html. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
4322f37c6d
commit
325228c514
@@ -0,0 +1,209 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="ccd-root">
|
||||
<!-- HEADER -->
|
||||
<div class="ccd-head">
|
||||
<div class="ccd-head-l">
|
||||
<fa-icon icon="phone" class="ccd-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="ccd-head-t">Reach {{ data.loan.customerName }}</h2>
|
||||
<div class="ccd-head-s">
|
||||
<span class="mono">{{ data.loan.accountNo }}</span> · <span class="mono">{{ data.loan.customerId }}</span> ·
|
||||
{{ data.loan.branch }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="ccd-x" (click)="close()" matTooltip="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- CONTACT LIST -->
|
||||
<div class="ccd-body">
|
||||
@for (c of contacts; track c.kind) {
|
||||
<div class="ccd-row-group">
|
||||
<div class="ccd-row" [ngClass]="'accent-' + c.accent" [class.is-logging]="isLogging(c)">
|
||||
<span class="ccd-row-strip"></span>
|
||||
|
||||
<div class="ccd-row-icon">
|
||||
<fa-icon [icon]="c.icon"></fa-icon>
|
||||
</div>
|
||||
|
||||
<div class="ccd-row-text">
|
||||
<div class="ccd-row-label">{{ c.label }}</div>
|
||||
@if (c.name) {
|
||||
<div class="ccd-row-name">{{ c.name }}</div>
|
||||
}
|
||||
<div class="ccd-row-mobile mono">+91 {{ formatMobile(c.mobile) }}</div>
|
||||
|
||||
@if (latestLog(c); as log) {
|
||||
<div class="ccd-row-lastlog" [ngClass]="'tone-' + outcomeTone(log.outcome)">
|
||||
<fa-icon [icon]="outcomeIcon(log.outcome)"></fa-icon>
|
||||
<span class="ccd-lastlog-l">{{ outcomeLabel(log.outcome) }}</span>
|
||||
<span class="ccd-lastlog-m">· {{ log.calledAt.slice(0, 16) }}</span>
|
||||
@if (log.promiseAmount && log.promiseDate) {
|
||||
<span class="ccd-lastlog-ptp">
|
||||
PTP ₹{{ log.promiseAmount | number: '1.0-0' }} by {{ log.promiseDate | date: 'd MMM' }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="ccd-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="ccd-act ccd-act--primary"
|
||||
(click)="call(c)"
|
||||
matTooltip="Call now"
|
||||
aria-label="Call"
|
||||
>
|
||||
<fa-icon icon="phone"></fa-icon>
|
||||
<span>Call</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ccd-act ccd-act--log"
|
||||
[class.is-on]="isLogging(c)"
|
||||
(click)="isLogging(c) ? cancelLogging() : startLogging(c)"
|
||||
matTooltip="Log call outcome"
|
||||
aria-label="Log call outcome"
|
||||
>
|
||||
<fa-icon icon="clipboard-check"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ccd-act"
|
||||
(click)="whatsapp(c)"
|
||||
matTooltip="Send a WhatsApp message"
|
||||
aria-label="WhatsApp"
|
||||
>
|
||||
<fa-icon icon="comment-dots"></fa-icon>
|
||||
</button>
|
||||
<button type="button" class="ccd-act" (click)="copy(c)" matTooltip="Copy number" aria-label="Copy number">
|
||||
<fa-icon icon="copy"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline disposition picker -->
|
||||
@if (isLogging(c)) {
|
||||
<div class="ccd-log">
|
||||
<div class="ccd-log-h">
|
||||
<fa-icon icon="clipboard-check" class="ccd-log-h-ic"></fa-icon>
|
||||
<span>Log call outcome</span>
|
||||
<span class="ccd-log-h-sub">{{ c.name || c.label }} · +91 {{ formatMobile(c.mobile) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="ccd-log-l">Pick an outcome</div>
|
||||
<div class="ccd-chips">
|
||||
@for (o of outcomeOptions; track o.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="ccd-chip"
|
||||
[ngClass]="'tone-' + o.tone"
|
||||
[class.is-on]="isOutcomePicked(o.value)"
|
||||
(click)="pickOutcome(o.value)"
|
||||
>
|
||||
<fa-icon [icon]="o.icon"></fa-icon>
|
||||
<span>{{ o.label }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isPromiseToPay) {
|
||||
<div class="ccd-ptp">
|
||||
<div class="ccd-field">
|
||||
<label>Promise amount (₹)</label>
|
||||
<input type="number" min="1" step="1" [(ngModel)]="draft.promiseAmount" placeholder="e.g. 50000" />
|
||||
</div>
|
||||
<div class="ccd-field">
|
||||
<label>Promise date</label>
|
||||
<input type="date" [(ngModel)]="draft.promiseDate" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="ccd-field">
|
||||
<label>Notes (optional)</label>
|
||||
<textarea
|
||||
rows="2"
|
||||
maxlength="240"
|
||||
[(ngModel)]="draft.notes"
|
||||
placeholder="Anything the next agent should know…"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="ccd-log-actions">
|
||||
<button type="button" class="ccd-act" (click)="cancelLogging()">
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
<button type="button" class="ccd-act ccd-act--save" [disabled]="!canSaveLog" (click)="saveLog(c)">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
<span>Save call log</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (contacts.length === 0) {
|
||||
<div class="ccd-empty">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>
|
||||
<strong>No contact numbers on file.</strong>
|
||||
<p>Update the customer profile to add mobile, family or nominee contacts.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Recent calls audit list -->
|
||||
@if (allLogs.length > 0) {
|
||||
<div class="ccd-history">
|
||||
<div class="ccd-history-h">
|
||||
<fa-icon icon="history"></fa-icon>
|
||||
<span>Recent calls</span>
|
||||
<span class="ccd-history-c">{{ allLogs.length }}</span>
|
||||
</div>
|
||||
<ul class="ccd-history-list">
|
||||
@for (log of allLogs; track log.id) {
|
||||
<li class="ccd-history-item" [ngClass]="'tone-' + outcomeTone(log.outcome)">
|
||||
<div class="ccd-history-row">
|
||||
<span class="ccd-history-dot"></span>
|
||||
<span class="ccd-history-outcome">
|
||||
<fa-icon [icon]="outcomeIcon(log.outcome)"></fa-icon>
|
||||
{{ outcomeLabel(log.outcome) }}
|
||||
</span>
|
||||
<span class="ccd-history-kind">
|
||||
{{ contactKindLabel(log.contactKind) }}
|
||||
@if (log.contactName) {
|
||||
· {{ log.contactName }}
|
||||
}
|
||||
</span>
|
||||
<span class="ccd-history-num mono">+91 {{ formatMobile(log.contactNumber) }}</span>
|
||||
<span class="ccd-history-meta">{{ log.calledAt.slice(0, 16) }} · {{ log.calledBy }}</span>
|
||||
</div>
|
||||
@if (log.promiseAmount && log.promiseDate) {
|
||||
<div class="ccd-history-ptp">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
Promise ₹{{ log.promiseAmount | number: '1.0-0' }} by
|
||||
{{ log.promiseDate | date: 'd MMM y' }}
|
||||
</div>
|
||||
}
|
||||
@if (log.notes) {
|
||||
<div class="ccd-history-notes">"{{ log.notes }}"</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,765 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--ccd-blue: #1d4ed8;
|
||||
--ccd-blue-d: #1e40af;
|
||||
--ccd-blue-l: #eff6ff;
|
||||
--ccd-blue-tint: #dbeafe;
|
||||
--ccd-teal: #0d9488;
|
||||
--ccd-teal-l: #ccfbf1;
|
||||
--ccd-amber: #d97706;
|
||||
--ccd-amber-l: #fef3c7;
|
||||
--ccd-red: #b91c1c;
|
||||
--ccd-red-l: #fee2e2;
|
||||
--ccd-red-tint: #fecaca;
|
||||
--ccd-green: #15803d;
|
||||
--ccd-green-l: #dcfce7;
|
||||
--ccd-purple: #6d28d9;
|
||||
--ccd-purple-l: #ede9fe;
|
||||
--ccd-g50: #f8fafc;
|
||||
--ccd-g100: #f1f5f9;
|
||||
--ccd-g200: #e2e8f0;
|
||||
--ccd-g300: #cbd5e1;
|
||||
--ccd-g500: #64748b;
|
||||
--ccd-g700: #334155;
|
||||
--ccd-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ccd-root {
|
||||
background: #fff;
|
||||
padding: 14px 18px 18px;
|
||||
width: 100%;
|
||||
max-height: 86vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ───────── Header ───────── */
|
||||
.ccd-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--ccd-g200);
|
||||
}
|
||||
|
||||
.ccd-head-l {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ccd-head-ic {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: var(--ccd-green);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.ccd-head-t {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--ccd-g900);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ccd-head-s {
|
||||
margin-top: 3px;
|
||||
font-size: 11.5px;
|
||||
color: var(--ccd-g500);
|
||||
}
|
||||
|
||||
.ccd-x {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--ccd-g200);
|
||||
background: #fff;
|
||||
color: var(--ccd-g500);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--ccd-red-l);
|
||||
color: var(--ccd-red);
|
||||
border-color: var(--ccd-red-tint);
|
||||
}
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* ───────── Body ───────── */
|
||||
.ccd-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
/* ───────── Contact row ───────── */
|
||||
.ccd-row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px 12px 18px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--ccd-g200);
|
||||
border-radius: 12px;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--ccd-blue-tint);
|
||||
box-shadow: 0 4px 14px -8px rgb(15 23 42 / 18%);
|
||||
}
|
||||
}
|
||||
|
||||
.ccd-row-strip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
|
||||
.accent-blue & {
|
||||
background: var(--ccd-blue);
|
||||
}
|
||||
|
||||
.accent-teal & {
|
||||
background: var(--ccd-teal);
|
||||
}
|
||||
|
||||
.accent-amber & {
|
||||
background: var(--ccd-amber);
|
||||
}
|
||||
|
||||
.accent-purple & {
|
||||
background: var(--ccd-purple);
|
||||
}
|
||||
}
|
||||
|
||||
.ccd-row-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.accent-blue & {
|
||||
background: var(--ccd-blue-l);
|
||||
color: var(--ccd-blue-d);
|
||||
}
|
||||
|
||||
.accent-teal & {
|
||||
background: var(--ccd-teal-l);
|
||||
color: var(--ccd-teal);
|
||||
}
|
||||
|
||||
.accent-amber & {
|
||||
background: var(--ccd-amber-l);
|
||||
color: var(--ccd-amber);
|
||||
}
|
||||
|
||||
.accent-purple & {
|
||||
background: var(--ccd-purple-l);
|
||||
color: var(--ccd-purple);
|
||||
}
|
||||
}
|
||||
|
||||
.ccd-row-text {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.ccd-row-label {
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ccd-g500);
|
||||
}
|
||||
|
||||
.ccd-row-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--ccd-g900);
|
||||
}
|
||||
|
||||
.ccd-row-mobile {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ccd-g700);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ───────── Row actions ───────── */
|
||||
.ccd-row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ccd-act {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 11px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--ccd-g200);
|
||||
background: #fff;
|
||||
color: var(--ccd-g700);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
|
||||
fa-icon {
|
||||
font-size: 12px;
|
||||
color: var(--ccd-g500);
|
||||
}
|
||||
|
||||
span {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--ccd-blue-l);
|
||||
border-color: var(--ccd-blue-tint);
|
||||
color: var(--ccd-blue-d);
|
||||
|
||||
fa-icon {
|
||||
color: var(--ccd-blue-d);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--ccd-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Primary "Call" button → solid green so it pops as the recommended action. */
|
||||
.ccd-act.ccd-act--primary {
|
||||
background: var(--ccd-green);
|
||||
color: #fff;
|
||||
border-color: var(--ccd-green);
|
||||
|
||||
fa-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #14532d;
|
||||
border-color: #14532d;
|
||||
color: #fff;
|
||||
|
||||
fa-icon {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* "Log call outcome" toggle button. */
|
||||
.ccd-act.ccd-act--log {
|
||||
width: 34px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
|
||||
&.is-on {
|
||||
background: var(--ccd-blue-d);
|
||||
border-color: var(--ccd-blue-d);
|
||||
color: #fff;
|
||||
|
||||
fa-icon {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* "Save call log" — primary blue when enabled. */
|
||||
.ccd-act.ccd-act--save {
|
||||
background: var(--ccd-blue-d);
|
||||
color: #fff;
|
||||
border-color: var(--ccd-blue-d);
|
||||
|
||||
fa-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--ccd-blue);
|
||||
border-color: var(--ccd-blue);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Latest-log strip on the contact row ───────── */
|
||||
.ccd-row-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.ccd-row.is-logging {
|
||||
border-color: var(--ccd-blue-tint);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.ccd-row-lastlog {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
align-self: flex-start;
|
||||
|
||||
fa-icon {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
&.tone-green {
|
||||
background: var(--ccd-green-l);
|
||||
color: var(--ccd-green);
|
||||
}
|
||||
|
||||
&.tone-blue {
|
||||
background: var(--ccd-blue-l);
|
||||
color: var(--ccd-blue-d);
|
||||
}
|
||||
|
||||
&.tone-amber {
|
||||
background: var(--ccd-amber-l);
|
||||
color: var(--ccd-amber);
|
||||
}
|
||||
|
||||
&.tone-red {
|
||||
background: var(--ccd-red-l);
|
||||
color: var(--ccd-red);
|
||||
}
|
||||
}
|
||||
|
||||
.ccd-lastlog-m {
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.ccd-lastlog-ptp {
|
||||
margin-left: 6px;
|
||||
padding-left: 6px;
|
||||
border-left: 1px solid currentcolor;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ───────── Inline disposition picker ───────── */
|
||||
.ccd-log {
|
||||
border: 1px solid var(--ccd-blue-tint);
|
||||
border-top: 0;
|
||||
border-bottom-left-radius: 12px;
|
||||
border-bottom-right-radius: 12px;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
|
||||
padding: 12px 14px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ccd-log-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
color: var(--ccd-g900);
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--ccd-g200);
|
||||
}
|
||||
|
||||
.ccd-log-h-ic {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
background: var(--ccd-blue-d);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ccd-log-h-sub {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--ccd-g500);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.ccd-log-l {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ccd-g500);
|
||||
}
|
||||
|
||||
/* Outcome chip group */
|
||||
.ccd-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ccd-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 11px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--ccd-g200);
|
||||
background: #fff;
|
||||
color: var(--ccd-g700);
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
|
||||
fa-icon {
|
||||
font-size: 11px;
|
||||
color: var(--ccd-g500);
|
||||
}
|
||||
|
||||
&:hover:not(.is-on) {
|
||||
background: var(--ccd-g50);
|
||||
border-color: var(--ccd-g300);
|
||||
}
|
||||
|
||||
&.is-on {
|
||||
color: #fff;
|
||||
|
||||
fa-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.tone-green {
|
||||
background: var(--ccd-green);
|
||||
border-color: var(--ccd-green);
|
||||
}
|
||||
|
||||
&.tone-blue {
|
||||
background: var(--ccd-blue-d);
|
||||
border-color: var(--ccd-blue-d);
|
||||
}
|
||||
|
||||
&.tone-amber {
|
||||
background: var(--ccd-amber);
|
||||
border-color: var(--ccd-amber);
|
||||
}
|
||||
|
||||
&.tone-red {
|
||||
background: var(--ccd-red);
|
||||
border-color: var(--ccd-red);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Promise-to-pay fields */
|
||||
.ccd-ptp {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--ccd-green-l);
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ccd-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ccd-g500);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
border: 1px solid var(--ccd-g200);
|
||||
border-radius: 8px;
|
||||
padding: 8px 11px;
|
||||
background: #fff;
|
||||
font-size: 12.5px;
|
||||
color: var(--ccd-g900);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--ccd-blue);
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.ccd-log-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* ───────── Recent-calls history list ───────── */
|
||||
.ccd-history {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed var(--ccd-g200);
|
||||
}
|
||||
|
||||
.ccd-history-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ccd-g500);
|
||||
margin-bottom: 8px;
|
||||
|
||||
fa-icon {
|
||||
font-size: 11px;
|
||||
color: var(--ccd-blue);
|
||||
}
|
||||
}
|
||||
|
||||
.ccd-history-c {
|
||||
margin-left: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--ccd-g100);
|
||||
color: var(--ccd-g500);
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ccd-history-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ccd-history-item {
|
||||
padding: 8px 10px 8px 14px;
|
||||
border: 1px solid var(--ccd-g200);
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
|
||||
&.tone-green {
|
||||
border-left: 4px solid var(--ccd-green);
|
||||
}
|
||||
|
||||
&.tone-blue {
|
||||
border-left: 4px solid var(--ccd-blue);
|
||||
}
|
||||
|
||||
&.tone-amber {
|
||||
border-left: 4px solid var(--ccd-amber);
|
||||
}
|
||||
|
||||
&.tone-red {
|
||||
border-left: 4px solid var(--ccd-red);
|
||||
}
|
||||
}
|
||||
|
||||
.ccd-history-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.ccd-history-outcome {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-weight: 800;
|
||||
color: var(--ccd-g900);
|
||||
|
||||
fa-icon {
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.tone-green & fa-icon {
|
||||
color: var(--ccd-green);
|
||||
}
|
||||
|
||||
.tone-blue & fa-icon {
|
||||
color: var(--ccd-blue-d);
|
||||
}
|
||||
|
||||
.tone-amber & fa-icon {
|
||||
color: var(--ccd-amber);
|
||||
}
|
||||
|
||||
.tone-red & fa-icon {
|
||||
color: var(--ccd-red);
|
||||
}
|
||||
}
|
||||
|
||||
.ccd-history-kind {
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ccd-g500);
|
||||
}
|
||||
|
||||
.ccd-history-num {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ccd-g700);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ccd-history-meta {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--ccd-g500);
|
||||
}
|
||||
|
||||
.ccd-history-ptp {
|
||||
margin-top: 6px;
|
||||
padding: 4px 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--ccd-green);
|
||||
background: var(--ccd-green-l);
|
||||
border-radius: 8px;
|
||||
|
||||
fa-icon {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.ccd-history-notes {
|
||||
margin-top: 6px;
|
||||
font-size: 11.5px;
|
||||
font-style: italic;
|
||||
color: var(--ccd-g500);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ───────── Empty state ───────── */
|
||||
.ccd-empty {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 18px 16px;
|
||||
border: 1px dashed var(--ccd-g300);
|
||||
border-radius: 10px;
|
||||
background: var(--ccd-g50);
|
||||
color: var(--ccd-g700);
|
||||
|
||||
fa-icon {
|
||||
color: var(--ccd-blue);
|
||||
font-size: 18px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--ccd-g500);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 540px) {
|
||||
.ccd-row {
|
||||
grid-template-columns: 38px 1fr;
|
||||
grid-template-areas:
|
||||
'icon text'
|
||||
'actions actions';
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.ccd-row-icon {
|
||||
grid-area: icon;
|
||||
}
|
||||
|
||||
.ccd-row-text {
|
||||
grid-area: text;
|
||||
}
|
||||
|
||||
.ccd-row-actions {
|
||||
grid-area: actions;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import { CallContactKind, CallLog, CallOutcome, OverdueLoan } from '../notice.component';
|
||||
|
||||
export interface CallContactDialogData {
|
||||
loan: OverdueLoan;
|
||||
}
|
||||
|
||||
export type ContactKind = CallContactKind;
|
||||
|
||||
export interface ContactEntry {
|
||||
kind: ContactKind;
|
||||
/** Display label e.g. "Customer", "Alternative", "Family — Spouse", "Nominee" */
|
||||
label: string;
|
||||
/** Person's name, when applicable (customer / family / nominee). */
|
||||
name?: string;
|
||||
/** Just the 10-digit India mobile (no +91). */
|
||||
mobile: string;
|
||||
/** Icon name (already registered) */
|
||||
icon: string;
|
||||
/** Color accent for the row strip. */
|
||||
accent: 'blue' | 'teal' | 'purple' | 'amber';
|
||||
}
|
||||
|
||||
interface OutcomeOption {
|
||||
value: CallOutcome;
|
||||
label: string;
|
||||
/** Pill color theme — green = good, amber = neutral, red = bad. */
|
||||
tone: 'green' | 'blue' | 'amber' | 'red';
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface LogDraft {
|
||||
outcome: CallOutcome | null;
|
||||
notes: string;
|
||||
promiseAmount: number | null;
|
||||
promiseDate: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-call-contact-dialog',
|
||||
templateUrl: './call-contact-dialog.component.html',
|
||||
styleUrls: ['./call-contact-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class CallContactDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<CallContactDialogComponent>);
|
||||
private snack = inject(MatSnackBar);
|
||||
data: CallContactDialogData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
/** Tracks which row is currently in disposition-logging mode (one at a time). */
|
||||
loggingKind: ContactKind | null = null;
|
||||
/** Per-session draft state for the inline disposition picker. */
|
||||
draft: LogDraft = this.emptyDraft();
|
||||
|
||||
readonly outcomeOptions: OutcomeOption[] = [
|
||||
{ value: 'PROMISE_TO_PAY', label: 'Promise to Pay', tone: 'green', icon: 'check-circle' },
|
||||
{ value: 'SETTLED', label: 'Settled / Paid', tone: 'green', icon: 'check-double' },
|
||||
{ value: 'CALLBACK_REQUESTED', label: 'Callback Requested', tone: 'blue', icon: 'history' },
|
||||
{ value: 'NOT_REACHABLE', label: 'Not Reachable', tone: 'amber', icon: 'phone' },
|
||||
{ value: 'SWITCHED_OFF', label: 'Switched Off', tone: 'amber', icon: 'mobile-alt' },
|
||||
{ value: 'RNR', label: 'Ring No Response', tone: 'amber', icon: 'bell' },
|
||||
{ value: 'BUSY', label: 'Busy', tone: 'amber', icon: 'hourglass-half' },
|
||||
{ value: 'WRONG_NUMBER', label: 'Wrong Number', tone: 'red', icon: 'exclamation-triangle' },
|
||||
{ value: 'REFUSED', label: 'Refused / Abusive', tone: 'red', icon: 'ban' }
|
||||
];
|
||||
|
||||
/** All reachable contacts for the loan — only the ones that actually have a number. */
|
||||
get contacts(): ContactEntry[] {
|
||||
const l = this.data.loan;
|
||||
const out: ContactEntry[] = [];
|
||||
|
||||
out.push({
|
||||
kind: 'PRIMARY',
|
||||
label: 'Customer (primary)',
|
||||
name: l.customerName,
|
||||
mobile: l.mobile,
|
||||
icon: 'user',
|
||||
accent: 'blue'
|
||||
});
|
||||
|
||||
if (l.mobileAlt) {
|
||||
out.push({
|
||||
kind: 'ALTERNATIVE',
|
||||
label: 'Alternative number',
|
||||
name: l.customerName,
|
||||
mobile: l.mobileAlt,
|
||||
icon: 'mobile-alt',
|
||||
accent: 'teal'
|
||||
});
|
||||
}
|
||||
|
||||
if (l.familyMobile) {
|
||||
out.push({
|
||||
kind: 'FAMILY',
|
||||
label: l.familyRelation ? `Family — ${l.familyRelation}` : 'Family',
|
||||
name: l.familyName,
|
||||
mobile: l.familyMobile,
|
||||
icon: 'users',
|
||||
accent: 'amber'
|
||||
});
|
||||
}
|
||||
|
||||
if (l.nomineeMobile) {
|
||||
out.push({
|
||||
kind: 'NOMINEE',
|
||||
label: 'Nominee',
|
||||
name: l.nomineeName,
|
||||
mobile: l.nomineeMobile,
|
||||
icon: 'shield-alt',
|
||||
accent: 'purple'
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** "9818877665" → "98188 77665" — easier to read in the row. */
|
||||
formatMobile(m: string): string {
|
||||
if (!m) return '';
|
||||
const trimmed = m.replace(/\D/g, '').slice(-10);
|
||||
return trimmed.length === 10 ? `${trimmed.slice(0, 5)} ${trimmed.slice(5)}` : m;
|
||||
}
|
||||
|
||||
call(c: ContactEntry): void {
|
||||
window.location.href = `tel:+91${c.mobile}`;
|
||||
this.snack.open(`Calling ${c.name || 'contact'} · +91 ${this.formatMobile(c.mobile)}…`, 'OK', {
|
||||
duration: 2500
|
||||
});
|
||||
}
|
||||
|
||||
whatsapp(c: ContactEntry): void {
|
||||
const msg = `Dear ${c.name || 'Sir/Madam'}, regarding your loan account ${this.data.loan.accountNo} with our bank — kindly contact our Recovery & Collections team.`;
|
||||
const url = `https://wa.me/91${c.mobile}?text=${encodeURIComponent(msg)}`;
|
||||
window.open(url, '_blank', 'noopener');
|
||||
this.snack.open(`Opening WhatsApp for ${c.name || 'contact'}…`, 'OK', { duration: 2000 });
|
||||
}
|
||||
|
||||
copy(c: ContactEntry): void {
|
||||
const number = `+91 ${this.formatMobile(c.mobile)}`;
|
||||
const fallback = (): void => {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = number;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
};
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(number).catch(fallback);
|
||||
} else {
|
||||
fallback();
|
||||
}
|
||||
this.snack.open(`Copied ${number} to clipboard.`, 'OK', { duration: 2000 });
|
||||
}
|
||||
|
||||
// ───────── Disposition logging ─────────
|
||||
isLogging(c: ContactEntry): boolean {
|
||||
return this.loggingKind === c.kind;
|
||||
}
|
||||
|
||||
startLogging(c: ContactEntry): void {
|
||||
this.loggingKind = c.kind;
|
||||
this.draft = this.emptyDraft();
|
||||
}
|
||||
|
||||
cancelLogging(): void {
|
||||
this.loggingKind = null;
|
||||
this.draft = this.emptyDraft();
|
||||
}
|
||||
|
||||
pickOutcome(o: CallOutcome): void {
|
||||
this.draft.outcome = this.draft.outcome === o ? null : o;
|
||||
}
|
||||
|
||||
isOutcomePicked(o: CallOutcome): boolean {
|
||||
return this.draft.outcome === o;
|
||||
}
|
||||
|
||||
get isPromiseToPay(): boolean {
|
||||
return this.draft.outcome === 'PROMISE_TO_PAY';
|
||||
}
|
||||
|
||||
/** True once an outcome is picked and (for PTP) the promise fields are filled. */
|
||||
get canSaveLog(): boolean {
|
||||
if (!this.draft.outcome) return false;
|
||||
if (this.isPromiseToPay) {
|
||||
if (!this.draft.promiseAmount || this.draft.promiseAmount <= 0) return false;
|
||||
if (!this.draft.promiseDate) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
saveLog(c: ContactEntry): void {
|
||||
if (!this.canSaveLog || !this.draft.outcome) return;
|
||||
const log: CallLog = {
|
||||
id: 'CL' + Date.now().toString().slice(-9),
|
||||
contactKind: c.kind,
|
||||
contactNumber: c.mobile,
|
||||
contactName: c.name,
|
||||
calledAt: this.nowStr(),
|
||||
calledBy: 'Current User',
|
||||
outcome: this.draft.outcome,
|
||||
promiseAmount: this.isPromiseToPay ? Number(this.draft.promiseAmount) : undefined,
|
||||
promiseDate: this.isPromiseToPay ? this.draft.promiseDate : undefined,
|
||||
notes: (this.draft.notes || '').trim() || undefined
|
||||
};
|
||||
this.data.loan.callLogs = [
|
||||
log,
|
||||
...(this.data.loan.callLogs || [])
|
||||
];
|
||||
const label = this.outcomeLabel(log.outcome);
|
||||
this.snack.open(`Call disposition saved · ${label}`, 'OK', { duration: 2400 });
|
||||
this.cancelLogging();
|
||||
}
|
||||
|
||||
// ───────── Display helpers ─────────
|
||||
/** Most-recent call disposition for a particular contact number (or null). */
|
||||
latestLog(c: ContactEntry): CallLog | null {
|
||||
const logs = this.data.loan.callLogs || [];
|
||||
return logs.find((l) => l.contactNumber === c.mobile) || null;
|
||||
}
|
||||
|
||||
/** All logs sorted newest first — drives the "Recent calls" footer list. */
|
||||
get allLogs(): CallLog[] {
|
||||
return this.data.loan.callLogs || [];
|
||||
}
|
||||
|
||||
outcomeLabel(o: CallOutcome): string {
|
||||
return this.outcomeOptions.find((x) => x.value === o)?.label || o;
|
||||
}
|
||||
|
||||
outcomeTone(o: CallOutcome): 'green' | 'blue' | 'amber' | 'red' {
|
||||
return this.outcomeOptions.find((x) => x.value === o)?.tone || 'amber';
|
||||
}
|
||||
|
||||
outcomeIcon(o: CallOutcome): string {
|
||||
return this.outcomeOptions.find((x) => x.value === o)?.icon || 'phone';
|
||||
}
|
||||
|
||||
contactKindLabel(k: ContactKind): string {
|
||||
switch (k) {
|
||||
case 'PRIMARY':
|
||||
return 'Primary';
|
||||
case 'ALTERNATIVE':
|
||||
return 'Alternative';
|
||||
case 'FAMILY':
|
||||
return 'Family';
|
||||
case 'NOMINEE':
|
||||
return 'Nominee';
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
private emptyDraft(): LogDraft {
|
||||
return { outcome: null, notes: '', promiseAmount: null, promiseDate: '' };
|
||||
}
|
||||
|
||||
private nowStr(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return (
|
||||
d.getFullYear() +
|
||||
'-' +
|
||||
pad(d.getMonth() + 1) +
|
||||
'-' +
|
||||
pad(d.getDate()) +
|
||||
' ' +
|
||||
pad(d.getHours()) +
|
||||
':' +
|
||||
pad(d.getMinutes()) +
|
||||
':' +
|
||||
pad(d.getSeconds())
|
||||
);
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="nhd-root">
|
||||
<!-- HEADER -->
|
||||
<div class="nhd-head">
|
||||
<div class="nhd-head-l">
|
||||
<fa-icon icon="history" class="nhd-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="nhd-head-t">
|
||||
{{ data.loan.customerName }} <small>· {{ data.loan.accountNo }}</small>
|
||||
</h2>
|
||||
<div class="nhd-head-s">
|
||||
{{ data.loan.notices.length }} notice(s) generated · Overdue
|
||||
<strong>₹{{ data.loan.overdueTotal | number: '1.0-0' }}</strong> · DPD {{ data.loan.daysOverdue }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="nhd-x" (click)="close()" matTooltip="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TIMELINE -->
|
||||
<div class="nhd-body">
|
||||
@if (data.loan.notices.length === 0) {
|
||||
<div class="nhd-empty">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>
|
||||
<strong>No notices have been generated yet.</strong>
|
||||
<p>Click the Generate icon on the row to issue a first-stage notice.</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<ol class="nhd-timeline">
|
||||
@for (n of data.loan.notices; track n.id) {
|
||||
<li class="nhd-tl-item" [ngClass]="'accent-' + stageColor(n.stage)">
|
||||
<span class="nhd-tl-dot">
|
||||
<fa-icon icon="file-signature"></fa-icon>
|
||||
</span>
|
||||
<div class="nhd-tl-card">
|
||||
<div class="nhd-tl-head">
|
||||
<span class="nhd-stage-pill" [ngClass]="'nhd-stage-pill--' + stageColor(n.stage)">
|
||||
{{ stageLabel(n.stage) }}
|
||||
</span>
|
||||
<span class="nhd-tl-meta">
|
||||
Generated <strong>{{ n.generatedAt }}</strong> by {{ n.generatedBy }}
|
||||
</span>
|
||||
<span class="nhd-tl-id mono">{{ n.id }}</span>
|
||||
<span class="nhd-tl-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="nhd-action-btn"
|
||||
(click)="viewNotice(n)"
|
||||
matTooltip="View / print this notice"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
</button>
|
||||
<button type="button" class="nhd-action-btn" (click)="downloadNotice(n)" matTooltip="Download PDF">
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (n.sends.length === 0) {
|
||||
<div class="nhd-no-sends">
|
||||
<fa-icon icon="hourglass-half"></fa-icon>
|
||||
Generated but not yet dispatched.
|
||||
</div>
|
||||
} @else {
|
||||
<table class="nhd-send-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Channel</th>
|
||||
<th>Destination</th>
|
||||
<th>Sent</th>
|
||||
<th>By</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (s of n.sends; track s.sentAt + s.channel) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="nhd-channel">
|
||||
<fa-icon [icon]="channelIcon(s.channel)"></fa-icon>
|
||||
{{ channelLabel(s.channel) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small mono">{{ s.destination }}</td>
|
||||
<td class="small mono">{{ s.sentAt }}</td>
|
||||
<td class="small">{{ s.sentBy }}</td>
|
||||
<td>
|
||||
<span class="nhd-status-pill" [ngClass]="'nhd-status-pill--' + statusColor(s.status)">
|
||||
{{ s.status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@if (s.channel === 'POST' && (s.consignmentNo || s.postOffice)) {
|
||||
<tr class="nhd-post-row">
|
||||
<td colspan="5">
|
||||
<div class="nhd-post-meta">
|
||||
@if (s.consignmentNo) {
|
||||
<span class="nhd-post-chip">
|
||||
<fa-icon icon="truck-fast"></fa-icon>
|
||||
<span class="nhd-post-l">Consignment</span>
|
||||
<span class="nhd-post-v mono">{{ s.consignmentNo }}</span>
|
||||
</span>
|
||||
}
|
||||
@if (s.postOffice) {
|
||||
<span class="nhd-post-chip">
|
||||
<fa-icon icon="map-marker-alt"></fa-icon>
|
||||
<span class="nhd-post-l">Post office</span>
|
||||
<span class="nhd-post-v">{{ s.postOffice }}</span>
|
||||
</span>
|
||||
}
|
||||
@if (s.bookedDate) {
|
||||
<span class="nhd-post-chip">
|
||||
<span class="nhd-post-l">Booked</span>
|
||||
<span class="nhd-post-v">{{ s.bookedDate | date: 'd MMM y' }}</span>
|
||||
</span>
|
||||
}
|
||||
@if (s.postage) {
|
||||
<span class="nhd-post-chip">
|
||||
<span class="nhd-post-l">Postage</span>
|
||||
<span class="nhd-post-v">₹{{ s.postage | number: '1.0-0' }}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--nhd-blue: #1d4ed8;
|
||||
--nhd-blue-d: #1e40af;
|
||||
--nhd-blue-l: #eff6ff;
|
||||
--nhd-blue-tint: #dbeafe;
|
||||
--nhd-green: #15803d;
|
||||
--nhd-green-l: #dcfce7;
|
||||
--nhd-amber: #d97706;
|
||||
--nhd-amber-l: #fef3c7;
|
||||
--nhd-red: #b91c1c;
|
||||
--nhd-red-l: #fee2e2;
|
||||
--nhd-red-tint: #fecaca;
|
||||
--nhd-purple: #6d28d9;
|
||||
--nhd-purple-l: #ede9fe;
|
||||
--nhd-purple-tint: #ddd6fe;
|
||||
--nhd-g50: #f8fafc;
|
||||
--nhd-g100: #f1f5f9;
|
||||
--nhd-g200: #e2e8f0;
|
||||
--nhd-g300: #cbd5e1;
|
||||
--nhd-g500: #64748b;
|
||||
--nhd-g700: #334155;
|
||||
--nhd-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nhd-root {
|
||||
background: #fff;
|
||||
padding: 16px 20px 22px;
|
||||
width: 100%;
|
||||
max-height: 84vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ───────── Header ───────── */
|
||||
.nhd-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 14px;
|
||||
border-bottom: 1px solid var(--nhd-g200);
|
||||
}
|
||||
|
||||
.nhd-head-l {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nhd-head-ic {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: var(--nhd-blue-d);
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.nhd-head-t {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--nhd-g900);
|
||||
|
||||
small {
|
||||
font-weight: 500;
|
||||
color: var(--nhd-g500);
|
||||
font-size: 12.5px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.nhd-head-s {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--nhd-g500);
|
||||
|
||||
strong {
|
||||
color: var(--nhd-g900);
|
||||
}
|
||||
}
|
||||
|
||||
.nhd-x {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--nhd-g200);
|
||||
background: #fff;
|
||||
color: var(--nhd-g500);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s,
|
||||
border-color 0.12s;
|
||||
|
||||
&:hover {
|
||||
background: var(--nhd-red-l);
|
||||
color: var(--nhd-red);
|
||||
border-color: var(--nhd-red-tint);
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Body / Empty ───────── */
|
||||
.nhd-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.nhd-empty {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 18px 16px;
|
||||
border: 1px dashed var(--nhd-g300);
|
||||
border-radius: 10px;
|
||||
background: var(--nhd-g50);
|
||||
color: var(--nhd-g700);
|
||||
|
||||
fa-icon {
|
||||
color: var(--nhd-blue);
|
||||
font-size: 18px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--nhd-g500);
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Timeline ───────── */
|
||||
.nhd-timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px 0 0 28px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 0;
|
||||
bottom: 8px;
|
||||
width: 2px;
|
||||
background: var(--nhd-g200);
|
||||
}
|
||||
}
|
||||
|
||||
.nhd-tl-item {
|
||||
position: relative;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.nhd-tl-dot {
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
top: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
background: var(--nhd-blue-d);
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 0 0 2px var(--nhd-blue-tint);
|
||||
|
||||
.accent-amber & {
|
||||
background: var(--nhd-amber);
|
||||
box-shadow: 0 0 0 2px #fde68a;
|
||||
}
|
||||
|
||||
.accent-red & {
|
||||
background: var(--nhd-red);
|
||||
box-shadow: 0 0 0 2px var(--nhd-red-tint);
|
||||
}
|
||||
|
||||
.accent-purple & {
|
||||
background: var(--nhd-purple);
|
||||
box-shadow: 0 0 0 2px var(--nhd-purple-tint);
|
||||
}
|
||||
}
|
||||
|
||||
.nhd-tl-card {
|
||||
border: 1px solid var(--nhd-g200);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.nhd-tl-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px dashed var(--nhd-g200);
|
||||
}
|
||||
|
||||
.nhd-stage-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
|
||||
&--blue {
|
||||
background: var(--nhd-blue-l);
|
||||
color: var(--nhd-blue-d);
|
||||
}
|
||||
|
||||
&--amber {
|
||||
background: var(--nhd-amber-l);
|
||||
color: var(--nhd-amber);
|
||||
}
|
||||
|
||||
&--red {
|
||||
background: var(--nhd-red-l);
|
||||
color: var(--nhd-red);
|
||||
}
|
||||
|
||||
&--purple {
|
||||
background: var(--nhd-purple-l);
|
||||
color: var(--nhd-purple);
|
||||
}
|
||||
}
|
||||
|
||||
.nhd-tl-meta {
|
||||
font-size: 11.5px;
|
||||
color: var(--nhd-g500);
|
||||
|
||||
strong {
|
||||
color: var(--nhd-g900);
|
||||
}
|
||||
}
|
||||
|
||||
.nhd-tl-id {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--nhd-g500);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.nhd-tl-actions {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nhd-action-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--nhd-g200);
|
||||
background: #fff;
|
||||
color: var(--nhd-g500);
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--nhd-blue-l);
|
||||
color: var(--nhd-blue-d);
|
||||
border-color: var(--nhd-blue-tint);
|
||||
}
|
||||
}
|
||||
|
||||
.nhd-no-sends {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
color: var(--nhd-amber);
|
||||
|
||||
fa-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Send table ───────── */
|
||||
.nhd-send-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
background: var(--nhd-g50);
|
||||
color: var(--nhd-g500);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 10px;
|
||||
border-bottom: 1px solid var(--nhd-g200);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--nhd-g100);
|
||||
color: var(--nhd-g900);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: var(--nhd-g700);
|
||||
}
|
||||
}
|
||||
|
||||
.nhd-channel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
color: var(--nhd-g900);
|
||||
|
||||
fa-icon {
|
||||
color: var(--nhd-blue);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.nhd-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
|
||||
&--green {
|
||||
background: var(--nhd-green-l);
|
||||
color: var(--nhd-green);
|
||||
}
|
||||
|
||||
&--amber {
|
||||
background: var(--nhd-amber-l);
|
||||
color: var(--nhd-amber);
|
||||
}
|
||||
|
||||
&--red {
|
||||
background: var(--nhd-red-l);
|
||||
color: var(--nhd-red);
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Postal-dispatch detail strip (under POST send rows) ───────── */
|
||||
.nhd-post-row td {
|
||||
padding: 6px 10px 10px;
|
||||
background: #fff7ed;
|
||||
border-bottom: 1px solid var(--nhd-g100);
|
||||
}
|
||||
|
||||
.nhd-post-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nhd-post-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
border: 1px solid #fed7aa;
|
||||
font-size: 11px;
|
||||
color: var(--nhd-g900);
|
||||
|
||||
fa-icon {
|
||||
font-size: 10px;
|
||||
color: #c2410c;
|
||||
}
|
||||
}
|
||||
|
||||
.nhd-post-l {
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nhd-g500);
|
||||
}
|
||||
|
||||
.nhd-post-v {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: var(--nhd-g900);
|
||||
|
||||
&.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import {
|
||||
downloadNoticePdf,
|
||||
NoticeChannel,
|
||||
NoticeEvent,
|
||||
NoticeStage,
|
||||
OverdueLoan,
|
||||
SendStatus
|
||||
} from '../notice.component';
|
||||
import {
|
||||
NoticePreviewDialogComponent,
|
||||
NoticePreviewDialogData
|
||||
} from '../notice-preview-dialog/notice-preview-dialog.component';
|
||||
|
||||
export interface NoticeHistoryDialogData {
|
||||
loan: OverdueLoan;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-notice-history-dialog',
|
||||
templateUrl: './notice-history-dialog.component.html',
|
||||
styleUrls: ['./notice-history-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class NoticeHistoryDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<NoticeHistoryDialogComponent>);
|
||||
private dialog = inject(MatDialog);
|
||||
private snack = inject(MatSnackBar);
|
||||
data: NoticeHistoryDialogData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
/** Opens the printable preview for a specific notice in the timeline. */
|
||||
viewNotice(notice: NoticeEvent): void {
|
||||
const data: NoticePreviewDialogData = { loan: this.data.loan, notice };
|
||||
this.dialog.open(NoticePreviewDialogComponent, {
|
||||
width: '880px',
|
||||
maxWidth: '96vw',
|
||||
maxHeight: '92vh',
|
||||
autoFocus: false,
|
||||
panelClass: 'ntc-preview-dialog',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** Builds and downloads the PDF version of a specific historical notice. */
|
||||
downloadNotice(notice: NoticeEvent): void {
|
||||
downloadNoticePdf(this.data.loan, notice);
|
||||
this.snack.open(`Notice ${notice.id} downloaded as PDF.`, 'OK', { duration: 2200 });
|
||||
}
|
||||
|
||||
stageLabel(s: NoticeStage): string {
|
||||
return s === 'FIRST'
|
||||
? '1st Notice'
|
||||
: s === 'SECOND'
|
||||
? '2nd Notice'
|
||||
: s === 'FINAL'
|
||||
? 'Final Notice'
|
||||
: 'Legal Notice';
|
||||
}
|
||||
|
||||
stageColor(s: NoticeStage): string {
|
||||
return s === 'FIRST' ? 'blue' : s === 'SECOND' ? 'amber' : s === 'FINAL' ? 'red' : 'purple';
|
||||
}
|
||||
|
||||
channelLabel(c: NoticeChannel): string {
|
||||
switch (c) {
|
||||
case 'EMAIL':
|
||||
return 'Email';
|
||||
case 'POST':
|
||||
return 'Speed Post';
|
||||
case 'COURIER':
|
||||
return 'Courier';
|
||||
case 'SMS':
|
||||
return 'SMS';
|
||||
case 'WHATSAPP':
|
||||
return 'WhatsApp';
|
||||
case 'IN_PERSON':
|
||||
return 'In-Person';
|
||||
}
|
||||
}
|
||||
|
||||
channelIcon(c: NoticeChannel): string {
|
||||
switch (c) {
|
||||
case 'EMAIL':
|
||||
return 'envelope';
|
||||
case 'POST':
|
||||
return 'stamp';
|
||||
case 'COURIER':
|
||||
return 'paper-plane';
|
||||
case 'SMS':
|
||||
return 'mobile-alt';
|
||||
case 'WHATSAPP':
|
||||
return 'comment-alt';
|
||||
case 'IN_PERSON':
|
||||
return 'user-tie';
|
||||
}
|
||||
}
|
||||
|
||||
statusColor(s: SendStatus): string {
|
||||
switch (s) {
|
||||
case 'DELIVERED':
|
||||
return 'green';
|
||||
case 'PENDING':
|
||||
return 'amber';
|
||||
case 'FAILED':
|
||||
case 'BOUNCED':
|
||||
return 'red';
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="npd-root">
|
||||
<!-- DIALOG TOOLBAR (hidden when printing) -->
|
||||
<div class="npd-toolbar no-print">
|
||||
<div class="npd-toolbar-l">
|
||||
<fa-icon icon="file-signature" class="npd-toolbar-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="npd-toolbar-t">{{ stageLabel(data.notice.stage) }}</div>
|
||||
<div class="npd-toolbar-s">{{ data.notice.id }} · {{ data.notice.generatedAt }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="npd-toolbar-r">
|
||||
<button class="npd-tb-btn" (click)="printNotice()" matTooltip="Print or Save as PDF">
|
||||
<fa-icon icon="print"></fa-icon> Print / PDF
|
||||
</button>
|
||||
<button class="npd-tb-btn" (click)="downloadPdf()" matTooltip="Download as PDF">
|
||||
<fa-icon icon="download"></fa-icon> Download PDF
|
||||
</button>
|
||||
<button class="npd-tb-btn npd-tb-x" (click)="close()" matTooltip="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LETTER PAGE -->
|
||||
<div class="npd-page" [ngClass]="'tone-' + letter.tone">
|
||||
<!-- Bank letterhead -->
|
||||
<div class="npd-letterhead">
|
||||
<div class="npd-bank-name">{{ bankName }}</div>
|
||||
<div class="npd-bank-sub">{{ bankAddress }}</div>
|
||||
<div class="npd-bank-sub">{{ bankRegNo }} · {{ bankContact }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Notice meta -->
|
||||
<div class="npd-meta-row">
|
||||
<div>
|
||||
<div class="npd-meta-l">Notice Reference</div>
|
||||
<div class="npd-meta-v mono">{{ data.notice.id }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="npd-meta-l">Date of Issue</div>
|
||||
<div class="npd-meta-v">{{ data.notice.generatedAt.slice(0, 10) | date: 'd MMMM y' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="npd-meta-l">Stage</div>
|
||||
<div class="npd-meta-v">
|
||||
<span class="npd-stage-pill" [ngClass]="'npd-stage-pill--' + letter.tone">
|
||||
{{ stageLabel(data.notice.stage) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Addressee -->
|
||||
<div class="npd-block">
|
||||
<div class="npd-block-l">To,</div>
|
||||
<div class="npd-block-v">
|
||||
<div>
|
||||
<strong>{{ data.loan.customerName }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
Customer ID: <span class="mono">{{ data.loan.customerId }}</span>
|
||||
</div>
|
||||
<div>{{ data.loan.address }}</div>
|
||||
<div>Mob: +91 {{ data.loan.mobile }} · {{ data.loan.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subject -->
|
||||
<div class="npd-subject">
|
||||
<span class="npd-subject-l">Subject:</span>
|
||||
<span class="npd-subject-v">{{ letter.subject }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<p class="npd-salutation">{{ letter.salutation }}</p>
|
||||
<p class="npd-para">{{ letter.intro }}</p>
|
||||
@for (p of letter.body; track p) {
|
||||
<p class="npd-para">{{ p }}</p>
|
||||
}
|
||||
|
||||
<!-- Loan summary table -->
|
||||
<table class="npd-summary">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Loan Account No.</td>
|
||||
<td class="mono">
|
||||
<strong>{{ data.loan.accountNo }}</strong>
|
||||
</td>
|
||||
<td>Branch</td>
|
||||
<td>{{ data.loan.branch }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Days Past Due</td>
|
||||
<td>
|
||||
<strong>{{ data.loan.daysOverdue }}</strong>
|
||||
</td>
|
||||
<td>Last Paid Date</td>
|
||||
<td>{{ data.loan.lastPaidDate | date: 'd MMM y' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Overdue Principal</td>
|
||||
<td>₹{{ data.loan.overduePrincipal | number: '1.2-2' }}</td>
|
||||
<td>Overdue Interest</td>
|
||||
<td>₹{{ data.loan.overdueInterest | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
<tr class="npd-summary-total">
|
||||
<td colspan="3">TOTAL DUES OUTSTANDING</td>
|
||||
<td>
|
||||
<strong>₹{{ data.loan.overdueTotal | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Call to action -->
|
||||
<p class="npd-para npd-cta">{{ letter.callToAction }}</p>
|
||||
|
||||
<!-- Consequences -->
|
||||
<p class="npd-para npd-consequences">{{ letter.consequences }}</p>
|
||||
|
||||
<!-- Sign-off -->
|
||||
<p class="npd-signoff">{{ letter.signOff }}</p>
|
||||
<div class="npd-signature">
|
||||
<div>
|
||||
<div class="npd-sig-line"></div>
|
||||
<div class="npd-sig-name">
|
||||
For <strong>{{ bankName }}</strong>
|
||||
</div>
|
||||
<div class="npd-sig-role">Authorised Signatory — Recovery & Collections</div>
|
||||
<div class="npd-sig-role mono">{{ data.loan.branch }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="npd-footer">
|
||||
<span
|
||||
>Notice generated by <strong>{{ data.notice.generatedBy }}</strong> on {{ data.notice.generatedAt }}.</span
|
||||
>
|
||||
<span class="mono">{{ data.notice.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+501
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--npd-blue: #1d4ed8;
|
||||
--npd-blue-d: #1e40af;
|
||||
--npd-blue-l: #eff6ff;
|
||||
--npd-blue-tint: #dbeafe;
|
||||
--npd-amber: #d97706;
|
||||
--npd-amber-l: #fef3c7;
|
||||
--npd-red: #b91c1c;
|
||||
--npd-red-l: #fee2e2;
|
||||
--npd-red-tint: #fecaca;
|
||||
--npd-purple: #6d28d9;
|
||||
--npd-purple-l: #ede9fe;
|
||||
--npd-g50: #f8fafc;
|
||||
--npd-g100: #f1f5f9;
|
||||
--npd-g200: #e2e8f0;
|
||||
--npd-g300: #cbd5e1;
|
||||
--npd-g500: #64748b;
|
||||
--npd-g700: #334155;
|
||||
--npd-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
.npd-root {
|
||||
background: var(--npd-g100);
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ───────── Toolbar (hidden on print) ───────── */
|
||||
.npd-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--npd-g200);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.npd-toolbar-l {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.npd-toolbar-ic {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: var(--npd-blue-d);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.npd-toolbar-t {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: var(--npd-g900);
|
||||
}
|
||||
|
||||
.npd-toolbar-s {
|
||||
font-size: 11.5px;
|
||||
color: var(--npd-g500);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.npd-toolbar-r {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.npd-tb-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid var(--npd-g200);
|
||||
background: #fff;
|
||||
color: var(--npd-g700);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
|
||||
fa-icon {
|
||||
font-size: 12px;
|
||||
color: var(--npd-blue);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--npd-blue-l);
|
||||
border-color: var(--npd-blue-tint);
|
||||
color: var(--npd-blue-d);
|
||||
}
|
||||
|
||||
&.npd-tb-x {
|
||||
width: 34px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--npd-red-l);
|
||||
color: var(--npd-red);
|
||||
border-color: var(--npd-red-tint);
|
||||
|
||||
fa-icon {
|
||||
color: var(--npd-red);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Letter page (A4 feel) ───────── */
|
||||
.npd-page {
|
||||
background: #fff;
|
||||
border: 1px solid var(--npd-g200);
|
||||
padding: 38px 48px 32px;
|
||||
margin: 18px auto;
|
||||
width: min(720px, 94%);
|
||||
border-top: 6px solid var(--npd-blue-d);
|
||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||
color: var(--npd-g900);
|
||||
line-height: 1.6;
|
||||
overflow: auto;
|
||||
|
||||
&.tone-firm {
|
||||
border-top-color: var(--npd-amber);
|
||||
}
|
||||
|
||||
&.tone-urgent {
|
||||
border-top-color: var(--npd-red);
|
||||
}
|
||||
|
||||
&.tone-legal {
|
||||
border-top-color: var(--npd-purple);
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Letterhead ───────── */
|
||||
.npd-letterhead {
|
||||
text-align: center;
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 18px;
|
||||
border-bottom: 1px solid var(--npd-g200);
|
||||
}
|
||||
|
||||
.npd-bank-name {
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--npd-blue-d);
|
||||
|
||||
.tone-firm & {
|
||||
color: var(--npd-amber);
|
||||
}
|
||||
|
||||
.tone-urgent & {
|
||||
color: var(--npd-red);
|
||||
}
|
||||
|
||||
.tone-legal & {
|
||||
color: var(--npd-purple);
|
||||
}
|
||||
}
|
||||
|
||||
.npd-bank-sub {
|
||||
font-size: 11.5px;
|
||||
color: var(--npd-g500);
|
||||
margin-top: 2px;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
/* ───────── Meta row ───────── */
|
||||
.npd-meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
font-size: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px dashed var(--npd-g200);
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.npd-meta-l {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--npd-g500);
|
||||
}
|
||||
|
||||
.npd-meta-v {
|
||||
font-size: 12.5px;
|
||||
color: var(--npd-g900);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.npd-stage-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
|
||||
&--polite {
|
||||
background: var(--npd-blue-l);
|
||||
color: var(--npd-blue-d);
|
||||
}
|
||||
|
||||
&--firm {
|
||||
background: var(--npd-amber-l);
|
||||
color: var(--npd-amber);
|
||||
}
|
||||
|
||||
&--urgent {
|
||||
background: var(--npd-red-l);
|
||||
color: var(--npd-red);
|
||||
}
|
||||
|
||||
&--legal {
|
||||
background: var(--npd-purple-l);
|
||||
color: var(--npd-purple);
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Addressee + subject ───────── */
|
||||
.npd-block {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.npd-block-l {
|
||||
font-weight: 700;
|
||||
color: var(--npd-g700);
|
||||
width: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.npd-block-v {
|
||||
flex: 1;
|
||||
color: var(--npd-g900);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.npd-subject {
|
||||
margin: 14px 0;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--npd-g200);
|
||||
border-bottom: 1px solid var(--npd-g200);
|
||||
font-size: 13px;
|
||||
|
||||
.npd-subject-l {
|
||||
font-weight: 800;
|
||||
color: var(--npd-g700);
|
||||
margin-right: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.npd-subject-v {
|
||||
font-weight: 700;
|
||||
color: var(--npd-g900);
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Body paragraphs ───────── */
|
||||
.npd-salutation {
|
||||
font-size: 13.5px;
|
||||
margin: 16px 0 8px;
|
||||
font-weight: 700;
|
||||
color: var(--npd-g900);
|
||||
}
|
||||
|
||||
.npd-para {
|
||||
margin: 10px 0;
|
||||
font-size: 13px;
|
||||
text-align: justify;
|
||||
color: var(--npd-g900);
|
||||
}
|
||||
|
||||
.npd-cta {
|
||||
background: var(--npd-blue-l);
|
||||
border-left: 4px solid var(--npd-blue-d);
|
||||
padding: 10px 14px;
|
||||
font-weight: 600;
|
||||
border-radius: 0 6px 6px 0;
|
||||
|
||||
.tone-firm & {
|
||||
background: var(--npd-amber-l);
|
||||
border-left-color: var(--npd-amber);
|
||||
}
|
||||
|
||||
.tone-urgent & {
|
||||
background: var(--npd-red-l);
|
||||
border-left-color: var(--npd-red);
|
||||
}
|
||||
|
||||
.tone-legal & {
|
||||
background: var(--npd-purple-l);
|
||||
border-left-color: var(--npd-purple);
|
||||
}
|
||||
}
|
||||
|
||||
.npd-consequences {
|
||||
font-size: 12px;
|
||||
color: var(--npd-g700);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ───────── Summary table ───────── */
|
||||
.npd-summary {
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
border-collapse: collapse;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
font-size: 12px;
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--npd-g200);
|
||||
vertical-align: top;
|
||||
color: var(--npd-g900);
|
||||
}
|
||||
|
||||
td:nth-child(odd) {
|
||||
background: var(--npd-g50);
|
||||
color: var(--npd-g500);
|
||||
font-weight: 700;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.npd-summary-total td {
|
||||
background: var(--npd-blue-l);
|
||||
color: var(--npd-blue-d);
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.tone-urgent &,
|
||||
.tone-legal & {
|
||||
.npd-summary-total td {
|
||||
background: var(--npd-red-l);
|
||||
color: var(--npd-red);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Sign-off ───────── */
|
||||
.npd-signoff {
|
||||
margin-top: 24px;
|
||||
font-size: 13px;
|
||||
color: var(--npd-g900);
|
||||
}
|
||||
|
||||
.npd-signature {
|
||||
display: flex;
|
||||
margin-top: 38px;
|
||||
}
|
||||
|
||||
.npd-sig-line {
|
||||
width: 220px;
|
||||
border-top: 1px solid var(--npd-g500);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.npd-sig-name {
|
||||
font-size: 12.5px;
|
||||
color: var(--npd-g900);
|
||||
}
|
||||
|
||||
.npd-sig-role {
|
||||
font-size: 11px;
|
||||
color: var(--npd-g500);
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.npd-footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--npd-g200);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
font-size: 10.5px;
|
||||
color: var(--npd-g500);
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* ───────── Print: show only the letter, no toolbar, no dialog chrome ───────── */
|
||||
@media print {
|
||||
:host {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.npd-root {
|
||||
background: #fff;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.npd-page {
|
||||
border: 0;
|
||||
border-top: 4px solid var(--npd-blue-d);
|
||||
margin: 0;
|
||||
padding: 24px 32px;
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import { downloadNoticePdf, NoticeEvent, NoticeStage, OverdueLoan } from '../notice.component';
|
||||
|
||||
export interface NoticePreviewDialogData {
|
||||
loan: OverdueLoan;
|
||||
notice: NoticeEvent;
|
||||
}
|
||||
|
||||
interface LetterCopy {
|
||||
subject: string;
|
||||
salutation: string;
|
||||
intro: string;
|
||||
body: string[];
|
||||
callToAction: string;
|
||||
consequences: string;
|
||||
signOff: string;
|
||||
tone: 'polite' | 'firm' | 'urgent' | 'legal';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-notice-preview-dialog',
|
||||
templateUrl: './notice-preview-dialog.component.html',
|
||||
styleUrls: ['./notice-preview-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class NoticePreviewDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<NoticePreviewDialogComponent>);
|
||||
data: NoticePreviewDialogData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
readonly bankName = 'Nexora Co-operative Bank Ltd.';
|
||||
readonly bankAddress = 'Head Office, 4th Floor, Nexora Tower, Mumbai 400051';
|
||||
readonly bankRegNo = 'RBI Lic. No. NX-2018/MH/482';
|
||||
readonly bankContact = 'recovery@nexorabank.in · 022 6789 0000';
|
||||
|
||||
/** The letter body adapts to the notice stage — polite reminder up to legal demand. */
|
||||
get letter(): LetterCopy {
|
||||
return buildLetterCopy(this.data.loan, this.data.notice);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
printNotice(): void {
|
||||
window.print();
|
||||
}
|
||||
|
||||
/** Builds and saves the notice as a PDF using the shared jsPDF generator. */
|
||||
downloadPdf(): void {
|
||||
downloadNoticePdf(this.data.loan, this.data.notice);
|
||||
}
|
||||
|
||||
stageLabel = stageLabel;
|
||||
}
|
||||
|
||||
function stageLabel(s: NoticeStage): string {
|
||||
return s === 'FIRST'
|
||||
? '1st Reminder Notice'
|
||||
: s === 'SECOND'
|
||||
? '2nd Reminder Notice'
|
||||
: s === 'FINAL'
|
||||
? 'Final Demand Notice'
|
||||
: 'Legal Notice (Pre-Litigation)';
|
||||
}
|
||||
|
||||
function buildLetterCopy(loan: OverdueLoan, notice: NoticeEvent): LetterCopy {
|
||||
const overdue = `Rs. ${loan.overdueTotal.toLocaleString('en-IN')}`;
|
||||
const lastPaid = loan.lastPaidDate;
|
||||
|
||||
switch (notice.stage) {
|
||||
case 'FIRST':
|
||||
return {
|
||||
tone: 'polite',
|
||||
subject: `Reminder — Overdue dues on Loan A/c ${loan.accountNo}`,
|
||||
salutation: `Dear ${loan.customerName},`,
|
||||
intro:
|
||||
'We refer to your captioned loan account with our bank. Our records indicate that your account has fallen into arrears as detailed below.',
|
||||
body: [
|
||||
`This is a gentle reminder requesting you to regularise the account at the earliest. The overdue amount of ${overdue} (as on date) has remained unpaid since your last payment on ${lastPaid}.`,
|
||||
'Maintaining a healthy repayment record helps preserve your credit score and avoids additional penal interest. If you have already made the payment in the last 24 hours, kindly disregard this notice.'
|
||||
],
|
||||
callToAction: `Action Requested: Please remit the total dues of ${overdue} within 7 days from the date of this notice through any of our accepted channels — branch deposit, NEFT/RTGS, IMPS, or UPI.`,
|
||||
consequences:
|
||||
'Failure to clear the dues may attract additional penal interest charges in accordance with the sanction terms and may lead to escalation to the next stage of recovery action.',
|
||||
signOff: 'Yours sincerely,'
|
||||
};
|
||||
|
||||
case 'SECOND':
|
||||
return {
|
||||
tone: 'firm',
|
||||
subject: `Second Reminder — Overdue dues on Loan A/c ${loan.accountNo}`,
|
||||
salutation: `Dear ${loan.customerName},`,
|
||||
intro:
|
||||
'Despite our earlier reminder, our records show that the dues in your captioned loan account remain unsettled.',
|
||||
body: [
|
||||
`The overdue amount of ${overdue} continues to remain outstanding. Your last payment was received on ${lastPaid}, after which no further repayment has been credited to the account.`,
|
||||
'We urge you to treat this matter with utmost urgency and clear the dues immediately to avoid further action on the account.'
|
||||
],
|
||||
callToAction: `Action Required: Kindly regularise the account by paying ${overdue} within 7 days from this notice. If you have already made the payment, please share the UTR / transaction reference with our recovery team so we can update the records.`,
|
||||
consequences:
|
||||
'Continued non-payment may result in your account being classified as a Special Mention Account (SMA) per RBI guidelines, reported to credit bureaus (CIBIL/Experian/Equifax/CRIF), and escalated for further recovery action including issuance of a final demand notice.',
|
||||
signOff: 'Regards,'
|
||||
};
|
||||
|
||||
case 'FINAL':
|
||||
return {
|
||||
tone: 'urgent',
|
||||
subject: `FINAL DEMAND NOTICE — Loan A/c ${loan.accountNo}`,
|
||||
salutation: `Dear ${loan.customerName},`,
|
||||
intro:
|
||||
'This communication serves as the FINAL DEMAND NOTICE in respect of your captioned loan account, which continues to remain in default despite our prior reminders.',
|
||||
body: [
|
||||
`As on date, an amount of ${overdue} is overdue and payable. Your last payment was received on ${lastPaid}. No payments have been credited since then.`,
|
||||
'You are hereby called upon to clear the entire overdue amount along with applicable penal interest within 15 days from the date of this notice.'
|
||||
],
|
||||
callToAction: `Action Required: Payment of ${overdue} (plus applicable penal interest accrued up to the date of payment) must reach the bank within 15 days from receipt of this notice.`,
|
||||
consequences:
|
||||
'Failure to comply within the stipulated period shall constrain the bank, without further reference to you, to (i) classify your account as a Non-Performing Asset (NPA) per RBI norms, (ii) report the same to all credit information companies, and (iii) initiate appropriate legal and recovery proceedings, including action under the SARFAESI Act, 2002 / RDDB Act, 1993 where applicable, with all costs and consequences thereof being recoverable from you.',
|
||||
signOff: 'For and on behalf of the bank,'
|
||||
};
|
||||
|
||||
case 'LEGAL':
|
||||
default:
|
||||
return {
|
||||
tone: 'legal',
|
||||
subject: `LEGAL NOTICE — Loan A/c ${loan.accountNo} — Demand for Payment`,
|
||||
salutation: `To ${loan.customerName},`,
|
||||
intro:
|
||||
'Take notice that the bank, through its undersigned authorised officer, hereby serves upon you this Legal Notice in respect of the captioned loan account.',
|
||||
body: [
|
||||
`That you availed credit facilities from the bank vide Loan Account No. ${loan.accountNo}. That as on the date of this notice, a sum of ${overdue} is outstanding and payable by you to the bank, having remained unpaid since ${lastPaid}, in spite of repeated reminders and demands made by the bank.`,
|
||||
'That your continued default constitutes a breach of the sanction terms and loan agreement entered into between you and the bank, and the entire outstanding has become recallable forthwith.'
|
||||
],
|
||||
callToAction: `You are hereby called upon to pay to the bank a sum of ${overdue} together with further interest, penal charges, and costs, within 15 days from receipt of this notice, failing which the bank shall be constrained to initiate appropriate legal proceedings against you, including but not limited to proceedings under the SARFAESI Act, 2002, the Negotiable Instruments Act, 1881, and / or recovery proceedings before the Debts Recovery Tribunal (DRT), entirely at your risk, cost, and consequences.`,
|
||||
consequences:
|
||||
'Please treat this notice with the seriousness it deserves. The bank reserves the right to take any and all such steps as may be advised in law, without any further reference to you, in the event of your continued default. A copy of this notice is being retained for legal record.',
|
||||
signOff: 'Authorised Officer,'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="ntc-root">
|
||||
<!-- HEADER -->
|
||||
<div class="ntc-head">
|
||||
<div class="ntc-head-l">
|
||||
<fa-icon icon="file-alt" class="ntc-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="ntc-head-t">Loan Overdue Notices</h2>
|
||||
<div class="ntc-head-s">
|
||||
Generate, dispatch, and audit notices for accounts with overdue dues. Bulk operations supported.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI STRIP -->
|
||||
<div class="ntc-kpis">
|
||||
<div class="ntc-kpi ntc-kpi--blue">
|
||||
<fa-icon icon="exclamation-triangle" class="ntc-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="ntc-kpi-n">{{ totalOverdueAccounts | number }}</div>
|
||||
<div class="ntc-kpi-l">Overdue Accounts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ntc-kpi ntc-kpi--red">
|
||||
<fa-icon icon="coins" class="ntc-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="ntc-kpi-n">₹{{ totalOverdueAmount | number: '1.0-0' }}</div>
|
||||
<div class="ntc-kpi-l">Total Overdue Value</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ntc-kpi ntc-kpi--green">
|
||||
<fa-icon icon="paper-plane" class="ntc-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="ntc-kpi-n">{{ noticesGeneratedCount | number }}</div>
|
||||
<div class="ntc-kpi-l">Notices Generated</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ntc-kpi ntc-kpi--amber">
|
||||
<fa-icon icon="hourglass-half" class="ntc-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="ntc-kpi-n">{{ noticesPendingGenCount | number }}</div>
|
||||
<div class="ntc-kpi-l">Pending Generation</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FILTERS -->
|
||||
<div class="ntc-filters">
|
||||
<div class="ntc-filter">
|
||||
<label>Account number / Customer</label>
|
||||
<div class="ntc-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. LN10029384 or customer name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ntc-filter">
|
||||
<label>Account type</label>
|
||||
<select [(ngModel)]="accountTypeFilter">
|
||||
<option value="">All types</option>
|
||||
@for (o of accountTypeOptions; track o.value) {
|
||||
<option [value]="o.value">{{ o.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="ntc-filter ntc-filter--date">
|
||||
<div class="ntc-date-header">
|
||||
<label>{{ filterMode === 'range' ? 'Date range' : 'As-on date' }}</label>
|
||||
<div class="ntc-date-toggle">
|
||||
<button type="button" [class.is-on]="filterMode === 'range'" (click)="setFilterMode('range')">Range</button>
|
||||
<button type="button" [class.is-on]="filterMode === 'ason'" (click)="setFilterMode('ason')">As-on</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (filterMode === 'range') {
|
||||
<div class="ntc-date-row">
|
||||
<input type="date" [(ngModel)]="startDate" aria-label="From" title="From" />
|
||||
<input type="date" [(ngModel)]="endDate" aria-label="To" title="To" />
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ntc-date-row">
|
||||
<input type="date" [(ngModel)]="asOnDate" aria-label="As on" title="As on" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (search || accountTypeFilter || startDate || endDate || asOnDate) {
|
||||
<button type="button" class="ntc-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- BULK ACTION BAR -->
|
||||
@if (selectedCount > 0) {
|
||||
<div class="ntc-bulk-bar">
|
||||
<div class="ntc-bulk-l">
|
||||
<fa-icon icon="check-square"></fa-icon>
|
||||
<strong>{{ selectedCount }}</strong> selected
|
||||
</div>
|
||||
<div class="ntc-bulk-r">
|
||||
<button
|
||||
class="ntc-btn ntc-btn-primary"
|
||||
(click)="bulkGenerate()"
|
||||
matTooltip="Generate next-stage notice for all selected"
|
||||
>
|
||||
<fa-icon icon="file-signature"></fa-icon> Generate notices
|
||||
</button>
|
||||
<button
|
||||
class="ntc-btn ntc-btn-secondary"
|
||||
(click)="bulkSend(['EMAIL'])"
|
||||
matTooltip="Email the most-recent notice for each"
|
||||
>
|
||||
<fa-icon icon="envelope"></fa-icon> Bulk email
|
||||
</button>
|
||||
<button
|
||||
class="ntc-btn ntc-btn-secondary"
|
||||
(click)="bulkSend(['POST'])"
|
||||
matTooltip="Speed-post the most-recent notice for each"
|
||||
>
|
||||
<fa-icon icon="stamp"></fa-icon> Bulk post
|
||||
</button>
|
||||
<button
|
||||
class="ntc-btn ntc-btn-secondary"
|
||||
(click)="bulkSend(['EMAIL', 'POST'])"
|
||||
matTooltip="Send via Email + Speed Post"
|
||||
>
|
||||
<fa-icon icon="paper-plane"></fa-icon> Email + Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Shared "More actions" menu — single mat-menu reused for every row -->
|
||||
<mat-menu #rowActionsMenu="matMenu" xPosition="before" class="ntc-actions-menu">
|
||||
<ng-template matMenuContent let-loan="loan">
|
||||
<!-- Context header: shows whose row this menu belongs to. Click-trapped
|
||||
so tapping inside doesn't close the menu. -->
|
||||
<div class="nmh" (click)="$event.stopPropagation()" tabindex="-1">
|
||||
<!-- Identity row -->
|
||||
<div class="nmh-id">
|
||||
<div class="nmh-avatar" [ngClass]="'nmh-avatar--' + dpdBucket(loan.daysOverdue)">
|
||||
{{ loan.customerName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="nmh-id-text">
|
||||
<div class="nmh-name">{{ loan.customerName }}</div>
|
||||
<div class="nmh-acc mono">{{ loan.accountNo }}</div>
|
||||
</div>
|
||||
<span class="nmh-type">{{ accountTypeLabel(loan.accountType) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Secondary facts -->
|
||||
<div class="nmh-facts">
|
||||
<div class="nmh-fact">
|
||||
<span class="nmh-fact-l">Customer ID</span>
|
||||
<span class="nmh-fact-v mono">{{ loan.customerId }}</span>
|
||||
</div>
|
||||
<div class="nmh-fact">
|
||||
<span class="nmh-fact-l">Branch</span>
|
||||
<span class="nmh-fact-v">{{ loan.branch }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero strip: Overdue + DPD -->
|
||||
<div class="nmh-hero" [ngClass]="'nmh-hero--' + dpdBucket(loan.daysOverdue)">
|
||||
<div class="nmh-overdue">
|
||||
<span class="nmh-overdue-l">Overdue dues</span>
|
||||
<span class="nmh-overdue-v">₹{{ loan.overdueTotal | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="nmh-dpd">
|
||||
<span class="nmh-dpd-v">{{ loan.daysOverdue }}</span>
|
||||
<span class="nmh-dpd-l">days past due</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action grid — 2 columns, grouped into 3 sections -->
|
||||
<div class="nma-section">
|
||||
<div class="nma-section-l">Notice document</div>
|
||||
<div class="nma-grid">
|
||||
<button type="button" class="nma-tile" [disabled]="!latestNotice(loan)" (click)="viewLatestNotice(loan)">
|
||||
<fa-icon icon="eye" class="nma-tile-ic"></fa-icon>
|
||||
<span class="nma-tile-l">View / Print</span>
|
||||
</button>
|
||||
<button type="button" class="nma-tile" [disabled]="!latestNotice(loan)" (click)="downloadLatestNotice(loan)">
|
||||
<fa-icon icon="download" class="nma-tile-ic"></fa-icon>
|
||||
<span class="nma-tile-l">Download PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nma-section">
|
||||
<div class="nma-section-l">Send to customer</div>
|
||||
<div class="nma-grid">
|
||||
<button type="button" class="nma-tile" [disabled]="!latestNotice(loan)" (click)="sendNotice(loan, 'EMAIL')">
|
||||
<fa-icon icon="envelope" class="nma-tile-ic"></fa-icon>
|
||||
<span class="nma-tile-l">Email</span>
|
||||
</button>
|
||||
<button type="button" class="nma-tile" [disabled]="!latestNotice(loan)" (click)="sendNotice(loan, 'POST')">
|
||||
<fa-icon icon="truck-fast" class="nma-tile-ic"></fa-icon>
|
||||
<span class="nma-tile-l">Speed Post</span>
|
||||
</button>
|
||||
<button type="button" class="nma-tile" [disabled]="!latestNotice(loan)" (click)="sendNotice(loan, 'SMS')">
|
||||
<fa-icon icon="mobile-alt" class="nma-tile-ic"></fa-icon>
|
||||
<span class="nma-tile-l">SMS</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="nma-tile"
|
||||
[disabled]="!latestNotice(loan)"
|
||||
(click)="sendNotice(loan, 'WHATSAPP')"
|
||||
>
|
||||
<fa-icon icon="comment-dots" class="nma-tile-ic"></fa-icon>
|
||||
<span class="nma-tile-l">WhatsApp</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nma-section">
|
||||
<div class="nma-section-l">Customer</div>
|
||||
<div class="nma-grid">
|
||||
<button type="button" class="nma-tile" (click)="callCustomer(loan)">
|
||||
<fa-icon icon="phone" class="nma-tile-ic"></fa-icon>
|
||||
<span class="nma-tile-l">Call customer</span>
|
||||
</button>
|
||||
<button type="button" class="nma-tile" (click)="copyAccountNo(loan)">
|
||||
<fa-icon icon="copy" class="nma-tile-ic"></fa-icon>
|
||||
<span class="nma-tile-l">Copy A/C No.</span>
|
||||
</button>
|
||||
<button type="button" class="nma-tile" [disabled]="loan.notices.length === 0" (click)="openHistory(loan)">
|
||||
<fa-icon icon="history" class="nma-tile-ic"></fa-icon>
|
||||
<span class="nma-tile-l">Notice history</span>
|
||||
</button>
|
||||
<button type="button" class="nma-tile" (click)="openLoanProfile(loan)">
|
||||
<fa-icon icon="external-link-alt" class="nma-tile-ic"></fa-icon>
|
||||
<span class="nma-tile-l">Open loan</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-menu>
|
||||
|
||||
<!-- TABLE -->
|
||||
<div class="ntc-card">
|
||||
<div class="ntc-table-wrap">
|
||||
<table class="ntc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-sel">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="allOnPageSelected"
|
||||
[indeterminate]="someOnPageSelected"
|
||||
(change)="toggleSelectAll($any($event.target).checked)"
|
||||
aria-label="Select all on page"
|
||||
/>
|
||||
</th>
|
||||
<th>Customer · A/C</th>
|
||||
<th>Type</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">Overdue</th>
|
||||
<th>DPD</th>
|
||||
<th>Last Paid</th>
|
||||
<th>Notice Status</th>
|
||||
<th>Last Sent</th>
|
||||
<th class="col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (loan of filteredLoans; track loan.accountNo) {
|
||||
<tr [class.is-selected]="isSelected(loan.accountNo)">
|
||||
<td class="col-sel">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isSelected(loan.accountNo)"
|
||||
(change)="toggleSelect(loan.accountNo, $any($event.target).checked)"
|
||||
[attr.aria-label]="'Select ' + loan.accountNo"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div class="ntc-cust-name">{{ loan.customerName }}</div>
|
||||
<div class="mono small muted">{{ loan.accountNo }} · {{ loan.customerId }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="ntc-pill ntc-pill--type">{{ accountTypeLabel(loan.accountType) }}</span>
|
||||
</td>
|
||||
<td class="small">{{ loan.branch }}</td>
|
||||
<td class="num">
|
||||
<div class="ntc-amount">₹{{ loan.overdueTotal | number: '1.0-0' }}</div>
|
||||
<div class="small muted">
|
||||
P ₹{{ loan.overduePrincipal | number: '1.0-0' }} · I ₹{{ loan.overdueInterest | number: '1.0-0' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="ntc-dpd-pill" [ngClass]="'ntc-dpd-pill--' + dpdBucket(loan.daysOverdue)">
|
||||
{{ loan.daysOverdue }} d
|
||||
</span>
|
||||
</td>
|
||||
<td class="small">{{ loan.lastPaidDate | date: 'd MMM y' }}</td>
|
||||
<td>
|
||||
@switch (noticeStatus(loan)) {
|
||||
@case ('NONE') {
|
||||
<span class="ntc-pill ntc-pill--amber">
|
||||
<fa-icon icon="exclamation-circle"></fa-icon> Not Generated
|
||||
</span>
|
||||
}
|
||||
@case ('GENERATED') {
|
||||
<span class="ntc-pill ntc-pill--blue">
|
||||
<fa-icon icon="file-signature"></fa-icon>
|
||||
{{ stageLabel(latestNotice(loan)!.stage) }} · Awaiting Send
|
||||
</span>
|
||||
}
|
||||
@case ('SENT') {
|
||||
<span class="ntc-pill ntc-pill--green">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
{{ stageLabel(latestNotice(loan)!.stage) }} · Sent ({{ totalSendCount(loan) }})
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td class="small">
|
||||
@if (latestNotice(loan) && latestNotice(loan)!.sends.length > 0) {
|
||||
<div class="ntc-last-send">
|
||||
<fa-icon [icon]="channelIcon(latestNotice(loan)!.sends[0].channel)"></fa-icon>
|
||||
{{ channelLabel(latestNotice(loan)!.sends[0].channel) }}
|
||||
</div>
|
||||
<div class="mono small muted">{{ latestNotice(loan)!.sends[0].sentAt }}</div>
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<div class="ntc-row-actions">
|
||||
<button
|
||||
class="ntc-icon-btn primary"
|
||||
(click)="generateNotice(loan)"
|
||||
matTooltip="Generate next-stage notice"
|
||||
>
|
||||
<fa-icon icon="file-signature"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
class="ntc-icon-btn ntc-more-btn"
|
||||
[matMenuTriggerFor]="rowActionsMenu"
|
||||
[matMenuTriggerData]="{ loan: loan }"
|
||||
matTooltip="More actions"
|
||||
aria-label="More actions"
|
||||
>
|
||||
<fa-icon icon="ellipsis-v"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (filteredLoans.length === 0) {
|
||||
<tr>
|
||||
<td colspan="10" class="ntc-empty">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
No overdue accounts match the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="spd-root">
|
||||
<!-- HEADER -->
|
||||
<div class="spd-head">
|
||||
<div class="spd-head-l">
|
||||
<fa-icon icon="truck-fast" class="spd-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="spd-head-t">Record Speed Post dispatch</h2>
|
||||
<div class="spd-head-s">
|
||||
{{ data.loan.customerName }} · <span class="mono">{{ data.loan.accountNo }}</span> · Notice
|
||||
<span class="mono">{{ data.notice.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="spd-x" (click)="cancel()" matTooltip="Cancel">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- DELIVERY ADDRESS SUMMARY -->
|
||||
<div class="spd-addr">
|
||||
<fa-icon icon="map-marker-alt" class="spd-addr-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="spd-addr-l">Dispatch address</div>
|
||||
<div class="spd-addr-v">{{ data.loan.address }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FORM -->
|
||||
<div class="spd-form">
|
||||
<div class="spd-field">
|
||||
<label> Consignment / Article No. <span class="spd-req">*</span> </label>
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="consignmentNo"
|
||||
(ngModelChange)="upperCaseConsignment($event)"
|
||||
placeholder="e.g. EM123456789IN"
|
||||
maxlength="20"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<small class="spd-hint">
|
||||
India Post Speed Post 13-char article ID, or any private courier tracking number.
|
||||
</small>
|
||||
@if (consignmentNo && !isConsignmentValid) {
|
||||
<small class="spd-err">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
Use 6–20 alphanumeric characters (e.g. EM123456789IN).
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="spd-row">
|
||||
<div class="spd-field">
|
||||
<label> Post office / Counter <span class="spd-req">*</span> </label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="postOffice"
|
||||
placeholder="e.g. New Delhi GPO"
|
||||
maxlength="80"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="spd-field">
|
||||
<label> Booking date <span class="spd-req">*</span> </label>
|
||||
<input type="date" [(ngModel)]="bookedDate" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spd-row">
|
||||
<div class="spd-field">
|
||||
<label>Postage paid (₹)</label>
|
||||
<input type="number" min="0" step="1" [(ngModel)]="postage" placeholder="e.g. 45" />
|
||||
<small class="spd-hint">Speed Post tariff payable at booking. Leave blank if unknown.</small>
|
||||
</div>
|
||||
<div class="spd-field spd-field--empty"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ACTIONS -->
|
||||
<div class="spd-actions">
|
||||
<button type="button" class="spd-btn" (click)="cancel()">
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
<button type="button" class="spd-btn spd-btn--primary" [disabled]="!isFormValid" (click)="confirm()">
|
||||
<fa-icon icon="truck-fast"></fa-icon>
|
||||
<span>Confirm dispatch</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--spd-blue: #1d4ed8;
|
||||
--spd-blue-d: #1e40af;
|
||||
--spd-blue-l: #eff6ff;
|
||||
--spd-blue-tint: #dbeafe;
|
||||
--spd-amber: #d97706;
|
||||
--spd-amber-l: #fef3c7;
|
||||
--spd-red: #b91c1c;
|
||||
--spd-red-l: #fee2e2;
|
||||
--spd-red-tint: #fecaca;
|
||||
--spd-green: #15803d;
|
||||
--spd-green-l: #dcfce7;
|
||||
--spd-orange: #c2410c;
|
||||
--spd-orange-l: #ffedd5;
|
||||
--spd-g50: #f8fafc;
|
||||
--spd-g100: #f1f5f9;
|
||||
--spd-g200: #e2e8f0;
|
||||
--spd-g300: #cbd5e1;
|
||||
--spd-g500: #64748b;
|
||||
--spd-g700: #334155;
|
||||
--spd-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
.spd-root {
|
||||
background: #fff;
|
||||
padding: 16px 20px 18px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ───────── Header ───────── */
|
||||
.spd-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--spd-g200);
|
||||
}
|
||||
|
||||
.spd-head-l {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.spd-head-ic {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: var(--spd-orange);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.spd-head-t {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--spd-g900);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.spd-head-s {
|
||||
margin-top: 3px;
|
||||
font-size: 11.5px;
|
||||
color: var(--spd-g500);
|
||||
}
|
||||
|
||||
.spd-x {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--spd-g200);
|
||||
background: #fff;
|
||||
color: var(--spd-g500);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--spd-red-l);
|
||||
color: var(--spd-red);
|
||||
border-color: var(--spd-red-tint);
|
||||
}
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* ───────── Delivery address card ───────── */
|
||||
.spd-addr {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--spd-orange-l);
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.spd-addr-ic {
|
||||
color: var(--spd-orange);
|
||||
font-size: 14px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spd-addr-l {
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--spd-g500);
|
||||
}
|
||||
|
||||
.spd-addr-v {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--spd-g900);
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ───────── Form ───────── */
|
||||
.spd-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.spd-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.spd-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
|
||||
label {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--spd-g500);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
border: 1px solid var(--spd-g200);
|
||||
border-radius: 8px;
|
||||
padding: 0 11px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: var(--spd-g900);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--spd-blue);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--spd-g300);
|
||||
}
|
||||
}
|
||||
|
||||
&--empty {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.spd-req {
|
||||
color: var(--spd-red);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.spd-hint {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--spd-g500);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.spd-err {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--spd-red);
|
||||
|
||||
fa-icon {
|
||||
font-size: 10.5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Footer actions ───────── */
|
||||
.spd-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--spd-g200);
|
||||
}
|
||||
|
||||
.spd-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 38px;
|
||||
padding: 0 16px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--spd-g200);
|
||||
background: #fff;
|
||||
color: var(--spd-g700);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
|
||||
fa-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--spd-g50);
|
||||
border-color: var(--spd-g300);
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--spd-orange);
|
||||
border-color: var(--spd-orange);
|
||||
color: #fff;
|
||||
|
||||
fa-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #9a3412;
|
||||
border-color: #9a3412;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import { NoticeEvent, OverdueLoan } from '../notice.component';
|
||||
|
||||
export interface SpeedPostDialogData {
|
||||
loan: OverdueLoan;
|
||||
notice: NoticeEvent;
|
||||
}
|
||||
|
||||
export interface SpeedPostDialogResult {
|
||||
confirmed: boolean;
|
||||
consignmentNo?: string;
|
||||
postOffice?: string;
|
||||
bookedDate?: string;
|
||||
postage?: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-speed-post-dialog',
|
||||
templateUrl: './speed-post-dialog.component.html',
|
||||
styleUrls: ['./speed-post-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class SpeedPostDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<SpeedPostDialogComponent, SpeedPostDialogResult>);
|
||||
data: SpeedPostDialogData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
consignmentNo = '';
|
||||
postOffice = '';
|
||||
bookedDate = todayIso();
|
||||
postage: number | null = 45;
|
||||
|
||||
/** Suggested India-post Speed Post 13-char article ID format: 2 letters + 9 digits + 2 letters
|
||||
(e.g. "EM123456789IN"). Loose pattern — operator can paste any tracking ID. */
|
||||
readonly consignmentPattern = /^[A-Z]{2}\d{9}[A-Z]{2}$|^[A-Za-z0-9]{6,20}$/;
|
||||
|
||||
get isConsignmentValid(): boolean {
|
||||
const c = (this.consignmentNo || '').trim();
|
||||
return c.length >= 6 && this.consignmentPattern.test(c);
|
||||
}
|
||||
|
||||
get isFormValid(): boolean {
|
||||
return this.isConsignmentValid && (this.postOffice || '').trim().length >= 2 && !!this.bookedDate;
|
||||
}
|
||||
|
||||
/** Force the consignment number to uppercase for the India-Post-style ID. */
|
||||
upperCaseConsignment(value: string): void {
|
||||
this.consignmentNo = (value || '').toUpperCase();
|
||||
}
|
||||
|
||||
confirm(): void {
|
||||
if (!this.isFormValid) return;
|
||||
this.dialogRef.close({
|
||||
confirmed: true,
|
||||
consignmentNo: this.consignmentNo.trim(),
|
||||
postOffice: this.postOffice.trim(),
|
||||
bookedDate: this.bookedDate,
|
||||
postage: this.postage != null ? Number(this.postage) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close({ confirmed: false });
|
||||
}
|
||||
}
|
||||
|
||||
function todayIso(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
Reference in New Issue
Block a user