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

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

View File

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

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

View File

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