310 lines
12 KiB
JavaScript
310 lines
12 KiB
JavaScript
/* schedule.js — filters, pagination, table render, CSV export */
|
||
|
||
const PAGE_SIZE = 100;
|
||
let currentOffset = 0;
|
||
let currentTotal = 0;
|
||
|
||
// ── init ──────────────────────────────────────────────────────────────────────
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
// Default date range: last 7 days
|
||
const today = new Date();
|
||
const week = new Date(today);
|
||
week.setDate(today.getDate() - 7);
|
||
document.getElementById("f-date-from").value = fmtDate(week);
|
||
document.getElementById("f-date-to").value = fmtDate(today);
|
||
|
||
loadData();
|
||
});
|
||
|
||
// ── filter helpers ────────────────────────────────────────────────────────────
|
||
|
||
function getFilters() {
|
||
return {
|
||
date_from: document.getElementById("f-date-from").value || "",
|
||
date_to: document.getElementById("f-date-to").value || "",
|
||
airport: document.getElementById("f-airport").value,
|
||
direction: document.getElementById("f-direction").value,
|
||
flight_number: document.getElementById("f-flight").value.trim(),
|
||
time_from: document.getElementById("f-time-from").value || "",
|
||
time_to: document.getElementById("f-time-to").value || "",
|
||
};
|
||
}
|
||
|
||
function buildQuery(extra = {}) {
|
||
const f = { ...getFilters(), limit: PAGE_SIZE, offset: currentOffset, ...extra };
|
||
return Object.entries(f)
|
||
.filter(([, v]) => v !== "" && v !== "all")
|
||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||
.join("&");
|
||
}
|
||
|
||
function applyFilters() {
|
||
currentOffset = 0;
|
||
loadData();
|
||
}
|
||
|
||
function resetFilters() {
|
||
document.getElementById("f-date-from").value = "";
|
||
document.getElementById("f-date-to").value = "";
|
||
document.getElementById("f-airport").value = "all";
|
||
document.getElementById("f-direction").value = "all";
|
||
document.getElementById("f-flight").value = "";
|
||
document.getElementById("f-time-from").value = "";
|
||
document.getElementById("f-time-to").value = "";
|
||
currentOffset = 0;
|
||
loadData();
|
||
}
|
||
|
||
// ── pagination ────────────────────────────────────────────────────────────────
|
||
|
||
function prevPage() {
|
||
if (currentOffset <= 0) return;
|
||
currentOffset = Math.max(0, currentOffset - PAGE_SIZE);
|
||
loadData();
|
||
}
|
||
|
||
function nextPage() {
|
||
if (currentOffset + PAGE_SIZE >= currentTotal) return;
|
||
currentOffset += PAGE_SIZE;
|
||
loadData();
|
||
}
|
||
|
||
function updatePagination() {
|
||
const page = Math.floor(currentOffset / PAGE_SIZE) + 1;
|
||
const pages = Math.max(1, Math.ceil(currentTotal / PAGE_SIZE));
|
||
document.getElementById("page-info").textContent = `Стр. ${page} / ${pages}`;
|
||
document.getElementById("total-info").textContent = `Всего: ${currentTotal.toLocaleString("ru")}`;
|
||
document.getElementById("btn-prev").disabled = currentOffset <= 0;
|
||
document.getElementById("btn-next").disabled = currentOffset + PAGE_SIZE >= currentTotal;
|
||
}
|
||
|
||
// ── data load ─────────────────────────────────────────────────────────────────
|
||
|
||
async function loadData() {
|
||
setLoading(true);
|
||
try {
|
||
const resp = await fetch(`/api/schedule/data?${buildQuery()}`);
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const data = await resp.json();
|
||
currentTotal = data.total || 0;
|
||
renderTable(data.flights || []);
|
||
renderCards(data.flights || []);
|
||
updatePagination();
|
||
document.getElementById("last-updated").textContent =
|
||
"Обновлено: " + new Date().toLocaleTimeString("ru");
|
||
} catch (e) {
|
||
showError(e.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
// ── render table ──────────────────────────────────────────────────────────────
|
||
|
||
function renderTable(flights) {
|
||
const tbody = document.getElementById("table-body");
|
||
if (!flights.length) {
|
||
tbody.innerHTML = `<tr><td colspan="12" class="state-msg">Нет данных по выбранным фильтрам</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = flights.map(f => {
|
||
const dirIcon = f.direction === "departure"
|
||
? `<span class="dir-icon dir-dep" title="Вылет">↑</span>`
|
||
: `<span class="dir-icon dir-arr" title="Прилёт">↓</span>`;
|
||
|
||
const route = routeStr(f);
|
||
const sched = fmtTime(f.scheduled_at);
|
||
const actual = f.actual_at ? fmtTime(f.actual_at) : "—";
|
||
const delay = delayCell(f.delay_min);
|
||
const badge = statusBadge(f.status);
|
||
const dateStr = fmtDateShort(f.scheduled_at);
|
||
|
||
// Actual times from FR24 enrichment
|
||
const actTakeoff = f.actual_takeoff ? fmtTime(f.actual_takeoff) : "";
|
||
const actLanded = f.actual_landed ? fmtTime(f.actual_landed) : "";
|
||
const delayTakeoff = delayCell(f.delay_takeoff_min);
|
||
const delayLanded = delayCell(f.delay_landed_min);
|
||
|
||
// Category badge
|
||
const catBadge = categoryBadge(f.flight_category);
|
||
|
||
// FR24 track link
|
||
const trackLink = f.fr24_id
|
||
? `<a href="https://www.flightradar24.com/data/flights/${f.fr24_id}" target="_blank" class="track-link" title="Трек FR24">✈</a>`
|
||
: "";
|
||
|
||
// Enriched actual time display: show actual takeoff/landed if available
|
||
const actualDisplay = actTakeoff || actLanded
|
||
? `<span class="act-time">${actTakeoff || "—"}</span>` +
|
||
(f.direction === "arrival" && actLanded ? ` <span class="act-landed">⇣${actLanded}</span>` : "")
|
||
: actual;
|
||
|
||
return `<tr>
|
||
<td>${dateStr}</td>
|
||
<td><strong>${esc(f.flight_number)}</strong> ${trackLink}</td>
|
||
<td>${esc(f.airline || "—")}</td>
|
||
<td>${esc(f.airport)}</td>
|
||
<td>${dirIcon}</td>
|
||
<td>${esc(route)}</td>
|
||
<td>${sched}</td>
|
||
<td>${actualDisplay}</td>
|
||
<td>${delayTakeoff}</td>
|
||
<td>${catBadge}</td>
|
||
<td>${badge}</td>
|
||
<td>${trackLink}</td>
|
||
</tr>`;
|
||
}).join("");
|
||
}
|
||
|
||
// ── render cards (mobile) ─────────────────────────────────────────────────────
|
||
|
||
function renderCards(flights) {
|
||
const container = document.getElementById("cards-container");
|
||
if (!flights.length) {
|
||
container.innerHTML = `<div class="state-msg">Нет данных по выбранным фильтрам</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = flights.map(f => {
|
||
const dirLabel = f.direction === "departure" ? "↑ Вылет" : "↓ Прилёт";
|
||
const dirClass = f.direction === "departure" ? "dir-dep" : "dir-arr";
|
||
const route = routeStr(f);
|
||
const sched = fmtTime(f.scheduled_at);
|
||
const actual = f.actual_at ? fmtTime(f.actual_at) : "—";
|
||
const delay = f.delay_min != null ? `${f.delay_min > 0 ? "+" : ""}${f.delay_min} мин` : "—";
|
||
const badge = statusBadge(f.status);
|
||
const catBadge = categoryBadge(f.flight_category);
|
||
const trackLink = f.fr24_id
|
||
? `<a href="https://www.flightradar24.com/data/flights/${f.fr24_id}" target="_blank" style="color:#58a6ff">Трек</a>`
|
||
: "";
|
||
const actTakeoff = f.actual_takeoff ? fmtTime(f.actual_takeoff) : "";
|
||
const actLanded = f.actual_landed ? fmtTime(f.actual_landed) : "";
|
||
|
||
return `<div class="card">
|
||
<div class="card-header">
|
||
<div>
|
||
<div class="card-flight">${esc(f.flight_number)} ${catBadge} ${trackLink}</div>
|
||
<div class="card-airline">${esc(f.airline || "—")}</div>
|
||
</div>
|
||
${badge}
|
||
</div>
|
||
<div class="card-row"><span>Аэропорт</span><span>${esc(f.airport)}</span></div>
|
||
<div class="card-row"><span>Направление</span><span class="${dirClass}">${dirLabel}</span></div>
|
||
<div class="card-row"><span>Маршрут</span><span>${esc(route)}</span></div>
|
||
<div class="card-row"><span>Запланировано</span><span>${sched}</span></div>
|
||
<div class="card-row"><span>Фактически</span><span>${actual}</span></div>
|
||
<div class="card-row"><span>Задержка</span><span>${delay}</span></div>
|
||
${actTakeoff ? `<div class="card-row"><span>Взлёт факт</span><span>${actTakeoff} ${delayCell(f.delay_takeoff_min)}</span></div>` : ""}
|
||
${actLanded ? `<div class="card-row"><span>Посадка факт</span><span>${actLanded} ${delayCell(f.delay_landed_min)}</span></div>` : ""}
|
||
</div>`;
|
||
}).join("");
|
||
}
|
||
|
||
// ── CSV export ────────────────────────────────────────────────────────────────
|
||
|
||
function exportCsv() {
|
||
const qs = buildQuery({ limit: 100000, offset: 0 });
|
||
window.location.href = `/api/schedule/export?${qs}`;
|
||
}
|
||
|
||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
function routeStr(f) {
|
||
// Prefer thread_title from Yandex (most accurate)
|
||
const tt = f.thread_title || "";
|
||
if (tt) {
|
||
// "Москва — Алматы" → for departure show destination, for arrival show origin
|
||
return tt;
|
||
}
|
||
const o = f.origin || "";
|
||
const d = f.destination || "";
|
||
if (!o && !d) return "—";
|
||
if (!o) return `→ ${d}`;
|
||
if (!d) return `${o} →`;
|
||
return `${o} → ${d}`;
|
||
}
|
||
|
||
function categoryBadge(category) {
|
||
if (!category) return "";
|
||
const cls = {
|
||
"Passenger": "cat-pax",
|
||
"Cargo": "cat-cargo",
|
||
"Military": "cat-mil",
|
||
}[category] || "cat-other";
|
||
const labels = {
|
||
"Passenger": "P",
|
||
"Cargo": "C",
|
||
"Military": "M",
|
||
};
|
||
return `<span class="cat-badge ${cls}" title="${category}">${labels[category] || category}</span>`;
|
||
}
|
||
|
||
function statusBadge(status) {
|
||
const map = {
|
||
scheduled: "badge-scheduled",
|
||
departed: "badge-departed",
|
||
arrived: "badge-arrived",
|
||
delayed: "badge-delayed",
|
||
cancelled: "badge-cancelled",
|
||
};
|
||
const cls = map[status] || "badge-scheduled";
|
||
const labels = {
|
||
scheduled: "По расписанию",
|
||
departed: "Вылетел",
|
||
arrived: "Прилетел",
|
||
delayed: "Задержан",
|
||
cancelled: "Отменён",
|
||
};
|
||
return `<span class="badge ${cls}">${labels[status] || status || "—"}</span>`;
|
||
}
|
||
|
||
function delayCell(min) {
|
||
if (min == null) return "—";
|
||
if (min > 30) return `<span class="delay-critical">+${min}</span>`;
|
||
if (min > 0) return `<span class="delay-pos">+${min}</span>`;
|
||
if (min < 0) return `<span class="delay-neg">${min}</span>`;
|
||
return `<span class="delay-ok">0</span>`;
|
||
}
|
||
|
||
function fmtDate(d) {
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function fmtDateShort(iso) {
|
||
if (!iso) return "—";
|
||
return iso.slice(0, 10);
|
||
}
|
||
|
||
function fmtTime(iso) {
|
||
if (!iso) return "—";
|
||
try {
|
||
return new Date(iso).toLocaleTimeString("ru", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Moscow" });
|
||
} catch { return iso.slice(11, 16); }
|
||
}
|
||
|
||
function esc(s) {
|
||
if (!s) return "—";
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">");
|
||
}
|
||
|
||
function setLoading(on) {
|
||
if (on) {
|
||
document.getElementById("table-body").innerHTML =
|
||
`<tr><td colspan="12" class="state-msg">Загрузка…</td></tr>`;
|
||
document.getElementById("cards-container").innerHTML =
|
||
`<div class="state-msg">Загрузка…</div>`;
|
||
}
|
||
}
|
||
|
||
function showError(msg) {
|
||
document.getElementById("table-body").innerHTML =
|
||
`<tr><td colspan="12" class="state-msg" style="color:#f85149">Ошибка: ${esc(msg)}</td></tr>`;
|
||
document.getElementById("cards-container").innerHTML =
|
||
`<div class="state-msg" style="color:#f85149">Ошибка: ${esc(msg)}</div>`;
|
||
}
|