povrezhdenie-po-stpk-avtomat/index.html

450 lines
20 KiB
HTML
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.

<!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>