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

@@ -1360,3 +1360,83 @@ ### 코드 품질:
- ✅ Pint 포맷팅 통과
---
## 2025-11-27 (수) - 테넌트 정보 모달 팝업 기능 구현
### 주요 작업
- 테넌트 행 클릭 시 모달 팝업 오픈 기능 구현
- 모달 내 JS 함수 window 객체 등록 (동적 HTML 접근 문제 해결)
- 삭제된 테넌트 경고 배너 추가 (삭제일, 삭제자 표시)
- 복원 후 모달 내용 자동 새로고침
### 수정된 파일:
#### JavaScript
- `public/js/tenant-modal.js`
- `toggleModalMenuChildren`, `hideModalMenuDescendants` → `window` 객체에 등록
- 모달 오픈 시 구독정보 탭 자동 로드 (`switchTab('subscription')`)
- `isOpen()` 메서드 추가 (모달 상태 확인용)
#### Blade Views
- `resources/views/tenants/partials/table.blade.php`
- 행 클릭 시 모달 오픈: `onclick="TenantModal.open({{ $tenant->id }})"`
- 액션 버튼 우선권: `onclick="event.stopPropagation()"`
- `resources/views/tenants/partials/modal-info.blade.php`
- 삭제된 테넌트 경고 배너 추가 (빨간색 bg-red-50)
- 삭제일/삭제자 정보 표시
- 배너 내 복원 버튼 추가
- `resources/views/tenants/index.blade.php`
- `confirmRestore()`: 복원 후 모달 내용 새로고침 로직 추가
#### Models & Services
- `app/Models/Tenants/Tenant.php`
- `deleted_by` 컬럼 fillable 추가
- `deletedByUser()` 관계 추가 (삭제자 정보 조회)
- `app/Services/TenantService.php`
- `deleteTenant()`: deleted_by 기록
- `restoreTenant()`: deleted_by null 초기화
- `getTenantForModal()`: deletedByUser 관계 eager loading 추가
### 기술 상세:
**JS 함수 window 등록 (동적 HTML 문제 해결):**
```javascript
// Before: function toggleModalMenuChildren() {}
// After: window.toggleModalMenuChildren = function() {}
```
- 동적으로 로드된 HTML에서 함수 접근 가능
**행 클릭 + 액션 버튼 우선권:**
```html
<tr onclick="TenantModal.open({{ $tenant->id }})">
<td onclick="event.stopPropagation()">
<!-- 액션 버튼들 -->
</td>
</tr>
```
- `event.stopPropagation()`으로 버블링 방지
**삭제된 테넌트 경고 배너:**
- 빨간색 배경 (bg-red-50)
- 삭제일 + 삭제자 정보 표시
- 복원 버튼 포함
**복원 후 모달 새로고침:**
```javascript
if (TenantModal.isOpen() && TenantModal.currentTenantId === id) {
TenantModal.loadTenantInfo();
}
```
### 이슈 해결:
- **toggleModalMenuChildren not defined**: window 객체에 함수 등록으로 해결
- **복원 후 배너 미갱신**: `loadTenantInfo()` 호출 추가
### Git 커밋:
- ✅ `b32f6cf` "feat: 테넌트 정보 모달 팝업 기능 추가"
- 17 files changed, 1476 insertions(+), 4 deletions(-)
---

View File

@@ -164,6 +164,28 @@ public function restore(Request $request, int $id): JsonResponse
]);
}
/**
* 사용자 모달 정보 조회
*/
public function modal(Request $request, int $id): JsonResponse
{
$user = $this->userService->getUserForModal($id);
if (! $user) {
return response()->json([
'success' => false,
'message' => '사용자를 찾을 수 없습니다.',
], 404);
}
$html = view('users.partials.modal-info', compact('user'))->render();
return response()->json([
'success' => true,
'html' => $html,
]);
}
/**
* 사용자 영구 삭제 (슈퍼관리자 전용)
*/

View File

@@ -20,6 +20,7 @@ public function __construct(UserPermissionService $userPermissionService)
public function index(Request $request)
{
$tenantId = session('selected_tenant_id');
$selectedUserId = $request->query('user_id');
// 테넌트 미선택 또는 전체 선택 시
if (! $tenantId || $tenantId === 'all') {
@@ -27,6 +28,7 @@ public function index(Request $request)
'users' => collect(),
'requireTenant' => true,
'selectedTenantId' => $tenantId,
'selectedUserId' => $selectedUserId,
]);
}
@@ -37,6 +39,7 @@ public function index(Request $request)
'users' => $users,
'requireTenant' => false,
'selectedTenantId' => $tenantId,
'selectedUserId' => $selectedUserId,
]);
}
}

