feat: KYC re-verify UX, family/nominee tab gating, savings dialogs, cfcfrms shell

- KYC tab: fix Verify (PAN) and Send OTP (Aadhaar) being disabled in edit mode;
  unmask Aadhaar input and show on-file masked value as hint; resolve kycId
  from id/kycId/resourceId fallbacks.
- Client stepper/view: add familyAllowed / nomineeAllowed predicates +
  CanActivate route guards. Family tab hidden for non-individuals, Nominee
  tab hidden for non-proprietor entities. Family-members step accepts a
  nomineeOnly input for proprietorship clients. Add/Edit/Delete buttons
  inside family-members and nominee tabs gated by canAdd.
- cfcfrms: add cfcfrms-shell wrapper, restyle complaint/fraud/mule/escalate
  pages and dashboard.
- Savings: identity-verify and transaction-confirm dialogs, manage / activate
  / add-charge flows, account preview styling.
- Tasks, core shell, account-nominees, icons: minor style and behaviour
  updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-21 18:49:43 +05:30
co-authored by Claude Opus 4.7
parent bf3e26a0d0
commit b149fef72e
99 changed files with 8575 additions and 2574 deletions
@@ -6,25 +6,22 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="tab-container mat-typography">
<div class="layout-row align-start">
<div class="m-b-10">
<h3>{{ 'labels.inputs.Charges' | translate }}</h3>
</div>
@if (this.chargesData) {
<div class="action-button m-b-20">
<button mat-raised-button color="primary" (click)="toggleCharges()">
<div class="chg-tab">
<div class="chg-tab__card">
<div class="chg-tab__toolbar">
<h3 class="chg-tab__title">{{ 'labels.inputs.Charges' | translate }}</h3>
<span class="chg-tab__spacer"></span>
@if (this.chargesData) {
<button mat-flat-button class="chg-tab__toggle" (click)="toggleCharges()">
{{
showInactiveCharges
? viewAllChargeButtons('View Active Charges')
: viewAllChargeButtons('View Inactive Charges')
}}
</button>
</div>
}
</div>
}
</div>
<div class="mat-elevation-z1 m-b-25">
<table mat-table #chargesTable [dataSource]="dataSource">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Name' | translate }}</th>
@@ -6,30 +6,107 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.tab-container {
padding: 1%;
margin: 1%;
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$blue: #1e40af;
h3 {
margin: 1% auto;
.chg-tab {
width: 100%;
}
.chg-tab__card {
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
}
.chg-tab__toolbar {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 18px;
background: $soft;
border-bottom: 1px solid $border;
}
.chg-tab__title {
margin: 0;
font-size: 14px;
font-weight: 700;
color: $navy;
letter-spacing: 0.01em;
}
.chg-tab__spacer {
flex: 1;
}
.chg-tab__toggle {
height: 34px !important;
border-radius: 8px !important;
font-weight: 600 !important;
font-size: 13px !important;
padding: 0 14px !important;
background: linear-gradient(135deg, #1e88e5 0%, $blue 100%) !important;
color: #fff !important;
}
table {
width: 100%;
}
::ng-deep .chg-tab__card {
.mat-mdc-header-row {
background: $soft;
}
.action-button {
margin-left: auto;
.mat-mdc-header-cell {
font-size: 11.5px;
font-weight: 800;
color: $slate;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 12px 14px;
border-bottom: 1px solid $border;
}
table {
width: 100%;
.mat-mdc-cell {
font-size: 13px;
color: $navy;
padding: 11px 14px;
border-bottom: 1px solid #f1f5f9;
}
.mat-mdc-row:hover {
background: #f8fafc;
cursor: pointer;
}
/* Right-align monetary columns. */
.mat-column-due,
.mat-column-paid,
.mat-column-waived,
.mat-column-outstanding {
text-align: right;
font-variant-numeric: tabular-nums;
}
.mat-column-actions {
text-align: center;
width: 140px;
.account-action-button {
min-width: 26px;
padding: 0 6px;
margin: 4px;
line-height: 25px;
}
.select-row:hover {
cursor: pointer;
min-width: 30px !important;
padding: 0 8px !important;
margin: 0 3px !important;
height: 30px !important;
line-height: 30px !important;
border-radius: 7px !important;
}
}
}
@@ -13,355 +13,378 @@
}
@if (!isLoading) {
<div class="savings-account-tables layout-row gap-2percent">
<div class="flex-49 layout-column gap-4percent">
@if (!savingsAccountData.status.rejected && !savingsAccountData.status.submittedAndPendingApproval) {
<div>
<h4 class="table-headers">{{ 'labels.heading.Savings Details' | translate }}</h4>
<table>
<tbody>
<tr>
<td>{{ 'labels.inputs.External Id' | translate }}</td>
@if (savingsAccountData.externalId) {
<td>
<mifosx-external-identifier
externalId="{{ savingsAccountData.externalId }}"
></mifosx-external-identifier>
</td>
}
@if (!savingsAccountData.externalId) {
<td>{{ 'labels.inputs.Not Provided' | translate }}</td>
}
</tr>
<tr>
<td>{{ 'labels.inputs.Activated On' | translate }}</td>
<td>
{{
savingsAccountData.timeline.activatedOnDate
? (savingsAccountData.timeline.activatedOnDate | dateFormat)
: 'Not Activated'
}}
</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Field Officer' | translate }}</td>
<td>{{ savingsAccountData.fieldOfficerName ? savingsAccountData.fieldOfficerName : 'Unassigned' }}</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Currency' | translate }}</td>
<td>{{ savingsAccountData.currency.name }} [{{ savingsAccountData.currency.code }}]</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Nominal Interest Rate' | translate }}</td>
<td class="r-amount">
@if (savingsAccountData.nominalAnnualInterestRate) {
<span>{{ savingsAccountData.nominalAnnualInterestRate | formatNumber }} %</span>
}
@if (!savingsAccountData.nominalAnnualInterestRate) {
<span>0 %</span>
}
</td>
</tr>
</tbody>
</table>
</div>
} @else {
<div>
<h4 class="table-headers">{{ 'labels.heading.Savings Details' | translate }}</h4>
<table>
<tbody>
<tr>
<td>{{ 'labels.inputs.Field Officer' | translate }}</td>
<td>{{ savingsAccountData.fieldOfficerName ? savingsAccountData.fieldOfficerName : 'Unassigned' }}</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Balance' | translate }}</td>
<td class="r-amount">
{{ savingsAccountData.summary.accountBalance | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
</tbody>
</table>
</div>
}
@if (!savingsAccountData.status.rejected && !savingsAccountData.status.submittedAndPendingApproval) {
<div>
<h4 class="table-headers">{{ 'labels.heading.Performance History' | translate }}</h4>
<table>
<tbody>
@if (savingsAccountData.summary.totalDeposits) {
<tr>
<td>{{ 'labels.inputs.Total Deposits' | translate }}</td>
<td class="r-amount">
{{ savingsAccountData.summary.totalDeposits | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
}
@if (savingsAccountData.summary.totalInterestEarned >= 0) {
<tr>
<td>{{ 'labels.inputs.Total Interest Earned' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.totalInterestEarned
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@if (isActive) {
<div class="flex-49">
<h4 class="table-headers">{{ 'labels.heading.Account Summary' | translate }}</h4>
<table>
<tbody>
@if (savingsAccountData.summary.totalWithdrawals) {
<tr>
<td>{{ 'labels.inputs.Total Withdrawls' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.totalWithdrawals | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
@if (savingsAccountData.summary.totalWithdrawalFees) {
<tr>
<td>{{ 'labels.inputs.Withdrawals Fees' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.totalWithdrawalFees | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
@if (savingsAccountData.summary.totalAnnualFees) {
<tr>
<td>{{ 'labels.inputs.Annual Fees' | translate }}</td>
<td class="r-amount">
{{ savingsAccountData.summary.totalAnnualFees | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
}
@if (savingsAccountData.summary.totalInterestEarned >= 0) {
<tr>
<td>{{ 'labels.inputs.Interest Earned' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.totalInterestEarned | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
@if (savingsAccountData.summary.totalInterestPosted) {
<tr>
<td>{{ 'labels.inputs.Interest Posted' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.totalInterestPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
@if (savingsAccountData.summary.interestNotPosted >= 0) {
<tr>
<td>{{ 'labels.inputs.Interest Earned Not Posted' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
@if (savingsAccountData.summary.totalOverdraftInterestDerived) {
<tr>
<td>{{ 'labels.inputs.Interest On Overdraft' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.totalOverdraftInterestDerived
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
@if (savingsAccountData.summary.interestNotPosted < 0) {
<tr>
<td>{{ 'labels.inputs.Overdraft Interest Not Posted' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
@if (savingsAccountData.nominalAnnualInterestRate) {
<tr>
<td>{{ 'labels.inputs.Nominal Interest Rate' | translate }}</td>
<td class="r-amount">{{ savingsAccountData.nominalAnnualInterestRate | formatNumber }} %</td>
</tr>
}
<tr>
<td>{{ 'labels.inputs.Interest Compounding Period' | translate }}</td>
<td>{{ savingsAccountData.interestCompoundingPeriodType.value | translateKey: 'catalogs' }}</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Interest Posting Period' | translate }}</td>
<td>{{ savingsAccountData.interestPostingPeriodType.value | translateKey: 'catalogs' }}</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Interest Calculated Using' | translate }}</td>
<td>{{ savingsAccountData.interestCalculationType.value | translateKey: 'catalogs' }}</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Days in Year' | translate }}</td>
<td>{{ savingsAccountData.interestCalculationDaysInYearType.value | translateKey: 'catalogs' }}</td>
</tr>
@if (savingsAccountData.withdrawalFee) {
<tr>
<td>{{ 'labels.inputs.Withdrawal Fee' | translate }}</td>
<td class="r-amount">
{{ savingsAccountData.withdrawalFee.amount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
}
@if (savingsAccountData.lastActiveTransactionDate) {
<tr>
<td>{{ 'labels.inputs.Last Active Transaction Date' | translate }}</td>
<td>{{ savingsAccountData.lastActiveTransactionDate | dateFormat }}</td>
</tr>
}
@if (!(savingsAccountData.subStatus.id === 0)) {
<tr>
<td>{{ 'labels.inputs.Substatus' | translate }}</td>
<td>{{ savingsAccountData.subStatus.value }}</td>
</tr>
}
@if (savingsAccountData.daysToInactive) {
<tr>
<td>{{ 'labels.inputs.Days to Inactive' | translate }}</td>
<td>{{ savingsAccountData.daysToInactive }}</td>
</tr>
}
@if (savingsAccountData.daysToDormancy) {
<tr>
<td>{{ 'labels.inputs.Days to Dormancy' | translate }}</td>
<td>{{ savingsAccountData.daysToDormancy }}</td>
</tr>
}
@if (savingsAccountData.daysToEscheat) {
<tr>
<td>{{ 'labels.inputs.Days to Escheat' | translate }}</td>
<td>{{ savingsAccountData.daysToEscheat }}</td>
</tr>
}
@if (savingsAccountData.annualFee) {
<tr>
<td>{{ 'labels.inputs.Annual Fee' | translate }}</td>
<td class="r-amount">
{{ savingsAccountData.annualFee.amount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
}
@if (savingsAccountData.allowOverdraft) {
<tr>
<td>{{ 'labels.inputs.Overdraft Limit' | translate }}</td>
<td class="r-amount">
{{ savingsAccountData.overdraftLimit | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
}
@if (savingsAccountData.allowOverdraft) {
<tr>
<td>{{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}</td>
<td>
{{
savingsAccountData.minOverdraftForInterestCalculation
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
@if (savingsAccountData.minBalanceForInterestCalculation) {
<tr>
<td>{{ 'labels.inputs.Min Balance Required for Interest Calculation' | translate }}</td>
<td>{{ savingsAccountData.minBalanceForInterestCalculation | formatNumber }}</td>
</tr>
}
@if (savingsAccountData.minRequiredBalance) {
<tr>
<td>{{ 'labels.inputs.Minimum Required Balance' | translate }}</td>
<td>{{ savingsAccountData.minRequiredBalance | formatNumber }}</td>
</tr>
}
@if (savingsAccountData.enforceMinRequiredBalance) {
<tr>
<td>{{ 'labels.inputs.Enforce Minimum Required Balance' | translate }}</td>
<td>{{ savingsAccountData.enforceMinRequiredBalance | yesNo }}</td>
</tr>
}
@if (savingsAccountData.summary.lastInterestCalculationDate) {
<tr>
<td>{{ 'labels.inputs.Interest Recalculation Date' | translate }}</td>
<td>{{ savingsAccountData.summary.lastInterestCalculationDate | dateFormat }}</td>
</tr>
}
@if (savingsAccountData.onHoldFunds) {
<tr>
<td>{{ 'labels.inputs.On Hold Funds' | translate }}</td>
<td>
<a *mifosxHasPermission="'READ_SAVINGSACCOUNT'">{{
savingsAccountData.onHoldFunds | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}</a>
</td>
</tr>
}
@if (savingsAccountData.withHoldTax) {
<tr>
<td>{{ 'labels.inputs.Withhold Tax Group' | translate }}</td>
<td>{{ savingsAccountData.taxGroup.name }}</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<div class="flex-49">
<h4 class="table-headers">{{ 'labels.heading.Account Summary' | translate }}</h4>
<table>
<tbody>
<tr>
<td>{{ 'labels.inputs.External Id' | translate }}</td>
@if (savingsAccountData.externalId) {
<td>
<mifosx-external-identifier
externalId="{{ savingsAccountData.externalId }}"
></mifosx-external-identifier>
</td>
}
@if (!savingsAccountData.externalId) {
<td>{{ 'labels.inputs.Not Provided' | translate }}</td>
}
</tr>
<tr>
<td>{{ 'labels.inputs.Activated On' | translate }}</td>
<td>
<div class="gt-grid">
<!-- ─── LEFT COLUMN ──────────────────────────────────────────── -->
<div class="gt-col">
<!-- Savings Details -->
<section class="nx-card">
<header class="nx-card__head">
<fa-icon icon="info-circle" class="nx-card__ico"></fa-icon>
<h3 class="nx-card__title">{{ 'labels.heading.Savings Details' | translate }}</h3>
</header>
<div class="nx-card__body">
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Account Number' | translate }}</span>
<span class="nx-row__value nx-mono">{{ savingsAccountData.accountNo }}</span>
</div>
<div class="nx-row">
<span class="nx-row__label"
>{{ 'labels.text.' + entityType | translate }} {{ 'labels.inputs.name' | translate }}</span
>
<span class="nx-row__value">
{{ savingsAccountData.clientName || savingsAccountData.groupName || '—' }}
</span>
</div>
@if (!savingsAccountData.status.rejected && !savingsAccountData.status.submittedAndPendingApproval) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Activated On' | translate }}</span>
<span class="nx-row__value">
{{
savingsAccountData.timeline.activatedOnDate
? (savingsAccountData.timeline.activatedOnDate | dateFormat)
: 'Not Activated'
}}
</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Currency' | translate }}</td>
<td>{{ savingsAccountData.currency.name }} [{{ savingsAccountData.currency.code }}]</td>
</tr>
</tbody>
</table>
</div>
}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Field Officer' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.fieldOfficerName ? savingsAccountData.fieldOfficerName : 'Unassigned' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Currency' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.currency.name }}
<span class="nx-mono">[{{ savingsAccountData.currency.code }}]</span>
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Nominal Interest Rate' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
@if (savingsAccountData.nominalAnnualInterestRate) {
{{ savingsAccountData.nominalAnnualInterestRate | formatNumber }} %
} @else {
0 %
}
</span>
</div>
} @else {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Field Officer' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.fieldOfficerName ? savingsAccountData.fieldOfficerName : 'Unassigned' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Balance' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ savingsAccountData.summary.accountBalance | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
</div>
</section>
<!-- Performance History -->
@if (!savingsAccountData.status.rejected && !savingsAccountData.status.submittedAndPendingApproval) {
<section class="nx-card">
<header class="nx-card__head">
<fa-icon icon="chart-line" class="nx-card__ico"></fa-icon>
<h3 class="nx-card__title">{{ 'labels.heading.Performance History' | translate }}</h3>
</header>
<div class="nx-card__body">
@if (savingsAccountData.summary.totalDeposits) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Total Deposits' | translate }}</span>
<span class="nx-row__value nx-row__value--amount nx-row__value--positive">
{{ savingsAccountData.summary.totalDeposits | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (savingsAccountData.summary.totalInterestEarned >= 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Total Interest Earned' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
savingsAccountData.summary.totalInterestEarned | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (!savingsAccountData.summary.totalDeposits && !(savingsAccountData.summary.totalInterestEarned >= 0)) {
<div class="nx-empty">
<fa-icon icon="inbox" class="nx-empty__ico"></fa-icon>
<span>No activity yet.</span>
</div>
}
</div>
</section>
}
</div>
<!-- ─── RIGHT COLUMN ─────────────────────────────────────────── -->
<div class="gt-col">
@if (isActive) {
<section class="nx-card">
<header class="nx-card__head">
<fa-icon icon="file-invoice" class="nx-card__ico"></fa-icon>
<h3 class="nx-card__title">{{ 'labels.heading.Account Summary' | translate }}</h3>
</header>
<div class="nx-card__body">
@if (savingsAccountData.summary.totalWithdrawals) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Total Withdrawls' | translate }}</span>
<span class="nx-row__value nx-row__value--amount nx-row__value--negative">
{{
savingsAccountData.summary.totalWithdrawals | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (savingsAccountData.summary.totalWithdrawalFees) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withdrawals Fees' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
savingsAccountData.summary.totalWithdrawalFees | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (savingsAccountData.summary.totalAnnualFees) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Annual Fees' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ savingsAccountData.summary.totalAnnualFees | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (savingsAccountData.summary.totalInterestEarned >= 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Earned' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
savingsAccountData.summary.totalInterestEarned | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (savingsAccountData.summary.totalInterestPosted) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Posted' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
savingsAccountData.summary.totalInterestPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (savingsAccountData.summary.interestNotPosted >= 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Earned Not Posted' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
savingsAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (savingsAccountData.summary.totalOverdraftInterestDerived) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest On Overdraft' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
savingsAccountData.summary.totalOverdraftInterestDerived
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (savingsAccountData.summary.interestNotPosted < 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Overdraft Interest Not Posted' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
savingsAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (savingsAccountData.nominalAnnualInterestRate) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Nominal Interest Rate' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ savingsAccountData.nominalAnnualInterestRate | formatNumber }} %
</span>
</div>
}
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Compounding Period' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.interestCompoundingPeriodType.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Posting Period' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.interestPostingPeriodType.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Calculated Using' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.interestCalculationType.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Days in Year' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.interestCalculationDaysInYearType.value | translateKey: 'catalogs' }}
</span>
</div>
@if (savingsAccountData.withdrawalFee) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withdrawal Fee' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ savingsAccountData.withdrawalFee.amount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (savingsAccountData.lastActiveTransactionDate) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Last Active Transaction Date' | translate }}</span>
<span class="nx-row__value">{{ savingsAccountData.lastActiveTransactionDate | dateFormat }}</span>
</div>
}
@if (!(savingsAccountData.subStatus.id === 0)) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Substatus' | translate }}</span>
<span class="nx-row__value">{{ savingsAccountData.subStatus.value }}</span>
</div>
}
@if (savingsAccountData.daysToInactive) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Days to Inactive' | translate }}</span>
<span class="nx-row__value">{{ savingsAccountData.daysToInactive }}</span>
</div>
}
@if (savingsAccountData.daysToDormancy) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Days to Dormancy' | translate }}</span>
<span class="nx-row__value">{{ savingsAccountData.daysToDormancy }}</span>
</div>
}
@if (savingsAccountData.daysToEscheat) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Days to Escheat' | translate }}</span>
<span class="nx-row__value">{{ savingsAccountData.daysToEscheat }}</span>
</div>
}
@if (savingsAccountData.annualFee) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Annual Fee' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ savingsAccountData.annualFee.amount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (savingsAccountData.allowOverdraft) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Overdraft Limit' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ savingsAccountData.overdraftLimit | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (savingsAccountData.allowOverdraft) {
<div class="nx-row">
<span class="nx-row__label">
{{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}
</span>
<span class="nx-row__value">
{{
savingsAccountData.minOverdraftForInterestCalculation
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (savingsAccountData.minBalanceForInterestCalculation) {
<div class="nx-row">
<span class="nx-row__label">
{{ 'labels.inputs.Min Balance Required for Interest Calculation' | translate }}
</span>
<span class="nx-row__value">
{{ savingsAccountData.minBalanceForInterestCalculation | formatNumber }}
</span>
</div>
}
@if (savingsAccountData.minRequiredBalance) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Minimum Required Balance' | translate }}</span>
<span class="nx-row__value">{{ savingsAccountData.minRequiredBalance | formatNumber }}</span>
</div>
}
@if (savingsAccountData.enforceMinRequiredBalance) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Enforce Minimum Required Balance' | translate }}</span>
<span class="nx-row__value">{{ savingsAccountData.enforceMinRequiredBalance | yesNo }}</span>
</div>
}
@if (savingsAccountData.summary.lastInterestCalculationDate) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Recalculation Date' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.summary.lastInterestCalculationDate | dateFormat }}
</span>
</div>
}
@if (savingsAccountData.onHoldFunds) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.On Hold Funds' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
<a *mifosxHasPermission="'READ_SAVINGSACCOUNT'">
{{ savingsAccountData.onHoldFunds | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</a>
</span>
</div>
}
@if (savingsAccountData.withHoldTax) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withhold Tax Group' | translate }}</span>
<span class="nx-row__value">{{ savingsAccountData.taxGroup.name }}</span>
</div>
}
</div>
</section>
} @else {
<section class="nx-card">
<header class="nx-card__head">
<fa-icon icon="file-invoice" class="nx-card__ico"></fa-icon>
<h3 class="nx-card__title">{{ 'labels.heading.Account Summary' | translate }}</h3>
</header>
<div class="nx-card__body">
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Activated On' | translate }}</span>
<span class="nx-row__value">
{{
savingsAccountData.timeline.activatedOnDate
? (savingsAccountData.timeline.activatedOnDate | dateFormat)
: 'Not Activated'
}}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Currency' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.currency.name }}
<span class="nx-mono">[{{ savingsAccountData.currency.code }}]</span>
</span>
</div>
</div>
</section>
}
</div>
</div>
}
@@ -6,9 +6,15 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
/* ─── Loader keyframes (legacy, untouched) ──────────────────────────── */
$bar: 160px;
$foo: $bar * 8;
$color: #e2e4ec;
.loader {
margin-bottom: 39px;
@@ -79,164 +85,136 @@ $color: #e2e4ec;
}
}
.general-tab-container {
padding: 16px 20px;
width: 100%;
box-sizing: border-box;
position: relative;
background-color: var(--background-color, #f8f9fa);
color: var(--text-color, #333);
transition:
background-color 0.3s ease,
color 0.3s ease;
/* ─── Two-column grid ───────────────────────────────────────────────── */
.gt-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
.page-header {
margin-bottom: 24px;
padding: 16px 0;
position: relative;
border-bottom: 1px solid var(--border-color, #e9ecef);
@media (width <= 1024px) {
grid-template-columns: 1fr;
}
}
.header-title {
font-size: 1.75rem;
font-weight: 600;
margin: 0 0 8px;
line-height: 1.2;
color: var(--primary-color, #007bff);
position: relative;
}
.gt-col {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.header-subtitle {
font-size: 0.95rem;
margin: 0;
line-height: 1.4;
color: var(--text-secondary, #6c757d);
position: relative;
}
/* ─── Nexora section card ───────────────────────────────────────────── */
.nx-card {
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
background: $soft;
border-bottom: 1px solid $border;
}
.content-wrapper {
position: relative;
width: 100%;
}
.account-info-card {
background: var(--card-background, #fff);
&__ico {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
overflow: hidden;
position: relative;
width: 100%;
max-width: 600px;
transition:
box-shadow 0.3s ease,
background-color 0.3s ease;
background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
color: #fff;
font-size: 12px;
}
.card-header {
padding: 16px 20px;
background: var(--card-header-bg, #f8f9fa);
border-bottom: 1px solid var(--border-color, #e9ecef);
position: relative;
&__title {
margin: 0;
font-size: 13.5px;
font-weight: 700;
color: $navy;
letter-spacing: 0.01em;
}
h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
line-height: 1.3;
color: var(--text-color, #333);
}
&__body {
padding: 4px 18px 10px;
}
}
/* ─── Key/Value row ─────────────────────────────────────────────────── */
.nx-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
min-height: 38px;
padding: 8px 0;
border-bottom: 1px dashed #eef2f7;
font-size: 13px;
&:last-child {
border-bottom: none;
}
&__label {
color: $slate;
font-weight: 500;
}
&__value {
color: $navy;
font-weight: 600;
text-align: right;
word-break: break-word;
&--amount {
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.card-content {
padding: 20px;
position: relative;
&--positive {
color: #047857;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 10px 0;
border-bottom: 1px solid var(--border-light, #f1f3f4);
position: relative;
min-height: 24px;
&:last-child {
border-bottom: none;
}
.label {
font-weight: 500;
color: var(--text-secondary, #6c757d);
flex: 0 0 auto;
margin-right: 16px;
white-space: nowrap;
line-height: 1.4;
}
.value {
font-weight: 600;
color: var(--text-color, #333);
text-align: right;
flex: 1 1 auto;
line-height: 1.4;
&.currency {
color: var(--success-color, #28a745);
font-size: 1.05rem;
}
}
}
&--negative {
color: #b91c1c;
}
}
}
// Dark mode styles
[data-theme='dark'] .general-tab-container,
.dark-theme .general-tab-container {
--background-color: #1a1a1a;
--text-color: #e9ecef;
--text-secondary: #adb5bd;
--primary-color: #4dabf7;
--card-background: #2d3748;
--card-header-bg: #374151;
--border-color: #4a5568;
--border-light: #2d3748;
--success-color: #68d391;
.nx-muted {
color: $muted;
font-weight: 500;
font-style: italic;
}
// Responsive adjustments for mobile
@media (width <= 768px) {
.general-tab-container {
padding: 12px 16px;
.nx-mono {
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 12.5px;
color: $muted;
margin-left: 4px;
}
.page-header {
margin-bottom: 20px;
padding: 12px 0;
/* ─── Empty state inside a card ─────────────────────────────────────── */
.nx-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 16px;
color: $muted;
.header-title {
font-size: 1.5rem;
}
&__ico {
font-size: 28px;
color: #cbd5e1;
}
.header-subtitle {
font-size: 0.9rem;
}
}
.account-info-card {
.card-header {
padding: 14px 16px;
}
.card-content {
padding: 16px;
.info-row {
padding: 8px 0;
.label {
margin-right: 12px;
}
}
}
}
span {
font-size: 13px;
font-weight: 500;
}
}
@@ -10,7 +10,6 @@ import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Currency } from 'app/shared/models/general.model';
import { CurrencyPipe } from '@angular/common';
import { ExternalIdentifierComponent } from '../../../shared/external-identifier/external-identifier.component';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
import { YesnoPipe } from '../../../pipes/yesno.pipe';
@@ -22,7 +21,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./general-tab.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
ExternalIdentifierComponent,
CurrencyPipe,
DateFormatPipe,
FormatNumberPipe,
@@ -6,239 +6,373 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<mat-card class="account-card">
<mat-card-header class="header layout-column">
<mat-card-title-group class="header-title-group">
<div class="profile-image-container">
<div>
<img
mat-card-md-image
class="profile-image"
matTooltip="{{ 'tooltips.Savings Account' | translate }}"
[src]="'assets/images/savings_account_placeholder.png'"
/>
</div>
<div class="sav-view">
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
<header class="sav-hero">
<div class="sav-hero__left">
<div class="sav-hero__avatar">
<fa-icon icon="piggy-bank"></fa-icon>
</div>
<div class="mat-typography account-card-title">
<mat-card-title class="layout-row layout-lt-md-column">
<div class="flex-60">
<h3>
@if (!savingsAccountData.subStatus.block) {
<i
class="fa fa-stop"
[ngClass]="savingsAccountData.status.code | statusLookup"
[matTooltip]="savingsAccountData.status.value"
></i>
}
@if (savingsAccountData.subStatus.block) {
<i
class="fa fa-stop"
[ngClass]="savingsAccountData.subStatus.value | statusLookup"
[matTooltip]="savingsAccountData.subStatus.value"
></i>
}
</h3>
<!-- Product Name Row -->
<span class="product-row">
<span class="m-r-5">{{ 'labels.inputs.Savings Product' | translate }} :</span>
<span class="m-r-5">
<mifosx-long-text textValue="{{ savingsAccountData.savingsProductName }}"></mifosx-long-text>
</span>
<div class="sav-hero__info">
<!-- Status pill (account status, or sub-status if blocked) -->
<div class="sav-hero__status-row">
@if (!savingsAccountData.subStatus.block) {
<span
class="sav-status-pill"
[ngClass]="savingsAccountData.status.code | statusLookup"
[matTooltip]="savingsAccountData.status.value"
>
<span class="sav-status-pill__dot"></span>
{{ savingsAccountData.status.value }}
</span>
<!-- Account Number Row -->
<span class="account-number-row">
<span class="m-r-5">{{ 'labels.inputs.Account Number' | translate }} :</span>
<span class="m-r-5">
<mifosx-account-number
accountNo="{{ savingsAccountData.accountNo }}"
display="left"
></mifosx-account-number>
</span>
} @else {
<span
class="sav-status-pill sav-status-pill--warn"
[ngClass]="savingsAccountData.subStatus.value | statusLookup"
[matTooltip]="savingsAccountData.subStatus.value"
>
<span class="sav-status-pill__dot"></span>
{{ savingsAccountData.subStatus.value }}
</span>
<!-- External ID Row -->
@if (savingsAccountData.externalId) {
<span class="external-id-row">
<span class="m-r-5">{{ 'labels.inputs.External Id' | translate }} :</span>
<span>{{ savingsAccountData.externalId }}</span>
</span>
}
<span class="account-overview">
{{ 'labels.text.' + entityType | translate }} {{ 'labels.inputs.name' | translate }}:
}
</div>
<h2 class="sav-hero__title">
<mifosx-long-text textValue="{{ savingsAccountData.savingsProductName }}"></mifosx-long-text>
</h2>
<div class="sav-hero__meta">
<span class="sav-hero__meta-item">
<span class="sav-hero__meta-label">{{ 'labels.inputs.Account Number' | translate }}</span>
<mifosx-account-number
accountNo="{{ savingsAccountData.accountNo }}"
display="left"
></mifosx-account-number>
</span>
<span class="sav-hero__meta-item">
<span class="sav-hero__meta-label">
{{ 'labels.text.' + entityType | translate }} {{ 'labels.inputs.name' | translate }}
</span>
<span class="sav-hero__meta-value">
{{ savingsAccountData.clientName || savingsAccountData.groupName }}
@if (savingsAccountData.clientAccountNo) {
<span class="m-l-10">
<mifosx-account-number accountNo="{{ savingsAccountData.clientAccountNo }}"></mifosx-account-number>
</span>
<mifosx-account-number accountNo="{{ savingsAccountData.clientAccountNo }}"></mifosx-account-number>
}
</span>
</div>
@if (!savingsAccountData.status.rejected && !savingsAccountData.status.submittedAndPendingApproval) {
<div class="flex-30">
<h3>{{ 'labels.heading.Account Overview' | translate }}</h3>
<table class="account-overview">
<tbody>
<tr>
<td>{{ 'labels.inputs.Current Balance' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.accountBalance | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Available Balance' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.availableBalance
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
</tbody>
</table>
</div>
}
<div class="flex-5">
<button
mat-icon-button
[matMenuTriggerFor]="accountMenu"
aria-label="Loan account actions"
yPosition="below"
>
<mat-icon matListIcon class="actions-menu">
<fa-icon icon="bars" size="sm"></fa-icon>
</mat-icon>
</button>
</div>
</mat-card-title>
</span>
</div>
</div>
</div>
<mat-menu #accountMenu="matMenu">
@for (item of buttonConfig.singleButtons; track item) {
<button mat-menu-item *mifosxHasPermission="item.taskPermissionName" (click)="doAction(item.name)">
<mat-icon matListIcon>
<fa-icon icon="{{ item.icon }}" size="sm"></fa-icon>
</mat-icon>
<span> {{ 'labels.menus.' + item.name | translate }} </span>
</button>
}
@if (buttonConfig.options && !savingsAccountData.subStatus.block) {
<button mat-menu-item [matMenuTriggerFor]="More">{{ 'labels.menus.More' | translate }}</button>
<mat-menu #More="matMenu">
@for (option of buttonConfig.options; track option) {
<span>
<button mat-menu-item *mifosxHasPermission="option.taskPermissionName" (click)="doAction(option.name)">
{{ 'labels.menus.' + option.name | translate }}
</button>
</span>
}
</mat-menu>
}
</mat-menu>
</mat-card-title-group>
</mat-card-header>
<mat-card-content class="content">
@if (isActive) {
<nav mat-tab-nav-bar class="navigation-tabs" [tabPanel]="tabPanel">
<a
mat-tab-link
[routerLink]="['./general']"
routerLinkActive
#general="routerLinkActive"
[active]="general.isActive"
>
{{ 'labels.inputs.General' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./transactions']"
routerLinkActive
#transactions="routerLinkActive"
[active]="transactions.isActive"
>
{{ 'labels.inputs.Transactions' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./passbook']"
routerLinkActive
#passbook="routerLinkActive"
[active]="passbook.isActive"
>
{{ 'labels.inputs.Passbook' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./charges']"
routerLinkActive
#charges="routerLinkActive"
[active]="charges.isActive"
>
{{ 'labels.inputs.Charges' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./documents']"
routerLinkActive
#savingsDocuments="routerLinkActive"
[active]="savingsDocuments.isActive"
>
{{ 'labels.inputs.Documents' | translate }}
</a>
<a
mat-tab-link
*mifosxHasPermission="'READ_SAVINGSNOTE'"
[routerLink]="['./notes']"
routerLinkActive
#notes="routerLinkActive"
[active]="notes.isActive"
>
{{ 'labels.inputs.Notes' | translate }}
</a>
<ng-container *mifosxHasPermission="'READ_SAVINGSACCOUNTCHARGE'">
@if (savingsAccountData.clientId) {
<a
mat-tab-link
[routerLink]="['./standing-instructions']"
routerLinkActive
#standingInstructions="routerLinkActive"
[active]="standingInstructions.isActive"
>
{{ 'labels.inputs.Standing Instructions' | translate }}
</a>
}
</ng-container>
<a
mat-tab-link
[routerLink]="['./nominees']"
routerLinkActive
#nominees="routerLinkActive"
[active]="nominees.isActive"
>
Nominees
</a>
@for (savingsDatatable of savingsDatatables; track savingsDatatable) {
<a
mat-tab-link
*mifosxHasPermission="'READ_' + savingsDatatable.registeredTableName"
[routerLink]="['./datatables', savingsDatatable.registeredTableName]"
routerLinkActive
#datatable="routerLinkActive"
[active]="datatable.isActive"
>
{{ savingsDatatable.registeredTableName }}
</a>
}
</nav>
<!-- Balance KPI strip (only for approved / active accounts) -->
@if (!savingsAccountData.status.rejected && !savingsAccountData.status.submittedAndPendingApproval) {
<div class="sav-hero__kpis">
<div class="sav-kpi">
<span class="sav-kpi__label">{{ 'labels.inputs.Current Balance' | translate }}</span>
<span class="sav-kpi__value">
{{ savingsAccountData.summary.accountBalance | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
<div class="sav-kpi sav-kpi--available">
<span class="sav-kpi__label">{{ 'labels.inputs.Available Balance' | translate }}</span>
<span class="sav-kpi__value">
{{ savingsAccountData.summary.availableBalance | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
</div>
}
<mat-tab-nav-panel #tabPanel>
<!-- Action menu (kebab) -->
<div class="sav-hero__actions">
<button mat-icon-button [matMenuTriggerFor]="accountMenu" aria-label="Account actions" yPosition="below">
<fa-icon icon="ellipsis-v"></fa-icon>
</button>
</div>
<!-- Primary actions menu (Nexora-styled). The grouped sections come
from `kindOf(name)` in the component — Transact, Restrict (block),
Interest, Admin, Danger. -->
<mat-menu #accountMenu="matMenu" class="sav-action-menu" xPosition="before">
<div class="sav-menu" (click)="$event.stopPropagation()">
<header class="sav-menu__head">
<fa-icon icon="bolt" class="sav-menu__head-ico"></fa-icon>
<div>
<div class="sav-menu__head-title">Account Actions</div>
<div class="sav-menu__head-sub">Manage deposits, blocks, interest &amp; more</div>
</div>
</header>
<!-- Cash movement (Deposit / Withdrawal / Hold Amount) -->
<div class="sav-menu__group">
<div class="sav-menu__group-title">Transact</div>
@for (item of buttonConfig.singleButtons; track item.name) {
@if (kindOf(item.name) === 'transact') {
<button
mat-menu-item
class="sav-menu__item sav-menu__item--transact"
*mifosxHasPermission="item.taskPermissionName"
(click)="doAction(item.name)"
>
<span class="sav-menu__ico">
<fa-icon [icon]="iconOf(item.name, item.icon)"></fa-icon>
</span>
<span class="sav-menu__label">{{ 'labels.menus.' + item.name | translate }}</span>
</button>
}
}
</div>
<!-- Restrictions (Block / Unblock variants) -->
<div class="sav-menu__group">
<div class="sav-menu__group-title">Restrict</div>
@for (item of buttonConfig.singleButtons; track item.name) {
@if (kindOf(item.name) === 'block') {
<button
mat-menu-item
class="sav-menu__item sav-menu__item--block"
*mifosxHasPermission="item.taskPermissionName"
(click)="doAction(item.name)"
>
<span class="sav-menu__ico">
<fa-icon [icon]="iconOf(item.name, item.icon)"></fa-icon>
</span>
<span class="sav-menu__label">{{ 'labels.menus.' + item.name | translate }}</span>
</button>
}
}
</div>
<!-- Interest -->
<div class="sav-menu__group">
<div class="sav-menu__group-title">Interest</div>
@for (item of buttonConfig.singleButtons; track item.name) {
@if (kindOf(item.name) === 'interest') {
<button
mat-menu-item
class="sav-menu__item sav-menu__item--interest"
*mifosxHasPermission="item.taskPermissionName"
(click)="doAction(item.name)"
>
<span class="sav-menu__ico">
<fa-icon [icon]="iconOf(item.name, item.icon)"></fa-icon>
</span>
<span class="sav-menu__label">{{ 'labels.menus.' + item.name | translate }}</span>
</button>
}
}
</div>
<!-- Admin (approval/staff/charges) — drawn from BOTH primary
buttons and the historical "More" options. -->
@if (buttonConfig.options && !savingsAccountData.subStatus.block) {
<div class="sav-menu__group">
<div class="sav-menu__group-title">Admin</div>
@for (item of buttonConfig.singleButtons; track item.name) {
@if (kindOf(item.name) === 'admin') {
<button
mat-menu-item
class="sav-menu__item sav-menu__item--admin"
*mifosxHasPermission="item.taskPermissionName"
(click)="doAction(item.name)"
>
<span class="sav-menu__ico">
<fa-icon [icon]="iconOf(item.name, item.icon)"></fa-icon>
</span>
<span class="sav-menu__label">{{ 'labels.menus.' + item.name | translate }}</span>
</button>
}
}
@for (option of buttonConfig.options; track option.name) {
@if (kindOf(option.name) === 'admin') {
<button
mat-menu-item
class="sav-menu__item sav-menu__item--admin"
*mifosxHasPermission="option.taskPermissionName"
(click)="doAction(option.name)"
>
<span class="sav-menu__ico">
<fa-icon [icon]="iconOf(option.name)"></fa-icon>
</span>
<span class="sav-menu__label">{{ 'labels.menus.' + option.name | translate }}</span>
</button>
}
}
@for (option of buttonConfig.options; track option.name) {
@if (kindOf(option.name) === 'interest') {
<button
mat-menu-item
class="sav-menu__item sav-menu__item--interest"
*mifosxHasPermission="option.taskPermissionName"
(click)="doAction(option.name)"
>
<span class="sav-menu__ico">
<fa-icon [icon]="iconOf(option.name)"></fa-icon>
</span>
<span class="sav-menu__label">{{ 'labels.menus.' + option.name | translate }}</span>
</button>
}
}
@for (option of buttonConfig.options; track option.name) {
@if (kindOf(option.name) === 'transact') {
<button
mat-menu-item
class="sav-menu__item sav-menu__item--transact"
*mifosxHasPermission="option.taskPermissionName"
(click)="doAction(option.name)"
>
<span class="sav-menu__ico">
<fa-icon [icon]="iconOf(option.name)"></fa-icon>
</span>
<span class="sav-menu__label">{{ 'labels.menus.' + option.name | translate }}</span>
</button>
}
}
</div>
}
<!-- Danger (Close / Reject / Delete) -->
@if (buttonConfig.options && !savingsAccountData.subStatus.block) {
<div class="sav-menu__group sav-menu__group--danger">
<div class="sav-menu__group-title">Danger zone</div>
@for (option of buttonConfig.options; track option.name) {
@if (kindOf(option.name) === 'danger') {
<button
mat-menu-item
class="sav-menu__item sav-menu__item--danger"
*mifosxHasPermission="option.taskPermissionName"
(click)="doAction(option.name)"
>
<span class="sav-menu__ico">
<fa-icon [icon]="iconOf(option.name)"></fa-icon>
</span>
<span class="sav-menu__label">{{ 'labels.menus.' + option.name | translate }}</span>
</button>
}
}
</div>
}
</div>
</mat-menu>
</header>
<!-- ─── Pill-style tab navigation ─────────────────────────────────── -->
@if (isActive) {
<nav mat-tab-nav-bar class="sav-tabs" [tabPanel]="tabPanel">
<a
mat-tab-link
class="sav-tab"
[routerLink]="['./general']"
routerLinkActive
#general="routerLinkActive"
[active]="general.isActive"
>
<fa-icon icon="info-circle" class="sav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.General' | translate }}</span>
</a>
<a
mat-tab-link
class="sav-tab"
[routerLink]="['./transactions']"
routerLinkActive
#transactions="routerLinkActive"
[active]="transactions.isActive"
>
<fa-icon icon="exchange-alt" class="sav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Transactions' | translate }}</span>
</a>
<a
mat-tab-link
class="sav-tab"
[routerLink]="['./passbook']"
routerLinkActive
#passbook="routerLinkActive"
[active]="passbook.isActive"
>
<fa-icon icon="book" class="sav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Passbook' | translate }}</span>
</a>
<a
mat-tab-link
class="sav-tab"
[routerLink]="['./charges']"
routerLinkActive
#charges="routerLinkActive"
[active]="charges.isActive"
>
<fa-icon icon="receipt" class="sav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Charges' | translate }}</span>
</a>
<a
mat-tab-link
class="sav-tab"
[routerLink]="['./documents']"
routerLinkActive
#savingsDocuments="routerLinkActive"
[active]="savingsDocuments.isActive"
>
<fa-icon icon="folder-open" class="sav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Documents' | translate }}</span>
</a>
<a
mat-tab-link
class="sav-tab"
*mifosxHasPermission="'READ_SAVINGSNOTE'"
[routerLink]="['./notes']"
routerLinkActive
#notes="routerLinkActive"
[active]="notes.isActive"
>
<fa-icon icon="sticky-note" class="sav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Notes' | translate }}</span>
</a>
<ng-container *mifosxHasPermission="'READ_SAVINGSACCOUNTCHARGE'">
@if (savingsAccountData.clientId) {
<a
mat-tab-link
class="sav-tab"
[routerLink]="['./standing-instructions']"
routerLinkActive
#standingInstructions="routerLinkActive"
[active]="standingInstructions.isActive"
>
<fa-icon icon="calendar-check" class="sav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Standing Instructions' | translate }}</span>
</a>
}
</ng-container>
<a
mat-tab-link
class="sav-tab"
[routerLink]="['./nominees']"
routerLinkActive
#nominees="routerLinkActive"
[active]="nominees.isActive"
>
<fa-icon icon="user-shield" class="sav-tab__ic"></fa-icon>
<span>Nominees</span>
</a>
@for (savingsDatatable of savingsDatatables; track savingsDatatable) {
<a
mat-tab-link
class="sav-tab"
*mifosxHasPermission="'READ_' + savingsDatatable.registeredTableName"
[routerLink]="['./datatables', savingsDatatable.registeredTableName]"
routerLinkActive
#datatable="routerLinkActive"
[active]="datatable.isActive"
>
<fa-icon icon="table" class="sav-tab__ic"></fa-icon>
<span>{{ savingsDatatable.registeredTableName }}</span>
</a>
}
</nav>
}
<mat-tab-nav-panel #tabPanel>
<div class="sav-body">
<router-outlet></router-outlet>
</mat-tab-nav-panel>
</mat-card-content>
</mat-card>
</div>
</mat-tab-nav-panel>
</div>
@@ -6,12 +6,397 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
mat-card-title {
display: flex;
$navy: #0d1b2a;
$blue: #1e88e5;
$blue-d: #1e40af;
$purple: #7c3aed;
$border: #e2e8f0;
$soft: #f8fafc;
.sav-view {
max-width: 1440px;
margin: 0 auto;
padding: 12px 20px 22px;
}
.product-row,
.account-number-row,
.external-id-row {
display: block;
/* ─── Gradient hero header ──────────────────────────────────────────── */
.sav-hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: stretch;
gap: 14px;
padding: 12px 18px;
border-radius: 12px;
color: #fff;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 45%, #4338ca 100%);
box-shadow: 0 6px 18px -10px rgb(15 23 42 / 35%);
overflow: hidden;
/* Subtle radial flare top-right for depth. */
&::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at top right, rgb(255 255 255 / 12%) 0%, transparent 55%);
pointer-events: none;
}
&__left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
position: relative;
z-index: 1;
}
&__avatar {
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(135deg, rgb(255 255 255 / 22%) 0%, rgb(255 255 255 / 10%) 100%);
border: 1px solid rgb(255 255 255 / 28%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 16px;
flex-shrink: 0;
box-shadow: 0 3px 10px -3px rgb(0 0 0 / 25%);
}
&__info {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
&__status-row {
display: flex;
align-items: center;
gap: 6px;
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.01em;
color: #fff;
line-height: 1.15;
::ng-deep mifosx-long-text {
color: #fff;
}
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 2px 18px;
margin-top: 1px;
}
&__meta-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: rgb(255 255 255 / 90%);
}
&__meta-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: rgb(255 255 255 / 60%);
font-weight: 600;
}
&__meta-value {
font-weight: 600;
color: #fff;
}
&__kpis {
display: flex;
gap: 8px;
align-items: stretch;
position: relative;
z-index: 1;
}
&__actions {
display: flex;
align-items: center;
position: relative;
z-index: 1;
button {
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
padding: 0 !important;
border-radius: 50% !important;
color: #fff !important;
background: rgb(255 255 255 / 10%) !important;
border: 1px solid rgb(255 255 255 / 22%) !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
line-height: 1;
transition:
background 0.15s ease,
border-color 0.15s ease,
transform 0.12s ease;
/* Center the FA icon — mat-icon-button can offset child glyphs. */
::ng-deep fa-icon {
font-size: 14px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Strip Material's ripple/state surface that paints a square. */
::ng-deep .mat-mdc-button-persistent-ripple,
::ng-deep .mat-mdc-button-touch-target {
border-radius: 50% !important;
}
}
button:hover {
background: rgb(255 255 255 / 22%) !important;
border-color: rgb(255 255 255 / 38%) !important;
}
button:active {
transform: scale(0.94);
}
}
}
/* Balance KPI tiles inside the hero */
.sav-kpi {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 130px;
padding: 7px 14px;
border-radius: 9px;
background: rgb(255 255 255 / 14%);
border: 1px solid rgb(255 255 255 / 22%);
backdrop-filter: blur(8px);
&__label {
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.07em;
font-weight: 700;
color: rgb(255 255 255 / 75%);
}
&__value {
margin-top: 2px;
font-size: 15px;
font-weight: 800;
color: #fff;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
&--available {
background: rgb(16 185 129 / 22%);
border-color: rgb(16 185 129 / 35%);
}
}
/* Status pill (Active / Pending / Closed / Blocked) */
.sav-status-pill {
display: inline-flex;
align-items: center;
gap: 5px;
height: 18px;
padding: 0 8px;
border-radius: 999px;
background: rgb(16 185 129 / 24%);
border: 1px solid rgb(16 185 129 / 40%);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #d1fae5;
&__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 0 2px rgb(16 185 129 / 30%);
}
&--warn {
background: rgb(245 158 11 / 24%);
border-color: rgb(245 158 11 / 40%);
color: #fef3c7;
.sav-status-pill__dot {
background: #f59e0b;
box-shadow: 0 0 0 3px rgb(245 158 11 / 30%);
}
}
/* Theme variants driven by `statusLookup` pipe — keeps colour coding
consistent with the rest of the app. */
&.statussubmitted,
&.statuspending {
background: rgb(245 158 11 / 24%);
border-color: rgb(245 158 11 / 40%);
color: #fef3c7;
.sav-status-pill__dot {
background: #f59e0b;
}
}
&.statusclosed,
&.statusrejected,
&.statuswithdrawn {
background: rgb(239 68 68 / 24%);
border-color: rgb(239 68 68 / 40%);
color: #fee2e2;
.sav-status-pill__dot {
background: #ef4444;
}
}
}
/* ─── Pill-style tab navigation ─────────────────────────────────────── */
.sav-tabs {
margin-top: 18px;
background: #fff;
border: 1px solid $border;
border-radius: 14px;
padding: 6px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
::ng-deep {
.mat-mdc-tab-link-container {
border-bottom: none !important;
}
.mat-mdc-tab-links {
gap: 4px;
flex-wrap: wrap;
}
.mdc-tab-indicator__content--underline,
.mat-mdc-tab-header-pagination-controls-enabled .mdc-tab-indicator__content--underline {
display: none !important;
}
}
}
.sav-tab {
display: inline-flex !important;
align-items: center;
gap: 8px;
height: 38px !important;
min-width: 0 !important;
padding: 0 16px !important;
border-radius: 10px !important;
font-family: Inter, 'DM Sans', Roboto, sans-serif !important;
font-size: 13px !important;
font-weight: 600 !important;
letter-spacing: 0.01em !important;
color: #64748b !important;
background: transparent !important;
transition: all 0.15s ease;
white-space: nowrap;
border: 1px solid transparent !important;
opacity: 1 !important;
&__ic {
font-size: 12px;
color: inherit;
}
&:hover:not(.mdc-tab--active) {
background: #f1f5f9 !important;
color: $navy !important;
}
&.mdc-tab--active,
&.mat-mdc-tab-link-active {
background: linear-gradient(135deg, $blue 0%, $blue-d 100%) !important;
color: #fff !important;
box-shadow: 0 2px 6px -1px rgb(30 64 175 / 35%);
border-color: $blue-d !important;
::ng-deep .mdc-tab__text-label,
::ng-deep .mat-mdc-tab-link-content,
::ng-deep span,
.sav-tab__ic {
color: #fff !important;
opacity: 1 !important;
}
}
}
.sav-body {
margin-top: 16px;
}
/* ─── Responsive ────────────────────────────────────────────────────── */
@media (width <= 1100px) {
.sav-hero {
grid-template-columns: 1fr;
&__kpis {
width: 100%;
flex-wrap: wrap;
}
&__actions {
position: absolute;
top: 18px;
right: 18px;
}
}
.sav-kpi {
flex: 1 1 auto;
min-width: 0;
}
}
@media (width <= 700px) {
.sav-view {
padding: 12px;
}
.sav-hero {
padding: 18px;
&__title {
font-size: 18px;
}
&__avatar {
width: 52px;
height: 52px;
font-size: 22px;
border-radius: 12px;
}
}
.sav-tab {
padding: 0 12px !important;
font-size: 12px !important;
}
}
@@ -13,6 +13,7 @@ import { MatDialog } from '@angular/material/dialog';
/** Custom Dialogs */
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-interest-dialog/calculate-interest-dialog.component';
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component';
import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
@@ -26,14 +27,6 @@ import { TranslateService } from '@ngx-translate/core';
/** Environment Configuration */
import { environment } from '../../../environments/environment';
import {
MatCard,
MatCardHeader,
MatCardTitleGroup,
MatCardMdImage,
MatCardTitle,
MatCardContent
} from '@angular/material/card';
import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, CurrencyPipe } from '@angular/common';
import { LongTextComponent } from '../../shared/long-text/long-text.component';
@@ -48,6 +41,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Savings Account View Component
*
* Nexora redesign: gradient hero card with balance KPIs + pill-style tab
* navigation. No more `mat-card` chrome — pure custom layout.
*/
@Component({
selector: 'mifosx-savings-account-view',
@@ -55,11 +51,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./savings-account-view.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCardHeader,
MatCardTitleGroup,
MatCardMdImage,
MatTooltip,
MatCardTitle,
NgClass,
LongTextComponent,
AccountNumberComponent,
@@ -97,6 +89,87 @@ export class SavingsAccountViewComponent implements OnInit {
isActive = false;
currency: Currency;
/**
* Categorisation map used by the action menu — every action name is
* bucketed into one of: 'transact' (cash movement), 'block' (restrict
* access), 'interest' (interest postings), 'admin' (approvals, charges,
* staff), 'danger' (close / reject / delete). Drives the section split
* and the colour of each icon.
*/
private readonly ACTION_KIND: Record<string, 'transact' | 'block' | 'interest' | 'admin' | 'danger'> = {
Deposit: 'transact',
Withdrawal: 'transact',
'Hold Amount': 'transact',
'Transfer Funds': 'transact',
'Interbank Transfer': 'transact',
'Block Deposit': 'block',
'Unblock Deposit': 'block',
'Block Withdrawal': 'block',
'Unblock Withdrawal': 'block',
'Block Account': 'block',
'Unblock Account': 'block',
'Calculate Interest': 'interest',
'Post Interest': 'interest',
'Post Interest As On': 'interest',
'Enable Withhold Tax': 'interest',
'Disable Withhold Tax': 'interest',
'Add Charge': 'admin',
'Apply Annual Fees': 'admin',
'Modify Application': 'admin',
'Assign Staff': 'admin',
'Unassign Staff': 'admin',
Approve: 'admin',
Activate: 'admin',
'Undo Approval': 'admin',
Close: 'danger',
Reject: 'danger',
Delete: 'danger',
'Withdrawn by Client': 'danger'
};
/** Lightweight icon fallback for actions whose buttonConfig doesn't
* carry an icon (the `options` sub-menu items historically didn't). */
private readonly ACTION_ICON: Record<string, string> = {
Deposit: 'arrow-down',
Withdrawal: 'arrow-up',
'Hold Amount': 'pause',
'Transfer Funds': 'exchange-alt',
'Interbank Transfer': 'random',
'Block Deposit': 'lock',
'Unblock Deposit': 'lock-open',
'Block Withdrawal': 'lock',
'Unblock Withdrawal': 'lock-open',
'Block Account': 'lock',
'Unblock Account': 'lock-open',
'Calculate Interest': 'calculator',
'Post Interest': 'percentage',
'Post Interest As On': 'calendar-day',
'Enable Withhold Tax': 'percentage',
'Disable Withhold Tax': 'percentage',
'Add Charge': 'plus-circle',
'Apply Annual Fees': 'receipt',
'Modify Application': 'edit',
'Assign Staff': 'user-plus',
'Unassign Staff': 'user-slash',
Approve: 'check-circle',
Activate: 'check',
'Undo Approval': 'undo',
Close: 'times-circle',
Reject: 'ban',
Delete: 'trash',
'Withdrawn by Client': 'user-slash'
};
/** Returns the colour kind for a given action name (used to tint the
* icon and hover state in the dropdown). */
kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' {
return this.ACTION_KIND[name] ?? 'admin';
}
iconOf(name: string, fallback?: string): string {
return this.ACTION_ICON[name] ?? fallback ?? 'circle';
}
/**
* Fetches savings account data from `resolve`
* @param {ActivatedRoute} route Activated Route
@@ -373,9 +446,33 @@ export class SavingsAccountViewComponent implements OnInit {
unblockSavingsAccountDialogRef.afterClosed().subscribe((response: { confirm: any }) => {
if (response.confirm) {
this.savingsService.executeSavingsAccountCommand(this.savingsAccountData.id, command, {}).subscribe(() => {
this.reload();
this.showUnblockSuccess(action);
});
}
});
}
/**
* Pops the shared success dialog with copy tuned to the unblock variant
* the cashier just performed, then reloads the savings view so the
* account substatus shows as unblocked.
*/
private showUnblockSuccess(action: string): void {
let title = 'Account Unblocked Successfully';
let message = 'Account activity has been restored.';
if (action === 'Unblock Deposit') {
title = 'Deposits Unblocked Successfully';
message = 'Incoming credits to this savings account are accepted again.';
} else if (action === 'Unblock Withdrawal') {
title = 'Withdrawals Unblocked Successfully';
message = 'Outgoing debits from this savings account are accepted again.';
}
const ref = this.dialog.open(SuccessDialogComponent, {
width: '420px',
maxWidth: '92vw',
autoFocus: false,
data: { title, message, buttonLabel: 'Done' }
});
ref.afterClosed().subscribe(() => this.reload());
}
}
@@ -6,23 +6,22 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="mat-elevation-z1 m-b-25" [hidden]="!accountWithTransactions">
<div class="layout-row align-end">
@if (checkStatus()) {
<div class="action-button m-b-20 layout-row gap-20px">
<mat-checkbox [formControl]="hideReversedParam" (click)="hideReversed()" class="accruals">
{{ 'labels.inputs.Hide Reversed' | translate }}</mat-checkbox
>
<mat-checkbox [formControl]="hideAccrualsParam" (click)="hideAccruals()" class="accruals">
{{ 'labels.inputs.Hide Accruals' | translate }}</mat-checkbox
>
<button mat-raised-button color="primary" [routerLink]="['export']">
{{ 'labels.buttons.Export' | translate }}
</button>
</div>
}
</div>
<div class="txn-tab">
<div class="txn-tab__card" [hidden]="!accountWithTransactions">
@if (checkStatus()) {
<div class="txn-tab__toolbar">
<mat-checkbox [formControl]="hideReversedParam" (click)="hideReversed()" class="txn-tab__check">
{{ 'labels.inputs.Hide Reversed' | translate }}
</mat-checkbox>
<mat-checkbox [formControl]="hideAccrualsParam" (click)="hideAccruals()" class="txn-tab__check">
{{ 'labels.inputs.Hide Accruals' | translate }}
</mat-checkbox>
<span class="txn-tab__spacer"></span>
<button mat-flat-button class="txn-tab__export" [routerLink]="['export']">
{{ 'labels.buttons.Export' | translate }}
</button>
</div>
}
<table mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="row">
@@ -59,13 +58,6 @@
</td>
</ng-container>
<ng-container matColumnDef="externalId">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.External Id' | translate }}</th>
<td mat-cell *matCellDef="let transaction" [ngClass]="savingsTransactionColor(transaction)">
<mifosx-external-identifier externalId="{{ transaction.externalId }}"></mifosx-external-identifier>
</td>
</ng-container>
<ng-container matColumnDef="transactionType">
<th mat-header-cell class="center" *matHeaderCellDef>{{ 'labels.inputs.Transaction Type' | translate }}</th>
<td
@@ -176,10 +168,11 @@
<mat-paginator [pageSize]="50" [pageSizeOptions]="[50, 100, 200]" showFirstLastButtons></mat-paginator>
</div>
<div class="alert" [hidden]="accountWithTransactions">
<div class="message">
<i class="fa fa-exclamation-circle alert-check"></i>
<div class="txn-tab__empty" [hidden]="accountWithTransactions">
<fa-icon icon="inbox" class="txn-tab__empty-ico"></fa-icon>
<div class="txn-tab__empty-title">
{{ 'labels.text.No transaction was found' | translate }}
</div>
<div class="txn-tab__empty-sub">Posted transactions will appear here.</div>
</div>
</div>
@@ -6,37 +6,153 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use 'assets/styles/helper';
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$blue: #1e40af;
.tab-container {
padding: 1%;
margin: 1%;
.txn-tab {
/* Wrapper hero (savings-account-view) already provides the page padding;
this tab sits flush so the table can use the full width. */
width: 100%;
}
h3 {
margin: 1% auto;
.txn-tab__card {
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
}
/* ─── Toolbar ─────────────────────────────────────────────────────── */
.txn-tab__toolbar {
display: flex;
align-items: center;
gap: 18px;
padding: 12px 18px;
background: $soft;
border-bottom: 1px solid $border;
flex-wrap: wrap;
}
.txn-tab__check {
font-size: 13px;
color: $slate;
font-weight: 500;
}
.txn-tab__spacer {
flex: 1;
}
.txn-tab__export {
height: 36px !important;
border-radius: 8px !important;
font-weight: 600 !important;
font-size: 13px !important;
padding: 0 16px !important;
background: linear-gradient(135deg, #1e88e5 0%, $blue 100%) !important;
color: #fff !important;
}
/* ─── Table ───────────────────────────────────────────────────────── */
table {
width: 100%;
}
::ng-deep .txn-tab__card {
.mat-mdc-header-row {
background: $soft;
}
.action-button {
margin-left: auto;
.mat-mdc-header-cell {
font-size: 11.5px;
font-weight: 800;
color: $slate;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 12px 14px;
border-bottom: 1px solid $border;
}
table {
width: 100%;
.mat-mdc-cell {
font-size: 13px;
color: $navy;
padding: 11px 14px;
border-bottom: 1px solid #f1f5f9;
}
.account-action-button {
min-width: 26px;
padding: 0 0 3px;
margin: 0 10%;
line-height: 25px;
.mat-mdc-row:hover {
background: #f8fafc;
cursor: pointer;
}
.accruals {
padding-top: 1%;
}
}
/* Tabular numerics on the money columns (Debit / Credit / Balance) +
right-align them. */
.mat-column-debit,
.mat-column-credit,
.mat-column-balance {
text-align: right;
font-variant-numeric: tabular-nums;
}
.mat-column-actions {
text-align: center;
width: 80px;
}
/* Compact paginator */
.mat-mdc-paginator {
border-top: 1px solid $border;
background: $soft;
min-height: 48px;
}
/* Row tint variants — strike-through reversed, dim accrual, blue transfer */
.strike td {
text-decoration: line-through;
color: $muted;
}
.accrual td {
color: $muted;
font-style: italic;
}
.transfer td {
color: #6d28d9;
}
}
.select-row:hover {
cursor: pointer;
font-weight: 800;
/* ─── Empty state ─────────────────────────────────────────────────── */
.txn-tab__empty {
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
padding: 56px 24px;
text-align: center;
color: $muted;
}
.txn-tab__empty-ico {
font-size: 34px;
color: #cbd5e1;
margin-bottom: 8px;
}
.txn-tab__empty-title {
font-size: 14.5px;
font-weight: 700;
color: $slate;
margin-top: 6px;
}
.txn-tab__empty-sub {
font-size: 12.5px;
color: $muted;
margin-top: 2px;
}
@@ -38,7 +38,6 @@ import { MatDialog } from '@angular/material/dialog';
import { Currency } from 'app/shared/models/general.model';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatButton, MatIconButton } from '@angular/material/button';
import { ExternalIdentifierComponent } from '../../../shared/external-identifier/external-identifier.component';
import { MatMenuTrigger, MatMenu, MatMenuItem } from '@angular/material/menu';
import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@@ -64,7 +63,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatCellDef,
MatCell,
NgClass,
ExternalIdentifierComponent,
MatIconButton,
MatMenuTrigger,
MatIcon,
@@ -102,7 +100,6 @@ export class TransactionsTabComponent implements OnInit {
'row',
'id',
'date',
'externalId',
'transactionType',
'debit',
'credit',
@@ -6,123 +6,133 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container transaction-buttons m-b-20 align-end-center gap-2percent" align="end">
@if (
!(
transactionData.interestedPostedAsOn === false &&
(transactionData.transactionType.id === 17 || transactionData.transactionType.id === 3)
)
) {
<span>
<span *mifosxHasPermission="'ADJUSTTRANSACTION_SAVINGSACCOUNT'">
@if (
transactionData.transactionType.value === 'Transfer' ||
transactionData.reversed === 'true' ||
transactionData.transactionType.id === 3 ||
transactionData.transactionType.id === 17
) {
<button mat-raised-button color="primary" [routerLink]="'edit'">
<fa-icon icon="edit" class="m-r-10"></fa-icon>{{ 'labels.buttons.Edit' | translate }}
</button>
}
<div class="txn">
<!-- ─── Compact toolbar strip ─────────────────────────────────────── -->
<div class="txn__toolbar">
<button mat-stroked-button class="txn__back" type="button" [routerLink]="['../../']" matTooltip="Back">
<fa-icon icon="arrow-left"></fa-icon>
</button>
<div class="txn__title-block">
<span class="txn__id">Transaction #{{ transactionData.id }}</span>
<span class="txn__type">{{ transactionData.transactionType.value | translateKey: 'catalogs' }}</span>
</div>
@if (transactionData.reversed) {
<span class="txn__pill txn__pill--reversed"><span class="txn__pill-dot"></span> Reversed</span>
} @else if (transactionData.transactionType.accrual) {
<span class="txn__pill txn__pill--accrual"><span class="txn__pill-dot"></span> Accrual</span>
} @else if (transactionData.transfer) {
<span class="txn__pill txn__pill--transfer"><span class="txn__pill-dot"></span> Transfer</span>
} @else {
<span class="txn__pill"><span class="txn__pill-dot"></span> Posted</span>
}
<div class="txn__amount" [class.txn__amount--debit]="isDebit()">
<span class="txn__amount-label">{{ isDebit() ? 'Debit' : 'Credit' }}</span>
<span class="txn__amount-value">
{{ isDebit() ? '−' : '+' }}
{{ transactionData.amount | currency: transactionData.currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</span>
}
@if (!transactionData.reversed && transactionData.transactionType.amountHold) {
<span>
<button
mat-raised-button
color="primary"
*mifosxHasPermission="'RELEASEAMOUNT_SAVINGSACCOUNT'"
(click)="releaseAmount()"
>
<fa-icon icon="lock-open" class="m-r-10"></fa-icon>{{ 'labels.buttons.Release Amount' | translate }}
</button>
</span>
}
@if (allowUndo()) {
<span>
<button
mat-raised-button
color="warn"
*mifosxHasPermission="'UNDOTRANSACTION_SAVINGSACCOUNT'"
(click)="undoTransaction()"
>
<fa-icon icon="undo" class="m-r-10"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
</button>
</span>
}
</div>
</div>
<div class="container">
<mat-card>
<mat-card-content>
<div class="layout-row-wrap card-content">
<div class="flex-100" [ngClass]="transactionColor()"></div>
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.Id' | translate }}
</div>
<div class="flex-50">
{{ transactionData.id }}
</div>
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.Transaction Type' | translate }}
</div>
<div class="flex-50">
{{ transactionData.transactionType.value | translateKey: 'catalogs' }}
</div>
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.Transaction Date' | translate }}
</div>
<div class="flex-50">
{{ transactionData.date | dateFormat }}
</div>
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.Currency' | translate }}
</div>
<div class="flex-50">
{{ transactionData.currency.displayLabel }}
</div>
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.Amount' | translate }}
</div>
<div class="flex-50">
{{ transactionData.amount | currency: transactionData.currency.code : 'symbol-narrow' : '1.2-2' }}
</div>
@if (transactionData.note) {
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.Note' | translate }}
</div>
}
@if (transactionData.note) {
<div class="flex-50">
{{ transactionData.note }}
</div>
}
@if (transactionData.paymentDetailData) {
<mifosx-transaction-payment-detail [paymentDetailData]="transactionData.paymentDetailData">
</mifosx-transaction-payment-detail>
}
</div>
<div class="layout-row layout-align-center gap-2percent column-on-mobile">
<button type="button" mat-raised-button color="primary" [routerLink]="['../../']">
{{ 'labels.buttons.Back' | translate }}
<div class="txn__actions">
@if (
!(
transactionData.interestedPostedAsOn === false &&
(transactionData.transactionType.id === 17 || transactionData.transactionType.id === 3)
)
) {
<span *mifosxHasPermission="'ADJUSTTRANSACTION_SAVINGSACCOUNT'">
@if (
transactionData.transactionType.value === 'Transfer' ||
transactionData.reversed === 'true' ||
transactionData.transactionType.id === 3 ||
transactionData.transactionType.id === 17
) {
<button mat-stroked-button class="txn__btn" [routerLink]="'edit'">
<fa-icon icon="edit" class="m-r-6"></fa-icon>{{ 'labels.buttons.Edit' | translate }}
</button>
}
</span>
}
@if (!transactionData.reversed && transactionData.transactionType.amountHold) {
<button
mat-stroked-button
class="txn__btn"
*mifosxHasPermission="'RELEASEAMOUNT_SAVINGSACCOUNT'"
(click)="releaseAmount()"
>
<fa-icon icon="lock-open" class="m-r-6"></fa-icon>{{ 'labels.buttons.Release Amount' | translate }}
</button>
}
@if (allowUndo()) {
<button
mat-flat-button
class="txn__btn txn__btn--undo"
*mifosxHasPermission="'UNDOTRANSACTION_SAVINGSACCOUNT'"
(click)="undoTransaction()"
>
<fa-icon icon="undo" class="m-r-6"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
</button>
}
</div>
</div>
<!-- ─── Details panel ─────────────────────────────────────────────── -->
<section class="txn__panel">
<div class="txn__panel-grid">
<div class="txn__cell">
<span class="txn__cell-label">{{ 'labels.inputs.Id' | translate }}</span>
<span class="txn__cell-value txn__cell-value--mono">{{ transactionData.id }}</span>
</div>
</mat-card-content>
</mat-card>
@if (accountNo) {
<div class="txn__cell">
<span class="txn__cell-label">Account Number</span>
<span class="txn__cell-value txn__cell-value--mono">{{ accountNo }}</span>
</div>
}
@if (clientName) {
<div class="txn__cell">
<span class="txn__cell-label">Client Name</span>
<span class="txn__cell-value">{{ clientName }}</span>
</div>
}
<div class="txn__cell">
<span class="txn__cell-label">{{ 'labels.inputs.Transaction Type' | translate }}</span>
<span class="txn__cell-value">{{ transactionData.transactionType.value | translateKey: 'catalogs' }}</span>
</div>
<div class="txn__cell">
<span class="txn__cell-label">{{ 'labels.inputs.Transaction Date' | translate }}</span>
<span class="txn__cell-value">{{ transactionData.date | dateFormat }}</span>
</div>
<div class="txn__cell">
<span class="txn__cell-label">{{ 'labels.inputs.Currency' | translate }}</span>
<span class="txn__cell-value">{{ transactionData.currency.displayLabel }}</span>
</div>
<div class="txn__cell">
<span class="txn__cell-label">{{ 'labels.inputs.Amount' | translate }}</span>
<span
class="txn__cell-value txn__cell-value--amount"
[class.txn__cell-value--negative]="isDebit()"
[class.txn__cell-value--positive]="!isDebit()"
>
{{ transactionData.amount | currency: transactionData.currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
@if (transactionData.note) {
<div class="txn__cell txn__cell--wide">
<span class="txn__cell-label">{{ 'labels.inputs.Note' | translate }}</span>
<span class="txn__cell-value">{{ transactionData.note }}</span>
</div>
}
</div>
@if (transactionData.paymentDetailData) {
<div class="txn__payment-detail">
<mifosx-transaction-payment-detail [paymentDetailData]="transactionData.paymentDetailData">
</mifosx-transaction-payment-detail>
</div>
}
</section>
</div>
@@ -6,40 +6,303 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use 'assets/styles/helper';
@use 'assets/styles/colours' as *;
/* Compact, banking-grade transaction view: single toolbar strip on top,
single wide grid panel below. Designed to fit on one screen on a normal
desktop without scrolling, no big gradient hero, no centered narrow
column — the layout uses full available width. */
.container {
max-width: 37rem;
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$blue: #1e40af;
$green: #047857;
$red: #b91c1c;
$amber: #b45309;
$purple: #6d28d9;
.card-content {
div {
margin: 1rem 0;
word-wrap: break-word;
.txn {
width: 100%;
padding: 12px 16px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* ─── Toolbar (back · title · status · amount · actions) ──────────── */
.txn__toolbar {
display: flex;
align-items: center;
gap: 14px;
background: #fff;
border: 1px solid $border;
border-radius: 12px;
padding: 10px 14px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
flex-wrap: wrap;
}
.txn__back {
width: 36px;
height: 36px;
min-width: 36px !important;
padding: 0 !important;
border-radius: 8px !important;
color: $slate !important;
border-color: $border !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
}
.txn__title-block {
display: flex;
flex-direction: column;
min-width: 0;
flex: 0 1 auto;
}
.txn__id {
font-size: 15px;
font-weight: 800;
color: $navy;
line-height: 1.1;
letter-spacing: 0.01em;
}
.txn__type {
margin-top: 2px;
font-size: 11px;
color: $muted;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 700;
}
.txn__pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 22px;
padding: 0 9px;
border-radius: 999px;
background: #ecfdf5;
color: $green;
border: 1px solid #a7f3d0;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
&-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: $green;
}
&--reversed {
background: #fef2f2;
color: $red;
border-color: #fecaca;
.txn__pill-dot {
background: $red;
}
}
mifosx-transaction-payment-detail {
width: 100%;
margin-bottom: 20px;
&--accrual {
background: #fffbeb;
color: $amber;
border-color: #fde68a;
.txn__pill-dot {
background: $amber;
}
}
&--transfer {
background: #f5f3ff;
color: $purple;
border-color: #ddd6fe;
.txn__pill-dot {
background: $purple;
}
}
}
.transaction-buttons {
margin-top: 3%;
/* Amount mini-tile inside the toolbar — Credit (green) / Debit (red). */
.txn__amount {
display: inline-flex;
flex-direction: column;
align-items: flex-end;
margin-left: auto;
padding: 6px 14px;
border-radius: 10px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
&--debit {
background: #fef2f2;
border-color: #fecaca;
.txn__amount-value {
color: $red;
}
.txn__amount-label {
color: $red;
}
}
}
.active {
background-color: $status-active;
height: 4px;
.txn__amount-label {
font-size: 10px;
font-weight: 700;
color: $green;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.linked {
background-color: $status-approved;
height: 4px;
.txn__amount-value {
font-size: 17px;
font-weight: 800;
color: $green;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.undo {
background-color: $status-active-overdue;
height: 4px;
.txn__actions {
display: inline-flex;
align-items: center;
gap: 8px;
.txn__btn {
height: 36px !important;
border-radius: 8px !important;
font-weight: 600 !important;
font-size: 13px !important;
padding: 0 14px !important;
letter-spacing: 0.01em;
}
.txn__btn--undo {
background: $red !important;
color: #fff !important;
&:hover {
background: #991b1b !important;
}
}
}
/* ─── Wide details panel ──────────────────────────────────────────── */
.txn__panel {
background: #fff;
border: 1px solid $border;
border-radius: 12px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
padding: 16px 18px;
}
/* Multi-column grid — each cell is one label/value pair. Uses the full
panel width so wide screens get more columns side-by-side. */
.txn__panel-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 4px 24px;
}
.txn__cell {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 10px 0;
border-bottom: 1px dashed #eef2f7;
font-size: 13px;
min-width: 0;
&--wide {
grid-column: 1 / -1;
}
}
.txn__cell-label {
color: $slate;
font-weight: 500;
flex-shrink: 0;
}
.txn__cell-value {
color: $navy;
font-weight: 600;
text-align: right;
word-break: break-word;
min-width: 0;
&--mono {
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 12.5px;
letter-spacing: 0.02em;
}
&--amount {
font-variant-numeric: tabular-nums;
font-weight: 800;
}
&--positive {
color: $green;
}
&--negative {
color: $red;
}
}
.txn__payment-detail {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
::ng-deep mat-card,
::ng-deep .mat-mdc-card {
border: none !important;
box-shadow: none !important;
padding: 0 !important;
background: transparent !important;
}
}
.m-r-6 {
margin-right: 6px;
}
/* ─── Responsive ──────────────────────────────────────────────────── */
@media (width <= 900px) {
.txn__toolbar {
row-gap: 10px;
}
.txn__amount {
margin-left: 0;
}
}
@media (width <= 600px) {
.txn__title-block {
flex: 1 1 auto;
}
.txn__actions {
width: 100%;
justify-content: flex-end;
.txn__btn {
flex: 1 1 auto;
}
}
}
@@ -45,11 +45,36 @@ export class SavingsTransactionGeneralTabComponent {
transactionId: string;
transactionData: any;
/** Parent savings account context — fetched on init so we can show the
* account number and client/group name in the hero. */
accountNo: string | null = null;
clientName: string | null = null;
constructor() {
this.route.data.subscribe((data: { savingsAccountTransaction: any }) => {
this.accountId = this.route.parent.snapshot.params['savingAccountId'];
this.transactionData = data.savingsAccountTransaction;
});
if (this.accountId) {
this.savingsService.getSavingsAccountData(this.accountId).subscribe({
next: (account: any) => {
this.accountNo = account?.accountNo || null;
this.clientName = account?.clientName || account?.groupName || null;
},
error: () => {
this.accountNo = null;
this.clientName = null;
}
});
}
}
/** Returns true for transactions that move money out (withdrawal, fees,
* overdraft interest, withhold-tax). Used to colour the Amount chip. */
isDebit(): boolean {
const t = this.transactionData?.transactionType;
if (!t) return false;
return t.withdrawal === true || t.feeDeduction === true || t.overdraftInterest === true || t.withholdTax === true;
}
allowUndo(): boolean {