Files
andreandClaude Opus 4.8 03102cf1a9 Fix horário calendar save: surface real error, validate sala
The save AJAX endpoint swallowed DB exceptions behind a generic
"Erro ao guardar" with no logging, making failures undiagnosable.
Now log the real exception (same sink as the global handler) and
echo the detail back in dev.

Also validate `local` against the allowed sala set instead of only
its length, so an out-of-enum value returns a clean 422 instead of
a 500 (horario.local is a strict ENUM).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:16:11 +01:00

722 lines
34 KiB
PHP
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.
<?php
require __DIR__ . '/../../../config/db.php';
require __DIR__ . '/../../../config/functions.php';
$user = require_admin();
$dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Quinta','sexta'=>'Sexta','sábado'=>'Sábado','domingo'=>'Domingo'];
$salas = ['sala1'=>'Sala 1','sala2'=>'Sala 2','crossfit'=>'CrossFit',''=>'Outro'];
// ─── AJAX: same-page JSON endpoints ───────────────────────────────────────────
// move — drag-and-drop reschedule (day + start/end; modalidade/sala unchanged)
// save — modal create (id=0) or full edit of a class
// delete — remove a class
// All validate CSRF via the X-CSRF-Token header.
if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($_POST['action'] ?? '', ['move', 'save', 'delete'], true)) {
header('Content-Type: application/json; charset=utf-8');
$supplied = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ($_POST['_csrf'] ?? '');
if (!is_string($supplied) || !hash_equals($_SESSION['csrf_token'] ?? '', $supplied)) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Sessão expirada']);
exit;
}
$fail = static function (int $code, string $msg): void {
http_response_code($code);
echo json_encode(['ok' => false, 'error' => $msg]);
exit;
};
$hhmm = '/^([01]\d|2[0-3]):[0-5]\d$/';
try {
switch ($_POST['action']) {
case 'move':
$id = (int) ($_POST['id'] ?? 0);
$dia = (string) ($_POST['dia'] ?? '');
$hi = (string) ($_POST['hora_inicio'] ?? '');
$hf = (string) ($_POST['hora_fim'] ?? '');
if ($id < 1 || !array_key_exists($dia, $dias) || !preg_match($hhmm, $hi) || !preg_match($hhmm, $hf)) {
$fail(422, 'Dados inválidos');
}
db_horario_move($pdo, $id, $dia, $hi . ':00', $hf . ':00');
echo json_encode(['ok' => true]);
break;
case 'delete':
$id = (int) ($_POST['id'] ?? 0);
if ($id < 1) $fail(422, 'Dados inválidos');
db_horario_delete($pdo, $id);
echo json_encode(['ok' => true]);
break;
case 'save':
$id = (int) ($_POST['id'] ?? 0); // 0 = criar nova aula
$mod = (int) ($_POST['id_modalidade'] ?? 0);
$dia = (string) ($_POST['dia'] ?? '');
$hi = (string) ($_POST['hora_inicio'] ?? '');
$hf = (string) ($_POST['hora_fim'] ?? '');
$local = trim((string) ($_POST['local'] ?? ''));
if ($id < 0 || !array_key_exists($dia, $dias)) $fail(422, 'Dados inválidos');
if (!preg_match($hhmm, $hi) || !preg_match($hhmm, $hf)) $fail(422, 'Horas inválidas');
if ($hf <= $hi) $fail(422, 'A hora de fim deve ser depois do início');
if (!array_key_exists($local, $salas)) $fail(422, 'Sala inválida'); // enum membership
if ($mod < 1 || !db_modalidades_get($pdo, $mod)) $fail(422, 'Modalidade inválida');
$payload = [
'id_modalidade' => $mod,
'dia_semana' => $dia,
'hora_inicio' => $hi . ':00',
'hora_fim' => $hf . ':00',
'local' => $local,
];
if ($id === 0) $id = db_horario_create($pdo, $payload);
else db_horario_update($pdo, $id, $payload);
echo json_encode(['ok' => true, 'id' => $id]);
break;
}
} catch (Throwable $e) {
// Surface the real cause: log it (same sink as the global handler) so a
// failed save on prod is diagnosable, and echo the detail back in dev.
if (function_exists('_error_log_write')) {
_error_log_write(get_class($e), $e->getMessage(), $e->getFile(), $e->getLine(), $e->getTraceAsString());
} else {
error_log('[horario/calendar save] ' . $e->getMessage());
}
http_response_code(500);
$detail = (defined('APP_ENV') && APP_ENV === 'prod') ? 'Erro ao guardar' : $e->getMessage();
echo json_encode(['ok' => false, 'error' => $detail]);
}
exit;
}
// ─── Build the calendar model ─────────────────────────────────────────────────
$items = db_horario_list($pdo);
$modalidades = db_modalidades_options($pdo);
$to_min = static function ($t): ?int {
if (!is_string($t) || !preg_match('/^(\d{1,2}):(\d{2})/', $t, $m)) return null;
return ((int) $m[1]) * 60 + (int) $m[2];
};
$events = [];
$minStart = 8 * 60; // default window 08:00–21:00, widened to fit the data
$maxEnd = 21 * 60;
foreach ($items as $it) {
$s = $to_min($it['hora_inicio_fmt'] ?? $it['hora_inicio'] ?? null);
if ($s === null) continue;
$e = $to_min($it['hora_fim_fmt'] ?? $it['hora_fim'] ?? null);
if ($e === null || $e <= $s) $e = $s + 60;
$minStart = min($minStart, $s);
$maxEnd = max($maxEnd, $e);
$events[] = [
'id' => (int) $it['id'],
'dia' => (string) $it['dia_semana'],
'start' => $s,
'end' => $e,
'mod' => (int) $it['id_modalidade'],
'nome' => (string) ($it['modalidade_nome'] ?? ''),
'cor' => ($it['modalidade_cor'] ?? '') ?: '#6c757d',
'sala' => (string) ($it['local'] ?? ''),
];
}
// Sala options for the filter + modal: the known set plus any legacy values in use.
$locais = $salas;
foreach ($events as $ev) {
if ($ev['sala'] !== '' && !isset($locais[$ev['sala']])) $locais[$ev['sala']] = $ev['sala'];
}
$rangeStart = max(0, intdiv($minStart, 60) * 60); // floor to the hour
$rangeEnd = (int) min(24 * 60, ceil($maxEnd / 60) * 60); // ceil to the hour
if ($rangeEnd <= $rangeStart) $rangeEnd = $rangeStart + 60;
$hourpx = 52;
$startHour = intdiv($rangeStart, 60);
$endHour = intdiv($rangeEnd, 60);
$bodyH = (int) round((($rangeEnd - $rangeStart) / 60) * $hourpx);
$byDay = array_fill_keys(array_keys($dias), []);
foreach ($events as $ev) { $byDay[$ev['dia']][] = $ev; }
$mods_js = [];
foreach ($modalidades as $m) {
$mods_js[(string) $m['m_id']] = ['nome' => $m['nome'], 'cor' => ($m['cor'] ?? '') ?: '#6c757d'];
}
$page_title = 'Horário — Calendário';
include __DIR__ . '/../../../includes/header.php';
?>
<style>
.cal-layout { display: flex; gap: 1rem; align-items: flex-start; }
.cal-layout .cal-wrap { flex: 1 1 auto; min-width: 0; }
.cal-wrap { overflow-x: auto; padding-bottom: 6px; }
.cal {
--hourpx: <?= $hourpx ?>px;
display: grid;
grid-template-columns: 56px repeat(7, minmax(116px, 1fr));
min-width: 920px;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
overflow: hidden;
}
.cal-corner, .cal-dayhead {
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
padding: 8px 6px;
}
.cal-dayhead {
border-left: 1px solid #e9ecef;
text-align: center;
font-weight: 600;
font-size: .8rem;
}
.cal-gutter { position: relative; }
.cal-hourlabel {
position: absolute; right: 6px; transform: translateY(-50%);
font-size: .7rem; color: #6c757d; white-space: nowrap;
}
.cal-col {
position: relative;
border-left: 1px solid #e9ecef;
background-image:
repeating-linear-gradient(to bottom, #f1f3f5 0, #f1f3f5 1px, transparent 1px, transparent calc(var(--hourpx) / 2)),
repeating-linear-gradient(to bottom, #e3e7eb 0, #e3e7eb 1px, transparent 1px, transparent var(--hourpx));
}
.cal-col.drop-hint { background-color: #eaf4ff; }
.cal-event {
position: absolute; left: 2px; width: calc(100% - 4px);
border-radius: 6px; padding: 3px 6px; overflow: hidden;
background: color-mix(in srgb, var(--c) 16%, #fff);
border: 1px solid color-mix(in srgb, var(--c) 42%, #fff);
border-left: 3px solid var(--c);
color: #1f2937; font-size: .74rem; line-height: 1.18;
box-shadow: 0 1px 2px rgba(0,0,0,.06);
cursor: grab; user-select: none; touch-action: none;
visibility: hidden; /* revealed by JS after first layout */
}
.cal.ready .cal-event { visibility: visible; }
.cal-event:hover { box-shadow: 0 2px 6px rgba(0,0,0,.14); }
.cal-event.dragging { opacity: .9; cursor: grabbing; z-index: 30; box-shadow: 0 8px 20px rgba(0,0,0,.22); }
.cal-event.cal-hidden { display: none; }
.cal-ev-title { font-weight: 600; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cal-ev-time { opacity: .8; font-size: .68rem; }
.cal-ev-sala { opacity: .8; font-size: .68rem; }
/* Modalidades side panel (drag source) */
.mod-panel { flex: 0 0 190px; }
.mod-panel .card-body { padding: .75rem; }
.mod-list { display: flex; flex-direction: column; gap: 6px; }
.mod-chip {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; border: 1px solid #dee2e6; border-radius: 6px;
background: #fff; font-size: .85rem;
cursor: grab; user-select: none; touch-action: none;
}
.mod-chip:hover { box-shadow: 0 2px 6px rgba(0,0,0,.12); }
.mod-chip .dot { width: 12px; height: 12px; border-radius: 3px; flex: 0 0 auto; }
.mod-chip .mod-nome { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mod-chip.ghost {
position: fixed; z-index: 1090; pointer-events: none;
opacity: .92; width: 170px; box-shadow: 0 8px 20px rgba(0,0,0,.25);
cursor: grabbing;
}
@media (max-width: 991px) {
.cal-layout { flex-direction: column; }
.mod-panel { flex: none; width: 100%; }
.mod-list { flex-direction: row; flex-wrap: wrap; }
}
.cal-toast {
position: fixed; right: 18px; bottom: 18px; z-index: 1080;
padding: .6rem .9rem; border-radius: 8px; color: #fff; font-size: .85rem;
box-shadow: 0 6px 18px rgba(0,0,0,.25); opacity: 0; transform: translateY(8px);
transition: opacity .18s, transform .18s; pointer-events: none; max-width: 280px;
}
.cal-toast.show { opacity: 1; transform: translateY(0); }
.cal-toast.ok { background: #198754; }
.cal-toast.err { background: #dc3545; }
</style>
<div class="container-fluid px-0">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<h1 class="h3 mb-0">Horário de Aulas</h1>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary" href="<?= base_url('views/admin/horario/instagram.php') ?>"><i class="bi bi-instagram me-1"></i>Instagram</a>
<a class="btn btn-outline-secondary" href="<?= base_url('views/admin/horario/index.php') ?>"><i class="bi bi-list-ul me-1"></i>Vista de lista</a>
<a class="btn btn-primary" id="btnAdd" href="<?= base_url('views/admin/horario/create.php') ?>"><i class="bi bi-plus-lg me-1"></i>Adicionar Aula</a>
</div>
</div>
<p class="text-muted small mb-2"><i class="bi bi-arrows-move me-1"></i>Arraste uma aula para reagendar, ou uma modalidade do painel para criar. Clique numa aula para editar.</p>
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
<i class="bi bi-funnel text-muted"></i>
<select id="fltMod" class="form-select form-select-sm" style="width:auto">
<option value="">Todas as modalidades</option>
<?php foreach ($modalidades as $m): ?>
<option value="<?= (int) $m['m_id'] ?>"><?= htmlspecialchars($m['nome']) ?></option>
<?php endforeach; ?>
</select>
<select id="fltSala" class="form-select form-select-sm" style="width:auto">
<option value="">Todas as salas</option>
<?php foreach ($locais as $val => $label): if ($val === '') continue; ?>
<option value="<?= htmlspecialchars($val) ?>"><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
<span id="fltCount" class="text-muted small"></span>
</div>
<?php if (!$events): ?>
<div class="alert alert-info">Ainda não há aulas. Arraste uma modalidade do painel para o calendário para criar a primeira.</div>
<?php endif; ?>
<div class="cal-layout">
<div class="mod-panel">
<div class="card shadow-sm"><div class="card-body">
<h2 class="h6 mb-1">Modalidades</h2>
<p class="text-muted mb-2" style="font-size:.72rem">Arraste para uma hora do calendário</p>
<div class="mod-list">
<?php foreach ($modalidades as $m): ?>
<div class="mod-chip" data-mod="<?= (int) $m['m_id'] ?>">
<span class="dot" style="background:<?= htmlspecialchars(($m['cor'] ?? '') ?: '#6c757d') ?>"></span>
<span class="mod-nome"><?= htmlspecialchars($m['nome']) ?></span>
</div>
<?php endforeach; ?>
</div>
</div></div>
</div>
<div class="cal-wrap">
<div class="cal" id="cal">
<div class="cal-corner"></div>
<?php foreach ($dias as $label): ?>
<div class="cal-dayhead"><?= htmlspecialchars($label) ?></div>
<?php endforeach; ?>
<div class="cal-gutter" style="height:<?= $bodyH ?>px">
<?php for ($h = $startHour; $h <= $endHour; $h++): ?>
<div class="cal-hourlabel" style="top:<?= ($h - $startHour) * $hourpx ?>px"><?= sprintf('%02d:00', $h) ?></div>
<?php endfor; ?>
</div>
<?php foreach ($dias as $val => $label): ?>
<div class="cal-col" data-day="<?= htmlspecialchars($val) ?>" style="height:<?= $bodyH ?>px">
<?php foreach ($byDay[$val] as $ev): ?>
<div class="cal-event"
data-id="<?= $ev['id'] ?>"
data-day="<?= htmlspecialchars($ev['dia']) ?>"
data-start="<?= $ev['start'] ?>"
data-end="<?= $ev['end'] ?>"
data-mod="<?= $ev['mod'] ?>"
data-sala="<?= htmlspecialchars($ev['sala']) ?>"
style="--c:<?= htmlspecialchars($ev['cor']) ?>"
title="<?= htmlspecialchars($ev['nome']) ?>">
<span class="cal-ev-title"><?= htmlspecialchars($ev['nome']) ?></span>
<span class="cal-ev-time"><?= sprintf('%02d:%02d', intdiv($ev['start'], 60), $ev['start'] % 60) ?>–<?= sprintf('%02d:%02d', intdiv($ev['end'], 60), $ev['end'] % 60) ?></span>
<span class="cal-ev-sala"><?= $ev['sala'] === '' ? '' : ' · ' . htmlspecialchars($locais[$ev['sala']] ?? $ev['sala']) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<!-- Edit / create modal -->
<div class="modal fade" id="evModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<form class="modal-content" id="evForm">
<div class="modal-header">
<h5 class="modal-title" id="evTitle">Editar Aula</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fechar"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger py-2 d-none" id="evErr"></div>
<input type="hidden" id="ev-id">
<div class="row g-3">
<div class="col-12"><label class="form-label">Modalidade <span class="text-danger">*</span></label>
<select class="form-select" id="ev-mod" required>
<option value="">Selecionar…</option>
<?php foreach ($modalidades as $m): ?>
<option value="<?= (int) $m['m_id'] ?>"><?= htmlspecialchars($m['nome']) ?></option>
<?php endforeach; ?>
</select></div>
<div class="col-md-4"><label class="form-label">Dia <span class="text-danger">*</span></label>
<select class="form-select" id="ev-dia" required>
<?php foreach ($dias as $val => $label): ?>
<option value="<?= htmlspecialchars($val) ?>"><?= $label ?></option>
<?php endforeach; ?>
</select></div>
<div class="col-md-4"><label class="form-label">Início <span class="text-danger">*</span></label>
<input type="time" class="form-control" id="ev-ini" required></div>
<div class="col-md-4"><label class="form-label">Fim <span class="text-danger">*</span></label>
<input type="time" class="form-control" id="ev-fim" required></div>
<div class="col-12"><label class="form-label">Sala</label>
<select class="form-select" id="ev-sala">
<?php foreach ($locais as $val => $label): ?>
<option value="<?= htmlspecialchars($val) ?>"><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select></div>
</div>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-danger" id="evDelete"><i class="bi bi-trash me-1"></i>Apagar</button>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-primary" id="evSave">Guardar</button>
</div>
</div>
</form>
</div>
</div>
<script>
(function () {
const RANGE_START = <?= $rangeStart ?>;
const RANGE_END = <?= $rangeEnd ?>;
const HOURPX = <?= $hourpx ?>;
const PXMIN = HOURPX / 60;
const SNAP = 15; // minute granularity
const CSRF = <?= json_encode(csrf_token()) ?>;
const DAYLBL = <?= json_encode($dias, JSON_UNESCAPED_UNICODE) ?>;
const SALAS = <?= json_encode($locais, JSON_UNESCAPED_UNICODE) ?>;
const MODS = <?= json_encode($mods_js, JSON_UNESCAPED_UNICODE) ?>;
const cal = document.getElementById('cal');
const cols = [...cal.querySelectorAll('.cal-col')];
const colByDay = {};
cols.forEach(c => { colByDay[c.dataset.day] = c; });
const pad = n => String(n).padStart(2, '0');
const toHHMM = m => pad(Math.floor(m / 60)) + ':' + pad(m % 60);
const toMin = t => { const p = t.split(':'); return (+p[0]) * 60 + (+p[1]); };
const salaLabel = v => v ? (SALAS[v] || v) : '';
// Sync a card's visuals from its data-* attributes.
function refreshCard(el) {
const m = MODS[el.dataset.mod] || { nome: '', cor: '#6c757d' };
el.style.setProperty('--c', m.cor);
el.title = m.nome;
el.querySelector('.cal-ev-title').textContent = m.nome;
el.querySelector('.cal-ev-time').textContent =
toHHMM(+el.dataset.start) + '–' + toHHMM(+el.dataset.end);
const sl = salaLabel(el.dataset.sala);
el.querySelector('.cal-ev-sala').textContent = sl ? ' · ' + sl : '';
}
function makeCard(d) { // {id, day, start, end, mod, sala}
const el = document.createElement('div');
el.className = 'cal-event';
el.dataset.id = d.id; el.dataset.day = d.day;
el.dataset.start = d.start; el.dataset.end = d.end;
el.dataset.mod = d.mod; el.dataset.sala = d.sala;
el.innerHTML = '<span class="cal-ev-title"></span><span class="cal-ev-time"></span><span class="cal-ev-sala"></span>';
refreshCard(el);
return el;
}
// Position every visible event in a column, splitting overlaps side by side.
function layout(col) {
const evs = [...col.querySelectorAll('.cal-event:not(.cal-hidden)')]
.map(el => ({ el, s: +el.dataset.start, e: +el.dataset.end }))
.sort((a, b) => a.s - b.s || a.e - b.e);
let i = 0;
while (i < evs.length) {
let j = i, clusterEnd = evs[i].e;
const cluster = [evs[i]];
while (j + 1 < evs.length && evs[j + 1].s < clusterEnd) {
j++; cluster.push(evs[j]); clusterEnd = Math.max(clusterEnd, evs[j].e);
}
const laneEnd = [];
cluster.forEach(ev => {
let lane = laneEnd.findIndex(end => end <= ev.s);
if (lane < 0) lane = laneEnd.length;
laneEnd[lane] = ev.e; ev.lane = lane;
});
const lanes = laneEnd.length;
cluster.forEach(ev => {
ev.el.style.top = ((ev.s - RANGE_START) * PXMIN) + 'px';
ev.el.style.height = Math.max((ev.e - ev.s) * PXMIN - 2, 16) + 'px';
ev.el.style.left = 'calc(' + (ev.lane * 100 / lanes) + '% + 2px)';
ev.el.style.width = 'calc(' + (100 / lanes) + '% - 4px)';
});
i = j + 1;
}
}
let toastTimer = null;
function toast(msg, ok) {
let t = document.getElementById('cal-toast');
if (!t) { t = document.createElement('div'); t.id = 'cal-toast'; document.body.appendChild(t); }
t.textContent = msg;
t.className = 'cal-toast ' + (ok ? 'ok' : 'err') + ' show';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 2400);
}
async function post(params) {
const res = await fetch(location.href, {
method: 'POST',
headers: { 'X-CSRF-Token': CSRF, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params),
});
const j = await res.json().catch(() => ({ ok: false }));
if (!res.ok || !j.ok) throw new Error(j.error || 'Erro ao guardar');
return j;
}
/* ── Filters ────────────────────────────────────────────────────────── */
const fltMod = document.getElementById('fltMod');
const fltSala = document.getElementById('fltSala');
const fltCount = document.getElementById('fltCount');
function applyFilter() {
const fm = fltMod.value, fs = fltSala.value;
let shown = 0;
cal.querySelectorAll('.cal-event').forEach(el => {
const show = (!fm || el.dataset.mod === fm) && (!fs || el.dataset.sala === fs);
el.classList.toggle('cal-hidden', !show);
if (show) shown++;
});
cols.forEach(layout);
const total = cal.querySelectorAll('.cal-event').length;
fltCount.textContent = (fm || fs) ? shown + ' de ' + total + ' aulas' : total + ' aulas';
}
fltMod.addEventListener('change', applyFilter);
fltSala.addEventListener('change', applyFilter);
/* ── Edit / create modal ────────────────────────────────────────────── */
const mEl = document.getElementById('evModal');
// Bootstrap's bundle is loaded by the footer, AFTER this inline script —
// resolve the Modal instance lazily, on first interaction.
let modalInst = null;
const getModal = () => modalInst || (modalInst = bootstrap.Modal.getOrCreateInstance(mEl));
const evId = document.getElementById('ev-id');
const evMod = document.getElementById('ev-mod');
const evDia = document.getElementById('ev-dia');
const evIni = document.getElementById('ev-ini');
const evFim = document.getElementById('ev-fim');
const evSala = document.getElementById('ev-sala');
const evErr = document.getElementById('evErr');
const evSave = document.getElementById('evSave');
const evDel = document.getElementById('evDelete');
const evTitle = document.getElementById('evTitle');
function showErr(msg) { evErr.textContent = msg; evErr.classList.remove('d-none'); }
function openModal(d) { // {id, mod, dia, start, end, sala}
evId.value = d.id || 0;
evMod.value = d.mod || '';
evDia.value = d.dia || 'segunda';
evIni.value = toHHMM(d.start);
evFim.value = toHHMM(d.end);
// Legacy sala values are pre-seeded into the select server-side; if one
// still isn't there (freshly typed elsewhere), add it so we don't wipe it.
if (![...evSala.options].some(o => o.value === (d.sala || ''))) {
evSala.add(new Option(d.sala, d.sala));
}
evSala.value = d.sala || '';
evTitle.textContent = d.id ? 'Editar Aula' : 'Nova Aula';
evDel.classList.toggle('d-none', !d.id);
evErr.classList.add('d-none');
getModal().show();
}
document.getElementById('evForm').addEventListener('submit', async e => {
e.preventDefault();
const id = +evId.value, mod = evMod.value, dia = evDia.value;
const hi = evIni.value, hf = evFim.value, sala = evSala.value;
if (!mod) return showErr('Escolha a modalidade.');
if (!hi || !hf || hf <= hi) return showErr('A hora de fim deve ser depois do início.');
evSave.disabled = true;
try {
const j = await post({
action: 'save', id, id_modalidade: mod, dia,
hora_inicio: hi, hora_fim: hf, local: sala,
});
let el = id ? cal.querySelector('.cal-event[data-id="' + id + '"]') : null;
if (!el) {
el = makeCard({ id: j.id, day: dia, start: toMin(hi), end: toMin(hf), mod, sala });
colByDay[dia].appendChild(el);
} else {
el.dataset.day = dia; el.dataset.start = toMin(hi); el.dataset.end = toMin(hf);
el.dataset.mod = mod; el.dataset.sala = sala;
if (el.parentElement !== colByDay[dia]) colByDay[dia].appendChild(el);
refreshCard(el);
}
applyFilter(); // also relayouts every column
getModal().hide();
toast('Aula guardada · ' + DAYLBL[dia] + ' ' + hi, true);
} catch (err) {
showErr(err.message || 'Erro ao guardar');
} finally {
evSave.disabled = false;
}
});
evDel.addEventListener('click', async () => {
const id = +evId.value;
if (!id || !confirm('Apagar esta aula?')) return;
evDel.disabled = true;
try {
await post({ action: 'delete', id });
const el = cal.querySelector('.cal-event[data-id="' + id + '"]');
if (el) { const col = el.parentElement; el.remove(); layout(col); }
applyFilter();
getModal().hide();
toast('Aula apagada', true);
} catch (err) {
showErr(err.message || 'Erro ao apagar');
} finally {
evDel.disabled = false;
}
});
document.getElementById('btnAdd').addEventListener('click', e => {
e.preventDefault();
openModal({ id: 0, mod: '', dia: 'segunda', start: 9 * 60, end: 10 * 60, sala: '' });
});
const colFromPoint = (x, y) => {
const el = document.elementFromPoint(x, y);
return el ? el.closest('.cal-col') : null;
};
/* ── Drag modalidade chip → create class at the drop slot ───────────── */
let chipDrag = null;
function chipDown(e, chip) {
e.preventDefault();
chipDrag = {
pid: e.pointerId, mod: chip.dataset.mod, chip,
startX: e.clientX, startY: e.clientY,
ghost: null, moved: false,
};
try { chip.setPointerCapture(e.pointerId); } catch (_) {}
}
function chipMove(e) {
if (!chipDrag.moved) {
if (Math.hypot(e.clientX - chipDrag.startX, e.clientY - chipDrag.startY) < 5) return;
chipDrag.moved = true;
const g = chipDrag.chip.cloneNode(true);
g.classList.add('ghost');
document.body.appendChild(g);
chipDrag.ghost = g;
}
chipDrag.ghost.style.left = (e.clientX + 10) + 'px';
chipDrag.ghost.style.top = (e.clientY + 6) + 'px';
const col = colFromPoint(e.clientX, e.clientY);
cols.forEach(c => c.classList.toggle('drop-hint', c === col));
}
function chipUp(e) {
const d = chipDrag; chipDrag = null;
try { d.chip.releasePointerCapture(e.pointerId); } catch (_) {}
if (d.ghost) d.ghost.remove();
cols.forEach(c => c.classList.remove('drop-hint'));
if (!d.moved) { // simple click → modal with defaults
openModal({ id: 0, mod: d.mod, dia: 'segunda', start: 9 * 60, end: 10 * 60, sala: '' });
return;
}
const col = colFromPoint(e.clientX, e.clientY);
if (!col) return; // dropped outside the calendar — nothing
const cr = col.getBoundingClientRect();
let mins = RANGE_START + (e.clientY - cr.top) / PXMIN;
mins = Math.round(mins / SNAP) * SNAP;
mins = Math.max(RANGE_START, Math.min(mins, RANGE_END - 60));
openModal({ id: 0, mod: d.mod, dia: col.dataset.day, start: mins, end: mins + 60, sala: '' });
}
/* ── Drag an existing class → reschedule ────────────────────────────── */
let drag = null;
function onDown(e) {
const chip = e.target.closest('.mod-chip');
if (chip && !chip.classList.contains('ghost')) { chipDown(e, chip); return; }
const el = e.target.closest('.cal-event');
if (!el || e.button !== 0 && e.pointerType === 'mouse') return;
e.preventDefault();
const r = el.getBoundingClientRect();
drag = {
el, pid: e.pointerId,
startX: e.clientX, startY: e.clientY,
grabDY: e.clientY - r.top,
dur: (+el.dataset.end) - (+el.dataset.start),
origCol: el.parentElement,
origStart: +el.dataset.start,
origDay: el.dataset.day,
cur: { mins: +el.dataset.start, day: el.dataset.day },
moved: false,
};
try { el.setPointerCapture(e.pointerId); } catch (_) {}
}
function onMove(e) {
if (chipDrag && e.pointerId === chipDrag.pid) { chipMove(e); return; }
if (!drag || e.pointerId !== drag.pid) return;
if (!drag.moved) {
if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 5) return;
drag.moved = true;
drag.el.classList.add('dragging');
drag.el.style.pointerEvents = 'none';
}
const col = colFromPoint(e.clientX, e.clientY) || drag.el.parentElement;
const cr = col.getBoundingClientRect();
let mins = RANGE_START + (e.clientY - cr.top - drag.grabDY) / PXMIN;
mins = Math.round(mins / SNAP) * SNAP;
mins = Math.max(RANGE_START, Math.min(mins, RANGE_END - drag.dur));
if (drag.el.parentElement !== col) col.appendChild(drag.el);
drag.el.style.top = ((mins - RANGE_START) * PXMIN) + 'px';
drag.el.style.left = '2px';
drag.el.style.width = 'calc(100% - 4px)';
drag.cur = { mins, day: col.dataset.day };
}
async function onUp(e) {
if (chipDrag && e.pointerId === chipDrag.pid) { chipUp(e); return; }
if (!drag || e.pointerId !== drag.pid) return;
const d = drag; drag = null;
try { d.el.releasePointerCapture(e.pointerId); } catch (_) {}
d.el.style.pointerEvents = '';
d.el.classList.remove('dragging');
if (!d.moved) { // a click, not a drag → edit modal
openModal({
id: +d.el.dataset.id, mod: d.el.dataset.mod, dia: d.el.dataset.day,
start: +d.el.dataset.start, end: +d.el.dataset.end, sala: d.el.dataset.sala,
});
return;
}
const newStart = d.cur.mins, newEnd = d.cur.mins + d.dur, day = d.cur.day;
if (day === d.origDay && newStart === d.origStart) { layout(d.el.parentElement); return; }
// Optimistic update + relayout the affected columns.
d.el.dataset.start = newStart; d.el.dataset.end = newEnd; d.el.dataset.day = day;
refreshCard(d.el);
layout(d.origCol);
if (d.el.parentElement !== d.origCol) layout(d.el.parentElement);
try {
await post({
action: 'move', id: d.el.dataset.id, dia: day,
hora_inicio: toHHMM(newStart), hora_fim: toHHMM(newEnd),
});
toast(DAYLBL[day] + ' · ' + toHHMM(newStart), true);
} catch (err) {
// Revert to the original slot.
d.el.dataset.start = d.origStart; d.el.dataset.end = d.origStart + d.dur; d.el.dataset.day = d.origDay;
refreshCard(d.el);
d.origCol.appendChild(d.el);
cols.forEach(layout);
toast(err.message || 'Não foi possível reagendar', false);
}
}
document.addEventListener('pointerdown', onDown);
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
cols.forEach(layout);
applyFilter();
cal.classList.add('ready');
})();
</script>
<?php include __DIR__ . '/../../../includes/footer.php'; ?>