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:
김보곤
2026-02-28 21:23:30 +09:00
parent 9823945807
commit 2a45b6bfe8
4 changed files with 295 additions and 114 deletions

View File

@@ -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')