536 lines
33 KiB
TypeScript
536 lines
33 KiB
TypeScript
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, AssessmentDimension } 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: '',
|
|
passingScore: 6,
|
|
totalTimeLimit: 1800,
|
|
perQuestionTimeLimit: 300,
|
|
});
|
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
const [dimensions, setDimensions] = useState<AssessmentDimension[]>([]);
|
|
|
|
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 || '',
|
|
passingScore: template.passingScore !== null && template.passingScore !== undefined ? template.passingScore / 10 : 6,
|
|
totalTimeLimit: template.totalTimeLimit ?? 1800,
|
|
perQuestionTimeLimit: template.perQuestionTimeLimit ?? 300,
|
|
});
|
|
setDimensions(template.dimensions || []);
|
|
} else {
|
|
setEditingTemplate(null);
|
|
setFormData({
|
|
name: '',
|
|
description: '',
|
|
keywords: '',
|
|
questionCount: 5,
|
|
difficultyDistribution: '{"Basic": 3, "Intermediate": 4, "Advanced": 3}',
|
|
style: 'Professional',
|
|
knowledgeGroupId: '',
|
|
passingScore: 6,
|
|
totalTimeLimit: 1800,
|
|
perQuestionTimeLimit: 300,
|
|
});
|
|
setDimensions([]);
|
|
}
|
|
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;
|
|
if (typeof diffDist === 'string' && diffDist.trim().startsWith('{')) {
|
|
try { diffDist = JSON.parse(diffDist); } catch (e) { diffDist = undefined; }
|
|
}
|
|
if (typeof diffDist !== 'object' || diffDist === null) diffDist = undefined;
|
|
|
|
const payload: CreateTemplateData = {
|
|
name: formData.name,
|
|
description: formData.description,
|
|
keywords: keywordsArray,
|
|
questionCount: formData.questionCount,
|
|
difficultyDistribution: diffDist,
|
|
style: formData.style,
|
|
knowledgeGroupId: formData.knowledgeGroupId || undefined,
|
|
dimensions: dimensions.length > 0 ? dimensions : undefined,
|
|
passingScore: formData.passingScore * 10,
|
|
totalTimeLimit: formData.totalTimeLimit,
|
|
perQuestionTimeLimit: formData.perQuestionTimeLimit,
|
|
};
|
|
|
|
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: any) {
|
|
console.error('Save failed:', error);
|
|
const msg = error?.message;
|
|
showError(msg && msg !== 'Request failed' ? msg : 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 handleDimensionChange = (index: number, field: 'name' | 'label' | 'weight', value: string | number) => {
|
|
const updated = [...dimensions];
|
|
updated[index] = { ...updated[index], [field]: value };
|
|
setDimensions(updated);
|
|
};
|
|
|
|
const handleAddDimension = () => {
|
|
setDimensions([...dimensions, { name: '', label: '', weight: 1 }]);
|
|
};
|
|
|
|
const handleRemoveDimension = (index: number) => {
|
|
setDimensions(dimensions.filter((_, i) => i !== index));
|
|
};
|
|
|
|
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>
|
|
|
|
{Array.isArray(template.dimensions) && template.dimensions.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
|
{template.dimensions.map((dim, i) => (
|
|
<span key={i} className="px-2 py-0.5 bg-amber-50 text-amber-700 text-[10px] font-bold rounded-full border border-amber-100/50">
|
|
{dim.label} ({dim.weight}%)
|
|
</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 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" />通过分 (0-10)
|
|
</label>
|
|
<input type="number" min="0" max="10" step="0.5"
|
|
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.passingScore}
|
|
onChange={e => setFormData({ ...formData, passingScore: parseFloat(e.target.value) || 0 })}
|
|
/>
|
|
</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" />总时间限制 (秒)
|
|
</label>
|
|
<input type="number" min="60" max="86400" step="60"
|
|
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.totalTimeLimit}
|
|
onChange={e => setFormData({ ...formData, totalTimeLimit: parseInt(e.target.value) || 1800 })}
|
|
/>
|
|
</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" />单题时间限制 (秒)
|
|
</label>
|
|
<input type="number" min="30" max="3600" step="30"
|
|
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.perQuestionTimeLimit}
|
|
onChange={e => setFormData({ ...formData, perQuestionTimeLimit: parseInt(e.target.value) || 300 })}
|
|
/>
|
|
</div>
|
|
</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('templateDimensions')} *
|
|
</label>
|
|
<div className="space-y-2">
|
|
{dimensions.length === 0 && (
|
|
<p className="text-xs text-slate-400 italic px-3">{t('mmEmpty')}</p>
|
|
)}
|
|
{dimensions.map((dim, index) => (
|
|
<div key={index} className="flex gap-2 items-center">
|
|
<input
|
|
className="w-1/3 px-4 py-3 bg-slate-50 border border-slate-200 rounded-[1rem] 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={dim.name}
|
|
onChange={e => handleDimensionChange(index, 'name', e.target.value)}
|
|
placeholder={t('dimensionName')}
|
|
/>
|
|
<input
|
|
className="w-1/3 px-4 py-3 bg-slate-50 border border-slate-200 rounded-[1rem] 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={dim.label}
|
|
onChange={e => handleDimensionChange(index, 'label', e.target.value)}
|
|
placeholder={t('dimensionLabel')}
|
|
/>
|
|
<input
|
|
type="number"
|
|
className="w-20 px-4 py-3 bg-slate-50 border border-slate-200 rounded-[1rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
|
|
value={dim.weight}
|
|
onChange={e => handleDimensionChange(index, 'weight', parseInt(e.target.value) || 0)}
|
|
min={0}
|
|
max={100}
|
|
placeholder={t('dimensionWeight')}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveDimension(index)}
|
|
className="p-2 text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all flex-shrink-0"
|
|
title={t('removeDimension')}
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={handleAddDimension}
|
|
className="text-xs font-bold text-indigo-600 hover:text-indigo-800 transition-colors px-1"
|
|
>
|
|
+ {t('addDimension')}
|
|
</button>
|
|
</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>
|
|
);
|
|
};
|