Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-04-09 16:27:31 -04:00
parent 5e80949269
commit f2f9cef0e7
21 changed files with 768 additions and 116 deletions
+175 -47
View File
@@ -1,11 +1,10 @@
'use client';
import React, { useState, useEffect } from 'react';
import { questionBankApi, QuestionBank, QuestionBankFilters, QuestionBankType } from '@/lib/api';
import React, { useState, useEffect, useCallback } from 'react';
import { questionBankApi, QuestionBank, QuestionBankFilters } from '@/lib/api';
import {
Plus, Search, Filter, Edit2, Trash2, Download,
Upload, Sparkles, ChevronDown, ChevronUp, X, Check, AlertCircle,
Headphones, BookOpen, Tag, Hash, Globe
Plus, Search, Filter,
Upload, BookOpen
} from 'lucide-react';
import QuestionBankEditor from '@/components/QuestionBank/QuestionBankEditor';
import QuestionBankCard from '@/components/QuestionBank/QuestionBankCard';
@@ -15,6 +14,68 @@ const isMySqlOrigin = (source?: string) => source === 'imported-mysql' || source
const isMaterialsOrigin = (source?: string) => source === 'imported-material';
const isAiOrigin = (source?: string) => source === 'ai-generated';
const toSafeText = (value: unknown): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (Array.isArray(value)) {
return value.map((v) => toSafeText(v)).filter(Boolean).join(', ');
}
if (typeof value === 'object') {
const obj = value as Record<string, unknown>;
if (typeof obj.answer === 'string') return obj.answer;
if (typeof obj.text === 'string') return obj.text;
if (typeof obj.label === 'string') return obj.label;
try {
return JSON.stringify(obj);
} catch {
return '';
}
}
return '';
};
const sanitizeQuestion = (q: QuestionBank): QuestionBank => {
const safeTags = Array.isArray(q.tags)
? q.tags.map((t) => toSafeText(t)).filter(Boolean)
: [];
const safeOptions = Array.isArray(q.options)
? q.options.map((opt) => {
if (typeof opt === 'object' && opt !== null) {
const item = opt as Record<string, unknown>;
if ('answer' in item || 'keywords' in item) {
return toSafeText(item.answer ?? item.text ?? item.label ?? item);
}
}
return opt;
})
: q.options;
const safeCorrectAnswer = (() => {
const raw = q.correct_answer;
if (Array.isArray(raw)) {
return raw.map((item) => toSafeText(item));
}
if (raw && typeof raw === 'object') {
const item = raw as Record<string, unknown>;
if ('answer' in item || 'keywords' in item || 'text' in item || 'label' in item) {
return toSafeText(item);
}
}
return raw;
})();
return {
...q,
question_text: toSafeText(q.question_text),
tags: safeTags,
options: safeOptions,
correct_answer: safeCorrectAnswer,
};
};
export default function QuestionBankPage() {
const [questions, setQuestions] = useState<QuestionBank[]>([]);
const [loading, setLoading] = useState(true);
@@ -24,22 +85,25 @@ export default function QuestionBankPage() {
const [showEditor, setShowEditor] = useState(false);
const [editingQuestion, setEditingQuestion] = useState<QuestionBank | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const loadQuestions = async () => {
const loadQuestions = useCallback(async () => {
try {
setLoading(true);
const data = await questionBankApi.list(filters);
setQuestions(data);
setQuestions(data.map(sanitizeQuestion));
setCurrentPage(1);
} catch (error) {
console.error('Failed to load questions:', error);
} finally {
setLoading(false);
}
};
}, [filters]);
useEffect(() => {
loadQuestions();
}, [filters]);
}, [loadQuestions]);
const handleCreate = () => {
setEditingQuestion(null);
@@ -68,31 +132,6 @@ export default function QuestionBankPage() {
await loadQuestions();
};
const getQuestionTypeLabel = (type: QuestionBankType) => {
const labels: Record<QuestionBankType, string> = {
'multiple-choice': 'Opción Múltiple',
'true-false': 'Verdadero/Falso',
'short-answer': 'Respuesta Corta',
'essay': 'Ensayo',
'matching': 'Emparejamiento',
'ordering': 'Ordenar',
'fill-in-the-blanks': 'Completar',
'audio-response': 'Respuesta Audio',
'hotspot': 'Hotspot',
'code-lab': 'Código',
};
return labels[type] || type;
};
const getDifficultyColor = (difficulty?: string) => {
switch (difficulty) {
case 'easy': return 'bg-green-100 text-green-800';
case 'medium': return 'bg-yellow-100 text-yellow-800';
case 'hard': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
@@ -187,7 +226,7 @@ export default function QuestionBankPage() {
</label>
<select
value={filters.question_type || ''}
onChange={(e) => setFilters({ ...filters, question_type: e.target.value as QuestionBankType || undefined })}
onChange={(e) => setFilters({ ...filters, question_type: (e.target.value || undefined) as QuestionBankFilters['question_type'] })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="">Todos los tipos</option>
@@ -280,18 +319,107 @@ export default function QuestionBankPage() {
</button>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{questions.map((question) => (
<QuestionBankCard
key={question.id}
question={question}
onEdit={() => handleEdit(question)}
onDelete={() => handleDelete(question.id)}
/>
))}
</div>
)}
) : (() => {
const totalPages = Math.ceil(questions.length / pageSize);
const paginated = questions.slice((currentPage - 1) * pageSize, currentPage * pageSize);
return (
<>
{/* Page size + info */}
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
Mostrando {(currentPage - 1) * pageSize + 1}{Math.min(currentPage * pageSize, questions.length)} de {questions.length} preguntas
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Por página:</span>
{[20, 50, 100].map((size) => (
<button
key={size}
onClick={() => { setPageSize(size); setCurrentPage(1); }}
className={`px-3 py-1 text-sm rounded-md border transition-colors ${
pageSize === size
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
{size}
</button>
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{paginated.map((question) => (
<QuestionBankCard
key={question.id}
question={question}
onEdit={() => handleEdit(question)}
onDelete={() => handleDelete(question.id)}
/>
))}
</div>
{/* Pagination controls */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-8">
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="px-3 py-1 text-sm rounded-md border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:cursor-not-allowed"
>
«
</button>
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-3 py-1 text-sm rounded-md border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:cursor-not-allowed"
>
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
.reduce<(number | '...')[]>((acc, p, idx, arr) => {
if (idx > 0 && (p as number) - (arr[idx - 1] as number) > 1) acc.push('...');
acc.push(p);
return acc;
}, [])
.map((p, idx) =>
p === '...' ? (
<span key={`ellipsis-${idx}`} className="px-2 text-gray-400"></span>
) : (
<button
key={p}
onClick={() => setCurrentPage(p as number)}
className={`px-3 py-1 text-sm rounded-md border transition-colors ${
currentPage === p
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
{p}
</button>
)
)}
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm rounded-md border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:cursor-not-allowed"
>
</button>
<button
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm rounded-md border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:cursor-not-allowed"
>
»
</button>
</div>
)}
</>
);
})()}
</div>
{/* Editor Modal */}
@@ -12,8 +12,34 @@ interface QuestionBankCardProps {
export default function QuestionBankCard({ question, onEdit, onDelete }: QuestionBankCardProps): React.JSX.Element {
const [isPlaying, setIsPlaying] = useState(false);
const [audio, setAudio] = useState<HTMLAudioElement | null>(null);
const safeQuestionText: React.ReactNode =
typeof question.question_text === 'string' ? question.question_text : '';
const toDisplayText = (value: any): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (Array.isArray(value)) {
return value.map((v) => toDisplayText(v)).filter(Boolean).join(', ');
}
if (typeof value === 'object') {
if (typeof value.answer === 'string') return value.answer;
if (typeof value.text === 'string') return value.text;
if (typeof value.label === 'string') return value.label;
try {
return JSON.stringify(value);
} catch {
return '';
}
}
return '';
};
const safeSourceText = toDisplayText(question.source);
const safeDifficultyText = toDisplayText(question.difficulty);
const safeSkillAssessedText = toDisplayText(question.skill_assessed);
const safePointsText = toDisplayText(question.points);
const safeQuestionTypeText = toDisplayText(question.question_type);
const safeQuestionText = toDisplayText(question.question_text);
const getQuestionTypeLabel = (type: string) => {
const labels: Record<string, string> = {
@@ -41,7 +67,7 @@ export default function QuestionBankCard({ question, onEdit, onDelete }: Questio
};
const sourceBadge = (() => {
switch (question.source) {
switch (safeSourceText) {
case 'imported-mysql':
case 'sam-diagnostico':
return (
@@ -64,8 +90,8 @@ export default function QuestionBankCard({ question, onEdit, onDelete }: Questio
case 'manual':
return <span className="text-xs text-gray-500 dark:text-gray-400">Manual</span>;
default:
return question.source ? (
<span className="text-xs text-gray-500 dark:text-gray-400">{question.source}</span>
return safeSourceText ? (
<span className="text-xs text-gray-500 dark:text-gray-400">{safeSourceText}</span>
) : null;
}
})();
@@ -104,14 +130,14 @@ export default function QuestionBankCard({ question, onEdit, onDelete }: Questio
<div className="flex-1">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 text-xs font-medium rounded">
{getQuestionTypeLabel(question.question_type)}
{getQuestionTypeLabel(safeQuestionTypeText)}
</span>
<span className={`px-2 py-1 text-xs font-medium rounded ${getDifficultyColor(question.difficulty)}`}>
{question.difficulty || 'Medium'}
<span className={`px-2 py-1 text-xs font-medium rounded ${getDifficultyColor(safeDifficultyText || undefined)}`}>
{safeDifficultyText || 'Medium'}
</span>
{question.skill_assessed && (
{safeSkillAssessedText && (
<span className="px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 text-xs font-medium rounded capitalize">
📊 {question.skill_assessed}
📊 {safeSkillAssessedText}
</span>
)}
</div>
@@ -145,7 +171,7 @@ export default function QuestionBankCard({ question, onEdit, onDelete }: Questio
{/* Question Text */}
<p className="text-gray-900 dark:text-white font-medium mb-3 line-clamp-3">
{safeQuestionText as any}
{safeQuestionText}
</p>
{/* Matching Pairs Preview */}
@@ -163,9 +189,9 @@ export default function QuestionBankCard({ question, onEdit, onDelete }: Questio
return pairs.slice(0, 3).map((pair: any, idx: number) => (
<div key={idx} className="text-xs bg-gray-50 dark:bg-gray-700/50 p-2 rounded flex items-center gap-2">
<span className="flex-1 text-gray-700 dark:text-gray-300">{pair.left || pair[0]}</span>
<span className="flex-1 text-gray-700 dark:text-gray-300">{toDisplayText(pair.left ?? pair[0])}</span>
<span className="text-gray-400"></span>
<span className="flex-1 text-gray-600 dark:text-gray-400 line-clamp-1">{pair.right || pair[1]}</span>
<span className="flex-1 text-gray-600 dark:text-gray-400 line-clamp-1">{toDisplayText(pair.right ?? pair[1])}</span>
</div>
));
})()}
@@ -194,12 +220,12 @@ export default function QuestionBankCard({ question, onEdit, onDelete }: Questio
{/* Options Preview */}
{question.options && (
<div className="mb-3 space-y-1">
{Array.isArray(question.options) && question.options.slice(0, 3).map((opt: string, idx: number) => (
{Array.isArray(question.options) && question.options.slice(0, 3).map((opt: any, idx: number) => (
<div key={idx} className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-2">
<span className="w-4 h-4 rounded border border-gray-300 dark:border-gray-600 flex items-center justify-center text-[10px]">
{String.fromCharCode(65 + idx)}
</span>
<span className="line-clamp-1">{opt}</span>
<span className="line-clamp-1">{toDisplayText(opt)}</span>
</div>
))}
{Array.isArray(question.options) && question.options.length > 3 && (
@@ -212,7 +238,7 @@ export default function QuestionBankCard({ question, onEdit, onDelete }: Questio
<div className="pt-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
{question.points} pts
{safePointsText || '0'} pts
</span>
{sourceBadge && <div className="flex items-center gap-1">{sourceBadge}</div>}
</div>
@@ -11,6 +11,20 @@ interface QuestionBankEditorProps {
}
export default function QuestionBankEditor({ question, onSuccess, onCancel }: QuestionBankEditorProps) {
const toDisplayText = (value: unknown): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) return value.map((v) => toDisplayText(v)).filter(Boolean).join(', ');
if (typeof value === 'object') {
const obj = value as Record<string, unknown>;
if (typeof obj.answer === 'string') return obj.answer;
if (typeof obj.text === 'string') return obj.text;
if (typeof obj.label === 'string') return obj.label;
try { return JSON.stringify(obj); } catch { return ''; }
}
return '';
};
const [formData, setFormData] = useState<CreateQuestionBankPayload>({
question_text: question?.question_text || '',
question_type: question?.question_type || 'multiple-choice',
@@ -34,7 +48,7 @@ export default function QuestionBankEditor({ question, onSuccess, onCancel }: Qu
const [generatingAI, setGeneratingAI] = useState(false);
const normalizedOptions = Array.isArray(formData.options)
? (formData.options as string[])
? (formData.options as unknown[]).map((opt) => toDisplayText(opt))
: [];
const handleSubmit = async (e: React.FormEvent) => {
@@ -476,10 +490,10 @@ export default function QuestionBankEditor({ question, onSuccess, onCancel }: Qu
key={idx}
className="px-3 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded-full text-sm flex items-center gap-1"
>
{tag}
{toDisplayText(tag)}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
onClick={() => handleRemoveTag(toDisplayText(tag))}
className="hover:text-blue-600"
>
<X className="w-3 h-3" />
+26 -4
View File
@@ -25,6 +25,28 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
const [userAnswers, setUserAnswers] = useState<Record<string, number[]>>({});
const [submitted, setSubmitted] = useState(false);
const toDisplayText = (value: unknown): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (Array.isArray(value)) {
return value.map((v) => toDisplayText(v)).filter(Boolean).join(', ');
}
if (typeof value === 'object') {
const obj = value as Record<string, unknown>;
if (typeof obj.answer === 'string') return obj.answer;
if (typeof obj.text === 'string') return obj.text;
if (typeof obj.label === 'string') return obj.label;
try {
return JSON.stringify(obj);
} catch {
return '';
}
}
return '';
};
const questions = quizData.questions || [];
const addQuestion = () => {
@@ -133,7 +155,7 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
<div className="space-y-3">
<label className="text-[10px] font-black text-slate-400 dark:text-gray-500 uppercase tracking-widest pl-1">Inquiry Description</label>
<textarea
value={q.question}
value={toDisplayText(q.question)}
onChange={(e) => updateQuestion(idx, { question: e.target.value })}
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-2xl p-6 text-lg font-black tracking-tight focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 transition-all placeholder:text-slate-300 resize-none shadow-inner h-24"
placeholder="What is the core problem being evaluated?"
@@ -172,7 +194,7 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
)}
</div>
<input
value={opt}
value={toDisplayText(opt)}
onChange={(e) => {
const newOpts = [...q.options];
newOpts[oIdx] = e.target.value;
@@ -221,7 +243,7 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
{questions.map((q) => (
<div key={q.id} className="space-y-6 p-10 bg-white dark:bg-white/5 border border-slate-100 dark:border-white/5 rounded-[2.5rem] shadow-sm hover:shadow-xl transition-all duration-700 group/qitem relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-indigo-500/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2 group-hover/qitem:bg-indigo-500/10 transition-colors"></div>
<h4 className="font-black text-2xl text-slate-900 dark:text-white leading-tight uppercase tracking-tight relative z-10">{q.question}</h4>
<h4 className="font-black text-2xl text-slate-900 dark:text-white leading-tight uppercase tracking-tight relative z-10">{toDisplayText(q.question)}</h4>
<div className="grid gap-3">
{q.options && q.options.length > 0 ? (
q.options.map((opt, oIdx) => {
@@ -246,7 +268,7 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
className={`p-6 rounded-2xl border transition-all text-left text-sm font-black uppercase tracking-tight relative overflow-hidden group/optans ${style}`}
>
<div className="flex items-center justify-between relative z-10">
<span>{opt}</span>
<span>{toDisplayText(opt)}</span>
{submitted ? (
<div className="flex items-center gap-2">
{isActuallyCorrect && <span className="text-green-600 dark:text-green-400"><CheckCircle2 size={18} /></span>}
+89 -10
View File
@@ -1324,19 +1324,98 @@ export interface UpdateQuestionBankPayload {
audio_text?: string;
}
const toSafeQuestionBankText = (value: unknown): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (Array.isArray(value)) {
return value.map((v) => toSafeQuestionBankText(v)).filter(Boolean).join(', ');
}
if (typeof value === 'object') {
const obj = value as Record<string, unknown>;
if (typeof obj.answer === 'string') return obj.answer;
if (typeof obj.text === 'string') return obj.text;
if (typeof obj.label === 'string') return obj.label;
try {
return JSON.stringify(obj);
} catch {
return '';
}
}
return '';
};
const normalizeQuestionBank = (q: QuestionBank): QuestionBank => {
const safeTags = Array.isArray(q.tags)
? q.tags.map((tag) => toSafeQuestionBankText(tag)).filter(Boolean)
: [];
const safeOptions = Array.isArray(q.options)
? q.options.map((opt) => toSafeQuestionBankText(opt))
: q.options;
const safeCorrectAnswer = (() => {
const raw = q.correct_answer;
if (Array.isArray(raw)) {
return raw.map((item) => toSafeQuestionBankText(item));
}
if (raw && typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
if (Array.isArray(obj.pairs)) {
return obj.pairs.map((pair) => {
if (pair && typeof pair === 'object') {
const pairObj = pair as Record<string, unknown>;
return {
left: toSafeQuestionBankText(pairObj.left ?? pairObj[0]),
right: toSafeQuestionBankText(pairObj.right ?? pairObj[1]),
};
}
return {
left: toSafeQuestionBankText(pair),
right: '',
};
});
}
if ('answer' in obj || 'keywords' in obj || 'text' in obj || 'label' in obj) {
return toSafeQuestionBankText(obj);
}
}
return raw;
})();
return {
...q,
question_text: toSafeQuestionBankText(q.question_text),
tags: safeTags,
options: safeOptions,
correct_answer: safeCorrectAnswer,
};
};
export const questionBankApi = {
list: (filters?: QuestionBankFilters): Promise<QuestionBank[]> =>
apiFetch('/question-bank', { method: 'GET', query: filters as Record<string, string | number | boolean | undefined | null> }, false),
get: (id: string): Promise<QuestionBank> =>
apiFetch(`/question-bank/${id}`, {}, false),
create: (payload: CreateQuestionBankPayload): Promise<QuestionBank> =>
apiFetch('/question-bank', { method: 'POST', body: JSON.stringify(payload) }, false),
update: (id: string, payload: UpdateQuestionBankPayload): Promise<QuestionBank> =>
apiFetch(`/question-bank/${id}`, { method: 'PUT', body: JSON.stringify(payload) }, false),
list: async (filters?: QuestionBankFilters): Promise<QuestionBank[]> => {
const questions = await apiFetch('/question-bank', { method: 'GET', query: filters as Record<string, string | number | boolean | undefined | null> }, false);
return (questions as QuestionBank[]).map(normalizeQuestionBank);
},
get: async (id: string): Promise<QuestionBank> => {
const question = await apiFetch(`/question-bank/${id}`, {}, false);
return normalizeQuestionBank(question as QuestionBank);
},
create: async (payload: CreateQuestionBankPayload): Promise<QuestionBank> => {
const question = await apiFetch('/question-bank', { method: 'POST', body: JSON.stringify(payload) }, false);
return normalizeQuestionBank(question as QuestionBank);
},
update: async (id: string, payload: UpdateQuestionBankPayload): Promise<QuestionBank> => {
const question = await apiFetch(`/question-bank/${id}`, { method: 'PUT', body: JSON.stringify(payload) }, false);
return normalizeQuestionBank(question as QuestionBank);
},
delete: (id: string): Promise<void> =>
apiFetch(`/question-bank/${id}`, { method: 'DELETE' }, false),
importFromMySQL: (courseId?: number, questionIds?: number[], importAll?: boolean): Promise<QuestionBank[]> =>
apiFetch('/question-bank/import-mysql', { method: 'POST', body: JSON.stringify({ mysql_course_id: courseId, question_ids: questionIds, import_all: importAll }) }, false),
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);
},
getMySQLPlans: async (): Promise<MySqlPlan[]> => {
const plans = (await apiFetch('/question-bank/mysql-plans', {}, false)) as MySqlPlanRaw[];
return plans.reduce((acc: MySqlPlan[], p: MySqlPlanRaw) => {