AssessmentTemplateManager.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. import React, { useState, useEffect } from 'react';
  2. import { createPortal } from 'react-dom';
  3. import { Plus, Edit2, Trash2, FileText, Loader2, X, Sparkles, Sliders, Hash, Type, Brain } from 'lucide-react';
  4. import { motion, AnimatePresence } from 'framer-motion';
  5. import { useLanguage } from '../../contexts/LanguageContext';
  6. import { useToast } from '../../contexts/ToastContext';
  7. import { useConfirm } from '../../contexts/ConfirmContext';
  8. import { templateService } from '../../services/templateService';
  9. import { knowledgeGroupService } from '../../services/knowledgeGroupService';
  10. import { AssessmentTemplate, CreateTemplateData, UpdateTemplateData, KnowledgeGroup } from '../../types';
  11. export const AssessmentTemplateManager: React.FC = () => {
  12. const { t } = useLanguage();
  13. const { showSuccess, showError } = useToast();
  14. const { confirm } = useConfirm();
  15. const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
  16. const [groups, setGroups] = useState<KnowledgeGroup[]>([]);
  17. const [isLoading, setIsLoading] = useState(false);
  18. const [isSaving, setIsSaving] = useState(false);
  19. const [showModal, setShowModal] = useState(false);
  20. const [editingTemplate, setEditingTemplate] = useState<AssessmentTemplate | null>(null);
  21. // UI state uses strings for easy input
  22. const [formData, setFormData] = useState({
  23. name: '',
  24. description: '',
  25. keywords: '',
  26. questionCount: 5,
  27. difficultyDistribution: 'Basic: 30%, Intermediate: 40%, Advanced: 30%',
  28. style: 'Professional',
  29. knowledgeGroupId: '',
  30. });
  31. const fetchTemplates = async () => {
  32. setIsLoading(true);
  33. try {
  34. const data = await templateService.getAll();
  35. setTemplates(data);
  36. } catch (error) {
  37. console.error('Failed to fetch templates:', error);
  38. showError(t('actionFailed'));
  39. } finally {
  40. setIsLoading(false);
  41. }
  42. };
  43. const fetchGroups = async () => {
  44. try {
  45. const data = await knowledgeGroupService.getGroups();
  46. setGroups(data);
  47. } catch (error) {
  48. console.error('Failed to fetch groups:', error);
  49. }
  50. };
  51. useEffect(() => {
  52. fetchTemplates();
  53. fetchGroups();
  54. }, []);
  55. const handleOpenModal = (template?: AssessmentTemplate) => {
  56. if (template) {
  57. setEditingTemplate(template);
  58. setFormData({
  59. name: template.name,
  60. description: template.description || '',
  61. keywords: Array.isArray(template.keywords) ? template.keywords.join(', ') : '',
  62. questionCount: template.questionCount,
  63. difficultyDistribution: typeof template.difficultyDistribution === 'object'
  64. ? JSON.stringify(template.difficultyDistribution)
  65. : (template.difficultyDistribution || ''),
  66. style: template.style || 'Professional',
  67. knowledgeGroupId: template.knowledgeGroupId || '',
  68. });
  69. } else {
  70. setEditingTemplate(null);
  71. setFormData({
  72. name: '',
  73. description: '',
  74. keywords: '',
  75. questionCount: 5,
  76. difficultyDistribution: '{"Basic": 3, "Intermediate": 4, "Advanced": 3}',
  77. style: 'Professional',
  78. knowledgeGroupId: '',
  79. });
  80. }
  81. setShowModal(true);
  82. };
  83. const handleSave = async (e: React.FormEvent) => {
  84. e.preventDefault();
  85. setIsSaving(true);
  86. try {
  87. // Convert UI strings back to required types
  88. const keywordsArray = formData.keywords.split(',').map(k => k.trim()).filter(k => k !== '');
  89. let diffDist: any = formData.difficultyDistribution;
  90. try {
  91. if (formData.difficultyDistribution.startsWith('{')) {
  92. diffDist = JSON.parse(formData.difficultyDistribution);
  93. }
  94. } catch (e) {
  95. // Keep as string if parsing fails
  96. }
  97. const payload: CreateTemplateData = {
  98. name: formData.name,
  99. description: formData.description,
  100. keywords: keywordsArray,
  101. questionCount: formData.questionCount,
  102. difficultyDistribution: diffDist,
  103. style: formData.style,
  104. knowledgeGroupId: formData.knowledgeGroupId || undefined,
  105. };
  106. if (editingTemplate) {
  107. await templateService.update(editingTemplate.id, payload as UpdateTemplateData);
  108. showSuccess(t('featureUpdated'));
  109. } else {
  110. await templateService.create(payload);
  111. showSuccess(t('confirm'));
  112. }
  113. setShowModal(false);
  114. fetchTemplates();
  115. } catch (error) {
  116. console.error('Save failed:', error);
  117. showError(t('actionFailed'));
  118. } finally {
  119. setIsSaving(false);
  120. }
  121. };
  122. const handleDelete = async (id: string) => {
  123. if (!(await confirm(t('confirmTitle')))) return;
  124. try {
  125. await templateService.delete(id);
  126. showSuccess(t('confirm'));
  127. fetchTemplates();
  128. } catch (error) {
  129. showError(t('actionFailed'));
  130. }
  131. };
  132. const renderDifficulty = (dist: any) => {
  133. if (typeof dist === 'string') return dist;
  134. if (typeof dist === 'object' && dist !== null) {
  135. return Object.entries(dist).map(([k, v]) => `${k}: ${v}`).join(', ');
  136. }
  137. return '';
  138. };
  139. return (
  140. <div className="space-y-6">
  141. <div className="flex items-center justify-between mb-2">
  142. <div className="flex items-center gap-3">
  143. <div className="w-10 h-10 bg-indigo-50 text-indigo-600 rounded-xl flex items-center justify-center">
  144. <FileText size={22} />
  145. </div>
  146. <div>
  147. <h3 className="text-lg font-bold text-slate-900">{t('assessmentTemplates')}</h3>
  148. <p className="text-xs text-slate-500">{t('assessmentTemplatesSubtitle')}</p>
  149. </div>
  150. </div>
  151. <button
  152. onClick={() => handleOpenModal()}
  153. className="px-4 py-2.5 bg-indigo-600 text-white rounded-xl text-sm font-black flex items-center gap-2 shadow-lg shadow-indigo-100 hover:bg-indigo-700 transition-all active:scale-95"
  154. >
  155. <Plus size={18} />
  156. {t('createTemplate')}
  157. </button>
  158. </div>
  159. {isLoading ? (
  160. <div className="flex items-center justify-center py-20">
  161. <Loader2 className="w-8 h-8 animate-spin text-indigo-600 opacity-20" />
  162. </div>
  163. ) : templates.length === 0 ? (
  164. <div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-16 text-center">
  165. <FileText className="w-12 h-12 text-slate-200 mx-auto mb-4" />
  166. <p className="text-slate-400 font-bold uppercase tracking-widest text-xs">{t('mmEmpty')}</p>
  167. </div>
  168. ) : (
  169. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  170. {templates.map((template) => (
  171. <motion.div
  172. key={template.id}
  173. initial={{ opacity: 0, scale: 0.95 }}
  174. animate={{ opacity: 1, scale: 1 }}
  175. className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden"
  176. >
  177. <div className="absolute top-0 right-0 w-24 h-24 bg-indigo-500/5 rounded-full blur-3xl -mr-12 -mt-12" />
  178. <div className="flex justify-between items-start mb-4 relative z-10">
  179. <h4 className="text-base font-black text-slate-900 truncate pr-8">{template.name}</h4>
  180. <div className="flex gap-1">
  181. <button
  182. onClick={() => handleOpenModal(template)}
  183. className="p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-all"
  184. >
  185. <Edit2 size={14} />
  186. </button>
  187. <button
  188. onClick={() => handleDelete(template.id)}
  189. className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all"
  190. >
  191. <Trash2 size={14} />
  192. </button>
  193. </div>
  194. </div>
  195. <p className="text-xs text-slate-500 mb-4 line-clamp-2 h-8">{template.description || t('noDescription')}</p>
  196. <div className="grid grid-cols-2 gap-2 mb-2">
  197. <div className="bg-slate-50 rounded-xl p-2 border border-slate-100">
  198. <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('questionCount')}</span>
  199. <span className="text-xs font-bold text-slate-700">{template.questionCount}</span>
  200. </div>
  201. <div className="bg-slate-50 rounded-xl p-2 border border-slate-100 overflow-hidden">
  202. <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('difficultyDistribution')}</span>
  203. <span className="text-xs font-bold text-slate-700 truncate block">
  204. {renderDifficulty(template.difficultyDistribution)}
  205. </span>
  206. </div>
  207. </div>
  208. <div className="bg-indigo-50/30 rounded-xl p-2 border border-indigo-100/50 mb-4 flex items-center gap-2">
  209. <Brain size={12} className="text-indigo-500" />
  210. <span className="text-[10px] font-bold text-indigo-700 truncate">
  211. {template.knowledgeGroup?.name || t('selectKnowledgeGroup')}
  212. </span>
  213. </div>
  214. <div className="flex flex-wrap gap-1.5 pt-4 border-t border-slate-50">
  215. {Array.isArray(template.keywords) && template.keywords.map((kw, i) => (
  216. <span key={i} className="px-2 py-0.5 bg-indigo-50 text-indigo-600 text-[10px] font-bold rounded-full border border-indigo-100/50">
  217. {kw}
  218. </span>
  219. ))}
  220. {(!template.keywords || template.keywords.length === 0) && <span className="text-[10px] text-slate-400 italic">No keywords</span>}
  221. </div>
  222. </motion.div>
  223. ))}
  224. </div>
  225. )}
  226. {createPortal(
  227. <AnimatePresence>
  228. {showModal && (
  229. <div key="assessment-template-modal" className="fixed inset-0 z-[1000] flex items-center justify-center p-4">
  230. <motion.div
  231. initial={{ opacity: 0 }}
  232. animate={{ opacity: 1 }}
  233. exit={{ opacity: 0 }}
  234. onClick={() => setShowModal(false)}
  235. className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
  236. />
  237. <motion.div
  238. initial={{ opacity: 0, scale: 0.9, y: 20 }}
  239. animate={{ opacity: 1, scale: 1, y: 0 }}
  240. exit={{ opacity: 0, scale: 0.9, y: 20 }}
  241. className="w-full max-w-xl bg-white rounded-[2.5rem] shadow-2xl relative z-10 overflow-hidden"
  242. >
  243. <div className="p-8 pb-4 flex items-center justify-between border-b border-slate-100">
  244. <div className="flex items-center gap-3">
  245. <div className="w-12 h-12 bg-indigo-50 text-indigo-600 rounded-2xl flex items-center justify-center">
  246. {editingTemplate ? <Edit2 size={24} /> : <Plus size={24} />}
  247. </div>
  248. <h3 className="text-xl font-black text-slate-900">
  249. {editingTemplate ? t('editTemplate') : t('createTemplate')}
  250. </h3>
  251. </div>
  252. <button onClick={() => setShowModal(false)} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all">
  253. <X size={20} />
  254. </button>
  255. </div>
  256. <form onSubmit={handleSave} className="p-8 space-y-5">
  257. <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
  258. <div className="space-y-1.5 md:col-span-2">
  259. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
  260. <Type size={12} className="text-indigo-500" />
  261. {t('templateName')} *
  262. </label>
  263. <input
  264. required
  265. className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all placeholder:text-slate-300"
  266. value={formData.name}
  267. onChange={e => setFormData({ ...formData, name: e.target.value })}
  268. placeholder="e.g. Senior Frontend Engineer Technical Interview"
  269. />
  270. </div>
  271. <div className="space-y-1.5 md:col-span-2">
  272. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
  273. <Sparkles size={12} className="text-indigo-500" />
  274. {t('keywords')} ({t('keywordsHint')})
  275. </label>
  276. <input
  277. className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all placeholder:text-slate-300"
  278. value={formData.keywords}
  279. onChange={e => setFormData({ ...formData, keywords: e.target.value })}
  280. placeholder={t('keywordsHint')}
  281. />
  282. </div>
  283. <div className="space-y-1.5">
  284. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
  285. <Hash size={12} className="text-indigo-500" />
  286. {t('questionCount')}
  287. </label>
  288. <input
  289. type="number"
  290. className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
  291. value={formData.questionCount}
  292. onChange={e => setFormData({ ...formData, questionCount: parseInt(e.target.value) })}
  293. />
  294. </div>
  295. <div className="space-y-1.5">
  296. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
  297. <Sliders size={12} className="text-indigo-500" />
  298. {t('difficultyDistribution')}
  299. </label>
  300. <input
  301. className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
  302. value={formData.difficultyDistribution}
  303. onChange={e => setFormData({ ...formData, difficultyDistribution: e.target.value })}
  304. placeholder='{"Basic": 3, "Inter": 4, "Adv": 3}'
  305. />
  306. </div>
  307. <div className="space-y-1.5 md:col-span-2">
  308. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
  309. <Sliders size={12} className="text-indigo-500" />
  310. {t('selectKnowledgeGroup')} *
  311. </label>
  312. <select
  313. required
  314. className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all appearance-none cursor-pointer"
  315. value={formData.knowledgeGroupId}
  316. onChange={e => setFormData({ ...formData, knowledgeGroupId: e.target.value })}
  317. >
  318. <option value="" disabled>{t('selectKnowledgeGroup')}</option>
  319. {groups.map(group => (
  320. <option key={group.id} value={group.id}>{group.name}</option>
  321. ))}
  322. </select>
  323. </div>
  324. <div className="space-y-1.5 md:col-span-2">
  325. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
  326. <Sliders size={12} className="text-indigo-500" />
  327. {t('style')}
  328. </label>
  329. <input
  330. className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
  331. value={formData.style}
  332. onChange={e => setFormData({ ...formData, style: e.target.value })}
  333. />
  334. </div>
  335. </div>
  336. <div className="flex justify-end gap-3 pt-4">
  337. <button
  338. type="button"
  339. onClick={() => setShowModal(false)}
  340. className="px-6 py-4 text-sm font-black text-slate-500 hover:text-slate-700 transition-colors"
  341. >
  342. {t('mmCancel')}
  343. </button>
  344. <button
  345. type="submit"
  346. disabled={isSaving}
  347. className="px-10 py-4 bg-indigo-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-indigo-100 hover:bg-indigo-700 transition-all active:scale-95 flex items-center gap-2"
  348. >
  349. {isSaving && <Loader2 size={16} className="animate-spin" />}
  350. {t('save')}
  351. </button>
  352. </div>
  353. </form>
  354. </motion.div>
  355. </div>
  356. )}
  357. </AnimatePresence>,
  358. document.body
  359. )}
  360. </div>
  361. );
  362. };