475 lines
17 KiB
TypeScript
475 lines
17 KiB
TypeScript
import './styles/common.css';
|
|
import './styles/login.css';
|
|
import { state, loadMasterDataFromDB, saveAsset } from './core/state';
|
|
import { renderNavigation } from './components/Navigation';
|
|
import { renderDashboard } from './views/DashboardView';
|
|
import { renderSWTable } from './views/SW_Table';
|
|
import { renderLocationView } from './views/LocationView';
|
|
import { renderAuditApprovalView } from './views/AuditApprovalView';
|
|
import { MapEditor } from './views/MapEditor';
|
|
import { initBaseModal } from './components/Modal/BaseModal';
|
|
import { initHwModal, openHwModal } from './components/Modal/HWModal';
|
|
import { initSwModal, openSwModal } from './components/Modal/SWModal';
|
|
import { initSwUserModal } from './components/Modal/SWUserModal';
|
|
import { initDomainModal, openDomainModal } from './components/Modal/DomainModal';
|
|
import { initPartsMasterModal, openPartsMasterModal } from './components/Modal/PartsMasterModal';
|
|
import { initJobSpecModal, openJobSpecModal } from './components/Modal/JobSpecModal';
|
|
import { initUserModal, openUserModal } from './components/Modal/UserModal';
|
|
import { activePartsMasterSubTab } from './views/List/PartsMasterListView';
|
|
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal';
|
|
import { initGuide } from './components/Guide';
|
|
import { pcFlowModal } from './components/Modal/PCFlowModal';
|
|
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide';
|
|
|
|
interface AuthSessionResponse {
|
|
authenticated: boolean;
|
|
user: unknown;
|
|
}
|
|
|
|
let phoneLoginPollTimer: number | undefined;
|
|
|
|
|
|
let activeMapEditorInstance: MapEditor | null = null;
|
|
|
|
// 화면 갱신 통합 핸들러
|
|
async function refreshView(tab?: string) {
|
|
const mainContent = document.getElementById('main-content')!;
|
|
if (!mainContent) return;
|
|
|
|
// Clean up any active MapEditor instance when navigating away
|
|
if (activeMapEditorInstance) {
|
|
activeMapEditorInstance.destroy();
|
|
activeMapEditorInstance = null;
|
|
}
|
|
|
|
const activeTab = tab || state.activeSubTab;
|
|
|
|
if (activeTab === '대시보드') {
|
|
renderDashboard(mainContent);
|
|
return;
|
|
}
|
|
|
|
if (activeTab === '실사 승인') {
|
|
await renderAuditApprovalView(mainContent);
|
|
return;
|
|
}
|
|
|
|
if (activeTab === '위치지정') {
|
|
// Render Map Editor directly into main content to maximize working area
|
|
mainContent.innerHTML = `
|
|
<div class="map-editor-page-wrapper" style="display: flex; flex: 1; height: calc(100vh - var(--header-height) - 48px); overflow: hidden; width: 100%;">
|
|
<!-- Left: File Selector -->
|
|
<div class="file-sidebar" id="file-sidebar"></div>
|
|
|
|
<!-- Center: Main Editor -->
|
|
<div class="editor-container" id="container">
|
|
<div class="img-wrapper" id="wrapper">
|
|
<img src="" id="target-img" alt="Map Image">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right: Control Panel -->
|
|
<div class="sidebar">
|
|
<h2>Map Editor <small class="editor-version">v3.0</small></h2>
|
|
<div class="current-path" id="current-path">파일을 선택하세요</div>
|
|
<p>
|
|
드래그하여 구역을 정의하세요. 저장 버튼을 누르면 즉시 시스템에 반영됩니다.
|
|
</p>
|
|
|
|
<div class="box-list" id="box-list"></div>
|
|
|
|
<div class="actions" style="display: flex; flex-direction: column; gap: 0.5rem;">
|
|
<button id="btn-clear-all" class="btn btn-outline">전체 삭제</button>
|
|
<button id="btn-print-map-qrs" class="btn btn-outline btn-primary">이 도면 QR 일괄인쇄</button>
|
|
<button id="btn-save-server" class="btn btn-primary">서버에 즉시 저장</button>
|
|
<div id="save-status"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Initialize MapEditor instance
|
|
const editor = new MapEditor();
|
|
await editor.init();
|
|
activeMapEditorInstance = editor;
|
|
return;
|
|
}
|
|
|
|
// 서버 탭이 아닐 경우에는 state.viewMode가 location이더라도 강제로 목록(list) 뷰를 그리도록 함
|
|
// (state.viewMode의 원래 상태는 보존하여, 서버 탭 복귀 시 최근 보던 모드를 유지함)
|
|
const isServerTab = activeTab === '서버';
|
|
const effectiveViewMode = isServerTab ? state.viewMode : 'list';
|
|
|
|
mainContent.innerHTML = `
|
|
<div id="view-body" class="view-container"></div>
|
|
`;
|
|
|
|
const viewBody = document.getElementById('view-body')!;
|
|
if (effectiveViewMode === 'location') {
|
|
renderLocationView(viewBody);
|
|
} else {
|
|
renderSWTable(viewBody); // 리스트 형식
|
|
}
|
|
}
|
|
|
|
// 통합 갱신 (저장은 이미 개별 모달에서 처리됨)
|
|
async function refreshAllData() {
|
|
await loadMasterDataFromDB();
|
|
refreshView();
|
|
}
|
|
|
|
// --- App Initialization ---
|
|
function initApp() {
|
|
const mainContent = document.getElementById('main-content')!;
|
|
if (!mainContent) return;
|
|
|
|
const { closeAllModals } = initBaseModal();
|
|
|
|
try {
|
|
renderNavigation((tab) => {
|
|
refreshView();
|
|
});
|
|
|
|
initHwModal(() => refreshAllData(), closeAllModals);
|
|
initSwModal(() => refreshAllData(), closeAllModals);
|
|
initSwUserModal(() => {
|
|
loadMasterDataFromDB().then(() => refreshView());
|
|
}, closeAllModals);
|
|
initDomainModal(() => refreshAllData(), closeAllModals);
|
|
initPartsMasterModal(() => refreshAllData(), closeAllModals);
|
|
initJobSpecModal(() => refreshAllData(), closeAllModals);
|
|
initUserModal(() => refreshAllData(), closeAllModals);
|
|
|
|
initDashboardDetailModal();
|
|
initGuide();
|
|
pcFlowModal.init(() => {
|
|
loadMasterDataFromDB().then(() => refreshView());
|
|
});
|
|
|
|
loadMasterDataFromDB().then((success) => {
|
|
if (success) {
|
|
refreshView();
|
|
initRoleSwitcher(); // [추가] 역할 전환 토글 초기화
|
|
}
|
|
});
|
|
} catch (e) { console.error('❌ Initialization failed:', e); }
|
|
|
|
console.log('🚀 ITAM App Multi-Table Optimized');
|
|
|
|
// --- 통합 이벤트 위임 (Dynamic Elements 지원) ---
|
|
document.addEventListener('click', (e) => {
|
|
const target = e.target as HTMLElement;
|
|
|
|
// 자산 추가
|
|
if (target.closest('#btn-add-asset')) {
|
|
const tab = state.activeSubTab;
|
|
const cat = state.activeCategory;
|
|
const newId = Math.random().toString(36).substring(2, 9);
|
|
|
|
if (cat === 'hw') {
|
|
if (tab === '부품 마스터') {
|
|
if (activePartsMasterSubTab === 'job-spec') {
|
|
openJobSpecModal({ id: '' } as any, 'add');
|
|
} else {
|
|
openPartsMasterModal({ id: '' } as any, 'add');
|
|
}
|
|
} else {
|
|
openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add');
|
|
}
|
|
} else if (cat === 'sw') {
|
|
const swType = tab === '외부SW' ? '외부SW' : (tab === '내부SW' ? '내부SW' : '외부SW');
|
|
openSwModal({ id: newId, asset_type: swType } as any, 'add');
|
|
} else if (cat === 'ops') {
|
|
if (tab === '도메인') openDomainModal(null);
|
|
else if (tab === '사용자') openUserModal({ id: '' }, 'add');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 부품 마스터 탭으로 바로가기 연동
|
|
if (target.closest('#btn-goto-parts-master')) {
|
|
state.activeCategory = 'hw';
|
|
state.activeSubTab = '부품 마스터';
|
|
renderNavigation((tab) => { refreshView(); });
|
|
refreshView();
|
|
return;
|
|
}
|
|
|
|
// PC 이동/반납 모달 열기
|
|
if (target.closest('#btn-pc-flow')) {
|
|
pcFlowModal.open();
|
|
return;
|
|
}
|
|
});
|
|
|
|
createIcons({
|
|
icons: { Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings }
|
|
});
|
|
window.addEventListener('refresh-view', () => refreshView());
|
|
}
|
|
|
|
/**
|
|
* 헤더 역할 전환 토글 로직
|
|
*/
|
|
function initRoleSwitcher() {
|
|
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
|
|
const userLabel = document.querySelector('.role-label.user');
|
|
const adminLabel = document.querySelector('.role-label.admin');
|
|
|
|
if (!checkbox || !userLabel || !adminLabel) return;
|
|
|
|
checkbox.addEventListener('change', () => {
|
|
if (checkbox.checked) {
|
|
state.currentUserRole = 'admin';
|
|
userLabel.classList.remove('active');
|
|
adminLabel.classList.add('active');
|
|
document.body.classList.add('admin-mode');
|
|
|
|
// 관리자 모드 전환 시 대시보드로 이동
|
|
state.activeCategory = 'hw';
|
|
state.activeSubTab = '대시보드';
|
|
} else {
|
|
state.currentUserRole = 'user';
|
|
adminLabel.classList.remove('active');
|
|
userLabel.classList.add('active');
|
|
document.body.classList.remove('admin-mode');
|
|
|
|
// 실무자 모드 전환 시 서버 목록으로 이동
|
|
state.activeCategory = 'hw';
|
|
state.activeSubTab = '서버';
|
|
}
|
|
// 모든 렌더링을 refreshView 하나로 통합하여 규격 유지
|
|
renderNavigation(() => refreshView());
|
|
refreshView();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 앱 초기화 (로그인 과정 없이 즉시 시작)
|
|
*/
|
|
function initializeAppDirectly() {
|
|
const loginContainer = document.getElementById('login-container');
|
|
const appLayout = document.getElementById('app-layout');
|
|
|
|
// 기본 권한 설정: 실무자 (User)
|
|
state.currentUserRole = 'user';
|
|
state.activeCategory = 'hw';
|
|
state.activeSubTab = '서버'; // 실무자 기본 탭
|
|
|
|
// 화면 전환
|
|
if (loginContainer) loginContainer.style.display = 'none';
|
|
if (appLayout) appLayout.style.display = 'flex';
|
|
|
|
// 앱 초기화 및 내비게이션(헤더 포함) 렌더링
|
|
initApp();
|
|
renderNavigation((tab) => refreshView(tab));
|
|
}
|
|
|
|
function showLoginScreen(errorMessage?: string) {
|
|
const loginContainer = document.getElementById('login-container');
|
|
const appLayout = document.getElementById('app-layout');
|
|
const loginError = document.getElementById('login-error');
|
|
const phoneLoginError = document.getElementById('phone-login-error');
|
|
const phoneLoginStatus = document.getElementById('phone-login-status');
|
|
const loginForm = document.getElementById('login-form') as HTMLFormElement | null;
|
|
const phoneLoginForm = document.getElementById('phone-login-form') as HTMLFormElement | null;
|
|
const loginModeTabs = document.querySelectorAll<HTMLButtonElement>('.login-mode-tab');
|
|
|
|
if (appLayout) appLayout.style.display = 'none';
|
|
if (loginContainer) loginContainer.style.display = 'flex';
|
|
|
|
const setMessage = (element: HTMLElement | null, message?: string) => {
|
|
if (!element) return;
|
|
if (message) {
|
|
element.textContent = message;
|
|
element.removeAttribute('hidden');
|
|
} else {
|
|
element.textContent = '';
|
|
element.setAttribute('hidden', 'true');
|
|
}
|
|
};
|
|
|
|
setMessage(loginError, errorMessage);
|
|
setMessage(phoneLoginError, undefined);
|
|
setMessage(phoneLoginStatus, undefined);
|
|
|
|
const switchLoginMode = (mode: 'password' | 'phone') => {
|
|
if (loginForm) loginForm.hidden = mode !== 'password';
|
|
if (phoneLoginForm) phoneLoginForm.hidden = mode !== 'phone';
|
|
loginModeTabs.forEach((tab) => tab.classList.toggle('active', tab.dataset.mode === mode));
|
|
setMessage(loginError, mode === 'password' ? errorMessage : undefined);
|
|
setMessage(phoneLoginError, mode === 'phone' ? errorMessage : undefined);
|
|
};
|
|
|
|
loginModeTabs.forEach((tab) => {
|
|
if (!tab.dataset.bound) {
|
|
tab.dataset.bound = 'true';
|
|
tab.addEventListener('click', () => switchLoginMode((tab.dataset.mode as 'password' | 'phone') || 'password'));
|
|
}
|
|
});
|
|
|
|
const clearPhonePollTimer = () => {
|
|
if (phoneLoginPollTimer) {
|
|
window.clearTimeout(phoneLoginPollTimer);
|
|
phoneLoginPollTimer = undefined;
|
|
}
|
|
};
|
|
|
|
const pollPhoneLogin = async (pendingRef: string, intervalMs: number) => {
|
|
clearPhonePollTimer();
|
|
phoneLoginPollTimer = window.setTimeout(async () => {
|
|
try {
|
|
const response = await fetch('/api/auth/headless/phone/poll', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ pendingRef })
|
|
});
|
|
const payload = await response.json();
|
|
|
|
if (!response.ok) {
|
|
const message = payload.redirectTo
|
|
? '접근 권한이 없는 테넌트입니다. 관리자에게 문의하세요.'
|
|
: (payload.error || '전화번호 로그인 확인에 실패했습니다.');
|
|
clearPhonePollTimer();
|
|
setMessage(phoneLoginStatus, undefined);
|
|
setMessage(phoneLoginError, message);
|
|
return;
|
|
}
|
|
|
|
if (payload.status === 'authenticated') {
|
|
clearPhonePollTimer();
|
|
initializeAppDirectly();
|
|
return;
|
|
}
|
|
|
|
setMessage(phoneLoginStatus, '모바일에서 인증 링크를 승인하는 중입니다. 승인 후 자동으로 로그인됩니다.');
|
|
pollPhoneLogin(payload.pendingRef || pendingRef, payload.intervalMs || intervalMs);
|
|
} catch (error) {
|
|
console.error('Phone SSO poll failed:', error);
|
|
clearPhonePollTimer();
|
|
setMessage(phoneLoginStatus, undefined);
|
|
setMessage(phoneLoginError, '전화번호 로그인 확인 중 오류가 발생했습니다.');
|
|
}
|
|
}, intervalMs);
|
|
};
|
|
|
|
if (loginForm && !loginForm.dataset.bound) {
|
|
loginForm.dataset.bound = 'true';
|
|
loginForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const submitButton = document.getElementById('login-submit') as HTMLButtonElement | null;
|
|
const loginId = (document.getElementById('login-id') as HTMLInputElement | null)?.value.trim() || '';
|
|
const password = (document.getElementById('login-password') as HTMLInputElement | null)?.value || '';
|
|
|
|
if (!loginId || !password) {
|
|
showLoginScreen('사번과 비밀번호를 입력하세요.');
|
|
return;
|
|
}
|
|
|
|
if (submitButton) {
|
|
submitButton.disabled = true;
|
|
submitButton.textContent = '로그인 중...';
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/auth/headless/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ loginId, password })
|
|
});
|
|
const payload = await response.json();
|
|
|
|
if (!response.ok) {
|
|
const message = payload.redirectTo
|
|
? '접근 권한이 없는 테넌트입니다. 관리자에게 문의하세요.'
|
|
: (payload.error || '로그인에 실패했습니다.');
|
|
showLoginScreen(message);
|
|
return;
|
|
}
|
|
|
|
initializeAppDirectly();
|
|
} catch (error) {
|
|
console.error('SSO login failed:', error);
|
|
showLoginScreen('로그인 요청 처리 중 오류가 발생했습니다.');
|
|
} finally {
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
submitButton.textContent = '로그인';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (phoneLoginForm && !phoneLoginForm.dataset.bound) {
|
|
phoneLoginForm.dataset.bound = 'true';
|
|
phoneLoginForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const submitButton = document.getElementById('phone-login-submit') as HTMLButtonElement | null;
|
|
const loginId = (document.getElementById('phone-login-id') as HTMLInputElement | null)?.value.trim() || '';
|
|
|
|
if (!loginId) {
|
|
setMessage(phoneLoginError, '전화번호를 입력하세요.');
|
|
return;
|
|
}
|
|
|
|
clearPhonePollTimer();
|
|
setMessage(phoneLoginError, undefined);
|
|
setMessage(phoneLoginStatus, '인증 링크를 요청하는 중입니다...');
|
|
|
|
if (submitButton) {
|
|
submitButton.disabled = true;
|
|
submitButton.textContent = '링크 전송 중...';
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/auth/headless/phone/init', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ loginId })
|
|
});
|
|
const payload = await response.json();
|
|
|
|
if (!response.ok) {
|
|
setMessage(phoneLoginStatus, undefined);
|
|
setMessage(phoneLoginError, payload.error || '전화번호 로그인 시작에 실패했습니다.');
|
|
return;
|
|
}
|
|
|
|
setMessage(phoneLoginStatus, payload.message || '인증 링크를 발송했습니다. 모바일에서 승인해 주세요.');
|
|
pollPhoneLogin(payload.pendingRef, payload.intervalMs || 3000);
|
|
} catch (error) {
|
|
console.error('Phone SSO init failed:', error);
|
|
setMessage(phoneLoginStatus, undefined);
|
|
setMessage(phoneLoginError, '전화번호 로그인 요청 중 오류가 발생했습니다.');
|
|
} finally {
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
submitButton.textContent = '인증 링크 보내기';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
switchLoginMode('password');
|
|
}
|
|
|
|
async function bootstrapApp() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const authError = params.get('auth_error_description') || params.get('auth_error');
|
|
|
|
try {
|
|
const response = await fetch('/api/auth/session');
|
|
const sessionInfo = await response.json() as AuthSessionResponse;
|
|
if (response.ok && sessionInfo.authenticated) {
|
|
initializeAppDirectly();
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load auth session:', error);
|
|
}
|
|
|
|
showLoginScreen(authError || undefined);
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', bootstrapApp);
|