feat(user-modal): 사용자 정보 모달 및 컨텍스트 메뉴 확장

사용자 모달 기능:
- 사용자 정보 모달 팝업 (조회/삭제/수정)
- 권한 요약 정보 (Web/API 권한 카운트)
- 2x2 그리드 레이아웃 (테넌트, 역할, 부서, 권한)
- 테이블 행 클릭으로 모달 열기
- 권한 관리 링크 클릭 시 해당 사용자 자동 선택

컨텍스트 메뉴 확장:
- permission-analyze 페이지 사용자 이름에 컨텍스트 메뉴
- user-permissions 페이지 사용자 버튼에 컨텍스트 메뉴
- 사용자 모달 내 테넌트 칩에 컨텍스트 메뉴
- 헤더 테넌트 배지에 컨텍스트 메뉴
- 테넌트 메뉴에 "이 테넌트로 전환" 기능 추가
This commit is contained in:
2025-11-27 20:05:27 +09:00
parent 9440742d84
commit 39ed2ac3e3
25 changed files with 1063 additions and 89 deletions

View File

@@ -25,6 +25,16 @@ class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex i
테넌트 수정
</button>
<button type="button"
data-menu-for="tenant"
onclick="handleContextMenuAction('switch-tenant')"
class="w-full px-4 py-2 text-left text-sm text-green-700 hover:bg-green-50 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
테넌트로 전환
</button>
{{-- 구분선 --}}
<div data-menu-for="tenant" class="border-t border-gray-100 my-1"></div>
@@ -49,4 +59,17 @@ class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex i
</svg>
사용자 수정
</button>
{{-- 구분선 --}}
<div data-menu-for="user" class="border-t border-gray-100 my-1"></div>
<button type="button"
data-menu-for="user"
onclick="handleContextMenuAction('delete-user')"
class="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
사용자 삭제
</button>
</div>

View File

