Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-04-28 17:33:36 -04:00
parent 42620cc9ac
commit 16e93bdf26
12 changed files with 2212 additions and 1913 deletions
+611 -539
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -21,7 +21,7 @@
"lodash": "^4.17.21",
"lucide-react": "^0.395.0",
"mermaid": "^11.14.0",
"next": "^14.2.35",
"next": "^15.3.9",
"react": "^18",
"react-dom": "^18",
"react-markdown": "^10.1.0",
@@ -35,7 +35,7 @@
"@types/react-dom": "^18",
"autoprefixer": "^10.4.19",
"eslint": "^8",
"eslint-config-next": "^16.2.4",
"eslint-config-next": "^15.3.9",
"postcss": "^8",
"prettier": "^3.2.0",
"prettier-plugin-tailwindcss": "^0.5.0",
+611 -745
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -21,19 +21,18 @@
"isomorphic-dompurify": "^3.10.0",
"lucide-react": "^0.395.0",
"mermaid": "^11.14.0",
"next": "^14.2.35",
"next": "^15.3.9",
"react": "^18",
"react-dom": "^18",
"react-markdown": "^10.1.0",
"tailwind-merge": "^2.3.0",
"xlsx": "^0.18.5"
"tailwind-merge": "^2.3.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "^16.2.4",
"eslint-config-next": "^15.3.9",
"postcss": "^8",
"prettier": "^3.2.0",
"prettier-plugin-tailwindcss": "^0.5.0",
@@ -1,8 +1,7 @@
'use client';
import React, { useState } from 'react';
import * as XLSX from 'xlsx';
import { CreateQuestionBankPayload, QuestionBankType, questionBankApi } from '@/lib/api';
import { questionBankApi } from '@/lib/api';
import { X, Upload, FileSpreadsheet, Check, AlertCircle, Download } from 'lucide-react';
interface ExcelImportModalProps {
@@ -16,131 +15,6 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
const [result, setResult] = useState<{ imported: number; skipped: number; error?: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const toQuestionType = (value: string): QuestionBankType | null => {
const normalized = value.trim().toLowerCase();
const mapping: Record<string, QuestionBankType> = {
'multiple-choice': 'multiple-choice',
'multiple choice': 'multiple-choice',
'mcq': 'multiple-choice',
'true-false': 'true-false',
'true false': 'true-false',
'boolean': 'true-false',
'short-answer': 'short-answer',
'short answer': 'short-answer',
'essay': 'essay',
'matching': 'matching',
'ordering': 'ordering',
'fill-in-the-blanks': 'fill-in-the-blanks',
'fill in the blanks': 'fill-in-the-blanks',
'audio-response': 'audio-response',
'audio response': 'audio-response',
'hotspot': 'hotspot',
'code-lab': 'code-lab',
'code lab': 'code-lab',
};
return mapping[normalized] || null;
};
const parseUnknownJson = (value: string): unknown => {
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
if (/^-?\d+$/.test(trimmed)) {
return Number.parseInt(trimmed, 10);
}
if (/^-?\d+\.\d+$/.test(trimmed)) {
return Number.parseFloat(trimmed);
}
return trimmed;
};
const parseOptions = (raw: string): unknown => {
const parsed = parseUnknownJson(raw);
if (Array.isArray(parsed)) return parsed;
if (typeof parsed === 'string') {
const pieces = parsed
.split(',')
.map((p) => p.trim())
.filter(Boolean);
return pieces.length > 0 ? pieces : undefined;
}
return undefined;
};
const parseExcelRows = async (file: File): Promise<CreateQuestionBankPayload[]> => {
const buffer = await file.arrayBuffer();
const workbook = XLSX.read(buffer, { type: 'array' });
const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
if (!firstSheet) return [];
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(firstSheet, {
defval: '',
raw: false,
});
return rows
.map((row) => {
const getField = (name: string): string => {
const direct = row[name];
if (direct !== undefined) return String(direct).trim();
const lowerName = name.toLowerCase();
const foundKey = Object.keys(row).find((k) => k.trim().toLowerCase() === lowerName);
return foundKey ? String(row[foundKey]).trim() : '';
};
const questionText = getField('question_text');
const questionTypeRaw = getField('question_type');
const questionType = toQuestionType(questionTypeRaw);
if (!questionText || !questionType) return null;
const optionsRaw = getField('options');
const correctAnswerRaw = getField('correct_answer');
const explanation = getField('explanation') || undefined;
const difficultyRaw = getField('difficulty').toLowerCase();
const difficulty = ['easy', 'medium', 'hard'].includes(difficultyRaw) ? difficultyRaw : 'medium';
const tagsRaw = getField('tags');
const pointsRaw = getField('points');
let options = parseOptions(optionsRaw);
let correctAnswer = parseUnknownJson(correctAnswerRaw);
if (questionType === 'true-false') {
options = ['Verdadero', 'Falso'];
if (typeof correctAnswer === 'string') {
const lower = correctAnswer.toLowerCase();
correctAnswer = lower === 'verdadero' || lower === 'true' ? 0 : 1;
}
}
const tags = tagsRaw
? tagsRaw
.split(',')
.map((t) => t.trim())
.filter(Boolean)
: undefined;
const pointsNum = Number.parseInt(pointsRaw, 10);
return {
question_text: questionText,
question_type: questionType,
options,
correct_answer: correctAnswer,
explanation,
difficulty,
tags,
points: Number.isFinite(pointsNum) && pointsNum > 0 ? pointsNum : 1,
} as CreateQuestionBankPayload;
})
.filter((item): item is CreateQuestionBankPayload => item !== null);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
@@ -158,42 +32,12 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
alert('Selecciona un archivo primero');
return;
}
try {
setUploading(true);
setError(null);
const payloads = await parseExcelRows(excelFile);
if (payloads.length === 0) {
throw new Error('No se encontraron filas válidas en el archivo. Verifica columnas y tipos.');
}
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (const payload of payloads) {
try {
await questionBankApi.create(payload);
imported += 1;
} catch (e) {
skipped += 1;
if (errors.length < 5) {
errors.push((e as Error).message || 'Error desconocido al crear pregunta');
}
}
}
setResult({
imported,
skipped,
error: errors.length > 0 ? errors.join(' | ') : undefined,
});
setTimeout(() => {
onSuccess?.();
}, 2000);
const res = await questionBankApi.importFromExcel(excelFile);
setResult(res);
setTimeout(() => { onSuccess?.(); }, 2000);
} catch (err: unknown) {
console.error('Excel import failed:', err);
setError((err as Error)?.message || 'Error al importar');
@@ -208,7 +52,6 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
'What color is the sky?,multiple-choice,"[""Blue"",""Green"",""Red"",""Yellow""]",0,"The sky appears blue due to Rayleigh scattering",easy,"science,colors",1',
'The sun rises in the east.,true-false,"[""Verdadero"",""Falso""]",0,"The sun always rises in the east",easy,"geography",1',
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
@@ -246,7 +89,6 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
{/* Content */}
<div className="p-6 space-y-4">
{/* Info Box */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h4 className="font-semibold text-blue-900 dark:text-blue-100 mb-2 text-sm">
¿Cómo funciona?
@@ -256,11 +98,9 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
<li> question_type: multiple-choice, true-false, short-answer, etc.</li>
<li> options: Formato JSON ["A","B","C","D"] o separado por comas</li>
<li> correct_answer: Índice de la opción correcta (0, 1, 2, 3)</li>
<li> Todas las preguntas se importarán al banco</li>
</ul>
</div>
{/* Download Template Button */}
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="flex items-center gap-3">
<Download className="w-5 h-5 text-gray-500" />
@@ -277,7 +117,6 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
</button>
</div>
{/* File Upload */}
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-8 text-center">
<input
type="file"
@@ -286,24 +125,16 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
className="hidden"
id="excel-upload"
/>
<label
htmlFor="excel-upload"
className="cursor-pointer flex flex-col items-center gap-3"
>
<label htmlFor="excel-upload" className="cursor-pointer flex flex-col items-center gap-3">
<FileSpreadsheet className="w-12 h-12 text-green-500" />
<div>
<span className="text-sm font-medium text-blue-600 hover:text-blue-700">
Subir archivo Excel
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
{' '}o arrastrar aquí
</span>
<span className="text-sm text-gray-500 dark:text-gray-400"> o arrastrar aquí</span>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500">
.xlsx, .xls - Máx 10MB
</p>
<p className="text-xs text-gray-400 dark:text-gray-500">.xlsx, .xls - Máx 10MB</p>
</label>
{excelFile && (
<div className="mt-4 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<div className="flex items-center gap-2 text-sm text-green-800 dark:text-green-200">
@@ -315,34 +146,25 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
)}
</div>
{/* Result Messages */}
{result && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-center gap-3 mb-2">
<Check className="w-5 h-5 text-green-600" />
<p className="font-semibold text-green-900 dark:text-green-100">
¡Importación completada!
</p>
<p className="font-semibold text-green-900 dark:text-green-100">¡Importación completada!</p>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-green-700 dark:text-green-300">Importadas:</span>
<span className="ml-2 font-bold text-green-900 dark:text-green-100">
{result.imported}
</span>
<span className="ml-2 font-bold text-green-900 dark:text-green-100">{result.imported}</span>
</div>
<div>
<span className="text-green-700 dark:text-green-300">Saltadas:</span>
<span className="ml-2 font-bold text-green-900 dark:text-green-100">
{result.skipped}
</span>
<span className="ml-2 font-bold text-green-900 dark:text-green-100">{result.skipped}</span>
</div>
{result.error && (
<div>
<span className="text-green-700 dark:text-green-300">Errores:</span>
<span className="ml-2 font-bold text-green-900 dark:text-green-100">
{result.error}
</span>
<span className="ml-2 font-bold text-green-900 dark:text-green-100">{result.error}</span>
</div>
)}
</div>
@@ -353,12 +175,8 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-600" />
<div>
<p className="text-sm font-medium text-red-900 dark:text-red-100">
Error
</p>
<p className="text-xs text-red-700 dark:text-red-300">
{error}
</p>
<p className="text-sm font-medium text-red-900 dark:text-red-100">Error</p>
<p className="text-xs text-red-700 dark:text-red-300">{error}</p>
</div>
</div>
)}
+6
View File
@@ -1622,6 +1622,12 @@ export const questionBankApi = {
},
delete: (id: string): Promise<void> =>
apiFetch(`/question-bank/${id}`, { method: 'DELETE' }, false),
importFromExcel: async (file: File): Promise<{ imported: number; skipped: number; error?: string }> => {
const formData = new FormData();
formData.append('file', file);
const result = await apiFetch('/question-bank/import-excel', { method: 'POST', body: formData }, false);
return result as { imported: number; skipped: number; error?: string };
},
importFromMySQL: async (courseId?: number, questionIds?: number[], importAll?: boolean): Promise<QuestionBank[]> => {
const questions = await apiFetch('/question-bank/import-mysql', { method: 'POST', body: JSON.stringify({ mysql_course_id: courseId, question_ids: questionIds, import_all: importAll }) }, false);
return (questions as QuestionBank[]).map(normalizeQuestionBank);
File diff suppressed because one or more lines are too long