feat(dds): upgrade DDS UI - agent, party, daily entry, approvals, product view

- Agent master: tabbed layout, customer search dialog, GL dropdown, account card, view page
- Party master: tabbed layout, expected party code, nominee cards, relation from code values
- Daily transaction entry: compact style, collections popup, Apply DDS Receipt placement
- Approvals: new review modal, Agent Approval tab, DDS routing into product/pending account
- DDS product: view page and list buttons; drop Auto Interest Post Day

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 10:50:03 +05:30
co-authored by Claude Sonnet 5.5
parent 357f666421
commit 293b671966
45 changed files with 5973 additions and 1292 deletions
@@ -19,72 +19,487 @@
<span>Loading entry…</span>
</div>
} @else {
<div class="dri-body">
<!-- Header card: the voucher it moves under, its status, who made it and when -->
<div class="dri-card dri-card--header">
<div class="dri-card-header">
<div class="dri-card-header__left">
<span class="dri-voucher">{{ voucherNo || '—' }}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">
{{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }}
</span>
<div class="dri-body" [class.ddr-body]="isProductReview || isAgentReview || isPartyReview">
@if (isProductReview) {
<!--
A DDS product request reads as the product it creates or changes: who and what at the top,
the four terms a checker looks at first, then every term grouped as the Account Type screen
asks for them. An edit shows each changed term as old → new.
-->
<section class="ddr-hero">
<div class="ddr-hero__icon">
<mat-icon>{{ isEdit ? 'edit_note' : 'savings' }}</mat-icon>
</div>
<div class="dri-card-header__meta">
<span class="dri-meta-item">
<div class="ddr-hero__main">
<div class="ddr-hero__eyebrow">
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{
isEdit ? 'Edit request' : 'New product'
}}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
</div>
<h2 class="ddr-hero__title">{{ valueOf('accountTypeDescription') || entry.details || 'DDS Product' }}</h2>
<div class="ddr-hero__code">
<span class="ddr-mono">{{ valueOf('accountTypeCode') || '—' }}</span>
@if (valueOf('accountTypeGroup')) {
<span class="ddr-dot"></span>
<span>{{ valueOf('accountTypeGroup') }} group</span>
}
</div>
</div>
<div class="ddr-hero__meta">
<span class="ddr-meta">
<mat-icon>person</mat-icon>
{{ entry.makerName || entry.maker }} ({{ entry.maker }})
<span>
<b>{{ entry.makerName || entry.maker }}</b>
<small>{{ entry.maker }}</small>
</span>
</span>
<span class="dri-meta-item">
<span class="ddr-meta">
<mat-icon>schedule</mat-icon>
{{ formatDateTime(entry.madeOn) }}
<span>
<b>{{ formatDateTime(entry.madeOn) }}</b>
<small>Submitted</small>
</span>
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="dri-meta-item">
<mat-icon>check_circle</mat-icon>
Actioned by: {{ entry.checkerName || entry.checker }}
<span class="ddr-meta">
<mat-icon>verified_user</mat-icon>
<span>
<b>{{ entry.checkerName || entry.checker }}</b>
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
</span>
</span>
}
</div>
</div>
</div>
</section>
<!-- Summary -->
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>swap_horiz</mat-icon>
{{ entry.type }} Summary
</div>
<div class="dri-detail-grid">
<div class="dri-detail-item">
<span class="dri-detail-label">Total Amount</span>
<span class="dri-detail-value dri-amount">
{{ entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—' }}
<!-- The terms a checker reads first. -->
<section class="ddr-stats">
<div class="ddr-stat ddr-stat--accent">
<span class="ddr-stat__label"><mat-icon>percent</mat-icon>Interest Rate</span>
<span class="ddr-stat__value">{{ valueOf('interestRate') || '—' }}</span>
<span class="ddr-stat__hint">p.a., worked out daily</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>event_repeat</mat-icon>Interest Posting</span>
<span class="ddr-stat__value">
{{ valueOf('autoInterestPost') === 'No' ? 'Manual' : valueOf('autoInterestPostWhen') || 'Monthly' }}
</span>
<span class="ddr-stat__hint">
{{ valueOf('autoInterestPost') === 'No' ? 'Posted from the batch screen' : 'Automatic, at period end' }}
</span>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">DR Accounts</span>
<span class="dri-detail-value"
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>lock_clock</mat-icon>Lock-in</span>
<span class="ddr-stat__value">{{ valueOf('prematureLockinMonths') || '—' }}</span>
<span class="ddr-stat__hint">before early closure</span>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">CR Accounts</span>
<span class="dri-detail-value"
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>event_available</mat-icon>Maturity</span>
<span class="ddr-stat__value">{{
valueOf('maturityAutoClose') === 'Yes' ? 'Auto close' : 'Manual payout'
}}</span>
<span class="ddr-stat__hint">on the maturity date</span>
</div>
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Details</span>
<span class="dri-detail-value">{{ entry.details || '—' }}</span>
</section>
@if (compareFields) {
<div class="ddr-banner" [class.ddr-banner--none]="!changedCount">
<mat-icon>{{ changedCount ? 'compare_arrows' : 'info' }}</mat-icon>
@if (changedCount) {
<span>
<b>{{ changedCount }} term{{ changedCount !== 1 ? 's' : '' }}</b> will change on approval - highlighted
below as current → proposed.
</span>
} @else {
<span>This edit changes nothing - every term matches the product as it stands.</span>
}
</div>
@if (detail?.description) {
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Description</span>
<span class="dri-detail-value">{{ detail.description }}</span>
}
@for (group of fieldGroups; track group.name) {
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"
><mat-icon>{{ group.icon }}</mat-icon></span
>
<span class="ddr-section__title">{{ group.name }}</span>
@if (group.changed) {
<span class="ddr-chip ddr-chip--edit">{{ group.changed }} changed</span>
}
</header>
<div class="ddr-grid">
@for (field of group.fields; track field.key) {
<div class="ddr-tile" [class.ddr-tile--changed]="field.changed">
<span class="ddr-tile__label">
{{ field.label }}
@if (field.changed) {
<span class="ddr-tile__flag">Changed</span>
}
</span>
@if (field.changed) {
<span class="ddr-tile__diff">
<span class="ddr-tile__old">{{ field.current || '—' }}</span>
<mat-icon>arrow_forward</mat-icon>
<ng-container *ngTemplateOutlet="valueTpl; context: { field: field }"></ng-container>
</span>
} @else {
<ng-container *ngTemplateOutlet="valueTpl; context: { field: field }"></ng-container>
}
</div>
}
</div>
</section>
}
<ng-template #valueTpl let-field="field">
@if (field.format === 'yesno' && field.value) {
<span class="ddr-pill" [class.ddr-pill--yes]="field.value === 'Yes'">
<mat-icon>{{ field.value === 'Yes' ? 'check_circle' : 'remove_circle_outline' }}</mat-icon>
{{ field.value }}
</span>
} @else {
<span class="ddr-tile__value" [class.ddr-mono]="field.key === 'accountTypeCode'">{{
field.value || '—'
}}</span>
}
</ng-template>
} @else if (isAgentReview) {
<!--
An agent request reads as the agent it opens or changes: who and when at the top, then the
client, address, branch and accounts - the branch, the ledger account and the client by name,
not number - and the guarantors named with it. An edit lists only what it changes.
-->
<section class="ddr-hero">
<div class="ddr-hero__icon">
<mat-icon>{{ isEdit ? 'manage_accounts' : 'person_add' }}</mat-icon>
</div>
<div class="ddr-hero__main">
<div class="ddr-hero__eyebrow">
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{ isEdit ? 'Edit request' : 'New agent' }}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
</div>
<h2 class="ddr-hero__title">{{ valueOf('agentNameId') || entry.details || 'DDS Agent' }}</h2>
<div class="ddr-hero__code">
@if (valueOf('clientId')) {
<span>Client {{ valueOf('clientId') }}</span>
}
@if (valueOf('agentSavingAccountNo')) {
<span class="ddr-dot"></span>
<span class="ddr-mono">{{ valueOf('agentSavingAccountNo') }}</span>
}
</div>
</div>
<div class="ddr-hero__meta">
<span class="ddr-meta">
<mat-icon>person</mat-icon>
<span>
<b>{{ entry.makerName || entry.maker }}</b>
<small>{{ entry.maker }}</small>
</span>
</span>
<span class="ddr-meta">
<mat-icon>schedule</mat-icon>
<span>
<b>{{ formatDateTime(entry.madeOn) }}</b>
<small>Submitted</small>
</span>
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="ddr-meta">
<mat-icon>verified_user</mat-icon>
<span>
<b>{{ entry.checkerName || entry.checker }}</b>
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
</span>
</span>
}
</div>
</section>
@for (group of fieldGroups; track group.name) {
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"
><mat-icon>{{ group.icon }}</mat-icon></span
>
<span class="ddr-section__title">{{ group.name }}</span>
</header>
<div class="ddr-grid">
@for (field of group.fields; track field.key) {
<div class="ddr-tile">
<span class="ddr-tile__label">{{ field.label }}</span>
<span class="ddr-tile__value" [class.ddr-mono]="field.key === 'agentSavingAccountNo'">{{
field.value || '—'
}}</span>
</div>
}
</div>
</section>
}
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"><mat-icon>shield_person</mat-icon></span>
<span class="ddr-section__title">Guarantors</span>
<span class="ddr-chip ddr-chip--edit">{{ guarantors.length }}</span>
</header>
@if (guarantors.length) {
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Client</th>
<th>Name</th>
<th>Telephone</th>
<th>Address</th>
</tr>
</thead>
<tbody>
@for (g of guarantors; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td>{{ g.client || '—' }}</td>
<td>{{ g.name || '—' }}</td>
<td class="cell-mono">{{ g.telephone || '—' }}</td>
<td>{{ g.address || '—' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="ddr-tile">
<span class="ddr-tile__value">No guarantors named in this request.</span>
</div>
}
</section>
} @else if (isPartyReview) {
<!--
A DDS account request reads as the account it opens or changes: who it is for and when, the
terms a checker looks at first, then every detail in the sections the Party Master Entry screen
has - the client, scheme, agent and branch by name, not number - and the nominees named with it.
An edit lists only what it changes.
-->
<section class="ddr-hero">
<div class="ddr-hero__icon">
<mat-icon>{{ isEdit ? 'edit_note' : 'account_balance_wallet' }}</mat-icon>
</div>
<div class="ddr-hero__main">
<div class="ddr-hero__eyebrow">
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{
isEdit ? 'Edit request' : 'New DDS account'
}}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
</div>
<h2 class="ddr-hero__title">{{ valueOf('partyName') || entry.details || 'DDS Account' }}</h2>
<div class="ddr-hero__code">
@if (valueOf('subType')) {
<span>{{ valueOf('subType') }}</span>
}
@if (valueOf('clientId')) {
<span class="ddr-dot"></span>
<span>Client {{ valueOf('clientId') }}</span>
}
</div>
</div>
<div class="ddr-hero__meta">
<span class="ddr-meta">
<mat-icon>person</mat-icon>
<span>
<b>{{ entry.makerName || entry.maker }}</b>
<small>{{ entry.maker }}</small>
</span>
</span>
<span class="ddr-meta">
<mat-icon>schedule</mat-icon>
<span>
<b>{{ formatDateTime(entry.madeOn) }}</b>
<small>Submitted</small>
</span>
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="ddr-meta">
<mat-icon>verified_user</mat-icon>
<span>
<b>{{ entry.checkerName || entry.checker }}</b>
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
</span>
</span>
}
</div>
</section>
<!-- The terms a checker reads first. -->
<section class="ddr-stats">
<div class="ddr-stat ddr-stat--accent">
<span class="ddr-stat__label"><mat-icon>payments</mat-icon>Daily Deposit</span>
<span class="ddr-stat__value">{{ valueOf('dailyDepositAmount') || '—' }}</span>
<span class="ddr-stat__hint">collected every day</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>date_range</mat-icon>Term</span>
<span class="ddr-stat__value">{{ valueOf('days') ? valueOf('days') + ' days' : '—' }}</span>
<span class="ddr-stat__hint">{{ valueOf('month') ? valueOf('month') + ' months' : '' }}</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>event</mat-icon>Opening Date</span>
<span class="ddr-stat__value">{{ valueOf('openingDate') || '—' }}</span>
<span class="ddr-stat__hint">{{
valueOf('maturityDate') ? 'Matures ' + valueOf('maturityDate') : 'account opens'
}}</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>support_agent</mat-icon>Agent</span>
<span class="ddr-stat__value ddr-stat__value--small">{{ valueOf('agentId') || '—' }}</span>
<span class="ddr-stat__hint">{{ valueOf('officeId') || '' }}</span>
</div>
</section>
@for (group of fieldGroups; track group.name) {
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"
><mat-icon>{{ group.icon }}</mat-icon></span
>
<span class="ddr-section__title">{{ group.name }}</span>
</header>
<div class="ddr-grid">
@for (field of group.fields; track field.key) {
<div class="ddr-tile">
<span class="ddr-tile__label">{{ field.label }}</span>
@if (field.format === 'yesno') {
<span class="ddr-pill" [class.ddr-pill--yes]="field.value === 'Yes'">
<mat-icon>{{ field.value === 'Yes' ? 'check_circle' : 'remove_circle_outline' }}</mat-icon>
{{ field.value }}
</span>
} @else {
<span class="ddr-tile__value">{{ field.value || '—' }}</span>
}
</div>
}
</div>
</section>
}
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"><mat-icon>family_restroom</mat-icon></span>
<span class="ddr-section__title">Nominees</span>
@if (detail?.nomineeSplit && nominees.length) {
<span class="ddr-chip ddr-chip--edit">Split by {{ detail.nomineeSplit }}</span>
}
<span class="ddr-chip ddr-chip--edit">{{ nominees.length }}</span>
</header>
@if (nominees.length) {
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Relation</th>
<th>Gender</th>
<th>Mobile</th>
<th>Birth Date</th>
<th>Guardian</th>
<th class="num">Share</th>
</tr>
</thead>
<tbody>
@for (n of nominees; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td>{{ n.name || '—' }}</td>
<td>{{ n.relation || '—' }}</td>
<td>{{ n.gender || '—' }}</td>
<td class="cell-mono">{{ n.mobileNo || '—' }}</td>
<td>{{ n.birthDate || '—' }}</td>
<td>{{ n.guardianName || '—' }}</td>
<td class="num">{{ n.share || '—' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="ddr-tile">
<span class="ddr-tile__value">No nominee named in this request.</span>
</div>
}
</section>
} @else {
<!-- Header card: the voucher it moves under, its status, who made it and when -->
<div class="dri-card dri-card--header">
<div class="dri-card-header">
<div class="dri-card-header__left">
<!-- No voucher slot for a change that moves no money - it would only ever read "—". -->
@if (voucherNo || movesMoney) {
<span class="dri-voucher">{{ voucherNo || '—' }}</span>
}
<span class="dri-status" [ngClass]="statusClass(entry.status)">
{{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }}
</span>
</div>
<div class="dri-card-header__meta">
<span class="dri-meta-item">
<mat-icon>person</mat-icon>
{{ entry.makerName || entry.maker }} ({{ entry.maker }})
</span>
<span class="dri-meta-item">
<mat-icon>schedule</mat-icon>
{{ formatDateTime(entry.madeOn) }}
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="dri-meta-item">
<mat-icon>check_circle</mat-icon>
Actioned by: {{ entry.checkerName || entry.checker }}
</span>
}
</div>
</div>
</div>
</div>
<!-- Summary -->
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>swap_horiz</mat-icon>
{{ entry.type }} Summary
</div>
<div class="dri-detail-grid">
<!-- Amount and sides only for an entry that moves money; a product or agent change has neither. -->
@if (movesMoney) {
<div class="dri-detail-item">
<span class="dri-detail-label">Total Amount</span>
<span class="dri-detail-value dri-amount">
{{
entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—'
}}
</span>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">DR Accounts</span>
<span class="dri-detail-value"
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">CR Accounts</span>
<span class="dri-detail-value"
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
>
</div>
}
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Details</span>
<span class="dri-detail-value">{{ entry.details || '—' }}</span>
</div>
@if (detail?.description) {
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Description</span>
<span class="dri-detail-value">{{ detail.description }}</span>
</div>
}
</div>
</div>
}
<!-- Debit entries -->
@if (debitLines.length) {
@@ -225,7 +640,7 @@
}
@if (entry.status === 'PENDING' && !done) {
<div class="dri-actions">
<div class="dri-actions" [class.ddr-actions]="isProductReview || isAgentReview || isPartyReview">
@if (!confirmReject) {
<button type="button" class="dri-btn dri-btn--approve" [disabled]="!canAct" (click)="act('approve')">
<mat-icon>check_circle</mat-icon>
@@ -0,0 +1,453 @@
/**
* 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/.
*/
// The DDS product review. Layered on the Disbursement & Transfers sheet and drawn from the same
// palette, so it reads as part of the same Approval Center rather than a screen of its own.
$brand: #0f4f3c;
$brand-mid: #1f7a5c;
$brand-light: #e6f4f0;
$primary: #4a6cf7;
$warning: #b45309;
$warning-bg: #fffbeb;
$warning-border: #fde68a;
$text-dark: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
$border: #e2e8f0;
$bg-white: #fff;
$bg-soft: #f8fafc;
$radius: 14px;
$shadow-sm:
0 1px 3px rgb(15 23 42 / 6%),
0 1px 2px rgb(15 23 42 / 4%);
@mixin icon($size) {
font-size: $size;
width: $size;
height: $size;
line-height: $size;
}
.ddr-mono {
font-family: 'JetBrains Mono', SFMono-Regular, Consolas, monospace;
letter-spacing: 0.02em;
}
// A product review has more to lay out than a money movement, so the dialog widens for it.
.dri-body.ddr-body {
max-width: none;
gap: 16px;
padding-bottom: 0;
}
.dri--dialog:has(.ddr-body) {
width: min(880px, 94vw);
}
// ── Hero ─────────────────────────────────────────────────────────────────────
.ddr-hero {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 18px;
align-items: center;
padding: 22px 24px;
border-radius: $radius;
background:
radial-gradient(circle at 100% 0%, rgb(255 255 255 / 12%) 0, transparent 45%),
linear-gradient(135deg, $brand 0%, $brand-mid 100%);
color: #fff;
box-shadow: 0 8px 24px rgb(15 79 60 / 22%);
&__icon {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 16px;
background: rgb(255 255 255 / 14%);
border: 1px solid rgb(255 255 255 / 22%);
mat-icon {
@include icon(28px);
}
}
&__main {
min-width: 0;
}
&__eyebrow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
&__title {
margin: 0;
font-size: 21px;
font-weight: 700;
line-height: 1.25;
overflow-wrap: anywhere;
}
&__code {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font-size: 13px;
color: rgb(255 255 255 / 78%);
}
&__meta {
display: flex;
flex-direction: column;
gap: 10px;
padding-left: 18px;
border-left: 1px solid rgb(255 255 255 / 18%);
}
// Status reads on the dark band as a light chip, whatever its colour on a white card.
.dri-status {
background: rgb(255 255 255 / 92%);
font-size: 11px;
padding: 3px 10px;
}
}
.ddr-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentcolor;
opacity: 0.6;
}
.ddr-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
mat-icon {
@include icon(18px);
opacity: 0.75;
}
b {
display: block;
font-weight: 600;
white-space: nowrap;
}
small {
display: block;
font-size: 11px;
color: rgb(255 255 255 / 65%);
}
}
.ddr-chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
background: rgb(255 255 255 / 16%);
color: #fff;
border: 1px solid rgb(255 255 255 / 28%);
&--edit {
background: $warning-bg;
color: $warning;
border-color: $warning-border;
}
}
// ── Key terms ────────────────────────────────────────────────────────────────
.ddr-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.ddr-stat {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
background: $bg-white;
border: 1px solid $border;
border-radius: 12px;
box-shadow: $shadow-sm;
&__label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-soft;
mat-icon {
@include icon(16px);
color: $brand-mid;
}
}
&__value {
font-size: 18px;
font-weight: 700;
color: $text-dark;
overflow-wrap: anywhere;
}
&__hint {
font-size: 11.5px;
color: $text-mid;
min-height: 14px;
}
// A value that is a name rather than a figure - an agent's code and name - reads smaller.
&__value--small {
font-size: 14px;
line-height: 1.3;
}
&--accent {
background: $brand-light;
border-color: #b7ddd0;
.ddr-stat__value {
font-size: 26px;
color: $brand;
}
}
}
// ── Edit banner ──────────────────────────────────────────────────────────────
.ddr-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 12px;
font-size: 13px;
background: $warning-bg;
border: 1px solid $warning-border;
color: $warning;
mat-icon {
@include icon(20px);
flex-shrink: 0;
}
&--none {
background: $bg-soft;
border-color: $border;
color: $text-mid;
}
}
// ── Sections ─────────────────────────────────────────────────────────────────
.ddr-section {
background: $bg-white;
border: 1px solid $border;
border-radius: $radius;
box-shadow: $shadow-sm;
overflow: hidden;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
border-bottom: 1px solid $border;
background: linear-gradient(90deg, $bg-soft 0%, $bg-white 100%);
}
&__icon {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: $brand-light;
color: $brand;
mat-icon {
@include icon(17px);
}
}
&__title {
font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: $text-mid;
margin-right: auto;
}
}
.ddr-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ddr-tile {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 18px;
border-bottom: 1px solid $border;
min-width: 0;
// Two columns: a hairline between them, and none under the last row.
&:nth-child(odd) {
border-right: 1px solid $border;
}
&:last-child,
&:nth-last-child(2):nth-child(odd) {
border-bottom: 0;
}
&__label {
display: flex;
align-items: center;
gap: 8px;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
color: $text-soft;
}
&__value {
font-size: 15px;
font-weight: 600;
color: $text-dark;
overflow-wrap: anywhere;
}
&__flag {
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
background: $warning-bg;
color: $warning;
border: 1px solid $warning-border;
}
&__diff {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
mat-icon {
@include icon(16px);
color: $warning;
}
}
&__old {
font-size: 14px;
color: $text-soft;
text-decoration: line-through;
}
&--changed {
background: linear-gradient(90deg, rgb(253 230 138 / 22%) 0%, $bg-white 85%);
box-shadow: inset 3px 0 0 #f59e0b;
}
}
.ddr-pill {
display: inline-flex;
align-items: center;
gap: 5px;
width: fit-content;
padding: 3px 10px 3px 7px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
background: #f1f5f9;
color: $text-mid;
mat-icon {
@include icon(16px);
}
&--yes {
background: #dcfce7;
color: #15803d;
}
}
// ── Actions stay in reach however far the terms run ─────────────────────────
.ddr-actions {
position: sticky;
bottom: 0;
z-index: 2;
margin: 4px -20px 0;
padding: 14px 20px;
background: rgb(255 255 255 / 94%);
backdrop-filter: blur(6px);
border-top: 1px solid $border;
box-shadow: 0 -6px 16px rgb(15 23 42 / 6%);
}
// ── Narrow screens ───────────────────────────────────────────────────────────
/* stylelint-disable-next-line media-feature-range-notation -- Safari compatibility */
@media (max-width: 720px) {
.ddr-hero {
grid-template-columns: auto 1fr;
&__meta {
grid-column: 1 / -1;
flex-flow: row wrap;
gap: 14px;
padding: 12px 0 0;
border-left: 0;
border-top: 1px solid rgb(255 255 255 / 18%);
}
}
.ddr-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ddr-grid {
grid-template-columns: 1fr;
}
.ddr-tile {
&:nth-child(odd) {
border-right: 0;
}
&:nth-last-child(2):nth-child(odd) {
border-bottom: 1px solid $border;
}
}
}
@@ -25,7 +25,10 @@ import { OrganizationService } from 'app/organization/organization.service';
@Component({
selector: 'mifosx-dds-approval-review',
templateUrl: './dds-approval-review.component.html',
styleUrls: ['../../../clerk/disbursement-review/disbursement-review.component.scss'],
styleUrls: [
'../../../clerk/disbursement-review/disbursement-review.component.scss',
'./dds-approval-review.component.scss'
],
standalone: true,
imports: [
CommonModule,
@@ -76,6 +79,99 @@ export class DdsApprovalReviewComponent implements OnInit {
return (this.detail?.lines || []).filter((line: any) => line.side === 'CR');
}
/** A product change's terms, field by field - empty for an entry that moves money. */
get fields(): any[] {
return this.detail?.fields || [];
}
/** An edit still waiting is shown against the product as it stands today. */
get compareFields(): boolean {
return this.fields.some((field: any) => 'current' in field);
}
/** A DDS product request, which is laid out as the product rather than as a money movement. */
get isProductReview(): boolean {
return this.entry?.entity === 'DDSACCOUNTTYPE' && this.fields.length > 0;
}
/** A DDS agent request, laid out as the agent and their guarantors rather than as a money movement. */
get isAgentReview(): boolean {
return this.entry?.entity === 'AGENT' && this.fields.length > 0;
}
/** A DDS account request, laid out as the account and its nominees rather than as a money movement. */
get isPartyReview(): boolean {
return this.entry?.entity === 'PARTY' && this.fields.length > 0;
}
/** The nominees a DDS account request names. */
get nominees(): any[] {
return this.detail?.nominees || [];
}
/** The guarantors an agent request names. */
get guarantors(): any[] {
return this.detail?.guarantors || [];
}
get isEdit(): boolean {
return this.entry?.action === 'UPDATE';
}
/** A term's value as it will stand once approved, by its request parameter name. */
valueOf(key: string): string {
return this.fields.find((field: any) => field.key === key)?.value || '';
}
get changedCount(): number {
return this.fields.filter((field: any) => field.changed).length;
}
private static readonly GROUP_ICONS: Record<string, string> = {
Product: 'inventory_2',
Interest: 'trending_up',
'Maturity & Premature Closure': 'event_busy',
'Customer & Scheme': 'groups',
'Personal & Contact': 'contact_page',
'Minor / Guardian': 'escalator_warning',
'Scheme Terms': 'event_note',
'Balances & Status': 'toggle_on',
'Maturity Payout': 'currency_exchange',
Agent: 'badge',
'Address & Contact': 'location_on',
'Branch & Accounts': 'account_balance',
Balances: 'balance'
};
/** The terms in the sections the server groups them into, in the order it sends them. */
get fieldGroups(): { name: string; icon: string; changed: number; fields: any[] }[] {
if (this.groupsFor !== this.fields) {
const groups: { name: string; icon: string; changed: number; fields: any[] }[] = [];
for (const field of this.fields) {
const name = field.group || 'Details';
let group = groups.find((g) => g.name === name);
if (!group) {
group = { name, icon: DdsApprovalReviewComponent.GROUP_ICONS[name] || 'tune', changed: 0, fields: [] };
groups.push(group);
}
group.fields.push(field);
group.changed += field.changed ? 1 : 0;
}
this.groupsFor = this.fields;
this.groups = groups;
}
return this.groups;
}
/** Built once per load rather than on every change detection pass. */
private groupsFor: any[] | null = null;
private groups: { name: string; icon: string; changed: number; fields: any[] }[] = [];
/** Whether the entry moves money at all, and so has an amount and debit and credit sides to show. */
get movesMoney(): boolean {
return (this.entry?.amount !== null && this.entry?.amount !== undefined) || !!this.detail?.lines?.length;
}
total(lines: any[]): number {
return lines.reduce((sum: number, line: any) => sum + Number(line.amount || 0), 0);
}
@@ -21,6 +21,7 @@ import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from './dds-approval-review/dds-approval-review.component';
import { isDdsRoutedRequest } from 'app/tasks/product-entities';
/**
* Approval Center › DDS Approvals: every DDS entry waiting on a checker, in the clerk's terms - what
@@ -104,7 +105,11 @@ export class DdsApprovalsComponent implements OnInit {
.getDdsApprovals(this.filterStatus, this.isoDate(this.fromDate), this.isoDate(this.toDate))
.pipe(catchError(() => of([])))
.subscribe((rows: any) => {
this.entries = Array.isArray(rows) ? rows : [];
// Pending DDS product and account requests wait under Product Approval and Pending Account
// instead; once actioned they show here again, so the DDS history stays in one place.
this.entries = (Array.isArray(rows) ? rows : []).filter(
(row: any) => this.filterStatus !== 'PENDING' || !isDdsRoutedRequest(row?.entity, row?.action)
);
this.pageIndex = 0;
this.loading = false;
this.loaded = true;