From dfe97308f3ee145fcc22d61b76cf59f5358a7065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 11 Mar 2026 02:12:51 +0900 Subject: [PATCH] =?UTF-8?q?deploy:=202026-03-11=20=EB=B0=B0=ED=8F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat: MNG→SAM 자동 로그인 토큰 (LoginToken 모델 도메인 매핑) - feat: 사용자/역할/부서 관리 개선 (Controller, Service, View) - feat: 메뉴 관리 개선 (MenuService, menu-tree.js) - fix: 문서 뷰어, FCM 토큰, 방화셔터 도면, 테넌트 테이블 뷰 수정 --- .../Api/Admin/DepartmentController.php | 48 +++ .../Controllers/Api/Admin/RoleController.php | 19 ++ .../Controllers/Api/Admin/UserController.php | 51 +++ .../ProjectManagementController.php | 9 +- app/Models/LoginToken.php | 20 +- app/Services/MenuService.php | 30 +- app/Services/UserService.php | 4 +- public/js/menu-tree.js | 53 +++- resources/views/departments/index.blade.php | 101 +++++- .../departments/partials/table.blade.php | 84 ++--- resources/views/documents/show.blade.php | 63 +++- .../views/fcm/partials/token-row.blade.php | 46 +-- .../views/fcm/partials/token-table.blade.php | 18 +- resources/views/menus/index.blade.php | 57 ++-- .../rd/fire-shutter-drawing/index.blade.php | 293 +++++++++++------- resources/views/roles/index.blade.php | 46 ++- .../views/roles/partials/table.blade.php | 44 ++- .../views/tenants/partials/table.blade.php | 72 ++--- resources/views/users/index.blade.php | 115 ++++++- .../views/users/partials/table.blade.php | 133 ++++---- routes/api.php | 7 + 21 files changed, 961 insertions(+), 352 deletions(-) diff --git a/app/Http/Controllers/Api/Admin/DepartmentController.php b/app/Http/Controllers/Api/Admin/DepartmentController.php index 66bc3345..b0bcd10d 100644 --- a/app/Http/Controllers/Api/Admin/DepartmentController.php +++ b/app/Http/Controllers/Api/Admin/DepartmentController.php @@ -178,6 +178,54 @@ public function restore(Request $request, int $id): JsonResponse ]); } + /** + * 일괄 삭제 (Soft Delete) + */ + public function bulkDelete(Request $request): JsonResponse + { + $validated = $request->validate(['ids' => 'required|array', 'ids.*' => 'integer']); + $deleted = 0; + foreach ($validated['ids'] as $id) { + if ($this->departmentService->deleteDepartment($id)) { + $deleted++; + } + } + + return response()->json(['success' => true, 'message' => "{$deleted}개 부서가 삭제되었습니다.", 'deleted' => $deleted]); + } + + /** + * 일괄 복원 + */ + public function bulkRestore(Request $request): JsonResponse + { + $validated = $request->validate(['ids' => 'required|array', 'ids.*' => 'integer']); + $restored = 0; + foreach ($validated['ids'] as $id) { + if ($this->departmentService->restoreDepartment($id)) { + $restored++; + } + } + + return response()->json(['success' => true, 'message' => "{$restored}개 부서가 복원되었습니다.", 'restored' => $restored]); + } + + /** + * 일괄 영구삭제 (슈퍼관리자 전용) + */ + public function bulkForceDelete(Request $request): JsonResponse + { + $validated = $request->validate(['ids' => 'required|array', 'ids.*' => 'integer']); + $deleted = 0; + foreach ($validated['ids'] as $id) { + if ($this->departmentService->forceDeleteDepartment($id)) { + $deleted++; + } + } + + return response()->json(['success' => true, 'message' => "{$deleted}개 부서가 영구 삭제되었습니다.", 'deleted' => $deleted]); + } + /** * 부서 영구 삭제 */ diff --git a/app/Http/Controllers/Api/Admin/RoleController.php b/app/Http/Controllers/Api/Admin/RoleController.php index 54769600..04d12f8f 100644 --- a/app/Http/Controllers/Api/Admin/RoleController.php +++ b/app/Http/Controllers/Api/Admin/RoleController.php @@ -115,6 +115,25 @@ public function update(UpdateRoleRequest $request, int $id): JsonResponse ]); } + /** + * 일괄 삭제 + */ + public function bulkDelete(Request $request): JsonResponse + { + $validated = $request->validate(['ids' => 'required|array', 'ids.*' => 'integer']); + $deleted = 0; + foreach ($validated['ids'] as $id) { + try { + $this->roleService->deleteRole($id); + $deleted++; + } catch (\Exception $e) { + continue; + } + } + + return response()->json(['success' => true, 'message' => "{$deleted}개 역할이 삭제되었습니다.", 'deleted' => $deleted]); + } + /** * 역할 삭제 (Soft Delete) */ diff --git a/app/Http/Controllers/Api/Admin/UserController.php b/app/Http/Controllers/Api/Admin/UserController.php index 020680f4..e0b99b2c 100644 --- a/app/Http/Controllers/Api/Admin/UserController.php +++ b/app/Http/Controllers/Api/Admin/UserController.php @@ -294,6 +294,57 @@ public function forceDestroy(Request $request, int $id): JsonResponse ]); } + /** + * 일괄 삭제 (Soft Delete) + */ + public function bulkDelete(Request $request): JsonResponse + { + $validated = $request->validate(['ids' => 'required|array', 'ids.*' => 'integer']); + $deleted = 0; + foreach ($validated['ids'] as $id) { + if ($this->userService->deleteUser($id)) { + $deleted++; + } + } + + return response()->json(['success' => true, 'message' => "{$deleted}명의 사용자가 삭제되었습니다.", 'deleted' => $deleted]); + } + + /** + * 일괄 복원 + */ + public function bulkRestore(Request $request): JsonResponse + { + $validated = $request->validate(['ids' => 'required|array', 'ids.*' => 'integer']); + $restored = 0; + foreach ($validated['ids'] as $id) { + if ($this->userService->restoreUser($id)) { + $restored++; + } + } + + return response()->json(['success' => true, 'message' => "{$restored}명의 사용자가 복원되었습니다.", 'restored' => $restored]); + } + + /** + * 일괄 영구삭제 (슈퍼관리자 전용) + */ + public function bulkForceDelete(Request $request): JsonResponse + { + $validated = $request->validate(['ids' => 'required|array', 'ids.*' => 'integer']); + $deleted = 0; + foreach ($validated['ids'] as $id) { + try { + $this->userService->forceDeleteUser($id); + $deleted++; + } catch (\Exception $e) { + continue; + } + } + + return response()->json(['success' => true, 'message' => "{$deleted}명의 사용자가 영구 삭제되었습니다.", 'deleted' => $deleted]); + } + /** * DEV 사이트 자동 로그인 토큰 생성 * MNG → DEV 자동 로그인용 One-Time Token 발급 diff --git a/app/Http/Controllers/ProjectManagementController.php b/app/Http/Controllers/ProjectManagementController.php index 902dbfaa..95491aa9 100644 --- a/app/Http/Controllers/ProjectManagementController.php +++ b/app/Http/Controllers/ProjectManagementController.php @@ -6,6 +6,8 @@ use App\Models\Admin\AdminPmTask; use App\Services\ProjectManagement\ImportService; use App\Services\ProjectManagement\ProjectService; +use Illuminate\Http\Response; +use Illuminate\Http\Request; use Illuminate\View\View; class ProjectManagementController extends Controller @@ -17,8 +19,13 @@ public function __construct( /** * 프로젝트 관리 대시보드 */ - public function index(): View + public function index(Request $request): View|Response { + // HTMX 부분 로드 시 @push('scripts')가 실행되지 않으므로 전체 페이지 리로드 + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('pm.index')); + } + $summary = $this->projectService->getDashboardSummary(); $statuses = AdminPmProject::getStatuses(); $taskStatuses = AdminPmTask::getStatuses(); diff --git a/app/Models/LoginToken.php b/app/Models/LoginToken.php index 23a146a1..212fec63 100644 --- a/app/Models/LoginToken.php +++ b/app/Models/LoginToken.php @@ -50,15 +50,23 @@ public static function createForUser(int $userId): self } /** - * DEV 사이트 자동 로그인 URL 생성 - * 현재 도메인에서 'mng'를 'dev'로 변경하여 동적 생성 + * SAM 사이트 자동 로그인 URL 생성 + * APP_ENV 기반 도메인 매핑 */ public function getAutoLoginUrl(): string { - $currentHost = request()->getHost(); // mng.sam.kr 또는 mng.codebridge-x.com - $devHost = str_replace('mng.', 'dev.', $currentHost); - $scheme = request()->getScheme(); // http 또는 https + $env = config('app.env'); - return "{$scheme}://{$devHost}/auto-login?token={$this->token}"; + if ($env === 'production') { + $baseUrl = 'https://stage.sam.it.kr'; + } else { + // local, DEV 등: 현재 호스트의 서브도메인을 dev로 변경 + $scheme = request()->getScheme(); + $host = request()->getHost(); + $devHost = preg_replace('/^[^.]+\./', 'dev.', $host); + $baseUrl = "{$scheme}://{$devHost}"; + } + + return "{$baseUrl}/auto-login?token={$this->token}"; } } diff --git a/app/Services/MenuService.php b/app/Services/MenuService.php index a842259f..8cbe2e66 100644 --- a/app/Services/MenuService.php +++ b/app/Services/MenuService.php @@ -455,7 +455,7 @@ public function forceDeleteMenu(int $id): array } /** - * 메뉴 활성 상태 토글 + * 메뉴 활성 상태 토글 (하위 메뉴 포함) */ public function toggleActive(int $id): bool { @@ -464,10 +464,34 @@ public function toggleActive(int $id): bool return false; } - $menu->is_active = ! $menu->is_active; + $newState = ! $menu->is_active; + $menu->is_active = $newState; $menu->updated_by = auth()->id(); + $menu->save(); - return $menu->save(); + // 하위 메뉴도 동일한 상태로 변경 + $this->setChildrenActiveState($menu, $newState); + + return true; + } + + /** + * 하위 메뉴의 활성 상태를 재귀적으로 변경 + */ + private function setChildrenActiveState($menu, bool $isActive): void + { + $children = $menu->children()->get(); + if ($children->isEmpty()) { + return; + } + + $userId = auth()->id(); + foreach ($children as $child) { + $child->is_active = $isActive; + $child->updated_by = $userId; + $child->save(); + $this->setChildrenActiveState($child, $isActive); + } } /** diff --git a/app/Services/UserService.php b/app/Services/UserService.php index e84be546..bc0fdad4 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -388,8 +388,8 @@ public function forceDeleteUser(int $id): bool $user->tenants()->detach(); DB::table('user_roles')->where('user_id', $user->id)->delete(); DB::table('department_user')->where('user_id', $user->id)->delete(); - DB::table('sales_partners')->where('user_id', $user->id)->delete(); - DB::table('sales_manager_documents')->where('user_id', $user->id)->delete(); + DB::connection('codebridge')->table('sales_partners')->where('user_id', $user->id)->delete(); + DB::connection('codebridge')->table('sales_manager_documents')->where('user_id', $user->id)->delete(); DB::table('login_tokens')->where('user_id', $user->id)->delete(); DB::table('notification_settings')->where('user_id', $user->id)->delete(); DB::table('notification_setting_group_states')->where('user_id', $user->id)->delete(); diff --git a/public/js/menu-tree.js b/public/js/menu-tree.js index 81d0c9df..8dbe70d3 100644 --- a/public/js/menu-tree.js +++ b/public/js/menu-tree.js @@ -3,8 +3,32 @@ * - 부서 권한 관리 (department-permissions) - 테이블 기반 * - 개인 권한 관리 (user-permissions) - 테이블 기반 * - 권한 분석 (permission-analyze) - div 기반 + * - 메뉴 관리 (menus) - 테이블 기반 + * + * localStorage 키: 'menu-tree-collapsed' (접힌 메뉴 ID 배열) */ +const MENU_TREE_STORAGE_KEY = 'menu-tree-collapsed'; + +// localStorage에서 접힌 메뉴 ID Set 로드 +function getCollapsedMenuIds() { + try { + const data = localStorage.getItem(MENU_TREE_STORAGE_KEY); + return data ? new Set(JSON.parse(data)) : new Set(); + } catch (e) { + return new Set(); + } +} + +// localStorage에 접힌 메뉴 ID Set 저장 +function saveCollapsedMenuIds(collapsedSet) { + try { + localStorage.setItem(MENU_TREE_STORAGE_KEY, JSON.stringify([...collapsedSet])); + } catch (e) { + // storage full 등 무시 + } +} + // 자식 메뉴 접기/펼치기 window.toggleChildren = function(menuId) { const button = document.querySelector(`.toggle-btn[data-menu-id="${menuId}"]`); @@ -13,17 +37,22 @@ window.toggleChildren = function(menuId) { const chevron = button.querySelector('.chevron-icon'); if (!chevron) return; + const collapsedSet = getCollapsedMenuIds(); const isCollapsed = chevron.classList.contains('rotate-[-90deg]'); if (isCollapsed) { // 펼치기 chevron.classList.remove('rotate-[-90deg]'); showChildren(menuId); + collapsedSet.delete(String(menuId)); } else { // 접기 chevron.classList.add('rotate-[-90deg]'); hideChildren(menuId); + collapsedSet.add(String(menuId)); } + + saveCollapsedMenuIds(collapsedSet); }; // 자식 요소 선택자 (테이블: tr.menu-row, div: .menu-item) @@ -49,6 +78,8 @@ function hideChildren(parentId) { // 전체 접기/펼치기 window.toggleAllChildren = function(collapse) { const buttons = document.querySelectorAll('.toggle-btn'); + const collapsedSet = collapse ? new Set() : new Set(); + buttons.forEach(btn => { const menuId = btn.getAttribute('data-menu-id'); const chevron = btn.querySelector('.chevron-icon'); @@ -57,11 +88,14 @@ window.toggleAllChildren = function(collapse) { if (collapse) { chevron.classList.add('rotate-[-90deg]'); hideChildren(menuId); + collapsedSet.add(String(menuId)); } else { chevron.classList.remove('rotate-[-90deg]'); showChildren(menuId); } }); + + saveCollapsedMenuIds(collapsedSet); }; // 재귀적으로 직계 자식만 표시 @@ -78,4 +112,21 @@ function showChildren(parentId) { } } }); -} \ No newline at end of file +} + +// HTMX 리로드 후 접힌 상태 복원 +window.restoreMenuTreeState = function() { + const collapsedSet = getCollapsedMenuIds(); + if (collapsedSet.size === 0) return; + + collapsedSet.forEach(menuId => { + const button = document.querySelector(`.toggle-btn[data-menu-id="${menuId}"]`); + if (!button) return; + + const chevron = button.querySelector('.chevron-icon'); + if (chevron) { + chevron.classList.add('rotate-[-90deg]'); + hideChildren(menuId); + } + }); +}; \ No newline at end of file diff --git a/resources/views/departments/index.blade.php b/resources/views/departments/index.blade.php index f48eb6eb..1a947acd 100644 --- a/resources/views/departments/index.blade.php +++ b/resources/views/departments/index.blade.php @@ -6,9 +6,31 @@

부서 관리

- - + 새 부서 - +
+ + + + + @if(auth()->user()?->is_super_admin) + + + @endif + + + 새 부서 + +
@@ -114,5 +136,78 @@ class="bg-white rounded-lg shadow-sm overflow-hidden"> }); }); }; + + // ===== 일괄 작업 ===== + window.toggleSelectAll = function(headerCheckbox) { + document.querySelectorAll('.bulk-checkbox').forEach(cb => cb.checked = headerCheckbox.checked); + updateBulkButtonState(); + }; + + window.updateBulkButtonState = function() { + const checked = document.querySelectorAll('.bulk-checkbox:checked'); + let activeCount = 0, deletedCount = 0; + checked.forEach(cb => { + if (cb.dataset.deleted === '1') { deletedCount++; } else { activeCount++; } + }); + + const deleteBtn = document.getElementById('bulkDeleteBtn'); + const restoreBtn = document.getElementById('bulkRestoreBtn'); + const forceDeleteBtn = document.getElementById('bulkForceDeleteBtn'); + + if (deleteBtn) { document.getElementById('deleteCount').textContent = activeCount; deleteBtn.disabled = activeCount === 0; } + if (restoreBtn) { document.getElementById('restoreCount').textContent = deletedCount; restoreBtn.disabled = deletedCount === 0; } + if (forceDeleteBtn) { document.getElementById('forceDeleteCount').textContent = deletedCount; forceDeleteBtn.disabled = deletedCount === 0; } + }; + + window.bulkDelete = function() { + const ids = Array.from(document.querySelectorAll('.bulk-checkbox:checked')) + .filter(cb => cb.dataset.deleted !== '1').map(cb => parseInt(cb.value)); + if (ids.length === 0) { showToast('삭제할 부서를 선택해주세요.', 'warning'); return; } + + showDeleteConfirm(`${ids.length}개 부서`, () => { + fetch('/api/admin/departments/bulk-delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }, + body: JSON.stringify({ ids }) + }).then(r => r.json()).then(data => { + showToast(data.message || '삭제 완료', data.success ? 'success' : 'error'); + htmx.trigger('#department-table', 'filterSubmit'); + }).catch(() => showToast('삭제 중 오류 발생', 'error')); + }); + }; + + window.bulkRestore = function() { + const ids = Array.from(document.querySelectorAll('.bulk-checkbox:checked')) + .filter(cb => cb.dataset.deleted === '1').map(cb => parseInt(cb.value)); + if (ids.length === 0) { showToast('복원할 부서를 선택해주세요.', 'warning'); return; } + + showConfirm(`선택한 ${ids.length}개의 부서를 복원하시겠습니까?`, () => { + fetch('/api/admin/departments/bulk-restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }, + body: JSON.stringify({ ids }) + }).then(r => r.json()).then(data => { + showToast(data.message || '복원 완료', data.success ? 'success' : 'error'); + htmx.trigger('#department-table', 'filterSubmit'); + }).catch(() => showToast('복원 중 오류 발생', 'error')); + }, { title: '부서 복원', icon: 'question' }); + }; + + window.bulkForceDelete = function() { + const ids = Array.from(document.querySelectorAll('.bulk-checkbox:checked')) + .filter(cb => cb.dataset.deleted === '1').map(cb => parseInt(cb.value)); + if (ids.length === 0) { showToast('영구삭제할 부서를 선택해주세요.', 'warning'); return; } + + showPermanentDeleteConfirm(`${ids.length}개 부서`, () => { + fetch('/api/admin/departments/bulk-force-delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }, + body: JSON.stringify({ ids }) + }).then(r => r.json()).then(data => { + showToast(data.message || '영구삭제 완료', data.success ? 'success' : 'error'); + htmx.trigger('#department-table', 'filterSubmit'); + }).catch(() => showToast('영구삭제 중 오류 발생', 'error')); + }); + }; @endpush \ No newline at end of file diff --git a/resources/views/departments/partials/table.blade.php b/resources/views/departments/partials/table.blade.php index f1b95749..02759d0c 100644 --- a/resources/views/departments/partials/table.blade.php +++ b/resources/views/departments/partials/table.blade.php @@ -3,71 +3,81 @@ - - - - - - + + + + + + + @forelse($departments as $department) - + - - - - - @empty - diff --git a/resources/views/documents/show.blade.php b/resources/views/documents/show.blade.php index ea480e4a..b0f5016a 100644 --- a/resources/views/documents/show.blade.php +++ b/resources/views/documents/show.blade.php @@ -138,6 +138,10 @@ class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg transiti if (!$fieldData) { $fieldData = $docData->where('field_key', 'bf_' . $field->label)->first(); } + // raw field_key 호환 (제품검사 요청서 등: field_key가 bf_ 없이 저장된 경우) + if (!$fieldData && $field->field_key) { + $fieldData = $docData->where('field_key', $field->field_key)->first(); + } $value = $fieldData?->field_value ?? '-'; @endphp
@@ -437,7 +441,7 @@ class="px-2 py-2 text-center text-xs font-medium text-gray-600 border border-gra @include('documents.partials.bending-inspection-data', ['inspectionData' => $inspectionData ?? null]) @else - {{-- 섹션 데이터 (테이블) --}} + {{-- 섹션 데이터 (테이블 또는 폼) --}} @if($document->template?->sections && $document->template->sections->count() > 0) @foreach($document->template->sections as $section)
@@ -451,9 +455,24 @@ class="px-2 py-2 text-center text-xs font-medium text-gray-600 border border-gra {{ $section->title }} @endif + {{-- 폼형 섹션: 데이터가 column_id 없이 field_key 기반으로 저장된 경우 (제품검사 요청서 등) --}} + @php + $sectionData = $docData->where('section_id', $section->id); + $isFormSection = $sectionData->isNotEmpty() && $sectionData->every(fn($d) => !$d->column_id); + @endphp + @if($isFormSection) +
+ @foreach($sectionData as $data) +
+
{{ $data->field_key }}
+
{{ $data->field_value ?? '-' }}
+
+ @endforeach +
+ {{-- 검사 데이터 테이블 (읽기 전용) --}} {{-- 정규화 형식: section_id + column_id + row_index + field_key 기반 조회 --}} - @if($section->items->count() > 0 && $document->template->columns->count() > 0) + @elseif($section->items->count() > 0 && $document->template->columns->count() > 0) @php // 데이터 조회 헬퍼: 정규화 형식 우선, 레거시 fallback $getData = function($sectionId, $colId, $rowIdx, $fieldKey) use ($docData) { @@ -810,6 +829,46 @@ class="px-2 py-2 text-center text-xs font-medium text-gray-600 uppercase border @endforeach @endif + {{-- 독립 테이블 데이터: section_id가 NULL이고 column_id가 있는 데이터 (제품검사 요청서의 검사대상 목록 등) --}} + @php + $standaloneTableData = $docData->filter(fn($d) => !$d->section_id && $d->column_id); + @endphp + @if($standaloneTableData->isNotEmpty() && $document->template?->columns && $document->template->columns->count() > 0) +
+

