Files
cms.techvanguard.vn/views/admin/admissions/calculator-option.ejs
T
Tống Thành Đạt a39c336dd4 feat(cms): enhance content editors with dynamic UI configs and validation
Implement a more flexible, configuration-driven approach for CMS editors across accreditation, admissions, and partnerships modules.
- Move UI labels, help texts, and field limits from hardcoded views to `editorUi` configurations in config files.
- Add server-side and client-side validation to prevent duplicate category tabs in accreditation and partnerships editors.
- Refactor partnership and admission views to dynamically render tabs and fields based on the provided configuration.
- Update field length constraints and default values across multiple content editors to better align with frontend requirements.
- Improve the admissions calculator editor with dynamic field configurations and default value fallbacks.
2026-04-22 18:06:03 +07:00

307 lines
14 KiB
Plaintext

<div class="container">
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
<div>
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
<p class="text-muted mb-0"><%= subtitle %></p>
</div>
<div class="d-flex gap-2">
<a href="/admin/admissions?tab=calculator" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Back to Calculator
</a>
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>View Page
</a>
</div>
</div>
<div class="row">
<div class="col-12">
<form action="/admin/admissions/calculator/<%= option.id %>/update" method="POST" class="content-with-fixed-buttons" id="calculatorOptionForm" novalidate>
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white">
<h6 class="mb-0"><i class="fas fa-calculator me-2"></i>Option Details</h6>
</div>
<div class="card-body p-4">
<div class="row g-3">
<div class="col-md-6">
<label for="label" class="form-label fw-semibold"><%= fieldConfig.label?.label || "Option label" %></label>
<input id="label" name="label" type="text" class="form-control" maxlength="<%= fieldLimits.label %>" value="<%= option.label %>" required />
<div class="field-meta-row">
<div class="form-text"><%= fieldConfig.label?.helpText || "" %></div>
<div class="field-char-count" data-counter-for="label">0/<%= fieldLimits.label %></div>
</div>
</div>
<div class="col-md-6">
<label for="paceLabel" class="form-label fw-semibold"><%= fieldConfig.paceLabel?.label || "Pace label" %></label>
<input id="paceLabel" name="paceLabel" type="text" class="form-control" maxlength="<%= fieldLimits.paceLabel %>" value="<%= option.paceLabel %>" required />
<div class="field-meta-row">
<div class="form-text"><%= fieldConfig.paceLabel?.helpText || "" %></div>
<div class="field-char-count" data-counter-for="paceLabel">0/<%= fieldLimits.paceLabel %></div>
</div>
</div>
<div class="col-md-6">
<label for="minPaceLabel" class="form-label fw-semibold"><%= fieldConfig.minPaceLabel?.label || "Minimum pace label" %></label>
<input id="minPaceLabel" name="minPaceLabel" type="text" class="form-control" maxlength="<%= fieldLimits.minPaceLabel %>" value="<%= option.minPaceLabel %>" required />
<div class="field-meta-row">
<div class="form-text"><%= fieldConfig.minPaceLabel?.helpText || "" %></div>
<div class="field-char-count" data-counter-for="minPaceLabel">0/<%= fieldLimits.minPaceLabel %></div>
</div>
</div>
<div class="col-md-6">
<label for="maxPaceLabel" class="form-label fw-semibold"><%= fieldConfig.maxPaceLabel?.label || "Maximum pace label" %></label>
<input id="maxPaceLabel" name="maxPaceLabel" type="text" class="form-control" maxlength="<%= fieldLimits.maxPaceLabel %>" value="<%= option.maxPaceLabel %>" required />
<div class="field-meta-row">
<div class="form-text"><%= fieldConfig.maxPaceLabel?.helpText || "" %></div>
<div class="field-char-count" data-counter-for="maxPaceLabel">0/<%= fieldLimits.maxPaceLabel %></div>
</div>
</div>
<div class="col-md-6">
<label for="resultLabel" class="form-label fw-semibold"><%= fieldConfig.resultLabel?.label || "Result label" %></label>
<input id="resultLabel" name="resultLabel" type="text" class="form-control" maxlength="<%= fieldLimits.resultLabel %>" value="<%= option.resultLabel %>" required />
<div class="field-meta-row">
<div class="form-text"><%= fieldConfig.resultLabel?.helpText || "" %></div>
<div class="field-char-count" data-counter-for="resultLabel">0/<%= fieldLimits.resultLabel %></div>
</div>
</div>
<div class="col-md-6">
<label for="monthlyAmount" class="form-label fw-semibold"><%= fieldConfig.monthlyAmount?.label || "Monthly amount" %></label>
<input id="monthlyAmount" name="monthlyAmount" type="number" class="form-control" min="1" step="1" inputmode="numeric" value="<%= option.monthlyAmount %>" required />
<div class="field-meta-row">
<div class="form-text"><%= fieldConfig.monthlyAmount?.helpText || "" %></div>
</div>
</div>
<div class="col-md-6">
<label for="monthlySuffix" class="form-label fw-semibold"><%= fieldConfig.monthlySuffix?.label || "Monthly suffix" %></label>
<input id="monthlySuffix" name="monthlySuffix" type="text" class="form-control" maxlength="<%= fieldLimits.monthlySuffix %>" value="<%= option.monthlySuffix %>" required />
<div class="field-meta-row">
<div class="form-text"><%= fieldConfig.monthlySuffix?.helpText || "" %></div>
<div class="field-char-count" data-counter-for="monthlySuffix">0/<%= fieldLimits.monthlySuffix %></div>
</div>
</div>
<div class="col-12">
<label class="form-label fw-semibold"><%= fieldConfig.noteIcon?.label || "Note icon" %></label>
<div class="cms-icon-combobox" id="noteIconCombobox">
<input type="hidden" name="noteIcon" id="noteIcon" value="<%= option.noteIcon %>" />
<button type="button" class="cms-icon-dropdown-trigger" id="noteIconTrigger">
<div class="cms-icon-dropdown-value">
<div class="cms-icon-preview" id="noteIconPreview"></div>
<div class="cms-icon-dropdown-text" id="noteIconText"></div>
</div>
<i class="fas fa-chevron-down cms-icon-dropdown-caret"></i>
</button>
<div class="cms-icon-dropdown-panel d-none" id="noteIconPanel">
<input type="text" class="form-control" id="noteIconSearch" placeholder="Search icon name" />
<div class="cms-icon-options" id="noteIconOptions"></div>
<div class="cms-icon-option-empty d-none" id="noteIconEmpty">No matching icons</div>
</div>
</div>
<div class="field-meta-row">
<div class="form-text"><%= fieldConfig.noteIcon?.helpText || "" %></div>
</div>
</div>
<div class="col-12">
<label for="note" class="form-label fw-semibold"><%= fieldConfig.note?.label || "Note text" %></label>
<input id="note" name="note" type="text" class="form-control" maxlength="<%= fieldLimits.note %>" value="<%= option.note %>" required />
<div class="field-meta-row">
<div class="form-text"><%= fieldConfig.note?.helpText || "" %></div>
<div class="field-char-count" data-counter-for="note">0/<%= fieldLimits.note %></div>
</div>
</div>
</div>
</div>
</div>
<div class="fixed-bottom-buttons">
<a href="/admin/admissions?tab=calculator" class="btn btn-secondary">
<i class="fas fa-times"></i>
<span>Cancel</span>
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
<span>Save Changes</span>
</button>
</div>
</form>
</div>
</div>
</div>
<script>
(function () {
const iconOptions = <%- JSON.stringify(iconOptions) %>;
const existingOptionLabels = <%- JSON.stringify(existingOptionLabels || []) %>;
const form = document.getElementById("calculatorOptionForm");
const labelInput = document.getElementById("label");
const hiddenInput = document.getElementById("noteIcon");
const combobox = document.getElementById("noteIconCombobox");
const trigger = document.getElementById("noteIconTrigger");
const panel = document.getElementById("noteIconPanel");
const preview = document.getElementById("noteIconPreview");
const text = document.getElementById("noteIconText");
const search = document.getElementById("noteIconSearch");
const optionsWrap = document.getElementById("noteIconOptions");
const empty = document.getElementById("noteIconEmpty");
form.querySelectorAll("[maxlength]").forEach((input) => {
const counter = form.querySelector(`[data-counter-for="${input.id}"]`);
const updateCounter = () => {
if (counter) {
counter.textContent = `${input.value.length}/${input.maxLength}`;
}
};
input.addEventListener("input", updateCounter);
updateCounter();
});
function normalizeLabel(value) {
return String(value || "").trim().toLowerCase();
}
function clearLabelValidation() {
labelInput.classList.remove("is-invalid");
}
function validateUniqueLabel() {
const normalizedValue = normalizeLabel(labelInput.value);
const isDuplicate = normalizedValue
? existingOptionLabels.some((label) => normalizeLabel(label) === normalizedValue)
: false;
labelInput.classList.toggle("is-invalid", isDuplicate);
return !isDuplicate;
}
labelInput.addEventListener("input", function () {
clearLabelValidation();
validateUniqueLabel();
});
form.addEventListener("submit", function (event) {
if (validateUniqueLabel()) {
return;
}
event.preventDefault();
showToast(
"Duplicate option label",
`Option label "${labelInput.value.trim()}" already exists. Please use a unique label before saving.`,
"danger",
);
labelInput.focus();
});
const monthlyAmountInput = document.getElementById("monthlyAmount");
monthlyAmountInput.addEventListener("input", function () {
this.value = this.value.replace(/[^\d]/g, "");
});
monthlyAmountInput.addEventListener("change", function () {
const numericValue = Number(this.value);
this.value = numericValue > 0 ? String(Math.floor(numericValue)) : "1";
});
function updateTrigger(value) {
preview.innerHTML = value
? `<i class="fa-solid ${escapeHtml(value)}"></i>`
: '<span class="text-muted small">--</span>';
text.innerHTML = value
? `<strong>${escapeHtml(value)}</strong><span class="text-muted small">Selected icon</span>`
: '<strong>Select icon</strong><span class="text-muted small">No icon selected</span>';
}
function setOpen(isOpen) {
combobox.classList.toggle("is-open", isOpen);
panel.classList.toggle("d-none", !isOpen);
if (isOpen) {
search.focus();
search.select();
}
}
function renderOptions(term) {
const query = String(term || "").trim().toLowerCase();
const filtered = iconOptions.filter((option) => option.toLowerCase().includes(query));
optionsWrap.innerHTML = "";
empty.classList.toggle("d-none", filtered.length > 0);
filtered.forEach((option) => {
const button = document.createElement("button");
button.type = "button";
button.className = `cms-icon-option ${hiddenInput.value === option ? "is-active" : ""}`;
button.innerHTML = `<span class="cms-icon-option-main"><i class="fa-solid ${escapeHtml(option)}"></i><span>${escapeHtml(option)}</span></span>${hiddenInput.value === option ? '<i class="fas fa-check small"></i>' : ""}`;
button.addEventListener("click", function () {
hiddenInput.value = option;
search.value = option;
updateTrigger(option);
renderOptions(option);
setOpen(false);
});
optionsWrap.appendChild(button);
});
}
trigger.addEventListener("click", function () {
const shouldOpen = panel.classList.contains("d-none");
setOpen(shouldOpen);
if (shouldOpen) {
renderOptions(search.value || hiddenInput.value);
}
});
search.addEventListener("input", function () {
renderOptions(search.value);
});
combobox.addEventListener("focusout", function () {
window.setTimeout(function () {
if (!combobox.contains(document.activeElement)) {
setOpen(false);
}
}, 0);
});
search.value = hiddenInput.value;
updateTrigger(hiddenInput.value);
renderOptions(hiddenInput.value);
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function showToast(title, message, type) {
const container =
document.querySelector(".toast-container") || createToastContainer();
const toast = document.createElement("div");
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
toast.setAttribute("role", "alert");
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(
title,
)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
container.appendChild(toast);
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
toast.addEventListener("hidden.bs.toast", function () {
toast.remove();
});
}
function createToastContainer() {
const container = document.createElement("div");
container.className = "toast-container position-fixed top-0 end-0 p-3";
document.body.appendChild(container);
return container;
}
})();
</script>