feat(user-modal): 사용자 정보 모달 및 컨텍스트 메뉴 확장
사용자 모달 기능: - 사용자 정보 모달 팝업 (조회/삭제/수정) - 권한 요약 정보 (Web/API 권한 카운트) - 2x2 그리드 레이아웃 (테넌트, 역할, 부서, 권한) - 테이블 행 클릭으로 모달 열기 - 권한 관리 링크 클릭 시 해당 사용자 자동 선택 컨텍스트 메뉴 확장: - permission-analyze 페이지 사용자 이름에 컨텍스트 메뉴 - user-permissions 페이지 사용자 버튼에 컨텍스트 메뉴 - 사용자 모달 내 테넌트 칩에 컨텍스트 메뉴 - 헤더 테넌트 배지에 컨텍스트 메뉴 - 테넌트 메뉴에 "이 테넌트로 전환" 기능 추가
This commit is contained in:
@@ -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(-)
|
||||
|
||||
---
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 영구 삭제 (슈퍼관리자 전용)
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' => '트라이얼 종료일',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 사용자 목록 조회 (드롭다운용)
|
||||
*/
|
||||
|
||||
@@ -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
191
public/js/user-modal.js
Normal 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();
|
||||
});
|
||||
@@ -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>
|
||||
39
resources/views/components/user-modal.blade.php
Normal file
39
resources/views/components/user-modal.blade.php
Normal 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>
|
||||
@@ -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"]');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]');
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"> {{ $user->user_id }} </span>
|
||||
<span class="user-id-badge px-1.5 py-0.5 text-xs bg-gray-200 text-gray-600 rounded"> {{ $user->user_id }} </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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
199
resources/views/users/partials/modal-info.blade.php
Normal file
199
resources/views/users/partials/modal-info.blade.php
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user