zhurnal-zayavok-na-remont-ob-2/script.js

552 lines
22 KiB
JavaScript
Raw 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.

// Журнал заявок на ремонт оборудования.
// Данные хранятся в localStorage; при первом открытии подставляются исходные записи.
var STATUS_ORDER = [
"new",
"assigned",
"in_progress",
"awaiting_parts",
"resolved",
"cancelled"
];
var STATUS_LABEL = {
new: "Новая",
assigned: "Назначена",
in_progress: "В работе",
awaiting_parts: "Ждёт запчасть",
resolved: "Завершена",
cancelled: "Отменена"
};
var STATUS_TONE = {
new: "info",
assigned: "info",
in_progress: "warn",
awaiting_parts: "risk",
resolved: "ok",
cancelled: "muted"
};
var SEED = [
{ id: "РМ-2104", equipment: "Серверная стойка A-3", place: "ЦОД, 1 этаж, стойка 12", cls: "Срочный", status: "in_progress", created: "2026-09-18", updated: "2026-09-21", resp: "И. Ахметов", desc: "Сбой охлаждения: температура в стойке выше 30 °C, датчики показывают 38 °C. Требуется диагностика блоков кондиционирования." },
{ id: "РМ-2103", equipment: "Транслирующая станция «Алматы-4»", place: "ДК-4, уровень 12", cls: "Срочный", status: "awaiting_parts", created: "2026-09-17", updated: "2026-09-20", resp: "С. Оспанов", desc: "Отказ модуля питания. Станция переведена на резервный канал, основной канал выведен из работы." },
{ id: "РМ-2102", equipment: "Видеонаблюдение, парковка П-2", place: "Штаб, парковка 2", cls: "Повседневный", status: "new", created: "2026-09-21", updated: "2026-09-21", resp: null, desc: "Камера №7 не передаёт изображение. Блок питания в порядке, сигнал отсутствует." },
{ id: "РМ-2101", equipment: "Принтер HP LaserJet M404", place: "БЦ «Тау Самал», кабинет 204", cls: "Повседневный", status: "resolved", created: "2026-09-10", updated: "2026-09-14", resp: "А. Нурланов", desc: "Замятие бумаги. Заменён роликовый узел подачи, тестовая печать выполнена." },
{ id: "РМ-2100", equipment: "IP-телефон Cisco 8845", place: "Кабинет 315", cls: "Повседневный", status: "assigned", created: "2026-09-19", updated: "2026-09-21", resp: "М. Тлеубердиева", desc: "Не регистрируется на шлюзе после планового обновления прошивки." },
{ id: "РМ-2099", equipment: "Климат-установка DX-140", place: "ЦОД, серверный зал 2", cls: "Срочный", status: "in_progress", created: "2026-09-15", updated: "2026-09-21", resp: "Т. Бекова", desc: "Течь фреонового контура. Выполнена локализация утечки, ожидается дозаправка." },
{ id: "РМ-2098", equipment: "Рабочая станция, отдел поддержки", place: "Кабинет 108", cls: "Повседневный", status: "resolved", created: "2026-09-08", updated: "2026-09-12", resp: "М. Тлеубердиева", desc: "Зависания системы при загрузке. Перенесена ОС на новый диск." },
{ id: "РМ-2097", equipment: "Патч-панель ОД-2", place: "Шкаф ОД-2, 2 этаж", cls: "Повседневный", status: "awaiting_parts", created: "2026-09-11", updated: "2026-09-18", resp: "И. Ахметов", desc: "Обрыв четырёх портов на секции 3. Заказать патч-корды и портовое исполнение." },
{ id: "РМ-2096", equipment: "Система контроля доступа, крыло Б", place: "Здание Б, этаж 1", cls: "Срочный", status: "assigned", created: "2026-09-20", updated: "2026-09-21", resp: "С. Оспанов", desc: "Турникет №2 не считывает RFID-карты. Коммуникация с контроллером не отвечает." },
{ id: "РМ-2095", equipment: "Кофемашина Breville", place: "Столовая, 3 этаж", cls: "Повседневный", status: "cancelled", created: "2026-09-05", updated: "2026-09-09", resp: null, desc: "Заявка дублирует РМ-2090, снята с исполнения." },
{ id: "РМ-2094", equipment: "Проектор Epson EB-2250", place: "Зал заседаний 4", cls: "Повседневный", status: "resolved", created: "2026-09-02", updated: "2026-09-06", resp: "А. Нурланов", desc: "Потускнение картинки, замена лампы выполнена, яркость в норме." },
{ id: "РМ-2093", equipment: "Генератор ГРА-250", place: "Подвал, генераторная", cls: "Срочный", status: "in_progress", created: "2026-09-16", updated: "2026-09-20", resp: "Т. Бекова", desc: "Не запускается при плановом тесте. Проверка системы запуска и топливного контура." },
{ id: "РМ-2092", equipment: "Ноутбук Lenovo ThinkPad T14", place: "Кабинет 220", cls: "Повседневный", status: "new", created: "2026-09-22", updated: "2026-09-22", resp: null, desc: "Заряд аккумулятора держит 20 минут, замена батареи требуется." },
{ id: "РМ-2091", equipment: "ОПТП-трасса, сегмент Д-3", place: "Линия Д-3, опоры 12–18", cls: "Срочный", status: "awaiting_parts", created: "2026-09-13", updated: "2026-09-19", resp: "С. Оспанов", desc: "Повреждение на сегменте после работ подрядчика. Ждём кабельный запас, согласован поставщик." },
{ id: "РМ-2090", equipment: "Кофемашина Breville", place: "Столовая, 3 этаж", cls: "Повседневный", status: "resolved", created: "2026-08-30", updated: "2026-09-04", resp: "М. Тлеубердиева", desc: "Промывка и чистка, замена уплотнителя. Работает в штатном режиме." },
{ id: "РМ-2089", equipment: "Коммутатор Cisco 3750", place: "Шкаф ОД-1, порт 2", cls: "Повседневный", status: "assigned", created: "2026-09-18", updated: "2026-09-21", resp: "А. Нурланов", desc: "Порт 2 гаснет при подключении терминала. Заменён кабель, порт в норме." },
{ id: "РМ-2088", equipment: "Климат-установка DX-110", place: "ЦОД, серверный зал 1", cls: "Срочный", status: "resolved", created: "2026-08-25", updated: "2026-09-01", resp: "Т. Бекова", desc: "Заменён компрессор, температура в зале стабилизирована." },
{ id: "РМ-2087", equipment: "Кресло оператора, рабочее место 7", place: "Оперативный зал, место 7", cls: "Повседневный", status: "new", created: "2026-09-22", updated: "2026-09-22", resp: null, desc: "Не регулируется по высоте." }
];
var LS_KEY = "zhurnal-zayavok-v1";
var state = {
reqs: null,
filter: "all",
search: "",
sortDesc: true,
selectedId: null
};
function $(id) {
return document.getElementById(id);
}
function esc(s) {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function pad2(n) {
var s = String(n);
return s.length < 2 ? "0" + s : s;
}
function fmtDate(d) {
if (!d) return "—";
var parts = String(d).split("-");
if (parts.length !== 3) return String(d);
return pad2(parseInt(parts[2], 10)) + "." + pad2(parseInt(parts[1], 10)) + "." + parts[0];
}
function nextId() {
var max = 2086;
for (var i = 0; i < state.reqs.length; i++) {
var m = /^РМ-(\d+)$/.exec(state.reqs[i].id || "");
if (m) {
var n = parseInt(m[1], 10);
if (n > max) max = n;
}
}
return "РМ-" + (max + 1);
}
function loadReqs() {
try {
var raw = window.localStorage.getItem(LS_KEY);
if (raw) {
var arr = JSON.parse(raw);
if (Object.prototype.toString.call(arr) === "[object Array]") return arr;
}
} catch (e) { /* повреждённое хранение — пересоздаём */ }
return null;
}
function saveReqs() {
try {
window.localStorage.setItem(LS_KEY, JSON.stringify(state.reqs));
} catch (e) { /* приватный режим и т.п. */ }
}
function cloneSeed() {
var out = [];
for (var i = 0; i < SEED.length; i++) {
var r = {};
for (var k in SEED[i]) r[k] = SEED[i][k];
out.push(r);
}
return out;
}
function resetToSeed() {
state.reqs = cloneSeed();
saveReqs();
}
// Фильтр: статус + поиск, сортировка по дате создания.
function visibleReqs() {
var q = state.search.trim().toLowerCase();
var out = [];
for (var i = 0; i < state.reqs.length; i++) {
var r = state.reqs[i];
if (state.filter !== "all" && r.status !== state.filter) continue;
if (q) {
var hay = (r.id + " " + r.equipment + " " + r.place + " " + (r.desc || "")).toLowerCase();
if (hay.indexOf(q) === -1) continue;
}
out.push(r);
}
out.sort(function (a, b) {
var da = a.created || "", db = b.created || "";
if (da < db) return state.sortDesc ? 1 : -1;
if (da > db) return state.sortDesc ? -1 : 1;
return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
});
return out;
}
function countByStatus() {
var m = {};
for (var i = 0; i < STATUS_ORDER.length; i++) m[STATUS_ORDER[i]] = 0;
for (var j = 0; j < state.reqs.length; j++) {
var s = state.reqs[j].status;
if (Object.prototype.hasOwnProperty.call(m, s)) m[s]++;
}
return m;
}
function findReq(id) {
for (var i = 0; i < state.reqs.length; i++) {
if (state.reqs[i].id === id) return state.reqs[i];
}
return null;
}
function pillHtml(status) {
var tone = STATUS_TONE[status] || "info";
return '<span class="kt-ai-status-pill" data-status="' + esc(tone) + '">' +
esc(STATUS_LABEL[status] || status) + "</span>";
}
// ————————————————————————————————
// Рендер
// ————————————————————————————————
function renderFilters() {
var c = countByStatus();
var host = $("statusFilters");
if (!host) return;
var parts = [];
parts.push('<button type="button" class="status-chip" data-status="all" data-active="' + (state.filter === "all") + '">Все <span class="count">' + state.reqs.length + "</span></button>");
for (var i = 0; i < STATUS_ORDER.length; i++) {
var s = STATUS_ORDER[i];
var active = state.filter === s ? "true" : "false";
var label = statusWordForFilter(s);
parts.push('<button type="button" class="status-chip" data-status="' + esc(s) + '" data-active="' + active + '">' + esc(label) + ' <span class="count">' + esc(String(c[s])) + "</span></button>");
}
host.innerHTML = parts.join("");
}
function statusWordForFilter(s) {
var short = {
new: "Новые",
assigned: "Назначенные",
in_progress: "В работе",
awaiting_parts: "Ждут запчасть",
resolved: "Завершённые",
cancelled: "Отменённые"
};
return short[s] || STATUS_LABEL[s];
}
function renderTable() {
var body = $("reqBody");
if (!body) return;
var rows = visibleReqs();
if (rows.length === 0) {
body.innerHTML = "";
var empty = $("emptyState");
if (empty) empty.hidden = false;
} else {
var empty2 = $("emptyState");
if (empty2) empty2.hidden = true;
var html = [];
for (var i = 0; i < rows.length; i++) {
html.push(rowHtml(rows[i]));
}
body.innerHTML = html.join("");
}
updateCount();
}
function rowHtml(r) {
var selected = state.selectedId === r.id ? "true" : "false";
var cls = esc(r.cls || "—");
return '<tr class="selectable" data-id="' + esc(r.id) + '" data-selected="' + selected + '">' +
'<td data-label="Заявка"><div class="req-title"><span class="id">' + esc(r.id) + "</span>" +
'<span class="req-sub">' + esc(r.equipment || "") + "</span></div></td>" +
"<td>" + pillHtml(r.status) + "</td>" +
'<td><span class="kt-ai-chip">' + cls + "</span></td>" +
'<td><span class="cell-date">' + fmtDate(r.created) + "</span></td>" +
"</tr>";
}
function renderDetail() {
var host = $("detailPane");
if (!host) return;
var r = state.selectedId ? findReq(state.selectedId) : null;
if (!r) {
host.innerHTML =
'<div class="detail-hint">' +
'<svg class="kt-icon" aria-hidden="true"><use href="#clipboard"></use></svg>' +
'<div><strong>Выберите заявку</strong></div>' +
'<div class="kt-ai-meta">Нажмите на строку в списке, чтобы увидеть детали и сменить статус.</div>' +
"</div>";
return;
}
var resp = r.resp ? esc(r.resp) : "—";
var cls = esc(r.cls || "—");
host.innerHTML =
'<div class="detail-card">' +
'<div class="detail-head">' +
'<div class="id">' + esc(r.id) + "</div>" +
'<div class="detail-title-row">' +
'<div class="detail-title">' + esc(r.equipment || "") + "</div>" +
pillHtml(r.status) +
"</div>" +
"</div>" +
'<div class="detail-rows">' +
detailRow("Место", r.place) +
detailRow("Класс", cls) +
detailRow("Создана", fmtDate(r.created)) +
detailRow("Обновлена", fmtDate(r.updated)) +
detailRow("Исполнитель", resp) +
"</div>" +
'<div class="kt-ai-meta desc-label">Описание</div>' +
'<p class="detail-desc">' + (r.desc ? esc(r.desc) : "—") + "</p>" +
'<div class="detail-status-line"><label class="kt-ai-meta" for="statusSelect">Статус</label></div>' +
'<select class="kt-ai-select" id="statusSelect" style="margin-top:var(--kt-ai-space-2)">' + statusOptions(r.status) + "</select>" +
"</div>";
var sel = $("statusSelect");
if (sel) {
sel.addEventListener("change", onStatusChange);
}
}
function detailRow(k, v) {
return '<div class="detail-row"><span class="k">' + esc(k) + '</span><span class="v">' + esc(v) + "</span></div>";
}
function statusOptions(current) {
var out = [];
for (var i = 0; i < STATUS_ORDER.length; i++) {
var s = STATUS_ORDER[i];
var selected = s === current ? "selected" : "";
out.push('<option value="' + esc(s) + '" ' + selected + "'>" + esc(STATUS_LABEL[s]) + "</option>");
}
return out.join("");
}
function updateCount() {
var total = state.reqs.length;
var shown = visibleReqs().length;
var el = $("tableCount");
if (!el) return;
if (state.filter === "all" && !state.search.trim()) {
el.textContent = "Показаны все заявки: " + shown;
} else {
el.textContent = "Показано " + shown + " из " + total;
}
}
function updateKpi() {
var c = countByStatus();
setKpi("kpiTotal", state.reqs.length);
setKpi("kpiNew", c.new);
setKpi("kpiWork", c.in_progress + c.assigned);
setKpi("kpiDone", c.resolved + c.cancelled);
}
function setKpi(id, n) {
var el = $(id);
if (el) el.textContent = String(n);
}
function renderAll() {
renderFilters();
renderTable();
renderDetail();
updateKpi();
updateClearButton();
}
function updateClearButton() {
var btn = $("clearFiltersBtn");
if (!btn) return;
var any = state.filter !== "all" || state.search.trim() !== "";
btn.hidden = !any;
}
// ————————————————————————————————
// Действия
// ————————————————————————————————
function selectRow(id) {
state.selectedId = id;
syncRowSelection();
renderDetail();
}
function syncRowSelection() {
var tbody = $("reqBody");
if (!tbody) return;
var rows = tbody.querySelectorAll("tr.selectable");
for (var i = 0; i < rows.length; i++) {
rows[i].setAttribute("data-selected", rows[i].getAttribute("data-id") === state.selectedId ? "true" : "false");
}
}
function setFilter(s) {
state.filter = s;
state.selectedId = null;
renderFilters();
renderTable();
renderDetail();
updateKpi();
updateClearButton();
}
function onStatusChange(e) {
var sel = e.target;
if (!sel || state.selectedId == null) return;
var r = findReq(state.selectedId);
if (!r) return;
r.status = sel.value;
r.updated = todayStr();
saveReqs();
renderAll();
toast("Статус " + esc(r.id) + ": " + STATUS_LABEL[sel.value]);
}
function todayStr() {
var d = new Date();
return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate());
}
function toast(text) {
var stack = $("toasts");
if (!stack) return;
var t = document.createElement("div");
t.className = "kt-ai-toast";
t.setAttribute("data-tone", "ok");
t.textContent = text;
stack.appendChild(t);
setTimeout(function () {
if (t.parentNode) t.parentNode.removeChild(t);
}, 3500);
}
function openModal() {
var m = $("modal");
if (!m) return;
m.hidden = false;
$("reqForm").reset();
if ($("fEquipment")) $("fEquipment").focus();
}
function closeModal() {
var m = $("modal");
if (m) m.hidden = true;
}
function doReset() {
resetToSeed();
state.filter = "all";
state.search = "";
state.selectedId = null;
if ($("searchInput")) $("searchInput").value = "";
renderAll();
toast("Список восстановлен");
}
// ————————————————————————————————
// Обработчики
// ————————————————————————————————
function onFiltersClick(e) {
var chip = e.target;
while (chip && chip !== document && !(chip.classList && chip.classList.contains("status-chip"))) {
chip = chip.parentNode;
}
if (!chip || chip === document) return;
setFilter(chip.getAttribute("data-status") || "all");
}
function onSortClick() {
state.sortDesc = !state.sortDesc;
var lbl = $("sortLabel");
if (lbl) lbl.textContent = state.sortDesc ? "Сначала новые" : "Сначала старые";
renderTable();
}
function onFormSubmit(e) {
e.preventDefault();
var eq = $("fEquipment").value.trim();
if (!eq) {
$("fEquipment").focus();
return;
}
var today = todayStr();
var r = {
id: nextId(),
equipment: eq,
place: $("fPlace").value.trim() || null,
cls: $("fClass").value,
status: "new",
created: today,
updated: today,
resp: null,
desc: $("fDesc").value.trim() || null
};
state.reqs.unshift(r);
saveReqs();
state.selectedId = r.id;
state.filter = "all";
state.search = "";
if ($("searchInput")) $("searchInput").value = "";
renderAll();
closeModal();
toast("Заявка " + r.id + " создана");
}
function bindEvents() {
$("reqBody").addEventListener("click", function (e) {
var tr = e.target;
while (tr && tr !== this && !(tr.tagName === "TR" && tr.classList.contains("selectable"))) {
tr = tr.parentNode;
}
if (!tr || tr === this) return;
selectRow(tr.getAttribute("data-id"));
});
$("statusFilters").addEventListener("click", onFiltersClick);
$("sortBtn").addEventListener("click", onSortClick);
$("searchInput").addEventListener("input", function (e) {
state.search = e.target.value || "";
renderTable();
updateClearButton();
});
$("newBtn").addEventListener("click", openModal);
$("cancelBtn").addEventListener("click", closeModal);
$("resetBtn").addEventListener("click", doReset);
$("clearFiltersBtn").addEventListener("click", doClearFilters);
$("emptyClearBtn").addEventListener("click", doClearFilters);
$("modal").addEventListener("click", function (e) {
if (e.target === this) closeModal();
});
$("reqForm").addEventListener("submit", onFormSubmit);
$("exportBtn").addEventListener("click", exportExcel);
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !$("modal").hidden) closeModal();
});
}
function csvCell(v) {
var s = v == null ? "" : String(v);
return '"' + s.replace(/"/g, '""') + '"';
}
function exportExcel() {
var rows = visibleReqs();
if (rows.length === 0) {
toast("Нечего экспортировать");
return;
}
var head = ["Номер", "Оборудование", "Место", "Класс", "Статус", "Создана", "Обновлена", "Исполнитель", "Описание"];
var lines = [head.map(csvCell).join(",")];
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
lines.push([
r.id, r.equipment, r.place, r.cls,
STATUS_LABEL[r.status] || r.status,
fmtDate(r.created), fmtDate(r.updated),
r.resp || "", r.desc || ""
].map(csvCell).join(","));
}
var blob = new Blob([lines.join("\r\n")], { type: "application/vnd.ms-excel" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = "zayavki-remont-" + todayStr() + ".xls";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
toast("Выгружено заявок: " + rows.length);
}
function doClearFilters() {
state.filter = "all";
state.search = "";
state.selectedId = null;
if ($("searchInput")) $("searchInput").value = "";
renderAll();
}
function init() {
var stored = loadReqs();
state.reqs = stored || cloneSeed();
if (!stored) saveReqs();
bindEvents();
renderAll();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}