Files
admin-edu-space/frontend/views/schedule/gradebook.html
T

528 lines
22 KiB
HTML

{% extends 'base.html' %}
{% block title %}Libro de Calificaciones — {{ commission.full_code or commission.code }} — Edu-Space{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb small">
<li class="breadcrumb-item"><a href="/dashboard">Inicio</a></li>
<li class="breadcrumb-item"><a href="/admin/commissions_list">Comisiones</a></li>
<li class="breadcrumb-item"><a href="/admin/commission_detail?id={{ commission.id }}">{{ commission.full_code or commission.code }}</a></li>
<li class="breadcrumb-item active">Libro de Calificaciones</li>
</ol>
</nav>
<!-- Header Banner -->
<div class="card border-0 shadow-sm mb-4 bg-body">
<div class="card-body p-4">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3">
<div>
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge bg-primary-subtle text-primary border border-primary-subtle font-monospace px-2 py-1">
{{ commission.full_code or commission.code }}
</span>
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
{{ commission.semester }} {{ commission.year }}
</span>
{% if commission.grades_closed %}
<span class="badge bg-danger text-white px-2 py-1 shadow-sm">
<i class="bi bi-lock-fill me-1"></i>ACTA CERRADA ({{ commission.acta_number }})
</span>
{% else %}
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1">
<i class="bi bi-unlock-fill me-1"></i>CALIFICACIONES ABIERTAS
</span>
{% endif %}
</div>
<h3 class="fw-bold mb-1">{{ commission.subject.name if commission.subject else 'Materia' }}</h3>
<p class="text-muted small mb-0">
<i class="bi bi-clock me-1"></i>Turno: <strong>{{ commission.shift or 'A definir' }}</strong>
{% if commission.teachers and commission.teachers|length > 0 %}
<span class="mx-2">·</span>
<i class="bi bi-person-badge me-1"></i>Cátedra:
{% for t in commission.teachers %}
<span class="badge bg-light text-dark border me-1">{{ t.name }} ({{ t.role_in_commission or 'Docente' }})</span>
{% endfor %}
{% endif %}
</p>
</div>
<div class="d-flex flex-wrap align-items-center gap-2">
<!-- Live Auto-save indicator -->
<div id="saveStatusIndicator" class="small px-3 py-1 rounded-pill bg-body-tertiary border text-muted d-flex align-items-center gap-2">
<i class="bi bi-cloud-check text-success fs-6" id="saveStatusIcon"></i>
<span id="saveStatusText">Todos los cambios guardados</span>
</div>
<!-- Print / Export -->
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="window.print()">
<i class="bi bi-printer me-1"></i>Imprimir Planilla
</button>
<!-- Acta Action Buttons -->
{% if commission.grades_closed %}
{% if role in ['admin', 'bedelia'] %}
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#reopenActaModal">
<i class="bi bi-unlock me-1"></i>Reabrir Acta
</button>
{% endif %}
{% else %}
<button type="button" class="btn btn-danger btn-sm shadow-sm" data-bs-toggle="modal" data-bs-target="#closeActaModal">
<i class="bi bi-file-earmark-lock me-1"></i>Cerrar Acta Formal
</button>
{% endif %}
</div>
</div>
</div>
</div>
<!-- KPI Summary Cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">Total Inscriptos</span>
<h3 class="fw-bold mb-0 mt-1" id="kpiTotalStudents">{{ students|length }}</h3>
</div>
<div class="bg-primary bg-opacity-10 text-primary rounded-circle p-3">
<i class="bi bi-people fs-4"></i>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">Promocionados (≥ 7)</span>
<h3 class="fw-bold mb-0 mt-1 text-success" id="kpiPromoted">0</h3>
</div>
<div class="bg-success bg-opacity-10 text-success rounded-circle p-3">
<i class="bi bi-award fs-4"></i>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">Regulares (4 a 6.9)</span>
<h3 class="fw-bold mb-0 mt-1 text-primary" id="kpiRegular">0</h3>
</div>
<div class="bg-info bg-opacity-10 text-info rounded-circle p-3">
<i class="bi bi-check2-circle fs-4"></i>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">Libres (< 4)</span>
<h3 class="fw-bold mb-0 mt-1 text-danger" id="kpiLibre">0</h3>
</div>
<div class="bg-danger bg-opacity-10 text-danger rounded-circle p-3">
<i class="bi bi-x-circle fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Gradebook Matrix Table Card -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-table me-2 text-primary"></i>Matriz de Evaluaciones y Calificaciones
</h5>
<small class="text-muted">Las notas se guardan automáticamente al escribirlas. Escala: 1 a 10 (Ausente: A o vacío).</small>
</div>
<div class="d-flex align-items-center gap-2">
<input type="text" id="filterStudentInput" class="form-control form-control-sm" placeholder="Buscar alumno o legajo..." style="max-width: 220px;">
</div>
</div>
<div class="card-body p-0">
{% if students and students|length > 0 %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" id="gradebookTable">
<thead class="table-light small">
<tr>
<th class="ps-3" style="width: 60px;">#</th>
<th style="min-width: 220px;">Estudiante</th>
<th style="min-width: 110px;">Legajo</th>
{% for col in milestone_columns %}
<th class="text-center" style="min-width: 130px;">
<div>{{ col.title }}</div>
<small class="text-muted fw-normal font-monospace">({{ col.date }})</small>
</th>
{% endfor %}
<th class="text-center" style="min-width: 110px;">Promedio</th>
<th class="text-center pe-3" style="min-width: 140px;">Condición Final</th>
</tr>
</thead>
<tbody>
{% for s in students %}
<tr data-student-id="{{ s.id }}" data-student-name="{{ s.name|lower }}" data-student-dni="{{ s.dni or '' }}">
<td class="ps-3 font-monospace text-muted small">{{ loop.index }}</td>
<td>
<div class="fw-semibold text-body">{{ s.name }}</div>
<small class="text-muted font-monospace">{{ s.email }}</small>
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary font-monospace">
{{ s.dni or ('LEG-' + s.id) }}
</span>
</td>
{% for col in milestone_columns %}
{% set grade_obj = s.grades[col.id] %}
<td class="text-center">
<div class="d-flex justify-content-center align-items-center">
<input
type="number"
step="0.5"
min="1"
max="10"
class="form-control form-control-sm text-center font-monospace fw-bold grade-input"
style="max-width: 85px; font-size: 0.95rem;"
data-milestone-id="{{ col.id }}"
data-student-id="{{ s.id }}"
value="{{ grade_obj.score if grade_obj and grade_obj.score is not none else '' }}"
placeholder="-"
{% if commission.grades_closed %}disabled{% endif %}
>
</div>
</td>
{% endfor %}
<!-- Promedio calculado en vivo -->
<td class="text-center">
<span class="font-monospace fw-bold fs-6 avg-cell">-</span>
</td>
<!-- Condición académica calculada en vivo -->
<td class="text-center pe-3">
<span class="badge condition-badge font-monospace py-2 px-2">Pendiente</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-people fs-1 text-muted d-block mb-3"></i>
<h5 class="fw-bold text-muted">No hay estudiantes inscriptos en esta comisión</h5>
<p class="text-muted small">Puede matricular alumnos desde el panel de detalle de la comisión.</p>
<a href="/admin/commission_detail?id={{ commission.id }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-person-plus me-1"></i>Ir a Matriculación
</a>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Modal: Cierre Formal de Acta -->
<div class="modal fade" id="closeActaModal" tabindex="-1" aria-labelledby="closeActaModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title h6 fw-bold" id="closeActaModalLabel">
<i class="bi bi-shield-lock me-1"></i>Cierre Formal de Acta de Regularidad
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning small mb-3">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<strong>Atención:</strong> Al cerrar el acta, todas las notas se congelan legalmente y se genera un número de acta único e inmutable. Sólo el personal de Bedelía o Administradores podrá reabrirla con justificación escrita.
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Observaciones o Notas de Cátedra (Opcional):</label>
<textarea id="closeActaNotes" class="form-control small" rows="3" placeholder="Ej: Cursada cerrada con 22 alumnos regulares y 5 promocionados."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-sm btn-danger" id="btnConfirmCloseActa">
<i class="bi bi-lock-fill me-1"></i>Confirmar y Sellar Acta
</button>
</div>
</div>
</div>
</div>
<!-- Modal: Reapertura de Acta (Admin / Bedelía) -->
<div class="modal fade" id="reopenActaModal" tabindex="-1" aria-labelledby="reopenActaModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-warning">
<h5 class="modal-title h6 fw-bold" id="reopenActaModalLabel">
<i class="bi bi-unlock-fill me-1"></i>Reapertura Excepcional de Acta
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="small text-muted mb-3">
Esta acción quedará asentada en la bitácora inmutable de auditoría institucional.
</p>
<div class="mb-3">
<label class="form-label small fw-semibold">Motivo formal de reapertura (Obligatorio):</label>
<textarea id="reopenActaReason" class="form-control small" rows="3" placeholder="Ej: Rectificación de nota de examen recuperatorio según resolución..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-sm btn-warning" id="btnConfirmReopenActa">
<i class="bi bi-unlock me-1"></i>Reabrir Acta
</button>
</div>
</div>
</div>
</div>
<div id="gradebookConfig" data-commission-id="{{ commission.id }}" data-is-closed="{{ 'true' if commission.grades_closed else 'false' }}" class="d-none"></div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const config = document.getElementById('gradebookConfig');
const commissionId = config ? config.dataset.commissionId : '';
const isClosed = config ? config.dataset.isClosed === 'true' : false;
const statusIndicator = document.getElementById('saveStatusIndicator');
const statusIcon = document.getElementById('saveStatusIcon');
const statusText = document.getElementById('saveStatusText');
function updateStatus(state, msg) {
if (state === 'saving') {
statusIcon.className = 'spinner-border spinner-border-sm text-primary';
statusText.textContent = msg || 'Guardando calificación...';
} else if (state === 'saved') {
statusIcon.className = 'bi bi-cloud-check text-success fs-6';
statusText.textContent = msg || 'Todos los cambios guardados';
} else if (state === 'error') {
statusIcon.className = 'bi bi-exclamation-circle text-danger fs-6';
statusText.textContent = msg || 'Error al guardar';
}
}
// Recalculate row averages and condition
function recalculateAllRows() {
let countPromoted = 0;
let countRegular = 0;
let countLibre = 0;
const rows = document.querySelectorAll('#gradebookTable tbody tr');
rows.forEach(row => {
const inputs = row.querySelectorAll('.grade-input');
const avgCell = row.querySelector('.avg-cell');
const badgeCell = row.querySelector('.condition-badge');
let sum = 0;
let count = 0;
let hasGrades = false;
inputs.forEach(inp => {
const val = parseFloat(inp.value);
if (!isNaN(val) && val >= 1 && val <= 10) {
sum += val;
count++;
hasGrades = true;
inp.classList.remove('is-invalid');
} else if (inp.value.trim() !== '') {
inp.classList.add('is-invalid');
} else {
inp.classList.remove('is-invalid');
}
});
if (count > 0) {
const avg = sum / count;
avgCell.textContent = avg.toFixed(1);
if (avg >= 7) {
badgeCell.className = 'badge bg-success-subtle text-success border border-success-subtle font-monospace py-2 px-2';
badgeCell.textContent = 'PROMOCIONADO';
countPromoted++;
} else if (avg >= 4) {
badgeCell.className = 'badge bg-primary-subtle text-primary border border-primary-subtle font-monospace py-2 px-2';
badgeCell.textContent = 'REGULAR';
countRegular++;
} else {
badgeCell.className = 'badge bg-danger-subtle text-danger border border-danger-subtle font-monospace py-2 px-2';
badgeCell.textContent = 'LIBRE';
countLibre++;
}
} else {
avgCell.textContent = '-';
badgeCell.className = 'badge bg-secondary-subtle text-secondary border font-monospace py-2 px-2';
badgeCell.textContent = 'PENDIENTE';
}
});
document.getElementById('kpiPromoted').textContent = countPromoted;
document.getElementById('kpiRegular').textContent = countRegular;
document.getElementById('kpiLibre').textContent = countLibre;
}
recalculateAllRows();
// Debounced auto-save on input
let saveTimeouts = {};
document.querySelectorAll('.grade-input').forEach(input => {
input.addEventListener('input', function() {
if (isClosed) return;
recalculateAllRows();
const milestoneId = this.dataset.milestoneId;
const studentId = this.dataset.studentId;
const key = `${studentId}-${milestoneId}`;
const rawVal = this.value.trim();
updateStatus('saving', 'Escribiendo...');
if (saveTimeouts[key]) clearTimeout(saveTimeouts[key]);
saveTimeouts[key] = setTimeout(async () => {
let score = null;
let isAbsent = false;
if (rawVal === '') {
score = null;
isAbsent = false;
} else {
const num = parseFloat(rawVal);
if (isNaN(num) || num < 1 || num > 10) {
updateStatus('error', 'Nota debe ser entre 1 y 10');
return;
}
score = num;
}
try {
updateStatus('saving', 'Guardando nota...');
const resp = await fetch(`/api/v1/commissions/${commissionId}/gradebook/grades`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
grades: [{
student_id: parseInt(studentId),
reservation_id: parseInt(milestoneId),
score: score,
is_absent: isAbsent
}]
})
});
const data = await resp.json();
if (resp.ok && data.success) {
updateStatus('saved', '✓ Guardado exitosamente');
} else {
updateStatus('error', data.message || 'Error al guardar');
}
} catch (err) {
console.error('Error auto-saving grade:', err);
updateStatus('error', 'Error de red al guardar');
}
}, 500);
});
});
// Client-side search filter
const filterInput = document.getElementById('filterStudentInput');
if (filterInput) {
filterInput.addEventListener('input', function() {
const term = this.value.toLowerCase().trim();
document.querySelectorAll('#gradebookTable tbody tr').forEach(row => {
const name = row.dataset.studentName || '';
const dni = row.dataset.studentDni || '';
if (name.includes(term) || dni.includes(term)) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
});
}
// Close Acta
const btnClose = document.getElementById('btnConfirmCloseActa');
if (btnClose) {
btnClose.addEventListener('click', async function() {
const notes = document.getElementById('closeActaNotes').value;
this.disabled = true;
this.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sellando...';
try {
const resp = await fetch(`/api/v1/commissions/${commissionId}/gradebook/close`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes })
});
const data = await resp.json();
if (resp.ok && data.success) {
window.location.reload();
} else {
alert('Error al cerrar acta: ' + (data.message || 'Error del servidor'));
btnClose.disabled = false;
btnClose.innerHTML = '<i class="bi bi-lock-fill me-1"></i>Confirmar y Sellar Acta';
}
} catch (err) {
alert('Error de conexión: ' + err.message);
btnClose.disabled = false;
btnClose.innerHTML = '<i class="bi bi-lock-fill me-1"></i>Confirmar y Sellar Acta';
}
});
}
// Reopen Acta
const btnReopen = document.getElementById('btnConfirmReopenActa');
if (btnReopen) {
btnReopen.addEventListener('click', async function() {
const reason = document.getElementById('reopenActaReason').value;
if (!reason || reason.trim().length < 5) {
alert('Debe especificar un motivo formal para la reapertura.');
return;
}
this.disabled = true;
this.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Reabriendo...';
try {
const resp = await fetch(`/api/v1/commissions/${commissionId}/gradebook/reopen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason })
});
const data = await resp.json();
if (resp.ok && data.success) {
window.location.reload();
} else {
alert('Error al reabrir acta: ' + (data.message || 'Error del servidor'));
btnReopen.disabled = false;
btnReopen.innerHTML = '<i class="bi bi-unlock me-1"></i>Reabrir Acta';
}
} catch (err) {
alert('Error de conexión: ' + err.message);
btnReopen.disabled = false;
btnReopen.innerHTML = '<i class="bi bi-unlock me-1"></i>Reabrir Acta';
}
});
}
});
</script>
{% endblock %}