formu-registratsii-dlya-iven/script.js

365 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function () {
"use strict";
var page = document.currentScript.getAttribute("data-page");
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function fmtDate(iso) {
if (!iso) return "—";
var d = new Date(iso);
var p = function (n) { return (n < 10 ? "0" : "") + n; };
return p(d.getDate()) + "." + p(d.getMonth() + 1) + "." + d.getFullYear();
}
function toastStack() {
var el = document.getElementById("toasts");
if (!el) { toastStack.el = (toastStack.el = el = document.createElement("div")); el.className = "kt-ai-toast-stack"; document.body.appendChild(el); }
return el;
}
function toast(text, tone) {
var t = document.createElement("div");
t.className = "kt-ai-toast";
if (tone) t.setAttribute("data-tone", tone);
t.textContent = text;
toastStack().appendChild(t);
setTimeout(function () { t.remove(); }, 4500);
}
async function api(path, opts) {
var r = await fetch("api/" + path, opts);
var j = null;
try { j = await r.json(); } catch (_) {}
if (!r.ok) throw new Error((j && j.error) || "Ошибка сети, попробуйте ещё раз");
return j;
}
if (page === "form") initForm();
if (page === "app") initApp();
/* ================= Страница формы ================= */
function initForm() {
var form = document.getElementById("regForm");
var btn = document.getElementById("regSubmit");
var errFor = { fName: 0, fEmail: 1, fPhone: 2 };
function check(id, ok) {
var field = document.getElementById(id).closest(".kt-ai-field");
var err = field.querySelector(".error");
field.setAttribute("data-invalid", ok ? "false" : "true");
if (err) err.hidden = ok;
return ok;
}
["fName", "fEmail", "fPhone"].forEach(function (id) {
document.getElementById(id).addEventListener("input", function () { check(id, true); });
});
form.addEventListener("submit", async function (e) {
e.preventDefault();
var okName = check("fName", document.getElementById("fName").value.trim().length >= 3);
var okEmail = check("fEmail", /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(document.getElementById("fEmail").value.trim()));
var okPhone = check("fPhone", document.getElementById("fPhone").value.replace(/\D/g, "").length >= 10);
if (!(okName && okEmail && okPhone)) return;
btn.setAttribute("data-loading", "true");
try {
var data = {};
Array.prototype.forEach.call(form.elements, function (el) {
if (el.name) data[el.name] = el.value.trim();
});
await api("registrations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
document.getElementById("formBlock").hidden = true;
document.getElementById("successBlock").hidden = false;
form.reset();
toast("Заявка отправлена организаторам", "ok");
} catch (err) {
toast(err.message, "risk");
} finally {
btn.removeAttribute("data-loading");
}
});
document.getElementById("againBtn").addEventListener("click", function () {
document.getElementById("formBlock").hidden = false;
document.getElementById("successBlock").hidden = true;
document.getElementById("fName").focus();
});
}
/* ================= Кабинет организатора ================= */
var ALL = [];
var state = { q: "", status: "" };
function initApp() {
var search = document.getElementById("topSearch");
var seg = document.getElementById("statusSeg");
search.addEventListener("input", debounce(function () {
state.q = search.value.trim().toLowerCase();
refreshTable();
}, 150));
seg.addEventListener("click", function (e) {
var b = e.target.closest("button[data-status]");
if (!b) return;
state.status = b.getAttribute("data-status");
markSeg();
refreshTable();
});
document.getElementById("exportBtn").addEventListener("click", exportCsv);
document.getElementById("drawer").addEventListener("click", function (e) {
if (e.target === e.currentTarget) closeDrawer();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") closeDrawer();
});
renderTable = renderTable.bind(null);
load();
markSeg();
}
function debounce(fn, ms) {
var t;
return function () {
var a = arguments, c = this;
clearTimeout(t);
t = setTimeout(function () { fn.apply(c, a); }, ms);
};
}
function refreshTable() {
var rows = visible();
renderEventHead();
renderKpi(rows);
renderTable(rows);
}
function markSeg() {
var seg = document.getElementById("statusSeg");
Array.prototype.forEach.call(seg.children, function (b) {
b.setAttribute("data-active", b.getAttribute("data-status") === state.status ? "true" : "false");
});
}
async function load() {
var d;
try {
d = await api("registrations");
ALL = d.registrations || [];
} catch (err) {
ALL = null;
}
document.getElementById("navCount").textContent = ALL ? ALL.length : "";
if (!ALL) {
var evHead = document.getElementById("eventHead");
if (evHead) evHead.innerHTML = "";
var el = document.getElementById("regEmpty");
el.hidden = false;
el.innerHTML = '<div class="kt-ai-empty"><div class="icon-spot"><svg class="kt-icon kt-icon-lg" aria-hidden="true"><use href="#alert-circle"></use></svg></div><span class="title">Список заявок недоступен</span><span>' + esc(err.message) + "</span></div>";
document.getElementById("regTable").style.display = "none";
return;
}
refreshTable();
}
function renderEventHead() {
var head = document.getElementById("eventHead");
if (!head) return;
var byRole = {};
(ALL || []).forEach(function (r) {
if (r.status !== "Отменена") byRole[r.role] = (byRole[r.role] || 0) + 1;
});
var roles = Object.keys(byRole).map(function (k) {
return '<span class="kt-ai-chip">' + esc(k) + " · " + byRole[k] + "</span>";
}).join(" ");
head.innerHTML =
'<h1 class="evt-title" style="font-size:var(--kt-ai-text-xl);color:var(--kt-ai-fg)">Корпоративный ивент 2026 · 30 окт, Алматы, ДК «Жибек жолы»</h1>' +
(roles ? '<div style="display:flex;flex-wrap:wrap;gap:var(--kt-ai-space-3);margin-top:var(--kt-ai-space-4)">' + roles + "</div>" : "");
}
function visible() {
return ALL.filter(function (r) {
if (state.status && r.status !== state.status) return false;
if (state.q) {
var hay = [r.name, r.email, r.phone, r.department, r.position, r.role, r.track].join(" ").toLowerCase();
if (hay.indexOf(state.q) === -1) return false;
}
return true;
}).sort(function (a, b) { return b.id - a.id; });
}
function renderTable(rows) {
if (!rows) rows = visible();
var body = document.getElementById("regBody");
var table = document.getElementById("regTable");
var empty = document.getElementById("regEmpty");
renderKpi(rows);
if (!rows.length) {
table.style.display = "none";
empty.hidden = false;
empty.innerHTML = '<div class="kt-ai-empty"><div class="icon-spot"><svg class="kt-icon kt-icon-lg" aria-hidden="true"><use href="#check-circle"></use></svg></div><span class="title">' +
(ALL.length ? "По фильтру ничего нет" : "Заявок пока нет") + "</span><span>" +
(ALL.length ? "Сбросьте статус или поправьте поиск." : "Разошлите коллегам ссылку на форму — заявки появятся здесь.") +
"</span></div>";
return;
}
table.style.display = "";
empty.hidden = true;
body.innerHTML = rows.map(function (r) {
return '<tr data-id="' + r.id + '" tabindex="0" style="cursor:pointer">' +
'<td><svg class="kt-icon kt-icon-sm" aria-hidden="true" style="color:var(--kt-ai-fg-faint)"><use href="#chevron-right"></use></svg></td>' +
"<td data-label='Участник'><div class='cell-title'>" + esc(r.name) + "</div><div class='cell-sub'>" + esc(r.role) + "</div></td>" +
"<td data-label='Контакты'>" + esc(r.email) + "<div class='cell-sub'>" + esc(r.phone) + "</div></td>" +
"<td data-label='Филиал / должность'>" + esc(r.department || "—") + (r.position ? "<div class='cell-sub'>" + esc(r.position) + "</div>" : "") + "</td>" +
"<td data-label='Сессия · Питание'>" + esc(r.track) + "<div class='cell-sub'>" + esc(r.food) + "</div></td>" +
"<td data-label='Заявка' class='num'>" + fmtDate(r.createdAt) + "</td>" +
"<td data-label='Статус'>" + statusPill(r.status) + "</td></tr>";
}).join("");
};
function statusPill(s) {
var m = { "Заявка": "info", "Подтверждена": "ok", "Отменена": "muted" };
return '<span class="kt-ai-status-pill" data-status="' + (m[s] || "muted") + '"><span class="dot"></span>' + esc(s) + "</span>";
}
function renderKpi(rows) {
var all = ALL || [];
var total = all.length;
var confirmed = all.filter(function (r) { return r.status === "Подтверждена"; }).length;
var cancelled = all.filter(function (r) { return r.status === "Отменена"; }).length;
var pending = total - confirmed - cancelled;
var tracks = {};
rows.forEach(function (r) { tracks[r.track] = (tracks[r.track] || 0) + 1; });
var top = Object.keys(tracks).sort(function (a, b) { return tracks[b] - tracks[a]; })[0];
var strip = document.getElementById("kpiStrip");
strip.innerHTML =
kpi(String(total), "всего заявок") +
kpi(String(pending), "ждут подтверждения") +
kpi(String(confirmed), "подтверждено") +
(top ? kpi(String(tracks[top]), "мест: " + top) : "");
renderConclusion(rows, total, confirmed);
}
function kpi(value, label) {
return '<div class="kt-ai-kpi"><span class="value">' + esc(value) + '</span><span class="label">' + esc(label) + "</span></div>";
}
function renderConclusion(rows, total, confirmed) {
var el = document.getElementById("conclusion");
if (!total) { el.innerHTML = ""; return; }
var cancelled = (ALL || []).filter(function (r) { return r.status === "Отменена"; }).length;
el.innerHTML = '<div class="title">Состояние потоков</div><div class="text">' +
"Всего заявок: " + total + ". Подтверждено: " + confirmed + "." +
(cancelled ? " Отменено: " + cancelled + "." : "") +
(rows.length === total ? " Фильтры не применены." : " По текущему фильтру: " + rows.length + ".") +
" Данные обновляются после каждого действия — статусы меняются из карточки заявки, а список записей доступен во вкладке «Данные» с выгрузкой в Excel.</div>";
}
function exportCsv() {
var rows = visible();
if (!rows.length) { toast("Экспортировать нечего", "warn"); return; }
var cols = ["id", "name", "email", "phone", "department", "position", "role", "track", "food", "status", "note", "createdAt"];
var head = ["id", "ФИО", "Почта", "Телефон", "Филиал", "Должность", "Роль", "Сессия", "Питание", "Статус", "Примечание", "Заявка"];
var lines = [head.join(",")].concat(rows.map(function (r) {
return cols.map(function (c) {
var v = r[c];
v = v == null ? "" : String(v);
return '"' + v.replace(/"/g, '""') + '"';
}).join(",");
}));
var blob = new Blob(["\uFEFF" + lines.join("\n")], { type: "text/csv;charset=utf-8" });
var a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "registratsii-" + new Date().toISOString().slice(0, 10) + ".csv";
document.body.appendChild(a);
a.click();
a.remove();
toast("Таблица выгружена");
}
function closeDrawer() {
document.getElementById("drawer").hidden = true;
}
document.getElementById("regBody").addEventListener("click", function (e) {
var tr = e.target.closest("tr[data-id]");
if (tr) openDrawer(Number(tr.getAttribute("data-id")));
});
document.getElementById("regBody").addEventListener("keydown", function (e) {
if (e.key === "Enter") {
var tr = e.target.closest("tr[data-id]");
if (tr) openDrawer(Number(tr.getAttribute("data-id")));
}
});
function openDrawer(id) {
var r = (ALL || []).find(function (x) { return x.id === id; });
if (!r) return;
var d = document.getElementById("drawer");
d.innerHTML =
'<div class="drawer-head"><div class="t"><div class="kt-ai-h2" id="drawerTitle">' + esc(r.name) + "</div>" +
statusPill(r.status) + '<div style="margin-top:6px;font-size:var(--kt-ai-text-xs);color:var(--kt-ai-fg-muted);z-index:0;position:relative">Заявка от ' + fmtDate(r.createdAt) + "</div></div>" +
'<button class="kt-ai-icon-button" aria-label="Закрыть" id="drawerClose"><svg class="kt-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>' +
'<div class="drawer-sec">' + props(r) + "</div>" +
'<div class="drawer-sec"><div class="kt-ai-h3" style="margin-bottom:var(--kt-ai-space-4)">Примечание</div>' +
'<textarea class="kt-ai-textarea" id="drawerNote" rows="3" placeholder="Необязательно">' + esc(r.note || "") + "</textarea></div>" +
'<div class="kt-ai-drawer-actions">' +
'<button class="kt-ai-btn" data-variant="primary" data-act="Подтверждена" style="width:100%;justify-content:center;display:flex">Подтвердить участие</button>' +
'<button class="kt-ai-btn" data-act="Отменена" style="width:100%;justify-content:center;display:flex">Отменить заявку</button>' +
'<button class="kt-ai-btn" id="drawerSaveNote" style="width:100%;justify-content:center;display:flex">Сохранить примечание</button>' +
"</div>";
d.hidden = false;
d.focus();
d.querySelector("#drawerClose").addEventListener("click", closeDrawer);
d.querySelectorAll("button[data-act]").forEach(function (b) {
b.addEventListener("click", function () { changeStatus(id, b.getAttribute("data-act")); });
});
d.querySelector("#drawerSaveNote").addEventListener("click", function () { changeStatus(id, null, d.querySelector("#drawerNote").value); });
}
function props(r) {
var items = [
["Почта", r.email], ["Телефон", r.phone],
["Филиал / подразделение", r.department], ["Должность", r.position],
["Роль", r.role], ["Сессия", r.track], ["Питание", r.food]
];
return items.map(function (it) {
return '<div class="kt-ai-prop"><span class="k">' + esc(it[0]) + '</span><span class="v" ' + (it[1] ? "" : 'data-empty="true"') + ">" + esc(it[1] || "—") + "</span></div>";
}).join("");
}
window.addEventListener("hashchange", function () {
if (location.hash === "#new") {
location.replace("index.html");
}
});
if (location.hash === "#new") location.replace("index.html");
async function changeStatus(id, status, note) {
try {
var body = {};
if (status) body.status = status;
if (typeof note === "string") body.note = note;
await api("registrations/" + id, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
var r = (ALL || []).find(function (x) { return x.id === id; });
if (r) {
if (status) r.status = status;
if (typeof note === "string") r.note = note;
}
closeDrawer();
refreshTable();
document.getElementById("navCount").textContent = ALL.length;
toast(status ? ("Статус: " + status) : "Примечание сохранено", "ok");
} catch (err) {
toast(err.message, "risk");
}
}
})();