From 2a45b6bfe88e87f7c2f42ffde186301bfec6494e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 28 Feb 2026 21:23:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[interview]=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EA=B3=84=EC=B8=B5=20=EA=B5=AC=EC=A1=B0(?= =?UTF-8?q?=EB=8C=80=EB=B6=84=EB=A5=98/=EC=A4=91=EB=B6=84=EB=A5=98)=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 헬퍼로 트리 내 카테고리 검색 --- .../Sales/InterviewScenarioController.php | 1 + app/Models/Interview/InterviewCategory.php | 11 + .../Sales/InterviewScenarioService.php | 139 ++++++---- .../views/sales/interviews/index.blade.php | 258 +++++++++++++----- 4 files changed, 295 insertions(+), 114 deletions(-) diff --git a/app/Http/Controllers/Sales/InterviewScenarioController.php b/app/Http/Controllers/Sales/InterviewScenarioController.php index 3f7536f3..e6a63131 100644 --- a/app/Http/Controllers/Sales/InterviewScenarioController.php +++ b/app/Http/Controllers/Sales/InterviewScenarioController.php @@ -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); diff --git a/app/Models/Interview/InterviewCategory.php b/app/Models/Interview/InterviewCategory.php index fc087c13..0262372f 100644 --- a/app/Models/Interview/InterviewCategory.php +++ b/app/Models/Interview/InterviewCategory.php @@ -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'); diff --git a/app/Services/Sales/InterviewScenarioService.php b/app/Services/Sales/InterviewScenarioService.php index 61bd6c07..22cfa88b 100644 --- a/app/Services/Sales/InterviewScenarioService.php +++ b/app/Services/Sales/InterviewScenarioService.php @@ -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') diff --git a/resources/views/sales/interviews/index.blade.php b/resources/views/sales/interviews/index.blade.php index a797f225..7468e2d1 100644 --- a/resources/views/sales/interviews/index.blade.php +++ b/resources/views/sales/interviews/index.blade.php @@ -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 (

도메인

- {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 ( -
onSelect(cat.id)} - className={`domain-item flex items-center justify-between px-3 py-2.5 ${cat.id === selectedId ? 'active' : ''}`}> -
- - {DOMAIN_LABELS[cat.domain] ? '📋' : '📁'} - - {cat.name} +
+ {/* 대분류 */} +
hasChildren ? toggleRoot(root.id) : onSelect(root.id)}> +
+ {hasChildren && ( + + {expanded ? '▼' : '▶'} + + )} + 📁 + {root.name} +
+ {countQuestions(root)}
- {qCount} + {/* 중분류 (자식) */} + {hasChildren && expanded && root.children.map(child => { + const qCount = (child.templates || []).reduce((sum, t) => sum + (t.questions || []).length, 0); + return ( +
onSelect(child.id)} + className={`domain-item flex items-center justify-between py-2 pr-3 ${child.id === selectedId ? 'active' : ''}`} + style={{ paddingLeft: '2rem' }}> +
+ 📋 + {child.name} +
+ {qCount} +
+ ); + })}
); })} @@ -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) => ( +
e.stopPropagation()}> + 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 /> + + +
+ ); + + const renderAddInput = (parentId = null) => ( +
+ 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" /> +
+ + +
+
+ ); + return (
카테고리 -
- {showAdd && ( -
- 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" /> -
- - -
-
- )} + {showAddRoot && renderAddInput(null)}
- {categories.length === 0 && !showAdd && ( + {categories.length === 0 && !showAddRoot && (

카테고리가 없습니다

-
)} - {categories.map(cat => ( -
onSelect(cat.id)}> - {editingId === cat.id ? ( -
e.stopPropagation()}> - 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 /> - - -
- ) : ( -
- {cat.name} - {selectedId === cat.id && ( -
e.stopPropagation()}> - + -
- )} -
- )} -
- ))} +
+ )} + + {/* 중분류 추가 입력 */} + {showAddChildFor === root.id && renderAddInput(root.id)} + + {/* 중분류 (자식) */} + {hasChildren && expanded && root.children.map(child => ( +
+ {editingId === child.id ? ( +
{renderEditRow(child.id)}
+ ) : ( +
onSelect(child.id)}> +
+ 📋 + {child.name} +
+ {selectedId === child.id && ( +
e.stopPropagation()}> + + +
+ )} +
+ )} +
+ ))} +
+ ); + })}
);