seconds_changes

main
elmer-20 2 months ago
parent 5b16207e51
commit e16b365df7

@ -4,41 +4,41 @@ namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use App\Models\Area;
use App\Models\Curso;
use App\Models\proceso;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class AreaController extends Controller
{
/**
* Listar áreas (con búsqueda, filtro y paginación)
*/
public function index(Request $request)
{
$query = Area::query();
public function index(Request $request)
{
$query = Area::withCount(['cursos', 'procesos']);
// 🔍 Buscar por nombre o código
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('nombre', 'like', "%{$search}%")
->where('codigo', 'like', "%{$search}%");
});
}
// 🔍 Buscar por nombre o código
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('nombre', 'like', "%{$search}%")
->orWhere('codigo', 'like', "%{$search}%");
});
}
// 🔄 Filtrar por estado
if ($request->filled('activo')) {
$query->where('activo', $request->activo);
}
// 🔄 Filtrar por estado
if (!is_null($request->activo)) {
$query->where('activo', $request->activo);
}
$areas = $query
->orderBy('created_at', 'desc')
->paginate($request->get('per_page', 10));
$areas = $query
->orderBy('created_at', 'desc')
->paginate($request->get('per_page', 10));
return response()->json([
'success' => true,
'data' => $areas
]);
}
return response()->json([
'success' => true,
'data' => $areas
]);
}
/**
* Crear área
@ -189,4 +189,306 @@ class AreaController extends Controller
'message' => 'Área eliminada correctamente'
]);
}
public function vincularCursosArea(Request $request, $areaId)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json(['success' => false, 'message' => 'No autorizado'], 403);
}
$area = Area::find($areaId);
if (!$area) {
return response()->json(['success' => false, 'message' => 'Área no encontrada'], 404);
}
$validator = Validator::make($request->all(), [
'cursos' => 'required|array',
'cursos.*' => 'required|integer|exists:cursos,id'
]);
if ($validator->fails()) {
return response()->json(['success' => false, 'errors' => $validator->errors()], 422);
}
// Sincronizar cursos
$area->cursos()->sync($request->cursos);
// Recargar cursos
$area->load('cursos:id,nombre,codigo');
return response()->json([
'success' => true,
'message' => 'Cursos vinculados a la área exitosamente',
'data' => $area
]);
} catch (\Exception $e) {
Log::error('Error vinculando cursos a área', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'area_id' => $areaId,
'request_data' => $request->all()
]);
return response()->json(['success' => false, 'message' => 'Error al vincular cursos: ' . $e->getMessage()], 500);
}
}
public function getCursosPorArea(Request $request, $areaId)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json(['success' => false, 'message' => 'No autorizado'], 403);
}
$area = Area::find($areaId);
if (!$area) {
return response()->json(['success' => false, 'message' => 'Área no encontrada'], 404);
}
$todosLosCursos = Curso::select('id', 'nombre', 'codigo')
->orderBy('nombre')
->get();
$cursosVinculadosIds = $area->cursos->pluck('id')->toArray();
return response()->json([
'success' => true,
'data' => [
'todos_los_cursos' => $todosLosCursos,
'cursos_vinculados' => $cursosVinculadosIds
]
]);
} catch (\Exception $e) {
Log::error('Error obteniendo cursos por área', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'area_id' => $areaId
]);
return response()->json(['success' => false, 'message' => 'Error al cargar cursos: ' . $e->getMessage()], 500);
}
}
public function desvincularCursoArea(Request $request, $areaId)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json(['success' => false, 'message' => 'No autorizado'], 403);
}
$area = Area::find($areaId);
if (!$area) {
return response()->json(['success' => false, 'message' => 'Área no encontrada'], 404);
}
$validator = Validator::make($request->all(), [
'curso_id' => 'required|exists:cursos,id'
]);
if ($validator->fails()) {
return response()->json(['success' => false, 'errors' => $validator->errors()], 422);
}
$area->cursos()->detach($request->curso_id);
return response()->json([
'success' => true,
'message' => 'Curso desvinculado de la área exitosamente'
]);
} catch (\Exception $e) {
Log::error('Error desvinculando curso de área', [
'error' => $e->getMessage()
]);
return response()->json(['success' => false, 'message' => 'Error al desvincular curso de la área'], 500);
}
}
public function vincularProcesosArea(Request $request, $areaId)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$area = Area::find($areaId);
if (!$area) {
return response()->json([
'success' => false,
'message' => 'Área no encontrada'
], 404);
}
$validator = Validator::make($request->all(), [
'procesos' => 'required|array',
'procesos.*' => 'required|integer|exists:procesos,id',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors()
], 422);
}
// 🔄 Sincronizar procesos
$area->procesos()->sync($request->procesos);
// 🔁 Recargar procesos vinculados
$area->load('procesos:id,nombre,tipo_proceso');
return response()->json([
'success' => true,
'message' => 'Procesos vinculados a la área exitosamente',
'data' => $area
]);
} catch (\Exception $e) {
Log::error('Error vinculando procesos a área', [
'error' => $e->getMessage(),
'area_id' => $areaId,
'request' => $request->all(),
]);
return response()->json([
'success' => false,
'message' => 'Error al vincular procesos'
], 500);
}
}
public function getProcesosPorArea(Request $request, $areaId)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$area = Area::find($areaId);
if (!$area) {
return response()->json([
'success' => false,
'message' => 'Área no encontrada'
], 404);
}
$todosLosProcesos = Proceso::select(
'id',
'nombre',
'tipo_proceso',
'activo'
)
->orderBy('nombre')
->get();
$procesosVinculadosIds = $area
->procesos
->pluck('id')
->toArray();
return response()->json([
'success' => true,
'data' => [
'todos_los_procesos' => $todosLosProcesos,
'procesos_vinculados' => $procesosVinculadosIds
]
]);
} catch (\Exception $e) {
Log::error('Error obteniendo procesos por área', [
'error' => $e->getMessage(),
'area_id'=> $areaId
]);
return response()->json([
'success' => false,
'message' => 'Error al cargar procesos'
], 500);
}
}
public function desvincularProcesoArea(Request $request, $areaId)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$area = Area::find($areaId);
if (!$area) {
return response()->json([
'success' => false,
'message' => 'Área no encontrada'
], 404);
}
$validator = Validator::make($request->all(), [
'proceso_id' => 'required|exists:procesos,id'
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors()
], 422);
}
$area->procesos()->detach($request->proceso_id);
return response()->json([
'success' => true,
'message' => 'Proceso desvinculado de la área exitosamente'
]);
} catch (\Exception $e) {
Log::error('Error desvinculando proceso de área', [
'error' => $e->getMessage()
]);
return response()->json([
'success' => false,
'message' => 'Error al desvincular proceso'
], 500);
}
}
}

@ -59,183 +59,204 @@ class PreguntaController extends Controller
public function getPregunta($id)
{
$pregunta = Pregunta::find($id);
if (!$pregunta) {
return response()->json([
'success' => false,
'message' => 'Pregunta no encontrada'
], 404);
}
$pregunta->imagenes = collect($pregunta->imagenes ?? [])->map(fn($path) => $path ? url(Storage::url($path)) : null);
$pregunta->imagenes_explicacion = collect($pregunta->imagenes_explicacion ?? [])->map(fn($path) => $path ? url(Storage::url($path)) : null);
return response()->json([
'success' => true,
'data' => $pregunta
]);
}
public function agregarPreguntaCurso(Request $request)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json(['success' => false, 'message' => 'No autorizado'], 403);
}
// Validación (igual que antes)
$validator = Validator::make($request->all(), [
'curso_id' => 'required|exists:cursos,id',
'enunciado' => 'required|string',
'enunciado_adicional' => 'nullable|string',
'opciones' => 'required',
'respuesta_correcta' => 'required|string',
'explicacion' => 'nullable|string',
'nivel_dificultad' => 'required|in:facil,medio,dificil',
'activo' => 'boolean',
'imagenes' => 'nullable|array',
'imagenes.*' => 'image|mimes:jpg,jpeg,png,gif,webp|max:2048',
'imagenes_explicacion' => 'nullable|array',
'imagenes_explicacion.*' => 'image|mimes:jpg,jpeg,png,gif,webp|max:2048',
]);
if ($validator->fails()) {
return response()->json(['success' => false, 'errors' => $validator->errors()], 422);
}
// Opciones
$opciones = is_string($request->opciones) ? json_decode($request->opciones, true) : $request->opciones;
$opcionesValidas = array_map('trim', $opciones);
// Validar respuesta correcta
if (!in_array($request->respuesta_correcta, $opcionesValidas)) {
return response()->json(['success' => false, 'errors' => ['respuesta_correcta' => ['La respuesta correcta debe estar entre las opciones']]] ,422);
}
// Procesar imágenes del enunciado y devolver URLs completas
$imagenesUrls = [];
if ($request->hasFile('imagenes')) {
foreach ($request->file('imagenes') as $imagen) {
$path = $imagen->store('preguntas/enunciados', 'public');
$imagenesUrls[] = url(Storage::url($path)); // URL completa
public function getPregunta($id)
{
$pregunta = Pregunta::find($id);
if (!$pregunta) {
return response()->json([
'success' => false,
'message' => 'Pregunta no encontrada'
], 404);
}
}
// Procesar imágenes de la explicación
$imagenesExplicacionUrls = [];
if ($request->hasFile('imagenes_explicacion')) {
foreach ($request->file('imagenes_explicacion') as $imagen) {
$path = $imagen->store('preguntas/explicaciones', 'public');
$imagenesExplicacionUrls[] = url(Storage::url($path)); // URL completa
}
}
// Crear pregunta
$pregunta = Pregunta::create([
'curso_id' => $request->curso_id,
'enunciado' => $request->enunciado,
'enunciado_adicional' => $request->enunciado_adicional,
'opciones' => $opcionesValidas,
'respuesta_correcta' => $request->respuesta_correcta,
'explicacion' => $request->explicacion,
'nivel_dificultad' => $request->nivel_dificultad,
'activo' => $request->boolean('activo'),
'imagenes' => $imagenesUrls,
'imagenes_explicacion' => $imagenesExplicacionUrls,
]);
return response()->json(['success' => true, 'message' => 'Pregunta creada correctamente', 'data' => $pregunta], 201);
$pregunta->imagenes = collect($pregunta->imagenes ?? [])->map(fn($path) => $path ? url(Storage::url($path)) : null);
$pregunta->imagenes_explicacion = collect($pregunta->imagenes_explicacion ?? [])->map(fn($path) => $path ? url(Storage::url($path)) : null);
} catch (\Exception $e) {
Log::error('Error creando pregunta', ['error' => $e->getMessage()]);
return response()->json(['success' => false, 'message' => 'Error al crear la pregunta'], 500);
}
}
public function actualizarPregunta(Request $request, $id)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json(['success' => false, 'message' => 'No autorizado'], 403);
}
$pregunta = Pregunta::find($id);
if (!$pregunta) {
return response()->json(['success' => false, 'message' => 'Pregunta no encontrada'], 404);
}
// Validación (igual que antes)
$validator = Validator::make($request->all(), [
'curso_id' => 'required|exists:cursos,id',
'enunciado' => 'required|string',
'enunciado_adicional' => 'nullable|string',
'opciones' => 'required',
'respuesta_correcta' => 'required|string',
'explicacion' => 'nullable|string',
'nivel_dificultad' => 'required|in:facil,medio,dificil',
'activo' => 'boolean',
'imagenes' => 'nullable|array',
'imagenes.*' => 'image|mimes:jpg,jpeg,png,gif,webp|max:2048',
'imagenes_explicacion' => 'nullable|array',
'imagenes_explicacion.*' => 'image|mimes:jpg,jpeg,png,gif,webp|max:2048',
]);
if ($validator->fails()) {
return response()->json(['success' => false, 'errors' => $validator->errors()], 422);
}
// Opciones
$opciones = is_string($request->opciones) ? json_decode($request->opciones, true) : $request->opciones;
$opcionesValidas = array_map('trim', $opciones);
if (!in_array($request->respuesta_correcta, $opcionesValidas)) {
return response()->json(['success' => false, 'errors' => ['respuesta_correcta' => ['La respuesta correcta debe estar entre las opciones']]], 422);
return response()->json([
'success' => true,
'data' => $pregunta
]);
}
// Imágenes del enunciado
$imagenesActuales = $request->imagenes_existentes ?? $pregunta->imagenes ?? [];
if ($request->hasFile('imagenes')) {
foreach ($request->file('imagenes') as $imagen) {
$path = $imagen->store('preguntas/enunciados', 'public');
$imagenesActuales[] = url(Storage::url($path));
public function agregarPreguntaCurso(Request $request)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json(['success' => false, 'message' => 'No autorizado'], 403);
}
// Validación (igual que antes)
$validator = Validator::make($request->all(), [
'curso_id' => 'required|exists:cursos,id',
'enunciado' => 'required|string',
'enunciado_adicional' => 'nullable|string',
'opciones' => 'required',
'respuesta_correcta' => 'required|string',
'explicacion' => 'nullable|string',
'nivel_dificultad' => 'required|in:facil,medio,dificil',
'activo' => 'boolean',
'imagenes' => 'nullable|array',
'imagenes.*' => 'image|mimes:jpg,jpeg,png,gif,webp|max:2048',
'imagenes_explicacion' => 'nullable|array',
'imagenes_explicacion.*' => 'image|mimes:jpg,jpeg,png,gif,webp|max:2048',
]);
if ($validator->fails()) {
return response()->json(['success' => false, 'errors' => $validator->errors()], 422);
}
// Opciones
$opciones = is_string($request->opciones) ? json_decode($request->opciones, true) : $request->opciones;
$opcionesValidas = array_map('trim', $opciones);
// Validar respuesta correcta
if (!in_array($request->respuesta_correcta, $opcionesValidas)) {
return response()->json(['success' => false, 'errors' => ['respuesta_correcta' => ['La respuesta correcta debe estar entre las opciones']]] ,422);
}
// Procesar imágenes del enunciado y devolver URLs completas
$imagenesUrls = [];
if ($request->hasFile('imagenes')) {
foreach ($request->file('imagenes') as $imagen) {
$path = $imagen->store('preguntas/enunciados', 'public');
$imagenesUrls[] = url(Storage::url($path)); // URL completa
}
}
// Procesar imágenes de la explicación
$imagenesExplicacionUrls = [];
if ($request->hasFile('imagenes_explicacion')) {
foreach ($request->file('imagenes_explicacion') as $imagen) {
$path = $imagen->store('preguntas/explicaciones', 'public');
$imagenesExplicacionUrls[] = url(Storage::url($path)); // URL completa
}
}
// Crear pregunta
$pregunta = Pregunta::create([
'curso_id' => $request->curso_id,
'enunciado' => $request->enunciado,
'enunciado_adicional' => $request->enunciado_adicional,
'opciones' => $opcionesValidas,
'respuesta_correcta' => $request->respuesta_correcta,
'explicacion' => $request->explicacion,
'nivel_dificultad' => $request->nivel_dificultad,
'activo' => $request->boolean('activo'),
'imagenes' => $imagenesUrls,
'imagenes_explicacion' => $imagenesExplicacionUrls,
]);
return response()->json(['success' => true, 'message' => 'Pregunta creada correctamente', 'data' => $pregunta], 201);
} catch (\Exception $e) {
Log::error('Error creando pregunta', ['error' => $e->getMessage()]);
return response()->json(['success' => false, 'message' => 'Error al crear la pregunta'], 500);
}
}
// Imágenes de la explicación
$imagenesExplicacionActuales = $request->imagenes_explicacion_existentes ?? $pregunta->imagenes_explicacion ?? [];
if ($request->hasFile('imagenes_explicacion')) {
foreach ($request->file('imagenes_explicacion') as $imagen) {
$path = $imagen->store('preguntas/explicaciones', 'public');
$imagenesExplicacionActuales[] = url(Storage::url($path));
public function actualizarPregunta(Request $request, $id)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json(['success' => false, 'message' => 'No autorizado'], 403);
}
$pregunta = Pregunta::find($id);
if (!$pregunta) {
return response()->json(['success' => false, 'message' => 'Pregunta no encontrada'], 404);
}
// Validación
$validator = Validator::make($request->all(), [
'curso_id' => 'required|exists:cursos,id',
'enunciado' => 'required|string',
'enunciado_adicional' => 'nullable|string',
'opciones' => 'required',
'respuesta_correcta' => 'required|string',
'explicacion' => 'nullable|string',
'nivel_dificultad' => 'required|in:facil,medio,dificil',
'activo' => 'boolean',
'imagenes' => 'nullable|array',
'imagenes.*' => 'image|mimes:jpg,jpeg,png,gif,webp|max:2048',
'imagenes_explicacion' => 'nullable|array',
'imagenes_explicacion.*' => 'image|mimes:jpg,jpeg,png,gif,webp|max:2048',
]);
if ($validator->fails()) {
return response()->json(['success' => false, 'errors' => $validator->errors()], 422);
}
// Decodificar opciones
$opciones = is_string($request->opciones) ? json_decode($request->opciones, true) : $request->opciones;
$opcionesValidas = array_map('trim', $opciones);
if (!in_array($request->respuesta_correcta, $opcionesValidas)) {
return response()->json([
'success' => false,
'errors' => ['respuesta_correcta' => ['La respuesta correcta debe estar entre las opciones']]
], 422);
}
// --- Imágenes del enunciado ---
$imagenesActuales = $request->input('imagenes_existentes', $pregunta->imagenes ?? []);
if (is_string($imagenesActuales)) {
$imagenesActuales = json_decode($imagenesActuales, true) ?? [];
}
if ($request->hasFile('imagenes')) {
foreach ($request->file('imagenes') as $imagen) {
$path = $imagen->store('preguntas/enunciados', 'public');
$imagenesActuales[] = url(Storage::url($path));
}
}
// --- Imágenes de la explicación ---
$imagenesExplicacionActuales = $request->input('imagenes_explicacion_existentes', $pregunta->imagenes_explicacion ?? []);
if (is_string($imagenesExplicacionActuales)) {
$imagenesExplicacionActuales = json_decode($imagenesExplicacionActuales, true) ?? [];
}
if ($request->hasFile('imagenes_explicacion')) {
foreach ($request->file('imagenes_explicacion') as $imagen) {
$path = $imagen->store('preguntas/explicaciones', 'public');
$imagenesExplicacionActuales[] = url(Storage::url($path));
}
}
// Actualizar pregunta
$pregunta->update([
'curso_id' => $request->curso_id,
'enunciado' => $request->enunciado,
'enunciado_adicional' => $request->enunciado_adicional,
'opciones' => $opcionesValidas,
'respuesta_correcta' => $request->respuesta_correcta,
'explicacion' => $request->explicacion,
'nivel_dificultad' => $request->nivel_dificultad,
'activo' => $request->boolean('activo'),
'imagenes' => $imagenesActuales,
'imagenes_explicacion' => $imagenesExplicacionActuales,
]);
return response()->json([
'success' => true,
'message' => 'Pregunta actualizada correctamente',
'data' => $pregunta
]);
} catch (\Exception $e) {
Log::error('Error actualizando pregunta', ['error' => $e->getMessage()]);
return response()->json([
'success' => false,
'message' => 'Error al actualizar la pregunta'
], 500);
}
}
$pregunta->update([
'curso_id' => $request->curso_id,
'enunciado' => $request->enunciado,
'enunciado_adicional' => $request->enunciado_adicional,
'opciones' => $opcionesValidas,
'respuesta_correcta' => $request->respuesta_correcta,
'explicacion' => $request->explicacion,
'nivel_dificultad' => $request->nivel_dificultad,
'activo' => $request->boolean('activo'),
'imagenes' => $imagenesActuales,
'imagenes_explicacion' => $imagenesExplicacionActuales,
]);
return response()->json(['success' => true, 'message' => 'Pregunta actualizada correctamente', 'data' => $pregunta]);
} catch (\Exception $e) {
Log::error('Error actualizando pregunta', ['error' => $e->getMessage()]);
return response()->json(['success' => false, 'message' => 'Error al actualizar la pregunta'], 500);
}
}
public function eliminarPregunta($id)
{

@ -25,10 +25,6 @@ class Area extends Model
/* ================= RELACIONES ================= */
public function cursos()
{
return $this->belongsToMany(Curso::class, 'area_curso');
}
public function examenes()
{
@ -91,4 +87,15 @@ class Area extends Model
{
return $this->examenes()->count();
}
public function cursos()
{
return $this->belongsToMany(Curso::class, 'area_curso')->withTimestamps();
}
public function procesos()
{
return $this->belongsToMany(Proceso::class, 'area_proceso')->withTimestamps();
}
}

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Calificacion extends Model
{
use HasFactory;
protected $table = 'calificaciones';
protected $fillable = [
'nombre',
'puntos_correcta',
'puntos_incorrecta',
'puntos_nula',
'puntaje_maximo',
];
public function procesos()
{
return $this->hasMany(Proceso::class);
}
}

