forked from UKSOURCE/cms.lams
feat(admin): implement unified submission and newsletter management
Introduce a centralized system to manage all website form submissions and newsletter subscriptions. - Add `Submission` and `NewsletterSubscription` models with MongoDB schema validation - Implement `submissionController` and `newsletterSubscriptionController` for CRUD operations and filtering - Create a unified admin UI for reviewing submissions across different sources (home, request, contact, partnership, newsletter) - Add database migration scripts for creating collections and indexes - Refactor partnership inquiry forms to use a fixed field structure - Update admin navigation and server CORS settings to support PATCH requests
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
<script>
|
||||
(function () {
|
||||
const config = window.submissionManagerConfig || {};
|
||||
const sources = config.sources || ["home", "request", "contact"];
|
||||
const statuses = config.statuses || [];
|
||||
const statusesBySource = config.statusesBySource || {};
|
||||
const hiddenFieldsBySource = config.hiddenFieldsBySource || {};
|
||||
const apiBySource = config.apiBySource || {};
|
||||
const itemLabelBySource = config.itemLabelBySource || {};
|
||||
const rows = document.getElementById("submissionRows");
|
||||
const searchInput = document.getElementById("submissionSearch");
|
||||
const statusInput = document.getElementById("submissionStatus");
|
||||
const startDateInput = document.getElementById("submissionStartDate");
|
||||
const endDateInput = document.getElementById("submissionEndDate");
|
||||
const pageSizeInput = document.getElementById("submissionPageSize");
|
||||
const summary = document.getElementById("submissionPaginationSummary");
|
||||
const prevButton = document.getElementById("submissionPrevPage");
|
||||
const nextButton = document.getElementById("submissionNextPage");
|
||||
const saveButton = document.getElementById("submissionSaveDetail");
|
||||
const detailModalNode = document.getElementById("submissionDetailModal");
|
||||
if (detailModalNode && detailModalNode.parentElement !== document.body) {
|
||||
document.body.appendChild(detailModalNode);
|
||||
}
|
||||
const detailModal = detailModalNode ? new bootstrap.Modal(detailModalNode) : null;
|
||||
const state = {
|
||||
source: config.initialSource || sources[0] || "home",
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
limit: parseInt(pageSizeInput?.value, 10) || 20,
|
||||
};
|
||||
|
||||
applyFieldVisibility();
|
||||
renderStatusFilterOptions();
|
||||
|
||||
document.querySelectorAll("[data-submission-tab]").forEach((tab) => {
|
||||
tab.addEventListener("click", function () {
|
||||
document.querySelectorAll("[data-submission-tab]").forEach((item) => item.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
state.source = tab.dataset.submissionTab;
|
||||
state.page = 1;
|
||||
updateUrlTab(state.source);
|
||||
applyFieldVisibility();
|
||||
renderStatusFilterOptions();
|
||||
loadSubmissions();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("submissionApplyFilters")?.addEventListener("click", function () {
|
||||
state.page = 1;
|
||||
loadSubmissions();
|
||||
});
|
||||
|
||||
document.getElementById("submissionClearFilters")?.addEventListener("click", function () {
|
||||
if (searchInput) searchInput.value = "";
|
||||
if (statusInput) statusInput.value = "";
|
||||
if (startDateInput) startDateInput.value = "";
|
||||
if (endDateInput) endDateInput.value = "";
|
||||
state.page = 1;
|
||||
loadSubmissions();
|
||||
});
|
||||
|
||||
searchInput?.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
state.page = 1;
|
||||
loadSubmissions();
|
||||
}
|
||||
});
|
||||
|
||||
prevButton?.addEventListener("click", function () {
|
||||
if (state.page > 1) {
|
||||
state.page -= 1;
|
||||
loadSubmissions();
|
||||
}
|
||||
});
|
||||
|
||||
nextButton?.addEventListener("click", function () {
|
||||
if (state.page < state.totalPages) {
|
||||
state.page += 1;
|
||||
loadSubmissions();
|
||||
}
|
||||
});
|
||||
|
||||
pageSizeInput?.addEventListener("change", function () {
|
||||
state.limit = parseInt(pageSizeInput.value, 10) || 20;
|
||||
state.page = 1;
|
||||
loadSubmissions();
|
||||
});
|
||||
|
||||
saveButton?.addEventListener("click", saveDetail);
|
||||
|
||||
async function loadSubmissions() {
|
||||
if (!rows) return;
|
||||
rows.innerHTML = `<tr><td colspan="${getTableColumnCount()}" class="text-center text-muted py-5">Loading submissions...</td></tr>`;
|
||||
const params = new URLSearchParams({
|
||||
source: state.source,
|
||||
page: String(state.page),
|
||||
limit: String(state.limit),
|
||||
});
|
||||
if (searchInput?.value) params.set("search", searchInput.value);
|
||||
if (statusInput?.value) params.set("status", statusInput.value);
|
||||
if (startDateInput?.value) params.set("startDate", startDateInput.value);
|
||||
if (endDateInput?.value) params.set("endDate", endDateInput.value);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getListUrl()}?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || "Unable to load submissions");
|
||||
}
|
||||
state.totalPages = result.pagination.totalPages || 1;
|
||||
renderRows(result.data || []);
|
||||
renderPagination(result.pagination);
|
||||
} catch (error) {
|
||||
rows.innerHTML = `<tr><td colspan="${getTableColumnCount()}" class="text-center text-danger py-5">${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRows(items) {
|
||||
if (!items.length) {
|
||||
rows.innerHTML = `<tr><td colspan="${getTableColumnCount()}" class="text-center text-muted py-5">No submissions found</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
rows.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td>${formatDate(item.createdAt)}</td>
|
||||
${isNameHidden() ? "" : `<td>${escapeHtml(item.name || "-")}</td>`}
|
||||
${isEmailHidden() ? "" : `<td><a href="mailto:${escapeHtml(item.email || "")}">${escapeHtml(item.email || "-")}</a></td>`}
|
||||
${isPhoneHidden() ? "" : `<td>${escapeHtml(item.phone || "-")}</td>`}
|
||||
<td><span class="badge ${statusClass(item.status)} rounded-pill">${escapeHtml((item.status || "").replace("_", " "))}</span></td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" data-view-submission="${item._id}">
|
||||
<i class="fas fa-eye me-1"></i>View
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
rows.querySelectorAll("[data-view-submission]").forEach((button) => {
|
||||
button.addEventListener("click", function () {
|
||||
openDetail(button.dataset.viewSubmission);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderPagination(pagination) {
|
||||
if (summary) {
|
||||
summary.textContent = `Page ${pagination.page} of ${pagination.totalPages || 1} - ${pagination.total} ${getItemLabel()}`;
|
||||
}
|
||||
if (prevButton) prevButton.disabled = pagination.page <= 1;
|
||||
if (nextButton) nextButton.disabled = pagination.page >= (pagination.totalPages || 1);
|
||||
}
|
||||
|
||||
async function openDetail(id) {
|
||||
try {
|
||||
const response = await fetch(`${getDetailBaseUrl()}/${id}`);
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || "Unable to load submission");
|
||||
}
|
||||
fillDetail(result.data);
|
||||
detailModal?.show();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function fillDetail(item) {
|
||||
setText("submissionDetailMeta", `${item.source || ""} - ${formatDate(item.createdAt)}`);
|
||||
setValue("submissionDetailId", item._id);
|
||||
renderDetailStatusOptions(item.source, item.status);
|
||||
setValue("submissionDetailNote", item.internalNote || "");
|
||||
|
||||
const email = document.getElementById("submissionDetailEmail");
|
||||
applyFieldVisibility(item.source);
|
||||
|
||||
if (item.source === "partnership") {
|
||||
setText("submissionDetailNameLabel", getPayloadLabel("firstName", item.source));
|
||||
setText("submissionDetailName", item.payload?.firstName || "-");
|
||||
if (!isEmailHidden(item.source)) {
|
||||
setText("submissionDetailEmailLabel", getPayloadLabel("lastName", item.source));
|
||||
if (email) {
|
||||
email.textContent = item.payload?.lastName || "-";
|
||||
email.href = "#";
|
||||
}
|
||||
}
|
||||
if (!isPhoneHidden(item.source)) {
|
||||
setText("submissionDetailPhoneLabel", getPayloadLabel("organization", item.source));
|
||||
setText("submissionDetailPhone", item.payload?.organization || "-");
|
||||
}
|
||||
setText("submissionDetailPageLabel", getPayloadLabel("partnershipType", item.source));
|
||||
setText("submissionDetailPage", item.payload?.partnershipType || "-");
|
||||
} else {
|
||||
setText("submissionDetailNameLabel", "Name");
|
||||
setText("submissionDetailName", item.name || "-");
|
||||
setText("submissionDetailEmailLabel", "Email");
|
||||
if (email) {
|
||||
email.textContent = item.email || "-";
|
||||
email.href = item.email ? `mailto:${item.email}` : "#";
|
||||
}
|
||||
setText("submissionDetailPhoneLabel", "Phone");
|
||||
setText("submissionDetailPhone", item.phone || "-");
|
||||
setText("submissionDetailPageLabel", "Page");
|
||||
setText("submissionDetailPage", item.pageUrl || "-");
|
||||
}
|
||||
|
||||
const payload = document.getElementById("submissionPayload");
|
||||
if (payload) {
|
||||
payload.innerHTML = getPayloadEntries(item).map(([key, value]) => `
|
||||
<div class="row py-1 border-bottom">
|
||||
<div class="col-md-4 text-muted">${escapeHtml(getPayloadLabel(key, item.source))}</div>
|
||||
<div class="col-md-8">${escapeHtml(formatValue(value))}</div>
|
||||
</div>
|
||||
`).join("") || '<span class="text-muted">No payload values</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function getTableColumnCount() {
|
||||
return 6 - (isNameHidden() ? 1 : 0) - (isEmailHidden() ? 1 : 0) - (isPhoneHidden() ? 1 : 0);
|
||||
}
|
||||
|
||||
function applyFieldVisibility(source) {
|
||||
const nameHidden = isNameHidden(source);
|
||||
const emailHidden = isEmailHidden(source);
|
||||
const phoneHidden = isPhoneHidden(source);
|
||||
document.querySelectorAll("[data-submission-name-column], [data-submission-name-field]").forEach((node) => {
|
||||
node.classList.toggle("d-none", nameHidden);
|
||||
});
|
||||
document.querySelectorAll("[data-submission-email-column], [data-submission-email-field]").forEach((node) => {
|
||||
node.classList.toggle("d-none", emailHidden);
|
||||
});
|
||||
document.querySelectorAll("[data-submission-phone-column], [data-submission-phone-field]").forEach((node) => {
|
||||
node.classList.toggle("d-none", phoneHidden);
|
||||
});
|
||||
}
|
||||
|
||||
function isNameHidden(source) {
|
||||
return Boolean(hiddenFieldsBySource[source || state.source]?.name);
|
||||
}
|
||||
|
||||
function isEmailHidden(source) {
|
||||
return Boolean(hiddenFieldsBySource[source || state.source]?.email);
|
||||
}
|
||||
|
||||
function isPhoneHidden(source) {
|
||||
return Boolean(hiddenFieldsBySource[source || state.source]?.phone);
|
||||
}
|
||||
|
||||
function getStatuses(source) {
|
||||
return statusesBySource[source || state.source] || statuses;
|
||||
}
|
||||
|
||||
function renderStatusFilterOptions() {
|
||||
if (!statusInput) return;
|
||||
const previousValue = statusInput.value;
|
||||
statusInput.innerHTML = '<option value="">All statuses</option>';
|
||||
getStatuses().forEach((status) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = status;
|
||||
option.textContent = status.replace("_", " ");
|
||||
statusInput.appendChild(option);
|
||||
});
|
||||
statusInput.value = getStatuses().includes(previousValue) ? previousValue : "";
|
||||
}
|
||||
|
||||
function renderDetailStatusOptions(source, selectedStatus) {
|
||||
const node = document.getElementById("submissionDetailStatus");
|
||||
if (!node) return;
|
||||
node.innerHTML = "";
|
||||
getStatuses(source).forEach((status) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = status;
|
||||
option.textContent = status.replace("_", " ");
|
||||
node.appendChild(option);
|
||||
});
|
||||
node.value = selectedStatus || getStatuses(source)[0] || "";
|
||||
}
|
||||
|
||||
function getListUrl() {
|
||||
return apiBySource[state.source]?.list || "/admin/submissions/data";
|
||||
}
|
||||
|
||||
function getDetailBaseUrl() {
|
||||
return apiBySource[state.source]?.detail || "/admin/submissions";
|
||||
}
|
||||
|
||||
function getUpdateBaseUrl() {
|
||||
return apiBySource[state.source]?.update || "/admin/submissions";
|
||||
}
|
||||
|
||||
function getItemLabel() {
|
||||
return itemLabelBySource[state.source] || "submissions";
|
||||
}
|
||||
|
||||
function updateUrlTab(tabKey) {
|
||||
if (!config.urlTabParam || !tabKey) return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState({}, "", `${url.pathname}?${url.searchParams.toString()}`);
|
||||
}
|
||||
|
||||
async function saveDetail() {
|
||||
const id = document.getElementById("submissionDetailId")?.value;
|
||||
if (!id) return;
|
||||
try {
|
||||
const response = await fetch(`${getUpdateBaseUrl()}/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
status: document.getElementById("submissionDetailStatus")?.value,
|
||||
internalNote: document.getElementById("submissionDetailNote")?.value || "",
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || "Unable to update submission");
|
||||
}
|
||||
detailModal?.hide();
|
||||
loadSubmissions();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function setText(id, value) {
|
||||
const node = document.getElementById(id);
|
||||
if (node) node.textContent = value;
|
||||
}
|
||||
|
||||
function setValue(id, value) {
|
||||
const node = document.getElementById(id);
|
||||
if (node) node.value = value;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatKey(value) {
|
||||
return String(value || "").replace(/_/g, " ").replace(/([A-Z])/g, " $1").trim();
|
||||
}
|
||||
|
||||
function getPayloadEntries(item) {
|
||||
const payload = item.payload || {};
|
||||
if (item.source !== "partnership") {
|
||||
return Object.entries(payload);
|
||||
}
|
||||
|
||||
const orderedKeys = getPartnershipFieldOrder();
|
||||
return orderedKeys
|
||||
.filter((key) => Object.prototype.hasOwnProperty.call(payload, key))
|
||||
.map((key) => [key, payload[key]]);
|
||||
}
|
||||
|
||||
function getPayloadLabel(key, source) {
|
||||
if (source === "partnership") {
|
||||
const field = getPartnershipFields().find((item) => item.id === key);
|
||||
if (field?.label) {
|
||||
return field.label;
|
||||
}
|
||||
const fallback = {
|
||||
firstName: "First Name",
|
||||
lastName: "Last Name",
|
||||
organization: "Organization Name",
|
||||
partnershipType: "Partnership Type",
|
||||
message: "Message",
|
||||
};
|
||||
return fallback[key] || formatKey(key);
|
||||
}
|
||||
|
||||
return formatKey(key);
|
||||
}
|
||||
|
||||
function getPartnershipFieldOrder() {
|
||||
const fields = getPartnershipFields();
|
||||
if (fields.length > 0) {
|
||||
return fields.map((field) => field.id);
|
||||
}
|
||||
return ["firstName", "lastName", "organization", "partnershipType", "message"];
|
||||
}
|
||||
|
||||
function getPartnershipFields() {
|
||||
return Array.isArray(window.partnershipsPageData?.inquiryForm?.fields)
|
||||
? window.partnershipsPageData.inquiryForm.fields
|
||||
: [];
|
||||
}
|
||||
|
||||
function formatValue(value) {
|
||||
if (Array.isArray(value)) return value.join(", ");
|
||||
if (value && typeof value === "object") return JSON.stringify(value);
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
return value == null || value === "" ? "-" : String(value);
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (status === "new") return "bg-warning text-dark";
|
||||
if (status === "contacted") return "bg-info text-dark";
|
||||
if (status === "info_provided") return "bg-success";
|
||||
if (status === "closed") return "bg-secondary";
|
||||
if (status === "subscribed") return "bg-success";
|
||||
if (status === "unsubscribed") return "bg-secondary";
|
||||
return "bg-light text-dark";
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
loadSubmissions();
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user