Merge pull request #1199 from dod-ccpo/reporting-refactor-part-1
Reporting refactor part 1
This commit is contained in:
commit
92ae191f37
@ -1,6 +1,7 @@
|
||||
from itertools import groupby
|
||||
from collections import OrderedDict
|
||||
from atst.utils.localization import translate
|
||||
import pendulum
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class ReportingInterface:
|
||||
@ -35,14 +36,16 @@ def generate_sample_dates(_max=8):
|
||||
current = pendulum.now()
|
||||
sample_dates = []
|
||||
for _i in range(_max):
|
||||
current = current.subtract(months=1)
|
||||
sample_dates.append(current.strftime("%m/%Y"))
|
||||
current = current.subtract(months=1)
|
||||
|
||||
reversed(sample_dates)
|
||||
return sample_dates
|
||||
|
||||
|
||||
class MockReportingProvider(ReportingInterface):
|
||||
MOCK_PERCENT_EXPENDED_FUNDS = 0.75
|
||||
|
||||
FIXTURE_MONTHS = generate_sample_dates()
|
||||
|
||||
MONTHLY_SPEND_BY_ENVIRONMENT = {
|
||||
@ -163,25 +166,8 @@ class MockReportingProvider(ReportingInterface):
|
||||
"FM_Prod": {FIXTURE_MONTHS[0]: 5686},
|
||||
}
|
||||
|
||||
CUMULATIVE_BUDGET_A_WING = {
|
||||
FIXTURE_MONTHS[7]: {"spend": 9857, "cumulative": 9857},
|
||||
FIXTURE_MONTHS[6]: {"spend": 7881, "cumulative": 17738},
|
||||
FIXTURE_MONTHS[5]: {"spend": 14010, "cumulative": 31748},
|
||||
FIXTURE_MONTHS[4]: {"spend": 43510, "cumulative": 75259},
|
||||
FIXTURE_MONTHS[3]: {"spend": 41725, "cumulative": 116_984},
|
||||
FIXTURE_MONTHS[2]: {"spend": 41328, "cumulative": 158_312},
|
||||
FIXTURE_MONTHS[1]: {"spend": 47491, "cumulative": 205_803},
|
||||
FIXTURE_MONTHS[0]: {"spend": 36028, "cumulative": 241_831},
|
||||
}
|
||||
|
||||
CUMULATIVE_BUDGET_B_WING = {
|
||||
FIXTURE_MONTHS[1]: {"spend": 4838, "cumulative": 4838},
|
||||
FIXTURE_MONTHS[0]: {"spend": 14500, "cumulative": 19338},
|
||||
}
|
||||
|
||||
REPORT_FIXTURE_MAP = {
|
||||
"A-Wing": {
|
||||
"cumulative": CUMULATIVE_BUDGET_A_WING,
|
||||
"applications": [
|
||||
MockApplication("LC04", ["Integ", "PreProd", "Prod"]),
|
||||
MockApplication("SF18", ["Integ", "PreProd", "Prod"]),
|
||||
@ -202,7 +188,6 @@ class MockReportingProvider(ReportingInterface):
|
||||
"budget": 500_000,
|
||||
},
|
||||
"B-Wing": {
|
||||
"cumulative": CUMULATIVE_BUDGET_B_WING,
|
||||
"applications": [
|
||||
MockApplication("NP02", ["Integ", "PreProd", "Prod"]),
|
||||
MockApplication("FM", ["Integ", "Prod"]),
|
||||
@ -211,28 +196,6 @@ class MockReportingProvider(ReportingInterface):
|
||||
},
|
||||
}
|
||||
|
||||
def _sum_monthly_spend(self, data):
|
||||
return sum(
|
||||
[
|
||||
spend
|
||||
for application in data
|
||||
for env in application.environments
|
||||
for spend in self.MONTHLY_SPEND_BY_ENVIRONMENT[env.id].values()
|
||||
]
|
||||
)
|
||||
|
||||
def get_budget(self, portfolio):
|
||||
if portfolio.name in self.REPORT_FIXTURE_MAP:
|
||||
return self.REPORT_FIXTURE_MAP[portfolio.name]["budget"]
|
||||
return 0
|
||||
|
||||
def get_total_spending(self, portfolio):
|
||||
if portfolio.name in self.REPORT_FIXTURE_MAP:
|
||||
return self._sum_monthly_spend(
|
||||
self.REPORT_FIXTURE_MAP[portfolio.name]["applications"]
|
||||
)
|
||||
return 0
|
||||
|
||||
def _rollup_application_totals(self, data):
|
||||
application_totals = {}
|
||||
for application, environments in data.items():
|
||||
@ -270,7 +233,14 @@ class MockReportingProvider(ReportingInterface):
|
||||
{ "01/2018": 79.85, "02/2018": 86.54 }
|
||||
|
||||
"""
|
||||
return self.MONTHLY_SPEND_BY_ENVIRONMENT.get(environment_id, {})
|
||||
environment_monthly_totals = self.MONTHLY_SPEND_BY_ENVIRONMENT.get(
|
||||
environment_id, {}
|
||||
).copy()
|
||||
|
||||
environment_monthly_totals["total_spend_to_date"] = sum(
|
||||
monthly_total for monthly_total in environment_monthly_totals.values()
|
||||
)
|
||||
return environment_monthly_totals
|
||||
|
||||
def monthly_totals(self, portfolio):
|
||||
"""Return month totals rolled up by environment, application, and portfolio.
|
||||
@ -309,19 +279,46 @@ class MockReportingProvider(ReportingInterface):
|
||||
"portfolio": portfolio_totals,
|
||||
}
|
||||
|
||||
def cumulative_budget(self, portfolio):
|
||||
def get_obligated_funds_by_JEDI_clin(self, portfolio):
|
||||
"""
|
||||
Returns a dictionary of obligated funds and spending per JEDI CLIN
|
||||
{
|
||||
JEDI_CLIN: {
|
||||
obligated_funds,
|
||||
expended_funds
|
||||
}
|
||||
}
|
||||
"""
|
||||
if portfolio.name in self.REPORT_FIXTURE_MAP:
|
||||
budget_months = self.REPORT_FIXTURE_MAP[portfolio.name]["cumulative"]
|
||||
else:
|
||||
budget_months = {}
|
||||
return_dict = {}
|
||||
for jedi_clin, clins in groupby(
|
||||
portfolio.active_clins, lambda clin: clin.jedi_clin_type
|
||||
):
|
||||
obligated_funds = sum(clin.obligated_amount for clin in clins)
|
||||
return_dict[translate(f"JEDICLINType.{jedi_clin.value}")] = {
|
||||
"obligated_funds": obligated_funds,
|
||||
"expended_funds": (
|
||||
obligated_funds * Decimal(self.MOCK_PERCENT_EXPENDED_FUNDS)
|
||||
),
|
||||
}
|
||||
return return_dict
|
||||
return {}
|
||||
|
||||
end = pendulum.now()
|
||||
start = end.subtract(months=12)
|
||||
period = pendulum.period(start, end)
|
||||
|
||||
all_months = OrderedDict()
|
||||
for t in period.range("months"):
|
||||
month_str = "{month:02d}/{year}".format(month=t.month, year=t.year)
|
||||
all_months[month_str] = budget_months.get(month_str, None)
|
||||
|
||||
return {"months": all_months}
|
||||
def get_expired_task_orders(self, portfolio):
|
||||
return [
|
||||
{
|
||||
"id": task_order.id,
|
||||
"number": task_order.number,
|
||||
"period_of_performance": {
|
||||
"start_date": task_order.start_date,
|
||||
"end_date": task_order.end_date,
|
||||
},
|
||||
"total_obligated_funds": task_order.total_obligated_funds,
|
||||
"expended_funds": (
|
||||
task_order.total_obligated_funds
|
||||
* Decimal(self.MOCK_PERCENT_EXPENDED_FUNDS)
|
||||
),
|
||||
}
|
||||
for task_order in portfolio.task_orders
|
||||
if task_order.is_expired
|
||||
]
|
||||
|
@ -2,16 +2,14 @@ from flask import current_app
|
||||
|
||||
|
||||
class Reports:
|
||||
@classmethod
|
||||
def portfolio_totals(cls, portfolio):
|
||||
budget = current_app.csp.reports.get_budget(portfolio)
|
||||
spent = current_app.csp.reports.get_total_spending(portfolio)
|
||||
return {"budget": budget, "spent": spent}
|
||||
|
||||
@classmethod
|
||||
def monthly_totals(cls, portfolio):
|
||||
return current_app.csp.reports.monthly_totals(portfolio)
|
||||
|
||||
@classmethod
|
||||
def cumulative_budget(cls, portfolio):
|
||||
return current_app.csp.reports.cumulative_budget(portfolio)
|
||||
def expired_task_orders(cls, portfolio):
|
||||
return current_app.csp.reports.get_expired_task_orders(portfolio)
|
||||
|
||||
@classmethod
|
||||
def obligated_funds_by_JEDI_clin(cls, portfolio):
|
||||
return current_app.csp.reports.get_obligated_funds_by_JEDI_clin(portfolio)
|
||||
|
@ -1,6 +1,7 @@
|
||||
from enum import Enum
|
||||
from sqlalchemy import Column, Date, Enum as SQLAEnum, ForeignKey, Numeric, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import date
|
||||
|
||||
from atst.models.base import Base
|
||||
import atst.models.mixins as mixins
|
||||
@ -61,3 +62,7 @@ class CLIN(Base, mixins.TimestampsMixin):
|
||||
for c in self.__table__.columns
|
||||
if c.name not in ["id"]
|
||||
}
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.start_date <= date.today() <= self.end_date
|
||||
|
@ -65,6 +65,58 @@ class Portfolio(
|
||||
def num_task_orders(self):
|
||||
return len(self.task_orders)
|
||||
|
||||
@property
|
||||
def active_clins(self):
|
||||
return [
|
||||
clin
|
||||
for task_order in self.task_orders
|
||||
for clin in task_order.clins
|
||||
if clin.is_active
|
||||
]
|
||||
|
||||
@property
|
||||
def active_task_orders(self):
|
||||
return [task_order for task_order in self.task_orders if task_order.is_active]
|
||||
|
||||
@property
|
||||
def funding_duration(self):
|
||||
"""
|
||||
Return the earliest period of performance start date and latest period
|
||||
of performance end date for all active task orders in a portfolio.
|
||||
@return: (datetime.date or None, datetime.date or None)
|
||||
"""
|
||||
start_dates = (
|
||||
task_order.start_date
|
||||
for task_order in self.task_orders
|
||||
if task_order.is_active
|
||||
)
|
||||
|
||||
end_dates = (
|
||||
task_order.end_date
|
||||
for task_order in self.task_orders
|
||||
if task_order.is_active
|
||||
)
|
||||
|
||||
earliest_pop_start_date = min(start_dates, default=None)
|
||||
latest_pop_end_date = max(end_dates, default=None)
|
||||
|
||||
return (earliest_pop_start_date, latest_pop_end_date)
|
||||
|
||||
@property
|
||||
def days_to_funding_expiration(self):
|
||||
"""
|
||||
Returns the number of days between today and the lastest period performance
|
||||
end date of all active Task Orders
|
||||
"""
|
||||
return max(
|
||||
(
|
||||
task_order.days_to_expiration
|
||||
for task_order in self.task_orders
|
||||
if task_order.is_active
|
||||
),
|
||||
default=0,
|
||||
)
|
||||
|
||||
@property
|
||||
def members(self):
|
||||
return (
|
||||
|
@ -46,34 +46,24 @@ def create_portfolio():
|
||||
def reports(portfolio_id):
|
||||
portfolio = Portfolios.get(g.current_user, portfolio_id)
|
||||
today = date.today()
|
||||
month = http_request.args.get("month", today.month)
|
||||
year = http_request.args.get("year", today.year)
|
||||
current_month = date(int(year), int(month), 15)
|
||||
current_month = date(int(today.year), int(today.month), 15)
|
||||
prev_month = current_month - timedelta(days=28)
|
||||
two_months_ago = prev_month - timedelta(days=28)
|
||||
|
||||
task_order = next(
|
||||
(task_order for task_order in portfolio.task_orders if task_order.is_active),
|
||||
None,
|
||||
# wrapped in str() because the sum of obligated funds returns a Decimal object
|
||||
total_portfolio_value = str(
|
||||
sum(
|
||||
task_order.total_obligated_funds
|
||||
for task_order in portfolio.active_task_orders
|
||||
)
|
||||
)
|
||||
expiration_date = task_order and task_order.end_date
|
||||
if expiration_date:
|
||||
remaining_difference = expiration_date - today
|
||||
remaining_days = remaining_difference.days
|
||||
else:
|
||||
remaining_days = None
|
||||
|
||||
return render_template(
|
||||
"portfolios/reports/index.html",
|
||||
cumulative_budget=Reports.cumulative_budget(portfolio),
|
||||
portfolio_totals=Reports.portfolio_totals(portfolio),
|
||||
portfolio=portfolio,
|
||||
total_portfolio_value=total_portfolio_value,
|
||||
current_obligated_funds=Reports.obligated_funds_by_JEDI_clin(portfolio),
|
||||
expired_task_orders=Reports.expired_task_orders(portfolio),
|
||||
monthly_totals=Reports.monthly_totals(portfolio),
|
||||
task_order=task_order,
|
||||
current_month=current_month,
|
||||
prev_month=prev_month,
|
||||
two_months_ago=two_months_ago,
|
||||
expiration_date=expiration_date,
|
||||
remaining_days=remaining_days,
|
||||
)
|
||||
|
||||
|
||||
|
14
js/components/accordion.js
Normal file
14
js/components/accordion.js
Normal file
@ -0,0 +1,14 @@
|
||||
import ToggleMixin from '../mixins/toggle'
|
||||
|
||||
export default {
|
||||
name: 'accordion',
|
||||
|
||||
mixins: [ToggleMixin],
|
||||
|
||||
props: {
|
||||
defaultVisible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
}
|
@ -1,189 +0,0 @@
|
||||
import {
|
||||
format,
|
||||
isWithinRange,
|
||||
addMonths,
|
||||
isSameMonth,
|
||||
getMonth,
|
||||
} from 'date-fns'
|
||||
import { abbreviateDollars, formatDollars } from '../../lib/dollars'
|
||||
|
||||
const TOP_OFFSET = 20
|
||||
const BOTTOM_OFFSET = 70
|
||||
const CHART_HEIGHT = 360
|
||||
|
||||
export default {
|
||||
name: 'budget-chart',
|
||||
props: {
|
||||
currentMonth: String,
|
||||
expirationDate: String,
|
||||
months: Object,
|
||||
budget: String,
|
||||
},
|
||||
|
||||
data: function() {
|
||||
const heightScale =
|
||||
this.budget / (CHART_HEIGHT - TOP_OFFSET - BOTTOM_OFFSET)
|
||||
return {
|
||||
numMonths: 10,
|
||||
focusedMonthPosition: 4,
|
||||
height: CHART_HEIGHT,
|
||||
heightScale,
|
||||
budgetHeight: CHART_HEIGHT - BOTTOM_OFFSET - this.budget / heightScale,
|
||||
baseHeight: CHART_HEIGHT - BOTTOM_OFFSET,
|
||||
width: 0,
|
||||
displayedMonths: [],
|
||||
spendPath: '',
|
||||
projectedPath: '',
|
||||
displayBudget: formatDollars(parseFloat(this.budget)),
|
||||
}
|
||||
},
|
||||
|
||||
mounted: function() {
|
||||
this._setDisplayedMonths()
|
||||
this._setMetrics()
|
||||
addEventListener('load', this._setMetrics)
|
||||
addEventListener('resize', this._setMetrics)
|
||||
},
|
||||
|
||||
methods: {
|
||||
_setMetrics: function() {
|
||||
this.width = this.$refs.panel.clientWidth
|
||||
this.spendPath = ''
|
||||
this.projectedPath = ''
|
||||
|
||||
let lastSpend = 0
|
||||
let lastSpendPoint = ''
|
||||
|
||||
for (let i = 0; i < this.numMonths; i++) {
|
||||
const {
|
||||
metrics,
|
||||
budget,
|
||||
rollingAverage,
|
||||
cumulativeTotal,
|
||||
} = this.displayedMonths[i]
|
||||
const blockWidth = this.width / this.numMonths
|
||||
const blockX = blockWidth * i
|
||||
const spend = budget && budget.spend ? budget.spend : rollingAverage
|
||||
const barHeight = spend / this.heightScale
|
||||
lastSpend = spend
|
||||
const cumulativeY =
|
||||
this.height - cumulativeTotal / this.heightScale - BOTTOM_OFFSET
|
||||
const cumulativeX = blockX + blockWidth / 2
|
||||
const cumulativePoint = `${cumulativeX} ${cumulativeY}`
|
||||
|
||||
this.displayedMonths[i].metrics = Object.assign(metrics, {
|
||||
blockWidth,
|
||||
blockX,
|
||||
barHeight,
|
||||
barWidth: 30,
|
||||
barX: blockX + (blockWidth / 2 - 15),
|
||||
barY: this.height - barHeight - BOTTOM_OFFSET,
|
||||
cumulativeR: 2.5,
|
||||
cumulativeY,
|
||||
cumulativeX,
|
||||
})
|
||||
|
||||
if (budget && budget.spend) {
|
||||
this.spendPath += this.spendPath === '' ? 'M' : ' L'
|
||||
this.spendPath += cumulativePoint
|
||||
lastSpendPoint = cumulativePoint
|
||||
} else if (lastSpendPoint !== '') {
|
||||
this.projectedPath +=
|
||||
this.projectedPath === '' ? `M${lastSpendPoint} L` : ' L'
|
||||
this.projectedPath += cumulativePoint
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_setDisplayedMonths: function() {
|
||||
const [month, year] = this.currentMonth.split('/')
|
||||
const [expYear, expMonth, expDate] = this.expirationDate.split('-') // assumes format 'YYYY-MM-DD'
|
||||
const monthsRange = []
|
||||
const monthsBack = this.focusedMonthPosition + 1
|
||||
const monthsForward = this.numMonths - this.focusedMonthPosition - 1
|
||||
|
||||
// currently focused date
|
||||
const current = new Date(year, month)
|
||||
|
||||
// starting date of the chart
|
||||
const start = addMonths(current, -monthsBack)
|
||||
|
||||
// ending date of the chart
|
||||
const end = addMonths(start, this.numMonths + 1)
|
||||
|
||||
// expiration date
|
||||
const expires = new Date(expYear, expMonth - 1, expDate)
|
||||
|
||||
// is the expiration date within the displayed date range?
|
||||
const expirationWithinRange = isWithinRange(expires, start, end)
|
||||
|
||||
let rollingAverage = 0
|
||||
let cumulativeTotal = 0
|
||||
|
||||
for (let i = 0; i < this.numMonths; i++) {
|
||||
const date = addMonths(start, i)
|
||||
const dateMinusOne = addMonths(date, -1)
|
||||
const dateMinusTwo = addMonths(date, -2)
|
||||
const dateMinusThree = addMonths(date, -3)
|
||||
|
||||
const index = format(date, 'MM/YYYY')
|
||||
const indexMinusOne = format(dateMinusOne, 'MM/YYYY')
|
||||
const indexMinusTwo = format(dateMinusTwo, 'MM/YYYY')
|
||||
const indexMinusThree = format(dateMinusThree, 'MM/YYYY')
|
||||
|
||||
const budget = this.months[index] || null
|
||||
const spendAmount = budget ? budget.spend : rollingAverage
|
||||
const spendMinusOne = this.months[indexMinusOne]
|
||||
? this.months[indexMinusOne].spend
|
||||
: rollingAverage
|
||||
const spendMinusTwo = this.months[indexMinusTwo]
|
||||
? this.months[indexMinusTwo].spend
|
||||
: rollingAverage
|
||||
const spendMinusThree = this.months[indexMinusThree]
|
||||
? this.months[indexMinusThree].spend
|
||||
: rollingAverage
|
||||
|
||||
const isExpirationMonth = isSameMonth(date, expires)
|
||||
|
||||
if (budget && budget.cumulative) {
|
||||
cumulativeTotal = budget.cumulative
|
||||
} else {
|
||||
cumulativeTotal += spendAmount
|
||||
}
|
||||
|
||||
rollingAverage =
|
||||
(spendAmount + spendMinusOne + spendMinusTwo + spendMinusThree) / 4
|
||||
|
||||
monthsRange.push({
|
||||
budget,
|
||||
rollingAverage,
|
||||
cumulativeTotal,
|
||||
isExpirationMonth,
|
||||
spendAmount: formatDollars(spendAmount),
|
||||
abbreviatedSpend: abbreviateDollars(spendAmount),
|
||||
cumulativeAmount: formatDollars(cumulativeTotal),
|
||||
abbreviatedCumulative: abbreviateDollars(cumulativeTotal),
|
||||
date: {
|
||||
monthIndex: format(date, 'M'),
|
||||
month: format(date, 'MMM'),
|
||||
year: format(date, 'YYYY'),
|
||||
},
|
||||
showYear: isExpirationMonth || i === 0 || getMonth(date) === 0,
|
||||
isHighlighted: this.currentMonth === index,
|
||||
metrics: {
|
||||
blockWidth: 0,
|
||||
blockX: 0,
|
||||
barHeight: 0,
|
||||
barWidth: 0,
|
||||
barX: 0,
|
||||
barY: 0,
|
||||
cumulativeY: 0,
|
||||
cumulativeX: 0,
|
||||
cumulativeR: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
this.displayedMonths = monthsRange
|
||||
},
|
||||
},
|
||||
}
|
@ -6,11 +6,9 @@ export default {
|
||||
|
||||
props: {
|
||||
applications: Object,
|
||||
portfolio: Object,
|
||||
environments: Object,
|
||||
currentMonthIndex: String,
|
||||
prevMonthIndex: String,
|
||||
twoMonthsAgoIndex: String,
|
||||
},
|
||||
|
||||
data: function() {
|
||||
@ -40,9 +38,5 @@ export default {
|
||||
formatDollars: function(value) {
|
||||
return formatDollars(value, false)
|
||||
},
|
||||
|
||||
round: function(value) {
|
||||
return Math.round(value)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -17,7 +17,6 @@ import ApplicationEnvironments from './components/forms/new_application/environm
|
||||
import MultiStepModalForm from './components/forms/multi_step_modal_form'
|
||||
import uploadinput from './components/upload_input'
|
||||
import Modal from './mixins/modal'
|
||||
import BudgetChart from './components/charts/budget_chart'
|
||||
import SpendTable from './components/tables/spend_table'
|
||||
import LocalDatetime from './components/local_datetime'
|
||||
import { isNotInVerticalViewport } from './lib/viewport'
|
||||
@ -30,6 +29,7 @@ import SemiCollapsibleText from './components/semi_collapsible_text'
|
||||
import ToForm from './components/forms/to_form'
|
||||
import ClinFields from './components/clin_fields'
|
||||
import PopDateRange from './components/pop_date_range'
|
||||
import Accordion from './components/accordion'
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
@ -40,6 +40,7 @@ Vue.mixin(Modal)
|
||||
const app = new Vue({
|
||||
el: '#app-root',
|
||||
components: {
|
||||
Accordion,
|
||||
dodlogin,
|
||||
toggler,
|
||||
optionsinput,
|
||||
@ -47,7 +48,6 @@ const app = new Vue({
|
||||
textinput,
|
||||
checkboxinput,
|
||||
ApplicationEnvironments,
|
||||
BudgetChart,
|
||||
SpendTable,
|
||||
LocalDatetime,
|
||||
MultiStepModalForm,
|
||||
|
@ -33,7 +33,6 @@
|
||||
@import "components/progress_menu.scss";
|
||||
@import "components/forms";
|
||||
@import "components/selector";
|
||||
@import "components/budget_chart";
|
||||
@import "components/audit_log";
|
||||
@import "components/usa_banner";
|
||||
@import "components/dod_login_notice.scss";
|
||||
|
@ -1,169 +0,0 @@
|
||||
.budget-chart {
|
||||
svg {
|
||||
display: block;
|
||||
|
||||
.filter__text-background {
|
||||
feFlood {
|
||||
flood-color: $color-white;
|
||||
flood-opacity: 1;
|
||||
}
|
||||
|
||||
&--highlighted {
|
||||
feFlood {
|
||||
flood-color: $color-aqua-lightest;
|
||||
flood-opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
stroke: $color-gray-light;
|
||||
stroke-dasharray: 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.filter__text-background {
|
||||
feFlood {
|
||||
flood-color: $color-aqua-lightest;
|
||||
flood-opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
border-bottom: 1px solid $color-gray-light;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__legend {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
dl {
|
||||
margin: 0 0 0 ($gap * 2);
|
||||
|
||||
> div {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
|
||||
dt {
|
||||
@include small-label;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: $gap;
|
||||
height: $gap;
|
||||
border-radius: $gap / 2;
|
||||
margin: 0 $gap;
|
||||
|
||||
&.accumulated {
|
||||
background-color: $color-gold;
|
||||
}
|
||||
|
||||
&.monthly {
|
||||
background-color: $color-blue;
|
||||
}
|
||||
}
|
||||
|
||||
&__line {
|
||||
height: 2px;
|
||||
width: $gap * 3;
|
||||
border-top-width: 2px;
|
||||
border-top-style: dashed;
|
||||
margin: $gap;
|
||||
|
||||
&.spend {
|
||||
border-color: $color-blue;
|
||||
}
|
||||
|
||||
&.accumulated {
|
||||
border-color: $color-gold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__block {
|
||||
fill: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&--highlighted {
|
||||
fill: rgba($color-aqua, 0.15);
|
||||
}
|
||||
|
||||
&--is-expiration {
|
||||
border-left: 2px dotted $color-gray;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
fill: rgba($color-aqua, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
&__bar {
|
||||
fill: $color-blue;
|
||||
|
||||
&--projected {
|
||||
fill: transparent;
|
||||
stroke-width: 2px;
|
||||
stroke: $color-blue;
|
||||
stroke-dasharray: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__expiration-line {
|
||||
stroke-width: 2px;
|
||||
stroke: $color-gray-light;
|
||||
stroke-dasharray: 4px;
|
||||
}
|
||||
|
||||
&__cumulative {
|
||||
&__dot {
|
||||
fill: $color-gold;
|
||||
}
|
||||
}
|
||||
|
||||
&__projected-path {
|
||||
stroke-width: 1px;
|
||||
stroke: $color-gold;
|
||||
stroke-dasharray: 4px;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
&__spend-path {
|
||||
stroke-width: 1px;
|
||||
stroke: $color-gold;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
&__budget-line {
|
||||
stroke-width: 2px;
|
||||
stroke: $color-gray-light;
|
||||
stroke-dasharray: 4px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
@include small-label;
|
||||
|
||||
fill: $color-gray;
|
||||
pointer-events: none;
|
||||
|
||||
&--strong {
|
||||
fill: $color-black;
|
||||
}
|
||||
}
|
||||
}
|
23
templates/components/accordion.html
Normal file
23
templates/components/accordion.html
Normal file
@ -0,0 +1,23 @@
|
||||
{% macro Accordion(title, id, heading_level="h2") %}
|
||||
<accordion inline-template>
|
||||
<div>
|
||||
<{{heading_level}}>
|
||||
<button
|
||||
v-on:click="toggle($event)"
|
||||
class="usa-accordion-button"
|
||||
aria-controls="{{ id }}"
|
||||
v-bind:aria-expanded= "isVisible ? 'true' : 'false'"
|
||||
>
|
||||
{{ title }}
|
||||
</button>
|
||||
</{{heading_level}}>
|
||||
<div
|
||||
id="{{ id }}"
|
||||
class="usa-accordion-content"
|
||||
v-bind:aria-hidden="isVisible ? 'false' : 'true'"
|
||||
>
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</div>
|
||||
</accordion>
|
||||
{% endmacro %}
|
@ -0,0 +1,82 @@
|
||||
{% from "components/empty_state.html" import EmptyState %}
|
||||
{% from "components/icon.html" import Icon %}
|
||||
|
||||
<div>
|
||||
<h2>Funds Expended per Application and Environment</h2>
|
||||
{% set current_month_index = current_month.strftime('%m/%Y') %}
|
||||
{% set prev_month_index = prev_month.strftime('%m/%Y') %}
|
||||
|
||||
{% if not portfolio.applications %}
|
||||
|
||||
{% set can_create_applications = user_can(permissions.CREATE_APPLICATION) %}
|
||||
{% set message = ('portfolios.reports.empty_state.sub_message.can_create_applications' | translate)
|
||||
if can_create_applications
|
||||
else ('portfolios.reports.empty_state.sub_message.cannot_create_applications' | translate)
|
||||
%}
|
||||
|
||||
{{ EmptyState(
|
||||
('portfolios.reports.empty_state.message' | translate),
|
||||
action_label= ('portfolios.reports.empty_state.action_label' | translate) if can_create_applications else None,
|
||||
action_href=url_for('applications.create_new_application_step_1', portfolio_id=portfolio.id) if can_create_applications else None,
|
||||
icon='chart',
|
||||
sub_message=message,
|
||||
add_perms=can_create_applications
|
||||
) }}
|
||||
{% else %}
|
||||
<spend-table
|
||||
v-bind:applications='{{ monthly_totals['applications'] | tojson }}'
|
||||
v-bind:environments='{{ monthly_totals['environments'] | tojson }}'
|
||||
current-month-index='{{ current_month_index }}'
|
||||
prev-month-index='{{ prev_month_index }}'
|
||||
inline-template>
|
||||
<div class="responsive-table-wrapper">
|
||||
<table class="atat-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Applications and Environments</th>
|
||||
<th class="table-cell--align-right">Current Month</th>
|
||||
<th class="table-cell--align-right">Last Month</th>
|
||||
<th class="table-cell--align-right">Total Spent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for='(application, name) in applicationsState'>
|
||||
<tr>
|
||||
<td>
|
||||
<button v-on:click='toggle($event, name)' class='icon-link icon-link--large'>
|
||||
<span v-html='name'></span>
|
||||
<template v-if='application.isVisible'>{{ Icon('caret_down') }}</template>
|
||||
<template v-else>{{ Icon('caret_up') }}</template>
|
||||
</button>
|
||||
</td>
|
||||
<td class="table-cell--align-right">
|
||||
<span v-html='formatDollars(application[currentMonthIndex] || 0)'></span>
|
||||
</td>
|
||||
<td class="table-cell--align-right">
|
||||
<span v-html='formatDollars(application[prevMonthIndex] || 0)'></span>
|
||||
</td>
|
||||
<td class="table-cell--align-right">
|
||||
<span v-html='formatDollars(application["total_spend_to_date"])'></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for='(environment, envName) in environments[name]' v-show='application.isVisible'>
|
||||
<td>
|
||||
<span v-html='envName'></span>
|
||||
</td>
|
||||
<td class="table-cell--align-right">
|
||||
<span v-html='formatDollars(environment[currentMonthIndex] || 0)'></span>
|
||||
</td>
|
||||
<td class="table-cell--align-right">
|
||||
<span v-html='formatDollars(environment[prevMonthIndex] || 0)'></span>
|
||||
</td>
|
||||
<td class="table-cell--align-right">
|
||||
<span v-html='formatDollars(environment["total_spend_to_date"])'></span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</spend-table>
|
||||
{% endif %}
|
||||
</div>
|
34
templates/portfolios/reports/expired_task_orders.html
Normal file
34
templates/portfolios/reports/expired_task_orders.html
Normal file
@ -0,0 +1,34 @@
|
||||
{% from "components/accordion.html" import Accordion %}
|
||||
|
||||
|
||||
<section>
|
||||
<div class="usa-accordion">
|
||||
{% call Accordion("Expired Task Orders", "expired_task_orders", "h3") %}
|
||||
{% for task_order in expired_task_orders %}
|
||||
<a href="{{ url_for("task_orders.review_task_order", task_order_id=task_order["id"]) }}">
|
||||
Task Order {{ task_order["number"] }}
|
||||
</a>
|
||||
<div>
|
||||
<p>Period of Performance</p>
|
||||
<p>
|
||||
{{ task_order["period_of_performance"].start_date | formattedDate(formatter="%B %d, %Y") }}
|
||||
-
|
||||
{{ task_order["period_of_performance"].end_date | formattedDate(formatter="%B %d, %Y") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Total Obligated</p>
|
||||
<p>{{ task_order["total_obligated_funds"] | dollars }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Total Expended</p>
|
||||
<p>{{ task_order["expended_funds"] | dollars }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Total Unused</p>
|
||||
<p>{{ (task_order["total_obligated_funds"] - task_order["expended_funds"]) | dollars }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
</section>
|
@ -1,463 +1,16 @@
|
||||
{% extends "portfolios/base.html" %}
|
||||
|
||||
{% from "components/icon.html" import Icon %}
|
||||
{% from "components/empty_state.html" import EmptyState %}
|
||||
{% from "components/sticky_cta.html" import StickyCTA %}
|
||||
|
||||
|
||||
{% block portfolio_content %}
|
||||
|
||||
<div class='portfolio-reports'>
|
||||
<div v-cloak class='funding-summary-row'>
|
||||
|
||||
<div class='funding-summary-row__col'>
|
||||
<div class='panel spend-summary'>
|
||||
<h4 class='spend-summary__heading subheading'>Portfolio Total Spend</h4>
|
||||
<div class='row'>
|
||||
<dl class='spend-summary__budget col col--grow row'>
|
||||
{% set budget = portfolio_totals['budget'] %}
|
||||
{% set spent = portfolio_totals['spent'] %}
|
||||
{% set remaining = budget - spent %}
|
||||
<dl class='col col--grow'>
|
||||
<dt>Budget</dt>
|
||||
<dd>{{ budget | dollars }}</dd>
|
||||
</dl>
|
||||
|
||||
<dl class='col col--grow'>
|
||||
<dt>Remaining</dt>
|
||||
<dd>{{ remaining | dollars }}</dd>
|
||||
</dl>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<hr></hr>
|
||||
|
||||
<div>
|
||||
<meter value='{{ spent }}' min='0' {% if budget %}max='{{ budget }}' {% endif %}title='{{ spent | dollars }} Total spend to date'>
|
||||
<div class='meter__fallback' style='width:{{ (spent / budget) * 100 if budget else 0 }}%;'></div>
|
||||
</meter>
|
||||
|
||||
<dl class='spend-summary__spent'>
|
||||
<dt>Total spending to date</dt>
|
||||
<dd>{{ spent | dollars }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='funding-summary-row__col'>
|
||||
<div class='panel to-summary'>
|
||||
<div class='to-summary__row'>
|
||||
|
||||
<div class='to-summary__to'>
|
||||
<h2 class='to-summary__heading subheading'>Current Task Order</h2>
|
||||
<dl class='to-summary__to-number'>
|
||||
<dt class='usa-sr-only'>Task Order Number</dt>
|
||||
<dd>{{ task_order.number }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<hr></hr>
|
||||
|
||||
<div class='to-summary__expiration'>
|
||||
<div class='row'>
|
||||
<h4 class='subheading'>Expiration Date</h4>
|
||||
</div>
|
||||
<div class='row'>
|
||||
|
||||
<div class='col col--grow'>
|
||||
<div>
|
||||
{% if expiration_date %}
|
||||
<local-datetime
|
||||
timestamp='{{ expiration_date }}'
|
||||
format='MMMM D, YYYY'>
|
||||
</local-datetime>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href='{{ url_for("task_orders.review_task_order", task_order_id=task_order.id) }}' class='icon-link'>
|
||||
{{ Icon('cog') }}
|
||||
Manage Task Order
|
||||
</a>
|
||||
</div>
|
||||
<div class='col col--grow'>
|
||||
<dl>
|
||||
<dt>Remaining Days</dt>
|
||||
<dd class='{{ 'ending-soon' if remaining_days is not none }}'>
|
||||
{% if remaining_days is not none %}
|
||||
{{ Icon('arrow-down') }}
|
||||
<span>{{ remaining_days }}</span>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr></hr>
|
||||
|
||||
<dl class='to-summary__co'>
|
||||
<dt class='subheading'>Contracting Officer</dt>
|
||||
<dd class='row'>
|
||||
<div class='col col--grow'>
|
||||
{% if task_order.ko_first_name and task_order.ko_last_name %}
|
||||
{{ task_order.ko_first_name }} {{ task_order.ko_last_name }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class='col'>
|
||||
{% if task_order.ko_email %}
|
||||
<a class='icon-link' href='mailto:{{ task_order.ko_email }}'>
|
||||
{{ Icon('envelope') }}
|
||||
{{ task_order.ko_email }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ StickyCTA("Reports") }}
|
||||
<div class="portfolio-reports col col--grow">
|
||||
{% include "portfolios/reports/portfolio_summary.html" %}
|
||||
<hr>
|
||||
{% include "portfolios/reports/obligated_funds.html" %}
|
||||
{% include "portfolios/reports/expired_task_orders.html" %}
|
||||
<hr>
|
||||
{% include "portfolios/reports/application_and_env_spending.html" %}
|
||||
</div>
|
||||
|
||||
{% set portfolio_totals = monthly_totals['portfolio'] %}
|
||||
{% set current_month_index = current_month.strftime('%m/%Y') %}
|
||||
{% set prev_month_index = prev_month.strftime('%m/%Y') %}
|
||||
{% set two_months_ago_index = two_months_ago.strftime('%m/%Y') %}
|
||||
{% set reports_url = url_for("portfolios.reports", portfolio_id=portfolio.id) %}
|
||||
|
||||
{% if not portfolio.applications %}
|
||||
|
||||
{% set can_create_applications = user_can(permissions.CREATE_APPLICATION) %}
|
||||
{% set message = 'This portfolio has no cloud environments set up, so there is no spending data to report. Create an application with some cloud environments to get started.'
|
||||
if can_create_applications
|
||||
else 'This portfolio has no cloud environments set up, so there is no spending data to report. Contact the portfolio owner to set up some cloud environments.'
|
||||
%}
|
||||
|
||||
{{ EmptyState(
|
||||
'Nothing to report',
|
||||
action_label='Add a new application' if can_create_applications else None,
|
||||
action_href=url_for('applications.create_new_application_step_1', portfolio_id=portfolio.id) if can_create_applications else None,
|
||||
icon='chart',
|
||||
sub_message=message,
|
||||
add_perms=can_create_applications
|
||||
) }}
|
||||
{% else %}
|
||||
|
||||
<budget-chart
|
||||
v-cloak
|
||||
budget={{ budget }}
|
||||
current-month='{{ current_month_index }}'
|
||||
expiration-date='{{ expiration_date }}'
|
||||
v-bind:months='{{ cumulative_budget.months | tojson }}'
|
||||
inline-template>
|
||||
|
||||
<div class='budget-chart panel' ref='panel'>
|
||||
<header class='budget-chart__header panel__heading panel__heading--tight'>
|
||||
<h4>Cumulative Budget</h4>
|
||||
|
||||
<div class='budget-chart__legend'>
|
||||
<dl class='budget-chart__legend__spend'>
|
||||
<div>
|
||||
<dt>Monthly Spend</dt>
|
||||
<dd class='budget-chart__legend__dot monthly'><span class='usa-sr-only'>Monthly spend visual key</span></dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt>Accumulated Spend</dt>
|
||||
<dd class='budget-chart__legend__dot accumulated'><span class='usa-sr-only'>Accumulated spend visual key</span></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<dl class='budget-chart__legend__projected'>
|
||||
<div>
|
||||
<dt>Projected</dt>
|
||||
<dd>
|
||||
<div class='budget-chart__legend__line spend'><span class='usa-sr-only'>Projected monthly spend visual key</span></div>
|
||||
<div class='budget-chart__legend__line accumulated'><span class='usa-sr-only'>Projected accumulated spend visual key</span></div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<svg v-bind:height='height' v-bind:width='width'>
|
||||
|
||||
<defs>
|
||||
<filter x="-0.04" y="0" width="1.08" height="1" class='filter__text-background' id="text-background">
|
||||
<feFlood/>
|
||||
<feComposite in="SourceGraphic"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{# spend/projected budget path lines #}
|
||||
<path class='budget-chart__projected-path' v-bind:d='projectedPath'></path>
|
||||
<path class='budget-chart__spend-path' v-bind:d='spendPath'></path>
|
||||
|
||||
{# max budget line #}
|
||||
<line
|
||||
class='budget-chart__budget-line'
|
||||
x1='0'
|
||||
v-bind:x2='width'
|
||||
v-bind:y1='budgetHeight'
|
||||
v-bind:y2='budgetHeight'></line>
|
||||
|
||||
<g v-for='month in displayedMonths' >
|
||||
|
||||
{# make this clickable to focus on that month #}
|
||||
<a v-bind:href='"{{ reports_url }}?month=" + month.date.monthIndex + "&year=" + month.date.year'>
|
||||
|
||||
<defs>
|
||||
<filter
|
||||
x="-0.04"
|
||||
y="0"
|
||||
width="1.08"
|
||||
height="1"
|
||||
class='filter__text-background'
|
||||
v-bind:class='{ "filter__text-background--highlighted": month.isHighlighted }'
|
||||
v-bind:id="'text-background__' +month.date.month + month.date.year">
|
||||
<feFlood/>
|
||||
<feComposite in="SourceGraphic"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<title>
|
||||
<span v-html='month.date.month + " " + month.date.year'></span> | <!--
|
||||
--><template v-if='month.cumulativeTotal'><!--
|
||||
--><template v-if='month.budget && month.budget.spend'>Spend:</template><!--
|
||||
--><template v-else>Projected Spend:</template><!--
|
||||
--><span v-html='month.spendAmount'></span><!--
|
||||
--> | <!--
|
||||
--><template v-if='month.budget'>Total:</template><!--
|
||||
--><template v-else>Projected Total:</template><!--
|
||||
--><span v-html='month.cumulativeAmount'></span><!--
|
||||
--></template><!--
|
||||
|
||||
--><template v-else>No spend for this month</template>
|
||||
</title>
|
||||
|
||||
{# container block #}
|
||||
<rect
|
||||
class='budget-chart__block'
|
||||
v-bind:class='{ "budget-chart__block--highlighted": month.isHighlighted, "budget-chart__block-is-expiration": month.isExpirationMonth }'
|
||||
v-bind:width='month.metrics.blockWidth'
|
||||
v-bind:x='month.metrics.blockX'
|
||||
v-bind:height='height'></rect>
|
||||
|
||||
{# budget bar #}
|
||||
<rect
|
||||
v-if='month.budget'
|
||||
class='budget-chart__bar'
|
||||
v-bind:class='{ "budget-chart__bar--projected": month.budget.projected }'
|
||||
v-bind:width='month.metrics.barWidth'
|
||||
v-bind:height='month.metrics.barHeight'
|
||||
v-bind:x='month.metrics.barX'
|
||||
v-bind:y='month.metrics.barY'></rect>
|
||||
|
||||
{# projected budget bar #}
|
||||
<rect
|
||||
v-if='!month.budget'
|
||||
class='budget-chart__bar budget-chart__bar--projected'
|
||||
v-bind:width='month.metrics.barWidth'
|
||||
v-bind:height='month.metrics.barHeight'
|
||||
v-bind:x='month.metrics.barX'
|
||||
v-bind:y='month.metrics.barY'></rect>
|
||||
|
||||
{# task order expiration line #}
|
||||
<line
|
||||
v-if='month.isExpirationMonth'
|
||||
class='budget-chart__expiration-line'
|
||||
v-bind:x1='month.metrics.cumulativeX'
|
||||
v-bind:x2='month.metrics.cumulativeX'
|
||||
y1='0'
|
||||
v-bind:y2='baseHeight'></line>
|
||||
|
||||
{# task order expiration label #}
|
||||
<text
|
||||
v-bind:filter="'url(#text-background__' + month.date.month + month.date.year + ')'"
|
||||
v-if='month.isExpirationMonth'
|
||||
text-anchor='middle'
|
||||
v-bind:x='month.metrics.cumulativeX'
|
||||
v-bind:y='budgetHeight + 20'
|
||||
class='budget-chart__label'>T.O. Expires</text>
|
||||
|
||||
{# cumulative dot #}
|
||||
<circle
|
||||
v-if='month.cumulativeTotal'
|
||||
class='budget-chart__cumulative__dot'
|
||||
v-bind:r='month.metrics.cumulativeR'
|
||||
v-bind:cx='month.metrics.cumulativeX'
|
||||
v-bind:cy='month.metrics.cumulativeY'></circle>
|
||||
|
||||
{# abbreviated cumulative label #}
|
||||
<text
|
||||
v-bind:filter="'url(#text-background__' + month.date.month + month.date.year + ')'"
|
||||
v-if='month.cumulativeTotal'
|
||||
v-bind:x='month.metrics.cumulativeX'
|
||||
v-bind:y='month.metrics.cumulativeY - 10'
|
||||
text-anchor='middle'
|
||||
class='budget-chart__label'
|
||||
v-html='month.abbreviatedCumulative'></text>
|
||||
|
||||
{# abbreviated spend label #}
|
||||
<text
|
||||
v-bind:filter="'url(#text-background__' + month.date.month + month.date.year + ')'"
|
||||
v-bind:x='month.metrics.cumulativeX'
|
||||
v-bind:y='baseHeight + 20'
|
||||
text-anchor='middle'
|
||||
class='budget-chart__label'
|
||||
v-html='"+" + month.abbreviatedSpend'></text>
|
||||
|
||||
{# month label #}
|
||||
<text
|
||||
v-bind:filter="'url(#text-background__' + month.date.month + month.date.year + ')'"
|
||||
v-bind:x='month.metrics.cumulativeX'
|
||||
v-bind:y='baseHeight + 40'
|
||||
text-anchor='middle'
|
||||
class='budget-chart__label budget-chart__label--strong'
|
||||
v-html='month.date.month'></text>
|
||||
|
||||
{# year label #}
|
||||
<text
|
||||
v-bind:filter="'url(#text-background__' + month.date.month + month.date.year + ')'"
|
||||
v-if='month.showYear'
|
||||
v-bind:x='month.metrics.cumulativeX'
|
||||
v-bind:y='baseHeight + 55'
|
||||
text-anchor='middle'
|
||||
class='budget-chart__label budget-chart__label--strong'
|
||||
v-html='month.date.year'></text>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
<text
|
||||
x='20'
|
||||
v-bind:y='budgetHeight + 20'
|
||||
class='budget-chart__label'>Total Budget</text>
|
||||
<text
|
||||
x='20'
|
||||
v-bind:y='budgetHeight + 40'
|
||||
class='budget-chart__label'
|
||||
v-html='displayBudget'></text>
|
||||
</svg>
|
||||
</div>
|
||||
</budget-chart>
|
||||
|
||||
<div class='accordion-table responsive-table-wrapper'>
|
||||
<div class='responsive-table-wrapper__header'>
|
||||
<h2 class='responsive-table-wrapper__title'>Total spent per month</h2>
|
||||
|
||||
<select name='month' id='month' onchange='location = this.value' class='spend-table__month-select'>
|
||||
{% for m in cumulative_budget["months"] %}
|
||||
{% set month = m | dateFromString %}
|
||||
<option
|
||||
{% if month.month == current_month.month and month.year == current_month.year %}
|
||||
selected='selected'
|
||||
{% endif %}
|
||||
value='{{ url_for("portfolios.reports",
|
||||
portfolio_id=portfolio.id,
|
||||
month=month.month,
|
||||
year=month.year) }}'
|
||||
>
|
||||
{{ month.strftime('%B %Y') }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% if not cumulative_budget["months"] %}
|
||||
<option>{{ current_month.strftime('%B %Y') }}</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<spend-table
|
||||
v-bind:applications='{{ monthly_totals['applications'] | tojson }}'
|
||||
v-bind:portfolio='{{ portfolio_totals | tojson }}'
|
||||
v-bind:environments='{{ monthly_totals['environments'] | tojson }}'
|
||||
current-month-index='{{ current_month_index }}'
|
||||
prev-month-index='{{ prev_month_index }}'
|
||||
two-months-ago-index='{{ two_months_ago_index }}'
|
||||
inline-template>
|
||||
<table class="atat-table">
|
||||
<thead>
|
||||
<th scope='col'><span class='usa-sr-only'>Spending scope</span></th>
|
||||
<th scope='col' class='table-cell--align-right previous-month'>{{ two_months_ago.strftime('%B %Y') }}</th>
|
||||
<th scope='col' class='table-cell--align-right previous-month'>{{ prev_month.strftime('%B %Y') }}</th>
|
||||
<th scope='col' class='table-cell--align-right current-month'>{{ current_month.strftime('%B %Y') }}</th>
|
||||
<th class='current-month'></th>
|
||||
</thead>
|
||||
|
||||
<tbody class='spend-table__portfolio'>
|
||||
<tr>
|
||||
<th scope='row'>Portfolio Total</th>
|
||||
<td class='table-cell--align-right previous-month'>{{ portfolio_totals.get(two_months_ago_index, 0) | dollars }}</td>
|
||||
<td class='table-cell--align-right previous-month'>{{ portfolio_totals.get(prev_month_index, 0) | dollars }}</td>
|
||||
<td class='table-cell--align-right current-month'>{{ portfolio_totals.get(current_month_index, 0) | dollars }}</td>
|
||||
<td class='table-cell--expand current-month meter-cell'>
|
||||
<meter value='{{ portfolio_totals.get(current_month_index, 0) }}' min='0' max='{{ portfolio_totals.get(current_month_index, 0) }}'>
|
||||
<div class='meter__fallback' style='width: 100%'></div>
|
||||
</meter>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
<tbody v-for='(application, name) in applicationsState' class='accordion-table__items'>
|
||||
<tr>
|
||||
<th scope='rowgroup'>
|
||||
<button v-on:click='toggle($event, name)' class='icon-link icon-link--large accordion-table__item__toggler'>
|
||||
<template v-if='application.isVisible'>{{ Icon('caret_down') }}<div class='open-indicator'></div></template>
|
||||
<template v-else>{{ Icon('caret_right') }}</template>
|
||||
<span v-html='name'></span>
|
||||
</button>
|
||||
</th>
|
||||
<td class='table-cell--align-right previous-month'>
|
||||
<span v-html='formatDollars(application[twoMonthsAgoIndex] || 0)'></span>
|
||||
</td>
|
||||
|
||||
<td class='table-cell--align-right previous-month'>
|
||||
<span v-html='formatDollars(application[prevMonthIndex] || 0)'></span>
|
||||
</td>
|
||||
|
||||
<td class='table-cell--align-right current-month'>
|
||||
<span v-html='formatDollars(application[currentMonthIndex] || 0)'></span>
|
||||
</td>
|
||||
|
||||
<td class='table-cell--expand current-month meter-cell'>
|
||||
<span class='spend-table__meter-value'>
|
||||
<span v-html='round( 100 * ((application[currentMonthIndex] || 0) / (portfolio[currentMonthIndex] || 1) )) + "%"'></span>
|
||||
</span>
|
||||
<meter v-bind:value='application[currentMonthIndex] || 0' min='0' v-bind:max='portfolio[currentMonthIndex] || 1'>
|
||||
<div class='meter__fallback' v-bind:style='"width:" + round( 100 * ((application[currentMonthIndex] || 0) / (portfolio[currentMonthIndex] || 1) )) + "%;"'></div>
|
||||
</meter>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-for='(environment, envName) in environments[name]' v-show='application.isVisible' class='accordion-table__item__expanded'>
|
||||
<th scope='rowgroup'>
|
||||
<div class='icon-link accordion-table__item__expanded'>
|
||||
<span v-html='envName'></span>
|
||||
</div>
|
||||
</th>
|
||||
|
||||
<td class='table-cell--align-right previous-month'>
|
||||
<span v-html='formatDollars(environment[twoMonthsAgoIndex] || 0)'></span>
|
||||
</td>
|
||||
|
||||
<td class='table-cell--align-right previous-month'>
|
||||
<span v-html='formatDollars(environment[prevMonthIndex] || 0)'></span>
|
||||
</td>
|
||||
|
||||
<td class='table-cell--align-right current-month'>
|
||||
<span v-html='formatDollars(environment[currentMonthIndex] || 0)'></span>
|
||||
</td>
|
||||
|
||||
<td class='table-cell--expand current-month'></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</spend-table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
31
templates/portfolios/reports/obligated_funds.html
Normal file
31
templates/portfolios/reports/obligated_funds.html
Normal file
@ -0,0 +1,31 @@
|
||||
|
||||
<section>
|
||||
<header>
|
||||
<h2>Current Obligated funds</h2>
|
||||
<span>As of DATE</span>
|
||||
</header>
|
||||
<div class='panel'>
|
||||
<div class='panel__content'>
|
||||
<div>
|
||||
{% for JEDI_clin, funds in current_obligated_funds.items() %}
|
||||
{{ JEDI_clin }}
|
||||
<meter value='{{ funds["expended_funds"] }}' min='0' max='{{ funds["obligated_funds"] }}' title='{{ JEDI_clin }}'>
|
||||
<div class='meter__fallback' style='width:{{ (funds["expended_funds"] / funds["obligated_funds"]) * 100 }}%;'></div>
|
||||
</meter>
|
||||
<div>
|
||||
<p>Remaining funds:</p>
|
||||
<p>{{ (funds["obligated_funds"] - funds["expended_funds"]) | dollars }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Funds expended to date:</p>
|
||||
<p>{{ funds["expended_funds"] | dollars }}</p>
|
||||
</div>
|
||||
<hr>
|
||||
{% endfor %}
|
||||
{% for task_order in portfolio.active_task_orders %}
|
||||
<a href="{{ url_for("task_orders.review_task_order", task_order_id=task_order.id) }}">{{ task_order.number }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
36
templates/portfolios/reports/portfolio_summary.html
Normal file
36
templates/portfolios/reports/portfolio_summary.html
Normal file
@ -0,0 +1,36 @@
|
||||
{% from "components/tooltip.html" import Tooltip %}
|
||||
{% from "components/icon.html" import Icon %}
|
||||
|
||||
|
||||
<section class="row">
|
||||
<div class='col col--grow'>
|
||||
<p>
|
||||
Total Portfolio Value
|
||||
{{Tooltip(("common.lorem" | translate), title="")}}
|
||||
</p>
|
||||
<p>{{ total_portfolio_value | dollars }}</p>
|
||||
</div>
|
||||
<div class='col col--grow'>
|
||||
<p>
|
||||
Funding Duration
|
||||
{{Tooltip(("common.lorem" | translate), title="")}}
|
||||
</p>
|
||||
{% set earliest_pop_start_date, latest_pop_end_date = portfolio.funding_duration %}
|
||||
{% if earliest_pop_start_date and latest_pop_end_date %}
|
||||
<p>
|
||||
{{ earliest_pop_start_date | formattedDate(formatter="%B %d, %Y") }}
|
||||
-
|
||||
{{ latest_pop_end_date | formattedDate(formatter="%B %d, %Y") }}
|
||||
</p>
|
||||
{% else %}
|
||||
<p> - </p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class='col col--grow'>
|
||||
<p>
|
||||
Days Remaining
|
||||
{{Tooltip(("common.lorem" | translate), title="")}}
|
||||
</p>
|
||||
<p>{{ portfolio.days_to_funding_expiration }} days</p>
|
||||
</div>
|
||||
</section>
|
@ -1,27 +1,22 @@
|
||||
from atst.domain.reports import Reports
|
||||
|
||||
from tests.factories import PortfolioFactory
|
||||
from tests.factories import *
|
||||
|
||||
|
||||
def test_portfolio_totals():
|
||||
portfolio = PortfolioFactory.create()
|
||||
report = Reports.portfolio_totals(portfolio)
|
||||
assert report == {"budget": 0, "spent": 0}
|
||||
|
||||
|
||||
# this is sketched in until we do real reporting
|
||||
# this is sketched out until we do real reporting
|
||||
def test_monthly_totals():
|
||||
portfolio = PortfolioFactory.create()
|
||||
monthly = Reports.monthly_totals(portfolio)
|
||||
|
||||
assert not monthly["environments"]
|
||||
assert not monthly["applications"]
|
||||
assert not monthly["portfolio"]
|
||||
pass
|
||||
|
||||
|
||||
# this is sketched in until we do real reporting
|
||||
def test_cumulative_budget():
|
||||
portfolio = PortfolioFactory.create()
|
||||
months = Reports.cumulative_budget(portfolio)
|
||||
# this is sketched out until we do real reporting
|
||||
def test_current_obligated_funds():
|
||||
pass
|
||||
|
||||
assert len(months["months"]) >= 12
|
||||
|
||||
# this is sketched out until we do real reporting
|
||||
def test_expired_task_orders():
|
||||
pass
|
||||
|
||||
|
||||
# this is sketched out until we do real reporting
|
||||
def test_obligated_funds_by_JEDI_clin():
|
||||
pass
|
||||
|
@ -289,6 +289,7 @@ class TaskOrderFactory(Base):
|
||||
)
|
||||
number = factory.LazyFunction(random_task_order_number)
|
||||
creator = factory.SubFactory(UserFactory)
|
||||
signed_at = None
|
||||
_pdf = factory.SubFactory(AttachmentFactory)
|
||||
|
||||
@classmethod
|
||||
|
@ -1,4 +1,12 @@
|
||||
from tests.factories import ApplicationFactory, PortfolioFactory
|
||||
from tests.factories import (
|
||||
ApplicationFactory,
|
||||
PortfolioFactory,
|
||||
TaskOrderFactory,
|
||||
CLINFactory,
|
||||
random_future_date,
|
||||
random_past_date,
|
||||
)
|
||||
import datetime
|
||||
|
||||
|
||||
def test_portfolio_applications_excludes_deleted():
|
||||
@ -7,3 +15,73 @@ def test_portfolio_applications_excludes_deleted():
|
||||
ApplicationFactory.create(portfolio=portfolio, deleted=True)
|
||||
assert len(portfolio.applications) == 1
|
||||
assert portfolio.applications[0].id == app.id
|
||||
|
||||
|
||||
def test_funding_duration(session):
|
||||
# portfolio with active task orders
|
||||
portfolio = PortfolioFactory()
|
||||
|
||||
funding_start_date = random_past_date()
|
||||
funding_end_date = random_future_date(year_min=2)
|
||||
|
||||
TaskOrderFactory.create(
|
||||
signed_at=random_past_date(),
|
||||
portfolio=portfolio,
|
||||
create_clins=[
|
||||
{
|
||||
"start_date": funding_start_date,
|
||||
"end_date": random_future_date(year_max=1),
|
||||
}
|
||||
],
|
||||
)
|
||||
TaskOrderFactory.create(
|
||||
portfolio=portfolio,
|
||||
signed_at=random_past_date(),
|
||||
create_clins=[
|
||||
{"start_date": datetime.datetime.now(), "end_date": funding_end_date,}
|
||||
],
|
||||
)
|
||||
|
||||
assert portfolio.funding_duration == (funding_start_date, funding_end_date)
|
||||
|
||||
# empty portfolio
|
||||
empty_portfolio = PortfolioFactory()
|
||||
assert empty_portfolio.funding_duration == (None, None)
|
||||
|
||||
|
||||
def test_days_remaining(session):
|
||||
# portfolio with task orders
|
||||
funding_end_date = random_future_date(year_min=2)
|
||||
portfolio = PortfolioFactory()
|
||||
TaskOrderFactory.create(
|
||||
portfolio=portfolio,
|
||||
signed_at=random_past_date(),
|
||||
create_clins=[{"end_date": funding_end_date}],
|
||||
)
|
||||
|
||||
assert (
|
||||
portfolio.days_to_funding_expiration
|
||||
== (funding_end_date - datetime.date.today()).days
|
||||
)
|
||||
|
||||
# empty portfolio
|
||||
empty_portfolio = PortfolioFactory()
|
||||
assert empty_portfolio.days_to_funding_expiration == 0
|
||||
|
||||
|
||||
def test_active_task_orders(session):
|
||||
portfolio = PortfolioFactory()
|
||||
TaskOrderFactory.create(
|
||||
portfolio=portfolio,
|
||||
signed_at=random_past_date(),
|
||||
create_clins=[
|
||||
{
|
||||
"start_date": datetime.date(2019, 1, 1),
|
||||
"end_date": datetime.date(2019, 10, 31),
|
||||
}
|
||||
],
|
||||
)
|
||||
TaskOrderFactory.create(
|
||||
portfolio=portfolio, signed_at=random_past_date(), clins=[CLINFactory.create()]
|
||||
)
|
||||
assert len(portfolio.active_task_orders) == 1
|
||||
|
@ -110,7 +110,6 @@ def test_portfolio_reports_with_mock_portfolio(client, user_session):
|
||||
response = client.get(url_for("portfolios.reports", portfolio_id=portfolio.id))
|
||||
assert response.status_code == 200
|
||||
assert portfolio.name in response.data.decode()
|
||||
assert "$251,626.00 Total spend to date" in response.data.decode()
|
||||
|
||||
|
||||
def test_delete_portfolio_success(client, user_session):
|
||||
|
@ -449,6 +449,14 @@ portfolios:
|
||||
name: Name
|
||||
portfolio_mgmt: Portfolio management
|
||||
reporting: Reporting
|
||||
reports:
|
||||
empty_state:
|
||||
message: Nothing to report.
|
||||
sub_message:
|
||||
can_create_applications: This portfolio has no cloud environments set up, so there is no spending data to report. Create an application with some cloud environments to get started.
|
||||
cannot_create_applications: This portfolio has no cloud environments set up, so there is no spending data to report. Contact the portfolio owner to set up some cloud environments.
|
||||
action_label: 'Add a new application'
|
||||
|
||||
task_orders:
|
||||
review:
|
||||
pdf_title: Approved Task Order
|
||||
|
Loading…
x
Reference in New Issue
Block a user