Initial project import
This commit is contained in:
578
web_app.py
Normal file
578
web_app.py
Normal file
@@ -0,0 +1,578 @@
|
||||
"""Interface web local em HTML para o planejador diario."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import threading
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from src.planejador_missao.main import executar_planejamento
|
||||
from src.planejador_missao.quadrinhos import quadrinhos_para_api
|
||||
from src.planejador_missao.utils import normalizar_texto, parse_bool
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
DADOS_DIR = BASE_DIR / "dados"
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 8050
|
||||
|
||||
|
||||
def read_csv(path: Path, default: pd.DataFrame) -> pd.DataFrame:
|
||||
if not path.exists():
|
||||
return default.copy()
|
||||
return pd.read_csv(path).fillna("")
|
||||
|
||||
|
||||
def load_state() -> dict:
|
||||
parametros_default = pd.DataFrame(
|
||||
{
|
||||
"parametro": ["data_planejamento", "criterio_missao", "criterio_sbv"],
|
||||
"valor": ["hoje", "meta_50", "equalizar_quadrinhos"],
|
||||
}
|
||||
)
|
||||
parametros_df = read_csv(DADOS_DIR / "parametros_missao.csv", parametros_default)
|
||||
parametros = dict(zip(parametros_df["parametro"], parametros_df["valor"]))
|
||||
|
||||
aeronaves_default = pd.DataFrame(
|
||||
{
|
||||
"aeronave": ["C98", "C97", "C95"],
|
||||
"disponivel_sede": [True, True, True],
|
||||
"em_pane": [False, False, False],
|
||||
"em_missao_rota": [False, False, False],
|
||||
"hora_livre": ["08:00", "08:00", "08:00"],
|
||||
}
|
||||
)
|
||||
aeronaves = read_csv(DADOS_DIR / "aeronaves_disponiveis.csv", aeronaves_default)
|
||||
for col in ["disponivel_sede", "em_pane", "em_missao_rota"]:
|
||||
aeronaves[col] = aeronaves[col].map(parse_bool)
|
||||
|
||||
rota_default = pd.DataFrame(
|
||||
{
|
||||
"origem": ["NT"],
|
||||
"destino": [""],
|
||||
"inicio": [pd.Timestamp.today().strftime("%Y-%m-%d 08:00")],
|
||||
"fim": [pd.Timestamp.today().strftime("%Y-%m-%d 17:00")],
|
||||
"tev_horas": [0],
|
||||
"pernoite_dias": [0],
|
||||
"rota": [""],
|
||||
}
|
||||
)
|
||||
rotas = read_csv(DADOS_DIR / "rotas_acionadas.csv", rota_default)
|
||||
rota = rota_default.iloc[0].to_dict() if rotas.empty else rotas.iloc[0].to_dict()
|
||||
|
||||
trips_default = pd.DataFrame({"tripulante": [], "disponivel": []})
|
||||
tripulantes = read_csv(DADOS_DIR / "tripulantes_disponiveis.csv", trips_default)
|
||||
if not tripulantes.empty:
|
||||
tripulantes["tripulante"] = tripulantes["tripulante"].map(normalizar_texto)
|
||||
tripulantes["disponivel"] = tripulantes["disponivel"].map(parse_bool)
|
||||
tripulantes = tripulantes.sort_values("tripulante")
|
||||
|
||||
return {
|
||||
"parametros": {
|
||||
"data_planejamento": str(parametros.get("data_planejamento", "hoje")),
|
||||
"criterio_missao": str(parametros.get("criterio_missao", "meta_50")),
|
||||
"criterio_sbv": str(parametros.get("criterio_sbv", "equalizar_quadrinhos")),
|
||||
},
|
||||
"aeronaves": aeronaves.to_dict(orient="records"),
|
||||
"rota": rota,
|
||||
"tem_rota": str(rota.get("destino", "")).strip() != "",
|
||||
"tripulantes": tripulantes.to_dict(orient="records"),
|
||||
}
|
||||
|
||||
|
||||
def save_state(payload: dict) -> None:
|
||||
parametros = payload.get("parametros", {})
|
||||
pd.DataFrame(
|
||||
{
|
||||
"parametro": ["data_planejamento", "criterio_missao", "criterio_sbv"],
|
||||
"valor": [
|
||||
parametros.get("data_planejamento", "hoje"),
|
||||
parametros.get("criterio_missao", "meta_50"),
|
||||
parametros.get("criterio_sbv", "equalizar_quadrinhos"),
|
||||
],
|
||||
}
|
||||
).to_csv(DADOS_DIR / "parametros_missao.csv", index=False)
|
||||
|
||||
pd.DataFrame(payload.get("aeronaves", [])).to_csv(DADOS_DIR / "aeronaves_disponiveis.csv", index=False)
|
||||
|
||||
if payload.get("tem_rota", False):
|
||||
rota = payload.get("rota", {})
|
||||
pd.DataFrame(
|
||||
{
|
||||
"origem": [normalizar_texto(rota.get("origem", "NT"))],
|
||||
"destino": [normalizar_texto(rota.get("destino", ""))],
|
||||
"inicio": [str(rota.get("inicio", "")).strip()],
|
||||
"fim": [str(rota.get("fim", "")).strip()],
|
||||
"tev_horas": [str(rota.get("tev_horas", "0")).strip()],
|
||||
"pernoite_dias": [str(rota.get("pernoite_dias", "0")).strip()],
|
||||
"rota": [normalizar_texto(rota.get("rota", ""))],
|
||||
}
|
||||
).to_csv(DADOS_DIR / "rotas_acionadas.csv", index=False)
|
||||
else:
|
||||
pd.DataFrame(columns=["origem", "destino", "inicio", "fim", "tev_horas", "pernoite_dias", "rota"]).to_csv(
|
||||
DADOS_DIR / "rotas_acionadas.csv", index=False
|
||||
)
|
||||
|
||||
tripulantes = pd.DataFrame(payload.get("tripulantes", []))
|
||||
if not tripulantes.empty:
|
||||
tripulantes["tripulante"] = tripulantes["tripulante"].map(normalizar_texto)
|
||||
tripulantes.to_csv(DADOS_DIR / "tripulantes_disponiveis.csv", index=False)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
if path == "/":
|
||||
self._send_html(INDEX_HTML)
|
||||
return
|
||||
if path == "/api/state":
|
||||
self._send_json(load_state())
|
||||
return
|
||||
if path == "/api/quadrinhos":
|
||||
self._send_json(quadrinhos_para_api(BASE_DIR))
|
||||
return
|
||||
if path.startswith("/assets/"):
|
||||
self._send_file(DADOS_DIR / "assets" / path.removeprefix("/assets/"))
|
||||
return
|
||||
self.send_error(404)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
payload = self._read_json()
|
||||
if path == "/api/save":
|
||||
save_state(payload)
|
||||
self._send_json({"ok": True, "message": "Entradas salvas em dados/."})
|
||||
return
|
||||
if path == "/api/run":
|
||||
save_state(payload)
|
||||
resultado = executar_planejamento(BASE_DIR)
|
||||
self._send_json(
|
||||
{
|
||||
"ok": True,
|
||||
"message": "Escala gerada com sucesso.",
|
||||
"data_planejamento": str(resultado["data_planejamento"]),
|
||||
"arquivo_saida": str(resultado["arquivo_saida"]),
|
||||
"total_candidatas": resultado["total_candidatas"],
|
||||
"total_selecionadas": resultado["total_selecionadas"],
|
||||
}
|
||||
)
|
||||
return
|
||||
if path == "/api/open-latest":
|
||||
arquivos = sorted((BASE_DIR / "resultados").glob("planejamento_diario_*.xlsx"), key=lambda p: p.stat().st_mtime)
|
||||
if not arquivos:
|
||||
self._send_json({"ok": False, "message": "Nenhuma planilha encontrada."}, status=404)
|
||||
return
|
||||
os.startfile(arquivos[-1])
|
||||
self._send_json({"ok": True, "message": f"Abrindo {arquivos[-1]}"})
|
||||
return
|
||||
self.send_error(404)
|
||||
|
||||
def _read_json(self) -> dict:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length == 0:
|
||||
return {}
|
||||
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
|
||||
def _send_json(self, payload: dict, status: int = 200) -> None:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _send_html(self, html: str) -> None:
|
||||
data = html.encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _send_file(self, path: Path) -> None:
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
assets_dir = (DADOS_DIR / "assets").resolve()
|
||||
if not resolved.is_file() or assets_dir not in resolved.parents:
|
||||
self.send_error(404)
|
||||
return
|
||||
data = resolved.read_bytes()
|
||||
content_type = mimetypes.guess_type(str(resolved))[0] or "application/octet-stream"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
except OSError:
|
||||
self.send_error(404)
|
||||
|
||||
def log_message(self, format: str, *args) -> None:
|
||||
return
|
||||
|
||||
|
||||
INDEX_HTML = r"""<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Planejador Diario de Missoes</title>
|
||||
<link rel="icon" href="/assets/favicon.ico">
|
||||
<style>
|
||||
:root {
|
||||
--ink:#172033; --muted:#667085; --line:#d7deea; --brand:#1f4e78; --brand-2:#2c6b9f;
|
||||
--bg:#eef3f8; --panel:#ffffff; --soft:#f7fafc; --ok:#26734d; --ok-2:#1d6b46; --warn:#9a3412;
|
||||
--shadow:0 12px 28px rgba(23,32,51,.10); --shadow-soft:0 5px 16px rgba(23,32,51,.07);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: Segoe UI, Arial, sans-serif; color:var(--ink); background:var(--bg); }
|
||||
header { background:linear-gradient(135deg, #12334f, #1f4e78 58%, #2f7a9f); color:white; padding:18px 26px; box-shadow:var(--shadow); }
|
||||
.topbar { max-width:1280px; margin:0 auto; display:flex; align-items:center; justify-content:space-between; gap:18px; }
|
||||
.brand { display:flex; align-items:center; gap:14px; min-width:0; }
|
||||
.brand img { width:48px; height:48px; object-fit:contain; background:white; border-radius:6px; padding:5px; }
|
||||
h1 { font-size:22px; line-height:1.15; margin:0; color:white; letter-spacing:0; }
|
||||
.subtitle { margin-top:4px; color:#dceaf5; font-size:13px; }
|
||||
main { padding:22px 24px 32px; max-width:1280px; margin:0 auto; }
|
||||
.grid { display:grid; grid-template-columns: minmax(0, 1fr) minmax(360px, .9fr); gap:18px; align-items:start; }
|
||||
section { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:18px; box-shadow:var(--shadow-soft); }
|
||||
h2 { font-size:15px; margin:0 0 14px; color:var(--brand); display:flex; align-items:center; gap:8px; }
|
||||
h2::before { content:""; width:4px; height:18px; border-radius:4px; background:var(--brand-2); display:inline-block; }
|
||||
label { display:block; font-size:12px; font-weight:700; color:#344054; margin-bottom:6px; }
|
||||
input, select { width:100%; padding:10px 11px; border:1px solid #c7d3e0; border-radius:6px; font-size:14px; background:white; color:var(--ink); outline:none; transition:border-color .15s, box-shadow .15s; }
|
||||
input:focus, select:focus { border-color:var(--brand-2); box-shadow:0 0 0 3px rgba(47,122,159,.14); }
|
||||
.row { display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:12px; margin-bottom:12px; }
|
||||
.check { display:flex; align-items:center; gap:8px; margin:9px 0; font-size:14px; color:#26364a; }
|
||||
.check input { width:16px; height:16px; accent-color:var(--brand); }
|
||||
.aircraft { border-top:1px solid var(--line); padding-top:13px; margin-top:13px; }
|
||||
.aircraft:first-of-type { border-top:0; padding-top:0; margin-top:0; }
|
||||
.aircraft strong { display:block; margin-bottom:9px; color:#122b43; font-size:14px; }
|
||||
.crewbar { display:flex; gap:8px; margin-bottom:10px; }
|
||||
button { border:0; border-radius:6px; padding:10px 14px; font-weight:700; cursor:pointer; background:#e7edf3; color:#172033; transition:transform .12s, box-shadow .12s, background .12s; }
|
||||
button:hover { transform:translateY(-1px); box-shadow:0 5px 12px rgba(23,32,51,.13); }
|
||||
button.primary { background:#ffffff; color:var(--brand); }
|
||||
button.success { background:var(--ok); color:white; }
|
||||
main button.primary { background:var(--brand); color:white; }
|
||||
main button.success { background:var(--ok-2); color:white; }
|
||||
button:disabled { opacity:.55; cursor:wait; }
|
||||
.crew { height:290px; overflow:auto; border:1px solid var(--line); border-radius:8px; padding:10px; display:grid; grid-template-columns: repeat(4, minmax(78px, 1fr)); gap:6px 10px; background:var(--soft); }
|
||||
.crew label { display:flex; align-items:center; gap:7px; font-size:13px; font-weight:600; margin:0; background:white; border:1px solid #e1e8f0; border-radius:5px; padding:6px 7px; }
|
||||
.crew input { width:15px; height:15px; accent-color:var(--brand); }
|
||||
pre { white-space:pre-wrap; background:#101928; color:#dbe7f2; border-radius:8px; padding:15px; min-height:132px; margin:0; font-size:13px; line-height:1.5; border:1px solid #26374d; }
|
||||
.actions { display:flex; flex-wrap:wrap; gap:10px; }
|
||||
.tabs { display:flex; gap:8px; margin-bottom:18px; border-bottom:1px solid var(--line); }
|
||||
.tab { border-radius:7px 7px 0 0; background:#dfe8f2; color:#1f2937; padding:10px 14px; box-shadow:none; }
|
||||
.tab:hover { box-shadow:none; }
|
||||
.tab.active { background:var(--brand); color:white; }
|
||||
.view { display:none; }
|
||||
.view.active { display:block; }
|
||||
.summary { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap:10px; margin-bottom:12px; }
|
||||
.metric { border:1px solid var(--line); border-radius:8px; padding:12px; background:var(--soft); color:#475467; font-weight:700; }
|
||||
.metric b { display:block; color:var(--brand); font-size:20px; margin-top:3px; }
|
||||
.toolbar { display:grid; grid-template-columns: 1.5fr 180px 180px; gap:10px; margin-bottom:10px; }
|
||||
.tablewrap { overflow:auto; border:1px solid var(--line); border-radius:8px; background:white; max-height:520px; }
|
||||
table { width:100%; border-collapse:collapse; font-size:13px; min-width:980px; }
|
||||
th, td { border-bottom:1px solid var(--line); padding:9px 10px; text-align:left; vertical-align:top; }
|
||||
th { background:#e8f0f8; color:#243b53; position:sticky; top:0; z-index:1; }
|
||||
tbody tr:nth-child(even) { background:#fafcff; }
|
||||
td.num { text-align:right; font-variant-numeric: tabular-nums; }
|
||||
@media (max-width: 900px) { .topbar { align-items:flex-start; flex-direction:column; } .grid { grid-template-columns:1fr; } .crew { grid-template-columns: repeat(2, minmax(80px, 1fr)); } }
|
||||
@media (max-width: 900px) { .summary, .toolbar { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<div class="brand">
|
||||
<img src="/assets/app_logo.png" alt="">
|
||||
<div>
|
||||
<h1>Planejador Diario de Missoes</h1>
|
||||
<div class="subtitle">Escala, sobreaviso e acompanhamento operacional</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button onclick="save()">Salvar entradas</button>
|
||||
<button class="primary" onclick="run()">Gerar escala</button>
|
||||
<button class="success" onclick="openLatest()">Abrir planilha</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-view="planejamento" onclick="showView('planejamento')">Planejamento</button>
|
||||
<button class="tab" data-view="quadrinhos" onclick="showView('quadrinhos')">Quadrinhos</button>
|
||||
</div>
|
||||
<div id="planejamento" class="view active">
|
||||
<div class="grid">
|
||||
<section>
|
||||
<h2>Parametros</h2>
|
||||
<div class="row">
|
||||
<div><label>Data</label><input id="data_planejamento" placeholder="hoje ou AAAA-MM-DD"></div>
|
||||
<div><label>Criterio missao</label><select id="criterio_missao"><option>meta_50</option><option>meta_110</option><option>menor_custo</option></select></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>Criterio SBV</label><select id="criterio_sbv"><option>equalizar_quadrinhos</option><option>meta_50</option><option>meta_110</option></select></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Aeronaves</h2>
|
||||
<div id="aeronaves"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Missao acionada</h2>
|
||||
<label class="check"><input id="tem_rota" type="checkbox"> Existe missao de rota acionada</label>
|
||||
<div class="row">
|
||||
<div><label>Origem</label><input id="origem"></div>
|
||||
<div><label>Destino</label><input id="destino"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>Inicio</label><input id="inicio" placeholder="AAAA-MM-DD HH:MM"></div>
|
||||
<div><label>Fim</label><input id="fim" placeholder="AAAA-MM-DD HH:MM"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>TEV horas</label><input id="tev_horas"></div>
|
||||
<div><label>Pernoites</label><input id="pernoite_dias"></div>
|
||||
</div>
|
||||
<div><label>Rota resumida</label><input id="rota"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Tripulantes disponiveis</h2>
|
||||
<div class="crewbar">
|
||||
<button onclick="setCrew(true)">Marcar todos</button>
|
||||
<button onclick="setCrew(false)">Desmarcar todos</button>
|
||||
</div>
|
||||
<div id="tripulantes" class="crew"></div>
|
||||
</section>
|
||||
|
||||
<section style="grid-column: 1 / -1;">
|
||||
<h2>Status</h2>
|
||||
<pre id="log">Carregando dados...</pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="quadrinhos" class="view">
|
||||
<section>
|
||||
<h2>Quadrinho operacional</h2>
|
||||
<div class="summary">
|
||||
<div class="metric">Tripulantes<b id="qtd_trip">-</b></div>
|
||||
<div class="metric">Quadrinhos abaixo de 50h<b id="qtd_abaixo50">-</b></div>
|
||||
<div class="metric">Media de horas no ano<b id="media_horas">-</b></div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<input id="q_busca" placeholder="Filtrar por tripulante, OI ou observacao" oninput="renderQuadrinhos()">
|
||||
<select id="q_aeronave" onchange="renderQuadrinhos()"><option value="">Todas aeronaves</option><option>C98</option><option>C97</option><option>C95</option></select>
|
||||
<select id="q_meta" onchange="renderQuadrinhos()"><option value="">Todos</option><option value="abaixo50">Abaixo de 50h</option></select>
|
||||
</div>
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Trip</th><th>Aeronave</th><th>Qualificacao</th><th>Proxima OI</th>
|
||||
<th>Horas base</th><th>Horas ano</th><th>SBV</th><th>Faltam 50h</th><th>Ultimo voo</th><th>Obs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="quadrinhos_body"><tr><td colspan="10">Carregando...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
let state = null;
|
||||
let quadrinhos = [];
|
||||
|
||||
async function load() {
|
||||
const res = await fetch('/api/state');
|
||||
state = await res.json();
|
||||
document.querySelector('#data_planejamento').value = state.parametros.data_planejamento;
|
||||
document.querySelector('#criterio_missao').value = state.parametros.criterio_missao;
|
||||
document.querySelector('#criterio_sbv').value = state.parametros.criterio_sbv;
|
||||
document.querySelector('#tem_rota').checked = state.tem_rota;
|
||||
for (const key of ['origem','destino','inicio','fim','tev_horas','pernoite_dias','rota']) {
|
||||
document.querySelector('#' + key).value = state.rota[key] ?? '';
|
||||
}
|
||||
renderAircraft();
|
||||
renderCrew();
|
||||
log('Pronto. Edite as entradas e clique em Salvar e gerar escala.');
|
||||
loadQuadrinhos();
|
||||
}
|
||||
|
||||
function showView(name) {
|
||||
for (const view of document.querySelectorAll('.view')) view.classList.toggle('active', view.id === name);
|
||||
for (const tab of document.querySelectorAll('.tab')) tab.classList.toggle('active', tab.dataset.view === name);
|
||||
if (name === 'quadrinhos' && quadrinhos.length === 0) loadQuadrinhos();
|
||||
}
|
||||
|
||||
async function loadQuadrinhos() {
|
||||
const res = await fetch('/api/quadrinhos');
|
||||
const data = await res.json();
|
||||
quadrinhos = data.linhas ?? [];
|
||||
document.querySelector('#qtd_trip').textContent = data.resumo?.tripulantes ?? 0;
|
||||
document.querySelector('#qtd_abaixo50').textContent = data.resumo?.abaixo_50 ?? 0;
|
||||
document.querySelector('#media_horas').textContent = `${data.resumo?.horas_media ?? 0} h`;
|
||||
renderQuadrinhos();
|
||||
}
|
||||
|
||||
function renderQuadrinhos() {
|
||||
const root = document.querySelector('#quadrinhos_body');
|
||||
const busca = (document.querySelector('#q_busca')?.value ?? '').trim().toUpperCase();
|
||||
const aeronave = document.querySelector('#q_aeronave')?.value ?? '';
|
||||
const meta = document.querySelector('#q_meta')?.value ?? '';
|
||||
const rows = quadrinhos.filter((q) => {
|
||||
const texto = `${q.tripulante} ${q.aeronave} ${q.qualificacao} ${q.proxima_oi} ${q.observacao}`.toUpperCase();
|
||||
return (!busca || texto.includes(busca)) && (!aeronave || q.aeronave === aeronave) && (!meta || Number(q.horas_voadas_ano) < 50);
|
||||
});
|
||||
if (rows.length === 0) {
|
||||
root.innerHTML = '<tr><td colspan="10">Nenhum quadrinho encontrado.</td></tr>';
|
||||
return;
|
||||
}
|
||||
root.innerHTML = rows.map((q) => `
|
||||
<tr>
|
||||
<td>${q.tripulante}</td>
|
||||
<td>${q.aeronave}</td>
|
||||
<td>${q.qualificacao}</td>
|
||||
<td>${q.proxima_oi}</td>
|
||||
<td class="num">${fmt(q.horas_aeronave_base)}</td>
|
||||
<td class="num">${fmt(q.horas_voadas_ano)}</td>
|
||||
<td class="num">${fmt(q.sbv_ano)}</td>
|
||||
<td class="num">${fmt(q.meta_50_faltante)}</td>
|
||||
<td>${q.ultima_data_voo ?? ''}</td>
|
||||
<td>${q.observacao ?? ''}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function fmt(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '';
|
||||
return n.toLocaleString('pt-BR', { maximumFractionDigits: 1 });
|
||||
}
|
||||
|
||||
function renderAircraft() {
|
||||
const root = document.querySelector('#aeronaves');
|
||||
root.innerHTML = '';
|
||||
for (const av of state.aeronaves) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'aircraft';
|
||||
item.innerHTML = `
|
||||
<strong>${av.aeronave}</strong>
|
||||
<label class="check"><input type="checkbox" data-av="${av.aeronave}" data-field="disponivel_sede" ${av.disponivel_sede ? 'checked' : ''}> Disponivel em sede</label>
|
||||
<label class="check"><input type="checkbox" data-av="${av.aeronave}" data-field="em_pane" ${av.em_pane ? 'checked' : ''}> Em pane</label>
|
||||
<label class="check"><input type="checkbox" data-av="${av.aeronave}" data-field="em_missao_rota" ${av.em_missao_rota ? 'checked' : ''}> Em missao de rota</label>
|
||||
<label>Hora livre</label><input value="${av.hora_livre ?? ''}" data-av="${av.aeronave}" data-field="hora_livre">
|
||||
`;
|
||||
root.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCrew() {
|
||||
const root = document.querySelector('#tripulantes');
|
||||
root.innerHTML = '';
|
||||
for (const trip of state.tripulantes) {
|
||||
const label = document.createElement('label');
|
||||
label.innerHTML = `<input type="checkbox" data-trip="${trip.tripulante}" ${trip.disponivel ? 'checked' : ''}> ${trip.tripulante}`;
|
||||
root.appendChild(label);
|
||||
}
|
||||
}
|
||||
|
||||
function collect() {
|
||||
const payload = {
|
||||
parametros: {
|
||||
data_planejamento: document.querySelector('#data_planejamento').value,
|
||||
criterio_missao: document.querySelector('#criterio_missao').value,
|
||||
criterio_sbv: document.querySelector('#criterio_sbv').value,
|
||||
},
|
||||
tem_rota: document.querySelector('#tem_rota').checked,
|
||||
rota: {},
|
||||
aeronaves: [],
|
||||
tripulantes: [],
|
||||
};
|
||||
for (const key of ['origem','destino','inicio','fim','tev_horas','pernoite_dias','rota']) {
|
||||
payload.rota[key] = document.querySelector('#' + key).value;
|
||||
}
|
||||
for (const av of state.aeronaves) {
|
||||
const out = { aeronave: av.aeronave };
|
||||
for (const field of ['disponivel_sede','em_pane','em_missao_rota']) {
|
||||
out[field] = document.querySelector(`[data-av="${av.aeronave}"][data-field="${field}"]`).checked;
|
||||
}
|
||||
out.hora_livre = document.querySelector(`[data-av="${av.aeronave}"][data-field="hora_livre"]`).value;
|
||||
payload.aeronaves.push(out);
|
||||
}
|
||||
for (const input of document.querySelectorAll('[data-trip]')) {
|
||||
payload.tripulantes.push({ tripulante: input.dataset.trip, disponivel: input.checked });
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch('/api/save', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(collect()) });
|
||||
const data = await res.json();
|
||||
log(data.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
setBusy(true);
|
||||
log('Gerando escala. Aguarde...');
|
||||
try {
|
||||
const res = await fetch('/api/run', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(collect()) });
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(data.message);
|
||||
log(`${data.message}\nData: ${data.data_planejamento}\nColunas candidatas: ${data.total_candidatas}\nEscalas selecionadas: ${data.total_selecionadas}\nArquivo: ${data.arquivo_saida}`);
|
||||
loadQuadrinhos();
|
||||
} catch (err) {
|
||||
log('Erro ao gerar escala:\n' + err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openLatest() {
|
||||
const res = await fetch('/api/open-latest', { method:'POST', headers:{'Content-Type':'application/json'}, body:'{}' });
|
||||
const data = await res.json();
|
||||
log(data.message);
|
||||
}
|
||||
|
||||
function setCrew(value) {
|
||||
for (const input of document.querySelectorAll('[data-trip]')) input.checked = value;
|
||||
}
|
||||
|
||||
function setBusy(value) {
|
||||
for (const btn of document.querySelectorAll('button')) btn.disabled = value;
|
||||
}
|
||||
|
||||
function log(text) {
|
||||
document.querySelector('#log').textContent = text;
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||
url = f"http://{HOST}:{PORT}"
|
||||
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
|
||||
print(f"Interface web aberta em {url}")
|
||||
print("Pressione Ctrl+C para encerrar.")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user