WEB-506 feat: add Remittance Module with 7-step payout wizard (#3200)
WEB-506 fix: address CodeRabbit review (security, validation) WEB-506 fix: make API gateway header name configurable (MIFOS_REMITTANCE_API_HEADER) WEB-506 fix: remove duplicate translation keys (Personal Information, Transaction Details)
This commit is contained in:
+234
@@ -0,0 +1,234 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
@if (recipientData) {
|
||||
<div class="step-content">
|
||||
<h3>{{ 'labels.heading.Validated Recipient Information' | translate }}</h3>
|
||||
|
||||
<!-- Section: Identification -->
|
||||
<div class="section-header">
|
||||
<mat-icon>badge</mat-icon>
|
||||
<h4>{{ 'labels.heading.Identification' | translate }}</h4>
|
||||
</div>
|
||||
<div class="details-grid">
|
||||
@if (recipientData.primaryDocument?.documentType) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Document Type' | translate }}</span>
|
||||
<span class="value">{{ recipientData.primaryDocument?.documentType }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recipientData.primaryDocument?.documentNumber) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Document Number' | translate }}</span>
|
||||
<span class="value">{{ recipientData.primaryDocument?.documentNumber }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recipientData.identityCard) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.National Identity Card Number' | translate }}</span>
|
||||
<span class="value">{{ recipientData.identityCard }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Section: Personal Information -->
|
||||
<div class="section-header">
|
||||
<mat-icon>person</mat-icon>
|
||||
<h4>{{ 'labels.heading.Personal Information' | translate }}</h4>
|
||||
</div>
|
||||
<div class="details-grid">
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.First Name' | translate }}</span>
|
||||
<span class="value">{{ recipientData.givenName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Last Name' | translate }}</span>
|
||||
<span class="value">{{ recipientData.lastName }}</span>
|
||||
</div>
|
||||
|
||||
@if (recipientData.motherMaidenName) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Mothers Maiden Name' | translate }}</span>
|
||||
<span class="value">{{ recipientData.motherMaidenName }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recipientData.dateOfBirth) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Date of Birth' | translate }}</span>
|
||||
<span class="value">{{ recipientData.dateOfBirth | date: 'mediumDate' }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recipientData.phone) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Phone Number' | translate }}</span>
|
||||
<span class="value">{{ recipientData.phone }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Section: Address -->
|
||||
<div class="section-header">
|
||||
<mat-icon>location_on</mat-icon>
|
||||
<h4>{{ 'labels.inputs.Address' | translate }}</h4>
|
||||
</div>
|
||||
<div class="details-grid">
|
||||
@if (recipientData.address?.line1) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Address Line' | translate }}</span>
|
||||
<span class="value">{{ recipientData.address?.line1 }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recipientData.address?.postalCode) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Postal Code' | translate }}</span>
|
||||
<span class="value">{{ recipientData.address?.postalCode }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recipientData.address?.state) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.State' | translate }}</span>
|
||||
<span class="value">{{ recipientData.address?.state }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recipientData.municipality || recipientData.address?.city) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Municipality' | translate }}</span>
|
||||
<span class="value">{{ recipientData.municipality || recipientData.address?.city }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recipientData.locality) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Locality' | translate }}</span>
|
||||
<span class="value">{{ recipientData.locality }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recipientData.newLocality) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.New Locality' | translate }}</span>
|
||||
<span class="value">{{ recipientData.newLocality }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recipientData.address?.country) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Country' | translate }}</span>
|
||||
<span class="value">{{ recipientData.address?.country }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Section: Professional Information -->
|
||||
<div class="section-header">
|
||||
<mat-icon>work</mat-icon>
|
||||
<h4>{{ 'labels.heading.Professional Information' | translate }}</h4>
|
||||
</div>
|
||||
<div class="details-grid">
|
||||
@if (recipientData.occupation) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Occupation' | translate }}</span>
|
||||
<span class="value">{{ recipientData.occupation }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recipientData.economicActivity) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Economic Activity' | translate }}</span>
|
||||
<span class="value">{{ recipientData.economicActivity }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recipientData.entityMember) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Entity Member' | translate }}</span>
|
||||
<span class="value">{{ recipientData.entityMember }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Section: Assign to Mifos Client -->
|
||||
<div class="section-header">
|
||||
<mat-icon>assignment_ind</mat-icon>
|
||||
<h4>{{ 'labels.heading.Assign to Mifos Client' | translate }}</h4>
|
||||
</div>
|
||||
<div class="client-search-section">
|
||||
<p class="search-instruction">
|
||||
{{ 'labels.text.Search and assign this beneficiary to an existing Mifos client account' | translate }}
|
||||
</p>
|
||||
|
||||
<mat-form-field appearance="outline" class="client-search-field">
|
||||
<mat-label>{{ 'labels.inputs.Search Client' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[formControl]="clientSearchControl"
|
||||
[matAutocomplete]="clientAuto"
|
||||
placeholder="{{ 'labels.inputs.Type client name or account number' | translate }}"
|
||||
/>
|
||||
<mat-icon matIconPrefix>search</mat-icon>
|
||||
<mat-autocomplete
|
||||
#clientAuto="matAutocomplete"
|
||||
[displayWith]="displayFn"
|
||||
(optionSelected)="onClientSelected($event.option.value)"
|
||||
>
|
||||
@for (client of filteredClients$ | async; track client.id) {
|
||||
<mat-option [value]="client">
|
||||
<div class="client-option">
|
||||
<span class="client-name">{{ client.displayName }}</span>
|
||||
<span class="client-details">
|
||||
@if (client.accountNo) {
|
||||
<span class="account-no">{{ 'labels.inputs.Account' | translate }}: {{ client.accountNo }}</span>
|
||||
}
|
||||
@if (client.externalId) {
|
||||
<span class="external-id"
|
||||
>{{ 'labels.inputs.External ID' | translate }}: {{ client.externalId }}</span
|
||||
>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
</mat-form-field>
|
||||
|
||||
@if (searchError) {
|
||||
<div class="error-message">
|
||||
<mat-icon>error</mat-icon>
|
||||
<span>{{ 'labels.text.Failed to search clients. Please try again.' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedClient) {
|
||||
<div class="selected-client-info">
|
||||
<mat-icon color="primary">check_circle</mat-icon>
|
||||
<div class="client-info">
|
||||
<strong>{{ selectedClient.displayName }}</strong>
|
||||
@if (selectedClient.accountNo) {
|
||||
<span class="subdued">{{ 'labels.inputs.Account' | translate }}: {{ selectedClient.accountNo }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="step-actions">
|
||||
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="!selectedClient">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<mat-icon>arrow_forward</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.step-content {
|
||||
padding: 16px 0;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 20px 0 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 100%));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.client-search-section {
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--mdc-theme-surface, rgb(0 0 0 / 4%));
|
||||
|
||||
.search-instruction {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.client-search-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--mdc-theme-error-container, #ffebee);
|
||||
border-radius: 4px;
|
||||
color: var(--mdc-theme-on-error-container, #c62828);
|
||||
font-size: 13px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-client-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background-color: var(--mdc-theme-primary-container, rgb(76 175 80 / 12%));
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--mdc-theme-primary, rgb(76 175 80 / 30%));
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--mdc-theme-primary, #4caf50);
|
||||
}
|
||||
|
||||
.client-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.subdued {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.client-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
|
||||
.client-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.client-details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
|
||||
.account-no,
|
||||
.external-id {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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, EventEmitter, Input, Output, inject, OnInit } from '@angular/core';
|
||||
import { STANDALONE_SHARED_IMPORTS } from '../../../standalone-shared.module';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, switchMap, startWith, catchError, map } from 'rxjs/operators';
|
||||
|
||||
import { ValidatedRecipient } from '../../models/beneficiary.model';
|
||||
import { ClientsService } from '../../../clients/clients.service';
|
||||
|
||||
interface ClientSearchResult {
|
||||
id: number;
|
||||
displayName: string;
|
||||
accountNo: string;
|
||||
externalId: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-beneficiary-details-step',
|
||||
standalone: true,
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatIcon,
|
||||
ReactiveFormsModule,
|
||||
MatAutocompleteModule
|
||||
],
|
||||
templateUrl: './beneficiary-details-step.component.html',
|
||||
styleUrls: ['./beneficiary-details-step.component.scss']
|
||||
})
|
||||
export class BeneficiaryDetailsStepComponent implements OnInit {
|
||||
@Input() recipientData: ValidatedRecipient | null = null;
|
||||
@Output() beneficiaryNext = new EventEmitter<number>();
|
||||
|
||||
private clientsService = inject(ClientsService);
|
||||
|
||||
clientSearchControl = new FormControl<string | ClientSearchResult>('');
|
||||
filteredClients$!: Observable<ClientSearchResult[]>;
|
||||
selectedClient: ClientSearchResult | null = null;
|
||||
searchError = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.filteredClients$ = this.clientSearchControl.valueChanges.pipe(
|
||||
startWith(''),
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
switchMap((searchValue) => {
|
||||
const searchText = typeof searchValue === 'string' ? searchValue : '';
|
||||
if (!searchText || searchText.trim().length < 2) {
|
||||
return of([]);
|
||||
}
|
||||
return this.searchClients(searchText.trim());
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private searchClients(searchText: string): Observable<ClientSearchResult[]> {
|
||||
this.searchError = false;
|
||||
return this.clientsService.searchByText(searchText, 0, 20, '', '').pipe(
|
||||
map((response: any) => {
|
||||
const clients = response?.content || [];
|
||||
return clients.map((client: any) => ({
|
||||
id: client.id,
|
||||
displayName: client.displayName || '',
|
||||
accountNo: client.accountNumber || '',
|
||||
externalId: client.externalId || ''
|
||||
}));
|
||||
}),
|
||||
catchError(() => {
|
||||
this.searchError = true;
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onClientSelected(client: ClientSearchResult): void {
|
||||
this.selectedClient = client;
|
||||
this.clientSearchControl.setValue(client.displayName);
|
||||
}
|
||||
|
||||
displayFn = (value: string | ClientSearchResult): string => {
|
||||
return typeof value === 'string' ? value : value?.displayName || '';
|
||||
};
|
||||
|
||||
onNext(): void {
|
||||
if (this.selectedClient) {
|
||||
this.beneficiaryNext.emit(this.selectedClient.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="step-content">
|
||||
<h3>{{ 'labels.heading.Confirm Payout' | translate }}</h3>
|
||||
<p class="description">
|
||||
{{ 'labels.text.Please review the details carefully before confirming the payment.' | translate }}
|
||||
</p>
|
||||
|
||||
<div class="confirmation-card">
|
||||
<!-- Transaction info -->
|
||||
@if (remittanceData) {
|
||||
<div class="card-section">
|
||||
<h4>{{ 'labels.heading.Transaction' | translate }}</h4>
|
||||
@if (officeName) {
|
||||
<div class="info-row">
|
||||
<span class="label">{{ 'labels.inputs.Office' | translate }}</span>
|
||||
<span class="value">{{ officeName }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="info-row">
|
||||
<span class="label">{{ 'labels.inputs.Code' | translate }}</span>
|
||||
<span class="value">{{ remittanceData.pin }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">{{ 'labels.inputs.Date' | translate }}</span>
|
||||
<span class="value">{{ remittanceData.created | date: 'medium' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">{{ 'labels.inputs.Payout Amount' | translate }}</span>
|
||||
<span class="value amount">{{
|
||||
remittanceData.receivingAmount | currency: remittanceData.receivingCurrency
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">{{ 'labels.inputs.Sender' | translate }}</span>
|
||||
<span class="value">{{ remittanceData.senderName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<!-- Recipient info -->
|
||||
<div class="card-section">
|
||||
<h4>{{ 'labels.heading.Recipient' | translate }}</h4>
|
||||
<div class="info-row">
|
||||
<span class="label">{{ 'labels.inputs.Name' | translate }}</span>
|
||||
@if (recipientData?.givenName) {
|
||||
<span class="value">{{ recipientData.givenName }} {{ recipientData.lastName }}</span>
|
||||
} @else {
|
||||
<span class="value">{{ remittanceData?.recipientName }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (recipientData?.primaryDocument?.documentType) {
|
||||
<div class="info-row">
|
||||
<span class="label">{{ 'labels.inputs.Document' | translate }}</span>
|
||||
<span class="value"
|
||||
>{{ recipientData.primaryDocument?.documentType }} -
|
||||
{{ recipientData.primaryDocument?.documentNumber }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (payoutAssignment) {
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<!-- Assignment info -->
|
||||
<div class="card-section">
|
||||
<h4>{{ 'labels.heading.Payout Assignment' | translate }}</h4>
|
||||
<div class="info-row">
|
||||
<span class="label">{{ 'labels.inputs.Status' | translate }}</span>
|
||||
<span class="value">{{ payoutAssignment.status }}</span>
|
||||
</div>
|
||||
@if (payoutAssignment?.trackingNumber) {
|
||||
<div class="info-row">
|
||||
<span class="label">{{ 'labels.inputs.Reference Number' | translate }}</span>
|
||||
<span class="value mono">{{ payoutAssignment.trackingNumber }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (errorMessage) {
|
||||
<div class="error-banner">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="step-actions">
|
||||
<button mat-raised-button color="warn" (click)="confirmPayout()" [disabled]="isLoading || !vendor">
|
||||
@if (isLoading) {
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
} @else {
|
||||
<span><mat-icon>check_circle</mat-icon> {{ 'labels.buttons.Confirm Payment' | translate }}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.step-content {
|
||||
padding: 16px 0;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0 0 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.confirmation-card {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-section {
|
||||
padding: 16px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 12px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
&.amount {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
&.mono {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
background: #f5f5f5;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
margin: 8px 0;
|
||||
background-color: #fdecea;
|
||||
border-radius: 4px;
|
||||
color: #611a15;
|
||||
|
||||
mat-icon {
|
||||
color: #f44336;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
margin-top: 16px;
|
||||
|
||||
button {
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { STANDALONE_SHARED_IMPORTS } from '../../../standalone-shared.module';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { RemittancesService } from '../../remittances.service';
|
||||
import {
|
||||
RemittanceTransaction,
|
||||
RemittanceVendor,
|
||||
PayoutAssignmentResponse,
|
||||
PayoutConfirmationResponse
|
||||
} from '../../models/remittance.model';
|
||||
import { ValidatedRecipient } from '../../models/beneficiary.model';
|
||||
import { AuthenticationService } from '../../../core/authentication/authentication.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-confirm-payment-step',
|
||||
standalone: true,
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatProgressSpinner,
|
||||
MatIcon,
|
||||
MatDivider
|
||||
],
|
||||
templateUrl: './confirm-payment-step.component.html',
|
||||
styleUrls: ['./confirm-payment-step.component.scss']
|
||||
})
|
||||
export class ConfirmPaymentStepComponent {
|
||||
@Input() vendor: RemittanceVendor | null = null;
|
||||
@Input() transactionId = '';
|
||||
@Input() remittanceData: RemittanceTransaction | null = null;
|
||||
@Input() recipientData: ValidatedRecipient | null = null;
|
||||
@Input() payoutAssignment: PayoutAssignmentResponse | null = null;
|
||||
@Input() clientId: number | null = null;
|
||||
@Output() payoutConfirmed = new EventEmitter<PayoutConfirmationResponse>();
|
||||
|
||||
private remittancesService = inject(RemittancesService);
|
||||
private authService = inject(AuthenticationService);
|
||||
private translateService = inject(TranslateService);
|
||||
|
||||
isLoading = false;
|
||||
errorMessage = '';
|
||||
|
||||
get officeName(): string {
|
||||
return this.authService.getCredentials()?.officeName || '';
|
||||
}
|
||||
|
||||
confirmPayout(): void {
|
||||
if (!this.vendor || !this.recipientData || !this.clientId) {
|
||||
this.errorMessage = this.translateService.instant('labels.text.Missing required information. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
this.remittancesService
|
||||
.confirmPayout(this.vendor.headerValue, this.transactionId, this.clientId, {
|
||||
additionalInfo: {
|
||||
comment: 'Payout confirmation from web-app'
|
||||
}
|
||||
})
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.isLoading = false;
|
||||
this.payoutConfirmed.emit(response);
|
||||
},
|
||||
error: (err) => {
|
||||
this.isLoading = false;
|
||||
this.errorMessage =
|
||||
err?.error?.message ||
|
||||
this.translateService.instant(
|
||||
'labels.text.Failed to confirm payment. Please try again or contact support.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="step-content">
|
||||
<div class="receipt-header" [ngClass]="{ success: isPaid, terminal: isTerminal }">
|
||||
<mat-icon class="receipt-icon">{{ receiptIcon }}</mat-icon>
|
||||
<h3>{{ receiptTitle }}</h3>
|
||||
</div>
|
||||
|
||||
@if (remittanceData) {
|
||||
<div class="receipt-body">
|
||||
<div class="receipt-section">
|
||||
<h4>{{ 'labels.heading.Transaction Details' | translate }}</h4>
|
||||
@if (officeName) {
|
||||
<div class="receipt-row">
|
||||
<span class="label">{{ 'labels.inputs.Office' | translate }}</span>
|
||||
<span class="value">{{ officeName }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="receipt-row">
|
||||
<span class="label">{{ 'labels.inputs.External ID' | translate }}</span>
|
||||
<span class="value">{{ remittanceData.pin }}</span>
|
||||
</div>
|
||||
<div class="receipt-row">
|
||||
<span class="label">{{ 'labels.inputs.Status' | translate }}</span>
|
||||
<span class="value status-text">{{ payoutConfirmation?.transaction?.status || remittanceData.status }}</span>
|
||||
</div>
|
||||
<div class="receipt-row">
|
||||
<span class="label">{{ 'labels.inputs.Payout Amount' | translate }}</span>
|
||||
<span class="value amount">{{
|
||||
remittanceData.receivingAmount | currency: remittanceData.receivingCurrency
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="receipt-row">
|
||||
<span class="label">{{ 'labels.inputs.Sender' | translate }}</span>
|
||||
<span class="value">{{ remittanceData.senderName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="receipt-section">
|
||||
<h4>{{ 'labels.heading.Recipient Details' | translate }}</h4>
|
||||
<div class="receipt-row">
|
||||
<span class="label">{{ 'labels.inputs.Name' | translate }}</span>
|
||||
@if (recipientData?.givenName) {
|
||||
<span class="value">{{ recipientData.givenName }} {{ recipientData.lastName }}</span>
|
||||
} @else {
|
||||
<span class="value">{{ remittanceData?.recipientName }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (recipientData?.primaryDocument?.documentType) {
|
||||
<div class="receipt-row">
|
||||
<span class="label">{{ 'labels.inputs.Document' | translate }}</span>
|
||||
<span class="value"
|
||||
>{{ recipientData.primaryDocument.documentType }} -
|
||||
{{ recipientData.primaryDocument.documentNumber }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (payoutConfirmation) {
|
||||
<div class="receipt-section">
|
||||
<h4>{{ 'labels.heading.Confirmation Details' | translate }}</h4>
|
||||
@if (payoutConfirmation?.transaction) {
|
||||
<div class="receipt-row">
|
||||
<span class="label">{{ 'labels.inputs.Status' | translate }}</span>
|
||||
<span class="value">{{ payoutConfirmation.transaction.status }}</span>
|
||||
</div>
|
||||
@if (payoutAssignment?.trackingNumber) {
|
||||
<div class="receipt-row">
|
||||
<span class="label">{{ 'labels.inputs.Reference Number' | translate }}</span>
|
||||
<span class="value mono">{{ payoutAssignment.trackingNumber }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="receipt-row">
|
||||
<span class="label">{{ 'labels.inputs.Payout Amount' | translate }}</span>
|
||||
<span class="value">{{
|
||||
payoutConfirmation.transaction.receivingAmount
|
||||
| currency: payoutConfirmation.transaction.receivingCurrency
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="step-actions">
|
||||
<button mat-raised-button (click)="printReceipt()">
|
||||
<mat-icon>print</mat-icon>
|
||||
{{ 'labels.buttons.Print Receipt' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="onNewRemittance()">
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'labels.buttons.New Remittance' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.step-content {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.receipt-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
|
||||
&.success {
|
||||
background-color: #e8f5e9;
|
||||
|
||||
.receipt-icon {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: #2e7d32;
|
||||
}
|
||||
}
|
||||
|
||||
&.terminal {
|
||||
background-color: #fff3e0;
|
||||
|
||||
.receipt-icon {
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: #e65100;
|
||||
}
|
||||
}
|
||||
|
||||
.receipt-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.receipt-body {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.receipt-section {
|
||||
padding: 16px;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 12px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.receipt-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
&.amount {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
&.status-text {
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
&.mono {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
background: #f5f5f5;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.step-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.receipt-header {
|
||||
border: none;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&.success {
|
||||
background-color: transparent;
|
||||
border-bottom: 2px solid #000;
|
||||
|
||||
.receipt-icon {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
&.terminal {
|
||||
background-color: transparent;
|
||||
border-bottom: 2px solid #000;
|
||||
|
||||
.receipt-icon {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.receipt-body {
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
.receipt-section {
|
||||
border-color: #000 !important;
|
||||
|
||||
h4 {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.receipt-row {
|
||||
.label,
|
||||
.value {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.value.amount {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.value.mono {
|
||||
background: transparent;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { STANDALONE_SHARED_IMPORTS } from '../../../standalone-shared.module';
|
||||
|
||||
import {
|
||||
RemittanceTransaction,
|
||||
PayoutAssignmentResponse,
|
||||
PayoutConfirmationResponse
|
||||
} from '../../models/remittance.model';
|
||||
import { ValidatedRecipient } from '../../models/beneficiary.model';
|
||||
import { RemittanceStatus, isTerminalStatus } from '../../models/remittance-status.enum';
|
||||
import { AuthenticationService } from '../../../core/authentication/authentication.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-payment-receipt-step',
|
||||
standalone: true,
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatIcon
|
||||
],
|
||||
templateUrl: './payment-receipt-step.component.html',
|
||||
styleUrls: ['./payment-receipt-step.component.scss']
|
||||
})
|
||||
export class PaymentReceiptStepComponent {
|
||||
private authService = inject(AuthenticationService);
|
||||
private translateService = inject(TranslateService);
|
||||
|
||||
@Input() remittanceData: RemittanceTransaction | null = null;
|
||||
@Input() recipientData: ValidatedRecipient | null = null;
|
||||
@Input() payoutConfirmation: PayoutConfirmationResponse | null = null;
|
||||
@Input() payoutAssignment: PayoutAssignmentResponse | null = null;
|
||||
@Input() parsedStatus: RemittanceStatus = RemittanceStatus.UNKNOWN;
|
||||
@Output() resetWorkflow = new EventEmitter<void>();
|
||||
|
||||
get officeName(): string {
|
||||
return this.authService.getCredentials()?.officeName || '';
|
||||
}
|
||||
|
||||
get isPaid(): boolean {
|
||||
return this.parsedStatus === RemittanceStatus.PAID;
|
||||
}
|
||||
|
||||
get isTerminal(): boolean {
|
||||
return isTerminalStatus(this.parsedStatus) && !this.isPaid;
|
||||
}
|
||||
|
||||
get receiptIcon(): string {
|
||||
if (this.isPaid) return 'check_circle';
|
||||
if (this.isTerminal) return 'cancel';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
get receiptTitle(): string {
|
||||
if (this.isPaid) return this.translateService.instant('labels.heading.Payment Successful');
|
||||
if (this.isTerminal)
|
||||
return this.translateService.instant('labels.heading.Transaction') + ' ' + (this.remittanceData?.status || '');
|
||||
return this.translateService.instant('labels.heading.Transaction Status');
|
||||
}
|
||||
|
||||
onNewRemittance(): void {
|
||||
this.resetWorkflow.emit();
|
||||
}
|
||||
|
||||
printReceipt(): void {
|
||||
window.print();
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
@if (remittanceData) {
|
||||
<div class="step-content">
|
||||
<div class="status-row">
|
||||
<span class="status-badge" [ngClass]="statusBadgeClass">{{ remittanceData.status }}</span>
|
||||
</div>
|
||||
|
||||
<div class="details-grid">
|
||||
@if (remittanceData.sendingPartner) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Sent By' | translate }}</span>
|
||||
<span class="value">{{ remittanceData.sendingPartner }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.External ID' | translate }}</span>
|
||||
<span class="value">{{ remittanceData.pin }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Created At' | translate }}</span>
|
||||
@if (isValidDate(remittanceData.created)) {
|
||||
<span class="value">{{ remittanceData.created | date: 'medium' }}</span>
|
||||
} @else {
|
||||
<span class="value">{{ remittanceData.created }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (remittanceData.processedAt && isValidDate(remittanceData.processedAt)) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Deposited At' | translate }}</span>
|
||||
<span class="value">{{ remittanceData.processedAt | date: 'medium' }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Sender Name' | translate }}</span>
|
||||
<span class="value">{{ remittanceData.senderName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Payout Amount' | translate }}</span>
|
||||
<span class="value">{{ remittanceData.receivingAmount | currency: remittanceData.receivingCurrency }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Recipient Name' | translate }}</span>
|
||||
<span class="value">{{ remittanceData.recipientName }}</span>
|
||||
</div>
|
||||
|
||||
@if (remittanceData.recipientAddress) {
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ 'labels.inputs.Address' | translate }}</span>
|
||||
<span class="value">{{ remittanceData.recipientAddress }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isPaid) {
|
||||
<div class="terminal-info paid-info">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<span>{{ 'labels.text.This remittance has already been paid successfully.' | translate }}</span>
|
||||
</div>
|
||||
} @else if (isTerminal) {
|
||||
<div class="terminal-warning">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{
|
||||
'labels.text.This remittance has a terminal status and cannot be processed further.' | translate
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="step-actions">
|
||||
<button mat-raised-button color="primary" (click)="onNext()">
|
||||
@if (isTerminal) {
|
||||
{{ 'labels.buttons.View Receipt' | translate }}
|
||||
} @else {
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
}
|
||||
<mat-icon>arrow_forward</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.step-content {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background-color: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.badge-ready {
|
||||
background-color: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.badge-paid {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.badge-terminal {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.badge-unknown {
|
||||
background-color: #f5f5f5;
|
||||
color: #616161;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 100%));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-warning,
|
||||
.terminal-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
margin: 16px 0;
|
||||
border-radius: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-warning {
|
||||
background-color: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.paid-info {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { STANDALONE_SHARED_IMPORTS } from '../../../standalone-shared.module';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
|
||||
import { RemittanceTransaction } from '../../models/remittance.model';
|
||||
import { RemittanceStatus, isTerminalStatus } from '../../models/remittance-status.enum';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-remittance-details-step',
|
||||
standalone: true,
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatIcon
|
||||
],
|
||||
templateUrl: './remittance-details-step.component.html',
|
||||
styleUrls: ['./remittance-details-step.component.scss']
|
||||
})
|
||||
export class RemittanceDetailsStepComponent {
|
||||
@Input() remittanceData: RemittanceTransaction | null = null;
|
||||
@Input() parsedStatus: RemittanceStatus = RemittanceStatus.UNKNOWN;
|
||||
@Output() detailsNext = new EventEmitter<void>();
|
||||
|
||||
get isTerminal(): boolean {
|
||||
return isTerminalStatus(this.parsedStatus);
|
||||
}
|
||||
|
||||
get isPaid(): boolean {
|
||||
return this.parsedStatus === RemittanceStatus.PAID;
|
||||
}
|
||||
|
||||
get statusBadgeClass(): string {
|
||||
switch (this.parsedStatus) {
|
||||
case RemittanceStatus.PENDING:
|
||||
return 'badge-pending';
|
||||
case RemittanceStatus.READY_FOR_PAYOUT:
|
||||
return 'badge-ready';
|
||||
case RemittanceStatus.PAID:
|
||||
return 'badge-paid';
|
||||
case RemittanceStatus.REJECTED:
|
||||
case RemittanceStatus.CANCELED:
|
||||
case RemittanceStatus.EXPIRED:
|
||||
return 'badge-terminal';
|
||||
default:
|
||||
return 'badge-unknown';
|
||||
}
|
||||
}
|
||||
|
||||
isValidDate(value: string | undefined): boolean {
|
||||
if (!value) return false;
|
||||
const timestamp = Date.parse(value);
|
||||
return !isNaN(timestamp);
|
||||
}
|
||||
|
||||
onNext(): void {
|
||||
this.detailsNext.emit();
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="step-content">
|
||||
<form [formGroup]="beneficiaryForm" (ngSubmit)="validateRecipient()">
|
||||
<div class="form-fields">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="givenName"
|
||||
placeholder="{{ 'labels.placeholders.Enter first name (include middle name)' | translate }}"
|
||||
/>
|
||||
@if (beneficiaryForm.get('givenName')?.hasError('required') && beneficiaryForm.get('givenName')?.touched) {
|
||||
<mat-error>{{ 'labels.inputs.First name is required' | translate }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="lastName"
|
||||
placeholder="{{ 'labels.placeholders.Enter last name' | translate }}"
|
||||
/>
|
||||
@if (beneficiaryForm.get('lastName')?.hasError('required') && beneficiaryForm.get('lastName')?.touched) {
|
||||
<mat-error>{{ 'labels.inputs.Last name is required' | translate }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ 'labels.inputs.Document Type' | translate }}</mat-label>
|
||||
<mat-select formControlName="documentType">
|
||||
@for (doc of documentTypes; track doc.value) {
|
||||
<mat-option [value]="doc.value">{{ doc.label | translate }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ 'labels.inputs.Document Number' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="documentNumber"
|
||||
placeholder="{{ 'labels.placeholders.Enter document number' | translate }}"
|
||||
/>
|
||||
@if (
|
||||
beneficiaryForm.get('documentNumber')?.hasError('required') && beneficiaryForm.get('documentNumber')?.touched
|
||||
) {
|
||||
<mat-error>{{ 'labels.inputs.Document number is required' | translate }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@if (errorMessage) {
|
||||
<div class="error-banner">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="step-actions">
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="beneficiaryForm.invalid || isLoading">
|
||||
@if (isLoading) {
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
} @else {
|
||||
<span><mat-icon>person_search</mat-icon> {{ 'labels.buttons.Validate Recipient' | translate }}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.step-content {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
margin: 8px 0;
|
||||
background-color: #fdecea;
|
||||
border-radius: 4px;
|
||||
color: #611a15;
|
||||
|
||||
mat-icon {
|
||||
color: #f44336;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
margin-top: 16px;
|
||||
|
||||
button {
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { STANDALONE_SHARED_IMPORTS } from '../../../standalone-shared.module';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { RemittancesService } from '../../remittances.service';
|
||||
import { RemittanceVendor } from '../../models/remittance.model';
|
||||
import { ValidatedRecipient } from '../../models/beneficiary.model';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-search-beneficiary-step',
|
||||
standalone: true,
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatProgressSpinner,
|
||||
MatIcon
|
||||
],
|
||||
templateUrl: './search-beneficiary-step.component.html',
|
||||
styleUrls: ['./search-beneficiary-step.component.scss']
|
||||
})
|
||||
export class SearchBeneficiaryStepComponent implements OnInit {
|
||||
@Input() vendor: RemittanceVendor | null = null;
|
||||
@Input() transactionId = '';
|
||||
@Output() recipientValidated = new EventEmitter<ValidatedRecipient>();
|
||||
|
||||
private fb = inject(FormBuilder);
|
||||
private remittancesService = inject(RemittancesService);
|
||||
private translateService = inject(TranslateService);
|
||||
|
||||
beneficiaryForm!: FormGroup;
|
||||
isLoading = false;
|
||||
errorMessage = '';
|
||||
|
||||
documentTypes = [
|
||||
{ value: 'NATIONAL_ID', label: 'labels.inputs.National ID' },
|
||||
{ value: 'PASSPORT', label: 'labels.inputs.Passport' },
|
||||
{ value: 'DRIVERS_LICENSE', label: "labels.inputs.Driver's License" },
|
||||
{ value: 'VOTER_ID', label: 'labels.inputs.Voter ID' }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.beneficiaryForm = this.fb.group({
|
||||
givenName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
lastName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
documentType: [
|
||||
'NATIONAL_ID',
|
||||
Validators.required
|
||||
],
|
||||
documentNumber: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.minLength(3)
|
||||
]
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
validateRecipient(): void {
|
||||
if (this.beneficiaryForm.invalid || !this.vendor) return;
|
||||
|
||||
this.isLoading = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
const { givenName, lastName, documentType, documentNumber } = this.beneficiaryForm.value;
|
||||
|
||||
this.remittancesService
|
||||
.validateRecipient(this.vendor.headerValue, this.transactionId, {
|
||||
givenName,
|
||||
lastName,
|
||||
primaryDocument: {
|
||||
documentType,
|
||||
documentNumber
|
||||
}
|
||||
})
|
||||
.subscribe({
|
||||
next: (recipient) => {
|
||||
this.isLoading = false;
|
||||
if (recipient && recipient.valid) {
|
||||
// Merge form data into recipient so details show in Confirm step
|
||||
const enrichedRecipient: ValidatedRecipient = {
|
||||
...recipient,
|
||||
firstName: givenName,
|
||||
givenName: givenName,
|
||||
lastName: lastName,
|
||||
primaryDocument: {
|
||||
documentType,
|
||||
documentNumber,
|
||||
issuingCountry: ''
|
||||
}
|
||||
};
|
||||
this.recipientValidated.emit(enrichedRecipient);
|
||||
} else {
|
||||
this.errorMessage = this.translateService.instant(
|
||||
'labels.text.Recipient is not valid for this transaction. Please check the details.'
|
||||
);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.isLoading = false;
|
||||
this.errorMessage =
|
||||
err?.error?.message ||
|
||||
this.translateService.instant(
|
||||
'labels.text.Recipient validation failed. Please check the document details and try again.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="step-content">
|
||||
<form [formGroup]="searchForm" (ngSubmit)="searchRemittance()">
|
||||
<div class="form-fields">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ 'labels.inputs.Vendor' | translate }}</mat-label>
|
||||
<mat-select formControlName="vendor" [disabled]="vendorLoadError">
|
||||
@for (v of vendors; track v.name) {
|
||||
<mat-option [value]="v.headerValue">{{ v.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (isLoadingVendors) {
|
||||
<mat-hint>{{ 'labels.inputs.Loading vendors...' | translate }}</mat-hint>
|
||||
}
|
||||
@if (vendorLoadError) {
|
||||
<mat-error>{{ 'labels.inputs.Failed to load vendors' | translate }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ 'labels.inputs.External Reference ID' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="externalId"
|
||||
placeholder="{{ 'labels.placeholders.Enter remittance reference number' | translate }}"
|
||||
/>
|
||||
@if (searchForm.get('externalId')?.hasError('required') && searchForm.get('externalId')?.touched) {
|
||||
<mat-error>{{ 'labels.inputs.Reference ID is required' | translate }}</mat-error>
|
||||
}
|
||||
@if (searchForm.get('externalId')?.hasError('minlength')) {
|
||||
<mat-error>{{ 'labels.inputs.Minimum 3 characters' | translate }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@if (vendorLoadError) {
|
||||
<div class="vendor-error-banner">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<span>{{ 'labels.text.Unable to load vendors. Please check your connection and try again.' | translate }}</span>
|
||||
<button mat-stroked-button color="primary" type="button" (click)="loadVendors()" [disabled]="isLoadingVendors">
|
||||
@if (isLoadingVendors) {
|
||||
<mat-spinner diameter="16"></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>refresh</mat-icon>
|
||||
<span>{{ 'labels.buttons.Retry' | translate }}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (errorMessage) {
|
||||
<div class="error-banner">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="step-actions">
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
(click)="searchRemittance()"
|
||||
[disabled]="isLoading || vendorLoadError"
|
||||
>
|
||||
@if (isLoading) {
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
} @else {
|
||||
<span><mat-icon>search</mat-icon> {{ 'labels.buttons.Search' | translate }}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.step-content {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
margin: 8px 0;
|
||||
background-color: #fdecea;
|
||||
border-radius: 4px;
|
||||
color: #611a15;
|
||||
|
||||
mat-icon {
|
||||
color: #f44336;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.vendor-error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
margin: 12px 0;
|
||||
background-color: #fff3e0;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #ff9800;
|
||||
color: #e65100;
|
||||
|
||||
mat-icon {
|
||||
color: #ff9800;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
margin-top: 16px;
|
||||
|
||||
button {
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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, EventEmitter, OnInit, Output, inject } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { STANDALONE_SHARED_IMPORTS } from '../../../standalone-shared.module';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { RemittancesService } from '../../remittances.service';
|
||||
import { RemittanceTransaction, RemittanceVendor } from '../../models/remittance.model';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-search-remittance-step',
|
||||
standalone: true,
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatProgressSpinner,
|
||||
MatIcon
|
||||
],
|
||||
templateUrl: './search-remittance-step.component.html',
|
||||
styleUrls: ['./search-remittance-step.component.scss']
|
||||
})
|
||||
export class SearchRemittanceStepComponent implements OnInit {
|
||||
@Output() remittanceFound = new EventEmitter<{
|
||||
transaction: RemittanceTransaction;
|
||||
transactionId: string;
|
||||
vendor: RemittanceVendor;
|
||||
}>();
|
||||
|
||||
private fb = inject(FormBuilder);
|
||||
private remittancesService = inject(RemittancesService);
|
||||
private translateService = inject(TranslateService);
|
||||
|
||||
searchForm!: FormGroup;
|
||||
vendors: RemittanceVendor[] = [];
|
||||
isLoading = false;
|
||||
isLoadingVendors = false;
|
||||
vendorLoadError = false;
|
||||
errorMessage = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.searchForm = this.fb.group({
|
||||
vendor: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
externalId: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.minLength(3)
|
||||
]
|
||||
]
|
||||
});
|
||||
|
||||
this.loadVendors();
|
||||
}
|
||||
|
||||
loadVendors(): void {
|
||||
this.isLoadingVendors = true;
|
||||
this.vendorLoadError = false;
|
||||
this.remittancesService.getVendors().subscribe({
|
||||
next: (response) => {
|
||||
this.vendors = response.vendors;
|
||||
this.isLoadingVendors = false;
|
||||
this.vendorLoadError = false;
|
||||
if (this.vendors.length === 1) {
|
||||
this.searchForm.patchValue({ vendor: this.vendors[0].headerValue });
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.vendorLoadError = true;
|
||||
this.isLoadingVendors = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
searchRemittance(): void {
|
||||
this.searchForm.markAllAsTouched();
|
||||
if (this.searchForm.invalid) return;
|
||||
|
||||
this.isLoading = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
const { vendor, externalId } = this.searchForm.value;
|
||||
const selectedVendor = this.vendors.find((v) => v.headerValue === vendor);
|
||||
|
||||
this.remittancesService.findRemittance(vendor, externalId).subscribe({
|
||||
next: (transaction) => {
|
||||
this.isLoading = false;
|
||||
this.remittanceFound.emit({
|
||||
transaction,
|
||||
transactionId: transaction.externalTransactionId || externalId,
|
||||
vendor: selectedVendor || { name: vendor, headerValue: vendor }
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
this.isLoading = false;
|
||||
this.errorMessage =
|
||||
err?.error?.message ||
|
||||
this.translateService.instant(
|
||||
'labels.text.Remittance not found. Please check the reference number and try again.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="step-content">
|
||||
<h3>{{ 'labels.heading.Transactional Profile' | translate }}</h3>
|
||||
<p class="description">
|
||||
{{
|
||||
'labels.text.Register the transactional profile for the beneficiary. Add entries for individuals or legal entities.'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
|
||||
<!-- Entity Type Toggle -->
|
||||
<div class="entity-type-toggle">
|
||||
<mat-button-toggle-group [(ngModel)]="entityType" aria-label="Entity type">
|
||||
<mat-button-toggle value="individual">
|
||||
<mat-icon>person</mat-icon>
|
||||
{{ 'labels.inputs.Individual' | translate }}
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle value="legal">
|
||||
<mat-icon>business</mat-icon>
|
||||
{{ 'labels.inputs.Legal Entity' | translate }}
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
|
||||
<!-- Transactional Profile Data Table -->
|
||||
<div class="table-section">
|
||||
@if (profileData.length > 0) {
|
||||
<table mat-table [dataSource]="profileData" class="profile-table">
|
||||
<!-- # Remittance Column -->
|
||||
<ng-container matColumnDef="remittanceNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.# Remittance' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.remittanceNumber }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Avg Amount/Transaction Column -->
|
||||
<ng-container matColumnDef="averageAmount">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Avg Amount/Transaction' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.averageAmount | currency: row.currency }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- # Senders Column -->
|
||||
<ng-container matColumnDef="senderCount">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.# Senders' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.senderCount }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Sender Registry Column -->
|
||||
<ng-container matColumnDef="senderRegistry">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Sender Registry' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.senderRegistry }}</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns; let i = index"
|
||||
(click)="removeProfileEntry(i)"
|
||||
matTooltip="{{ 'labels.text.Click to remove' | translate }}"
|
||||
class="clickable-row"
|
||||
></tr>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (profileData.length === 0) {
|
||||
<div class="empty-table">
|
||||
<mat-icon>table_chart</mat-icon>
|
||||
<p>{{ 'labels.text.No transactional profile entries yet. Add one below.' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Add Profile Entry Form -->
|
||||
<div class="add-entry-section">
|
||||
<h4>{{ 'labels.heading.Add Profile Entry' | translate }}</h4>
|
||||
<form [formGroup]="profileForm" class="entry-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ 'labels.inputs.# Remittance' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="remittanceNumber"
|
||||
placeholder="{{ 'labels.placeholders.# Remittance' | translate }}"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ 'labels.inputs.Avg Amount/Transaction' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="averageAmount" placeholder="0.00" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ 'labels.inputs.# Senders' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="senderCount" placeholder="0" min="0" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ 'labels.inputs.Sender Registry' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="senderRegistry"
|
||||
placeholder="{{ 'labels.placeholders.Sender name' | translate }}"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
type="button"
|
||||
(click)="addProfileEntry()"
|
||||
[disabled]="profileForm.invalid"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'labels.buttons.Add Entry' | translate }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if (errorMessage) {
|
||||
<div class="error-banner">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="step-actions">
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="assignPayout()"
|
||||
[disabled]="isLoading || !vendor || !recipientData || profileData.length === 0"
|
||||
>
|
||||
@if (isLoading) {
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
} @else {
|
||||
<span><mat-icon>assignment_turned_in</mat-icon> {{ 'labels.buttons.Assign Payout' | translate }}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.step-content {
|
||||
padding: 16px 0;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0 0 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.entity-type-toggle {
|
||||
margin-bottom: 20px;
|
||||
|
||||
mat-button-toggle-group {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.table-section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.profile-table {
|
||||
width: 100%;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
th.mat-mdc-header-cell {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
td.mat-mdc-cell {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff3f3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
background-color: #fafafa;
|
||||
border: 1px dashed #e0e0e0;
|
||||
border-radius: 8px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-entry-section {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 12px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.entry-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
|
||||
mat-form-field {
|
||||
flex: 1 1 180px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-bottom: 3.5em;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
margin: 8px 0;
|
||||
background-color: #fdecea;
|
||||
border-radius: 4px;
|
||||
color: #611a15;
|
||||
|
||||
mat-icon {
|
||||
color: #f44336;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
margin-top: 16px;
|
||||
|
||||
button {
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 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, EventEmitter, Input, Output, inject, OnInit } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators, FormsModule } from '@angular/forms';
|
||||
import { STANDALONE_SHARED_IMPORTS } from '../../../standalone-shared.module';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { RemittancesService } from '../../remittances.service';
|
||||
import { RemittanceTransaction, RemittanceVendor, PayoutAssignmentResponse } from '../../models/remittance.model';
|
||||
import { ValidatedRecipient } from '../../models/beneficiary.model';
|
||||
|
||||
/**
|
||||
* Represents a single row in the Transactional Profile data table.
|
||||
* As required by the spec: # Remittance, Avg Amount/Transaction, # Senders, Sender Registry.
|
||||
*/
|
||||
export interface TransactionalProfileRow {
|
||||
remittanceNumber: string;
|
||||
averageAmount: number;
|
||||
currency: string;
|
||||
senderCount: number;
|
||||
senderRegistry: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-transactional-profile-step',
|
||||
standalone: true,
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatProgressSpinner,
|
||||
MatIcon,
|
||||
MatTableModule,
|
||||
MatButtonToggleModule,
|
||||
FormsModule
|
||||
],
|
||||
templateUrl: './transactional-profile-step.component.html',
|
||||
styleUrls: ['./transactional-profile-step.component.scss']
|
||||
})
|
||||
export class TransactionalProfileStepComponent implements OnInit {
|
||||
@Input() vendor: RemittanceVendor | null = null;
|
||||
@Input() transactionId = '';
|
||||
@Input() remittanceData: RemittanceTransaction | null = null;
|
||||
@Input() recipientData: ValidatedRecipient | null = null;
|
||||
@Output() payoutAssigned = new EventEmitter<PayoutAssignmentResponse>();
|
||||
|
||||
private remittancesService = inject(RemittancesService);
|
||||
private fb = inject(FormBuilder);
|
||||
private translateService = inject(TranslateService);
|
||||
|
||||
isLoading = false;
|
||||
errorMessage = '';
|
||||
|
||||
/** Transactional profile data table */
|
||||
displayedColumns: string[] = [
|
||||
'remittanceNumber',
|
||||
'averageAmount',
|
||||
'senderCount',
|
||||
'senderRegistry'
|
||||
];
|
||||
profileData: TransactionalProfileRow[] = [];
|
||||
|
||||
/** Form for adding new profile entries */
|
||||
profileForm!: FormGroup;
|
||||
|
||||
/** Entity type toggle: 'individual' or 'legal' */
|
||||
entityType: 'individual' | 'legal' = 'individual';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.profileForm = this.fb.group({
|
||||
remittanceNumber: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
averageAmount: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0)
|
||||
]
|
||||
],
|
||||
senderCount: [
|
||||
0,
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0)
|
||||
]
|
||||
],
|
||||
senderRegistry: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
|
||||
// Pre-populate from the current transaction if available
|
||||
if (this.remittanceData) {
|
||||
this.profileData = [
|
||||
{
|
||||
remittanceNumber: this.remittanceData.pin,
|
||||
averageAmount: this.remittanceData.receivingAmount,
|
||||
currency: this.remittanceData.receivingCurrency,
|
||||
senderCount: 1,
|
||||
senderRegistry: this.remittanceData.senderName
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
addProfileEntry(): void {
|
||||
if (this.profileForm.invalid) return;
|
||||
const val = this.profileForm.value;
|
||||
this.profileData = [
|
||||
...this.profileData,
|
||||
{
|
||||
remittanceNumber: val.remittanceNumber,
|
||||
averageAmount: val.averageAmount,
|
||||
currency: this.remittanceData?.receivingCurrency || 'USD',
|
||||
senderCount: Math.max(1, val.senderCount || 1),
|
||||
senderRegistry: val.senderRegistry
|
||||
}
|
||||
];
|
||||
this.profileForm.reset({
|
||||
senderCount: 1
|
||||
});
|
||||
}
|
||||
|
||||
removeProfileEntry(index: number): void {
|
||||
this.profileData = this.profileData.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
assignPayout(): void {
|
||||
if (!this.vendor || !this.recipientData) return;
|
||||
|
||||
this.isLoading = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
this.remittancesService
|
||||
.assignPayout(this.vendor.headerValue, this.transactionId, {
|
||||
additionalInfo: {
|
||||
comment: 'Payout assignment from web-app',
|
||||
transactionalProfile: JSON.stringify(this.profileData),
|
||||
entityType: this.entityType,
|
||||
profileEntryCount: this.profileData.length.toString()
|
||||
}
|
||||
})
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.isLoading = false;
|
||||
this.payoutAssigned.emit(response);
|
||||
},
|
||||
error: (err) => {
|
||||
this.isLoading = false;
|
||||
this.errorMessage =
|
||||
err?.error?.message ||
|
||||
this.translateService.instant('labels.text.Failed to assign payout. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user