From 3c363d037226424f8b0b02c985048841c093e7c0 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 18 Sep 2026 18:56:34 +0530 Subject: [PATCH] feat(shares): one-voucher-per-slot receipt dialogs, Apply Additional Shares as a popup Apply Collection and Apply Additional Shares settle the same Share Collection receipts for the same member, so they are now deliberately the same screen. Apply Collection takes exactly two receipts and no more: one for the share value, one for the entry fee, in two separate slots. They are different things -- the share receipt is money moving into share capital, while the fee receipt only proves the fee was already collected and is never posted a second time -- and picking both out of one ticked list made that distinction invisible. Apply Additional Shares moves from its own route to a dialog. It was a full page a clerk had to leave the account for and come back from, to fill in a short form with the member standing at the counter. Its old route, resolver case and action- host branch are removed rather than left behind as dead paths. Both screens now draw their receipts from a single pool, whether a receipt already names the member or is a voucher paid straight into a GL account; an unattributed one is attached at the moment it is picked, so what used to be an Attach in one table and a tick in another is one deliberate click. The by-voucher search and the GL-entry detail both sit behind buttons, as exceptions rather than permanent furniture. Dialog chrome and the receipt shape are shared (share-dialog.shared.scss, share-receipt-option.ts) so the two cannot drift apart again. Also fixes content being clipped at the dialog edges: the wrapper cancelled a 24px surface padding that the panel class had already zeroed, pushing every edge outside the surface where overflow:hidden cut it off, and the height cap sat on the scrolling body rather than the wrapper, letting the footer stack past the bottom of the viewport. Adds the product-wise share summary report (authorised, issued, available and unpaid shares, distinctive range and account count per product). Co-Authored-By: Claude Opus 5 (1M context) --- src/app/cashier/cashier.service.ts | 21 +- src/app/core/shell/shell.component.html | 3 + .../share-account-actions.resolver.ts | 1 - .../share-product-summary.component.html | 168 +++ .../share-product-summary.component.ts | 168 +++ .../shares/reports/share-report.shared.scss | 17 + src/app/shares/share-dialog.shared.scss | 996 +++++++++++++ src/app/shares/share-receipt-option.ts | 85 ++ .../apply-shares/apply-shares.component.html | 953 ++++-------- .../apply-shares/apply-shares.component.scss | 1274 ++--------------- .../apply-shares/apply-shares.component.ts | 768 +++++----- .../shares-account-actions.component.html | 3 - .../shares-account-actions.component.ts | 4 - .../apply-collection-dialog.component.html | 921 +++++------- .../apply-collection-dialog.component.scss | 1060 +------------- .../apply-collection-dialog.component.ts | 578 +++----- .../shares-account-view.component.ts | 63 +- src/app/shares/shares-routing.module.ts | 8 + src/app/shares/shares.module.ts | 2 - src/app/shares/shares.service.ts | 8 + src/environments/.env.ts | 2 +- src/main.scss | 32 +- 22 files changed, 2906 insertions(+), 4229 deletions(-) create mode 100644 src/app/shares/reports/share-product-summary/share-product-summary.component.html create mode 100644 src/app/shares/reports/share-product-summary/share-product-summary.component.ts create mode 100644 src/app/shares/share-dialog.shared.scss create mode 100644 src/app/shares/share-receipt-option.ts diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index bab2c1ff7..3078701f8 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -391,20 +391,17 @@ export class CashierService { } /** - * Approved share-collection receipts that name no member yet. + * Everything an Apply screen needs, in one call: `own` is this member's own unapplied receipts, + * ready to tick; `rows` is every other Share Collection receipt still available — unclaimed GL + * deposits and receipts linked to another member — which attach by voucher instead. * - * A receipt taken straight into a GL account carries no member of its own, so it is never - * attributed automatically. This lists them so the counter can see the voucher they just raised - * and attach it deliberately, instead of being told there is nothing to apply. + * One request rather than two: both lists come from the same table under the same filters, and + * splitting them across endpoints only meant the same rows were fetched twice and, where they + * overlapped, drawn twice on the same screen. */ - getUnattributedShareCollections(): Observable { - return this.http.get('/shareadvancecollection/unattributed'); - } - - /** Receipts still sitting in Share Sundry, optionally for a single member. */ - getUnappliedShareAdvances(clientId?: number): Observable { - const suffix = clientId ? `?clientId=${clientId}` : ''; - return this.http.get(`/shareadvancecollection${suffix}`); + getShareCollectionReceipts(clientId?: number): Observable { + const params = clientId ? new HttpParams().set('clientId', String(clientId)) : undefined; + return this.http.get('/shareadvancecollection/unattributed', { params }); } /** Moves a receipt out of Sundry into share capital for an activated account. */ diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index aa9516dbe..3351e3022 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1353,6 +1353,9 @@ +
diff --git a/src/app/shares/common-resolvers/share-account-actions.resolver.ts b/src/app/shares/common-resolvers/share-account-actions.resolver.ts index 21749db82..24679e699 100644 --- a/src/app/shares/common-resolvers/share-account-actions.resolver.ts +++ b/src/app/shares/common-resolvers/share-account-actions.resolver.ts @@ -32,7 +32,6 @@ export class ShareAccountActionsResolver { const actionName = route.paramMap.get('name'); const shareAccountId = route.paramMap.get('shareAccountId') || route.parent.parent.paramMap.get('shareAccountId'); switch (actionName) { - case 'Apply Additional Shares': case 'Redeem Shares': case 'Approve Additional Shares': case 'Reject Additional Shares': diff --git a/src/app/shares/reports/share-product-summary/share-product-summary.component.html b/src/app/shares/reports/share-product-summary/share-product-summary.component.html new file mode 100644 index 000000000..5ef7dcd6b --- /dev/null +++ b/src/app/shares/reports/share-product-summary/share-product-summary.component.html @@ -0,0 +1,168 @@ + + +
+
+
+ +
+

Share Product Summary

+
+ Every share class against its own authorised capital — shares issued, shares remaining, and the + distinctive-number range issued so far. "Issued" means paid up, not merely allotted. +
+
+
+
+ + +
+
+ + @if (rows.length) { +
+
+ +
+
{{ totals.authorisedShares | number }}
+
Authorised Shares (all classes)
+
+
+
+ +
+
{{ totals.issuedShares | number }}
+
Shares Issued (paid up)
+
+
+
+ +
+
₹{{ totals.issuedCapital | number: '1.2-2' }}
+
Share Capital Issued
+
+
+
+ +
+
{{ totals.unpaidShares | number }}
+
Allotted, Unpaid
+
+
+
+ } + + @if (loading) { +
Loading product summary…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No share product is configured.
+ } @else { +
+ + + + + + + + + + + + + + + + + @for (row of rows; track row.productId) { + + + + + + + + + + + + + } + + + + + + + + + + + + +
Share ClassUnit PriceAuthorisedIssued (Paid Up)AvailableUtilisationAllotted, UnpaidDistinctive RangeAccountsMin / Nominal / Max
+ {{ row.productName }} + @if (row.productShortName) { +
{{ row.productShortName }}
+ } +
{{ row.unitPrice | number: '1.2-2' }} + {{ row.authorisedShares | number }} +
+ ₹{{ row.authorisedCapital | number: '1.0-0' }} +
+
+ {{ row.issuedShares | number }} +
+ ₹{{ row.issuedCapital | number: '1.0-0' }} +
+
{{ row.availableShares | number }} +
+
+
+
+ {{ utilisationPercent(row) | number: '1.0-1' }}% +
+
+ @if (row.unpaidShares) { + {{ row.unpaidShares | number }} +
+ ₹{{ row.unpaidAmount | number: '1.0-0' }} · {{ row.unpaidAccounts }} + {{ row.unpaidAccounts === 1 ? 'account' : 'accounts' }} +
+ } @else { + 0 + } +
{{ distinctiveRange(row) }} + {{ row.accountCount | number }} +
+ {{ row.memberCount | number }} {{ row.memberCount === 1 ? 'member' : 'members' }} +
+
+ {{ row.minimumClientShares ?? '—' }} / {{ row.nominalClientShares ?? '—' }} / + {{ row.maximumClientShares ?? '—' }} +
Total — {{ rows.length | number }} share classes{{ totals.authorisedShares | number }}{{ totals.issuedShares | number }}{{ totals.unpaidShares | number }}
+
+ + @if (totals.unpaidShares) { +
+ + + {{ totals.unpaidShares | number }} shares across every class are allotted but not yet paid for — + ₹{{ totals.unpaidAmount | number: '1.2-2' }} in all. They are excluded from "Issued" above; see Final + Balance for which members they belong to. + +
+ } + } +
diff --git a/src/app/shares/reports/share-product-summary/share-product-summary.component.ts b/src/app/shares/reports/share-product-summary/share-product-summary.component.ts new file mode 100644 index 000000000..c2ac6ba91 --- /dev/null +++ b/src/app/shares/reports/share-product-summary/share-product-summary.component.ts @@ -0,0 +1,168 @@ +/** + * 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 { CommonModule } from '@angular/common'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface ProductSummaryRow { + productId: number; + productName: string; + productShortName: string; + unitPrice: number; + minimumClientShares: number; + nominalClientShares: number; + maximumClientShares: number; + authorisedShares: number; + authorisedCapital: number; + issuedShares: number; + issuedCapital: number; + availableShares: number; + unpaidAccounts: number; + unpaidShares: number; + unpaidAmount: number; + distinctiveFrom: number | null; + distinctiveTo: number | null; + accountCount: number; + memberCount: number; +} + +/** + * Share Product Summary — every share class measured against its own authorised capital: how many + * shares are issued, how many remain, and the distinctive-number range issued so far. + * + *

+ * "Issued" is paid-up shares — settled purchases less redemptions, computed the same way every other + * report in this suite computes them — not {@code m_share_product.totalsubscribed_shares}. That + * platform column counts an allotment the moment it is submitted, before any money has necessarily + * arrived, so it runs ahead of what the society has actually received. A board reading this page is + * reading the same figure the GL Share Capital account would show, not a subscription count. + */ +@Component({ + selector: 'mifosx-share-product-summary', + templateUrl: './share-product-summary.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FaIconComponent + ] +}) +export class ShareProductSummaryComponent implements OnInit { + private sharesService = inject(SharesService); + + loading = true; + loadError = ''; + rows: ProductSummaryRow[] = []; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareProductSummary() + .pipe( + catchError((err: any) => { + this.loadError = this.readError(err, 'The product summary could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.loading = false; + }); + } + + /** How much of a product's authorised capital is actually issued, for the progress bar. */ + utilisationPercent(row: ProductSummaryRow): number { + if (!row.authorisedShares) { + return 0; + } + const pct = (row.issuedShares / row.authorisedShares) * 100; + return Math.max(0, Math.min(100, pct)); + } + + distinctiveRange(row: ProductSummaryRow): string { + if (!row.distinctiveFrom || !row.distinctiveTo) { + return 'None allotted yet'; + } + return `${row.distinctiveFrom} – ${row.distinctiveTo}`; + } + + get totals() { + return { + authorisedShares: this.rows.reduce((sum, r) => sum + Number(r.authorisedShares || 0), 0), + authorisedCapital: this.rows.reduce((sum, r) => sum + Number(r.authorisedCapital || 0), 0), + issuedShares: this.rows.reduce((sum, r) => sum + Number(r.issuedShares || 0), 0), + issuedCapital: this.rows.reduce((sum, r) => sum + Number(r.issuedCapital || 0), 0), + unpaidShares: this.rows.reduce((sum, r) => sum + Number(r.unpaidShares || 0), 0), + unpaidAmount: this.rows.reduce((sum, r) => sum + Number(r.unpaidAmount || 0), 0) + }; + } + + exportCsv(): void { + shareReportCsv( + 'share-product-summary', + [ + 'Product', + 'Unit Price', + 'Authorised Shares', + 'Authorised Capital', + 'Issued Shares', + 'Issued Capital', + 'Available Shares', + 'Unpaid Accounts', + 'Unpaid Shares', + 'Unpaid Amount', + 'Distinctive Range', + 'Accounts', + 'Members', + 'Min / Nominal / Max Per Member' + ], + this.rows.map((r) => [ + r.productName, + r.unitPrice, + r.authorisedShares, + r.authorisedCapital, + r.issuedShares, + r.issuedCapital, + r.availableShares, + r.unpaidAccounts, + r.unpaidShares, + r.unpaidAmount, + this.distinctiveRange(r), + r.accountCount, + r.memberCount, + `${r.minimumClientShares ?? '—'} / ${r.nominalClientShares ?? '—'} / ${r.maximumClientShares ?? '—'}` + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-report.shared.scss b/src/app/shares/reports/share-report.shared.scss index 26eabff50..b837ce60f 100644 --- a/src/app/shares/reports/share-report.shared.scss +++ b/src/app/shares/reports/share-report.shared.scss @@ -278,6 +278,23 @@ $srep-muted: #64748b; font-weight: 500; } +/* A share class's own authorised-vs-issued fraction, read at a glance rather than only in the two + numbers beside it. */ +.product-utilisation-bar { + width: 100%; + min-width: 90px; + height: 6px; + border-radius: 999px; + background: $srep-border-soft; + overflow: hidden; +} + +.product-utilisation-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, #2563eb, #059669); +} + /* Something the reader has to know before trusting the number above it — an exclusion, a gap, a figure that will not reconcile until someone acts. Loud enough to be read, not an error. */ .srep-warn { diff --git a/src/app/shares/share-dialog.shared.scss b/src/app/shares/share-dialog.shared.scss new file mode 100644 index 000000000..710f215b4 --- /dev/null +++ b/src/app/shares/share-dialog.shared.scss @@ -0,0 +1,996 @@ +/** + * 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/. + */ + +// Shared chrome for Apply Collection and Apply Additional Shares dialogs. +// Each component @uses this, so every rule is emitted once per component +// and stays scoped to it -- nothing here leaks into the global stylesheet. + +/* ── Design tokens ─────────────────────────────────────────────────────── */ + +$primary: #1e3a8a; +$primary-mid: #2448b0; +$accent: #2563eb; +$accent-soft: #eff6ff; +$accent-border: #bfdbfe; + +$text-main: #0f172a; +$text-sub: #334155; +$text-muted: #64748b; +$border-color: #e2e8f0; +$bg-page: #f8fafc; +$bg-white: #fff; + +$success: #059669; +$success-bg: #ecfdf5; +$success-border: #6ee7b7; + +$danger: #dc2626; +$danger-bg: #fef2f2; +$danger-border: #fca5a5; + +/* ── Utilities ─────────────────────────────────────────────────────────── */ + +.mono { + font-family: 'Roboto Mono', 'Courier New', monospace; + font-variant-numeric: tabular-nums; +} + +/* ── Dialog wrapper ────────────────────────────────────────────────────── */ + +.dialog-wrapper { + display: flex; + flex-direction: column; + max-height: 88vh; + background: $bg-white; + border-radius: 14px; + overflow: hidden; +} + +/* ── Header ────────────────────────────────────────────────────────────── */ + +.dialog-header { + display: flex; + flex: none; + align-items: center; + gap: 0.85rem; + padding: 1.1rem 1.5rem; + background: linear-gradient(135deg, $primary 0%, $primary-mid 60%, #3b5fc0 100%); + color: #fff; + + .header-icon { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 38px; + height: 38px; + border-radius: 9px; + background: rgb(255 255 255 / 15%); + border: 1px solid rgb(255 255 255 / 20%); + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #fff; + } + } + + .header-text { + flex: 1; + min-width: 0; + + h2 { + margin: 0; + font-size: 1.08rem; + font-weight: 700; + letter-spacing: 0.01em; + } + + p { + margin: 0.18rem 0 0; + font-size: 0.78rem; + opacity: 0.78; + } + } + + .header-close { + color: rgb(255 255 255 / 80%); + flex: none; + + &:hover { + color: #fff; + background: rgb(255 255 255 / 12%) !important; + } + } +} + +/* ── Body ──────────────────────────────────────────────────────────────── */ + +.dialog-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 1.1rem; + min-height: 0; + max-height: none !important; + padding: 1.4rem 1.5rem !important; + overflow-y: auto; + background: $bg-page; + + &::-webkit-scrollbar { + width: 5px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 99px; + } +} + +.state-block { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 2rem 0; + color: $text-muted; + font-size: 0.85rem; +} + +/* ═══════════════════════════════════════════════════════════════════════ + Collection Summary Card (sc-* prefix) + ═══════════════════════════════════════════════════════════════════════ */ + +.sc-card { + border: 1px solid $accent-border; + border-radius: 10px; + background: $bg-white; + // No overflow:hidden — it clips row content in certain flex contexts + box-shadow: 0 2px 8px rgb(37 99 235 / 8%); +} + +/* ── Each item row (Share / Entry Fee) ─────────────────────────────────── */ + +.sc-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1.15rem; + background: $bg-white; + border-left: 4px solid transparent; + transition: + background 0.15s, + border-color 0.2s; + + &.sc-sep { + border-top: 1px solid $border-color; + } + + &.sc-ok { + background: $success-bg; + border-left-color: $success; + } + + &.sc-bad { + background: $danger-bg; + border-left-color: $danger; + } +} + +/* Left side: label + required amount */ +.sc-left { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.18rem; +} + +.sc-label { + display: block; + font-size: 0.86rem; + font-weight: 700; + color: $text-main; + line-height: 1.3; + min-height: 1em; // guarantees height even if content is empty +} + +.sc-sub { + display: block; + font-size: 0.72rem; + color: $text-muted; + line-height: 1.35; + min-height: 1em; + + b { + font-weight: 600; + color: $text-sub; + } +} + +/* Right side: collected amount + badge */ +.sc-right { + display: flex; + align-items: center; + gap: 0.5rem; + flex: none; +} + +.sc-val { + font-family: 'Roboto Mono', 'Courier New', monospace; + font-variant-numeric: tabular-nums; + font-size: 0.96rem; + font-weight: 700; + color: $text-muted; + letter-spacing: -0.01em; + transition: color 0.2s; + + &.sc-val-ok { + color: $success; + } + + &.sc-val-bad { + color: $danger; + } +} + +.sc-tag { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.5rem; + border-radius: 99px; + font-size: 0.68rem; + font-weight: 700; + white-space: nowrap; + + &.sc-tag-ok { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + + &.sc-tag-bad { + background: $danger-bg; + color: $danger; + border: 1px solid $danger-border; + } +} + +/* ── Total row ─────────────────────────────────────────────────────────── */ + +.sc-total-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.8rem 1.15rem; + border-top: 1px solid $border-color; + background: $bg-page; + transition: background 0.2s; + + &.sc-total-ok { + background: $success-bg; + } +} + +.sc-total-lbl { + font-size: 0.82rem; + font-weight: 800; + color: $text-main; + letter-spacing: 0.01em; +} + +.sc-total-nums { + display: flex; + align-items: center; + gap: 0.45rem; + flex: none; +} + +.sc-total-req { + font-family: 'Roboto Mono', 'Courier New', monospace; + font-size: 0.78rem; + font-weight: 500; + color: $text-muted; +} + +.sc-arrow { + font-size: 0.8rem; + color: #94a3b8; + font-weight: 300; +} + +.sc-total-got { + font-family: 'Roboto Mono', 'Courier New', monospace; + font-variant-numeric: tabular-nums; + font-size: 1rem; + font-weight: 800; + color: $text-muted; + letter-spacing: -0.02em; + transition: color 0.2s; + + &.sc-total-got-ok { + color: $success; + } +} + +.sc-status-pill { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.65rem; + border-radius: 99px; + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.04em; + white-space: nowrap; + transition: all 0.2s; + + &.sc-pill-ok { + background: $success; + color: #fff; + box-shadow: 0 2px 6px rgb(5 150 105 / 30%); + } + + &.sc-pill-pending { + background: $border-color; + color: $text-muted; + } +} + +/* ═══════════════════════════════════════════════════════════════════════ */ + +/* ── Receipt step slots ────────────────────────────────────────────────── */ + +.slot { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1.1rem 1.25rem; + border: 1px solid $border-color; + border-radius: 10px; + background: $bg-white; + box-shadow: 0 1px 4px rgb(15 23 42 / 4%); + transition: box-shadow 0.2s; + + &:hover { + box-shadow: 0 3px 12px rgb(15 23 42 / 7%); + } +} + +.slot-head { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.slot-step { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 26px; + height: 26px; + border-radius: 50%; + background: linear-gradient(135deg, $primary 0%, $accent 100%); + color: #fff; + font-size: 0.72rem; + font-weight: 800; + box-shadow: 0 2px 6px rgb(37 99 235 / 30%); +} + +.slot-title { + flex: 1; + min-width: 0; + + h3 { + margin: 0; + font-size: 0.9rem; + font-weight: 700; + color: $text-main; + } + + p { + margin: 0.12rem 0 0; + font-size: 0.72rem; + color: $text-muted; + } +} + +.slot-search { + display: inline-flex; + align-items: center; + gap: 0.3rem; + flex: none; + padding: 0.38rem 0.72rem; + border: 1.5px solid $accent-border; + border-radius: 7px; + background: $accent-soft; + color: $accent; + font-size: 0.74rem; + font-weight: 700; + cursor: pointer; + transition: all 0.15s; + + mat-icon { + width: 15px; + height: 15px; + font-size: 15px; + } + + &:hover { + background: $accent; + border-color: $accent; + color: #fff; + box-shadow: 0 2px 8px rgb(37 99 235 / 25%); + } +} + +/* ── Pickable option list ───────────────────────────────────────────────── */ + +.options { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + max-height: 210px; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 99px; + } +} + +.option { + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.72rem 0.9rem; + border: 1.5px solid $border-color; + border-radius: 7px; + cursor: pointer; + background: $bg-white; + transition: all 0.14s; + + &:hover { + border-color: $accent; + background: $accent-soft; + box-shadow: 0 2px 6px rgb(37 99 235 / 10%); + } + + &.option-busy { + opacity: 0.6; + pointer-events: none; + } +} + +.option-radio { + flex: none; + width: 14px; + height: 14px; + border: 2px solid #cbd5e1; + border-radius: 50%; + transition: border-color 0.14s; + + .option:hover & { + border-color: $accent; + } +} + +.option-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.option-voucher { + font-size: 0.81rem; + font-weight: 700; + color: $text-main; +} + +.option-meta { + font-size: 0.7rem; + color: $text-muted; +} + +.option-desc { + font-size: 0.7rem; + color: $text-muted; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.option-amount { + flex: none; + font-size: 0.85rem; + font-weight: 700; + color: $text-main; +} + +.option-info { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 26px; + height: 26px; + padding: 0; + border: 1px solid $border-color; + border-radius: 5px; + background: transparent; + color: $text-muted; + cursor: pointer; + transition: all 0.14s; + + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + } + + &:hover { + color: $accent; + border-color: $accent; + background: $accent-soft; + } +} + +/* ── Chosen receipt ─────────────────────────────────────────────────────── */ + +.chosen { + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0.75rem 1rem; + border: 2px solid $success-border; + border-radius: 9px; + background: $success-bg; + box-shadow: 0 2px 8px rgb(5 150 105 / 10%); + + &.chosen-short { + border-color: $danger-border; + background: $danger-bg; + box-shadow: 0 2px 8px rgb(220 38 38 / 8%); + } +} + +.chosen-badge { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 30px; + height: 30px; + border-radius: 7px; + background: $success; + + .chosen-short & { + background: $danger; + } + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + color: #fff; + } +} + +.chosen-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.chosen-voucher { + font-size: 0.84rem; + font-weight: 700; + color: $text-main; +} + +.chosen-gl { + font-size: 0.7rem; + color: $text-muted; +} + +.chosen-amount { + flex: none; + font-size: 0.95rem; + font-weight: 800; + color: $text-main; + font-family: 'Roboto Mono', 'Courier New', monospace; +} + +.chosen-clear { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 26px; + height: 26px; + padding: 0; + border: 1px solid $danger-border; + border-radius: 5px; + background: transparent; + color: #f87171; + cursor: pointer; + transition: all 0.14s; + + mat-icon { + width: 15px; + height: 15px; + font-size: 15px; + } + + &:hover { + color: $danger; + background: $danger-bg; + border-color: $danger; + } +} + +/* ── Slot notes ─────────────────────────────────────────────────────────── */ + +.slot-note { + margin: 0; + font-size: 0.74rem; + font-weight: 500; + + &.note-good { + color: $success; + } + + &.note-bad { + color: $danger; + font-weight: 700; + } + + &.note-muted { + color: $text-muted; + } +} + +.slot-empty { + margin: 0; + padding: 0.8rem 1rem; + border: 1.5px dashed $border-color; + border-radius: 7px; + background: $bg-page; + font-size: 0.75rem; + line-height: 1.55; + color: $text-muted; +} + +/* ── Error alert ─────────────────────────────────────────────────────────── */ + +.alert { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.7rem 0.9rem; + border: 1.5px solid $danger-border; + border-radius: 7px; + background: $danger-bg; + color: $danger; + font-size: 0.76rem; + line-height: 1.45; + + mat-icon { + flex: none; + width: 17px; + height: 17px; + font-size: 17px; + margin-top: 1px; + } +} + +/* ── Footer ─────────────────────────────────────────────────────────────── */ + +.dialog-actions { + display: flex; + flex: none; + justify-content: flex-end; + align-items: center; + gap: 0.75rem; + min-height: 0; + padding: 1rem 1.5rem !important; + margin: 0 !important; + border-top: 1px solid $border-color; + background: $bg-white; + box-shadow: 0 -2px 8px rgb(15 23 42 / 5%); + + // Both buttons: same height, same border-radius, same font + button { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + gap: 0.3rem !important; + height: 40px !important; + min-width: 110px !important; + padding: 0 1.1rem !important; + border-radius: 8px !important; + font-weight: 600 !important; + font-size: 0.85rem !important; + line-height: 1 !important; + letter-spacing: 0.01em !important; + + // Icon inside button — vertically center it and size it correctly + mat-icon { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 18px !important; + height: 18px !important; + font-size: 18px !important; + line-height: 1 !important; + vertical-align: middle !important; + margin: 0 !important; + flex: none; + } + + mat-spinner { + flex: none; + } + } + + // Stroked cancel button + [mat-stroked-button] { + border-color: $border-color !important; + color: $text-sub !important; + + &:hover { + border-color: #94a3b8 !important; + background: $bg-page !important; + } + } + + // Primary action button (enabled) + [mat-flat-button][color='primary']:not([disabled]) { + background: linear-gradient(135deg, $accent 0%, #1d4ed8 100%) !important; + box-shadow: 0 2px 8px rgb(37 99 235 / 28%) !important; + color: #fff !important; + + &:hover { + box-shadow: 0 4px 14px rgb(37 99 235 / 40%) !important; + transform: translateY(-1px) !important; + } + + &:active { + transform: translateY(0) !important; + } + } + + // Primary action button (disabled) + [mat-flat-button][color='primary'][disabled] { + background: #e2e8f0 !important; + color: #94a3b8 !important; + box-shadow: none !important; + } +} + +/* ── Form fields (in search popup) ─────────────────────────────────────── */ + +.field { + display: flex; + flex-direction: column; + gap: 0.35rem; + + > span { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $text-muted; + } + + select, + input { + padding: 0.55rem 0.7rem; + border: 1.5px solid $border-color; + border-radius: 7px; + font-size: 0.82rem; + color: $text-main; + background: #fff; + outline: none; + transition: + border-color 0.14s, + box-shadow 0.14s; + + &:hover { + border-color: #94a3b8; + } + + &:focus { + border-color: $accent; + box-shadow: 0 0 0 3px rgb(37 99 235 / 12%); + } + } +} + +/* ── Sub-popups ─────────────────────────────────────────────────────────── */ + +.overlay { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + background: rgb(15 23 42 / 55%); + backdrop-filter: blur(2px); +} + +.popup { + display: flex; + flex-direction: column; + width: 100%; + max-width: 460px; + max-height: 80vh; + border-radius: 12px; + background: #fff; + overflow: hidden; + box-shadow: 0 20px 50px -8px rgb(15 23 42 / 30%); + animation: popup-in 0.15s cubic-bezier(0.22, 1, 0.36, 1); +} + +@keyframes popup-in { + from { + opacity: 0; + transform: scale(0.96) translateY(8px); + } + + to { + opacity: 1; + transform: none; + } +} + +.popup-head { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.85rem 0.85rem 0.85rem 1.2rem; + border-bottom: 1px solid $border-color; + background: $bg-page; + + h3 { + flex: 1; + margin: 0; + font-size: 0.92rem; + font-weight: 700; + color: $text-main; + } +} + +.popup-body { + display: flex; + flex-direction: column; + gap: 0.85rem; + padding: 1.2rem; + overflow-y: auto; +} + +.popup-foot { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0.8rem 1.2rem; + border-top: 1px solid $border-color; + background: $bg-page; +} + +/* ── Receipt detail popup ───────────────────────────────────────────────── */ + +.detail-list { + display: flex; + flex-direction: column; + gap: 0; + margin: 0; + border: 1px solid $border-color; + border-radius: 8px; + overflow: hidden; + + > div { + display: flex; + border-bottom: 1px solid $border-color; + + &:last-child { + border-bottom: none; + } + } + + dt { + flex: none; + width: 110px; + padding: 0.6rem 0.9rem; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $text-muted; + background: $bg-page; + border-right: 1px solid $border-color; + } + + dd { + flex: 1; + min-width: 0; + margin: 0; + padding: 0.6rem 0.9rem; + font-size: 0.8rem; + color: $text-main; + word-break: break-word; + } +} + +.detail-heading { + margin: 0.4rem 0 0.1rem; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $text-muted; +} + +.gl-table { + width: 100%; + border-collapse: collapse; + font-size: 0.76rem; + border: 1px solid $border-color; + border-radius: 7px; + overflow: hidden; + + th, + td { + padding: 0.45rem 0.6rem; + border-bottom: 1px solid $border-color; + text-align: left; + } + + tbody tr:last-child td { + border-bottom: none; + } + + th { + font-size: 0.64rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $text-muted; + background: $bg-page; + } + + tbody tr:hover { + background: $accent-soft; + } + + .num { + text-align: right; + } +} diff --git a/src/app/shares/share-receipt-option.ts b/src/app/shares/share-receipt-option.ts new file mode 100644 index 000000000..3218b0efa --- /dev/null +++ b/src/app/shares/share-receipt-option.ts @@ -0,0 +1,85 @@ +/** + * 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/. + */ + +/** + * One Share Collection receipt the counter can choose, in a single shape whatever it came from. + * + * {@code GET /shareadvancecollection/unattributed} answers with two differently-shaped lists — the + * member's own receipts under {@code own}, and every other receipt still going spare under + * {@code rows} — and normalising both to this on arrival is what lets a screen show one plain list + * of vouchers instead of two tables whose difference a counter has to understand before they can + * pick anything. + * + * Shared by the two screens that settle a share purchase (the first application's Apply Collection + * dialog and the Apply Additional Shares dialog) so a receipt reads identically on both. + */ +export interface ReceiptOption { + /** Stable identity for tracking and comparison; unique across both source lists. */ + key: string; + /** + * The {@code m_share_advance_collection} row behind this receipt, or null while it is still only + * a voucher number. A null one is attached to the member on first pick. + */ + collectionId: number | null; + voucherNo: string; + glAccountNo: string; + glAccountName: string; + date: string; + amount: number; + description: string; + cashierName: string; +} + +/** + * A receipt that already has an {@code m_share_advance_collection} row — ready to settle as-is. + * + * Also the shape {@code GET /shareadvancecollection/by-voucher} answers with, so a receipt found by + * number arrives here too, already attached. + */ +export function receiptFromOwn(row: any): ReceiptOption { + return { + key: `c${row.id}`, + collectionId: Number(row.id), + voucherNo: row.voucher_no || row.receipt_no || '', + glAccountNo: row.gl_code || '', + glAccountName: '', + date: String(row.collection_date || '').slice(0, 10), + amount: Number(row.amount || 0), + description: row.note || '', + cashierName: row.collected_by_name || '' + }; +} + +/** A receipt that is still only a voucher, attached to the member when it is picked. */ +export function receiptFromUnattached(row: any): ReceiptOption { + return { + key: `v${row.id}`, + collectionId: null, + voucherNo: row.voucherNo || '', + glAccountNo: row.glAccountNo || '', + glAccountName: row.glAccountName || '', + date: String(row.txnDate || '').slice(0, 10), + amount: Number(row.amount || 0), + description: row.description || '', + cashierName: row.cashierName || '' + }; +} + +/** + * The GL account a receipt sits in, named where the name is known and coded where it is not. + * + * An attached receipt carries only its {@code gl_code}, so the name is looked up in whatever GL + * account list the screen already loaded for its voucher search rather than fetched again. + */ +export function receiptGlLabel(option: ReceiptOption, glAccounts: any[]): string { + const name = option.glAccountName || glAccounts.find((gl) => gl.gl_code === option.glAccountNo)?.name || ''; + if (!option.glAccountNo) { + return name || '—'; + } + return name ? `${option.glAccountNo} — ${name}` : option.glAccountNo; +} diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html index 45cc66466..0299ae92f 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html @@ -6,692 +6,367 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -

- - -
-
- add_card +
+
+
+ trending_up +
+
+

Apply Additional Shares

+

+ Share account {{ data.accountNo }} +

+
+ +
+ + +
+
+
+ Shares +
-
-

{{ 'Apply Additional Shares' | translate }}

-

Purchase additional share tranches via cash receipts or savings debit

+
+ Unit Price + {{ unitPrice | number: '1.2-2' }} +
+
+ Total Amount + {{ amount | number: '1.2-2' }}
+
-
- - -
-
- assignment - Application & Resolution Details -
- -
- - - {{ 'labels.inputs.Request Date' | translate }} - - - - @if (applySharesForm.controls.requestedDate.hasError('required')) { - - {{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - - {{ 'labels.inputs.Resolution Number' | translate }} - - @if (applySharesForm.controls.resolutionNumber.hasError('required')) { - - {{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - - {{ 'labels.inputs.Resolution Date' | translate }} - - - - @if (applySharesForm.controls.resolutionDate.hasError('required')) { - - {{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - -
+ + +
+
+ 1 +
+

Board Resolution

+

The meeting that authorised this tranche, not the one the account was opened under.

+
- -
-
- calculate - Share Purchase Calculation -
+
+ + + +
+
-
- - - {{ 'labels.inputs.Total No. of Shares' | translate }} - - @if (applySharesForm.controls.requestedShares.hasError('required')) { - - {{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - - {{ 'labels.inputs.Current Price' | translate }} - - per share - - - -
- {{ 'labels.inputs.Amount' | translate }} - {{ amount | number: '1.2-2' }} - {{ applySharesForm.get('requestedShares').value || 0 }} shares × - {{ sharesAccountData?.currentMarketPrice || 0 }} -
-
+ +
+
+ 2 +
+

Funding

+

Approval posts Dr Sundry / Cr Share Capital either way.

+ @if (funding === 'CASH') { + + } +
- -
-
- account_balance_wallet - {{ 'labels.inputs.Payment Option' | translate }} +
+ + +
+ + @if (funding === 'CASH') { + @if (loadingReceipts) { +
+ + Loading receipts…
- -
-
-
- payments -
-
- Apply Collection (Cash) - Link receipts paid at counter sitting in Share Sundry -
- @if (paymentMode === 'CASH') { - check_circle - } + } @else if (receipt) { +
+
+ {{ shortfall > 0 ? 'warning' : 'check_circle' }}
- -
-
- account_balance -
-
- Debit from Savings - Direct debit from member's linked savings account -
- @if (paymentMode === 'SAVINGS') { - check_circle - } +
+ {{ receipt.voucherNo }} + {{ glLabel(receipt) }}
-
-
- - - @if (paymentMode === 'CASH') { -
-
- - - Select the one receipt that covers this payment. Click - visibility on any row to view full details popup. - -
- - - - - - @if (unattributed.length) { -
-
- person_add - Unattached Share Collection Receipts — Attach to This Tranche -
-
- - - - - - - - - - - - - @for (u of unattributed; track u.id) { - - - - - - - - - } - -
Voucher NoDateDeposited ToDescriptionAmountAction
- {{ u.voucherNo }} - @if (isOwnClientVoucher(u)) { - This member's receipt - } - {{ u.txnDate }}{{ u.glAccountNo }} — {{ u.glAccountName }}{{ u.description || '—' }}{{ u.amount | number: '1.2-2' }} - -
-
-
- } - - @if (voucherError) { -
- error_outline - {{ voucherError }} -
- } - - -
- @if (loadingVouchers) { -
- - Loading member receipts… -
- } @else if (vouchers.length === 0) { -
- receipt_long -
- No unapplied receipts found for this member. -

- Collect cash at Cashier › Transaction Entry as a deposit to Share Sundry GL. Once - posted, it will appear here. -

-
-
- } @else { -
- -
-
- receipt - Vouchers / Receipts - {{ vouchers.length }} -
-
- - -
- - - - - - - - - - - - - - - @for (v of vouchers; track v.id) { - - - - - - - - - - } - -
#Voucher / Receipt NoDateDescriptionCollected ByAmountDetail
- - - {{ v.voucher_no || v.receipt_no }} - {{ v.collection_date }}{{ v.note || 'Share Collection' }} - - person_outline - {{ v.collected_by_name || 'System' }} - - - {{ v.amount | number: '1.2-2' }} - - -
-
- - - -
- - @if (selectedVoucherIds.length > 0 && voucherShortfall > 0) { -
- error_outline - - Selected total {{ selectedVoucherTotal | number: '1.2-2' }} is - {{ voucherShortfall | number: '1.2-2' }} short of tranche target - {{ amount | number: '1.2-2' }}. Tick another receipt or collect balance. - -
- } - } -
+ @if (shortfall > 0) { +

+ Short by {{ shortfall | number: '1.2-2' }} — this receipt does not cover {{ amount | number: '1.2-2' }}. +

+ } @else { +

Covers the {{ amount | number: '1.2-2' }} tranche.

+ } + } @else if (available.length) { +
    + @for (option of available; track option.key) { +
  • + +
    + {{ option.voucherNo }} + {{ option.date }} · {{ glLabel(option) }} + @if (option.description) { + {{ option.description }} + } +
    + {{ option.amount | number: '1.2-2' }} + @if (attaching === option.key) { + + } @else if (option.collectionId !== null) { + + } +
  • + } +
+ } @else { +

+ No Share Collection receipt is waiting. Collect the cash at Cashier › Transaction Entry with entry + type Share Collection, or find an earlier voucher by number. +

} + } - - @if (paymentMode === 'SAVINGS') { -
-
- - - The tranche amount will be automatically debited from the member's linked savings account into Share - Sundry upon submission. + @if (funding === 'SAVINGS') { + @if (checkingSavings) { +
+ + Checking the linked savings account… +
+ } @else if (savings) { +
+
+ {{ savings.eligible ? 'check_circle' : 'warning' }} +
+
+ + @if (savings.savingsAccountNo) { + Savings {{ savings.savingsAccountNo }} + } @else { + Linked savings account + } + + + {{ savings.eligible ? 'Debited into Share Sundry on submit.' : savings.reason }}
- - @if (checkingSavings) { -
- - Checking linked savings account eligibility… -
- } @else if (savingsEligibility) { -
- @if (savingsEligibility.savingsAccountNo) { -
- Savings Account No. - {{ savingsEligibility.savingsAccountNo }} -
-
- Available Balance - {{ savingsEligibility.savingsBalance | number: '1.2-2' }} -
- } -
- Amount to Debit - {{ amount | number: '1.2-2' }} -
-
- - @if (!savingsEligibility.eligible) { -
- error_outline - {{ savingsEligibility.reason }} -
- } + @if (savings.savingsBalance !== null) { + {{ savings.savingsBalance | number: '1.2-2' }} }
+ } @else { +

Enter the number of shares to check the member's balance.

} + } +
+ + @if (errorMsg) { +
+ error_outline + {{ errorMsg }} +
+ } +
+ + + + + +
+ + +@if (searchOpen) { +
+