diff --git a/app/budget/BudgetCtrl.html b/app/budget/BudgetCtrl.html index d3c45bfb7..f86297c06 100644 --- a/app/budget/BudgetCtrl.html +++ b/app/budget/BudgetCtrl.html @@ -3,104 +3,111 @@
- - + + -
- - - - - +
+ + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - + - - + + -
- - - - - - - - - - -
-
+
+ + + + + + + + + + + + +
+
diff --git a/app/budget/budgetApp.js b/app/budget/budgetApp.js index 1179c00ec..9bda6ca9d 100644 --- a/app/budget/budgetApp.js +++ b/app/budget/budgetApp.js @@ -19,6 +19,7 @@ import AuthService from './../shared/services/AuthService.js'; import SectionService from './../shared/services/SectionService.js'; // Directives +import incomingChanges from './directives/incomingChanges/incomingChanges.js'; import budgetNav from './directives/budgetNav/budgetNav.js'; import lineItemFilters from './directives/budgetNav/lineItemFilters/lineItemFilters.js'; @@ -103,6 +104,7 @@ const budgetApp = angular.module("budgetApp", dependencies) .directive('addLineItem', addLineItem) .directive('addLineItemComments', addLineItemComments) .directive('courseList', courseList) +.directive('incomingChanges', incomingChanges) .constant('ActionTypes', { INIT_STATE: "INIT_STATE", CREATE_BUDGET_SCENARIO: "CREATE_BUDGET_SCENARIO", diff --git a/app/budget/directives/budgetNav/budgetNav.html b/app/budget/directives/budgetNav/budgetNav.html index 646f6ffe3..05526547f 100644 --- a/app/budget/directives/budgetNav/budgetNav.html +++ b/app/budget/directives/budgetNav/budgetNav.html @@ -15,6 +15,9 @@
Course List
+
+ Incoming Changes +
diff --git a/app/budget/directives/incomingChanges/incomingChanges.css b/app/budget/directives/incomingChanges/incomingChanges.css new file mode 100644 index 000000000..f9eb6bd7e --- /dev/null +++ b/app/budget/directives/incomingChanges/incomingChanges.css @@ -0,0 +1,63 @@ +.incoming-changes__title { + font-weight: bold; +} + +.incoming-changes__change-text { + color: rgb(153, 153, 153); + text-transform: capitalize; +} + +.incoming-changes__sub-title-indent { + margin-left: 10px; +} + +.incoming-changes__course-divide { + border-bottom: 1px black solid; +} + +.incoming-changes__section-divide { + border-top: 1px #ebebeb solid; +} + +.incoming-changes__ui { + text-align: end; + padding-right: 15px; +} + +.incoming-changes__table { + width: 100%; + margin-bottom: 20px; +} + +.incoming-changes__apply-term { + padding-bottom: 20px; + padding-top: 20px; + display: flex; + justify-content: center; + align-content: center; + border-bottom: 1p solid #ebebeb; +} + +.incoming-changes__table-header { + width: 10%; + border-bottom: 1px solid #ddd; + border-top: 1px solid #ddd; + padding-top: 10px; + padding-bottom: 10px; +} + +.incoming-changes__table-header-container { + border-right: 1px solid #ddd; + padding-left: 15px; + padding-right: 15px; + padding-top: 5px; + padding-bottom: 5px; +} + +.incoming-changes__no-changes { + color: #777; + display: flex; + justify-content: center; + align-items: center; + padding-top: 20px; +} diff --git a/app/budget/directives/incomingChanges/incomingChanges.html b/app/budget/directives/incomingChanges/incomingChanges.html new file mode 100644 index 000000000..b7df595d9 --- /dev/null +++ b/app/budget/directives/incomingChanges/incomingChanges.html @@ -0,0 +1,106 @@ +
+ +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Course +
+
+
+ Change +
+
+
+ Scheduled +
+
+
+ Scenario +
+
+
+
+
+ {{ change.display.title }} +
+
+ {{ change.display.subTitle }} +
+
+ + + + + + + {{ change.display.changeText }} + + + + {{ change.display.scheduleText }} + + + {{ change.display.scenarioText }} + + + + + +
+
+ No changes +
+
+
+
+
diff --git a/app/budget/directives/incomingChanges/incomingChanges.js b/app/budget/directives/incomingChanges/incomingChanges.js new file mode 100644 index 000000000..49f2ce68d --- /dev/null +++ b/app/budget/directives/incomingChanges/incomingChanges.js @@ -0,0 +1,452 @@ +import './incomingChanges.css'; + +let incomingChanges = function (BudgetActions, $rootScope, TermService) { + return { + restrict: 'E', + template: require('./incomingChanges.html'), + replace: true, + scope: { + termNav: '=', + sectionGroups: '=', + courses: '=', + sectionGroupCosts: '=', + selectedBudgetScenario: '=' + }, + link: function (scope, element, attrs) { + scope.totalChanges = {}; + scope.tabOverrides = {}; + + scope.setActiveTerm = function(activeTermTab) { + BudgetActions.selectTerm(activeTermTab); + }; + + scope.calculateChanges = function () { + scope.changes = []; + + var scenarioSectionGroupCostIds = scope.getActiveSectionGroupCostIds(scope.sectionGroupCosts, scope.selectedBudgetScenario); + var presentSectionGroupCostIds = scope.getPresentSectionGroupCostIds(scenarioSectionGroupCostIds, scope.sectionGroupCosts, scope.sectionGroups); + + var changedValues = scope.calculateChangedValues(presentSectionGroupCostIds); + var missingCourses = scope.calculateMissingCourses(); + var addedCourses = scope.calculateAddedCourses(scenarioSectionGroupCostIds); + + scope.changes = scope.changes.concat(changedValues); + scope.changes = scope.changes.concat(missingCourses); + scope.changes = scope.changes.concat(addedCourses); + + scope.changes = _.sortBy(scope.changes, function (change) { + return change.sortKey; + }); + + scope.changes = scope.breakIntoTerms(scope.changes); + + Object.keys(scope.changes).forEach(function(term) { + var changes = scope.changes[term].length > 0 ? " (" + scope.changes[term].length + ")" : ""; + scope.tabOverrides[TermService.getShortTermName(term)] = TermService.getShortTermName(term) + changes; + }); + + scope.changes = scope.addCourseHeaders(scope.changes); + }; + + // Calculate changes for sections present in both the scheduled data and the scenario + scope.calculateChangedValues = function (sectionGroupCostIds) { + var changes = []; + + sectionGroupCostIds.forEach(function(sectionGroupCostId) { + var sectionGroupCost = scope.sectionGroupCosts.list[sectionGroupCostId]; + var uniqueKey = sectionGroupCost.subjectCode + "-" + sectionGroupCost.courseNumber + "-" + sectionGroupCost.sequencePattern + "-" + sectionGroupCost.termCode; + var sectionGroupId = scope.sectionGroups.idsByUniqueKey[uniqueKey]; + var sectionGroup = scope.sectionGroups.list[sectionGroupId]; + + // Check enrollment + if (sectionGroup.totalSeats != sectionGroupCost.enrollment) { + var change = { + payload: { + sectionGroupCost: sectionGroupCost, + enrollment: sectionGroup.totalSeats + }, + term: sectionGroup.termCode.slice(-2), + courseTitle: sectionGroup.title, + course: sectionGroup.subjectCode + " " + sectionGroup.courseNumber, + sortKey: sectionGroup.subjectCode + sectionGroup.courseNumber + scope.courses.list[sectionGroup.courseId].sequencePattern, + display: { + subTitle: scope.courses.list[sectionGroup.courseId].sequencePattern, + changeText: "seats", + scheduleText: sectionGroup.totalSeats || '0', + scenarioText: sectionGroupCost.enrollment, + tooltip: "Are you sure you want to set seats to " + (sectionGroup.totalSeats || '0') + "?" + } + }; + + changes.push(change); + } + + // Check TAs + if (sectionGroup.teachingAssistantAppointments != sectionGroupCost.taCount) { + var change = { + payload: { + sectionGroupCost: sectionGroupCost, + taCount: sectionGroup.teachingAssistantAppointments + }, + term: sectionGroup.termCode.slice(-2), + courseTitle: sectionGroup.title, + course: sectionGroup.subjectCode + " " + sectionGroup.courseNumber, + sortKey: sectionGroup.subjectCode + sectionGroup.courseNumber + scope.courses.list[sectionGroup.courseId].sequencePattern, + display: { + subTitle: scope.courses.list[sectionGroup.courseId].sequencePattern, + changeText: "TA count", + scheduleText: sectionGroup.teachingAssistantAppointments || '0', + scenarioText: sectionGroupCost.taCount, + tooltip: "Are you sure you want to set TAs to " + (sectionGroup.teachingAssistantAppointments || '0') + "?" + } + }; + + changes.push(change); + } + + // Check Readers + if (sectionGroup.readerAppointments != sectionGroupCost.readerCount) { + var change = { + payload: { + sectionGroupCost: sectionGroupCost, + readerCount: sectionGroup.readerAppointments + }, + term: sectionGroup.termCode.slice(-2), + courseTitle: sectionGroup.title, + course: sectionGroup.subjectCode + " " + sectionGroup.courseNumber, + sortKey: sectionGroup.subjectCode + sectionGroup.courseNumber + scope.courses.list[sectionGroup.courseId].sequencePattern, + display: { + subTitle: scope.courses.list[sectionGroup.courseId].sequencePattern, + changeText: "reader count", + scheduleText: sectionGroup.readerAppointments || '0', + scenarioText: sectionGroupCost.readerCount, + tooltip: "Are you sure you want to set readers to " + (sectionGroup.readerAppointments || '0') + "?" + } + }; + + changes.push(change); + } + + // Check assigned instructor / instructorType + var sectionGroupInstructorId = sectionGroup.assignedInstructorIds[0]; + var sectionGroupInstructorTypeId = sectionGroup.assignedInstructorTypeIds[0]; + var sectionGroupCostInstructorId = sectionGroupCost.instructorId; + var sectionGroupCostInstructorTypeId = sectionGroupCost.instructorTypeId; + + if (sectionGroupInstructorId != sectionGroupCostInstructorId) { + var change = { + payload: { + sectionGroupCost: sectionGroupCost, + instructorId: sectionGroupInstructorId, + }, + term: sectionGroupCost.termCode.slice(-2), + courseTitle: sectionGroup.title, + course: sectionGroup.subjectCode + " " + sectionGroup.courseNumber, + sortKey: sectionGroup.subjectCode + sectionGroup.courseNumber + scope.courses.list[sectionGroup.courseId].sequencePattern, + display: { + subTitle: scope.courses.list[sectionGroup.courseId].sequencePattern, + changeText: "instructor", + scheduleText: sectionGroup.assignedInstructorNames[0] || 'unassigned', + scenarioText: sectionGroupCost.instructor ? sectionGroupCost.instructor.description : null, + tooltip: "Are you sure you want to set the instructor to " + (sectionGroup.assignedInstructorNames[0] || 'unassigned') + "?" + } + }; + + changes.push(change); + } else if (!sectionGroupInstructorId && sectionGroupInstructorTypeId != sectionGroupCostInstructorTypeId) { + var scheduleText = sectionGroup.assignedInstructorType ? sectionGroup.assignedInstructorType.description : 'unassigned'; + var change = { + payload: { + sectionGroupCost: sectionGroupCost, + instructorTypeId: sectionGroupInstructorTypeId, + }, + term: sectionGroupCost.termCode.slice(-2), + courseTitle: sectionGroup.title, + course: sectionGroup.subjectCode + " " + sectionGroup.courseNumber, + sortKey: sectionGroup.subjectCode + sectionGroup.courseNumber + scope.courses.list[sectionGroup.courseId].sequencePattern, + display: { + subTitle: scope.courses.list[sectionGroup.courseId].sequencePattern, + changeText: "instructor", + scheduleText: scheduleText, + scenarioText: sectionGroupCost.instructorType ? sectionGroupCost.instructorType.description : 'unassigned', + tooltip: "Are you sure you want to set the instructor type to " + scheduleText + "?" + } + }; + + changes.push(change); + } + + // Check section count + if (sectionGroup.sectionCount != sectionGroupCost.sectionCount) { + var change = { + payload: { + sectionGroupCost: sectionGroupCost, + sectionCount: sectionGroup.sectionCount, + }, + term: sectionGroupCost.termCode.slice(-2), + courseTitle: sectionGroup.title, + course: sectionGroup.subjectCode + " " + sectionGroup.courseNumber, + sortKey: sectionGroup.subjectCode + sectionGroup.courseNumber + scope.courses.list[sectionGroup.courseId].sequencePattern, + display: { + subTitle: scope.courses.list[sectionGroup.courseId].sequencePattern, + changeText: "section count", + scheduleText: sectionGroup.sectionCount || '0', + scenarioText: sectionGroupCost.sectionCount, + tooltip: "Are you sure you want to set the section count to " + (sectionGroup.sectionCount || '0') + "?" + } + }; + + changes.push(change); + } + }); + + return changes; + }; + + // Filters provided sectionGroupCostIds to ensure they match a sectionGroup. + scope.getPresentSectionGroupCostIds = function (sectionGroupCostIds, sectionGroupCosts, sectionGroups) { + return sectionGroupCostIds.filter(function(sectionGroupCostId) { + var sectionGroupCost = sectionGroupCosts.list[sectionGroupCostId]; + + // Ensure sectionGroupCost is not disabled + if (sectionGroupCost.disabled) { return false; } + + var uniqueKey = sectionGroupCost.subjectCode + "-" + sectionGroupCost.courseNumber + "-" + sectionGroupCost.sequencePattern + "-" + sectionGroupCost.termCode; + var sectionGroupId = sectionGroups.idsByUniqueKey[uniqueKey]; + var sectionGroup = sectionGroups.list[sectionGroupId]; + + // Ensure sectionGroupCost has a match + if (!sectionGroup) { return false; } + + return true; + }); + }; + + // Filters sectionGroupCosts against selected scenario and activeTerms + scope.getActiveSectionGroupCostIds = function (sectionGroupCosts, selectedBudgetScenario) { + return sectionGroupCosts.ids.filter(function(sectionGroupCostId) { + var sectionGroupCost = sectionGroupCosts.list[sectionGroupCostId]; + + // Ensure sectionGroupCost matches scenario + if (sectionGroupCost.budgetScenarioId != selectedBudgetScenario.id) { return false; } + + // Ensure sectionGroupCost matches termCode + var activeTerms = scope.selectedBudgetScenario.terms; + var sectionGroupCostTerm = sectionGroupCost.termCode.slice(-2); + + if (activeTerms.indexOf(sectionGroupCostTerm) == -1) { return false; } + + return true; + }); + }; + + // Returns change objects with sectionGroups that need a corresponding sectionGroupCost created + scope.calculateMissingCourses = function () { + var changes = []; + + scope.sectionGroups.ids.forEach(function(sectionGroupId) { + var sectionGroup = scope.sectionGroups.list[sectionGroupId]; + var sectionGroupTerm = sectionGroup.termCode.slice(-2); + + // Ensure sectionGroupCost matches termCode + if (scope.selectedBudgetScenario.terms.indexOf(sectionGroupTerm) == -1) { return; } + + var uniqueKey = sectionGroup.subjectCode + "-" + sectionGroup.courseNumber + "-" + sectionGroup.sequencePattern + "-" + sectionGroup.termCode + "-" + scope.selectedBudgetScenario.id; + var sectionGroupCostId = scope.sectionGroupCosts.idsByUniqueKey[uniqueKey]; + var sectionGroupCost = sectionGroupCostId ? scope.sectionGroupCosts.list[sectionGroupCostId] : null; + + // No matching active sectionGroupCost found for this sectionGroup + if (!sectionGroupCost) { + var change = { + payload: { + type: "create", + sectionGroup: sectionGroup, + sectionGroupCost: null, + }, + term: sectionGroup.termCode.slice(-2), + courseTitle: sectionGroup.title, + course: sectionGroup.subjectCode + " " + sectionGroup.courseNumber, + sortKey: sectionGroup.subjectCode + sectionGroup.courseNumber + scope.courses.list[sectionGroup.courseId].sequencePattern, + display: { + subTitle: scope.courses.list[sectionGroup.courseId].sequencePattern, + changeText: "add course", + scheduleText: "check", + scenarioText: "", + tooltip: "Are you sure you want to add " + sectionGroup.subjectCode + " " + sectionGroup.courseNumber + "?" + } + }; + + changes.push(change); + } else if (sectionGroupCost.disabled) { + var change = { + payload: { + type: "update", + disabled: false, + sectionGroupCost: sectionGroupCost, + }, + term: sectionGroup.termCode.slice(-2), + courseTitle: sectionGroup.title, + course: sectionGroup.subjectCode + " " + sectionGroup.courseNumber, + sortKey: sectionGroup.subjectCode + sectionGroup.courseNumber + scope.courses.list[sectionGroup.courseId].sequencePattern, + display: { + subTitle: scope.courses.list[sectionGroup.courseId].sequencePattern, + changeText: "add course", + scheduleText: "check", + scenarioText: "", + tooltip: "Are you sure you want to remove " + sectionGroup.subjectCode + " " + sectionGroup.courseNumber + "?" + } + }; + + changes.push(change); + } + }); + + return changes; + }; + + scope.calculateAddedCourses = function (sectionGroupCostIds) { + var changes = []; + + sectionGroupCostIds.forEach(function(sectionGroupCostId) { + var sectionGroupCost = scope.sectionGroupCosts.list[sectionGroupCostId]; + // Course is on scenario but not on schedule + if (sectionGroupCost.isBudgeted && sectionGroupCost.isScheduled == false) { + var change = { + payload: { + type: "remove", + sectionGroupCost: sectionGroupCost, + sectionGroup: null, + }, + term: sectionGroupCost.termCode.slice(-2), + courseTitle: sectionGroupCost.title, + course: sectionGroupCost.subjectCode + " " + sectionGroupCost.courseNumber, + sortKey: sectionGroupCost.subjectCode + sectionGroupCost.courseNumber + sectionGroupCost.sequencePattern, + display: { + subTitle: sectionGroupCost.sequencePattern, + changeText: "remove course", + scheduleText: "", + scenarioText: "check", + tooltip: "Are you sure you want to remove " + sectionGroupCost.subjectCode + " " + sectionGroupCost.courseNumber + "?" + } + }; + + changes.push(change); + } + }); + + return changes; + }; + + scope.breakIntoTerms = function(changes) { + var separatedChanges = {}; + + changes.forEach(function(change) { + separatedChanges[change.term] = separatedChanges[change.term] || []; + separatedChanges[change.term].push(change); + }); + + return separatedChanges; + }; + + scope.addCourseHeaders = function (changes) { + var changesWithHeaders = {}; + Object.keys(changes).forEach(function (term) { + var termChanges = changes[term]; + changesWithHeaders[term] = []; + scope.totalChanges[term] = changes[term].length; + + var courseHeader = null; + + termChanges.forEach(function(change) { + if (!courseHeader || courseHeader.course != change.course) { + courseHeader = { + courseHeader: true, + payload: { + changes: [] + }, + term: change.term, + course: change.course, + courseTitle: change.courseTitle, + sortKey: change.course, + display: { + title: change.course, + subTitle: change.courseTitle, + } + }; + + changesWithHeaders[term].push(courseHeader); + } + + courseHeader.payload.changes.push(change); + changesWithHeaders[term].push(change); + }); + }); + + return changesWithHeaders; + }; + + scope.applyTermChanges = function () { + scope.changes[scope.termNav.activeTerm].forEach(function (change) { + if (change.courseHeader) { + scope.applyCourseChanges(change); + } + }); + }; + + scope.applyCourseChanges = function (courseHeader) { + courseHeader.payload.changes.forEach(function(change) { + scope.applyChange(change); + }); + }; + + scope.applyChange = function (change) { + // Create sectionGroupCost + if (change.payload.type == "create") { + var sectionGroupCost = { + sectionGroupId: change.payload.sectionGroup.id, + disabled: false + }; + + BudgetActions.createSectionGroupCost(sectionGroupCost); + return; + } + + // Disable sectionGroupCost + if (change.payload.type == "remove") { + change.payload.sectionGroupCost.disabled = true; + BudgetActions.updateSectionGroupCost(change.payload.sectionGroupCost); + return; + } + + // Update sectionGroupCost + if ('enrollment' in change.payload) { + change.payload.sectionGroupCost.enrollment = change.payload.enrollment; + } else if ('taCount' in change.payload) { + change.payload.sectionGroupCost.taCount = change.payload.taCount; + } else if ('readerCount' in change.payload) { + change.payload.sectionGroupCost.readerCount = change.payload.readerCount; + } else if ('instructorTypeId' in change.payload) { + change.payload.sectionGroupCost.instructorTypeId = change.payload.instructorTypeId; + } else if ('instructorId' in change.payload) { + change.payload.sectionGroupCost.instructorId = change.payload.instructorId; + } else if ('sectionCount' in change.payload) { + change.payload.sectionGroupCost.sectionCount = change.payload.sectionCount; + } else if ('disabled' in change.payload) { + change.payload.sectionGroupCost.disabled = change.payload.disabled; + } + + BudgetActions.updateSectionGroupCost(change.payload.sectionGroupCost); + }; + + // Recalculate on changes + $rootScope.$on('budgetStateChanged', function (event, data) { + scope.calculateChanges(); + }); + + // Calculate on instantation of directive + scope.calculateChanges(); + } + }; +}; + +export default incomingChanges; diff --git a/app/budget/services/actions/budgetActions.js b/app/budget/services/actions/budgetActions.js index 29a729092..8da3d783a 100644 --- a/app/budget/services/actions/budgetActions.js +++ b/app/budget/services/actions/budgetActions.js @@ -295,6 +295,14 @@ class BudgetActions { sectionGroupCost.unitsLow = course.unitsLow; sectionGroupCost.taCount = sectionGroup.teachingAssistantAppointments; sectionGroupCost.readerCount = sectionGroup.readerAppointments; + sectionGroupCost.title = course.title; + sectionGroupCost.subjectCode = course.subjectCode; + sectionGroupCost.courseNumber = course.courseNumber; + sectionGroupCost.sequencePattern = course.sequencePattern; + sectionGroupCost.sectionCount = sectionGroup.sectionCount; + sectionGroupCost.enrollment = sectionGroup.plannedSeats; + sectionGroupCost.instructorId = sectionGroup.assignedInstructorIds[0]; + sectionGroupCost.instructorTypeId = sectionGroup.assignedInstructorTypeIds[0]; } sectionGroupCost.termCode = TermService.termToTermCode(term, year); diff --git a/app/budget/services/budgetReducers.js b/app/budget/services/budgetReducers.js index 55db7cd68..fcd3d4f10 100644 --- a/app/budget/services/budgetReducers.js +++ b/app/budget/services/budgetReducers.js @@ -710,7 +710,7 @@ class BudgetReducers { }, sectionNav: { activeTab: "Summary", - allTabs: ["Schedule Costs", "Funds", "Summary", "Instructor List", "Course List"] + allTabs: ["Schedule Costs", "Funds", "Summary", "Instructor List", "Course List", "Incoming Changes"] }, termNav: { activeTab: null, @@ -914,7 +914,7 @@ class BudgetReducers { newState.lineItemComments = scope.lineItemCommentReducers(action, scope._state.lineItemComments); newState.lineItemCategories = scope.lineItemCategoryReducers(action, scope._state.lineItemCategories); newState.sectionGroupCosts = scope.sectionGroupCostReducers(action, scope._state.sectionGroupCosts); - newState.sectionGroupCostComments = scope.sectionGroupCostCommentReducers(action, scope._state.sectionGroupCostComments); + newState.sectionGroupCostComments = scope.sectionGroupCostCommentReducers(action, scope._state.sectionGroupCostComments); newState.scheduleSectionGroups = scope.scheduleSectionGroupReducers(action, scope._state.scheduleSectionGroups); newState.assignedInstructors = scope.assignedInstructorReducers(action, scope._state.assignedInstructors); newState.activeInstructors = scope.activeInstructorReducers(action, scope._state.activeInstructors); @@ -947,6 +947,7 @@ class BudgetReducers { newPageState.lineItemCategories = BudgetSelectors.generateLineItemCategories(newState.lineItemCategories); newPageState.courses = newState.courses; newPageState.sectionGroups = newState.sectionGroups; + newPageState.sectionGroupCosts = newState.sectionGroupCosts; newPageState.calculatedCourseList = newState.calculatedCourseList; newPageState.calculatedScheduleCosts = newState.calculatedScheduleCosts;