feat: 테넌트 정보 모달 팝업 기능 추가

- 테넌트 row 클릭 시 모달 팝업 표시
- 컨텍스트 메뉴 (우클릭) 지원
- 탭 구조: 구독정보, 사용자, 부서, 역할, 메뉴
- 메뉴 탭 트리 구조 접기/펼치기 기능
- 삭제된 테넌트 경고 배너 (삭제일, 삭제자 표시)
- 복원 버튼으로 즉시 복원 및 모달 새로고침
- 액션 버튼 (수정/삭제) 클릭 시 모달 미표시
This commit is contained in:
2025-11-27 19:11:32 +09:00
parent ff943ab728
commit b32f6cfcf0
17 changed files with 1476 additions and 4 deletions

View File

@@ -205,4 +205,111 @@ public function stats(Request $request): JsonResponse
'data' => $stats,
]);
}
/**
* 테넌트 모달 정보 조회
*/
public function modal(Request $request, int $id): JsonResponse
{
$tenant = $this->tenantService->getTenantForModal($id);
if (! $tenant) {
return response()->json([
'success' => false,
'message' => '테넌트를 찾을 수 없습니다.',
], 404);
}
$html = view('tenants.partials.modal-info', compact('tenant'))->render();
return response()->json([
'success' => true,
'html' => $html,
]);
}
/**
* 테넌트 사용자 탭 데이터
*/
public function users(Request $request, int $id): JsonResponse
{
$users = $this->tenantService->getTenantUsers($id);
$html = view('tenants.partials.modal-users', compact('users'))->render();
return response()->json([
'success' => true,
'html' => $html,
]);
}
/**
* 테넌트 부서 탭 데이터
*/
public function departments(Request $request, int $id): JsonResponse
{
$departments = $this->tenantService->getTenantDepartments($id);
$html = view('tenants.partials.modal-departments', compact('departments'))->render();
return response()->json([
'success' => true,
'html' => $html,
]);
}
/**
* 테넌트 역할 탭 데이터
*/
public function roles(Request $request, int $id): JsonResponse
{
$roles = $this->tenantService->getTenantRoles($id);
$html = view('tenants.partials.modal-roles', compact('roles'))->render();
return response()->json([
'success' => true,
'html' => $html,
]);
}
/**
* 테넌트 메뉴 탭 데이터
*/
public function menus(Request $request, int $id): JsonResponse
{
$menus = $this->tenantService->getTenantMenus($id);
$html = view('tenants.partials.modal-menus', compact('menus'))->render();
return response()->json([
'success' => true,
'html' => $html,
]);
}
/**
* 테넌트 구독 정보 탭 데이터
*/
public function subscription(Request $request, int $id): JsonResponse
{
$tenant = $this->tenantService->getTenantForModal($id);
if (! $tenant) {
return response()->json([
'success' => false,
'message' => '테넌트를 찾을 수 없습니다.',
], 404);
}
$subscription = $this->tenantService->getTenantSubscription($id);
$usage = $this->tenantService->getTenantUsage($id);
$html = view('tenants.partials.modal-subscription', compact('tenant', 'subscription', 'usage'))->render();
return response()->json([
'success' => true,
'html' => $html,
]);
}
}

View File

@@ -29,6 +29,8 @@ class Tenant extends Model
'fax',
// 구독 정보
'tenant_st_code',
'tenant_type',
'template_id',
'billing_tp_code',
'max_users',
'trial_ends_at',
@@ -36,6 +38,8 @@ class Tenant extends Model
'last_paid_at',
// 관리 메모
'admin_memo',
// 삭제 정보
'deleted_by',
];
protected $casts = [
@@ -88,6 +92,14 @@ public function roles(): HasMany
return $this->hasMany(\App\Models\Permissions\Role::class, 'tenant_id');
}
/**
* 관계: 삭제한 사용자
*/
public function deletedByUser(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class, 'deleted_by');
}
/**
* 상태 배지 색상 (Blade 뷰에서 사용)
*/

View File

