feat: catalogo dinamico de tipificaciones de hitos, matriz RBAC y desacoplamiento en modal de eventos
This commit is contained in:
@@ -6,6 +6,7 @@ from .subject import Subject, Commission
|
||||
from .reservation import Reservation, ReservationStatus
|
||||
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
|
||||
from .role import Role, Permission, SYSTEM_MODULES
|
||||
from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
@@ -21,5 +22,8 @@ __all__ = [
|
||||
'ReservationOptimizer',
|
||||
'Role',
|
||||
'Permission',
|
||||
'SYSTEM_MODULES'
|
||||
'SYSTEM_MODULES',
|
||||
'MilestoneType',
|
||||
'AcademicMilestone',
|
||||
'BASE_MILESTONE_TYPES'
|
||||
]
|
||||
@@ -0,0 +1,176 @@
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
class MilestoneType(db.Model):
|
||||
"""
|
||||
Catálogo dinámico y administrable de tipificaciones de hitos y evaluaciones académicas.
|
||||
(Parciales, Trabajos Prácticos, Recuperatorios, Finales, etc.)
|
||||
"""
|
||||
__tablename__ = 'milestone_types'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False) # Ej: "Parcial 1", "Trabajo Práctico 1"
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # Ej: "parcial_1", "tp_1"
|
||||
color = db.Column(db.String(30), default='#0d6efd') # Hex color para badges y calendario
|
||||
description = db.Column(db.String(255), nullable=True)
|
||||
sort_order = db.Column(db.Integer, default=0) # Orden de aparición en selects
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False) # Activo / Inactivo
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relación uno-a-muchos con AcademicMilestone
|
||||
milestones = db.relationship('AcademicMilestone', backref='type_obj', lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'color': self.color or '#0d6efd',
|
||||
'description': self.description or '',
|
||||
'sort_order': self.sort_order or 0,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<MilestoneType {self.code}: {self.name}>'
|
||||
|
||||
|
||||
class AcademicMilestone(db.Model):
|
||||
"""
|
||||
Hito o evento académico programado para una materia/comisión (Examen, TP, Entrega).
|
||||
"""
|
||||
__tablename__ = 'academic_milestones'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(150), nullable=True)
|
||||
materia_codigo = db.Column(db.String(50), nullable=False, index=True) # Código de materia (ej: INGA0003)
|
||||
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=True)
|
||||
milestone_type_id = db.Column(db.Integer, db.ForeignKey('milestone_types.id'), nullable=False)
|
||||
date = db.Column(db.DateTime, nullable=False) # Fecha y hora del hito
|
||||
classroom = db.Column(db.String(100), nullable=True) # Aula o enlace virtual asignado
|
||||
link = db.Column(db.String(255), nullable=True) # Link Teams / Meet / Zoom si aplica
|
||||
notes = db.Column(db.Text, nullable=True) # Temario, consignas o notas
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relación con Asignatura
|
||||
subject = db.relationship('Subject', backref=db.backref('milestones', lazy='dynamic'))
|
||||
|
||||
@property
|
||||
def tipo(self):
|
||||
return self.type_obj.name if self.type_obj else 'Otro'
|
||||
|
||||
@property
|
||||
def color(self):
|
||||
return self.type_obj.color if self.type_obj and self.type_obj.color else '#0d6efd'
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'title': self.title or self.tipo,
|
||||
'materiaCodigo': self.materia_codigo,
|
||||
'tipo': self.tipo,
|
||||
'milestone_type_id': self.milestone_type_id,
|
||||
'tipo_info': self.type_obj.to_dict() if self.type_obj else None,
|
||||
'color': self.color,
|
||||
'fecha': self.date.isoformat() if self.date else None,
|
||||
'aula': self.classroom,
|
||||
'link': self.link,
|
||||
'notas': self.notes,
|
||||
'is_active': self.is_active
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AcademicMilestone {self.materia_codigo} - {self.tipo} ({self.date})>'
|
||||
|
||||
|
||||
# Datos semilla estándar para inicialización
|
||||
BASE_MILESTONE_TYPES = [
|
||||
{
|
||||
'name': 'Parcial 1',
|
||||
'code': 'parcial_1',
|
||||
'color': '#0d6efd',
|
||||
'description': 'Primer examen parcial de la cursada',
|
||||
'sort_order': 1
|
||||
},
|
||||
{
|
||||
'name': 'Parcial 2',
|
||||
'code': 'parcial_2',
|
||||
'color': '#0b5ed7',
|
||||
'description': 'Segundo examen parcial de la cursada',
|
||||
'sort_order': 2
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Práctico 1',
|
||||
'code': 'tp_1',
|
||||
'color': '#198754',
|
||||
'description': 'Primera entrega o evaluación práctica',
|
||||
'sort_order': 3
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Práctico 2',
|
||||
'code': 'tp_2',
|
||||
'color': '#157347',
|
||||
'description': 'Segunda entrega o evaluación práctica',
|
||||
'sort_order': 4
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Práctico 3',
|
||||
'code': 'tp_3',
|
||||
'color': '#0f5132',
|
||||
'description': 'Tercera entrega o proyecto integrador',
|
||||
'sort_order': 5
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio Parcial 1',
|
||||
'code': 'recu_p1',
|
||||
'color': '#fd7e14',
|
||||
'description': 'Instancia de recuperación para el Parcial 1',
|
||||
'sort_order': 6
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio Parcial 2',
|
||||
'code': 'recu_p2',
|
||||
'color': '#e76b08',
|
||||
'description': 'Instancia de recuperación para el Parcial 2',
|
||||
'sort_order': 7
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 1',
|
||||
'code': 'recu_tp1',
|
||||
'color': '#ffc107',
|
||||
'description': 'Reentrega o recuperación de Trabajo Práctico 1',
|
||||
'sort_order': 8
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 2',
|
||||
'code': 'recu_tp2',
|
||||
'color': '#e0a800',
|
||||
'description': 'Reentrega o recuperación de Trabajo Práctico 2',
|
||||
'sort_order': 9
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 3',
|
||||
'code': 'recu_tp3',
|
||||
'color': '#c69500',
|
||||
'description': 'Reentrega o recuperación de Trabajo Práctico 3',
|
||||
'sort_order': 10
|
||||
},
|
||||
{
|
||||
'name': 'Final',
|
||||
'code': 'final',
|
||||
'color': '#dc3545',
|
||||
'description': 'Examen final regular o libre',
|
||||
'sort_order': 11
|
||||
},
|
||||
{
|
||||
'name': 'Otro',
|
||||
'code': 'otro',
|
||||
'color': '#6c757d',
|
||||
'description': 'Taller, seminario, clase especial o hito no tipificado',
|
||||
'sort_order': 12
|
||||
}
|
||||
]
|
||||
@@ -58,6 +58,13 @@ SYSTEM_MODULES = [
|
||||
'name_en': 'Users & Access RBAC',
|
||||
'icon': 'bi-shield-lock',
|
||||
'description': 'Administración de cuentas, roles y matriz granular de permisos.'
|
||||
},
|
||||
{
|
||||
'id': 'milestone_types',
|
||||
'name': 'Tipificaciones de Hitos',
|
||||
'name_en': 'Milestone Types',
|
||||
'icon': 'bi-tags',
|
||||
'description': 'Catálogo dinámico de tipos de evaluaciones, parciales, TPs y eventos.'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
+6
-1
@@ -55,7 +55,12 @@ class User(UserMixin, db.Model):
|
||||
|
||||
def can_manage(self):
|
||||
"""Check if user can access the Management / Admin section"""
|
||||
return self.is_admin() or self.has_permission('users', 'read')
|
||||
return (
|
||||
self.is_admin() or
|
||||
self.has_permission('users', 'read') or
|
||||
self.has_permission('milestone_types', 'read') or
|
||||
self.has_permission('import', 'read')
|
||||
)
|
||||
|
||||
|
||||
def update_last_login(self):
|
||||
|
||||
@@ -4,8 +4,10 @@ from flask_babel import gettext as _
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models.role import Role, Permission, SYSTEM_MODULES, ACCESS_LEVELS
|
||||
from app.models.milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
|
||||
from app.utils.decorators import admin_required, permission_required
|
||||
from sqlalchemy import or_
|
||||
import re
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
@@ -340,3 +342,136 @@ def google_sheets_import():
|
||||
recent_subjects=recent_subjects
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# MILESTONE TYPES & ACADEMIC CATEGORIES (ABM / CRUD)
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@admin_bp.route('/milestone-types')
|
||||
@permission_required('milestone_types', 'read')
|
||||
def milestone_types_list():
|
||||
"""List all milestone categories / types"""
|
||||
# Preload default milestone types if table is empty
|
||||
if MilestoneType.query.count() == 0:
|
||||
for item in BASE_MILESTONE_TYPES:
|
||||
mt = MilestoneType(
|
||||
name=item['name'],
|
||||
code=item['code'],
|
||||
color=item['color'],
|
||||
description=item['description'],
|
||||
sort_order=item['sort_order'],
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(mt)
|
||||
db.session.commit()
|
||||
|
||||
types = MilestoneType.query.order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
|
||||
can_edit = current_user.has_permission('milestone_types', 'read_write')
|
||||
return render_template('admin/milestone_types/list.html', types=types, can_edit=can_edit)
|
||||
|
||||
|
||||
@admin_bp.route('/milestone-types/add', methods=['POST'])
|
||||
@permission_required('milestone_types', 'read_write')
|
||||
def milestone_type_add():
|
||||
"""Create a new milestone type"""
|
||||
name = request.form.get('name', '').strip()
|
||||
code = request.form.get('code', '').strip().lower()
|
||||
color = request.form.get('color', '#0d6efd').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
sort_order = request.form.get('sort_order', 0, type=int)
|
||||
is_active = True if request.form.get('is_active') in ['1', 'true', 'True', 'on'] else False
|
||||
|
||||
if not name:
|
||||
flash(_('El nombre de la tipificación es obligatorio.'), 'error')
|
||||
return redirect(url_for('admin.milestone_types_list'))
|
||||
|
||||
# Auto-generate code slug if not provided
|
||||
if not code:
|
||||
code = re.sub(r'[^a-zA-Z0-9_]+', '_', name.lower()).strip('_')
|
||||
|
||||
# Check code uniqueness
|
||||
existing = MilestoneType.query.filter_by(code=code).first()
|
||||
if existing:
|
||||
flash(_('Ya existe una tipificación con el código "%(code)s".', code=code), 'error')
|
||||
return redirect(url_for('admin.milestone_types_list'))
|
||||
|
||||
new_type = MilestoneType(
|
||||
name=name,
|
||||
code=code,
|
||||
color=color or '#0d6efd',
|
||||
description=description,
|
||||
sort_order=sort_order,
|
||||
is_active=is_active
|
||||
)
|
||||
db.session.add(new_type)
|
||||
db.session.commit()
|
||||
flash(_('Tipificación "%(name)s" creada exitosamente.', name=new_type.name), 'success')
|
||||
return redirect(url_for('admin.milestone_types_list'))
|
||||
|
||||
|
||||
@admin_bp.route('/milestone-types/edit/<int:id>', methods=['POST'])
|
||||
@permission_required('milestone_types', 'read_write')
|
||||
def milestone_type_edit(id):
|
||||
"""Edit an existing milestone type"""
|
||||
item = MilestoneType.query.get_or_404(id)
|
||||
name = request.form.get('name', '').strip()
|
||||
code = request.form.get('code', '').strip().lower()
|
||||
color = request.form.get('color', '#0d6efd').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
sort_order = request.form.get('sort_order', item.sort_order, type=int)
|
||||
is_active = True if request.form.get('is_active') in ['1', 'true', 'True', 'on'] else False
|
||||
|
||||
if not name:
|
||||
flash(_('El nombre de la tipificación es obligatorio.'), 'error')
|
||||
return redirect(url_for('admin.milestone_types_list'))
|
||||
|
||||
if not code:
|
||||
code = re.sub(r'[^a-zA-Z0-9_]+', '_', name.lower()).strip('_')
|
||||
|
||||
# Check code uniqueness against other records
|
||||
existing = MilestoneType.query.filter(MilestoneType.code == code, MilestoneType.id != id).first()
|
||||
if existing:
|
||||
flash(_('Ya existe otra tipificación con el código "%(code)s".', code=code), 'error')
|
||||
return redirect(url_for('admin.milestone_types_list'))
|
||||
|
||||
item.name = name
|
||||
item.code = code
|
||||
item.color = color or item.color
|
||||
item.description = description
|
||||
item.sort_order = sort_order
|
||||
item.is_active = is_active
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Tipificación "%(name)s" actualizada correctamente.', name=item.name), 'success')
|
||||
return redirect(url_for('admin.milestone_types_list'))
|
||||
|
||||
|
||||
@admin_bp.route('/milestone-types/toggle/<int:id>', methods=['POST'])
|
||||
@permission_required('milestone_types', 'read_write')
|
||||
def milestone_type_toggle(id):
|
||||
"""Toggle active status of a milestone type"""
|
||||
item = MilestoneType.query.get_or_404(id)
|
||||
item.is_active = not item.is_active
|
||||
db.session.commit()
|
||||
|
||||
if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'id': item.id,
|
||||
'is_active': item.is_active,
|
||||
'message': _('Tipificación "%(name)s" %(status)s.', name=item.name, status=_('activada') if item.is_active else _('desactivada'))
|
||||
})
|
||||
|
||||
status_str = _('activada') if item.is_active else _('desactivada')
|
||||
flash(_('Tipificación "%(name)s" ha sido %(status)s.', name=item.name, status=status_str), 'success')
|
||||
return redirect(url_for('admin.milestone_types_list'))
|
||||
|
||||
|
||||
@admin_bp.route('/api/milestone-types')
|
||||
@login_required
|
||||
def api_milestone_types():
|
||||
"""API endpoint to get active milestone types for forms and dropdowns"""
|
||||
types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
|
||||
return jsonify([t.to_dict() for t in types])
|
||||
|
||||
|
||||
|
||||
+22
-1
@@ -134,4 +134,25 @@ def set_language(language=None):
|
||||
current_user.preferred_language = language
|
||||
db.session.commit()
|
||||
|
||||
return redirect(request.referrer or url_for('main.dashboard'))␍
|
||||
return redirect(request.referrer or url_for('main.dashboard'))
|
||||
|
||||
|
||||
@main_bp.route('/api/milestone-types')
|
||||
def api_milestone_types():
|
||||
"""Retorna las tipificaciones de hitos activas en JSON para consumo de frontend/mobile"""
|
||||
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
|
||||
if MilestoneType.query.count() == 0:
|
||||
for item in BASE_MILESTONE_TYPES:
|
||||
mt = MilestoneType(
|
||||
name=item['name'],
|
||||
code=item['code'],
|
||||
color=item['color'],
|
||||
description=item['description'],
|
||||
sort_order=item['sort_order'],
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(mt)
|
||||
db.session.commit()
|
||||
|
||||
types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
|
||||
return jsonify([t.to_dict() for t in types])␍
|
||||
@@ -0,0 +1,322 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Tipificaciones de Hitos y Categorías{% endtrans %} - Edu-Space{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1 small">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('main.dashboard') }}">{% trans %}Dashboard{% endtrans %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{% trans %}Tipificaciones{% endtrans %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="h3 fw-bold mb-1">
|
||||
<i class="bi bi-tags text-warning me-2"></i>{% trans %}Tipificaciones de Hitos Académicos{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0">
|
||||
{% trans %}Catálogo dinámico y configurable de tipos de evaluaciones, parciales, TPs y eventos para el calendario institucional.{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddType">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Tipificación{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert / Help Info -->
|
||||
<div class="alert alert-info border-0 shadow-sm d-flex align-items-center mb-4 py-2 px-3">
|
||||
<i class="bi bi-info-circle-fill text-info fs-5 me-3"></i>
|
||||
<div class="small">
|
||||
{% trans %}Las tipificaciones activas aparecen de forma inmediata en el selector de tipo de evento/hito tanto en el portal web como en la app móvil. Puedes desactivar una tipificación sin eliminar registros históricos asociados.{% endtrans %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Card -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-body-tertiary">
|
||||
<tr>
|
||||
<th class="ps-4" style="width: 70px;">{% trans %}Color{% endtrans %}</th>
|
||||
<th>{% trans %}Nombre Visible{% endtrans %}</th>
|
||||
<th>{% trans %}Código / Slug{% endtrans %}</th>
|
||||
<th>{% trans %}Descripción{% endtrans %}</th>
|
||||
<th class="text-center">{% trans %}Orden{% endtrans %}</th>
|
||||
<th class="text-center">{% trans %}Estado{% endtrans %}</th>
|
||||
<th class="text-end pe-4">{% trans %}Acciones{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in types %}
|
||||
<tr id="row-{{ item.id }}">
|
||||
<td class="ps-4">
|
||||
<span class="d-inline-block rounded-circle shadow-sm border border-light"
|
||||
style="width: 26px; height: 26px; background-color: {{ item.color or '#0d6efd' }}; vertical-align: middle;"
|
||||
title="{{ item.color }}"></span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-bold fs-6">{{ item.name }}</div>
|
||||
<span class="badge rounded-pill text-white shadow-xs" style="background-color: {{ item.color or '#0d6efd' }}; font-size: 0.72rem;">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<code class="text-primary fw-semibold">{{ item.code }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted small">{{ item.description or '—' }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-body-tertiary text-secondary border font-monospace">#{{ item.sort_order }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if can_edit %}
|
||||
<form action="{{ url_for('admin.milestone_type_toggle', id=item.id) }}" method="POST" class="d-inline">
|
||||
<div class="form-check form-switch d-inline-block">
|
||||
<input class="form-check-input status-toggle" type="checkbox" role="switch"
|
||||
onchange="this.form.submit()"
|
||||
{% if item.is_active %}checked{% endif %}
|
||||
title="{% trans %}Cambiar estado{% endtrans %}">
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
{% if item.is_active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">{% trans %}Activo{% endtrans %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">{% trans %}Inactivo{% endtrans %}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary py-1 px-2 btn-edit"
|
||||
data-id="{{ item.id }}"
|
||||
data-name="{{ item.name }}"
|
||||
data-code="{{ item.code }}"
|
||||
data-color="{{ item.color or '#0d6efd' }}"
|
||||
data-description="{{ item.description or '' }}"
|
||||
data-sort-order="{{ item.sort_order }}"
|
||||
data-is-active="{{ 'true' if item.is_active else 'false' }}">
|
||||
<i class="bi bi-pencil-square me-1"></i>{% trans %}Editar{% endtrans %}
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-muted small"><i class="bi bi-lock me-1"></i>{% trans %}Solo lectura{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
|
||||
{% trans %}No se han encontrado tipificaciones registradas.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Nueva Tipificación -->
|
||||
{% if can_edit %}
|
||||
<div class="modal fade" id="modalAddType" tabindex="-1" aria-labelledby="modalAddTypeLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header bg-body-tertiary">
|
||||
<h5 class="modal-title fw-bold" id="modalAddTypeLabel">
|
||||
<i class="bi bi-plus-circle text-primary me-2"></i>{% trans %}Nueva Tipificación de Hito{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="{{ url_for('admin.milestone_type_add') }}" method="POST">
|
||||
<div class="modal-body p-4">
|
||||
<div class="mb-3">
|
||||
<label for="add_name" class="form-label fw-semibold">{% trans %}Nombre Visible{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="add_name" name="name" placeholder="Ej: Parcial 3, TP Integrador" required>
|
||||
<div class="form-text small">{% trans %}Texto que se mostrará en los selectores y tarjetas.{% endtrans %}</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-7">
|
||||
<label for="add_code" class="form-label fw-semibold">{% trans %}Código / Identificador{% endtrans %}</label>
|
||||
<input type="text" class="form-control font-monospace" id="add_code" name="code" placeholder="ej. parcial_3">
|
||||
<div class="form-text small">{% trans %}Slug único (se autogenera si se deja vacío).{% endtrans %}</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label for="add_color" class="form-label fw-semibold">{% trans %}Color Distintivo{% endtrans %}</label>
|
||||
<div class="input-group">
|
||||
<input type="color" class="form-control form-control-color w-100" id="add_color" name="color" value="#0d6efd" title="Elegir color">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paleta rápida de colores UniCABA -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted d-block mb-1">{% trans %}Colores sugeridos{% endtrans %}:</label>
|
||||
<div class="d-flex gap-2 flex-wrap" id="presetColorsAdd">
|
||||
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #0d6efd;" data-color="#0d6efd"></button>
|
||||
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #198754;" data-color="#198754"></button>
|
||||
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #fd7e14;" data-color="#fd7e14"></button>
|
||||
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #ffc107;" data-color="#ffc107"></button>
|
||||
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #dc3545;" data-color="#dc3545"></button>
|
||||
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #6f42c1;" data-color="#6f42c1"></button>
|
||||
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #0dcaf0;" data-color="#0dcaf0"></button>
|
||||
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #6c757d;" data-color="#6c757d"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="add_description" class="form-label fw-semibold">{% trans %}Descripción{% endtrans %}</label>
|
||||
<textarea class="form-control" id="add_description" name="description" rows="2" placeholder="Opcional: detalles sobre el tipo de evaluación..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-6">
|
||||
<label for="add_sort_order" class="form-label fw-semibold">{% trans %}Orden de Aparición{% endtrans %}</label>
|
||||
<input type="number" class="form-control" id="add_sort_order" name="sort_order" value="10" min="0">
|
||||
</div>
|
||||
<div class="col-6 d-flex align-items-center pt-3">
|
||||
<div class="form-check form-switch pt-2">
|
||||
<input class="form-check-input" type="checkbox" id="add_is_active" name="is_active" value="1" checked>
|
||||
<label class="form-check-label fw-semibold" for="add_is_active">{% trans %}Activo{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer bg-body-tertiary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Crear Tipificación{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Editar Tipificación -->
|
||||
<div class="modal fade" id="modalEditType" tabindex="-1" aria-labelledby="modalEditTypeLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header bg-body-tertiary">
|
||||
<h5 class="modal-title fw-bold" id="modalEditTypeLabel">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Tipificación{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form id="formEditType" method="POST">
|
||||
<div class="modal-body p-4">
|
||||
<div class="mb-3">
|
||||
<label for="edit_name" class="form-label fw-semibold">{% trans %}Nombre Visible{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="edit_name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-7">
|
||||
<label for="edit_code" class="form-label fw-semibold">{% trans %}Código / Slug{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control font-monospace" id="edit_code" name="code" required>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label for="edit_color" class="form-label fw-semibold">{% trans %}Color Distintivo{% endtrans %}</label>
|
||||
<input type="color" class="form-control form-control-color w-100" id="edit_color" name="color" title="Elegir color">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="edit_description" class="form-label fw-semibold">{% trans %}Descripción{% endtrans %}</label>
|
||||
<textarea class="form-control" id="edit_description" name="description" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-6">
|
||||
<label for="edit_sort_order" class="form-label fw-semibold">{% trans %}Orden{% endtrans %}</label>
|
||||
<input type="number" class="form-control" id="edit_sort_order" name="sort_order" min="0">
|
||||
</div>
|
||||
<div class="col-6 d-flex align-items-center pt-3">
|
||||
<div class="form-check form-switch pt-2">
|
||||
<input class="form-check-input" type="checkbox" id="edit_is_active" name="is_active" value="1">
|
||||
<label class="form-check-label fw-semibold" for="edit_is_active">{% trans %}Activo{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer bg-body-tertiary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Guardar Cambios{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Autogenerate code slug in Add Modal
|
||||
var addNameInput = document.getElementById('add_name');
|
||||
var addCodeInput = document.getElementById('add_code');
|
||||
if (addNameInput && addCodeInput) {
|
||||
addNameInput.addEventListener('input', function() {
|
||||
if (!addCodeInput.dataset.touched) {
|
||||
var slug = addNameInput.value.toLowerCase()
|
||||
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
addCodeInput.value = slug;
|
||||
}
|
||||
});
|
||||
addCodeInput.addEventListener('input', function() {
|
||||
addCodeInput.dataset.touched = "true";
|
||||
});
|
||||
}
|
||||
|
||||
// Color preset clicks
|
||||
document.querySelectorAll('#presetColorsAdd button').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var color = this.getAttribute('data-color');
|
||||
var input = document.getElementById('add_color');
|
||||
if (input) input.value = color;
|
||||
});
|
||||
});
|
||||
|
||||
// Edit modal bindings
|
||||
var modalEditEl = document.getElementById('modalEditType');
|
||||
var modalEdit = modalEditEl ? new bootstrap.Modal(modalEditEl) : null;
|
||||
var formEdit = document.getElementById('formEditType');
|
||||
|
||||
document.querySelectorAll('.btn-edit').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var id = this.getAttribute('data-id');
|
||||
var name = this.getAttribute('data-name');
|
||||
var code = this.getAttribute('data-code');
|
||||
var color = this.getAttribute('data-color');
|
||||
var description = this.getAttribute('data-description');
|
||||
var sortOrder = this.getAttribute('data-sort-order');
|
||||
var isActive = this.getAttribute('data-is-active') === 'true';
|
||||
|
||||
if (formEdit) {
|
||||
formEdit.action = "{{ url_for('admin.milestone_types_list') }}/edit/" + id;
|
||||
document.getElementById('edit_name').value = name;
|
||||
document.getElementById('edit_code').value = code;
|
||||
document.getElementById('edit_color').value = color;
|
||||
document.getElementById('edit_description').value = description;
|
||||
document.getElementById('edit_sort_order').value = sortOrder;
|
||||
document.getElementById('edit_is_active').checked = isActive;
|
||||
if (modalEdit) modalEdit.show();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -107,6 +107,7 @@
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Users{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles & Groups{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2"></i>{% trans %}Buildings{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-warning"></i>{% trans %}Tipificaciones{% endtrans %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Import from Google Sheets{% endtrans %}</a></li>
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user