Merge branch 'fea/dat-24042026-submission-management' of https://gits.techvanguard.vn/UKSOURCE/cms.lams into merge/toan-24042026

This commit is contained in:
2026-04-24 23:33:12 +07:00
18 changed files with 1534 additions and 54 deletions
+13 -4
View File
@@ -55,11 +55,20 @@
<%- include("partials/templates", { editorUi }) %>
<script type="application/json" id="partnershipsPageBootstrapJson"><%- JSON.stringify({
data,
backendUrl,
editorConfig,
editorUi,
}) %></script>
<script>
window.partnershipsPageData = <%- JSON.stringify(data) %>;
window.partnershipsBackendUrl = <%- JSON.stringify(backendUrl) %>;
window.partnershipsEditorConfig = <%- JSON.stringify(editorConfig) %>;
window.partnershipsEditorUi = <%- JSON.stringify(editorUi) %>;
const partnershipsPageBootstrap = JSON.parse(
document.getElementById("partnershipsPageBootstrapJson").textContent || "{}",
);
window.partnershipsPageData = partnershipsPageBootstrap.data;
window.partnershipsBackendUrl = partnershipsPageBootstrap.backendUrl;
window.partnershipsEditorConfig = partnershipsPageBootstrap.editorConfig;
window.partnershipsEditorUi = partnershipsPageBootstrap.editorUi;
</script>
<%- include("partials/editor-script") %>
@@ -97,12 +97,6 @@
renderPartners();
});
document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () {
state.inquiryForm.fields.push(getDefaultInquiryField());
ensurePartnershipIds();
renderInquiryFields();
});
document.getElementById("resetPartnershipsForm")?.addEventListener("click", function () {
window.location.reload();
});
@@ -378,7 +372,7 @@
input.value = field[key] || "";
input.addEventListener("input", function () {
field[key] = input.value;
if (key === "label") {
if (key === "label") {
title.textContent = input.value || `${inquiryFieldFields.label?.label || "Field"} ${index + 1}`;
}
if (key === "type") {
@@ -401,11 +395,6 @@
}
});
node.querySelector("[data-remove-item]").addEventListener("click", function () {
state.inquiryForm.fields.splice(index, 1);
renderInquiryFields();
});
node.querySelector("[data-add-option]").addEventListener("click", function () {
field.options = Array.isArray(field.options) ? field.options : [];
field.options.push("");
@@ -444,7 +433,6 @@
inquiryFieldsList.appendChild(node);
});
initSortable(inquiryFieldsList, state.inquiryForm.fields, renderInquiryFields, '[data-item="inquiry-field"]');
}
function initSortable(container, list, rerender, draggableSelector) {
@@ -16,17 +16,13 @@
<div class="mb-3">
<div>
<label class="form-label fw-semibold mb-1"><%= inquiryUi.fields?.label || "Form fields" %></label>
<div class="form-text mt-0"><%= inquiryUi.fieldsHelpText || inquiryUi.fields?.helpText || "" %></div>
<div class="form-text mt-0">
Field structure is fixed to match the inquiry form: First Name, Last Name, Organization Name, Partnership Type, and Message. You can edit field text, required state, width, and Partnership Type select options.
</div>
</div>
</div>
<div id="inquiryFieldsList"></div>
<button type="button" class="cms-add-button mt-3" id="addInquiryFieldBtn">
<i class="fas fa-plus me-2"></i><%= inquiryUi.fields?.addLabel || "Add Field" %>
</button>
</div>
</div>
</div>
</div>
@@ -100,9 +100,6 @@
<div class="card cms-item-card mb-3" data-item="inquiry-field">
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div class="d-flex align-items-center gap-2">
<button type="button" class="drag-handle" title="Drag to reorder">
<i class="fas fa-grip-vertical"></i>
</button>
<div>
<div class="fw-semibold" data-title>Field</div>
<div class="small text-muted" data-subtitle></div>
@@ -112,9 +109,6 @@
<button type="button" class="cms-collapse-toggle" data-toggle-item title="Collapse item">
<i class="fas fa-chevron-down"></i>
</button>
<button type="button" class="cms-remove-button" data-remove-item title="Remove item">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
<div class="card-body">
@@ -129,7 +123,7 @@
</div>
<div class="col-md-4">
<label class="form-label fw-semibold"><%= inquiryFieldFields.type?.label || "Field type" %></label>
<select class="form-select" data-field="type">
<select class="form-select" data-field="type" disabled>
<% (inquiryFieldFields.type?.options || []).forEach((option) => { %>
<option value="<%= option.value %>"><%= option.label %></option>
<% }) %>
@@ -144,10 +138,10 @@
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" data-field="required">
<label class="form-check-label fw-semibold"><%= inquiryFieldFields.required?.label || "Required field" %></label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" data-field="required">
<label class="form-check-label fw-semibold"><%= inquiryFieldFields.required?.label || "Required field" %></label>
</div>
</div>
<div class="col-12" data-options-wrap>
<div class="cms-editor-group">
+137
View File
@@ -0,0 +1,137 @@
<div class="container my-4">
<div class="d-flex justify-content-between align-items-center 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>
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-bottom">
<ul class="nav nav-tabs card-header-tabs" role="tablist">
<% sources.forEach((source) => { %>
<li class="nav-item" role="presentation">
<button
class="nav-link <%= activeSource === source ? 'active' : '' %>"
type="button"
role="tab"
data-submission-tab="<%= source %>"
>
<%= sourceLabels[source] %>
<small class="text-muted">(<%= sourceUrls[source] %>)</small>
</button>
</li>
<% }) %>
</ul>
</div>
<div class="card-body">
<div class="row g-3 align-items-end mb-4">
<div class="col-md-3">
<label class="form-label small text-muted">Search</label>
<input type="search" class="form-control" id="submissionSearch" placeholder="Name, email, phone">
</div>
<div class="col-md-2">
<label class="form-label small text-muted">Status</label>
<select class="form-select" id="submissionStatus">
<option value="">All statuses</option>
<% statuses.forEach((status) => { %>
<option value="<%= status %>"><%= status.replace("_", " ") %></option>
<% }) %>
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted">Start Date</label>
<input type="date" class="form-control" id="submissionStartDate">
</div>
<div class="col-md-2">
<label class="form-label small text-muted">End Date</label>
<input type="date" class="form-control" id="submissionEndDate">
</div>
<div class="col-md-1">
<label class="form-label small text-muted">Show</label>
<select class="form-select" id="submissionPageSize">
<option value="10">10</option>
<option value="20" selected>20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div class="col-md-2 d-flex gap-2">
<button type="button" class="btn btn-primary flex-fill" id="submissionApplyFilters">
<i class="fas fa-filter me-1"></i>Filter
</button>
<button type="button" class="btn btn-outline-secondary" id="submissionClearFilters">
Clear
</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Date</th>
<th data-submission-name-column>Name</th>
<th data-submission-email-column>Email</th>
<th data-submission-phone-column>Phone</th>
<th>Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody id="submissionRows">
<tr>
<td colspan="6" class="text-center text-muted py-5">Loading submissions...</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-muted" id="submissionPaginationSummary"></small>
<div class="btn-group">
<button type="button" class="btn btn-outline-secondary btn-sm" id="submissionPrevPage">Previous</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="submissionNextPage">Next</button>
</div>
</div>
</div>
</div>
</div>
<%- include("partials/submission-modal", { statuses }) %>
<script type="application/json" id="submissionManagerConfigJson"><%- JSON.stringify({
sources,
statuses,
statusesBySource: {
newsletter: newsletterStatuses,
},
initialSource: activeSource,
urlTabParam: true,
hiddenFieldsBySource: {
partnership: {
email: true,
phone: true,
},
newsletter: {
name: true,
phone: true,
},
},
apiBySource: {
newsletter: {
list: "/admin/newsletter-subscriptions/data",
detail: "/admin/newsletter-subscriptions",
update: "/admin/newsletter-subscriptions",
},
},
itemLabelBySource: {
newsletter: "subscriptions",
},
}) %></script>
<script>
window.submissionManagerConfig = JSON.parse(
document.getElementById("submissionManagerConfigJson").textContent || "{}",
);
</script>
<%- include("partials/submission-manager-script") %>
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
loadSubmissions();
})();
</script>
@@ -0,0 +1,62 @@
<div class="modal fade" id="submissionDetailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable" style="max-height: calc(100vh - 3.5rem);">
<div class="modal-content" style="max-height: calc(100vh - 3.5rem);">
<div class="modal-header">
<div>
<h5 class="modal-title mb-0">Submission Detail</h5>
<small class="text-muted" id="submissionDetailMeta"></small>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" style="overflow-y: auto;">
<input type="hidden" id="submissionDetailId">
<div class="row g-3 mb-4">
<div class="col-md-6" data-submission-name-field>
<label class="form-label small text-muted" id="submissionDetailNameLabel">Name</label>
<div class="fw-semibold" id="submissionDetailName">-</div>
</div>
<%# Hidden by Partnerships submissions because that inquiry form has no email field. %>
<div class="col-md-6" data-submission-email-field>
<label class="form-label small text-muted" id="submissionDetailEmailLabel">Email</label>
<div><a id="submissionDetailEmail" href="#">-</a></div>
</div>
<%# Hidden by Partnerships submissions because that inquiry form has no phone field. %>
<div class="col-md-6" data-submission-phone-field>
<label class="form-label small text-muted" id="submissionDetailPhoneLabel">Phone</label>
<div id="submissionDetailPhone">-</div>
</div>
<div class="col-md-6">
<label class="form-label small text-muted" id="submissionDetailPageLabel">Page</label>
<div id="submissionDetailPage">-</div>
</div>
</div>
<div class="mb-4">
<label class="form-label small text-muted">Submitted Values</label>
<div class="border rounded p-3 bg-light" id="submissionPayload"></div>
</div>
<div class="row g-3">
<div class="col-md-5">
<label class="form-label">Status</label>
<select class="form-select" id="submissionDetailStatus">
<% statuses.forEach((status) => { %>
<option value="<%= status %>"><%= status.replace("_", " ") %></option>
<% }) %>
</select>
</div>
<div class="col-12">
<label class="form-label">Internal note</label>
<textarea class="form-control" id="submissionDetailNote" rows="4" maxlength="2000" placeholder="Add internal processing notes"></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="submissionSaveDetail">
<i class="fas fa-save me-1"></i>Save
</button>
</div>
</div>
</div>
</div>