import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { CurrencyService } from '@core/services/currency.service'; import { PolicyService } from '@core/services/policy.service'; import { StatusService } from '@core/services/status.service'; import { BudgetDashboard, BudgetDashboardFundingSource, BudgetDashboardProgram, FundingSourceTypes, PaymentTableType, TopLevelStats } from '@core/typings/budget.typing'; import { PaymentForProcess } from '@core/typings/payment.typing'; import { PaymentStatus } from '@core/typings/status.typing'; import { ApplicantManagerService } from '@features/applicant/applicant-manager.service'; import { BudgetResources } from '@features/budgets/budget.resources'; import { BudgetService } from '@features/budgets/budget.service'; import { ClientSettingsService } from '@features/client-settings/client-settings.service'; import { NonprofitService } from '@features/nonprofit/nonprofit.service'; import { ProgramService } from '@features/programs/program.service'; import { SystemTagsService } from '@features/system-tags/system-tags.service'; import { SystemTags } from '@features/system-tags/typings/system-tags.typing'; import { ALL_SKIP_FILTER, ArrayHelpersService, AutoTableRepository, AutoTableRepositoryFactory, ChartService, DashboardTableData, DebounceFactory, PaginationOptions, PanelTypes, TableDataFactory, TopLevelFilter, TopLevelFilterOptionsConfig, TypeaheadSelectOption, ValueComparisonService } from '@yourcause/common'; import { I18nService } from '@yourcause/common/i18n'; import { NotifierService } from '@yourcause/common/notifier'; import { ChartData, ChartOptions, ChartType, TooltipItem } from 'chart.js'; import { uniqBy } from 'lodash'; import { from as observableFrom, map } from 'rxjs'; @Component({ selector: 'gc-budget-dashboard', templateUrl: './budget-dashboard.component.html', styleUrls: ['./budget-dashboard.component.scss'] }) export class BudgetDashboardComponent implements OnInit { budget: BudgetDashboard; topLevelStats: TopLevelStats; PaymentTableType = PaymentTableType; id: number; FundingSourceTypes = FundingSourceTypes; PanelTypes = PanelTypes; branding = this.clientSettingsService.get('clientBranding'); permission = this.policyService.insights; canManage = this.policyService.grantApplication.canAccessApplicationManager(); canAccessPrograms = this.canManage && this.permission.canViewPrograms(); colors = [ this.branding.brandPrimary, this.branding.brandSecondary, this.branding.brandUtility ]; baseChart: ChartOptions = { responsive: true, plugins: { legend: { display: false } } }; defaultCurrency = this.clientSettingsService.defaultCurrency; clientDefaultTz = this.clientSettingsService.clientSettings.defaultTimezone || 'UTC'; totalRemaining = this.i18n.translate( 'BUDGET:textTotalRemaining', {}, 'Total remaining' ); totalUnavailable = this.i18n.translate( 'GLOBAL:textTotalUnavailable', {}, 'Total unavailable' ); totalAllocatedFooterText: string; totalSpentFooterText: string; amountReservedText: string; isUnits = false; // Programs // programRepo: AutoTableRepository; programLabels: string[] = []; programData: number[] = []; programColors: string[] = []; programTableData: DashboardTableData[]; programChartOptions = { ...this.baseChart, tooltips: { callbacks: { label: () => { return this.i18n.translate( 'BUDGET:textTotalPaymentAmount2', {}, 'Total payment amount' ) + ': '; }, beforeLabel: (tooltipItem: TooltipItem, data: ChartData) => { return this.chartService.getLabelForTooltip( tooltipItem, data ); }, afterLabel: (tooltipItem: TooltipItem, data: ChartData) => { const amount = this.chartService.getAmountForTooltip( tooltipItem, data ); return this.currencyService.formatMoney( amount ); } } } } as ChartOptions; // Funding Sources // fundingSourceRepo: AutoTableRepository; fundingSourceLabels: string[] = []; fundingSourceData: number[] = []; fundingSourceColors: string[] = []; fundingSourceTableData: DashboardTableData[]; fundingSourceChartOptions = { ...this.baseChart, tooltips: { callbacks: { label: (tooltipItem: TooltipItem, data: ChartData) => { const amount = this.chartService.getAmountForTooltip( tooltipItem, data ); const label = this.chartService.getLabelForTooltip( tooltipItem, data ); const value = this.currencyService.formatMoney( amount ); if (label === this.totalRemaining) { const percent = Math.round( (this.budget.stats.totalRemaining / this.budget.stats.totalAllocated) * 100 ); return value + ` (${percent}%)`; } else if (label === this.totalUnavailable) { const percent = Math.round( (this.budget.stats.totalUnavailableAmount / this.budget.stats.totalAllocated) * 100 ); return value + ` (${percent}%)`; } else { return this.i18n.translate( 'BUDGET:textTotalSpent', {}, 'Total spent' ) + ': ' + value; } }, beforeLabel: (tooltipItem: TooltipItem, data: ChartData) => { return this.chartService.getLabelForTooltip( tooltipItem, data ); }, afterLabel: (tooltipItem: TooltipItem, data: ChartData) => { const label = this.chartService.getLabelForTooltip( tooltipItem, data ); if ( label !== this.totalRemaining && label !== this.totalUnavailable ) { const amount = this.chartService.getAmountForTooltip( tooltipItem, data ); return ' (' + this.i18n.translate( 'BUDGET:textPercentOfBudgetDynamic', { percent: Math.round( (amount / this.budget.stats.totalAllocated) * 100 ) }, '__percent__% of budget' ) + ')'; } return ''; } } } } as ChartOptions; // Payments // paymentStatusMap = this.statusService.paymentStatusMap; tableDataFactory: TableDataFactory; paymentsKey: string; programOptions: TypeaheadSelectOption[] = []; tagOptions = uniqBy( this.systemTagsService.getTagsForBucket(SystemTags.Buckets.Payment, true) .concat(this.systemTagsService.getTagsForBucket(SystemTags.Buckets.Application, true)) .concat(this.systemTagsService.getTagsForBucket(SystemTags.Buckets.NonprofitProfile, true)), 'id' ).map(tag => { return { label: tag.name, value: tag.id }; }); topLevelFilters: TopLevelFilter[] = []; paymentStatusOptions: TopLevelFilterOptionsConfig = { selectOptions: [{ value: ALL_SKIP_FILTER, display: this.i18n.translate('common:lblAllCap') }, { value: PaymentStatus.Pending, display: this.i18n.translate('common:lblPending') }, { value: PaymentStatus.Scheduled, display: this.i18n.translate('GLOBAL:textScheduled') }, { value: PaymentStatus.Processing, display: this.i18n.translate('GLOBAL:textProcessing') }, { value: PaymentStatus.Outstanding, display: this.i18n.translate('GLOBAL:textOutstanding') }, { value: PaymentStatus.Cleared, display: this.i18n.translate('GLOBAL:lblCleared') }, { value: PaymentStatus.Voided, display: this.i18n.translate('GLOBAL:textVoided') }] }; constructor ( private budgetService: BudgetService, private activatedRoute: ActivatedRoute, private autoTableFactory: AutoTableRepositoryFactory, private notifierService: NotifierService, private valueComparisonService: ValueComparisonService, private clientSettingsService: ClientSettingsService, private chartService: ChartService, private i18n: I18nService, private budgetResources: BudgetResources, private statusService: StatusService, private systemTagsService: SystemTagsService, private applicantManagerService: ApplicantManagerService, private nonprofitService: NonprofitService, private arrayHelper: ArrayHelpersService, private currencyService: CurrencyService, private programService: ProgramService, private policyService: PolicyService ) { } get sourceOptions () { return this.budgetService.get('allSourceOptions'); } get applicantRouterLink () { return this.applicantManagerService.get('applicantProfileRouterLink'); } get nonprofitRouterLink () { return this.nonprofitService.get('nonprofitProfileRouterLink'); } get canReserveFunds () { return this.clientSettingsService.clientSettings.reserveFunds; } ngOnInit () { this.id = this.activatedRoute.snapshot.params.id; this.paymentsKey = `BUDGET_${this.id}_PAYMENTS`; this.budget = this.budgetService.get( 'budgetDashboardMap' )[this.id]; this.setTopLevelStats(); const budgetSources = this.budget.detail.budgetFundingSources[0]; this.isUnits = budgetSources ? budgetSources.fundingSourceType === FundingSourceTypes.UNITS : false; this.totalAllocatedFooterText = this.i18n.translate( 'BUDGET:textNumberOfFundingSourcesDynamic', { number: this.budget.stats.numberOfFundingSources }, '__number__ funding sources' ); this.totalSpentFooterText = this.i18n.translate( 'common:textNumberOfPayments', { number: this.budget.stats.numberOfPayments }, '__number__ payments' ); const reserved = this.budget.stats.reservedAmount; if (reserved && this.canReserveFunds) { this.amountReservedText = this.i18n.translate( 'GLOBAL:textAmountReserved', { amount: this.currencyService.formatMoney( reserved ) }, '__amount__ reserved' ); } this.setupPayments(); this.setupProgramStats(); this.setupFundingSourceStats(); } setTopLevelStats () { this.topLevelStats = { totalSpent: this.budget.stats.totalSpent, totalRemaining: this.budget.stats.totalRemaining, totalAllocated: this.budget.stats.totalAllocated, totalUnavailableAmount: this.budget.stats.totalUnavailableAmount, totalReserved: this.budget.stats.reservedAmount }; } setupPayments () { this.topLevelFilters = [ new TopLevelFilter( 'text', 'applicantInfo.fullName', '', this.i18n.translate( 'MANAGE:textSearchByApplicantOrgProgramSource2', {}, 'Search by applicant, organization, program, or source' ), undefined, undefined, [{ column: 'applicantInfo.fullName', filterType: 'cn' }, { column: 'organizationInfo.name', filterType: 'cn' }, { column: 'fundingSourceName', filterType: 'cn' }, { column: 'programName', filterType: 'cn' }] ), new TopLevelFilter( 'typeaheadSingleEquals', 'statusId', ALL_SKIP_FILTER, '', this.paymentStatusOptions, this.i18n.translate( 'MANAGE:textPaymentStatus', {}, 'Payment status' ) ), new TopLevelFilter( 'dateRange', 'createdDate', '', this.i18n.translate( 'GLOBAL:textPaymentDateRange', {}, 'Payment date range' ) ) ]; this.tableDataFactory = DebounceFactory.createSimple( (options: PaginationOptions) => { const needsProgramOptions = !this.programOptions.length; options = this.systemTagsService.formatPaginationOptions(options); return observableFrom( this.budgetResources.getBudgetDashboardPayments( this.id, options, needsProgramOptions ) ).pipe(map(result => { if (needsProgramOptions) { this.programOptions = this.arrayHelper.sort(Object.keys(result.programFacets) .map(id => { const translationMap = this.programService.programTranslationMap[id]; return { value: +id, label: translationMap && translationMap.Name ? translationMap.Name : result.programFacets[id] }; }), 'label'); } return result; }), map(result => { return { success: true, data: { recordCount: result.recordCount, records: result.records } }; })); } ); } setupProgramStats () { this.programRepo = this.autoTableFactory.create({ key: 'PROGRAM_STATS', columns: [], notifier: this.notifierService, rowsPerPage: 1000, valueComparisonService: this.valueComparisonService, rows: this.budget.programs }); this.programLabels = this.budget.programs.map((prog) => { return prog.programName; }); this.programData = this.budget.programs.map((prog) => { return prog.paymentsAmount; }); this.programColors = this.chartService.getFixedAmountOfColors( this.budget.programs.length, this.colors ); const totalPaymentAmount = this.budget.programs.reduce((acc, program) => { const val = program.paymentsAmount || 0; return acc + val; }, 0); const totalPaymentAmountText = this.currencyService.formatMoney( totalPaymentAmount ); this.programTableData = [{ columnName: this.i18n.translate( 'common:lblProgram' ), isNumber: false, isMoney: false, getRouterLink: this.canAccessPrograms ? (row) => { return '/management/insights/program-manager/program/' + row.programId + '/applications'; } : null, isLegendRef: true, key: 'programName' }, { columnName: this.i18n.translate( 'common:hdrPayments' ), isNumber: true, isMoney: false, getRouterLink: null, isLegendRef: false, key: 'numberOfPayments', total: this.budget.programs.reduce((acc, program) => { return acc + (program.numberOfPayments || 0); }, 0) }, { columnName: this.i18n.translate( 'GLOBAL:textTotalPaymentAmount' ), isNumber: false, isMoney: false, isMixedVal: true, getRouterLink: null, isLegendRef: false, key: 'paymentsAmount', getRowValue: (row) => { return this.currencyService.formatMoney( row.paymentsAmount ); }, total: totalPaymentAmountText }]; } setupFundingSourceStats () { this.fundingSourceRepo = this.autoTableFactory.create({ key: 'FUNDING_SOURCE_STATS', columns: [], notifier: this.notifierService, rowsPerPage: 1000, valueComparisonService: this.valueComparisonService, rows: this.budget.sources }); this.fundingSourceLabels = this.budget.sources.map((fs) => { return fs.fundingSourceName; }); this.fundingSourceLabels = [ ...this.fundingSourceLabels, this.totalRemaining, this.totalUnavailable ]; this.fundingSourceData = this.budget.sources.map((fs) => { return fs.totalSpent; }); this.fundingSourceData = [ ...this.fundingSourceData, this.budget.stats.totalRemaining, this.budget.stats.totalUnavailableAmount ]; this.fundingSourceColors = this.chartService.getFixedAmountOfColors( this.fundingSourceData.length, this.colors ); const totalAllocated = this.budget.sources.reduce((acc, fs) => { const val = fs.totalAllocated || 0; return acc + val; }, 0); const totalSpent = this.budget.sources.reduce((acc, fs) => { const val = fs.totalSpent || 0; return acc + val; }, 0); const totalRemaining = this.budget.sources.reduce((acc, fs) => { const val = fs.totalRemaining || 0; return acc + val; }, 0); const totalUnavailable = this.budget.sources.reduce((acc, fs) => { const val = fs.totalUnavailableAmount || 0; return acc + val; }, 0); const totalSpentText = this.currencyService.formatMoney(totalSpent); const totalAllocatedText = this.currencyService.formatMoney(totalAllocated); const totalRemainingText = this.currencyService.formatMoney(totalRemaining); const totalUnavailableText = this.currencyService.formatMoney(totalUnavailable); this.fundingSourceTableData = [{ columnName: this.i18n.translate( 'BUDGET:lblFundingSource' ), isNumber: false, isMoney: false, getRouterLink: null, isLegendRef: true, key: 'fundingSourceName', getRowValue: (row: BudgetDashboardFundingSource) => { const closedText = ` (${this.i18n.translate('GLOBAL:textClosed')})`; return `${row.fundingSourceName}${row.isClosed ? closedText : ''}`; } }, { columnName: this.i18n.translate( 'GLOBAL:textTotalAllocated' ), isNumber: false, isMoney: false, isMixedVal: true, getRouterLink: null, isLegendRef: false, getRowValue: (row) => { return this.currencyService.formatMoney( row.totalAllocated ); }, key: 'totalAllocated', total: totalAllocatedText }, { columnName: this.i18n.translate( 'GLOBAL:textTotalSpent' ), isNumber: false, isMoney: false, isMixedVal: true, getRouterLink: null, isLegendRef: false, key: 'totalSpent', getRowValue: (row) => { return this.currencyService.formatMoney( row.totalSpent ); }, total: totalSpentText }, { columnName: this.i18n.translate( 'GLOBAL:textTotalRemaining' ), isNumber: false, isMoney: false, isMixedVal: true, getRouterLink: null, isLegendRef: false, key: 'totalRemaining', subKey: this.canReserveFunds ? 'reservedAmount' : '', subKeyType: 'money', getRowValue: (row) => { return this.currencyService.formatMoney( row.totalRemaining ); }, getRowSubValue: (row) => { return this.i18n.translate( 'common:textReserved', {}, 'reserved' ); }, total: totalRemainingText, subTotalText: this.canReserveFunds ? this.i18n.translate( 'GLOBAL:textAmountReserved', { amount: this.currencyService.formatMoney( this.budget.sources.reduce((acc, source) => { return acc + source.reservedAmount; }, 0) ) }, '__amount__ reserved' ) : null }, { columnName: this.i18n.translate( 'BUDGET:hdrTotalUnavailable' ), isNumber: false, isMoney: false, isMixedVal: true, getRouterLink: null, isLegendRef: false, key: 'totalUnavailableAmount', getRowValue: (row: BudgetDashboardFundingSource) => { return this.currencyService.formatMoney( row.totalUnavailableAmount ); }, total: totalUnavailableText }]; } }