From 2dd57694162091c0ee4d2b1c915fb9216c860254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=E1=BB=91ng=20Th=C3=A0nh=20=C4=90=E1=BA=A1t?= <84076965+tongthanhdat009@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:39:47 +0700 Subject: [PATCH] feat(admissions): implement tuition section in admin editor Add functionality to manage tuition data within the admissions admin editor. This includes implementing state normalization for tuition series, label extraction, and the rendering logic for the tuition tab. - Add `renderTuitionSection` and `normalizeTuitionState` logic - Implement helper functions for tuition series and point normalization - Integrate tuition tab routing in the editor script --- .../admissions/partials/editor-script.ejs | 375 ++++++++++++++++++ 1 file changed, 375 insertions(+) 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 = {};