450 lines
20 KiB
HTML
450 lines
20 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Ремедиация DSLAM — Проблемные билеты</title>
|
||
<style>
|
||
* { margin: 0; box-sizing: border-box; }
|
||
body { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; background: #f5f5f7; color: #1d1d1f; padding: 20px; }
|
||
.container { max-width: 1400px; margin: 0 auto; }
|
||
h1 { font-size: 28px; margin-bottom: 8px; color: #1d1d1f; }
|
||
.subtitle { color: #86868b; margin-bottom: 24px; font-size: 14px; }
|
||
|
||
.stats { display: flex; gap: 16px; margin-bottom: 24px; flex-wrap: wrap; }
|
||
.stat { background: white; padding: 16px 24px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); flex: 1; min-width: 140px; }
|
||
.stat-value { font-size: 32px; font-weight: 700; }
|
||
.stat-total { color: #0071e3; }
|
||
.stat-problem { color: #e30000; }
|
||
.stat-remedy { color: #f57c00; }
|
||
.stat-fixed { color: #388e3c; }
|
||
.stat-label { font-size: 14px; color: #86868b; margin-top: 4px; }
|
||
|
||
.tabs { display: flex; gap: 8px; margin-bottom: 24px; border-bottom: 2px solid #e5e5e5; padding-bottom: 8px; }
|
||
.tab { padding: 8px 20px; border: none; background: none; cursor: pointer; font-size: 14px; color: #86868b; border-radius: 8px 8px 0 0; }
|
||
.tab.active { background: #0071e3; color: white; }
|
||
|
||
.filters { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; align-items: center; }
|
||
.filter-btn { padding: 8px 16px; border: 1px solid #d2d2d7; background: white; border-radius: 20px; cursor: pointer; font-size: 14px; }
|
||
.filter-btn.active { background: #0071e3; color: white; border-color: #0071e3; }
|
||
.search { flex: 1; min-width: 200px; padding: 8px 16px; border: 1px solid #d2d2d7; border-radius: 8px; font-size: 14px; }
|
||
|
||
.form-card { background: white; padding: 24px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 24px; }
|
||
.form-row { display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
|
||
.form-group { flex: 1; min-width: 150px; }
|
||
.form-group.full { flex-basis: 100%; }
|
||
label { display: block; font-size: 13px; color: #86868b; margin-bottom: 6px; font-weight: 500; }
|
||
input, select, textarea { width: 100%; padding: 10px 14px; border: 1px solid #d2d2d7; border-radius: 8px; font-size: 14px; }
|
||
textarea { resize: vertical; min-height: 70px; }
|
||
.btn { padding: 10px 24px; background: #0071e3; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; }
|
||
.btn:hover { background: #005bb5; }
|
||
.btn-secondary { background: #86868b; }
|
||
.btn-danger { background: #e30000; }
|
||
.btn-success { background: #388e3c; }
|
||
.btn-warning { background: #f57c00; }
|
||
|
||
.table-card { background: white; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow-x: auto; }
|
||
table { width: 100%; border-collapse: collapse; min-width: 900px; }
|
||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e5e5e5; font-size: 13px; }
|
||
th { background: #f5f5f7; font-weight: 600; color: #86868b; white-space: nowrap; }
|
||
tr:hover { background: #f9f9f9; }
|
||
.status { padding: 4px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; white-space: nowrap; }
|
||
.status-новая { background: #e3f2fd; color: #1976d2; }
|
||
.status-в-работе { background: #fff3e0; color: #f57c00; }
|
||
.status-ожидание { background: #fce4ec; color: #c2185b; }
|
||
.status-выполнено { background: #e8f5e9; color: #388e3c; }
|
||
.status-закрыто { background: #f5f5f5; color: #86868b; }
|
||
.actions { display: flex; gap: 6px; white-space: nowrap; }
|
||
.btn-sm { padding: 5px 10px; font-size: 11px; }
|
||
|
||
.priority-high { background: #ffebee; }
|
||
.priority-medium { background: #fff8e1; }
|
||
.priority-low { background: #e8f5e9; }
|
||
|
||
.export-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; }
|
||
|
||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; background: #e5e5e5; color: #333; }
|
||
|
||
@media (max-width: 1024px) {
|
||
.form-row { flex-direction: column; }
|
||
.stats { flex-direction: column; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>🔧 Ремедиация DSLAM</h1>
|
||
<div class="subtitle">stpk.telecom.kz — Управление проблемными билетами</div>
|
||
|
||
<div class="stats">
|
||
<div class="stat">
|
||
<div class="stat-value stat-total" id="stat-total">0</div>
|
||
<div class="stat-label">Всего билетов</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-value stat-problem" id="stat-problem">0</div>
|
||
<div class="stat-label">Проблемные</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-value stat-remedy" id="stat-remedy">0</div>
|
||
<div class="stat-label">В ремедиации</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-value stat-fixed" id="stat-fixed">0</div>
|
||
<div class="stat-label">Исправлено</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tabs">
|
||
<button class="tab active" data-view="tickets">Билеты</button>
|
||
<button class="tab" data-view="log">Лог действий</button>
|
||
</div>
|
||
|
||
<div id="view-tickets">
|
||
<div class="form-card">
|
||
<h2 style="margin-bottom: 16px; font-size: 18px;">➕ Новый билет</h2>
|
||
<form id="form">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>DSLAM</label>
|
||
<input type="text" name="dslam" placeholder="Например: DSLAM-01" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Порт</label>
|
||
<input type="text" name="port" placeholder="Например: 1/0/5" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Приоритет</label>
|
||
<select name="priority" required>
|
||
<option value="низкий">Низкий</option>
|
||
<option value="средний" selected>Средний</option>
|
||
<option value="высокий">Высокий</option>
|
||
<option value="критический">Критический</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Абонент</label>
|
||
<input type="text" name="client" placeholder="ФИО или название организации" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Адрес</label>
|
||
<input type="text" name="address" placeholder="Улица, дом, квартира" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Телефон</label>
|
||
<input type="tel" name="phone" placeholder="+7 (___) ___-__-__" required>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Тип проблемы</label>
|
||
<select name="problemType" required>
|
||
<option value="">Выберите тип</option>
|
||
<option value="нет связи">Нет связи</option>
|
||
<option value="низкая скорость">Низкая скорость</option>
|
||
<option value="обрыв линии">Обрыв линии</option>
|
||
<option value="ошибка порта">Ошибка порта</option>
|
||
<option value="шумы в линии">Шумы в линии</option>
|
||
<option value="другое">Другое</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Ответственный</label>
|
||
<input type="text" name="owner" placeholder="ФИО сотрудника" required>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group full">
|
||
<label>Описание проблемы</label>
|
||
<textarea name="description" placeholder="Подробное описание проблемы, результаты диагностики..." required></textarea>
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn">Создать билет</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="filters">
|
||
<button class="filter-btn active" data-status="all">Все</button>
|
||
<button class="filter-btn" data-status="новая">Новые</button>
|
||
<button class="filter-btn" data-status="в работе">В работе</button>
|
||
<button class="filter-btn" data-status="ожидание">Ожидание</button>
|
||
<button class="filter-btn" data-status="выполнено">Выполнено</button>
|
||
<button class="filter-btn" data-status="закрыто">Закрыто</button>
|
||
<input type="text" class="search" id="search" placeholder="Поиск по DSLAM, порту, абоненту...">
|
||
</div>
|
||
|
||
<div class="export-row">
|
||
<div style="font-size: 14px; color: #86868b;">
|
||
<span id="count-display">0 билетов</span>
|
||
</div>
|
||
<button class="btn" onclick="exportCsv()">📥 Выгрузить в Excel</button>
|
||
</div>
|
||
|
||
<div class="table-card">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>№</th>
|
||
<th>DSLAM</th>
|
||
<th>Порт</th>
|
||
<th>Абонент</th>
|
||
<th>Адрес</th>
|
||
<th>Тип проблемы</th>
|
||
<th>Приоритет</th>
|
||
<th>Ответственный</th>
|
||
<th>Статус</th>
|
||
<th>Дата</th>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="table-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="view-log" style="display: none;">
|
||
<div class="table-card">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Время</th>
|
||
<th>Событие</th>
|
||
<th>Кто</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="log-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let items = [];
|
||
let currentFilter = "all";
|
||
let editId = null;
|
||
let lastCount = 0;
|
||
let audioContext = null;
|
||
|
||
function requestNotificationPermission() {
|
||
if ("Notification" in window && Notification.permission === "default") {
|
||
Notification.requestPermission();
|
||
}
|
||
}
|
||
|
||
function playNotificationSound() {
|
||
try {
|
||
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||
const osc = audioContext.createOscillator();
|
||
const gain = audioContext.createGain();
|
||
osc.connect(gain);
|
||
gain.connect(audioContext.destination);
|
||
osc.frequency.value = 800;
|
||
osc.type = "sine";
|
||
gain.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.4);
|
||
osc.start(audioContext.currentTime);
|
||
osc.stop(audioContext.currentTime + 0.4);
|
||
setTimeout(() => {
|
||
const osc2 = audioContext.createOscillator();
|
||
const gain2 = audioContext.createGain();
|
||
osc2.connect(gain2);
|
||
gain2.connect(audioContext.destination);
|
||
osc2.frequency.value = 1000;
|
||
osc2.type = "sine";
|
||
gain2.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||
gain2.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.4);
|
||
osc2.start(audioContext.currentTime);
|
||
osc2.stop(audioContext.currentTime + 0.4);
|
||
}, 150);
|
||
} catch (e) { console.log("Audio not supported"); }
|
||
}
|
||
|
||
function showNotification(title, body) {
|
||
playNotificationSound();
|
||
if ("Notification" in window && Notification.permission === "granted") {
|
||
new Notification(title, { body: body, icon: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='45' fill='%230071e3'/%3E%3Ctext x='50' y='65' font-size='50' text-anchor='middle' fill='white'%3E🔧%3C/text%3E%3C/svg%3E" });
|
||
}
|
||
const alert = document.createElement("div");
|
||
alert.style.cssText = "position:fixed;top:20px;right:20px;background:#0071e3;color:white;padding:16px 24px;border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,0.2);z-index:1000;";
|
||
alert.innerHTML = "<strong>" + title + "</strong><br>" + body;
|
||
document.body.appendChild(alert);
|
||
setTimeout(() => alert.remove(), 5000);
|
||
}
|
||
|
||
async function load() {
|
||
const res = await fetch("/api/items");
|
||
items = await res.json();
|
||
if (lastCount > 0 && items.length > lastCount) {
|
||
const last = items[items.length - 1];
|
||
showNotification("🔔 Новый билет DSLAM", "Порт: " + (last.port||"") + ", Проблема: " + (last.problemType||""));
|
||
}
|
||
lastCount = items.length;
|
||
render();
|
||
}
|
||
|
||
function render() {
|
||
const search = document.getElementById("search").value.toLowerCase();
|
||
const filtered = items.filter(item => {
|
||
const matchStatus = currentFilter === "all" || item.status === currentFilter;
|
||
const matchSearch = !search ||
|
||
(item.dslam && item.dslam.toLowerCase().includes(search)) ||
|
||
(item.port && item.port.toLowerCase().includes(search)) ||
|
||
(item.client && item.client.toLowerCase().includes(search)) ||
|
||
(item.address && item.address.toLowerCase().includes(search));
|
||
return matchStatus && matchSearch;
|
||
});
|
||
|
||
document.getElementById("table-body").innerHTML = filtered.map(item => {
|
||
const date = new Date(item.createdAt).toLocaleString("ru-RU");
|
||
const priorityClass = "priority-" + (item.priority === "высокий" || item.priority === "критический" ? "high" : item.priority === "средний" ? "medium" : "low");
|
||
return `<tr class="${priorityClass}">
|
||
<td><strong>${item.id}</strong></td>
|
||
<td>${item.dslam || ""}</td>
|
||
<td><span class="badge">${item.port || ""}</span></td>
|
||
<td>${item.client || ""}</td>
|
||
<td>${item.address || ""}</td>
|
||
<td>${item.problemType || ""}</td>
|
||
<td><span class="badge" style="background:${getPriorityColor(item.priority)};color:white">${item.priority || "средний"}</span></td>
|
||
<td>${item.owner || ""}</td>
|
||
<td><span class="status status-${(item.status||'новая').replace(' ','-')}">${item.status || "новая"}</span></td>
|
||
<td>${date}</td>
|
||
<td class="actions">
|
||
<button class="btn btn-sm btn-warning" onclick="setStatus(${item.id},'в работе')" title="В работу">▶</button>
|
||
<button class="btn btn-sm btn-success" onclick="setStatus(${item.id},'выполнено')" title="Выполнено">✓</button>
|
||
<button class="btn btn-sm btn-secondary" onclick="editItem(${item.id})">✎</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteItem(${item.id})">✕</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join("");
|
||
|
||
document.getElementById("stat-total").textContent = items.length;
|
||
document.getElementById("stat-problem").textContent = items.filter(i => i.status === "новая" || i.status === "в работе").length;
|
||
document.getElementById("stat-remedy").textContent = items.filter(i => i.status === "в работе" || i.status === "ожидание").length;
|
||
document.getElementById("stat-fixed").textContent = items.filter(i => i.status === "выполнено").length;
|
||
document.getElementById("count-display").textContent = filtered.length + " из " + items.length + " билетов";
|
||
}
|
||
|
||
function getPriorityColor(p) {
|
||
if (p === "критический") return "#c62828";
|
||
if (p === "высокий") return "#e65100";
|
||
if (p === "средний") return "#f9a825";
|
||
return "#2e7d32";
|
||
}
|
||
|
||
document.getElementById("form").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const form = e.target;
|
||
const data = {
|
||
dslam: form.dslam.value,
|
||
port: form.port.value,
|
||
priority: form.priority.value,
|
||
client: form.client.value,
|
||
address: form.address.value,
|
||
phone: form.phone.value,
|
||
problemType: form.problemType.value,
|
||
owner: form.owner.value,
|
||
description: form.description.value
|
||
};
|
||
|
||
if (editId) {
|
||
await fetch("/api/items/" + editId, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) });
|
||
editId = null;
|
||
form.querySelector("button[type=submit]").textContent = "Создать билет";
|
||
} else {
|
||
await fetch("/api/items", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) });
|
||
}
|
||
form.reset();
|
||
load();
|
||
requestNotificationPermission();
|
||
});
|
||
|
||
async function setStatus(id, status) {
|
||
const item = items.find(i => i.id === id);
|
||
if (!item) return;
|
||
await fetch("/api/items/" + id, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ status: status })
|
||
});
|
||
load();
|
||
}
|
||
|
||
async function editItem(id) {
|
||
const item = items.find(i => i.id === id);
|
||
if (!item) return;
|
||
const form = document.getElementById("form");
|
||
form.dslam.value = item.dslam || "";
|
||
form.port.value = item.port || "";
|
||
form.priority.value = item.priority || "средний";
|
||
form.client.value = item.client || "";
|
||
form.address.value = item.address || "";
|
||
form.phone.value = item.phone || "";
|
||
form.problemType.value = item.problemType || "";
|
||
form.owner.value = item.owner || "";
|
||
form.description.value = item.description || "";
|
||
editId = id;
|
||
form.querySelector("button[type=submit]").textContent = "Сохранить изменения";
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
}
|
||
|
||
async function deleteItem(id) {
|
||
if (!confirm("Удалить билет №" + id + "?")) return;
|
||
await fetch("/api/items/" + id, { method: "DELETE" });
|
||
load();
|
||
}
|
||
|
||
document.querySelectorAll(".filter-btn").forEach(btn => {
|
||
btn.addEventListener("click", () => {
|
||
document.querySelectorAll(".filter-btn").forEach(b => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
currentFilter = btn.dataset.status;
|
||
render();
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll(".tab").forEach(tab => {
|
||
tab.addEventListener("click", () => {
|
||
document.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
|
||
tab.classList.add("active");
|
||
if (tab.dataset.view === "log") {
|
||
document.getElementById("view-tickets").style.display = "none";
|
||
document.getElementById("view-log").style.display = "block";
|
||
loadLog();
|
||
} else {
|
||
document.getElementById("view-tickets").style.display = "block";
|
||
document.getElementById("view-log").style.display = "none";
|
||
}
|
||
});
|
||
});
|
||
|
||
async function loadLog() {
|
||
const res = await fetch("/api/log");
|
||
const log = await res.json();
|
||
document.getElementById("log-body").innerHTML = log.slice().reverse().map(entry => {
|
||
const time = new Date(entry.when).toLocaleString("ru-RU");
|
||
return `<tr><td>${time}</td><td>${entry.what}</td><td>${entry.who}</td></tr>`;
|
||
}).join("");
|
||
}
|
||
|
||
document.getElementById("search").addEventListener("input", render);
|
||
|
||
function exportCsv() {
|
||
const head = ["№", "DSLAM", "Порт", "Абонент", "Адрес", "Телефон", "Тип проблемы", "Приоритет", "Ответственный", "Статус", "Описание", "Дата"];
|
||
const body = items.map(r => [
|
||
r.id, r.dslam, r.port, r.client, r.address, r.phone, r.problemType, r.priority, r.owner, r.status, r.description,
|
||
new Date(r.createdAt).toLocaleString("ru-RU")
|
||
].map(v => '"' + String(v ?? "").replace(/"/g, '""') + '"').join(";"));
|
||
const csv = "\uFEFF" + [head.join(";"), ...body].join("\n");
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
|
||
a.download = "bilety_dslam.csv";
|
||
a.click();
|
||
}
|
||
|
||
load();
|
||
</script>
|
||
</body>
|
||
</html>
|