feat: módulo de evaluaciones para admin con constructor dinámico

- Modelos completos: Evaluacion, EvaluacionTipo, Pregunta, PreguntaTipo,
  PreguntaOpcion, EvaluacionSubmission, Respuesta (con $table explícito
  para nombres en español que Laravel pluraliza incorrectamente)
- Relaciones evaluaciones() añadidas a Plantel y Nivel
- EvaluacionBuilder (Livewire): constructor interactivo con preguntas
  de tipo texto libre, opción múltiple, escala 1-5/1-10 y sí/no;
  reorden con flechas, vista previa por tipo, selección de planteles
  y niveles, configuración de estado, fechas y flag de anónima
- EvaluacionController: CRUD completo + toggleStatus
- Vistas: index, create/edit (con Livewire builder), show con detalle
  de preguntas y cambio de estado
- Rutas resource /evaluacion + PATCH /evaluacion/{id}/status
- Migraciones: evaluaciones, preguntas, pregunta_opciones,
  evaluacion_submissions, respuestas, evaluacion_plantel, evaluacion_nivel
- Menú: enlace Evaluaciones en panel de Administrador

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 18:09:48 -06:00
parent f81237b23c
commit 2af36ea272
27 changed files with 1471 additions and 21 deletions
@@ -0,0 +1,384 @@
<div>
{{-- Mensajes de error globales --}}
@if($errors->any())
<div class="alert alert-danger alert-dismissible fade show mb-3">
<strong>Por favor corrige los siguientes errores:</strong>
<ul class="mb-0 mt-1">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
<button type="button" class="close" data-dismiss="alert"><span>&times;</span></button>
</div>
@endif
<div class="row">
{{-- ===================== COLUMNA PRINCIPAL ===================== --}}
<div class="col-lg-8">
{{-- Info general --}}
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-info-circle mr-1"></i> Información general
</h6>
</div>
<div class="card-body">
<div class="form-group">
<label>Título <span class="text-danger">*</span></label>
<input type="text"
wire:model="titulo"
class="form-control @error('titulo') is-invalid @enderror"
placeholder="Ej: Evaluación docente 1er semestre 2026">
@error('titulo') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group">
<label>Descripción / Instrucciones</label>
<textarea wire:model="descripcion"
class="form-control"
rows="3"
placeholder="Instrucciones opcionales para los evaluadores…"></textarea>
</div>
</div>
</div>
{{-- ================== CONSTRUCTOR DE PREGUNTAS ================== --}}
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-list-ol mr-1"></i>
Preguntas <span class="badge badge-primary ml-1">{{ count($preguntas) }}</span>
</h6>
<button type="button" wire:click="addPregunta"
class="btn btn-sm btn-outline-primary">
<i class="fas fa-plus mr-1"></i> Agregar pregunta
</button>
</div>
<div class="card-body">
@error('preguntas')
<div class="alert alert-warning py-2">{{ $message }}</div>
@enderror
@forelse($preguntas as $i => $pregunta)
<div wire:key="pregunta-{{ $i }}"
class="card mb-3"
style="border-left: 4px solid #4e73df;">
{{-- Header de la pregunta --}}
<div class="card-header py-2 d-flex align-items-center justify-content-between bg-light">
<span class="font-weight-bold text-primary small">Pregunta {{ $i + 1 }}</span>
<div class="btn-group btn-group-sm">
<button type="button"
wire:click="movePregunta({{ $i }}, 'up')"
class="btn btn-outline-secondary"
title="Subir" {{ $i === 0 ? 'disabled' : '' }}>
<i class="fas fa-chevron-up"></i>
</button>
<button type="button"
wire:click="movePregunta({{ $i }}, 'down')"
class="btn btn-outline-secondary"
title="Bajar" {{ $i === count($preguntas) - 1 ? 'disabled' : '' }}>
<i class="fas fa-chevron-down"></i>
</button>
<button type="button"
wire:click="removePregunta({{ $i }})"
class="btn btn-outline-danger"
title="Eliminar"
onclick="return confirm('¿Eliminar esta pregunta?')">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="card-body py-3">
<div class="row">
{{-- Texto de la pregunta --}}
<div class="col-md-7">
<div class="form-group mb-2">
<label class="small font-weight-bold">Pregunta <span class="text-danger">*</span></label>
<input type="text"
wire:model="preguntas.{{ $i }}.texto"
class="form-control form-control-sm @error("preguntas.{$i}.texto") is-invalid @enderror"
placeholder="Escribe la pregunta…">
@error("preguntas.{$i}.texto")
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
{{-- Tipo de respuesta --}}
<div class="col-md-4">
<div class="form-group mb-2">
<label class="small font-weight-bold">Tipo de respuesta</label>
<select wire:model.live="preguntas.{{ $i }}.pregunta_tipo_id"
class="form-control form-control-sm @error("preguntas.{$i}.pregunta_tipo_id") is-invalid @enderror">
@foreach($preguntaTipos as $tipo)
<option value="{{ $tipo['id'] }}">{{ $tipo['nombre'] }}</option>
@endforeach
</select>
@error("preguntas.{$i}.pregunta_tipo_id")
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
{{-- Obligatoria --}}
<div class="col-md-1 d-flex align-items-end pb-3">
<div class="form-check" title="Obligatoria">
<input type="checkbox"
wire:model="preguntas.{{ $i }}.requerida"
class="form-check-input"
id="req-{{ $i }}">
<label class="form-check-label small" for="req-{{ $i }}">*</label>
</div>
</div>
</div>
{{-- Vista previa y opciones según tipo --}}
@php
$tipoSlug = collect($preguntaTipos)
->firstWhere('id', (int)($pregunta['pregunta_tipo_id'] ?? 0))['slug'] ?? '';
@endphp
{{-- Opciones múltiples --}}
@if($tipoSlug === 'opcion_multiple')
<div class="mt-2 p-3 bg-light rounded">
<label class="small font-weight-bold mb-2 d-block">
Opciones de respuesta
</label>
@foreach($pregunta['opciones'] as $j => $opcion)
<div wire:key="opcion-{{ $i }}-{{ $j }}"
class="input-group input-group-sm mb-2">
<div class="input-group-prepend">
<span class="input-group-text">{{ $j + 1 }}</span>
</div>
<input type="text"
wire:model="preguntas.{{ $i }}.opciones.{{ $j }}.texto"
class="form-control @error("preguntas.{$i}.opciones.{$j}.texto") is-invalid @enderror"
placeholder="Opción {{ $j + 1 }}">
<div class="input-group-append">
<button type="button"
wire:click="removeOpcion({{ $i }}, {{ $j }})"
class="btn btn-outline-danger">
<i class="fas fa-times"></i>
</button>
</div>
@error("preguntas.{$i}.opciones.{$j}.texto")
<div class="invalid-feedback d-block">{{ $message }}</div>
@enderror
</div>
@endforeach
<button type="button"
wire:click="addOpcion({{ $i }})"
class="btn btn-outline-primary btn-sm mt-1">
<i class="fas fa-plus mr-1"></i> Agregar opción
</button>
@if(empty($pregunta['opciones']))
<p class="text-muted small mt-2 mb-0">
<i class="fas fa-info-circle"></i>
Agrega al menos una opción de respuesta.
</p>
@endif
</div>
{{-- Vista previa: / No --}}
@elseif($tipoSlug === 'si_no')
<div class="mt-2 p-2 bg-light rounded d-flex align-items-center">
<span class="badge badge-success mr-2 px-3 py-2"></span>
<span class="badge badge-danger px-3 py-2">No</span>
<small class="text-muted ml-3">Vista previa</small>
</div>
{{-- Vista previa: Escala 1-5 --}}
@elseif($tipoSlug === 'escala_5')
<div class="mt-2 p-2 bg-light rounded">
@for($k = 1; $k <= 5; $k++)
<span class="badge badge-light border mr-1 px-3 py-2">{{ $k }}</span>
@endfor
<small class="text-muted ml-2">Escala del 1 al 5 Vista previa</small>
</div>
{{-- Vista previa: Escala 1-10 --}}
@elseif($tipoSlug === 'escala_10')
<div class="mt-2 p-2 bg-light rounded">
@for($k = 1; $k <= 10; $k++)
<span class="badge badge-light border mr-1 px-2 py-2">{{ $k }}</span>
@endfor
<small class="text-muted ml-2">Escala del 1 al 10 Vista previa</small>
</div>
{{-- Vista previa: Texto libre --}}
@elseif($tipoSlug === 'texto_libre')
<div class="mt-2 p-2 bg-light rounded">
<input type="text" class="form-control form-control-sm" disabled
placeholder="El evaluador escribirá su respuesta aquí…">
</div>
@endif
</div>
</div>
@empty
<div class="text-center py-5 text-muted">
<i class="fas fa-question-circle fa-3x mb-3 d-block text-primary" style="opacity:.4"></i>
<p class="mb-0">No hay preguntas todavía.</p>
<p class="small">Haz clic en <strong>"Agregar pregunta"</strong> para comenzar a construir la evaluación.</p>
</div>
@endforelse
@if(count($preguntas) > 0)
<div class="text-center mt-3">
<button type="button" wire:click="addPregunta"
class="btn btn-outline-primary btn-sm">
<i class="fas fa-plus mr-1"></i> Agregar otra pregunta
</button>
</div>
@endif
</div>
</div>
</div>
{{-- ===================== COLUMNA LATERAL ===================== --}}
<div class="col-lg-4">
{{-- Guardar --}}
<div class="card shadow mb-4">
<div class="card-body">
<button type="button" wire:click="guardar"
wire:loading.attr="disabled"
class="btn btn-success btn-block btn-lg">
<span wire:loading.remove>
<i class="fas fa-save mr-1"></i>
{{ $evaluacionId ? 'Guardar cambios' : 'Crear evaluación' }}
</span>
<span wire:loading>
<i class="fas fa-spinner fa-spin mr-1"></i> Guardando…
</span>
</button>
<a href="{{ route('evaluacion.index') }}"
class="btn btn-outline-secondary btn-block btn-sm mt-2">
Cancelar
</a>
</div>
</div>
{{-- Configuración --}}
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-cog mr-1"></i> Configuración
</h6>
</div>
<div class="card-body">
<div class="form-group">
<label class="small font-weight-bold">
Dirigida a <span class="text-danger">*</span>
</label>
<select wire:model="evaluacionTipoId"
class="form-control form-control-sm @error('evaluacionTipoId') is-invalid @enderror">
<option value=""> Selecciona </option>
@foreach($tipos as $tipo)
<option value="{{ $tipo['id'] }}">{{ $tipo['nombre'] }}</option>
@endforeach
</select>
@error('evaluacionTipoId')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label class="small font-weight-bold">Estado</label>
<select wire:model="status" class="form-control form-control-sm">
<option value="borrador">Borrador</option>
<option value="activa">Activa</option>
<option value="cerrada">Cerrada</option>
</select>
</div>
<div class="row">
<div class="col-6">
<div class="form-group">
<label class="small font-weight-bold">Fecha inicio</label>
<input type="date" wire:model="fechaInicio"
class="form-control form-control-sm">
</div>
</div>
<div class="col-6">
<div class="form-group">
<label class="small font-weight-bold">Fecha fin</label>
<input type="date" wire:model="fechaFin"
class="form-control form-control-sm">
</div>
</div>
</div>
<div class="form-check">
<input type="checkbox" wire:model="anonima"
class="form-check-input" id="anonima">
<label class="form-check-label small" for="anonima">
<i class="fas fa-user-secret mr-1"></i> Evaluación anónima
</label>
</div>
</div>
</div>
{{-- Planteles --}}
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-school mr-1"></i> Planteles
<small class="text-muted font-weight-normal">(opcional)</small>
</h6>
</div>
<div class="card-body py-2" style="max-height:180px; overflow-y:auto;">
@forelse($planteles as $plantel)
<div class="form-check py-1">
<input type="checkbox"
wire:model="selectedPlanteles"
value="{{ $plantel['id'] }}"
class="form-check-input"
id="plantel-{{ $plantel['id'] }}">
<label class="form-check-label small" for="plantel-{{ $plantel['id'] }}">
{{ $plantel['name'] }}
</label>
</div>
@empty
<p class="text-muted small mb-0">No hay planteles registrados.</p>
@endforelse
</div>
</div>
{{-- Niveles --}}
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-layer-group mr-1"></i> Niveles educativos
<small class="text-muted font-weight-normal">(opcional)</small>
</h6>
</div>
<div class="card-body py-2" style="max-height:180px; overflow-y:auto;">
@forelse($niveles as $nivel)
<div class="form-check py-1">
<input type="checkbox"
wire:model="selectedNiveles"
value="{{ $nivel['id'] }}"
class="form-check-input"
id="nivel-{{ $nivel['id'] }}">
<label class="form-check-label small" for="nivel-{{ $nivel['id'] }}">
{{ $nivel['name'] }}
</label>
</div>
@empty
<p class="text-muted small mb-0">No hay niveles registrados.</p>
@endforelse
</div>
</div>
</div>{{-- fin columna lateral --}}
</div>{{-- fin row --}}
</div>