@@ -5,6 +5,7 @@
use App\Models\Tenants\Tenant;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class TenantService
{
@@ -88,6 +89,10 @@ public function deleteTenant(int $id): bool
{
$tenant = Tenant::findOrFail($id);
// 삭제자 기록
$tenant->deleted_by = auth()->id();
$tenant->save();
return $tenant->delete();
}
@@ -98,6 +103,9 @@ public function restoreTenant(int $id): bool
{
$tenant = Tenant::onlyTrashed()->findOrFail($id);
// 삭제 정보 초기화
$tenant->deleted_by = null;
return $tenant->restore();
}
@@ -156,4 +164,195 @@ public function getTenantStats(): array
'trashed' => Tenant::onlyTrashed()->count(),
];
}
/**
* 모달용 테넌트 상세 정보 조회
*/
public function getTenantForModal(int $id): ?Tenant
{
return Tenant::query()
->with('deletedByUser')
->withCount(['users', 'departments', 'menus', 'roles'])
->withTrashed()
->find($id);
}
/**
* 테넌트 소속 사용자 목록 조회
*/
public function getTenantUsers(int $tenantId): Collection
{
$tenant = Tenant::find($tenantId);
if (! $tenant) {
return collect();
}
// 사용자와 함께 역할/부서 정보를 가져옴
return $tenant->users()
->with(['userRoles' => function ($query) use ($tenantId) {
$query->where('tenant_id', $tenantId)->with('role');
}, 'departmentUsers' => function ($query) use ($tenantId) {
$query->where('tenant_id', $tenantId)->with('department');
}])
->orderBy('name')
->get()
->map(function ($user) {
// 역할과 부서를 플랫하게 매핑
$user->roles = $user->userRoles->pluck('role')->filter();
$user->department = $user->departmentUsers->first()?->department;
return $user;
});
}
/**
* 테넌트 부서 목록 조회 (계층 구조)
*/
public function getTenantDepartments(int $tenantId): Collection
{
// Department 모델의 users() 관계가 잘못된 네임스페이스를 참조하므로 직접 카운트
$departments = \App\Models\Department::query()
->where('tenant_id', $tenantId)
->orderBy('sort_order')
->orderBy('name')
->get();
// 부서별 사용자 수 직접 카운트
$userCounts = DB::table('department_user')
->whereIn('department_id', $departments->pluck('id'))
->selectRaw('department_id, count(*) as cnt')
->groupBy('department_id')
->pluck('cnt', 'department_id');
return $departments->map(function ($dept) use ($userCounts, $departments) {
// 사용자 수 설정
$dept->users_count = $userCounts[$dept->id] ?? 0;
// 계층 깊이 계산
$depth = 0;
$parentId = $dept->parent_id;
while ($parentId) {
$depth++;
$parent = $departments->firstWhere('id', $parentId);
$parentId = $parent?->parent_id;
}
$dept->depth = $depth;
// 상위 부서 이름
$dept->parent = $departments->firstWhere('id', $dept->parent_id);
return $dept;
});
}
/**
* 테넌트 역할 목록 조회
*/
public function getTenantRoles(int $tenantId): Collection
{
// Role 모델의 userRoles() 관계가 잘못된 네임스페이스를 참조하므로 직접 카운트
$roles = \App\Models\Role::query()
->where('tenant_id', $tenantId)
->withCount('permissions')
->with('permissions')
->orderBy('name')
->get();
// 역할별 사용자 수 직접 카운트
$userCounts = DB::table('user_roles')
->whereIn('role_id', $roles->pluck('id'))
->selectRaw('role_id, count(*) as cnt')
->groupBy('role_id')
->pluck('cnt', 'role_id');
return $roles->map(function ($role) use ($userCounts) {
$role->users_count = $userCounts[$role->id] ?? 0;
return $role;
});
}
/**
* 테넌트 메뉴 목록 조회 (계층 구조 - 트리 정렬)
*/
public function getTenantMenus(int $tenantId): \Illuminate\Support\Collection
{
$menus = \App\Models\Commons\Menu::query()
->where('tenant_id', $tenantId)
->orderBy('sort_order')
->orderBy('id')
->get();
// 트리 구조로 정렬 후 플랫한 배열로 변환
return $this->flattenMenuTree($menus);
}
/**
* 트리 구조를 플랫한 배열로 변환 (depth 정보 포함)
*/
private function flattenMenuTree(Collection $menus, ?int $parentId = null, int $depth = 0): \Illuminate\Support\Collection
{
$result = collect();
$filteredMenus = $menus->where('parent_id', $parentId)->sortBy('sort_order');
foreach ($filteredMenus as $menu) {
$menu->depth = $depth;
// 자식 메뉴 존재 여부 확인
$menu->has_children = $menus->where('parent_id', $menu->id)->count() > 0;
$result->push($menu);
// 자식 메뉴 재귀적으로 추가
$children = $this->flattenMenuTree($menus, $menu->id, $depth + 1);
$result = $result->merge($children);
}
return $result;
}
/**
* 테넌트 구독 정보 조회
*/
public function getTenantSubscription(int $tenantId): object
{
$tenant = Tenant::find($tenantId);
// 기본 구독 정보 (실제 구현 시 별도 테이블에서 조회)
return (object) [
'plan_name' => $tenant?->subscription_plan ?? '기본 플랜',
'status' => $tenant?->subscription_status ?? 'active',
'started_at' => $tenant?->created_at?->format('Y-m-d'),
'expires_at' => $tenant?->subscription_expires_at ?? null,
'next_billing_date' => null,
'max_users' => $tenant?->max_users ?? null,
'max_storage' => $tenant?->max_storage ?? null,
'max_storage_mb' => null,
'has_api_access' => true,
'has_advanced_reports' => false,
'features' => [
['name' => '기본 기능', 'enabled' => true],
['name' => '사용자 관리', 'enabled' => true],
['name' => '부서 관리', 'enabled' => true],
['name' => '역할 관리', 'enabled' => true],
['name' => 'API 접근', 'enabled' => true],
['name' => '고급 보고서', 'enabled' => false],
],
];
}
/**
* 테넌트 사용량 정보 조회
*/
public function getTenantUsage(int $tenantId): object
{
$tenant = Tenant::withCount('users')->find($tenantId);
return (object) [
'users_count' => $tenant?->users_count ?? 0,
'storage_used' => '0 MB',
'storage_used_mb' => 0,
];
}
}

139
public/js/context-menu.js Normal file
View File

