diff --git a/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.scss b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.scss index 37ec50b55..fc423f722 100644 --- a/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.scss +++ b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.scss @@ -56,12 +56,13 @@ flex-shrink: 0; } +// A ticked day or voucher, in the screen's own green rather than the shared sheet's blue. .option-picked { - border-color: #2563eb !important; - background: #eff6ff !important; + border-color: #0f766e !important; + background: #e6f4ee !important; .option-check { - color: #2563eb; + color: #0f766e; } } @@ -93,3 +94,76 @@ border-radius: 6px; background: #f8fafc; } + +// ── Compact density ────────────────────────────────────────────────────────────────────────────────── +// A step smaller than the sheet it shares, for this screen only: the shared sheet is sized in rem for +// Apply Additional Shares and is left alone, so the whole dialog is scaled here instead. Nothing in it +// is an overlay, so the scaling cannot throw a popup out of place. +.dialog-wrapper { + zoom: 0.88; +} + +// ── The Daily Transaction screen's colours ─────────────────────────────────────────────────────────── +// The shared sheet is indigo and blue, for Apply Additional Shares. This dialog opens from a screen +// drawn in green and teal, so it takes that palette here - the shared sheet is left as it is, and these +// rules only state what differs from it. +$page-teal: #0f766e; +$page-dark: #0f4f3c; +$page-soft: #e6f4ee; +$page-border: #b7ddd0; + +.dialog-header { + background: linear-gradient(135deg, $page-dark 0%, $page-teal 70%, #1f7a5c 100%); +} + +.sc-card { + border-color: $page-border; +} + +.slot-step { + background: linear-gradient(135deg, $page-dark 0%, $page-teal 100%); +} + +.slot-search { + border-color: $page-border; + background: $page-soft; + color: $page-teal; + + &:hover { + background: $page-teal; + border-color: $page-teal; + color: #fff; + box-shadow: 0 2px 8px rgb(15 118 110 / 25%); + } +} + +.option { + &:hover { + border-color: $page-teal; + background: $page-soft; + box-shadow: 0 2px 6px rgb(15 118 110 / 10%); + } + + &:hover .option-radio { + border-color: $page-teal; + } +} + +.option-info:hover { + color: $page-teal; + border-color: $page-teal; + background: $page-soft; +} + +.gl-table tbody tr:hover { + background: $page-soft; +} + +.dialog-actions [mat-flat-button][color='primary']:not([disabled]) { + background: linear-gradient(135deg, $page-teal 0%, $page-dark 100%) !important; + box-shadow: 0 2px 8px rgb(15 118 110 / 28%) !important; + + &:hover { + box-shadow: 0 4px 14px rgb(15 118 110 / 40%) !important; + } +} diff --git a/src/app/deposits/daily-transaction-entry/collections-dialog/collections-dialog.component.html b/src/app/deposits/daily-transaction-entry/collections-dialog/collections-dialog.component.html new file mode 100644 index 000000000..c9aabee61 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/collections-dialog/collections-dialog.component.html @@ -0,0 +1,78 @@ + + +
+
+ +
+

Collections up to {{ data.dateText }}

+

{{ data.agentName }} - written down and not yet handed in

+
+ + +
+ +
+ @if (data.loading()) { +

Reading this agent's entries...

+ } @else if (entries.length === 0) { +

{{ data.agentName }} has nothing outstanding up to this date.

+ } @else { +
+
+ Date + Party + Name + Mode + Amount +
+ @for (entry of pageEntries; track entry.id) { + +
+ {{ entry.entryDate | dateFormat }} + {{ entry.partyCode }} + {{ entry.partyName }} + {{ entry.transactionMode }} + @if (entry.advanceDays) { + {{ entry.advanceDays }}d advance + } + @if (entry.arrearsDays) { + {{ entry.arrearsDays }}d previous + } + + {{ entry.amount | number: '1.2-2' }} +
+ } +
+ +
+ {{ entries.length }} {{ entries.length === 1 ? 'entry' : 'entries' }} + {{ data.total() | number: '1.2-2' }} +
+ +

+ The cash and its receipts are under Agent Tran. Click a line to see that customer's account. +

+ } +
+ + @if (entries.length > 0) { + + } +
diff --git a/src/app/deposits/daily-transaction-entry/collections-dialog/collections-dialog.component.scss b/src/app/deposits/daily-transaction-entry/collections-dialog/collections-dialog.component.scss new file mode 100644 index 000000000..41e9252be --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/collections-dialog/collections-dialog.component.scss @@ -0,0 +1,235 @@ +/** + * 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/. + */ + +$primary: #0f766e; +$primary-dark: #0f4f3c; +$primary-light: #e6f4ee; +$border: #e2e8f0; +$text-dark: #0f172a; +$text-mid: #475569; +$text-soft: #94a3b8; + +:host { + display: block; +} + +// Small type throughout: this is a list to read down, and a page of it should fit the window. +.cd { + display: flex; + flex-direction: column; + max-height: 86vh; + font-size: 12.5px; + color: $text-dark; +} + +.cd-head { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-bottom: 1px solid $border; + + &__ic { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 30px; + height: 30px; + border-radius: 9px; + background: $primary-light; + color: $primary-dark; + font-size: 13px; + } + + &__text { + flex: 1; + min-width: 0; + + h2 { + margin: 0; + font-size: 14px; + font-weight: 700; + } + + p { + margin: 1px 0 0; + font-size: 11.5px; + color: $text-mid; + } + } +} + +.cd-refresh { + height: 30px; + padding: 0 12px; + font-size: 12px; + color: $primary; +} + +.cd-close { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 0; + border-radius: 8px; + background: transparent; + color: $text-mid; + cursor: pointer; + + &:hover { + background: #f1f5f9; + } +} + +.cd-body { + flex: 1; + min-height: 0; + padding: 10px 16px 6px; + overflow-y: auto; +} + +.cd-empty { + margin: 8px 0; + padding: 16px; + border: 1.5px dashed #cbd5e1; + border-radius: 10px; + background: #f8fafc; + color: $text-mid; + text-align: center; +} + +.cd-table { + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; +} + +.cd-row { + display: grid; + grid-template-columns: 112px 78px minmax(0, 1fr) 120px 92px; + align-items: center; + gap: 10px; + padding: 7px 12px; + border-bottom: 1px solid #eef2f6; + cursor: pointer; + + &:last-child { + border-bottom: 0; + } + + &:hover { + background: #f0fdf9; + } + + &--head { + cursor: default; + background: #f1f5f9; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: $text-soft; + + &:hover { + background: #f1f5f9; + } + } +} + +.cd-date { + color: $text-mid; + font-variant-numeric: tabular-nums; +} + +.cd-code { + font-weight: 700; + color: $primary-dark; + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace; + font-size: 12px; +} + +.cd-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cd-mode { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + + small { + font-size: 10.5px; + color: $text-mid; + } +} + +.cd-chip { + padding: 1px 8px; + border-radius: 999px; + background: #eef2ff; + color: #3730a3; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.cd-amount { + text-align: right; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.cd-total { + display: flex; + justify-content: flex-end; + align-items: baseline; + gap: 12px; + margin-top: 8px; + padding: 8px 12px; + border-radius: 8px; + background: $primary-light; + color: $text-mid; + + strong { + font-size: 15px; + color: $primary-dark; + font-variant-numeric: tabular-nums; + } +} + +.cd-note { + margin: 8px 2px 4px; + font-size: 11.5px; + color: $text-soft; +} + +.cd-paginator { + flex: 0 0 auto; + border-top: 1px solid $border; + font-size: 12px; + + --mat-paginator-container-text-size: 12px; + --mat-paginator-container-size: 44px; + --mat-paginator-form-field-container-height: 30px; + --mat-paginator-form-field-container-vertical-padding: 3px; +} + +@media (width <= 640px) { + .cd-row { + grid-template-columns: 90px 70px minmax(0, 1fr) 80px; + + .cd-mode { + display: none; + } + } +} diff --git a/src/app/deposits/daily-transaction-entry/collections-dialog/collections-dialog.component.ts b/src/app/deposits/daily-transaction-entry/collections-dialog/collections-dialog.component.ts new file mode 100644 index 000000000..35ef7a61c --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/collections-dialog/collections-dialog.component.ts @@ -0,0 +1,93 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { DateFormatPipe } from 'app/pipes/date-format.pipe'; + +/** + * What the dialog is given. The entries are read through functions rather than copied in, so a Refresh + * on the screen behind - which reloads the list - shows here as soon as it arrives. + */ +export interface CollectionsDialogData { + agentName: string; + /** The day the list runs up to, already worded for a reader. */ + dateText: string; + entries: () => any[]; + total: () => number; + loading: () => boolean; + refresh: () => void; +} + +/** + * Everything the agent wrote down and has not handed in yet, up to the chosen day, customer by + * customer - the list the clerk counts a bag of cash against. + * + * It lives in a window, paged, rather than down the page: a round can run to hundreds of lines, and + * the screen behind it has a good deal more to show. A line can be clicked to see that customer's + * account on the screen; the dialog closes with the entry picked. + */ +@Component({ + selector: 'mifosx-dds-collections-dialog', + standalone: true, + templateUrl: './collections-dialog.component.html', + styleUrls: ['./collections-dialog.component.scss'], + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatPaginatorModule, + DateFormatPipe + ] +}) +export class CollectionsDialogComponent { + readonly data = inject(MAT_DIALOG_DATA); + private dialogRef = inject(MatDialogRef); + + readonly pageSizeOptions = [ + 10, + 25, + 50, + 100 + ]; + pageIndex = 0; + pageSize = 10; + + get entries(): any[] { + return this.data.entries() || []; + } + + /** The lines on the page being looked at. */ + get pageEntries(): any[] { + const start = this.pageIndex * this.pageSize; + return this.entries.slice(start, start + this.pageSize); + } + + onPage(event: PageEvent): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + } + + refresh(): void { + // A reloaded list may be shorter than the page being looked at. + this.pageIndex = 0; + this.data.refresh(); + } + + /** Closes with the line clicked, for the screen to show whose account it is. */ + pick(entry: any): void { + this.dialogRef.close(entry); + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html index e8ae7d13b..41dfa9a5b 100644 --- a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html @@ -11,14 +11,20 @@ -

Daily Transaction Detail

+
+
+

Daily Transaction Detail

+

+ An agent's round - what was collected, the cash handed in and applied - and transfers between accounts. +

+
-
Agent Information
+
Agent Information
Name
{{ agentInfo.name || '—' }}
@@ -28,17 +34,22 @@
{{ agentInfo.city || '—' }}
A/C
{{ agentInfo.accountNo || '—' }}
-
Guarantor
-
{{ agentInfo.guarantor1 || '—' }}
-
Guarantor
-
{{ agentInfo.guarantor2 || '—' }}
+ + @for (guarantor of agentInfo.guarantors; track $index; let i = $index) { +
{{ agentInfo.guarantors.length > 1 ? 'Guarantor ' + (i + 1) : 'Guarantor' }}
+
{{ guarantor }}
+ } @empty { +
Guarantor
+
—
+ }
Curr Bal
{{ agentInfo.currentBalance | number: '1.2-2' }}
-
-
Party Information
+ +
+
Party Information
@if (partyInfo) {
Client Id
@@ -79,15 +90,62 @@
} @else {

- Pick a party in a collection row below — this panel shows whose account it is and what the balance + + Click an entry in the collections list below — this panel shows whose account it is and what the balance becomes.

}
+ + @if (agentInfo.name) { +
+
+ Parties + {{ agentParties.length }} + to collect from +
+ @if (isPartyTran) { +
+ Entries + {{ pendingEntries.length }} + written down, not handed in +
+
+ To count + {{ pendingTotal | number: '1.2-2' }} + the agent should hand over +
+ } + + @if (isAgentTran) { +
+ To take + {{ agentOwed | number: '1.2-2' }} + written down, not handed in +
+
+ Received (approved) + {{ agentReceived | number: '1.2-2' }} + taken in by the cashier +
+
+ Still to take + {{ agentBalance | number: '1.2-2' }} + left to receive +
+ } +
+ Agent Balance + {{ agentInfo.currentBalance | number: '1.2-2' }} + held by {{ agentInfo.name }} +
+
+ } +
- + Agent @for (agent of selectableAgents; track agent.id) { @@ -112,7 +170,7 @@ } - + Date @@ -127,7 +185,7 @@ - + Tran. Type @for (type of transactionTypes; track type.label) { @@ -140,100 +198,43 @@ } -
- - @if (isPartyTran && agentInfo.name) { -
-
- -

Collections up to {{ entryForm.get('transactionDate')?.value | dateFormat }}

- -
- - @if (pendingLoading) { -

Reading this agent's entries...

- } @else if (pendingEntries.length === 0) { -

{{ agentInfo.name }} has nothing outstanding up to this date.

- } @else { - @for (entry of pendingEntries; track entry.id) { -
- - {{ entry.entryDate | dateFormat }} - {{ entry.partyCode }} - {{ entry.partyName }} - {{ entry.transactionMode }} - @if (entry.advanceDays) { - · {{ entry.advanceDays }}d advance - } - @if (entry.arrearsDays) { - · {{ entry.arrearsDays }}d previous - } - - {{ entry.amount | number: '1.2-2' }} -
- } - -
- {{ pendingEntries.length }} {{ pendingEntries.length === 1 ? 'entry' : 'entries' }} - {{ pendingTotal | number: '1.2-2' }} -
- - -

- Everything {{ agentInfo.name }} wrote down and has not handed in yet, up to this date, customer by - customer. The cash and its receipts are under Agent Tran. -

- } -
- } - - - @if (isAgentTran && agentInfo.name) { -
-
- -

Agent's cash

- -
+ } + + @if (isPartyTran && agentInfo.name) { + + } +
+ + + + + @if (isAgentTran && agentInfo.name) { +
@if (!agentGlCode) {

{{ agentInfo.name }} has no GL account, so the cashier has nowhere to take their cash. Set one on Agent @@ -246,21 +247,6 @@ takes {{ agentInfo.name }}'s cash into this GL (Entry Type DDS).

} - -
-
- To take - {{ agentOwed | number: '1.2-2' }} -
-
- Received (approved) - {{ agentReceived | number: '1.2-2' }} -
-
- Still to take - {{ agentBalance | number: '1.2-2' }} -
-
@if (agentReceivedBeyondDay > 0) {

{{ agentReceivedBeyondDay | number: '1.2-2' }} received at the counter is more than everything outstanding @@ -273,66 +259,6 @@ {{ agentWaiting | number: '1.2-2' }} more is at the counter awaiting the manager's approval.

} - -

- Outstanding entries up to {{ entryForm.get('transactionDate')?.value | dateFormat }} -

- @if (agentMoneyLoading) { -

Reading this agent's entries...

- } @else if (agentDays.length === 0) { -

{{ agentInfo.name }} has nothing outstanding.

- } @else { - @for (day of agentDays; track day.date) { -
- {{ day.date | dateFormat }} - - {{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }} - - {{ day.total | number: '1.2-2' }} - -
- @if (agentOpenDay === day.date) { -
- @for (entry of day.entries; track entry.id) { -
- {{ entry.partyCode }} - {{ entry.partyName }} - {{ entry.transactionMode }} - @if (entry.advanceDays) { - · {{ entry.advanceDays }}d advance - } - @if (entry.arrearsDays) { - · {{ entry.arrearsDays }}d previous - } - - {{ entry.amount | number: '1.2-2' }} -
- } -
- } - } - } - -

Cashier receipts

- @if (agentReceipts.length === 0) { -

- No DDS receipt on {{ agentInfo.name }}'s GL account{{ agentGlCode ? ' ' + agentGlCode : '' }} waiting to - be applied. -

- } @else { - @for (receipt of agentReceipts; track receipt.id) { -
- {{ receipt.voucherNo }} - {{ receipt.txnDate | dateFormat }} - {{ receipt.cashierName || '—' }} - - {{ receipt.status === 'APPROVED' ? 'Approved' : 'Awaiting manager' }} - - {{ receipt.amount | number: '1.2-2' }} -
- } - }
} @@ -342,84 +268,102 @@

Withdraw Deposite

