deploy: 2026-03-11 배포
- feat: MNG→SAM 자동 로그인 토큰 (LoginToken 모델 도메인 매핑) - feat: 사용자/역할/부서 관리 개선 (Controller, Service, View) - feat: 메뉴 관리 개선 (MenuService, menu-tree.js) - fix: 문서 뷰어, FCM 토큰, 방화셔터 도면, 테넌트 테이블 뷰 수정
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 영구 삭제
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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 발급
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -6,9 +6,31 @@
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">부서 관리</h1>
|
||||
<a href="{{ route('departments.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center w-full sm:w-auto">
|
||||
+ 새 부서
|
||||
</a>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<!-- 일괄 삭제 -->
|
||||
<button onclick="bulkDelete()" id="bulkDeleteBtn"
|
||||
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled>
|
||||
선택 삭제 (<span id="deleteCount">0</span>)
|
||||
</button>
|
||||
<!-- 일괄 복원 -->
|
||||
<button onclick="bulkRestore()" id="bulkRestoreBtn"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled>
|
||||
선택 복원 (<span id="restoreCount">0</span>)
|
||||
</button>
|
||||
@if(auth()->user()?->is_super_admin)
|
||||
<!-- 일괄 영구삭제 (슈퍼관리자) -->
|
||||
<button onclick="bulkForceDelete()" id="bulkForceDeleteBtn"
|
||||
class="bg-gray-800 hover:bg-gray-900 text-white px-4 py-2 rounded-lg transition flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled>
|
||||
선택 영구삭제 (<span id="forceDeleteCount">0</span>)
|
||||
</button>
|
||||
@endif
|
||||
<a href="{{ route('departments.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center">
|
||||
+ 새 부서
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 영역 -->
|
||||
@@ -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'));
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@endpush
|
||||
@@ -3,71 +3,81 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">부서 코드</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">부서명</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">상위 부서</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">상태</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">정렬순서</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">작업</th>
|
||||
<th class="px-3 py-3 text-center w-10">
|
||||
<input type="checkbox" onchange="toggleSelectAll(this)" class="rounded border-gray-300">
|
||||
</th>
|
||||
<th class="px-3 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">부서 코드</th>
|
||||
<th class="px-3 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">부서명</th>
|
||||
<th class="px-3 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">상위 부서</th>
|
||||
<th class="px-3 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">상태</th>
|
||||
<th class="px-3 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">정렬순서</th>
|
||||
<th class="px-3 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@forelse($departments as $department)
|
||||
<tr class="{{ $department->trashed() ? 'bg-red-50' : 'hover:bg-gray-50' }}">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm {{ $department->trashed() ? 'text-gray-400' : 'text-gray-900' }}">
|
||||
<td class="px-3 py-4 text-center">
|
||||
<input type="checkbox" class="bulk-checkbox rounded border-gray-300"
|
||||
value="{{ $department->id }}"
|
||||
data-deleted="{{ $department->trashed() ? '1' : '0' }}"
|
||||
onchange="updateBulkButtonState()">
|
||||
</td>
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-center {{ $department->trashed() ? 'text-gray-400' : 'text-gray-900' }}">
|
||||
{{ $department->code }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 text-left">
|
||||
<div class="text-sm font-medium {{ $department->trashed() ? 'text-gray-400' : 'text-gray-900' }}">{{ $department->name }}</div>
|
||||
@if($department->description)
|
||||
<div class="text-sm {{ $department->trashed() ? 'text-gray-300' : 'text-gray-500' }}">{{ Str::limit($department->description, 50) }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm {{ $department->trashed() ? 'text-gray-400' : 'text-gray-500' }}">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-center {{ $department->trashed() ? 'text-gray-400' : 'text-gray-500' }}">
|
||||
{{ $department->parent?->name ?? '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-center">
|
||||
@if($department->trashed())
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||
삭제됨
|
||||
</span>
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">삭제됨</span>
|
||||
@elseif($department->is_active)
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
활성
|
||||
</span>
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">활성</span>
|
||||
@else
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
비활성
|
||||
</span>
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">비활성</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm {{ $department->trashed() ? 'text-gray-400' : 'text-gray-500' }}">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-center {{ $department->trashed() ? 'text-gray-400' : 'text-gray-500' }}">
|
||||
{{ $department->sort_order }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||
@if($department->trashed())
|
||||
<!-- 삭제된 항목 - 복원은 일반관리자도 가능, 영구삭제는 슈퍼관리자만 -->
|
||||
<button onclick="confirmRestore({{ $department->id }}, '{{ $department->name }}')" class="text-green-600 hover:text-green-900 mr-3">
|
||||
복원
|
||||
</button>
|
||||
@if(auth()->user()?->is_super_admin)
|
||||
<button onclick="confirmForceDelete({{ $department->id }}, '{{ $department->name }}')" class="text-red-600 hover:text-red-900">
|
||||
영구삭제
|
||||
</button>
|
||||
@endif
|
||||
<div class="inline-flex flex-col gap-1">
|
||||
<button onclick="confirmRestore({{ $department->id }}, '{{ $department->name }}')"
|
||||
class="w-full px-2.5 py-1 text-xs font-medium rounded bg-green-100 text-green-700 hover:bg-green-200 transition text-center">
|
||||
복원
|
||||
</button>
|
||||
@if(auth()->user()?->is_super_admin)
|
||||
<button onclick="confirmForceDelete({{ $department->id }}, '{{ $department->name }}')"
|
||||
class="w-full px-2.5 py-1 text-xs font-medium rounded bg-red-100 text-red-700 hover:bg-red-200 transition text-center">
|
||||
영구삭제
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<a href="{{ route('departments.edit', $department->id) }}" class="text-blue-600 hover:text-blue-900 mr-3">
|
||||
수정
|
||||
</a>
|
||||
<button onclick="confirmDelete({{ $department->id }}, '{{ $department->name }}')" class="text-red-600 hover:text-red-900">
|
||||
삭제
|
||||
</button>
|
||||
<div class="inline-flex gap-1">
|
||||
<a href="{{ route('departments.edit', $department->id) }}"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded bg-blue-100 text-blue-700 hover:bg-blue-200 transition text-center">
|
||||
수정
|
||||
</a>
|
||||
<button onclick="confirmDelete({{ $department->id }}, '{{ $department->name }}')"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded bg-red-100 text-red-700 hover:bg-red-200 transition text-center">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-4 text-center text-gray-500">
|
||||
<td colspan="7" class="px-6 py-4 text-center text-gray-500">
|
||||
부서가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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
|
||||
<div class="{{ $field->field_type === 'textarea' ? 'col-span-2' : '' }}">
|
||||
@@ -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)
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
@@ -451,9 +455,24 @@ class="px-2 py-2 text-center text-xs font-medium text-gray-600 border border-gra
|
||||
<img src="{{ $sectionImgUrl }}" alt="{{ $section->title }}" class="max-w-full h-auto mb-4 rounded border">
|
||||
@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)
|
||||
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@foreach($sectionData as $data)
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">{{ $data->field_key }}</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ $data->field_value ?? '-' }}</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
|
||||
{{-- 검사 데이터 테이블 (읽기 전용) --}}
|
||||
{{-- 정규화 형식: 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)
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">검사대상 목록</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full border border-gray-300 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
@foreach($document->template->columns as $col)
|
||||
<th class="px-3 py-2 text-center text-xs font-medium text-gray-600 border border-gray-300">{{ $col->label }}</th>
|
||||
@endforeach
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@php
|
||||
$maxRow = $standaloneTableData->max('row_index') ?? 0;
|
||||
$colIdMap = $document->template->columns->keyBy('id');
|
||||
@endphp
|
||||
@for($ri = 0; $ri <= $maxRow; $ri++)
|
||||
<tr class="hover:bg-gray-50">
|
||||
@foreach($document->template->columns as $col)
|
||||
@php
|
||||
$cellVal = $standaloneTableData
|
||||
->where('column_id', $col->id)
|
||||
->where('row_index', $ri)
|
||||
->first()?->field_value ?? '';
|
||||
@endphp
|
||||
<td class="px-3 py-2 border border-gray-300 text-center text-sm">{{ $cellVal ?: '-' }}</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@endfor
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@endif {{-- end: 스냅샷 vs 블록빌더 vs 레거시 분기 --}}
|
||||
|
||||
@endif {{-- end: 절곡 검사 vs 일반 섹션 분기 --}}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<tr id="token-row-{{ $token->id }}" class="{{ !$token->is_active ? 'bg-red-50' : '' }}">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-left">
|
||||
<div class="text-sm font-medium {{ !$token->is_active ? 'text-gray-500' : 'text-gray-900' }}">{{ $token->user?->name ?? '-' }}</div>
|
||||
<div class="text-sm text-gray-500">{{ $token->user?->email ?? '-' }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ $token->tenant?->company_name ?? '-' }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-500 text-left">{{ $token->tenant?->company_name ?? '-' }}</td>
|
||||
<td class="px-3 py-4 whitespace-nowrap text-center">
|
||||
<span class="px-2 py-1 text-xs rounded-full
|
||||
@if($token->platform === 'android') bg-green-100 text-green-800
|
||||
@elseif($token->platform === 'ios') bg-blue-100 text-blue-800
|
||||
@@ -13,7 +13,7 @@
|
||||
{{ $token->platform }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-left">
|
||||
<div class="text-sm text-gray-900" title="{{ $token->device_name }}">{{ $token->parsed_device_name }}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
@if($token->parsed_os_version)
|
||||
@@ -25,12 +25,12 @@
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-left">
|
||||
<div class="text-xs text-gray-500 font-mono truncate max-w-[150px]" title="{{ $token->token }}">
|
||||
{{ Str::limit($token->token, 30) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-center">
|
||||
@if($token->is_active)
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800">활성</span>
|
||||
@else
|
||||
@@ -40,22 +40,24 @@
|
||||
<div class="text-xs text-red-500 mt-1">{{ $token->last_error }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-500 text-center">
|
||||
{{ $token->created_at?->format('Y-m-d H:i') ?? '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<button hx-post="{{ route('fcm.tokens.toggle', $token->id) }}"
|
||||
hx-target="#token-row-{{ $token->id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="text-blue-600 hover:text-blue-900 mr-3">
|
||||
{{ $token->is_active ? '비활성화' : '활성화' }}
|
||||
</button>
|
||||
<button hx-delete="{{ route('fcm.tokens.delete', $token->id) }}"
|
||||
hx-target="#token-row-{{ $token->id }}"
|
||||
hx-swap="outerHTML swap:1s"
|
||||
hx-confirm="토큰을 삭제하시겠습니까?"
|
||||
class="text-red-600 hover:text-red-900">
|
||||
삭제
|
||||
</button>
|
||||
<td class="px-3 py-4 whitespace-nowrap text-center">
|
||||
<div class="inline-flex gap-1">
|
||||
<button hx-post="{{ route('fcm.tokens.toggle', $token->id) }}"
|
||||
hx-target="#token-row-{{ $token->id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded {{ $token->is_active ? 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200' : 'bg-green-100 text-green-700 hover:bg-green-200' }} transition text-center">
|
||||
{{ $token->is_active ? '비활성화' : '활성화' }}
|
||||
</button>
|
||||
<button hx-delete="{{ route('fcm.tokens.delete', $token->id) }}"
|
||||
hx-target="#token-row-{{ $token->id }}"
|
||||
hx-swap="outerHTML swap:1s"
|
||||
hx-confirm="토큰을 삭제하시겠습니까?"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded bg-red-100 text-red-700 hover:bg-red-200 transition text-center">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tr>
|
||||
@@ -2,14 +2,14 @@
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">사용자</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">테넌트</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">플랫폼</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">기기/버전</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">토큰</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">상태</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">등록일</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">작업</th>
|
||||
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">사용자</th>
|
||||
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">테넌트</th>
|
||||
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">플랫폼</th>
|
||||
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">기기/버전</th>
|
||||
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">토큰</th>
|
||||
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">상태</th>
|
||||
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">등록일</th>
|
||||
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@@ -30,4 +30,4 @@
|
||||
{{ $tokens->withQueryString()->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -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) {
|
||||
};
|
||||
|
||||
</script>
|
||||
<script src="{{ asset('js/menu-tree.js') }}"></script>
|
||||
<script src="{{ asset('js/menu-tree.js') }}?v={{ filemtime(public_path('js/menu-tree.js')) }}"></script>
|
||||
@endpush
|
||||
|
||||
@@ -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 = `<rect x="${bx+t2}" y="${by+t2}" width="${bSw-t2}" height="${bOuterW-2*t2}" fill="#0f172a"/>`;
|
||||
|
||||
// ── ② 본체 C채널 (절곡: lip-flange-sideWall-backWall-sideWall-flange-lip) ──
|
||||
const bodySvg = [
|
||||
// 채널 내부 배경 (어두운 색)
|
||||
`<rect x="${bx+t}" y="${by+t}" width="${swLen-2*t}" height="${fw-2*t}" fill="#0f172a"/>`,
|
||||
// 백월 (좌측 수직)
|
||||
`<rect x="${bx}" y="${by}" width="${t}" height="${fw}" fill="${mc}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
// 상단 사이드월 (백월→플랜지 절곡점)
|
||||
`<rect x="${bx}" y="${by}" width="${swLen}" height="${t}" fill="${mc}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
// 백월 (수직, 좌측)
|
||||
`<rect x="${bx}" y="${by}" width="${t2}" height="${bOuterW}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
// 상단 사이드월 (백월→개구부 방향, 80mm)
|
||||
`<rect x="${bx}" y="${by}" width="${t2+bSw}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
// 하단 사이드월
|
||||
`<rect x="${bx}" y="${by+fw-t}" width="${swLen}" height="${t}" fill="${mc}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
// 상단 플랜지 (사이드월 끝에서 안쪽/중앙으로 절곡)
|
||||
`<rect x="${bx+swLen-t}" y="${by+t}" width="${t}" height="${fl-t}" fill="${mc}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
// 하단 플랜지 (사이드월 끝에서 안쪽/중앙으로 절곡)
|
||||
`<rect x="${bx+swLen-t}" y="${by+fw-fl}" width="${t}" height="${fl-t}" fill="${mc}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
// 상단 립 (플랜지 끝에서 개구부 방향/바깥으로 절곡)
|
||||
`<rect x="${bx+swLen}" y="${by+fl-t}" width="${lp}" height="${t}" fill="${mc}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
// 하단 립 (플랜지 끝에서 개구부 방향/바깥으로 절곡)
|
||||
`<rect x="${bx+swLen}" y="${by+fw-fl}" width="${lp}" height="${t}" fill="${mc}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
`<rect x="${bx}" y="${by+bOuterW-t2}" width="${t2+bSw}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
// 상단 플랜지 (사이드월 끝에서 안쪽/중앙으로 26mm 절곡)
|
||||
`<rect x="${swEndX-t2}" y="${by+t2}" width="${t2}" height="${bFl-t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
// 하단 플랜지
|
||||
`<rect x="${swEndX-t2}" y="${by+bOuterW-bFl}" width="${t2}" height="${bFl-t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
// 상단 립 (플랜지 끝에서 개구부 방향으로 10mm)
|
||||
`<rect x="${swEndX}" y="${by+bFl-t2}" width="${bLip}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
// 하단 립
|
||||
`<rect x="${swEndX}" y="${by+bOuterW-bFl}" width="${bLip}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
].join('\n');
|
||||
|
||||
// ③ 브라켓 (본체 좌측)
|
||||
const wcBody = 45 * sc;
|
||||
const bracketSvg = `<rect x="${bx-bkExt}" y="${by+fw/2-wcBody/2}" width="${bkExt}" height="${wcBody}" fill="#4b5563" stroke="#64748b" stroke-width="1" opacity="0.6" rx="1"/>
|
||||
${g.showDim ? `<text x="${bx-bkExt/2}" y="${by+fw/2+3}" fill="#94a3b8" font-size="8" font-weight="700" text-anchor="middle" font-family="Pretendard">③④</text>` : ''}`;
|
||||
// ── ③ 벽연형-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, 벽쪽)
|
||||
`<rect x="${c3BodyX}" y="${c3Y}" width="${t2}" height="${c3Body}" fill="${cBk3}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
// 상단 립 (몸체→본체 방향 30mm)
|
||||
`<rect x="${c3BodyX}" y="${c3Y}" width="${c3Lip}" height="${t2}" fill="${cBk3}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
// 하단 립 (몸체→본체 방향 30mm)
|
||||
`<rect x="${c3BodyX}" y="${c3Y+c3Body-t2}" width="${c3Lip}" height="${t2}" fill="${cBk3}" stroke="${ms}" stroke-width="${mw}"/>`,
|
||||
].join('\n');
|
||||
|
||||
// ⑤ SUS 트림 (상하 외면)
|
||||
const trimT = Math.max((g.trimThick || 1.2) * sc, 2);
|
||||
const trimSvg = `
|
||||
<rect x="${bx}" y="${by-trimT}" width="${bodyD+tmExt}" height="${trimT}" fill="#9ca3af" opacity="0.4" rx="1"/>
|
||||
<rect x="${bx}" y="${by+fw}" width="${bodyD+tmExt}" height="${trimT}" fill="#9ca3af" opacity="0.4" rx="1"/>
|
||||
${g.showDim ? `<text x="${bx+bodyD+tmExt+3}" y="${by-1}" fill="#9ca3af" font-size="7" font-weight="700" text-anchor="start" font-family="Pretendard">⑤</text>` : ''}`;
|
||||
// ── ④ 벽연형-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, ③ 안쪽 벽쪽)
|
||||
`<rect x="${c4BodyX}" y="${c4Y}" width="${t2}" height="${c4c}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
|
||||
// 상단 사이드 (23mm, 본체 방향)
|
||||
`<rect x="${c4BodyX+t2}" y="${c4Y}" width="${c4b}" height="${t2}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
|
||||
// 하단 사이드
|
||||
`<rect x="${c4BodyX+t2}" y="${c4Y+c4c-t2}" width="${c4b}" height="${t2}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
|
||||
// 상단 립 (11mm, 사이드 끝에서 안쪽/중앙으로 절곡)
|
||||
`<rect x="${c4SideEnd-t2}" y="${c4Y+t2}" width="${t2}" height="${c4a}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
|
||||
// 하단 립 (11mm, 사이드 끝에서 안쪽/중앙으로 절곡)
|
||||
`<rect x="${c4SideEnd-t2}" y="${c4Y+c4c-t2-c4a}" width="${t2}" height="${c4a}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
|
||||
].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 = `
|
||||
<polyline points="${trimL},${by-t5+m5a} ${trimL},${by-t5} ${trimR},${by-t5} ${trimR},${by+m5d}"
|
||||
fill="none" stroke="${cTrim}" stroke-width="${t5}" stroke-linejoin="miter" opacity="0.7"/>
|
||||
<polyline points="${trimL},${by+bOuterW+t5-m5a} ${trimL},${by+bOuterW+t5} ${trimR},${by+bOuterW+t5} ${trimR},${by+bOuterW-m5d}"
|
||||
fill="none" stroke="${cTrim}" stroke-width="${t5}" stroke-linejoin="miter" opacity="0.7"/>`;
|
||||
|
||||
// ── ① 클립 (채널 내부, 개구부 근처 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 = `
|
||||
<path d="M${clipX1},${clipY1} L${clipX1},${clipY1+clipLegLen} L${clipX1-clipArmLen},${clipY1+clipLegLen} L${clipX1-clipArmLen},${clipY1+clipLegLen-clipT} L${clipX1-clipT},${clipY1+clipLegLen-clipT} L${clipX1-clipT},${clipY1} Z" fill="${cClip}" opacity="0.4"/>
|
||||
<path d="M${clipX1},${clipY2} L${clipX1},${clipY2-clipLegLen} L${clipX1-clipArmLen},${clipY2-clipLegLen} L${clipX1-clipArmLen},${clipY2-clipLegLen+clipT} L${clipX1-clipT},${clipY2-clipLegLen+clipT} L${clipX1-clipT},${clipY2} Z" fill="${cClip}" opacity="0.4"/>`;
|
||||
|
||||
// ── 연기차단재 ──
|
||||
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 += `<rect x="${sealPosX}" y="${slatCenterY - slatT/2 - sealH}" width="${sealLen}" height="${sealH}" fill="#f97316" opacity="0.7" rx="1"/>`;
|
||||
sealSvg += `<rect x="${sealPosX}" y="${slatCenterY + slatT/2}" width="${sealLen}" height="${sealH}" fill="#f97316" opacity="0.7" rx="1"/>`;
|
||||
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 = `
|
||||
<path d="M${clipX1},${clipY1} L${clipX1},${clipY1+clipLegLen} L${clipX1-clipArmLen},${clipY1+clipLegLen} L${clipX1-clipArmLen},${clipY1+clipLegLen-clipT} L${clipX1-clipT},${clipY1+clipLegLen-clipT} L${clipX1-clipT},${clipY1} Z" fill="#8b5cf6" opacity="0.4"/>
|
||||
<path d="M${clipX1},${clipY2} L${clipX1},${clipY2-clipLegLen} L${clipX1-clipArmLen},${clipY2-clipLegLen} L${clipX1-clipArmLen},${clipY2-clipLegLen+clipT} L${clipX1-clipT},${clipY2-clipLegLen+clipT} L${clipX1-clipT},${clipY2} Z" fill="#8b5cf6" opacity="0.4"/>
|
||||
${g.showDim ? `<text x="${clipX1-clipArmLen-2}" y="${clipY1+clipLegLen/2+3}" fill="#8b5cf6" font-size="7" font-weight="700" text-anchor="end" font-family="Pretendard">①</text>` : ''}`;
|
||||
// ── 방화벽 ──
|
||||
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 += `<line x1="${totalLeft}" y1="${by+fw+25}" x2="${totalRight}" y2="${by+fw+25}" stroke="#3b82f6" stroke-width="1"/>`;
|
||||
dimLines += `<line x1="${totalLeft}" y1="${by+fw+20}" x2="${totalLeft}" y2="${by+fw+30}" stroke="#3b82f6" stroke-width="0.5"/>`;
|
||||
dimLines += `<line x1="${totalRight}" y1="${by+fw+20}" x2="${totalRight}" y2="${by+fw+30}" stroke="#3b82f6" stroke-width="0.5"/>`;
|
||||
dimLines += `<text x="${(totalLeft+totalRight)/2}" y="${by+fw+42}" fill="#3b82f6" font-size="12" font-weight="900" text-anchor="middle" font-family="Pretendard">${g.depth} mm</text>`;
|
||||
dimLines += `<line x1="${totalLeft}" y1="${by+bOuterW+35}" x2="${totalRight}" y2="${by+bOuterW+35}" stroke="#3b82f6" stroke-width="1"/>`;
|
||||
dimLines += `<line x1="${totalLeft}" y1="${by+bOuterW+30}" x2="${totalLeft}" y2="${by+bOuterW+40}" stroke="#3b82f6" stroke-width="0.5"/>`;
|
||||
dimLines += `<line x1="${totalRight}" y1="${by+bOuterW+30}" x2="${totalRight}" y2="${by+bOuterW+40}" stroke="#3b82f6" stroke-width="0.5"/>`;
|
||||
dimLines += `<text x="${(totalLeft+totalRight)/2}" y="${by+bOuterW+52}" fill="#3b82f6" font-size="12" font-weight="900" text-anchor="middle" font-family="Pretendard">${g.depth} mm</text>`;
|
||||
// 폭 70mm (우측)
|
||||
dimLines += `<line x1="${totalRight+12}" y1="${by}" x2="${totalRight+12}" y2="${by+fw}" stroke="#3b82f6" stroke-width="1"/>`;
|
||||
dimLines += `<line x1="${totalRight+12}" y1="${by}" x2="${totalRight+12}" y2="${by+bOuterW}" stroke="#3b82f6" stroke-width="1"/>`;
|
||||
dimLines += `<line x1="${totalRight+7}" y1="${by}" x2="${totalRight+17}" y2="${by}" stroke="#3b82f6" stroke-width="0.5"/>`;
|
||||
dimLines += `<line x1="${totalRight+7}" y1="${by+fw}" x2="${totalRight+17}" y2="${by+fw}" stroke="#3b82f6" stroke-width="0.5"/>`;
|
||||
dimLines += `<text x="${totalRight+22}" y="${by+fw/2+4}" fill="#3b82f6" font-size="12" font-weight="900" text-anchor="start" font-family="Pretendard">${g.width} mm</text>`;
|
||||
// 플랜지 (우측 상단 — 사이드월 끝에서 안쪽 절곡)
|
||||
dimLines += `<line x1="${bx+swLen+lp+3}" y1="${by}" x2="${bx+swLen+lp+3}" y2="${by+fl}" stroke="#94a3b8" stroke-width="0.8"/>`;
|
||||
dimLines += `<text x="${bx+swLen+lp+8}" y="${by+fl/2+3}" fill="#94a3b8" font-size="9" font-weight="700" text-anchor="start" font-family="Pretendard">FL ${g.flange}</text>`;
|
||||
// 슬롯 개구부 (플랜지 사이, 립 끝에서)
|
||||
const slotGap = g.width - 2 * g.flange;
|
||||
dimLines += `<line x1="${bx+swLen+lp}" y1="${by+fl+3}" x2="${bx+swLen+lp}" y2="${by+fw-fl-3}" stroke="#22c55e" stroke-width="0.8"/>`;
|
||||
dimLines += `<text x="${bx+swLen+lp+6}" y="${by+fw/2+3}" fill="#22c55e" font-size="8" font-weight="700" text-anchor="start" font-family="Pretendard">슬롯 ${slotGap}</text>`;
|
||||
// 립 깊이 (플랜지 끝에서 개구부 방향으로)
|
||||
dimLines += `<line x1="${bx+swLen}" y1="${by+fl+3}" x2="${bx+swLen+lp}" y2="${by+fl+3}" stroke="#94a3b8" stroke-width="0.8"/>`;
|
||||
dimLines += `<text x="${bx+swLen+lp/2}" y="${by+fl+14}" fill="#94a3b8" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard">립깊이 ${g.lip}</text>`;
|
||||
dimLines += `<line x1="${totalRight+7}" y1="${by+bOuterW}" x2="${totalRight+17}" y2="${by+bOuterW}" stroke="#3b82f6" stroke-width="0.5"/>`;
|
||||
dimLines += `<text x="${totalRight+22}" y="${by+bOuterW/2+4}" fill="#3b82f6" font-size="12" font-weight="900" text-anchor="start" font-family="Pretendard">${g.width} mm</text>`;
|
||||
// 플랜지 26mm
|
||||
dimLines += `<line x1="${lipEndX+3}" y1="${by}" x2="${lipEndX+3}" y2="${by+bFl}" stroke="#94a3b8" stroke-width="0.8"/>`;
|
||||
dimLines += `<text x="${lipEndX+8}" y="${by+bFl/2+3}" fill="#94a3b8" font-size="9" font-weight="700" text-anchor="start" font-family="Pretendard">FL ${g.flange}</text>`;
|
||||
// 슬롯 개구
|
||||
dimLines += `<line x1="${lipEndX+1}" y1="${by+bFl+3}" x2="${lipEndX+1}" y2="${by+bOuterW-bFl-3}" stroke="#22c55e" stroke-width="0.8"/>`;
|
||||
dimLines += `<text x="${lipEndX+6}" y="${by+bOuterW/2+3}" fill="#22c55e" font-size="8" font-weight="700" text-anchor="start" font-family="Pretendard">슬롯 ${bSlot/sc}</text>`;
|
||||
// 립 깊이
|
||||
dimLines += `<line x1="${swEndX}" y1="${by+bFl+3}" x2="${lipEndX}" y2="${by+bFl+3}" stroke="#94a3b8" stroke-width="0.8"/>`;
|
||||
dimLines += `<text x="${(swEndX+lipEndX)/2}" y="${by+bFl+14}" fill="#94a3b8" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard">립 ${g.lip}</text>`;
|
||||
// 두께
|
||||
dimLines += `<text x="${bx+t}" y="${by+fw+55}" fill="#94a3b8" font-size="9" font-weight="700" font-family="Pretendard">t=${g.thickness}</text>`;
|
||||
dimLines += `<text x="${bx+t2}" y="${by+bOuterW+65}" fill="#94a3b8" font-size="9" font-weight="700" font-family="Pretendard">②t=${g.thickness} ⑤t=${g.trimThick||1.2}</text>`;
|
||||
// 부재 번호 라벨
|
||||
dimLines += `<text x="${bx+t2+bSw/2}" y="${by+bOuterW/2+4}" fill="#475569" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">②</text>`;
|
||||
dimLines += `<text x="${c3BodyX+c3Lip/2}" y="${c3CenterY+4}" fill="${cBk3}" font-size="9" font-weight="900" text-anchor="middle" font-family="Pretendard">③</text>`;
|
||||
dimLines += `<text x="${c4BodyX+c4b/2}" y="${c4CenterY+4}" fill="${cBk4}" font-size="9" font-weight="900" text-anchor="middle" font-family="Pretendard">④</text>`;
|
||||
dimLines += `<text x="${(trimL+trimR)/2}" y="${by-t5-8}" fill="${cTrim}" font-size="9" font-weight="900" text-anchor="middle" font-family="Pretendard">⑤</text>`;
|
||||
dimLines += `<text x="${clipX1-clipArmLen-2}" y="${clipY1+clipLegLen/2+3}" fill="${cClip}" font-size="8" font-weight="900" text-anchor="end" font-family="Pretendard">①</text>`;
|
||||
}
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" style="max-width:100%;max-height:100%;">
|
||||
@@ -857,26 +924,30 @@ function renderGrCross() {
|
||||
</pattern>
|
||||
</defs>
|
||||
<text x="${svgW/2}" y="25" fill="#94a3b8" font-size="14" font-weight="900" text-anchor="middle" font-family="Pretendard">가이드레일 평면도 (Plan View) — 스크린형</text>
|
||||
<!-- 방화벽 (좌측 = 벽쪽) -->
|
||||
<rect x="${wallX}" y="${by-10}" width="${wallW}" height="${fw+20}" fill="url(#wallHatch)" stroke="#8b7355" stroke-width="1" rx="2"/>
|
||||
<text x="${wallX+wallW/2}" y="${by+fw+25}" fill="#a1887f" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard">방화벽</text>
|
||||
<!-- ⑤ SUS 트림 -->
|
||||
${trimSvg}
|
||||
<!-- ③④ 브라켓 -->
|
||||
${bracketSvg}
|
||||
<!-- 본체 (개별 부재 — 슬롯 열림) -->
|
||||
<!-- 방화벽 -->
|
||||
<rect x="${wallX}" y="${by-20}" width="${wallW}" height="${bOuterW+40}" fill="url(#wallHatch)" stroke="#8b7355" stroke-width="1" rx="2"/>
|
||||
<text x="${wallX+wallW/2}" y="${by+bOuterW+35}" fill="#a1887f" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard">방화벽</text>
|
||||
<!-- 채널 내부 -->
|
||||
${interiorSvg}
|
||||
<!-- ⑤ 마감재 SUS 커버 -->
|
||||
${trim5Svg}
|
||||
<!-- ③ 벽연형-C -->
|
||||
${bk3Svg}
|
||||
<!-- ④ 벽연형-D -->
|
||||
${bk4Svg}
|
||||
<!-- ② 본체 C채널 -->
|
||||
${bodySvg}
|
||||
<!-- ① 클립 -->
|
||||
${clipSvg}
|
||||
<!-- 연기차단재 -->
|
||||
${sealSvg}
|
||||
<!-- 슬랫 (수평, 채널 중심) -->
|
||||
<!-- 슬랫 -->
|
||||
<rect x="${slatX1}" y="${slatY}" width="${slatX2-slatX1}" height="${slatT}" fill="#c084fc" opacity="0.8" rx="1"/>
|
||||
${g.showDim ? `<text x="${(slatX1+slatX2)/2}" y="${slatY-4}" fill="#c084fc" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard">슬랫 t=${g.slatThick}</text>` : ''}
|
||||
<!-- 개구부 방향 -->
|
||||
<text x="${bx+swLen+lp+tmExt+5}" y="${by+fw/2+4}" fill="#22c55e" font-size="10" font-weight="700" text-anchor="start" font-family="Pretendard">→ 개구부</text>
|
||||
<text x="${lipEndX+15}" y="${by+bOuterW/2+4}" fill="#22c55e" font-size="10" font-weight="700" text-anchor="start" font-family="Pretendard">→ 개구부</text>
|
||||
${dimLines}
|
||||
<text x="${(bx+bx+bodyD)/2}" y="${by+fw+55}" fill="#94a3b8" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">GUIDE RAIL BODY — 스크린형</text>
|
||||
<text x="${(bx+lipEndX)/2}" y="${by+bOuterW+65}" fill="#94a3b8" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">GUIDE RAIL — 스크린형 (5부재 조립)</text>
|
||||
</svg>`;
|
||||
|
||||
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 (백월)
|
||||
|
||||
@@ -6,9 +6,17 @@
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">역할 관리</h1>
|
||||
<a href="{{ route('roles.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center w-full sm:w-auto">
|
||||
+ 새 역할
|
||||
</a>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<!-- 일괄 삭제 -->
|
||||
<button onclick="bulkDelete()" id="bulkDeleteBtn"
|
||||
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled>
|
||||
선택 삭제 (<span id="deleteCount">0</span>)
|
||||
</button>
|
||||
<a href="{{ route('roles.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center">
|
||||
+ 새 역할
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 영역 -->
|
||||
@@ -51,7 +59,6 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@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'));
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@endpush
|
||||
@@ -8,6 +8,9 @@
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-center w-10">
|
||||
<input type="checkbox" onchange="toggleSelectAll(this)" class="rounded border-gray-300">
|
||||
</th>
|
||||
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">ID</th>
|
||||
@if($isAllTenants)
|
||||
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">테넌트</th>
|
||||
@@ -23,11 +26,16 @@
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@forelse($roles as $role)
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<td class="px-3 py-4 text-center">
|
||||
<input type="checkbox" class="bulk-checkbox rounded border-gray-300"
|
||||
value="{{ $role->id }}"
|
||||
onchange="updateBulkButtonState()">
|
||||
</td>
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-900 text-center">
|
||||
{{ $role->id }}
|
||||
</td>
|
||||
@if($isAllTenants)
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-left">
|
||||
@if($role->tenant)
|
||||
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded cursor-pointer hover:bg-gray-200"
|
||||
data-context-menu="tenant"
|
||||
@@ -41,37 +49,39 @@
|
||||
@endif
|
||||
</td>
|
||||
@endif
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-left">
|
||||
<div class="text-sm font-medium text-gray-900">{{ $role->name }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-center">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded {{ $role->guard_name === 'api' ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700' }}">
|
||||
{{ strtoupper($role->guard_name ?? '-') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<td class="px-3 py-4 text-left">
|
||||
<div class="text-sm text-gray-500">{{ $role->description ?? '-' }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-center text-gray-900">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-center text-gray-900">
|
||||
{{ $role->permissions_count ?? 0 }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-500 text-center">
|
||||
{{ $role->created_at?->format('Y-m-d') ?? '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a href="{{ route('roles.edit', $role->id) }}"
|
||||
class="text-blue-600 hover:text-blue-900 mr-3">
|
||||
수정
|
||||
</a>
|
||||
<button onclick="confirmDelete({{ $role->id }}, '{{ $role->name }}')"
|
||||
class="text-red-600 hover:text-red-900">
|
||||
삭제
|
||||
</button>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||
<div class="inline-flex gap-1">
|
||||
<a href="{{ route('roles.edit', $role->id) }}"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded bg-blue-100 text-blue-700 hover:bg-blue-200 transition text-center">
|
||||
수정
|
||||
</a>
|
||||
<button onclick="confirmDelete({{ $role->id }}, '{{ $role->name }}')"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded bg-red-100 text-red-700 hover:bg-red-200 transition text-center">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="{{ $isAllTenants ? 8 : 7 }}" class="px-6 py-12 text-center text-gray-500">
|
||||
<td colspan="{{ $isAllTenants ? 9 : 8 }}" class="px-6 py-12 text-center text-gray-500">
|
||||
등록된 역할이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">메뉴</th>
|
||||
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">역할</th>
|
||||
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">사용량</th>
|
||||
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider" colspan="2">관리</th>
|
||||
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@@ -23,13 +23,13 @@
|
||||
<tr class="{{ $tenant->deleted_at ? 'bg-gray-100' : '' }} hover:bg-gray-50 cursor-pointer"
|
||||
onclick="TenantModal.open({{ $tenant->id }})"
|
||||
data-tenant-id="{{ $tenant->id }}">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-900 text-center">
|
||||
{{ $tenant->id }}
|
||||
</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500 text-center">
|
||||
{{ $tenant->created_at?->format('ymd') ?? '-' }}
|
||||
</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<td class="px-3 py-2 whitespace-nowrap text-left">
|
||||
<div class="text-sm font-medium text-gray-900 cursor-pointer hover:text-blue-600"
|
||||
data-context-menu="tenant"
|
||||
data-entity-id="{{ $tenant->id }}"
|
||||
@@ -40,10 +40,10 @@
|
||||
<div class="text-sm text-gray-500">대표: {{ $tenant->ceo_name }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-900">
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-900 text-center">
|
||||
{{ $tenant->code }}
|
||||
</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<td class="px-3 py-2 whitespace-nowrap text-center">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
{{ $tenant->status_badge_color === 'success' ? 'bg-green-100 text-green-800' : '' }}
|
||||
{{ $tenant->status_badge_color === 'warning' ? 'bg-yellow-100 text-yellow-800' : '' }}
|
||||
@@ -65,7 +65,7 @@
|
||||
{{ $typeLabels[$type] ?? $type }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<td class="px-3 py-2 whitespace-nowrap text-left">
|
||||
<div class="text-sm text-gray-900">{{ $tenant->email ?? '-' }}</div>
|
||||
@if($tenant->phone)
|
||||
<div class="text-xs text-gray-500">{{ $tenant->phone_formatted }}</div>
|
||||
@@ -83,7 +83,7 @@
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-center text-gray-900">
|
||||
{{ $tenant->roles_count ?? 0 }}
|
||||
</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<td class="px-3 py-2 whitespace-nowrap text-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-24 bg-gray-200 rounded-full h-2 mb-1">
|
||||
<div class="h-2 rounded-full
|
||||
@@ -95,43 +95,37 @@
|
||||
<span class="text-xs text-gray-500">{{ $tenant->storage_used_formatted }}</span>
|
||||
</div>
|
||||
</td>
|
||||
@if($tenant->deleted_at)
|
||||
{{-- 삭제된 항목 --}}
|
||||
<td class="px-2 py-2 whitespace-nowrap text-center text-sm font-medium" onclick="event.stopPropagation()">
|
||||
<button onclick="confirmRestore({{ $tenant->id }}, '{{ $tenant->company_name }}')"
|
||||
class="text-green-600 hover:text-green-900">
|
||||
복원
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-2 py-2 whitespace-nowrap text-center text-sm font-medium" onclick="event.stopPropagation()">
|
||||
@if(auth()->user()?->is_super_admin)
|
||||
<button onclick="confirmForceDelete({{ $tenant->id }}, '{{ $tenant->company_name }}')"
|
||||
class="text-red-600 hover:text-red-900">
|
||||
영구삭제
|
||||
</button>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-center" onclick="event.stopPropagation()">
|
||||
@if($tenant->deleted_at)
|
||||
<div class="inline-flex flex-col gap-1">
|
||||
<button onclick="confirmRestore({{ $tenant->id }}, '{{ $tenant->company_name }}')"
|
||||
class="w-full px-2.5 py-1 text-xs font-medium rounded bg-green-100 text-green-700 hover:bg-green-200 transition text-center">
|
||||
복원
|
||||
</button>
|
||||
@if(auth()->user()?->is_super_admin)
|
||||
<button onclick="confirmForceDelete({{ $tenant->id }}, '{{ $tenant->company_name }}')"
|
||||
class="w-full px-2.5 py-1 text-xs font-medium rounded bg-red-100 text-red-700 hover:bg-red-200 transition text-center">
|
||||
영구삭제
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
<div class="inline-flex gap-1">
|
||||
<a href="{{ route('tenants.edit', $tenant->id) }}"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded bg-blue-100 text-blue-700 hover:bg-blue-200 transition text-center">
|
||||
수정
|
||||
</a>
|
||||
<button onclick="confirmDelete({{ $tenant->id }}, '{{ $tenant->company_name }}')"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded bg-red-100 text-red-700 hover:bg-red-200 transition text-center">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
@else
|
||||
{{-- 활성 항목 --}}
|
||||
<td class="px-2 py-2 whitespace-nowrap text-center text-sm font-medium" onclick="event.stopPropagation()">
|
||||
<a href="{{ route('tenants.edit', $tenant->id) }}"
|
||||
class="text-blue-600 hover:text-blue-900">
|
||||
수정
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-2 py-2 whitespace-nowrap text-center text-sm font-medium" onclick="event.stopPropagation()">
|
||||
<button onclick="confirmDelete({{ $tenant->id }}, '{{ $tenant->company_name }}')"
|
||||
class="text-red-600 hover:text-red-900">
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
@endif
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="14" class="px-6 py-12 text-center text-gray-500">
|
||||
<td colspan="13" class="px-6 py-12 text-center text-gray-500">
|
||||
등록된 테넌트가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -6,9 +6,31 @@
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">사용자 관리</h1>
|
||||
<a href="{{ route('users.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center w-full sm:w-auto">
|
||||
+ 새 사용자
|
||||
</a>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<!-- 일괄 삭제 -->
|
||||
<button onclick="bulkDelete()" id="bulkDeleteBtn"
|
||||
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled>
|
||||
선택 삭제 (<span id="deleteCount">0</span>)
|
||||
</button>
|
||||
<!-- 일괄 복원 -->
|
||||
<button onclick="bulkRestore()" id="bulkRestoreBtn"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled>
|
||||
선택 복원 (<span id="restoreCount">0</span>)
|
||||
</button>
|
||||
@if(auth()->user()?->is_super_admin)
|
||||
<!-- 일괄 영구삭제 (슈퍼관리자) -->
|
||||
<button onclick="bulkForceDelete()" id="bulkForceDeleteBtn"
|
||||
class="bg-gray-800 hover:bg-gray-900 text-white px-4 py-2 rounded-lg transition flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled>
|
||||
선택 영구삭제 (<span id="forceDeleteCount">0</span>)
|
||||
</button>
|
||||
@endif
|
||||
<a href="{{ route('users.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center">
|
||||
+ 새 사용자
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 영역 -->
|
||||
@@ -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'));
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@endpush
|
||||
@@ -3,15 +3,17 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">ID</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">이름</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">테넌트</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">이메일</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">부서</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">역할</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">재직</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">상태</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">작업</th>
|
||||
<th class="px-3 py-3 text-center w-10">
|
||||
<input type="checkbox" onchange="toggleSelectAll(this)" class="rounded border-gray-300">
|
||||
</th>
|
||||
<th class="px-3 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">ID</th>
|
||||
<th class="px-3 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">이름</th>
|
||||
<th class="px-3 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">테넌트</th>
|
||||
<th class="px-3 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">부서</th>
|
||||
<th class="px-3 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">역할</th>
|
||||
<th class="px-3 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">재직</th>
|
||||
<th class="px-3 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">상태</th>
|
||||
<th class="px-3 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@@ -19,20 +21,25 @@
|
||||
<tr class="{{ $user->deleted_at ? 'bg-gray-100' : '' }} hover:bg-gray-50 cursor-pointer"
|
||||
onclick="UserModal.open({{ $user->id }})"
|
||||
data-user-id="{{ $user->id }}">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<td class="px-3 py-4 text-center" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="bulk-checkbox rounded border-gray-300"
|
||||
value="{{ $user->id }}"
|
||||
data-deleted="{{ $user->deleted_at ? '1' : '0' }}"
|
||||
onchange="updateBulkButtonState()">
|
||||
</td>
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-900 text-center">
|
||||
{{ $user->user_id ?? '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
{{ $user->name }}
|
||||
</div>
|
||||
<td class="px-3 py-4 text-left">
|
||||
<div class="text-sm font-medium text-gray-900">{{ $user->name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $user->email }}</div>
|
||||
@if($user->is_super_admin && auth()->user()?->is_super_admin)
|
||||
<span class="text-xs text-red-600 font-semibold">슈퍼 관리자</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<td class="px-3 py-4 text-center">
|
||||
@if($user->tenants && $user->tenants->count() > 0)
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div class="flex flex-wrap gap-1 justify-center">
|
||||
@foreach($user->tenants as $tenant)
|
||||
<span class="px-1.5 py-0.5 text-xs rounded cursor-pointer hover:ring-2 hover:ring-indigo-300 {{ $tenant->pivot->is_default ? 'bg-indigo-100 text-indigo-700' : 'bg-gray-100 text-gray-600' }}"
|
||||
data-context-menu="tenant"
|
||||
@@ -47,12 +54,9 @@
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $user->email }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<td class="px-3 py-4 text-center">
|
||||
@if($user->departmentUsers && $user->departmentUsers->count() > 0)
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div class="flex flex-wrap gap-1 justify-center">
|
||||
@foreach($user->departmentUsers as $du)
|
||||
<span class="px-1.5 py-0.5 text-xs rounded {{ $du->is_primary ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600' }}">
|
||||
{{ $du->department?->name ?? '-' }}
|
||||
@@ -63,11 +67,11 @@
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<td class="px-3 py-4 text-center">
|
||||
@if($user->userRoles && $user->userRoles->count() > 0)
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div class="flex flex-wrap gap-1 justify-center">
|
||||
@foreach($user->userRoles as $ur)
|
||||
<div class="px-2 py-1 rounded {{ $ur->role?->guard_name === 'web' ? 'bg-blue-50 border border-blue-200' : 'bg-purple-50 border border-purple-200' }}">
|
||||
<div class="text-left px-2 py-1 rounded {{ $ur->role?->guard_name === 'web' ? 'bg-blue-50 border border-blue-200' : 'bg-purple-50 border border-purple-200' }}">
|
||||
<div class="text-xs font-medium {{ $ur->role?->guard_name === 'web' ? 'text-blue-700' : 'text-purple-700' }}">
|
||||
{{ $ur->role?->name ?? '-' }}
|
||||
</div>
|
||||
@@ -81,7 +85,7 @@
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-center">
|
||||
@php $empStatus = $user->employee_status ?? 'active'; @endphp
|
||||
@if($empStatus === 'active')
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">재직</span>
|
||||
@@ -91,56 +95,55 @@
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">퇴직</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-center">
|
||||
@if($user->is_active)
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
활성
|
||||
</span>
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">활성</span>
|
||||
@else
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
비활성
|
||||
</span>
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">비활성</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium" onclick="event.stopPropagation()">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center" onclick="event.stopPropagation()">
|
||||
@php
|
||||
// 슈퍼관리자 보호: 일반관리자가 슈퍼관리자를 수정/삭제할 수 없음
|
||||
$canModify = ! $user->is_super_admin || auth()->user()?->is_super_admin;
|
||||
@endphp
|
||||
@if($user->deleted_at)
|
||||
<!-- 삭제된 항목 - 복원은 일반관리자도 가능, 영구삭제는 슈퍼관리자만 -->
|
||||
@if($canModify)
|
||||
<button onclick="confirmRestore({{ $user->id }}, @js($user->name))"
|
||||
class="text-green-600 hover:text-green-900 mr-3">
|
||||
복원
|
||||
</button>
|
||||
@endif
|
||||
@if(auth()->user()?->is_super_admin)
|
||||
<button onclick="confirmForceDelete({{ $user->id }}, @js($user->name))"
|
||||
class="text-red-600 hover:text-red-900">
|
||||
영구삭제
|
||||
</button>
|
||||
@endif
|
||||
@if(!$canModify && !auth()->user()?->is_super_admin)
|
||||
<span class="text-gray-400 text-xs">삭제됨</span>
|
||||
@endif
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
@if($canModify)
|
||||
<button onclick="confirmRestore({{ $user->id }}, @js($user->name))"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded bg-green-100 text-green-700 hover:bg-green-200 transition">
|
||||
복원
|
||||
</button>
|
||||
@endif
|
||||
@if(auth()->user()?->is_super_admin)
|
||||
<button onclick="confirmForceDelete({{ $user->id }}, @js($user->name))"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded bg-red-100 text-red-700 hover:bg-red-200 transition">
|
||||
영구삭제
|
||||
</button>
|
||||
@endif
|
||||
@if(!$canModify && !auth()->user()?->is_super_admin)
|
||||
<span class="text-gray-400 text-xs">삭제됨</span>
|
||||
@endif
|
||||
</div>
|
||||
@elseif($canModify)
|
||||
<!-- 활성 항목 (수정 가능한 경우만) -->
|
||||
<button onclick="openDevSite({{ $user->id }}, @js($user->name))"
|
||||
class="text-emerald-600 hover:text-emerald-900 mr-3"
|
||||
title="DEV 사이트에 이 사용자로 로그인">
|
||||
DEV 접속
|
||||
</button>
|
||||
<a href="{{ route('users.edit', $user->id) }}"
|
||||
onclick="event.stopPropagation()"
|
||||
class="text-blue-600 hover:text-blue-900 mr-3">
|
||||
수정
|
||||
</a>
|
||||
<button onclick="confirmDelete({{ $user->id }}, @js($user->name))" class="text-red-600 hover:text-red-900">
|
||||
삭제
|
||||
</button>
|
||||
<div class="inline-flex flex-col gap-1">
|
||||
<button onclick="openDevSite({{ $user->id }}, @js($user->name))"
|
||||
class="w-full px-2.5 py-1 text-xs font-medium rounded bg-emerald-100 text-emerald-700 hover:bg-emerald-200 transition text-center"
|
||||
title="SAM 사이트에 이 사용자로 로그인">
|
||||
SAM 접속
|
||||
</button>
|
||||
<div class="flex gap-1">
|
||||
<a href="{{ route('users.edit', $user->id) }}"
|
||||
onclick="event.stopPropagation()"
|
||||
class="flex-1 px-2.5 py-1 text-xs font-medium rounded bg-blue-100 text-blue-700 hover:bg-blue-200 transition text-center">
|
||||
수정
|
||||
</a>
|
||||
<button onclick="confirmDelete({{ $user->id }}, @js($user->name))"
|
||||
class="flex-1 px-2.5 py-1 text-xs font-medium rounded bg-red-100 text-red-700 hover:bg-red-200 transition text-center">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<!-- 슈퍼관리자 - 일반관리자는 수정/삭제 불가 -->
|
||||
<span class="text-gray-400 text-xs">수정 불가</span>
|
||||
@endif
|
||||
</td>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user