@ -24,10 +24,11 @@ class Curso extends Model
];
public function areas()
{
return $this->belongsToMany(Area::class, 'area_curso');
return $this->belongsToMany(Area::class, 'area_curso')
->withTimestamps();
}

@ -57,4 +57,6 @@ class Pregunta extends Model
->orWhere('explicacion', 'like', "%{$texto}%");
});
}
}

@ -112,4 +112,12 @@ class Proceso extends Model
}
});
}
public function areas()
{
return $this->belongsToMany(
Area::class,
'area_proceso'
)->withTimestamps();
}
}

@ -46,6 +46,16 @@ Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {
Route::delete('/areas/{id}', [AreaController::class, 'destroy']);
Route::patch('/areas/{id}/toggle', [AreaController::class, 'toggleEstado']);
Route::post('/areas/{area}/vincular-cursos', [AreaController::class, 'vincularCursosArea']);
Route::post('/areas/{area}/desvincular-curso', [AreaController::class, 'desvincularCursoArea']);
Route::get('/areas/{area}/cursos-disponibles', [AreaController::class, 'getCursosPorArea']);
Route::post('areas/{area}/vincular-procesos', [AreaController::class, 'vincularProcesosArea']);
Route::get('areas/{area}/procesos-disponibles', [AreaController::class, 'getProcesosPorArea'] );
Route::post('areas/{area}/desvincular-procesos', [AreaController::class, 'desvincularProcesoArea'] );
});
Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {

@ -8,11 +8,19 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@vueup/vue-quill": "^1.2.0",
"ant-design-vue": "^4.2.6",
"axios": "^1.13.3",
"chart.js": "^4.5.1",
"dayjs": "^1.11.19",
"katex": "^0.16.28",
"markdown-it": "^14.1.0",
"markdown-it-katex": "^2.0.3",
"marked": "^17.0.1",
"pinia": "^3.0.4",
"quill": "^1.3.4",
"quill-blot-formatter": "^1.0.5",
"quill-delta": "^5.1.0",
"vue": "^3.5.24",
"vue-chartjs": "^5.3.3",
"vue-qrcode": "^2.2.2",
@ -1105,12 +1113,81 @@
"integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==",
"license": "MIT"
},
"node_modules/@vueup/vue-quill": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@vueup/vue-quill/-/vue-quill-1.2.0.tgz",
"integrity": "sha512-kd5QPSHMDpycklojPXno2Kw2JSiKMYduKYQckTm1RJoVDA557MnyUXgcuuDpry4HY/Rny9nGNcK+m3AHk94wag==",
"license": "MIT",
"dependencies": {
"quill": "^1.3.7",
"quill-delta": "^4.2.2"
},
"peerDependencies": {
"vue": "^3.2.41"
}
},
"node_modules/@vueup/vue-quill/node_modules/fast-diff": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
"license": "Apache-2.0"
},
"node_modules/@vueup/vue-quill/node_modules/parchment": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"license": "BSD-3-Clause"
},
"node_modules/@vueup/vue-quill/node_modules/quill": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
"license": "BSD-3-Clause",
"dependencies": {
"clone": "^2.1.1",
"deep-equal": "^1.0.1",
"eventemitter3": "^2.0.3",
"extend": "^3.0.2",
"parchment": "^1.1.4",
"quill-delta": "^3.6.2"
}
},
"node_modules/@vueup/vue-quill/node_modules/quill-delta": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-4.2.2.tgz",
"integrity": "sha512-qjbn82b/yJzOjstBgkhtBjN2TNK+ZHP/BgUQO+j6bRhWQQdmj2lH6hXG7+nwwLF41Xgn//7/83lxs9n2BkTtTg==",
"license": "MIT",
"dependencies": {
"fast-diff": "1.2.0",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0"
}
},
"node_modules/@vueup/vue-quill/node_modules/quill-delta/node_modules/fast-diff": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz",
"integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==",
"license": "Apache-2.0"
},
"node_modules/@vueup/vue-quill/node_modules/quill/node_modules/quill-delta": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
"license": "MIT",
"dependencies": {
"deep-equal": "^1.0.1",
"extend": "^3.0.2",
"fast-diff": "1.1.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@ -1120,7 +1197,6 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"peer": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@ -1170,6 +1246,12 @@
"vue": ">=3.2.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/array-tree-filter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
@ -1208,6 +1290,24 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.0",
"es-define-property": "^1.0.0",
"get-intrinsic": "^1.2.4",
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@ -1221,12 +1321,27 @@
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@ -1236,6 +1351,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@ -1248,19 +1364,26 @@
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"peer": true,
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"color-name": "~1.1.4"
},
@ -1272,8 +1395,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
@ -1287,6 +1409,15 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/compute-scroll-into-view": {
"version": "1.0.20",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
@ -1336,11 +1467,73 @@
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-equal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
"integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
"license": "MIT",
"dependencies": {
"is-arguments": "^1.1.1",
"is-date-object": "^1.0.5",
"is-regex": "^1.1.4",
"object-is": "^1.1.5",
"object-keys": "^1.1.1",
"regexp.prototype.flags": "^1.5.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/deepmerge": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -1354,8 +1547,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/dom-align": {
"version": "1.12.4",
@ -1387,8 +1579,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/entities": {
"version": "7.0.1",
@ -1495,6 +1686,24 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/eventemitter3": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"license": "MIT"
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"license": "Apache-2.0"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -1518,7 +1727,6 @@
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"peer": true,
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
@ -1587,12 +1795,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/functions-have-names": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"peer": true,
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
@ -1646,6 +1862,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@ -1691,12 +1919,43 @@
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-date-object": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@ -1710,6 +1969,24 @@
"node": ">=0.10.0"
}
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-what": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
@ -1728,12 +2005,36 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/katex": {
"version": "0.16.28",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz",
"integrity": "sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"peer": true,
"dependencies": {
"p-locate": "^4.1.0"
},
@ -1753,6 +2054,19 @@
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"license": "MIT"
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -1774,6 +2088,73 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it-katex": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/markdown-it-katex/-/markdown-it-katex-2.0.3.tgz",
"integrity": "sha512-nUkkMtRWeg7OpdflamflE/Ho/pWl64Lk9wNBKOmaj33XkQdumhXAIYhI0WO03GeiycPCsxbmX536V5NEXpC3Ng==",
"license": "MIT",
"dependencies": {
"katex": "^0.6.0"
}
},
"node_modules/markdown-it-katex/node_modules/katex": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.6.0.tgz",
"integrity": "sha512-rS4mY3SvHYg5LtQV6RBcK0if7ur6plyEukAOV+jGGPqFImuzu8fHL6M752iBmRGoUyF0bhZbAPoezehn7xYksA==",
"license": "MIT",
"dependencies": {
"match-at": "^0.1.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/markdown-it/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/marked": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/match-at": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/match-at/-/match-at-0.1.1.tgz",
"integrity": "sha512-h4Yd392z9mST+dzc+yjuybOGFNOZjmXIPKWjxBd1Bb23r4SmDOsk2NYCU2BMUBGbSpZqwVsZYNq26QS3xfaT3Q=="
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -1783,6 +2164,12 @@
"node": ">= 0.4"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@ -1834,12 +2221,36 @@
"integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==",
"license": "MIT"
},
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"peer": true,
"dependencies": {
"p-try": "^2.0.0"
},
@ -1855,7 +2266,6 @@
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"peer": true,
"dependencies": {
"p-limit": "^2.2.0"
},
@ -1868,17 +2278,25 @@
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
},
"node_modules/parchment": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.1.tgz",
"integrity": "sha512-+9UT5NZVBCsdRqi3vJ8n73iPEHlA+OcHzf8F+AFLw/XP6VDg737zZQ0yMfDqC6QiSSpkrNXdZ2XcCNPBgDuiSQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">= 5.3",
"npm": ">= 3.5"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@ -1901,6 +2319,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -1934,7 +2353,6 @@
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.13.0"
}
@ -1973,6 +2391,15 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@ -1991,12 +2418,91 @@
"node": ">=10.13.0"
}
},
"node_modules/quill": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.4.tgz",
"integrity": "sha512-JNQtAA8jRhFEM1zlpb8Cee/4JHvgdKMWQ0boZJFIsQAH1X0vdAYISsjKPMdI4dOgj1XUG1cSUXeLPxSpoD7Ryg==",
"license": "BSD-3-Clause",
"dependencies": {
"clone": "^2.1.1",
"deep-equal": "^1.0.1",
"eventemitter3": "^2.0.3",
"extend": "^3.0.1",
"parchment": "1.1.1",
"quill-delta": "^3.6.2"
}
},
"node_modules/quill-blot-formatter": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/quill-blot-formatter/-/quill-blot-formatter-1.0.5.tgz",
"integrity": "sha512-iVmuEdmMIpvERBnnDfosWul6VAVN6tqQRruUzAEwA9ZbQ/Ef7DTHGZDUR4KklXpxM+z50opFp6m1NhNdN6HJhw==",
"license": "Apache-2.0",
"dependencies": {
"deepmerge": "^2.0.0"
},
"peerDependencies": {
"quill": "^1.3.4"
}
},
"node_modules/quill-delta": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
"license": "MIT",
"dependencies": {
"fast-diff": "^1.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/quill/node_modules/fast-diff": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
"license": "Apache-2.0"
},
"node_modules/quill/node_modules/quill-delta": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
"license": "MIT",
"dependencies": {
"deep-equal": "^1.0.1",
"extend": "^3.0.2",
"fast-diff": "1.1.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"define-properties": "^1.2.1",
"es-errors": "^1.3.0",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"set-function-name": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -2005,8 +2511,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
@ -2078,8 +2583,39 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/set-function-name": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"functions-have-names": "^1.2.3",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/shallow-equal": {
"version": "1.2.1",
@ -2110,7 +2646,6 @@
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@ -2125,7 +2660,6 @@
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@ -2183,12 +2717,19 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -2263,6 +2804,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz",
"integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.27",
"@vue/compiler-sfc": "3.5.27",
@ -2354,15 +2896,13 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
@ -2376,15 +2916,13 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"peer": true,
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
@ -2407,7 +2945,6 @@
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"peer": true,
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"

@ -9,11 +9,19 @@
"preview": "vite preview"
},
"dependencies": {
"@vueup/vue-quill": "^1.2.0",
"ant-design-vue": "^4.2.6",
"axios": "^1.13.3",
"chart.js": "^4.5.1",
"dayjs": "^1.11.19",
"katex": "^0.16.28",
"markdown-it": "^14.1.0",
"markdown-it-katex": "^2.0.3",
"marked": "^17.0.1",
"pinia": "^3.0.4",
"quill": "^1.3.4",
"quill-blot-formatter": "^1.0.5",
"quill-delta": "^5.1.0",
"vue": "^3.5.24",
"vue-chartjs": "^5.3.3",
"vue-qrcode": "^2.2.2",

@ -6,6 +6,7 @@ import { useUserStore } from './store/user'
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import 'katex/dist/katex.min.css'
const app = createApp(App)
const pinia = createPinia()

@ -5,7 +5,11 @@ export const useAreaStore = defineStore('area', {
state: () => ({
areas: [],
area: null,
cursosDisponibles: [], // todos los cursos
cursosVinculados: [],
procesosDisponibles: [],
procesosVinculados: [],
// paginación
pagination: {
current_page: 1,
@ -21,13 +25,44 @@ export const useAreaStore = defineStore('area', {
loading: false,
errors: null,
error: null,
}),
getters: {
// Cursos ya vinculados (objetos completos)
cursosVinculadosCompletos: (state) => {
return state.cursosDisponibles.filter(curso =>
state.cursosVinculados.includes(curso.id)
);
},
// Cursos disponibles (no vinculados)
cursosDisponiblesFiltrados: (state) => {
return state.cursosDisponibles.filter(curso =>
!state.cursosVinculados.includes(curso.id)
);
},
// Procesos ya vinculados (objetos completos)
procesosVinculadosCompletos: (state) => {
return state.procesosDisponibles.filter(proceso =>
state.procesosVinculados.includes(proceso.id)
);
},
// Procesos disponibles (no vinculados)
procesosDisponiblesFiltrados: (state) => {
return state.procesosDisponibles.filter(proceso =>
!state.procesosVinculados.includes(proceso.id)
);
}
},
actions: {
/* =============================
* LISTAR ÁREAS (con filtros)
* GET /api/admin/areas
* ============================= */
// =============================
// LISTAR ÁREAS (con filtros)
// GET /api/admin/areas
// =============================
async fetchAreas(params = {}) {
this.loading = true
this.errors = null
@ -53,10 +88,10 @@ export const useAreaStore = defineStore('area', {
}
},
/* =============================
* MOSTRAR ÁREA
* GET /api/admin/areas/{id}
* ============================= */
// =============================
// MOSTRAR ÁREA
// GET /api/admin/areas/{id}
// =============================
async fetchArea(id) {
this.loading = true
this.errors = null
@ -71,10 +106,10 @@ export const useAreaStore = defineStore('area', {
}
},
/* =============================
* CREAR ÁREA
* POST /api/admin/areas
* ============================= */
// =============================
// CREAR ÁREA
// POST /api/admin/areas
// =============================
async createArea(payload) {
this.loading = true
this.errors = null
@ -93,10 +128,10 @@ export const useAreaStore = defineStore('area', {
}
},
/* =============================
* ACTUALIZAR ÁREA
* PUT /api/admin/areas/{id}
* ============================= */
// =============================
// ACTUALIZAR ÁREA
// PUT /api/admin/areas/{id}
// =============================
async updateArea(id, payload) {
this.loading = true
this.errors = null
@ -115,43 +150,42 @@ export const useAreaStore = defineStore('area', {
}
},
/* =============================
* ACTIVAR / DESACTIVAR ÁREA
* PATCH /api/admin/areas/{id}/toggle
* ============================= */
// =============================
// ACTIVAR / DESACTIVAR ÁREA
// PATCH /api/admin/areas/{id}/toggle
// =============================
async toggleArea(id) {
this.loading = true
this.errors = null
this.loading = true
this.errors = null
try {
try {
const res = await api.patch(`/admin/areas/${id}/toggle`)
// actualizar lista
const index = this.areas.findIndex(a => a.id === id)
if (index !== -1) {
this.areas[index].activo = res.data.data.activo
this.areas[index].activo = res.data.data.activo
}
// actualizar área actual si se está viendo
if (this.area?.id === id) {
this.area.activo = res.data.data.activo
this.area.activo = res.data.data.activo
}
return res.data
} catch (error) {
} catch (error) {
console.error(error)
if (error.response) this.errors = error.response.data
return null
} finally {
} finally {
this.loading = false
}
}
},
/* =============================
* ELIMINAR ÁREA
* DELETE /api/admin/areas/{id}
* ============================= */
// =============================
// ELIMINAR ÁREA
// DELETE /api/admin/areas/{id}
// =============================
async deleteArea(id) {
this.loading = true
@ -166,9 +200,169 @@ export const useAreaStore = defineStore('area', {
}
},
/* =============================
* SETTERS DE FILTROS
* ============================= */
// =============================
// CURSOS POR ÁREA
// GET /api/areas/{areaId}/cursos-disponibles
// =============================
async fetchCursosPorArea(areaId) {
this.loading = true
this.error = null
try {
const response = await api.get(`/admin/areas/${areaId}/cursos-disponibles`)
if (response.data.success) {
this.cursosDisponibles = response.data.data.todos_los_cursos || []
this.cursosVinculados = response.data.data.cursos_vinculados || []
return response.data
} else {
this.error = response.data.message || 'Error cargando cursos'
return null
}
} catch (err) {
this.error = err.response?.data?.message || err.message
console.error('Error fetching cursos:', err)
return null
} finally {
this.loading = false
}
},
// =============================
// VINCULAR CURSOS
// POST /api/areas/{areaId}/vincular-cursos
// =============================
async vincularCursos(areaId, cursosIds) {
this.loading = true
this.error = null
try {
const response = await api.post(`/admin/areas/${areaId}/vincular-cursos`, {
cursos: cursosIds
})
if (response.data.success) {
this.cursosVinculados = cursosIds
return response.data
} else {
this.error = response.data.message || 'Error vinculando cursos'
return null
}
} catch (err) {
this.error = err.response?.data?.message || err.message
console.error('Error vinculando cursos:', err)
return null
} finally {
this.loading = false
}
},
// =============================
// DESVINCULAR CURSO
// POST /api/areas/{areaId}/desvincular-curso
// =============================
async desvincularCurso(areaId, cursoId) {
this.loading = true
this.error = null
try {
const response = await api.post(`/admin/areas/${areaId}/desvincular-curso`, {
curso_id: cursoId
})
if (response.data.success) {
this.cursosVinculados = this.cursosVinculados.filter(id => id !== cursoId)
return response.data
} else {
this.error = response.data.message || 'Error desvinculando curso'
return null
}
} catch (err) {
this.error = err.response?.data?.message || err.message
console.error('Error desvinculando curso:', err)
return null
} finally {
this.loading = false
}
},
// =============================
// PROCESOS POR ÁREA
// GET /api/areas/{areaId}/procesos-disponibles
// =============================
async fetchProcesosPorArea(areaId) {
this.loading = true
this.error = null
try {
const response = await api.get(`/admin/areas/${areaId}/procesos-disponibles`)
if (response.data.success) {
this.procesosDisponibles = response.data.data.todos_los_procesos || []
this.procesosVinculados = response.data.data.procesos_vinculados || []
return response.data
} else {
this.error = response.data.message || 'Error cargando procesos'
return null
}
} catch (err) {
this.error = err.response?.data?.message || err.message
console.error('Error fetching procesos:', err)
return null
} finally {
this.loading = false
}
},
// =============================
// VINCULAR PROCESOS
// POST /api/areas/{areaId}/vincular-procesos
// =============================
async vincularProcesos(areaId, procesosIds) {
this.loading = true
this.error = null
try {
const response = await api.post(`/admin/areas/${areaId}/vincular-procesos`, {
procesos: procesosIds
})
if (response.data.success) {
this.procesosVinculados = procesosIds
return response.data
} else {
this.error = response.data.message || 'Error vinculando procesos'
return null
}
} catch (err) {
this.error = err.response?.data?.message || err.message
console.error('Error vinculando procesos:', err)
return null
} finally {
this.loading = false
}
},
// =============================
// DESVINCULAR PROCESO
// POST /api/areas/{areaId}/desvincular-proceso
// =============================
async desvincularProceso(areaId, procesoId) {
this.loading = true
this.error = null
try {
const response = await api.post(`/admin/areas/${areaId}/desvincular-proceso`, {
proceso_id: procesoId
})
if (response.data.success) {
this.procesosVinculados = this.procesosVinculados.filter(id => id !== procesoId)
return response.data
} else {
this.error = response.data.message || 'Error desvinculando proceso'
return null
}
} catch (err) {
this.error = err.response?.data?.message || err.message
console.error('Error desvinculando proceso:', err)
return null
} finally {
this.loading = false
}
},
// =============================
// ACCIONES DE LIMPIEZA
// =============================
setSearch(search) {
this.filters.search = search
},
@ -184,6 +378,16 @@ export const useAreaStore = defineStore('area', {
clearErrors() {
this.errors = null
this.error = null
},
},
})
clearState() {
this.cursosDisponibles = []
this.cursosVinculados = []
this.procesosDisponibles = []
this.procesosVinculados = []
this.errors = null
this.error = null
}
}
})

@ -0,0 +1,74 @@
// stores/areasStore.js
import { defineStore } from 'pinia';
import axios from 'axios';
export const useAreasStore = defineStore('areas', {
state: () => ({
cursosDisponibles: [], // todos los cursos
cursosVinculados: [], // cursos vinculados a la área
loading: false,
error: null,
}),
actions: {
// Obtener cursos de un área
async fetchCursosPorArea(areaId) {
this.loading = true;
this.error = null;
try {
const response = await axios.get(`/areas/${areaId}/cursos-disponibles`);
if (response.data.success) {
this.cursosDisponibles = response.data.data.todos_los_cursos;
this.cursosVinculados = response.data.data.cursos_vinculados;
} else {
this.error = 'Error cargando cursos';
}
} catch (err) {
this.error = err.response?.data?.message || err.message;
} finally {
this.loading = false;
}
},
// Vincular cursos a un área
async vincularCursos(areaId, cursosIds) {
this.loading = true;
this.error = null;
try {
const response = await axios.post(`/areas/${areaId}/vincular-cursos`, {
cursos: cursosIds
});
if (response.data.success) {
this.cursosVinculados = cursosIds;
} else {
this.error = 'Error vinculando cursos';
}
} catch (err) {
this.error = err.response?.data?.message || err.message;
} finally {
this.loading = false;
}
},
// Desvincular un curso de un área
async desvincularCurso(areaId, cursoId) {
this.loading = true;
this.error = null;
try {
const response = await axios.post(`/areas/${areaId}/desvincular-curso`, {
curso_id: cursoId
});
if (response.data.success) {
// Actualizar lista local
this.cursosVinculados = this.cursosVinculados.filter(id => id !== cursoId);
} else {
this.error = 'Error desvinculando curso';
}
} catch (err) {
this.error = err.response?.data?.message || err.message;
} finally {
this.loading = false;
}
}
}
});

@ -91,6 +91,7 @@ async crearPregunta(formData) {
headers: {
'Content-Type': 'multipart/form-data'
}
})
if (response.data.success) {
@ -110,63 +111,32 @@ async crearPregunta(formData) {
/* ===============================
ACTUALIZAR PREGUNTA (SUMA IMÁGENES)
=============================== */
async actualizarPregunta(id, data) {
this.loading = true
this.errors = null
try {
const formData = new FormData()
formData.append('enunciado', data.enunciado)
formData.append('nivel_dificultad', data.nivel_dificultad)
formData.append('activo', data.activo ? 1 : 0)
if (data.enunciado_adicional)
formData.append('enunciado_adicional', data.enunciado_adicional)
if (data.respuesta_correcta)
formData.append('respuesta_correcta', data.respuesta_correcta)
if (data.explicacion)
formData.append('explicacion', data.explicacion)
if (data.opciones)
formData.append('opciones', JSON.stringify(data.opciones))
if (data.imagenes?.length) {
data.imagenes.forEach(img => {
formData.append('imagenes[]', img)
})
}
if (data.imagenes_explicacion?.length) {
data.imagenes_explicacion.forEach(img => {
formData.append('imagenes_explicacion[]', img)
})
}
formData.append('_method', 'PUT')
const res = await api.post(`/admin/preguntas/${id}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const index = this.preguntas.findIndex(p => p.id === id)
if (index !== -1) this.preguntas[index] = res.data.data
if (this.pregunta?.id === id) this.pregunta = res.data.data
return res.data.data
} catch (error) {
this.errors =
error.response?.status === 422
? error.response.data.errors
: error.response?.data || error.message
throw error
} finally {
this.loading = false
async actualizarPregunta(id, formData) {
this.loading = true
this.errors = null
try {
// Usar PUT directamente
const response = await api.put(`/admin/preguntas/${id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
},
})
if (response.data.success) {
return response.data
} else {
throw new Error(response.data.message || 'Error al actualizar pregunta')
}
} catch (error) {
if (error.response?.status === 422) {
this.errors = error.response.data.errors
}
throw error
} finally {
this.loading = false
}
},
/* ===============================
ELIMINAR PREGUNTA

@ -97,9 +97,43 @@
{{ formatDate(record.created_at) }}
</template>
<!-- Cursos Count -->
<template v-if="column.key === 'cursos_count'">
<div class="count-badge" @click="showCourseModal(record)" style="cursor: pointer;">
<a-tag color="blue">{{ record.cursos_count || 0 }}</a-tag>
<span v-if="record.cursos_count > 0" class="count-label">curso(s)</span>
</div>
</template>
<!-- Procesos Count -->
<template v-if="column.key === 'procesos_count'">
<div class="count-badge" @click="showProcessModal(record)" style="cursor: pointer;">
<a-tag color="green">{{ record.procesos_count || 0 }}</a-tag>
<span v-if="record.procesos_count > 0" class="count-label">proceso(s)</span>
</div>
</template>
<!-- Acciones -->
<template v-if="column.key === 'acciones'">
<a-space>
<a-button
type="link"
size="small"
@click="showProcessModal(record)"
class="action-btn"
>
<ApartmentOutlined /> Procesos
</a-button>
<a-button
type="link"
size="small"
@click="showCourseModal(record)"
class="action-btn"
>
<BookOutlined /> Cursos
</a-button>
<a-button
type="link"
size="small"
@ -203,6 +237,21 @@
</div>
</div>
</a-modal>
<CourseModal
v-if="selectedAreaForCourses"
v-model:open="courseModalVisible"
:area-id="selectedAreaForCourses?.id"
:area-nombre="selectedAreaForCourses?.nombre"
@courses-updated="handleCoursesUpdated"
/>
<ProcesosModal
v-if="selectedAreaForProcesses"
v-model:open="processModalVisible"
:area-id="selectedAreaForProcesses?.id"
:area-nombre="selectedAreaForProcesses?.nombre"
@procesos-updated="handleProcessesUpdated"
/>
</div>
</template>
@ -215,9 +264,12 @@ import {
EditOutlined,
DeleteOutlined,
SearchOutlined,
ReloadOutlined
ReloadOutlined,
BookOutlined,
ApartmentOutlined,
} from '@ant-design/icons-vue'
import CourseModal from '../areas/CursosModal.vue'
import ProcesosModal from '../areas/ProcesosModal.vue'
// Store
const areaStore = useAreaStore()
@ -237,6 +289,32 @@ const formState = reactive({
nombre: '',
codigo: ''
})
const courseModalVisible = ref(false)
const selectedAreaForCourses = ref(null)
const processModalVisible = ref(false)
const selectedAreaForProcesses = ref(null)
const showCourseModal = (area) => {
selectedAreaForCourses.value = area
courseModalVisible.value = true
}
// Método para actualizar después de cambios en cursos
const handleCoursesUpdated = () => {
// Recargar las áreas para reflejar cambios
areaStore.fetchAreas()
message.success('Cursos actualizados correctamente')
}
const showProcessModal = (area) => {
selectedAreaForProcesses.value = area
processModalVisible.value = true
}
const handleProcessesUpdated = () => {
areaStore.fetchAreas()
message.success('Procesos actualizados correctamente')
}
// Reglas de validación del formulario
const formRules = {
@ -260,7 +338,6 @@ const pagination = computed(() => ({
showTotal: (total, range) => `${range[0]}-${range[1]} de ${total} áreas`
}))
// Columnas de la tabla
const columns = [
{
title: 'ID',
@ -280,6 +357,20 @@ const columns = [
key: 'codigo',
width: 120
},
{
title: 'Cursos',
key: 'cursos_count',
width: 120,
align: 'center',
// customRender: ({ record }) => record.cursos_count || 0
},
{
title: 'Procesos', // NUEVA COLUMNA
key: 'procesos_count',
width: 120,
align: 'center',
// customRender: ({ record }) => record.procesos_count || 0
},
{
title: 'Estado',
dataIndex: 'activo',
@ -295,11 +386,10 @@ const columns = [
{
title: 'Acciones',
key: 'acciones',
width: 180,
width: 250, // Aumentar el ancho por los dos botones nuevos
align: 'center'
}
]
// Métodos
const showCreateModal = () => {
isEditing.value = false
@ -528,7 +618,9 @@ onMounted(() => {
.areas-table {
border-radius: 12px;
}
.areas-table {
min-width: 900px; /* Aumentar por las nuevas columnas */
}
.areas-table :deep(.ant-table) {
border-radius: 12px;
}
@ -552,7 +644,16 @@ onMounted(() => {
padding: 4px 8px;
height: auto;
}
/* Para los botones de cursos y procesos */
.action-btn:nth-child(1),
.action-btn:nth-child(2) {
color: #1890ff;
}
.action-btn:nth-child(1):hover,
.action-btn:nth-child(2):hover {
color: #40a9ff;
}
.action-btn :deep(.anticon) {
font-size: 14px;
}
@ -634,8 +735,27 @@ onMounted(() => {
overflow-x: auto;
}
.areas-table {
min-width: 800px;
}
/* En el scoped style */
.action-btn {
padding: 4px 8px;
height: auto;
font-size: 12px;
}
.action-btn :deep(.anticon) {
font-size: 12px;
margin-right: 4px;
}
/* Para el botón de cursos específicamente */
.action-btn:first-child {
color: #1890ff;
}
.action-btn:first-child:hover {
color: #40a9ff;
}
}
</style>

@ -0,0 +1,325 @@
<template>
<a-modal
v-model:open="visible"
:title="`Gestionar Cursos - ${areaNombre}`"
:confirm-loading="loading"
width="800px"
:footer="null"
@cancel="handleCancel"
class="course-modal"
>
<!-- Barra de búsqueda -->
<div class="search-section">
<a-input-search
v-model:value="searchText"
placeholder="Buscar cursos..."
@search="handleSearch"
style="width: 300px; margin-bottom: 16px"
>
<template #enterButton>
<SearchOutlined />
</template>
</a-input-search>
</div>
<!-- Lista de cursos -->
<div class="courses-container">
<a-spin :spinning="loading">
<div class="courses-list">
<div
v-for="curso in filteredCursos"
:key="curso.id"
:class="['course-item', { 'course-selected': isCursoSelected(curso.id) }]"
@click="toggleCurso(curso.id)"
>
<div class="course-info">
<div class="course-header">
<span class="course-codigo">{{ curso.codigo }}</span>
<a-tag :color="isCursoSelected(curso.id) ? 'green' : 'default'">
{{ isCursoSelected(curso.id) ? 'Agregado' : 'No agregado' }}
</a-tag>
</div>
<h4 class="course-nombre">{{ curso.nombre }}</h4>
</div>
<div class="course-actions">
<a-button
:type="isCursoSelected(curso.id) ? 'danger' : 'primary'"
size="small"
@click.stop="toggleCurso(curso.id)"
>
<template #icon>
<CheckOutlined v-if="isCursoSelected(curso.id)" />
<PlusOutlined v-else />
</template>
{{ isCursoSelected(curso.id) ? 'Quitar' : 'Agregar' }}
</a-button>
</div>
</div>
</div>
</a-spin>
</div>
<!-- Resumen -->
<div class="summary-section">
<a-alert
:message="`${selectedCursosCount} cursos seleccionados de ${cursosDisponibles.length} disponibles`"
type="info"
show-icon
/>
</div>
<!-- Acciones -->
<div class="modal-footer">
<a-button @click="handleCancel">Cancelar</a-button>
<a-button type="primary" @click="handleSave" :loading="loading">
Guardar cambios
</a-button>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { message } from 'ant-design-vue'
import { SearchOutlined, PlusOutlined, CheckOutlined } from '@ant-design/icons-vue'
import { useAreaStore } from '../../../store/area.store'
const props = defineProps({
areaId: {
type: Number,
required: true
},
areaNombre: {
type: String,
default: ''
},
open: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:open', 'courses-updated'])
// Usar el nombre correcto para v-model
const visible = computed({
get: () => props.open,
set: (value) => emit('update:open', value)
})
const areaStore = useAreaStore()
const searchText = ref('')
const selectedCursos = ref([])
const loading = computed(() => areaStore.loading)
const cursosDisponibles = computed(() => areaStore.cursosDisponibles || [])
const cursosVinculados = computed(() => areaStore.cursosVinculados || [])
// Cursos filtrados por búsqueda
const filteredCursos = computed(() => {
if (!searchText.value) return cursosDisponibles.value
const searchLower = searchText.value.toLowerCase()
return cursosDisponibles.value.filter(curso =>
curso.nombre.toLowerCase().includes(searchLower) ||
curso.codigo.toLowerCase().includes(searchLower)
)
})
// Contador de cursos seleccionados
const selectedCursosCount = computed(() => selectedCursos.value.length)
// Verificar si un curso está seleccionado
const isCursoSelected = (cursoId) => {
return selectedCursos.value.includes(cursoId)
}
// Toggle de selección de curso
const toggleCurso = (cursoId) => {
const index = selectedCursos.value.indexOf(cursoId)
if (index > -1) {
selectedCursos.value.splice(index, 1)
} else {
selectedCursos.value.push(cursoId)
}
}
// Buscar cursos
const handleSearch = () => {
// La búsqueda se maneja en computed filteredCursos
}
// Cancelar
const handleCancel = () => {
visible.value = false
selectedCursos.value = []
searchText.value = ''
areaStore.clearState() // Limpiar estado
}
// Guardar cambios
const handleSave = async () => {
try {
const result = await areaStore.vincularCursos(props.areaId, selectedCursos.value)
if (result?.success) {
message.success('Cursos actualizados correctamente')
emit('courses-updated')
handleCancel()
} else {
message.error(areaStore.error || 'Error al guardar los cursos')
}
} catch (error) {
message.error('Error al guardar los cursos')
}
}
// Función para cargar cursos
const loadCursos = async () => {
if (props.areaId) {
await areaStore.fetchCursosPorArea(props.areaId)
// Inicializar selección con cursos ya vinculados
selectedCursos.value = [...areaStore.cursosVinculados]
}
}
// Cargar cursos cuando se abre el modal
watch(() => props.open, async (newVal) => {
if (newVal && props.areaId) {
// Usar nextTick para asegurar que el modal esté montado
await nextTick()
await loadCursos()
}
})
// También cargar cuando cambia el áreaId (por si cambia mientras el modal está abierto)
watch(() => props.areaId, async (newVal) => {
if (props.open && newVal) {
await loadCursos()
}
})
// Limpiar al cerrar
watch(() => props.open, (newVal) => {
if (!newVal) {
// Pequeño delay para permitir que la animación de cierre termine
setTimeout(() => {
selectedCursos.value = []
searchText.value = ''
areaStore.clearState()
}, 300)
}
})
// También cargar cursos cuando el componente se monta si ya está abierto
onMounted(() => {
if (props.open && props.areaId) {
loadCursos()
}
})
</script>
<style scoped>
.course-modal :deep(.ant-modal-body) {
padding: 20px;
}
.courses-container {
max-height: 400px;
overflow-y: auto;
margin-bottom: 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 8px;
}
.courses-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.course-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border: 1px solid #e8e8e8;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
background: white;
}
.course-item:hover {
background: #fafafa;
border-color: #d9d9d9;
}
.course-selected {
background-color: #f6ffed;
border-color: #b7eb8f;
}
.course-info {
flex: 1;
}
.course-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.course-codigo {
font-weight: 600;
color: #1890ff;
font-size: 14px;
}
.course-nombre {
margin: 0;
font-size: 14px;
color: #333;
}
.course-actions {
margin-left: 16px;
}
.search-section {
margin-bottom: 16px;
}
.summary-section {
margin-top: 16px;
margin-bottom: 16px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
/* Scrollbar personalizado */
.courses-container::-webkit-scrollbar {
width: 6px;
}
.courses-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.courses-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.courses-container::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>

@ -0,0 +1,303 @@
<template>
<a-modal
v-model:open="visible"
:title="`Gestionar Procesos - ${areaNombre}`"
:confirm-loading="loading"
width="800px"
:footer="null"
@cancel="handleCancel"
class="process-modal"
>
<!-- Barra de búsqueda -->
<div class="search-section">
<a-input-search
v-model:value="searchText"
placeholder="Buscar procesos..."
style="width: 300px; margin-bottom: 16px"
@search="handleSearch"
>
<template #enterButton>
<SearchOutlined />
</template>
</a-input-search>
</div>
<!-- Lista de procesos -->
<div class="processes-container">
<a-spin :spinning="loading">
<div v-if="procesosDisponibles.length === 0 && !loading" class="empty-state">
<a-empty description="No hay procesos disponibles" />
</div>
<div v-else class="processes-list">
<div
v-for="proceso in filteredProcesos"
:key="proceso.id"
:class="[
'process-item',
{ 'process-selected': isProcesoSelected(proceso.id) }
]"
@click="toggleProceso(proceso.id)"
>
<div class="process-info">
<div class="process-header">
<span class="process-codigo">{{ proceso.codigo || 'N/A' }}</span>
<a-tag :color="isProcesoSelected(proceso.id) ? 'green' : 'default'">
{{ isProcesoSelected(proceso.id) ? 'Agregado' : 'No agregado' }}
</a-tag>
</div>
<h4 class="process-nombre">{{ proceso.nombre }}</h4>
<div v-if="proceso.tipo_proceso" class="process-tipo">
<a-tag size="small">{{ proceso.tipo_proceso }}</a-tag>
</div>
</div>
<div class="process-actions">
<a-button
:type="isProcesoSelected(proceso.id) ? 'danger' : 'primary'"
size="small"
@click.stop="toggleProceso(proceso.id)"
>
<template #icon>
<CheckOutlined v-if="isProcesoSelected(proceso.id)" />
<PlusOutlined v-else />
</template>
{{ isProcesoSelected(proceso.id) ? 'Quitar' : 'Agregar' }}
</a-button>
</div>
</div>
</div>
</a-spin>
</div>
<!-- Resumen -->
<div class="summary-section">
<a-alert
:message="`${selectedProcesosCount} procesos seleccionados de ${procesosDisponibles.length} disponibles`"
type="info"
show-icon
/>
</div>
<!-- Acciones -->
<div class="modal-footer">
<a-button @click="handleCancel">Cancelar</a-button>
<a-button type="primary" @click="handleSave" :loading="loading">
Guardar cambios
</a-button>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { message } from 'ant-design-vue'
import { SearchOutlined, PlusOutlined, CheckOutlined } from '@ant-design/icons-vue'
import { useAreaStore } from '../../../store/area.store'
const props = defineProps({
areaId: {
type: Number,
required: true
},
areaNombre: {
type: String,
default: ''
},
open: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:open', 'procesos-updated'])
const visible = computed({
get: () => props.open,
set: (value) => emit('update:open', value)
})
const areaStore = useAreaStore()
const searchText = ref('')
const selectedProcesos = ref([])
const loading = computed(() => areaStore.loading)
const procesosDisponibles = computed(() => areaStore.procesosDisponibles || [])
const procesosVinculados = computed(() => areaStore.procesosVinculados || [])
/* ================= FILTRO ================= */
const filteredProcesos = computed(() => {
if (!searchText.value) return procesosDisponibles.value
const search = searchText.value.toLowerCase()
return procesosDisponibles.value.filter(p =>
p.nombre.toLowerCase().includes(search) ||
(p.codigo || '').toLowerCase().includes(search) ||
(p.tipo_proceso || '').toLowerCase().includes(search)
)
})
const selectedProcesosCount = computed(() => selectedProcesos.value.length)
const isProcesoSelected = (id) => {
return selectedProcesos.value.includes(id)
}
const toggleProceso = (id) => {
const index = selectedProcesos.value.indexOf(id)
index > -1
? selectedProcesos.value.splice(index, 1)
: selectedProcesos.value.push(id)
}
/* ================= ACCIONES ================= */
const handleCancel = () => {
visible.value = false
resetModal()
}
const handleSave = async () => {
try {
const result = await areaStore.vincularProcesos(props.areaId, selectedProcesos.value)
if (result) {
message.success('Procesos actualizados correctamente')
emit('procesos-updated')
handleCancel()
} else {
message.error(areaStore.error || 'Error al guardar procesos')
}
} catch (error) {
message.error('Error al guardar procesos')
}
}
const handleSearch = () => {
// La búsqueda se maneja en computed filteredProcesos
}
const loadProcesos = async () => {
if (props.areaId) {
await areaStore.fetchProcesosPorArea(props.areaId)
selectedProcesos.value = [...areaStore.procesosVinculados]
}
}
const resetModal = () => {
searchText.value = ''
selectedProcesos.value = []
}
/* ================= WATCHERS ================= */
watch(() => props.open, async (open) => {
if (open && props.areaId) {
await nextTick()
await loadProcesos()
}
})
watch(() => props.areaId, async (id) => {
if (props.open && id) {
await loadProcesos()
}
})
// También cargar cursos cuando el componente se monta si ya está abierto
onMounted(() => {
if (props.open && props.areaId) {
loadProcesos()
}
})
// Limpiar al cerrar
watch(() => props.open, (newVal) => {
if (!newVal) {
setTimeout(() => {
resetModal()
}, 300)
}
})
</script>
<style scoped>
.process-modal :deep(.ant-modal-body) {
padding: 20px;
}
.processes-container {
max-height: 400px;
overflow-y: auto;
margin-bottom: 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 8px;
}
.processes-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.process-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border: 1px solid #e8e8e8;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
background: #fff;
}
.process-item:hover {
background: #fafafa;
}
.process-selected {
background: #f6ffed;
border-color: #b7eb8f;
}
.process-info {
flex: 1;
}
.process-header {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 4px;
}
.process-codigo {
font-weight: 600;
color: #1890ff;
font-size: 14px;
}
.process-nombre {
margin: 0;
font-size: 14px;
color: #333;
}
.process-tipo {
margin-top: 4px;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 0;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
</style>

@ -0,0 +1,137 @@
<template>
<div v-html="renderedHtml" class="markdown-content"></div>
</template>
<script setup>
import { computed, onMounted, nextTick } from 'vue'
import MarkdownIt from 'markdown-it'
import katex from 'katex'
import 'katex/dist/katex.css'
const props = defineProps({
content: {
type: String,
required: true
}
})
// Configuración de markdown-it SIN breaks
const md = new MarkdownIt({
html: true,
breaks: false // Esto es clave: desactiva la conversión automática de \n a <br>
})
// Función para renderizar LaTeX
const renderLatex = (content) => {
// Procesar bloques de display math ($$...$$)
content = content.replace(/\$\$([\s\S]*?)\$\$/g, (match, tex) => {
try {
return katex.renderToString(tex.trim(), {
displayMode: true,
throwOnError: false,
fleqn: true
})
} catch (e) {
return `<div class="latex-error">${match}</div>`
}
})
// Procesar bloques de display math (\[...\])
content = content.replace(/\\\[([\s\S]*?)\\\]/g, (match, tex) => {
try {
return katex.renderToString(tex.trim(), {
displayMode: true,
throwOnError: false,
fleqn: true
})
} catch (e) {
return `<div class="latex-error">${match}</div>`
}
})
// Procesar LaTeX en línea ($...$)
content = content.replace(/\$([^\$]*?)\$/g, (match, tex) => {
try {
return katex.renderToString(tex.trim(), {
displayMode: false,
throwOnError: false
})
} catch (e) {
return `<span class="latex-error">${match}</span>`
}
})
return content
}
const renderedHtml = computed(() => {
if (!props.content) return ''
try {
// Primero procesar el LaTeX
let content = renderLatex(props.content)
// Luego procesar el markdown
content = md.render(content)
return content
} catch (error) {
console.error('Error rendering:', error)
return props.content
}
})
// Para asegurar que KaTeX se renderice correctamente
onMounted(() => {
nextTick(() => {
// Intentar renderizar cualquier elemento KaTeX que no se haya renderizado
const katexElements = document.querySelectorAll('.katex')
katexElements.forEach(el => {
if (!el.querySelector('.katex-html')) {
try {
const tex = el.textContent
const isDisplayMode = el.classList.contains('katex-display')
katex.render(tex, el, {
displayMode: isDisplayMode,
throwOnError: false
})
} catch (error) {
console.warn('Error re-rendering KaTeX:', error)
}
}
})
})
})
</script>
<style scoped>
.markdown-content {
line-height: 1.6;
font-size: 14px;
}
/* Listas con sangría bonita */
.markdown-content ul {
padding-left: 20px;
list-style-type: disc;
}
.markdown-content ol {
padding-left: 20px;
list-style-type: decimal;
}
/* Fórmulas centradas */
.markdown-content :deep(.katex-display) {
margin: 1em 0;
text-align: center;
overflow-x: auto;
}
.markdown-content :deep(.latex-error) {
color: red;
background-color: #fff0f0;
padding: 4px;
border: 1px solid red;
}
</style>

@ -201,7 +201,7 @@
:confirm-loading="preguntaStore.loading"
@ok="handleModalPreguntaOk"
@cancel="handleModalPreguntaCancel"
width="900px"
width="1200px"
class="pregunta-modal"
>
<a-form
@ -224,13 +224,43 @@
:validate-status="getFieldStatus('enunciado')"
:help="getFieldHelp('enunciado')"
>
<a-textarea
v-model:value="formPreguntaState.enunciado"
placeholder="Escribe el enunciado principal de la pregunta..."
:rows="4"
:maxlength="1000"
show-count
/>
<div class="editor-container">
<div class="editor-column">
<div class="editor-header">
<span>Editor</span>
<a-tag color="blue">Markdown & LaTeX</a-tag>
</div>
<a-textarea
v-model:value="formPreguntaState.enunciado"
placeholder="Escribe el enunciado principal de la pregunta..."
:rows="6"
:maxlength="2000"
show-count
@input="handleEnunciadoInput"
class="markdown-editor"
/>
<div class="editor-tips">
<small>
<strong>Tips:</strong> Usa **negrita**, *cursiva*, $$fórmulas$$, `código`
</small>
</div>
</div>
<div class="preview-column">
<div class="editor-header">
<span>Vista Previa</span>
<a-tag color="green">Actualización en tiempo real</a-tag>
</div>
<div class="preview-content">
<div v-if="formPreguntaState.enunciado" class="markdown-preview">
<MarkdownLatex :content="formPreguntaState.enunciado" />
</div>
<div v-else class="empty-preview">
<EyeOutlined style="font-size: 24px; color: #ccc;" />
<p>El contenido aparecerá aquí mientras escribes...</p>
</div>
</div>
</div>
</div>
</a-form-item>
<!-- Subida de imágenes para el enunciado -->
@ -276,14 +306,39 @@
</div>
</a-form-item>
<!-- Enunciado Adicional con vista previa -->
<a-form-item
label="Enunciado Adicional (Opcional)"
>
<a-textarea
v-model:value="formPreguntaState.enunciado_adicional"
placeholder="Información adicional, contexto o detalles importantes..."
:rows="3"
/>
<div class="editor-container">
<div class="editor-column">
<div class="editor-header">
<span>Editor</span>
<a-tag color="blue">Markdown & LaTeX</a-tag>
</div>
<a-textarea
v-model:value="formPreguntaState.enunciado_adicional"
placeholder="Información adicional, contexto o detalles importantes..."
:rows="4"
@input="handleAdicionalInput"
class="markdown-editor"
/>
</div>
<div class="preview-column">
<div class="editor-header">
<span>Vista Previa</span>
</div>
<div class="preview-content">
<div v-if="formPreguntaState.enunciado_adicional" class="markdown-preview">
<MarkdownLatex :content="formPreguntaState.enunciado_adicional" />
</div>
<div v-else class="empty-preview">
<EyeOutlined style="font-size: 24px; color: #ccc;" />
<p>Contenido adicional aparecerá aquí...</p>
</div>
</div>
</div>
</div>
</a-form-item>
</a-card>
@ -353,11 +408,40 @@
<a-form-item
label="Explicación de la Respuesta Correcta (Opcional)"
>
<a-textarea
v-model:value="formPreguntaState.explicacion"
placeholder="Explica por qué esta opción es la correcta..."
:rows="4"
/>
<div class="editor-container">
<div class="editor-column">
<div class="editor-header">
<span>Editor</span>
<a-tag color="blue">Markdown & LaTeX</a-tag>
</div>
<a-textarea
v-model:value="formPreguntaState.explicacion"
placeholder="Explica por qué esta opción es la correcta..."
:rows="6"
@input="handleExplicacionInput"
class="markdown-editor"
/>
<div class="editor-tips">
<small>
<strong>Tips:</strong> Usa $$fórmulas$$ para ecuaciones, **importante**, listas con *
</small>
</div>
</div>
<div class="preview-column">
<div class="editor-header">
<span>Vista Previa</span>
</div>
<div class="preview-content">
<div v-if="formPreguntaState.explicacion" class="markdown-preview">
<MarkdownLatex :content="formPreguntaState.explicacion" />
</div>
<div v-else class="empty-preview">
<EyeOutlined style="font-size: 24px; color: #ccc;" />
<p>La explicación aparecerá aquí mientras escribes...</p>
</div>
</div>
</div>
</div>
</a-form-item>
<!-- Subida de imágenes para la explicación -->
@ -481,32 +565,34 @@
@cancel="verPreguntaModalVisible = false"
>
<div class="pregunta-view" v-if="preguntaSeleccionada">
<!-- Enunciado -->
<div class="enunciado-completo" v-html="preguntaSeleccionada.enunciado" />
<!-- Imágenes del enunciado -->
<!-- 1. Enunciado principal -->
<div class="enunciado-principal">
<MarkdownLatex :content="preguntaSeleccionada.enunciado" />
</div>
<!-- 2. Imágenes del enunciado -->
<div v-if="preguntaSeleccionada.imagenes && preguntaSeleccionada.imagenes.length"
class="imagenes-preview">
<h4>Imágenes del Enunciado:</h4>
class="imagenes-enunciado">
<div class="imagenes-grid">
<img
v-for="(imagen, index) in preguntaSeleccionada.imagenes"
:key="index"
:src="imagen"
:alt="'Imagen ' + (index+1)"
@click="verImagen(imagen)"
class="clickable-image"
/>
<img
v-for="(imagen, index) in preguntaSeleccionada.imagenes"
:key="index"
:src="imagen"
:alt="'Imagen ' + (index+1)"
@click="verImagen(imagen)"
class="clickable-image centered-image"
/>
</div>
</div>
<!-- Enunciado Adicional -->
<!-- 3. Enunciado adicional -->
<div v-if="preguntaSeleccionada.enunciado_adicional" class="enunciado-adicional">
<h4>Información Adicional:</h4>
<div v-html="preguntaSeleccionada.enunciado_adicional" />
<MarkdownLatex :content="preguntaSeleccionada.enunciado_adicional" />
</div>
<!-- Opciones -->
<!-- 4. Opciones -->
<div class="opciones-view">
<h4>Opciones de Respuesta:</h4>
<div
@ -523,16 +609,15 @@
</div>
</div>
<!-- Explicación -->
<!-- 5. Explicación -->
<div v-if="preguntaSeleccionada.explicacion" class="explicacion-view">
<h4>Explicación:</h4>
<div v-html="preguntaSeleccionada.explicacion" />
<MarkdownLatex :content="preguntaSeleccionada.explicacion" />
</div>
<!-- Imágenes de la explicación -->
<!-- 6. Imágenes de la explicación -->
<div v-if="preguntaSeleccionada.imagenes_explicacion && preguntaSeleccionada.imagenes_explicacion.length"
class="imagenes-preview">
<h4>Imágenes de la Explicación:</h4>
class="imagenes-explicacion">
<div class="imagenes-grid">
<img
v-for="(imagen, index) in preguntaSeleccionada.imagenes_explicacion"
@ -540,12 +625,12 @@
:src="getImageUrl(imagen)"
:alt="'Imagen ' + (index+1)"
@click="verImagen(getImageUrl(imagen))"
class="clickable-image"
class="clickable-image centered-image"
/>
</div>
</div>
<!-- Información Adicional -->
<!-- 7. Información adicional -->
<div class="info-adicional">
<a-divider />
<div class="info-row">
@ -594,12 +679,10 @@
</div>
</a-modal>
<!-- Modal de Vista Previa de Imagen -->
<a-modal :open="previewVisible" :title="previewTitle" :footer="null" @cancel="previewVisible = false">
<img alt="Vista previa" style="width: 100%" :src="previewImage" />
</a-modal>
<!-- Modal para ver imagen en grande -->
<a-modal v-model:open="modalImagenVisible" :footer="null" @cancel="modalImagenVisible = false">
<img :src="imagenGrande" style="width: 100%" />
</a-modal>
@ -612,6 +695,7 @@ import { useRoute, useRouter } from 'vue-router'
import { usePreguntaStore } from '../../../store/pregunta.store'
import { useCursoStore } from '../../../store/curso.store'
import { message } from 'ant-design-vue'
import MarkdownLatex from '../cursos/MarkdownLatex.vue'
import {
PlusOutlined,
EditOutlined,
@ -830,10 +914,10 @@ const getImageUrl = (path) => {
const eliminarImagenExistente = (tipo, index) => {
if (tipo === 'enunciado') {
formPreguntaState.imagenes_existentes.splice(index, 1)
formPreguntaState.imagenes_existentes = formPreguntaState.imagenes_existentes.filter((_, i) => i !== index)
message.success('Imagen eliminada (se aplicará al guardar)')
} else if (tipo === 'explicacion') {
formPreguntaState.imagenes_explicacion_existentes.splice(index, 1)
formPreguntaState.imagenes_explicacion_existentes = formPreguntaState.imagenes_explicacion_existentes.filter((_, i) => i !== index)
message.success('Imagen eliminada (se aplicará al guardar)')
}
}
@ -844,6 +928,19 @@ const verImagen = (url) => {
modalImagenVisible.value = true
}
// Handlers para vista previa en tiempo real
const handleEnunciadoInput = () => {
// Actualización automática a través de v-model
}
const handleAdicionalInput = () => {
// Actualización automática a través de v-model
}
const handleExplicacionInput = () => {
// Actualización automática a través de v-model
}
// Métodos principales
const goBack = () => {
router.push({ name: 'AcademiaCursos' })
@ -976,87 +1073,74 @@ const handleModalPreguntaCancel = () => {
const submitPreguntaForm = async () => {
try {
// Validar antes de enviar
const erroresValidacion = validarFormulario()
if (erroresValidacion.length > 0) {
erroresValidacion.forEach(error => {
message.error(error)
})
erroresValidacion.forEach(error => message.error(error))
return
}
// Crear FormData para enviar archivos
// Crear FormData
const formData = new FormData()
// Agregar campos básicos
formData.append('curso_id', formPreguntaState.curso_id)
formData.append('enunciado', formPreguntaState.enunciado)
formData.append('nivel_dificultad', formPreguntaState.nivel_dificultad)
formData.append('activo', formPreguntaState.activo ? 1 : 0)
if (formPreguntaState.enunciado_adicional) {
formData.append('enunciado_adicional', formPreguntaState.enunciado_adicional)
}
if (formPreguntaState.explicacion) {
formData.append('explicacion', formPreguntaState.explicacion)
}
// Agregar opciones como array (no JSON stringificado)
// Opciones
const opcionesValidas = formPreguntaState.opciones.filter(op => op && op.trim() !== '')
opcionesValidas.forEach((opcion, index) => {
formData.append(`opciones[${index}]`, opcion)
})
if (formPreguntaState.respuesta_correcta) {
formData.append('respuesta_correcta', formPreguntaState.respuesta_correcta)
}
// Agregar nuevas imágenes del enunciado
formData.append('opciones', JSON.stringify(opcionesValidas))
formData.append('respuesta_correcta', formPreguntaState.respuesta_correcta)
// NUEVAS IMÁGENES DEL ENUNCIADO
imagenesEnunciadoFiles.value.forEach(file => {
if (file.originFileObj) {
formData.append('imagenes[]', file.originFileObj)
}
})
// Agregar nuevas imágenes de la explicación
// NUEVAS IMÁGENES DE EXPLICACIÓN
imagenesExplicacionFiles.value.forEach(file => {
if (file.originFileObj) {
formData.append('imagenes_explicacion[]', file.originFileObj)
}
})
// IMÁGENES EXISTENTES (solo arrays, nunca strings dobles)
if (isEditingPregunta.value) {
// Para edición, también enviar imágenes existentes
if (formPreguntaState.imagenes_existentes?.length) {
formData.append('imagenes_existentes', JSON.stringify(formPreguntaState.imagenes_existentes))
}
if (formPreguntaState.imagenes_explicacion_existentes?.length) {
formData.append('imagenes_explicacion_existentes', JSON.stringify(formPreguntaState.imagenes_explicacion_existentes))
}
// IMPORTANTE: Enviar curso_id también en actualización
formData.append('curso_id', formPreguntaState.curso_id)
// Llamar a store para actualizar
await preguntaStore.actualizarPregunta(formPreguntaState.id, formData)
message.success('Pregunta actualizada correctamente')
} else {
// Crear nueva pregunta
await preguntaStore.crearPregunta(formData)
message.success('Pregunta creada correctamente')
}
// Cerrar modal y limpiar formulario
modalPreguntaVisible.value = false
resetPreguntaForm()
preguntaStore.errors = null
await fetchPreguntas()
} catch (error) {
console.error('Error al guardar pregunta:', error)
// Mostrar errores específicos del backend si existen
if (error.response && error.response.data.errors) {
const errors = error.response.data.errors
Object.values(errors).forEach(errorList => {
Object.values(error.response.data.errors).forEach(errorList => {
errorList.forEach(err => message.error(err))
})
} else {
@ -1362,7 +1446,6 @@ onMounted(async () => {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
}
@ -1379,6 +1462,137 @@ onMounted(async () => {
padding: 24px;
}
/* Nuevos estilos para editor con vista previa */
.editor-container {
display: flex;
gap: 16px;
margin-bottom: 16px;
height: 300px;
}
.editor-column,
.preview-column {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.editor-header span {
font-weight: 500;
color: #333;
}
.markdown-editor {
flex: 1;
resize: none;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background-color: #fafafa;
}
.markdown-editor:focus {
border-color: #1890ff;
background-color: #fff;
}
.editor-tips {
margin-top: 8px;
padding: 8px;
background-color: #f6ffed;
border-radius: 4px;
border: 1px solid #b7eb8f;
font-size: 12px;
color: #666;
}
.preview-content {
flex: 1;
overflow-y: auto;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background-color: #fff;
min-height: 200px;
}
.empty-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
text-align: center;
}
.empty-preview p {
margin-top: 8px;
font-size: 14px;
}
.markdown-preview {
font-size: 14px;
line-height: 1.6;
}
.markdown-preview :deep(h1) {
font-size: 1.5em;
margin-bottom: 0.5em;
}
.markdown-preview :deep(h2) {
font-size: 1.3em;
margin-bottom: 0.5em;
}
.markdown-preview :deep(p) {
margin-bottom: 1em;
}
.markdown-preview :deep(ul),
.markdown-preview :deep(ol) {
margin-left: 1.5em;
margin-bottom: 1em;
}
.markdown-preview :deep(code) {
background-color: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9em;
}
.markdown-preview :deep(pre) {
background-color: #f5f5f5;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
margin-bottom: 1em;
}
.markdown-preview :deep(blockquote) {
border-left: 4px solid #1890ff;
padding-left: 12px;
margin-left: 0;
color: #666;
font-style: italic;
}
/* Estilos adicionales */
.section-card {
margin-bottom: 16px;
@ -1537,7 +1751,7 @@ onMounted(async () => {
border-radius: 3px;
}
.enunciado-completo {
.enunciado-principal {
font-size: 16px;
line-height: 1.6;
margin-bottom: 20px;
@ -1725,5 +1939,74 @@ onMounted(async () => {
max-width: 150px;
max-height: 150px;
}
.editor-container {
flex-direction: column;
height: auto;
}
.editor-column,
.preview-column {
width: 100%;
min-height: 200px;
}
.markdown-editor {
min-height: 150px;
}
.pregunta-view h4 {
margin-top: 16px;
margin-bottom: 8px;
color: #333;
}
.imagenes-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
margin: 8px 0;
}
.centered-image {
max-width: 100%;
height: auto;
cursor: pointer;
border-radius: 4px;
transition: transform 0.2s;
}
.centered-image:hover {
transform: scale(1.05);
}
.opcion-view {
display: flex;
align-items: center;
padding: 6px 12px;
margin-bottom: 6px;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.opcion-view.correcta {
background-color: #f6ffed;
border-color: #b7eb8f;
}
.opcion-letter {
font-weight: bold;
margin-right: 8px;
}
.opcion-correct {
margin-left: auto;
}
.explicacion-view,
.enunciado-adicional {
margin-top: 12px;
}
}
</style>

@ -0,0 +1,118 @@
<template>
<div class="quill-content" v-html="content"></div>
</template>
<script setup>
import { onMounted, nextTick } from 'vue'
const props = defineProps({
content: {
type: String,
default: ''
}
})
// No necesitamos procesar fórmulas si Quill guarda HTML simple
</script>
<style scoped>
.quill-content {
line-height: 1.6;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-size: 14px;
}
.quill-content :deep(p) {
margin-bottom: 0.75em;
}
.quill-content :deep(h1) {
font-size: 1.5em;
margin: 1em 0 0.5em 0;
font-weight: 600;
}
.quill-content :deep(h2) {
font-size: 1.25em;
margin: 1em 0 0.5em 0;
font-weight: 600;
}
.quill-content :deep(h3) {
font-size: 1.125em;
margin: 1em 0 0.5em 0;
font-weight: 600;
}
.quill-content :deep(ul),
.quill-content :deep(ol) {
padding-left: 1.5em;
margin: 0.5em 0;
}
.quill-content :deep(blockquote) {
border-left: 4px solid #1890ff;
margin: 1em 0;
padding-left: 16px;
color: #666;
font-style: italic;
}
.quill-content :deep(code) {
background-color: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.quill-content :deep(pre) {
background-color: #282c34;
color: #abb2bf;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
margin: 1em 0;
}
.quill-content :deep(pre code) {
background-color: transparent;
color: inherit;
padding: 0;
}
.quill-content :deep(table) {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
border: 1px solid #ddd;
}
.quill-content :deep(th) {
background-color: #fafafa;
font-weight: 600;
text-align: left;
}
.quill-content :deep(th),
.quill-content :deep(td) {
border: 1px solid #ddd;
padding: 8px 12px;
}
.quill-content :deep(img) {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 0.5em 0;
}
.quill-content :deep(a) {
color: #1890ff;
text-decoration: none;
}
.quill-content :deep(a:hover) {
text-decoration: underline;
}
</style>
Loading…
Cancel
Save