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 (
카테고리가 없습니다
-