diff --git a/app/Http/Controllers/Api/Admin/ItemFieldController.php b/app/Http/Controllers/Api/Admin/ItemFieldController.php index b931f392..b3de3e9c 100644 --- a/app/Http/Controllers/Api/Admin/ItemFieldController.php +++ b/app/Http/Controllers/Api/Admin/ItemFieldController.php @@ -246,6 +246,44 @@ public function destroyCustomField(int $id): JsonResponse return response()->json($result, $result['success'] ? 200 : 400); } + /** + * 소프트 삭제된 커스텀 필드 복원 + */ + public function restoreCustomField(int $id): JsonResponse + { + $tenantId = session('selected_tenant_id'); + + if (! $tenantId || $tenantId === 'all') { + return response()->json([ + 'success' => false, + 'message' => '테넌트를 선택해주세요.', + ], 400); + } + + $result = $this->service->restoreCustomField($tenantId, $id); + + return response()->json($result, $result['success'] ? 200 : 400); + } + + /** + * 커스텀 필드 영구 삭제 + */ + public function forceDestroyCustomField(int $id): JsonResponse + { + $tenantId = session('selected_tenant_id'); + + if (! $tenantId || $tenantId === 'all') { + return response()->json([ + 'success' => false, + 'message' => '테넌트를 선택해주세요.', + ], 400); + } + + $result = $this->service->forceDeleteCustomField($tenantId, $id); + + return response()->json($result, $result['success'] ? 200 : 400); + } + /** * 커스텀 필드 일괄 삭제 */ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b65..1a30ee6a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Services\SidebarMenuService; +use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -11,7 +13,8 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + // SidebarMenuService 싱글턴 등록 + $this->app->singleton(SidebarMenuService::class); } /** @@ -19,6 +22,16 @@ public function register(): void */ public function boot(): void { - // + // 사이드바에 메뉴 데이터 전달 + View::composer('partials.sidebar', function ($view) { + $menuService = app(SidebarMenuService::class); + $menusBySection = $menuService->getMenusBySection(); + + $view->with([ + 'mainMenus' => $menusBySection['main'], + 'toolsMenus' => $menusBySection['tools'], + 'labsMenus' => $menusBySection['labs'], + ]); + }); } } diff --git a/app/Services/ItemFieldSeedingService.php b/app/Services/ItemFieldSeedingService.php index f2b887d7..597dbe98 100644 --- a/app/Services/ItemFieldSeedingService.php +++ b/app/Services/ItemFieldSeedingService.php @@ -337,7 +337,7 @@ public function resetAll(int $tenantId): array */ public function getFields(int $tenantId, array $filters = []): Collection { - $query = ItemField::where('tenant_id', $tenantId); + $query = ItemField::withTrashed()->where('tenant_id', $tenantId); // 필드 유형 필터 (system=is_common:1, custom=is_common:0) if (! empty($filters['field_category'])) { @@ -524,20 +524,11 @@ public function deleteCustomField(int $tenantId, int $fieldId): array ]; } - // 시스템 필드는 삭제 불가 - if ($field->storage_type === 'column') { - return [ - 'success' => false, - 'message' => '시스템 필드는 삭제할 수 없습니다. 초기화 기능을 사용하세요.', - ]; - } - - $field->update(['deleted_by' => auth()->id()]); - $field->delete(); + $field->forceDelete(); return [ 'success' => true, - 'message' => '커스텀 필드가 삭제되었습니다.', + 'message' => '필드가 삭제되었습니다.', ]; } @@ -561,4 +552,61 @@ public function deleteCustomFields(int $tenantId, array $fieldIds): array 'deleted_count' => $deletedCount, ]; } + + /** + * 소프트 삭제된 커스텀 필드 복원 + */ + public function restoreCustomField(int $tenantId, int $fieldId): array + { + $field = ItemField::withTrashed() + ->where('tenant_id', $tenantId) + ->where('id', $fieldId) + ->first(); + + if (! $field) { + return [ + 'success' => false, + 'message' => '필드를 찾을 수 없습니다.', + ]; + } + + if (is_null($field->deleted_at)) { + return [ + 'success' => false, + 'message' => '이미 활성화된 필드입니다.', + ]; + } + + $field->restore(); + + return [ + 'success' => true, + 'message' => '필드가 복원되었습니다.', + ]; + } + + /** + * 커스텀 필드 영구 삭제 + */ + public function forceDeleteCustomField(int $tenantId, int $fieldId): array + { + $field = ItemField::withTrashed() + ->where('tenant_id', $tenantId) + ->where('id', $fieldId) + ->first(); + + if (! $field) { + return [ + 'success' => false, + 'message' => '필드를 찾을 수 없습니다.', + ]; + } + + $field->forceDelete(); + + return [ + 'success' => true, + 'message' => '필드가 영구 삭제되었습니다.', + ]; + } } diff --git a/app/Services/SidebarMenuService.php b/app/Services/SidebarMenuService.php new file mode 100644 index 00000000..d9eed89b --- /dev/null +++ b/app/Services/SidebarMenuService.php @@ -0,0 +1,188 @@ +user(); + $tenantId = session('selected_tenant_id', 1); + + // 테넌트의 모든 활성 메뉴 조회 + $allMenus = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->where('hidden', false) + ->orderBy('sort_order') + ->get(); + + // 슈퍼관리자는 모든 메뉴 표시 + if ($user && $user->is_super_admin) { + return $this->buildMenuTree($allMenus); + } + + // 일반 사용자: 부서 권한 기반 메뉴 ID 조회 + $permittedMenuIds = $this->getPermittedMenuIds($user, $tenantId); + + // 역할 기반 필터링 + 부서 권한 필터링 + $filteredMenus = $allMenus->filter(function ($menu) use ($user, $permittedMenuIds) { + $requiredRole = $menu->getRequiresRole(); + + // super_admin 역할 필요시 슈퍼관리자만 + if ($requiredRole === 'super_admin') { + return $user && $user->is_super_admin; + } + + // 기타 역할 체크 + if ($requiredRole && ! ($user && $user->hasRole($requiredRole))) { + return false; + } + + // 부서 권한 체크: 허용된 메뉴 ID만 표시 + return in_array($menu->id, $permittedMenuIds); + }); + + return $this->buildMenuTree($filteredMenus); + } + + /** + * 사용자가 접근 가능한 메뉴 ID 목록 조회 (부서 권한 기반) + */ + private function getPermittedMenuIds(?User $user, int $tenantId): array + { + if (! $user) { + return []; + } + + // 사용자의 부서 ID 조회 + $departmentIds = DB::table('department_user') + ->where('user_id', $user->id) + ->pluck('department_id') + ->toArray(); + + if (empty($departmentIds)) { + return []; + } + + $now = now(); + + // permission_overrides 테이블에서 부서에 ALLOW된 menu:*.view 권한 조회 + $permittedMenuIds = DB::table('permission_overrides as po') + ->join('permissions as p', 'p.id', '=', 'po.permission_id') + ->where('po.model_type', Department::class) + ->whereIn('po.model_id', $departmentIds) + ->where('po.tenant_id', $tenantId) + ->where('po.effect', 1) // ALLOW + ->where('p.name', 'like', 'menu:%.view') + ->whereNull('po.deleted_at') + ->where(function ($query) use ($now) { + $query->whereNull('po.effective_from') + ->orWhere('po.effective_from', '<=', $now); + }) + ->where(function ($query) use ($now) { + $query->whereNull('po.effective_to') + ->orWhere('po.effective_to', '>=', $now); + }) + ->pluck('p.name') + ->map(function ($name) { + // menu:{id}.view에서 id 추출 + if (preg_match('/^menu:(\d+)\.view$/', $name, $matches)) { + return (int) $matches[1]; + } + + return null; + }) + ->filter() + ->unique() + ->toArray(); + + return $permittedMenuIds; + } + + /** + * 섹션별 메뉴 조회 (main, tools, labs) + */ + public function getMenusBySection(?User $user = null): array + { + $menuTree = $this->getUserMenuTree($user); + + return [ + 'main' => $menuTree->filter(fn ($m) => $m->getSection() === 'main')->values(), + 'tools' => $menuTree->filter(fn ($m) => $m->getSection() === 'tools')->values(), + 'labs' => $menuTree->filter(fn ($m) => $m->getSection() === 'labs')->values(), + ]; + } + + /** + * 메뉴 트리 구성 + */ + private function buildMenuTree(Collection $menus, ?int $parentId = null): Collection + { + return $menus->where('parent_id', $parentId) + ->map(function ($menu) use ($menus) { + $menu->menuChildren = $this->buildMenuTree($menus, $menu->id); + + return $menu; + }) + ->values(); + } + + /** + * 현재 라우트가 메뉴와 일치하는지 확인 + */ + public function isMenuActive(Menu $menu): bool + { + $routeName = $menu->getRouteName(); + + if ($routeName) { + // 라우트 패턴 매칭 (예: pm.* → pm.index, pm.projects.index 등) + if (str_ends_with($routeName, '.*')) { + $prefix = substr($routeName, 0, -2); + + return request()->routeIs($prefix.'*'); + } + + return request()->routeIs($routeName); + } + + // URL 매칭 + if ($menu->url) { + $currentPath = '/'.ltrim(request()->path(), '/'); + + return $currentPath === $menu->url || str_starts_with($currentPath, $menu->url.'/'); + } + + return false; + } + + /** + * 메뉴 또는 자식 메뉴가 활성 상태인지 확인 + */ + public function isMenuOrChildActive(Menu $menu): bool + { + if ($this->isMenuActive($menu)) { + return true; + } + + // 자식 메뉴 중 활성 상태가 있는지 확인 + if (isset($menu->menuChildren) && $menu->menuChildren->isNotEmpty()) { + foreach ($menu->menuChildren as $child) { + if ($this->isMenuOrChildActive($child)) { + return true; + } + } + } + + return false; + } +} diff --git a/resources/views/components/sidebar/menu-group.blade.php b/resources/views/components/sidebar/menu-group.blade.php new file mode 100644 index 00000000..a21c7d00 --- /dev/null +++ b/resources/views/components/sidebar/menu-group.blade.php @@ -0,0 +1,47 @@ +@props(['menu', 'depth' => 0]) + +@php + $sidebarMenuService = app(\App\Services\SidebarMenuService::class); + $isExpanded = $sidebarMenuService->isMenuOrChildActive($menu); + $groupId = 'menu-group-' . $menu->id; + $children = $menu->menuChildren ?? collect(); + $paddingLeft = $depth > 0 ? ($depth * 0.75 + 0.75) . 'rem' : '0.75rem'; +@endphp + +
  • + {{-- 그룹 헤더 (접기/펼치기 버튼) --}} + + + {{-- 하위 메뉴 --}} + +
  • diff --git a/resources/views/components/sidebar/menu-icon.blade.php b/resources/views/components/sidebar/menu-icon.blade.php new file mode 100644 index 00000000..7b44f99f --- /dev/null +++ b/resources/views/components/sidebar/menu-icon.blade.php @@ -0,0 +1,41 @@ +@props(['icon' => null, 'class' => 'w-4 h-4 flex-shrink-0']) + +@php + $icons = [ + 'home' => '', + 'folder' => '', + 'chart-bar' => '', + 'calendar' => '', + 'building' => '', + 'users' => '', + 'user-group' => '', + 'menu' => '', + 'shield-check' => '', + 'key' => '', + 'cog' => '', + 'beaker' => '', + 'code' => '', + 'document-text' => '', + 'clipboard-list' => '', + 'cube' => '', + 'collection' => '', + 'tag' => '', + 'database' => '', + 'terminal' => '', + 'server' => '', + 'adjustments' => '', + 'sparkles' => '', + 'lightning-bolt' => '', + 'puzzle' => '', + 'external-link' => '', + 'default' => '', + ]; + + $path = $icons[$icon] ?? $icons['default']; +@endphp + +@if($icon) +merge(['class' => $class]) }} fill="none" stroke="currentColor" viewBox="0 0 24 24"> + {!! $path !!} + +@endif diff --git a/resources/views/components/sidebar/menu-item.blade.php b/resources/views/components/sidebar/menu-item.blade.php new file mode 100644 index 00000000..1bd460ea --- /dev/null +++ b/resources/views/components/sidebar/menu-item.blade.php @@ -0,0 +1,41 @@ +@props(['menu', 'depth' => 0]) + +@php + $sidebarMenuService = app(\App\Services\SidebarMenuService::class); + $isActive = $sidebarMenuService->isMenuActive($menu); + $paddingLeft = $depth > 0 ? ($depth * 0.75 + 0.75) . 'rem' : '0.75rem'; + + $url = $menu->url; + if ($menu->is_external && $menu->external_url) { + $url = $menu->external_url; + } + + // 라우트명이 있으면 라우트 URL 사용 + $routeName = $menu->getRouteName(); + if ($routeName && !str_contains($routeName, '*') && \Route::has($routeName)) { + $url = route($routeName); + } + + $activeClass = $isActive + ? 'bg-primary text-white hover:bg-primary' + : 'text-gray-700 hover:bg-gray-100'; + + $target = $menu->is_external ? '_blank' : '_self'; +@endphp + +
  • + is_external) target="{{ $target }}" rel="noopener noreferrer" @endif + > + @if($menu->icon) + + @endif + {{ $menu->name }} + @if($menu->is_external) + + @endif + +
  • diff --git a/resources/views/components/sidebar/menu-tree.blade.php b/resources/views/components/sidebar/menu-tree.blade.php new file mode 100644 index 00000000..538bb65e --- /dev/null +++ b/resources/views/components/sidebar/menu-tree.blade.php @@ -0,0 +1,33 @@ +@props(['menus', 'section' => null]) + +@php + // 섹션 레이블 + $sectionLabels = [ + 'main' => null, + 'tools' => '개발 도구', + 'labs' => 'R&D Labs', + ]; + + $sectionLabel = $section ? ($sectionLabels[$section] ?? null) : null; +@endphp + +@if($menus->isNotEmpty()) + {{-- 섹션 레이블 (tools, labs) --}} + @if($sectionLabel) +
  • + + {{ $sectionLabel }} + +
  • + @endif + + @foreach($menus as $menu) + @if($menu->menuChildren && $menu->menuChildren->isNotEmpty()) + {{-- 자식 메뉴가 있으면 그룹으로 렌더링 --}} + + @else + {{-- 자식 없으면 단일 아이템 --}} + + @endif + @endforeach +@endif diff --git a/resources/views/item-fields/partials/custom-fields.blade.php b/resources/views/item-fields/partials/custom-fields.blade.php index f5118d12..8ce110fc 100644 --- a/resources/views/item-fields/partials/custom-fields.blade.php +++ b/resources/views/item-fields/partials/custom-fields.blade.php @@ -18,10 +18,34 @@

    시스템 필드 시딩 또는 커스텀 필드 추가를 해보세요.

    @else + + +
    + @@ -38,6 +62,7 @@ @foreach($fields as $field) @php $isSystemField = $field->is_common || $field->storage_type === 'column'; + $isDeleted = !is_null($field->deleted_at); $hasOptions = !empty($field->options); $hasProperties = !empty($field->properties); $hasValidation = !empty($field->validation_rules); @@ -50,8 +75,13 @@ $displayConditionData = $hasDisplayCondition ? (is_array($field->display_condition) ? $field->display_condition : json_decode($field->display_condition, true)) : null; @endphp - + + - +
    + + 상태 유형
    + + @if($hasAnyJson) @@ -60,10 +90,14 @@ @endif
    - @if($field->is_active) + @if($isDeleted) + + 삭제됨 + + @elseif($field->is_active) @else @@ -182,13 +216,26 @@ class="text-gray-500 hover:text-gray-700 p-1" title="상세보기"> - - @if(!$isSystemField) + @if($isDeleted) + + + @else + + {{ config('app.name') }} +
    + + + + + + + + +
    + + +
    + + + + + + + + + \ No newline at end of file diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 61f10a46..7f2860cb 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -1,4 +1,4 @@ - + \ No newline at end of file + +// JavaScript 기반 툴팁 (overflow 제약 우회) - 이벤트 위임 방식 +function initSidebarTooltips() { + if (document.getElementById('sidebar-tooltip')) { + return; + } + + const tooltip = document.createElement('div'); + tooltip.id = 'sidebar-tooltip'; + tooltip.className = 'fixed bg-gray-800 text-white text-sm font-medium px-3 py-2 rounded-lg shadow-lg z-[9999] pointer-events-none opacity-0 transition-opacity duration-150 whitespace-nowrap'; + tooltip.style.cssText = 'display: none;'; + document.body.appendChild(tooltip); + + const sidebar = document.getElementById('sidebar'); + let currentTarget = null; + + sidebar.addEventListener('mouseover', function(e) { + if (!sidebar.classList.contains('sidebar-collapsed') && !document.documentElement.classList.contains('sidebar-is-collapsed')) { + return; + } + + const target = e.target.closest('.sidebar-nav a[title], .sidebar-nav span[title], #dev-tools-group a[title]'); + if (!target || target === currentTarget) return; + + currentTarget = target; + const title = target.getAttribute('title'); + if (!title) return; + + if (!target.hasAttribute('data-tooltip')) { + target.setAttribute('data-tooltip', title); + } + target.removeAttribute('title'); + + tooltip.textContent = title; + tooltip.style.display = 'block'; + + const rect = target.getBoundingClientRect(); + tooltip.style.left = (rect.right + 12) + 'px'; + tooltip.style.top = (rect.top + (rect.height / 2) - (tooltip.offsetHeight / 2)) + 'px'; + + requestAnimationFrame(() => { + tooltip.style.opacity = '1'; + }); + }); + + sidebar.addEventListener('mouseout', function(e) { + const target = e.target.closest('.sidebar-nav a, .sidebar-nav span, #dev-tools-group a'); + if (!target) return; + + const relatedTarget = e.relatedTarget; + if (relatedTarget && target.contains(relatedTarget)) { + return; + } + + const originalTitle = target.getAttribute('data-tooltip'); + if (originalTitle) { + target.setAttribute('title', originalTitle); + } + + currentTarget = null; + tooltip.style.opacity = '0'; + setTimeout(() => { + if (tooltip.style.opacity === '0') { + tooltip.style.display = 'none'; + } + }, 150); + }); +} + +// 페이지 로드 시 상태 복원 +document.addEventListener('DOMContentLoaded', function() { + // 사이드바 상태 복원 + const isCollapsed = localStorage.getItem('sidebar-collapsed') === 'true'; + if (isCollapsed) { + document.documentElement.classList.add('sidebar-is-collapsed'); + document.getElementById('sidebar')?.classList.add('sidebar-collapsed'); + } + + // 그룹 상태 복원 + ['lab-group', 'dev-tools-group'].forEach(function(groupId) { + const state = localStorage.getItem('sidebar-' + groupId); + if (state === 'closed') { + const group = document.getElementById(groupId); + const icon = document.getElementById(groupId + '-icon'); + if (group) group.style.display = 'none'; + if (icon) icon.style.transform = 'rotate(-90deg)'; + } + }); + + // 메뉴 그룹 상태 복원 (동적 메뉴) + document.querySelectorAll('[id^="menu-group-"]').forEach(function(group) { + const groupId = group.id; + const savedState = localStorage.getItem('menu-group-' + groupId); + const icon = document.getElementById(groupId + '-icon'); + + if (savedState === 'hidden') { + group.classList.add('hidden'); + icon?.classList.remove('rotate-180'); + } else if (savedState === 'visible') { + group.classList.remove('hidden'); + icon?.classList.add('rotate-180'); + } + }); + + // R&D Labs 탭 상태 복원 + const savedTab = localStorage.getItem('lab-active-tab'); + if (savedTab && ['s', 'a', 'm'].includes(savedTab)) { + window._skipLabScroll = true; + switchLabTab(savedTab); + switchLabFlyoutTab(savedTab); + window._skipLabScroll = false; + } + + // 플라이아웃 위치 초기화 + initLabFlyoutPosition(); + + // 스크롤 위치 복원 + restoreSidebarScroll(); + + // 사이드바 툴팁 초기화 + initSidebarTooltips(); + + // R&D Labs 메뉴 클릭 시 스크롤 + const labMenuContainer = document.getElementById('lab-menu-container'); + if (labMenuContainer) { + labMenuContainer.addEventListener('click', function(e) { + if (e.target.closest('a[href]')) { + scrollSidebarToBottom(); + } + }); + } + + // 일반 메뉴 클릭 시 스크롤 위치 초기화 + const sidebarNav = document.querySelector('.sidebar-nav'); + if (sidebarNav) { + sidebarNav.addEventListener('click', function(e) { + const link = e.target.closest('a[href]'); + if (link && !link.closest('#lab-menu-container')) { + resetSidebarScroll(); + } + }); + } +}); + diff --git a/routes/api.php b/routes/api.php index dc702fb1..32aebdff 100644 --- a/routes/api.php +++ b/routes/api.php @@ -435,6 +435,12 @@ // 커스텀 필드 일괄 삭제 Route::delete('/custom-fields', [ItemFieldController::class, 'destroyCustomFields'])->name('destroyCustomFields'); + // 커스텀 필드 복원 + Route::post('/custom-fields/{id}/restore', [ItemFieldController::class, 'restoreCustomField'])->name('restoreCustomField'); + + // 커스텀 필드 영구 삭제 + Route::delete('/custom-fields/{id}/force', [ItemFieldController::class, 'forceDestroyCustomField'])->name('forceDestroyCustomField'); + // 오류 로그 조회 (HTMX partial) Route::get('/error-logs', [ItemFieldController::class, 'errorLogs'])->name('errorLogs');