diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ddb6af06c..977623889 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,20 +22,14 @@ jobs:
- name: Create a .env file
run: cp .env.ci .env
- - name: Build images
- run: make build
-
- - name: Run containers
- run: make up-detatched
-
- - name: Install React app
- run: npm ci
+ - name: Install poetry
+ run: pip install poetry
- - name: Collect static
- run: make collectstatic
+ - name: Install python dependencies
+ run: poetry install --with dev
- name: Run ruff
- run: make ruff
+ run: make ruff-check
- name: Run isort
run: make isort-check
@@ -43,15 +37,27 @@ jobs:
- name: Run black
run: make black-check
+ - name: Install React app
+ run: npm ci
+
+ - name: Build images
+ run: make build
+
+ - name: Run containers
+ run: make up-detatched
+
+ - name: Run makemigrations in check mode
+ run: make check-migrations
+
+ - name: Collect static
+ run: make collectstatic
+
- name: Run tests
run: make test-ci
- name: Bring up chrome
run: docker compose up -d chrome
- - name: Run makemigrations in check mode
- run: make check-migrations
-
- name: Run BDD tests
run: make bdd
diff --git a/config/settings/base.py b/config/settings/base.py
index 2fa2e73a4..f5648772c 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -40,6 +40,7 @@
VCAP_SERVICES = env.json("VCAP_SERVICES", {})
INSTALLED_APPS = [
+ "payroll.apps.PayrollConfig",
"user",
"authbroker_client",
"future_years.apps.FutureYearsConfig",
diff --git a/config/urls.py b/config/urls.py
index 9b85eacbd..193478e7e 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -35,6 +35,7 @@
path("data-lake/", include("data_lake.urls")),
path("oscar_return/", include("oscar_return.urls")),
path("upload_split_file/", include("upload_split_file.urls")),
+ path("payroll/", include("payroll.urls")),
path("admin/", admin.site.urls),
# TODO - split below out into develop only?
path(
diff --git a/core/models.py b/core/models.py
index ee7ce1a75..0c69ae69f 100644
--- a/core/models.py
+++ b/core/models.py
@@ -61,6 +61,17 @@ def future_year_dictionary(self):
)
+class FinancialYearQuerySet(models.QuerySet):
+ def current(self):
+ return self.filter(current=True).first()
+
+ def future(self):
+ current_financial_year = self.current().financial_year
+ return self.filter(financial_year__gt=current_financial_year).order_by(
+ "-financial_year"
+ )
+
+
class FinancialYear(BaseModel):
"""Key and representation of the financial year"""
@@ -69,7 +80,7 @@ class FinancialYear(BaseModel):
current = models.BooleanField(default=False)
archived = models.BooleanField(default=False)
archived_at = models.DateTimeField(blank=True, null=True)
- objects = models.Manager() # The default manager.
+ objects = FinancialYearQuerySet.as_manager()
financial_year_objects = FinancialYearManager()
def __str__(self):
diff --git a/core/templates/base_generic.html b/core/templates/base_generic.html
index 61a1b6f0c..d35f42160 100644
--- a/core/templates/base_generic.html
+++ b/core/templates/base_generic.html
@@ -244,7 +244,11 @@
{% block scripts %}
{% endblock %}
{% vite_dev_client react=False %}
diff --git a/costcentre/fixtures/test_costcentre_data.json b/costcentre/fixtures/test_costcentre_data.json
new file mode 100644
index 000000000..ed96055ef
--- /dev/null
+++ b/costcentre/fixtures/test_costcentre_data.json
@@ -0,0 +1,74 @@
+[
+ {
+ "model": "costcentre.departmentalgroup",
+ "pk": "8888AA",
+ "fields": {
+ "created": "2024-10-03T16:05:41.208Z",
+ "updated": "2024-10-03T16:05:41.208Z",
+ "active": true,
+ "group_name": "Departmental Group 0",
+ "director_general": null,
+ "treasury_segment_fk": null
+ }
+ },
+ {
+ "model": "costcentre.directorate",
+ "pk": "88880A",
+ "fields": {
+ "created": "2024-10-03T16:05:41.226Z",
+ "updated": "2024-10-03T16:05:41.226Z",
+ "active": true,
+ "directorate_name": "Directorate 1",
+ "director": null,
+ "group": "8888AA"
+ }
+ },
+ {
+ "model": "costcentre.costcentre",
+ "pk": "888812",
+ "fields": {
+ "created": "2024-10-03T16:05:41.230Z",
+ "updated": "2024-10-03T16:05:41.230Z",
+ "active": true,
+ "cost_centre_name": "Cost Centre 2",
+ "directorate": "88880A",
+ "deputy_director": null,
+ "business_partner": null,
+ "bsce_email": null,
+ "disabled_with_actual": false,
+ "used_for_travel": true
+ }
+ },
+ {
+ "model": "costcentre.costcentre",
+ "pk": "888813",
+ "fields": {
+ "created": "2024-10-03T16:05:41.233Z",
+ "updated": "2024-10-03T16:05:41.233Z",
+ "active": true,
+ "cost_centre_name": "Cost Centre 3",
+ "directorate": "88880A",
+ "deputy_director": null,
+ "business_partner": null,
+ "bsce_email": null,
+ "disabled_with_actual": false,
+ "used_for_travel": true
+ }
+ },
+ {
+ "model": "costcentre.costcentre",
+ "pk": "888814",
+ "fields": {
+ "created": "2024-10-03T16:05:41.236Z",
+ "updated": "2024-10-03T16:05:41.236Z",
+ "active": true,
+ "cost_centre_name": "Cost Centre 4",
+ "directorate": "88880A",
+ "deputy_director": null,
+ "business_partner": null,
+ "bsce_email": null,
+ "disabled_with_actual": false,
+ "used_for_travel": true
+ }
+ }
+]
diff --git a/docker-compose.yml b/docker-compose.yml
index 0f0f48781..e93709a7f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,3 @@
-version: "3"
-
services:
web:
build:
diff --git a/forecast/models.py b/forecast/models.py
index 89180b7b1..f4c20daf1 100644
--- a/forecast/models.py
+++ b/forecast/models.py
@@ -153,6 +153,12 @@ def __str__(self):
return self.forecast_expenditure_type_name
+class FinancialPeriodQuerySet(models.QuerySet):
+ def months(self):
+ """Filter by real months, excluding the 3 adjustment periods (13, 14, 15)."""
+ return self.filter(financial_period_code__lte=12)
+
+
class FinancialPeriodManager(models.Manager):
def month_display_list(self):
return list(
@@ -296,7 +302,7 @@ class FinancialPeriod(BaseModel):
actual_loaded_previous_year = models.BooleanField(default=False)
display_figure = models.BooleanField(default=True)
- objects = models.Manager() # The default manager.
+ objects = FinancialPeriodQuerySet.as_manager()
financial_period_info = FinancialPeriodManager()
class Meta:
diff --git a/front_end/src/Apps/Payroll.jsx b/front_end/src/Apps/Payroll.jsx
new file mode 100644
index 000000000..10a6ccb06
--- /dev/null
+++ b/front_end/src/Apps/Payroll.jsx
@@ -0,0 +1,62 @@
+import { useEffect, useReducer } from "react";
+
+import EditPayroll from "../Components/EditPayroll";
+import * as api from "../Components/EditPayroll/api";
+
+const initialPayrollState = [];
+
+export default function Payroll() {
+ const [payroll, dispatch] = useReducer(payrollReducer, initialPayrollState);
+
+ useEffect(() => {
+ api.getPayrollData().then((data) => dispatch({ type: "fetched", data }));
+ }, []);
+
+ // Handlers
+ async function handleSavePayroll() {
+ try {
+ api.postPayrollData(payroll);
+ } catch (error) {
+ console.error("Error saving payroll: ", error);
+ }
+ }
+
+ function handleTogglePayPeriods(employeeNo, index, enabled) {
+ dispatch({ type: "updatePayPeriods", employeeNo, index, enabled });
+ }
+
+ return (
+
+ );
+}
+
+function payrollReducer(payroll, action) {
+ switch (action.type) {
+ case "fetched": {
+ return action.data;
+ }
+ case "updatePayPeriods": {
+ return payroll.map((employeeRow) => {
+ if (employeeRow.employee_no == action.employeeNo) {
+ const updatedPayPeriods = employeeRow.pay_periods.map(
+ (period, index) => {
+ if (index + 1 >= action.index + 1) {
+ return !action.enabled;
+ }
+ return period;
+ }
+ );
+ return {
+ ...employeeRow,
+ pay_periods: updatedPayPeriods,
+ };
+ }
+ return employeeRow;
+ });
+ }
+ }
+}
diff --git a/front_end/src/Components/EditPayroll/EmployeeRow/index.jsx b/front_end/src/Components/EditPayroll/EmployeeRow/index.jsx
new file mode 100644
index 000000000..0463ea4d0
--- /dev/null
+++ b/front_end/src/Components/EditPayroll/EmployeeRow/index.jsx
@@ -0,0 +1,25 @@
+import { useState } from "react";
+
+const EmployeeRow = ({ row, onTogglePayPeriods }) => {
+ return (
+
+ {row.name} |
+ {row.employee_no} |
+ {row.pay_periods.map((enabled, index) => {
+ return (
+
+
+ onTogglePayPeriods(row.employee_no, index, enabled)
+ }
+ />
+ |
+ );
+ })}
+
+ );
+};
+
+export default EmployeeRow;
diff --git a/front_end/src/Components/EditPayroll/PayrollTable/index.jsx b/front_end/src/Components/EditPayroll/PayrollTable/index.jsx
new file mode 100644
index 000000000..713089373
--- /dev/null
+++ b/front_end/src/Components/EditPayroll/PayrollTable/index.jsx
@@ -0,0 +1,38 @@
+import EmployeeRow from "../EmployeeRow";
+
+/**
+ *
+ * @param {object} props
+ * @param {types.PayrollData[]} props.payroll
+ * @returns
+ */
+export default function PayrollTable({ headers, payroll, onTogglePayPeriods }) {
+ return (
+ <>
+
+
+
+ {headers.map((header) => {
+ return (
+
+ {header}
+ |
+ );
+ })}
+
+
+
+ {payroll.map((row) => {
+ return (
+
+ );
+ })}
+
+
+ >
+ );
+}
diff --git a/front_end/src/Components/EditPayroll/api.js b/front_end/src/Components/EditPayroll/api.js
new file mode 100644
index 000000000..e1d734894
--- /dev/null
+++ b/front_end/src/Components/EditPayroll/api.js
@@ -0,0 +1,35 @@
+import { getData, postData } from "../../Util";
+
+import * as types from "./types";
+
+/**
+ * Fetch payroll data and return it as a promise.
+ * @returns {Promise} A promise resolving to an array of objects containing employee information.
+ */
+export function getPayrollData() {
+ return getData(getPayrollApiUrl()).then((data) => data.data);
+}
+
+/**
+ * Post modified payroll data.
+ *
+ * @param {types.PayrollData[]} payrollData - Payroll data to be sent.
+ * @returns {import("../../Util").PostDataResponse} Updated payroll data received.
+ */
+export function postPayrollData(payrollData) {
+ return postData(getPayrollApiUrl(), JSON.stringify(payrollData));
+}
+
+/**
+ * Return the payroll API URL.
+ *
+ * This function relies on the `costCentreCode` and `financialYear` being available on
+ * the `window` object.
+ *
+ * @returns {string} The payroll API URL.
+ */
+function getPayrollApiUrl() {
+ const costCentreCode = window.costCentreCode;
+ const financialYear = window.financialYear;
+ return `/payroll/api/${costCentreCode}/${financialYear}/`;
+}
diff --git a/front_end/src/Components/EditPayroll/index.jsx b/front_end/src/Components/EditPayroll/index.jsx
new file mode 100644
index 000000000..6985856dc
--- /dev/null
+++ b/front_end/src/Components/EditPayroll/index.jsx
@@ -0,0 +1,43 @@
+import * as types from "./types";
+import PayrollTable from "./PayrollTable/index";
+
+/**
+ *
+ * @param {object} props
+ * @param {types.PayrollData[]} props.payroll
+ * @returns
+ */
+export default function EditPayroll({
+ payroll,
+ onSavePayroll,
+ onTogglePayPeriods,
+}) {
+ const headers = [
+ "Name",
+ "Employee No",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ "Jan",
+ "Feb",
+ "Mar",
+ ];
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/front_end/src/Components/EditPayroll/types.js b/front_end/src/Components/EditPayroll/types.js
new file mode 100644
index 000000000..13c2fadad
--- /dev/null
+++ b/front_end/src/Components/EditPayroll/types.js
@@ -0,0 +1,8 @@
+/**
+ * @typedef {Object} PayrollData
+ * @property {string} name - The employee's name.
+ * @property {string} employee_no - The employee's number.
+ * @property {boolean[]} pay_periods - Whether the employee is being paid in periods.
+ */
+
+export const Types = {};
diff --git a/front_end/src/Util.js b/front_end/src/Util.js
index 63257acd4..cbaf08700 100644
--- a/front_end/src/Util.js
+++ b/front_end/src/Util.js
@@ -38,7 +38,46 @@ export const formatValue = (value) => {
return nfObject.format(pounds);
}
+/**
+ * Make a HTTP request to fetch JSON data.
+ *
+ * @param {string} url The HTTP request URL for the API.
+ * @returns JSON response data.
+ */
+export async function getData(url) {
+ const request = new Request(
+ url,
+ {
+ method: "GET",
+ },
+ );
+
+ let resp = await fetch(request);
+
+ if (!resp.ok) {
+ throw new Error("Something went wrong");
+ }
+
+ return await resp.json();
+}
+
+/**
+ * @typedef {object} PostDataResponse
+ * @property {number} status
+ * @property {object} data
+ */
+
+/**
+ * POST data to an API.
+ *
+ * @param {string} url - URL to POST data to.
+ * @param {object} data - Payload to send.
+ * @returns {PostDataResponse}
+ */
export async function postData(url = '', data = {}) {
+ // NOTE: This doesn't work! We set `CSRF_COOKIE_HTTPONLY = True` so the code which
+ // uses this function include the CSRF token as part of the submitted form data by
+ // pulling it from DOM.
var csrftoken = getCookie('csrftoken');
/*
@@ -129,3 +168,18 @@ export const processForecastData = (forecastData) => {
return rows;
}
+
+
+/**
+ * Retrieves JSON data from an HTML element with the given ID.
+ *
+ * @param {string} id The ID of the HTML element containing the JSON data.
+ * @returns {Promise