Files
sam-manage/resources/views/layouts/app.blade.php
권혁성 f271f8bdc3 feat:품목관리 3-Panel 페이지 신규 구현 + FormulaEvaluatorService 연동
- 품목관리 3-Panel 레이아웃 (좌:목록, 중:BOM/수식산출, 우:상세)
- FormulaApiService로 API 견적수식 엔진 연동
- FG 품목 선택 시 기본값(W:1000, H:1000, QTY:1) 자동 산출
- 수식 산출 결과 트리 렌더링 (그룹별/소계/합계)
- 중앙 패널 클릭 시 우측 상세만 변경 (skipCenterUpdate)
- API 인증 버튼 전역 헤더로 이동 (모든 페이지에서 사용 가능)
- FormulaApiService에 Bearer 토큰 지원 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:50:24 +09:00

578 lines
23 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') }}',
debug: {{ config('app.debug') ? 'true' : 'false' }},
};
// 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>
<!-- 페이지 로딩 오버레이 스타일 -->
<style id="page-loader-styles">
.page-loader-overlay {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.page-loader-content {
text-align: center;
}
.page-loader-bar {
width: 200px;
height: 4px;
background: #e5e7eb;
border-radius: 2px;
overflow: hidden;
}
.page-loader-progress {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
border-radius: 2px;
animation: loader-progress 1.5s ease-in-out infinite;
}
@keyframes loader-progress {
0% { width: 0%; margin-left: 0; }
50% { width: 60%; margin-left: 20%; }
100% { width: 0%; margin-left: 100%; }
}
.page-loader-text {
margin-top: 12px;
font-size: 14px;
color: #6b7280;
}
/* 블러 해제 애니메이션 */
.page-loader-overlay.fade-out {
animation: loader-fade-out 0.3s ease-out forwards;
}
@keyframes loader-fade-out {
to {
opacity: 0;
visibility: hidden;
}
}
/* select 요소 텍스트-화살표 겹침 방지 */
select {
padding-right: 2rem !important;
}
</style>
<!-- 사이드바 + 메뉴 상태 즉시 적용 -->
<script>
(function() {
// 사이드바 접힘 상태
if (localStorage.getItem('sidebar-collapsed') === 'true') {
document.documentElement.classList.add('sidebar-is-collapsed');
}
// 메뉴 그룹: hidden 상태인 것만 CSS로 숨김
var style = document.createElement('style');
style.id = 'menu-group-preload-styles';
var css = '';
var iconCss = '';
for (var i = 0; i < localStorage.length; i++) {
var key = localStorage.key(i);
if (key && key.startsWith('menu-group-menu-group-')) {
var groupId = key.replace('menu-group-', '');
var state = localStorage.getItem(key);
if (state === 'hidden') {
css += '#' + groupId + '{display:none!important}';
iconCss += '#' + groupId + '-icon{transform:rotate(0deg)!important}';
}
}
}
if (css || iconCss) {
style.textContent = css + iconCss;
document.head.appendChild(style);
}
// 스크롤 위치 미리 저장
window._savedSidebarScroll = localStorage.getItem('sidebar-scroll-top');
window._savedSidebarScrollBottom = localStorage.getItem('sidebar-scroll-bottom');
})();
</script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Alpine.js Plugins (Collapse) - 반드시 Alpine.js 전에 로드 -->
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- HTMX 전역 설정 (HTMX 로드 직후 등록하여 load 트리거보다 먼저 실행) -->
<script>
// HTMX 에러 핸들러 (401 세션 만료 / 419 CSRF 만료)
document.addEventListener('htmx:responseError', function(event) {
const status = event.detail.xhr.status;
// 419 CSRF 토큰 만료 → 페이지 새로고침으로 토큰 갱신
if (status === 419) {
console.log('[Session] CSRF 토큰 만료, 페이지 새로고침');
window.location.reload();
return;
}
if (status !== 401) return;
// 중복 처리 방지
if (window._sessionRefreshing) return;
window._sessionRefreshing = true;
// 원본 요청 정보 저장
const originalPath = event.detail.pathInfo?.requestPath || window.location.pathname;
// 세션 갱신 시도 (Remember Token으로 재인증)
fetch('/auth/refresh-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'Accept': 'application/json',
},
credentials: 'same-origin',
})
.then(response => response.json())
.then(data => {
window._sessionRefreshing = false;
if (data.success) {
// 세션 갱신 성공 - 페이지 전체 새로고침 (CSRF 토큰도 갱신)
console.log('[Session] 세션 갱신 성공, 페이지 새로고침');
window.location.reload();
} else {
// 세션 갱신 실패 - 로그인 페이지로 이동
redirectToLogin(data.message || '세션이 만료되었습니다. 다시 로그인해주세요.');
}
})
.catch(error => {
window._sessionRefreshing = false;
console.error('[Session] 세션 갱신 실패:', error);
redirectToLogin('세션이 만료되었습니다. 다시 로그인해주세요.');
});
});
// 로그인 페이지로 리다이렉트 (중복 방지 + 명확한 팝업)
function redirectToLogin(message) {
if (window._sessionExpiredRedirecting) return;
window._sessionExpiredRedirecting = true;
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'warning',
title: '세션 만료',
text: message,
confirmButtonText: '로그인 페이지로 이동',
allowOutsideClick: false,
allowEscapeKey: false,
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',
},
buttonsStyling: false,
}).then(() => {
window.location.href = '/login';
});
} else {
alert(message);
window.location.href = '/login';
}
}
document.addEventListener('htmx:configRequest', (event) => {
// CSRF 토큰 설정
const csrfToken = document.querySelector('meta[name="csrf-token"]');
if (csrfToken) {
event.detail.headers['X-CSRF-TOKEN'] = csrfToken.getAttribute('content');
}
// 페이지네이션 per_page 값 적용 (쿠키에서 읽기)
if (event.detail.parameters && 'per_page' in event.detail.parameters) {
const getCookieValue = (name) => {
const nameEQ = name + "=";
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i].trim();
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length);
}
return null;
};
const savedPerPage = getCookieValue('pagination_per_page') || '10';
event.detail.parameters['per_page'] = savedPerPage;
// perPageInput hidden input도 동기화
const perPageInput = document.getElementById('perPageInput');
if (perPageInput) {
perPageInput.value = savedPerPage;
}
}
});
// HTMX 페이지 전환 후 페이지별 스크립트 초기화 (hx-boost 네비게이션용)
document.addEventListener('htmx:afterSettle', function(event) {
// main-content 교체 시에만 처리
if (event.detail.target && event.detail.target.id === 'main-content') {
initPageScripts();
}
});
// 페이지별 스크립트 초기화 함수
function initPageScripts() {
const path = window.location.pathname;
// /menus 페이지: SortableJS 및 필터 폼 초기화
if (path === '/menus' || path.startsWith('/menus/')) {
// SortableJS 초기화 (테이블 로드 후)
if (typeof initMenuSortable === 'function') {
setTimeout(() => initMenuSortable(), 100);
}
// 필터 폼 이벤트 리스너 재연결
const filterForm = document.getElementById('filterForm');
if (filterForm && !filterForm._htmxInitialized) {
filterForm.addEventListener('submit', function(e) {
e.preventDefault();
htmx.trigger('#menu-table', 'filterSubmit');
});
filterForm._htmxInitialized = true;
}
}
}
</script>
<!-- SweetAlert2 -->
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
@stack('styles')
</head>
<body class="bg-gray-100 no-transition">
<!-- 페이지 로딩 오버레이 -->
<div id="page-loader" class="page-loader-overlay">
<div class="page-loader-content">
<div class="page-loader-bar">
<div class="page-loader-progress"></div>
</div>
<div class="page-loader-text">Loading...</div>
</div>
</div>
<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 id="main-content" 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>
<!-- API 인증 모달 (전역) -->
@include('dev-tools.partials.auth-modal')
<!-- 전역 컨텍스트 메뉴 -->
@include('components.context-menu')
<!-- 테넌트 정보 모달 -->
@include('components.tenant-modal')
<!-- 사용자 정보 모달 -->
@include('components.user-modal')
<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>
<!-- SortableJS (메뉴 관리 드래그 드롭용) -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
<script src="{{ asset('js/menu-sortable.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>
<!-- API 인증 스크립트 (전역) -->
@include('dev-tools.partials.auth-scripts')
<!-- 헤더 API 인증 상태 동기화 -->
<script>
(function() {
function updateHeaderAuthBtn() {
const btn = document.getElementById('header-api-auth-btn');
if (!btn) return;
const dot = btn.querySelector('.dev-tools-auth-dot');
const label = btn.querySelector('.dev-tools-auth-status');
const isAuth = window.DevToolsAuth && DevToolsAuth.isAuthenticated();
if (dot) {
dot.classList.toggle('bg-green-500', isAuth);
dot.classList.toggle('bg-gray-300', !isAuth);
}
if (label) {
label.textContent = isAuth ? 'API 인증됨' : 'API 인증';
label.classList.toggle('text-green-600', isAuth);
label.classList.toggle('text-gray-500', !isAuth);
}
btn.classList.toggle('border-green-300', isAuth);
btn.classList.toggle('border-gray-300', !isAuth);
}
// DevToolsAuth 콜백 등록
if (window.DevToolsAuth) {
DevToolsAuth.onAuthChange(updateHeaderAuthBtn);
}
// 초기 상태 동기화
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', updateHeaderAuthBtn);
} else {
updateHeaderAuthBtn();
}
})();
</script>
@stack('scripts')
</body>
</html>