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>
722 lines
34 KiB
PHP
722 lines
34 KiB
PHP
<?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'; ?>
|