-
- - Creditor Party Code - - arrow_drop_down - - @for (party of transferPartyOptions('creditor'); track party.id) { - - {{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }} - - } - @if (transferPartyOptions('creditor').length === 0) { - No open DDS account matches - } - - Account the money goes into - - - Creditor Party Name - - - - Creditor Curr Bal - - +
+ +
+
Money goes into
+
+ + Creditor Party Code + + arrow_drop_down + + @for (party of transferPartyOptions('creditor'); track party.id) { + + {{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }} + + } + @if (transferPartyOptions('creditor').length === 0) { + No open DDS account matches + } + + Account the money goes into + + + Creditor Party Name + + + + Creditor Curr Bal + + +
+
- - Debit Party Code - - arrow_drop_down - - @for (party of transferPartyOptions('debit'); track party.id) { - - {{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }} - - } - @if (transferPartyOptions('debit').length === 0) { - No open DDS account matches - } - - @if (transferToItself) { - Pick a different account - it cannot transfer to itself - } @else { - Account the money is taken from - } - - - Debit Party Name - - - - Debit Curr Bal - - + +
+
Money is taken from
+
+ + Debit Party Code + + arrow_drop_down + + @for (party of transferPartyOptions('debit'); track party.id) { + + {{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }} + + } + @if (transferPartyOptions('debit').length === 0) { + No open DDS account matches + } + + @if (transferToItself) { + Pick a different account - it cannot transfer to itself + } @else { + Account the money is taken from + } + + + Debit Party Name + + + + Debit Curr Bal + + +
+
Amount diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss index 5a05ca32b..35304127e 100644 --- a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss @@ -6,7 +6,16 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* stylelint-disable no-duplicate-selectors -- the compact-density pass below deliberately re-opens earlier rules; kept in cascade order */ + $primary: #0f766e; +$primary-dark: #0f4f3c; +$primary-light: #e6f4ee; +$border: #e2e8f0; +$text-dark: #0f172a; +$text-mid: #475569; +$text-soft: #94a3b8; +$danger: #b91c1c; .dte-container { // Wider than a normal form page: a collection row carries six fields side by side, and a clerk @@ -19,13 +28,37 @@ $primary: #0f766e; .dte-header { display: flex; align-items: center; - gap: 16px; + gap: 14px; margin-bottom: 20px; + &__icon { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 46px; + height: 46px; + border-radius: 14px; + background: linear-gradient(135deg, $primary 0%, $primary-dark 100%); + color: #fff; + font-size: 19px; + box-shadow: 0 6px 16px rgb(15 79 60 / 22%); + } + + &__text { + min-width: 0; + } + &__title { margin: 0; font-size: 22px; - font-weight: 500; + font-weight: 600; + color: $text-dark; + } + + &__sub { + margin: 2px 0 0; + font-size: 13px; + color: $text-mid; } } @@ -35,8 +68,9 @@ $primary: #0f766e; .dte-card { background: var(--md-sys-color-surface, #fff); - border-radius: 8px; - box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + border: 1px solid $border; + border-radius: 14px; + box-shadow: 0 2px 10px rgb(15 23 42 / 6%); padding: 20px 24px 24px; } @@ -44,46 +78,136 @@ $primary: #0f766e; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; - margin-bottom: 20px; + margin-bottom: 18px; } .dte-info-panel { - border: 1px solid rgb(0 0 0 / 10%); - border-radius: 8px; - padding: 12px 16px; - background: rgb(15 118 110 / 4%); + border: 1px solid $border; + border-radius: 12px; + padding: 14px 18px 12px; + background: linear-gradient(180deg, #f8fafc 0%, #fff 100%); &__title { - font-weight: 600; - font-size: 13px; + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid $border; + font-weight: 700; + font-size: 12.5px; text-transform: uppercase; - letter-spacing: 0.04em; - color: $primary; - margin-bottom: 8px; + letter-spacing: 0.06em; + color: $primary-dark; + + i { + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 8px; + background: $primary-light; + color: $primary-dark; + font-size: 12px; + } } &__hint { + display: flex; + align-items: flex-start; + gap: 8px; margin: 0; - color: rgb(0 0 0 / 50%); + color: $text-mid; font-size: 13px; + line-height: 1.5; + + i { + margin-top: 3px; + color: $text-soft; + } } dl { display: grid; - grid-template-columns: auto 1fr; - gap: 4px 12px; + grid-template-columns: minmax(96px, auto) 1fr; + gap: 0 14px; margin: 0; } + dt, + dd { + padding: 6px 0; + border-bottom: 1px dashed #edf2f7; + } + dt { - font-weight: 500; - color: rgb(0 0 0 / 60%); - font-size: 13px; + font-weight: 600; + color: $text-soft; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.03em; } dd { margin: 0; - font-size: 13px; + font-size: 13.5px; + color: $text-dark; + word-break: break-word; + } +} + +// The round at a glance: parties, entries, what to count, what the agent holds. +.dte-kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 12px; + margin-bottom: 18px; +} + +.dte-kpi { + display: flex; + flex-direction: column; + gap: 3px; + padding: 12px 16px; + border: 1px solid $border; + border-radius: 12px; + background: #fff; + box-shadow: 0 1px 3px rgb(15 23 42 / 5%); + + &__label { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: $text-soft; + + i { + color: $primary; + } + } + + &__value { + font-size: 21px; + font-weight: 700; + color: $text-dark; + font-variant-numeric: tabular-nums; + } + + &__hint { + font-size: 12px; + color: $text-mid; + } + + &--accent { + background: $primary-light; + border-color: #b7ddd0; + + .dte-kpi__value { + color: $primary-dark; + } } } @@ -93,6 +217,10 @@ $primary: #0f766e; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; + padding: 14px 16px 0; + border: 1px solid $border; + border-radius: 12px; + background: #f8fafc; } // A sensible floor for the fields laid out with flex in .dte-controls, where there is room to spare. @@ -112,8 +240,11 @@ $primary: #0f766e; } .dte-section { - padding: 16px 0; - border-top: 1px solid rgb(0 0 0 / 8%); + margin-top: 16px; + padding: 16px 18px 18px; + border: 1px solid $border; + border-radius: 12px; + background: #fff; // A very narrow window scrolls the grid sideways rather than the whole page. overflow-x: auto; @@ -121,16 +252,26 @@ $primary: #0f766e; display: flex; align-items: center; gap: 10px; - margin-bottom: 16px; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 1px solid $border; - i { - color: $primary; + > i { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 9px; + background: $primary-light; + color: $primary-dark; + font-size: 13px; } h3 { margin: 0; font-size: 16px; font-weight: 600; + color: $text-dark; flex: 1; } } @@ -141,15 +282,64 @@ $primary: #0f766e; } .dte-empty { - color: rgb(0 0 0 / 50%); - font-size: 14px; margin: 0; + padding: 18px 16px; + border: 1.5px dashed #cbd5e1; + border-radius: 10px; + background: #f8fafc; + color: $text-mid; + font-size: 14px; + text-align: center; } .dte-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px 16px; + + // Withdraw Deposite: the two accounts each in a card of their own, then the amount and description. + &--transfer { + grid-template-columns: 1fr; + } +} + +.dte-party-card { + padding: 12px 16px 2px; + border: 1px solid $border; + border-radius: 12px; + background: #f8fafc; + + &__title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: $text-mid; + } + + &--to &__title i { + color: #16a34a; + } + + &--from &__title i { + color: #d97706; + } + + &__fields { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0 16px; + } +} + +@media (width <= 900px) { + .dte-party-card__fields { + grid-template-columns: 1fr; + } } /* @@ -207,8 +397,10 @@ $primary: #0f766e; justify-content: flex-end; gap: 12px; margin-top: 20px; - padding-top: 16px; - border-top: 1px solid rgb(0 0 0 / 8%); + padding: 14px 16px; + border: 1px solid $border; + border-radius: 12px; + background: #f8fafc; flex-wrap: wrap; } @@ -257,14 +449,16 @@ $primary: #0f766e; justify-content: flex-end; align-items: baseline; gap: 14px; - padding: 10px 4px 2px; - border-top: 1px solid rgb(0 0 0 / 10%); + margin-top: 6px; + padding: 12px 14px; + border-radius: 10px; + background: $primary-light; font-size: 0.88rem; - color: rgb(0 0 0 / 60%); + color: $text-mid; strong { - font-size: 1.1rem; - color: rgb(0 0 0 / 85%); + font-size: 1.15rem; + color: $primary-dark; font-variant-numeric: tabular-nums; } } @@ -343,25 +537,32 @@ $primary: #0f766e; */ .dte-pending-row { display: flex; - align-items: baseline; + align-items: center; gap: 12px; - padding: 10px 2px; - border-bottom: 1px solid rgb(0 0 0 / 8%); + padding: 11px 12px; + border-bottom: 1px solid #eef2f6; &:last-of-type { border-bottom: none; } + &:hover { + background: #f8fafc; + } + /* First, and in the quieter weight: the date is context for the line, not what is being read. */ &__date { min-width: 96px; - opacity: 0.7; + color: $text-mid; font-variant-numeric: tabular-nums; } &__code { - font-weight: 600; + font-weight: 700; min-width: 84px; + color: $primary-dark; + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace; + font-size: 13px; } &__name { @@ -370,30 +571,76 @@ $primary: #0f766e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - opacity: 0.75; + color: $text-dark; } + // The mode of payment as a small label rather than loose capitals. &__mode { - font-size: 12px; + padding: 2px 10px; + border-radius: 999px; + background: #eef2ff; + color: #3730a3; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; text-transform: uppercase; - letter-spacing: 0.5px; - opacity: 0.6; + white-space: nowrap; } /* Amounts are read down the column and compared, so the digits have to line up. */ &__amount { font-variant-numeric: tabular-nums; - font-weight: 600; + font-weight: 700; min-width: 96px; text-align: right; + color: $text-dark; + } +} + +/* A line that can be clicked to show its party above, and the one currently shown. */ +.dte-pending-row--pick { + cursor: pointer; +} + +.dte-pending-row--active { + background: #eef6f3; + box-shadow: inset 3px 0 0 $primary; +} + +/* Names the columns of the lines below, in the same widths so they sit over them. */ +.dte-pending-head { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-radius: 8px; + background: #f1f5f9; + + span { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: $text-soft; + } + + .dte-pending-row__mode { + padding: 0; + background: none; + color: $text-soft; } } /* The one figure the clerk acts on, said as a sentence so it is not read as another column total. */ .dte-expected { - margin: 14px 2px 0; + margin: 14px 0 0; + padding: 10px 14px; + border-left: 3px solid $primary; + border-radius: 0 8px 8px 0; + background: #f0fdf9; font-size: 14px; line-height: 1.5; + color: $text-dark; strong { font-variant-numeric: tabular-nums; @@ -402,9 +649,11 @@ $primary: #0f766e; /* Quieter than the figure above it: a note about how the screen behaves, not part of the count. */ .dte-expected--note { + border-left-color: #cbd5e1; + background: #f8fafc; font-size: 13px; - opacity: 0.65; - margin-top: 4px; + color: $text-mid; + margin-top: 8px; } .dte-apply-receipt { @@ -413,62 +662,113 @@ $primary: #0f766e; /* Agent Tran: what the agent owes, what the cashier has taken in, and what is left. */ .dte-money { - display: flex; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; - margin: 8px 0 12px; - flex-wrap: wrap; + margin: 12px 0; &__box { - flex: 1; - min-width: 160px; - padding: 10px 14px; - border: 1px solid rgb(0 0 0 / 10%); - border-radius: 8px; + padding: 14px 18px; + border: 1px solid $border; + border-radius: 12px; display: flex; flex-direction: column; - gap: 4px; + gap: 6px; + background: #fff; + box-shadow: 0 1px 3px rgb(15 23 42 / 5%); font-variant-numeric: tabular-nums; strong { - font-size: 18px; + font-size: 22px; + color: $text-dark; + } + + &--owed { + background: #eff6ff; + border-color: #bfdbfe; + + strong { + color: #1d4ed8; + } + } + + &--received { + background: #f0fdf4; + border-color: #bbf7d0; + + strong { + color: #15803d; + } } &--due { border-color: rgb(198 40 40 / 40%); - background: rgb(198 40 40 / 4%); + background: #fef2f2; + + strong { + color: $danger; + } } } &__label { - font-size: 12px; - opacity: 0.7; + display: flex; + align-items: center; + gap: 7px; + font-size: 11px; + font-weight: 700; + color: $text-mid; text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.05em; + } +} + +@media (width <= 720px) { + .dte-money { + grid-template-columns: 1fr; } } .dte-subhead { - margin: 16px 0 4px; - font-size: 14px; - font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + margin: 20px 0 8px; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: $text-mid; + + i { + color: $primary; + } } .dte-pending-row--day { cursor: pointer; + background: #f8fafc; + border-radius: 8px; + margin-top: 4px; + + &:hover { + background: #eef6f3; + } } .dte-day-lines { - margin-left: 24px; + margin: 0 0 6px 24px; max-height: 280px; overflow-y: auto; - border-left: 3px solid rgb(25 118 210 / 30%); + border-left: 3px solid #b7ddd0; padding-left: 10px; } .dte-status { font-size: 12px; - padding: 2px 8px; - border-radius: 10px; + font-weight: 600; + padding: 3px 11px; + border-radius: 999px; background: rgb(237 108 2 / 12%); color: #b45309; @@ -486,3 +786,383 @@ $primary: #0f766e; color: #c62828; font-weight: 600; } + +// ── Compact density ───────────────────────────────────────────────────────────────────────────────── +// The whole screen a step smaller: type, spacing and the height of every field. It is a working screen +// kept open all day, and at full size a round needed scrolling to be read. Everything above states the +// look; this states the size, so one place says how big the page is. +.dte-container { + max-width: 1200px; + padding: 10px 12px; + font-size: 13px; + + // Material sizes every field on the page from these. + --mat-form-field-container-height: 38px; + --mat-form-field-container-vertical-padding: 8px; + --mat-form-field-container-text-size: 13px; + --mat-form-field-container-text-line-height: 20px; + --mat-form-field-outlined-label-text-populated-size: 11px; + --mat-form-field-subscript-text-size: 11px; + --mat-select-trigger-text-size: 13px; + + // The datepicker's toggle and a field's icon buttons default to a 48px touch target, which reopens + // the height just saved - and is why the Date box stood taller than the two beside it. + ::ng-deep .mat-mdc-form-field-icon-suffix .mat-mdc-icon-button.mat-mdc-button-base { + --mat-icon-button-state-layer-size: 30px; + + width: 30px; + height: 30px; + padding: 4px; + } + + ::ng-deep .mat-mdc-form-field-icon-suffix .mat-datepicker-toggle svg { + width: 16px; + height: 16px; + } + + ::ng-deep .mat-mdc-form-field-hint, + ::ng-deep .mat-mdc-form-field-error { + font-size: 11px; + } + + ::ng-deep .mat-mdc-button-base { + font-size: 12.5px; + } +} + +.dte-header { + gap: 10px; + margin-bottom: 10px; + + &__icon { + width: 36px; + height: 36px; + border-radius: 11px; + font-size: 15px; + } + + &__title { + font-size: 18px; + } + + &__sub { + font-size: 11.5px; + } +} + +.dte-card { + padding: 12px 14px 14px; + border-radius: 12px; +} + +.dte-info-panels { + gap: 10px; + margin-bottom: 10px; +} + +.dte-info-panel { + padding: 8px 12px 6px; + border-radius: 10px; + + &__title { + gap: 7px; + margin-bottom: 6px; + padding-bottom: 6px; + font-size: 11px; + + i { + width: 22px; + height: 22px; + border-radius: 7px; + font-size: 10.5px; + } + } + + &__hint { + font-size: 12px; + } + + dl { + gap: 0 12px; + } + + dt, + dd { + padding: 3px 0; + } + + dt { + font-size: 10.5px; + } + + dd { + font-size: 12.5px; + } +} + +.dte-kpis { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 8px; + margin-bottom: 10px; +} + +.dte-kpi { + gap: 1px; + padding: 7px 12px; + border-radius: 10px; + + &__label { + font-size: 10px; + } + + &__value { + font-size: 16px; + } + + &__hint { + font-size: 11px; + } +} + +// The three controls the same size, whatever each holds: one width, one height, side by side. +.dte-controls { + align-items: flex-start; + gap: 10px; + margin-bottom: 8px; + padding: 10px 12px; + border-radius: 10px; + + .dte-field { + flex: 0 0 240px; + width: 240px; + min-width: 0; + } +} + +// The button that opens the round: the same height as the fields beside it. +.dte-view-btn { + height: 38px; + margin-left: auto; + padding: 0 14px; + border-radius: 8px; + color: $primary-dark; + border-color: #b7ddd0; + background: $primary-light; + + i { + margin-right: 6px; + } + + &__count { + margin-left: 8px; + padding: 1px 8px; + border-radius: 999px; + background: $primary; + color: #fff; + font-size: 11px; + font-weight: 700; + } +} + +.dte-section { + margin-top: 10px; + padding: 10px 12px 12px; + border-radius: 10px; + + &__header { + gap: 8px; + margin-bottom: 8px; + padding-bottom: 8px; + + > i { + width: 24px; + height: 24px; + border-radius: 7px; + font-size: 11px; + } + + h3 { + font-size: 14px; + } + } +} + +.dte-empty { + padding: 12px; + font-size: 12.5px; +} + +.dte-money { + gap: 8px; + margin: 8px 0; + + &__box { + padding: 8px 12px; + gap: 3px; + border-radius: 10px; + + strong { + font-size: 17px; + } + } + + &__label { + font-size: 10px; + } +} + +.dte-subhead { + margin: 12px 0 6px; + font-size: 11.5px; +} + +.dte-expected { + margin-top: 8px; + padding: 7px 10px; + font-size: 12.5px; +} + +.dte-expected--note { + font-size: 12px; +} + +.dte-pending-row { + padding: 6px 10px; + font-size: 12.5px; + + &__code { + font-size: 12px; + } + + &__mode { + font-size: 10.5px; + } +} + +.dte-pending-head { + padding: 6px 10px; + + span { + font-size: 10px; + } +} + +.dte-party-card { + padding: 8px 12px 0; + border-radius: 10px; + + &__title { + margin-bottom: 6px; + font-size: 11px; + } +} + +.dte-action-bar { + gap: 8px; + margin-top: 12px; + padding: 8px 12px; + border-radius: 10px; +} + +// ── Agent Tran summary tiles and the actions beside the Date ──────────────────────────────────────── +// To take in blue, Received in green, Still to take in red once something is left: the colours the +// Agent's cash section used, now on the summary tiles. +.dte-kpi { + &--owed { + background: #eff6ff; + border-color: #bfdbfe; + + .dte-kpi__value { + color: #1d4ed8; + } + } + + &--received { + background: #f0fdf4; + border-color: #bbf7d0; + + .dte-kpi__value { + color: #15803d; + } + } + + &--due { + background: #fef2f2; + border-color: rgb(198 40 40 / 40%); + + .dte-kpi__value { + color: $danger; + } + } +} + +// Notes under the summary: the GL the cash goes into, and anything that looks wrong. +.dte-notes { + display: grid; + gap: 6px; + margin: 0 0 8px; + + .dte-expected { + margin: 0; + } +} + +// Refresh and Apply DDS Receipt, at the end of the row and the same height as the fields beside them. +.dte-agent-actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + + .dte-refresh-btn, + .dte-apply-btn { + height: 38px; + padding: 0 14px; + border-radius: 8px; + font-size: 12.5px; + + i { + margin-right: 6px; + } + } + + .dte-refresh-btn { + color: $primary; + } +} + +// ── Party Information, wider and two pairs to a line ─────────────────────────────────────────────── +// A party carries twice what an agent does, so listing it one pair to a line made the card the taller of +// the two and pushed the rest of the screen down. It takes the larger share of the row and sets its +// details out as two columns of label and value, which brings it level with the agent's card. +.dte-info-panels { + grid-template-columns: minmax(0, 1fr) minmax(0, 1.8fr); +} + +.dte-info-panel--party { + dl { + grid-template-columns: minmax(64px, auto) minmax(0, 1fr) minmax(64px, auto) minmax(0, 1fr); + gap: 0 14px; + } + + // The schedule is a sentence, so it takes the rest of the line rather than being squeezed into half of it. + dd.dte-sched { + grid-column: span 3; + } +} + +@media (width <= 1000px) { + .dte-info-panels { + grid-template-columns: 1fr; + } +} + +@media (width <= 640px) { + .dte-info-panel--party dl { + grid-template-columns: minmax(64px, auto) minmax(0, 1fr); + + dd.dte-sched { + grid-column: span 1; + } + } +} diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts index dce4d07d8..171db5430 100644 --- a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts @@ -19,6 +19,7 @@ import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/conf /** Custom Components */ import { DayCollectionDialogComponent } from './day-collection-dialog/day-collection-dialog.component'; import { NoticeDialogComponent } from './notice-dialog/notice-dialog.component'; +import { CollectionsDialogComponent } from './collections-dialog/collections-dialog.component'; import { ApplyDdsReceiptDialogComponent, EntryDay, @@ -152,6 +153,8 @@ export class DailyTransactionEntryComponent implements OnInit { accountNo: '', guarantor1: '', guarantor2: '', + // Every guarantor the agent has, by name - the panel lists as many as there are rather than two fixed lines. + guarantors: [] as string[], currentBalance: 0 }; @@ -505,6 +508,34 @@ export class DailyTransactionEntryComponent implements OnInit { * agent still has outstanding. Its own screen, like Apply Additional Shares, so the list above stays * what it is - what to take from the agent - and the applying is done in one deliberate place. */ + /** + * Opens what the agent wrote down up to the chosen day, in a window of its own and paged - it used to + * run down the page, where a long round pushed everything else out of sight. A line clicked there + * shows that customer in the Party Information panel. + */ + openCollections(): void { + this.dialog + .open(CollectionsDialogComponent, { + width: '820px', + maxWidth: '96vw', + autoFocus: false, + data: { + agentName: this.agentInfo.name, + dateText: this.dateUtils.formatDate(this.entryForm.get('transactionDate')?.value, 'dd MMMM yyyy'), + entries: () => this.pendingEntries, + total: () => this.pendingTotal, + loading: () => this.pendingLoading, + refresh: () => this.loadPendingEntries() + } + }) + .afterClosed() + .subscribe((entry: any) => { + if (entry) { + this.showPartyOfEntry(entry); + } + }); + } + openApplyReceipt(): void { const agentId = this.entryForm.get('agentId')?.value; if (!agentId) { @@ -513,7 +544,7 @@ export class DailyTransactionEntryComponent implements OnInit { const picked = this.agents.find((agent: any) => agent.id === agentId); this.dialog .open(ApplyDdsReceiptDialogComponent, { - width: '780px', + width: '700px', maxWidth: '96vw', panelClass: 'apply-collection-dialog-panel', data: { @@ -799,6 +830,7 @@ export class DailyTransactionEntryComponent implements OnInit { accountNo: agent.agentSavingAccountNo || '', guarantor1: guarantors[0]?.guarantorName || '', guarantor2: guarantors[1]?.guarantorName || '', + guarantors: guarantors.map((guarantor: any) => guarantor?.guarantorName).filter((name: any) => !!name), currentBalance: agent.agentCurrentBalance || 0 }; this.agentGlCode = agent.glAccountCode || null; @@ -813,6 +845,7 @@ export class DailyTransactionEntryComponent implements OnInit { accountNo: '', guarantor1: '', guarantor2: '', + guarantors: [], currentBalance: 0 }; } @@ -890,11 +923,29 @@ export class DailyTransactionEntryComponent implements OnInit { row.get('balanceAfterTransaction')?.setValue(current + amount, { emitEvent: false }); } + /** + * The entry in the collections list the Party Information panel is showing, if one was clicked. The + * agent writes the round down on their own screen now, so there is no row here to key a party into - + * the panel is filled from the line the clerk is looking at instead. + */ + selectedEntry: any = null; + + /** Shows whose account a written-down entry is, and what its balance becomes with the entry on it. */ + showPartyOfEntry(entry: any): void { + this.selectedEntry = entry; + this.activeRow = -1; + this.partyInfo = this.agentParties.find((party) => String(party.partyCode) === String(entry?.partyCode)) || null; + } + /** The balance the party panel shows - live against whatever the active row currently holds. */ get partyBalanceAfter(): number { - if (!this.partyInfo || this.activeRow < 0) { + if (!this.partyInfo) { return 0; } + if (this.activeRow < 0) { + // No keyed row: it is the entry picked from the list that is being added to the balance. + return Number(this.partyInfo.currentBalance || 0) + Number(this.selectedEntry?.amount || 0); + } const row = this.collections.at(this.activeRow); return Number(this.partyInfo.currentBalance || 0) + Number(row?.get('amount')?.value || 0); } diff --git a/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.scss b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.scss index 3f31a5163..5ad969c95 100644 --- a/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.scss +++ b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.scss @@ -6,6 +6,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* stylelint-disable no-duplicate-selectors -- the compact-density pass below deliberately re-opens earlier rules; kept in cascade order */ + .dcd-field { width: 100%; } @@ -40,3 +42,34 @@ color: rgb(0 0 0 / 60%); } } + +// ── Compact density ────────────────────────────────────────────────────────────────────────────────── +// The same step smaller as the rest of the screen it opens from. +:host { + --mat-form-field-container-height: 38px; + --mat-form-field-container-vertical-padding: 8px; + --mat-form-field-container-text-size: 13px; + --mat-form-field-container-text-line-height: 20px; + --mat-form-field-outlined-label-text-populated-size: 11px; + --mat-form-field-subscript-text-size: 11px; + --mat-select-trigger-text-size: 13px; + + font-size: 13px; +} + +/* stylelint-disable-next-line selector-no-qualifying-type */ +h2[mat-dialog-title] { + font-size: 15px; +} + +.dcd-amount { + padding: 8px 12px; + + &__figure { + font-size: 1.1rem; + } + + &__basis { + font-size: 0.74rem; + } +} diff --git a/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.scss b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.scss index 1965dd040..0918fce6d 100644 --- a/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.scss +++ b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.scss @@ -6,6 +6,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* stylelint-disable no-duplicate-selectors -- the compact-density pass below deliberately re-opens earlier rules; kept in cascade order */ + .nd-head { display: flex; align-items: center; @@ -42,3 +44,22 @@ line-height: 1.55; color: rgb(0 0 0 / 70%); } + +// ── Compact density ────────────────────────────────────────────────────────────────────────────────── +.nd-head { + padding: 14px 20px 2px; + + &__ic { + width: 30px; + height: 30px; + } + + &__title { + font-size: 0.95rem; + } +} + +.nd-message { + font-size: 0.82rem; + line-height: 1.5; +} diff --git a/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html index 9a1b3412e..fc3137e05 100644 --- a/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html +++ b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html @@ -19,72 +19,487 @@ Loading entry…
} @else { -
- -
-
-
- {{ voucherNo || '—' }} - - {{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }} - +
+ @if (isProductReview) { + +
+
+ {{ isEdit ? 'edit_note' : 'savings' }}
-
- +
+
+ {{ + isEdit ? 'Edit request' : 'New product' + }} + {{ entry.status }} +
+

{{ valueOf('accountTypeDescription') || entry.details || 'DDS Product' }}

+
+ {{ valueOf('accountTypeCode') || '—' }} + @if (valueOf('accountTypeGroup')) { + + {{ valueOf('accountTypeGroup') }} group + } +
+
+
+ person - {{ entry.makerName || entry.maker }} ({{ entry.maker }}) + + {{ entry.makerName || entry.maker }} + {{ entry.maker }} + - + schedule - {{ formatDateTime(entry.madeOn) }} + + {{ formatDateTime(entry.madeOn) }} + Submitted + @if (entry.status !== 'PENDING' && entry.checker) { - - check_circle - Actioned by: {{ entry.checkerName || entry.checker }} + + verified_user + + {{ entry.checkerName || entry.checker }} + {{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }} + }
-
-
+ - -
-
- swap_horiz - {{ entry.type }} Summary -
-
-
- Total Amount - - {{ entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—' }} + +
+
+ percentInterest Rate + {{ valueOf('interestRate') || '—' }} + p.a., worked out daily +
+
+ event_repeatInterest Posting + + {{ valueOf('autoInterestPost') === 'No' ? 'Manual' : valueOf('autoInterestPostWhen') || 'Monthly' }} + + + {{ valueOf('autoInterestPost') === 'No' ? 'Posted from the batch screen' : 'Automatic, at period end' }}
-
- DR Accounts - {{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} +
+ lock_clockLock-in + {{ valueOf('prematureLockinMonths') || '—' }} + before early closure
-
- CR Accounts - {{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} +
+ event_availableMaturity + {{ + valueOf('maturityAutoClose') === 'Yes' ? 'Auto close' : 'Manual payout' + }} + on the maturity date
-
- Details - {{ entry.details || '—' }} +
+ + @if (compareFields) { +
+ {{ changedCount ? 'compare_arrows' : 'info' }} + @if (changedCount) { + + {{ changedCount }} term{{ changedCount !== 1 ? 's' : '' }} will change on approval - highlighted + below as current → proposed. + + } @else { + This edit changes nothing - every term matches the product as it stands. + }
- @if (detail?.description) { -
- Description - {{ detail.description }} + } + + @for (group of fieldGroups; track group.name) { +
+
+ {{ group.icon }} + {{ group.name }} + @if (group.changed) { + {{ group.changed }} changed + } +
+
+ @for (field of group.fields; track field.key) { +
+ + {{ field.label }} + @if (field.changed) { + Changed + } + + @if (field.changed) { + + {{ field.current || '—' }} + arrow_forward + + + } @else { + + } +
+ } +
+
+ } + + + @if (field.format === 'yesno' && field.value) { + + {{ field.value === 'Yes' ? 'check_circle' : 'remove_circle_outline' }} + {{ field.value }} + + } @else { + {{ + field.value || '—' + }} + } + + } @else if (isAgentReview) { + +
+
+ {{ isEdit ? 'manage_accounts' : 'person_add' }} +
+
+
+ {{ isEdit ? 'Edit request' : 'New agent' }} + {{ entry.status }} +
+

{{ valueOf('agentNameId') || entry.details || 'DDS Agent' }}

+
+ @if (valueOf('clientId')) { + Client {{ valueOf('clientId') }} + } + @if (valueOf('agentSavingAccountNo')) { + + {{ valueOf('agentSavingAccountNo') }} + } +
+
+
+ + person + + {{ entry.makerName || entry.maker }} + {{ entry.maker }} + + + + schedule + + {{ formatDateTime(entry.madeOn) }} + Submitted + + + @if (entry.status !== 'PENDING' && entry.checker) { + + verified_user + + {{ entry.checkerName || entry.checker }} + {{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }} + + + } +
+
+ + @for (group of fieldGroups; track group.name) { +
+
+ {{ group.icon }} + {{ group.name }} +
+
+ @for (field of group.fields; track field.key) { +
+ {{ field.label }} + {{ + field.value || '—' + }} +
+ } +
+
+ } + +
+
+ shield_person + Guarantors + {{ guarantors.length }} +
+ @if (guarantors.length) { + + + + + + + + + + + + @for (g of guarantors; track $index; let i = $index) { + + + + + + + + } + +
#ClientNameTelephoneAddress
{{ i + 1 }}{{ g.client || '—' }}{{ g.name || '—' }}{{ g.telephone || '—' }}{{ g.address || '—' }}
+ } @else { +
+ No guarantors named in this request.
} +
+ } @else if (isPartyReview) { + +
+
+ {{ isEdit ? 'edit_note' : 'account_balance_wallet' }} +
+
+
+ {{ + isEdit ? 'Edit request' : 'New DDS account' + }} + {{ entry.status }} +
+

{{ valueOf('partyName') || entry.details || 'DDS Account' }}

+
+ @if (valueOf('subType')) { + {{ valueOf('subType') }} + } + @if (valueOf('clientId')) { + + Client {{ valueOf('clientId') }} + } +
+
+
+ + person + + {{ entry.makerName || entry.maker }} + {{ entry.maker }} + + + + schedule + + {{ formatDateTime(entry.madeOn) }} + Submitted + + + @if (entry.status !== 'PENDING' && entry.checker) { + + verified_user + + {{ entry.checkerName || entry.checker }} + {{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }} + + + } +
+
+ + +
+
+ paymentsDaily Deposit + {{ valueOf('dailyDepositAmount') || '—' }} + collected every day +
+
+ date_rangeTerm + {{ valueOf('days') ? valueOf('days') + ' days' : '—' }} + {{ valueOf('month') ? valueOf('month') + ' months' : '' }} +
+
+ eventOpening Date + {{ valueOf('openingDate') || '—' }} + {{ + valueOf('maturityDate') ? 'Matures ' + valueOf('maturityDate') : 'account opens' + }} +
+
+ support_agentAgent + {{ valueOf('agentId') || '—' }} + {{ valueOf('officeId') || '' }} +
+
+ + @for (group of fieldGroups; track group.name) { +
+
+ {{ group.icon }} + {{ group.name }} +
+
+ @for (field of group.fields; track field.key) { +
+ {{ field.label }} + @if (field.format === 'yesno') { + + {{ field.value === 'Yes' ? 'check_circle' : 'remove_circle_outline' }} + {{ field.value }} + + } @else { + {{ field.value || '—' }} + } +
+ } +
+
+ } + +
+
+ family_restroom + Nominees + @if (detail?.nomineeSplit && nominees.length) { + Split by {{ detail.nomineeSplit }} + } + {{ nominees.length }} +
+ @if (nominees.length) { + + + + + + + + + + + + + + + @for (n of nominees; track $index; let i = $index) { + + + + + + + + + + + } + +
#NameRelationGenderMobileBirth DateGuardianShare
{{ i + 1 }}{{ n.name || '—' }}{{ n.relation || '—' }}{{ n.gender || '—' }}{{ n.mobileNo || '—' }}{{ n.birthDate || '—' }}{{ n.guardianName || '—' }}{{ n.share || '—' }}
+ } @else { +
+ No nominee named in this request. +
+ } +
+ } @else { + +
+
+
+ + @if (voucherNo || movesMoney) { + {{ voucherNo || '—' }} + } + + {{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }} + +
+
+ + person + {{ entry.makerName || entry.maker }} ({{ entry.maker }}) + + + schedule + {{ formatDateTime(entry.madeOn) }} + + @if (entry.status !== 'PENDING' && entry.checker) { + + check_circle + Actioned by: {{ entry.checkerName || entry.checker }} + + } +
+
-
+ + +
+
+ swap_horiz + {{ entry.type }} Summary +
+
+ + @if (movesMoney) { +
+ Total Amount + + {{ + entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—' + }} + +
+
+ DR Accounts + {{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} +
+
+ CR Accounts + {{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} +
+ } +
+ Details + {{ entry.details || '—' }} +
+ @if (detail?.description) { +
+ Description + {{ detail.description }} +
+ } +
+
+ } @if (debitLines.length) { @@ -225,7 +640,7 @@ } @if (entry.status === 'PENDING' && !done) { -
+
@if (!confirmReject) { -

{{ partyId ? 'Edit Party' : 'Party Master Entry' }}

+
+
+

{{ partyId ? 'Edit Party' : 'Party Master Entry' }}

+

+ {{ + partyId + ? 'Update this daily deposit party.' + : 'Open a daily deposit party: who it is for, the scheme it runs under and the agent who collects.' + }} +

+
@if (isClosed) { Closed — view only } @@ -20,7 +30,13 @@
- + + + Customer & Scheme + @if (tabHasError('customer')) { + + } +
@@ -38,35 +54,27 @@ matInput required formControlName="clientId" - [matAutocomplete]="clientAuto" - (focus)="onClientFocus()" - placeholder="Click to see customers, or type a name or ID…" + (keydown.enter)="$event.preventDefault(); openClientSearch()" + placeholder="Client ID, or search by name or mobile number…" autocomplete="off" /> - - + + @if (clientNeedsSearch) { + Press search to find this client by name or mobile number + } @else if (loadedClientName) { {{ loadedClientName }} } @else if (clientLookupFailed) { No client with this ID @@ -228,18 +236,28 @@
} +
- - - +
+ {{ + (partyId ? partyForm.get('partyCode')?.value : expectedPartyCode) || '—' + }} + {{ + partyId ? 'Assigned when this party was opened' : 'Expected - assigned when saved' + }} +
+
- - - +
+ {{ lastAccountNo || '—' }} + Latest DDS account opened +
@@ -251,7 +269,27 @@
+
+
+
+ + + + + Personal & Contact + @if (tabHasError('personal')) { + + } + +
+
+
+ +

Personal & Contact

+ Name, address, contact and identity - filled from the client +
+
@@ -427,7 +465,17 @@
+
+
+ + + Minor / Guardian + @if (tabHasError('minor')) { + + } + +
@@ -519,7 +567,17 @@

An adult's account needs no guardian details.

}
+
+ + + + Scheme Terms + @if (tabHasError('scheme')) { + + } + +
@@ -620,7 +678,17 @@
+
+
+ + + Balances & Status + @if (tabHasError('balances')) { + + } + +
@@ -761,8 +829,19 @@
+
+
- @if (creditToRequired) { + + @if (creditToRequired) { + + + Maturity Payout + @if (tabHasError('payout')) { + + } + +
@@ -821,15 +900,30 @@
- } -
-
+
+ + } - + + + Nominee's Detail + @if (tabHasError('nominee')) { + + } +
-
-
+
+ +

Nominee's Detail

+ Who the deposit is paid to if the account holder is not there to claim it +
+ + +
+
@@ -840,139 +934,172 @@
@if (nomineesRequired) { - - Percentage - Sequence - - - +
+ + + Percentage + Sequence + +
@if (splitByPercentage && nominees.length > 0) { - +
+
+ {{ nomineeShareTotal }}% of 100% allocated + @if (nomineeShareError) { + — {{ nomineeShareError }} + } +
+
+
+
+
} @else if (splitBySequence && nominees.length > 0) { - +
+
+ @if (nomineeOrderError) { + {{ nomineeOrderError }} + } @else { + Paid in the order numbered, 1 first + } +
+
} + + }
@if (!nomineesRequired) {

Set Nominee to Yes to nominate someone for this account.

+ } @else if (nominees.length === 0) { +
+ +

No nominee added yet.

+ Use Add Nominee to name who the deposit is paid to. +
} @for (nominee of nominees.controls; track nominee; let i = $index) { -
-
- - - - - - +
+
+ {{ i + 1 }} + Nominee {{ i + 1 }} + @if (isNomineeMinor(nominee)) { + Minor + } +
-
- - - - -
-
- - - - -
-
- - - - - - -
-
- - - - - From date of birth - -
-
- - - - -
-
- - - - Male - Female - Other - - -
-
- - - - @if (isNomineeMinor(nominee)) { - Required for a minor - } - -
- @if (splitByPercentage) { -
- + +
+
+ - + + +
- } @else { -
- +
+ - +
- } - +
+ + + + +
+
+ + + + + + +
+ +
+ + + + @for (relation of relationChoices(nominee); track relation) { + {{ relation }} + } + + +
+
+ + + + Male + Female + Other + + +
+
+ + + + + From date of birth + +
+ @if (splitByPercentage) { +
+ + + + +
+ } @else { +
+ + + + +
+ } + + + @if (isNomineeMinor(nominee)) { +
+ + + + Required for a minor + +
+ } +
}
@@ -1036,6 +1163,16 @@ Close Account } + @if (selectedTabIndex > 0) { + + } + @if (selectedTabIndex < tabCount - 1) { + + } + @if (!a.closeDate) { -

Agent Master Entry

+
+
+

Agent Master Entry

+

+ {{ agentId ? 'Update this collection agent.' : 'Register a collection agent, their accounts and guarantors.' }} +

+
-
- - -
-
- -

Agent Details

-
-
- - Client ID or Name - - - - - @for (client of clientOptions; track client.id) { - -
- {{ client.id }} - {{ client.displayName }} - {{ client.officeName }} -
-
- } - @if (clientListIsPartial) { - - Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest - - } -
- @if (agentForm.get('clientId')?.hasError('pattern')) { - Pick a client from the list - } @else if (clientAlreadyAgent) { - This client is already agent {{ clientAlreadyAgent }} - } @else if (clientAgeProblem) { - {{ clientAgeProblem }} - } @else if (clientOtherBranch) { - This client belongs to another branch - } @else if (clientLookupFailed) { - No client found with this ID - } @else if (loadedClientName) { - {{ loadedClientName }} - } -
+ + + + + + + + Agent Details + @if (tabHasError(0)) { + + } + +
+
+

Agent Details

+

+ Choose the branch, then find the client who will act as agent - their name, address and accounts are + filled in from them. +

+
+
+ + + Home Branch + + @for (office of offices; track office.id) { + {{ office.name }} + } + + The branch this agent's collections belong to + - - Agent Code - - - - - Agent Name ID - - - - - Agent Address (Line 1) - - - - - Agent Address (Line 2) - - - - - Agent City - - - - - Agent PinCode - - - - - Agent Telephone - - - - - - Home Branch - - @for (office of offices; track office.id) { - {{ office.name }} - } - - The branch this agent's collections belong to - - - - - GL Account - - @for (account of glAccounts; track account.id) { - {{ account.glCode }} · {{ account.name }} - } - - This agent's collections pass through it. One agent to an account. - - - - - Agent Saving A/C Type - - @for (accountType of savingsAccountTypes; track accountType) { - {{ savingsAccountTypeCodes[accountType] || accountType }} - } - - - @if (savingsAccountTypes.length === 0) { - @if (loadedClientName) { - - {{ loadedClientName }} has no savings account — commission can only be paid into one - - } @else { - Enter a Client ID first — types come from that client's own accounts - } - } - - - - - Agent Saving A/C No - - @for (account of accountsOfChosenType; track account.id) { - {{ account.accountNo }} - } - - @if (accountsOfChosenType.length === 0) { - Choose an A/C Type first - } - - - - Agent Opening Date - - - - - - - - Agent Birth Date - - - From the client's record - -
-
- -
-
- -

Guarantor's Detail

- -
- - @if (guarantors.length === 0) { -

No guarantors added yet.

- } - - @for (guarantor of guarantors.controls; track guarantor; let i = $index) { -
Client ID or Name - - - @for (client of guarantorOptionsFor(guarantor); track client.id) { - -
- {{ client.id }} - {{ client.displayName }} - {{ client.officeName }} -
-
- } -
- - @if (guarantor.get('clientId')?.hasError('selfGuarantor')) { - The agent cannot be their own guarantor - } @else if (guarantor.get('clientId')?.hasError('duplicateGuarantor')) { - This client is already a guarantor above - } @else if (guarantor.get('clientId')?.hasError('pattern')) { - Pick a client from the list + + + @if (clientNeedsSearch) { + Press search to find this client by name or mobile number + } @else if (clientAlreadyAgent) { + This client is already agent {{ clientAlreadyAgent }} + } @else if (clientAgeProblem) { + {{ clientAgeProblem }} + } @else if (clientOtherBranch) { + This client belongs to another branch + } @else if (clientLookupFailed) { + No client found with this ID + } @else if (loadedClientName) { + {{ loadedClientName }} }
- - Guarantor Name - + + +
+ Agent Code + {{ + (agentId ? agentForm.get('agentCode')?.value : expectedAgentCode) || '—' + }} + {{ + agentId ? 'Assigned when this agent was opened' : 'Expected - assigned when saved' + }} +
+ + + Agent Name ID + + + - Address 1 - + Agent Opening Date + + + {{ agentId ? 'The date this agent was opened' : 'Business date' }} + + - Address 2 - + Agent Birth Date + + + From the client's record +
+
+
+ + + + + + Address & Contact + @if (tabHasError(1)) { + + } + +
+
+

Address & Contact

+

Filled from the client; change anything that differs for the agent.

+
+
+ + Agent Address (Line 1) + + + + + Agent Address (Line 2) + + + - City - + Agent City + + - Pin Code - + Agent PinCode + + - Telephone - + Agent Telephone + -
+
+
+ + + + + + Accounts + @if (tabHasError(2)) { + + } + +
+
+

Accounts

+

The ledger account the agent's cash passes through, and the savings account commission is paid into.

+
+
+ + + GL Account + + + + + + @for (account of filteredGlAccounts; track account.id) { + {{ account.glCode }} · {{ account.name }} + } + + This agent's collections pass through it. One agent to an account. + + + + + Agent Saving A/C Type + + @for (accountType of savingsAccountTypes; track accountType) { + {{ + savingsAccountTypeCodes[accountType] || accountType + }} + } + + + @if (savingsAccountTypes.length === 0) { + @if (loadedClientName) { + + {{ loadedClientName }} has no savings account — commission can only be paid into one + + } @else { + Enter a Client ID first — types come from that client's own accounts + } + } + + + + + Agent Saving A/C No + + @for (account of accountsOfChosenType; track account.id) { + {{ account.accountNo }} + } + + @if (accountsOfChosenType.length === 0) { + Choose an A/C Type first + } + + + + @if (savingsInfoLoading) { +
Reading the account…
+ } @else if (savingsInfoFailed) { +
+ Could not read this account's balance just now. +
+ } @else if (savingsInfo) { +
+
+ Account Status + + {{ savingsInfo.status }} + +
+
+ Balance + {{ savingsInfo.symbol }}{{ savingsInfo.balance | number: '1.2-2' }} +
+
+ Hold + + {{ savingsInfo.symbol }}{{ savingsInfo.hold | number: '1.2-2' }} + +
+
+ Available Balance + {{ savingsInfo.symbol }}{{ savingsInfo.available | number: '1.2-2' }} +
+
+ } +
+
+
+ + + + + + Guarantors + @if (guarantors.length > 0) { + {{ guarantors.length }} + } + @if (tabHasError(3)) { + + } + +
+
+
+

Guarantor's Detail

+

Someone other than the agent who stands behind them. Add as many as are needed.

+
+
- } -
-
-
- -

Balances

+ @if (guarantors.length === 0) { +
+ +

No guarantors added yet.

+
+ } + + @for (guarantor of guarantors.controls; track guarantor; let i = $index) { +
+
+ {{ i + 1 }} + Guarantor {{ i + 1 }} + +
+
+ + Client ID or Name + + + + @if (guarantor.get('clientId')?.hasError('selfGuarantor')) { + The agent cannot be their own guarantor + } @else if (guarantor.get('clientId')?.hasError('duplicateGuarantor')) { + This client is already a guarantor above + } @else if (guarantor.get('clientId')?.hasError('pattern')) { + Press search to find this client by name or mobile number + } + + + Guarantor Name + + + + Telephone + + + + Address 1 + + + + Address 2 + + + + City + + + + Pin Code + + +
+
+ }
-
- - Agent Current Balance - - + - - Agent Opening Balance - - -
-
+ + @if (agentId) { + + + + Balances + @if (tabHasError(4)) { + + } + +
+
+

Balances

+

What the agent holds on the books at the start.

+
+
+ + Agent Current Balance + + -
+ + Agent Opening Balance + + +
+
+ + } + + +
+
+
+
+ @if (selectedTabIndex > 0) { + + } + @if (selectedTabIndex < tabCount - 1) { + + }
- -
+
+
diff --git a/src/app/organization/agent-master/create-agent/create-agent.component.scss b/src/app/organization/agent-master/create-agent/create-agent.component.scss index e1852abe2..39332a1d3 100644 --- a/src/app/organization/agent-master/create-agent/create-agent.component.scss +++ b/src/app/organization/agent-master/create-agent/create-agent.component.scss @@ -7,6 +7,13 @@ */ $primary: #0f766e; +$primary-dark: #0f4f3c; +$primary-light: #e6f4ee; +$danger: #b3261e; +$border: #e2e8f0; +$text-dark: #0f172a; +$text-mid: #475569; +$text-soft: #94a3b8; .am-container { max-width: 1040px; @@ -14,16 +21,41 @@ $primary: #0f766e; padding: 24px 16px; } +// ── Header ────────────────────────────────────────────────────────────────── .am-header { display: flex; align-items: center; - gap: 16px; + gap: 14px; margin-bottom: 20px; + &__icon { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 46px; + height: 46px; + border-radius: 14px; + background: linear-gradient(135deg, $primary 0%, $primary-dark 100%); + color: #fff; + font-size: 19px; + box-shadow: 0 6px 16px rgb(15 79 60 / 22%); + } + + &__text { + min-width: 0; + } + &__title { margin: 0; font-size: 22px; - font-weight: 500; + font-weight: 600; + color: $text-dark; + } + + &__sub { + margin: 2px 0 0; + font-size: 13px; + color: $text-mid; } } @@ -31,40 +63,110 @@ $primary: #0f766e; color: $primary; } -.am-card { +// ── Tabs ──────────────────────────────────────────────────────────────────── +.am-tabs { background: var(--md-sys-color-surface, #fff); - border-radius: 8px; - box-shadow: 0 2px 8px rgb(0 0 0 / 8%); - padding: 8px 24px 24px; + border: 1px solid $border; + border-radius: 14px; + box-shadow: 0 2px 10px rgb(15 23 42 / 6%); + overflow: hidden; + + ::ng-deep { + .mat-mdc-tab-header { + background: #f8fafc; + border-bottom: 1px solid $border; + } + + .mat-mdc-tab { + min-width: 100px; + padding: 0 16px; + height: 54px; + } + + .mat-mdc-tab .mdc-tab__text-label { + color: $text-mid; + font-weight: 500; + } + + .mat-mdc-tab.mdc-tab--active .mdc-tab__text-label { + color: $primary-dark; + font-weight: 600; + } + + .mat-mdc-tab .mdc-tab-indicator__content--underline { + border-color: $primary; + border-top-width: 3px; + } + + .mat-mdc-tab-body-wrapper { + min-height: 220px; + } + + // Floating labels and the date picker's panel open past the edge of a tab; do not clip them. + .mat-mdc-tab-body, + .mat-mdc-tab-body-content { + overflow: visible !important; + } + } } -.am-section { - padding: 20px 0; - border-bottom: 1px solid rgb(0 0 0 / 8%); +.am-tab-icon { + margin-right: 8px; + font-size: 14px; +} - &:last-of-type { - border-bottom: none; +.am-tab-alert { + margin-left: 8px; + font-size: 14px; + color: $danger; +} + +.am-tab-count { + display: inline-grid; + place-items: center; + min-width: 20px; + height: 20px; + margin-left: 8px; + padding: 0 6px; + border-radius: 999px; + background: $primary-light; + color: $primary-dark; + font-size: 11px; + font-weight: 700; +} + +.am-tab-content { + padding: 22px 24px 8px; +} + +.am-tab-intro { + margin-bottom: 18px; + padding-bottom: 12px; + border-bottom: 1px dashed $border; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: $text-dark; } - &__header { + p { + margin: 3px 0 0; + font-size: 13px; + color: $text-mid; + } + + &--split { display: flex; align-items: center; - gap: 10px; - margin-bottom: 16px; - - i { - color: $primary; - } - - h3 { - margin: 0; - font-size: 16px; - font-weight: 600; - flex: 1; - } + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; } } +// ── Fields ────────────────────────────────────────────────────────────────── .am-grid { display: grid; grid-template-columns: repeat(3, 1fr); @@ -77,115 +179,319 @@ $primary: #0f766e; &--wide { grid-column: span 2; } -} -.am-add-guarantor { - color: $primary; -} - -.am-empty-guarantors { - color: rgb(0 0 0 / 50%); - font-size: 14px; - margin: 0 0 8px; -} - -.am-guarantor-row { - display: grid; - grid-template-columns: repeat(7, 1fr) auto; - gap: 8px 12px; - align-items: start; - padding: 10px 0; - border-top: 1px dashed rgb(0 0 0 / 10%); - - &:first-of-type { - border-top: none; + &--full { + grid-column: 1 / -1; } } -.am-limit-row { +// How the account commission is paid into stands: status, balance, hold, available balance. +.am-acct { display: grid; - grid-template-columns: repeat(3, 1fr) auto; - gap: 8px 12px; - align-items: start; - padding: 10px 0; - border-top: 1px dashed rgb(0 0 0 / 10%); + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; + margin-bottom: 8px; + border: 1px solid $border; + border-radius: 12px; + background: #fff; + overflow: hidden; - &:first-of-type { - border-top: none; + &--note { + display: block; + padding: 14px 18px; + font-size: 13px; + color: $text-mid; + background: #f8fafc; + } + + &--error { + color: $danger; + } + + &__tile { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 18px; + border-right: 1px solid $border; + + &:last-child { + border-right: 0; + } + + &--accent { + background: $primary-light; + } + } + + &__label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: $text-soft; + } + + &__value { + font-size: 18px; + font-weight: 700; + color: $text-dark; + + &--hold { + color: #b45309; + } + } + + &__status { + display: inline-flex; + align-items: center; + gap: 7px; + width: fit-content; + padding: 3px 12px 3px 9px; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + background: #f1f5f9; + color: $text-mid; + + i { + font-size: 8px; + } + + &--ok { + background: #dcfce7; + color: #15803d; + } + + &--wait { + background: #fef3c7; + color: #b45309; + } + + &--off { + background: #fee2e2; + color: #b91c1c; + } } } -.am-remove-guarantor { - color: #b91c1c; - margin-top: 8px; +@media (width <= 720px) { + .am-acct { + grid-template-columns: repeat(2, minmax(0, 1fr)); + + &__tile:nth-child(2) { + border-right: 0; + } + + &__tile:nth-child(-n + 2) { + border-bottom: 1px solid $border; + } + } } -.am-action-bar { +// The agent code, shown rather than asked for: it is assigned when the agent is saved. +.am-code { display: flex; - justify-content: flex-end; - gap: 12px; - margin-top: 20px; - padding-top: 16px; - flex-wrap: wrap; + flex-direction: column; + justify-content: center; + gap: 1px; + min-height: 56px; + margin-bottom: 20px; + padding: 6px 16px; + border: 1px solid #b7ddd0; + border-radius: 4px; + background: $primary-light; + + &__label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: $text-mid; + } + + &__value { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace; + font-size: 17px; + font-weight: 700; + letter-spacing: 0.04em; + color: $primary-dark; + } + + &__hint { + font-size: 11.5px; + color: $text-mid; + } } /* Client ID lookup came back empty — the officer typed an id that doesn't exist. */ .am-hint--error { - color: #b3261e; + color: $danger; +} + +// The search icon is a button: it opens Search Customer. +.am-search-btn { + margin-right: 8px; + padding: 6px 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: rgb(0 0 0 / 55%); + font-size: 1rem; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; + + &:hover:not(:disabled) { + background: $primary-light; + color: $primary-dark; + } + + &:disabled { + cursor: default; + opacity: 0.4; + } +} + +// ── Guarantors ────────────────────────────────────────────────────────────── +.am-add-guarantor { + color: $primary; +} + +.am-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + margin-bottom: 16px; + padding: 28px 16px; + border: 1.5px dashed #cbd5e1; + border-radius: 12px; + background: #f8fafc; + color: $text-soft; + + i { + font-size: 28px; + } +} + +.am-empty-guarantors { + margin: 0; + font-size: 14px; + color: $text-mid; +} + +.am-guarantor-row { + margin-bottom: 16px; + padding: 14px 16px 6px; + border: 1px solid $border; + border-radius: 12px; + background: #fff; + box-shadow: 0 1px 3px rgb(15 23 42 / 5%); +} + +.am-guarantor-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.am-guarantor-no { + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 50%; + background: $primary-light; + color: $primary-dark; + font-size: 12px; + font-weight: 700; +} + +.am-guarantor-title { + flex: 1; + font-size: 14px; + font-weight: 600; + color: $text-dark; +} + +.am-guarantor-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px 16px; +} + +.am-remove-guarantor { + padding: 6px 9px; + border: 0; + border-radius: 8px; + background: transparent; + color: $danger; + cursor: pointer; + transition: background 0.15s; + + &:hover { + background: #fef2f2; + } +} + +// ── Action bar ────────────────────────────────────────────────────────────── +.am-action-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-top: 18px; + padding: 14px 0; + border-top: 1px solid rgb(0 0 0 / 8%); + + &__left, + &__right { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + } +} + +.am-btn-ic { + margin-right: 8px; + font-size: 12px; + + &--after { + margin-right: 0; + margin-left: 8px; + } } @media (width <= 900px) { - .am-grid { + .am-grid, + .am-guarantor-grid { grid-template-columns: repeat(2, 1fr); } - .am-guarantor-row, - .am-limit-row { - grid-template-columns: repeat(2, 1fr); + .am-tab-content { + padding: 18px 14px 4px; } } -/* One row of the client search list: the id, the name, and the branch - as on the Party form. */ -.am-opt { - display: flex; - align-items: baseline; - gap: 10px; - overflow: hidden; -} +@media (width <= 560px) { + .am-grid, + .am-guarantor-grid { + grid-template-columns: 1fr; + } -.am-opt-more { - font-size: 0.75rem; - font-style: italic; - opacity: 0.75; -} + .am-field--wide, + .am-field--full { + grid-column: span 1; + } -.am-opt-id { - flex: 0 0 auto; - min-width: 52px; - font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; - font-size: 0.78rem; - color: #0f766e; -} - -.am-opt-main { - flex: 1 1 auto; - min-width: 8rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.am-opt-sub { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 0.75rem; - color: rgb(0 0 0 / 50%); -} - -.am-search-ic { - margin-right: 8px; - color: rgb(0 0 0 / 40%); + .am-action-bar__left, + .am-action-bar__right { + width: 100%; + justify-content: flex-end; + } } diff --git a/src/app/organization/agent-master/create-agent/create-agent.component.ts b/src/app/organization/agent-master/create-agent/create-agent.component.ts index 0847ae6a6..14b97e687 100644 --- a/src/app/organization/agent-master/create-agent/create-agent.component.ts +++ b/src/app/organization/agent-master/create-agent/create-agent.component.ts @@ -7,9 +7,10 @@ */ /** Angular Imports */ -import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormBuilder, + UntypedFormControl, UntypedFormGroup, UntypedFormArray, Validators, @@ -26,6 +27,7 @@ import { OrganizationService } from 'app/organization/organization.service'; import { AccountingService } from 'app/accounting/accounting.service'; import { SystemService } from 'app/system/system.service'; import { ClientsService } from 'app/clients/clients.service'; +import { SavingsService } from 'app/savings/savings.service'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; import { AlertService } from 'app/core/alert/alert.service'; @@ -33,10 +35,19 @@ import { AuthenticationService } from 'app/core/authentication/authentication.se /** Custom Directives */ import { RestrictInputDirective } from 'app/directives/restrict-input.directive'; -import { MatAutocompleteModule, MatAutocompleteTrigger } from '@angular/material/autocomplete'; -import { searchClientsAnyCase } from 'app/deposits/party-master/client-search'; +import { MatDialog } from '@angular/material/dialog'; +import { MatTabsModule } from '@angular/material/tabs'; +import { NgxMatSelectSearchModule } from 'ngx-mat-select-search'; +import { businessToday } from 'app/core/utils/business-today'; +import { + CustomerSearchDialogComponent, + CustomerSearchResult +} from 'app/shared/customer-search-dialog/customer-search-dialog.component'; import { officesUnder } from '../offices-under'; +/** The chart's account type for a liability: 1 asset, 2 liability, 3 equity, 4 income, 5 expense. */ +const AGENT_GL_TYPE_LIABILITY = 2; + /** Writes a value into a form group only when there is something to write. */ function setGroupField(group: UntypedFormGroup, controlName: string, value: any): void { if (value === null || value === undefined || value === '') { @@ -67,7 +78,8 @@ function setGroupField(group: UntypedFormGroup, controlName: string, value: any) ...STANDALONE_SHARED_IMPORTS, // See the Party form: an unimported directive is ignored without a word. RestrictInputDirective, - MatAutocompleteModule + MatTabsModule, + NgxMatSelectSearchModule ] }) export class CreateAgentComponent implements OnInit { @@ -76,17 +88,14 @@ export class CreateAgentComponent implements OnInit { private route = inject(ActivatedRoute); private organizationService = inject(OrganizationService); private clientsService = inject(ClientsService); + private savingsService = inject(SavingsService); private accountingService = inject(AccountingService); private systemService = inject(SystemService); private settingsService = inject(SettingsService); private dateUtils = inject(Dates); private alertService = inject(AlertService); private authenticationService = inject(AuthenticationService); - private changeDetector = inject(ChangeDetectorRef); - - /** The agent's client field and its list, so a list that arrives late can still be opened. */ - @ViewChild('clientTrigger') clientTrigger?: MatAutocompleteTrigger; - @ViewChild('clientInput') clientInput?: ElementRef; + private dialog = inject(MatDialog); /** * The branch the officer is logged in to. An agent is opened there and nowhere else: the Home @@ -118,11 +127,6 @@ export class CreateAgentComponent implements OnInit { private static readonly MIN_AGENT_AGE = 16; private static readonly MAX_AGENT_AGE = 60; - /** The agent's client list without the clients who are already agents. */ - private notYetAgents(clients: any[]): any[] { - return clients.filter((client: any) => !this.agentCodeByClientId[String(client.id)]); - } - agentId: number | null = null; /** Shown under the Client ID field once a client has been resolved. */ @@ -130,17 +134,99 @@ export class CreateAgentComponent implements OnInit { clientLookupFailed = false; /** - * Clients to pick from, as on the Party form: the first page when the field is focused, then - * whatever matches what has been typed. An officer knows the agent's name, rarely their client id. + * Set when what was typed in the Client ID field is not an ID - a name, or a mobile number - so the + * field can say to search for it rather than calling it a client that does not exist. */ - clientOptions: any[] = []; - private initialClients: any[] = []; - private static readonly CLIENT_LIST_SIZE = 50; - clientTotal = 0; - showingClientList = false; + clientNeedsSearch = false; - get clientListIsPartial(): boolean { - return this.showingClientList && this.clientTotal > this.clientOptions.length; + /** + * The tabs the form is set out over - Agent Details, Address & Contact, Branch & Accounts, + * Guarantors, Balances - and the one showing. Every field is part of the one form whichever tab + * it is on, so moving between tabs loses and changes nothing. + */ + selectedTabIndex = 0; + + /** Balances is the fifth tab, and only an agent already on the books has one. */ + get tabCount(): number { + return this.agentId ? 5 : 4; + } + + /** The controls each tab holds, in tab order, so a tab can say it still needs attention. */ + private static readonly TAB_CONTROLS: string[][] = [ + [ + 'officeId', + 'clientId', + 'agentCode', + 'agentNameId', + 'agentOpeningDate', + 'agentBirthDate' + ], + [ + 'agentAddress1', + 'agentAddress2', + 'agentCity', + 'agentPinCode', + 'agentTelephone' + ], + [ + 'glAccountId', + 'agentSavingAccountType', + 'agentSavingAccountNo' + ], + [], + [ + 'agentCurrentBalance', + 'agentOpeningBalance' + ] + ]; + + /** + * Whether a tab holds something to put right: a field that is invalid and has been visited, or - on + * the first tab - a client who cannot be made an agent. Display only; Save is decided as before. + */ + tabHasError(index: number): boolean { + const visited = (control: AbstractControl | null) => + !!control && control.invalid && (control.touched || control.dirty); + if (index === 3) { + return this.guarantors.controls.some((row) => { + const group = row as UntypedFormGroup; + return Object.values(group.controls).some((control) => visited(control)); + }); + } + const controls = CreateAgentComponent.TAB_CONTROLS[index] ?? []; + const flagged = controls.some((name) => visited(this.agentForm.get(name))); + if (index === 0) { + return ( + flagged || + this.clientNeedsSearch || + !!this.clientAlreadyAgent || + !!this.clientAgeProblem || + this.clientOtherBranch + ); + } + return flagged; + } + + /** An Indian mobile number is ten digits; a client ID is never that long. */ + private static readonly MOBILE_NUMBER_LENGTH = 10; + + /** + * The code the next agent is expected to get, shown in place of an Agent Code field - the code is + * assigned by the server when the agent is saved, never keyed. It is worked out the way the server + * does it: "AG" and one more than the highest number among every agent on the books, closed ones + * included. An estimate only - another agent saved first, or one waiting for approval, can take it. + */ + expectedAgentCode = ''; + + private static nextAgentCode(agents: any[]): string { + let highest = 0; + for (const agent of agents) { + const match = /^AG(\d+)$/.exec(String(agent?.agentCode ?? '')); + if (match) { + highest = Math.max(highest, Number(match[1])); + } + } + return `AG${String(highest + 1).padStart(4, '0')}`; } /** @@ -175,6 +261,31 @@ export class CreateAgentComponent implements OnInit { */ glAccounts: any[] = []; + /** + * What is typed in the GL Account list's search box. The list narrows to the accounts whose code or + * name contains it, as the account pickers on the product screens do. Only what is shown changes - + * the field still holds the account's id. + */ + glAccountSearch = new UntypedFormControl(''); + + get filteredGlAccounts(): any[] { + const search = String(this.glAccountSearch.value ?? '') + .trim() + .toLowerCase(); + if (!search) { + return this.glAccounts; + } + return this.glAccounts.filter( + (account: any) => + String(account.glCode ?? '') + .toLowerCase() + .includes(search) || + String(account.name ?? '') + .toLowerCase() + .includes(search) + ); + } + /** * The savings product a DDS customer's own scheme account is opened under, read from the setting * that names it rather than guessed from a product name a bank is free to change. @@ -202,9 +313,10 @@ export class CreateAgentComponent implements OnInit { clientId: [ '', // A name typed to search with is not a client; only an id picked or keyed saves. + // Up to nine digits - a mobile number is ten, so one typed in full is not mistaken for an id. [ Validators.required, - Validators.pattern(/^\d+$/) + Validators.pattern(/^\d{1,9}$/) ] ], agentCode: [ @@ -246,8 +358,10 @@ export class CreateAgentComponent implements OnInit { '', Validators.required ], + // A new agent is opened on the bank's business date, not the computer's clock; it can still be + // changed, and an agent being edited keeps the date it was opened on. agentOpeningDate: [ - '', + businessToday(), Validators.required ], // Filled from the client, whose date of birth was checked when they were opened. @@ -271,13 +385,11 @@ export class CreateAgentComponent implements OnInit { }); ngOnInit(): void { - // A different branch has different clients: the list is read again, and a client already keyed - // is checked against the new branch. + // A client already keyed is checked against a different branch when that is chosen. this.agentForm .get('officeId') ?.valueChanges.pipe(distinctUntilChanged()) .subscribe(() => { - this.initialClients = []; if (!this.agentId && this.agentForm.get('clientId')?.value) { this.loadClient(); } @@ -291,6 +403,7 @@ export class CreateAgentComponent implements OnInit { this.agentCodeByClientId[String(agent.clientId)] = agent.agentCode; } } + this.expectedAgentCode = CreateAgentComponent.nextAgentCode(agents || []); }, error: () => (this.agentCodeByClientId = {}) }); @@ -325,7 +438,14 @@ export class CreateAgentComponent implements OnInit { const rows = Array.isArray(accounts) ? accounts : accounts?.pageItems || []; // usage 1 is a detail account, 2 a header. Disabled accounts refuse every posting, so an // agent pointed at one would fail on their first collection rather than here. - this.glAccounts = rows.filter((a: any) => a.usage?.id === 1 && !a.disabled); + // + // Liability accounts only (type 2). The cashier credits this account with the cash an agent + // hands over and Apply DDS Receipt debits it as the money is placed on the customers, so what + // stands on it is held for others - a liability. An asset, income or expense account would + // book that money as the bank's own. + this.glAccounts = rows.filter( + (a: any) => a.usage?.id === 1 && !a.disabled && a.type?.id === AGENT_GL_TYPE_LIABILITY + ); }, error: () => (this.glAccounts = []) }); @@ -342,22 +462,63 @@ export class CreateAgentComponent implements OnInit { ?.valueChanges.pipe(debounceTime(500), distinctUntilChanged()) .subscribe((value) => { const typed = String(value ?? '').trim(); - // A name is something to search for, not a client yet - the fields stay until one is picked. - if (typed && !/^\d+$/.test(typed)) { - this.searchClients(typed); + // A name or a mobile number is something to search for, not a client yet. Nothing is looked + // up as the officer types - the search button does that - so the fields stay until one is + // picked, and no client is asked for on a guess. + this.clientNeedsSearch = this.needsSearch(typed); + if (this.clientNeedsSearch) { return; } - if (!typed) { - this.showClientList(); - } this.loadClient(); // A guarantor row that was fine may now be the agent itself, or the other way round. for (const row of this.guarantors.controls) { row.get('clientId')?.updateValueAndValidity(); - // And the agent's own client leaves (or rejoins) each row's list. - if (!row.get('clientId')?.value) { - this.setGuarantorOptions(row, this.eligibleGuarantors(row, this.initialClients)); - } + } + }); + } + + /** + * Whether what was typed is something to search for rather than a client ID: letters, which make it + * a name, or digits as long as a mobile number. + */ + private needsSearch(typed: string): boolean { + if (!typed) { + return false; + } + return !/^\d+$/.test(typed) || typed.length >= CreateAgentComponent.MOBILE_NUMBER_LENGTH; + } + + /** + * Opens Search Customer - the same dialog, and the same customer search API, the cash screens use - + * to find the agent's client by ID, name or mobile number, and puts the one picked into the field, + * which then fills the rest as an ID keyed in would. + */ + openClientSearch(): void { + if (this.agentId) { + return; + } + this.openCustomerSearch((result) => this.agentForm.get('clientId')?.setValue(String(result.clientId))); + } + + /** The same search for a guarantor row, which fills that row from the client picked. */ + openGuarantorSearch(group: AbstractControl): void { + this.openCustomerSearch((result) => group.get('clientId')?.setValue(String(result.clientId)), group); + } + + private openCustomerSearch(picked: (result: CustomerSearchResult) => void, row?: AbstractControl): void { + const typed = String((row ?? this.agentForm).get('clientId')?.value ?? '').trim(); + this.dialog + .open(CustomerSearchDialogComponent, { + width: '960px', + maxWidth: '95vw', + // An agent or guarantor is a customer first - they need not hold an account to be picked, and + // are listed once however many accounts they have. What was already typed is searched for. + data: { requireAccount: false, uniqueCustomers: true, prefill: typed } + }) + .afterClosed() + .subscribe((result: CustomerSearchResult | undefined) => { + if (result?.clientId) { + picked(result); } }); } @@ -372,7 +533,7 @@ export class CreateAgentComponent implements OnInit { '', [ Validators.required, - Validators.pattern(/^\d+$/), + Validators.pattern(/^\d{1,9}$/), this.notTheAgentItself(), this.notAlreadyAGuarantor() ] @@ -398,26 +559,16 @@ export class CreateAgentComponent implements OnInit { }); // A guarantor is a client too, so its row fills from the client the same way the agent's does. - // Searched by name like the agent's own client field; a picked or keyed id fills the row. + // A keyed id fills the row; a name or mobile number is found with the row's search button. group .get('clientId') ?.valueChanges.pipe(debounceTime(500), distinctUntilChanged()) .subscribe((value) => { const typed = String(value ?? '').trim(); - if (typed && !/^\d+$/.test(typed)) { - searchClientsAnyCase(this.clientsService, typed).subscribe((found: any[]) => { - this.setGuarantorOptions(group, this.eligibleGuarantors(group, found).slice(0, 25)); - const panel = (group as any).panel; - this.openWhenFocused(panel?.trigger, panel?.input); - }); + if (this.needsSearch(typed)) { return; } - this.setGuarantorOptions(group, []); this.loadGuarantorClient(group, typed); - if (!typed) { - // Emptied: the list comes back rather than leaving nothing to pick from. - this.onGuarantorFocus(group); - } // Two rows naming the same client: the other row's check has to be run again too. for (const row of this.guarantors.controls) { if (row !== group) { @@ -429,59 +580,6 @@ export class CreateAgentComponent implements OnInit { return group; } - /** - * Each guarantor row's list lives on the row itself, as a plain array the template reads - the same - * way the agent's own list works - so one row's typing never fills another's. - */ - private setGuarantorOptions(group: AbstractControl, options: any[]): void { - (group as any).clientOptions = options; - } - - guarantorOptionsFor(group: AbstractControl): any[] { - return (group as any).clientOptions || GUARANTOR_NO_OPTIONS; - } - - /** - * Opens the client list on a guarantor row as soon as it is focused, as the agent's field does. - * The list is filled before the panel is asked to open, since a panel opened empty stays shut. - */ - onGuarantorFocus(group: AbstractControl, trigger?: MatAutocompleteTrigger, input?: HTMLElement): void { - if (trigger) { - // Kept so a search answered later can open this row's list too. - (group as any).panel = { trigger, input }; - } - if (group.get('clientId')?.value) { - return; - } - const show = () => { - this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients)); - const panel = (group as any).panel; - this.openWhenFocused(panel?.trigger, panel?.input); - }; - if (this.initialClients.length > 0) { - show(); - return; - } - this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({ - next: (response: any) => { - const found = response?.pageItems || response || []; - this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : []; - this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length; - show(); - }, - error: () => undefined - }); - } - - /** Clients who can stand guarantor on this row: not the agent, and not already on another row. */ - private eligibleGuarantors(group: AbstractControl, found: any[]): any[] { - const agentClientId = String(this.agentForm.get('clientId')?.value ?? ''); - const taken = new Set( - this.guarantors.controls.filter((row) => row !== group).map((row) => String(row.get('clientId')?.value ?? '')) - ); - return found.filter((client: any) => String(client.id) !== agentClientId && !taken.has(String(client.id))); - } - /** The same client cannot guarantee one agent twice - it counts as one guarantee, not two. */ private notAlreadyAGuarantor(): ValidatorFn { return (control: AbstractControl): ValidationErrors | null => { @@ -551,8 +649,6 @@ export class CreateAgentComponent implements OnInit { addGuarantor(): void { const group = this.buildGuarantorGroup(); this.guarantors.push(group); - // Ready before the field is ever focused, so the list is there the moment it is clicked. - this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients)); } removeGuarantor(index: number): void { @@ -573,60 +669,6 @@ export class CreateAgentComponent implements OnInit { 'agentPinCode' ]; - /** Opens the client list when the field is focused, unless a client is already in it. */ - onClientFocus(): void { - if (this.agentId || this.agentForm.get('clientId')?.value) { - return; - } - this.showClientList(); - } - - /** Shows the first page of clients, fetching it the first time it is asked for. */ - private showClientList(): void { - if (this.agentId) { - return; - } - if (this.initialClients.length > 0) { - this.showingClientList = true; - this.clientOptions = this.notYetAgents(this.initialClients); - this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement); - return; - } - // The chosen branch's clients only, since an agent is opened for the branch they belong to. - this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({ - next: (response: any) => { - const found = response?.pageItems || response || []; - this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : []; - this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length; - this.showingClientList = true; - this.clientOptions = this.notYetAgents(this.initialClients); - this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement); - }, - // A failed list is not a failed screen - the id can still be typed. - error: () => (this.clientOptions = []) - }); - } - - private searchClients(term: string): void { - searchClientsAnyCase(this.clientsService, term, this.branchOfficeId).subscribe((found: any[]) => { - this.showingClientList = false; - this.clientOptions = this.notYetAgents(found).slice(0, 25); - this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement); - }); - } - - /** - * Shows a list that has just arrived. An autocomplete opened while its list was still empty stays - * shut when the options come in, so the screen is refreshed and the panel opened again - but only - * on the field still being typed in, never one the officer has moved on from. - */ - private openWhenFocused(trigger?: MatAutocompleteTrigger, input?: HTMLElement): void { - this.changeDetector.detectChanges(); - if (trigger && input && document.activeElement === input) { - trigger.openPanel(); - } - } - /** * Refills the client-derived fields whenever the Client ID changes, as the legacy screen did. * They always show the client currently entered: changing the id replaces them, clearing it @@ -645,6 +687,8 @@ export class CreateAgentComponent implements OnInit { this.savingsAccountTypes = []; this.agentForm.get('agentSavingAccountType')?.setValue(''); this.agentForm.get('agentSavingAccountNo')?.setValue(''); + this.savingsInfo = null; + this.savingsInfoFailed = false; this.clientOtherBranch = false; this.clientAlreadyAgent = ''; @@ -722,6 +766,9 @@ export class CreateAgentComponent implements OnInit { this.savingsAccountTypeCodes[account.productName] = account.shortProductName; } } + // An agent being edited already has an account chosen: show how it stands as soon as the + // client's accounts are known. + this.showSavingsAccountInfo(this.agentForm.get('agentSavingAccountNo')?.value); }, error: () => { this.clientSavingsAccounts = []; @@ -738,6 +785,63 @@ export class CreateAgentComponent implements OnInit { */ onSavingsAccountTypePicked(): void { this.agentForm.get('agentSavingAccountNo')?.setValue(''); + this.savingsInfo = null; + } + + /** + * How the account commission is paid into stands: its status, balance, what is on hold, and what + * is left to use. Read from the account itself, the same figures the cashier's screen shows for it, + * so the officer sees whether it can take the credit before saving rather than when a run fails. + * Display only - nothing here is saved or checked. + */ + savingsInfo: { + status: string; + statusClass: 'ok' | 'wait' | 'off'; + symbol: string; + balance: number; + hold: number; + available: number; + } | null = null; + savingsInfoLoading = false; + savingsInfoFailed = false; + + onSavingsAccountPicked(): void { + this.showSavingsAccountInfo(this.agentForm.get('agentSavingAccountNo')?.value); + } + + private showSavingsAccountInfo(accountNo: string | null | undefined): void { + this.savingsInfo = null; + this.savingsInfoFailed = false; + this.savingsInfoLoading = false; + const match = accountNo ? this.clientSavingsAccounts.find((account: any) => account.accountNo === accountNo) : null; + if (!match) { + return; + } + this.savingsInfoLoading = true; + this.savingsService.getSavingsAccountData(String(match.id)).subscribe({ + next: (account: any) => { + // The account may have been swapped for another while this was on its way. + if (this.agentForm.get('agentSavingAccountNo')?.value !== accountNo) { + return; + } + const status = account?.status ?? {}; + this.savingsInfo = { + status: status.value || '—', + statusClass: status.active ? 'ok' : status.submittedAndPendingApproval || status.approved ? 'wait' : 'off', + symbol: account?.currency?.displaySymbol ?? '₹', + balance: Number(account?.summary?.accountBalance ?? 0), + // Savings carries a lien or hold as a figure on the account itself, and its available + // balance is already worked out with every hold taken off - as on the cashier's screen. + hold: Number(account?.savingsAmountOnHold ?? 0), + available: Number(account?.summary?.availableBalance ?? account?.summary?.accountBalance ?? 0) + }; + this.savingsInfoLoading = false; + }, + error: () => { + this.savingsInfoLoading = false; + this.savingsInfoFailed = true; + } + }); } private loadClientAddress(clientId: string): void { @@ -790,9 +894,14 @@ export class CreateAgentComponent implements OnInit { this.agentForm.reset(); this.agentForm.patchValue({ agentCurrentBalance: 0, - agentOpeningBalance: 0 + agentOpeningBalance: 0, + agentOpeningDate: businessToday() }); this.guarantors.clear(); + this.savingsInfo = null; + this.savingsInfoFailed = false; + // Back to the first tab, where a fresh entry begins. + this.selectedTabIndex = 0; } exit(): void { @@ -910,9 +1019,6 @@ function asDate(value: any): Date | null { return null; } -/** One shared empty list, so a row with nothing to offer hands the template the same array each time. */ -const GUARANTOR_NO_OPTIONS: any[] = []; - /** Full years between a date of birth and a day, or null without a date of birth. */ function ageOn(birthDate: Date | null, on: Date): number | null { if (!birthDate) { diff --git a/src/app/organization/agent-master/view-agent/view-agent.component.html b/src/app/organization/agent-master/view-agent/view-agent.component.html new file mode 100644 index 000000000..de277c187 --- /dev/null +++ b/src/app/organization/agent-master/view-agent/view-agent.component.html @@ -0,0 +1,193 @@ + + +
+ + + @if (loading) { +
Loading agent…
+ } @else if (notFound || !agent) { +
This agent could not be found.
+ } @else { + +
+
+
+
+
DDS Agent
+

{{ displayName }}

+
+ {{ agent.agentCode }} + @if (agent.homeBranch) { + {{ agent.homeBranch }} + } + @if (agent.closeDate) { + + + Closed {{ agent.closeDate | dateFormat }} + + } @else { + + + {{ agent.active ? 'Active' : 'Inactive' }} + + } +
+
+
+ + @if (!agent.closeDate) { + + } +
+ + +
+
+
+

Details

+
+
+
+ Agent Code + {{ agent.agentCode }} +
+
+ Client + {{ agent.clientName || '—' }} +
+
+ Agent Name ID + {{ agent.agentNameId || '—' }} +
+
+ Home Branch + {{ agent.homeBranch || '—' }} +
+
+ Opening Date + {{ agent.agentOpeningDate ? (agent.agentOpeningDate | dateFormat) : '—' }} +
+
+ Birth Date + {{ agent.agentBirthDate ? (agent.agentBirthDate | dateFormat) : '—' }} +
+
+ Created + + {{ agent.createdDate ? (agent.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }} + +
+
+ Last Modified + + {{ agent.lastModifiedDate ? (agent.lastModifiedDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }} + +
+
+
+ + +
+
+
+

Address & Contact

+
+
+
+ Address + {{ address || '—' }} +
+
+ City + {{ agent.agentCity || '—' }} +
+
+ Pin Code + {{ agent.agentPinCode || '—' }} +
+
+ Telephone + {{ agent.agentTelephone || '—' }} +
+
+
+ + +
+
+
+

Accounts & Balances

+
+
+
+ GL Account + + @if (agent.glAccountCode) { + {{ agent.glAccountCode }} · {{ agent.glAccountName }} + } @else { + — + } + +
+
+ Saving A/C Type + {{ agent.agentSavingAccountType || '—' }} +
+
+ Saving A/C No + {{ agent.agentSavingAccountNo || '—' }} +
+
+ Current Balance + {{ money(agent.agentCurrentBalance) }} +
+
+ Opening Balance + {{ money(agent.agentOpeningBalance) }} +
+
+
+ + +
+
+
+

Guarantors ({{ agent.guarantors?.length || 0 }})

+
+ @for (guarantor of agent.guarantors || []; track $index) { +
+
+ Guarantor {{ $index + 1 }} + {{ guarantor.guarantorName || '—' }} +
+
+ Telephone + {{ guarantor.telephone || '—' }} +
+
+ Address + {{ guarantorAddress(guarantor) || '—' }} +
+
+ } @empty { +
No guarantors on file.
+ } +
+ } +
diff --git a/src/app/organization/agent-master/view-agent/view-agent.component.scss b/src/app/organization/agent-master/view-agent/view-agent.component.scss new file mode 100644 index 000000000..da0604079 --- /dev/null +++ b/src/app/organization/agent-master/view-agent/view-agent.component.scss @@ -0,0 +1,51 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// Only what the agent view adds to the fixed deposit product sheet it shares: a way back to the list and a +// placeholder while the agent loads. + +.va-page { + padding-top: 8px; +} + +.va-back { + display: inline-flex; + align-items: center; + gap: 8px; + align-self: flex-start; + padding: 6px 12px; + border: 1px solid var(--vsp-g200); + border-radius: 8px; + background: #fff; + color: var(--vsp-g700); + font: inherit; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease; + + &:hover { + background: var(--vsp-g100); + border-color: var(--vsp-g300); + } +} + +.va-state { + padding: 32px 0; + text-align: center; + color: var(--vsp-g500); + font-size: 14px; +} + +.va-empty { + padding: 14px 0 4px; + color: var(--vsp-g500); + font-size: 13px; +} diff --git a/src/app/organization/agent-master/view-agent/view-agent.component.ts b/src/app/organization/agent-master/view-agent/view-agent.component.ts new file mode 100644 index 000000000..1289bd617 --- /dev/null +++ b/src/app/organization/agent-master/view-agent/view-agent.component.ts @@ -0,0 +1,103 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { OrganizationService } from 'app/organization/organization.service'; + +/** + * A DDS collection agent, read-only: who the agent is, where they live, the accounts they collect against, their + * balances and the guarantors standing behind them. Laid out as the DDS product view is laid out - and drawn by the same + * stylesheet - so every master reads the same way. Edit leads to the same form the list does, and is not offered on an + * agent that has been closed. + */ +@Component({ + selector: 'mifosx-view-agent', + templateUrl: './view-agent.component.html', + styleUrls: [ + '../../../products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.scss', + './view-agent.component.scss' + ], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + RouterLink, + FaIconComponent + ] +}) +export class ViewAgentComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private organizationService = inject(OrganizationService); + + agent: any = null; + loading = true; + notFound = false; + + ngOnInit(): void { + const id = this.route.snapshot.paramMap.get('agentId'); + if (!id) { + this.router.navigate(['/organization/agent-master']); + return; + } + this.organizationService.getAgent(id).subscribe({ + next: (agent: any) => { + this.agent = agent; + this.loading = false; + }, + error: () => { + this.loading = false; + this.notFound = true; + } + }); + } + + private static readonly MONEY = new Intl.NumberFormat('en-IN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + + money(value: any): string { + return ViewAgentComponent.MONEY.format(Number(value || 0)); + } + + /** The agent's name as the list shows it: the name on file, falling back to the client's. */ + get displayName(): string { + return this.agent?.agentNameId || this.agent?.clientName || this.agent?.agentCode || ''; + } + + /** The address on one line, skipping whatever was left blank. */ + get address(): string { + return [ + this.agent?.agentAddress1, + this.agent?.agentAddress2, + this.agent?.agentCity, + this.agent?.agentPinCode + ] + .filter((part) => !!part) + .join(', '); + } + + /** A guarantor's address on one line, skipping whatever was left blank. */ + guarantorAddress(guarantor: any): string { + return [ + guarantor?.address1, + guarantor?.address2, + guarantor?.city, + guarantor?.pinCode + ] + .filter((part) => !!part) + .join(', '); + } + + back(): void { + this.router.navigate(['/organization/agent-master']); + } +} diff --git a/src/app/organization/organization-routing.module.ts b/src/app/organization/organization-routing.module.ts index 40dadf490..6dfb6f46f 100644 --- a/src/app/organization/organization-routing.module.ts +++ b/src/app/organization/organization-routing.module.ts @@ -19,6 +19,7 @@ import { LoanProvisioningCriteriaComponent } from './loan-provisioning-criteria/ import { OfficesComponent } from './offices/offices.component'; import { AgentMasterComponent } from './agent-master/agent-master.component'; import { CreateAgentComponent } from './agent-master/create-agent/create-agent.component'; +import { ViewAgentComponent } from './agent-master/view-agent/view-agent.component'; import { CommissionSlabMasterComponent } from './commission-slab-master/commission-slab-master.component'; import { DdsBatchProcessingComponent } from './dds-batch-processing/dds-batch-processing.component'; import { EmployeesComponent } from './employees/employees.component'; @@ -254,6 +255,11 @@ const routes: Routes = [ component: CreateAgentComponent, data: { title: 'Create Agent', breadcrumb: 'Create' } }, + { + path: ':agentId', + component: ViewAgentComponent, + data: { title: 'View Agent', breadcrumb: 'View', routeParamBreadcrumb: false } + }, { path: ':agentId/edit', component: CreateAgentComponent, diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts index 2d63e6728..50550df61 100644 --- a/src/app/organization/organization.service.ts +++ b/src/app/organization/organization.service.ts @@ -272,6 +272,11 @@ export class OrganizationService { return this.http.get('/dds/parties'); } + /** The code the next DDS party is expected to get - { partyCode: 'PT0005' } - to show before saving. */ + getNextPartyCode(): Observable { + return this.http.get('/dds/parties/nextcode'); + } + /** * The parties a given agent collects for — what the daily transaction screen works from, since * it handles one agent's round at a time. diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html index 3e0eff78f..0f34fac75 100644 --- a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html +++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html @@ -61,16 +61,10 @@ {{ option }} } + + Interest posts on the last day of each month, quarter, half-year or year - - @if (autoPostOn) { - - Auto Interest Post Day - - - } - + +
+ + + @if (loading) { +
Loading product…
+ } @else if (notFound || !accountType) { +
This DDS product could not be found.
+ } @else { + +
+
+
+
+
DDS Product
+

{{ accountType.accountTypeDescription }}

+
+ {{ accountType.accountTypeCode }} + @if (accountType.accountTypeGroup) { + {{ accountType.accountTypeGroup }} + } + + + {{ accountType.active ? 'Active' : 'Inactive' }} + +
+
+
+ +
+ + +
+
+
+

Details

+
+
+
+ Account Type Code + {{ accountType.accountTypeCode }} +
+
+ Account Type Group + {{ accountType.accountTypeGroup || '—' }} +
+
+ Description + {{ accountType.accountTypeDescription || '—' }} +
+
+ Created + + {{ accountType.createdDate ? (accountType.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }} + +
+
+ Last Modified + + {{ + accountType.lastModifiedDate ? (accountType.lastModifiedDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' + }} + +
+
+
+ + +
+
+
+

Interest

+
+
+
+ Interest Rate + {{ percent(accountType.interestRate) }} + Worked out daily on the balance (rate ÷ 365) +
+
+ Auto Interest Post + + + {{ accountType.autoInterestPost ? 'Yes' : 'No' }} + + +
+ @if (accountType.autoInterestPost) { +
+ Auto Interest Post When + {{ accountType.autoInterestPostWhen || 'Monthly' }} + Posts on the last day of each period +
+ } +
+
+ + +
+
+
+

Maturity & Premature Closure

+
+
+
+ Maturity Auto Close + + + {{ accountType.maturityAutoClose ? 'Yes' : 'No' }} + + + Closes on the maturity date, if it names a savings account to be paid into +
+
+ Premature Lock-in + {{ lockinLabel(accountType.prematureLockinMonths) }} + Below this, early closure is refused outright +
+
+ Premature Interest Reduction + {{ percent(accountType.prematureInterestReduction) }} + Taken off the customer's rate when they bear the cost of closing early +
+
+ Agent Commission Penalty + {{ percent(accountType.prematureAgentCommissionPenalty) }} + Taken off the agent's commission on an early closure +
+
+
+ } +
diff --git a/src/app/products/dds-account-types/view-dds-account-type/view-dds-account-type.component.scss b/src/app/products/dds-account-types/view-dds-account-type/view-dds-account-type.component.scss new file mode 100644 index 000000000..1f9dd9229 --- /dev/null +++ b/src/app/products/dds-account-types/view-dds-account-type/view-dds-account-type.component.scss @@ -0,0 +1,53 @@ +/** + * 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/. + */ + +// Only what the DDS view adds to the fixed deposit product sheet it shares: a way back to the +// list, a line of explanation under a term, and a placeholder while the product loads. + +.vdt-page { + padding-top: 8px; +} + +.vdt-back { + display: inline-flex; + align-items: center; + gap: 8px; + align-self: flex-start; + padding: 6px 12px; + border: 1px solid var(--vsp-g200); + border-radius: 8px; + background: #fff; + color: var(--vsp-g700); + font: inherit; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; + + &:hover { + background: var(--vsp-g100); + color: var(--vsp-g900); + } +} + +.vdt-note { + font-size: 11.5px; + line-height: 1.4; + color: var(--vsp-g500); +} + +.vdt-state { + padding: 48px 16px; + text-align: center; + color: var(--vsp-g500); + background: #fff; + border: 1px solid var(--vsp-g200); + border-radius: 12px; +} diff --git a/src/app/products/dds-account-types/view-dds-account-type/view-dds-account-type.component.ts b/src/app/products/dds-account-types/view-dds-account-type/view-dds-account-type.component.ts new file mode 100644 index 000000000..dc1f10903 --- /dev/null +++ b/src/app/products/dds-account-types/view-dds-account-type/view-dds-account-type.component.ts @@ -0,0 +1,83 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { ProductsService } from 'app/products/products.service'; + +/** + * A DDS product, read-only: the terms an account opened under it takes, laid out as the savings, + * fixed and recurring deposit product views lay theirs out - and drawn by the same stylesheet, so a + * checker or clerk reads every product the same way. Edit leads to the same form the list does. + */ +@Component({ + selector: 'mifosx-view-dds-account-type', + templateUrl: './view-dds-account-type.component.html', + styleUrls: [ + '../../fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.scss', + './view-dds-account-type.component.scss' + ], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + RouterLink, + FaIconComponent + ] +}) +export class ViewDdsAccountTypeComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private productsService = inject(ProductsService); + + accountType: any = null; + loading = true; + notFound = false; + + ngOnInit(): void { + const id = this.route.snapshot.paramMap.get('accountTypeId'); + if (!id) { + this.router.navigate(['/products/dds-account-types']); + return; + } + this.productsService.getDdsAccountType(id).subscribe({ + next: (accountType: any) => { + this.accountType = accountType; + this.loading = false; + }, + error: () => { + this.loading = false; + this.notFound = true; + } + }); + } + + /** 12 → "12 months", 1 → "1 month", 0 → "None - may be closed from day one". */ + lockinLabel(months: number | null | undefined): string { + if (months === null || months === undefined) { + return '—'; + } + if (months === 0) { + return 'None - may be closed from day one'; + } + return `${months} month${months === 1 ? '' : 's'}`; + } + + /** A rate or percentage as typed, without the trailing zeros the database pads it with. */ + percent(value: number | string | null | undefined): string { + if (value === null || value === undefined || value === '') { + return '—'; + } + return `${Number(value)}%`; + } + + back(): void { + this.router.navigate(['/products/dds-account-types']); + } +} diff --git a/src/app/products/products-routing.module.ts b/src/app/products/products-routing.module.ts index 888d4de1c..435578663 100644 --- a/src/app/products/products-routing.module.ts +++ b/src/app/products/products-routing.module.ts @@ -32,6 +32,7 @@ import { RecurringDepositProductsComponent } from './recurring-deposit-products/ import { ViewRecurringDepositProductComponent } from './recurring-deposit-products/view-recurring-deposit-product/view-recurring-deposit-product.component'; import { DdsAccountTypesComponent } from './dds-account-types/dds-account-types.component'; import { CreateDdsAccountTypeComponent } from './dds-account-types/create-dds-account-type/create-dds-account-type.component'; +import { ViewDdsAccountTypeComponent } from './dds-account-types/view-dds-account-type/view-dds-account-type.component'; import { ChargesComponent } from './charges/charges.component'; import { ViewChargeComponent } from './charges/view-charge/view-charge.component'; import { CreateChargeComponent } from './charges/create-charge/create-charge.component'; @@ -756,6 +757,12 @@ const routes: Routes = [ path: ':accountTypeId/edit', component: CreateDdsAccountTypeComponent, data: { title: 'Edit DDS Product', breadcrumb: 'Edit', routeParamBreadcrumb: false } + }, + { + // After 'create', so that word is never read as a product id. + path: ':accountTypeId', + component: ViewDdsAccountTypeComponent, + data: { title: 'View DDS Product', breadcrumb: 'View', routeParamBreadcrumb: false } } ] }, diff --git a/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts b/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts index 4f47b4539..a285b0acd 100644 --- a/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts +++ b/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts @@ -37,10 +37,24 @@ export interface CustomerSearchDialogData { * need one; a screen that only needs the customer (cash exchange) passes false. */ requireAccount?: boolean; + /** + * Whether each customer is listed once. The search returns a row per account, which an account + * screen wants; a screen that is choosing the customer itself (an agent, a guarantor) wants each + * person once however many accounts they hold. + */ + uniqueCustomers?: boolean; + /** + * What the caller had already typed, to search for on opening. All digits is read as a client ID, + * or a mobile number once it is as long as one; anything else is a name. + */ + prefill?: string; } type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit'; +/** An Indian mobile number is ten digits; a client ID is never that long. */ +const MOBILE_NUMBER_LENGTH = 10; + @Component({ selector: 'mifosx-customer-search-dialog', standalone: true, @@ -77,6 +91,19 @@ type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit
+ + Client ID + badge + + + Name person @@ -84,6 +111,20 @@ type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit + Mobile Number + call + + + + Account Number account_balance_wallet @@ -266,12 +307,13 @@ type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit width: 100%; } - .csd-field--name { + .csd-field--name, + .csd-field--wide { grid-column: span 2; } .csd-field--address { - grid-column: span 3; + grid-column: 1 / -1; } .csd-upper { @@ -522,6 +564,7 @@ type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit } .csd-field--name, + .csd-field--wide, .csd-field--address { grid-column: span 1; } @@ -550,7 +593,9 @@ export class CustomerSearchDialogComponent implements OnInit { ngOnInit(): void { this.form = this.fb.group({ + clientId: [''], name: [''], + mobileNo: [''], accountNumber: [''], aadhaarNumber: [''], panNumber: [''], @@ -560,6 +605,36 @@ export class CustomerSearchDialogComponent implements OnInit { fixedDeposit: [false], recurringDeposit: [false] }); + + // What the caller had typed already is searched for straight away, so opening the dialog from a + // half-filled field does not mean typing it a second time. + const typed = (this.data?.prefill ?? '').trim(); + if (typed) { + const field = this.fieldFor(typed); + this.form.get(field)?.setValue(typed); + this.onSearch(); + } + } + + /** + * Which field something typed belongs in. Digits alone are a client ID - they are short - until + * they are as long as a mobile number, and anything with letters in it is a name. + */ + private fieldFor(typed: string): 'clientId' | 'mobileNo' | 'name' { + if (!/^\d+$/.test(typed)) { + return 'name'; + } + return typed.length >= MOBILE_NUMBER_LENGTH ? 'mobileNo' : 'clientId'; + } + + /** Keeps a numeric field to digits however it was filled - typed, pasted or dropped. */ + digitsOnly(name: string): void { + const control = this.form.get(name); + const value = String(control?.value ?? ''); + const digits = value.replace(/\D/g, ''); + if (value !== digits) { + control?.setValue(digits, { emitEvent: false }); + } } /** The account screens need an account to open; a caller that only needs the customer opts out. */ @@ -627,14 +702,25 @@ export class CustomerSearchDialogComponent implements OnInit { if (v.fixedDeposit) accountTypes.push('FIXED_DEPOSIT'); if (v.recurringDeposit) accountTypes.push('RECURRING_DEPOSIT'); - if (!v.name && !v.accountNumber && !v.aadhaarNumber && !v.panNumber && !v.address && accountTypes.length === 0) { + if ( + !v.clientId && + !v.name && + !v.mobileNo && + !v.accountNumber && + !v.aadhaarNumber && + !v.panNumber && + !v.address && + accountTypes.length === 0 + ) { this.snackBar.open('Enter at least one search field, or check an account type.', 'Close', { duration: 3000 }); return; } this.loading = true; let params = new HttpParams(); + if (v.clientId) params = params.set('clientId', v.clientId); if (v.name) params = params.set('name', v.name); + if (v.mobileNo) params = params.set('mobileNo', v.mobileNo); if (v.accountNumber) params = params.set('accountNumber', v.accountNumber); if (v.aadhaarNumber) params = params.set('aadhaarNumber', v.aadhaarNumber); if (v.panNumber) params = params.set('panNumber', v.panNumber); @@ -643,7 +729,7 @@ export class CustomerSearchDialogComponent implements OnInit { this.http.get('/customer-search', { params }).subscribe({ next: (data) => { - this.results = data || []; + this.results = this.data?.uniqueCustomers ? this.onePerCustomer(data || []) : data || []; this.searched = true; this.loading = false; }, @@ -654,9 +740,17 @@ export class CustomerSearchDialogComponent implements OnInit { }); } + /** The search answers per account; one row per customer is the first of each person's rows. */ + private onePerCustomer(rows: CustomerSearchResult[]): CustomerSearchResult[] { + const seen = new Set(); + return rows.filter((row) => !seen.has(row.clientId) && !!seen.add(row.clientId)); + } + onClear(): void { this.form.reset({ + clientId: '', name: '', + mobileNo: '', accountNumber: '', aadhaarNumber: '', panNumber: '', diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/agent-approvals/agent-approvals.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/agent-approvals/agent-approvals.component.html new file mode 100644 index 000000000..bee14ea61 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/agent-approvals/agent-approvals.component.html @@ -0,0 +1,125 @@ + + +
+ @if (dataSource.data.length > 0) { +
+ + search + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Request + {{ + requestLabel(row) + }} + Agent + {{ row.agentName || '—' }} + +
{{ row.agentCode || 'Code on approval' }}
+
Client{{ row.clientName || '—' }}Branch{{ row.officeName || '—' }}Saving A/C{{ row.savingAccountNo || '—' }}Guarantors{{ row.guarantorCount ?? 0 }}Submitted By{{ row.makerName || row.maker }}Submitted On{{ formatDate(row.submittedOn) }}Status + {{ row.status }} + Actions +
+ + + + +
+
+
+ } @else if (loading) { +
+

Loading agent requests…

+
+ } @else { +
+ +

No agent requests waiting

+

There are no agents awaiting approval at this time.

+
+ } +
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/agent-approvals/agent-approvals.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/agent-approvals/agent-approvals.component.scss new file mode 100644 index 000000000..ad50f16cb --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/agent-approvals/agent-approvals.component.scss @@ -0,0 +1,30 @@ +/** + * 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/. + */ + +// What the Agent Approval tab adds to the Product Approval sheet it shares. + +// View, Approve and Reject sit on one line: the column takes the width its buttons need and the +// other columns - whose names and addresses can wrap - give way. +.aa-actions { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 6px; + white-space: nowrap; + + .psp-btn { + flex: 0 0 auto; + margin-right: 0; + } +} + +th.mat-column-actions, +td.mat-column-actions { + width: 1%; + white-space: nowrap; +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/agent-approvals/agent-approvals.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/agent-approvals/agent-approvals.component.ts new file mode 100644 index 000000000..bdc6c631e --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/agent-approvals/agent-approvals.component.ts @@ -0,0 +1,175 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { + MatTableDataSource, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIcon } from '@angular/material/icon'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OrganizationService } from 'app/organization/organization.service'; +import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { DdsApprovalReviewComponent } from 'app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component'; +import { ddsMadeOn } from '../../product-entities'; + +/** + * Approvals › Agent Approval: every DDS agent request waiting on a checker - an agent being opened, + * changed or closed. + * + * An agent has no voucher and no amount, so the money columns of DDS Approvals say nothing about one. + * This lists them by what identifies an agent - who they are, which branch, which savings account the + * commission goes into, how many guarantors - and opens the same review the DDS queue uses, which + * shows the agent and their guarantors in full and approves or rejects through the DDS queue. + */ +@Component({ + selector: 'mifosx-agent-approvals', + templateUrl: './agent-approvals.component.html', + // The same look as Product Approval, so the tabs of this screen read alike. + styleUrls: [ + '../pending-savings-products/pending-savings-products.component.scss', + './agent-approvals.component.scss' + ], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatIcon + ] +}) +export class AgentApprovalsComponent implements OnInit { + private organizationService = inject(OrganizationService); + private authenticationService = inject(AuthenticationService); + private dialog = inject(MatDialog); + + private readonly currentUserId = this.authenticationService.getCredentials()?.userId; + + dataSource = new MatTableDataSource([]); + loading = true; + + displayedColumns: string[] = [ + 'request', + 'agent', + 'client', + 'branch', + 'savingAccount', + 'guarantors', + 'maker', + 'madeOn', + 'status', + 'actions' + ]; + + ngOnInit(): void { + this.dataSource.filterPredicate = (row: any, filter: string) => + [ + row.agentName, + row.agentCode, + row.clientName, + row.officeName, + row.savingAccountNo, + row.type, + row.maker, + row.makerName + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + .includes(filter); + this.load(); + } + + load(): void { + this.loading = true; + this.organizationService + .getDdsApprovals('PENDING') + .pipe(catchError(() => of([]))) + .subscribe((rows: any) => { + const agents = (Array.isArray(rows) ? rows : []).filter((row: any) => row?.entity === 'AGENT'); + this.dataSource.data = agents.map((row: any) => ({ ...row, submittedOn: ddsMadeOn(row.madeOn) })); + this.loading = false; + }); + } + + applyFilter(value: string = ''): void { + this.dataSource.filter = value.trim().toLowerCase(); + } + + /** What the request is, as the badge reads it. */ + requestLabel(row: any): string { + return row.action === 'CREATE' ? 'New' : row.action === 'CLOSE' ? 'Close' : 'Edit'; + } + + isMine(row: any): boolean { + return !!this.currentUserId && Number(row.makerId) === Number(this.currentUserId); + } + + formatDate(date: Date | null): string { + return date ? date.toLocaleString('en-IN') : ''; + } + + /** Opens the agent in full - the client, address, branch, accounts and guarantors the request carries. */ + view(row: any): void { + this.dialog + .open(DdsApprovalReviewComponent, { + data: { entry: row, currentUserId: this.currentUserId }, + autoFocus: false + }) + .afterClosed() + .subscribe((done: boolean) => { + if (done) { + this.load(); + } + }); + } + + /** + * Whether this user may approve or reject the request. It is the same act the review and DDS + * Approvals perform, through the DDS queue, which refuses whoever made the request - so it is not + * offered to them here either. + */ + canAct(row: any): boolean { + return !this.isMine(row); + } + + /** + * Approve and Reject open the agent in full rather than acting at once: a request is never actioned + * without its client, address, branch, accounts and guarantors on screen. The review carries the + * Approve and Reject buttons, and asks before rejecting. + */ + approve(row: any): void { + this.view(row); + } + + reject(row: any): void { + this.view(row); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts index 6d64536f9..f789b0552 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts @@ -40,7 +40,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatCheckbox } from '@angular/material/checkbox'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; -import { isProductEntity, isTempTableAccountEdit } from '../../product-entities'; +import { isDdsRoutedRequest, isProductEntity, isTempTableAccountEdit } from '../../product-entities'; @Component({ selector: 'mifosx-checker-inbox', @@ -118,7 +118,11 @@ export class CheckerInboxComponent implements OnInit { */ private excludeRoutedElsewhere(items: any[]): any[] { return (items || []).filter( - (item: any) => !isProductEntity(item?.entityName) && !isTempTableAccountEdit(item?.entityName, item?.actionName) + (item: any) => + !isProductEntity(item?.entityName) && + !isTempTableAccountEdit(item?.entityName, item?.actionName) && + // DDS product and account requests are listed under Product Approval and Pending Account. + !isDdsRoutedRequest(item?.entityName, item?.actionName) ); } diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-account/pending-account.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-account/pending-account.component.html index c34911dca..78d8d18dd 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-account/pending-account.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-account/pending-account.component.html @@ -61,7 +61,10 @@ Account No - {{ account.accountNo }} + + + {{ account.accountNo || (account.source === 'DDS' ? 'On approval' : '') }} + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-account/pending-account.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-account/pending-account.component.ts index aea0233d3..465ab4756 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-account/pending-account.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-account/pending-account.component.ts @@ -35,6 +35,9 @@ import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.componen import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; import { DatepickerBase } from 'app/shared/form-dialog/formfield/model/datepicker-base'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OrganizationService } from 'app/organization/organization.service'; +import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { DdsApprovalReviewComponent } from 'app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component'; @Component({ selector: 'mifosx-pending-account', @@ -64,6 +67,8 @@ export class PendingAccountComponent { private dateUtils = inject(Dates); private settingsService = inject(SettingsService); private tasksService = inject(TasksService); + private organizationService = inject(OrganizationService); + private authenticationService = inject(AuthenticationService); dataSource: MatTableDataSource; selection: SelectionModel; @@ -110,7 +115,27 @@ export class PendingAccountComponent { this.dataSource.filter = filterValue.trim().toLowerCase(); } + /** A DDS account opening or edit, which waits in - and is actioned through - the DDS approval queue. */ + private isDds(account: any): boolean { + return account?.source === 'DDS'; + } + navigateToAccount(account: any): void { + if (this.isDds(account)) { + // A new DDS account has no page until it is approved, so it opens the DDS review instead. + this.dialog + .open(DdsApprovalReviewComponent, { + data: { entry: account.ddsEntry, currentUserId: this.authenticationService.getCredentials()?.userId }, + autoFocus: false + }) + .afterClosed() + .subscribe((done: boolean) => { + if (done) { + this.reload(); + } + }); + return; + } const clientId = account.clientId; const accountId = account.id; const depositTypeId = account.depositType?.id; @@ -190,7 +215,9 @@ export class PendingAccountComponent { selected.forEach((account: any) => { const depositTypeId = account.depositType?.id; let reject$; - if (account.accountType === 'Current') { + if (this.isDds(account)) { + reject$ = this.organizationService.actOnDdsApproval(account.id, 'reject'); + } else if (account.accountType === 'Current') { reject$ = this.tasksService.rejectCurrentAccount(account.id, payload); } else if (depositTypeId === 200) { reject$ = this.tasksService.rejectFDAccount(account.id, payload); @@ -223,6 +250,10 @@ export class PendingAccountComponent { * approving from inside an individual account (see e.g. ApproveSavingsAccountComponent). */ private approveAndActivate$(account: any, approvedOnDate: string, dateFormat: string, locale: string) { + // A DDS account opens active once its request is approved; there is no separate activation step. + if (this.isDds(account)) { + return this.organizationService.actOnDdsApproval(account.id, 'approve'); + } const approvePayload = { approvedOnDate, dateFormat, locale }; const activatePayload = { activatedOnDate: approvedOnDate, dateFormat, locale }; const depositTypeId = account.depositType?.id; diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts index 13f137f29..e54a649d5 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts @@ -35,6 +35,8 @@ import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield- import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { productTypeDisplayLabel } from '../../product-entities'; +import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { DdsApprovalReviewComponent } from 'app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component'; @Component({ selector: 'mifosx-pending-savings-products', @@ -62,6 +64,7 @@ export class PendingSavingsProductsComponent { private dialog = inject(MatDialog); private productsService = inject(ProductsService); private tasksService = inject(TasksService); + private authenticationService = inject(AuthenticationService); dataSource: MatTableDataSource; showData = false; @@ -173,6 +176,10 @@ export class PendingSavingsProductsComponent { * renders the submitted payload and can action the entry itself. */ viewDiff(row: any): void { + if (this.isDds(row)) { + this.openDdsReview(row); + return; + } if (!this.isPendingEdit(row)) { this.dialog .open(ViewCheckerInboxComponent, { @@ -212,7 +219,35 @@ export class PendingSavingsProductsComponent { this.loadingDiffFor = null; } + /** A DDS product request, which is read and actioned through the DDS approval queue. */ + isDds(row: any): boolean { + return row?.source === 'DDS'; + } + + /** + * The DDS review shows the product's terms - against today's on an edit - and approves or rejects + * it there, refusing the maker as the DDS queue does. Approve and Reject on the row open it too, so + * a DDS product is never actioned without its terms on screen. + */ + private openDdsReview(row: any): void { + this.dialog + .open(DdsApprovalReviewComponent, { + data: { entry: row.ddsEntry, currentUserId: this.authenticationService.getCredentials()?.userId }, + autoFocus: false + }) + .afterClosed() + .subscribe((done: boolean) => { + if (done) { + this.reload(); + } + }); + } + approve(row: any): void { + if (this.isDds(row)) { + this.openDdsReview(row); + return; + } const isEdit = this.isPendingEdit(row); const dialogRef = this.dialog.open(ConfirmationDialogComponent, { data: { @@ -234,6 +269,10 @@ export class PendingSavingsProductsComponent { } reject(row: any): void { + if (this.isDds(row)) { + this.openDdsReview(row); + return; + } if (!this.isPendingEdit(row)) { // The native /makercheckers reject endpoint takes no payload, so there is nowhere to store a // reason - ask for plain confirmation rather than collecting text that would be discarded. diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index 19e6096d1..b325968cb 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -271,6 +271,13 @@ {{ count('dds-approvals') }} } + + + Agent Approval + @if (count('agent-approvals')) { + {{ count('agent-approvals') }} + } + { private tasksService = inject(TasksService); + private organizationService = inject(OrganizationService); resolve(): Observable { return forkJoin({ savings: this.tasksService.getPendingSavingsAccounts().pipe(catchError(() => of({ pageItems: [] }))), current: this.tasksService.getPendingCurrentAccounts().pipe(catchError(() => of([]))), - offices: this.tasksService.getAllOffices().pipe(catchError(() => of([]))) + offices: this.tasksService.getAllOffices().pipe(catchError(() => of([]))), + dds: this.organizationService.getDdsApprovals('PENDING').pipe(catchError(() => of([]))) }).pipe( map((results) => { const officeMap: Record = {}; @@ -39,9 +43,34 @@ export class GetPendingAccounts implements Resolve { (a: any) => ({ ...a, accountType: 'Current' }) ); + /* + * DDS account openings and edits wait in the DDS approval queue rather than as submitted + * accounts, so they are laid onto the same columns here. The status filter in the component + * reads status.id 100, which these carry so they are kept like any other pending account. + */ + const ddsItems = (Array.isArray(results.dds) ? results.dds : []) + .filter((entry: any) => isDdsAccountEntry(entry)) + .map((entry: any) => ({ + id: entry.id, + source: 'DDS', + ddsEntry: entry, + accountNo: entry.accountNo || '', + clientName: entry.clientName || entry.details, + productName: entry.productName, + accountType: entry.action === 'UPDATE' ? 'DDS · Edit' : 'DDS', + officeName: entry.officeName || '', + fieldOfficerName: entry.agentName || '', + timeline: { submittedOnDate: ddsMadeOn(entry.madeOn) }, + status: { + id: 100, + value: entry.action === 'UPDATE' ? 'Edit pending approval' : 'Submitted and pending approval' + } + })); + return { pageItems: [ ...savingsItems, - ...currentItems + ...currentItems, + ...ddsItems ] }; }) ); diff --git a/src/app/tasks/common-resolvers/getPendingSavingsProducts.resolver.ts b/src/app/tasks/common-resolvers/getPendingSavingsProducts.resolver.ts index ebd61654d..daae4746c 100644 --- a/src/app/tasks/common-resolvers/getPendingSavingsProducts.resolver.ts +++ b/src/app/tasks/common-resolvers/getPendingSavingsProducts.resolver.ts @@ -11,8 +11,15 @@ import { Resolve } from '@angular/router'; import { Observable, forkJoin, of } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { ProductsService } from 'app/products/products.service'; +import { OrganizationService } from 'app/organization/organization.service'; import { TasksService } from '../tasks.service'; -import { isProductEntity, isTempTableProductEdit, PRODUCT_ENTITY_TYPE_LABEL } from '../product-entities'; +import { + ddsMadeOn, + isDdsProductEntry, + isProductEntity, + isTempTableProductEdit, + PRODUCT_ENTITY_TYPE_LABEL +} from '../product-entities'; /** * Builds the Approvals -> Product Approval list from the two pending-request sources the platform @@ -32,6 +39,7 @@ import { isProductEntity, isTempTableProductEdit, PRODUCT_ENTITY_TYPE_LABEL } fr export class GetPendingSavingsProducts implements Resolve { private productsService = inject(ProductsService); private tasksService = inject(TasksService); + private organizationService = inject(OrganizationService); resolve(): Observable { return forkJoin({ @@ -42,7 +50,8 @@ export class GetPendingSavingsProducts implements Resolve { rd: this.productsService.getPendingRecurringDepositProductEdits().pipe(catchError(() => of([]))), // includeJson so a CREATE row - which has no resourceId, and therefore no product to look up - // can still show the submitted product name. - makerChecker: this.tasksService.getMakerCheckerData({ includeJson: true }).pipe(catchError(() => of([]))) + makerChecker: this.tasksService.getMakerCheckerData({ includeJson: true }).pipe(catchError(() => of([]))), + dds: this.organizationService.getDdsApprovals('PENDING').pipe(catchError(() => of([]))) }).pipe( map((results) => [ ...this.asEditRows(results.savings, 'SAVINGS'), @@ -50,11 +59,31 @@ export class GetPendingSavingsProducts implements Resolve { ...this.asEditRows(results.share, 'SHARE'), ...this.asEditRows(results.fd, 'FD'), ...this.asEditRows(results.rd, 'RD'), - ...this.asMakerCheckerRows(results.makerChecker) + ...this.asMakerCheckerRows(results.makerChecker), + ...this.asDdsRows(results.dds) ]) ); } + /** DDS product requests, from the DDS queue - which is also how they are viewed and actioned. */ + private asDdsRows(entries: any): any[] { + return (Array.isArray(entries) ? entries : []) + .filter((entry: any) => isDdsProductEntry(entry)) + .map((entry: any) => ({ + id: entry.id, + source: 'DDS', + entityName: entry.entity, + actionName: entry.action, + productType: 'DDS', + savingsProductId: entry.resourceId ?? null, + savingsProductName: entry.details || entry.type, + makerUsername: entry.maker, + createdOn: ddsMadeOn(entry.madeOn), + status: 'PENDING', + ddsEntry: entry + })); + } + /** Staged product edits, unchanged apart from the source/action tags the merged table needs. */ private asEditRows(edits: any[], productType: string): any[] { return (edits || []).map((edit: any) => ({ diff --git a/src/app/tasks/product-entities.ts b/src/app/tasks/product-entities.ts index 90a5457f6..e8cef8209 100644 --- a/src/app/tasks/product-entities.ts +++ b/src/app/tasks/product-entities.ts @@ -56,6 +56,53 @@ export const TEMP_TABLE_ACCOUNT_ENTITIES: readonly string[] = [ 'CURRENTACCOUNT' ]; +/** + * DDS keeps its own approval queue (/dds/approvals), which also describes each entry in the clerk's + * terms. Its product and account requests are listed in Product Approval and Pending Account as well, + * so a checker finds them where every other product and account waits - but they are read from, and + * actioned through, that DDS queue rather than /makercheckers. + */ +export function isDdsProductEntry(entry: any): boolean { + return isDdsRoutedRequest(entry?.entity, entry?.action) && entry?.entity === 'DDSACCOUNTTYPE'; +} + +export function isDdsAccountEntry(entry: any): boolean { + return isDdsRoutedRequest(entry?.entity, entry?.action) && entry?.entity === 'PARTY'; +} + +/** + * Whether a command is a DDS product or DDS account create/edit - one listed under Product Approval or + * Pending Account, and so left out of DDS Approvals and the Checker Inbox, which would otherwise show + * the same request a second and third time. + */ +export function isDdsRoutedRequest( + entityName: string | undefined | null, + actionName: string | undefined | null +): boolean { + const entity = (entityName ?? '').toUpperCase(); + // An agent request of any kind waits under Agent Approval: an agent has no voucher and no amount, + // so the money columns of DDS Approvals say nothing about one. + if (entity === 'AGENT') { + return true; + } + return [ + 'DDSACCOUNTTYPE', + 'PARTY' + ].includes(entity) && [ + 'CREATE', + 'UPDATE' + ].includes((actionName ?? '').toUpperCase()); +} + +/** The DDS queue's "2026-10-27 11:04" as a Date, for the tables' date columns. */ +export function ddsMadeOn(madeOn: string | undefined | null): Date | null { + if (!madeOn) { + return null; + } + const d = new Date(String(madeOn).replace(' ', 'T')); + return isNaN(d.getTime()) ? null : d; +} + /** Short type badge shown in the Product Approval table, matching the pending-edit productType tags. */ export const PRODUCT_ENTITY_TYPE_LABEL: Record = { LOANPRODUCT: 'LOAN', @@ -111,7 +158,8 @@ export const PRODUCT_TYPE_DISPLAY_LABEL: Record = { FD: 'FIXED DEPOSIT PRODUCT', 'FIXED DEPOSIT': 'FIXED DEPOSIT PRODUCT', RD: 'RECURRING DEPOSIT PRODUCT', - 'RECURRING DEPOSIT': 'RECURRING DEPOSIT PRODUCT' + 'RECURRING DEPOSIT': 'RECURRING DEPOSIT PRODUCT', + DDS: 'DDS PRODUCT' }; /** Label shown in the Product Approval Type column. Unmapped types render unchanged. */ diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index 79fc431f9..3d3e4277a 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -56,6 +56,7 @@ import { GetPendingCurrentAccountEdits } from './common-resolvers/getPendingCurr import { GetPendingShareAccountEdits } from './common-resolvers/getPendingShareAccountEdits.resolver'; import { GetPendingShareAccounts } from './common-resolvers/getPendingShareAccounts.resolver'; import { DdsApprovalsComponent } from '../deposits/dds-approvals/dds-approvals.component'; +import { AgentApprovalsComponent } from './checker-inbox-and-tasks-tabs/agent-approvals/agent-approvals.component'; /** Tasks Routes */ const routes: Routes = [ @@ -235,6 +236,11 @@ const routes: Routes = [ component: DdsApprovalsComponent, data: { title: 'DDS Approvals' } }, + { + path: 'agent-approvals', + component: AgentApprovalsComponent, + data: { title: 'Agent Approval' } + }, { path: 'chequebook-requests', component: ChequebookRequestsComponent,