From b50b569d6904d4f414f7e79a3fe6261295175e4d Mon Sep 17 00:00:00 2001 From: Karan Takalkar Date: Fri, 31 Jul 2020 13:06:29 +0530 Subject: [PATCH] feat: add XBRL report page (#629) Fixes: #628 --- .../core/shell/toolbar/toolbar.component.html | 2 +- ...ate-recurring-deposit-product.component.ts | 2 +- .../common-resolvers/mixmappings.resolver.ts | 30 ++++ .../common-resolvers/mixtaxonomy.resolver.ts | 30 ++++ .../reports.resolver.ts | 2 +- .../run-report.resolver.ts | 0 src/app/reports/reports-routing.module.ts | 34 +++- src/app/reports/reports.module.ts | 6 +- src/app/reports/reports.service.ts | 35 +++- .../xbrl-report/xbrl-report.component.html | 79 +++++++++ .../xbrl-report/xbrl-report.component.scss | 3 + .../xbrl-report/xbrl-report.component.spec.ts | 25 +++ .../xbrl-report/xbrl-report.component.ts | 125 ++++++++++++++ src/app/reports/xbrl/xbrl.component.html | 59 +++++++ src/app/reports/xbrl/xbrl.component.scss | 3 + src/app/reports/xbrl/xbrl.component.spec.ts | 25 +++ src/app/reports/xbrl/xbrl.component.ts | 158 ++++++++++++++++++ 17 files changed, 610 insertions(+), 8 deletions(-) create mode 100644 src/app/reports/common-resolvers/mixmappings.resolver.ts create mode 100644 src/app/reports/common-resolvers/mixtaxonomy.resolver.ts rename src/app/reports/{ => common-resolvers}/reports.resolver.ts (91%) rename src/app/reports/{run-report => common-resolvers}/run-report.resolver.ts (100%) create mode 100644 src/app/reports/xbrl-report/xbrl-report.component.html create mode 100644 src/app/reports/xbrl-report/xbrl-report.component.scss create mode 100644 src/app/reports/xbrl-report/xbrl-report.component.spec.ts create mode 100644 src/app/reports/xbrl-report/xbrl-report.component.ts create mode 100644 src/app/reports/xbrl/xbrl.component.html create mode 100644 src/app/reports/xbrl/xbrl.component.scss create mode 100644 src/app/reports/xbrl/xbrl.component.spec.ts create mode 100644 src/app/reports/xbrl/xbrl.component.ts diff --git a/src/app/core/shell/toolbar/toolbar.component.html b/src/app/core/shell/toolbar/toolbar.component.html index 355607b0d..2a733e8cc 100644 --- a/src/app/core/shell/toolbar/toolbar.component.html +++ b/src/app/core/shell/toolbar/toolbar.component.html @@ -96,7 +96,7 @@ - + diff --git a/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.ts b/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.ts index daf8e0b37..95a0547df 100644 --- a/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.ts +++ b/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.ts @@ -89,7 +89,7 @@ export class CreateRecurringDepositProductComponent implements OnInit { submit() { // TODO: Update once language and date settings are setup - let recurringDepositProduct = { + const recurringDepositProduct = { ...this.recurringDepositProduct, charges: this.recurringDepositProduct.charges.map((charge: any) => ({ id: charge.id })), locale: 'en' // locale required for depositAmount diff --git a/src/app/reports/common-resolvers/mixmappings.resolver.ts b/src/app/reports/common-resolvers/mixmappings.resolver.ts new file mode 100644 index 000000000..9de7110db --- /dev/null +++ b/src/app/reports/common-resolvers/mixmappings.resolver.ts @@ -0,0 +1,30 @@ +/** Angular Imports */ +import { Injectable } from '@angular/core'; +import { Resolve } from '@angular/router'; + +/** rxjs Imports */ +import { Observable } from 'rxjs'; + +/** Custom Services */ +import { ReportsService } from '../reports.service'; + +/** + * Mix Mappings data resolver. + */ +@Injectable() +export class MixMappingsResolver implements Resolve { + + /** + * @param {ReportsService} reportsService Reports service. + */ + constructor(private reportsService: ReportsService) {} + + /** + * Returns Mix Mappings. + * @returns {Observable} + */ + resolve(): Observable { + return this.reportsService.getMixMappings(); + } + +} diff --git a/src/app/reports/common-resolvers/mixtaxonomy.resolver.ts b/src/app/reports/common-resolvers/mixtaxonomy.resolver.ts new file mode 100644 index 000000000..967b2c100 --- /dev/null +++ b/src/app/reports/common-resolvers/mixtaxonomy.resolver.ts @@ -0,0 +1,30 @@ +/** Angular Imports */ +import { Injectable } from '@angular/core'; +import { Resolve } from '@angular/router'; + +/** rxjs Imports */ +import { Observable } from 'rxjs'; + +/** Custom Services */ +import { ReportsService } from '../reports.service'; + +/** + * Mix Taxononmy data resolver. + */ +@Injectable() +export class MixTaxonomyResolver implements Resolve { + + /** + * @param {ReportsService} reportsService Reports service. + */ + constructor(private reportsService: ReportsService) {} + + /** + * Returns the Mix Taxonomy data. + * @returns {Observable} + */ + resolve(): Observable { + return this.reportsService.getMixTaxonomyArray(); + } + +} diff --git a/src/app/reports/reports.resolver.ts b/src/app/reports/common-resolvers/reports.resolver.ts similarity index 91% rename from src/app/reports/reports.resolver.ts rename to src/app/reports/common-resolvers/reports.resolver.ts index 05cf70c27..91bc28f99 100644 --- a/src/app/reports/reports.resolver.ts +++ b/src/app/reports/common-resolvers/reports.resolver.ts @@ -6,7 +6,7 @@ import { Resolve } from '@angular/router'; import { Observable } from 'rxjs'; /** Custom Services */ -import { ReportsService } from './reports.service'; +import { ReportsService } from '../reports.service'; /** * Reports data resolver. diff --git a/src/app/reports/run-report/run-report.resolver.ts b/src/app/reports/common-resolvers/run-report.resolver.ts similarity index 100% rename from src/app/reports/run-report/run-report.resolver.ts rename to src/app/reports/common-resolvers/run-report.resolver.ts diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts index eab3d3994..a0ed6357a 100644 --- a/src/app/reports/reports-routing.module.ts +++ b/src/app/reports/reports-routing.module.ts @@ -11,10 +11,15 @@ import { extract } from '../core/i18n/i18n.service'; /** Custom Components */ import { ReportsComponent } from './reports.component'; import { RunReportComponent } from './run-report/run-report.component'; +import { XBRLComponent } from './xbrl/xbrl.component'; +import { XBRLReportComponent } from './xbrl-report/xbrl-report.component'; /** Custom Resolvers */ -import { ReportsResolver } from './reports.resolver'; -import { RunReportResolver } from './run-report/run-report.resolver'; +import { ReportsResolver } from './common-resolvers/reports.resolver'; +import { RunReportResolver } from './common-resolvers/run-report.resolver'; +import { MixTaxonomyResolver } from './common-resolvers/mixtaxonomy.resolver'; +import { MixMappingsResolver } from './common-resolvers/mixmappings.resolver'; +import { GlAccountsResolver } from '../accounting/common-resolvers/gl-accounts.resolver'; /** Reports Routes */ const routes: Routes = [ @@ -59,6 +64,26 @@ const routes: Routes = [ ] } ] + }, + { + path: 'xbrl', + data: { title: extract('XBRL'), breadcrumb: 'XBRL' }, + children: [ + { + path: '', + component: XBRLComponent, + resolve: { + mixtaxonomy: MixTaxonomyResolver, + mixmapping: MixMappingsResolver, + glAccounts: GlAccountsResolver + } + }, + { + path: 'report', + data: { title: extract('XBRL Report'), breadcrumb: 'Run Report' }, + component: XBRLReportComponent + } + ] } ]) ]; @@ -73,7 +98,10 @@ const routes: Routes = [ exports: [RouterModule], providers: [ ReportsResolver, - RunReportResolver + RunReportResolver, + MixTaxonomyResolver, + MixMappingsResolver, + GlAccountsResolver ] }) export class ReportsRoutingModule { } diff --git a/src/app/reports/reports.module.ts b/src/app/reports/reports.module.ts index c1e649778..836f3f900 100644 --- a/src/app/reports/reports.module.ts +++ b/src/app/reports/reports.module.ts @@ -12,6 +12,8 @@ import { RunReportComponent } from './run-report/run-report.component'; import { TableAndSmsComponent } from './run-report/table-and-sms/table-and-sms.component'; import { ChartComponent } from './run-report/chart/chart.component'; import { PentahoComponent } from './run-report/pentaho/pentaho.component'; +import { XBRLComponent } from './xbrl/xbrl.component'; +import { XBRLReportComponent } from './xbrl-report/xbrl-report.component'; /** * Reports Module @@ -28,7 +30,9 @@ import { PentahoComponent } from './run-report/pentaho/pentaho.component'; RunReportComponent, TableAndSmsComponent, ChartComponent, - PentahoComponent + PentahoComponent, + XBRLComponent, + XBRLReportComponent ], providers: [ DecimalPipe ] }) diff --git a/src/app/reports/reports.service.ts b/src/app/reports/reports.service.ts index 4a80b17a4..f837ac5a1 100644 --- a/src/app/reports/reports.service.ts +++ b/src/app/reports/reports.service.ts @@ -28,7 +28,40 @@ export class ReportsService { * @returns {Observable} Reports data */ getReports(): Observable { - return this.http.get('/reports'); + return this.http.get('/reports'); + } + + /** + * @returns {Observable} Mix Taxonomy data + */ + getMixTaxonomyArray(): Observable { + return this.http.get('/mixtaxonomy'); + } + + /** + * @returns {Observable} Mix Taxonomy data + */ + getMixMappings(): Observable { + return this.http.get('/mixmapping'); + } + + /** + * @param {any} mixMappingData Mix Mapping data to be posted + * @returns {Observable} response code + */ + editMixMappings(mixMappingData: any): Observable { + return this.http.put('/mixmapping', mixMappingData); + } + + /** + * @param {any} dates start date and end date + * @returns {Observable} Mix Report + */ + getMixReport(dates: any) { + const httpParams = new HttpParams() + .set('startDate', dates.startDate) + .set('endDate', dates.endDate); + return this.http.get('/mixreport', { params: httpParams, responseType: 'text' }); } /** diff --git a/src/app/reports/xbrl-report/xbrl-report.component.html b/src/app/reports/xbrl-report/xbrl-report.component.html new file mode 100644 index 000000000..e59125996 --- /dev/null +++ b/src/app/reports/xbrl-report/xbrl-report.component.html @@ -0,0 +1,79 @@ +
+ + + +
+ + + + + Start Date + + + + + + + End Date + + + + + + + + + + + + +
+ +
+ +
+ +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
Name {{ entry.name }} Dimension {{ entry.dimension }} Value
+ +
+ + + + +
+ +
+ +
+ +
diff --git a/src/app/reports/xbrl-report/xbrl-report.component.scss b/src/app/reports/xbrl-report/xbrl-report.component.scss new file mode 100644 index 000000000..5a72ab375 --- /dev/null +++ b/src/app/reports/xbrl-report/xbrl-report.component.scss @@ -0,0 +1,3 @@ +.form { + max-width: 37rem; +} diff --git a/src/app/reports/xbrl-report/xbrl-report.component.spec.ts b/src/app/reports/xbrl-report/xbrl-report.component.spec.ts new file mode 100644 index 000000000..4838c85dc --- /dev/null +++ b/src/app/reports/xbrl-report/xbrl-report.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { XBRLReportComponent } from './xbrl-report.component'; + +describe('XBRLReportComponent', () => { + let component: XBRLReportComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ XBRLReportComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(XBRLReportComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/reports/xbrl-report/xbrl-report.component.ts b/src/app/reports/xbrl-report/xbrl-report.component.ts new file mode 100644 index 000000000..631d51bac --- /dev/null +++ b/src/app/reports/xbrl-report/xbrl-report.component.ts @@ -0,0 +1,125 @@ +/** Angular Imports */ +import { Component, OnInit } from '@angular/core'; +import { FormGroup, FormBuilder, Validators, FormControl} from '@angular/forms'; +import { MatTableDataSource } from '@angular/material'; +import { DatePipe } from '@angular/common'; +import { DomSanitizer } from '@angular/platform-browser'; + +/** Custom Services */ +import { ReportsService } from '../reports.service'; + +/** + * XBRL Report Component + */ +@Component({ + selector: 'mifosx-xbrl-report', + templateUrl: './xbrl-report.component.html', + styleUrls: ['./xbrl-report.component.scss'] +}) +export class XBRLReportComponent implements OnInit { + + /** Minimum Due Date allowed. */ + minDate = new Date(2000, 0, 1); + /** Maximum Due Date allowed. */ + maxDate = new Date(); + /** XBRL Form */ + xbrlForm: FormGroup; + /** XML response from API */ + rawXml: any; + /** Parsed xml data */ + xmlData: any[] = []; + /** Collapsing Boolean */ + isCollapsed = true; + /** Trusted url resource for xml download */ + fileUrl: any; + /** Columns to be displayed in XBRL reports table */ + displayedColumns: string[] = ['name', 'dimension', 'value']; + /** Data source for XBRL reports table */ + dataSource = new MatTableDataSource(); + + /** + * @param {FormBuilder} formBuilder Form Builder. + * @param {ActivatedRoute} route Activated Route. + * @param {Router} router Router for navigation. + */ + constructor(private reportService: ReportsService, + private formBuilder: FormBuilder, + private datePipe: DatePipe, + private sanitizer: DomSanitizer) {} + + /** + * Creates the XBRL form. + */ + ngOnInit() { + this.createXbrlForm(); + } + + createXbrlForm() { + this.xbrlForm = this.formBuilder.group({ + 'startDate': ['', Validators.required], + 'endDate': ['', Validators.required], + }); + } + + /** + * Parses XML for mat-table data + * @param {any} response XML API response + */ + buildXMLTable(response: any) { + const parser = new DOMParser(); + const xml = parser.parseFromString(response, 'text/xml'); + xml.querySelectorAll('[contextRef]').forEach( (element: HTMLElement) => { + const contextId = element.getAttribute('contextRef'); + const scenario = xml.querySelector(`#${contextId}`).querySelector('scenario'); + const context = scenario ? scenario.textContent : undefined; + const entry = { + name: element.tagName, + dimension: context, + value: new FormControl(element.textContent) + }; + this.xmlData.push(entry); + }); + } + + /** + * Fetches XML Response, parses it and initializes mat-table with parsed data. + */ + runreport() { + const startDate: Date = this.xbrlForm.value.startDate; + const endDate: Date = this.xbrlForm.value.endDate; + // TODO: Update once language and date settings are setup + const dateFormat = 'yyyy-MM-dd'; + this.xbrlForm.patchValue({ + startDate: this.datePipe.transform(startDate, dateFormat), + endDate: this.datePipe.transform(endDate, dateFormat) + }); + const dates = this.xbrlForm.value; + this.reportService.getMixReport(dates).subscribe((response: any) => { + this.rawXml = response; + this.buildXMLTable(response); + this.dataSource = new MatTableDataSource(this.xmlData); + this.isCollapsed = false; + }); + } + + /** + * Outputs XML File with desired changes to entry values. + */ + submit() { + const parser = new DOMParser(); + const xmlDOM = parser.parseFromString(this.rawXml, 'text/xml'); + xmlDOM.querySelectorAll('[contextRef]').forEach( (element: HTMLElement) => { + this.xmlData.forEach( (entry: any) => { + if (entry.name === element.tagName) { + element.textContent = entry.value.value; + } + }); + }); + const serializer = new XMLSerializer(); + const xml = serializer.serializeToString(xmlDOM); + const blob = new Blob([xml], { type: 'application/octet-stream' }); + this.fileUrl = this.sanitizer.bypassSecurityTrustResourceUrl(window.URL.createObjectURL(blob)); + return this.fileUrl; + } + +} diff --git a/src/app/reports/xbrl/xbrl.component.html b/src/app/reports/xbrl/xbrl.component.html new file mode 100644 index 000000000..8271be053 --- /dev/null +++ b/src/app/reports/xbrl/xbrl.component.html @@ -0,0 +1,59 @@ + + +
+

Saved Successfully

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + {{ mixtaxonomy.name }} {{ mixtaxonomy.dimension }}
+ +
+ + +
+ + + + {{ glAccount.name + ' (' + glAccount.glCode + ')' }} + + + +
+ +
diff --git a/src/app/reports/xbrl/xbrl.component.scss b/src/app/reports/xbrl/xbrl.component.scss new file mode 100644 index 000000000..946be772f --- /dev/null +++ b/src/app/reports/xbrl/xbrl.component.scss @@ -0,0 +1,3 @@ +.mat-header-cell, .mat-cell { + min-width: 50px; +} diff --git a/src/app/reports/xbrl/xbrl.component.spec.ts b/src/app/reports/xbrl/xbrl.component.spec.ts new file mode 100644 index 000000000..9ef206092 --- /dev/null +++ b/src/app/reports/xbrl/xbrl.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { XBRLComponent } from './xbrl.component'; + +describe('XBRLComponent', () => { + let component: XBRLComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ XBRLComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(XBRLComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/reports/xbrl/xbrl.component.ts b/src/app/reports/xbrl/xbrl.component.ts new file mode 100644 index 000000000..e1dbde9bd --- /dev/null +++ b/src/app/reports/xbrl/xbrl.component.ts @@ -0,0 +1,158 @@ +/** Angular Imports */ +import { Component, OnInit, AfterViewInit } from '@angular/core'; +import { MatTableDataSource } from '@angular/material'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormControl } from '@angular/forms'; + +/** Custom Services */ +import { ReportsService } from '../reports.service'; + +/** + * XBRL Component + */ +@Component({ + selector: 'mifosx-xbrl', + templateUrl: './xbrl.component.html', + styleUrls: ['./xbrl.component.scss'] +}) +export class XBRLComponent implements OnInit, AfterViewInit { + + /** Mix Taxonomy Array */ + mixtaxonomyArray: any = []; + /** Mix Mappings */ + mixMappingJson: any; + /** GL Accounts data */ + glAccounts: any; + /** Filtered GL Accounts */ + filteredGlAccounts: any; + /** Columns to be displayed in reports table. */ + displayedColumns: string[] = ['info', 'name', 'dimension', 'mapping']; + /** Data source for reports table. */ + dataSource = new MatTableDataSource(); + /** XBRL save status */ + XBRLSuccess = false; + + /** + * Retrieves data from `resolve`. + * @param {ActivatedRoute} route Activated Route. + * Prevents reuse of Route Params while navigating. + * @param {Router} router: Route. + * @param {ReportService} reportService ReportService + */ + constructor(private route: ActivatedRoute, + private router: Router, + private reportService: ReportsService) { + this.route.data.subscribe(( data: { mixtaxonomy: any, mixmapping: any, glAccounts: any }) => { + this.mixtaxonomyArray = data.mixtaxonomy; + this.mixMappingJson = data.mixmapping.config; + this.glAccounts = data.glAccounts; + }); + } + + /** + * Creates Mappings, sets the dataSource and datasource filterPredicate. + */ + ngOnInit() { + if (localStorage.getItem('XbrlReportSaveSuccess')) { + localStorage.removeItem('XbrlReportSaveSuccess'); + this.XBRLSuccess = true; + setTimeout(() => { this.XBRLSuccess = false; }, 3000); + } + this.createMappings(); + this.dataSource = new MatTableDataSource(this.mixtaxonomyArray); + this.setDatasourceFilter(); + } + + /** + * Subscribe to taxonomy's value changes and filters options accordingly. + */ + ngAfterViewInit() { + this.mixtaxonomyArray.forEach( (taxonomy: any) => { + taxonomy.mapping.valueChanges.subscribe( (value: string) => { + this.filteredGlAccounts = this.applyFilter(value); + }); + }); + } + + /** + * Maps Taxonomy to it's value and add's formcontrol. + */ + createMappings() { + if (this.mixMappingJson !== undefined && this.mixMappingJson.length > 0) { + this.mixtaxonomyArray.forEach( (taxonomy: any) => { + const mapping = JSON.parse(this.mixMappingJson)[taxonomy.id]; + taxonomy.mapping = new FormControl(''); + taxonomy.mapping.value = mapping; + }); + } + } + + /** + * Sets custom datasource filter. + */ + setDatasourceFilter() { + this.dataSource.filterPredicate = (data: any, filter: string) => { + return data.type.toString() === filter; + }; + this.dataSource.filter = '0'; // defaults to 'Portfolio' + } + + /** + * Filter for GL Account AutoComplete. + * @param {string} value Filter Value. + */ + applyFilter(value: string) { + const filterValue = value.toLowerCase(); + return this.glAccounts.filter( + (entry: any) => entry.name.toLowerCase().includes(filterValue) || entry.glCode.includes(filterValue) + ); + } + + /** + * Filters Taxonomies by index. + * @param {number} index number representing type of taxonomy. + */ + filterTaxonomiesBy(index: number) { + this.dataSource.filter = index.toString(); + } + + /** + * Displays gl account name in form control input. + * @param {any} glAccount Gl Account data. + * @returns {string} Gl Account name if valid otherwise undefined. + */ + displayGLAccount(glAccount?: any): string | undefined { + if (typeof(glAccount) === 'object') { + return glAccount ? glAccount.name + ' (' + glAccount.glCode + ')' : undefined; + } else { + return glAccount; + } + } + + /** + * Reloads the component post saving changes. + */ + reloadComponent() { + this.router.routeReuseStrategy.shouldReuseRoute = () => false; + this.router.onSameUrlNavigation = 'reload'; + this.router.navigate(['./xbrl']); + } + + /** + * Posts Taxonomy Data onto the API. + */ + submit() { + const config: any = {}; + const serialObject: any = {}; + this.mixtaxonomyArray.forEach( (taxonomy: any) => { + config[taxonomy.id] = taxonomy.mapping.value; + }); + serialObject['config'] = JSON.stringify(config); + serialObject['identifier'] = 'default;'; + this.reportService.editMixMappings(serialObject).subscribe((response: any) => { + localStorage.setItem('XbrlReportSaveSuccess', 'true'); + this.reloadComponent(); + }); + } + +}