diff --git a/views/admin/admissions/partials/editor-script.ejs b/views/admin/admissions/partials/editor-script.ejs
index 2c991b3..88c317d 100644
--- a/views/admin/admissions/partials/editor-script.ejs
+++ b/views/admin/admissions/partials/editor-script.ejs
@@ -74,6 +74,11 @@
return;
}
+ if (tabKey === "tuition") {
+ renderTuitionSection(container);
+ return;
+ }
+
if (tabKey === "calculator") {
renderCalculatorSection(container);
return;
@@ -101,6 +106,14 @@
return fields.find((field) => field.key === fieldKey) || {};
}
+ function getNestedObjectListItemFieldConfig(tabKey, listKey, nestedListKey, fieldKey) {
+ const listField = getObjectFieldConfig(tabKey, listKey);
+ const itemFields = listField?.itemSchema?.fields || [];
+ const nestedListField = itemFields.find((field) => field.key === nestedListKey) || {};
+ const nestedItemFields = nestedListField?.itemSchema?.fields || [];
+ return nestedItemFields.find((field) => field.key === fieldKey) || {};
+ }
+
function renderKeyDatesSection(container) {
normalizeKeyDatesState();
@@ -386,6 +399,368 @@
});
}
+ function normalizeTuitionState() {
+ if (!isObject(state.tuition)) {
+ state.tuition = {};
+ }
+
+ const tuition = state.tuition;
+ tuition.id = String(tuition.id || "");
+ tuition.title = String(tuition.title || "");
+ tuition.chartTitle = String(tuition.chartTitle || "");
+ tuition.chartDescription = String(tuition.chartDescription || "");
+
+ const rawSeries = Array.isArray(tuition.series) ? tuition.series : [];
+ const rawLabels = extractTuitionLabels(rawSeries);
+
+ tuition.series = rawSeries.length
+ ? rawSeries.map((series, seriesIndex) => normalizeTuitionSeries(series, seriesIndex, rawLabels))
+ : [createDefaultTuitionSeries(0, rawLabels)];
+
+ const normalizedLabels = extractTuitionLabels(tuition.series);
+ tuition.series.forEach((series, seriesIndex) => {
+ series.points = normalizedLabels.map((label, pointIndex) => {
+ const existingPoint = Array.isArray(series.points) ? series.points[pointIndex] : null;
+ return {
+ time: label,
+ value: Number.isFinite(Number(existingPoint?.value))
+ ? Number(existingPoint.value)
+ : 0,
+ };
+ });
+
+ if (!series.label) {
+ series.label = `Series ${seriesIndex + 1}`;
+ }
+ });
+ }
+
+ function normalizeTuitionSeries(series, seriesIndex, labels) {
+ const source = isObject(series) ? series : {};
+ const rawPoints = Array.isArray(source.points) ? source.points : [];
+ const effectiveLabels = labels.length ? labels : extractTuitionLabels([source]);
+
+ return {
+ label: String(source.label || `Series ${seriesIndex + 1}`),
+ color: String(source.color || defaultTuitionSeriesColor(seriesIndex)),
+ points: effectiveLabels.map((label, pointIndex) => {
+ const point = rawPoints[pointIndex];
+ return {
+ time: label,
+ value: Number.isFinite(Number(point?.value)) ? Number(point.value) : 0,
+ };
+ }),
+ };
+ }
+
+ function extractTuitionLabels(seriesList) {
+ const labels = [];
+ (Array.isArray(seriesList) ? seriesList : []).forEach((series) => {
+ (Array.isArray(series?.points) ? series.points : []).forEach((point, pointIndex) => {
+ const label = String(point?.time || point?.label || `Year ${pointIndex + 1}`).trim();
+ if (!labels.includes(label)) {
+ labels.push(label);
+ }
+ });
+ });
+
+ return labels.length ? labels : ["Year 1", "Year 2", "Year 3", "Year 4"];
+ }
+
+ function createDefaultTuitionSeries(seriesIndex, labels) {
+ const effectiveLabels = Array.isArray(labels) && labels.length
+ ? labels
+ : ["Year 1", "Year 2", "Year 3", "Year 4"];
+
+ return {
+ label: `Series ${seriesIndex + 1}`,
+ color: defaultTuitionSeriesColor(seriesIndex),
+ points: effectiveLabels.map((label) => ({
+ time: label,
+ value: 0,
+ })),
+ };
+ }
+
+ function defaultTuitionSeriesColor(seriesIndex) {
+ return ["#0F172A", "#c49b27", "#2563eb", "#16a34a"][seriesIndex] || "#0F172A";
+ }
+
+ function renderTuitionSection(container) {
+ normalizeTuitionState();
+
+ const tuition = state.tuition;
+ const titleField = getObjectFieldConfig("tuition", "title");
+ const chartTitleField = getObjectFieldConfig("tuition", "chartTitle");
+ const chartDescriptionField = getObjectFieldConfig("tuition", "chartDescription");
+ const seriesField = getObjectFieldConfig("tuition", "series");
+ const seriesLabelField = getObjectListItemFieldConfig("tuition", "series", "label");
+ const pointTimeField = getNestedObjectListItemFieldConfig("tuition", "series", "points", "time");
+ const pointValueField = getNestedObjectListItemFieldConfig("tuition", "series", "points", "value");
+ const row = document.createElement("div");
+ row.className = "row g-3";
+ container.appendChild(row);
+
+ renderLeafField(
+ {
+ key: "title",
+ label: titleField.label || "Section title",
+ type: titleField.type || "text",
+ maxLength: titleField.maxLength || 45,
+ helpText: titleField.helpText,
+ },
+ row,
+ tuition,
+ "title",
+ { path: "tuition.title", root: state, item: tuition },
+ );
+ renderLeafField(
+ {
+ key: "chartTitle",
+ label: chartTitleField.label || "Chart title",
+ type: chartTitleField.type || "text",
+ maxLength: chartTitleField.maxLength || 40,
+ helpText: chartTitleField.helpText,
+ },
+ row,
+ tuition,
+ "chartTitle",
+ { path: "tuition.chartTitle", root: state, item: tuition },
+ );
+ renderLeafField(
+ {
+ key: "chartDescription",
+ label: chartDescriptionField.label || "Chart description",
+ type: chartDescriptionField.type || "textarea",
+ maxLength: chartDescriptionField.maxLength || 140,
+ rows: chartDescriptionField.rows || 3,
+ helpText: chartDescriptionField.helpText,
+ },
+ row,
+ tuition,
+ "chartDescription",
+ { path: "tuition.chartDescription", root: state, item: tuition },
+ );
+
+ const matrixCol = createCol("col-12");
+ const matrixCard = document.createElement("div");
+ matrixCard.className = "cms-editor-group";
+
+ const matrixHeader = document.createElement("div");
+ matrixHeader.className = "d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3";
+ matrixHeader.innerHTML = `
+
+
+
${escapeHtml(seriesField.helpText || "Edit chart data as a matrix: each row is an X label and each series is a column.")}
+
+
+
+
+
+ `;
+ matrixCard.appendChild(matrixHeader);
+
+ const tableWrap = document.createElement("div");
+ tableWrap.className = "table-responsive";
+ const table = document.createElement("table");
+ table.className = "table align-middle mb-0";
+
+ const thead = document.createElement("thead");
+ const headRow = document.createElement("tr");
+
+ const xLabelHead = document.createElement("th");
+ xLabelHead.style.minWidth = "220px";
+ xLabelHead.innerHTML = `
+ ${escapeHtml(pointTimeField.label || "X label")}
+ ${escapeHtml(pointTimeField.placeholder || "Example: Year 1")}
+ `;
+ headRow.appendChild(xLabelHead);
+
+ tuition.series.forEach((series, seriesIndex) => {
+ const th = document.createElement("th");
+ th.style.minWidth = "220px";
+
+ const seriesWrap = document.createElement("div");
+ seriesWrap.className = "d-flex flex-column gap-2";
+
+ const topRow = document.createElement("div");
+ topRow.className = "d-flex align-items-center gap-2";
+
+ const labelInput = document.createElement("input");
+ labelInput.type = "text";
+ labelInput.className = "form-control form-control-sm";
+ labelInput.maxLength = seriesLabelField.maxLength || 20;
+ labelInput.placeholder = seriesLabelField.label || `Series ${seriesIndex + 1}`;
+ labelInput.value = series.label || "";
+ labelInput.addEventListener("input", function () {
+ series.label = labelInput.value;
+ });
+
+ const colorInput = document.createElement("input");
+ colorInput.type = "color";
+ colorInput.className = "form-control form-control-color";
+ colorInput.value = series.color || defaultTuitionSeriesColor(seriesIndex);
+ colorInput.title = "Series color";
+ colorInput.addEventListener("input", function () {
+ series.color = colorInput.value;
+ });
+
+ const removeButton = document.createElement("button");
+ removeButton.type = "button";
+ removeButton.className = "cms-remove-button";
+ removeButton.title = "Remove series";
+ removeButton.innerHTML = '';
+ removeButton.disabled = tuition.series.length <= 1;
+ removeButton.addEventListener("click", function () {
+ if (tuition.series.length <= 1) {
+ return;
+ }
+ tuition.series.splice(seriesIndex, 1);
+ renderSection("tuition");
+ });
+
+ topRow.appendChild(labelInput);
+ topRow.appendChild(colorInput);
+ topRow.appendChild(removeButton);
+
+ const meta = document.createElement("div");
+ meta.className = "small text-muted";
+ meta.textContent = `${pointValueField.label || "Value"} column`;
+
+ seriesWrap.appendChild(topRow);
+ seriesWrap.appendChild(meta);
+ th.appendChild(seriesWrap);
+ headRow.appendChild(th);
+ });
+
+ thead.appendChild(headRow);
+ table.appendChild(thead);
+
+ const tbody = document.createElement("tbody");
+ const labels = extractTuitionLabels(tuition.series);
+
+ if (!labels.length) {
+ const emptyRow = document.createElement("tr");
+ const emptyCell = document.createElement("td");
+ emptyCell.colSpan = tuition.series.length + 1;
+ emptyCell.className = "text-center text-muted py-4";
+ emptyCell.textContent = "No chart rows yet.";
+ emptyRow.appendChild(emptyCell);
+ tbody.appendChild(emptyRow);
+ } else {
+ labels.forEach((label, rowIndex) => {
+ const tr = document.createElement("tr");
+
+ const labelCell = document.createElement("td");
+ const labelGroup = document.createElement("div");
+ labelGroup.className = "d-flex align-items-start gap-2";
+
+ const labelInput = document.createElement("input");
+ labelInput.type = "text";
+ labelInput.className = "form-control";
+ labelInput.maxLength = pointTimeField.maxLength || 14;
+ labelInput.placeholder = pointTimeField.placeholder || `Year ${rowIndex + 1}`;
+ labelInput.value = label;
+ labelInput.addEventListener("input", function () {
+ const nextLabel = labelInput.value;
+ tuition.series.forEach((series) => {
+ if (!Array.isArray(series.points)) {
+ series.points = [];
+ }
+ if (!series.points[rowIndex]) {
+ series.points[rowIndex] = { time: nextLabel, value: 0 };
+ }
+ series.points[rowIndex].time = nextLabel;
+ });
+ });
+
+ const removeRowButton = document.createElement("button");
+ removeRowButton.type = "button";
+ removeRowButton.className = "cms-remove-button mt-1";
+ removeRowButton.title = "Remove row";
+ removeRowButton.innerHTML = '';
+ removeRowButton.disabled = labels.length <= 1;
+ removeRowButton.addEventListener("click", function () {
+ if (labels.length <= 1) {
+ return;
+ }
+ tuition.series.forEach((series) => {
+ if (Array.isArray(series.points)) {
+ series.points.splice(rowIndex, 1);
+ }
+ });
+ renderSection("tuition");
+ });
+
+ labelGroup.appendChild(labelInput);
+ labelGroup.appendChild(removeRowButton);
+ labelCell.appendChild(labelGroup);
+ tr.appendChild(labelCell);
+
+ tuition.series.forEach((series) => {
+ const td = document.createElement("td");
+ const input = document.createElement("input");
+ input.type = "number";
+ input.className = "form-control";
+ input.min = String(pointValueField.min || 0);
+ input.step = pointValueField.step || "1";
+ input.value = Number.isFinite(Number(series.points?.[rowIndex]?.value))
+ ? String(series.points[rowIndex].value)
+ : "0";
+ input.addEventListener("input", function () {
+ if (!Array.isArray(series.points)) {
+ series.points = [];
+ }
+ if (!series.points[rowIndex]) {
+ series.points[rowIndex] = { time: labels[rowIndex], value: 0 };
+ }
+ series.points[rowIndex].value = Number(input.value || 0);
+ });
+ td.appendChild(input);
+ tr.appendChild(td);
+ });
+
+ tbody.appendChild(tr);
+ });
+ }
+
+ table.appendChild(tbody);
+ tableWrap.appendChild(table);
+ matrixCard.appendChild(tableWrap);
+
+ matrixHeader.querySelector('[data-add-series="true"]').addEventListener("click", function () {
+ const labels = extractTuitionLabels(tuition.series);
+ tuition.series.push(createDefaultTuitionSeries(tuition.series.length, labels));
+ renderSection("tuition");
+ });
+
+ matrixHeader.querySelector('[data-add-row="true"]').addEventListener("click", function () {
+ const nextLabel = `Year ${extractTuitionLabels(tuition.series).length + 1}`;
+ tuition.series.forEach((series) => {
+ if (!Array.isArray(series.points)) {
+ series.points = [];
+ }
+ series.points.push({
+ time: nextLabel,
+ value: 0,
+ });
+ });
+ renderSection("tuition");
+ });
+
+ const note = document.createElement("div");
+ note.className = "form-text mt-3";
+ note.textContent = "Each row is one X-axis label. Each series column stores the numeric value for that label.";
+ matrixCard.appendChild(note);
+
+ matrixCol.appendChild(matrixCard);
+ container.appendChild(matrixCol);
+ }
+
function normalizeCalculatorState() {
if (!isObject(state.calculator)) {
state.calculator = {};