| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414 |
- import React, { useState, useEffect } from 'react';
- import { createPortal } from 'react-dom';
- import { Plus, Edit2, Trash2, FileText, Loader2, X, Sparkles, Sliders, Hash, Type, Brain, Copy, Check } from 'lucide-react';
- import { motion, AnimatePresence } from 'framer-motion';
- import { useLanguage } from '../../contexts/LanguageContext';
- import { useToast } from '../../contexts/ToastContext';
- import { useConfirm } from '../../contexts/ConfirmContext';
- import { templateService } from '../../services/templateService';
- import { knowledgeGroupService } from '../../services/knowledgeGroupService';
- import { AssessmentTemplate, CreateTemplateData, UpdateTemplateData, KnowledgeGroup } from '../../types';
- export const AssessmentTemplateManager: React.FC = () => {
- const { t } = useLanguage();
- const { showSuccess, showError } = useToast();
- const { confirm } = useConfirm();
- const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
- const [groups, setGroups] = useState<KnowledgeGroup[]>([]);
- const [isLoading, setIsLoading] = useState(false);
- const [isSaving, setIsSaving] = useState(false);
- const [showModal, setShowModal] = useState(false);
- const [editingTemplate, setEditingTemplate] = useState<AssessmentTemplate | null>(null);
-
- // UI state uses strings for easy input
- const [formData, setFormData] = useState({
- name: '',
- description: '',
- keywords: '',
- questionCount: 5,
- difficultyDistribution: 'Basic: 30%, Intermediate: 40%, Advanced: 30%',
- style: 'Professional',
- knowledgeGroupId: '',
- });
- const [copiedId, setCopiedId] = useState<string | null>(null);
- const fetchTemplates = async () => {
- setIsLoading(true);
- try {
- const data = await templateService.getAll();
- setTemplates(data);
- } catch (error) {
- console.error('Failed to fetch templates:', error);
- showError(t('actionFailed'));
- } finally {
- setIsLoading(false);
- }
- };
- const fetchGroups = async () => {
- try {
- const data = await knowledgeGroupService.getGroups();
- setGroups(data);
- } catch (error) {
- console.error('Failed to fetch groups:', error);
- }
- };
- useEffect(() => {
- fetchTemplates();
- fetchGroups();
- }, []);
- const handleOpenModal = (template?: AssessmentTemplate) => {
- if (template) {
- setEditingTemplate(template);
- setFormData({
- name: template.name,
- description: template.description || '',
- keywords: Array.isArray(template.keywords) ? template.keywords.join(', ') : '',
- questionCount: template.questionCount,
- difficultyDistribution: typeof template.difficultyDistribution === 'object'
- ? JSON.stringify(template.difficultyDistribution)
- : (template.difficultyDistribution || ''),
- style: template.style || 'Professional',
- knowledgeGroupId: template.knowledgeGroupId || '',
- });
- } else {
- setEditingTemplate(null);
- setFormData({
- name: '',
- description: '',
- keywords: '',
- questionCount: 5,
- difficultyDistribution: '{"Basic": 3, "Intermediate": 4, "Advanced": 3}',
- style: 'Professional',
- knowledgeGroupId: '',
- });
- }
- setShowModal(true);
- };
- const handleSave = async (e: React.FormEvent) => {
- e.preventDefault();
- setIsSaving(true);
- try {
- // Convert UI strings back to required types
- const keywordsArray = formData.keywords.split(',').map(k => k.trim()).filter(k => k !== '');
- let diffDist: any = formData.difficultyDistribution;
- try {
- if (formData.difficultyDistribution.startsWith('{')) {
- diffDist = JSON.parse(formData.difficultyDistribution);
- }
- } catch (e) {
- // Keep as string if parsing fails
- }
- const payload: CreateTemplateData = {
- name: formData.name,
- description: formData.description,
- keywords: keywordsArray,
- questionCount: formData.questionCount,
- difficultyDistribution: diffDist,
- style: formData.style,
- knowledgeGroupId: formData.knowledgeGroupId || undefined,
- };
- if (editingTemplate) {
- await templateService.update(editingTemplate.id, payload as UpdateTemplateData);
- showSuccess(t('featureUpdated'));
- } else {
- await templateService.create(payload);
- showSuccess(t('confirm'));
- }
- setShowModal(false);
- fetchTemplates();
- } catch (error) {
- console.error('Save failed:', error);
- showError(t('actionFailed'));
- } finally {
- setIsSaving(false);
- }
- };
- const handleCopyId = async (id: string) => {
- try {
- await navigator.clipboard.writeText(id);
- setCopiedId(id);
- showSuccess(t('copySuccess') || 'ID copied to clipboard');
- setTimeout(() => setCopiedId(null), 2000);
- } catch (err) {
- showError(t('actionFailed'));
- }
- };
- const handleDelete = async (id: string) => {
- if (!(await confirm(t('confirmTitle')))) return;
- try {
- await templateService.delete(id);
- showSuccess(t('confirm'));
- fetchTemplates();
- } catch (error) {
- showError(t('actionFailed'));
- }
- };
- const renderDifficulty = (dist: any) => {
- if (typeof dist === 'string') return dist;
- if (typeof dist === 'object' && dist !== null) {
- return Object.entries(dist).map(([k, v]) => `${k}: ${v}`).join(', ');
- }
- return '';
- };
- return (
- <div className="space-y-6">
- <div className="flex items-center justify-between mb-2">
- <div className="flex items-center gap-3">
- <div className="w-10 h-10 bg-indigo-50 text-indigo-600 rounded-xl flex items-center justify-center">
- <FileText size={22} />
- </div>
- <div>
- <h3 className="text-lg font-bold text-slate-900">{t('assessmentTemplates')}</h3>
- <p className="text-xs text-slate-500">{t('assessmentTemplatesSubtitle')}</p>
- </div>
- </div>
- <button
- onClick={() => handleOpenModal()}
- 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"
- >
- <Plus size={18} />
- {t('createTemplate')}
- </button>
- </div>
- {isLoading ? (
- <div className="flex items-center justify-center py-20">
- <Loader2 className="w-8 h-8 animate-spin text-indigo-600 opacity-20" />
- </div>
- ) : templates.length === 0 ? (
- <div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-16 text-center">
- <FileText className="w-12 h-12 text-slate-200 mx-auto mb-4" />
- <p className="text-slate-400 font-bold uppercase tracking-widest text-xs">{t('mmEmpty')}</p>
- </div>
- ) : (
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
- {templates.map((template) => (
- <motion.div
- key={template.id}
- initial={{ opacity: 0, scale: 0.95 }}
- animate={{ opacity: 1, scale: 1 }}
- className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden"
- >
- <div className="absolute top-0 right-0 w-24 h-24 bg-indigo-500/5 rounded-full blur-3xl -mr-12 -mt-12" />
-
- <div className="flex justify-between items-start mb-4 relative z-10">
- <h4 className="text-base font-black text-slate-900 truncate pr-8">{template.name}</h4>
- <div className="flex gap-1">
- <button
- onClick={() => handleOpenModal(template)}
- className="p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-all"
- >
- <Edit2 size={14} />
- </button>
- <button
- onClick={() => handleDelete(template.id)}
- className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all"
- >
- <Trash2 size={14} />
- </button>
- </div>
- </div>
- <p className="text-xs text-slate-500 mb-4 line-clamp-2 h-8">{template.description || t('noDescription')}</p>
- <div className="grid grid-cols-2 gap-2 mb-2">
- <div className="bg-slate-50 rounded-xl p-2 border border-slate-100">
- <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('questionCount')}</span>
- <span className="text-xs font-bold text-slate-700">{template.questionCount}</span>
- </div>
- <div className="bg-slate-50 rounded-xl p-2 border border-slate-100 overflow-hidden">
- <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('difficultyDistribution')}</span>
- <span className="text-xs font-bold text-slate-700 truncate block">
- {renderDifficulty(template.difficultyDistribution)}
- </span>
- </div>
- </div>
- <div className="bg-slate-50 rounded-xl p-2 border border-slate-100 mb-2 flex items-center justify-between group/id">
- <div className="flex flex-col min-w-0">
- <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">Template ID</span>
- <span className="text-[10px] font-mono font-medium text-slate-500 truncate">{template.id}</span>
- </div>
- <button
- onClick={() => handleCopyId(template.id)}
- 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"
- title="Copy ID"
- >
- {copiedId === template.id ? <Check size={12} className="text-emerald-500" /> : <Copy size={12} />}
- </button>
- </div>
- <div className="bg-indigo-50/30 rounded-xl p-2 border border-indigo-100/50 mb-4 flex items-center gap-2">
- <Brain size={12} className="text-indigo-500" />
- <span className="text-[10px] font-bold text-indigo-700 truncate">
- {template.knowledgeGroup?.name || t('selectKnowledgeGroup')}
- </span>
- </div>
- <div className="flex flex-wrap gap-1.5 pt-4 border-t border-slate-50">
- {Array.isArray(template.keywords) && template.keywords.map((kw, i) => (
- <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">
- {kw}
- </span>
- ))}
- {(!template.keywords || template.keywords.length === 0) && <span className="text-[10px] text-slate-400 italic">No keywords</span>}
- </div>
- </motion.div>
- ))}
- </div>
- )}
- {createPortal(
- <AnimatePresence>
- {showModal && (
- <div key="assessment-template-modal" className="fixed inset-0 z-[1000] flex items-center justify-center p-4">
- <motion.div
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- exit={{ opacity: 0 }}
- onClick={() => setShowModal(false)}
- className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
- />
- <motion.div
- initial={{ opacity: 0, scale: 0.9, y: 20 }}
- animate={{ opacity: 1, scale: 1, y: 0 }}
- exit={{ opacity: 0, scale: 0.9, y: 20 }}
- className="w-full max-w-xl bg-white rounded-[2.5rem] shadow-2xl relative z-10 overflow-hidden"
- >
- <div className="p-8 pb-4 flex items-center justify-between border-b border-slate-100">
- <div className="flex items-center gap-3">
- <div className="w-12 h-12 bg-indigo-50 text-indigo-600 rounded-2xl flex items-center justify-center">
- {editingTemplate ? <Edit2 size={24} /> : <Plus size={24} />}
- </div>
- <h3 className="text-xl font-black text-slate-900">
- {editingTemplate ? t('editTemplate') : t('createTemplate')}
- </h3>
- </div>
- <button onClick={() => setShowModal(false)} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all">
- <X size={20} />
- </button>
- </div>
- <form onSubmit={handleSave} className="p-8 space-y-5">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
- <div className="space-y-1.5 md:col-span-2">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
- <Type size={12} className="text-indigo-500" />
- {t('templateName')} *
- </label>
- <input
- required
- 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"
- value={formData.name}
- onChange={e => setFormData({ ...formData, name: e.target.value })}
- placeholder="e.g. Senior Frontend Engineer Technical Interview"
- />
- </div>
- <div className="space-y-1.5 md:col-span-2">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
- <Sparkles size={12} className="text-indigo-500" />
- {t('keywords')} ({t('keywordsHint')})
- </label>
- <input
- 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"
- value={formData.keywords}
- onChange={e => setFormData({ ...formData, keywords: e.target.value })}
- placeholder={t('keywordsHint')}
- />
- </div>
- <div className="space-y-1.5">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
- <Hash size={12} className="text-indigo-500" />
- {t('questionCount')}
- </label>
- <input
- type="number"
- 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"
- value={formData.questionCount}
- onChange={e => setFormData({ ...formData, questionCount: parseInt(e.target.value) })}
- />
- </div>
- <div className="space-y-1.5">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
- <Sliders size={12} className="text-indigo-500" />
- {t('difficultyDistribution')}
- </label>
- <input
- 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"
- value={formData.difficultyDistribution}
- onChange={e => setFormData({ ...formData, difficultyDistribution: e.target.value })}
- placeholder='{"Basic": 3, "Inter": 4, "Adv": 3}'
- />
- </div>
- <div className="space-y-1.5 md:col-span-2">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
- <Sliders size={12} className="text-indigo-500" />
- {t('selectKnowledgeGroup')} *
- </label>
- <select
- required
- 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"
- value={formData.knowledgeGroupId}
- onChange={e => setFormData({ ...formData, knowledgeGroupId: e.target.value })}
- >
- <option value="" disabled>{t('selectKnowledgeGroup')}</option>
- {groups.map(group => (
- <option key={group.id} value={group.id}>{group.name}</option>
- ))}
- </select>
- </div>
- <div className="space-y-1.5 md:col-span-2">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
- <Sliders size={12} className="text-indigo-500" />
- {t('style')}
- </label>
- <input
- 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"
- value={formData.style}
- onChange={e => setFormData({ ...formData, style: e.target.value })}
- />
- </div>
- </div>
- <div className="flex justify-end gap-3 pt-4">
- <button
- type="button"
- onClick={() => setShowModal(false)}
- className="px-6 py-4 text-sm font-black text-slate-500 hover:text-slate-700 transition-colors"
- >
- {t('mmCancel')}
- </button>
- <button
- type="submit"
- disabled={isSaving}
- 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"
- >
- {isSaving && <Loader2 size={16} className="animate-spin" />}
- {t('save')}
- </button>
- </div>
- </form>
- </motion.div>
- </div>
- )}
- </AnimatePresence>,
- document.body
- )}
- </div>
- );
- };
|