@@ -0,0 +1,139 @@
/**
* 컨텍스트 메뉴 (우클릭 메뉴)
* 테넌트명, 사용자명 등에서 우클릭 시 해당 위치에 메뉴 표시
*/
class ContextMenu {
constructor() {
this.menuElement = null;
this.currentTarget = null;
this.init();
}
init() {
// 컨텍스트 메뉴 요소 찾기
this.menuElement = document.getElementById('context-menu');
if (!this.menuElement) return;
// 우클릭 이벤트 등록 (이벤트 위임)
document.addEventListener('contextmenu', (e) => this.handleContextMenu(e));
// 클릭 시 메뉴 닫기
document.addEventListener('click', () => this.hide());
// ESC 키로 메뉴 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') this.hide();
});
// 스크롤 시 메뉴 닫기
document.addEventListener('scroll', () => this.hide(), true);
}
handleContextMenu(e) {
const trigger = e.target.closest('[data-context-menu]');
if (!trigger) return;
e.preventDefault();
const menuType = trigger.dataset.contextMenu;
const entityId = trigger.dataset.entityId;
const entityName = trigger.dataset.entityName;
this.currentTarget = {
type: menuType,
id: entityId,
name: entityName,
element: trigger
};
this.show(e.clientX, e.clientY, menuType);
}
show(x, y, menuType) {
if (!this.menuElement) return;
// 메뉴 타입에 따라 항목 표시/숨김
this.updateMenuItems(menuType);
// 메뉴 표시
this.menuElement.classList.remove('hidden');
// 위치 조정 (화면 밖으로 나가지 않도록)
const menuRect = this.menuElement.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let posX = x;
let posY = y;
if (x + menuRect.width > viewportWidth) {
posX = viewportWidth - menuRect.width - 10;
}
if (y + menuRect.height > viewportHeight) {
posY = viewportHeight - menuRect.height - 10;
}
this.menuElement.style.left = `${posX}px`;
this.menuElement.style.top = `${posY}px`;
}
hide() {
if (this.menuElement) {
this.menuElement.classList.add('hidden');
}
this.currentTarget = null;
}
updateMenuItems(menuType) {
// 모든 메뉴 항목 숨기기
const allItems = this.menuElement.querySelectorAll('[data-menu-for]');
allItems.forEach(item => {
const forTypes = item.dataset.menuFor.split(',');
if (forTypes.includes(menuType) || forTypes.includes('all')) {
item.classList.remove('hidden');
} else {
item.classList.add('hidden');
}
});
}
// 메뉴 항목 클릭 핸들러
handleMenuAction(action) {
if (!this.currentTarget) return;
const { type, id, name } = this.currentTarget;
switch (action) {
case 'view-tenant':
if (typeof TenantModal !== 'undefined') {
TenantModal.open(id);
}
break;
case 'edit-tenant':
window.location.href = `/tenants/${id}/edit`;
break;
case 'view-user':
if (typeof UserModal !== 'undefined') {
UserModal.open(id);
}
break;
case 'edit-user':
window.location.href = `/users/${id}/edit`;
break;
default:
console.log('Unknown action:', action, { type, id, name });
}
this.hide();
}
}
// 전역 인스턴스 생성
const contextMenu = new ContextMenu();
// 전역 함수로 노출 (onclick에서 사용)
function handleContextMenuAction(action) {
contextMenu.handleMenuAction(action);
}

242
public/js/tenant-modal.js Normal file
View File