검사대상 목록

+
+
부서 코드부서명상위 부서상태정렬순서작업 + + 부서 코드부서명상위 부서상태정렬순서작업
+ + + {{ $department->code }} +
{{ $department->name }}
@if($department->description)
{{ Str::limit($department->description, 50) }}
@endif
+ {{ $department->parent?->name ?? '-' }} + @if($department->trashed()) - - 삭제됨 - + 삭제됨 @elseif($department->is_active) - - 활성 - + 활성 @else - - 비활성 - + 비활성 @endif + {{ $department->sort_order }} + @if($department->trashed()) - - - @if(auth()->user()?->is_super_admin) - - @endif +
+ + @if(auth()->user()?->is_super_admin) + + @endif +
@else - - 수정 - - +
+ + 수정 + + +
@endif
+ 부서가 없습니다.
+ + + @foreach($document->template->columns as $col) + + @endforeach + + + + @php + $maxRow = $standaloneTableData->max('row_index') ?? 0; + $colIdMap = $document->template->columns->keyBy('id'); + @endphp + @for($ri = 0; $ri <= $maxRow; $ri++) + + @foreach($document->template->columns as $col) + @php + $cellVal = $standaloneTableData + ->where('column_id', $col->id) + ->where('row_index', $ri) + ->first()?->field_value ?? ''; + @endphp + + @endforeach + + @endfor + +
{{ $col->label }}
{{ $cellVal ?: '-' }}
+ + + @endif + @endif {{-- end: 스냅샷 vs 블록빌더 vs 레거시 분기 --}} @endif {{-- end: 절곡 검사 vs 일반 섹션 분기 --}} diff --git a/resources/views/fcm/partials/token-row.blade.php b/resources/views/fcm/partials/token-row.blade.php index fea7cce4..49d0ecac 100644 --- a/resources/views/fcm/partials/token-row.blade.php +++ b/resources/views/fcm/partials/token-row.blade.php @@ -1,10 +1,10 @@ - +
{{ $token->user?->name ?? '-' }}
{{ $token->user?->email ?? '-' }}
- {{ $token->tenant?->company_name ?? '-' }} - + {{ $token->tenant?->company_name ?? '-' }} + +
{{ $token->parsed_device_name }}
@if($token->parsed_os_version) @@ -25,12 +25,12 @@ @endif
- +
{{ Str::limit($token->token, 30) }}
- + @if($token->is_active) 활성 @else @@ -40,22 +40,24 @@
{{ $token->last_error }}
@endif - + {{ $token->created_at?->format('Y-m-d H:i') ?? '-' }} - - - + +
+ + +
- + \ No newline at end of file diff --git a/resources/views/fcm/partials/token-table.blade.php b/resources/views/fcm/partials/token-table.blade.php index 335f011b..f929c05e 100644 --- a/resources/views/fcm/partials/token-table.blade.php +++ b/resources/views/fcm/partials/token-table.blade.php @@ -2,14 +2,14 @@ - - - - - - - - + + + + + + + + @@ -30,4 +30,4 @@ {{ $tokens->withQueryString()->links() }} @endif - + \ No newline at end of file diff --git a/resources/views/menus/index.blade.php b/resources/views/menus/index.blade.php index ff0208eb..ea206eb3 100644 --- a/resources/views/menus/index.blade.php +++ b/resources/views/menus/index.blade.php @@ -366,13 +366,17 @@ function initFilterForm() { document.addEventListener('DOMContentLoaded', initFilterForm); })(); - // HTMX 응답 처리 + SortableJS 초기화 (menu-table 내부 갱신용) + // HTMX 응답 처리 + SortableJS 초기화 + 메뉴 트리 상태 복원 document.body.addEventListener('htmx:afterSwap', function(event) { if (event.detail.target.id === 'menu-table') { // 테이블 로드 후 SortableJS 초기화 (전역 함수 사용) if (typeof initMenuSortable === 'function') { initMenuSortable(); } + // 접힌 메뉴 상태 복원 + if (typeof restoreMenuTreeState === 'function') { + restoreMenuTreeState(); + } } }); @@ -433,7 +437,28 @@ function initFilterForm() { }); }; - // 활성 토글 (새로고침 없이 UI만 업데이트) + // 활성 토글 UI 업데이트 헬퍼 + function setActiveUI(btn, active) { + const thumb = btn.querySelector('span'); + if (!thumb) return; + btn.classList.toggle('bg-blue-500', active); + btn.classList.toggle('bg-gray-400', !active); + thumb.classList.toggle('translate-x-3.5', active); + thumb.classList.toggle('translate-x-0.5', !active); + } + + // 하위 메뉴의 활성 토글 버튼 UI를 재귀적으로 업데이트 + function setChildrenActiveUI(parentId, active) { + const childRows = document.querySelectorAll(`tr.menu-row[data-parent-id="${parentId}"]`); + childRows.forEach(row => { + const childBtn = row.querySelector('button[onclick*="toggleActive"]'); + if (childBtn) setActiveUI(childBtn, active); + const childId = row.getAttribute('data-menu-id'); + setChildrenActiveUI(childId, active); + }); + } + + // 활성 토글 (새로고침 없이 UI만 업데이트, 하위 메뉴 포함) window.toggleActive = function(id, buttonEl) { // 버튼 요소 찾기 const btn = buttonEl || document.querySelector(`tr[data-menu-id="${id}"] button[onclick*="toggleActive"]`); @@ -441,13 +466,11 @@ function initFilterForm() { // 현재 상태 확인 (파란색이면 활성) const isCurrentlyActive = btn.classList.contains('bg-blue-500'); - const thumb = btn.querySelector('span'); + const newState = !isCurrentlyActive; - // 즉시 UI 토글 (낙관적 업데이트) - btn.classList.toggle('bg-blue-500', !isCurrentlyActive); - btn.classList.toggle('bg-gray-400', isCurrentlyActive); - thumb.classList.toggle('translate-x-3.5', !isCurrentlyActive); - thumb.classList.toggle('translate-x-0.5', isCurrentlyActive); + // 즉시 UI 토글 (낙관적 업데이트) - 본인 + 하위 메뉴 + setActiveUI(btn, newState); + setChildrenActiveUI(String(id), newState); // 백엔드 요청 fetch(`/api/admin/menus/${id}/toggle-active`, { @@ -460,20 +483,16 @@ function initFilterForm() { .then(response => response.json()) .then(data => { if (!data.success) { - // 실패 시 롤백 - btn.classList.toggle('bg-blue-500', isCurrentlyActive); - btn.classList.toggle('bg-gray-400', !isCurrentlyActive); - thumb.classList.toggle('translate-x-3.5', isCurrentlyActive); - thumb.classList.toggle('translate-x-0.5', !isCurrentlyActive); + // 실패 시 롤백 - 본인 + 하위 메뉴 + setActiveUI(btn, isCurrentlyActive); + setChildrenActiveUI(String(id), isCurrentlyActive); showToast(data.message || '상태 변경에 실패했습니다.', 'error'); } }) .catch(error => { - // 에러 시 롤백 - btn.classList.toggle('bg-blue-500', isCurrentlyActive); - btn.classList.toggle('bg-gray-400', !isCurrentlyActive); - thumb.classList.toggle('translate-x-3.5', isCurrentlyActive); - thumb.classList.toggle('translate-x-0.5', !isCurrentlyActive); + // 에러 시 롤백 - 본인 + 하위 메뉴 + setActiveUI(btn, isCurrentlyActive); + setChildrenActiveUI(String(id), isCurrentlyActive); showToast('상태 변경 중 오류가 발생했습니다.', 'error'); console.error('Toggle active error:', error); }); @@ -904,5 +923,5 @@ function setChildren(parentId) { }; - + @endpush diff --git a/resources/views/rd/fire-shutter-drawing/index.blade.php b/resources/views/rd/fire-shutter-drawing/index.blade.php index d4b78add..15584fad 100644 --- a/resources/views/rd/fire-shutter-drawing/index.blade.php +++ b/resources/views/rd/fire-shutter-drawing/index.blade.php @@ -401,7 +401,7 @@ openHeight: 3000, quantity: 1, // Guide Rail - gr: { width:70, depth:120, thickness:1.55, lip:10, flange:30, sideWall:80, backWall:67, trimThick:1.2, sealThick:0.8, sealDepth:40, slatThick:0.8, anchorSpacing:500, viewMode:'cross', showDim:true, showSeal:true }, + gr: { width:70, depth:120, thickness:1.55, lip:10, flange:26, sideWall:80, backWall:67, trimThick:1.2, sealThick:0.8, sealDepth:40, slatThick:0.8, anchorSpacing:500, viewMode:'cross', showDim:true, showSeal:true }, // Shutter Box sb: { width:2280, height:380, depth:500, thickness:1.6, shaftDia:80, bracketW:10, motorSide:'right', viewMode:'front', showShaft:true, showSlatRoll:true, showMotor:true, showBrake:true, showSpring:true }, // 3D @@ -420,7 +420,7 @@ sb:{height:550, depth:650, frontH:410, bottomOpen:75, shaftDia:120}, bk:{nmH:320, nmD:320, mtH:320, mtD:530, thick:18, sprocketR:215, motorSpR:40, motorOffset:160} }, screen: { marginW:140, marginH:350, weightFactor:2, - gr:{width:70, depth:120, thickness:1.55, lip:10, flange:30, sideWall:80, backWall:67, trimThick:1.2}, + gr:{width:70, depth:120, thickness:1.55, lip:10, flange:26, sideWall:80, backWall:67, trimThick:1.2}, slatThick:0.8, sb:{height:380, depth:500, frontH:240, bottomOpen:75, shaftDia:80}, bk:{nmH:180, nmD:180, mtH:180, mtD:380, thick:18, sprocketR:70, motorSpR:30, motorOffset:0} }, @@ -721,79 +721,153 @@ function renderGuideRail() { function renderGrCross() { const g = S.gr; - // ====== 스크린형: 참조 도면 기준 평면도 ====== - // 가로 = 깊이 방향 (120mm, 좌:벽/③④ → 우:개구부/LIP) - // 세로 = 개구부/폭 (70mm) + // ====== 스크린형: 실제 조립 구조 평면도 (5개 부재) ====== + // 좌→우: 방화벽 → ③벽연형C → ④벽연형D → ②본체(C채널) → ⑤마감재(SUS커버) → 개구부 + // 세로 = 채널 폭 70mm if (S.productType === 'screen') { - const sc = 4; - const fw = g.width * sc; // 70mm → 280px (개구부/폭, 세로) - const sw = (g.sideWall || 80) * sc; // 80mm 사이드월 - const t = g.thickness * sc; // 1.55mm - const bodyD = sw + t; // ~82mm 본체 깊이 (가로) - const fl = (g.flange || 30) * sc; // 30mm 플랜지 (슬롯=width-2*fl=10mm) - const lp = g.lip * sc; // 10mm 립 + const sc = 4; // px per mm + const t2 = g.thickness * sc; // 본체 EGI 1.55T + const t5 = (g.trimThick || 1.2) * sc; // 마감재 SUS 1.2T const sealT = g.sealThick * sc; - const sealD = g.sealDepth * sc; const slatT = Math.max(g.slatThick * sc, 2); - const bkExt = 30 * sc; // ③ 브라켓 확장 (좌측) - const tmExt = 9 * sc; // ⑤ 트림 확장 (우측) + // ── ② 본체 치수 (절곡: 10-26-80-67-80-26-10) ── + const bLip = g.lip * sc; // 10mm 립 + const bFl = g.flange * sc; // 26mm 플랜지 + const bSw = g.sideWall * sc; // 80mm 사이드월 + const bBw = g.backWall * sc; // 67mm 백월 + const bOuterW = bBw + 2 * t2; // 70mm 외폭 (세로) + const bSlot = bBw - 2 * bFl; // 15mm 슬롯 개구 - const pad = 80; - const wallW = 35; - const bx = pad + wallW + 10 + bkExt; // 본체 좌측 (백월 외면) - const by = pad; // 본체 상단 - const svgW = bx + bodyD + tmExt + pad + 50; - const svgH = fw + pad * 2 + 60; + // ── ③ 벽연형-C 치수 (절곡: 30-45-30) ── + const c3Lip = 30 * sc; // 립 30mm + const c3Body = 45 * sc; // 몸체 45mm (백월에 밀착) - // 본체 — 개별 부재로 분리 (슬롯 개구부가 열린 상태로 표현) - // 구조: 백월 + 사이드월×2 + 플랜지(안쪽절곡)×2 + 립(바깥절곡)×2 - // 사이드월은 bodyD-lp 까지, 플랜지가 안쪽으로 꺾이고, 립이 개구부 방향으로 꺾임 - const mc = '#64748b', ms = '#94a3b8', mw = 1; - const swLen = bodyD - lp; // 사이드월 길이 (플랜지 절곡점까지) + // ── ④ 벽연형-D 치수 (절곡: 11-23-40-23-11) ── + const c4a = 11 * sc, c4b = 23 * sc, c4c = 40 * sc; + + // ── ⑤ 마감재 치수 (절곡: 10-11-110-30-15-15-15) ── + const m5a = 10 * sc; // 벽측 립 + const m5b = 11 * sc; // 벽측 절곡면 + const m5c = 110 * sc; // 사이드월 커버 (긴 면) + const m5d = 30 * sc; // A각 절곡 후 면 + const m5e = 15 * sc; // 끝단 1 + const m5f = 15 * sc; // 끝단 2 + const m5g = 15 * sc; // 끝단 3 + + // ── 레이아웃 (좌→우 배치) ── + const pad = 80, wallW = 35; + + // ③ 벽연형-C의 가장 왼쪽: 벽면에서 립 30mm 돌출 + const bkTotalD = c3Lip; // 벽→본체 백월 사이 깊이 + const bx = pad + wallW + 10 + bkTotalD; // 본체 백월 외면 X + + // 본체 사이드월 끝 (립 시작점) + const swEndX = bx + t2 + bSw; // 백월두께 + 사이드월 + // 립 끝 + const lipEndX = swEndX + bLip; + + const by = pad; // 본체 상단 Y + const svgW = lipEndX + pad + 120; + const svgH = bOuterW + pad * 2 + 70; + + // 색상 정의 + const cBody = '#64748b'; // ② 본체 (EGI) + const cBk3 = '#78716c'; // ③ 벽연형-C + const cBk4 = '#92400e'; // ④ 벽연형-D + const cTrim = '#a1a1aa'; // ⑤ 마감재 (SUS) + const cClip = '#8b5cf6'; // ① 클립 + const ms = '#94a3b8'; // 스트로크 + const mw = 0.8; + + // ── 채널 내부 배경 ── + const interiorSvg = ``; + + // ── ② 본체 C채널 (절곡: lip-flange-sideWall-backWall-sideWall-flange-lip) ── const bodySvg = [ - // 채널 내부 배경 (어두운 색) - ``, - // 백월 (좌측 수직) - ``, - // 상단 사이드월 (백월→플랜지 절곡점) - ``, + // 백월 (수직, 좌측) + ``, + // 상단 사이드월 (백월→개구부 방향, 80mm) + ``, // 하단 사이드월 - ``, - // 상단 플랜지 (사이드월 끝에서 안쪽/중앙으로 절곡) - ``, - // 하단 플랜지 (사이드월 끝에서 안쪽/중앙으로 절곡) - ``, - // 상단 립 (플랜지 끝에서 개구부 방향/바깥으로 절곡) - ``, - // 하단 립 (플랜지 끝에서 개구부 방향/바깥으로 절곡) - ``, + ``, + // 상단 플랜지 (사이드월 끝에서 안쪽/중앙으로 26mm 절곡) + ``, + // 하단 플랜지 + ``, + // 상단 립 (플랜지 끝에서 개구부 방향으로 10mm) + ``, + // 하단 립 + ``, ].join('\n'); - // ③ 브라켓 (본체 좌측) - const wcBody = 45 * sc; - const bracketSvg = ` - ${g.showDim ? `③④` : ''}`; + // ── ③ 벽연형-C (절곡: 30-45-30, 백월 뒤 C브라켓) ── + // C 개구부가 ② 본체쪽(우측)을 향함: 몸체(벽쪽) → 립(본체쪽) + const c3CenterY = by + bOuterW / 2; + const c3Y = c3CenterY - c3Body / 2; + const c3BodyX = bx - c3Lip; // 몸체 X (벽쪽, 좌측 끝) + const c3LipEnd = bx; // 립 끝 = ② 백월 외면 + const bk3Svg = [ + // 몸체 (세로 45mm, 벽쪽) + ``, + // 상단 립 (몸체→본체 방향 30mm) + ``, + // 하단 립 (몸체→본체 방향 30mm) + ``, + ].join('\n'); - // ⑤ SUS 트림 (상하 외면) - const trimT = Math.max((g.trimThick || 1.2) * sc, 2); - const trimSvg = ` - - - ${g.showDim ? `` : ''}`; + // ── ④ 벽연형-D (절곡: 11-23-40-23-11, ③ 내부에 중첩) ── + // ③ 내부에 배치, C 개구부가 ② 본체쪽(우측)을 향함 + // 몸체(40) 벽쪽, 사이드(23) 본체 방향, 립(11) 안쪽 절곡 + const c4CenterY = c3CenterY; + const c4Y = c4CenterY - c4c / 2; + const c4BodyX = c3BodyX + t2 + 2; // ③ 몸체 바로 안쪽 + const c4SideEnd = c4BodyX + t2 + c4b; // 사이드 끝 (본체쪽) + const bk4Svg = [ + // 몸체 (세로 40mm, ③ 안쪽 벽쪽) + ``, + // 상단 사이드 (23mm, 본체 방향) + ``, + // 하단 사이드 + ``, + // 상단 립 (11mm, 사이드 끝에서 안쪽/중앙으로 절곡) + ``, + // 하단 립 (11mm, 사이드 끝에서 안쪽/중앙으로 절곡) + ``, + ].join('\n'); - // 방화벽 (좌측 = 벽쪽) - const wallX = pad; + // ── ⑤ 마감재 SUS 1.2T × 2 (절곡: 10-11-110-30-15-15-15) ── + // 평면도(위에서 봄): 접힌 후 외곽 프레임 형태로 보임 + // 좌측: ③ 좌단과 정렬, 우측: 본체 립 끝 바깥으로 감싸는 형태 + const trimL = bx - c3Lip; // 좌측 끝 (③ 좌단 정렬) + const trimR = lipEndX + t5; // 우측 끝 (립 바깥) + const trimX2 = trimR; // 치수선용 - // 연기차단재 — 슬롯에서 슬랫에 접촉하여 차연 - // 참조 스케치: 립 사이 슬롯에서 슬랫 양면을 감싸는 형태 + // 상단/하단 마감재: 좌측 리턴(10mm 안쪽절곡) + 수평(110mm) + 우측 리턴(30mm A각) + const trim5Svg = ` + + `; + + // ── ① 클립 (채널 내부, 개구부 근처 L형) ── + const clipArmLen = 10 * sc; + const clipLegLen = 15 * sc; + const clipT = Math.max(t2, 2); + const clipX1 = swEndX - t2 - 2; // 플랜지 내면 근처 + const clipY1 = by + t2 + 2; + const clipY2 = by + bOuterW - t2 - 2; + const clipSvg = ` + + `; + + // ── 연기차단재 ── let sealSvg = ''; if (g.showSeal) { - const slatCenterY = by + fw / 2; // 슬랫 중심 Y - const sealLen = lp * 0.8; // 차단재 길이 (립 깊이 방향) - const sealPosX = bx + swLen + 2; // 립 바깥쪽 (개구부 방향) - // 슬랫 위아래로 차단재 (슬랫을 감싸서 차연) - const sealH = Math.max(sealT * 1.5, 6); // 차단재 두께 + const slatCenterY = by + bOuterW / 2; + const sealLen = bLip * 0.8; + const sealPosX = swEndX + 2; + const sealH = Math.max(sealT * 1.5, 6); sealSvg += ``; sealSvg += ``; if (g.showDim) { @@ -801,53 +875,46 @@ function renderGrCross() { } } - // 슬랫 — 슬롯 통과 수평선 (채널 내부 + 립 구간 관통) - const slatY = by + fw / 2 - slatT / 2; - const slatX1 = bx + t + 2; - const slatX2 = bx + swLen + lp - 2; // 슬랫은 립 끝까지 관통 + // ── 슬랫 ── + const slatY = by + bOuterW / 2 - slatT / 2; + const slatX1 = bx + t2 + 2; + const slatX2 = lipEndX - 2; - // ① 클립 (채널 내부, 사이드월 내면 — L형 브라켓) - // 참조 도면 기준: 개구부 근처 사이드월에 부착된 작은 브라켓 - const clipArmLen = 10 * sc; // 10mm 돌출 - const clipLegLen = 15 * sc; // 15mm 사이드월 따라 - const clipT = Math.max(t, 2); - const clipX1 = bx + swLen - t - 2; // 플랜지 내면 근처 - // 상단 클립 (사이드월 내면에서 아래로 L형) - const clipY1 = by + t + 2; - // 하단 클립 (사이드월 내면에서 위로 L형) - const clipY2 = by + fw - t - 2; - const clipSvg = ` - - - ${g.showDim ? `` : ''}`; + // ── 방화벽 ── + const wallX = pad; - // 치수선 + // ── 치수선 ── let dimLines = ''; if (g.showDim) { - const totalLeft = bx - bkExt; - const totalRight = bx + bodyD + tmExt; + const totalLeft = bx - c3Lip; + const totalRight = trimX2; // 깊이 120mm (하단) - dimLines += ``; - dimLines += ``; - dimLines += ``; - dimLines += `${g.depth} mm`; + dimLines += ``; + dimLines += ``; + dimLines += ``; + dimLines += `${g.depth} mm`; // 폭 70mm (우측) - dimLines += ``; + dimLines += ``; dimLines += ``; - dimLines += ``; - dimLines += `${g.width} mm`; - // 플랜지 (우측 상단 — 사이드월 끝에서 안쪽 절곡) - dimLines += ``; - dimLines += `FL ${g.flange}`; - // 슬롯 개구부 (플랜지 사이, 립 끝에서) - const slotGap = g.width - 2 * g.flange; - dimLines += ``; - dimLines += `슬롯 ${slotGap}`; - // 립 깊이 (플랜지 끝에서 개구부 방향으로) - dimLines += ``; - dimLines += `립깊이 ${g.lip}`; + dimLines += ``; + dimLines += `${g.width} mm`; + // 플랜지 26mm + dimLines += ``; + dimLines += `FL ${g.flange}`; + // 슬롯 개구 + dimLines += ``; + dimLines += `슬롯 ${bSlot/sc}`; + // 립 깊이 + dimLines += ``; + dimLines += `립 ${g.lip}`; // 두께 - dimLines += `t=${g.thickness}`; + dimLines += `②t=${g.thickness} ⑤t=${g.trimThick||1.2}`; + // 부재 번호 라벨 + dimLines += ``; + dimLines += ``; + dimLines += ``; + dimLines += ``; + dimLines += ``; } const svg = ` @@ -857,26 +924,30 @@ function renderGrCross() { 가이드레일 평면도 (Plan View) — 스크린형 - - - 방화벽 - - ${trimSvg} - - ${bracketSvg} - + + + 방화벽 + + ${interiorSvg} + + ${trim5Svg} + + ${bk3Svg} + + ${bk4Svg} + ${bodySvg} ${clipSvg} ${sealSvg} - + ${g.showDim ? `슬랫 t=${g.slatThick}` : ''} - → 개구부 + → 개구부 ${dimLines} - GUIDE RAIL BODY — 스크린형 + GUIDE RAIL — 스크린형 (5부재 조립) `; displaySvg(svg); @@ -1653,7 +1724,7 @@ function createRailGroup() { if (isScreen) { // ====== 스크린형 가이드레일 (실제 조립 구조) ====== // 단면 좌표계: X=폭(0→70), Y=깊이(0=개구부/실내측, +=벽쪽) - const fl = g.flange; // 30mm (플랜지, 슬롯=width-2*fl=10mm) + const fl = g.flange; // 26mm 플랜지 (슬롯=backWall-2*fl=15mm) const lp = g.lip; // 10mm (립) const sw = g.sideWall; // 80mm (사이드월) const bw = g.backWall; // 67mm (백월) diff --git a/resources/views/roles/index.blade.php b/resources/views/roles/index.blade.php index 35a2019a..589e6179 100644 --- a/resources/views/roles/index.blade.php +++ b/resources/views/roles/index.blade.php @@ -6,9 +6,17 @@

