diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 732655f6b..367d26e37 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -20,6 +20,7 @@ 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'; const routes: Routes = [ Route.withShell([ @@ -76,6 +77,11 @@ const routes: Routes = [ path: 'excess-report', component: ExcessTransactionReportComponent, data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' } + }, + { + path: 'share-collection', + component: ShareCollectionComponent, + data: { title: 'Share Collection', breadcrumb: 'Share Collection' } } ] } diff --git a/src/app/cashier/cashier.module.ts b/src/app/cashier/cashier.module.ts index ec6dcfeeb..2cf93e092 100644 --- a/src/app/cashier/cashier.module.ts +++ b/src/app/cashier/cashier.module.ts @@ -20,6 +20,7 @@ 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: [ @@ -34,7 +35,8 @@ import { ExcessTransactionReportComponent } from './excess-transaction-report/ex ReconciliationComponent, CashHistoryComponent, ClosingCashReportComponent, - ExcessTransactionReportComponent + ExcessTransactionReportComponent, + ShareCollectionComponent ] }) export class CashierModule {} diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index d27a9923a..a37de9832 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -156,4 +156,22 @@ export class CashierService { if (asOfDate) params = params.set('asOfDate', asOfDate); return this.http.get('/cashreconciliation/expected/me', { params }); } + + // ── Payment Types ── + getPaymentTypes(): Observable { + 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}`); + } } diff --git a/src/app/cashier/share-collection/share-collection.component.html b/src/app/cashier/share-collection/share-collection.component.html new file mode 100644 index 000000000..6839b15b3 --- /dev/null +++ b/src/app/cashier/share-collection/share-collection.component.html @@ -0,0 +1,294 @@ + + +
+ +
+
+
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. +
+
+
+ + +
+ + 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 new file mode 100644 index 000000000..440c255aa --- /dev/null +++ b/src/app/cashier/share-collection/share-collection.component.scss @@ -0,0 +1,541 @@ +/** + * 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; + } +} + +// ── 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 new file mode 100644 index 000000000..5bb95bcdd --- /dev/null +++ b/src/app/cashier/share-collection/share-collection.component.ts @@ -0,0 +1,199 @@ +/** + * 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 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; + + 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 isValid(): boolean { + return !!this.account && this.totalAmount > 0 && !!this.transactionDate; + } + + 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 || [])); + } + + 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; + } +} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 3a2b3275f..83b6e8382 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -725,6 +725,9 @@ + diff --git a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.html b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.html index e9b864c9d..c5ef501a3 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.html +++ b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.html @@ -7,96 +7,111 @@ -->
-
- -

{{ 'labels.heading.Accounting Rule' | translate }}

+ + + +

Accounting

-
-
- - @for (accountingRule of accountingRuleData; track accountingRule; let i = $index) { - - {{ 'labels.accounting.' + accountingRule | translate }} - - } - + +
+ +
+
+

Accounting Rule

+
+
+ Cash +
+ + + @for (section of accountingSectionConfigs; track section.key) { +
+
+

{{ section.title }}

+ +
+ +
+ @for (row of section.rows; track row.key) { +
+
+ + {{ section.pillLabel }} + + {{ row.label }} +
+ +
+ } +
+ +
+ @for ( + otherRow of getOtherGlMappingsArray(section.otherMappingsControlName).controls; + track $index; + let i = $index + ) { +
+
+
+ + {{ section.pillLabel }} + + +
+
+ + GL Code + + @for (glAccount of section.otherAccountOptions; track glAccount.id) { + + {{ '(' + glAccount.glCode + ') ' + glAccount.name }} + + } + + +
+
+ +
+ } +
+
+ }
- - @if (shareProductAccountingForm.value.accountingRule === 2) { - -
-
- -

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

-
-
- - -
-
- - -
-
- -

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

-
-
- - -
-
- - -
-
- -

{{ 'labels.heading.Share Equity' | translate }}

-
-
- - -
-
- - -
-
- -

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

-
-
- - -
-
- } diff --git a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.scss b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.scss index fa8e0ab44..79976907a 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.scss +++ b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.scss @@ -6,54 +6,73 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-500: #6b7280; +$gray-700: #334155; +$gray-900: #202124; +$primary: #1353a0; + .shpa-section { - border: 1px solid #e2e8f0; + border: 1px solid $border; border-radius: 10px; overflow: hidden; background: #fff; +} +.shpa-section__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: $gray-900; + } +} + +.shpa-section__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + background: #eff6ff; + color: #2563eb; + font-size: 13px; +} + +.shpa-section__body { + padding: 16px 24px 20px; +} + +.shpa-group { & + & { - margin-top: 16px; + margin-top: 20px; } &__header { display: flex; align-items: center; - gap: 10px; - padding: 12px 24px; - background: #f8f9fa; - border-bottom: 1px solid #e8eaed; - - i { - font-size: 14px; - color: #0ea5e9; - } - - h3 { - margin: 0; - font-size: 14px; - font-weight: 600; - color: #202124; - } + justify-content: space-between; + gap: 16px; + margin: 0 0 12px; } -} -.shpa-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 4px 16px; - padding: 16px 24px; - - @media (width <= 600px) { - grid-template-columns: 1fr; - } -} - -.shpa-field { - width: 100%; - - &--full { - grid-column: 1 / -1; + h4 { + margin: 0 0 12px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #94a3b8; } } @@ -62,3 +81,215 @@ gap: 24px; flex-wrap: wrap; } + +.shpa-add-btn { + border: 0; + background: transparent; + color: #2563eb; + font-size: 12px; + font-weight: 700; + padding: 0; + cursor: pointer; +} + +.shpa-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.shpa-list + .shpa-list { + margin-top: 12px; +} + +.shpa-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + + @media (width <= 900px) { + flex-direction: column; + align-items: stretch; + } +} + +.shpa-row:has(mifosx-gl-account-selector) { + align-items: stretch; +} + +.shpa-row--other { + border-style: dashed; + border-color: #cbd5e1; + background: #f8fafc; + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); + align-items: center; + padding: 12px 16px; + + @media (width <= 1100px) { + grid-template-columns: 1fr; + } +} + +.shpa-other-row-wrap { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + + @media (width <= 1100px) { + grid-template-columns: 1fr; + gap: 8px; + } +} + +.shpa-row__meta { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + flex: 1 1 auto; +} + +.shpa-row__meta--other { + min-width: 0; + flex: 1 1 auto; + align-self: stretch; +} + +.shpa-row__label { + font-size: 14px; + font-weight: 600; + color: $gray-700; +} + +.shpa-row__input { + flex: 0 0 22rem; + max-width: 22rem; + min-width: 18rem; + + @media (width <= 900px) { + width: 100%; + max-width: 100%; + min-width: 0; + } +} + +.shpa-row__input :is(mifosx-gl-account-selector) { + width: 100%; + display: block; +} + +.shpa-row__input :is(mifosx-gl-account-selector) :is(.layout-column) { + width: 100%; + margin-right: 0 !important; +} + +.shpa-row__input :is(mifosx-gl-account-selector) :is(mat-form-field) { + width: 100%; +} + +.shpa-row__actions { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.shpa-row__actions--other { + justify-content: flex-end; + align-self: stretch; + width: 100%; + + @media (width <= 1100px) { + justify-content: flex-start; + } +} + +.shpa-row__name { + width: 100%; + min-width: 0; + max-width: 24rem; + height: 40px; + border: 1px solid #cbd5e1; + border-radius: 6px; + padding: 0 12px; + font-size: 14px; + background: #fff; + + @media (width <= 900px) { + max-width: 100%; + } +} + +.shpa-row__name--other-account { + max-width: 100%; + flex: 1 1 auto; + height: 48px; +} + +.shpa-row__other-field { + width: 100%; + min-width: 0; +} + +.shpa-row__other-field :is(mat-form-field) { + width: 100%; +} + +.shpa-row__other-field :is(.mat-mdc-form-field-required-marker) { + display: none; +} + +.shpa-remove-btn { + border: 0; + background: transparent; + color: #ef4444; + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} + +.shpa-remove-btn--side { + place-self: center end; + margin-right: 4px; + + @media (width <= 1100px) { + justify-self: start; + margin-right: 0; + } +} + +.shpa-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; + + &--green { + background: #dcfce7; + color: #16a34a; + } + + &--amber { + background: #fef3c7; + color: #d97706; + } + + &--blue { + background: #dbeafe; + color: #2563eb; + } +} 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 e7a19905b..cd20da7bc 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 @@ -8,15 +8,44 @@ import { Component, OnInit, Input, inject } from '@angular/core'; import { + FormsModule, UntypedFormGroup, UntypedFormBuilder, - Validators, + UntypedFormArray, UntypedFormControl, + Validators, ReactiveFormsModule } from '@angular/forms'; import { MatRadioGroup, MatRadioButton } from '@angular/material/radio'; import { GlAccountSelectorComponent } from '../../../../shared/accounting/gl-account-selector/gl-account-selector.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { GLAccount } from 'app/shared/models/general.model'; + +type ShareAccountingSectionKey = 'assets' | 'liabilities' | 'income' | 'equity'; + +type ShareAccountingRowConfig = { + key: string; + label: string; + inputLabel: string; + controlName: string; + options: GLAccount[]; + required?: boolean; +}; + +type ShareAccountingSectionConfig = { + key: ShareAccountingSectionKey; + title: string; + pillLabel: string; + pillClass: 'green' | 'amber' | 'blue'; + rows: ShareAccountingRowConfig[]; + otherMappingsControlName: string; + otherAccountOptions: GLAccount[]; +}; + +type OtherGlMappingValue = { + accountName: string; + glAccountId: number | null; +}; @Component({ selector: 'mifosx-share-product-accounting-step', @@ -24,6 +53,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; styleUrls: ['./share-product-accounting-step.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, + FormsModule, + ReactiveFormsModule, MatRadioGroup, MatRadioButton, GlAccountSelectorComponent @@ -37,65 +68,172 @@ export class ShareProductAccountingStepComponent implements OnInit { @Input() shareProductFormValid: boolean; shareProductAccountingForm: UntypedFormGroup; + accountingSectionConfigs: ShareAccountingSectionConfig[] = []; - assetAccountData: any; - incomeAccountData: any; - equityAccountData: any; - liabilityAccountData: any; + assetAccountData: GLAccount[] = []; + liabilityAccountData: GLAccount[] = []; + incomeAccountData: GLAccount[] = []; + equityAccountData: GLAccount[] = []; constructor() { this.createShareProductAccountingForm(); - this.setConditionalControls(); } ngOnInit() { - this.assetAccountData = this.shareProductsTemplate.accountingMappingOptions.assetAccountOptions || []; - this.incomeAccountData = this.shareProductsTemplate.accountingMappingOptions.incomeAccountOptions || []; - this.equityAccountData = this.shareProductsTemplate.accountingMappingOptions.equityAccountOptions || []; - this.liabilityAccountData = this.shareProductsTemplate.accountingMappingOptions.liabilityAccountOptions || []; + const opts = this.shareProductsTemplate.accountingMappingOptions || {}; + this.assetAccountData = opts.assetAccountOptions || []; + this.liabilityAccountData = opts.liabilityAccountOptions || []; + this.incomeAccountData = opts.incomeAccountOptions || []; + this.equityAccountData = opts.equityAccountOptions || []; - if (this.shareProductsTemplate.accountingRule) { + this.buildAccountingSectionConfigs(); + + if (this.shareProductsTemplate.accountingMappings) { + const m = this.shareProductsTemplate.accountingMappings; this.shareProductAccountingForm.patchValue({ - accountingRule: this.shareProductsTemplate.accountingRule.id + shareReferenceId: m.shareReferenceId?.id || '', + shareSuspenseId: m.shareSuspenseId?.id || '', + incomeFromFeeAccountId: m.incomeFromFeeAccountId?.id || '', + shareEquityId: m.shareEquityId?.id || '' }); - - if (this.shareProductsTemplate.accountingRule.id === 2) { - this.shareProductAccountingForm.patchValue({ - shareReferenceId: this.shareProductsTemplate.accountingMappings.shareReferenceId.id, - shareSuspenseId: this.shareProductsTemplate.accountingMappings.shareSuspenseId.id, - shareEquityId: this.shareProductsTemplate.accountingMappings.shareEquityId.id, - incomeFromFeeAccountId: this.shareProductsTemplate.accountingMappings.incomeFromFeeAccountId.id - }); - } } } createShareProductAccountingForm() { this.shareProductAccountingForm = this.formBuilder.group({ - accountingRule: [1] + accountingRule: [2], + shareReferenceId: [ + '', + Validators.required + ], + shareSuspenseId: [ + '', + Validators.required + ], + incomeFromFeeAccountId: [ + '', + Validators.required + ], + shareEquityId: [ + '', + Validators.required + ], + assetsOtherGlMappings: this.formBuilder.array([]), + liabilitiesOtherGlMappings: this.formBuilder.array([]), + incomeOtherGlMappings: this.formBuilder.array([]), + equityOtherGlMappings: this.formBuilder.array([]) }); } - setConditionalControls() { - this.shareProductAccountingForm.get('accountingRule').valueChanges.subscribe((accountingRule: any) => { - if (accountingRule === 2) { - this.shareProductAccountingForm.addControl('shareReferenceId', new UntypedFormControl('', Validators.required)); - this.shareProductAccountingForm.addControl('shareSuspenseId', new UntypedFormControl('', Validators.required)); - this.shareProductAccountingForm.addControl('shareEquityId', new UntypedFormControl('', Validators.required)); - this.shareProductAccountingForm.addControl( - 'incomeFromFeeAccountId', - new UntypedFormControl('', Validators.required) - ); - } else { - this.shareProductAccountingForm.removeControl('shareReferenceId'); - this.shareProductAccountingForm.removeControl('shareSuspenseId'); - this.shareProductAccountingForm.removeControl('shareEquityId'); - this.shareProductAccountingForm.removeControl('incomeFromFeeAccountId'); + buildAccountingSectionConfigs() { + this.accountingSectionConfigs = [ + { + key: 'assets', + title: 'Assets', + pillLabel: 'Asset', + pillClass: 'green', + rows: [ + { + key: 'shareReferenceId', + label: 'Share Reference (Cash Vault)', + inputLabel: 'Share reference', + controlName: 'shareReferenceId', + options: this.assetAccountData, + required: true + } + ], + otherMappingsControlName: 'assetsOtherGlMappings', + otherAccountOptions: this.assetAccountData + }, + { + key: 'liabilities', + title: 'Liabilities', + pillLabel: 'Liability', + pillClass: 'green', + rows: [ + { + key: 'shareSuspenseId', + label: 'Share Sundry (Suspense)', + inputLabel: 'Share sundry / suspense', + controlName: 'shareSuspenseId', + options: this.liabilityAccountData, + required: true + } + ], + otherMappingsControlName: 'liabilitiesOtherGlMappings', + otherAccountOptions: this.liabilityAccountData + }, + { + key: 'income', + title: 'Income', + pillLabel: 'Income', + pillClass: 'blue', + rows: [ + { + key: 'incomeFromFeeAccountId', + label: 'Income From Fees', + inputLabel: 'Income from fees', + controlName: 'incomeFromFeeAccountId', + options: this.incomeAccountData, + required: true + } + ], + otherMappingsControlName: 'incomeOtherGlMappings', + otherAccountOptions: this.incomeAccountData + }, + { + key: 'equity', + title: 'Share Equity', + pillLabel: 'Equity', + pillClass: 'blue', + rows: [ + { + key: 'shareEquityId', + label: 'Share Equity', + inputLabel: 'Share equity', + controlName: 'shareEquityId', + options: this.equityAccountData, + required: true + } + ], + otherMappingsControlName: 'equityOtherGlMappings', + otherAccountOptions: this.equityAccountData } + ]; + } + + getOtherGlMappingsArray(controlName: string): UntypedFormArray { + return this.shareProductAccountingForm.get(controlName) as UntypedFormArray; + } + + addOtherGlMapping(controlName: string) { + this.getOtherGlMappingsArray(controlName).push(this.createOtherGlMappingGroup()); + } + + removeOtherGlMapping(controlName: string, index: number) { + this.getOtherGlMappingsArray(controlName).removeAt(index); + } + + getOtherGlMappingControl(controlName: string, index: number, fieldName: string): UntypedFormControl { + return this.getOtherGlMappingsArray(controlName).at(index).get(fieldName) as UntypedFormControl; + } + + private createOtherGlMappingGroup(val: Partial = {}) { + return this.formBuilder.group({ + accountName: [ + val.accountName || '', + Validators.required + ], + glAccountId: [ + val.glAccountId || '', + Validators.required + ] }); } get shareProductAccounting() { - return this.shareProductAccountingForm.value; + const { assetsOtherGlMappings, liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } = + this.shareProductAccountingForm.value; + return rest; } } diff --git a/src/app/products/share-products/share-product-stepper/share-product-currency-step/share-product-currency-step.component.html b/src/app/products/share-products/share-product-stepper/share-product-currency-step/share-product-currency-step.component.html index 9489452a4..bae0aebb1 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-currency-step/share-product-currency-step.component.html +++ b/src/app/products/share-products/share-product-stepper/share-product-currency-step/share-product-currency-step.component.html @@ -49,35 +49,4 @@
- -
-
- -

{{ 'labels.inputs.Multiples' | translate }}

-
-
-
- - {{ 'labels.inputs.Set the share installment in multiples of' | translate }} - -
- - @if (shareProductCurrencyForm.value.setMultiples) { - - {{ 'labels.inputs.Currency in multiples of' | translate }} - - - {{ 'labels.inputs.Currency in multiples of' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - } -
-
diff --git a/src/app/products/share-products/share-product-stepper/share-product-currency-step/share-product-currency-step.component.ts b/src/app/products/share-products/share-product-stepper/share-product-currency-step/share-product-currency-step.component.ts index 5dff6480f..4e6602ef2 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-currency-step/share-product-currency-step.component.ts +++ b/src/app/products/share-products/share-product-stepper/share-product-currency-step/share-product-currency-step.component.ts @@ -6,11 +6,9 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, Input, inject, DestroyRef } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Component, OnInit, Input, inject } from '@angular/core'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { MatTooltip } from '@angular/material/tooltip'; -import { MatCheckbox } from '@angular/material/checkbox'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -21,13 +19,11 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, MatTooltip, - FaIconComponent, - MatCheckbox + FaIconComponent ] }) export class ShareProductCurrencyStepComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); - private destroyRef = inject(DestroyRef); @Input() shareProductsTemplate: any; @@ -44,12 +40,8 @@ export class ShareProductCurrencyStepComponent implements OnInit { this.shareProductCurrencyForm.patchValue({ currencyCode: this.shareProductsTemplate.currency?.code || this.currencyData[0].code, - digitsAfterDecimal: this.shareProductsTemplate.digitsAfterDecimal ?? '', - setMultiples: !!this.shareProductsTemplate.inMultiplesOf, - inMultiplesOf: this.shareProductsTemplate.inMultiplesOf ?? '' + digitsAfterDecimal: this.shareProductsTemplate.digitsAfterDecimal ?? 2 }); - - this.setupConditionalValidation(); } createShareProductCurrencyForm() { @@ -59,54 +51,20 @@ export class ShareProductCurrencyStepComponent implements OnInit { Validators.required ], digitsAfterDecimal: [ - '', + 2, [ Validators.required, Validators.min(0) ] - ], - setMultiples: [false], - inMultiplesOf: [''] - }); - } - - setupConditionalValidation() { - const inMultiplesOfControl = this.shareProductCurrencyForm.get('inMultiplesOf'); - const setMultiplesControl = this.shareProductCurrencyForm.get('setMultiples'); - - const applyInMultiplesValidators = (checked: boolean) => { - if (checked) { - inMultiplesOfControl?.setValidators([ - Validators.required, - Validators.min(1) - ]); - } else { - inMultiplesOfControl?.clearValidators(); - inMultiplesOfControl?.setValue(''); - } - inMultiplesOfControl?.updateValueAndValidity(); - }; - - // Apply validators based on initial value - applyInMultiplesValidators(setMultiplesControl?.value); - - // Listen for changes - setMultiplesControl?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((checked) => { - applyInMultiplesValidators(checked); + ] }); } get shareProductCurrency() { const formValue = this.shareProductCurrencyForm.value; - const result: any = { + return { currencyCode: formValue.currencyCode, digitsAfterDecimal: formValue.digitsAfterDecimal }; - - if (formValue.inMultiplesOf !== '' && formValue.inMultiplesOf !== null && formValue.inMultiplesOf !== undefined) { - result.inMultiplesOf = formValue.inMultiplesOf; - } - - return result; } }