From 29949d66eb255003c39badb3b92bc720cad8e5e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 12:50:15 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CategoryController: 카테고리 관리 페이지 - CategoryApiController: 테넌트별 카테고리 CRUD API - GlobalCategoryApiController: 글로벌 카테고리 관리 API - Category, GlobalCategory 모델 추가 - 카테고리 관리 뷰 (index, partials) - config/categories.php 설정 파일 Co-Authored-By: Claude Opus 4.5 --- .../Api/Admin/CategoryApiController.php | 291 +++++++++ .../Api/Admin/GlobalCategoryApiController.php | 295 +++++++++ app/Http/Controllers/CategoryController.php | 123 ++++ app/Models/Category.php | 82 +++ app/Models/GlobalCategory.php | 87 +++ config/categories.php | 27 + resources/views/categories/index.blade.php | 572 ++++++++++++++++++ .../categories/partials/option.blade.php | 6 + .../categories/partials/tree-item.blade.php | 81 +++ .../views/categories/partials/tree.blade.php | 15 + routes/api.php | 48 +- routes/web.php | 4 + 12 files changed, 1630 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Api/Admin/CategoryApiController.php create mode 100644 app/Http/Controllers/Api/Admin/GlobalCategoryApiController.php create mode 100644 app/Http/Controllers/CategoryController.php create mode 100644 app/Models/Category.php create mode 100644 app/Models/GlobalCategory.php create mode 100644 config/categories.php create mode 100644 resources/views/categories/index.blade.php create mode 100644 resources/views/categories/partials/option.blade.php create mode 100644 resources/views/categories/partials/tree-item.blade.php create mode 100644 resources/views/categories/partials/tree.blade.php diff --git a/app/Http/Controllers/Api/Admin/CategoryApiController.php b/app/Http/Controllers/Api/Admin/CategoryApiController.php new file mode 100644 index 00000000..8685861e --- /dev/null +++ b/app/Http/Controllers/Api/Admin/CategoryApiController.php @@ -0,0 +1,291 @@ +json(['success' => false, 'data' => []]); + } + + $codeGroup = $request->input('code_group', 'product'); + + $categories = Category::query() + ->where('tenant_id', $tenantId) + ->where('code_group', $codeGroup) + ->whereNull('deleted_at') + ->orderBy('parent_id') + ->orderBy('sort_order') + ->get(['id', 'parent_id', 'code', 'name']); + + return response()->json([ + 'success' => true, + 'data' => $categories, + ]); + } + + /** + * 트리 데이터 조회 (HTMX) + */ + public function tree(Request $request): View + { + $tenantId = session('selected_tenant_id'); + $codeGroup = $request->input('code_group', 'product'); + + $categories = collect(); + if ($tenantId) { + $categories = Category::query() + ->where('tenant_id', $tenantId) + ->where('code_group', $codeGroup) + ->whereNull('parent_id') + ->with('childrenRecursive') + ->orderBy('sort_order') + ->get(); + } + + return view('categories.partials.tree', [ + 'categories' => $categories, + 'codeGroup' => $codeGroup, + ]); + } + + /** + * 단일 조회 + */ + public function show(int $id): JsonResponse + { + $category = Category::with('children')->findOrFail($id); + + return response()->json([ + 'success' => true, + 'data' => $category, + ]); + } + + /** + * 생성 + */ + public function store(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id'); + if (! $tenantId) { + return response()->json(['success' => false, 'message' => '테넌트를 선택해주세요.'], 400); + } + + $validated = $request->validate([ + 'code_group' => 'required|string|max:50', + 'parent_id' => 'nullable|integer|exists:categories,id', + 'code' => 'required|string|max:50', + 'name' => 'required|string|max:100', + 'profile_code' => 'nullable|string|max:50', + 'description' => 'nullable|string|max:500', + 'is_active' => 'boolean', + ]); + + // 코드 중복 체크 + $exists = Category::query() + ->where('tenant_id', $tenantId) + ->where('code_group', $validated['code_group']) + ->where('code', $validated['code']) + ->exists(); + + if ($exists) { + return response()->json(['success' => false, 'message' => '이미 존재하는 코드입니다.'], 400); + } + + // 최대 sort_order 조회 + $maxSortOrder = Category::query() + ->where('tenant_id', $tenantId) + ->where('code_group', $validated['code_group']) + ->where('parent_id', $validated['parent_id'] ?? null) + ->max('sort_order') ?? 0; + + $category = Category::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $validated['parent_id'] ?? null, + 'code_group' => $validated['code_group'], + 'code' => $validated['code'], + 'name' => $validated['name'], + 'profile_code' => $validated['profile_code'] ?? null, + 'description' => $validated['description'] ?? null, + 'is_active' => $validated['is_active'] ?? true, + 'sort_order' => $maxSortOrder + 1, + 'created_by' => Auth::id(), + ]); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 생성되었습니다.', + 'data' => $category, + ]); + } + + /** + * 수정 + */ + public function update(Request $request, int $id): JsonResponse + { + $category = Category::findOrFail($id); + + $validated = $request->validate([ + 'code' => 'sometimes|required|string|max:50', + 'name' => 'sometimes|required|string|max:100', + 'profile_code' => 'nullable|string|max:50', + 'description' => 'nullable|string|max:500', + 'is_active' => 'sometimes|boolean', + ]); + + // 코드 변경 시 중복 체크 + if (isset($validated['code']) && $validated['code'] !== $category->code) { + $exists = Category::query() + ->where('tenant_id', $category->tenant_id) + ->where('code_group', $category->code_group) + ->where('code', $validated['code']) + ->where('id', '!=', $id) + ->exists(); + + if ($exists) { + return response()->json(['success' => false, 'message' => '이미 존재하는 코드입니다.'], 400); + } + } + + $category->update(array_merge($validated, ['updated_by' => Auth::id()])); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 수정되었습니다.', + 'data' => $category->fresh(), + ]); + } + + /** + * 삭제 + */ + public function destroy(int $id): JsonResponse + { + $category = Category::findOrFail($id); + + // 하위 카테고리 존재 여부 확인 + if ($category->children()->count() > 0) { + return response()->json([ + 'success' => false, + 'message' => '하위 카테고리가 있어 삭제할 수 없습니다.', + ], 400); + } + + $category->update(['deleted_by' => Auth::id()]); + $category->delete(); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 삭제되었습니다.', + ]); + } + + /** + * 활성 상태 토글 + */ + public function toggle(int $id): JsonResponse + { + $category = Category::findOrFail($id); + $category->update([ + 'is_active' => ! $category->is_active, + 'updated_by' => Auth::id(), + ]); + + return response()->json([ + 'success' => true, + 'message' => $category->is_active ? '활성화되었습니다.' : '비활성화되었습니다.', + 'is_active' => $category->is_active, + ]); + } + + /** + * 이동 (부모 변경) + */ + public function move(Request $request, int $id): JsonResponse + { + $category = Category::findOrFail($id); + + $validated = $request->validate([ + 'parent_id' => 'nullable|integer|exists:categories,id', + ]); + + $newParentId = $validated['parent_id'] ?? null; + + // 자기 자신이나 자식을 부모로 설정 불가 + if ($newParentId === $id) { + return response()->json(['success' => false, 'message' => '자기 자신을 부모로 설정할 수 없습니다.'], 400); + } + + // 자식 카테고리를 부모로 설정하는 것 방지 (순환 참조) + if ($newParentId) { + $parent = Category::find($newParentId); + while ($parent) { + if ($parent->id === $id) { + return response()->json(['success' => false, 'message' => '하위 카테고리를 부모로 설정할 수 없습니다.'], 400); + } + $parent = $parent->parent; + } + } + + $category->update([ + 'parent_id' => $newParentId, + 'updated_by' => Auth::id(), + ]); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 이동되었습니다.', + ]); + } + + /** + * 순서 변경 + */ + public function reorder(Request $request): JsonResponse + { + $validated = $request->validate([ + 'items' => 'required|array', + 'items.*.id' => 'required|integer|exists:categories,id', + 'items.*.sort_order' => 'required|integer|min:0', + ]); + + DB::beginTransaction(); + try { + foreach ($validated['items'] as $item) { + Category::where('id', $item['id'])->update([ + 'sort_order' => $item['sort_order'], + 'updated_by' => Auth::id(), + ]); + } + DB::commit(); + + return response()->json([ + 'success' => true, + 'message' => '순서가 변경되었습니다.', + ]); + } catch (\Exception $e) { + DB::rollBack(); + + return response()->json([ + 'success' => false, + 'message' => '순서 변경 중 오류가 발생했습니다.', + ], 500); + } + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Admin/GlobalCategoryApiController.php b/app/Http/Controllers/Api/Admin/GlobalCategoryApiController.php new file mode 100644 index 00000000..33ba6dcc --- /dev/null +++ b/app/Http/Controllers/Api/Admin/GlobalCategoryApiController.php @@ -0,0 +1,295 @@ +input('code_group', 'product'); + + $categories = GlobalCategory::query() + ->where('code_group', $codeGroup) + ->whereNull('deleted_at') + ->orderBy('parent_id') + ->orderBy('sort_order') + ->get(['id', 'parent_id', 'code', 'name']); + + return response()->json([ + 'success' => true, + 'data' => $categories, + ]); + } + + /** + * 단일 조회 + */ + public function show(int $id): JsonResponse + { + $category = GlobalCategory::findOrFail($id); + + return response()->json([ + 'success' => true, + 'data' => $category, + ]); + } + + /** + * 생성 + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'code_group' => 'required|string|max:30', + 'parent_id' => 'nullable|integer|exists:global_categories,id', + 'code' => 'required|string|max:30', + 'name' => 'required|string|max:100', + 'profile_code' => 'nullable|string|max:30', + 'description' => 'nullable|string|max:255', + 'sort_order' => 'nullable|integer|min:0', + ]); + + // 코드 중복 체크 + $exists = GlobalCategory::query() + ->where('code_group', $validated['code_group']) + ->where('code', $validated['code']) + ->whereNull('deleted_at') + ->exists(); + + if ($exists) { + return response()->json(['success' => false, 'message' => '이미 존재하는 코드입니다.'], 400); + } + + // 최대 sort_order 조회 + $maxSortOrder = GlobalCategory::query() + ->where('code_group', $validated['code_group']) + ->where('parent_id', $validated['parent_id'] ?? null) + ->max('sort_order') ?? 0; + + $category = GlobalCategory::create([ + 'parent_id' => $validated['parent_id'] ?? null, + 'code_group' => $validated['code_group'], + 'code' => $validated['code'], + 'name' => $validated['name'], + 'profile_code' => $validated['profile_code'] ?? null, + 'description' => $validated['description'] ?? null, + 'is_active' => true, + 'sort_order' => $validated['sort_order'] ?? ($maxSortOrder + 1), + 'created_by' => Auth::id(), + ]); + + return response()->json([ + 'success' => true, + 'message' => '글로벌 카테고리가 생성되었습니다.', + 'data' => $category, + ]); + } + + /** + * 수정 + */ + public function update(Request $request, int $id): JsonResponse + { + $category = GlobalCategory::findOrFail($id); + + $validated = $request->validate([ + 'name' => 'sometimes|required|string|max:100', + 'parent_id' => 'nullable|integer|exists:global_categories,id', + 'profile_code' => 'nullable|string|max:30', + 'description' => 'nullable|string|max:255', + 'sort_order' => 'nullable|integer|min:0', + ]); + + $category->update(array_merge($validated, ['updated_by' => Auth::id()])); + + return response()->json([ + 'success' => true, + 'message' => '글로벌 카테고리가 수정되었습니다.', + 'data' => $category->fresh(), + ]); + } + + /** + * 삭제 + */ + public function destroy(int $id): JsonResponse + { + $category = GlobalCategory::findOrFail($id); + + // 하위 카테고리 존재 여부 확인 + if (GlobalCategory::where('parent_id', $id)->whereNull('deleted_at')->exists()) { + return response()->json([ + 'success' => false, + 'message' => '하위 카테고리가 있어 삭제할 수 없습니다.', + ], 400); + } + + $category->update(['deleted_by' => Auth::id()]); + $category->delete(); + + return response()->json([ + 'success' => true, + 'message' => '글로벌 카테고리가 삭제되었습니다.', + ]); + } + + /** + * 활성 상태 토글 + */ + public function toggle(int $id): JsonResponse + { + $category = GlobalCategory::findOrFail($id); + $category->update([ + 'is_active' => ! $category->is_active, + 'updated_by' => Auth::id(), + ]); + + return response()->json([ + 'success' => true, + 'message' => $category->is_active ? '활성화되었습니다.' : '비활성화되었습니다.', + 'is_active' => $category->is_active, + ]); + } + + /** + * 테넌트로 복사 + */ + public function copyToTenant(int $id): JsonResponse + { + $tenantId = session('selected_tenant_id'); + if (! $tenantId) { + return response()->json(['success' => false, 'message' => '테넌트를 선택해주세요.'], 400); + } + + $globalCategory = GlobalCategory::findOrFail($id); + + // 이미 존재하는지 확인 + $exists = Category::query() + ->where('tenant_id', $tenantId) + ->where('code_group', $globalCategory->code_group) + ->where('code', $globalCategory->code) + ->whereNull('deleted_at') + ->exists(); + + if ($exists) { + return response()->json(['success' => false, 'message' => '이미 존재하는 카테고리입니다.'], 400); + } + + // 복사 (parent_id는 복사하지 않음 - 개별 복사이므로) + Category::create([ + 'tenant_id' => $tenantId, + 'parent_id' => null, + 'code_group' => $globalCategory->code_group, + 'code' => $globalCategory->code, + 'name' => $globalCategory->name, + 'profile_code' => $globalCategory->profile_code, + 'description' => $globalCategory->description, + 'is_active' => $globalCategory->is_active, + 'sort_order' => $globalCategory->sort_order, + 'created_by' => Auth::id(), + ]); + + return response()->json([ + 'success' => true, + 'message' => '테넌트로 복사되었습니다.', + ]); + } + + /** + * 일괄 테넌트로 복사 + */ + public function bulkCopyToTenant(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id'); + if (! $tenantId) { + return response()->json(['success' => false, 'message' => '테넌트를 선택해주세요.'], 400); + } + + $validated = $request->validate([ + 'ids' => 'required|array', + 'ids.*' => 'integer|exists:global_categories,id', + ]); + + $globalCategories = GlobalCategory::whereIn('id', $validated['ids']) + ->whereNull('deleted_at') + ->orderBy('parent_id') + ->orderBy('sort_order') + ->get(); + + $copied = 0; + $skipped = 0; + $idMap = []; // global_id => tenant_id + + DB::beginTransaction(); + try { + foreach ($globalCategories as $gc) { + // 이미 존재하는지 확인 + $exists = Category::query() + ->where('tenant_id', $tenantId) + ->where('code_group', $gc->code_group) + ->where('code', $gc->code) + ->whereNull('deleted_at') + ->exists(); + + if ($exists) { + $skipped++; + continue; + } + + // parent_id 매핑 + $parentId = null; + if ($gc->parent_id && isset($idMap[$gc->parent_id])) { + $parentId = $idMap[$gc->parent_id]; + } + + $newCategory = Category::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $parentId, + 'code_group' => $gc->code_group, + 'code' => $gc->code, + 'name' => $gc->name, + 'profile_code' => $gc->profile_code, + 'description' => $gc->description, + 'is_active' => $gc->is_active, + 'sort_order' => $gc->sort_order, + 'created_by' => Auth::id(), + ]); + + $idMap[$gc->id] = $newCategory->id; + $copied++; + } + + DB::commit(); + + $message = "{$copied}개 카테고리가 복사되었습니다."; + if ($skipped > 0) { + $message .= " ({$skipped}개 중복으로 건너뜀)"; + } + + return response()->json([ + 'success' => true, + 'message' => $message, + 'copied' => $copied, + 'skipped' => $skipped, + ]); + } catch (\Exception $e) { + DB::rollBack(); + + return response()->json([ + 'success' => false, + 'message' => '복사 중 오류가 발생했습니다.', + ], 500); + } + } +} \ No newline at end of file diff --git a/app/Http/Controllers/CategoryController.php b/app/Http/Controllers/CategoryController.php new file mode 100644 index 00000000..3379634c --- /dev/null +++ b/app/Http/Controllers/CategoryController.php @@ -0,0 +1,123 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('categories.index')); + } + + $tenantId = session('selected_tenant_id'); + $tenant = $tenantId ? Tenant::find($tenantId) : null; + $isHQ = $tenantId == 1; + + // 모든 code_group 조회 (글로벌 + 테넌트) + $globalGroups = GlobalCategory::whereNull('deleted_at') + ->select('code_group') + ->distinct() + ->pluck('code_group') + ->toArray(); + + $tenantGroups = []; + if ($tenantId) { + $tenantGroups = Category::where('tenant_id', $tenantId) + ->whereNull('deleted_at') + ->select('code_group') + ->distinct() + ->pluck('code_group') + ->toArray(); + } + + $allGroups = array_unique(array_merge($globalGroups, $tenantGroups)); + sort($allGroups); + + $codeGroupLabels = config('categories.code_group_labels', []); + + $codeGroups = []; + foreach ($allGroups as $group) { + $codeGroups[$group] = $codeGroupLabels[$group] ?? $group; + } + + if (empty($codeGroups)) { + $codeGroups['product'] = $codeGroupLabels['product'] ?? 'product'; + } + + $selectedGroup = $request->get('group', array_key_first($codeGroups)); + if (! isset($codeGroups[$selectedGroup])) { + $selectedGroup = array_key_first($codeGroups); + } + + // 글로벌 카테고리 (트리 구조로 평탄화) + $globalCategoriesRaw = GlobalCategory::where('code_group', $selectedGroup) + ->whereNull('deleted_at') + ->orderBy('sort_order') + ->get(); + $globalCategories = $this->flattenTree($globalCategoriesRaw); + + // 테넌트 카테고리 (트리 구조로 평탄화) + $tenantCategories = collect(); + $tenantCodes = []; + if ($tenantId) { + $tenantCategoriesRaw = Category::where('tenant_id', $tenantId) + ->where('code_group', $selectedGroup) + ->whereNull('deleted_at') + ->orderBy('sort_order') + ->get(); + $tenantCategories = $this->flattenTree($tenantCategoriesRaw); + $tenantCodes = $tenantCategoriesRaw->pluck('code')->toArray(); + } + + return view('categories.index', [ + 'codeGroups' => $codeGroups, + 'codeGroupLabels' => $codeGroupLabels, + 'selectedGroup' => $selectedGroup, + 'globalCategories' => $globalCategories, + 'tenantCategories' => $tenantCategories, + 'tenantCodes' => $tenantCodes, + 'tenant' => $tenant, + 'isHQ' => $isHQ, + ]); + } + + /** + * 카테고리 컬렉션을 트리 구조로 평탄화 (부모-자식 순서 유지, depth 추가) + */ + private function flattenTree($categories): \Illuminate\Support\Collection + { + $result = collect(); + $byParent = $categories->groupBy(fn($c) => $c->parent_id ?? 0); + + $this->addChildrenRecursive($result, $byParent, 0, 0); + + return $result; + } + + /** + * 재귀적으로 자식 추가 + */ + private function addChildrenRecursive(&$result, $byParent, $parentId, $depth): void + { + $children = $byParent->get($parentId, collect()); + + foreach ($children as $child) { + $child->depth = $depth; + $result->push($child); + $this->addChildrenRecursive($result, $byParent, $child->id, $depth + 1); + } + } +} \ No newline at end of file diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 00000000..52edcead --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,82 @@ + 'boolean', + 'sort_order' => 'integer', + ]; + + /** + * 부모 카테고리 + */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** + * 자식 카테고리 + */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order'); + } + + /** + * 재귀적으로 자식 카테고리 로드 + */ + public function childrenRecursive(): HasMany + { + return $this->children()->with('childrenRecursive'); + } + + /** + * 코드 그룹 스코프 + */ + public function scopeGroup($query, string $group) + { + return $query->where('code_group', $group); + } + + /** + * 루트 카테고리만 조회 + */ + public function scopeRoot($query) + { + return $query->whereNull('parent_id'); + } + + /** + * 활성 카테고리만 조회 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} \ No newline at end of file diff --git a/app/Models/GlobalCategory.php b/app/Models/GlobalCategory.php new file mode 100644 index 00000000..887244bb --- /dev/null +++ b/app/Models/GlobalCategory.php @@ -0,0 +1,87 @@ + 'boolean', + 'sort_order' => 'integer', + ]; + + /** + * 부모 카테고리 + */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** + * 자식 카테고리 + */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order'); + } + + /** + * 코드 그룹 스코프 + */ + public function scopeGroup($query, string $group) + { + return $query->where('code_group', $group); + } + + /** + * 루트 카테고리만 조회 + */ + public function scopeRoot($query) + { + return $query->whereNull('parent_id'); + } + + /** + * 활성 카테고리만 조회 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * 계층 깊이를 포함한 이름 반환 + */ + public function getIndentedNameAttribute(): string + { + $depth = 0; + $parent = $this->parent; + while ($parent) { + $depth++; + $parent = $parent->parent; + } + + return str_repeat('ㄴ ', $depth).$this->name; + } +} \ No newline at end of file diff --git a/config/categories.php b/config/categories.php new file mode 100644 index 00000000..457fbb10 --- /dev/null +++ b/config/categories.php @@ -0,0 +1,27 @@ + [ + 'product' => '제품 카테고리', + 'material' => '자재 카테고리', + 'item' => '품목 카테고리', + 'item_category' => '품목 분류', + 'item_group' => '품목 그룹', + 'item_type' => '품목 유형', + 'item_feature1' => '품목 특성1', + 'item_feature2' => '품목 특성2', + 'account_type' => '계정 유형', + 'estimate' => '견적 유형', + 'process_type' => '공정 유형', + 'procurement_type' => '조달 유형', + ], +]; \ No newline at end of file diff --git a/resources/views/categories/index.blade.php b/resources/views/categories/index.blade.php new file mode 100644 index 00000000..5b511975 --- /dev/null +++ b/resources/views/categories/index.blade.php @@ -0,0 +1,572 @@ +@extends('layouts.app') + +@section('title', '카테고리 관리') + +@section('content') +
+ +
+
+

카테고리 관리

+

+ @if($tenant) + + {{ $tenant->company_name }} + @if($isHQ) + 본사 + @endif + 카테고리를 관리합니다. + + @else + 테넌트별 카테고리를 관리합니다. + @endif +

+
+ @if($tenant) + + @endif +
+ + + @if(!$tenant) +
+ + + + 헤더에서 테넌트를 선택해주세요. +
+ @else + +
+
+ +
+
+ + +
+ +
+
+
+ + + + + +

글로벌 카테고리

+ ({{ $globalCategories->count() }}) +
+
+ @if($globalCategories->count() > 0) + + @endif + @if(!$isHQ) + 본사만 편집 가능 + @endif +
+
+
+ @forelse($globalCategories as $cat) + @php + $existsInTenant = in_array($cat->code, $tenantCodes); + @endphp +
+
+ + {{ $cat->code }} + {{ $cat->name }} + @if($cat->depth > 0) + Lv.{{ $cat->depth + 1 }} + @endif + @if($existsInTenant) + 복사됨 + @else + NEW + @endif + {{ $cat->sort_order }} + @if($isHQ) + + @else + + {{ $cat->is_active ? 'ON' : 'OFF' }} + + @endif +
+ @if($isHQ) + + @endif + + @if($isHQ) + + @endif +
+
+
+ @empty +
글로벌 카테고리가 없습니다.
+ @endforelse +
+
+ + +
+
+
+ + + + + +

테넌트 카테고리

+ ({{ $tenantCategories->count() }}) +
+
+
+ @forelse($tenantCategories as $cat) +
+
+ {{ $cat->code }} + {{ $cat->name }} + @if($cat->depth > 0) + Lv.{{ $cat->depth + 1 }} + @endif + {{ $cat->sort_order }} + +
+ + +
+
+
+ @empty +
+ 테넌트 카테고리가 없습니다.
+ 글로벌 카테고리를 복사하거나 새로 추가하세요. +
+ @endforelse +
+
+
+ @endif +
+ + + +@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/categories/partials/option.blade.php b/resources/views/categories/partials/option.blade.php new file mode 100644 index 00000000..52ee3673 --- /dev/null +++ b/resources/views/categories/partials/option.blade.php @@ -0,0 +1,6 @@ + +@if($category->childrenRecursive->count() > 0) + @foreach($category->childrenRecursive as $child) + @include('categories.partials.option', ['category' => $child, 'depth' => $depth + 1]) + @endforeach +@endif \ No newline at end of file diff --git a/resources/views/categories/partials/tree-item.blade.php b/resources/views/categories/partials/tree-item.blade.php new file mode 100644 index 00000000..77f679bb --- /dev/null +++ b/resources/views/categories/partials/tree-item.blade.php @@ -0,0 +1,81 @@ +
+
+
+ +
+ + + +
+ + + @if($depth > 0) +
+ @endif + + + @if($category->childrenRecursive->count() > 0) + + @else +
+ @endif + + +
+ {{ $category->name }} + ({{ $category->code }}) + @if($category->description) + + @endif +
+
+ +
+ + + + + + + + + + + +
+
+ + + @if($category->childrenRecursive->count() > 0) +
+ @foreach($category->childrenRecursive as $child) + @include('categories.partials.tree-item', ['category' => $child, 'depth' => $depth + 1]) + @endforeach +
+ @endif +
\ No newline at end of file diff --git a/resources/views/categories/partials/tree.blade.php b/resources/views/categories/partials/tree.blade.php new file mode 100644 index 00000000..0ba74125 --- /dev/null +++ b/resources/views/categories/partials/tree.blade.php @@ -0,0 +1,15 @@ +@if($categories->isEmpty()) +
+ + + +

등록된 카테고리가 없습니다.

+

새 카테고리를 추가해주세요.

+
+@else +
+ @foreach($categories as $category) + @include('categories.partials.tree-item', ['category' => $category, 'depth' => 0]) + @endforeach +
+@endif \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index cb165b34..ce1b9b8c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,7 +3,10 @@ use App\Http\Controllers\Api\Admin\BoardController; use App\Http\Controllers\Api\Admin\CustomerCenterController; use App\Http\Controllers\Api\Admin\DailyLogController; -use App\Http\Controllers\Api\Admin\DepartmentController;use App\Http\Controllers\Api\Admin\DocumentTemplateApiController; +use App\Http\Controllers\Api\Admin\DepartmentController; +use App\Http\Controllers\Api\Admin\DocumentTemplateApiController; +use App\Http\Controllers\Api\Admin\CategoryApiController; +use App\Http\Controllers\Api\Admin\GlobalCategoryApiController; use App\Http\Controllers\Api\Admin\GlobalMenuController; use App\Http\Controllers\Api\Admin\ItemFieldController; use App\Http\Controllers\Api\Admin\MeetingLogController; @@ -787,6 +790,49 @@ Route::post('/upload-image', [DocumentTemplateApiController::class, 'uploadImage'])->name('upload-image'); }); +/* +|-------------------------------------------------------------------------- +| 카테고리 관리 API +|-------------------------------------------------------------------------- +*/ +Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/categories')->name('api.admin.categories.')->group(function () { + // 고정 경로 + Route::get('/list', [CategoryApiController::class, 'list'])->name('list'); + Route::get('/tree', [CategoryApiController::class, 'tree'])->name('tree'); + Route::post('/reorder', [CategoryApiController::class, 'reorder'])->name('reorder'); + + // 기본 CRUD + Route::get('/{id}', [CategoryApiController::class, 'show'])->name('show'); + Route::post('/', [CategoryApiController::class, 'store'])->name('store'); + Route::put('/{id}', [CategoryApiController::class, 'update'])->name('update'); + Route::delete('/{id}', [CategoryApiController::class, 'destroy'])->name('destroy'); + + // 추가 액션 + Route::post('/{id}/toggle', [CategoryApiController::class, 'toggle'])->name('toggle'); + Route::post('/{id}/move', [CategoryApiController::class, 'move'])->name('move'); +}); + +/* +|-------------------------------------------------------------------------- +| 글로벌 카테고리 관리 API +|-------------------------------------------------------------------------- +*/ +Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/global-categories')->name('api.admin.global-categories.')->group(function () { + // 고정 경로 + Route::get('/list', [GlobalCategoryApiController::class, 'list'])->name('list'); + Route::post('/bulk-copy-to-tenant', [GlobalCategoryApiController::class, 'bulkCopyToTenant'])->name('bulkCopyToTenant'); + + // 기본 CRUD + Route::get('/{id}', [GlobalCategoryApiController::class, 'show'])->name('show'); + Route::post('/', [GlobalCategoryApiController::class, 'store'])->name('store'); + Route::put('/{id}', [GlobalCategoryApiController::class, 'update'])->name('update'); + Route::delete('/{id}', [GlobalCategoryApiController::class, 'destroy'])->name('destroy'); + + // 추가 액션 + Route::post('/{id}/toggle', [GlobalCategoryApiController::class, 'toggle'])->name('toggle'); + Route::post('/{id}/copy-to-tenant', [GlobalCategoryApiController::class, 'copyToTenant'])->name('copyToTenant'); +}); + /* |-------------------------------------------------------------------------- | 웹 녹음 AI 요약 API diff --git a/routes/web.php b/routes/web.php index 115ca085..2646da70 100644 --- a/routes/web.php +++ b/routes/web.php @@ -27,6 +27,7 @@ use App\Http\Controllers\TenantSettingController; use App\Http\Controllers\CommonCodeController; use App\Http\Controllers\DocumentTemplateController; +use App\Http\Controllers\CategoryController; use App\Http\Controllers\MenuSyncController; use App\Http\Controllers\UserController; use Illuminate\Support\Facades\Route; @@ -316,6 +317,9 @@ Route::get('/{id}/edit', [DocumentTemplateController::class, 'edit'])->name('edit'); }); + // 카테고리 관리 + Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index'); + /* |-------------------------------------------------------------------------- | 바로빌 Routes