역할 관리

- - + 새 역할 - +
+ + + + + 새 역할 + +
@@ -51,7 +59,6 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
- @endsection @push('scripts') @@ -77,5 +84,36 @@ class="bg-white rounded-lg shadow-sm overflow-hidden"> }); }); }; + + // ===== 일괄 작업 ===== + window.toggleSelectAll = function(headerCheckbox) { + document.querySelectorAll('.bulk-checkbox').forEach(cb => cb.checked = headerCheckbox.checked); + updateBulkButtonState(); + }; + + window.updateBulkButtonState = function() { + const checked = document.querySelectorAll('.bulk-checkbox:checked'); + const deleteBtn = document.getElementById('bulkDeleteBtn'); + if (deleteBtn) { + document.getElementById('deleteCount').textContent = checked.length; + deleteBtn.disabled = checked.length === 0; + } + }; + + window.bulkDelete = function() { + const ids = Array.from(document.querySelectorAll('.bulk-checkbox:checked')).map(cb => parseInt(cb.value)); + if (ids.length === 0) { showToast('삭제할 역할을 선택해주세요.', 'warning'); return; } + + showDeleteConfirm(`${ids.length}개 역할`, () => { + fetch('/api/admin/roles/bulk-delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }, + body: JSON.stringify({ ids }) + }).then(r => r.json()).then(data => { + showToast(data.message || '삭제 완료', data.success ? 'success' : 'error'); + htmx.trigger('#role-table', 'filterSubmit'); + }).catch(() => showToast('삭제 중 오류 발생', 'error')); + }); + }; @endpush \ No newline at end of file diff --git a/resources/views/roles/partials/table.blade.php b/resources/views/roles/partials/table.blade.php index f3d4e24b..b7f8df45 100644 --- a/resources/views/roles/partials/table.blade.php +++ b/resources/views/roles/partials/table.blade.php @@ -8,6 +8,9 @@
사용자테넌트플랫폼기기/버전토큰상태등록일작업사용자테넌트플랫폼기기/버전토큰상태등록일작업
+ @if($isAllTenants) @@ -23,11 +26,16 @@ @forelse($roles as $role) - + @if($isAllTenants) - - - - - - @empty - diff --git a/resources/views/tenants/partials/table.blade.php b/resources/views/tenants/partials/table.blade.php index 64cfe480..fc55f7e7 100644 --- a/resources/views/tenants/partials/table.blade.php +++ b/resources/views/tenants/partials/table.blade.php @@ -15,7 +15,7 @@ - + @@ -23,13 +23,13 @@ - - - - - - - @if($tenant->deleted_at) - {{-- 삭제된 항목 --}} - - - @else - {{-- 활성 항목 --}} - - - @endif @empty - diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php index d409e6d4..c123cf8b 100644 --- a/resources/views/users/index.blade.php +++ b/resources/views/users/index.blade.php @@ -6,9 +6,31 @@

