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
+
+
+ {{-- 그룹 헤더 (접기/펼치기 버튼) --}}
+
+
+ {{-- 하위 메뉴 --}}
+
+ @foreach($children as $child)
+ @if($child->menuChildren && $child->menuChildren->isNotEmpty())
+ {{-- 하위에 또 그룹이 있는 경우 (중첩 그룹) --}}
+
+ @else
+ {{-- 일반 메뉴 아이템 --}}
+
+ @endif
+ @endforeach
+
+
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)
+
+@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
+
+ @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)
+
+
+
+ @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
+
+
+
+
+ 0개 선택됨
+
+
+
+
+
+
+
+
+ |
+
+ |
|
상태 |
유형 |
@@ -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
+
|