feat: implement QuestionBank CRUD with pagination and template query
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
@@ -0,0 +1,554 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, FolderInput, ArrowRight, Info, Layers, Clock, Upload, Calendar } from 'lucide-react';
|
||||
import { isExtensionAllowed } from '../constants/fileSupport';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { ModelConfig, ModelType, IndexingConfig, KnowledgeGroup } from '../types';
|
||||
import { modelConfigService } from '../services/modelConfigService';
|
||||
import { knowledgeGroupService } from '../services/knowledgeGroupService';
|
||||
import { apiClient } from '../services/apiClient';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import IndexingModalWithMode from './IndexingModalWithMode';
|
||||
|
||||
interface ImportFolderDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
authToken: string;
|
||||
initialGroupId?: string;
|
||||
initialGroupName?: string;
|
||||
onImportSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface FileWithPath {
|
||||
file: File;
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
type ImportMode = 'immediate' | 'scheduled';
|
||||
|
||||
export const ImportFolderDrawer: React.FC<ImportFolderDrawerProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
authToken,
|
||||
initialGroupId,
|
||||
initialGroupName,
|
||||
onImportSuccess,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { showError, showSuccess } = useToast();
|
||||
|
||||
// Tab
|
||||
const [importMode, setImportMode] = useState<ImportMode>('immediate');
|
||||
|
||||
// Immediate mode state
|
||||
const [localFiles, setLocalFiles] = useState<FileWithPath[]>([]);
|
||||
const [folderName, setFolderName] = useState('');
|
||||
const [targetName, setTargetName] = useState('');
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [isIndexingConfigOpen, setIsIndexingConfigOpen] = useState(false);
|
||||
const [models, setModels] = useState<ModelConfig[]>([]);
|
||||
|
||||
// Scheduled mode state
|
||||
const [serverPath, setServerPath] = useState('');
|
||||
const [scheduledTime, setScheduledTime] = useState(() => {
|
||||
// Default to 30 min from now
|
||||
const d = new Date();
|
||||
d.setMinutes(d.getMinutes() + 30);
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
||||
return d.toISOString().slice(0, 16); // "YYYY-MM-DDTHH:mm"
|
||||
});
|
||||
const [schedTargetName, setSchedTargetName] = useState('');
|
||||
const [schedUseHierarchy, setSchedUseHierarchy] = useState(false);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [allGroups, setAllGroups] = useState<KnowledgeGroup[]>([]);
|
||||
const [parentGroupId, setParentGroupId] = useState<string>('');
|
||||
const [schedParentGroupId, setSchedParentGroupId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalFiles([]);
|
||||
setFolderName('');
|
||||
setTargetName(initialGroupName || '');
|
||||
setUseHierarchy(false);
|
||||
setIsIndexingConfigOpen(false);
|
||||
setImportMode('immediate');
|
||||
setServerPath('');
|
||||
setSchedTargetName(initialGroupName || '');
|
||||
setSchedUseHierarchy(false);
|
||||
setParentGroupId('');
|
||||
setSchedParentGroupId('');
|
||||
|
||||
// Default scheduled time = 30min from now
|
||||
const d = new Date();
|
||||
d.setMinutes(d.getMinutes() + 30);
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
||||
setScheduledTime(d.toISOString().slice(0, 16));
|
||||
|
||||
modelConfigService.getAll(authToken).then(res => {
|
||||
setModels(res.filter(m => m.type === ModelType.EMBEDDING));
|
||||
});
|
||||
|
||||
knowledgeGroupService.getGroups().then(groups => {
|
||||
const flat: any[] = [];
|
||||
function walk(items: any[], depth = 0) {
|
||||
for (const g of items) {
|
||||
flat.push({ ...g, d: depth });
|
||||
if (g.children?.length) walk(g.children, depth + 1);
|
||||
}
|
||||
}
|
||||
walk(groups);
|
||||
setAllGroups(flat);
|
||||
});
|
||||
}
|
||||
}, [isOpen, authToken, initialGroupName]);
|
||||
|
||||
// ---- Immediate mode handlers ----
|
||||
const handleLocalFolderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const allFiles = Array.from(e.target.files);
|
||||
const files: FileWithPath[] = allFiles
|
||||
.filter(file => {
|
||||
const ext = file.name.split('.').pop() || '';
|
||||
return isExtensionAllowed(ext, 'group');
|
||||
})
|
||||
.map(file => ({
|
||||
file,
|
||||
relativePath: file.webkitRelativePath || file.name,
|
||||
}));
|
||||
|
||||
if (files.length === 0 && allFiles.length > 0) {
|
||||
showError(t('noFilesFound'));
|
||||
return;
|
||||
}
|
||||
setLocalFiles(files);
|
||||
|
||||
const firstPath = allFiles[0].webkitRelativePath;
|
||||
if (firstPath) {
|
||||
const parts = firstPath.split('/');
|
||||
if (parts.length > 0) {
|
||||
const name = parts[0];
|
||||
setFolderName(name);
|
||||
if (!initialGroupId && !targetName) setTargetName(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleImmediateNext = () => {
|
||||
if (localFiles.length === 0) {
|
||||
showError(t('clickToSelectFolder'));
|
||||
return;
|
||||
}
|
||||
if (!initialGroupId && !targetName) {
|
||||
showError(t('fillTargetName'));
|
||||
return;
|
||||
}
|
||||
setIsIndexingConfigOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmConfig = async (config: IndexingConfig) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { uploadService } = await import('../services/uploadService');
|
||||
|
||||
if (useHierarchy) {
|
||||
// Step 1: Determine root group
|
||||
let rootGroupId = initialGroupId ?? null;
|
||||
if (!rootGroupId) {
|
||||
const newGroup = await knowledgeGroupService.createGroup({
|
||||
name: targetName,
|
||||
description: t('importedFromLocalFolder').replace('$1', folderName),
|
||||
parentId: parentGroupId || null,
|
||||
});
|
||||
rootGroupId = newGroup.id;
|
||||
}
|
||||
|
||||
// Step 2: Collect all unique directory paths
|
||||
const dirSet = new Set<string>();
|
||||
for (const { relativePath } of localFiles) {
|
||||
const parts = relativePath.split('/');
|
||||
const dirParts = initialGroupId
|
||||
? parts.slice(1, parts.length - 1)
|
||||
: parts.slice(0, parts.length - 1);
|
||||
for (let i = 1; i <= dirParts.length; i++) {
|
||||
dirSet.add(dirParts.slice(0, i).join('/'));
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Sort by depth, create groups sequentially
|
||||
const sortedDirs = Array.from(dirSet).sort((a, b) =>
|
||||
a.split('/').length - b.split('/').length
|
||||
);
|
||||
const dirToGroupId = new Map<string, string>();
|
||||
dirToGroupId.set(initialGroupId ? '' : folderName, rootGroupId);
|
||||
|
||||
for (const dirPath of sortedDirs) {
|
||||
if (!dirPath || dirToGroupId.has(dirPath)) continue;
|
||||
const segments = dirPath.split('/');
|
||||
const segName = segments[segments.length - 1];
|
||||
const parentPath = segments.slice(0, segments.length - 1).join('/');
|
||||
const parentId = dirToGroupId.get(parentPath) ?? rootGroupId;
|
||||
const newGroup = await knowledgeGroupService.createGroup({ name: segName, parentId });
|
||||
dirToGroupId.set(dirPath, newGroup.id);
|
||||
}
|
||||
|
||||
// Step 4: Upload files in parallel batches
|
||||
const BATCH_SIZE = 3;
|
||||
for (let i = 0; i < localFiles.length; i += BATCH_SIZE) {
|
||||
const batch = localFiles.slice(i, i + BATCH_SIZE);
|
||||
await Promise.all(batch.map(async ({ file, relativePath }) => {
|
||||
try {
|
||||
const parts = relativePath.split('/');
|
||||
const dirParts = initialGroupId
|
||||
? parts.slice(1, parts.length - 1)
|
||||
: parts.slice(0, parts.length - 1);
|
||||
const fileDirPath = dirParts.join('/');
|
||||
const targetGroupId = dirToGroupId.get(fileDirPath) ?? rootGroupId!;
|
||||
const uploadedKb = await uploadService.uploadFileWithConfig(file, config, authToken);
|
||||
await knowledgeGroupService.addFileToGroups(uploadedKb.id, [targetGroupId]);
|
||||
} catch (err) {
|
||||
console.error(`Failed to upload ${file.name}:`, err);
|
||||
}
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// Single-group mode
|
||||
let groupId = initialGroupId ?? null;
|
||||
if (!groupId) {
|
||||
const newGroup = await knowledgeGroupService.createGroup({
|
||||
name: targetName,
|
||||
description: t('importedFromLocalFolder').replace('$1', folderName),
|
||||
});
|
||||
groupId = newGroup.id;
|
||||
}
|
||||
const BATCH_SIZE = 3;
|
||||
for (let i = 0; i < localFiles.length; i += BATCH_SIZE) {
|
||||
const batch = localFiles.slice(i, i + BATCH_SIZE);
|
||||
await Promise.all(batch.map(async ({ file }) => {
|
||||
try {
|
||||
const uploadedKb = await uploadService.uploadFileWithConfig(file, config, authToken);
|
||||
if (groupId) await knowledgeGroupService.addFileToGroups(uploadedKb.id, [groupId]);
|
||||
} catch (err) {
|
||||
console.error(`Failed to upload ${file.name}:`, err);
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
showSuccess(t('importComplete'));
|
||||
onImportSuccess?.();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
showError(t('submitFailed', error.message));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsIndexingConfigOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Scheduled mode handler ----
|
||||
const handleScheduledSubmit = async () => {
|
||||
if (!serverPath.trim()) {
|
||||
showError(t('fillServerPath'));
|
||||
return;
|
||||
}
|
||||
if (!initialGroupId && !schedTargetName.trim()) {
|
||||
showError(t('fillTargetName'));
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledAt = new Date(scheduledTime);
|
||||
if (isNaN(scheduledAt.getTime())) {
|
||||
showError(t('invalidDateTime' as any));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
let finalGroupId = initialGroupId || undefined;
|
||||
if (!finalGroupId && schedTargetName.trim()) {
|
||||
const newGroup = await knowledgeGroupService.createGroup({
|
||||
name: schedTargetName.trim(),
|
||||
description: t('importedFromLocalFolder').replace('$1', schedTargetName.trim()),
|
||||
parentId: schedParentGroupId || null,
|
||||
});
|
||||
finalGroupId = newGroup.id;
|
||||
}
|
||||
|
||||
const defaultModel = models[0];
|
||||
await apiClient.post('/import-tasks', {
|
||||
sourcePath: serverPath.trim(),
|
||||
targetGroupId: finalGroupId,
|
||||
targetGroupName: undefined,
|
||||
embeddingModelId: defaultModel?.id,
|
||||
scheduledAt: scheduledAt.toISOString(),
|
||||
chunkSize: 500,
|
||||
chunkOverlap: 50,
|
||||
mode: 'fast',
|
||||
useHierarchy: schedUseHierarchy,
|
||||
});
|
||||
|
||||
showSuccess(t('scheduleTaskCreated'));
|
||||
onImportSuccess?.();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
showError(t('submitFailed', error.message));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 flex justify-end">
|
||||
<div className="absolute inset-0 bg-black/20 backdrop-blur-sm transition-opacity" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-md bg-white shadow-2xl flex flex-col h-full animate-in slide-in-from-right duration-300">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between shrink-0 bg-white">
|
||||
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||
<FolderInput className="w-5 h-5 text-blue-600" />
|
||||
{t('importFolderTitle')}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-2 -mr-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-full transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mode Tabs */}
|
||||
<div className="flex border-b border-slate-100 shrink-0">
|
||||
<button
|
||||
onClick={() => setImportMode('immediate')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors border-b-2 ${importMode === 'immediate'
|
||||
? 'border-blue-600 text-blue-600 bg-blue-50/40'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Upload size={15} />
|
||||
{t('importImmediate')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setImportMode('scheduled')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors border-b-2 ${importMode === 'scheduled'
|
||||
? 'border-blue-600 text-blue-600 bg-blue-50/40'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Clock size={15} />
|
||||
{t('importScheduled')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||
{importMode === 'immediate' ? (
|
||||
<>
|
||||
{/* Immediate: folder picker */}
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-slate-200 rounded-xl p-8 flex flex-col items-center justify-center gap-3 cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-all group"
|
||||
>
|
||||
<div className="w-12 h-12 bg-slate-50 text-slate-400 rounded-full flex items-center justify-center group-hover:bg-blue-100 group-hover:text-blue-600 transition-colors">
|
||||
<FolderInput size={24} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{localFiles.length > 0
|
||||
? t('selectedFilesCount').replace('$1', localFiles.length.toString())
|
||||
: t('clickToSelectFolder')}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{localFiles.length > 0 ? folderName : t('selectFolderTip')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleLocalFolderChange}
|
||||
className="hidden"
|
||||
multiple
|
||||
// @ts-ignore
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Target group */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-slate-700">{t('lblTargetGroup')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={targetName}
|
||||
onChange={e => setTargetName(e.target.value)}
|
||||
disabled={!!initialGroupId}
|
||||
placeholder={t('placeholderNewGroup')}
|
||||
className={`w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none ${initialGroupId ? 'bg-slate-50 text-slate-500' : ''}`}
|
||||
/>
|
||||
{initialGroupId && <p className="text-xs text-slate-400">{t('importToCurrentGroup')}</p>}
|
||||
</div>
|
||||
{!initialGroupId && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-slate-700">{t('parentCategory') || 'Parent Category'}</label>
|
||||
<select
|
||||
value={parentGroupId}
|
||||
onChange={e => setParentGroupId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
>
|
||||
<option value="">{t('allGroups' as any) || '-- Root --'}</option>
|
||||
{allGroups.map((g: any) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{'\u00A0'.repeat(g.d * 4)}{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hierarchy toggle */}
|
||||
<HierarchyToggle value={useHierarchy} onChange={setUseHierarchy} t={t} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Scheduled: server path */}
|
||||
<div className="bg-amber-50 border border-amber-100 rounded-lg p-3 text-sm text-amber-800 flex items-start gap-2">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<p className="text-xs">{t('scheduledImportTip')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-slate-700">{t('lblServerPath')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={serverPath}
|
||||
onChange={e => setServerPath(e.target.value)}
|
||||
placeholder={t('placeholderServerPath')}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Target group */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-slate-700">{t('lblTargetGroup')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={schedTargetName}
|
||||
onChange={e => setSchedTargetName(e.target.value)}
|
||||
disabled={!!initialGroupId}
|
||||
placeholder={t('placeholderNewGroup')}
|
||||
className={`w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none ${initialGroupId ? 'bg-slate-50 text-slate-500' : ''}`}
|
||||
/>
|
||||
{initialGroupId && <p className="text-xs text-slate-400">{t('importToCurrentGroup')}</p>}
|
||||
</div>
|
||||
{!initialGroupId && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-slate-700">{t('parentCategory') || 'Parent Category'}</label>
|
||||
<select
|
||||
value={schedParentGroupId}
|
||||
onChange={e => setSchedParentGroupId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
>
|
||||
<option value="">{t('allGroups' as any) || '-- Root --'}</option>
|
||||
{allGroups.map((g: any) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{'\u00A0'.repeat(g.d * 4)}{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scheduled datetime */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-slate-700 flex items-center gap-1.5">
|
||||
<Calendar size={14} className="text-blue-500" />
|
||||
{t('lblScheduledTime')}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduledTime}
|
||||
onChange={e => setScheduledTime(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
<p className="text-xs text-slate-400">{t('scheduledTimeHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Hierarchy toggle */}
|
||||
<HierarchyToggle value={schedUseHierarchy} onChange={setSchedUseHierarchy} t={t} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-slate-100 shrink-0 flex gap-3 bg-slate-50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
{importMode === 'immediate' ? (
|
||||
<button
|
||||
onClick={handleImmediateNext}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-sm flex justify-center items-center gap-2 transition-all"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span>{isLoading ? t('uploading') : t('nextStep')}</span>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleScheduledSubmit}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-sm flex justify-center items-center gap-2 transition-all"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Clock size={16} />
|
||||
<span>{isLoading ? t('uploading') : t('scheduleImport')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indexing Config Modal (immediate mode only) */}
|
||||
<IndexingModalWithMode
|
||||
isOpen={isIndexingConfigOpen}
|
||||
onClose={() => setIsIndexingConfigOpen(false)}
|
||||
files={[]}
|
||||
embeddingModels={models}
|
||||
defaultEmbeddingId={models.length > 0 ? models[0].id : ''}
|
||||
onConfirm={handleConfirmConfig}
|
||||
isReconfiguring={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** Reusable hierarchy toggle */
|
||||
const HierarchyToggle: React.FC<{
|
||||
value: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
t: (key: string) => string;
|
||||
}> = ({ value, onChange, t }) => (
|
||||
<label className="flex items-center gap-3 cursor-pointer select-none">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`w-10 h-5 rounded-full transition-colors ${value ? 'bg-blue-600' : 'bg-slate-200'}`} />
|
||||
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${value ? 'translate-x-5' : ''}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 flex items-center gap-1.5">
|
||||
<Layers size={14} className="text-blue-500" />
|
||||
{t('useHierarchyImport')}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('useHierarchyImportDesc')}</p>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
Reference in New Issue
Block a user