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 = {};