feat: [interview] 카테고리 계층 구조(대분류/중분류) 지원
- InterviewCategory 모델에 parent/children 관계 추가 - Service: getTree, getProjectTree 루트+children eager loading - Service: createCategory에 parent_id 지원 - Service: cloneMaster 2단계 계층 복제 - Controller: storeCategory validation에 parent_id 추가 - UI: CategorySidebar/DomainSidebar 트리 뷰 렌더링 - UI: findCategory 헬퍼로 트리 내 카테고리 검색
This commit is contained in:
@@ -42,6 +42,7 @@ public function storeCategory(Request $request): JsonResponse
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string',
|
||||
'parent_id' => 'nullable|integer|exists:interview_categories,id',
|
||||
]);
|
||||
|
||||
$category = $this->service->createCategory($validated);
|
||||
|
||||
@@ -14,6 +14,7 @@ class InterviewCategory extends Model
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'interview_project_id',
|
||||
'parent_id',
|
||||
'name',
|
||||
'description',
|
||||
'domain',
|
||||
@@ -29,6 +30,16 @@ class InterviewCategory extends Model
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function parent()
|
||||
{
|
||||
return $this->belongsTo(self::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function children()
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function project()
|
||||
{
|
||||
return $this->belongsTo(InterviewProject::class, 'interview_project_id');
|
||||
|
||||
@@ -21,7 +21,9 @@ class InterviewScenarioService
|
||||
|
||||
public function getCategories()
|
||||
{
|
||||
return InterviewCategory::orderBy('sort_order')
|
||||
return InterviewCategory::whereNull('parent_id')
|
||||
->with(['children' => fn ($q) => $q->orderBy('sort_order')->orderBy('id')])
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
@@ -29,10 +31,19 @@ public function getCategories()
|
||||
public function createCategory(array $data): InterviewCategory
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$maxSort = InterviewCategory::max('sort_order') ?? 0;
|
||||
$parentId = $data['parent_id'] ?? null;
|
||||
|
||||
$query = InterviewCategory::query();
|
||||
if ($parentId) {
|
||||
$query->where('parent_id', $parentId);
|
||||
} else {
|
||||
$query->whereNull('parent_id');
|
||||
}
|
||||
$maxSort = $query->max('sort_order') ?? 0;
|
||||
|
||||
return InterviewCategory::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'parent_id' => $parentId,
|
||||
'name' => $data['name'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'sort_order' => $maxSort + 1,
|
||||
@@ -209,14 +220,15 @@ public function bulkImport(int $categoryId, array $templates): array
|
||||
|
||||
public function getTree()
|
||||
{
|
||||
return InterviewCategory::with([
|
||||
'templates' => function ($q) {
|
||||
$q->orderBy('sort_order')->orderBy('id');
|
||||
$q->with(['questions' => function ($q2) {
|
||||
$q2->orderBy('sort_order')->orderBy('id');
|
||||
}]);
|
||||
},
|
||||
])
|
||||
return InterviewCategory::whereNull('parent_id')
|
||||
->with([
|
||||
'children' => fn ($q) => $q->orderBy('sort_order')->orderBy('id')
|
||||
->with(['templates' => fn ($q2) => $q2->orderBy('sort_order')->orderBy('id')
|
||||
->with(['questions' => fn ($q3) => $q3->orderBy('sort_order')->orderBy('id')]),
|
||||
]),
|
||||
'templates' => fn ($q) => $q->orderBy('sort_order')->orderBy('id')
|
||||
->with(['questions' => fn ($q2) => $q2->orderBy('sort_order')->orderBy('id')]),
|
||||
])
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
@@ -431,58 +443,84 @@ private function cloneMasterQuestionsToProject(InterviewProject $project): void
|
||||
$tenantId = $project->tenant_id;
|
||||
$userId = auth()->id();
|
||||
|
||||
// interview_project_id = NULL이고 domain이 있는 마스터 카테고리
|
||||
$masterCategories = InterviewCategory::withoutGlobalScopes()
|
||||
// 루트 마스터 카테고리 (parent_id=null, project=null)
|
||||
$masterRoots = InterviewCategory::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('interview_project_id')
|
||||
->whereNotNull('domain')
|
||||
->with(['templates.questions'])
|
||||
->whereNull('parent_id')
|
||||
->with(['children.templates.questions', 'templates.questions'])
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
foreach ($masterCategories as $masterCat) {
|
||||
$newCat = InterviewCategory::create([
|
||||
foreach ($masterRoots as $masterRoot) {
|
||||
// 루트 카테고리 복제
|
||||
$newRoot = InterviewCategory::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'interview_project_id' => $project->id,
|
||||
'name' => $masterCat->name,
|
||||
'description' => $masterCat->description,
|
||||
'domain' => $masterCat->domain,
|
||||
'sort_order' => $masterCat->sort_order,
|
||||
'parent_id' => null,
|
||||
'name' => $masterRoot->name,
|
||||
'description' => $masterRoot->description,
|
||||
'domain' => $masterRoot->domain,
|
||||
'sort_order' => $masterRoot->sort_order,
|
||||
'is_active' => true,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
foreach ($masterCat->templates as $masterTpl) {
|
||||
$newTpl = InterviewTemplate::create([
|
||||
// 루트의 직접 templates 복제
|
||||
$this->cloneTemplates($masterRoot->templates, $newRoot->id, $tenantId, $userId);
|
||||
|
||||
// 자식 카테고리 복제
|
||||
foreach ($masterRoot->children as $masterChild) {
|
||||
$newChild = InterviewCategory::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'interview_category_id' => $newCat->id,
|
||||
'name' => $masterTpl->name,
|
||||
'description' => $masterTpl->description,
|
||||
'sort_order' => $masterTpl->sort_order,
|
||||
'interview_project_id' => $project->id,
|
||||
'parent_id' => $newRoot->id,
|
||||
'name' => $masterChild->name,
|
||||
'description' => $masterChild->description,
|
||||
'domain' => $masterChild->domain,
|
||||
'sort_order' => $masterChild->sort_order,
|
||||
'is_active' => true,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
foreach ($masterTpl->questions as $masterQ) {
|
||||
InterviewQuestion::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'interview_template_id' => $newTpl->id,
|
||||
'question_text' => $masterQ->question_text,
|
||||
'question_type' => $masterQ->question_type,
|
||||
'options' => $masterQ->options,
|
||||
'ai_hint' => $masterQ->ai_hint,
|
||||
'expected_format' => $masterQ->expected_format,
|
||||
'depends_on' => $masterQ->depends_on,
|
||||
'domain' => $masterQ->domain,
|
||||
'is_required' => $masterQ->is_required,
|
||||
'sort_order' => $masterQ->sort_order,
|
||||
'is_active' => true,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
}
|
||||
$this->cloneTemplates($masterChild->templates, $newChild->id, $tenantId, $userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function cloneTemplates($templates, int $categoryId, int $tenantId, ?int $userId): void
|
||||
{
|
||||
foreach ($templates as $masterTpl) {
|
||||
$newTpl = InterviewTemplate::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'interview_category_id' => $categoryId,
|
||||
'name' => $masterTpl->name,
|
||||
'description' => $masterTpl->description,
|
||||
'sort_order' => $masterTpl->sort_order,
|
||||
'is_active' => true,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
foreach ($masterTpl->questions as $masterQ) {
|
||||
InterviewQuestion::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'interview_template_id' => $newTpl->id,
|
||||
'question_text' => $masterQ->question_text,
|
||||
'question_type' => $masterQ->question_type,
|
||||
'options' => $masterQ->options,
|
||||
'ai_hint' => $masterQ->ai_hint,
|
||||
'expected_format' => $masterQ->expected_format,
|
||||
'depends_on' => $masterQ->depends_on,
|
||||
'domain' => $masterQ->domain,
|
||||
'is_required' => $masterQ->is_required,
|
||||
'sort_order' => $masterQ->sort_order,
|
||||
'is_active' => true,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -493,13 +531,14 @@ private function cloneMasterQuestionsToProject(InterviewProject $project): void
|
||||
public function getProjectTree(int $projectId)
|
||||
{
|
||||
return InterviewCategory::where('interview_project_id', $projectId)
|
||||
->whereNull('parent_id')
|
||||
->with([
|
||||
'templates' => function ($q) {
|
||||
$q->orderBy('sort_order')->orderBy('id');
|
||||
$q->with(['questions' => function ($q2) {
|
||||
$q2->orderBy('sort_order')->orderBy('id');
|
||||
}]);
|
||||
},
|
||||
'children' => fn ($q) => $q->orderBy('sort_order')->orderBy('id')
|
||||
->with(['templates' => fn ($q2) => $q2->orderBy('sort_order')->orderBy('id')
|
||||
->with(['questions' => fn ($q3) => $q3->orderBy('sort_order')->orderBy('id')]),
|
||||
]),
|
||||
'templates' => fn ($q) => $q->orderBy('sort_order')->orderBy('id')
|
||||
->with(['questions' => fn ($q2) => $q2->orderBy('sort_order')->orderBy('id')]),
|
||||
])
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
|
||||
@@ -133,6 +133,18 @@
|
||||
const IconTable = createIcon('table');
|
||||
const IconCalculator = createIcon('calculator');
|
||||
|
||||
// 트리에서 카테고리 검색 (루트 + children 2단계)
|
||||
const findCategory = (tree, id) => {
|
||||
for (const cat of tree) {
|
||||
if (cat.id === id) return cat;
|
||||
if (cat.children) {
|
||||
const found = cat.children.find(c => c.id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const DOMAIN_LABELS = {
|
||||
product_classification: '제품 분류 체계',
|
||||
bom_structure: 'BOM 구조',
|
||||
@@ -203,7 +215,10 @@ function InterviewScenarioApp() {
|
||||
setTree(data);
|
||||
setCategories(data);
|
||||
if (!selectedCategoryId && data.length > 0) {
|
||||
setSelectedCategoryId(data[0].id);
|
||||
// 첫 번째 루트의 첫 번째 자식 선택, 없으면 루트
|
||||
const first = data[0];
|
||||
const firstChild = first.children && first.children.length > 0 ? first.children[0] : first;
|
||||
setSelectedCategoryId(firstChild.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('트리 로드 실패:', e);
|
||||
@@ -227,7 +242,9 @@ function InterviewScenarioApp() {
|
||||
const data = await api.get(`/sales/interviews/api/projects/${projectId}/tree`);
|
||||
setProjectTree(data);
|
||||
if (data.length > 0 && !selectedDomainCategoryId) {
|
||||
setSelectedDomainCategoryId(data[0].id);
|
||||
const first = data[0];
|
||||
const firstChild = first.children && first.children.length > 0 ? first.children[0] : first;
|
||||
setSelectedDomainCategoryId(firstChild.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('프로젝트 트리 로드 실패:', e);
|
||||
@@ -243,9 +260,9 @@ function InterviewScenarioApp() {
|
||||
}
|
||||
}, [selectedProjectId]);
|
||||
|
||||
const selectedCategory = tree.find(c => c.id === selectedCategoryId);
|
||||
const selectedCategory = findCategory(tree, selectedCategoryId);
|
||||
const selectedProject = projects.find(p => p.id === selectedProjectId);
|
||||
const selectedDomainCategory = projectTree.find(c => c.id === selectedDomainCategoryId);
|
||||
const selectedDomainCategory = findCategory(projectTree, selectedDomainCategoryId);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -468,25 +485,73 @@ function ProjectStatusBar({ project, onRefresh }) {
|
||||
// 도메인 사이드바
|
||||
// ============================================================
|
||||
function DomainSidebar({ categories, selectedId, onSelect }) {
|
||||
const [expandedRoots, setExpandedRoots] = useState(() => {
|
||||
const map = {};
|
||||
categories.forEach(c => { map[c.id] = true; });
|
||||
return map;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedRoots(prev => {
|
||||
const map = { ...prev };
|
||||
categories.forEach(c => { if (map[c.id] === undefined) map[c.id] = true; });
|
||||
return map;
|
||||
});
|
||||
}, [categories]);
|
||||
|
||||
const toggleRoot = (id) => {
|
||||
setExpandedRoots(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const countQuestions = (cat) => {
|
||||
let count = (cat.templates || []).reduce((sum, t) => sum + (t.questions || []).length, 0);
|
||||
(cat.children || []).forEach(child => {
|
||||
count += (child.templates || []).reduce((sum, t) => sum + (t.questions || []).length, 0);
|
||||
});
|
||||
return count;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="px-3 py-3 border-b border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-700">도메인</h3>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{categories.map(cat => {
|
||||
const qCount = (cat.templates || []).reduce((sum, t) => sum + (t.questions || []).length, 0);
|
||||
{categories.map(root => {
|
||||
const hasChildren = root.children && root.children.length > 0;
|
||||
const expanded = expandedRoots[root.id];
|
||||
return (
|
||||
<div key={cat.id}
|
||||
onClick={() => onSelect(cat.id)}
|
||||
className={`domain-item flex items-center justify-between px-3 py-2.5 ${cat.id === selectedId ? 'active' : ''}`}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs">
|
||||
{DOMAIN_LABELS[cat.domain] ? '📋' : '📁'}
|
||||
</span>
|
||||
<span className="text-sm truncate">{cat.name}</span>
|
||||
<div key={root.id}>
|
||||
{/* 대분류 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => hasChildren ? toggleRoot(root.id) : onSelect(root.id)}>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{hasChildren && (
|
||||
<span className="text-xs text-gray-400" style={{ width: '14px', display: 'inline-block' }}>
|
||||
{expanded ? '▼' : '▶'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs">📁</span>
|
||||
<span className="text-sm font-medium text-gray-700 truncate">{root.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">{countQuestions(root)}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">{qCount}</span>
|
||||
{/* 중분류 (자식) */}
|
||||
{hasChildren && expanded && root.children.map(child => {
|
||||
const qCount = (child.templates || []).reduce((sum, t) => sum + (t.questions || []).length, 0);
|
||||
return (
|
||||
<div key={child.id}
|
||||
onClick={() => onSelect(child.id)}
|
||||
className={`domain-item flex items-center justify-between py-2 pr-3 ${child.id === selectedId ? 'active' : ''}`}
|
||||
style={{ paddingLeft: '2rem' }}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs">📋</span>
|
||||
<span className="text-sm truncate">{child.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">{qCount}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1184,17 +1249,36 @@ className="w-full px-3 py-2 border border-gray-300 rounded text-sm" rows={3}
|
||||
// 좌측: 카테고리 사이드바
|
||||
// ============================================================
|
||||
function CategorySidebar({ categories, selectedId, onSelect, onRefresh }) {
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [showAddRoot, setShowAddRoot] = useState(false);
|
||||
const [showAddChildFor, setShowAddChildFor] = useState(null);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [expandedRoots, setExpandedRoots] = useState(() => {
|
||||
const map = {};
|
||||
categories.forEach(c => { map[c.id] = true; });
|
||||
return map;
|
||||
});
|
||||
|
||||
const handleAdd = async () => {
|
||||
useEffect(() => {
|
||||
setExpandedRoots(prev => {
|
||||
const map = { ...prev };
|
||||
categories.forEach(c => { if (map[c.id] === undefined) map[c.id] = true; });
|
||||
return map;
|
||||
});
|
||||
}, [categories]);
|
||||
|
||||
const toggleRoot = (id) => {
|
||||
setExpandedRoots(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const handleAdd = async (parentId = null) => {
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
await api.post('/sales/interviews/api/categories', { name: newName.trim() });
|
||||
await api.post('/sales/interviews/api/categories', { name: newName.trim(), parent_id: parentId });
|
||||
setNewName('');
|
||||
setShowAdd(false);
|
||||
setShowAddRoot(false);
|
||||
setShowAddChildFor(null);
|
||||
onRefresh();
|
||||
} catch (e) { alert('카테고리 생성 실패: ' + e.message); }
|
||||
};
|
||||
@@ -1216,70 +1300,116 @@ function CategorySidebar({ categories, selectedId, onSelect, onRefresh }) {
|
||||
} catch (e) { alert('삭제 실패: ' + e.message); }
|
||||
};
|
||||
|
||||
const renderEditRow = (id) => (
|
||||
<div className="flex items-center gap-1 px-2" onClick={e => e.stopPropagation()}>
|
||||
<input type="text" value={editName} onChange={e => setEditName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleUpdate(id); if (e.key === 'Escape') setEditingId(null); }}
|
||||
className="flex-1 text-sm border rounded px-1.5 py-0.5" autoFocus />
|
||||
<button onClick={() => handleUpdate(id)}
|
||||
className="text-xs px-1.5 py-0.5 bg-green-600 text-white rounded hover:bg-green-700">저장</button>
|
||||
<button onClick={() => setEditingId(null)}
|
||||
className="text-xs px-1.5 py-0.5 text-gray-500 hover:text-gray-700">취소</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAddInput = (parentId = null) => (
|
||||
<div className="px-3 py-2 border-b border-gray-100 bg-blue-50" style={{ paddingLeft: parentId ? '2rem' : undefined }}>
|
||||
<input type="text" value={newName} onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAdd(parentId)}
|
||||
placeholder={parentId ? '중분류명 입력' : '대분류명 입력'} autoFocus
|
||||
className="w-full text-sm border border-gray-300 rounded px-2 py-1 mb-1" />
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button onClick={() => { parentId ? setShowAddChildFor(null) : setShowAddRoot(false); setNewName(''); }}
|
||||
className="text-xs px-2 py-1 text-gray-500 hover:text-gray-700">취소</button>
|
||||
<button onClick={() => handleAdd(parentId)}
|
||||
className="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">추가</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<span className="text-sm font-semibold text-gray-700">카테고리</span>
|
||||
<button onClick={() => setShowAdd(!showAdd)}
|
||||
<button onClick={() => { setShowAddRoot(!showAddRoot); setShowAddChildFor(null); setNewName(''); }}
|
||||
className="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
+ 추가
|
||||
+ 대분류
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="px-3 py-2 border-b border-gray-100 bg-blue-50">
|
||||
<input type="text" value={newName} onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="카테고리명 입력" autoFocus
|
||||
className="w-full text-sm border border-gray-300 rounded px-2 py-1 mb-1" />
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button onClick={() => { setShowAdd(false); setNewName(''); }}
|
||||
className="text-xs px-2 py-1 text-gray-500 hover:text-gray-700">취소</button>
|
||||
<button onClick={handleAdd}
|
||||
className="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">추가</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showAddRoot && renderAddInput(null)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{categories.length === 0 && !showAdd && (
|
||||
{categories.length === 0 && !showAddRoot && (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-sm text-gray-400 mb-3">카테고리가 없습니다</p>
|
||||
<button onClick={() => setShowAdd(true)}
|
||||
<button onClick={() => setShowAddRoot(true)}
|
||||
className="text-sm px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
+ 카테고리 추가
|
||||
+ 대분류 추가
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{categories.map(cat => (
|
||||
<div key={cat.id}
|
||||
className={`category-item px-3 py-2.5 cursor-pointer ${selectedId === cat.id ? 'active' : ''}`}
|
||||
onClick={() => onSelect(cat.id)}>
|
||||
{editingId === cat.id ? (
|
||||
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
|
||||
<input type="text" value={editName} onChange={e => setEditName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleUpdate(cat.id); if (e.key === 'Escape') setEditingId(null); }}
|
||||
className="flex-1 text-sm border rounded px-1.5 py-0.5" autoFocus />
|
||||
<button onClick={() => handleUpdate(cat.id)}
|
||||
className="text-xs px-1.5 py-0.5 bg-green-600 text-white rounded hover:bg-green-700">저장</button>
|
||||
<button onClick={() => setEditingId(null)}
|
||||
className="text-xs px-1.5 py-0.5 text-gray-500 hover:text-gray-700">취소</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700 truncate">{cat.name}</span>
|
||||
{selectedId === cat.id && (
|
||||
<div className="flex items-center gap-1 ml-2 flex-shrink-0" onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => { setEditingId(cat.id); setEditName(cat.name); }}
|
||||
{categories.map(root => {
|
||||
const hasChildren = root.children && root.children.length > 0;
|
||||
const expanded = expandedRoots[root.id];
|
||||
return (
|
||||
<div key={root.id}>
|
||||
{/* 대분류 (루트) */}
|
||||
{editingId === root.id ? (
|
||||
<div className="px-3 py-2">{renderEditRow(root.id)}</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-50 border-b border-gray-100"
|
||||
onClick={() => hasChildren ? toggleRoot(root.id) : onSelect(root.id)}>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{hasChildren && (
|
||||
<span className="text-xs text-gray-400" style={{ width: '14px', display: 'inline-block' }}>
|
||||
{expanded ? '▼' : '▶'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-800 truncate">{root.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0" onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => { setShowAddChildFor(showAddChildFor === root.id ? null : root.id); setShowAddRoot(false); setNewName(''); }}
|
||||
className="text-xs text-green-600 hover:text-green-800" title="중분류 추가">+</button>
|
||||
<button onClick={() => { setEditingId(root.id); setEditName(root.name); }}
|
||||
className="text-xs text-blue-600 hover:text-blue-800">수정</button>
|
||||
<button onClick={() => handleDelete(cat.id)}
|
||||
<button onClick={() => handleDelete(root.id)}
|
||||
className="text-xs text-red-500 hover:text-red-700">삭제</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 중분류 추가 입력 */}
|
||||
{showAddChildFor === root.id && renderAddInput(root.id)}
|
||||
|
||||
{/* 중분류 (자식) */}
|
||||
{hasChildren && expanded && root.children.map(child => (
|
||||
<div key={child.id}>
|
||||
{editingId === child.id ? (
|
||||
<div className="py-2" style={{ paddingLeft: '2rem' }}>{renderEditRow(child.id)}</div>
|
||||
) : (
|
||||
<div className={`category-item flex items-center justify-between py-2 pr-3 cursor-pointer ${selectedId === child.id ? 'active' : ''}`}
|
||||
style={{ paddingLeft: '2rem' }}
|
||||
onClick={() => onSelect(child.id)}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs">📋</span>
|
||||
<span className="text-sm text-gray-700 truncate">{child.name}</span>
|
||||
</div>
|
||||
{selectedId === child.id && (
|
||||
<div className="flex items-center gap-1 ml-2 flex-shrink-0" onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => { setEditingId(child.id); setEditName(child.name); }}
|
||||
className="text-xs text-blue-600 hover:text-blue-800">수정</button>
|
||||
<button onClick={() => handleDelete(child.id)}
|
||||
className="text-xs text-red-500 hover:text-red-700">삭제</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user