@@ -0,0 +1,242 @@
/**
* 테넌트 정보 모달
* 테넌트 상세 정보를 팝업으로 표시하고 탭으로 관련 정보 관리
*/
const TenantModal = {
modalElement: null,
currentTenantId: null,
currentTab: 'info',
init() {
this.modalElement = document.getElementById('tenant-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(tenantId) {
if (!this.modalElement) {
console.error('Tenant modal element not found');
return;
}
this.currentTenantId = tenantId;
this.currentTab = 'subscription';
// 모달 표시
this.modalElement.classList.remove('hidden');
document.body.style.overflow = 'hidden';
// 로딩 표시
this.showLoading();
// 테넌트 정보 로드
await this.loadTenantInfo();
},
close() {
if (this.modalElement) {
this.modalElement.classList.add('hidden');
document.body.style.overflow = '';
}
this.currentTenantId = null;
},
showLoading() {
const content = document.getElementById('tenant-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 loadTenantInfo() {
try {
const response = await fetch(`/api/admin/tenants/${this.currentTenantId}/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('tenant-modal-content');
if (content) {
content.innerHTML = data.html;
}
// 구독정보 탭 자동 로드
this.currentTab = ''; // switchTab에서 early return 방지
await this.switchTab('subscription');
}
} catch (error) {
console.error('Failed to load tenant info:', error);
this.showError('테넌트 정보를 불러오는데 실패했습니다.');
}
},
async switchTab(tab) {
if (this.currentTab === tab) return;
this.currentTab = tab;
// 탭 버튼 스타일 업데이트
document.querySelectorAll('#tenant-modal [data-tab-btn]').forEach(btn => {
if (btn.dataset.tabBtn === tab) {
btn.classList.add('border-blue-500', 'text-blue-600');
btn.classList.remove('border-transparent', 'text-gray-500');
} else {
btn.classList.remove('border-blue-500', 'text-blue-600');
btn.classList.add('border-transparent', 'text-gray-500');
}
});
// 탭 콘텐츠 로드
const tabContent = document.getElementById('tenant-modal-tab-content');
if (!tabContent) return;
// 로딩 표시
tabContent.innerHTML = `
<div class="flex items-center justify-center h-32">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
`;
try {
const response = await fetch(`/api/admin/tenants/${this.currentTenantId}/${tab}`, {
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json'
}
});
const data = await response.json();
if (data.html) {
tabContent.innerHTML = data.html;
}
} catch (error) {
console.error(`Failed to load ${tab} tab:`, error);
tabContent.innerHTML = `
<div class="text-center py-8 text-red-500">
데이터를 불러오는데 실패했습니다.
</div>
`;
}
},
showError(message) {
const content = document.getElementById('tenant-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="TenantModal.close()" class="mt-4 px-4 py-2 bg-gray-200 rounded hover:bg-gray-300">
닫기
</button>
</div>
`;
}
},
// 수정 페이지로 이동
goToEdit() {
if (this.currentTenantId) {
window.location.href = `/tenants/${this.currentTenantId}/edit`;
}
}
};
/**
* 모달 내 메뉴 자식 토글
* @param {number} menuId 부모 메뉴 ID
*/
window.toggleModalMenuChildren = function(menuId) {
const rows = document.querySelectorAll('.modal-menu-row');
const toggleBtn = document.querySelector(`.modal-menu-toggle[data-menu-id="${menuId}"]`);
const chevron = toggleBtn?.querySelector('.chevron-icon');
// 직접 자식들 찾기
const directChildren = [];
rows.forEach(row => {
if (row.dataset.parentId == menuId) {
directChildren.push(row);
}
});
if (directChildren.length === 0) return;
// 첫 번째 자식의 현재 상태 확인
const isHidden = directChildren[0].classList.contains('hidden');
if (isHidden) {
// 펼치기: 직접 자식만 표시
directChildren.forEach(child => {
child.classList.remove('hidden');
});
// 쉐브론 회전 초기화
if (chevron) {
chevron.classList.remove('-rotate-90');
}
} else {
// 접기: 모든 자손 숨기기 (재귀적으로)
window.hideModalMenuDescendants(menuId, rows);
// 쉐브론 회전
if (chevron) {
chevron.classList.add('-rotate-90');
}
}
};
/**
* 특정 메뉴의 모든 자손 숨기기 (재귀)
* @param {number} parentId 부모 메뉴 ID
* @param {NodeList} rows 모든 메뉴 행
*/
window.hideModalMenuDescendants = function(parentId, rows) {
rows.forEach(row => {
if (row.dataset.parentId == parentId) {
row.classList.add('hidden');
// 자식의 쉐브론도 접힌 상태로
const childToggle = row.querySelector('.modal-menu-toggle');
const childChevron = childToggle?.querySelector('.chevron-icon');
if (childChevron) {
childChevron.classList.add('-rotate-90');
}
// 손자들도 재귀적으로 숨기기
const childMenuId = row.dataset.menuId;
if (childMenuId) {
window.hideModalMenuDescendants(childMenuId, rows);
}
}
});
};
// DOM 로드 시 초기화
document.addEventListener('DOMContentLoaded', () => {
TenantModal.init();
});

View File

@@ -0,0 +1,52 @@
{{-- 컨텍스트 메뉴 (우클릭 메뉴) --}}
<div id="context-menu"
class="hidden fixed z-50 bg-white rounded-lg shadow-lg border border-gray-200 py-1 min-w-[160px]"
style="left: 0; top: 0;">
{{-- 테넌트 관련 메뉴 --}}
<button type="button"
data-menu-for="tenant"
onclick="handleContextMenuAction('view-tenant')"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
테넌트 조회
</button>
<button type="button"
data-menu-for="tenant"
onclick="handleContextMenuAction('edit-tenant')"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
테넌트 수정
</button>
{{-- 구분선 --}}
<div data-menu-for="tenant" class="border-t border-gray-100 my-1"></div>
{{-- 사용자 관련 메뉴 --}}
<button type="button"
data-menu-for="user"
onclick="handleContextMenuAction('view-user')"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
사용자 조회
</button>
<button type="button"
data-menu-for="user"
onclick="handleContextMenuAction('edit-user')"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
사용자 수정
</button>
</div>

View File

@@ -0,0 +1,39 @@
{{-- 테넌트 정보 모달 --}}
<div id="tenant-modal"
class="hidden fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="tenant-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-5xl 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="tenant-modal-title" class="text-xl font-semibold text-gray-800">
테넌트 정보
</h2>
<button type="button"
onclick="TenantModal.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="tenant-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

@@ -26,6 +26,12 @@
</div>
</div>
<!-- 전역 컨텍스트 메뉴 -->
@include('components.context-menu')
<!-- 테넌트 정보 모달 -->
@include('components.tenant-modal')
<!-- HTMX CSRF 토큰 설정 -->
<script>
document.body.addEventListener('htmx:configRequest', (event) => {
@@ -34,6 +40,8 @@
</script>
<script src="{{ asset('js/pagination.js') }}"></script>
<script src="{{ asset('js/context-menu.js') }}"></script>
<script src="{{ asset('js/tenant-modal.js') }}"></script>
@stack('scripts')
</body>
</html>

View File

@@ -109,6 +109,10 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
}
}).then(() => {
htmx.trigger('#tenant-table', 'filterSubmit');
// 모달이 열려있으면 내용 새로고침
if (TenantModal.isOpen() && TenantModal.currentTenantId === id) {
TenantModal.loadTenantInfo();
}
});
}
};

View File

