diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 32f24de9b..045252339 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -26,7 +26,6 @@ import { VaultPositionComponent } from './vault-position/vault-position.componen import { AccountRegisterComponent } from './account-register/account-register.component'; import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component'; import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component'; -import { ShareCollectionComponent } from './share-collection/share-collection.component'; const routes: Routes = [ Route.withShell([ @@ -345,11 +344,6 @@ const routes: Routes = [ component: ExcessTransactionReportComponent, data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' } }, - { - path: 'share-collection', - component: ShareCollectionComponent, - data: { title: 'Share Collection', breadcrumb: 'Share Collection' } - }, { path: 'transaction-entry', canActivate: [CashierOnlyGuard], diff --git a/src/app/cashier/cashier.module.ts b/src/app/cashier/cashier.module.ts index 2cf93e092..ec6dcfeeb 100644 --- a/src/app/cashier/cashier.module.ts +++ b/src/app/cashier/cashier.module.ts @@ -20,7 +20,6 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone import { CashHistoryComponent } from './cash-history/cash-history.component'; import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component'; import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component'; -import { ShareCollectionComponent } from './share-collection/share-collection.component'; @NgModule({ imports: [ @@ -35,8 +34,7 @@ import { ShareCollectionComponent } from './share-collection/share-collection.co ReconciliationComponent, CashHistoryComponent, ClosingCashReportComponent, - ExcessTransactionReportComponent, - ShareCollectionComponent + ExcessTransactionReportComponent ] }) export class CashierModule {} diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index 9845ce8a4..83d3ba55b 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -178,6 +178,13 @@ export class CashierService { return this.http.get('/vouchers/my-work', { params }); } + /** One voucher and every debit/credit line it is made of — the full slip behind a summarised + * row (a "+2 more" badge in a register only ever shows the first side of each). */ + voucherDetail(voucherNo: string): Observable { + const params = new HttpParams().set('voucherNo', voucherNo); + return this.http.get('/vouchers/detail', { params }); + } + /** * Account register for one date: what opened and closed that day, each account's balance as it * stood then, and what it holds now. @@ -357,19 +364,6 @@ export class CashierService { return this.http.get('/paymenttypes'); } - // ── Share Collection ── - getShareAccountForCollection(accountId: string): Observable { - return this.http.get(`/accounts/share/${accountId}`); - } - - collectSharePayment(data: any): Observable { - return this.http.post('/cashier/sharecollection', data); - } - - getShareCollectionStatus(shareAccountId: number): Observable { - return this.http.get(`/cashier/sharecollection/${shareAccountId}`); - } - /** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */ getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable { // The voucher travels as a query parameter, not a path segment: numbers are formatted @@ -407,6 +401,12 @@ export class CashierService { return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data); } + /** The receipt's own real Dr/Cr GL legs — never the GL account currently picked in a search box, + * which is unrelated screen state that can name a different account than this receipt sits in. */ + getShareAdvanceGlEntries(collectionId: number): Observable { + return this.http.get(`/shareadvancecollection/${collectionId}/gl-entries`); + } + /** Every additional-share tranche awaiting approval, across every share account. */ getShareAdditionRequests(): Observable { return this.http.get('/shareadditionrequests'); @@ -419,4 +419,14 @@ export class CashierService { getShareAdditionRequest(transactionId: number): Observable { return this.http.get(`/shareadditionrequests/${transactionId}`); } + + /** + * Entry Type options for a GL credit (General / Share Collection / DD Issue / …) — the + * "GLEntryType" code under Admin > System > Manage Codes, so an admin can add another type later + * without a deployment. Empty on failure rather than erroring, since a row missing this dropdown's + * options should still be usable for every other field. + */ + getEntryTypeOptions(): Observable { + return this.http.get('/codes/name/GLEntryType/codevalues').pipe(catchError(() => of([]))); + } } diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts index b5876e77e..929a2af27 100644 --- a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts +++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts @@ -174,19 +174,24 @@ export class ClosingCashReportComponent implements OnInit { return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0); } + /** + * Summed from each row's own `netCount`, not re-derived from today's legs — a row's `netCount` + * already carries forward what a cashier was holding from before today (`openingCount`), for the + * force-closed-day/late-settlement case. Recomputing here from only today's vaultIn/deposit/ + * withdrawal/settledOut dropped that carry-forward and put the Total row back into negative + * numbers a cashier had already reconciled. + */ get noteNetCount(): number { - return this.noteVaultInCount + this.noteDepositCount - this.noteWithdrawalCount - this.noteSettledOutCount; + return this.noteRows.reduce((s, r) => s + Number(r.netCount ?? 0), 0); } /** - * What is left in the drawer in notes: everything that came in, less everything that went out. - * - * Was deposits minus withdrawals, which is the counter's movement rather than the drawer's - * contents — it left out the cash drawn from the vault, so a teller holding a ₹500 note saw a net - * of zero and had nothing to count against. + * What is left in the drawer in notes: everything that came in, less everything that went out — + * including cash carried forward from before today. See `noteNetCount` for why this sums each + * row's own `netAmount` rather than re-deriving it from today's legs. */ get noteNetAmount(): number { - return this.noteVaultInAmount + this.noteDepositAmount - this.noteWithdrawalAmount - this.noteSettledOutAmount; + return this.noteRows.reduce((s, r) => s + Number(r.netAmount ?? 0), 0); } get coinRows(): any[] { return this.rows.filter((r) => r.kind === 'coin'); diff --git a/src/app/cashier/my-work/my-work.component.html b/src/app/cashier/my-work/my-work.component.html index 50139959d..e4c96e7db 100644 --- a/src/app/cashier/my-work/my-work.component.html +++ b/src/app/cashier/my-work/my-work.component.html @@ -152,9 +152,21 @@ @for (r of g.rows; track r.voucherNo) { - - {{ r.voucherNo }} + is when the heading has scrolled away. Clicking anywhere on the row opens the full + slip -- every line, not just the first debit and first credit this table has room + to show. --> + + + {{ r.voucherNo }} + @if (loadingDetailFor === r.voucherNo) { + hourglass_empty + } + {{ sideLabel(r.side) }} @@ -171,7 +183,7 @@ {{ r.creditAccount }} } @if (r.extraLines > 0) { - +{{ r.extraLines }} more + +{{ r.extraLines }} more } ₹{{ r.amount | number: '1.2-2' }} diff --git a/src/app/cashier/my-work/my-work.component.scss b/src/app/cashier/my-work/my-work.component.scss index 674708b5e..4e8d33e25 100644 --- a/src/app/cashier/my-work/my-work.component.scss +++ b/src/app/cashier/my-work/my-work.component.scss @@ -178,6 +178,21 @@ } } +.mw-row--clickable { + cursor: pointer; + + &:hover { + background: #f8fafc; + } +} + +.mw-vno__spinner { + margin-left: 4px; + font-size: 14px; + vertical-align: middle; + color: #94a3b8; +} + /* One section per part played. The heading carries the section's own count and total, so a maker's tally and a checker's are each readable without adding rows up. */ .mw-group { diff --git a/src/app/cashier/my-work/my-work.component.ts b/src/app/cashier/my-work/my-work.component.ts index 9173fc55f..4dbee818f 100644 --- a/src/app/cashier/my-work/my-work.component.ts +++ b/src/app/cashier/my-work/my-work.component.ts @@ -10,8 +10,10 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; -import { catchError, of } from 'rxjs'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { catchError, finalize, of } from 'rxjs'; import { CashierService } from '../cashier.service'; +import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component'; /** What the signed-in user's part in a voucher was. */ export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED'; @@ -72,11 +74,17 @@ interface MyWorkReport { imports: [ CommonModule, FormsModule, - MatIconModule + MatIconModule, + MatDialogModule ] }) export class MyWorkComponent implements OnInit { private service = inject(CashierService); + private dialog = inject(MatDialog); + + /** Which voucher's detail is currently being fetched, so the row that was clicked can show its + * own spinner instead of a page-wide one for what is a small, single-row lookup. */ + loadingDetailFor: string | null = null; /** Day is the working default; a month is what a close or an audit asks for. */ period: 'DAY' | 'MONTH' = 'DAY'; @@ -323,6 +331,31 @@ export class MyWorkComponent implements OnInit { return { from: first, to: lastIso }; } + /** Opens the full slip for one voucher — every debit and credit line, not just the first side of + * each a table row has room to show. */ + openDetail(voucherNo: string): void { + if (this.loadingDetailFor) { + return; + } + this.loadingDetailFor = voucherNo; + this.service + .voucherDetail(voucherNo) + .pipe( + catchError(() => of(null)), + finalize(() => (this.loadingDetailFor = null)) + ) + .subscribe((detail: any) => { + if (!detail) { + return; + } + this.dialog.open(VoucherDetailDialogComponent, { + data: { detail }, + width: '600px', + autoFocus: false + }); + }); + } + private todayIso(): string { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; diff --git a/src/app/cashier/my-work/voucher-detail-dialog.component.ts b/src/app/cashier/my-work/voucher-detail-dialog.component.ts new file mode 100644 index 000000000..93137c3c7 --- /dev/null +++ b/src/app/cashier/my-work/voucher-detail-dialog.component.ts @@ -0,0 +1,320 @@ +/** + * 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 { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; + +export interface VoucherDetailDialogData { + /** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog + * only renders, so one loading state lives in the row's own click handler rather than being + * duplicated inside every dialog that could ever show a voucher. */ + detail: { + voucherNo: string; + voucherType: string | null; + amount: number | null; + branchName: string | null; + createdBy: string | null; + createdAt: string | null; + totalDebit: number; + totalCredit: number; + balanced: boolean; + lines: { + lineNo: number; + entryType: 'DR' | 'CR'; + partyName: string | null; + accountNo: string | null; + amount: number; + description: string | null; + }[]; + /** GL entries a native platform command posted for the same underlying event, outside this + * voucher entirely — a share redemption's Dr Share Capital / Cr Share Sundry pair, posted at + * manager approval, before this voucher's own Dr Sundry / Cr Cash at Vault settlement leg even + * exists. Empty for every voucher that already is the whole transaction on its own. */ + relatedLines: { + stage: string; + entryType: 'DR' | 'CR'; + partyName: string | null; + accountNo: string | null; + amount: number; + description: string | null; + }[]; + }; +} + +/** The full slip behind a summarised register row — every debit and credit line a voucher is + * made of, not just the first side of each that a table row has room for. */ +@Component({ + selector: 'mifosx-voucher-detail-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatIconModule + ], + template: ` +
+
+
+ receipt_long +
+

{{ detail.voucherNo }}

+

{{ detail.voucherType || 'Voucher' }} · {{ detail.branchName || 'Head Office' }}

+
+
+ +
+ +
+ Raised by {{ detail.createdBy || '—' }} + @if (detail.createdAt) { + {{ detail.createdAt | date: 'dd MMM yyyy, h:mm a' }} + } + @if (!detail.balanced) { + + warning + Debit and credit do not balance + + } +
+ + + + + + + + + + + + @for (line of detail.lines; track line.lineNo) { + + + + + + + @if (line.description) { + + + + + } + } @empty { + + + + } + + @if (detail.lines.length > 0) { + + + + + + + } +
SidePartyAccount No.Amount
+ + {{ line.entryType === 'DR' ? 'Debit' : 'Credit' }} + + {{ line.partyName || '—' }}{{ line.accountNo || '—' }}₹{{ line.amount | number: '1.2-2' }}
{{ line.description }}
This voucher's individual lines were not recorded.
Total + ₹{{ detail.totalDebit | number: '1.2-2' }} / ₹{{ detail.totalCredit | number: '1.2-2' }} +
+ + @if (detail.relatedLines.length > 0) { + + } + +
+ +
+
+ `, + styles: [ + ` + .vd-shell { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px 24px 16px; + min-width: 420px; + max-width: 560px; + } + .vd-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + } + .vd-header__title { + display: flex; + align-items: center; + gap: 12px; + } + .vd-header__title h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + } + .vd-header__title p { + margin: 2px 0 0; + font-size: 13px; + color: rgba(0, 0, 0, 0.6); + } + .vd-icon { + color: #1976d2; + } + .vd-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + font-size: 13px; + color: rgba(0, 0, 0, 0.65); + } + .vd-unbalanced { + display: inline-flex; + align-items: center; + gap: 4px; + color: #b91c1c; + font-weight: 600; + } + .vd-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + } + .vd-table th { + text-align: left; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.03em; + color: rgba(0, 0, 0, 0.5); + padding: 6px 8px; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + } + .vd-table td { + padding: 8px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + } + .vd-table .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + .vd-acct { + color: rgba(0, 0, 0, 0.55); + } + .vd-side { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + } + .vd-side--dr { + background: #fee2e2; + color: #b91c1c; + } + .vd-side--cr { + background: #dcfce7; + color: #15803d; + } + .vd-desc-row td { + padding-top: 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + } + .vd-desc { + font-size: 12px; + color: rgba(0, 0, 0, 0.5); + font-style: italic; + } + .vd-empty { + text-align: center; + color: rgba(0, 0, 0, 0.5); + padding: 16px 8px; + } + .vd-table tfoot td { + font-weight: 600; + border-bottom: none; + border-top: 1px solid rgba(0, 0, 0, 0.1); + } + .vd-related { + border-top: 1px dashed rgba(0, 0, 0, 0.15); + padding-top: 12px; + } + .vd-related__title { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #6d28d9; + margin-bottom: 8px; + } + .vd-table--related td { + background: #faf5ff; + } + .vd-related__hint { + margin: 6px 0 0; + font-size: 12px; + color: rgba(0, 0, 0, 0.5); + } + .vd-actions { + display: flex; + justify-content: flex-end; + } + ` + ] +}) +export class VoucherDetailDialogComponent { + dialogRef = inject(MatDialogRef); + private data = inject(MAT_DIALOG_DATA); + + detail = this.data.detail; +} diff --git a/src/app/cashier/share-collection/share-collection.component.html b/src/app/cashier/share-collection/share-collection.component.html deleted file mode 100644 index a17ed89f7..000000000 --- a/src/app/cashier/share-collection/share-collection.component.html +++ /dev/null @@ -1,375 +0,0 @@ - - -
- -
-
-
CASHIER · SHARE COLLECTION
-

Share Collection

-

- Collect cash from member for share purchase — amounts are held in Sundry GL until the account is activated. -

-
-
- - - @if (pageState !== 'success') { - - } - - - @if (errorMsg) { -
- error_outline - {{ errorMsg }} -
- } - - - @if (pageState === 'searching') { -
- hourglass_empty -

Looking up account…

-
- } - - - @if (pageState === 'loaded') { - -
-
- person -
-
-
{{ account.clientName }}
-
- - {{ account.productName }} - {{ account.status?.value }} -
-
- - -
-
-
- - -
-
×
-
- - -
-
=
-
-
Share Amount
-
{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}
-
-
-
-
- - -
- -
-
- receipt_long - Amount Breakdown -
- - - - - - - - - - - - - @for (charge of charges; track charge.id) { - - - - - } - @if (charges.length === 0) { - - - - } - - - - - - - -
DescriptionAmount
- Share Amount - - ({{ requestedSharesInput }} shares × {{ currencySymbol }}{{ pricePerShare | number: '1.2-2' }}) - - {{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}
{{ charge.name }} - -
- No charges applicable -
Total Payable - {{ currencySymbol }}{{ totalAmount | number: '1.2-2' }} -
-
- - -
-
- account_tree - GL Entries Preview -
- - - - - - - - - - - - - - - - - - - - @if (totalChargeAmount > 0) { - - - - - - } - -
AccountDr / CrAmount
Cash Vault GLDr{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}
Share Sundry GLCr{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}
Charge Receivable GLCr{{ currencySymbol }}{{ totalChargeAmount | number: '1.2-2' }}
-
- info_outline - Funds move to Share GL & Income GL when account is activated. -
-
-
- - - @if (isCashPayment && allDenominations.length > 0) { -
-
- payments - Denomination Entry - @if (denominationTotal > 0) { - - ₹{{ denominationTotal | number: '1.2-2' }} - {{ denomMatchesTotal ? '✓ Matches' : '≠ Mismatch' }} - - } - @if (denominationTotal === 0) { - ⚠ Required for Cash - } -
-
- - - - - - - - - - @for (d of allDenominations; track d.id) { - - - - - - } - - - - - - - - -
DenominationCountAmount
- {{ d.label }} - -
- - - -
-
{{ subtotalFor(d) > 0 ? (subtotalFor(d) | number: '1.2-2') : '—' }}
Total - ₹{{ denominationTotal | number: '1.2-2' }} -
- @if (!denomMatchesTotal && denominationTotal > 0) { -
- warning - Denomination ₹{{ denominationTotal | number: '1.2-2' }} must equal Total Payable ₹{{ - totalAmount | number: '1.2-2' - }}. -
- } - @if (denominationTotal > 0) { -
- -
- } -
-
- } - - -
- - Transaction Date * - - - - - Payment Mode - - — Cash (Default) — - @for (pt of paymentTypes; track pt.id) { - {{ pt.name }} - } - - - - - Note (Optional) - - -
- - - - } - - - @if (pageState === 'success' && collectionResult) { -
-
- check_circle -
-

Collection Posted Successfully

-

- Cash of - {{ collectionResult.currencyCode }} {{ collectionResult.totalAmount | number: '1.2-2' }} - collected from {{ collectionResult.clientName }} on - {{ collectionResult.collectionDate }} by {{ collectionResult.collectedBy }}. -

- - -
- Transaction ID - {{ collectionResult.transactionId }} -
- - -
- @for (entry of collectionResult.glEntries; track entry.account) { -
- {{ entry.type }}. {{ entry.account }} - {{ entry.currency }} {{ entry.amount | number: '1.2-2' }} -
- } -
- -

- Funds are held in Sundry GL. They will move to Share Equity GL when the account is activated. -

- - -
- } -
diff --git a/src/app/cashier/share-collection/share-collection.component.scss b/src/app/cashier/share-collection/share-collection.component.scss deleted file mode 100644 index c3a6606a2..000000000 --- a/src/app/cashier/share-collection/share-collection.component.scss +++ /dev/null @@ -1,701 +0,0 @@ -/** - * 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/. - */ - -// ── Search Bar ─────────────────────────────────────────────────────────────── - -.sc-search-bar { - display: flex; - align-items: center; - gap: 10px; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 12px; - padding: 10px 14px; - - &__input-wrap { - display: flex; - align-items: center; - gap: 8px; - flex: 1 1 auto; - } - - &__icon { - color: #94a3b8; - font-size: 20px; - width: 20px; - height: 20px; - flex-shrink: 0; - } - - &__input { - flex: 1 1 auto; - border: none; - outline: none; - font-size: 14px; - font-family: inherit; - color: #0d1b2a; - background: transparent; - - &::placeholder { - color: #94a3b8; - } - } -} - -// ── Alert ──────────────────────────────────────────────────────────────────── - -.sc-alert { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 16px; - border-radius: 10px; - font-size: 13px; - font-weight: 500; - - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - flex-shrink: 0; - } - - &--error { - background: #fef2f2; - border: 1px solid #fecaca; - color: #dc2626; - } -} - -// ── Spinner ────────────────────────────────────────────────────────────────── - -.sc-spin { - animation: sc-spin 1.2s linear infinite; - display: block; - margin: 0 auto 8px; - font-size: 32px !important; - width: 32px !important; - height: 32px !important; - color: #94a3b8; -} - -@keyframes sc-spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - -// ── Member Card ────────────────────────────────────────────────────────────── - -.sc-member-card { - display: flex; - align-items: center; - gap: 16px; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 12px; - padding: 16px 20px; - - &__avatar { - display: flex; - align-items: center; - justify-content: center; - width: 48px; - height: 48px; - border-radius: 50%; - background: #eff6ff; - flex-shrink: 0; - - mat-icon { - color: #2563eb; - font-size: 24px; - width: 24px; - height: 24px; - } - } - - &__info { - flex: 1 1 auto; - min-width: 0; - } - - &__name { - font-size: 16px; - font-weight: 700; - color: #1e293b; - margin-bottom: 6px; - } - - &__meta { - display: flex; - flex-wrap: wrap; - gap: 6px; - } - - &__shares { - flex-shrink: 0; - display: flex; - align-items: center; - } -} - -// ── Badges ─────────────────────────────────────────────────────────────────── - -.sc-badge { - display: inline-flex; - align-items: center; - height: 22px; - padding: 0 8px; - border-radius: 5px; - font-size: 11px; - font-weight: 700; - background: #f1f5f9; - color: #475569; - border: 1px solid #e2e8f0; - - &--account { - background: #eff6ff; - color: #1d4ed8; - border-color: #bfdbfe; - font-family: 'Roboto Mono', monospace; - } - - &--product { - background: #f0fdf4; - color: #15803d; - border-color: #bbf7d0; - } - - &--pending { - background: #fffbeb; - color: #d97706; - border-color: #fde68a; - } - - &--active { - background: #f0fdf4; - color: #15803d; - border-color: #bbf7d0; - } -} - -// ── Editable Share Inputs ──────────────────────────────────────────────────── - -.sc-shares-inputs { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - justify-content: flex-end; -} - -.sc-shares-field { - display: flex; - flex-direction: column; - gap: 3px; -} - -.sc-shares-label { - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - color: #94a3b8; -} - -.sc-shares-input { - width: 110px; - height: 38px; - border: 1.5px solid #e2e8f0; - border-radius: 8px; - padding: 0 10px; - font-size: 14px; - font-weight: 700; - font-family: inherit; - color: #1353a0; - text-align: right; - outline: none; - background: #f8fafc; - transition: border-color 0.15s; - - &:focus { - border-color: #1353a0; - background: #fff; - } - - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - appearance: none; - } -} - -.sc-shares-sep { - font-size: 20px; - font-weight: 300; - color: #94a3b8; - padding-top: 16px; -} - -.sc-shares-result { - text-align: right; -} - -.sc-shares-total { - font-size: 20px; - font-weight: 800; - color: #0f4f3c; - font-variant-numeric: tabular-nums; -} - -// ── Editable Charge Amount ─────────────────────────────────────────────────── - -.sc-charge-amount { - padding: 4px 14px !important; -} - -.sc-charge-input { - width: 100px; - height: 32px; - border: 1.5px solid #e2e8f0; - border-radius: 6px; - padding: 0 8px; - font-size: 13px; - font-weight: 700; - font-family: inherit; - color: #1353a0; - text-align: right; - outline: none; - background: #f8fafc; - transition: border-color 0.15s; - - &:focus { - border-color: #1353a0; - background: #fff; - } - - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - appearance: none; - } -} - -// ── Split Layout ───────────────────────────────────────────────────────────── - -.sc-split { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; - - @media (width <= 900px) { - grid-template-columns: 1fr; - } -} - -// ── Breakdown Card ─────────────────────────────────────────────────────────── - -.sc-breakdown { - &__header { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 16px; - background: #f8fafc; - border-bottom: 1px solid #e2e8f0; - font-size: 13px; - font-weight: 700; - color: #334155; - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: #0f4f3c; - } - } -} - -.sc-gl-preview { - .sc-breakdown__header { - mat-icon { - color: #1d4ed8; - } - } -} - -.sc-gl-note { - display: flex; - align-items: flex-start; - gap: 6px; - padding: 10px 14px; - font-size: 12px; - color: #64748b; - background: #f8fafc; - border-top: 1px solid #e2e8f0; - - mat-icon { - font-size: 16px; - width: 16px; - height: 16px; - flex-shrink: 0; - margin-top: 1px; - color: #94a3b8; - } -} - -// ── Table helpers ──────────────────────────────────────────────────────────── - -.sc-dim { - color: #94a3b8; - font-size: 12px; -} - -.sc-total-row td { - background: #f8fafc; - border-top: 2px solid #e2e8f0; - font-size: 14px; -} - -.sc-total-amount { - color: #0f4f3c; -} - -// ── Dr / Cr badges ─────────────────────────────────────────────────────────── - -.sc-dr-badge, -.sc-cr-badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 20px; - border-radius: 4px; - font-size: 11px; - font-weight: 800; - letter-spacing: 0.04em; -} - -.sc-dr-badge { - background: #fef2f2; - color: #dc2626; - border: 1px solid #fecaca; -} - -.sc-cr-badge { - background: #f0fdf4; - color: #15803d; - border: 1px solid #bbf7d0; -} - -// ── Footer ─────────────────────────────────────────────────────────────────── - -.sc-footer { - justify-content: space-between !important; - - &__summary { - display: flex; - flex-direction: column; - gap: 2px; - } - - &__label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - color: #64748b; - } - - &__amount { - font-size: 22px; - font-weight: 800; - color: #0f4f3c; - font-variant-numeric: tabular-nums; - } - - &__actions { - display: flex; - align-items: center; - gap: 10px; - } -} - -// ── Denomination Panel ──────────────────────────────────────────────────────── - -.sc-denom { - padding: 0; - overflow: hidden; -} - -.sc-denom-body { - padding: 14px 16px; -} - -.sc-denom-table { - width: 100%; - - th, - td { - padding: 7px 10px; - font-size: 13px; - } - - tfoot td { - border-top: 2px solid #e2e8f0; - background: #f8fafc; - font-size: 14px; - } -} - -.sc-denom-chip { - display: inline-flex; - align-items: center; - height: 24px; - padding: 0 10px; - border-radius: 5px; - font-size: 12px; - font-weight: 700; - background: #eff6ff; - color: #1d4ed8; - border: 1px solid #bfdbfe; - - &--coin { - background: #fffbeb; - color: #d97706; - border-color: #fde68a; - } -} - -.sc-denom-stepper { - display: flex; - align-items: center; - gap: 4px; - justify-content: flex-end; - - button { - width: 26px; - height: 26px; - border: 1.5px solid #e2e8f0; - border-radius: 5px; - background: #f8fafc; - color: #475569; - font-size: 16px; - line-height: 1; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - padding: 0; - - &:hover:not(:disabled) { - background: #e0f2fe; - border-color: #7dd3fc; - color: #0369a1; - } - - &:disabled { - opacity: 0.35; - cursor: not-allowed; - } - } - - input { - width: 62px; - height: 28px; - border: 1.5px solid #e2e8f0; - border-radius: 6px; - padding: 0 6px; - font-size: 13px; - font-weight: 600; - font-family: inherit; - text-align: right; - outline: none; - background: #fff; - - &:focus { - border-color: #1353a0; - } - - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - appearance: none; - } - } -} - -.sc-denom-badge { - margin-left: auto; - display: inline-flex; - align-items: center; - height: 22px; - padding: 0 10px; - border-radius: 5px; - font-size: 12px; - font-weight: 700; - - &--match { - background: #f0fdf4; - color: #15803d; - border: 1px solid #bbf7d0; - } - - &--mismatch { - background: #fef2f2; - color: #dc2626; - border: 1px solid #fecaca; - } -} - -.sc-denom-required { - margin-left: auto; - font-size: 12px; - font-weight: 700; - color: #d97706; -} - -.sc-denom-mismatch { - display: flex; - align-items: center; - gap: 6px; - margin-top: 10px; - padding: 9px 12px; - background: #fef2f2; - border: 1px solid #fecaca; - border-radius: 8px; - font-size: 12px; - font-weight: 600; - color: #dc2626; - - mat-icon { - font-size: 16px; - width: 16px; - height: 16px; - flex-shrink: 0; - } -} - -.sc-denom-actions { - display: flex; - justify-content: flex-end; - margin-top: 10px; -} - -// ── Success ────────────────────────────────────────────────────────────────── - -.sc-success { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - padding: 40px 24px; - gap: 16px; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 16px; - - &__icon-wrap { - width: 72px; - height: 72px; - border-radius: 50%; - background: #f0fdf4; - display: flex; - align-items: center; - justify-content: center; - - mat-icon { - font-size: 40px; - width: 40px; - height: 40px; - color: #16a34a; - } - } - - &__title { - font-size: 22px; - font-weight: 700; - color: #1e293b; - margin: 0; - } - - &__sub { - font-size: 14px; - color: #64748b; - max-width: 480px; - margin: 0; - } - - &__ref { - display: flex; - align-items: center; - gap: 10px; - background: #f1f5f9; - border: 1px solid #e2e8f0; - border-radius: 8px; - padding: 8px 16px; - } - - &__ref-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - color: #94a3b8; - white-space: nowrap; - } - - &__ref-val { - font-family: 'Roboto Mono', 'Courier New', monospace; - font-size: 13px; - font-weight: 600; - color: #1353a0; - } - - &__note { - font-size: 12px; - color: #94a3b8; - max-width: 420px; - margin: 0; - text-align: center; - } - - &__gl { - display: flex; - flex-direction: column; - gap: 6px; - width: 100%; - max-width: 400px; - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 10px; - padding: 14px 16px; - } - - &__gl-row { - display: flex; - justify-content: space-between; - font-size: 13px; - font-weight: 600; - padding: 4px 0; - border-bottom: 1px dotted #e2e8f0; - - &:last-child { - border-bottom: none; - } - - &--dr { - color: #dc2626; - } - - &--cr { - color: #15803d; - } - } -} diff --git a/src/app/cashier/share-collection/share-collection.component.ts b/src/app/cashier/share-collection/share-collection.component.ts deleted file mode 100644 index e9d2119f3..000000000 --- a/src/app/cashier/share-collection/share-collection.component.ts +++ /dev/null @@ -1,282 +0,0 @@ -/** - * 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 { FormsModule } from '@angular/forms'; -import { RouterModule } from '@angular/router'; -import { MatIconModule } from '@angular/material/icon'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { MatInputModule } from '@angular/material/input'; -import { catchError } from 'rxjs/operators'; -import { of } from 'rxjs'; -import { CashierService } from '../cashier.service'; - -type PageState = 'idle' | 'searching' | 'loaded' | 'submitting' | 'success'; - -interface Denomination { - id: number; - value: number; - kind: 'note' | 'coin'; - label: string; -} - -interface ShareCharge { - id: number; - name: string; - amount: number; - currencySymbol: string; -} - -@Component({ - selector: 'mifosx-share-collection', - templateUrl: './share-collection.component.html', - styleUrls: [ - '../cashier-shared.scss', - './share-collection.component.scss' - ], - standalone: true, - imports: [ - CommonModule, - FormsModule, - RouterModule, - MatIconModule, - MatSnackBarModule, - MatFormFieldModule, - MatSelectModule, - MatInputModule - ] -}) -export class ShareCollectionComponent implements OnInit { - private service = inject(CashierService); - private snackBar = inject(MatSnackBar); - - pageState: PageState = 'idle'; - accountInput = ''; - transactionDate = ''; - paymentTypeId: number | null = null; - paymentTypes: any[] = []; - note = ''; - errorMsg = ''; - saving = false; - - account: any = null; - charges: ShareCharge[] = []; - collectionResult: any = null; // filled after successful POST - - // Editable fields — cashier can override if API returns 0 - requestedSharesInput = 0; - pricePerShare = 0; - - // ── Denomination ──────────────────────────────────────────────────────────── - notes: Denomination[] = []; - coins: Denomination[] = []; - denominationCounts: Record = {}; - - get shareAmount(): number { - return this.requestedSharesInput * this.pricePerShare; - } - - get totalChargeAmount(): number { - return this.charges.reduce((s, c) => s + c.amount, 0); - } - - get totalAmount(): number { - return this.shareAmount + this.totalChargeAmount; - } - - get currencySymbol(): string { - return this.account?.currency?.displaySymbol ?? '₹'; - } - - get isCashPayment(): boolean { - if (this.paymentTypeId === null) return true; - const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId); - return !!pt?.isCashPayment; - } - - get allDenominations(): Denomination[] { - const desc = (a: Denomination, b: Denomination) => b.value - a.value; - return [ - ...[...this.notes].sort(desc), - ...[...this.coins].sort(desc) - ]; - } - - get denominationTotal(): number { - return this.allDenominations.reduce((s, d) => s + this.subtotalFor(d), 0); - } - - get denomMatchesTotal(): boolean { - return this.denominationTotal === this.totalAmount; - } - - get isValid(): boolean { - if (!this.account || this.totalAmount <= 0 || !this.transactionDate) return false; - if (this.isCashPayment && this.allDenominations.length > 0) { - return this.denomMatchesTotal; - } - return true; - } - - ngOnInit(): void { - const d = new Date(); - this.transactionDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; - this.service - .getPaymentTypes() - .pipe(catchError(() => of([]))) - .subscribe((types: any[]) => (this.paymentTypes = types || [])); - this.loadDenominations(); - } - - private loadDenominations(): void { - this.service - .getActiveDenominations() - .pipe(catchError(() => of([]))) - .subscribe((list: any[]) => { - const rows: Denomination[] = (list || []).map((d: any) => ({ - id: d.id, - value: d.denomination ?? d.value, - kind: d.type === 'coin' ? 'coin' : 'note', - label: '₹' + (d.denomination ?? d.value) - })); - const order = (a: Denomination, b: Denomination) => b.value - a.value; - this.notes = rows.filter((r) => r.kind === 'note').sort(order); - this.coins = rows.filter((r) => r.kind === 'coin').sort(order); - }); - } - - // ── Denomination helpers ────────────────────────────────────────────────── - - denomKey(d: Denomination): string { - return `${d.kind}:${d.value}`; - } - - countFor(d: Denomination): number { - return this.denominationCounts[this.denomKey(d)] || 0; - } - - subtotalFor(d: Denomination): number { - return this.countFor(d) * d.value; - } - - onDenomChange(d: Denomination, raw: any): void { - this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0)); - } - - bumpDenom(d: Denomination, delta: number): void { - this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta); - } - - clearDenominations(): void { - this.denominationCounts = {}; - } - - searchAccount(): void { - const query = this.accountInput.trim(); - if (!query) return; - this.pageState = 'searching'; - this.account = null; - this.charges = []; - this.errorMsg = ''; - - this.service - .getShareAccountForCollection(query) - .pipe( - catchError((err) => { - this.errorMsg = - err?.error?.errors?.[0]?.defaultUserMessage || - err?.error?.developerMessage || - 'Account not found. Please check the account ID and try again.'; - this.pageState = 'idle'; - return of(null); - }) - ) - .subscribe((acc: any) => { - if (!acc) return; - this.account = acc; - - // Auto-detect shares from summary or first pending purchasedShares entry - const pendingTx = (acc.purchasedShares || []).find( - (t: any) => - t.status?.code === 'purchasedSharesStatusType.applied' && t.type?.code === 'purchasedSharesType.purchased' - ); - this.requestedSharesInput = Number( - acc.summary?.totalPendingForApprovalShares || pendingTx?.numberOfShares || acc.requestedShares || 0 - ); - this.pricePerShare = Number(pendingTx?.purchasedPrice || acc.currentMarketPrice || acc.unitPrice || 0); - - // Show ALL charges on the account — no type filtering - this.charges = (acc.charges || []).map((c: any) => ({ - id: c.chargeId ?? c.id, - name: c.name, - amount: Number(c.amount ?? c.amountOutstanding ?? 0), - currencySymbol: c.currency?.displaySymbol ?? '₹' - })); - - this.pageState = 'loaded'; - }); - } - - recalc(): void { - // triggered by input changes — nothing extra needed, getters auto-recalculate - } - - submit(): void { - if (!this.isValid || this.saving) return; - this.saving = true; - const payload = { - shareAccountId: this.account.id, - accountNo: this.account.accountNo, - clientName: this.account.clientName, - transactionDate: this.transactionDate, - requestedShares: this.requestedSharesInput, - pricePerShare: this.pricePerShare, - shareAmount: this.shareAmount, - chargeAmount: this.totalChargeAmount, - totalAmount: this.totalAmount, - paymentTypeId: this.paymentTypeId || null, - note: this.note || null - }; - - this.service.collectSharePayment(payload).subscribe({ - next: (result: any) => { - this.collectionResult = result; - this.snackBar.open('Share collection posted successfully.', 'Close', { duration: 3000 }); - this.pageState = 'success'; - this.saving = false; - }, - error: (err: any) => { - const msg = - err?.error?.errors?.[0]?.defaultUserMessage || - err?.error?.developerMessage || - err?.error?.defaultUserMessage || - 'Collection failed. Please try again.'; - this.snackBar.open(msg, 'Close', { duration: 8000 }); - this.saving = false; - } - }); - } - - reset(): void { - this.pageState = 'idle'; - this.accountInput = ''; - this.account = null; - this.charges = []; - this.errorMsg = ''; - this.note = ''; - this.paymentTypeId = null; - this.requestedSharesInput = 0; - this.pricePerShare = 0; - this.collectionResult = null; - this.saving = false; - this.clearDenominations(); - } -} diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index de55bc0f0..ee14e4589 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -166,23 +166,50 @@
- + @if (acTypLockedByToken && row === selectedRow) { + {{ acTypLockedLabel }} + } @else { + + }
+ @if (showsEntryType(row)) { +
+ + +
+ } @@ -206,14 +233,20 @@ ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number' " - [readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)" - [class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)" + [readonly]=" + (row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow) + " + [class.ws-f__ctl--readonly]=" + (row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow) + " [title]=" - isDdAccountDebitRow(row) - ? 'Member account the draft is funded from' - : row.typ === 'DD' - ? 'Configured Sponsor DD Payable GL (Auto-selected)' - : 'Account number' + acTypLockedByToken && row === selectedRow + ? 'Locked — filled from a token-driven request, not a normal account entry' + : isDdAccountDebitRow(row) + ? 'Member account the draft is funded from' + : row.typ === 'DD' + ? 'Configured Sponsor DD Payable GL (Auto-selected)' + : 'Account number' " /> @@ -1523,23 +1556,50 @@
- + @if (acTypLockedByToken && row === selectedRow) { + {{ acTypLockedLabel }} + } @else { + + }
+ @if (showsEntryType(row)) { +
+ + +
+ }
@@ -1556,10 +1616,16 @@ [placeholder]=" row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number' " - [readonly]="row.typ === 'DD'" - [class.ws-f__ctl--readonly]="row.typ === 'DD'" + [readonly]="row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)" + [class.ws-f__ctl--readonly]=" + row.typ === 'DD' || (acTypLockedByToken && row === selectedRow) + " [title]=" - row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number' + acTypLockedByToken && row === selectedRow + ? 'Locked — filled from a token-driven request, not a normal account entry' + : row.typ === 'DD' + ? 'Configured Sponsor DD Payable GL (Auto-selected)' + : 'Account number' " /> diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.scss b/src/app/cashier/transaction-entry/transaction-entry.component.scss index 36cd56460..06858c598 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.scss +++ b/src/app/cashier/transaction-entry/transaction-entry.component.scss @@ -916,6 +916,20 @@ input.ws-f__ctl--readonly { font-size: 11px; } +/* AC.TP shown as a locked label instead of the normal editable select, while the row is filled + from a token-driven request whose real account type row.acTyp cannot show (see + acTypLockedByToken in the component). */ +span.ws-f__ctl--locked { + display: inline-flex; + align-items: center; + background: #f1f5f9; + color: #475569; + cursor: not-allowed; + user-select: none; + font-size: 11px; + font-weight: 600; +} + input.ws-amt-input--locked { background: #fef9c3; color: #713f12; diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index 2ea622557..a96bf4f00 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -53,6 +53,19 @@ interface TxnRow { /** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */ ddCharges: number | null; sponsorBankId?: number | null; + /** + * What a GL credit is actually for — the id of a code value under "GLEntryType" (General, Share + * Collection, DD Issue, or whatever an admin has added under Admin > System > Manage Codes). + * + * A cashier Receipt (or a clerk Collection) into a GL account is the counter picking, by hand, + * any account to post cash into — deliberately, so a broken product mapping can never block a + * genuine voucher. That freedom is also the hole: two unrelated flows can land in the same GL + * account with nothing on the row itself saying which one the money was collected for, and a + * manager applying a share request can pick up a voucher that was really meant to fund a DD. This + * tag is what a day-end reconciliation groups by, and what Share Advance Collection / Sponsor DD + * issuance check before adopting a row — only shown/sent for AC.TP=GL, DR./CR.=Cr rows. + */ + entryTypeCvId?: number | null; /** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings * account — governs the share redemption (see submitShareRedemptionTransfer()). */ resolutionNumber?: string; @@ -1703,15 +1716,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { /** * Account types a clerk may fund a demand draft from. * - * A clerk raises a draft against a member's own balance, so the only sensible sources are the - * accounts a member actually holds money in. GL was offered here too, and picking it quietly - * switched the row onto the cash path — the one that asks for a walk-in purchaser's name and a - * note-and-coin count, neither of which a clerk debiting an account has. The option is removed - * rather than validated afterwards, so the wrong path cannot be reached at all. + * A clerk raises a draft against a member's own balance (SD/CA) or, now, against a GL account + * directly for an institutional/holding-account draft (see isDdAccountDebitRow()) — both are + * governed, maker-checker submissions. GL used to be offered here too in a different sense: picking + * it quietly switched the row onto the CASHIER's walk-in cash path instead (the one that asks for a + * purchaser's name and a note-and-coin count), which isDdAccountDebitRow() now disambiguates by + * role — GL means "debit this GL account" for a clerk, and still means "cash/GL purchase" for a + * cashier, who never reaches this list at all (isDdAccountDebitRow() is clerk-only). */ private static readonly DD_CLERK_ACTYPS = [ 'SD', - 'CA' + 'CA', + 'GL' ]; /** @@ -1726,6 +1742,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } return this.acTypOptions; } + + /** True while the selected row is filled from a token-driven request whose row.acTyp is a + * technical stand-in rather than the account's real type — a dividend claim or an account closure + * both set row.acTyp = 'SD' purely to route past the SHR-specific guards elsewhere on this screen + * (see onTokNoBlur()), not because the counter is paying against a savings account. The AC.TP + * dropdown must not be left editable there: it already shows the wrong value, and a cashier + * changing it further would desync the row from the request it was auto-filled from. A plain + * withdrawal-slip token is deliberately excluded — its row.acTyp is resolved to the account's real + * type there, so the normal editable control already shows it correctly and there's nothing to + * lock. */ + get acTypLockedByToken(): boolean { + return !!(this.activeDividendClaimRequest || this.activeClosureRequest); + } + + /** What to show in place of the AC.TP dropdown while it's locked — the same label the account + * summary strip above already uses for each of these token pools, so the two never contradict + * each other on screen. */ + get acTypLockedLabel(): string { + if (this.activeClosureRequest) { + return 'Closure'; + } + if (this.activeDividendClaimRequest) { + return 'Dividend'; + } + return ''; + } + dbCrOptions = [ 'Dr', 'Cr' @@ -1834,6 +1877,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.loadCashVaultGl(); this.loadDdGstPercentage(); this.loadSponsorBanks(); + this.cashierService + .getEntryTypeOptions() + .pipe(catchError(() => of([]))) + .subscribe((options: any[]) => { + this.entryTypeOptions = options ?? []; + }); if (this.reviewMode) { this.loadRequestIntoForm(this.reviewRequestId!, 'review'); @@ -2321,14 +2370,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } /** - * TYPE=DD funded by debiting a member's savings/current account rather than by cash. + * TYPE=DD funded by debiting a member's savings/current account, or a GL account directly, rather + * than by cash. * - * Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account debit - * would let a draft be raised against an account with no cash to reconcile it against — and the - * clerk flow is the one that carries a maker-checker step over the member's balance. + * Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account/GL debit + * would let a draft be raised with no cash to reconcile it against — and the clerk flow is the one + * that carries a maker-checker step over the money moved. AC.TP = GL means something different for + * a cashier's own DD row (a walk-in cash/GL purchase, see isDdCashGlRow()) — this method is checked + * first there specifically so a clerk's GL row is claimed here instead. */ isDdAccountDebitRow(row: TxnRow): boolean { - return this.isClerk && row.typ === 'DD' && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB'); + return ( + this.isClerk && + row.typ === 'DD' && + (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL') + ); } /** @@ -2342,6 +2398,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row); } + /** + * Whether this row needs an Entry Type tag — a credit into a GL account, which is exactly the + * case where the money's real purpose isn't otherwise recorded anywhere (see TxnRow.entryTypeCvId). + * A debit out of a GL account, or any entry against a savings/current/share account, carries no + * such ambiguity — the account itself says what it's for. + */ + showsEntryType(row: TxnRow): boolean { + return row.acTyp === 'GL' && row.dbCr === 'Cr'; + } + + /** + * Entry Type choices for a GL credit — the "GLEntryType" code (Admin > System > Manage Codes), + * fetched once in ngOnInit(). A code value, not a free-text field, so the day-end "group by type" + * this exists for stays reliable; an admin extends the list there, not in this file. + */ + entryTypeOptions: { id: number; name: string }[] = []; + /** GST on DD charges only (never on the DD face value), at the admin-configured rate. */ ddGstAmountFor(row: TxnRow): number { const charges = Number(row.ddCharges) || 0; @@ -2408,19 +2481,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // An account-funded draft needs the account resolved to a Fineract id before it can be sent: // the server takes a savings/current account id and a client id, not the typed number. Both are // filled in by the A/C NO. lookup, so an unresolved account means the number was never found. + // A GL-funded draft is the exception: a GL account is never tied to a client at all (see + // fetchAllDetails() — GL rows never populate rowClientIds), so only the account id is required. + const isGlDebit = accountDebit && row.acTyp === 'GL'; let fundingAccountId: number | null = null; let fundingClientId: number | null = null; if (accountDebit) { const acNo = (row.acNo || '').trim(); if (!acNo) { this.showAlert('error', 'Account Required', [ - 'Enter the account number the Demand Draft is funded from.' + isGlDebit + ? 'Enter the GL account the Demand Draft is funded from.' + : 'Enter the account number the Demand Draft is funded from.' ]); return; } fundingAccountId = this.rowAccountIds[acNo] ?? null; - fundingClientId = this.rowClientIds[acNo] ?? null; - if (!fundingAccountId || !fundingClientId) { + fundingClientId = isGlDebit ? null : (this.rowClientIds[acNo] ?? null); + if (!fundingAccountId || (!isGlDebit && !fundingClientId)) { this.showAlert('error', 'Account Not Found', [ `Account ${acNo} could not be resolved.`, 'Check the account number and wait for its details to load before raising the draft.' @@ -2510,29 +2588,32 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } /** - * Raises a Demand Draft funded by debiting a member's savings or current account. + * Raises a Demand Draft funded by debiting a member's savings/current account, or a GL account + * directly. * - * Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The member's - * account is only touched when a checker approves, which is also when the balance is re-checked — - * so a draft raised against a sufficient balance can still be refused if the balance has since - * gone. No denominations are sent, because no cash is taken. + * Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The + * account/GL is only touched when a checker approves, which for a member account is also when the + * balance is re-checked — so a draft raised against a sufficient balance can still be refused if + * the balance has since gone (a GL account carries no such balance check). No denominations are + * sent, because no cash is taken. * - * The account goes in savingsAccountId or currentAccountId, never both: the server insists on - * exactly one, since which table the account lives in decides which control GL is debited. + * The funding account goes in exactly one of savingsAccountId, currentAccountId or glAccountId: the + * server insists on exactly one, since which one decides what gets debited on approval. */ private submitAccountDebitDd( row: TxnRow, leaflet: any, beneficiaryData: any, fundingAccountId: number, - fundingClientId: number + fundingClientId: number | null ): void { const d = new Date(); const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB'; + const isGl = row.acTyp === 'GL'; const payload = { - clientId: fundingClientId, + clientId: isGl ? undefined : (fundingClientId ?? undefined), beneficiaryName: beneficiaryData.beneficiaryName, payableAtBranch: beneficiaryData.payableAtBranch, payableAtCity: beneficiaryData.payableAtCity, @@ -2540,9 +2621,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { chargesAmount: Number(row.ddCharges) || 0, issuingBranchId: row.br, issuedDate, - paymentMethod: 'ACCOUNT_DEBIT' as const, - savingsAccountId: isCurrent ? undefined : fundingAccountId, - currentAccountId: isCurrent ? fundingAccountId : undefined + paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT', + savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId, + currentAccountId: isGl || !isCurrent ? undefined : fundingAccountId, + glAccountId: isGl ? fundingAccountId : undefined }; this.saving = true; @@ -2802,7 +2884,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // Without this the backend defaults every request from this screen to DISBURSEMENT — a // Collection or Fund Transfer submitted here was being saved (and shown in the checker's // inbox) as a plain Disbursement. - requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT' + requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT', + // Same ambiguity as a cashier Receipt — a Collection credits a GL account picked by hand. + // See TxnRow.entryTypeCvId. + ...(isCollectionMode ? { entryTypeCvId: crRows[0]?.entryTypeCvId || null } : {}) }; this.saving = true; @@ -3044,6 +3129,168 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { ); } + /** + * Share redemption settled to a GL account the clerk picks on the CR row, instead of the + * member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the + * credit side is any GL account (e.g. a "DD Payable" holding account), not a fixed linked + * account, so there is no "must match the linked account" check here. + */ + private submitShareGlSettlementTransfer(shareRow: TxnRow, glRow: TxnRow): void { + // Belt-and-braces: the TXN TP. dropdown already hides cash payment types for AC.TP = SHR (see + // paymentTypesForRow()), but re-check here too in case the payment type was set before that + // filter applied. + const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === glRow.paymentTypeId); + if (!glRow.paymentTypeId) { + this.showAlert('error', 'Settlement Payment Type Required', [ + 'Select a Payment Type on the CR (GL account) row — it governs how the settlement credit posts.' + ]); + return; + } + if (settlementPaymentType?.isCashPayment) { + this.showAlert('error', 'Cash Not Allowed for Share Transactions', [ + 'Share redemption settlement is always a book-transfer, never cash.', + 'Select Transfer as the Payment Type on the CR row.' + ]); + return; + } + + const shareAccountId = this.rowAccountIds[shareRow.acNo]; + if (!shareAccountId) { + this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]); + return; + } + const settlementGlAccountId = this.rowAccountIds[glRow.acNo]; + if (!settlementGlAccountId) { + this.showAlert('error', 'GL Account Not Loaded', [`Please load account details for: ${glRow.acNo}`]); + return; + } + + const shares = Number(shareRow.amount) || 0; + if (shares <= 0) { + this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']); + return; + } + if (!shareRow.resolutionNumber?.trim()) { + this.showAlert('error', 'Board Resolution Number Required', [ + 'A share withdrawal must be authorised by a Board Resolution.' + ]); + return; + } + if (!shareRow.meetingDate) { + this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']); + return; + } + + const today = new Date(); + const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + + // Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim + // for audit purposes, same as the linked-savings path above. + const settlementRowSnapshot = { + branch: glRow.br, + accountType: glRow.acTyp, + accountNo: glRow.acNo, + ecsAccountNo: glRow.ecsAcNo || null, + dbCr: glRow.dbCr, + amount: glRow.amount ?? null, + chequeNo: glRow.chequeNo || null, + chequeDate: glRow.cheqDate || null, + tokenNo: glRow.tokNo || null, + description: glRow.description || null, + bankName: glRow.bankName || null, + routingCode: glRow.routingCode || null, + paymentTypeId: glRow.paymentTypeId, + paymentTypeName: settlementPaymentType?.name ?? null + }; + + const payload = { + shareAccountId, + accountNo: shareRow.acNo, + clientId: this.rowClientIds[shareRow.acNo] ?? null, + clientName: this.rowAccountNames[shareRow.acNo] ?? null, + requestedShares: shares, + requestedDate, + resolutionNumber: shareRow.resolutionNumber.trim(), + meetingDate: shareRow.meetingDate, + description: shareRow.description || null, + settlementPaymentTypeId: glRow.paymentTypeId, + settlementRowSnapshot, + settlementMode: 'GL', + settlementGlAccountId + }; + + const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0; + const currentHolding = this.rowShareHoldings[shareRow.acNo] ?? 0; + const remainingShares = currentHolding - shares; + const willCloseAccount = remainingShares <= 0; + const confirmLines = [ + `Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`, + `Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`, + `Remaining Shares After Withdrawal: ${Math.max(remainingShares, 0)}`, + ...(willCloseAccount + ? ['⚠ All shares withdrawn — this account will be CLOSED automatically when the Manager approves.'] + : []), + `Board Resolution No.: ${payload.resolutionNumber}`, + `Date of Meeting: ${payload.meetingDate}`, + `Settlement Credit To (GL): ${glRow.acNo}`, + `Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`, + ...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []), + '', + willCloseAccount + ? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.' + : 'Pending Manager approval — redemption and settlement both post together on approval.' + ]; + this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () => + this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo) + ); + } + + private doSubmitShareGlSettlementTransfer( + payload: Record, + shareRow: TxnRow, + shares: number, + glAcNo: string + ): void { + this.saving = true; + this.clerkShareWithdrawalService + .submit(payload) + .pipe( + catchError((err: any) => { + const msg = + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.developerMessage ?? + err?.error?.defaultUserMessage ?? + 'Share withdrawal submission failed. Please try again.'; + this.showAlert('error', 'Submission Failed', [msg]); + this.saving = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + if (res) { + const willCloseAccount = (this.rowShareHoldings[shareRow.acNo] ?? 0) - shares <= 0; + this.showAlert('success', 'Share Withdrawal Submitted', [ + `${shares} share(s) from ${shareRow.acNo} → settlement to GL ${glAcNo} on approval.`, + willCloseAccount + ? 'Pending Manager approval — this will close the share account, since all shares will be withdrawn. Track status in "My Share Requests".' + : 'Pending Manager approval. Track status in "My Share Requests".' + ]); + this.rows = []; + this.rowAccountIds = {}; + this.rowAccountNames = {}; + this.rowSharePrices = {}; + this.rowClientIds = {}; + this.rowLinkedSavingsAcNo = {}; + this.rowShareHoldings = {}; + this.addRow(); + this.selectedRow = this.rows[0]; + this.accountDetails = null; + this.clientDetails = null; + } + }); + } + private doSubmitShareRedemptionTransfer( payload: Record, shareRow: TxnRow, @@ -3103,37 +3350,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { .pipe( catchError((err: any) => { // Not a withdrawal-request token at all (as opposed to PENDING/WITHDRAWN/REJECTED, - // which are legitimate matches with their own specific error) — try the dividend - // cash-claim pool, then the account-closure pool, before giving up: all three share - // the same TOK. NO. field at this counter. + // which are legitimate matches with their own specific error) — try the account-closure + // pool before giving up: both share the same TOK. NO. field at this counter. A dividend + // claim was once a third pool here, but a dividend never actually pays out in cash over + // this counter — it only ever settles through a Demand Draft, which is its own DD + // issuance request and its own token, so looking one up here could only ever 404. if (err?.status === 404) { - return this.dividendClaimRequestService.getByToken(token).pipe( - catchError((divErr: any) => { - if (divErr?.status === 404) { - return this.closureRequestService.getByToken(token).pipe( - catchError((closeErr: any) => { - this.loadingWithdrawal = false; - const msg: string = - closeErr?.error?.message ?? - closeErr?.error?.errors?.[0]?.defaultUserMessage ?? - `Token '${token}' could not be verified. Please try again.`; - this.showAlert('error', `Token '${token}'`, [msg]); - return of(null); - }), - map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null)) - ); - } + return this.closureRequestService.getByToken(token).pipe( + catchError((closeErr: any) => { this.loadingWithdrawal = false; const msg: string = - divErr?.error?.message ?? - divErr?.error?.errors?.[0]?.defaultUserMessage ?? + closeErr?.error?.message ?? + closeErr?.error?.errors?.[0]?.defaultUserMessage ?? `Token '${token}' could not be verified. Please try again.`; this.showAlert('error', `Token '${token}'`, [msg]); return of(null); }), - // Only tag a result that actually came from the dividend pool — a closure hit - // recovered by the inner fallback above is already tagged and must stay that way. - map((divReq: any) => (divReq && !divReq.__closureRequest ? { __dividendClaim: true, ...divReq } : divReq)) + map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null)) ); } this.loadingWithdrawal = false; @@ -3159,7 +3392,16 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.clientDetails = null; this.accountError = ''; row.acNo = req.client_name ?? 'Dividend Claim #' + req.id; + // Marks this acNo as already resolved, the same way loadAccountDetails() itself would — + // without it, the next click into this row (onRowSelect) sees an acNo that was never + // "loaded" and re-searches for a real account matching this stub string, which can land + // on an unrelated real account whose number happens to share a suffix with it. + this.lastLoadedAcNo = row.acNo; row.acTyp = 'SD'; + // Every token-driven row is cash leaving the branch to a member, which is what the + // Payment register is for — not what this row's TYPE happened to default to before the + // token was entered. + row.typ = 'Payment'; row.amount = Number(req.amount) || null; row.dbCr = 'Dr'; this.selectedRow = row; @@ -3181,7 +3423,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.clientDetails = null; this.accountError = ''; row.acNo = req.account_no ?? ''; + // See the same line in the dividend-claim branch above for why this is required. + this.lastLoadedAcNo = row.acNo; row.acTyp = 'SD'; + row.typ = 'Payment'; row.amount = Number(req.cashier_disbursed_amount) || null; row.dbCr = 'Dr'; // A closure request stores payment_type_id as NULL, meaning "Cash (Default)" — resolve the @@ -3194,15 +3439,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } this.activeWithdrawalRequest = req; + // A plain withdrawal-slip request carries its own real account_type (SAVINGS, CURRENT, + // FIXED_DEPOSIT or RECURRING_DEPOSIT — see ClerkWithdrawalRequestApiResource.submit()), unlike + // the closure/dividend pools above, which are never anything but a savings-shaped stub. + // Hardcoding 'SD' here searched every non-savings account on the wrong endpoint (Current + // accounts live under /currentaccounts, not /savingsaccounts) and showed it mislabelled even + // when the lookup happened to succeed. + const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD'; row.acNo = req.account_no ?? ''; - row.acTyp = 'SD'; + row.acTyp = acTyp; + row.typ = 'Payment'; row.amount = Number(req.amount) || null; row.dbCr = 'Dr'; row.paymentTypeId = req.payment_type_id ?? null; row.description = req.description ?? ''; this.selectedRow = row; if (req.account_no) { - this.loadAccountDetails(req.account_no.trim(), 'SD'); + this.loadAccountDetails(req.account_no.trim(), acTyp); } }); } @@ -3229,10 +3482,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { onAcNoBlur(row: TxnRow, index: number): void { if (!row.acNo.trim()) return; this.selectedRow = row; + // A token-driven row's account fields are already authoritative (see onTokNoBlur) -- merely + // clicking into and out of the A/C NO. field here must not re-search for them. Doing so used + // row.acTyp's internal stand-in value (e.g. 'SD' for a dividend claim, which is never the + // account's real type) against the account's real number, either finding nothing ("Account + // not found") or, worse, matching a completely unrelated real account that happened to share a + // number suffix. + if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) { + return; + } this.loadAccountDetails(row.acNo.trim(), row.acTyp); } onAcNoInput(row: TxnRow, index: number, value: string): void { + // Same reasoning as the guard at the top of onAcNoBlur() -- a token-driven row's account + // fields must not be edited or re-searched via this field at all. + if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) { + return; + } row.acNo = value; this.lastLoadedAcNo = ''; this.acNoInput$.next({ row, index, value }); @@ -3341,8 +3608,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { const match = exactMatch ?? partialMatch ?? idMatch ?? null; if (match?.id) { const matchedGlCode = match[f] ?? acNo; - if (this.selectedRow && this.selectedRow.typ === 'DD' && match.glCode) { - this.selectedRow.acNo = match.glCode; + // Any row resolved via the partial/endsWith/id fallback above (not an exact code match) + // loads and displays the right account, but the row itself still carries whatever shorter + // string the cashier actually typed to search with -- and that's what gets submitted and + // shown everywhere downstream (vouchers, My Work, credit_entries), not the account's real + // full number. Correct the row to the account's actual code whenever the match wasn't + // already exact, for every account type -- not just the DD/GL cases this was previously + // limited to. + if (this.selectedRow && !exactMatch && matchedGlCode) { + this.selectedRow.acNo = matchedGlCode; } const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null; this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch); @@ -4519,6 +4793,26 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // "Disbursement". 'Enter Bank Transfer' never carries a GL leg, so it always stays in this // branch; only skip out to the Disbursement/Collection branch below when every row is 'Fund // Transfer' and at least one of them is GL. + // Share redemption settled to a GL account instead of the linked savings account: DR=Share + // account, CR=any GL account the clerk picks (e.g. a "DD Payable" holding account, ahead of a + // second, independent manual entry that later moves the value out to an actual draft). Checked + // ahead of both branches below — the Enter Bank Transfer/Fund Transfer branch would otherwise + // treat a Fund Transfer carrying a GL leg as the Disbursement/Collection shape (see + // fundTransferRowsHaveGl below), and a Share account can never be debited by that path at all — + // no such Fineract command exists — so it would either be silently mis-posted or rejected late. + if ( + this.isClerk && + this.rows.length === 2 && + this.rows.every((r) => r.typ === 'Fund Transfer' || r.typ === 'Enter Bank Transfer') && + this.rows.some((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR') && + this.rows.some((r) => r.dbCr === 'Cr' && r.acTyp === 'GL') + ) { + const shareRow = this.rows.find((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR')!; + const glRow = this.rows.find((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')!; + this.submitShareGlSettlementTransfer(shareRow, glRow); + return; + } + const fundTransferRowsHaveGl = this.rows.every((r) => r.typ === 'Fund Transfer') && this.rows.some((r) => r.acTyp === 'GL'); if ( @@ -5650,6 +5944,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { chequeNo: row.chequeNo || null, bankName: row.bankName || null, denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null, + // What this GL credit is actually for — see TxnRow.entryTypeCvId. Sent for every GL deposit + // (the backend defaults to "General" when the counter didn't pick one), so Share Advance + // Collection and Sponsor DD issuance can tell whose voucher this really is before adopting it. + ...(isGl ? { entryTypeCvId: row.entryTypeCvId || null } : {}), // DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face // value/charges/GST back once this request is approved, instead of retyping them. ...(this.isDdCashGlRow(row) diff --git a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss index 13b5f7636..dc84e930b 100644 --- a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss +++ b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss @@ -576,13 +576,11 @@ } /* ───────── Print — only the page, no toolbar / dialog chrome ───────── - This dialog is opened with `maxHeight: '92vh'`, which Angular Material carries onto the - overlay pane / .mat-mdc-dialog-container as an inline max-height + overflow:auto clip. - `position: absolute` still lives inside that clip and gets cut off — only the on-screen - scroll position prints. `position: fixed` escapes it: a fixed element's containing block is - the viewport itself, not the clipped ancestor (the classic "fixed child ignores an - overflow:hidden parent" behaviour), as long as nothing between here and the viewport sets a - transform/filter — the CDK overlay chrome doesn't. */ + The document-level clip (`html, body { overflow: hidden }`) is lifted globally in main.scss's + print block; what remains here is this dialog's own clip — it opens with `maxHeight: '92vh'`, + which Angular Material carries onto the overlay pane / .mat-mdc-dialog-container as an inline + max-height plus overflow:auto. Freeing those lets the whole certificate flow onto the page in + normal static order (a fixed/absolute hack would pin it to the first page instead). */ @media print { body > :not(.cdk-overlay-container) { display: none !important; @@ -611,9 +609,7 @@ } .cpd-root { - position: fixed; - left: 0; - top: 0; + position: static; width: 100% !important; height: auto !important; background: #fff; diff --git a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts index 14693833d..4cb5e33d2 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts +++ b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts @@ -70,16 +70,9 @@ export class ShareProductAccountingStepComponent implements OnInit { shareProductAccountingForm: UntypedFormGroup; accountingSectionConfigs: ShareAccountingSectionConfig[] = []; - assetAccountData: GLAccount[] = []; liabilityAccountData: GLAccount[] = []; incomeAccountData: GLAccount[] = []; equityAccountData: GLAccount[] = []; - /** "Share Reference" is where a redemption's credit side lands pending settlement — some banks - * model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default), - * others as a payable-like Liability suspense account (e.g. an existing "Sundry" account - * already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts - * either instead of Asset-only. */ - referenceAccountData: GLAccount[] = []; constructor() { this.createShareProductAccountingForm(); @@ -87,18 +80,15 @@ export class ShareProductAccountingStepComponent implements OnInit { ngOnInit() { const opts = this.shareProductsTemplate.accountingMappingOptions || {}; - this.assetAccountData = opts.assetAccountOptions || []; this.liabilityAccountData = opts.liabilityAccountOptions || []; this.incomeAccountData = opts.incomeAccountOptions || []; this.equityAccountData = opts.equityAccountOptions || []; - this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData); this.buildAccountingSectionConfigs(); if (this.shareProductsTemplate.accountingMappings) { const m = this.shareProductsTemplate.accountingMappings; this.shareProductAccountingForm.patchValue({ - shareReferenceId: m.shareReferenceId?.id || '', shareSuspenseId: m.shareSuspenseId?.id || '', incomeFromFeeAccountId: m.incomeFromFeeAccountId?.id || '', shareEquityId: m.shareEquityId?.id || '' @@ -109,10 +99,6 @@ export class ShareProductAccountingStepComponent implements OnInit { createShareProductAccountingForm() { this.shareProductAccountingForm = this.formBuilder.group({ accountingRule: [2], - shareReferenceId: [ - '', - Validators.required - ], shareSuspenseId: [ '', Validators.required @@ -125,7 +111,6 @@ export class ShareProductAccountingStepComponent implements OnInit { '', Validators.required ], - assetsOtherGlMappings: this.formBuilder.array([]), liabilitiesOtherGlMappings: this.formBuilder.array([]), incomeOtherGlMappings: this.formBuilder.array([]), equityOtherGlMappings: this.formBuilder.array([]) @@ -134,24 +119,6 @@ export class ShareProductAccountingStepComponent implements OnInit { buildAccountingSectionConfigs() { this.accountingSectionConfigs = [ - { - key: 'assets', - title: 'Assets', - pillLabel: 'Asset', - pillClass: 'green', - rows: [ - { - key: 'shareReferenceId', - label: 'Share Reference (Cash Vault or Suspense)', - inputLabel: 'Share reference', - controlName: 'shareReferenceId', - options: this.referenceAccountData, - required: true - } - ], - otherMappingsControlName: 'assetsOtherGlMappings', - otherAccountOptions: this.assetAccountData - }, { key: 'liabilities', title: 'Liabilities', @@ -239,7 +206,7 @@ export class ShareProductAccountingStepComponent implements OnInit { } get shareProductAccounting() { - const { assetsOtherGlMappings, liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } = + const { liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } = this.shareProductAccountingForm.value; return rest; } diff --git a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html index 4bc274f94..4f1064ae8 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html +++ b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html @@ -213,14 +213,6 @@ @if (shareProduct.accountingRule === 2) { -
{{ 'labels.heading.Assets' | translate }}
-
- {{ 'labels.inputs.Share reference' | translate }}: - {{ - shareProduct.shareReferenceId - | find: shareProductsTemplate.accountingMappingOptions.assetAccountOptions : 'id' : 'name' - }} -
{{ 'labels.heading.Liabilities' | translate }}
{{ 'labels.inputs.Share suspense control' | translate }}: diff --git a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html index 4538cc62a..2cd01ff86 100644 --- a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html +++ b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html @@ -208,16 +208,6 @@ @if (shareProduct.accountingRule.id === 2) {
-

{{ 'labels.heading.Assets' | translate }}

-
- - -

{{ 'labels.heading.Liabilities' | translate }}

-
- +
+ + +
+
+ add_card +
+
+

{{ 'Apply Additional Shares' | translate }}

+

Purchase additional share tranches via cash receipts or savings debit

+
+
+
- -
- - {{ '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 }} - - } - - - - {{ '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 }} - - - - - - {{ 'labels.inputs.Amount' | translate }} - - {{ 'labels.inputs.Total No. of Shares' | translate }} × - {{ 'labels.inputs.Current Price' | translate }} - - - -
-
{{ 'labels.inputs.Payment Option' | translate }}
- - Apply Collection (Cash) - Debit from Savings - + + +
+
+ assignment + Application & Resolution Details
- @if (paymentMode === 'CASH') { -
-

- The member has already paid at the counter. Tick the receipts that make up this payment, or enter a - voucher number to find one — tick more than one where they paid in instalments. The cash is sitting in - Share Sundry against them, and approving this request moves it into share capital. -

+
+ + + {{ '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 }} + + } + +
+
+ + +
+
+ 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 }} +
+
+
+ + +
+
+ account_balance_wallet + {{ 'labels.inputs.Payment Option' | translate }} +
+ +
+
+
+ payments +
+
+ Apply Collection (Cash) + Link receipts paid at counter sitting in Share Sundry +
+ @if (paymentMode === 'CASH') { + check_circle + } +
+ +
+
+ account_balance +
+
+ Debit from Savings + Direct debit from member's linked savings account +
+ @if (paymentMode === 'SAVINGS') { + check_circle + } +
+
+
+ + + @if (paymentMode === 'CASH') { +
+
+ + + Select the one receipt that covers this payment, or enter a voucher number to search. Click + visibility on any row to view full details popup. + +
+ + +
+
+ - -
- - @if (voucherError) { -
- error_outline - {{ voucherError }} +
+ +
+ +
- } +
+
+ @if (voucherError) { +
+ error_outline + {{ voucherError }} +
+ } + + +
@if (loadingVouchers) {
- Loading the member's receipts… + Loading member receipts…
} @else if (vouchers.length === 0) {
- info_outline -
- No unapplied receipt for this member. -
- Collect the cash first from Cashier › Transaction Entry as a deposit to the Share Sundry - GL. Once it is approved, the voucher appears here. -
+ 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 { - - - - - - - - - - - - @for (v of vouchers; track v.id) { - - - - - - - } - - @if (selectedVoucherIds.length > 0) { - - - - - - - - } -
Voucher / ReceiptDateAmount
- - {{ v.voucher_no || v.receipt_no }}{{ v.collection_date }} - {{ v.amount | number: '1.2-2' }} -
- {{ selectedVoucherIds.length }} selected, against {{ amount | number: '1.2-2' }} - - {{ selectedVoucherTotal | number: '1.2-2' }} -
+
+ +
+
+ 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 - The selected receipts are {{ voucherShortfall | number: '1.2-2' }} short of - {{ amount | number: '1.2-2' }}. Tick another receipt, or collect the balance at the counter first. + 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 (paymentMode === 'SAVINGS') { -
-

- The amount is debited from the member's linked savings into Share Sundry when this is submitted, so - their balance drops now. Approving the request then moves it into share capital. -

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

- Submitting records a request for approval. The shares are issued, and the money moved out of Share Sundry, - only once it is approved — until then it appears under pending, not in the account's transactions. -

+ +
+ schedule + + Submitting records a request for approval. Shares are issued and funds moved out of Share Sundry only upon + manager approval. +
- - -
+ + +@if (viewingVoucherDetail) { + +} diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss index 9a9fa643c..80603efdc 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss @@ -6,201 +6,1027 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -$primary: #1a3a6b; -$border: #d8e0ec; -$muted: #6b7a90; -$danger: #b3261e; +// Modern Color Tokens +$primary: #1e3a8a; +$primary-light: #eff6ff; +$primary-border: #bfdbfe; +$accent-blue: #2563eb; +$text-main: #0f172a; +$text-muted: #64748b; +$border-color: #e2e8f0; +$border-light: #f1f5f9; +$bg-surface: #fff; +$bg-slate: #f8fafc; -.container { - max-width: 37rem; +$success: #059669; +$success-bg: #ecfdf5; +$success-border: #a7f3d0; + +$warning: #d97706; +$warning-bg: #fffbeb; +$warning-border: #fde68a; + +$danger: #dc2626; +$danger-bg: #fef2f2; +$danger-border: #fecaca; + +.action-container { + max-width: 72rem; + margin: 0.75rem auto; + padding: 0 0.5rem; } -.payment-mode { - margin: 4px 0 16px; +.action-card { + border-radius: 12px; + border: 1px solid $border-color; + background: $bg-surface; + box-shadow: 0 4px 16px -4px rgb(15 23 42 / 6%); + overflow: hidden; } -.payment-mode-label { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.4px; - color: $muted; - margin-bottom: 8px; +/* Compact Header */ +.card-header-banner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%); + color: #fff; + + .header-icon-box { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + background: rgb(255 255 255 / 20%); + border-radius: 8px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #fff; + } + } + + .header-text { + h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + color: #fff; + } + + p { + margin: 1px 0 0; + font-size: 0.78rem; + color: rgb(255 255 255 / 85%); + } + } } -.payment-mode-options { +.card-body { + padding: 0.85rem !important; +} + +/* Form Sections */ +.form-section { + background: $bg-surface; + border: 1px solid $border-light; + border-radius: 10px; + padding: 0.75rem 0.85rem; + margin-bottom: 0.65rem; + + &.financial-section { + background: $bg-slate; + border-color: $border-color; + } +} + +.section-title { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + font-weight: 700; + color: $text-main; + margin-bottom: 0.65rem; + padding-bottom: 0.35rem; + border-bottom: 1px solid $border-light; + + .sec-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } +} + +/* Compact Form Grids */ +.form-grid { + display: grid; + gap: 0.65rem; + + &.form-grid-3 { + grid-template-columns: repeat(3, 1fr); + + @media (width <= 680px) { + grid-template-columns: 1fr; + } + } +} + +.align-items-center { + align-items: center; +} + +mat-form-field { + width: 100%; +} + +.shares-count-input { + text-align: left; +} + +// Disabled read-back field (auto-filled market price) — right-aligned so the figure sits right +// next to its "per share" suffix instead of stranded at the far left of an otherwise-empty field. +.price-input { + text-align: right; +} + +// Material fades a disabled outline field almost to invisibility by default, which next to the +// enabled "Total No. of Shares" field (a solid dark border) reads as a missing box rather than a +// deliberately read-only one. Matched back to the same border weight and label/value contrast. +.price-field ::ng-deep { + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: $border-color !important; + opacity: 1 !important; + } + + .mdc-floating-label { + color: $text-muted !important; + } + + input:disabled { + color: $text-main !important; + -webkit-text-fill-color: $text-main !important; + } +} + +.currency-label { + color: $text-muted; + font-size: 0.8rem; + white-space: nowrap; + margin-left: 0.35rem; +} + +/* Financial Stat Display Card */ +.amount-display-card { display: flex; flex-direction: column; - gap: 4px; + justify-content: center; + background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); + border: 1px solid $primary-border; + border-radius: 8px; + padding: 0.45rem 0.75rem; + height: 48px; + box-sizing: border-box; + + .amount-label { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #1e40af; + font-weight: 700; + } + + .amount-value { + font-size: 1.1rem; + font-weight: 700; + color: #1e3a8a; + font-family: 'Courier New', monospace; + line-height: 1.1; + } + + .amount-subtext { + font-size: 0.65rem; + color: #3b82f6; + } } -/* The funding route's own detail, boxed off from the share fields above it: which voucher, or - which savings account, is what the clerk checks before submitting. */ +/* Payment Option Radio Cards */ +.payment-card-group { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.65rem; + + @media (width <= 580px) { + grid-template-columns: 1fr; + } +} + +.payment-card-option { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.65rem 0.75rem; + border: 1.5px solid $border-color; + border-radius: 10px; + background: $bg-surface; + cursor: pointer; + user-select: none; + transition: all 0.15s ease; + + .option-icon-box { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: $bg-slate; + color: $text-muted; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } + + .option-content { + display: flex; + flex-direction: column; + + .option-title { + font-size: 0.82rem; + font-weight: 600; + color: $text-main; + } + + .option-desc { + font-size: 0.72rem; + color: $text-muted; + } + } + + .selected-check { + margin-left: auto; + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } + + &:hover { + border-color: #93c5fd; + background: #f8fafc; + } + + &.active { + border-color: $accent-blue; + background: $primary-light; + + .option-icon-box { + background: $accent-blue; + color: #fff; + } + } +} + +/* Funding Panel */ .funding-panel { - border: 1px solid $border; + border: 1px solid $border-color; + border-radius: 10px; + padding: 0.75rem; + background: #fcfdfe; + margin-top: 0.4rem; + margin-bottom: 0.65rem; +} + +.funding-intro-banner { + display: flex; + align-items: center; + gap: 0.5rem; + background: #f0f9ff; + border: 1px solid #bae6fd; border-radius: 6px; - padding: 14px; - margin-bottom: 16px; - background: #fbfcfe; + padding: 0.45rem 0.65rem; + font-size: 0.78rem; + color: #0369a1; + margin-bottom: 0.65rem; + + .banner-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: #0284c7; + flex-shrink: 0; + } + + .inline-icon { + font-size: 14px; + width: 14px; + height: 14px; + vertical-align: middle; + } } -.funding-intro { - font-size: 13px; - color: $muted; - margin: 0 0 12px; +/* Lookup Toolbar */ +.lookup-toolbar { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.65rem; + background: $bg-slate; + padding: 0.65rem; + border-radius: 8px; + border: 1px solid $border-color; + margin-bottom: 0.65rem; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } } -.gl-select-field { - margin: 0 0 10px; +.toolbar-label { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + color: $text-muted; + margin-bottom: 4px; + + .mini-icon { + font-size: 14px; + width: 14px; + height: 14px; + } } -.gl-select { +.custom-select { width: 100%; - padding: 9px 11px; - border: 1px solid $border; + height: 34px; + padding: 0 0.65rem; + border: 1px solid #cbd5e1; border-radius: 6px; - font-size: 13px; - color: $primary; + font-size: 0.8rem; + color: $text-main; background: #fff; &:focus { outline: none; - border-color: $primary; - } - - &:disabled { - background: #f4f7fb; - color: $muted; + border-color: $accent-blue; } } -.voucher-lookup { +.lookup-input-group { display: flex; - gap: 8px; - align-items: center; - margin: 0 0 12px; -} + gap: 0.4rem; -.voucher-input { - flex: 1; - min-width: 0; - padding: 9px 11px; - border: 1px solid $border; - border-radius: 6px; - font-family: 'Roboto Mono', monospace; - font-size: 13px; - letter-spacing: 0.4px; - color: $primary; + .lookup-input { + flex: 1; + height: 34px; + padding: 0 0.65rem; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 0.8rem; + color: $text-main; + background: #fff; - &:focus { - outline: none; - border-color: $primary; + &:focus { + outline: none; + border-color: $accent-blue; + } } - &:disabled { - background: #f4f7fb; - color: $muted; + .find-btn { + height: 34px; + border-radius: 6px; + padding: 0 0.75rem; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.8rem; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } } } -.funding-loading { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 0; - font-size: 13px; - color: $muted; -} - -.funding-summary { - border: 1px solid $border; - border-radius: 6px; +/* Vouchers Array Card & Table */ +.vouchers-card { + border: 1px solid $border-color; + border-radius: 8px; background: #fff; - padding: 10px 12px; - font-size: 13px; + overflow: hidden; } -.funding-row { +.vouchers-header { display: flex; justify-content: space-between; - gap: 12px; - padding: 4px 0; - color: $primary; + align-items: center; + padding: 0.5rem 0.75rem; + background: #f1f5f9; + border-bottom: 1px solid $border-color; - > span:first-child { - color: $muted; + .vouchers-title { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.84rem; + font-weight: 700; + color: $text-main; + + .v-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: $accent-blue; + } + + .badge-count { + background: $accent-blue; + color: #fff; + font-size: 0.7rem; + font-weight: 700; + padding: 0 6px; + border-radius: 999px; + } + } + + .select-all-btn { + font-size: 0.75rem; + color: $accent-blue; + font-weight: 600; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + margin-right: 2px; + } } } -.funding-row--total { - border-top: 1px solid #eceff4; - margin-top: 4px; - padding-top: 8px; - font-weight: 500; -} - -.mono { - font-family: 'Courier New', monospace; -} - -.funding-empty { - display: flex; - gap: 10px; - align-items: flex-start; - background: #f4f7fb; - border: 1px solid $border; - border-radius: 6px; - padding: 12px; - font-size: 13px; -} - -.funding-empty-hint { - color: $muted; - margin-top: 4px; +.table-responsive { + width: 100%; + overflow-x: auto; } .receipt-table { width: 100%; - border-collapse: collapse; - font-size: 13px; - background: #fff; - border: 1px solid $border; - border-radius: 6px; + border-collapse: separate; + border-spacing: 0; + font-size: 0.78rem; th { - background: #eef2f8; - color: $primary; - font-size: 11px; + background: $bg-slate; + color: $text-muted; + font-size: 0.7rem; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.4px; - padding: 8px; + padding: 0.5rem 0.65rem; + border-bottom: 1px solid $border-color; } td { - padding: 8px; - border-bottom: 1px solid #eceff4; + padding: 0.45rem 0.65rem; + border-bottom: 1px solid $border-light; + vertical-align: middle; } - tbody tr { + .receipt-row { cursor: pointer; + transition: background 0.12s ease; &:hover { - background: #f4f7fb; + background: #f8fafc; } &.selected { - background: #e7effb; + background: #eff6ff; + + .voucher-badge { + background: #dbeafe; + color: #1e40af; + border-color: #bfdbfe; + } } } - /* The running total against the tranche amount — what decides whether the request is accepted, - and not something to be added up by eye once a second receipt is in play. */ - tfoot .total-row { - border-top: 2px solid #cbd5e1; + .col-checkbox { + width: 36px; + } + + .col-actions { + width: 44px; + } + + .custom-checkbox { + width: 16px; + height: 16px; + accent-color: $accent-blue; + cursor: pointer; + } + + .voucher-badge { + display: inline-block; + background: #f1f5f9; + color: #334155; + padding: 2px 6px; + border-radius: 4px; + border: 1px solid #cbd5e1; + font-weight: 600; + font-size: 0.78rem; + } + + .desc-cell { + color: $text-muted; + max-width: 160px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .user-chip { + display: inline-flex; + align-items: center; + gap: 3px; + background: $bg-slate; + border: 1px solid $border-color; + padding: 1px 6px; + border-radius: 999px; + font-size: 0.72rem; + color: $text-muted; + + .chip-icon { + font-size: 12px; + width: 12px; + height: 12px; + } + } + + .amount-cell { + color: $text-main; + font-size: 0.84rem; + } + + .view-detail-btn { + width: 28px; + height: 28px; + line-height: 28px; + + .eye-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } + } +} + +/* Vouchers Footer Bar */ +.vouchers-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + background: $bg-slate; + border-top: 1.5px solid #cbd5e1; + flex-wrap: wrap; + gap: 0.5rem; + + .selected-summary { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + + .summary-text { + font-size: 0.78rem; + color: $text-muted; + } + + .summary-amounts { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.8rem; + + .total-label { + color: $text-muted; + } + + .total-value { + color: $primary; + font-family: 'Courier New', monospace; + } + + .target-divider { + color: $text-muted; + } + + .target-value { + color: $text-muted; + } + } + } +} + +/* Coverage Status Badges */ +.coverage-status { + .status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &.badge-neutral { + background: #f1f5f9; + color: #64748b; + border: 1px solid #cbd5e1; + } + + &.badge-warning { + background: $warning-bg; + color: $warning; + border: 1px solid $warning-border; + } + + &.badge-success { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } +} + +/* Savings Panel Details */ +.savings-card { + background: #fff; + border: 1px solid $border-color; + border-radius: 8px; + padding: 0.65rem 0.85rem; +} + +.savings-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.35rem 0; + + .lbl { + font-size: 0.78rem; + color: $text-muted; + } + + .val { + font-size: 0.82rem; + color: $text-main; + } + + &.savings-row-total { + border-top: 1px dashed $border-color; + margin-top: 0.2rem; + padding-top: 0.5rem; + + .lbl { + font-weight: 600; + color: $text-main; + } + + .val { + font-size: 0.95rem; + font-weight: 700; + } + } +} + +/* Alert Boxes */ +.alert-box { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: 8px; + font-size: 0.78rem; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + flex-shrink: 0; + } + + &.alert-error { + background: $danger-bg; + color: $danger; + border: 1px solid $danger-border; + } + + &.alert-warning { + background: $warning-bg; + color: #92400e; + border: 1px solid $warning-border; + } +} + +/* Pending Notice Banner */ +.pending-notice { + display: flex; + align-items: center; + gap: 0.5rem; + background: #f8fafc; + border: 1px solid $border-color; + border-radius: 8px; + padding: 0.5rem 0.75rem; + font-size: 0.76rem; + color: $text-muted; + + .notice-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: $accent-blue; + flex-shrink: 0; + } +} + +/* Action Buttons Bar */ +.card-actions-bar { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.65rem; + padding: 0.75rem 1rem !important; + background: $bg-slate; + border-top: 1px solid $border-color; + + .action-btn { + height: 36px; + padding: 0 1.25rem; + border-radius: 6px; + font-size: 0.82rem; + font-weight: 600; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + margin-right: 4px; + } + } +} + +/* POPUP MODAL DIALOG STYLES */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgb(15 23 42 / 50%); + backdrop-filter: blur(4px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.voucher-modal-dialog { + background: #fff; + border-radius: 12px; + width: 100%; + max-width: 36rem; + box-shadow: 0 20px 40px -10px rgb(15 23 42 / 25%); + border: 1px solid $border-color; + overflow: hidden; + animation: modal-scale-in 0.2s ease-out; + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.85rem 1.25rem; + background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%); + color: #fff; + + .header-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.95rem; + font-weight: 700; + + .m-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + + .modal-badge { + background: rgb(255 255 255 / 20%); + padding: 2px 8px; + border-radius: 6px; + font-size: 0.8rem; + } + } + + .close-btn { + color: #fff; + width: 32px; + height: 32px; + line-height: 32px; + } + } + + .modal-body { + padding: 1rem 1.25rem; + max-height: 70vh; + overflow-y: auto; + } + + .detail-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + + @media (width <= 480px) { + grid-template-columns: 1fr; + } + } + + .detail-item { + display: flex; + flex-direction: column; + gap: 2px; + background: $bg-slate; + padding: 0.5rem 0.75rem; + border-radius: 8px; + border: 1px solid $border-light; + + .detail-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $text-muted; + font-weight: 600; + } + + .detail-val { + font-size: 0.84rem; + color: $text-main; + } + + &.full-width { + grid-column: 1 / -1; + } + + &.highlight-amount-box { + background: $primary-light; + border-color: $primary-border; + + .detail-label { + color: #1e40af; + } + + .amount-val { + font-size: 1.25rem; + font-weight: 700; + color: #1e3a8a; + } + } + } + + .status-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + width: fit-content; + + &.status-unapplied { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } + + .note-box { + background: #fff; + border: 1px solid $border-color; + border-radius: 6px; + padding: 0.5rem 0.65rem; + font-size: 0.8rem; + color: $text-main; + line-height: 1.4; + margin-top: 4px; + white-space: pre-wrap; + } + + .gl-entries-loading, + .gl-entries-empty { + font-size: 0.8rem; + color: $text-muted; + padding: 0.5rem 0; + } + + .gl-entries-table { + width: 100%; + border-collapse: collapse; + margin-top: 4px; + font-size: 0.8rem; + background: #fff; + border: 1px solid $border-color; + border-radius: 6px; + overflow: hidden; + + th { + background: $bg-slate; + color: $text-muted; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 700; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid $border-color; + } td { - padding-top: 10px; - color: #1f3d7a; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid $border-light; + color: $text-main; } + + tr:last-child td { + border-bottom: none; + } + } + + .drcr-pill { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 700; + + &.drcr-pill--dr { + background: $danger-bg; + color: $danger; + border: 1px solid $danger-border; + } + + &.drcr-pill--cr { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.65rem; + padding: 0.75rem 1.25rem; + background: $bg-slate; + border-top: 1px solid $border-color; + + .modal-action-btn { + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 4px; + } + } + } +} + +@keyframes modal-scale-in { + from { + opacity: 0; + transform: scale(0.95); + } + + to { + opacity: 1; + transform: scale(1); } } @@ -216,21 +1042,50 @@ $danger: #b3261e; text-align: center; } -.alert-error { - display: flex; - align-items: center; - gap: 8px; - background: #fdecea; - color: $danger; - border: 1px solid #f5c6c2; - border-radius: 6px; - padding: 10px 12px; - font-size: 13px; - margin-bottom: 12px; +.font-mono { + font-family: 'Courier New', monospace; } -.pending-hint { - font-size: 12px; - color: $muted; - margin: 0; +.font-semibold { + font-weight: 600; +} + +.font-accent { + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; +} + +.font-success { + color: $success !important; +} + +.mt-8 { + margin-top: 8px; +} + +.mt-10 { + margin-top: 10px; +} + +.mt-12 { + margin-top: 12px; +} + +.animate-fade-in { + animation: fade-in 0.2s ease-in-out; +} + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } } diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts index b071be51b..8922da149 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts @@ -107,6 +107,15 @@ export class ApplySharesComponent implements OnInit { glAccounts: any[] = []; loadingGlAccounts = true; + /** Currently selected voucher for viewing in the detail popup. */ + viewingVoucherDetail: any = null; + + /** The receipt's own real Dr/Cr GL legs, fetched fresh per voucher — never the GL Account + * dropdown above (that is just what the by-voucher search box is currently set to, which can + * name a completely different account than the one this specific receipt actually sits in). */ + voucherGlEntries: { glCode: string; accountName: string; drCr: string; amount: number }[] = []; + loadingGlEntries = false; + /** What the member's savings can cover, for the savings route. */ savingsEligibility: { eligible: boolean; @@ -134,10 +143,43 @@ export class ApplySharesComponent implements OnInit { .pipe(catchError(() => of(null))) .subscribe((res: any) => { this.glAccounts = res?.glAccounts || []; + // Auto-picked only when there is exactly one live candidate -- the backend already narrowed + // this list to accounts that actually hold an eligible receipt today, so a single option is + // unambiguous. Two or more still means the counter chooses by hand: picking "whichever + // sorts first" among several real candidates is the same blind guess that used to land on + // Cash at Vault, just with a shorter, filtered list to guess from. + if (this.glAccounts.length === 1 && !this.glAccountNo) { + this.glAccountNo = this.glAccounts[0].gl_code; + } this.loadingGlAccounts = false; }); } + setPaymentMode(mode: string): void { + this.applySharesForm.get('paymentMode')?.setValue(mode); + } + + openVoucherDetail(voucher: any, event?: Event): void { + if (event) { + event.stopPropagation(); + } + this.viewingVoucherDetail = voucher; + this.voucherGlEntries = []; + this.loadingGlEntries = true; + this.cashierService + .getShareAdvanceGlEntries(voucher.id) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.voucherGlEntries = res?.entries || []; + this.loadingGlEntries = false; + }); + } + + closeVoucherDetail(): void { + this.viewingVoucherDetail = null; + this.voucherGlEntries = []; + } + /** * Creates the apply shares form. * @@ -254,12 +296,24 @@ export class ApplySharesComponent implements OnInit { return this.selectedVoucherIds.includes(id); } + /** + * One receipt funds one tranche — no combining instalments here (unlike the first-application + * Apply Collection dialog, which still allows several). Picking a different receipt replaces the + * current one rather than adding to it, so {@link selectedVoucherIds} never holds more than one id. + */ toggleVoucher(id: number): void { - this.selectedVoucherIds = this.isVoucherSelected(id) ? this.selectedVoucherIds.filter((x) => x !== id) : [ - ...this.selectedVoucherIds, - id - ]; + const wasSelected = this.isVoucherSelected(id); + this.selectedVoucherIds = wasSelected ? [] : [id]; this.voucherError = ''; + // The GL Account picker above follows whichever receipt is ticked — its own actual GL account + // (see gl_code on the row), not left showing an unrelated account or "Select…" once a receipt + // that plainly does have one is already chosen. + if (!wasSelected) { + const voucher = this.vouchers.find((v) => v.id === id); + if (voucher?.gl_code) { + this.glAccountNo = voucher.gl_code; + } + } } /** @@ -307,19 +361,13 @@ export class ApplySharesComponent implements OnInit { this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`; return; } - // Added to the selection rather than replacing it — a looked-up voucher is usually the - // second half of a part-payment, not a change of mind about the first. No amount check - // here: one instalment is not meant to cover the tranche on its own. + // Replaces the selection rather than adding to it — one receipt funds one tranche here, so + // finding a voucher by number is a different way to pick THE receipt, not a second one. this.vouchers = [ res, ...this.vouchers.filter((v) => v.id !== res.id) ]; - if (!this.isVoucherSelected(res.id)) { - this.selectedVoucherIds = [ - ...this.selectedVoucherIds, - res.id - ]; - } + this.selectedVoucherIds = [res.id]; }); } diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html index 4733ce6b2..756d2d39e 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html @@ -168,6 +168,8 @@ Voucher / Receipt Date + Description + Collected By Amount @@ -186,6 +188,8 @@ {{ r.voucher_no || r.receipt_no }} {{ r.collection_date }} + {{ r.note || '—' }} + {{ r.collected_by_name || '—' }} {{ r.amount | number: '1.2-2' }} } @@ -197,7 +201,7 @@ - + {{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected {{ selectedTotal | number: '1.2-2' }} diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts index 7ceb51747..b556591c7 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts @@ -135,6 +135,12 @@ export class ApplyCollectionDialogComponent implements OnInit { ) .subscribe((res: any) => { this.glAccounts = res?.glAccounts || []; + // Auto-picked only when there is exactly one live candidate -- the backend already narrowed + // this list to accounts that actually hold an eligible receipt today, so a single option is + // unambiguous. Two or more still means the counter chooses by hand. + if (this.glAccounts.length === 1 && !this.glAccountNo) { + this.glAccountNo = this.glAccounts[0].gl_code; + } this.loadingGlAccounts = false; }); diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss index 840774130..274b178df 100644 --- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss +++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss @@ -384,18 +384,59 @@ /* Print CSS */ @media print { - /* Hide non-overlay contents during print */ + ::ng-deep html, + ::ng-deep body { + height: auto !important; + overflow: visible !important; + } + + /* Hide non-overlay contents during print. Deliberately NOT `::ng-deep` here: under Angular's + emulated encapsulation a bare `body` selector never carries this component's scoping + attribute, so a scoped rule can never match the real and is safely inert — whereas + `::ng-deep` emits it fully unscoped into the GLOBAL stylesheet for the rest of the session + (Angular never removes a component's injected styles when it's destroyed), silently hiding + every other page's print output app-wide the moment this dialog is opened once. The app + shell (nav, toolbar, sidebar) is already hidden for every print via the global rules in + main.scss, so this dialog doesn't need its own global override to get the same effect. */ body > :not(.cdk-overlay-container) { display: none !important; } + /* The dialog's own scroll region clips anything past 80vh -- printing only captures what a + scrollable/max-height box shows on screen, so both constraints must be lifted. */ + .dialog-content-container { + max-height: none !important; + overflow: visible !important; + } + + /* CDK's overlay wrapper/backdrop/pane are all position: fixed. Unset them so the full certificate prints statically. */ + ::ng-deep .cdk-overlay-backdrop { + display: none !important; + } + + ::ng-deep .cdk-overlay-container, + ::ng-deep .cdk-global-overlay-wrapper, + ::ng-deep .cdk-overlay-pane, + ::ng-deep .mat-mdc-dialog-container, + ::ng-deep .mdc-dialog__container, + ::ng-deep .mdc-dialog__surface { + position: static !important; + display: block !important; + max-height: none !important; + max-width: none !important; + height: auto !important; + width: auto !important; + overflow: visible !important; + transform: none !important; + box-shadow: none !important; + background: transparent !important; + } + /* Make certificate fill page */ #share-certificate-print { - position: absolute; - left: 0; - top: 0; + position: static !important; width: 100% !important; - height: 100% !important; + height: auto !important; padding: 0 !important; margin: 0 !important; background-color: #fff !important; @@ -411,7 +452,135 @@ display: none !important; } - .mat-mdc-dialog-container { + ::ng-deep .mat-mdc-dialog-container { padding: 0 !important; } + + /* Compact every spacing/font value so the whole certificate fits a single printed page */ + .certificate-container { + padding: 6px !important; + } + + .certificate-border-inner { + padding: 14px !important; + } + + .certificate-header { + margin-bottom: 10px !important; + padding-bottom: 10px !important; + + .bank-name { + font-size: 19px !important; + margin-bottom: 2px !important; + } + + .bank-sub { + font-size: 10px !important; + margin-bottom: 8px !important; + } + + .cert-title { + font-size: 19px !important; + letter-spacing: 2px !important; + margin-bottom: 2px !important; + } + + .cert-subtitle { + font-size: 9px !important; + } + } + + .cert-meta-row { + margin-bottom: 10px !important; + padding-bottom: 8px !important; + + .meta-label { + font-size: 9px !important; + margin-bottom: 1px !important; + } + + .meta-value { + font-size: 12px !important; + } + } + + .cert-body-section { + margin-bottom: 10px !important; + + .cert-main-text { + font-size: 11px !important; + line-height: 1.4 !important; + margin-bottom: 10px !important; + } + } + + .issued-to-details { + padding: 8px 14px !important; + margin: 8px auto !important; + + .client-name-details { + font-size: 14px !important; + } + + .client-ids, + .client-address { + font-size: 10px !important; + } + } + + .certificate-details-table { + margin-bottom: 12px !important; + + td { + padding: 5px 10px !important; + font-size: 10px !important; + } + + .lbl-col-highlight { + font-size: 12px !important; + } + + .val-col-highlight { + font-size: 13px !important; + } + } + + .allotment-table { + margin-bottom: 12px !important; + + th { + padding: 4px 8px !important; + font-size: 8px !important; + } + + td { + padding: 4px 8px !important; + font-size: 9px !important; + } + } + + .cert-footer { + margin-top: 8px !important; + + .seal-ring { + width: 60px !important; + height: 60px !important; + } + + .seal-text { + font-size: 8px !important; + } + + .closing-text { + font-size: 10px !important; + } + + .signature-line { + height: 22px !important; + } + + .signatory-title { + font-size: 10px !important; + } + } } diff --git a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html index e1a192400..b4b5384ed 100644 --- a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html +++ b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html @@ -5,124 +5,244 @@ 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/. --> -
-
-
-

Sponsor Bank DD Inventory

-

Manage allocated ranges of physical Demand Draft leaflets received from Sponsor Banks.

+ +
+ +
+
+
+ +
+
+

Sponsor Bank DD Inventory

+

+ Manage allocated ranges of physical Demand Draft leaflets received from Sponsor Banks. +

+
- +
- + @if (dividendContext) { -
-
- Issuing a dividend Demand Draft +
+
+ +
+
+ Issuing Dividend Demand Draft - {{ dividendContext.beneficiary }} · - {{ dividendContext.amount | number: '1.2-2' }} + Beneficiary: {{ dividendContext.beneficiary }} · Amount: + ₹{{ dividendContext.amount | number: '1.2-2' }} @if (dividendContext.financialYear) { · FY {{ dividendContext.financialYear }} } - Open a series below and choose an available leaflet. + Select a series below to pick an available leaflet.
- +
} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Batch ID{{ element.id }}Sponsor Bank{{ element.sponsorBankName }}Branch{{ element.branchName }}Start No.{{ element.seriesStart }}End No.{{ element.seriesEnd }}Total Leaves{{ element.totalLeaves }}Available Leaves - {{ element.availableLeaves }} - Received Date{{ element.receivedDate }}Status - - {{ element.status }} - - Actions - - -
- -
- No DD series registered yet. Click "Register DD Series" to add stock. + +
+
+
+ +
+
+ Total Series Batches + {{ totalBatches }} +
- + +
+
+ +
+
+ Active Stock Series + {{ activeBatchesCount }} +
+
+ +
+
+ +
+
+ Total Allocated Leaves + {{ totalLeavesCount | number }} +
+
+ +
+
+ +
+
+ Available Leaves + {{ totalAvailableLeaves | number }} +
+
+
+ + +
+ + +
+ + + +
+
+ + +
+
+
+ + Registered Series + {{ filteredData.length }} of {{ dataSource.length }} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Batch ID#{{ element.id }}Sponsor Bank + + {{ element.sponsorBankName }} + Branch{{ element.branchName }}Start No.{{ element.seriesStart }}End No.{{ element.seriesEnd }}Total Leaves{{ element.totalLeaves }}Available Leaves + + {{ element.availableLeaves }} + + Received Date{{ element.receivedDate }}Status + + + {{ element.status }} + + Actions + +
+ + @if (filteredData.length === 0) { +
+ +

No DD Series Found

+

No registered DD stock series match your filter criteria.

+
+ } +
+
diff --git a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts index 22da93780..9fe0013b0 100644 --- a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts +++ b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts @@ -22,6 +22,8 @@ import { MatNativeDateModule } from '@angular/material/core'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { HttpClient } from '@angular/common/http'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { SponsorDDService } from '../sponsor-dd.service'; @Component({ @@ -30,106 +32,482 @@ import { SponsorDDService } from '../sponsor-dd.service'; imports: [ CommonModule, RouterModule, + FormsModule, + ReactiveFormsModule, MatCardModule, MatTableModule, MatButtonModule, MatDialogModule, - MatSnackBarModule + MatSnackBarModule, + MatFormFieldModule, + MatInputModule, + MatIconModule, + MatTooltipModule, + FaIconComponent ], templateUrl: './dd-inventory-list.component.html', styles: [ ` - .inventory-container { + .inv-container { padding: 24px; - max-width: 1200px; + max-width: 1360px; margin: 0 auto; } - .header-row { + + /* Hero Header */ + .inv-hero { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; + gap: 16px; + flex-wrap: wrap; } - .title-area h2 { - margin: 0 0 4px 0; - font-size: 24px; - font-weight: 600; - color: #1e293b; - } - .title-area p { - margin: 0; - color: #64748b; - font-size: 14px; - } - .mat-mdc-card { - border-radius: 12px; - border: 1px solid #e2e8f0; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); - overflow: hidden; - } - table { - width: 100%; - } - th { - font-weight: 600; - color: #475569; - background-color: #f8fafc; - } - td { - color: #334155; - } - .status-badge { - display: inline-flex; - align-items: center; - padding: 4px 8px; - border-radius: 9999px; - font-size: 12px; - font-weight: 500; - } - .status-badge.active { - background-color: #dcfce7; - color: #15803d; - } - .status-badge.exhausted { - background-color: #f1f5f9; - color: #475569; - } - .action-btn { - color: #7c3aed; - font-weight: 500; - } - .leaves-low { - color: #b91c1c; - font-weight: 600; - } - .dividend-banner { + .inv-hero__left { display: flex; align-items: center; gap: 16px; - margin-bottom: 20px; - padding: 12px 16px; - border: 1px solid #c7d2fe; - border-left: 4px solid #4f46e5; - border-radius: 8px; - background-color: #eef2ff; } - .dividend-banner__text { + .inv-hero__icon { + width: 52px; + height: 52px; + border-radius: 14px; + background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + box-shadow: 0 4px 14px rgba(37, 99, 235, 0.25); + } + .inv-hero__title { + margin: 0; + font-size: 22px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.02em; + } + .inv-hero__sub { + margin: 2px 0 0 0; + font-size: 13.5px; + color: #64748b; + } + + /* Dividend Banner */ + .inv-dividend-banner { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; + padding: 16px 20px; + border-radius: 12px; + border: 1px solid #c7d2fe; + background: linear-gradient(135deg, #eef2ff 0%, #f5f3ff 100%); + box-shadow: 0 2px 8px rgba(79, 70, 229, 0.08); + } + .inv-dividend-banner__ic { + width: 40px; + height: 40px; + border-radius: 10px; + background: #4f46e5; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; + } + .inv-dividend-banner__text { display: flex; flex-direction: column; gap: 2px; - font-size: 13px; + font-size: 13.5px; color: #312e81; } - .dividend-banner__text strong { - font-size: 13.5px; - } - .dividend-banner__hint { - color: #4338ca; + .inv-dividend-banner__hint { font-size: 12px; + color: #4338ca; } - .dividend-banner button { + .inv-dividend-banner button { margin-left: auto; } + + /* KPI Cards */ + .inv-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; + margin-bottom: 24px; + } + .inv-kpi-card { + background: #ffffff; + border-radius: 12px; + padding: 18px 20px; + border: 1px solid #e2e8f0; + display: flex; + align-items: center; + gap: 16px; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); + transition: + transform 0.15s ease, + box-shadow 0.15s ease; + } + .inv-kpi-card:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08); + } + .inv-kpi-card__icon { + width: 46px; + height: 46px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; + } + .inv-kpi-card__icon.blue { + background: #eff6ff; + color: #2563eb; + } + .inv-kpi-card__icon.green { + background: #f0fdf4; + color: #16a34a; + } + .inv-kpi-card__icon.purple { + background: #faf5ff; + color: #9333ea; + } + .inv-kpi-card__icon.emerald { + background: #ecfdf5; + color: #059669; + } + .inv-kpi-card__info { + display: flex; + flex-direction: column; + } + .inv-kpi-card__label { + font-size: 12px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; + } + .inv-kpi-card__val { + font-size: 24px; + font-weight: 800; + color: #0f172a; + margin-top: 2px; + line-height: 1; + } + + /* Toolbar */ + .inv-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + flex-wrap: wrap; + } + .inv-search { + position: relative; + flex: 1; + min-width: 280px; + max-width: 460px; + } + .inv-search input { + width: 100%; + padding: 10px 14px 10px 38px; + border: 1px solid #cbd5e1; + border-radius: 10px; + font-size: 13.5px; + background: #ffffff; + color: #0f172a; + outline: none; + box-sizing: border-box; + transition: + border-color 0.15s, + box-shadow 0.15s; + } + .inv-search input:focus { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); + } + .inv-search__ic { + position: absolute; + left: 13px; + top: 50%; + transform: translateY(-50%); + color: #94a3b8; + font-size: 13px; + pointer-events: none; + } + .inv-search__clear { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + border: none; + background: transparent; + color: #94a3b8; + cursor: pointer; + padding: 4px; + } + .inv-search__clear:hover { + color: #ef4444; + } + + .inv-filter-chips { + display: flex; + align-items: center; + gap: 8px; + } + .inv-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: 999px; + border: 1px solid #e2e8f0; + background: #ffffff; + color: #475569; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + } + .inv-chip:hover { + background: #f8fafc; + border-color: #cbd5e1; + } + .inv-chip.active { + background: #1e40af; + border-color: #1e40af; + color: #ffffff !important; + } + .inv-chip.active-chip.active { + background: #16a34a; + border-color: #16a34a; + color: #ffffff !important; + } + .inv-chip.exhausted-chip.active { + background: #475569; + border-color: #475569; + color: #ffffff !important; + } + .inv-chip__dot { + width: 7px; + height: 7px; + border-radius: 50%; + } + .inv-chip__dot.green { + background: #16a34a; + } + .inv-chip__dot.grey { + background: #64748b; + } + .inv-chip.active .inv-chip__dot { + background: #ffffff; + } + .inv-chip__count { + padding: 2px 7px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.07); + font-size: 11px; + font-weight: 700; + } + .inv-chip.active .inv-chip__count { + background: rgba(255, 255, 255, 0.25); + color: #ffffff; + } + + /* Card & Table */ + .inv-card { + background: #ffffff; + border-radius: 14px; + border: 1px solid #e2e8f0; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05); + overflow: hidden; + } + .inv-card__head { + padding: 16px 20px; + border-bottom: 1px solid #e2e8f0; + background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%); + } + .inv-card__title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: #0f172a; + } + .inv-card__title-ic { + color: #2563eb; + } + .inv-card__count { + margin-left: 6px; + padding: 2px 10px; + border-radius: 999px; + background: #eff6ff; + color: #1e40af; + font-size: 11px; + font-weight: 700; + } + + .inv-table-wrap { + overflow-x: auto; + } + .inv-table { + width: 100%; + border-collapse: collapse; + } + .inv-table th { + background: #f1f5f9; + color: #475569; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 12px 16px; + border-bottom: 1px solid #e2e8f0; + white-space: nowrap; + } + .inv-table td { + padding: 12px 16px; + color: #334155; + border-bottom: 1px solid #f1f5f9; + font-size: 13.5px; + vertical-align: middle; + } + .inv-table tr:hover { + background: #f8fafc; + } + + .inv-cell-bank { + display: flex; + align-items: center; + gap: 8px; + } + .bank-icon { + color: #2563eb; + font-size: 14px; + } + + .inv-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .inv-strong { + font-weight: 700; + color: #0f172a; + } + .inv-muted { + color: #64748b; + } + .inv-num { + text-align: right; + } + + .inv-leaves-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 6px; + background: #f0fdf4; + color: #16a34a; + font-weight: 700; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .inv-leaves-badge.low { + background: #fef2f2; + color: #dc2626; + } + + .inv-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + } + .inv-badge__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + } + .inv-badge.active { + background: #dcfce7; + color: #15803d; + } + .inv-badge.exhausted { + background: #f1f5f9; + color: #64748b; + } + + .inv-actions-col { + text-align: right; + } + + /* Buttons */ + .inv-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 18px; + border-radius: 10px; + font-size: 13.5px; + font-weight: 600; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.15s ease; + } + .inv-btn.primary { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + color: #ffffff; + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3); + } + .inv-btn.primary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 14px rgba(37, 99, 235, 0.4); + } + .inv-btn.ghost { + background: #ffffff; + border-color: #cbd5e1; + color: #475569; + } + .inv-btn.ghost:hover { + background: #f8fafc; + color: #0f172a; + } + .inv-btn.small { + padding: 6px 12px; + font-size: 12px; + border-radius: 8px; + } + + .inv-empty { + text-align: center; + padding: 48px 20px; + color: #64748b; + } + .inv-empty__ic { + font-size: 36px; + color: #cbd5e1; + margin-bottom: 8px; + } + .inv-empty__title { + font-size: 16px; + font-weight: 700; + color: #334155; + margin: 0 0 4px 0; + } + .inv-empty__sub { + font-size: 13px; + margin: 0; + } ` ] }) @@ -140,14 +518,9 @@ export class DdInventoryListComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); - /** - * The dividend this visit is drawing a draft for, when the clerk arrived from a dividend claim - * rather than from the menu. - * - * Null on an ordinary visit, which is what keeps this page's normal behaviour untouched — the - * banner, the relabelled action and the forwarded query params all hang off this being set. - */ dividendContext: { lockId: number; amount: number; beneficiary: string; financialYear: string } | null = null; + searchText = ''; + selectedStatusFilter = 'ALL'; displayedColumns: string[] = [ 'id', @@ -168,13 +541,6 @@ export class DdInventoryListComponent implements OnInit { this.loadInventories(); } - /** - * Picks up the dividend a claim sent us here to pay out. - * - * Read from the URL rather than handed over in a service so a refreshed or bookmarked page still - * knows which lock it is drawing against — losing it silently would issue an ordinary counter - * draft against the member instead of settling their dividend. - */ private readDividendContext(): void { const params = this.route.snapshot.queryParams; const lockId = Number(params['dividendLockId']); @@ -189,7 +555,6 @@ export class DdInventoryListComponent implements OnInit { }; } - /** The dividend context to carry onto the leaflets page; empty on an ordinary visit. */ get dividendQueryParams(): Record { return this.dividendContext ? { @@ -201,7 +566,6 @@ export class DdInventoryListComponent implements OnInit { : {}; } - /** Abandons the claim and returns to the dividend dashboard, leaving the lock untouched. */ cancelDividendClaim(): void { this.router.navigate([ '/clerk', @@ -212,7 +576,7 @@ export class DdInventoryListComponent implements OnInit { loadInventories(): void { this.sponsorDDService.getInventories().subscribe({ next: (data) => { - this.dataSource = data; + this.dataSource = data || []; }, error: (err) => { this.snackBar.open('Error loading inventories: ' + (err.error?.message || err.message), 'Close', { @@ -222,9 +586,44 @@ export class DdInventoryListComponent implements OnInit { }); } + get totalBatches(): number { + return this.dataSource.length; + } + + get activeBatchesCount(): number { + return this.dataSource.filter((x) => (x.status || '').toUpperCase() === 'ACTIVE').length; + } + + get totalLeavesCount(): number { + return this.dataSource.reduce((acc, curr) => acc + (Number(curr.totalLeaves) || 0), 0); + } + + get totalAvailableLeaves(): number { + return this.dataSource.reduce((acc, curr) => acc + (Number(curr.availableLeaves) || 0), 0); + } + + get filteredData(): any[] { + let list = this.dataSource; + if (this.selectedStatusFilter !== 'ALL') { + list = list.filter((item) => (item.status || '').toUpperCase() === this.selectedStatusFilter); + } + if (this.searchText.trim()) { + const q = this.searchText.trim().toLowerCase(); + list = list.filter( + (item) => + String(item.id).toLowerCase().includes(q) || + (item.sponsorBankName || '').toLowerCase().includes(q) || + (item.branchName || '').toLowerCase().includes(q) || + String(item.seriesStart).includes(q) || + String(item.seriesEnd).includes(q) + ); + } + return list; + } + openAddInventoryDialog(): void { const dialogRef = this.dialog.open(AddInventoryDialogComponent, { - width: '500px', + width: '520px', disableClose: true }); diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.html b/src/app/sponsor-dd/dd-list/dd-list.component.html index 1a0b5be09..4350c76c8 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.html +++ b/src/app/sponsor-dd/dd-list/dd-list.component.html @@ -5,251 +5,326 @@ 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/. --> -
-
-
- -

DD Leaflet Registry

-

- Sponsor Bank: {{ inventoryDetails.sponsorBankName }} | Series Range: - {{ inventoryDetails.seriesStart }} - {{ inventoryDetails.seriesEnd }} -

+ +
+

DD Leaflet Registry

+
+ Sponsor Bank {{ inventoryDetails.sponsorBankName }} · Series + {{ inventoryDetails.seriesStart }}–{{ inventoryDetails.seriesEnd }} +
+
+
+
+ + +
+
+
+ +
+
+ Total Stock + {{ dataSource.data.length }} +
+
+ +
+
+ +
+
+ Available + {{ statusCount('AVAILABLE') }} +
+
+ +
+
+ +
+
+ Issued + {{ statusCount('ISSUED') }} +
+
+ +
+
+ +
+
+ Cancelled / Stopped + {{ statusCount('CANCELLED') + statusCount('STOPPED') }} +
+
+ +
+
+ +
+
+ Revoked / Spoiled + {{ statusCount('REVOKED') }} +
-
-
+
+ +
Issuing a dividend Demand Draft - {{ dividendContext.beneficiary }} · {{ dividendContext.amount | number: '1.2-2' }} + {{ dividendContext.beneficiary }} · ₹{{ dividendContext.amount | number: '1.2-2' }} · FY {{ dividendContext.financialYear }} - Pick an available leaflet below and press Issue. + Pick an available leaflet below and press "Draw for Dividend".
- +
- - Search by DD Leaflet No. - - - -
- +
+ +
+ + +
-

- Only DDs actually ISSUED today are shown - AVAILABLE, CANCELLED, STOPPED and CLEARED are hidden. Click "Showing All +

+ + Only DDs actually ISSUED today are shown — Available, Cancelled, Stopped and Cleared are hidden. Click "Showing All Dates" above to see everything.

- - - - - - - + +
+
+
+ + Leaflets + {{ dataSource.filteredData.length }} of {{ dataSource.data.length }} +
+
+
+
DD Leaflet No. - {{ element.ddNumber }} -
+ + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - Revoke Pending Approval (Req #{{ element.pendingRevokeRequestId }}) - + + + + + - - - Revoked → replaced by DD {{ element.revokedReplacedByDdNumber }} - - - Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }} - - - - + + + + + + - - -
DD Leaflet No.{{ element.ddNumber }}Status - - {{ element.status }} - - Face Value - {{ element.faceValue > 0 ? (element.faceValue | number: '1.2-2') : '-' }} - Purchaser / Client - {{ element.issuedToClientName || element.walkinPurchaserName || '-' }} - Beneficiary{{ element.beneficiaryName || '-' }}Issued Date{{ element.issuedDate || '-' }}Advice Status - {{ element.status === 'ISSUED' ? element.adviceStatus : '-' }} - Actions -
- - - - Pending Approval (Req #{{ element.pendingIssueRequestId }}) - - - - - Amendment Pending (Req #{{ element.pendingAmendmentRequestId }}) - - - - - - - - Reprint Pending Approval (Req #{{ element.pendingReprintRequestId }}) + + +
Status + + + {{ element.status | titlecase }} + Face Value + {{ element.faceValue > 0 ? '₹' + (element.faceValue | number: '1.2-2') : '—' }} + Purchaser / Client + {{ element.issuedToClientName || element.walkinPurchaserName || '—' }} +
+ + + Beneficiary + {{ element.beneficiaryName || '—' }} + -
- {{ dataSource.data.length === 0 ? 'Loading leaflets...' : 'No leaflets match the selected filter.' }} + + + Issued Date + {{ element.issuedDate || '—' }} + + + + + Advice Status + + {{ element.status === 'ISSUED' ? (element.adviceStatus | titlecase) : '—' }} + + + + + + Actions + +
+ + + + + Pending Approval (Req #{{ element.pendingIssueRequestId }}) + + + + + + + + + + + + + + + + + + + + + + Amendment Pending (Req #{{ element.pendingAmendmentRequestId }}) + + + + Reprint Pending (Req #{{ element.pendingReprintRequestId }}) + + + + Revoke Pending (Req #{{ element.pendingRevokeRequestId }}) + + + + + + Replaced by DD {{ element.revokedReplacedByDdNumber }} + + + + Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }} + +
+ +
+ + + + + +
+ + {{ dataSource.data.length === 0 ? 'Loading leaflets…' : 'No leaflets match the selected filter.' }} +
-
+
diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.ts b/src/app/sponsor-dd/dd-list/dd-list.component.ts index d9df2dc0d..2a45d2e05 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.ts +++ b/src/app/sponsor-dd/dd-list/dd-list.component.ts @@ -21,6 +21,9 @@ import { MatSelectModule } from '@angular/material/select'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatNativeDateModule } from '@angular/material/core'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { HttpClient } from '@angular/common/http'; import { debounceTime } from 'rxjs/operators'; @@ -40,145 +43,624 @@ import { DividendClaimRequestService } from '../../clerk/dividend-claim-request. MatDialogModule, MatSnackBarModule, MatFormFieldModule, - MatInputModule + MatInputModule, + MatMenuModule, + MatIconModule, + MatTooltipModule, + FaIconComponent ], templateUrl: './dd-list.component.html', styles: [ ` - .leaflet-container { - padding: 24px; - max-width: 1200px; + /* Same visual language as users.component.scss (gradient header icon, KPI-style chips, card + table with sticky head + row-hover, icon-button/overflow-menu actions) so this registry + reads as part of the same app rather than a one-off style. */ + .ddreg-container { + padding: 20px 24px; + max-width: 1320px; margin: 0 auto; } - .dividend-banner { + + /* ─── Header ─── */ + .ddreg-header { display: flex; align-items: center; - gap: 16px; - margin-bottom: 20px; + margin-bottom: 16px; + } + .ddreg-header__left { + display: flex; + align-items: center; + gap: 12px; + } + .ddreg-back { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9px; + border: 1px solid #d0d5dd; + background: #fff; + color: #475569; + cursor: pointer; + transition: all 0.15s; + } + .ddreg-back:hover { + background: #f8fafc; + border-color: #94a3b8; + color: #1e293b; + } + .ddreg-header__icon { + width: 44px; + height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: linear-gradient(135deg, #1e88e5 0%, #7c3aed 100%); + color: #fff; + font-size: 18px; + box-shadow: 0 2px 8px rgb(29 78 216 / 25%); + } + .ddreg-header__title { + margin: 0; + font-size: 20px; + font-weight: 800; + color: #0d1b2a; + line-height: 1.1; + } + .ddreg-header__sub { + margin-top: 2px; + font-size: 12.5px; + color: #64748b; + } + + /* ─── Dividend context banner ─── */ + .ddreg-dividend { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; padding: 12px 16px; border: 1px solid #c7d2fe; border-left: 4px solid #4f46e5; - border-radius: 8px; - background-color: #eef2ff; + border-radius: 10px; + background: linear-gradient(180deg, #eef2ff 0%, #f5f3ff 100%); } - .dividend-banner__text { + .ddreg-dividend__ic { + width: 34px; + height: 34px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9px; + background: #4f46e5; + color: #fff; + font-size: 14px; + } + .ddreg-dividend__text { display: flex; flex-direction: column; gap: 2px; font-size: 13px; color: #312e81; } - .dividend-banner__hint { + .ddreg-dividend__hint { color: #4338ca; font-size: 12px; } - .dividend-banner button { + .ddreg-dividend .ddreg-btn { margin-left: auto; } - .header-row { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 24px; - } - .title-area h2 { - margin: 0 0 4px 0; - font-size: 24px; - font-weight: 600; - color: #1e293b; - } - .title-area p { - margin: 0; - color: #64748b; - font-size: 14px; - } - .search-field { - width: 320px; - display: block; - margin-bottom: 8px; - } - .mat-mdc-card { - border-radius: 12px; - border: 1px solid #e2e8f0; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); - overflow: hidden; - } - table { - width: 100%; - } - th { - font-weight: 600; - color: #475569; - background-color: #f8fafc; - } - td { - color: #334155; - } - .status-badge { + + /* ─── Buttons ─── */ + .ddreg-btn { display: inline-flex; align-items: center; - padding: 4px 8px; - border-radius: 9999px; + gap: 8px; + padding: 8px 16px; + border-radius: 9px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + border: 1px solid transparent; + background: #fff; + } + .ddreg-btn fa-icon { font-size: 12px; - font-weight: 500; } - .status-badge.available { - background-color: #dcfce7; - color: #15803d; - } - .status-badge.issued { - background-color: #dbeafe; - color: #1d4ed8; - } - .status-badge.cleared { - background-color: #f1f5f9; + .ddreg-btn.ghost { + border-color: #d0d5dd; color: #475569; } - .status-badge.cancelled { - background-color: #fee2e2; + .ddreg-btn.ghost:hover { + background: #f8fafc; + border-color: #94a3b8; + color: #0d1b2a; + } + .ddreg-btn.ghost.active { + background: #ede9fe; + border-color: #c4b5fd; + color: #6d28d9; + } + .ddreg-btn.primary { + background: linear-gradient(135deg, #1e88e5 0%, #1e40af 100%); + border-color: #1e40af; + color: #fff; + box-shadow: 0 2px 6px -2px rgb(30 64 175 / 35%); + } + .ddreg-btn.primary:hover { + box-shadow: 0 4px 12px -3px rgb(30 64 175 / 45%); + transform: translateY(-1px); + } + .ddreg-btn.small { + padding: 6px 12px; + font-size: 12px; + } + + /* ─── Toolbar ─── */ + .ddreg-toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; + } + .ddreg-toolbar__search { + position: relative; + flex: 1; + min-width: 260px; + max-width: 420px; + } + .ddreg-toolbar__search input { + width: 100%; + padding: 9px 34px; + border: 1px solid #d0d5dd; + border-radius: 9px; + font-size: 13px; + background: #fff; + color: #0d1b2a; + transition: all 0.15s; + box-sizing: border-box; + } + .ddreg-toolbar__search input:focus { + outline: none; + border-color: #1e88e5; + box-shadow: 0 0 0 3px rgb(30 136 229 / 12%); + } + .ddreg-toolbar__search-ic { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: #94a3b8; + font-size: 12px; + pointer-events: none; + } + .ddreg-toolbar__clear { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + width: 22px; + height: 22px; + border-radius: 50%; + border: 1px solid #e2e8f0; + background: #fff; + color: #94a3b8; + font-size: 10px; + cursor: pointer; + } + .ddreg-toolbar__clear:hover { + background: #fef2f2; + color: #ef4444; + border-color: #fecaca; + } + + /* ─── KPI Cards ─── */ + .ddreg-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; + margin-bottom: 20px; + } + .ddreg-kpi-card { + background: #ffffff; + border-radius: 12px; + padding: 16px 18px; + border: 1px solid #e2e8f0; + display: flex; + align-items: center; + gap: 14px; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); + transition: + transform 0.15s ease, + box-shadow 0.15s ease; + } + .ddreg-kpi-card:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08); + } + .ddreg-kpi-card__icon { + width: 42px; + height: 42px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; + } + .ddreg-kpi-card__icon.blue { + background: #eff6ff; + color: #2563eb; + } + .ddreg-kpi-card__icon.green { + background: #f0fdf4; + color: #16a34a; + } + .ddreg-kpi-card__icon.indigo { + background: #eef2ff; + color: #4f46e5; + } + .ddreg-kpi-card__icon.amber { + background: #fffbeb; + color: #d97706; + } + .ddreg-kpi-card__icon.slate { + background: #f8fafc; + color: #64748b; + } + + .ddreg-kpi-card__info { + display: flex; + flex-direction: column; + } + .ddreg-kpi-card__label { + font-size: 11.5px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; + } + .ddreg-kpi-card__val { + font-size: 22px; + font-weight: 800; + color: #0f172a; + margin-top: 1px; + line-height: 1; + } + + /* ─── Status filter chips (doubles as the count-at-a-glance) ─── */ + .ddreg-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; + } + .ddreg-chip { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid #e2e8f0; + background: #fff; + color: #475569; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + } + .ddreg-chip:hover { + border-color: #cbd5e1; + background: #f8fafc; + } + .ddreg-chip__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + opacity: 0.6; + } + .ddreg-chip__count { + padding: 1px 7px; + border-radius: 999px; + background: rgb(0 0 0 / 6%); + font-size: 11px; + font-weight: 700; + } + .ddreg-chip.s-available { + color: #15803d; + } + .ddreg-chip.s-issued { + color: #1d4ed8; + } + .ddreg-chip.s-cancelled { color: #b91c1c; } - .status-badge.stopped { - background-color: #fef3c7; + .ddreg-chip.s-stopped { + color: #b45309; + } + .ddreg-chip.s-cleared, + .ddreg-chip.s-revoked { + color: #475569; + } + .ddreg-chip.s-all { + color: #4338ca; + } + .ddreg-chip.active { + background: #1e40af; + border-color: #1e40af; + color: #ffffff !important; + } + .ddreg-chip.active.s-available { + background: #16a34a; + border-color: #16a34a; + } + .ddreg-chip.active.s-issued { + background: #2563eb; + border-color: #2563eb; + } + .ddreg-chip.active.s-cancelled { + background: #dc2626; + border-color: #dc2626; + } + .ddreg-chip.active.s-stopped { + background: #d97706; + border-color: #d97706; + } + .ddreg-chip.active.s-cleared, + .ddreg-chip.active.s-revoked { + background: #475569; + border-color: #475569; + } + .ddreg-chip.active.s-all { + background: #4f46e5; + border-color: #4f46e5; + } + .ddreg-chip.active .ddreg-chip__dot { + background: #fff; + opacity: 0.9; + } + .ddreg-chip.active .ddreg-chip__count { + background: rgb(255 255 255 / 25%); + color: #fff; + } + + .ddreg-hint { + display: flex; + align-items: center; + gap: 6px; + color: #64748b; + font-size: 12.5px; + margin: 0 0 16px; + } + + /* ─── Card wrapper ─── */ + .ddreg-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + box-shadow: 0 1px 3px rgb(15 23 42 / 6%); + overflow: hidden; + } + .ddreg-card__head { + display: flex; + align-items: center; + padding: 14px 18px; + border-bottom: 1px solid #e2e8f0; + background: linear-gradient(180deg, #f8fafc 0%, #fff 100%); + } + .ddreg-card__title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: #0d1b2a; + } + .ddreg-card__title-ic { + color: #1e88e5; + } + .ddreg-card__count { + margin-left: 8px; + padding: 2px 10px; + background: #eff6ff; + color: #1e40af; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + } + + /* ─── Table ─── */ + .ddreg-table-wrap { + overflow: auto; + } + .ddreg-table { + width: 100%; + } + .ddreg-table th { + position: sticky; + top: 0; + z-index: 1; + background: #f1f5f9; + color: #475569; + text-align: left; + padding: 11px 14px !important; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid #e2e8f0; + } + .ddreg-table td { + padding: 10px 14px !important; + color: #334155; + border-bottom: 1px solid #f1f5f9; + } + .ddreg-table tr.mat-mdc-row:hover { + background: #f8fafc; + } + .ddreg-num { + text-align: right; + } + .ddreg-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12.5px; + } + .ddreg-strong { + font-weight: 700; + color: #0d1b2a; + } + .ddreg-muted { + color: #94a3b8; + } + + /* ─── Status badge (per-row, same palette as the filter chips) ─── */ + .ddreg-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 9999px; + font-size: 11.5px; + font-weight: 600; + } + .ddreg-badge__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + } + .ddreg-badge.available { + background: #dcfce7; + color: #15803d; + } + .ddreg-badge.issued { + background: #dbeafe; + color: #1d4ed8; + } + .ddreg-badge.cleared { + background: #f1f5f9; + color: #475569; + } + .ddreg-badge.cancelled { + background: #fee2e2; + color: #b91c1c; + } + .ddreg-badge.stopped { + background: #fef3c7; color: #b45309; } /* Spoiled stock. Grey rather than red: nothing went wrong with the customer's draft, only with the paper it was printed on, and the draft itself is alive on another leaflet. */ - .status-badge.revoked { - background-color: #e2e8f0; + .ddreg-badge.revoked { + background: #e2e8f0; color: #475569; } - .action-btn-group { - display: flex; - gap: 8px; + + /* ─── Actions ─── */ + .ddreg-actions-col { + min-width: 190px; } - .pending-badge { + .ddreg-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + } + .ddreg-act { + width: 30px; + height: 30px; + border: 1px solid #e2e8f0; + background: #fff; + border-radius: 7px; + cursor: pointer; display: inline-flex; align-items: center; - padding: 4px 10px; - border-radius: 9999px; - font-size: 12px; - font-weight: 500; - background-color: #fef3c7; - color: #b45309; - } - .status-filter-row { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 16px; - } - .status-filter-row button.filter-active { - background-color: #ede9fe; - } - .filter-count { + justify-content: center; color: #64748b; - margin-left: 4px; + font-size: 12px; + transition: all 0.12s; } - .hint-text { + .ddreg-act:hover { + transform: translateY(-1px); + } + .ddreg-act.more:hover { + background: #f5f3ff; + border-color: #ddd6fe; + color: #7c3aed; + } + .ddreg-menu__ic { + width: 18px; + margin-right: 10px; color: #64748b; font-size: 13px; - margin: -8px 0 16px 0; + text-align: center; + } + .ddreg-menu__ic.warn { + color: #ef4444; + } + + .ddreg-pending { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 9999px; + font-size: 11px; + font-weight: 600; + background: #fef3c7; + color: #b45309; + } + .ddreg-pending fa-icon { + font-size: 10px; + } + .ddreg-info { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11.5px; + color: #64748b; + } + .ddreg-info fa-icon { + font-size: 10px; + } + + .ddreg-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 48px 16px; + color: #94a3b8; + font-size: 13px; + } + .ddreg-empty fa-icon { + font-size: 22px; + opacity: 0.6; + } + + /* ─── Responsive ─── */ + @media (max-width: 900px) { + .ddreg-toolbar { + flex-direction: column; + align-items: stretch; + } + .ddreg-toolbar__search { + max-width: none; + } + } + @media (max-width: 600px) { + .ddreg-table th, + .ddreg-table td { + padding: 8px 10px !important; + font-size: 12px; + } } ` ] diff --git a/src/app/sponsor-dd/sponsor-dd.service.ts b/src/app/sponsor-dd/sponsor-dd.service.ts index 707a5a9da..9ee34c484 100644 --- a/src/app/sponsor-dd/sponsor-dd.service.ts +++ b/src/app/sponsor-dd/sponsor-dd.service.ts @@ -108,13 +108,14 @@ export class SponsorDDService { chargesAmount?: number; issuingBranchId: number; issuedDate: string; // yyyy-MM-dd - paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'CASH'; + paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'CASH' | 'GL'; savingsAccountId?: number; currentAccountId?: number; chequeNumber?: string; chequeBankName?: string; cashierDepositRequestId?: number; denominationData?: string; + glAccountId?: number; } ): Observable { return this.http.post(`/sponsordd/leaflets/${leafletId}/issue-request`, data); diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.html index d7f2c7136..4e7fc8f42 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.html @@ -68,18 +68,70 @@ Voucher / Receipt Date + Description Collected by Amount + Detail @for (v of detail.vouchers; track v.id) { - + {{ v.voucherNo || v.receiptNo }} {{ v.collectionDate }} + {{ v.note || '—' }} {{ v.collectedByName || '—' }} {{ v.amount | number: '1.2-2' }} + + + + @if (expandedVoucherId === v.id) { + + + @if (loadingGlEntriesId === v.id) { +
Loading GL entry…
+ } @else if (!glEntriesByVoucherId[v.id]?.length) { +
No GL posting recorded for this receipt.
+ } @else { + + + + + + + + + + @for (leg of glEntriesByVoucherId[v.id]; track leg.glCode + leg.drCr) { + + + + + + } + +
GL AccountDr / CrAmount
{{ leg.glCode }} — {{ leg.accountName }} + + {{ leg.drCr }} + + {{ leg.amount | number: '1.2-2' }}
+ } + + + } } diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.scss index 4e8a7aff8..7b9c8d19f 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.scss @@ -121,3 +121,93 @@ $danger: #b3261e; font-size: 13px; margin-top: 12px; } + +.ta-center { + text-align: center; +} + +.sard-voucher-row { + cursor: pointer; + + &:hover { + background: #f4f7fb; + } +} + +.sard-desc-cell { + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sard-detail-btn { + width: 30px; + height: 30px; + line-height: 30px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $primary; + } +} + +.sard-gl-row td { + background: #fbfcfe; + border-top: none; + padding: 4px 8px 10px; +} + +.sard-gl-loading, +.sard-gl-empty { + font-size: 12px; + color: $muted; + padding: 4px 0; +} + +.sard-gl-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + background: #fff; + border: 1px solid $border; + border-radius: 6px; + overflow: hidden; + + th { + color: $muted; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; + padding: 5px 8px; + background: #f4f7fb; + border-bottom: 1px solid $border; + } + + td { + padding: 5px 8px; + border-top: 1px solid #eceff4; + } +} + +.sard-drcr-pill { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + + &.sard-drcr-pill--dr { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; + } + + &.sard-drcr-pill--cr { + background: #ecfdf5; + color: #059669; + border: 1px solid #a7f3d0; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.ts index 05a5267e8..2920bb3b7 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.ts @@ -50,6 +50,12 @@ export class ShareAdditionRequestDetailDialogComponent implements OnInit { actioning = false; detail: any = null; + /** Which voucher's row is currently expanded to show its real Dr/Cr GL legs, or null if none. */ + expandedVoucherId: number | null = null; + loadingGlEntriesId: number | null = null; + /** Fetched once per voucher and kept — a checker toggling the same row twice shouldn't re-fetch. */ + glEntriesByVoucherId: { [id: number]: { glCode: string; accountName: string; drCr: string; amount: number }[] } = {}; + public dialogRef = inject(MatDialogRef); public data = inject<{ transactionId: number }>(MAT_DIALOG_DATA); @@ -76,6 +82,26 @@ export class ShareAdditionRequestDetailDialogComponent implements OnInit { return this.detail?.vouchers?.length || 0; } + /** Shows or hides a voucher's real GL posting, fetching it the first time it is opened. */ + toggleVoucherGlEntries(voucherId: number): void { + if (this.expandedVoucherId === voucherId) { + this.expandedVoucherId = null; + return; + } + this.expandedVoucherId = voucherId; + if (this.glEntriesByVoucherId[voucherId]) { + return; + } + this.loadingGlEntriesId = voucherId; + this.cashierService + .getShareAdvanceGlEntries(voucherId) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.glEntriesByVoucherId[voucherId] = res?.entries || []; + this.loadingGlEntriesId = null; + }); + } + approve(): void { this.act('approveadditionalshares'); } diff --git a/src/main.scss b/src/main.scss index 387d35837..c6f7900ea 100644 --- a/src/main.scss +++ b/src/main.scss @@ -982,6 +982,16 @@ mat-card { .mat-mdc-tab-header, .mat-stepper-vertical-line, .mat-step-header, + /* Account-view pages (Shares, Savings, Current, Deposits…) use `