@@ -0,0 +1,39 @@
{{-- 사용자 정보 모달 --}}
<div id="user-modal"
class="hidden fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="user-modal-title"
role="dialog"
aria-modal="true">
{{-- 배경 오버레이 --}}
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
{{-- 모달 컨테이너 --}}
<div class="flex min-h-full items-start justify-center p-4 pt-16">
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
{{-- 모달 헤더 --}}
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 flex-shrink-0">
<h2 id="user-modal-title" class="text-xl font-semibold text-gray-800">
사용자 정보
</h2>
<button type="button"
onclick="UserModal.close()"
class="text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{{-- 모달 콘텐츠 --}}
<div id="user-modal-content" class="flex-1 overflow-y-auto">
{{-- 콘텐츠는 JS로 로드됨 --}}
<div class="flex items-center justify-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -31,8 +31,7 @@ class="department-button px-4 py-2 text-sm font-medium rounded-lg border transit
data-auto-select="{{ $loop->first ? 'true' : 'false' }}"
hx-get="/api/admin/department-permissions/matrix"
hx-target="#permission-matrix"
hx-include="[name='guard_name']"
hx-vals='{"department_id": {{ $department->id }}}'
hx-vals='{"department_id": {{ $department->id }}, "guard_name": "api"}'
onclick="selectDepartment(this)"
>
{{ $department->name }}
@@ -50,21 +49,7 @@ class="department-button px-4 py-2 text-sm font-medium rounded-lg border transit
<span class="text-sm font-medium text-gray-700" id="selected-department-name">선택된 부서</span>
<div class="flex items-center gap-2">
<input type="hidden" name="department_id" id="departmentIdInput" value="">
<!-- Guard 선택 -->
<span class="text-sm font-medium text-gray-700">Guard:</span>
<select
id="guardNameSelect"
name="guard_name"
class="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-primary focus:border-primary"
onchange="reloadPermissions()"
>
<option value="api" selected>API</option>
<option value="web">Web</option>
</select>
<!-- 구분선 -->
<div class="h-8 w-px bg-gray-300 mx-1"></div>
<input type="hidden" name="guard_name" value="api">
<button
type="button"
@@ -129,14 +114,6 @@ function selectDepartment(button) {
document.getElementById('action-buttons').style.display = 'block';
}
// Guard 변경 시 권한 매트릭스 새로고침
function reloadPermissions() {
const selectedButton = document.querySelector('.department-button.bg-blue-700');
if (selectedButton) {
htmx.trigger(selectedButton, 'click');
}
}
// 페이지 로드 시 첫 번째 부서 자동 선택 (특정 테넌트 선택 시에만)
document.addEventListener('DOMContentLoaded', function() {
const autoSelectButton = document.querySelector('.department-button[data-auto-select="true"]');

View File

@@ -32,6 +32,9 @@
<!-- 테넌트 정보 모달 -->
@include('components.tenant-modal')
<!-- 사용자 정보 모달 -->
@include('components.user-modal')
<!-- HTMX CSRF 토큰 설정 -->
<script>
document.body.addEventListener('htmx:configRequest', (event) => {
@@ -42,6 +45,7 @@
<script src="{{ asset('js/pagination.js') }}"></script>
<script src="{{ asset('js/context-menu.js') }}"></script>
<script src="{{ asset('js/tenant-modal.js') }}"></script>
<script src="{{ asset('js/user-modal.js') }}"></script>
@stack('scripts')
</body>
</html>

View File

@@ -41,7 +41,11 @@ class="border-gray-300 rounded-lg text-sm focus:ring-primary focus:border-primar
$currentTenant = $globalTenants->firstWhere('id', session('selected_tenant_id'));
@endphp
@if($currentTenant)
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary/10 text-primary">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary/10 text-primary cursor-pointer hover:bg-primary/20"
data-context-menu="tenant"
data-entity-id="{{ $currentTenant->id }}"
data-entity-name="{{ $currentTenant->company_name }}"
title="우클릭하여 메뉴 열기">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>

View File

@@ -68,7 +68,7 @@ class="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring
<!-- 2 레이아웃 (고정 높이, 좌우 분할) -->
<div class="flex gap-6" style="height: calc(100vh - 220px);">
<!-- 좌측: 메뉴 트리 (고정 너비) -->
<div class="w-80 flex-shrink-0">
<div class="flex-shrink-0" style="width: 320px;">
<div class="bg-white rounded-lg shadow-sm h-full flex flex-col">
<div class="px-4 py-3 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-800 mb-3">메뉴 트리</h2>

View File

@@ -48,7 +48,11 @@
@foreach($analysis['access_allowed'] as $user)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<div class="font-medium text-gray-900">{{ $user['name'] }}</div>
<div class="font-medium text-gray-900 cursor-pointer hover:text-blue-600"
data-context-menu="user"
data-entity-id="{{ $user['id'] }}"
data-entity-name="{{ $user['name'] }}"
title="우클릭하여 메뉴 열기">{{ $user['name'] }}</div>
<div class="text-xs text-gray-500">{{ $user['email'] }}</div>
</td>
<td class="px-4 py-3">
@@ -136,7 +140,11 @@
@foreach($analysis['explicit_deny'] as $user)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<div class="font-medium text-gray-900">{{ $user['name'] }}</div>
<div class="font-medium text-gray-900 cursor-pointer hover:text-blue-600"
data-context-menu="user"
data-entity-id="{{ $user['id'] }}"
data-entity-name="{{ $user['name'] }}"
title="우클릭하여 메뉴 열기">{{ $user['name'] }}</div>
<div class="text-xs text-gray-500">{{ $user['email'] }}</div>
</td>
<td class="px-4 py-3">

View File

@@ -21,7 +21,11 @@
@foreach($trace['by_role'] as $item)
<tr class="hover:bg-gray-50">
<td class="px-4 py-2">
<div class="font-medium text-gray-900">{{ $item['user_name'] }}</div>
<div class="font-medium text-gray-900 cursor-pointer hover:text-blue-600"
data-context-menu="user"
data-entity-id="{{ $item['user_id'] }}"
data-entity-name="{{ $item['user_name'] }}"
title="우클릭하여 메뉴 열기">{{ $item['user_name'] }}</div>
<div class="text-xs text-gray-500">{{ $item['email'] }}</div>
</td>
<td class="px-4 py-2">
@@ -62,7 +66,11 @@
@foreach($trace['by_department'] as $item)
<tr class="hover:bg-gray-50">
<td class="px-4 py-2">
<div class="font-medium text-gray-900">{{ $item['user_name'] }}</div>
<div class="font-medium text-gray-900 cursor-pointer hover:text-blue-600"
data-context-menu="user"
data-entity-id="{{ $item['user_id'] }}"
data-entity-name="{{ $item['user_name'] }}"
title="우클릭하여 메뉴 열기">{{ $item['user_name'] }}</div>
<div class="text-xs text-gray-500">{{ $item['email'] }}</div>
</td>
<td class="px-4 py-2">
@@ -106,7 +114,11 @@
@foreach($trace['by_personal'] as $item)
<tr class="hover:bg-gray-50">
<td class="px-4 py-2">
<div class="font-medium text-gray-900">{{ $item['user_name'] }}</div>
<div class="font-medium text-gray-900 cursor-pointer hover:text-blue-600"
data-context-menu="user"
data-entity-id="{{ $item['user_id'] }}"
data-entity-name="{{ $item['user_name'] }}"
title="우클릭하여 메뉴 열기">{{ $item['user_name'] }}</div>
</td>
<td class="px-4 py-2">
<div class="text-gray-500">{{ $item['email'] }}</div>
@@ -144,7 +156,11 @@
@foreach($trace['denied_users'] as $item)
<tr class="hover:bg-gray-50">
<td class="px-4 py-2">
<div class="font-medium text-gray-900">{{ $item['user_name'] }}</div>
<div class="font-medium text-gray-900 cursor-pointer hover:text-blue-600"
data-context-menu="user"
data-entity-id="{{ $item['user_id'] }}"
data-entity-name="{{ $item['user_name'] }}"
title="우클릭하여 메뉴 열기">{{ $item['user_name'] }}</div>
</td>
<td class="px-4 py-2">
<div class="text-gray-500">{{ $item['email'] }}</div>

View File

@@ -28,14 +28,16 @@
class="role-button px-4 py-2 text-sm font-medium rounded-lg border transition-colors bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
data-role-id="{{ $role->id }}"
data-role-name="{{ $role->name }}"
data-guard-name="{{ $role->guard_name }}"
data-auto-select="{{ $loop->first ? 'true' : 'false' }}"
hx-get="/api/admin/role-permissions/matrix"
hx-target="#permission-matrix"
hx-include="[name='guard_name']"
hx-vals='{"role_id": {{ $role->id }}}'
hx-vals='{"role_id": {{ $role->id }}, "guard_name": "{{ $role->guard_name }}"}'
onclick="selectRole(this)"
>
{{ $role->name }}
<span>{{ $role->name }}</span>
<span class="ml-1 px-1.5 py-0.5 text-xs rounded {{ $role->guard_name === 'web' ? 'bg-blue-100 text-blue-600' : 'bg-green-100 text-green-600' }}">{{ $role->guard_name }}</span>
</button>
@endforeach
</div>
@@ -50,21 +52,7 @@ class="role-button px-4 py-2 text-sm font-medium rounded-lg border transition-co
<span class="text-sm font-medium text-gray-700" id="selected-role-name">선택된 역할</span>
<div class="flex items-center gap-2">
<input type="hidden" name="role_id" id="roleIdInput" value="">
<!-- Guard 선택 -->
<span class="text-sm font-medium text-gray-700">Guard:</span>
<select
id="guardNameSelect"
name="guard_name"
class="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-primary focus:border-primary"
onchange="reloadPermissions()"
>
<option value="web" selected>Web</option>
<option value="api">API</option>
</select>
<!-- 구분선 -->
<div class="h-8 w-px bg-gray-300 mx-1"></div>
<input type="hidden" name="guard_name" id="guardNameInput" value="">
<button
type="button"
@@ -119,24 +107,22 @@ function selectRole(button) {
// 역할 정보 저장
const roleId = button.getAttribute('data-role-id');
const roleName = button.getAttribute('data-role-name');
const guardName = button.getAttribute('data-guard-name');
const tenantName = button.getAttribute('data-tenant-name');
document.getElementById('roleIdInput').value = roleId;
const displayName = tenantName ? `[${tenantName}] ${roleName} 역할` : `${roleName} 역할`;
document.getElementById('selected-role-name').textContent = displayName;
document.getElementById('guardNameInput').value = guardName;
const guardBadge = guardName === 'web'
? '<span class="ml-1 px-1.5 py-0.5 text-xs rounded bg-blue-100 text-blue-600">web</span>'
: '<span class="ml-1 px-1.5 py-0.5 text-xs rounded bg-green-100 text-green-600">api</span>';
const displayName = tenantName ? `[${tenantName}] ${roleName}` : `${roleName}`;
document.getElementById('selected-role-name').innerHTML = displayName + ' 역할 ' + guardBadge;
// 액션 버튼 표시
document.getElementById('action-buttons').style.display = 'block';
}
// Guard 변경 시 권한 매트릭스 새로고침
function reloadPermissions() {
const selectedButton = document.querySelector('.role-button.bg-blue-700');
if (selectedButton) {
htmx.trigger(selectedButton, 'click');
}
}
// 페이지 로드 시 첫 번째 역할 자동 선택 (특정 테넌트 선택 시에만)
document.addEventListener('DOMContentLoaded', function() {
const autoSelectButton = document.querySelector('.role-button[data-auto-select="true"]');

View File

@@ -107,6 +107,32 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<option value="expired" {{ old('tenant_st_code', $tenant->tenant_st_code) === 'expired' ? 'selected' : '' }}>만료</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
테넌트 유형 <span class="text-red-500">*</span>
</label>
<div class="flex items-center gap-4 mt-2">
<label class="inline-flex items-center cursor-pointer">
<input type="radio" name="tenant_type" value="STD"
{{ old('tenant_type', $tenant->tenant_type ?? 'STD') === 'STD' ? 'checked' : '' }}
class="w-4 h-4 text-gray-600 bg-gray-100 border-gray-300 focus:ring-gray-500">
<span class="ml-2 px-2 py-1 text-sm font-medium bg-gray-100 text-gray-700 rounded">일반</span>
</label>
<label class="inline-flex items-center cursor-pointer">
<input type="radio" name="tenant_type" value="TPL"
{{ old('tenant_type', $tenant->tenant_type) === 'TPL' ? 'checked' : '' }}
class="w-4 h-4 text-purple-600 bg-gray-100 border-gray-300 focus:ring-purple-500">
<span class="ml-2 px-2 py-1 text-sm font-medium bg-purple-100 text-purple-700 rounded">템플릿</span>
</label>
<label class="inline-flex items-center cursor-pointer">
<input type="radio" name="tenant_type" value="HQ"
{{ old('tenant_type', $tenant->tenant_type) === 'HQ' ? 'checked' : '' }}
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500">
<span class="ml-2 px-2 py-1 text-sm font-medium bg-blue-100 text-blue-700 rounded">본사</span>
</label>
</div>
<p class="mt-1 text-xs text-gray-500">템플릿: 테넌트 생성 메뉴 복사 원본 / 본사: 모든 테넌트 관리 가능</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">결제 유형</label>
<select name="billing_tp_code"

View File

@@ -29,6 +29,9 @@
@else
<div class="flex flex-wrap items-center gap-3">
<span class="text-sm font-medium text-gray-700">사용자 선택:</span>
@php
$autoSelectId = $selectedUserId ?? ($users->first()->id ?? null);
@endphp
@foreach($users as $user)
<button
type="button"
@@ -36,7 +39,10 @@ class="user-button px-4 py-2 text-sm font-medium rounded-lg border transition-co
data-user-id="{{ $user->id }}"
data-user-name="{{ $user->name }}"
data-user-login="{{ $user->user_id }}"
data-auto-select="{{ $loop->first ? 'true' : 'false' }}"
data-auto-select="{{ $user->id == $autoSelectId ? 'true' : 'false' }}"
data-context-menu="user"
data-entity-id="{{ $user->id }}"
data-entity-name="{{ $user->name }}"
hx-get="/api/admin/user-permissions/matrix"
hx-target="#permission-matrix"
hx-include="[name='guard_name']"
@@ -44,7 +50,17 @@ class="user-button px-4 py-2 text-sm font-medium rounded-lg border transition-co
onclick="selectUser(this)"
>
{{ $user->name }}
<span class="px-1.5 py-0.5 text-xs bg-gray-200 text-gray-600 rounded">&nbsp;{{ $user->user_id }}&nbsp;</span>
<span class="user-id-badge px-1.5 py-0.5 text-xs bg-gray-200 text-gray-600 rounded">&nbsp;{{ $user->user_id }}&nbsp;</span>
@if($user->web_permission_count > 0 || $user->api_permission_count > 0)
<span class="permission-counts inline-flex items-center gap-1 text-[10px]">
@if($user->web_permission_count > 0)
<span class="px-1 py-0.5 bg-blue-100 text-blue-600 rounded">web:{{ $user->web_permission_count }}</span>
@endif
@if($user->api_permission_count > 0)
<span class="px-1 py-0.5 bg-green-100 text-green-600 rounded">api:{{ $user->api_permission_count }}</span>
@endif
</span>
@endif
</button>
@endforeach
</div>
@@ -61,17 +77,30 @@ class="user-button px-4 py-2 text-sm font-medium rounded-lg border transition-co
<div class="flex items-center gap-2">
<input type="hidden" name="user_id" id="userIdInput" value="">
<!-- Guard 선택 -->
<span class="text-sm font-medium text-gray-700">Guard:</span>
<select
id="guardNameSelect"
name="guard_name"
class="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-primary focus:border-primary"
onchange="reloadPermissions()"
>
<option value="api" selected>API</option>
<option value="web">Web</option>
</select>
<!-- Guard 선택 (라디오 버튼) -->
<div class="flex items-center gap-4">
<label class="inline-flex items-center cursor-pointer">
<input
type="radio"
name="guard_name"
value="api"
checked
class="w-4 h-4 text-green-600 bg-gray-100 border-gray-300 focus:ring-green-500"
onchange="reloadPermissions()"
>
<span class="ml-2 px-2 py-1 text-sm font-medium bg-green-100 text-green-700 rounded">API</span>
</label>
<label class="inline-flex items-center cursor-pointer">
<input
type="radio"
name="guard_name"
value="web"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500"
onchange="reloadPermissions()"
>
<span class="ml-2 px-2 py-1 text-sm font-medium bg-blue-100 text-blue-700 rounded">Web</span>
</label>
</div>
<!-- 구분선 -->
<div class="h-8 w-px bg-gray-300 mx-1"></div>
@@ -120,8 +149,8 @@ function selectUser(button) {
document.querySelectorAll('.user-button').forEach(btn => {
btn.classList.remove('bg-blue-700', 'text-white', 'border-blue-700', 'hover:bg-blue-800');
btn.classList.add('bg-white', 'text-gray-700', 'border-gray-300', 'hover:bg-gray-50');
// 내부 뱃지 스타일 복원
const badge = btn.querySelector('span');
// user-id 뱃지 스타일 복원
const badge = btn.querySelector('.user-id-badge');
if (badge) {
badge.classList.remove('bg-blue-500', 'text-white');
badge.classList.add('bg-gray-200', 'text-gray-600');
@@ -131,8 +160,8 @@ function selectUser(button) {
// 클릭된 버튼 활성화
button.classList.remove('bg-white', 'text-gray-700', 'border-gray-300', 'hover:bg-gray-50');
button.classList.add('bg-blue-700', 'text-white', 'border-blue-700', 'hover:bg-blue-800');
// 내부 뱃지 스타일 변경
const activeBadge = button.querySelector('span');
// user-id 뱃지 스타일 변경
const activeBadge = button.querySelector('.user-id-badge');
if (activeBadge) {
activeBadge.classList.remove('bg-gray-200', 'text-gray-600');
activeBadge.classList.add('bg-blue-500', 'text-white');

View File

@@ -101,6 +101,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
{{ in_array($role->id, old('role_ids', [])) ? 'checked' : '' }}
class="h-4 w-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500">
<span class="ml-2 text-sm text-gray-700">{{ $role->name }}</span>
<span class="ml-1 px-1.5 py-0.5 text-xs rounded {{ $role->guard_name === 'web' ? 'bg-blue-100 text-blue-600' : 'bg-green-100 text-green-600' }}">{{ $role->guard_name }}</span>
</label>
@endforeach
</div>

View File

@@ -105,6 +105,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
{{ in_array($role->id, old('role_ids', $userRoleIds)) ? 'checked' : '' }}
class="h-4 w-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500">
<span class="ml-2 text-sm text-gray-700">{{ $role->name }}</span>
<span class="ml-1 px-1.5 py-0.5 text-xs rounded {{ $role->guard_name === 'web' ? 'bg-blue-100 text-blue-600' : 'bg-green-100 text-green-600' }}">{{ $role->guard_name }}</span>
</label>
@endforeach
</div>

View File

@@ -0,0 +1,199 @@
{{-- 사용자 모달 - 기본 정보 --}}
<div class="p-6">
{{-- 삭제된 사용자 경고 배너 --}}
@if($user->deleted_at)
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 text-red-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="text-sm font-semibold text-red-800"> 사용자는 삭제되었습니다.</p>
<p class="text-xs text-red-600 mt-0.5">
삭제일: {{ $user->deleted_at->format('Y-m-d H:i') }}
@if($user->deletedByUser)
· 삭제자: {{ $user->deletedByUser->name }}
@endif
</p>
</div>
</div>
<button type="button"
onclick="event.stopPropagation(); UserModal.restoreUser()"
class="px-3 py-1.5 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors">
복원하기
</button>
</div>
</div>
@endif
{{-- 상단: 기본 정보 카드 --}}
<div class="bg-gray-50 rounded-lg p-4 mb-6">
<div class="flex gap-4">
{{-- 좌측: 프로필 이미지/아이콘 --}}
<div class="flex-shrink-0">
@if($user->profile_photo_path)
<img src="{{ asset('storage/' . $user->profile_photo_path) }}"
alt="{{ $user->name }}"
class="w-20 h-20 rounded-full object-cover">
@else
<div class="w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center">
<span class="text-2xl font-bold text-blue-600">
{{ strtoupper(substr($user->name, 0, 1)) }}
</span>
</div>
@endif
</div>
{{-- 우측: 정보 테이블 --}}
<div class="flex-1">
<div class="mb-2">
<h3 id="user-modal-name" class="text-xl font-bold text-gray-900">{{ $user->name }}</h3>
@if($user->is_super_admin)
<span class="text-xs text-red-600 font-semibold">슈퍼 관리자</span>
@endif
</div>
<table class="w-full text-sm">
<tbody>
<tr>
<td class="py-1 pr-4 text-gray-500 w-24">사용자 ID</td>
<td class="py-1 font-medium text-gray-900">{{ $user->user_id ?? '-' }}</td>
<td class="py-1 pr-4 text-gray-500 w-24">이메일</td>
<td class="py-1 text-gray-900">{{ $user->email }}</td>
</tr>
<tr>
<td class="py-1 pr-4 text-gray-500">연락처</td>
<td class="py-1 text-gray-900">{{ $user->phone ?? '-' }}</td>
<td class="py-1 pr-4 text-gray-500">상태</td>
<td class="py-1">
@if($user->is_active)
<span class="px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800">활성</span>
@else
<span class="px-2 py-0.5 text-xs font-semibold rounded-full bg-gray-100 text-gray-800">비활성</span>
@endif
</td>
</tr>
<tr>
<td class="py-1 pr-4 text-gray-500">가입일</td>
<td class="py-1 text-gray-900">{{ $user->created_at?->format('Y-m-d') ?? '-' }}</td>
<td class="py-1 pr-4 text-gray-500">최근 로그인</td>
<td class="py-1 text-gray-900">{{ $user->last_login_at?->format('Y-m-d H:i') ?? '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{{-- 2x2 그리드: 테넌트, 역할, 부서, 권한 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
{{-- 소속 테넌트 --}}
<div class="bg-gray-50 rounded-lg p-3">
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">소속 테넌트</h4>
@if($user->tenants && $user->tenants->count() > 0)
<div class="flex flex-wrap gap-1.5">
@foreach($user->tenants as $tenant)
<span class="px-2 py-1 text-xs rounded cursor-pointer hover:ring-1 hover:ring-blue-400 {{ $tenant->pivot->is_default ? 'bg-blue-100 text-blue-800 font-medium' : 'bg-white text-gray-700 border border-gray-200' }}"
data-context-menu="tenant"
data-entity-id="{{ $tenant->id }}"
data-entity-name="{{ $tenant->company_name }}"
title="우클릭하여 메뉴 열기">
{{ $tenant->company_name }}
@if($tenant->pivot->is_default)
<span class="text-[10px]">(기본)</span>
@endif
</span>
@endforeach
</div>
@else
<p class="text-xs text-gray-400">소속된 테넌트 없음</p>
@endif
</div>
{{-- 역할 정보 --}}
<div class="bg-gray-50 rounded-lg p-3">
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">역할</h4>
@if($user->userRoles && $user->userRoles->count() > 0)
<div class="flex flex-wrap gap-1.5">
@foreach($user->userRoles as $userRole)
@if($userRole->role)
<span class="px-2 py-1 text-xs rounded {{ $userRole->role->guard_name === 'web' ? 'bg-blue-100 text-blue-700' : 'bg-purple-100 text-purple-700' }}"
title="{{ $userRole->role->description }}">
{{ $userRole->role->name }}
</span>
@endif
@endforeach
</div>
@else
<p class="text-xs text-gray-400">할당된 역할 없음</p>
@endif
</div>
{{-- 부서 정보 --}}
<div class="bg-gray-50 rounded-lg p-3">
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">부서</h4>
@if($user->departmentUsers && $user->departmentUsers->count() > 0)
<div class="flex flex-wrap gap-1.5">
@foreach($user->departmentUsers as $departmentUser)
@if($departmentUser->department)
<span class="px-2 py-1 text-xs rounded {{ $departmentUser->is_primary ? 'bg-green-100 text-green-800 font-medium' : 'bg-white text-gray-700 border border-gray-200' }}">
{{ $departmentUser->department->name }}
@if($departmentUser->is_primary)
<span class="text-[10px]">()</span>
@endif
</span>
@endif
@endforeach
</div>
@else
<p class="text-xs text-gray-400">소속된 부서 없음</p>
@endif
</div>
{{-- 권한 정보 --}}
<div class="bg-gray-50 rounded-lg p-3">
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">권한</h4>
@if(isset($user->web_permission_count) || isset($user->api_permission_count))
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex items-center gap-1">
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-blue-100 text-blue-700 rounded">Web</span>
<span class="text-sm font-semibold text-gray-900">{{ $user->web_permission_count ?? 0 }}</span>
</div>
<div class="flex items-center gap-1">
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-green-100 text-green-700 rounded">API</span>
<span class="text-sm font-semibold text-gray-900">{{ $user->api_permission_count ?? 0 }}</span>
</div>
</div>
<a href="{{ route('user-permissions.index') }}?user_id={{ $user->id }}"
class="text-xs text-blue-600 hover:text-blue-800 hover:underline">
관리
</a>
</div>
@else
<p class="text-xs text-gray-400">테넌트 선택 필요</p>
@endif
</div>
</div>
{{-- 하단 버튼 --}}
<div class="flex justify-end gap-2 mt-6 pt-4 border-t border-gray-200">
<button type="button"
onclick="UserModal.close()"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">
닫기
</button>
@if(!$user->deleted_at)
<button type="button"
onclick="UserModal.deleteUser()"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
삭제
</button>
@endif
<button type="button"
onclick="UserModal.goToEdit()"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700">
수정
</button>
</div>
</div>

View File

@@ -5,20 +5,29 @@
<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>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($users as $user)
<tr class="hover:bg-gray-50">
<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">
{{ $user->user_id ?? '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ $user->name }}</div>
<div class="text-sm font-medium text-gray-900 cursor-pointer hover:text-blue-600"
data-context-menu="user"
data-entity-id="{{ $user->id }}"
data-entity-name="{{ $user->name }}"
title="우클릭하여 메뉴 열기"
onclick="event.stopPropagation()">
{{ $user->name }}
</div>
@if($user->is_super_admin)
<span class="text-xs text-red-600 font-semibold">슈퍼 관리자</span>
@endif
@@ -26,11 +35,36 @@
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $user->email }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $user->phone ?? '-' }}
<td class="px-6 py-4 text-sm text-gray-500">
@if($user->departmentUsers && $user->departmentUsers->count() > 0)
<div class="flex flex-wrap gap-1">
@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 ?? '-' }}
</span>
@endforeach
</div>
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $user->currentTenant()?->company_name ?? '-' }}
<td class="px-6 py-4 text-sm text-gray-500">
@if($user->userRoles && $user->userRoles->count() > 0)
<div class="flex flex-wrap gap-1">
@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-xs font-medium {{ $ur->role?->guard_name === 'web' ? 'text-blue-700' : 'text-purple-700' }}">
{{ $ur->role?->name ?? '-' }}
</div>
@if($ur->role?->description)
<div class="text-[10px] text-gray-500 leading-tight">{{ $ur->role->description }}</div>
@endif
</div>
@endforeach
</div>
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if($user->is_active)
@@ -43,7 +77,7 @@
</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium" onclick="event.stopPropagation()">
@if($user->deleted_at)
<!-- 삭제된 항목 -->
<button onclick="confirmRestore({{ $user->id }}, '{{ $user->name }}')"
@@ -58,7 +92,9 @@ class="text-red-600 hover:text-red-900">
@endif
@else
<!-- 활성 항목 -->
<a href="{{ route('users.edit', $user->id) }}" class="text-blue-600 hover:text-blue-900 mr-3">
<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 }}, '{{ $user->name }}')" class="text-red-600 hover:text-red-900">