View File

@@ -44,6 +44,7 @@ public function rules(): array
// 구독 정보
'tenant_st_code' => ['required', 'string', 'in:trial,active,suspended,expired'],
'tenant_type' => ['required', 'string', 'in:STD,TPL,HQ'],
'billing_tp_code' => ['nullable', 'string', 'in:monthly,yearly,free'],
'max_users' => ['nullable', 'integer', 'min:1'],
'trial_ends_at' => ['nullable', 'date'],
@@ -69,6 +70,8 @@ public function messages(): array
'homepage.url' => '올바른 URL 형식이 아닙니다.',
'tenant_st_code.required' => '상태는 필수입니다.',
'tenant_st_code.in' => '올바른 상태를 선택해주세요.',
'tenant_type.required' => '테넌트 유형은 필수입니다.',
'tenant_type.in' => '올바른 테넌트 유형을 선택해주세요.',
'billing_tp_code.in' => '올바른 결제 유형을 선택해주세요.',
'max_users.integer' => '최대 사용자 수는 숫자여야 합니다.',
'max_users.min' => '최대 사용자 수는 최소 1명 이상이어야 합니다.',
@@ -92,6 +95,7 @@ public function attributes(): array
'homepage' => '홈페이지',
'fax' => '팩스',
'tenant_st_code' => '상태',
'tenant_type' => '테넌트 유형',
'billing_tp_code' => '결제 유형',
'max_users' => '최대 사용자 수',
'trial_ends_at' => '트라이얼 종료일',

View File

@@ -127,4 +127,12 @@ public function getDepartmentsForTenant(int $tenantId)
->get()
->pluck('department');
}
/**
* 관계: 삭제한 사용자
*/
public function deletedByUser(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class, 'deleted_by');
}
}

View File

