diff --git a/admin/api/requisitor_lookup.php b/admin/api/requisitor_lookup.php new file mode 100644 index 0000000..c529a25 --- /dev/null +++ b/admin/api/requisitor_lookup.php @@ -0,0 +1,45 @@ + 'Acesso negado']); + exit; +} + +$query = isset($_GET['q']) ? sanitize_input($_GET['q'], 100) : ''; +$query = trim($query); + +if (mb_strlen($query) < 2) { + echo json_encode(['items' => [], 'limit' => 10, 'requiresFilter' => true]); + $db->close(); + exit; +} + +$escaped = str_replace(['%', '_'], ['\\%', '\\_'], $query); +$idPrefixParam = $escaped . '%'; +$searchParam = '%' . $escaped . '%'; +$limit = 10; + +$stmt = $db->prepare("SELECT id, nome, email FROM cache WHERE id LIKE ? ESCAPE '\\\\' OR nome LIKE ? ESCAPE '\\\\' OR email LIKE ? ESCAPE '\\\\' ORDER BY nome ASC LIMIT ?"); +$stmt->bind_param("sssi", $idPrefixParam, $searchParam, $searchParam, $limit); +$stmt->execute(); +$result = $stmt->get_result(); + +$items = []; +while ($row = $result->fetch_assoc()) { + $items[] = [ + 'id' => $row['id'], + 'title' => $row['nome'], + 'subtitle' => $row['email'] + ]; +} + +$stmt->close(); +$db->close(); + +echo json_encode(['items' => $items, 'limit' => $limit, 'requiresFilter' => false]); +?> diff --git a/admin/api/sala_lookup.php b/admin/api/sala_lookup.php new file mode 100644 index 0000000..c848ea5 --- /dev/null +++ b/admin/api/sala_lookup.php @@ -0,0 +1,44 @@ + 'Acesso negado']); + exit; +} + +$query = isset($_GET['q']) ? sanitize_input($_GET['q'], 100) : ''; +$query = trim($query); + +if (mb_strlen($query) < 2) { + echo json_encode(['items' => [], 'limit' => 10, 'requiresFilter' => true]); + $db->close(); + exit; +} + +$escaped = str_replace(['%', '_'], ['\\%', '\\_'], $query); +$idPrefixParam = $escaped . '%'; +$searchParam = '%' . $escaped . '%'; +$limit = 10; + +$stmt = $db->prepare("SELECT id, nome FROM salas WHERE id LIKE ? ESCAPE '\\\\' OR nome LIKE ? ESCAPE '\\\\' ORDER BY nome ASC LIMIT ?"); +$stmt->bind_param("ssi", $idPrefixParam, $searchParam, $limit); +$stmt->execute(); +$result = $stmt->get_result(); + +$items = []; +while ($row = $result->fetch_assoc()) { + $items[] = [ + 'id' => $row['id'], + 'title' => $row['nome'] + ]; +} + +$stmt->close(); +$db->close(); + +echo json_encode(['items' => $items, 'limit' => $limit, 'requiresFilter' => false]); +?> diff --git a/admin/api/tempo_lookup.php b/admin/api/tempo_lookup.php new file mode 100644 index 0000000..57063a5 --- /dev/null +++ b/admin/api/tempo_lookup.php @@ -0,0 +1,44 @@ + 'Acesso negado']); + exit; +} + +$query = isset($_GET['q']) ? sanitize_input($_GET['q'], 100) : ''; +$query = trim($query); + +if (mb_strlen($query) < 2) { + echo json_encode(['items' => [], 'limit' => 10, 'requiresFilter' => true]); + $db->close(); + exit; +} + +$escaped = str_replace(['%', '_'], ['\\%', '\\_'], $query); +$idPrefixParam = $escaped . '%'; +$searchParam = '%' . $escaped . '%'; +$limit = 10; + +$stmt = $db->prepare("SELECT id, horashumanos FROM tempos WHERE id LIKE ? ESCAPE '\\\\' OR horashumanos LIKE ? ESCAPE '\\\\' ORDER BY horashumanos ASC LIMIT ?"); +$stmt->bind_param("ssi", $idPrefixParam, $searchParam, $limit); +$stmt->execute(); +$result = $stmt->get_result(); + +$items = []; +while ($row = $result->fetch_assoc()) { + $items[] = [ + 'id' => $row['id'], + 'title' => $row['horashumanos'] + ]; +} + +$stmt->close(); +$db->close(); + +echo json_encode(['items' => $items, 'limit' => $limit, 'requiresFilter' => false]); +?> diff --git a/admin/materiais.php b/admin/materiais.php index 8a2bb9c..92ebca0 100644 --- a/admin/materiais.php +++ b/admin/materiais.php @@ -1,13 +1,16 @@ - +

