fix:카테고리 스코프 분류 버그 수정, 복사 시 소프트삭제 복원, UI 개선

- isset→array_key_exists: description NULL인 그룹 스코프 오분류 수정
- 글로벌+테넌트 필터 버튼 추가 (공통코드/카테고리)
- 전체선택 체크박스를 헤더 아이콘 앞에 배치
- 스크롤 영역 calc(100vh-180px) 화면 기준으로 변경
- 복사 시 소프트삭제된 동일 코드 존재하면 복원 처리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 04:51:32 +09:00
parent df762d6cf4
commit d43f8d0ba1
5 changed files with 87 additions and 13 deletions

View File

@@ -174,19 +174,42 @@ public function copyToTenant(int $id): JsonResponse
$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는 복사하지 않음 - 개별 복사이므로)
// 소프트 삭제된 레코드 확인 → 복원
$trashed = Category::withoutGlobalScopes()->onlyTrashed()
->where('tenant_id', $tenantId)
->where('code_group', $globalCategory->code_group)
->where('code', $globalCategory->code)
->first();
if ($trashed) {
$trashed->restore();
$trashed->update([
'name' => $globalCategory->name,
'profile_code' => $globalCategory->profile_code,
'description' => $globalCategory->description,
'is_active' => $globalCategory->is_active,
'sort_order' => $globalCategory->sort_order,
'updated_by' => Auth::id(),
]);
return response()->json([
'success' => true,
'message' => '삭제된 카테고리를 복원하였습니다.',
]);
}
// 신규 복사
Category::create([
'tenant_id' => $tenantId,
'parent_id' => null,
@@ -234,12 +257,11 @@ public function bulkCopyToTenant(Request $request): JsonResponse
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) {
@@ -247,6 +269,28 @@ public function bulkCopyToTenant(Request $request): JsonResponse
continue;
}
// 소프트 삭제된 레코드 확인 → 복원
$trashed = Category::withoutGlobalScopes()->onlyTrashed()
->where('tenant_id', $tenantId)
->where('code_group', $gc->code_group)
->where('code', $gc->code)
->first();
if ($trashed) {
$trashed->restore();
$trashed->update([
'name' => $gc->name,
'profile_code' => $gc->profile_code,
'description' => $gc->description,
'is_active' => $gc->is_active,
'sort_order' => $gc->sort_order,
'updated_by' => Auth::id(),
]);
$idMap[$gc->id] = $trashed->id;
$copied++;
continue;
}
// parent_id 매핑
$parentId = null;
if ($gc->parent_id && isset($idMap[$gc->parent_id])) {

View File

@@ -136,8 +136,8 @@ public function index(Request $request): View|Response
$codeGroups = [];
$groupScopes = [];
foreach ($allGroupKeys as $group) {
$inGlobal = isset($globalGroupDescs[$group]);
$inTenant = isset($tenantGroupDescs[$group]);
$inGlobal = array_key_exists($group, $globalGroupDescs);
$inTenant = array_key_exists($group, $tenantGroupDescs);
$codeGroups[$group] = ! empty($globalGroupDescs[$group])
? $globalGroupDescs[$group]

View File

@@ -65,8 +65,8 @@ public function index(Request $request): View|Response
$codeGroups = [];
$groupScopes = [];
foreach ($allGroupKeys as $group) {
$inGlobal = isset($globalGroupDescs[$group]);
$inTenant = isset($tenantGroupDescs[$group]);
$inGlobal = array_key_exists($group, $globalGroupDescs);
$inTenant = array_key_exists($group, $tenantGroupDescs);
// 라벨: 글로벌 description 우선 → 테넌트 → 커스텀 → 키 자체
$codeGroups[$group] = ! empty($globalGroupDescs[$group])

View File

@@ -70,6 +70,8 @@ class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-blue-60
<div class="px-1.5 py-1 border-b border-gray-200 flex flex-wrap gap-0.5 flex-shrink-0">
<button type="button" onclick="filterGroups('all')"
class="group-filter-btn active text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors bg-gray-700 text-white" data-scope="all">전체</button>
<button type="button" onclick="filterGroups('global-tenant')"
class="group-filter-btn text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors bg-gray-100 text-gray-600 hover:bg-gray-200" data-scope="global-tenant">글로벌+테넌트</button>
<button type="button" onclick="filterGroups('global-based')"
class="group-filter-btn text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors bg-gray-100 text-gray-600 hover:bg-gray-200" data-scope="global-based">글로벌</button>
<button type="button" onclick="filterGroups('tenant-based')"
@@ -105,9 +107,13 @@ class="group-item block px-2 py-1.5 border-l-3 transition-colors
<!-- 우측: 카테고리 목록 -->
<div class="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4 min-w-0">
<!-- 글로벌 카테고리 -->
<div class="bg-white rounded-lg shadow-sm">
<div class="bg-white rounded-lg shadow-sm flex flex-col overflow-hidden" style="max-height: calc(100vh - 180px)">
<div class="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
<div class="flex items-center gap-2">
@if($globalCategories->count() > 0)
<input type="checkbox" id="selectAllGlobal" onchange="toggleSelectAllGlobal(this)"
class="w-4 h-4 rounded text-green-600 border-gray-300 focus:ring-green-500">
@endif
<span class="w-6 h-6 bg-purple-100 rounded flex items-center justify-center">
<svg class="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
@@ -133,7 +139,7 @@ class="hidden px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs fon
@endif
</div>
</div>
<div class="overflow-auto max-h-[600px] p-2">
<div class="overflow-auto flex-1 min-h-0 p-2">
@forelse($globalCategories as $cat)
@php
$existsInTenant = in_array($cat->code, $tenantCodes);
@@ -190,9 +196,13 @@ class="relative inline-flex h-4 w-7 items-center rounded-full transition-colors
</div>
<!-- 테넌트 카테고리 -->
<div class="bg-white rounded-lg shadow-sm">
<div class="bg-white rounded-lg shadow-sm flex flex-col overflow-hidden" style="max-height: calc(100vh - 180px)">
<div class="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
<div class="flex items-center gap-2">
@if($tenantCategories->count() > 0 && ($isHQ || auth()->user()?->isSuperAdmin()))
<input type="checkbox" id="selectAllTenant" onchange="toggleSelectAllTenant(this)"
class="w-4 h-4 rounded text-purple-600 border-gray-300 focus:ring-purple-500">
@endif
<span class="w-6 h-6 bg-blue-100 rounded flex items-center justify-center">
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
@@ -215,7 +225,7 @@ class="hidden px-3 py-1.5 bg-purple-600 hover:bg-purple-700 text-white text-xs f
@endif
</div>
</div>
<div class="overflow-auto max-h-[600px] p-2">
<div class="overflow-auto flex-1 min-h-0 p-2">
@forelse($tenantCategories as $cat)
@php $existsInGlobal = in_array($cat->code, $globalCodes); @endphp
<div class="border rounded-lg mb-1 {{ !$cat->is_active ? 'opacity-50 bg-gray-50' : 'hover:bg-blue-50' }}" style="margin-left: {{ $cat->depth * 1.5 }}rem">
@@ -381,6 +391,9 @@ function filterGroups(filter) {
case 'all':
show = true;
break;
case 'global-tenant':
show = (scope === 'global' || scope === 'both' || scope === 'tenant');
break;
case 'global-based':
show = (scope === 'global' || scope === 'both');
break;
@@ -640,6 +653,18 @@ function toggleSelectAll(checkbox) {
updateBulkCopyButton();
}
function toggleSelectAllGlobal(master) {
const checkboxes = document.querySelectorAll('.global-checkbox:not(:disabled)');
checkboxes.forEach(cb => cb.checked = master.checked);
updateBulkCopyButton();
}
function toggleSelectAllTenant(master) {
const checkboxes = document.querySelectorAll('.tenant-cat-checkbox:not(:disabled)');
checkboxes.forEach(cb => cb.checked = master.checked);
updateBulkPromoteBtn();
}
function updateBulkCopyButton() {
const checked = document.querySelectorAll('.global-checkbox:checked');
const btn = document.getElementById('bulkCopyBtn');

View File

@@ -88,6 +88,8 @@ class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-blue-60
<div class="px-1.5 py-1 border-b border-gray-200 flex flex-wrap gap-0.5 flex-shrink-0">
<button type="button" onclick="filterGroups('all')"
class="group-filter-btn active text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors bg-gray-700 text-white" data-scope="all">전체</button>
<button type="button" onclick="filterGroups('global-tenant')"
class="group-filter-btn text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors bg-gray-100 text-gray-600 hover:bg-gray-200" data-scope="global-tenant">글로벌+테넌트</button>
<button type="button" onclick="filterGroups('global-based')"
class="group-filter-btn text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors bg-gray-100 text-gray-600 hover:bg-gray-200" data-scope="global-based">글로벌</button>
<button type="button" onclick="filterGroups('tenant-based')"
@@ -528,6 +530,9 @@ function filterGroups(filter) {
case 'all':
show = true;
break;
case 'global-tenant':
show = (scope === 'global' || scope === 'both' || scope === 'tenant');
break;
case 'global-based':
show = (scope === 'global' || scope === 'both');
break;