diff --git a/sample_data/sample_detail_sheet.xlsx b/sample_data/sample_detail_sheet.xlsx index 97f1c03..96d70c7 100644 Binary files a/sample_data/sample_detail_sheet.xlsx and b/sample_data/sample_detail_sheet.xlsx differ diff --git a/src/js/components/form/form.css b/src/js/components/form/form.css index b432df0..6dd8620 100644 --- a/src/js/components/form/form.css +++ b/src/js/components/form/form.css @@ -9,4 +9,14 @@ textarea, input { width: 60%; margin-left: 20%; background-color: var(--spiritgreen); +} + +#new-form label { + display: block; /* Ensure label is on its own line */ + margin-bottom: 0.5em; +} + +#new-form select { + margin: auto; + width: 300px; } \ No newline at end of file diff --git a/src/js/components/form/subcomponents/dropdown.js b/src/js/components/form/subcomponents/dropdown.js index c881624..90ddf14 100644 --- a/src/js/components/form/subcomponents/dropdown.js +++ b/src/js/components/form/subcomponents/dropdown.js @@ -1,10 +1,10 @@ -async function createDropdownFromJSON(json_path) { - // Fetch JSON data from a file asynchronously - const response = await fetch(json_path); - const dataArray = await response.json(); - // create and return element - return createDropdown(dataArray); -} +// async function createDropdownFromJSON(json_path) { +// // Fetch JSON data from a file asynchronously +// const response = await fetch(json_path); +// const dataArray = await response.json(); +// // create and return element +// return createDropdown(dataArray); +// } function createDropdown(dataArray) { @@ -25,8 +25,8 @@ function createDropdown(dataArray) { export const Dropdown = { - createFromJSON : function(json_path){ return createDropdownFromJSON(json_path) }, - create : function(dataArray) { return createDropdown(dataArray) } + // createFromJSON : function(json_path){ return createDropdownFromJSON(json_path) }, + create : function(dataArray) { return createDropdown(dataArray) }, } export default Dropdown; \ No newline at end of file diff --git a/src/js/components/form/subcomponents/fields.js b/src/js/components/form/subcomponents/fields.js index 1b65087..ce3d12b 100644 --- a/src/js/components/form/subcomponents/fields.js +++ b/src/js/components/form/subcomponents/fields.js @@ -1,10 +1,12 @@ // function to add questions to forms -// type is 'input' or 'textarea' + +import Dropdown from "./dropdown"; + // inputType is for validation ('number' or 'text', etc) -function appendFormElement(type, label, inputId, required, inputType, form_id = 'new-form', cost = false) { +function appendFormElement(label, inputEl, inputId, required) { // change if we want forms elsewhere - const form = document.getElementById(form_id); + const form = document.getElementById('new-form'); // create outer wrapper for element const wrapper = document.createElement('div'); @@ -12,18 +14,7 @@ function appendFormElement(type, label, inputId, required, inputType, form_id = // label question const labelEl = document.createElement('label'); labelEl.textContent = label; - - // set type (input or textarea) - let inputEl; - if (type === 'input') { - inputEl = document.createElement('input'); - inputEl.type = inputType; - } else if (type === 'textarea') { - inputEl = document.createElement('textarea'); - } else { - throw new Error('Unsupported element type'); - } - + // mark as required if applicable inputEl.required = required; @@ -39,14 +30,23 @@ function appendFormElement(type, label, inputId, required, inputType, form_id = } export const NewField = { - shortText : function(label, inputId, required = false, form_id = 'new-form', cost = false) { - appendFormElement('input', label, inputId, required, 'text', form_id); + shortText : function(label, inputId, required = false) { + const inputEl = document.createElement('input'); + inputEl.type = 'text'; + appendFormElement(label, inputEl, inputId,required); + }, + longText : function(label, inputId, required = false) { + const inputEl = document.createElement('textarea'); + appendFormElement(label, inputEl, inputId, required); }, - longText : function(label, inputId, required = false, form_id = 'new-form', cost = false) { - appendFormElement('textarea', label, inputId, required, form_id); + numericInput : function(label, inputId, required = false) { + const inputEl = document.createElement('input'); + inputEl.type = 'number'; + appendFormElement(label, inputEl, inputId,required); }, - numericInput: function(label, inputId, required = false, form_id = 'new-form', cost = true) { - appendFormElement('input', label, inputId, required, 'number', form_id); + dropdown : function(label, inputId, optionArray, required = false){ + var inputEl = Dropdown.create(optionArray); + appendFormElement(label, inputEl, inputId, required); } } diff --git a/src/js/components/table/subcomponents/cells.js b/src/js/components/table/subcomponents/cells.js index 50697b1..73e8eec 100644 --- a/src/js/components/table/subcomponents/cells.js +++ b/src/js/components/table/subcomponents/cells.js @@ -34,25 +34,24 @@ function createEditableCell(cellClass, isCost){ textbox.type = 'text'; if (isCost){ var value = cell.getAttribute('value'); + textbox.value = displayWithCommas(value); } else { - var value = cell.textContent; + textbox.value = cell.textContent; } - textbox.value = displayWithCommas(value); // Clear the current content and append the textbox to the cell cell.innerHTML = ''; cell.appendChild(textbox); } -function createServiceDropdown(){ +function createDropdown(cellClass, optionArray){ // get cell - var cellClass = 'service'; const cell = document.querySelector(`.active-editing td.${cellClass}`); // add service dropdown - const serviceDropdown = Dropdown.create(Services.list()); - serviceDropdown.value = cell.textContent; + const dropdown = Dropdown.create(optionArray); + dropdown.value = cell.textContent; // Clear the current content and append the textbox to the cell cell.innerHTML = ''; - cell.appendChild(serviceDropdown); + cell.appendChild(dropdown); } const Cell = { @@ -68,7 +67,8 @@ const Cell = { createTextbox : function(className, isCost) { createEditableCell(className, isCost) }, - createServiceDropdown : createServiceDropdown + createServiceDropdown : () => { createDropdown('service', Services.list()) }, + createDropdown : createDropdown }; export default Cell; \ No newline at end of file diff --git a/src/js/components/tooltip/tooltip.js b/src/js/components/tooltip/tooltip.js index 8901107..2406cb3 100644 --- a/src/js/components/tooltip/tooltip.js +++ b/src/js/components/tooltip/tooltip.js @@ -176,6 +176,10 @@ export const Tooltip = { linkAllNP : function() { this.linkAccountStringCol(); this.linkCPACol(); + }, + + linkAllRevenue : function() { + this.linkAccountStringCol(); } } diff --git a/src/js/init.js b/src/js/init.js index ee24013..3be21f6 100644 --- a/src/js/init.js +++ b/src/js/init.js @@ -6,7 +6,7 @@ import { CurrentPage } from './utils/data_utils/local_storage_handlers.js'; // temporary hard-coding export let REVENUE = 0; -export let TARGET = 14000000; +export let TARGET = 10000000; // Set to equal current fiscal year export var FISCAL_YEAR = '26'; diff --git a/src/js/utils/data_utils/local_storage_handlers.js b/src/js/utils/data_utils/local_storage_handlers.js index 6918d89..4c887ad 100644 --- a/src/js/utils/data_utils/local_storage_handlers.js +++ b/src/js/utils/data_utils/local_storage_handlers.js @@ -85,7 +85,7 @@ class StoredTable { case 'nonpersonnel': return `FY${FISCAL_YEAR} Request`; case 'revenue': - break; + return `Departmental Request Total`; default: break; } @@ -106,7 +106,9 @@ function colSum(table, colName) { if (headers.includes(colName)) { let sum = 0; for (let i = 0; i < table.length; i++){ - sum += Math.round(parseFloat(table[i][colName])); + var value = Math.round(parseFloat(table[i][colName])); + // treat NaN (non-numerics) as zeroes + if (value) { sum += value; } } return sum; } else { @@ -188,9 +190,21 @@ export class Initiative { this.name = row['Initiative Name']; } - expenses() { return this.data['Ballpark Total Expenses']} + expenses() { + if (this.data['Ballpark Total Expenses']) { + return this.data['Ballpark Total Expenses']; + } else { + return 0; + } + } - revenue() { return this.data['Revenue'] } + revenue() { + if (this.data['Revenue']) { + return this.data['Revenue']; + } else { + return 0; + } + } net() { return this.expenses() - this.revenue() } diff --git a/src/js/views/03_revenue/helpers.js b/src/js/views/03_revenue/helpers.js index 06709c5..4c27354 100644 --- a/src/js/views/03_revenue/helpers.js +++ b/src/js/views/03_revenue/helpers.js @@ -1,60 +1,65 @@ import Prompt from '../../components/prompt/prompt.js' -import { formatCurrency } from '../../utils/common_utils.js' -import { REVENUE } from '../../init.js' import Body from '../../components/body/body.js' import NavButtons from '../../components/nav_buttons/nav_buttons.js' -import { nextPage } from '../view_logic.js' import Subtitle from '../../components/header/header.js' -import Modal from '../../components/modal/modal.js' -import Form from '../../components/form/form.js' +import Sidebar from '../../components/sidebar/sidebar.js' +import Table from '../../components/table/table.js' +import Tooltip from '../../components/tooltip/tooltip.js' + +const revenueColumns = [ + { title: 'Edit', className : 'edit' }, + { title : 'Account String', className : 'account-string'}, + { title: 'Recurring or One-Time', className: 'recurring'}, + { title: 'Object Category', className: 'object-category'}, + { title: 'Departmental Request Total', className: 'request', isCost: true}, + { title: 'Departmental Request Notes', className: 'notes'}, + + // hidden columns used for calcs and info boxes + { title: 'Appropriation Name', className: 'approp-name', hide: true }, + { title: 'Cost Center Name', className: 'cc-name', hide: true }, + { title: 'Object Name', className: 'object-name', hide: true} +]; export function preparePageView(){ // prepare page view Body.reset(); NavButtons.show(); + Sidebar.show(); + Table.adjustWidth('100%'); // update page text - Subtitle.update('Revenue Projections'); - // TODO: update to make dynamic - Prompt.Text.update(`Your revenue projection for FY26 is ${formatCurrency(REVENUE, true)}`); - Prompt.Buttons.Left.updateText('Confirm'); - Prompt.Buttons.Right.updateText("This doesn't look right"); -} + Subtitle.update('Revenues'); -export function setUpNavButtons(){ - // clicking 'confirm' will also take us to the next page - Prompt.Buttons.Left.addAction(nextPage); - // TODO: allow user to edit revenue here - Modal.Link.add('option2'); - handleErrorComment(); -} + // set up table + initializeRevTable() -export function removeButtonEvents(){ - // remove event listeners on prompt buttons - Prompt.Buttons.Left.removeAction(nextPage); - Modal.Link.remove('option2'); -} + // enable continue button + NavButtons.Next.enable(); + + Prompt.Text.update('Review and edit revenue line items.'); -function handleErrorComment(){ - var fund = localStorage.getItem("fund"); - Modal.clear(); - Modal.Title.update(`Comment on ${fund} Revenue`); - Form.new('modal-body'); - Form.NewField.longText('Explain your concerns here. Someone from the revenue team will follow up with you.', - 'revenue-comment', true); - Form.SubmitButton.add(); - // save comment on submission - Modal.Submit.init(handleRevenueCommentSubmission); } -function handleRevenueCommentSubmission(event){ - // get data from form in modal - const responses = Form.fetchAllResponses(event); - // TODO: save comment here +export async function initializeRevTable(){ + // load table data from storage + if(await Table.Data.load()) { + //after table is loaded, fill it + Table.show(); + Table.Columns.addAtEnd(Table.Buttons.edit_confirm_btns, "Edit"); + // assign cost classes + Table.Columns.assignClasses(revenueColumns); + // enable editing + Table.Buttons.Edit.init(revRowOnEdit, Table.save); + // show info boxes on click + Tooltip.linkAllRevenue(); + } else { + Prompt.Text.update('No revenues for this fund.') + } +} - // hide modal, update page, and enable continue - Modal.hide(); - Prompt.Buttons.hide(); - Prompt.Text.update('Your comment has been received.'); - NavButtons.Next.enable(); +function revRowOnEdit(){ + // make it editable + Table.Cell.createTextbox('request', true); + Table.Cell.createTextbox('notes'); + Table.Cell.createDropdown('recurring', ['One-Time', 'Recurring']); } \ No newline at end of file diff --git a/src/js/views/03_revenue/main.js b/src/js/views/03_revenue/main.js index 8b45220..36960e6 100644 --- a/src/js/views/03_revenue/main.js +++ b/src/js/views/03_revenue/main.js @@ -1,14 +1,10 @@ import { CurrentPage } from '../../utils/data_utils/local_storage_handlers.js' -import { preparePageView, removeButtonEvents, setUpNavButtons } from './helpers.js' +import { preparePageView } from './helpers.js' export function loadRevenuePage() { //update page state CurrentPage.update('revenue'); preparePageView(); - setUpNavButtons(); } -export function cleanupRevenuePage() { - removeButtonEvents(); -}; \ No newline at end of file diff --git a/src/js/views/05_overtime/helpers.js b/src/js/views/05_overtime/helpers.js index 70b8082..f1874d4 100644 --- a/src/js/views/05_overtime/helpers.js +++ b/src/js/views/05_overtime/helpers.js @@ -50,6 +50,7 @@ function OTRowOnEdit(){ Table.Cell.createTextbox('OT-wages', true); Table.Cell.createTextbox('OT-salary', true); Table.Cell.createServiceDropdown(Services.list()); + Table.Cell.createDropdown('recurring', ['One-Time', 'Recurring']); } export async function initializeOTTable(){ diff --git a/src/js/views/06_nonpersonnel/helpers.js b/src/js/views/06_nonpersonnel/helpers.js index e021b60..000078d 100644 --- a/src/js/views/06_nonpersonnel/helpers.js +++ b/src/js/views/06_nonpersonnel/helpers.js @@ -34,7 +34,7 @@ export function preparePageView(){ Table.adjustWidth('100%'); // update page text Subtitle.update('Non-Personnel'); - Prompt.Text.update('Select an action item for each non-personnel line item from last year.'); + Prompt.Text.update('Review and edit non-personnel line items.'); NavButtons.Next.enable(); } @@ -59,5 +59,6 @@ function nonPersonnelRowOnEdit(){ // make it editable Table.Cell.createTextbox('request', true); Table.Cell.createServiceDropdown(); + Table.Cell.createDropdown('recurring', ['One-Time', 'Recurring']); } diff --git a/src/js/views/07_new_initiatives/helpers.js b/src/js/views/07_new_initiatives/helpers.js index 62ee00e..183b83a 100644 --- a/src/js/views/07_new_initiatives/helpers.js +++ b/src/js/views/07_new_initiatives/helpers.js @@ -9,27 +9,35 @@ import { nextPage } from '../view_logic.js' import Subtitle from '../../components/header/header.js' import Sidebar from '../../components/sidebar/sidebar.js' +const explanation = `New initiative submissions will count as supplemental line items and will be the starting + point for a conversation with both OB and ODFS, who will help with the details.` + +const dropdownOptions = ['N/A', 'One-Time', 'Recurring'] + export function initializePageView() { // Prepare page view Body.reset(); NavButtons.show(); Sidebar.show(); + NavButtons.Next.enable(); //table appearance - Table.adjustWidth('70%'); - Table.Buttons.AddRow.updateText('Add another new initiative'); + Table.adjustWidth('100%'); + Table.Buttons.AddRow.updateText('Add new initiative'); // remove fund selection localStorage.setItem("fund", ''); // Load text Subtitle.update('New Initiatives'); - Prompt.Text.update('Do you have any new initiatives for FY26?'); - Prompt.Buttons.Left.updateText('Yes'); - Prompt.Buttons.Right.updateText('No'); + Prompt.Text.update('This is the place to propose new initiatives for FY26. ' + explanation); + NavButtons.Next.enable + // Prompt.Buttons.Left.updateText('Yes, propose a new initiative'); + // Prompt.Buttons.Right.updateText('No new initiatives'); // clicking 'no new initialitives' will also take us to the next page - Prompt.Buttons.Right.addAction(nextPage); - Prompt.Buttons.Left.addAction(NavButtons.Next.enable); + Table.Buttons.AddRow.show(); + // Prompt.Buttons.Right.addAction(nextPage); + // Prompt.Buttons.Left.addAction(NavButtons.Next.enable); } export function setUpModal() { @@ -43,17 +51,26 @@ export function setUpModal() { export function setUpForm() { // Set up form Form.new('modal-body'); + + // general questions Form.NewField.shortText('Initiative Name:', 'Initiative Name', true); - Form.NewField.longText(`Describe what the Initiative is and why it is needed and should be funded: - i). What is the business case for the Initiative? - ii). Why is the initiative needed? What is the value-add to residents? What is the Department’s plan for implementing the Initiative? - iii). Why can’t the Initiative be funded with the Department’s baseline budget?`, 'Explanation', true); + Form.NewField.longText('What is the business case for the Initiative?', 'Q1', true); + Form.NewField.longText(`Why is the initiative needed? What is the value-add to residents? + What is the Department’s plan for implementing the Initiative?`, 'Q2', true); + Form.NewField.longText(`Why can’t the Initiative be funded with the Department’s baseline budget?`, 'Q3', true); - Form.NewField.numericInput('What is your ballpark estimate of TOTAL ADDITONAL expenses associated with this initiative?', 'Ballpark Total Expenses', false); + // TODO: Edit to drop down + Form.NewField.shortText('Relevant account string (if known)?', 'Account String', false); + // Numbers + Form.NewField.numericInput('What is your ballpark estimate of TOTAL ADDITONAL expenses associated with this initiative?', + 'Ballpark Total Expenses', false); Form.NewField.numericInput('Estimate of ADDITONAL personnel cost?', 'Personnel Cost', false); Form.NewField.numericInput('Estimate of ADDITONAL nonpersonnel cost?', 'Non-personnel Cost', false); Form.NewField.numericInput('Estimate of ADDITONAL revenue (if applicable)?', 'Revenue', false); + Form.NewField.dropdown(`If there will be revenue, is it one-time or recurring?`, + 'One-time v. Recurring', dropdownOptions); + Form.SubmitButton.add(); // Initialize form submission to table data @@ -64,11 +81,18 @@ function assignClasses() { // record columns and their classes const initiativesCols = [ { title: 'Initiative Name', className: 'init-name' }, - { title: `Explanation`, className: 'explanation' }, + { title: 'Account String', className: 'account-string' }, { title: 'Ballpark Total Expenses', className: 'total', isCost: true }, { title: 'Revenue', className: 'revenue', isCost: true }, { title: 'Personnel Cost', className: 'personnel', isCost: true }, - { title: 'Non-personnel Cost', className: 'nonpersonnel', isCost: true } + { title: 'Non-personnel Cost', className: 'nonpersonnel', isCost: true }, + { title: 'One-time v. Recurring', className: 'rev-type' }, + { title: 'Edit', className : 'edit' }, + + // hide the explanation columns + { title: 'Q1', className: 'q1', hide: true }, + { title: 'Q2', className: 'q2', hide: true }, + { title: 'Q3', className: 'q3', hide: true }, ]; // assign cost classes @@ -80,11 +104,25 @@ export async function initializeInitTable(){ // load table data from storage if(await Table.Data.load()) { //after table is loaded, fill it + Table.Columns.addAtEnd(Table.Buttons.edit_confirm_btns, "Edit"); assignClasses(); - tableView(); + // enable editing + Table.Buttons.Edit.init(rowOnEdit, Table.save); + // show table + Table.show(); } } +function rowOnEdit(){ + Table.Cell.createTextbox('total', true); + Table.Cell.createTextbox('revenue', true); + Table.Cell.createTextbox('personnel', true); + Table.Cell.createTextbox('nonpersonnel', true); + Table.Cell.createTextbox('account-string'); + Table.Cell.createTextbox('init-name'); + Table.Cell.createDropdown('rev-type', dropdownOptions); +} + function handleNewInitSubmission(event){ // get answers from form, hide form, show answers in table const responses = Form.fetchAllResponses(event); @@ -94,20 +132,13 @@ function handleNewInitSubmission(event){ Table.Rows.add(responses); // save it Table.save(); - tableView(); + // show updated table + initializeInitTable(); + Modal.hide(); + Table.Buttons.AddRow.updateText('Add another new initiative'); } } -function tableView() { - // change page view - Table.show(); - Modal.hide(); - Prompt.hide(); - assignClasses(); - Table.Buttons.AddRow.show(); - NavButtons.Next.enable(); -} - export function removeModalLinks(){ Modal.Link.remove('option1'); Modal.Link.remove('add-btn'); diff --git a/src/js/views/view_logic.js b/src/js/views/view_logic.js index b32751d..48febd6 100644 --- a/src/js/views/view_logic.js +++ b/src/js/views/view_logic.js @@ -24,7 +24,6 @@ export let PAGES = { export let CLEANUP = { 'new-inits' : cleanUpInitiativesPage, - 'revenue' : cleanupRevenuePage, 'summary' : cleanUpSummaryPage } @@ -50,6 +49,13 @@ export function nextPage(){ // clean up current page if (CLEANUP[page_state]) { CLEANUP[page_state]() }; + // unless on personnel (which will go to overtime), return to summary if all funds are viewed + const returnPages = ['revenue', 'nonpersonnel', 'new-inits', 'overtime']; + if (!FundLookupTable.fundsLeft() && returnPages.includes(CurrentPage.load())) { + visitPage('summary'); + return; + } + // if on non-personnel, circle back to fund selection unless all funds are edited if (CurrentPage.load() == 'nonpersonnel'){ // mark fund as viewed/edited @@ -61,13 +67,6 @@ export function nextPage(){ } } - // unless on personnel (which will go to overtime), return to summary if all funds are viewed - const returnPages = ['revenue', 'nonpersonnel', 'new-inits', 'overtime']; - if (!FundLookupTable.fundsLeft() && returnPages.includes(CurrentPage.load())) { - visitPage('summary'); - return; - } - if (currentIndex >= 0 && currentIndex < keys.length - 1) { // Check if there is a next key, and get it const nextKey = keys[currentIndex + 1];