From a48183eb40e8d0f205b5a4e79d858d53f7b919e6 Mon Sep 17 00:00:00 2001 From: Caitlin Barnard <54268863+CaitBarnard@users.noreply.github.com> Date: Thu, 7 Nov 2024 16:10:50 +0000 Subject: [PATCH] FFT-80 Payroll override edit forecast (#539) Co-authored-by: Sam Dudley --- .../commands/create_stub_forecast_data.py | 2 +- forecast/templates/forecast/edit/edit.html | 7 ++- forecast/views/edit_forecast.py | 17 ++++-- .../Common/ToggleCheckbox/index.jsx | 26 +++++++++ .../src/Components/EditForecast/index.jsx | 15 ++++-- front_end/src/Components/Table/index.jsx | 4 +- front_end/src/Components/TableCell/index.jsx | 54 ++++++++++--------- front_end/src/Util.js | 43 ++++++++++++++- front_end/styles/styles.scss | 4 ++ 9 files changed, 135 insertions(+), 37 deletions(-) create mode 100644 front_end/src/Components/Common/ToggleCheckbox/index.jsx diff --git a/core/management/commands/create_stub_forecast_data.py b/core/management/commands/create_stub_forecast_data.py index 9f163bf42..cd327fa1b 100644 --- a/core/management/commands/create_stub_forecast_data.py +++ b/core/management/commands/create_stub_forecast_data.py @@ -34,7 +34,7 @@ def create_monthly_figures(): current_financial_year = FinancialYear.objects.get(current=True) cost_centre_fk = CostCentre.objects.first() programme_list = ProgrammeCode.objects.all() - project_list = ProjectCode.objects.all() + project_list = list(ProjectCode.objects.all()) + [None] natural_account_list = NaturalCode.objects.all() financial_periods = FinancialPeriod.objects.exclude( period_long_name__icontains="adj" diff --git a/forecast/templates/forecast/edit/edit.html b/forecast/templates/forecast/edit/edit.html index 63a0345b4..8b43b6999 100644 --- a/forecast/templates/forecast/edit/edit.html +++ b/forecast/templates/forecast/edit/edit.html @@ -38,14 +38,17 @@ {% csrf_token %} {{ paste_form }} -{% endblock %} -{% block scripts %} + {% endblock %} + {% block scripts %} + {{ view.get_payroll_forecast_report|json_script:'payroll_forecast_data' }} {% vite_dev_client %} {% vite_js 'src/index.jsx' %} diff --git a/forecast/views/edit_forecast.py b/forecast/views/edit_forecast.py index 96b84e9b6..4a20de7b4 100644 --- a/forecast/views/edit_forecast.py +++ b/forecast/views/edit_forecast.py @@ -6,11 +6,12 @@ from django.db import transaction from django.db.models import Exists, OuterRef, Prefetch, Q from django.http import JsonResponse -from django.shortcuts import redirect +from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.views.generic.base import TemplateView from django.views.generic.edit import FormView +from core.models import FinancialYear from core.utils.generic_helpers import get_current_financial_year, get_year_display from costcentre.models import CostCentre from forecast.forms import ( @@ -51,6 +52,7 @@ NoCostCentreCodeInURLError, NoFinancialYearInURLError, ) +from payroll.services import payroll as payroll_service UNAVAILABLE_FORECAST_EDIT_TITLE = "Forecast editing is locked" @@ -472,12 +474,10 @@ def get_context_data(self, **kwargs): "cost_centre_code": self.cost_centre_code, } ) - financial_code_serialiser = get_financial_code_serialiser( self.cost_centre_code, self.financial_year, ) - serialiser_data = financial_code_serialiser.data forecast_dump = json.dumps(serialiser_data) if self.financial_year == get_current_financial_year(): @@ -498,9 +498,20 @@ def get_context_data(self, **kwargs): context["forecast_dump"] = forecast_dump context["actuals"] = actual_data context["period_display"] = period_display + context["can_toggle_payroll"] = self.request.user.is_superuser return context + def get_payroll_forecast_report(self): + cost_centre_obj = get_object_or_404(CostCentre, pk=self.cost_centre_code) + financial_year_obj = get_object_or_404(FinancialYear, pk=self.financial_year) + queryset = payroll_service.payroll_forecast_report( + cost_centre_obj, financial_year_obj + ) + data = list(queryset) + + return data + @property def future_year_display(self): if self._future_year_display is None: diff --git a/front_end/src/Components/Common/ToggleCheckbox/index.jsx b/front_end/src/Components/Common/ToggleCheckbox/index.jsx new file mode 100644 index 000000000..b2001e380 --- /dev/null +++ b/front_end/src/Components/Common/ToggleCheckbox/index.jsx @@ -0,0 +1,26 @@ +export default function ToggleCheckbox({ toggle, handler, id, value, label }) { + return ( + <> +
+
+
+
+ + +
+
+
+
+ + ); +} diff --git a/front_end/src/Components/EditForecast/index.jsx b/front_end/src/Components/EditForecast/index.jsx index 575b528c5..ca82c1c0c 100644 --- a/front_end/src/Components/EditForecast/index.jsx +++ b/front_end/src/Components/EditForecast/index.jsx @@ -14,6 +14,7 @@ import { postData, processForecastData, } from '../../Util' +import ToggleCheckbox from '../Common/ToggleCheckbox'; function EditForecast() { @@ -27,12 +28,19 @@ function EditForecast() { const editCellId = useSelector(state => state.edit.cellId); const [sheetUpdating, setSheetUpdating] = useState(false) + const [isPayrollEnabled, setIsPayrollEnabled] = useState(false) + + const handleIsPayrollEnabled = () => { + setIsPayrollEnabled(!isPayrollEnabled); + + localStorage.setItem('isPayrollEnabled', JSON.stringify(!isPayrollEnabled)); + } useEffect(() => { const timer = () => { setTimeout(() => { if (window.table_data) { - let rows = processForecastData(window.table_data) + let rows = processForecastData(window.table_data, window.payroll_forecast_data, isPayrollEnabled) dispatch({ type: SET_CELLS, cells: rows @@ -45,7 +53,7 @@ function EditForecast() { } timer() - }, [dispatch]) + }, [dispatch, isPayrollEnabled]) useEffect(() => { const capturePaste = (event) => { @@ -317,6 +325,7 @@ function EditForecast() { return ( + {window.can_toggle_payroll === "True" && } {errorMessage != null &&

@@ -332,7 +341,7 @@ function EditForecast() {

} - +
); } diff --git a/front_end/src/Components/Table/index.jsx b/front_end/src/Components/Table/index.jsx index 2cb6703a6..c6360bf88 100644 --- a/front_end/src/Components/Table/index.jsx +++ b/front_end/src/Components/Table/index.jsx @@ -23,7 +23,7 @@ import { SET_EDITING_CELL } from '../../Reducers/Edit' import { SET_SELECTED_ROW, SELECT_ALL, UNSELECT_ALL } from '../../Reducers/Selected' -function Table({rowData, sheetUpdating}) { +function Table({rowData, sheetUpdating, payrollData}) { const dispatch = useDispatch(); const rows = useSelector(state => state.allCells.cells); @@ -167,7 +167,7 @@ function Table({rowData, sheetUpdating}) { {window.period_display.map((value, index) => { - return + return })} diff --git a/front_end/src/Components/TableCell/index.jsx b/front_end/src/Components/TableCell/index.jsx index a2ffb0c59..e42e9e4ce 100644 --- a/front_end/src/Components/TableCell/index.jsx +++ b/front_end/src/Components/TableCell/index.jsx @@ -9,9 +9,10 @@ import { import { SET_ERROR } from '../../Reducers/Error' import { SET_CELLS } from '../../Reducers/Cells' -const TableCell = ({rowIndex, cellId, cellKey, sheetUpdating}) => { +const TableCell = ({rowIndex, cellId, cellKey, sheetUpdating, payrollData}) => { let editing = false + const isPayrollEnabled = JSON.parse(localStorage.getItem('isPayrollEnabled')) const checkValue = (val) => { if (cellId === val) { @@ -46,6 +47,15 @@ const TableCell = ({rowIndex, cellId, cellKey, sheetUpdating}) => { const cell = useSelector(state => state.allCells.cells[rowIndex][cellKey]); const editCellId = useSelector(state => state.edit.cellId, checkValue); + const isOverride = () => { + // Is override if cell exists, has an override amount and is not an actual + return (cell && cell.overrideAmount !== null && cell.isEditable) + } + + if (isOverride()) { + cell.amount = cell.overrideAmount + } + const [isUpdating, setIsUpdating] = useState(false) const selectedRow = useSelector(state => state.selected.selectedRow, checkSelectRow); @@ -90,23 +100,18 @@ const TableCell = ({rowIndex, cellId, cellKey, sheetUpdating}) => { } const getClasses = () => { - let editable = '' - - if (!isEditable) { - editable = ' not-editable' - } - - if (!cell) - return "govuk-table__cell forecast-month-cell figure-cell " + (isSelected() ? 'selected' : '') + editable - - let negative = '' - - if (cell.amount < 0) { - negative = " negative" - } - - return "govuk-table__cell forecast-month-cell figure-cell " + (wasEdited() ? 'edited ' : '') + (isSelected() ? 'selected' : '') + editable + negative - } + const classes = ["govuk-table__cell", "forecast-month-cell", "figure-cell"]; + + if (!isEditable) classes.push("not-editable"); + if (isSelected()) classes.push("selected"); + if (!cell) return classes.join(" "); + + if (cell && cell.amount < 0) classes.push("negative"); + if (isOverride()) classes.push("override"); + if (wasEdited()) classes.push("edited"); + + return classes.join(" "); + }; const setContentState = (value) => { var re = /^-?\d*\.?\d{0,12}$/; @@ -155,12 +160,13 @@ const TableCell = ({rowIndex, cellId, cellKey, sheetUpdating}) => { payload.append("amount", intAmount) postData( - `/forecast/update-forecast/${window.cost_centre}/${window.financial_year}`, - payload + `/forecast/update-forecast/${window.cost_centre}/${window.financial_year}`, + payload ).then((response) => { - setIsUpdating(false) - if (response.status === 200) { - let rows = processForecastData(response.data) + setIsUpdating(false) + if (response.status === 200) { + // TODO (FFT-100): Test paste to excel with locked payroll forecast rows + let rows = processForecastData(response.data, payrollData, isPayrollEnabled) dispatch({ type: SET_CELLS, cells: rows @@ -256,7 +262,7 @@ const TableCell = ({rowIndex, cellId, cellKey, sheetUpdating}) => { className={getClasses()} id={getId()} onDoubleClick={ () => { - if (isEditable) { + if (isEditable && !isOverride()) { dispatch( SET_EDITING_CELL({ "cellId": cellId diff --git a/front_end/src/Util.js b/front_end/src/Util.js index cbaf08700..ee0510407 100644 --- a/front_end/src/Util.js +++ b/front_end/src/Util.js @@ -117,8 +117,13 @@ export async function postData(url = '', data = {}) { } } -export const processForecastData = (forecastData) => { +export const processForecastData = (forecastData, payrollData = null, isPayrollEnabled = false) => { let rows = []; + let mappedPayrollData = null + + if (isPayrollEnabled) { + mappedPayrollData = processPayrollData(payrollData) + } let financialCodeCols = [ "analysis1_code", @@ -149,15 +154,33 @@ export const processForecastData = (forecastData) => { colIndex++ } + const forecastKey = makeFinancialCodeKey( + rowData.programme, + rowData.natural_account_code, + rowData.analysis1_code, + rowData.analysis2_code, + rowData.project_code + ); + // eslint-disable-next-line for (const [key, monthlyFigure] of Object.entries(rowData["monthly_figures"])) { + let overrideAmount = null + + if (isPayrollEnabled && mappedPayrollData[forecastKey]) { + const period = `period_${(parseInt(key)+1)}_sum` + // TODO (FFT-99): Decide on decimal vs pence + // Old code stores monetary values in pence whereas new code has used decimals. + overrideAmount = mappedPayrollData[forecastKey][period] * 100 + } + cells[monthlyFigure.month] = { rowIndex: rowIndex, colIndex: colIndex, key: monthlyFigure.month, amount: monthlyFigure.amount, startingAmount: monthlyFigure.starting_amount, - isEditable: !monthlyFigure.actual + isEditable: !monthlyFigure.actual, + overrideAmount: overrideAmount, } colIndex++ @@ -169,6 +192,22 @@ export const processForecastData = (forecastData) => { return rows; } +const processPayrollData = (payrollData) => { + const results = {}; + + for (const [key, value] of Object.entries(payrollData)) { + const generatedKey = makeFinancialCodeKey(value.programme_code, value.pay_element__type__group__natural_code) + + results[generatedKey] = value; + } + + return results +} + +const makeFinancialCodeKey = (programme, nac, analysis1=null, analysis2=null, project=null) => { + return `${programme}/${nac}/${analysis1}/${analysis2}/${project}` +} + /** * Retrieves JSON data from an HTML element with the given ID. diff --git a/front_end/styles/styles.scss b/front_end/styles/styles.scss index ea962f76b..7f909fbbd 100644 --- a/front_end/styles/styles.scss +++ b/front_end/styles/styles.scss @@ -223,6 +223,10 @@ th { background-color: rgba(86, 148, 202, 0.25); } +.override { + background-color: rgba(201, 155, 75, 0.25); +} + .link-button { border: none; padding: 0 !important;