diff --git a/atst/domain/reports.py b/atst/domain/reports.py index 15df3ba4..7e49e0de 100644 --- a/atst/domain/reports.py +++ b/atst/domain/reports.py @@ -196,3 +196,11 @@ class Reports: "projects": project_totals, "workspace": workspace_totals, } + + @classmethod + def cumulative_budget(cls, alternate): + return { + "months": CUMULATIVE_BUDGET_BELUGA + if alternate + else CUMULATIVE_BUDGET_AARDVARK + } diff --git a/atst/routes/workspaces.py b/atst/routes/workspaces.py index e8d0df8d..f13f6ec1 100644 --- a/atst/routes/workspaces.py +++ b/atst/routes/workspaces.py @@ -87,6 +87,7 @@ def workspace_reports(workspace_id): return render_template( "workspaces/reports/index.html", + cumulative_budget=Reports.cumulative_budget(alternate_reports), workspace_totals=Reports.workspace_totals(alternate_reports), monthly_totals=Reports.monthly_totals(alternate_reports), current_month=current_month, diff --git a/js/components/charts/budget_chart.js b/js/components/charts/budget_chart.js new file mode 100644 index 00000000..68f071fe --- /dev/null +++ b/js/components/charts/budget_chart.js @@ -0,0 +1,135 @@ +import { format } from 'date-fns' +import { abbreviateDollars, formatDollars } from '../../lib/dollars' + +const TOP_OFFSET = 20 +const BOTTOM_OFFSET = 60 +const CHART_HEIGHT = 360 + +export default { + name: 'budget-chart', + props: { + currentMonth: 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('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 } = this.displayedMonths[i] + const blockWidth = (this.width / this.numMonths) + const blockX = blockWidth * i + const spend = budget + ? budget.spend || lastSpend + : 0 + const cumulative = budget + ? budget.cumulative || budget.projected + : 0 + const barHeight = spend / this.heightScale + lastSpend = spend + const cumulativeY = this.height - (cumulative / 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 + } + + if (budget && budget.projected) { + this.projectedPath += this.projectedPath === '' ? `M${lastSpendPoint} L` : ' L' + this.projectedPath += cumulativePoint + } + } + }, + + _setDisplayedMonths: function () { + const [month, year] = this.currentMonth.split('/') + const monthsRange = [] + const monthsBack = this.focusedMonthPosition + const monthsForward = this.numMonths - this.focusedMonthPosition - 1 + const start = new Date(year, month - 1 - monthsBack) + + let previousAmount = 0 + + for (let i = 0; i < this.numMonths; i++) { + const date = new Date(start.getFullYear(), start.getMonth() + i) + const index = format(date, 'MM/YYYY') + const budget = this.months[index] || null + const spendAmount = budget ? budget.spend || previousAmount : 0 + const cumulativeAmount = budget ? budget.cumulative || budget.projected : 0 + previousAmount = spendAmount + + monthsRange.push({ + budget, + spendAmount: formatDollars(spendAmount), + abbreviatedSpend: abbreviateDollars(spendAmount), + cumulativeAmount: formatDollars(cumulativeAmount), + abbreviatedCumulative: abbreviateDollars(cumulativeAmount), + date: { + monthIndex: format(date, 'M'), + month: format(date, 'MMM'), + year: format(date,'YYYY') + }, + 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 + } + } +} diff --git a/js/index.js b/js/index.js index 2ce01476..f5e9613d 100644 --- a/js/index.js +++ b/js/index.js @@ -1,3 +1,4 @@ +import 'svg-innerhtml' import 'babel-polyfill' import classes from '../styles/atat.scss' @@ -14,6 +15,7 @@ import toggler from './components/toggler' import NewProject from './components/forms/new_project' import Modal from './mixins/modal' import selector from './components/selector' +import BudgetChart from './components/charts/budget_chart' Vue.use(VTooltip) @@ -30,7 +32,8 @@ const app = new Vue({ poc, financial, NewProject, - selector + selector, + BudgetChart }, mounted: function() { const modalOpen = document.querySelector("#modalOpen") diff --git a/js/lib/dollars.js b/js/lib/dollars.js new file mode 100644 index 00000000..65fc69ff --- /dev/null +++ b/js/lib/dollars.js @@ -0,0 +1,12 @@ +export const formatDollars = value => `$${value.toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,')}` + +export const abbreviateDollars = (value, decimals = 1) => { + if (value === null) { return null } // terminate early + if (value === 0) { return '0' } // terminate early + var b = (value).toPrecision(2).split("e"), // get power + k = b.length === 1 ? 0 : Math.floor(Math.min(b[1].slice(1), 14) / 3), // floor at decimals, ceiling at trillions + c = k < 1 ? value.toFixed(0 + decimals) : (value / Math.pow(10, k * 3) ).toFixed(decimals), // divide by power + d = c < 0 ? c : Math.abs(c), // enforce -0 is 0 + e = d + ['', 'k', 'M', 'B', 'T'][k]; // append power + return e; +} diff --git a/package.json b/package.json index 4c942980..f83facd5 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,10 @@ "dependencies": { "autoprefixer": "^9.1.3", "babel-polyfill": "^6.26.0", + "date-fns": "^1.29.0", "npm": "^6.0.1", "parcel": "^1.9.7", + "svg-innerhtml": "^1.1.0", "text-mask-addons": "^3.8.0", "uswds": "^1.6.3", "v-tooltip": "^2.0.0-rc.33", diff --git a/styles/atat.scss b/styles/atat.scss index 3d70a544..ebaaa214 100644 --- a/styles/atat.scss +++ b/styles/atat.scss @@ -34,7 +34,7 @@ @import 'components/search_bar'; @import 'components/forms'; @import 'components/selector'; - +@import 'components/budget_chart'; @import 'sections/login'; @import 'sections/request_approval'; diff --git a/styles/components/_budget_chart.scss b/styles/components/_budget_chart.scss new file mode 100644 index 00000000..92c22c39 --- /dev/null +++ b/styles/components/_budget_chart.scss @@ -0,0 +1,130 @@ +.budget-chart { + .budget-chart__header { + border-bottom: 1px solid $color-gray-light; + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + .budget-chart__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; + } + + .budget-chart__legend__dot { + width: $gap; + height: $gap; + border-radius: $gap / 2; + margin: 0 $gap; + + &.accumulated { + background-color: $color-gold; + } + &.monthly { + background-color: $color-blue; + } + } + + .budget-chart__legend__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; + } + } + } + } + } + } + + .budget-chart__block { + fill: $color-white; + cursor: pointer; + + &--highlighted { + fill: $color-aqua-lightest; + } + + &:hover { + fill: $color-aqua-lightest; + } + } + + svg { + display: block; + + a { + text-decoration: none; + &:focus { + outline: none; + stroke: $color-gray-light; + stroke-dasharray: 2px; + } + } + } + + .budget-chart__bar { + fill: $color-blue; + + &--projected { + fill: transparent; + stroke-width: 2px; + stroke: $color-blue; + stroke-dasharray: 4px; + } + } + + .budget-chart__cumulative__dot { + fill: $color-gold; + } + + .budget-chart__projected-path { + stroke-width: 1px; + stroke: $color-gold; + stroke-dasharray: 4px; + fill: none; + } + + .budget-chart__spend-path { + stroke-width: 1px; + stroke: $color-gold; + fill: none; + } + + .budget-chart__budget-line { + stroke-width: 2px; + stroke: $color-gray-light; + stroke-dasharray: 4px; + } + + .budget-chart__label { + @include small-label; + fill: $color-gray; + + &--strong { + fill: $color-black; + } + } +} diff --git a/styles/core/_grid.scss b/styles/core/_grid.scss index 17aa81a6..5f75b64f 100644 --- a/styles/core/_grid.scss +++ b/styles/core/_grid.scss @@ -42,5 +42,6 @@ &.col--grow { flex: 1; flex-grow: 1; + overflow: auto } } diff --git a/styles/elements/_panels.scss b/styles/elements/_panels.scss index c18c2e9b..fd10671d 100644 --- a/styles/elements/_panels.scss +++ b/styles/elements/_panels.scss @@ -62,13 +62,13 @@ } .panel__heading { - margin: $gap * 2; + padding: $gap * 2; @include media($medium-screen) { - margin: $gap * 4; + padding: $gap * 4; } &--tight { - margin: $gap*2; + padding: $gap*2; } h1, h2, h3, h4, h5, h6 { diff --git a/styles/elements/_typography.scss b/styles/elements/_typography.scss index a51d839f..8626dd13 100644 --- a/styles/elements/_typography.scss +++ b/styles/elements/_typography.scss @@ -68,3 +68,9 @@ dl { margin-bottom: $gap * 2; } } + +@mixin small-label { + font-size: $h6-font-size; + font-weight: $font-bold; + color: $color-black; +} diff --git a/templates/workspaces/members/edit.html b/templates/workspaces/members/edit.html index ac37cafa..af66d615 100644 --- a/templates/workspaces/members/edit.html +++ b/templates/workspaces/members/edit.html @@ -134,16 +134,16 @@ {% endcall %}