@@ -0,0 +1,74 @@
{{-- 테넌트 모달 - 부서 --}}
<div class="space-y-4">
{{-- 헤더 --}}
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-700">부서 목록</h3>
<span class="text-xs text-gray-500"> {{ $departments->count() }}</span>
</div>
@if($departments->count() > 0)
{{-- 부서 트리 구조 --}}
<div class="overflow-hidden border border-gray-200 rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">부서명</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">코드</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">상위 부서</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">소속 인원</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">상태</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@foreach($departments as $department)
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 whitespace-nowrap">
<div class="flex items-center">
@if($department->depth > 0)
<span class="text-gray-300 mr-1">
@for($i = 0; $i < $department->depth; $i++)
&nbsp;&nbsp;
@endfor
</span>
@endif
<span class="text-sm font-medium text-gray-900">
{{ $department->name }}
</span>
</div>
</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500">
{{ $department->code ?? '-' }}
</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500">
{{ $department->parent?->name ?? '-' }}
</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500">
{{ $department->users_count ?? 0 }}
</td>
<td class="px-4 py-2 whitespace-nowrap">
@if($department->is_active ?? true)
<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>
@endforeach
</tbody>
</table>
</div>
@else
{{-- 상태 --}}
<div class="text-center py-8 text-gray-500">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<p>등록된 부서가 없습니다.</p>
</div>
@endif
</div>

View File

