AssessmentTemplateManager.tsx 24 KB

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