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
This commit is contained in:
Tống Thành Đạt
2026-04-22 20:39:47 +07:00
parent 2cee0172f2
commit 2dd5769416
@@ -74,6 +74,11 @@
return; return;
} }
if (tabKey === "tuition") {
renderTuitionSection(container);
return;
}
if (tabKey === "calculator") { if (tabKey === "calculator") {
renderCalculatorSection(container); renderCalculatorSection(container);
return; return;
@@ -101,6 +106,14 @@
return fields.find((field) => field.key === fieldKey) || {}; 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) { function renderKeyDatesSection(container) {
normalizeKeyDatesState(); 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 = `
<div>
<label class="form-label fw-semibold mb-1">${escapeHtml(seriesField.label || "Chart series")}</label>
<div class="form-text mt-0">${escapeHtml(seriesField.helpText || "Edit chart data as a matrix: each row is an X label and each series is a column.")}</div>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" data-add-series="true">
<i class="fas fa-chart-line me-1"></i>Add Series
</button>
<button type="button" class="btn btn-outline-primary btn-sm" data-add-row="true">
<i class="fas fa-plus me-1"></i>Add Row
</button>
</div>
`;
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 = `
<div class="fw-semibold">${escapeHtml(pointTimeField.label || "X label")}</div>
<div class="small text-muted">${escapeHtml(pointTimeField.placeholder || "Example: Year 1")}</div>
`;
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 = '<i class="fas fa-trash-alt"></i>';
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 = '<i class="fas fa-trash-alt"></i>';
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() { function normalizeCalculatorState() {
if (!isObject(state.calculator)) { if (!isObject(state.calculator)) {
state.calculator = {}; state.calculator = {};