- 모바일 사이드바 오버레이 구현 (슬라이드 인/아웃) - 헤더에 햄버거 메뉴 버튼 추가 - 모바일 백드롭 오버레이 추가 - ESC 키 및 메뉴 클릭 시 사이드바 자동 닫힘
306 lines
12 KiB
PHP
306 lines
12 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
<title>@yield('title', 'Dashboard') - {{ config('app.name') }}</title>
|
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
|
<!-- SAM 전역 설정 (FCM 등에서 사용) -->
|
|
<script>
|
|
window.SAM_CONFIG = {
|
|
apiBaseUrl: '{{ config('services.api.base_url', 'https://api.codebridge-x.com') }}',
|
|
apiKey: '{{ config('services.api.key', '') }}',
|
|
appVersion: '{{ config('app.version', '1.0.0') }}',
|
|
};
|
|
|
|
// API 토큰 sessionStorage 동기화 (FCM 등에서 사용)
|
|
@if(session('api_access_token'))
|
|
(function() {
|
|
const token = '{{ session('api_access_token') }}';
|
|
const expiresAt = {{ session('api_token_expires_at', 0) }};
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
// 토큰이 유효한 경우에만 저장
|
|
if (expiresAt > now) {
|
|
sessionStorage.setItem('api_access_token', token);
|
|
sessionStorage.setItem('api_token_expires_at', expiresAt);
|
|
} else {
|
|
// 만료된 토큰 정리
|
|
sessionStorage.removeItem('api_access_token');
|
|
sessionStorage.removeItem('api_token_expires_at');
|
|
}
|
|
})();
|
|
@else
|
|
// 세션에 토큰이 없으면 sessionStorage도 정리
|
|
sessionStorage.removeItem('api_access_token');
|
|
sessionStorage.removeItem('api_token_expires_at');
|
|
@endif
|
|
</script>
|
|
<!-- 사이드바 상태 즉시 적용 (깜빡임 방지) -->
|
|
<script>
|
|
(function() {
|
|
if (localStorage.getItem('sidebar-collapsed') === 'true') {
|
|
document.documentElement.classList.add('sidebar-is-collapsed');
|
|
}
|
|
})();
|
|
</script>
|
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
<!-- SweetAlert2 -->
|
|
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
|
@stack('styles')
|
|
</head>
|
|
<body class="bg-gray-100">
|
|
<div class="flex h-screen overflow-hidden" id="app-container">
|
|
<!-- 모바일 사이드바 백드롭 (lg 미만에서만 동작) -->
|
|
<div id="sidebar-backdrop"
|
|
class="fixed inset-0 bg-black/50 z-40 hidden lg:hidden"
|
|
onclick="closeMobileSidebar()">
|
|
</div>
|
|
|
|
<!-- Sidebar -->
|
|
@include('partials.sidebar')
|
|
|
|
<!-- Main Content Area -->
|
|
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
|
|
<!-- Header -->
|
|
@include('partials.header')
|
|
|
|
<!-- Page Content -->
|
|
<main class="flex-1 overflow-y-auto bg-gray-100 p-6">
|
|
@yield('content')
|
|
</main>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 전역 컨텍스트 메뉴 -->
|
|
@include('components.context-menu')
|
|
|
|
<!-- 테넌트 정보 모달 -->
|
|
@include('components.tenant-modal')
|
|
|
|
<!-- 사용자 정보 모달 -->
|
|
@include('components.user-modal')
|
|
|
|
<!-- HTMX CSRF 토큰 설정 -->
|
|
<script>
|
|
document.body.addEventListener('htmx:configRequest', (event) => {
|
|
event.detail.headers['X-CSRF-TOKEN'] = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
|
});
|
|
</script>
|
|
|
|
<script src="{{ asset('js/pagination.js') }}"></script>
|
|
<script src="{{ asset('js/table-sort.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>
|
|
<!-- FCM Push Notification (Capacitor 앱에서만 동작) -->
|
|
<script src="{{ asset('js/fcm.js') }}"></script>
|
|
|
|
<!-- SweetAlert2 공통 함수 (Tailwind 테마) -->
|
|
<script>
|
|
// Tailwind 커스텀 클래스
|
|
const SwalTailwind = Swal.mixin({
|
|
customClass: {
|
|
popup: 'rounded-xl shadow-2xl border-0',
|
|
title: 'text-gray-900 font-semibold',
|
|
htmlContainer: 'text-gray-600',
|
|
confirmButton: 'bg-blue-600 hover:bg-blue-700 text-white font-medium px-5 py-2.5 rounded-lg transition-colors focus:ring-4 focus:ring-blue-300',
|
|
cancelButton: 'bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium px-5 py-2.5 rounded-lg transition-colors focus:ring-4 focus:ring-gray-100',
|
|
denyButton: 'bg-red-600 hover:bg-red-700 text-white font-medium px-5 py-2.5 rounded-lg transition-colors focus:ring-4 focus:ring-red-300',
|
|
actions: 'gap-3',
|
|
},
|
|
buttonsStyling: false,
|
|
});
|
|
|
|
// Toast (스낵바) - 우상단 알림
|
|
const Toast = Swal.mixin({
|
|
toast: true,
|
|
position: 'top-end',
|
|
showConfirmButton: false,
|
|
timer: 3000,
|
|
timerProgressBar: true,
|
|
customClass: {
|
|
popup: 'rounded-lg shadow-lg',
|
|
},
|
|
didOpen: (toast) => {
|
|
toast.onmouseenter = Swal.stopTimer;
|
|
toast.onmouseleave = Swal.resumeTimer;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 토스트 알림 표시
|
|
* @param {string} message - 메시지
|
|
* @param {string} type - 'success' | 'error' | 'warning' | 'info'
|
|
* @param {number} timer - 표시 시간 (ms), 기본 3000
|
|
*/
|
|
function showToast(message, type = 'info', timer = 3000) {
|
|
Toast.fire({
|
|
icon: type,
|
|
title: message,
|
|
timer: timer,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 확인 모달 표시
|
|
* @param {string} message - 확인 메시지
|
|
* @param {Function} onConfirm - 확인 시 콜백
|
|
* @param {Object} options - 추가 옵션
|
|
*/
|
|
function showConfirm(message, onConfirm, options = {}) {
|
|
const defaultOptions = {
|
|
title: options.title || '확인',
|
|
icon: options.icon || 'question',
|
|
confirmButtonText: options.confirmText || '확인',
|
|
cancelButtonText: options.cancelText || '취소',
|
|
showCancelButton: true,
|
|
reverseButtons: true,
|
|
};
|
|
|
|
SwalTailwind.fire({
|
|
...defaultOptions,
|
|
html: message,
|
|
}).then((result) => {
|
|
if (result.isConfirmed && typeof onConfirm === 'function') {
|
|
onConfirm();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 삭제 확인 모달 (위험 스타일)
|
|
* @param {string} itemName - 삭제할 항목명
|
|
* @param {Function} onConfirm - 확인 시 콜백
|
|
*/
|
|
function showDeleteConfirm(itemName, onConfirm) {
|
|
SwalTailwind.fire({
|
|
title: '삭제 확인',
|
|
html: `<span class="text-red-600 font-medium">"${itemName}"</span>을(를) 삭제하시겠습니까?`,
|
|
icon: 'warning',
|
|
showCancelButton: true,
|
|
confirmButtonText: '삭제',
|
|
cancelButtonText: '취소',
|
|
reverseButtons: true,
|
|
customClass: {
|
|
popup: 'rounded-xl shadow-2xl border-0',
|
|
title: 'text-gray-900 font-semibold',
|
|
htmlContainer: 'text-gray-600',
|
|
confirmButton: 'bg-red-600 hover:bg-red-700 text-white font-medium px-5 py-2.5 rounded-lg transition-colors focus:ring-4 focus:ring-red-300',
|
|
cancelButton: 'bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium px-5 py-2.5 rounded-lg transition-colors focus:ring-4 focus:ring-gray-100',
|
|
actions: 'gap-3',
|
|
},
|
|
buttonsStyling: false,
|
|
}).then((result) => {
|
|
if (result.isConfirmed && typeof onConfirm === 'function') {
|
|
onConfirm();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 영구 삭제 확인 모달 (매우 위험)
|
|
* @param {string} itemName - 삭제할 항목명
|
|
* @param {Function} onConfirm - 확인 시 콜백
|
|
*/
|
|
function showPermanentDeleteConfirm(itemName, onConfirm) {
|
|
SwalTailwind.fire({
|
|
title: '⚠️ 영구 삭제',
|
|
html: `<span class="text-red-600 font-bold">"${itemName}"</span>을(를) 영구 삭제하시겠습니까?<br><br><span class="text-sm text-gray-500">이 작업은 되돌릴 수 없습니다!</span>`,
|
|
icon: 'error',
|
|
showCancelButton: true,
|
|
confirmButtonText: '영구 삭제',
|
|
cancelButtonText: '취소',
|
|
reverseButtons: true,
|
|
customClass: {
|
|
popup: 'rounded-xl shadow-2xl border-0',
|
|
title: 'text-red-600 font-bold',
|
|
htmlContainer: 'text-gray-600',
|
|
confirmButton: 'bg-red-600 hover:bg-red-700 text-white font-medium px-5 py-2.5 rounded-lg transition-colors focus:ring-4 focus:ring-red-300',
|
|
cancelButton: 'bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium px-5 py-2.5 rounded-lg transition-colors focus:ring-4 focus:ring-gray-100',
|
|
actions: 'gap-3',
|
|
},
|
|
buttonsStyling: false,
|
|
}).then((result) => {
|
|
if (result.isConfirmed && typeof onConfirm === 'function') {
|
|
onConfirm();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 성공 알림 모달
|
|
* @param {string} message - 메시지
|
|
* @param {Function} onClose - 닫기 후 콜백 (선택)
|
|
*/
|
|
function showSuccess(message, onClose = null) {
|
|
SwalTailwind.fire({
|
|
title: '완료',
|
|
text: message,
|
|
icon: 'success',
|
|
confirmButtonText: '확인',
|
|
}).then(() => {
|
|
if (typeof onClose === 'function') {
|
|
onClose();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 에러 알림 모달
|
|
* @param {string} message - 에러 메시지
|
|
*/
|
|
function showError(message) {
|
|
SwalTailwind.fire({
|
|
title: '오류',
|
|
text: message,
|
|
icon: 'error',
|
|
confirmButtonText: '확인',
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<!-- 사이드바 토글 스크립트 -->
|
|
<script>
|
|
// 사이드바 상태 관리
|
|
const SIDEBAR_STATE_KEY = 'sidebar-collapsed';
|
|
|
|
function toggleSidebar() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const html = document.documentElement;
|
|
const isCollapsed = sidebar.classList.contains('sidebar-collapsed') || html.classList.contains('sidebar-is-collapsed');
|
|
|
|
if (isCollapsed) {
|
|
// 펼치기
|
|
sidebar.classList.remove('sidebar-collapsed');
|
|
html.classList.remove('sidebar-is-collapsed');
|
|
localStorage.setItem(SIDEBAR_STATE_KEY, 'false');
|
|
} else {
|
|
// 접기
|
|
sidebar.classList.add('sidebar-collapsed');
|
|
html.classList.add('sidebar-is-collapsed');
|
|
localStorage.setItem(SIDEBAR_STATE_KEY, 'true');
|
|
}
|
|
}
|
|
|
|
// 페이지 로드 시 저장된 사이드바 상태 복원 (클래스 동기화)
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const html = document.documentElement;
|
|
const savedState = localStorage.getItem(SIDEBAR_STATE_KEY);
|
|
|
|
if (savedState === 'true') {
|
|
sidebar.classList.add('sidebar-collapsed');
|
|
html.classList.add('sidebar-is-collapsed');
|
|
} else {
|
|
sidebar.classList.remove('sidebar-collapsed');
|
|
html.classList.remove('sidebar-is-collapsed');
|
|
}
|
|
});
|
|
</script>
|
|
|
|
@stack('scripts')
|
|
</body>
|
|
</html>
|