diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index 9845ce8a4..4ef40be49 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. 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/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index de55bc0f0..b6732deb6 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -166,22 +166,30 @@
- + @if (acTypLockedByToken && row === selectedRow) { + {{ acTypLockedLabel }} + } @else { + + }
@@ -206,14 +214,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,22 +1537,30 @@
- + @if (acTypLockedByToken && row === selectedRow) { + {{ acTypLockedLabel }} + } @else { + + }
@@ -1556,10 +1578,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..c7daa9584 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -1703,15 +1703,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 +1729,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' @@ -2321,14 +2351,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') + ); } /** @@ -2408,19 +2445,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 +2552,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 +2585,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; @@ -3044,6 +3090,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 +3311,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 +3353,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 +3384,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 +3400,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 +3443,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 +3569,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 +4754,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 ( diff --git a/src/app/shared/icons.module.ts b/src/app/shared/icons.module.ts index 07f2b7ee5..cce399f5a 100644 --- a/src/app/shared/icons.module.ts +++ b/src/app/shared/icons.module.ts @@ -56,6 +56,7 @@ import { faGavel, faGlobe, faHandHoldingUsd, + faHandPaper, faHome, faKey, faLink, @@ -303,6 +304,7 @@ export class IconsModule { faHandHoldingHand, faHandHoldingHeart, faHandHoldingUsd, + faHandPaper, faHeart, faHome, faKey, 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);