사용자 관리

- - + 새 사용자 - +
+ + + + + @if(auth()->user()?->is_super_admin) + + + @endif + + + 새 사용자 + +
@@ -123,10 +145,9 @@ class="bg-white rounded-lg shadow-sm overflow-hidden"> }); }; - // DEV 사이트 접속 (자동 로그인) + // SAM 사이트 접속 (자동 로그인) window.openDevSite = function(id, name) { - showConfirm(`"${name}" 사용자로 DEV 사이트에 접속하시겠습니까?`, () => { - // 토큰 생성 API 호출 + showConfirm(`"${name}" 사용자로 SAM 사이트에 접속하시겠습니까?`, () => { fetch(`/api/admin/users/${id}/login-token`, { method: 'POST', headers: { @@ -137,17 +158,89 @@ class="bg-white rounded-lg shadow-sm overflow-hidden"> .then(response => response.json()) .then(data => { if (data.success && data.data?.url) { - // 새 창에서 DEV 사이트 열기 window.open(data.data.url, '_blank'); } else { - showAlert(data.message || 'DEV 접속 토큰 생성에 실패했습니다.', 'error'); + showAlert(data.message || 'SAM 접속 토큰 생성에 실패했습니다.', 'error'); } }) .catch(error => { - console.error('DEV 접속 오류:', error); - showAlert('DEV 접속 중 오류가 발생했습니다.', 'error'); + console.error('SAM 접속 오류:', error); + showAlert('SAM 접속 중 오류가 발생했습니다.', 'error'); }); - }, { title: 'DEV 사이트 접속', icon: 'question' }); + }, { title: 'SAM 사이트 접속', icon: 'question' }); + }; + + // ===== 일괄 작업 ===== + window.toggleSelectAll = function(headerCheckbox) { + document.querySelectorAll('.bulk-checkbox').forEach(cb => cb.checked = headerCheckbox.checked); + updateBulkButtonState(); + }; + + window.updateBulkButtonState = function() { + const checked = document.querySelectorAll('.bulk-checkbox:checked'); + let activeCount = 0, deletedCount = 0; + checked.forEach(cb => { + if (cb.dataset.deleted === '1') { deletedCount++; } else { activeCount++; } + }); + + const deleteBtn = document.getElementById('bulkDeleteBtn'); + const restoreBtn = document.getElementById('bulkRestoreBtn'); + const forceDeleteBtn = document.getElementById('bulkForceDeleteBtn'); + + if (deleteBtn) { document.getElementById('deleteCount').textContent = activeCount; deleteBtn.disabled = activeCount === 0; } + if (restoreBtn) { document.getElementById('restoreCount').textContent = deletedCount; restoreBtn.disabled = deletedCount === 0; } + if (forceDeleteBtn) { document.getElementById('forceDeleteCount').textContent = deletedCount; forceDeleteBtn.disabled = deletedCount === 0; } + }; + + window.bulkDelete = function() { + const ids = Array.from(document.querySelectorAll('.bulk-checkbox:checked')) + .filter(cb => cb.dataset.deleted !== '1').map(cb => parseInt(cb.value)); + if (ids.length === 0) { showToast('삭제할 사용자를 선택해주세요.', 'warning'); return; } + + showDeleteConfirm(`${ids.length}명 사용자`, () => { + fetch('/api/admin/users/bulk-delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }, + body: JSON.stringify({ ids }) + }).then(r => r.json()).then(data => { + showToast(data.message || '삭제 완료', data.success ? 'success' : 'error'); + htmx.trigger('#user-table', 'filterSubmit'); + }).catch(() => showToast('삭제 중 오류 발생', 'error')); + }); + }; + + window.bulkRestore = function() { + const ids = Array.from(document.querySelectorAll('.bulk-checkbox:checked')) + .filter(cb => cb.dataset.deleted === '1').map(cb => parseInt(cb.value)); + if (ids.length === 0) { showToast('복원할 사용자를 선택해주세요.', 'warning'); return; } + + showConfirm(`선택한 ${ids.length}명의 사용자를 복원하시겠습니까?`, () => { + fetch('/api/admin/users/bulk-restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }, + body: JSON.stringify({ ids }) + }).then(r => r.json()).then(data => { + showToast(data.message || '복원 완료', data.success ? 'success' : 'error'); + htmx.trigger('#user-table', 'filterSubmit'); + }).catch(() => showToast('복원 중 오류 발생', 'error')); + }, { title: '사용자 복원', icon: 'question' }); + }; + + window.bulkForceDelete = function() { + const ids = Array.from(document.querySelectorAll('.bulk-checkbox:checked')) + .filter(cb => cb.dataset.deleted === '1').map(cb => parseInt(cb.value)); + if (ids.length === 0) { showToast('영구삭제할 사용자를 선택해주세요.', 'warning'); return; } + + showPermanentDeleteConfirm(`${ids.length}명 사용자`, () => { + fetch('/api/admin/users/bulk-force-delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }, + body: JSON.stringify({ ids }) + }).then(r => r.json()).then(data => { + showToast(data.message || '영구삭제 완료', data.success ? 'success' : 'error'); + htmx.trigger('#user-table', 'filterSubmit'); + }).catch(() => showToast('영구삭제 중 오류 발생', 'error')); + }); }; @endpush \ No newline at end of file diff --git a/resources/views/users/partials/table.blade.php b/resources/views/users/partials/table.blade.php index 0de5b388..cdd9c562 100644 --- a/resources/views/users/partials/table.blade.php +++ b/resources/views/users/partials/table.blade.php @@ -3,15 +3,17 @@
+ + ID테넌트
+ + + {{ $role->id }} + @if($role->tenant) @endif - +
{{ $role->name }}
+ {{ strtoupper($role->guard_name ?? '-') }} +
{{ $role->description ?? '-' }}
+ {{ $role->permissions_count ?? 0 }} + {{ $role->created_at?->format('Y-m-d') ?? '-' }} - - 수정 - - + +
+ + 수정 + + +
+ 등록된 역할이 없습니다.
메뉴 역할 사용량관리관리
+ {{ $tenant->id }} + {{ $tenant->created_at?->format('ymd') ?? '-' }} +
대표: {{ $tenant->ceo_name }}
@endif
+ {{ $tenant->code }} + +
{{ $tenant->email ?? '-' }}
@if($tenant->phone)
{{ $tenant->phone_formatted }}
@@ -83,7 +83,7 @@
{{ $tenant->roles_count ?? 0 }} +
{{ $tenant->storage_used_formatted }}
- - - @if(auth()->user()?->is_super_admin) - + + @if($tenant->deleted_at) +
+ + @if(auth()->user()?->is_super_admin) + + @endif +
@else - - +
+ + 수정 + + +
@endif
- - 수정 - - - -
+ 등록된 테넌트가 없습니다.
- - - - - - - - - + + + + + + + + + @@ -19,20 +21,25 @@ - + - - - - - - - - diff --git a/routes/api.php b/routes/api.php index 1decaeb3..4d5395ad 100644 --- a/routes/api.php +++ b/routes/api.php @@ -267,6 +267,7 @@ // 역할 관리 API Route::prefix('roles')->name('roles.')->group(function () { Route::get('/', [RoleController::class, 'index'])->name('index'); + Route::post('/bulk-delete', [RoleController::class, 'bulkDelete'])->name('bulkDelete'); Route::post('/', [RoleController::class, 'store'])->name('store'); Route::get('/{id}', [RoleController::class, 'show'])->name('show'); Route::put('/{id}', [RoleController::class, 'update'])->name('update'); @@ -276,6 +277,8 @@ // 부서 관리 API Route::prefix('departments')->name('departments.')->group(function () { Route::get('/', [DepartmentController::class, 'index'])->name('index'); + Route::post('/bulk-delete', [DepartmentController::class, 'bulkDelete'])->name('bulkDelete'); + Route::post('/bulk-restore', [DepartmentController::class, 'bulkRestore'])->name('bulkRestore'); Route::post('/', [DepartmentController::class, 'store'])->name('store'); Route::get('/{id}', [DepartmentController::class, 'show'])->name('show'); Route::put('/{id}', [DepartmentController::class, 'update'])->name('update'); @@ -286,6 +289,7 @@ // 슈퍼관리자 전용 액션 (영구삭제) Route::middleware('super.admin')->group(function () { + Route::post('/bulk-force-delete', [DepartmentController::class, 'bulkForceDelete'])->name('bulkForceDelete'); Route::delete('/{id}/force', [DepartmentController::class, 'forceDelete'])->name('forceDelete'); }); }); @@ -293,6 +297,8 @@ // 사용자 관리 API Route::prefix('users')->name('users.')->group(function () { Route::get('/', [UserController::class, 'index'])->name('index'); + Route::post('/bulk-delete', [UserController::class, 'bulkDelete'])->name('bulkDelete'); + Route::post('/bulk-restore', [UserController::class, 'bulkRestore'])->name('bulkRestore'); Route::post('/', [UserController::class, 'store'])->name('store'); Route::get('/{id}', [UserController::class, 'show'])->name('show'); Route::put('/{id}', [UserController::class, 'update'])->name('update'); @@ -306,6 +312,7 @@ // 슈퍼관리자 전용 액션 (영구삭제) Route::middleware('super.admin')->group(function () { + Route::post('/bulk-force-delete', [UserController::class, 'bulkForceDelete'])->name('bulkForceDelete'); Route::delete('/{id}/force', [UserController::class, 'forceDestroy'])->name('forceDestroy'); });
ID이름테넌트이메일부서역할재직상태작업 + + ID이름테넌트부서역할재직상태작업
+ + + {{ $user->user_id ?? '-' }} -
- {{ $user->name }} -
+
+
{{ $user->name }}
+
{{ $user->email }}
@if($user->is_super_admin && auth()->user()?->is_super_admin) 슈퍼 관리자 @endif
+ @if($user->tenants && $user->tenants->count() > 0) -
+
@foreach($user->tenants as $tenant) - @endif
- {{ $user->email }} - + @if($user->departmentUsers && $user->departmentUsers->count() > 0) -
+
@foreach($user->departmentUsers as $du) {{ $du->department?->name ?? '-' }} @@ -63,11 +67,11 @@ - @endif
+ @if($user->userRoles && $user->userRoles->count() > 0) -
+
@foreach($user->userRoles as $ur) -
+
{{ $ur->role?->name ?? '-' }}
@@ -81,7 +85,7 @@ - @endif
+ @php $empStatus = $user->employee_status ?? 'active'; @endphp @if($empStatus === 'active') 재직 @@ -91,56 +95,55 @@ 퇴직 @endif + @if($user->is_active) - - 활성 - + 활성 @else - - 비활성 - + 비활성 @endif + @php - // 슈퍼관리자 보호: 일반관리자가 슈퍼관리자를 수정/삭제할 수 없음 $canModify = ! $user->is_super_admin || auth()->user()?->is_super_admin; @endphp @if($user->deleted_at) - - @if($canModify) - - @endif - @if(auth()->user()?->is_super_admin) - - @endif - @if(!$canModify && !auth()->user()?->is_super_admin) - 삭제됨 - @endif +
+ @if($canModify) + + @endif + @if(auth()->user()?->is_super_admin) + + @endif + @if(!$canModify && !auth()->user()?->is_super_admin) + 삭제됨 + @endif +
@elseif($canModify) - - - - 수정 - - +
+ +
+ + 수정 + + +
+
@else - 수정 불가 @endif