Gestão de Materiais

Importar Materiais via CSV
- Download do modelo CSV + Download do modelo CSV

Nota: Para obter o RoomID de uma sala, consulte a gestão de salas ou use a listagem abaixo.

Deve de consultar o manual do administrador para mais informações.

+
@@ -26,9 +29,16 @@ switch (isset($_GET['action']) ? $_GET['action'] : null){ // Import CSV case "import": - if (!isset($_FILES['csvfile']) || $_FILES['csvfile']['error'] !== UPLOAD_ERR_OK) { + $maxFileSize = 2 * 1024 * 1024; // 2 MB + if (!isset($_POST['csrf_token']) || !verify_csrf_token($_POST['csrf_token'])) { + echo ""; + break; + } elseif (!isset($_FILES['csvfile']) || $_FILES['csvfile']['error'] !== UPLOAD_ERR_OK) { echo ""; break; + } elseif ($_FILES['csvfile']['size'] > $maxFileSize) { + echo ""; + break; } // Set database charset to utf8mb4 @@ -53,7 +63,7 @@ $errors = []; $lineNumber = 0; - while (($data = fgetcsv($tempFile, 0, ';')) !== FALSE) { // Changed: Added ';' as delimiter + while (($data = fgetcsv($tempFile, 0, ',')) !== FALSE) { $lineNumber++; // Skip empty lines diff --git a/admin/reservaemmassa.php b/admin/reservaemmassa.php index f7f9ff4..71efdeb 100644 --- a/admin/reservaemmassa.php +++ b/admin/reservaemmassa.php @@ -1,6 +1,8 @@

Reserva em Massa

@@ -101,6 +103,116 @@ function clearUserSelection() { document.getElementById('selectedUserDisplay').value = ''; document.getElementById('selectedUserDisplay').placeholder = 'Selecione um utilizador...'; } + + const lookupConfig = { + requisitor: { + endpoint: 'api/requisitor_lookup.php', + inputId: 'lookupRequisitorInput', + resultsId: 'lookupRequisitorResults', + emptyMessage: 'Sem resultados de requisitorID.' + }, + tempo: { + endpoint: 'api/tempo_lookup.php', + inputId: 'lookupTempoInput', + resultsId: 'lookupTempoResults', + emptyMessage: 'Sem resultados de tempoID.' + }, + sala: { + endpoint: 'api/sala_lookup.php', + inputId: 'lookupSalaInput', + resultsId: 'lookupSalaResults', + emptyMessage: 'Sem resultados de salaID.' + } + }; + + function escapeHtml(text) { + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function showLookupSkeleton(targetId) { + const target = document.getElementById(targetId); + target.innerHTML = ` +
+
+
+ `; + } + + function searchLookup(type) { + const config = lookupConfig[type]; + if (!config) return; + + const input = document.getElementById(config.inputId); + const target = document.getElementById(config.resultsId); + const query = input.value.trim(); + + if (query.length < 2) { + target.innerHTML = "
Digite pelo menos 2 caracteres para pesquisar (máx. 10 resultados).
"; + return; + } + + showLookupSkeleton(config.resultsId); + + const params = new URLSearchParams(); + params.set('q', query); + fetch(config.endpoint + '?' + params.toString()) + .then(response => { + if (!response.ok) { + throw new Error('HTTP ' + response.status); + } + return response.json(); + }) + .then(data => { + if (!Array.isArray(data.items) || data.items.length === 0) { + target.innerHTML = "
" + config.emptyMessage + "
"; + return; + } + + const itemsHtml = data.items.map(item => { + const title = item.title ? `${escapeHtml(item.title)}
` : ''; + const subtitle = item.subtitle ? `${escapeHtml(item.subtitle)}
` : ''; + const itemId = escapeHtml(item.id || ''); + return `
+ ${title} + ${subtitle} + ${itemId} +
`; + }).join(''); + + target.innerHTML = `
A mostrar até 10 resultados.
${itemsHtml}
`; + }) + .catch((err) => { + console.error(err); + target.innerHTML = "
Erro ao pesquisar. Tente novamente.
"; + }); + } + + function initLookupTabs() { + const tabButtons = document.querySelectorAll('#csvLookupTabs button[data-bs-toggle="tab"]'); + tabButtons.forEach(button => { + button.addEventListener('shown.bs.tab', function (event) { + const targetType = event.target.getAttribute('data-lookup-type'); + if (!targetType) return; + searchLookup(targetType); + }); + }); + + const lookupModal = document.getElementById('csvLookupModal'); + let lookupModalInitialized = false; + if (lookupModal) { + lookupModal.addEventListener('shown.bs.modal', function () { + if (!lookupModalInitialized) { + searchLookup('requisitor'); + lookupModalInitialized = true; + } + }); + } + } // Form validation function validateForm(event) { @@ -114,14 +226,242 @@ function validateForm(event) { } document.addEventListener('DOMContentLoaded', function() { - const form = document.querySelector('form'); + const form = document.getElementById('massReservationForm'); if (form) { form.addEventListener('submit', validateForm); } + initLookupTabs(); }); - +Erro: Token CSRF inválido.
"; + } elseif (!isset($_FILES['csvfile']) || $_FILES['csvfile']['error'] !== UPLOAD_ERR_OK) { + echo ""; + } elseif ($_FILES['csvfile']['size'] > $maxFileSize) { + echo ""; + } else { + // Detect encoding and convert to UTF-8 if needed (consistent with materiais.php CSV import) + $fileContent = file_get_contents($_FILES['csvfile']['tmp_name']); + $encoding = mb_detect_encoding($fileContent, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true); + if ($encoding && $encoding !== 'UTF-8') { + $fileContent = mb_convert_encoding($fileContent, 'UTF-8', $encoding); + } + + $tempFile = tmpfile(); + fwrite($tempFile, $fileContent); + rewind($tempFile); + unset($fileContent); + + if ($tempFile === false) { + echo ""; + $tempFile = null; + } + } + + if ($tempFile) { + + $stmtSalaExists = $db->prepare("SELECT 1 FROM salas WHERE id = ? LIMIT 1"); + $stmtRequisitorExists = $db->prepare("SELECT 1 FROM cache WHERE id = ? LIMIT 1"); + $stmtTempoExists = $db->prepare("SELECT 1 FROM tempos WHERE id = ? LIMIT 1"); + + $salaValidationCache = []; + $requisitorValidationCache = []; + $tempoValidationCache = []; + + $validateExists = function (mysqli_stmt $stmt, string $id, array &$cache): bool { + if ($id === '') { + return false; + } + + if (array_key_exists($id, $cache)) { + return $cache[$id]; + } + + $lookupId = $id; + $stmt->bind_param("s", $lookupId); + $stmt->execute(); + $stmt->store_result(); + $exists = $stmt->num_rows > 0; + $stmt->free_result(); + + $cache[$id] = $exists; + return $exists; + }; + + $stmtCheck = $db->prepare("SELECT 1 FROM reservas WHERE sala = ? AND tempo = ? AND data = ? LIMIT 1"); + $stmtInsert = $db->prepare("INSERT INTO reservas (sala, tempo, data, requisitor, aprovado, motivo, extra) VALUES (?, ?, ?, ?, 1, ?, ?)"); + + $lineNumber = 0; + $successCount = 0; + $errorCount = 0; + $duplicateCount = 0; + $errors = []; + $maxDisplayedErrors = 10; + + $recordError = function(string $message) use (&$errors, $maxDisplayedErrors): void { + if (count($errors) < $maxDisplayedErrors) { + $errors[] = $message; + } + }; + + while (($data = fgetcsv($tempFile, 0, ',')) !== false) { + $lineNumber++; + + if (!isset($data[0]) || trim($data[0]) === '') { + continue; + } + + $firstColumn = preg_replace('/^\xEF\xBB\xBF/', '', trim($data[0])); + if ($lineNumber === 1 && strtolower($firstColumn) === 'salaid') { + continue; + } + + if (count($data) < 4) { + $errorCount++; + $recordError("Linha {$lineNumber} inválida (mínimo 4 colunas)."); + continue; + } + + $salaId = $firstColumn; + $requisitorId = trim($data[1]); + $tempoId = trim($data[2]); + $dataReserva = trim($data[3]); + $motivo = isset($data[4]) ? trim($data[4]) : ''; + $extra = isset($data[5]) ? trim($data[5]) : ''; + + if (!validate_date($dataReserva)) { + $errorCount++; + $recordError("Linha {$lineNumber}: Data inválida '{$dataReserva}' (formato esperado: YYYY-MM-DD)."); + continue; + } + + if (!$validateExists($stmtSalaExists, $salaId, $salaValidationCache)) { + $errorCount++; + $recordError("Linha {$lineNumber}: Sala inválida '{$salaId}'."); + continue; + } + + if (!$validateExists($stmtRequisitorExists, $requisitorId, $requisitorValidationCache)) { + $errorCount++; + $recordError("Linha {$lineNumber}: Requisitor inválido '{$requisitorId}'."); + continue; + } + + if (!$validateExists($stmtTempoExists, $tempoId, $tempoValidationCache)) { + $errorCount++; + $recordError("Linha {$lineNumber}: Tempo inválido '{$tempoId}'."); + continue; + } + + if ($motivo === '') { + $motivo = 'Importada via CSV (reserva em massa)'; + } + + $stmtCheck->bind_param("sss", $salaId, $tempoId, $dataReserva); + $stmtCheck->execute(); + $exists = $stmtCheck->get_result()->fetch_assoc(); + if ($exists) { + $duplicateCount++; + continue; + } + + $stmtInsert->bind_param("ssssss", $salaId, $tempoId, $dataReserva, $requisitorId, $motivo, $extra); + if ($stmtInsert->execute()) { + $successCount++; + } else { + $errorCount++; + $recordError("Linha {$lineNumber}: Erro ao inserir reserva."); + } + } + + $stmtSalaExists->close(); + $stmtRequisitorExists->close(); + $stmtTempoExists->close(); + $stmtCheck->close(); + $stmtInsert->close(); + fclose($tempFile); + + if ($successCount > 0) { + echo ""; + acaoexecutada("Importação de Reservas em Massa via CSV"); + } + if ($duplicateCount > 0) { + echo ""; + } + if ($errorCount > 0) { + $displayedErrors = array_map(function($error) { + return htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); + }, $errors); + $truncatedNote = $errorCount > $maxDisplayedErrors ? "
A mostrar os primeiros {$maxDisplayedErrors} de {$errorCount} erro(s)." : ""; + echo ""; + } + } +} +?> + +
+
Importar Reservas via CSV
+ Download do modelo CSV +

Formato: SalaID,RequisitorID,TempoID,Data(YYYY-MM-DD),Motivo,Extra(opcional)

+ + + +
+ +
+ + +
+ + + +
diff --git a/assets/csvsample.csv b/assets/csvsample.csv deleted file mode 100644 index bfa4eb9..0000000 --- a/assets/csvsample.csv +++ /dev/null @@ -1,6 +0,0 @@ -MaterialName;MaterialDescription;RoomID -Projetor HD;Projetor Full HD 1080p com cabo HDMI;example-sala-uuid-123 -Computador Portátil;Dell Latitude 15" com Windows 11;example-sala-uuid-123 -Quadro Interativo;Smart Board 75" com canetas digitais;example-sala-uuid-456 -Colunas de Som;Sistema de som estéreo 100W;example-sala-uuid-123 -Webcam HD;Logitech C920 Full HD 1080p;example-sala-uuid-456 \ No newline at end of file diff --git a/assets/csvsample_materiais.csv b/assets/csvsample_materiais.csv new file mode 100644 index 0000000..935634f --- /dev/null +++ b/assets/csvsample_materiais.csv @@ -0,0 +1,6 @@ +MaterialName,MaterialDescription,RoomID +Projetor HD,Projetor Full HD 1080p com cabo HDMI,example-sala-uuid-123 +Computador Portátil,Dell Latitude 15" com Windows 11,example-sala-uuid-123 +Quadro Interativo,Smart Board 75" com canetas digitais,example-sala-uuid-456 +Colunas de Som,Sistema de som estéreo 100W,example-sala-uuid-123 +Webcam HD,Logitech C920 Full HD 1080p,example-sala-uuid-456 \ No newline at end of file diff --git a/assets/csvsample_reservas.csv b/assets/csvsample_reservas.csv new file mode 100644 index 0000000..01e1779 --- /dev/null +++ b/assets/csvsample_reservas.csv @@ -0,0 +1,2 @@ +SalaID,RequisitorID,TempoID,Data,Motivo,Extra +00000000-0000-0000-0000-000000000001,00000000-0000-0000-0000-000000000002,00000000-0000-0000-0000-000000000003,2026-09-15,Aula de reposição,Turma 10.ºA