@@ -692,19 +692,125 @@ public function hasPermission(int $userId, int $menuId, string $permissionType,
}
/**
* 테넌트별 사용자 목록 조회
* 테넌트별 사용자 목록 조회 (권한 개수 포함)
*
* @param int $tenantId 테넌트 ID
* @return \Illuminate\Support\Collection 사용자 목록
*/
public function getUsersByTenant(int $tenantId): \Illuminate\Support\Collection
{
return User::whereHas('tenants', function ($query) use ($tenantId) {
$users = User::whereHas('tenants', function ($query) use ($tenantId) {
$query->where('tenants.id', $tenantId)
->where('user_tenants.is_active', true);
})
->where('is_active', true)
->orderBy('name')
->get();
// 각 사용자별 권한 개수 계산
$now = now();
foreach ($users as $user) {
$permissionCounts = $this->getUserPermissionCounts($user->id, $tenantId, $now);
$user->web_permission_count = $permissionCounts['web'];
$user->api_permission_count = $permissionCounts['api'];
}
return $users;
}
/**
* 사용자별 guard별 권한 개수 조회 (역할 + 부서 + 개인 오버라이드 통합)
*
* @param int $userId 사용자 ID
* @param int $tenantId 테넌트 ID
* @param \Carbon\Carbon $now 현재 시간
* @return array ['web' => int, 'api' => int]
*/
private function getUserPermissionCounts(int $userId, int $tenantId, $now): array
{
$result = ['web' => 0, 'api' => 0];
foreach (['web', 'api'] as $guardName) {
// 1. 역할 권한
$rolePermissions = DB::table('model_has_roles as mhr')
->join('role_has_permissions as rhp', 'rhp.role_id', '=', 'mhr.role_id')
->join('permissions as p', 'p.id', '=', 'rhp.permission_id')
->where('mhr.model_type', User::class)
->where('mhr.model_id', $userId)
->where('p.guard_name', $guardName)
->where('p.name', 'like', 'menu:%')
->pluck('p.name')
->toArray();
// 2. 부서 권한
$deptPermissions = DB::table('department_user as du')
->join('permission_overrides as po', function ($j) use ($now, $tenantId) {
$j->on('po.model_id', '=', 'du.department_id')
->where('po.model_type', 'App\\Models\\Tenants\\Department')
->where('po.tenant_id', $tenantId)
->whereNull('po.deleted_at')
->where('po.effect', 1)
->where(function ($w) use ($now) {
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
})
->where(function ($w) use ($now) {
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
});
})
->join('permissions as p', 'p.id', '=', 'po.permission_id')
->whereNull('du.deleted_at')
->where('du.user_id', $userId)
->where('du.tenant_id', $tenantId)
->where('p.guard_name', $guardName)
->where('p.name', 'like', 'menu:%')
->pluck('p.name')
->toArray();
// 3. 개인 오버라이드 (ALLOW)
$personalAllows = DB::table('permission_overrides as po')
->join('permissions as p', 'p.id', '=', 'po.permission_id')
->where('po.model_type', User::class)
->where('po.model_id', $userId)
->where('po.tenant_id', $tenantId)
->where('po.effect', 1)
->whereNull('po.deleted_at')
->where(function ($w) use ($now) {
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
})
->where(function ($w) use ($now) {
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
})
->where('p.guard_name', $guardName)
->where('p.name', 'like', 'menu:%')
->pluck('p.name')
->toArray();
// 4. 개인 오버라이드 (DENY) - 제외할 권한
$personalDenies = DB::table('permission_overrides as po')
->join('permissions as p', 'p.id', '=', 'po.permission_id')
->where('po.model_type', User::class)
->where('po.model_id', $userId)
->where('po.tenant_id', $tenantId)
->where('po.effect', 0)
->whereNull('po.deleted_at')
->where(function ($w) use ($now) {
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
})
->where(function ($w) use ($now) {
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
})
->where('p.guard_name', $guardName)
->where('p.name', 'like', 'menu:%')
->pluck('p.name')
->toArray();
// 통합: (역할 OR 부서 OR 개인ALLOW) - 개인DENY
$allAllowed = array_unique(array_merge($rolePermissions, $deptPermissions, $personalAllows));
$effectivePermissions = array_diff($allAllowed, $personalDenies);
$result[$guardName] = count($effectivePermissions);
}
return $result;
}
}

View File

@@ -18,6 +18,14 @@ public function getUsers(array $filters = [], int $perPage = 15): LengthAwarePag
$tenantId = session('selected_tenant_id');
$query = User::query()->withTrashed();
// 역할/부서 관계 eager loading (테넌트별)
if ($tenantId) {
$query->with([
'userRoles' => fn ($q) => $q->where('tenant_id', $tenantId)->with('role'),
'departmentUsers' => fn ($q) => $q->where('tenant_id', $tenantId)->with('department'),
]);
}
// 테넌트 필터링 (user_tenants pivot을 통한 필터링)
if ($tenantId) {
$query->whereHas('tenants', function ($q) use ($tenantId) {
@@ -226,6 +234,132 @@ public function forceDeleteUser(int $id): bool
return $user->forceDelete();
}
/**
* 모달용 사용자 상세 정보 조회
*/
public function getUserForModal(int $id): ?User
{
$tenantId = session('selected_tenant_id');
$query = User::query()
->with('deletedByUser')
->withTrashed();
// 역할/부서 관계 eager loading (테넌트별)
if ($tenantId) {
$query->with([
'userRoles' => fn ($q) => $q->where('tenant_id', $tenantId)->with('role'),
'departmentUsers' => fn ($q) => $q->where('tenant_id', $tenantId)->with('department'),
'tenants',
]);
} else {
$query->with(['userRoles.role', 'departmentUsers.department', 'tenants']);
}
$user = $query->find($id);
// 권한 카운트 추가
if ($user && $tenantId) {
$permissionCounts = $this->getUserPermissionCounts($user->id, $tenantId);
$user->web_permission_count = $permissionCounts['web'];
$user->api_permission_count = $permissionCounts['api'];
}
return $user;
}
/**
* 사용자별 guard별 권한 개수 조회 (역할 + 부서 + 개인 오버라이드 통합)
*/
private function getUserPermissionCounts(int $userId, int $tenantId): array
{
$result = ['web' => 0, 'api' => 0];
$now = now();
foreach (['web', 'api'] as $guardName) {
// 1. 역할 권한
$rolePermissions = \DB::table('model_has_roles as mhr')
->join('role_has_permissions as rhp', 'rhp.role_id', '=', 'mhr.role_id')
->join('permissions as p', 'p.id', '=', 'rhp.permission_id')
->where('mhr.model_type', User::class)
->where('mhr.model_id', $userId)
->where('p.guard_name', $guardName)
->where('p.name', 'like', 'menu:%')
->pluck('p.name')
->toArray();
// 2. 부서 권한
$deptPermissions = \DB::table('department_user as du')
->join('permission_overrides as po', function ($j) use ($now, $tenantId) {
$j->on('po.model_id', '=', 'du.department_id')
->where('po.model_type', 'App\\Models\\Tenants\\Department')
->where('po.tenant_id', $tenantId)
->whereNull('po.deleted_at')
->where('po.effect', 1)
->where(function ($w) use ($now) {
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
})
->where(function ($w) use ($now) {
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
});
})
->join('permissions as p', 'p.id', '=', 'po.permission_id')
->whereNull('du.deleted_at')
->where('du.user_id', $userId)
->where('du.tenant_id', $tenantId)
->where('p.guard_name', $guardName)
->where('p.name', 'like', 'menu:%')
->pluck('p.name')
->toArray();
// 3. 개인 오버라이드 (ALLOW)
$personalAllows = \DB::table('permission_overrides as po')
->join('permissions as p', 'p.id', '=', 'po.permission_id')
->where('po.model_type', User::class)
->where('po.model_id', $userId)
->where('po.tenant_id', $tenantId)
->where('po.effect', 1)
->whereNull('po.deleted_at')
->where(function ($w) use ($now) {
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
})
->where(function ($w) use ($now) {
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
})
->where('p.guard_name', $guardName)
->where('p.name', 'like', 'menu:%')
->pluck('p.name')
->toArray();
// 4. 개인 오버라이드 (DENY) - 제외할 권한
$personalDenies = \DB::table('permission_overrides as po')
->join('permissions as p', 'p.id', '=', 'po.permission_id')
->where('po.model_type', User::class)
->where('po.model_id', $userId)
->where('po.tenant_id', $tenantId)
->where('po.effect', 0)
->whereNull('po.deleted_at')
->where(function ($w) use ($now) {
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
})
->where(function ($w) use ($now) {
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
})
->where('p.guard_name', $guardName)
->where('p.name', 'like', 'menu:%')
->pluck('p.name')
->toArray();
// 통합: (역할 OR 부서 OR 개인ALLOW) - 개인DENY
$allAllowed = array_unique(array_merge($rolePermissions, $deptPermissions, $personalAllows));
$effectivePermissions = array_diff($allAllowed, $personalDenies);
$result[$guardName] = count($effectivePermissions);
}
return $result;
}
/**
* 활성 사용자 목록 조회 (드롭다운용)
*/

View File

@@ -114,6 +114,9 @@ class ContextMenu {
case 'edit-tenant':
window.location.href = `/tenants/${id}/edit`;
break;
case 'switch-tenant':
this.switchTenant(id, name);
break;
case 'view-user':
if (typeof UserModal !== 'undefined') {
UserModal.open(id);
@@ -122,12 +125,83 @@ class ContextMenu {
case 'edit-user':
window.location.href = `/users/${id}/edit`;
break;
case 'delete-user':
if (typeof UserModal !== 'undefined') {
// 모달 열고 삭제 실행
UserModal.currentUserId = id;
UserModal.deleteUser();
} else {
// UserModal이 없으면 직접 삭제 확인
if (confirm(`"${name}"을(를) 삭제하시겠습니까?`)) {
this.deleteUser(id);
}
}
break;
default:
console.log('Unknown action:', action, { type, id, name });
}
this.hide();
}
// 테넌트 전환
async switchTenant(tenantId, tenantName) {
try {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/tenant/switch';
// CSRF 토큰
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = '_token';
csrfInput.value = csrfToken;
form.appendChild(csrfInput);
// 테넌트 ID
const tenantInput = document.createElement('input');
tenantInput.type = 'hidden';
tenantInput.name = 'tenant_id';
tenantInput.value = tenantId;
form.appendChild(tenantInput);
document.body.appendChild(form);
form.submit();
} catch (error) {
console.error('Failed to switch tenant:', error);
alert('테넌트 전환에 실패했습니다.');
}
}
// 사용자 삭제 (fallback)
async deleteUser(userId) {
try {
const response = await fetch(`/api/admin/users/${userId}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json'
}
});
const data = await response.json();
if (data.success) {
// 테이블 새로고침
if (typeof htmx !== 'undefined') {
htmx.trigger('#user-table', 'filterSubmit');
} else {
window.location.reload();
}
} else {
alert(data.message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('Failed to delete user:', error);
alert('삭제에 실패했습니다.');
}
}
}
// 전역 인스턴스 생성

191
public/js/user-modal.js Normal file
View File

@@ -0,0 +1,191 @@
/**
* 사용자 정보 모달
* 사용자 상세 정보를 팝업으로 표시
*/
const UserModal = {
modalElement: null,
currentUserId: null,
init() {
this.modalElement = document.getElementById('user-modal');
if (!this.modalElement) return;
// ESC 키로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.isOpen()) {
this.close();
}
});
// 배경 클릭 시 모달 닫기
this.modalElement.addEventListener('click', (e) => {
if (e.target === this.modalElement) {
this.close();
}
});
},
isOpen() {
return this.modalElement && !this.modalElement.classList.contains('hidden');
},
async open(userId) {
if (!this.modalElement) {
console.error('User modal element not found');
return;
}
this.currentUserId = userId;
// 모달 표시
this.modalElement.classList.remove('hidden');
document.body.style.overflow = 'hidden';
// 로딩 표시
this.showLoading();
// 사용자 정보 로드
await this.loadUserInfo();
},
close() {
if (this.modalElement) {
this.modalElement.classList.add('hidden');
document.body.style.overflow = '';
}
this.currentUserId = null;
},
showLoading() {
const content = document.getElementById('user-modal-content');
if (content) {
content.innerHTML = `
<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>
`;
}
},
async loadUserInfo() {
try {
const response = await fetch(`/api/admin/users/${this.currentUserId}/modal`, {
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json'
}
});
const data = await response.json();
if (data.html) {
const content = document.getElementById('user-modal-content');
if (content) {
content.innerHTML = data.html;
}
}
} catch (error) {
console.error('Failed to load user info:', error);
this.showError('사용자 정보를 불러오는데 실패했습니다.');
}
},
showError(message) {
const content = document.getElementById('user-modal-content');
if (content) {
content.innerHTML = `
<div class="flex flex-col items-center justify-center h-64 text-red-500">
<svg class="w-12 h-12 mb-4" 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>
<p>${message}</p>
<button onclick="UserModal.close()" class="mt-4 px-4 py-2 bg-gray-200 rounded hover:bg-gray-300">
닫기
</button>
</div>
`;
}
},
// 수정 페이지로 이동
goToEdit() {
if (this.currentUserId) {
window.location.href = `/users/${this.currentUserId}/edit`;
}
},
// 삭제 실행
async deleteUser() {
if (!this.currentUserId) return;
const userName = document.getElementById('user-modal-name')?.textContent || '이 사용자';
if (confirm(`"${userName}"을(를) 삭제하시겠습니까?`)) {
try {
const response = await fetch(`/api/admin/users/${this.currentUserId}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json'
}
});
const data = await response.json();
if (data.success) {
this.close();
// 테이블 새로고침
if (typeof htmx !== 'undefined') {
htmx.trigger('#user-table', 'filterSubmit');
}
} else {
alert(data.message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('Failed to delete user:', error);
alert('삭제에 실패했습니다.');
}
}
},
// 복원 실행
async restoreUser() {
if (!this.currentUserId) return;
const userName = document.getElementById('user-modal-name')?.textContent || '이 사용자';
if (confirm(`"${userName}"을(를) 복원하시겠습니까?`)) {
try {
const response = await fetch(`/api/admin/users/${this.currentUserId}/restore`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json'
}
});
const data = await response.json();
if (data.success) {
// 모달 내용 새로고침
await this.loadUserInfo();
// 테이블 새로고침
if (typeof htmx !== 'undefined') {
htmx.trigger('#user-table', 'filterSubmit');
}
} else {
alert(data.message || '복원에 실패했습니다.');
}
} catch (error) {
console.error('Failed to restore user:', error);
alert('복원에 실패했습니다.');
}
}
}
};
// DOM 로드 시 초기화
document.addEventListener('DOMContentLoaded', () => {
UserModal.init();
});

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">

View File

@@ -76,6 +76,9 @@
// 추가 액션
Route::post('/{id}/restore', [UserController::class, 'restore'])->name('restore');
Route::delete('/{id}/force', [UserController::class, 'forceDestroy'])->name('forceDestroy');
// 모달 관련 API
Route::get('/{id}/modal', [UserController::class, 'modal'])->name('modal');
});
// 메뉴 관리 API