@@ -0,0 +1,159 @@
{{-- 테넌트 모달 - 기본 정보 + 구조 --}}
<div class="p-6">
{{-- 삭제된 테넌트 경고 배너 --}}
@if($tenant->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">
삭제일: {{ $tenant->deleted_at->format('Y-m-d H:i') }}
@if($tenant->deletedByUser)
· 삭제자: {{ $tenant->deletedByUser->name }}
@endif
</p>
</div>
</div>
<button type="button"
onclick="event.stopPropagation(); confirmRestore({{ $tenant->id }}, '{{ $tenant->company_name }}')"
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">
<div class="w-20 h-20 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-10 h-10 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
</div>
{{-- 우측: 정보 테이블 --}}
<div class="flex-1">
<table class="w-full text-sm">
<tbody>
<tr>
<td class="py-1 pr-4 text-gray-500 w-24">회사명</td>
<td class="py-1 font-medium text-gray-900">{{ $tenant->company_name }}</td>
<td class="py-1 pr-4 text-gray-500 w-24">코드</td>
<td class="py-1 font-medium text-gray-900">{{ $tenant->code }}</td>
<td class="py-1 pr-4 text-gray-500 w-24">유형</td>
<td class="py-1">
@php
$typeLabels = ['STD' => '일반', 'TPL' => '템플릿', 'HQ' => '본사'];
$typeColors = [
'STD' => 'bg-gray-100 text-gray-800',
'TPL' => 'bg-purple-100 text-purple-800',
'HQ' => 'bg-blue-100 text-blue-800'
];
$type = $tenant->tenant_type ?? 'STD';
@endphp
<span class="px-2 py-0.5 text-xs font-semibold rounded-full {{ $typeColors[$type] ?? 'bg-gray-100 text-gray-800' }}">
{{ $typeLabels[$type] ?? $type }}
</span>
</td>
</tr>
<tr>
<td class="py-1 pr-4 text-gray-500">대표자</td>
<td class="py-1 text-gray-900">{{ $tenant->ceo_name ?? '-' }}</td>
<td class="py-1 pr-4 text-gray-500">사업자번호</td>
<td class="py-1 text-gray-900">{{ $tenant->business_num ?? '-' }}</td>
<td class="py-1 pr-4 text-gray-500">상태</td>
<td class="py-1">
<span class="px-2 py-0.5 text-xs font-semibold rounded-full
{{ $tenant->status_badge_color === 'success' ? 'bg-green-100 text-green-800' : '' }}
{{ $tenant->status_badge_color === 'warning' ? 'bg-yellow-100 text-yellow-800' : '' }}
{{ $tenant->status_badge_color === 'error' ? 'bg-red-100 text-red-800' : '' }}">
{{ $tenant->status_label }}
</span>
</td>
</tr>
<tr>
<td class="py-1 pr-4 text-gray-500">이메일</td>
<td class="py-1 text-gray-900">{{ $tenant->email ?? '-' }}</td>
<td class="py-1 pr-4 text-gray-500">전화번호</td>
<td class="py-1 text-gray-900">{{ $tenant->phone ?? '-' }}</td>
<td class="py-1 pr-4 text-gray-500">등록일</td>
<td class="py-1 text-gray-900">{{ $tenant->created_at?->format('Y-m-d') ?? '-' }}</td>
</tr>
<tr>
<td class="py-1 pr-4 text-gray-500">주소</td>
<td colspan="5" class="py-1 text-gray-900">{{ $tenant->address ?? '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{{-- 메뉴 --}}
<div class="border-b border-gray-200 mb-4">
<nav class="flex -mb-px gap-1">
<button type="button"
data-tab-btn="users"
onclick="TenantModal.switchTab('users')"
class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
사용자 <span class="ml-1 px-1.5 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">{{ $tenant->users_count ?? 0 }}</span>
</button>
<button type="button"
data-tab-btn="departments"
onclick="TenantModal.switchTab('departments')"
class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
부서 <span class="ml-1 px-1.5 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">{{ $tenant->departments_count ?? 0 }}</span>
</button>
<button type="button"
data-tab-btn="roles"
onclick="TenantModal.switchTab('roles')"
class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
역할 <span class="ml-1 px-1.5 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">{{ $tenant->roles_count ?? 0 }}</span>
</button>
<button type="button"
data-tab-btn="menus"
onclick="TenantModal.switchTab('menus')"
class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
메뉴 <span class="ml-1 px-1.5 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">{{ $tenant->menus_count ?? 0 }}</span>
</button>
<button type="button"
data-tab-btn="subscription"
onclick="TenantModal.switchTab('subscription')"
class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
구독 정보
</button>
</nav>
</div>
{{-- 콘텐츠 --}}
<div id="tenant-modal-tab-content" class="min-h-[200px]">
<div class="text-center py-12 text-gray-500">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p> 탭을 선택하여 상세 정보를 확인하세요.</p>
</div>
</div>
{{-- 하단 버튼 --}}
<div class="flex justify-end gap-2 mt-6 pt-4 border-t border-gray-200">
<button type="button"
onclick="TenantModal.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>
<button type="button"
onclick="TenantModal.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

@@ -0,0 +1,108 @@
{{-- 테넌트 모달 - 메뉴 --}}
<div class="space-y-4">
{{-- 헤더 --}}
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-700">메뉴 목록</h3>
<span class="text-xs text-gray-500"> {{ $menus->count() }}</span>
</div>
@if($menus->count() > 0)
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-700 uppercase">ID</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-700 uppercase">메뉴명</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-700 uppercase">URL</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-700 uppercase">정렬</th>
<th class="px-4 py-2 text-center text-xs font-semibold text-gray-700 uppercase">활성</th>
<th class="px-4 py-2 text-center text-xs font-semibold text-gray-700 uppercase">숨김</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@foreach($menus as $menu)
<tr class="hover:bg-gray-50 modal-menu-row"
data-menu-id="{{ $menu->id }}"
data-parent-id="{{ $menu->parent_id ?? '' }}"
data-depth="{{ $menu->depth ?? 0 }}">
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">
{{ $menu->id }}
</td>
<td class="px-4 py-2 whitespace-nowrap">
<div class="flex items-center gap-2" style="padding-left: {{ (($menu->depth ?? 0) * 1.5) }}rem;">
{{-- 트리 구조 표시 --}}
@if(($menu->depth ?? 0) > 0)
<span class="text-gray-300 text-xs font-mono flex-shrink-0">└─</span>
@endif
{{-- 폴더/아이템 아이콘 --}}
@if($menu->has_children ?? false)
<button type="button"
onclick="toggleModalMenuChildren({{ $menu->id }})"
class="modal-menu-toggle flex items-center text-blue-500 hover:text-blue-700 focus:outline-none"
data-menu-id="{{ $menu->id }}">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<svg class="w-3 h-3 ml-0.5 transform transition-transform chevron-icon" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
</button>
@else
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
@endif
{{-- 메뉴 정보 --}}
<div class="flex items-center gap-2">
<span class="text-sm {{ ($menu->depth ?? 0) === 0 ? 'font-semibold text-gray-900' : 'font-medium text-gray-700' }}">
{{ $menu->name }}
</span>
@if($menu->is_external ?? false)
<span class="text-xs text-blue-600">(외부)</span>
@endif
</div>
</div>
</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500">
@if(($menu->is_external ?? false) && $menu->external_url)
<a href="{{ $menu->external_url }}" target="_blank" class="text-blue-600 hover:underline">
{{ Str::limit($menu->external_url, 25) }}
</a>
@elseif($menu->url)
{{ Str::limit($menu->url, 25) }}
@else
-
@endif
</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500">
{{ $menu->sort_order ?? 0 }}
</td>
<td class="px-4 py-2 whitespace-nowrap text-center">
{{-- 활성 상태 표시 (액션 없음) --}}
<span class="relative inline-flex h-4 w-8 items-center rounded-full {{ $menu->is_active ? 'bg-blue-500' : 'bg-gray-300' }}">
<span class="inline-block h-3 w-3 transform rounded-full bg-white shadow-sm {{ $menu->is_active ? 'translate-x-4' : 'translate-x-0.5' }}"></span>
</span>
</td>
<td class="px-4 py-2 whitespace-nowrap text-center">
{{-- 숨김 상태 표시 (액션 없음) --}}
<span class="relative inline-flex h-4 w-8 items-center rounded-full {{ $menu->hidden ? 'bg-amber-500' : 'bg-gray-300' }}">
<span class="inline-block h-3 w-3 transform rounded-full bg-white shadow-sm {{ $menu->hidden ? 'translate-x-4' : 'translate-x-0.5' }}"></span>
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
{{-- 상태 --}}
<div class="text-center py-8 text-gray-500">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<p>등록된 메뉴가 없습니다.</p>
</div>
@endif
</div>

View File

@@ -0,0 +1,77 @@
{{-- 테넌트 모달 - 역할 --}}
<div class="space-y-4">
{{-- 헤더 --}}
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-700">역할 목록</h3>
<span class="text-xs text-gray-500"> {{ $roles->count() }}</span>
</div>
@if($roles->count() > 0)
{{-- 역할 카드 그리드 --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@foreach($roles as $role)
<div class="border border-gray-200 rounded-lg p-4 hover:bg-gray-50">
<div class="flex items-start justify-between">
<div class="flex-1">
<h4 class="text-sm font-medium text-gray-900">{{ $role->name }}</h4>
@if($role->description)
<p class="mt-1 text-xs text-gray-500">{{ $role->description }}</p>
@endif
</div>
<div class="ml-4">
@if($role->is_system ?? false)
<span class="px-2 py-0.5 text-xs font-semibold rounded-full bg-purple-100 text-purple-800">
시스템
</span>
@else
<span class="px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
사용자 정의
</span>
@endif
</div>
</div>
{{-- 역할 통계 --}}
<div class="mt-3 flex items-center gap-4 text-xs text-gray-500">
<span class="flex items-center gap-1">
<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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
{{ $role->users_count ?? 0 }}
</span>
<span class="flex items-center gap-1">
<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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
{{ $role->permissions_count ?? 0 }} 권한
</span>
</div>
{{-- 주요 권한 미리보기 --}}
@if(isset($role->permissions) && $role->permissions->count() > 0)
<div class="mt-3 flex flex-wrap gap-1">
@foreach($role->permissions->take(3) as $permission)
<span class="px-1.5 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
{{ $permission->name }}
</span>
@endforeach
@if($role->permissions->count() > 3)
<span class="px-1.5 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
+{{ $role->permissions->count() - 3 }}
</span>
@endif
</div>
@endif
</div>
@endforeach
</div>
@else
{{-- 상태 --}}
<div class="text-center py-8 text-gray-500">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<p>등록된 역할이 없습니다.</p>
</div>
@endif
</div>

View File

@@ -0,0 +1,135 @@
{{-- 테넌트 모달 - 구독 정보 --}}
<div class="space-y-4">
{{-- 헤더 --}}
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-700">구독 정보</h3>
</div>
{{-- 구독 상태 카드 --}}
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg p-4 border border-blue-100">
<div class="flex items-center justify-between">
<div>
<span class="text-sm text-gray-500">현재 플랜</span>
<h4 class="text-xl font-bold text-gray-900 mt-1">
{{ $subscription->plan_name ?? '기본 플랜' }}
</h4>
</div>
<div class="text-right">
@php
$statusColors = [
'active' => 'bg-green-100 text-green-800',
'trial' => 'bg-blue-100 text-blue-800',
'expired' => 'bg-red-100 text-red-800',
'cancelled' => 'bg-gray-100 text-gray-800',
];
$statusLabels = [
'active' => '활성',
'trial' => '체험판',
'expired' => '만료',
'cancelled' => '취소됨',
];
$status = $subscription->status ?? 'active';
@endphp
<span class="px-3 py-1 text-sm font-semibold rounded-full {{ $statusColors[$status] ?? 'bg-gray-100 text-gray-800' }}">
{{ $statusLabels[$status] ?? $status }}
</span>
</div>
</div>
</div>
{{-- 구독 상세 정보 --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{{-- 기간 정보 --}}
<div class="border border-gray-200 rounded-lg p-4">
<h5 class="text-xs font-medium text-gray-500 uppercase mb-3">기간 정보</h5>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-gray-500">시작일</span>
<span class="text-gray-900">{{ $subscription->started_at ?? $tenant->created_at?->format('Y-m-d') ?? '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">만료일</span>
<span class="text-gray-900">{{ $subscription->expires_at ?? '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">갱신 예정일</span>
<span class="text-gray-900">{{ $subscription->next_billing_date ?? '-' }}</span>
</div>
</div>
</div>
{{-- 사용량 정보 --}}
<div class="border border-gray-200 rounded-lg p-4">
<h5 class="text-xs font-medium text-gray-500 uppercase mb-3">사용량</h5>
<div class="space-y-3">
{{-- 사용자 --}}
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-500">사용자</span>
<span class="text-gray-900">
{{ $usage->users_count ?? $tenant->users_count ?? 0 }} / {{ $subscription->max_users ?? '무제한' }}
</span>
</div>
@if(isset($subscription->max_users) && $subscription->max_users > 0)
@php
$usersPercent = min(100, (($usage->users_count ?? $tenant->users_count ?? 0) / $subscription->max_users) * 100);
@endphp
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div class="bg-blue-600 h-1.5 rounded-full" style="width: {{ $usersPercent }}%"></div>
</div>
@endif
</div>
{{-- 저장 용량 --}}
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-500">저장 용량</span>
<span class="text-gray-900">
{{ $usage->storage_used ?? '0 MB' }} / {{ $subscription->max_storage ?? '무제한' }}
</span>
</div>
@if(isset($subscription->max_storage_mb) && $subscription->max_storage_mb > 0)
@php
$storagePercent = min(100, (($usage->storage_used_mb ?? 0) / $subscription->max_storage_mb) * 100);
@endphp
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div class="bg-green-600 h-1.5 rounded-full" style="width: {{ $storagePercent }}%"></div>
</div>
@endif
</div>
</div>
</div>
</div>
{{-- 포함된 기능 --}}
<div class="border border-gray-200 rounded-lg p-4">
<h5 class="text-xs font-medium text-gray-500 uppercase mb-3">포함된 기능</h5>
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
@php
$features = $subscription->features ?? [
['name' => '기본 기능', 'enabled' => true],
['name' => '사용자 관리', 'enabled' => true],
['name' => '부서 관리', 'enabled' => true],
['name' => '역할 관리', 'enabled' => true],
['name' => 'API 접근', 'enabled' => $subscription->has_api_access ?? false],
['name' => '고급 보고서', 'enabled' => $subscription->has_advanced_reports ?? false],
];
@endphp
@foreach($features as $feature)
<div class="flex items-center gap-2 text-sm">
@if($feature['enabled'] ?? false)
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span class="text-gray-900">{{ $feature['name'] }}</span>
@else
<svg class="w-4 h-4 text-gray-300" 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>
<span class="text-gray-400">{{ $feature['name'] }}</span>
@endif
</div>
@endforeach
</div>
</div>
</div>

View File

@@ -0,0 +1,86 @@
{{-- 테넌트 모달 - 사용자 --}}
<div class="space-y-4">
{{-- 헤더 --}}
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-700">소속 사용자 목록</h3>
<span class="text-xs text-gray-500"> {{ $users->count() }}</span>
</div>
@if($users->count() > 0)
{{-- 사용자 테이블 --}}
<div class="overflow-hidden border border-gray-200 rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">이름</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">이메일</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">부서</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">역할</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">상태</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">가입일</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@foreach($users as $user)
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 whitespace-nowrap">
<span 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 }}">
{{ $user->name }}
</span>
</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500">
{{ $user->email }}
</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500">
{{ $user->department?->name ?? '-' }}
</td>
<td class="px-4 py-2 whitespace-nowrap">
@if($user->roles && $user->roles->count() > 0)
<div class="flex flex-wrap gap-1">
@foreach($user->roles->take(2) as $role)
<span class="px-1.5 py-0.5 text-xs bg-blue-100 text-blue-800 rounded">
{{ $role->name }}
</span>
@endforeach
@if($user->roles->count() > 2)
<span class="px-1.5 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
+{{ $user->roles->count() - 2 }}
</span>
@endif
</div>
@else
<span class="text-xs text-gray-400">-</span>
@endif
</td>
<td class="px-4 py-2 whitespace-nowrap">
@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>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500">
{{ $user->created_at?->format('Y-m-d') ?? '-' }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
{{-- 상태 --}}
<div class="text-center py-8 text-gray-500">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<p>등록된 사용자가 없습니다.</p>
</div>
@endif
</div>

View File

@@ -6,6 +6,7 @@
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">회사명</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">코드</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">상태</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">유형</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">이메일</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">전화번호</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">사용자</th>
@@ -18,12 +19,19 @@
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($tenants as $tenant)
<tr class="{{ $tenant->deleted_at ? 'bg-gray-100' : '' }}">
<tr class="{{ $tenant->deleted_at ? 'bg-gray-100' : '' }} hover:bg-gray-50 cursor-pointer"
onclick="TenantModal.open({{ $tenant->id }})"
data-tenant-id="{{ $tenant->id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ $tenant->id }}
</td>
<td class="px-3 py-2 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ $tenant->company_name }}</div>
<div class="text-sm font-medium text-gray-900 cursor-pointer hover:text-blue-600"
data-context-menu="tenant"
data-entity-id="{{ $tenant->id }}"
data-entity-name="{{ $tenant->company_name }}">
{{ $tenant->company_name }}
</div>
@if($tenant->ceo_name)
<div class="text-sm text-gray-500">대표: {{ $tenant->ceo_name }}</div>
@endif
@@ -39,6 +47,20 @@
{{ $tenant->status_label }}
</span>
</td>
<td class="px-3 py-2 whitespace-nowrap text-center">
@php
$typeLabels = ['STD' => '일반', 'TPL' => '템플릿', 'HQ' => '본사'];
$typeColors = [
'STD' => 'bg-gray-100 text-gray-800',
'TPL' => 'bg-purple-100 text-purple-800',
'HQ' => 'bg-blue-100 text-blue-800'
];
$type = $tenant->tenant_type ?? 'STD';
@endphp
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ $typeColors[$type] ?? 'bg-gray-100 text-gray-800' }}">
{{ $typeLabels[$type] ?? $type }}
</span>
</td>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">
{{ $tenant->email ?? '-' }}
</td>
@@ -60,7 +82,7 @@
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">
{{ $tenant->created_at?->format('Y-m-d') ?? '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium" onclick="event.stopPropagation()">
@if($tenant->deleted_at)
<!-- 삭제된 항목 -->
<button onclick="confirmRestore({{ $tenant->id }}, '{{ $tenant->company_name }}')"
@@ -76,6 +98,7 @@ class="text-red-600 hover:text-red-900">
@else
<!-- 활성 항목 -->
<a href="{{ route('tenants.edit', $tenant->id) }}"
onclick="event.stopPropagation()"
class="text-blue-600 hover:text-blue-900 mr-3">
수정
</a>
@@ -88,7 +111,7 @@ class="text-red-600 hover:text-red-900">
</tr>
@empty
<tr>
<td colspan="12" class="px-6 py-12 text-center text-gray-500">
<td colspan="13" class="px-6 py-12 text-center text-gray-500">
등록된 테넌트가 없습니다.
</td>
</tr>

View File

@@ -35,6 +35,14 @@
// 추가 액션
Route::post('/{id}/restore', [TenantController::class, 'restore'])->name('restore');
Route::delete('/{id}/force', [TenantController::class, 'forceDestroy'])->name('forceDestroy');
// 모달 관련 API
Route::get('/{id}/modal', [TenantController::class, 'modal'])->name('modal');
Route::get('/{id}/users', [TenantController::class, 'users'])->name('users');
Route::get('/{id}/departments', [TenantController::class, 'departments'])->name('departments');
Route::get('/{id}/roles', [TenantController::class, 'roles'])->name('roles');
Route::get('/{id}/menus', [TenantController::class, 'menus'])->name('menus');
Route::get('/{id}/subscription', [TenantController::class, 'subscription'])->name('subscription');
});
// 역할 관리 API