상해 AWE 2026
참관단 안내
참가자 유형을 선택하세요
const WEATHER_TTL = 30 * 60 * 1000; // ===== localStorage 키 ===== const LS_CONTENT = 'awe_cache_content'; const LS_FILES = 'awe_cache_files'; const LS_CONTACTS = 'awe_cache_contacts'; const LS_NOTICES = 'awe_cache_notices'; const LS_PARTICIPANTS = 'awe_cache_participants'; const LS_SCHEDULE = 'awe_cache_schedule'; function lsSave(key, data) { try { if (key === LS_FILES) { const slim = (data || []).map(f => { const { file_data, ...rest } = f; return rest; }); localStorage.setItem(key, JSON.stringify(slim)); } else { localStorage.setItem(key, JSON.stringify(data || [])); } } catch (e) {} } function lsLoad(key) { try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : null; } catch { return null; } } function lsSaveFileData(fileId, fileData) { try { localStorage.setItem('awe_file_' + fileId, fileData); } catch {} } function lsLoadFileData(fileId) { try { return localStorage.getItem('awe_file_' + fileId) || null; } catch { return null; } } function lsRemoveFileData(fileId) { try { localStorage.removeItem('awe_file_' + fileId); } catch {} } const ADMIN_PW = '0000'; const TABLE_CONTENT = 'awe_content'; const TABLE_FILES = 'awe_files'; const TABLE_CONTACTS = 'awe_contacts'; const TABLE_NOTICES = 'awe_notices'; const TABLE_PARTICIPANTS = 'awe_participants'; const TABLE_SCHEDULE = 'awe_schedule'; const MENUS = [ { key: 'important_notice', label: '중요 안내사항', icon: 'fa-exclamation-circle' }, { key: 'company_info', label: '방문기업 정보', icon: 'fa-building' }, { key: 'learning', label: '학습자료', icon: 'fa-book-open' }, { key: 'participants', label: '참가자명단', icon: 'fa-users' }, { key: 'schedule', label: '일정표', icon: 'fa-calendar-alt' }, { key: 'room_assignment', label: '호텔 정보', icon: 'fa-hotel' }, ]; // ===== 앱 진입점 ===== async function initGuideApp(type) { AppState.userType = type; AppState.isAdmin = false; AppState.currentMenu = 'important_notice'; const app = document.getElementById('main-app'); app.style.display = 'block'; app.innerHTML = buildAppHTML(); bindEvents(); // 🔥 캐시 강제 초기화 제거 Cache.loaded = true; // 🔥 모든 데이터 DB에서 다시 로드 await invalidateNotices(); await invalidateParticipants(); await invalidateContacts?.(); await invalidateContent?.(); await invalidateFiles?.(); renderMenu(AppState.currentMenu); loadScheduleFromDB(); } /** * D1 awe_schedule 테이블에서 일정 데이터를 로드합니다. * - 항상 D1에서 최신 데이터를 fetch * - 실패 시 STATIC_SCHEDULE 폴백 (오프라인/DB 오류 대비) */ async function loadScheduleFromDB() { try { const res = await fetch(`/tables/${TABLE_SCHEDULE}?limit=500&sort=sort_order`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const rows = data.data || []; // 🔥 user_type 기준 필터링 const filtered = rows.filter(r => r.user_type === AppState.userType); Cache.schedule = filtered; if (AppState.currentMenu === 'schedule') { const content = document.getElementById('menu-content'); if (content) renderSchedule(content); } } catch (e) { console.warn('일정 DB 로드 실패:', e); Cache.schedule = []; if (AppState.currentMenu === 'schedule') { const content = document.getElementById('menu-content'); if (content) renderSchedule(content); } } } async function prefetchAll() {} async function invalidateContent() { try { const res = await fetch(`/tables/${TABLE_CONTENT}?limit=500`); const data = await res.json(); const srv = data.data || []; if (srv.length > 0) { Cache.content = srv; } } catch {} } async function invalidateFiles() { try { const res = await fetch(`/tables/${TABLE_FILES}?limit=500`); const data = await res.json(); const srv = data.data || []; if (srv.length > 0) { Cache.files = srv; Cache.files.forEach(f => { if (f.file_data) lsSaveFileData(f.id, f.file_data); }); } } catch {} } async function invalidateContacts() { try { const res = await fetch(`/tables/${TABLE_CONTACTS}?limit=200`); const data = await res.json(); const srv = data.data || []; if (srv.length > 0) { Cache.contacts = srv; } } catch {} } async function invalidateNotices() { try { const res = await fetch(`/tables/${TABLE_NOTICES}?limit=500`); const data = await res.json(); const srv = data.data || []; Cache.notices = srv; // 🔥 조건 제거 } catch (e) { console.warn('invalidateNotices 실패:', e); } } async function invalidateParticipants() { try { const res = await fetch(`/tables/${TABLE_PARTICIPANTS}?limit=500`); const data = await res.json(); const srv = data.data || []; if (srv.length > 0) { Cache.participants = srv; } } catch {} } async function invalidateSchedule() { try { const res = await fetch(`/tables/${TABLE_SCHEDULE}`); if (!res.ok) throw new Error('Fetch 실패'); const data = await res.json(); const rows = data.data || []; if (rows.length > 0) { Cache.schedule = rows; } else { console.warn('예상치 못한 응답 구조:', rows); } } catch(e) { console.warn('invalidateSchedule 실패:', e); } } function getCachedContent(menu, section) { return (Cache.content || []).find(r => r.user_type === AppState.userType && r.menu === menu && r.section === section ) || null; } function getCachedFiles(menu) { return (Cache.files || []) .filter(r => r.user_type === AppState.userType && r.menu === menu) .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); } function getCachedContacts() { return (Cache.contacts || []) .filter(r => r.user_type === AppState.userType) .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); } // ===== HTML 빌드 ===== function buildAppHTML() { const gradeLabel = AppState.userType === 'executive' ? '책임자급' : '실무자급'; const gradeCls = AppState.userType === 'executive' ? 'executive' : 'staff'; const navTabs = MENUS.map(m => ` `).join(''); return `
상해 AWE 2026 참관 ${gradeLabel}
관리자 모드
`; } // ===== 이벤트 바인딩 ===== function bindEvents() { if (!document.getElementById('file-input')) { const fi = document.createElement('input'); fi.type = 'file'; fi.id = 'file-input'; fi.style.display = 'none'; fi.accept = '.pdf,.jpg,.jpeg,.png,.gif,.xls,.xlsx,.doc,.docx'; fi.onchange = handleFileUpload; document.body.appendChild(fi); } } // ===== 메뉴 전환 ===== function switchMenu(menuKey) { AppState.currentMenu = menuKey; document.querySelectorAll('.nav-tab').forEach(t => t.classList.toggle('active', t.dataset.menu === menuKey)); renderMenu(menuKey); } function renderMenu(menuKey) { const content = document.getElementById('menu-content'); if (!content) return; if (!Cache.loaded) { content.innerHTML = '
'; const timer = setInterval(() => { if (Cache.loaded) { clearInterval(timer); renderMenu(menuKey); } }, 100); return; } switch (menuKey) { case 'important_notice': renderImportantNotice(content); break; case 'company_info': renderCompanyInfo(content); break; case 'learning': renderLearning(content); break; case 'participants': renderParticipants(content); break; case 'schedule': renderSchedule(content); break; case 'room_assignment': renderFileSection(content, 'room_assignment','호텔 정보', 'fa-hotel'); break; } } // ===== 모드 전환 ===== function switchToUser() { AppState.isAdmin = false; document.body.classList.remove('admin-mode'); document.getElementById('user-mode-btn').classList.add('active'); document.getElementById('admin-mode-btn').classList.remove('active'); document.getElementById('admin-badge').classList.remove('visible'); showToast('사용자 모드로 전환되었습니다.', 'info'); renderMenu(AppState.currentMenu); } function requestAdminMode() { document.getElementById('pw-modal').classList.remove('hidden'); document.getElementById('pw-input').value = ''; document.getElementById('pw-error').style.display = 'none'; setTimeout(() => document.getElementById('pw-input').focus(), 100); } function confirmAdminPw() { if (document.getElementById('pw-input').value === ADMIN_PW) { AppState.isAdmin = true; document.body.classList.add('admin-mode'); document.getElementById('admin-mode-btn').classList.add('active'); document.getElementById('user-mode-btn').classList.remove('active'); document.getElementById('admin-badge').classList.add('visible'); closePwModal(); showToast('관리자 모드로 전환되었습니다.', 'success'); renderMenu(AppState.currentMenu); } else { document.getElementById('pw-error').style.display = 'block'; document.getElementById('pw-input').value = ''; document.getElementById('pw-input').focus(); } } function closePwModal() { document.getElementById('pw-modal').classList.add('hidden'); } function goHome() { AppState.isAdmin = false; document.body.classList.remove('admin-mode'); document.getElementById('main-app').style.display = 'none'; document.body.style.overflow = 'hidden'; document.body.style.background = '#0a0e27'; const landing = document.getElementById('landing-page'); landing.style.display = 'flex'; landing.classList.remove('fade-out'); } // ===== 편집 모달 ===== function openEditModal(title, desc, currentText, callback) { AppState.editCallback = callback; document.getElementById('edit-modal-title').textContent = title; document.getElementById('edit-modal-desc').textContent = desc; document.getElementById('edit-textarea').value = currentText || ''; document.getElementById('edit-modal').classList.remove('hidden'); setTimeout(() => document.getElementById('edit-textarea').focus(), 100); } function closeEditModal() { document.getElementById('edit-modal').classList.add('hidden'); AppState.editCallback = null; } function requestSaveEdit() { AppState._pendingEditText = document.getElementById('edit-textarea').value.trim(); document.getElementById('edit-modal').classList.add('hidden'); document.getElementById('confirm-modal').classList.remove('hidden'); } function closeConfirmModal() { document.getElementById('confirm-modal').classList.add('hidden'); if (AppState.editCallback && AppState.editCallback !== saveContactsCallback) { document.getElementById('edit-modal').classList.remove('hidden'); } AppState.editCallback = null; AppState._pendingEditText = null; } async function executeConfirmedSave() { document.getElementById('confirm-modal').classList.add('hidden'); const cb = AppState.editCallback; const text = AppState._pendingEditText; AppState.editCallback = null; AppState._pendingEditText = null; if (cb) await cb(text); } function closeDeleteModal() { document.getElementById('delete-modal').classList.add('hidden'); } // ===== 저장 헬퍼 ===== async function saveContent(menu, section, title, text) { try { const existing = getCachedContent(menu, section); if (existing) { await fetch(`/tables/${TABLE_CONTENT}/${existing.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...existing, content: text, title }) }); } else { await fetch(`/tables/${TABLE_CONTENT}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_type: AppState.userType, menu, section, title, content: text, sort_order: 0 }) }); } await invalidateContent(); showToast('저장되었습니다!', 'success'); } catch (e) { showToast('저장 실패: ' + e.message, 'error'); } } // ===== 1. 중요 안내사항 ===== function renderImportantNotice(container) { const prepRec = getCachedContent('important_notice', 'prep'); const contacts = getCachedContacts(); const prepText = prepRec ? prepRec.content : ''; const notices = (Cache.notices || []) .filter(n => (n.user_type || '').trim() === (AppState.userType || '').trim()) .sort((a, b) => (b.created_at_custom || b.created_at || 0) - (a.created_at_custom || a.created_at || 0)); container.innerHTML = `
중요 안내 IMPORTANT
${renderNoticeList(notices)}
기본 안내 BASIC
스태프 연락처
${renderContactTable(contacts)}
준비물
${renderPrepListCompact(prepText)}
현재 날씨 LIVE
${renderWeatherPlaceholders()}
`; loadWeather(); } function renderNoticeList(notices) { if (!notices || notices.length === 0) { return '
등록된 중요 안내사항이 없습니다.
관리자 모드에서 작성 버튼을 눌러 등록하세요.
'; } return notices.map((n, idx) => { const ts = n.created_at_custom || n.created_at || 0; const date = ts ? new Date(ts) : null; const dateStr = date ? `${date.getFullYear()}.${String(date.getMonth()+1).padStart(2,'0')}.${String(date.getDate()).padStart(2,'0')} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}` : ''; const isNew = ts && (Date.now() - ts) < 24 * 60 * 60 * 1000; const escapedContent = escapeHtml(n.content || '').replace(/\n/g,'
'); const contentForEdit = encodeURIComponent(n.content || ''); return `
${notices.length - idx} ${isNew ? 'NEW' : ''} ${idx === 0 ? '최신' : ''}
${dateStr}
${escapedContent}
`; }).join('
'); } function openNoticeWriteModal() { AppState._editingNoticeId = null; document.getElementById('notice-modal-title').textContent = '중요 안내 작성'; document.getElementById('notice-modal-desc').textContent = '작성한 내용은 최신순으로 상단에 표시됩니다.'; document.getElementById('notice-confirm-label').textContent = '등록하시겠습니까?'; document.getElementById('notice-confirm-desc').textContent = '작성한 중요 안내를 등록합니다.'; document.getElementById('notice-confirm-action-btn').textContent = '등록'; document.getElementById('notice-textarea').value = ''; document.getElementById('notice-write-modal').classList.remove('hidden'); setTimeout(() => document.getElementById('notice-textarea').focus(), 100); } function closeNoticeWriteModal() { document.getElementById('notice-write-modal').classList.add('hidden'); AppState._editingNoticeId = null; } function openNoticeEditModal(noticeId, currentContent) { AppState._editingNoticeId = noticeId; document.getElementById('notice-modal-title').textContent = '중요 안내 수정'; document.getElementById('notice-modal-desc').textContent = '내용을 수정하고 저장을 클릭하세요.'; document.getElementById('notice-confirm-label').textContent = '수정하시겠습니까?'; document.getElementById('notice-confirm-desc').textContent = '변경된 내용으로 저장됩니다.'; document.getElementById('notice-confirm-action-btn').textContent = '수정 완료'; document.getElementById('notice-textarea').value = currentContent; document.getElementById('notice-write-modal').classList.remove('hidden'); setTimeout(() => document.getElementById('notice-textarea').focus(), 100); } function requestSaveNotice() { const text = document.getElementById('notice-textarea').value.trim(); if (!text) { showToast('내용을 입력하세요.', 'error'); return; } AppState._pendingNoticeText = text; document.getElementById('notice-write-modal').classList.add('hidden'); document.getElementById('notice-confirm-modal').classList.remove('hidden'); } function closeNoticeConfirmModal() { document.getElementById('notice-confirm-modal').classList.add('hidden'); document.getElementById('notice-write-modal').classList.remove('hidden'); } async function executeSaveNotice() { document.getElementById('notice-confirm-modal').classList.add('hidden'); const text = AppState._pendingNoticeText; const editId = AppState._editingNoticeId; AppState._pendingNoticeText = null; AppState._editingNoticeId = null; if (!text) return; try { if (editId) { const existing = (Cache.notices || []).find(n => n.id === editId); await fetch(`/tables/${TABLE_NOTICES}/${editId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...existing, content: text }) }); showToast('수정되었습니다!', 'success'); } else { const now = Date.now(); await fetch(`/tables/${TABLE_NOTICES}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_type: AppState.userType, content: text, created_at_custom: now }) }); showToast('중요 안내가 등록되었습니다!', 'success'); } await invalidateNotices(); const board = document.getElementById('notice-board'); if (board) { const notices = (Cache.notices || []) .filter(n => (n.user_type || '').trim() === (AppState.userType || '').trim()) .sort((a, b) => (b.created_at_custom || b.created_at || 0) - (a.created_at_custom || a.created_at || 0)); board.innerHTML = renderNoticeList(notices); } } catch (e) { showToast('저장 실패: ' + e.message, 'error'); } } let pendingNoticeDeleteId = ''; function confirmDeleteNotice(noticeId) { pendingNoticeDeleteId = noticeId; document.getElementById('delete-modal-desc').textContent = '이 안내 게시물을 삭제하시겠습니까?'; document.getElementById('delete-confirm-btn').onclick = executeDeleteNotice; document.getElementById('delete-modal').classList.remove('hidden'); } async function executeDeleteNotice() { document.getElementById('delete-modal').classList.add('hidden'); try { await fetch(`/tables/${TABLE_NOTICES}/${pendingNoticeDeleteId}`, { method: 'DELETE' }); await invalidateNotices(); showToast('삭제되었습니다.', 'success'); const board = document.getElementById('notice-board'); if (board) { const notices = (Cache.notices || []) .filter(n => n.user_type === AppState.userType) .sort((a, b) => (b.created_at_custom || b.created_at || 0) - (a.created_at_custom || a.created_at || 0)); board.innerHTML = renderNoticeList(notices); } } catch (e) { showToast('삭제 실패: ' + e.message, 'error'); } } function renderContactTable(contacts) { if (!contacts || contacts.length === 0) return '
연락처가 없습니다.
'; return `${contacts.map(c => ` `).join('')}
이름역할연락처
${escapeHtml(c.name||'')} ${escapeHtml(c.role||'')} ${escapeHtml(c.phone||'')}
`; } function renderPrepListCompact(text) { if (!text) return '
준비물 목록이 없습니다.
'; const items = text.split('\n').filter(t => t.trim()); return `
    ${items.map((item, i) => `
  1. ${i + 1}${escapeHtml(item.trim())}
  2. ` ).join('')}
`; } function renderPrepList(text) { if (!text) return '
준비물 목록이 없습니다.
'; const items = text.split('\n').filter(t => t.trim()); return `
${items.map(item => `
${escapeHtml(item.trim())}
` ).join('')}
`; } function editPrepItems() { const rec = getCachedContent('important_notice', 'prep'); openEditModal('준비물 편집', '준비물을 한 줄에 하나씩 입력하세요.', rec ? rec.content : '', async (text) => { await saveContent('important_notice', 'prep', '준비물', text); const el = document.getElementById('prep-display'); if (el) el.innerHTML = renderPrepListCompact(text); }); } // ===== 연락처 ===== let tempContacts = []; function openContactEditModal() { tempContacts = getCachedContacts().map(c => ({ ...c })); renderContactEditList(); document.getElementById('contact-modal').classList.remove('hidden'); } function closeContactModal() { document.getElementById('contact-modal').classList.add('hidden'); } function renderContactEditList() { document.getElementById('contact-edit-list').innerHTML = tempContacts.map((c, i) => `
`).join(''); } function addContactRow() { tempContacts.push({ name:'', role:'', phone:'', user_type: AppState.userType, sort_order: tempContacts.length }); renderContactEditList(); } function removeContactRow(idx) { tempContacts.splice(idx, 1); renderContactEditList(); } async function requestSaveContacts() { closeContactModal(); AppState.editCallback = saveContactsCallback; AppState._pendingEditText = ''; document.getElementById('confirm-modal').classList.remove('hidden'); } async function saveContactsCallback() { try { const existing = (Cache.contacts || []).filter(r => r.user_type === AppState.userType); await Promise.all(existing.map(c => fetch(`/tables/${TABLE_CONTACTS}/${c.id}`, { method: 'DELETE' }))); await Promise.all(tempContacts.filter(c => c.name || c.phone).map((c, i) => fetch(`/tables/${TABLE_CONTACTS}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...c, user_type: AppState.userType, sort_order: i }) }) )); await invalidateContacts(); showToast('연락처가 저장되었습니다!', 'success'); const el = document.getElementById('contact-display'); if (el) el.innerHTML = renderContactTable(getCachedContacts()); } catch (e) { showToast('저장 실패: ' + e.message, 'error'); } } // ===== 날씨 ===== function renderWeatherPlaceholders() { return ['🏯 베이징 (北京)', '🏙️ 상하이 (上海)'].map(name => `
날씨 불러오는 중...
`).join(''); } async function loadWeather() { const now = Date.now(); if (Cache.weather && (now - Cache.weatherTime) < WEATHER_TTL) { document.getElementById('weather-container').innerHTML = Cache.weather; return; } await fetchWeather(); } async function refreshWeather() { Cache.weather = null; const container = document.getElementById('weather-container'); if (container) container.innerHTML = renderWeatherPlaceholders(); await fetchWeather(); } async function fetchWeather() { const cities = [ { name: '베이징 (北京)', lat: 39.9042, lon: 116.4074, emoji: '🏯' }, { name: '상하이 (上海)', lat: 31.2304, lon: 121.4737, emoji: '🏙️' }, ]; const container = document.getElementById('weather-container'); if (!container) return; const htmlArr = await Promise.all(cities.map(fetchCityWeather)); const html = htmlArr.join(''); Cache.weather = html; Cache.weatherTime = Date.now(); container.innerHTML = html; } async function fetchCityWeather(city) { try { const url = `https://api.open-meteo.com/v1/forecast?latitude=${city.lat}&longitude=${city.lon}¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=Asia%2FShanghai&forecast_days=7`; const res = await fetch(url); const data = await res.json(); const cur = data.current; const daily = data.daily; const weeklyHtml = daily.time.map((d, i) => { const dateObj = new Date(d); const m = dateObj.getMonth() + 1; const day = dateObj.getDate(); const label = i === 0 ? `오늘(${m}/${day})` : i === 1 ? `내일(${m}/${day})` : `${m}/${day}`; return `
${label}
${weatherCodeToIcon(daily.weather_code[i])}
${Math.round(daily.temperature_2m_min[i])}° / ${Math.round(daily.temperature_2m_max[i])}°
`; }).join(''); return `
${city.emoji} ${city.name}
${weatherCodeToIcon(cur.weather_code)}
${Math.round(cur.temperature_2m)}°C
${weatherCodeToDesc(cur.weather_code)}
습도 ${cur.relative_humidity_2m}% · 바람 ${Math.round(cur.wind_speed_10m)}km/h
${weeklyHtml}
`; } catch { return `
${city.emoji} ${city.name}
날씨 정보를 불러올 수 없습니다.
`; } } function weatherCodeToIcon(c) { if (c===0) return '☀️'; if (c<=2) return '🌤️'; if (c===3) return '☁️'; if (c<=49) return '🌫️'; if (c<=59) return '🌦️'; if (c<=65) return '🌧️'; if (c<=69) return '🌨️'; if (c<=77) return '🌨️'; if (c<=82) return '🌧️'; if (c<=94) return '🌩️'; return '⛈️'; } function weatherCodeToDesc(c) { if (c===0) return '맑음'; if (c<=2) return '구름 조금'; if (c===3) return '흐림'; if (c<=49) return '안개'; if (c<=59) return '이슬비'; if (c<=65) return '비'; if (c<=69) return '눈비'; if (c<=77) return '눈'; if (c<=82) return '소나기'; if (c<=94) return '뇌우'; return '심한 뇌우'; } // ===== 2. 방문기업 정보 ===== function renderCompanyInfo(container) { const rec = getCachedContent('company_info', 'main'); const text = rec ? rec.content : ''; container.innerHTML = `
방문기업 정보
${text ? escapeHtml(text).replace(/\n/g,'
') : '방문기업 정보를 입력하세요. (관리자 모드에서 편집 가능)'}
`; } function editCompanyInfo() { const rec = getCachedContent('company_info', 'main'); openEditModal('방문기업 정보 편집', '방문기업에 대한 정보를 입력하세요.', rec ? rec.content : '', async (text) => { await saveContent('company_info', 'main', '방문기업 정보', text); const el = document.getElementById('company-info-text'); if (el) el.innerHTML = text ? escapeHtml(text).replace(/\n/g,'
') : '방문기업 정보를 입력하세요.'; }); } // ===== 3. 학습자료 ===== function renderLearning(container) { const files = getCachedFiles('learning'); const ytFiles = files.filter(f => f.file_type === 'youtube'); const docFiles = files.filter(f => f.file_type !== 'youtube'); container.innerHTML = `
문서 자료

클릭하여 파일 업로드
또는 파일을 여기에 드래그하세요

PDF · 이미지(JPG, PNG) · 엑셀(XLS, XLSX) · 워드(DOC, DOCX)

${docFiles.length === 0 ? '

업로드된 문서가 없습니다.

' : docFiles.map(renderFileItem).join('')}
영상 자료 (YouTube)
${ytFiles.length === 0 ? '

등록된 영상이 없습니다.

' : `
${ytFiles.map(renderYoutubeCard).join('')}
`}
`; setupDragDrop('learning'); } // ===== 4. 참가자명단 ===== function renderParticipants(container) { const list = (Cache.participants || []) .filter(p => p.user_type === AppState.userType) .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); const groups = {}; list.forEach(p => { const key = p.company || '기타'; if (!groups[key]) groups[key] = []; groups[key].push(p); }); const totalCount = list.length; container.innerHTML = `
참가자명단 ${totalCount > 0 ? `${totalCount}명` : ''}
${totalCount === 0 ? `

등록된 참가자가 없습니다.
관리자 모드에서 참가자를 추가하세요.

` : Object.entries(groups).map(([company, members]) => `
${escapeHtml(company)} ${members.length}명
${members.map(p => renderParticipantCard(p)).join('')}
`).join('') }
`; } function renderParticipantCard(p) { const initials = (p.name || '?').charAt(0); const companyColors = getCompanyColor(p.company || ''); return `
${initials}
${escapeHtml(p.name || '')}
${p.position ? `${escapeHtml(p.position)}` : ''} ${p.role ? `${escapeHtml(p.role)}` : ''}
`; } function getCompanyColor(company) { const palettes = [ { bg:'#e8eaf6', text:'#3949ab' }, { bg:'#e3f2fd', text:'#1565c0' }, { bg:'#e8f5e9', text:'#2e7d32' }, { bg:'#fff3e0', text:'#e65100' }, { bg:'#fce4ec', text:'#c62828' }, { bg:'#f3e5f5', text:'#6a1b9a' }, { bg:'#e0f2f1', text:'#00695c' }, { bg:'#fff8e1', text:'#f57f17' }, ]; let hash = 0; for (let i = 0; i < company.length; i++) hash = (hash * 31 + company.charCodeAt(i)) & 0xffff; return palettes[hash % palettes.length]; } let _editingParticipantId = null; let _pendingParticipantData = null; function openParticipantAddModal() { _editingParticipantId = null; document.getElementById('participant-modal-title').innerHTML = '참가자 추가'; document.getElementById('p-company').value = ''; document.getElementById('p-name').value = ''; document.getElementById('p-position').value = ''; document.getElementById('p-role').value = ''; document.getElementById('participant-modal').classList.remove('hidden'); setTimeout(() => document.getElementById('p-company').focus(), 100); } function openParticipantEditModal(id) { const p = (Cache.participants || []).find(x => x.id === id); if (!p) return; _editingParticipantId = id; document.getElementById('participant-modal-title').innerHTML = '참가자 수정'; document.getElementById('p-company').value = p.company || ''; document.getElementById('p-name').value = p.name || ''; document.getElementById('p-position').value = p.position || ''; document.getElementById('p-role').value = p.role || ''; document.getElementById('participant-modal').classList.remove('hidden'); setTimeout(() => document.getElementById('p-name').focus(), 100); } function closeParticipantModal() { document.getElementById('participant-modal').classList.add('hidden'); _editingParticipantId = null; } function requestSaveParticipant() { const company = document.getElementById('p-company').value.trim(); const name = document.getElementById('p-name').value.trim(); const position = document.getElementById('p-position').value.trim(); const role = document.getElementById('p-role').value.trim(); if (!company) { showToast('소속 계열사를 입력하세요.', 'error'); return; } if (!name) { showToast('이름을 입력하세요.', 'error'); return; } _pendingParticipantData = { company, name, position, role }; document.getElementById('participant-modal').classList.add('hidden'); document.getElementById('participant-confirm-title').textContent = _editingParticipantId ? '수정하시겠습니까?' : '등록하시겠습니까?'; document.getElementById('participant-confirm-desc').textContent = _editingParticipantId ? '참가자 정보를 수정합니다.' : '참가자를 명단에 추가합니다.'; document.getElementById('participant-confirm-modal').classList.remove('hidden'); } function closeParticipantConfirmModal() { document.getElementById('participant-confirm-modal').classList.add('hidden'); document.getElementById('participant-modal').classList.remove('hidden'); } async function executeSaveParticipant() { document.getElementById('participant-confirm-modal').classList.add('hidden'); const data = _pendingParticipantData; const editId = _editingParticipantId; _pendingParticipantData = null; _editingParticipantId = null; if (!data) return; try { if (editId) { const existing = (Cache.participants || []).find(p => p.id === editId); await fetch(`/tables/${TABLE_PARTICIPANTS}/${editId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...existing, ...data }) }); showToast('수정되었습니다!', 'success'); } else { await fetch(`/tables/${TABLE_PARTICIPANTS}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_type: AppState.userType, ...data, sort_order: Date.now() }) }); showToast('참가자가 추가되었습니다!', 'success'); } await invalidateParticipants(); const content = document.getElementById('menu-content'); if (content) renderParticipants(content); } catch (e) { showToast('저장 실패: ' + e.message, 'error'); } } let _pendingDeleteParticipantId = ''; function confirmDeleteParticipant(id, name) { _pendingDeleteParticipantId = id; document.getElementById('delete-modal-desc').textContent = `"${name}" 참가자를 삭제하시겠습니까?`; document.getElementById('delete-confirm-btn').onclick = executeDeleteParticipant; document.getElementById('delete-modal').classList.remove('hidden'); } async function executeDeleteParticipant() { document.getElementById('delete-modal').classList.add('hidden'); try { await fetch(`/tables/${TABLE_PARTICIPANTS}/${_pendingDeleteParticipantId}`, { method: 'DELETE' }); await invalidateParticipants(); showToast('삭제되었습니다.', 'success'); const content = document.getElementById('menu-content'); if (content) renderParticipants(content); } catch (e) { showToast('삭제 실패: ' + e.message, 'error'); } } // ===== 5. 일정표 ===== // 편집 중인 일정 ID let _editingScheduleId = null; let _editSlots = []; function renderSchedule(container) { const allDays = (Cache.schedule || []).filter(d => d.user_type === AppState.userType); const seen = new Set(); const days = allDays .sort((a, b) => (a.sort_order || a.day_num || 0) - (b.sort_order || b.day_num || 0)) .filter(d => { const key = d.id; if (seen.has(key)) return false; seen.add(key); return true; }); const totalDays = days.length; const typeLabel = AppState.userType === 'executive' ? '책임자급 (5박6일 북경+상해)' : '실무자급 (4박5일 상해)'; const colorClass = AppState.userType === 'executive' ? 'exec' : 'staff'; const isAdmin = AppState.isAdmin; container.innerHTML = `
일정표 ${typeLabel}
${isAdmin ? `` : ''}
${totalDays === 0 ? `

등록된 일정이 없습니다.${isAdmin ? '
상단 "+ 일정 추가" 버튼을 눌러 추가하세요.' : ''}

` : `
${days.map((d, idx) => renderScheduleDay(d, idx, totalDays)).join('')}
` }
`; } function renderSingleSlot(slot, color) { const raw = slot.detail || ''; const isPoint = raw.startsWith('▶'); const hasBullet= raw.startsWith('•'); let text = raw.replace(/^▶\s*/, '').replace(/^•\s*/, ''); const parts = text.split(/(※[^\n]*)/g); const mainText = parts[0].trim(); const memoText = parts.slice(1).join('').trim(); return `
${slot.time ? `${escapeHtml(slot.time)}` : ''}
${isPoint ? `` : (hasBullet ? '' : '')}
${escapeHtml(mainText)} ${memoText ? `${escapeHtml(memoText)}` : ''}
`; } function renderScheduleDay(d, idx, total) { const isLast = idx === total - 1; const dayColors = ['#1a1f4e','#1565c0','#1976d2','#0288d1','#0097a7','#00796b']; const color = dayColors[Math.min(idx, dayColors.length - 1)]; let slots = []; try { slots = JSON.parse(d.time_slots || '[]'); } catch(e) { slots = []; } const meals = []; if (d.meal_morning) meals.push({ label:'조', val: d.meal_morning, cls:'morning' }); if (d.meal_lunch) meals.push({ label:'중', val: d.meal_lunch, cls:'lunch' }); if (d.meal_dinner) meals.push({ label:'석', val: d.meal_dinner, cls:'dinner' }); const teamColorMap = { '인천공항 출발팀': { bg:'#e3f2fd', color:'#1565c0', border:'#90caf9', icon:'✈' }, '김포공항 출발팀': { bg:'#e8f5e9', color:'#2e7d32', border:'#a5d6a7', icon:'✈' }, '인천공항 도착팀': { bg:'#e3f2fd', color:'#1565c0', border:'#90caf9', icon:'🛬' }, '김포공항 도착팀': { bg:'#e8f5e9', color:'#2e7d32', border:'#a5d6a7', icon:'🛬' }, }; const teamSlotMap = {}; const teamOrderArr = []; slots.forEach(slot => { const m = (slot.detail || '').match(/^\[([^\]]+)\]/); if (m) { const tl = m[1]; if (!teamSlotMap[tl]) { teamSlotMap[tl] = []; teamOrderArr.push(tl); } teamSlotMap[tl].push(slot); } }); const seenTeams = new Set(); const renderQueue = []; slots.forEach(slot => { const m = (slot.detail || '').match(/^\[([^\]]+)\]/); if (!m) { renderQueue.push({ type: 'single', slot }); } else { const tl = m[1]; if (!seenTeams.has(tl)) { seenTeams.add(tl); renderQueue.push({ type: 'teamBox', teamLabel: tl }); } } }); const renderTeamBox = (teamLabel) => { const tc = teamColorMap[teamLabel] || { bg:'#f5f5f5', color:'#555', border:'#ddd', icon:'✈' }; const tSlots = teamSlotMap[teamLabel] || []; const rowsHtml = tSlots.map(s => { let text = (s.detail || '').replace(/^\[[^\]]+\]\s*/, ''); text = text.replace(/^((?:OZ|KE|CA)\s*\d+)\s*[·•]\s*/, (_, f) => `${escapeHtml(f)} · ` ); const ps = text.split(/(※[^\n]*)/g); const main = ps[0].trim(); const memo = ps.slice(1).join('').trim(); return `
${s.time ? escapeHtml(s.time) : ''} ${main}${memo ? ` ${escapeHtml(memo)}` : ''}
`; }).join(''); return `
${tc.icon} ${escapeHtml(teamLabel)}
${rowsHtml}
`; }; let slotRows = renderQueue.map(item => item.type === 'single' ? renderSingleSlot(item.slot, color) : renderTeamBox(item.teamLabel) ).join(''); return `
${escapeHtml(d.day_label || '')}
${escapeHtml(d.date_label || '')}
${!isLast ? `
` : '
'}
${escapeHtml(d.region || '')}
${d.transport ? (() => { const t = d.transport; const icon = t.includes('열차') || t.includes('KTX') || t.includes('고속') ? 'fa-train' : t.includes('버스') ? 'fa-bus' : 'fa-plane'; return `
${escapeHtml(t)}
`; })() : ''}
${d.notes ? `
${escapeHtml(d.notes)}
` : ''}
${slotRows}
`; } // ===== 일정표 편집 함수들 ===== function openSchedEditModal(dayId) { const d = (Cache.schedule || []).find(x => x.id === dayId); if (!d) return; _editingScheduleId = dayId; document.getElementById('se-day-label').value = d.day_label || ''; document.getElementById('se-date-label').value = d.date_label || ''; document.getElementById('se-region').value = d.region || ''; document.getElementById('se-transport').value = d.transport || ''; document.getElementById('se-notes').value = d.notes || ''; document.getElementById('se-meal-morning').value= d.meal_morning|| ''; document.getElementById('se-meal-lunch').value = d.meal_lunch || ''; document.getElementById('se-meal-dinner').value = d.meal_dinner || ''; document.getElementById('se-hotel').value = d.hotel || ''; try { _editSlots = JSON.parse(d.time_slots || '[]').map(s => ({...s})); } catch(e) { _editSlots = []; } renderSeSlotList(); document.getElementById('sched-edit-modal').classList.remove('hidden'); } function closeSchedEditModal() { document.getElementById('sched-edit-modal').classList.add('hidden'); _editingScheduleId = null; _editSlots = []; } function renderSeSlotList() { const list = document.getElementById('se-slot-list'); if (!list) return; if (_editSlots.length === 0) { list.innerHTML = '
슬롯이 없습니다. 아래 버튼으로 추가하세요.
'; return; } list.innerHTML = _editSlots.map((s, i) => `
`).join(''); } function seAddSlot() { _editSlots.push({ time: '', detail: '' }); renderSeSlotList(); // 마지막 detail input에 포커스 const items = document.querySelectorAll('.sched-slot-edit-item'); if (items.length > 0) { const last = items[items.length - 1]; const inp = last.querySelector('.sched-slot-edit-detail'); if (inp) setTimeout(() => inp.focus(), 50); } } function seDelSlot(idx) { _editSlots.splice(idx, 1); renderSeSlotList(); } async function saveSchedEdit() { if (!_editingScheduleId) return; const idx = Cache.schedule.findIndex(x => x.id === _editingScheduleId); if (idx === -1) return; const updated = { ...Cache.schedule[idx], day_label: document.getElementById('se-day-label').value.trim(), date_label: document.getElementById('se-date-label').value.trim(), region: document.getElementById('se-region').value.trim(), transport: document.getElementById('se-transport').value.trim(), notes: document.getElementById('se-notes').value.trim(), meal_morning: document.getElementById('se-meal-morning').value.trim(), meal_lunch: document.getElementById('se-meal-lunch').value.trim(), meal_dinner: document.getElementById('se-meal-dinner').value.trim(), hotel: document.getElementById('se-hotel').value.trim(), time_slots: JSON.stringify(_editSlots.filter(s => s.detail.trim() !== '')), }; const res = await fetch(`/tables/${TABLE_SCHEDULE}/${_editingScheduleId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated) }); if (!res.ok) { showToast('DB 저장 실패 ❌', 'error'); return; } await invalidateSchedule(); closeSchedEditModal(); showToast('일정이 영구 수정되었습니다 ✅', 'success'); const content = document.getElementById('menu-content'); if (content) renderSchedule(content); } // ===== 일정 추가/삭제 함수 ===== let _addSlots = []; function openSchedAddModal() { if (!AppState.isAdmin) return; _addSlots = []; // 현재 user_type의 마지막 sort_order + 1 계산 const userDays = (Cache.schedule || []).filter(d => d.user_type === AppState.userType); const maxSort = userDays.reduce((m, d) => Math.max(m, d.sort_order || d.day_num || 0), 0); const nextDay = maxSort + 1; document.getElementById('sa-usertype-label').textContent = AppState.userType === 'executive' ? '책임자급' : '실무자급'; document.getElementById('sa-day-label').value = `제${nextDay}일`; document.getElementById('sa-date-label').value = ''; document.getElementById('sa-region').value = ''; document.getElementById('sa-transport').value = ''; document.getElementById('sa-notes').value = ''; document.getElementById('sa-meal-morning').value = ''; document.getElementById('sa-meal-lunch').value = ''; document.getElementById('sa-meal-dinner').value = ''; document.getElementById('sa-hotel').value = ''; renderSaSlotList(); document.getElementById('sched-add-modal').classList.remove('hidden'); } function closeSchedAddModal() { document.getElementById('sched-add-modal').classList.add('hidden'); _addSlots = []; } function renderSaSlotList() { const list = document.getElementById('sa-slot-list'); if (!list) return; if (_addSlots.length === 0) { list.innerHTML = '
슬롯이 없습니다. 아래 버튼으로 추가하세요.
'; return; } list.innerHTML = _addSlots.map((s, i) => `
`).join(''); } function saAddSlot() { _addSlots.push({ time: '', detail: '' }); renderSaSlotList(); const items = document.querySelectorAll('#sa-slot-list .sched-slot-edit-item'); if (items.length > 0) { const inp = items[items.length - 1].querySelector('.sched-slot-edit-detail'); if (inp) setTimeout(() => inp.focus(), 50); } } function saDelSlot(idx) { _addSlots.splice(idx, 1); renderSaSlotList(); } async function executeAddSchedDay() { const dayLabel = document.getElementById('sa-day-label').value.trim(); const dateLabel = document.getElementById('sa-date-label').value.trim(); const region = document.getElementById('sa-region').value.trim(); if (!dayLabel) { showToast('날짜 라벨을 입력하세요.', 'error'); return; } if (!dateLabel) { showToast('날짜를 입력하세요.', 'error'); return; } if (!region) { showToast('지역을 입력하세요.', 'error'); return; } const userDays = (Cache.schedule || []).filter(d => d.user_type === AppState.userType); const maxSort = userDays.reduce((m, d) => Math.max(m, d.sort_order || d.day_num || 0), 0); const dayNum = maxSort + 1; const newDay = { user_type: AppState.userType, day_num: dayNum, sort_order: dayNum, day_label: dayLabel, date_label: dateLabel, region: region, transport: document.getElementById('sa-transport').value.trim(), notes: document.getElementById('sa-notes').value.trim(), meal_morning: document.getElementById('sa-meal-morning').value.trim(), meal_lunch: document.getElementById('sa-meal-lunch').value.trim(), meal_dinner: document.getElementById('sa-meal-dinner').value.trim(), hotel: document.getElementById('sa-hotel').value.trim(), time_slots: JSON.stringify(_addSlots.filter(s => s.detail.trim() !== '')), }; const res = await fetch(`/tables/${TABLE_SCHEDULE}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newDay) }); if (!res.ok) { showToast('DB 저장 실패 ❌', 'error'); return; } await invalidateSchedule(); // ⭐ 반드시 재조회 closeSchedAddModal(); showToast('일정이 영구 저장되었습니다 ✅', 'success'); const content = document.getElementById('menu-content'); if (content) renderSchedule(content); } // 일정 삭제 관련 function confirmDeleteSchedDay() { const d = (Cache.schedule || []).find(x => x.id === _editingScheduleId); if (!d) return; const desc = document.getElementById('sched-delete-desc'); if (desc) desc.textContent = `"${d.day_label || ''} (${d.date_label || ''})" 일정을 삭제하시겠습니까? 삭제 후 복구할 수 없습니다.`; document.getElementById('sched-edit-modal').classList.add('hidden'); document.getElementById('sched-delete-modal').classList.remove('hidden'); } function closeSchedDeleteModal() { document.getElementById('sched-delete-modal').classList.add('hidden'); document.getElementById('sched-edit-modal').classList.remove('hidden'); } async function executeDeleteSchedDay() { const delId = _editingScheduleId; if (!delId) return; const res = await fetch(`/tables/${TABLE_SCHEDULE}/${delId}`, { method: 'DELETE' }); if (!res.ok && res.status !== 204) { showToast('DB 삭제 실패 ❌', 'error'); return; } await invalidateSchedule(); // ⭐ 재조회 showToast('일정이 영구 삭제되었습니다.', 'success'); const content = document.getElementById('menu-content'); if (content) renderSchedule(content); } function renderFileSection(container, menu, title, icon) { const files = getCachedFiles(menu); container.innerHTML = `
${escapeHtml(title)}

클릭하여 파일 업로드
또는 파일을 여기에 드래그하세요

PDF · 이미지(JPG, PNG) · 엑셀(XLS, XLSX) · 워드(DOC, DOCX)

${files.length === 0 ? `

업로드된 파일이 없습니다.
관리자 모드에서 파일을 업로드하세요.

` : files.map(renderFileItem).join('')}
`; setupDragDrop(menu); } function renderFileItem(f) { const iconCls = { pdf:'pdf', image:'image', excel:'excel', word:'word' }[f.file_type] || 'default'; const iconEl = { pdf:'', image:'', excel:'', word:'' }[f.file_type] || ''; return `
${iconEl}
${escapeHtml(f.file_name)}
${(f.file_type||'').toUpperCase()} · ${f.file_size||''}
${f.file_type==='image' ? `` : ''}
`; } function renderYoutubeCard(f) { const ytId = extractYtId(f.file_data); const thumb = f.thumbnail_url || `https://img.youtube.com/vi/${ytId}/maxresdefault.jpg`; return `
${escapeHtml(f.file_name)}
${escapeHtml(f.file_name)}
`; } let currentUploadMenu = ''; function triggerFileUpload(menu) { if (!AppState.isAdmin) return; currentUploadMenu = menu; const fi = document.getElementById('file-input'); fi.value = ''; fi.accept = '.pdf,.jpg,.jpeg,.png,.gif,.xls,.xlsx,.doc,.docx'; fi.click(); } function setupDragDrop(menu) { document.querySelectorAll('.upload-area').forEach(area => { area.ondragover = (e) => { e.preventDefault(); area.style.borderColor = '#3949ab'; }; area.ondragleave = () => { area.style.borderColor = '#c5cae9'; }; area.ondrop = (e) => { e.preventDefault(); area.style.borderColor = '#c5cae9'; if (AppState.isAdmin && e.dataTransfer.files[0]) processFileUpload(e.dataTransfer.files[0], menu); }; }); } async function handleFileUpload(e) { if (e.target.files[0]) await processFileUpload(e.target.files[0], currentUploadMenu); } async function processFileUpload(file, menu) { if (file.size > 10 * 1024 * 1024) { showToast('파일 크기는 10MB 이하만 업로드 가능합니다.', 'error'); return; } showToast('파일 업로드 중...', 'info'); try { const base64 = await fileToBase64(file); await fetch(`/tables/${TABLE_FILES}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_type: AppState.userType, menu, file_name: file.name, file_type: getFileType(file.name), file_data: base64, file_size: formatFileSize(file.size), thumbnail_url: '', sort_order: Date.now() }) }); await invalidateFiles(); showToast(`"${file.name}" 업로드 완료!`, 'success'); renderMenu(menu); } catch (e) { showToast('업로드 실패: ' + e.message, 'error'); } } // ===== 유튜브 ===== async function addYoutubeLink() { const input = document.getElementById('yt-url-input'); const url = input?.value?.trim(); if (!url) { showToast('YouTube URL을 입력하세요.', 'error'); return; } const ytId = extractYtId(url); if (!ytId) { showToast('유효한 YouTube URL이 아닙니다.', 'error'); return; } showToast('YouTube 링크 추가 중...', 'info'); try { const title = await fetchYtTitle(ytId); await fetch(`/tables/${TABLE_FILES}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_type: AppState.userType, menu: 'learning', file_name: title || url, file_type: 'youtube', file_data: url, file_size: '', thumbnail_url: `https://img.youtube.com/vi/${ytId}/maxresdefault.jpg`, sort_order: Date.now() }) }); if (input) input.value = ''; await invalidateFiles(); showToast('YouTube 영상이 추가되었습니다!', 'success'); renderMenu('learning'); } catch (e) { showToast('추가 실패: ' + e.message, 'error'); } } async function fetchYtTitle(ytId) { try { const res = await fetch(`https://noembed.com/embed?url=https://www.youtube.com/watch?v=${ytId}`); const data = await res.json(); return data.title || ''; } catch { return ''; } } function extractYtId(url) { if (!url) return ''; const m = url.match(/(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/|shorts\/))([a-zA-Z0-9_-]{11})/); return m ? m[1] : ''; } function openYoutube(url) { const ytId = extractYtId(url); if (!ytId) return; document.getElementById('yt-iframe').src = `https://www.youtube.com/embed/${ytId}?autoplay=1`; document.getElementById('yt-modal').classList.remove('hidden'); } function closeYtModal() { document.getElementById('yt-modal').classList.add('hidden'); document.getElementById('yt-iframe').src = ''; } // ===== 파일 다운로드/미리보기 ===== function getFileData(fileId) { const f = (Cache.files || []).find(f => f.id === fileId); if (f && f.file_data) return { meta: f, data: f.file_data }; const lsData = lsLoadFileData(fileId); if (f && lsData) return { meta: f, data: lsData }; return null; } function downloadFile(fileId) { const result = getFileData(fileId); if (!result) { showToast('파일 데이터를 찾을 수 없습니다.', 'error'); return; } const link = document.createElement('a'); link.href = result.data; link.download = result.meta.file_name; link.click(); } function previewImageById(fileId) { const result = getFileData(fileId); if (!result) return; document.getElementById('img-modal-img').src = result.data; document.getElementById('img-modal').classList.remove('hidden'); } function closeImgModal() { document.getElementById('img-modal').classList.add('hidden'); } // ===== 파일 삭제 ===== let pendingDeleteId = ''; function confirmDeleteFile(fileId, fileName) { pendingDeleteId = fileId; document.getElementById('delete-modal-desc').textContent = `"${fileName}" 파일을 삭제하시겠습니까?`; document.getElementById('delete-confirm-btn').onclick = executeDeleteFile; document.getElementById('delete-modal').classList.remove('hidden'); } async function executeDeleteFile() { document.getElementById('delete-modal').classList.add('hidden'); try { await fetch(`/tables/${TABLE_FILES}/${pendingDeleteId}`, { method: 'DELETE' }); lsRemoveFileData(pendingDeleteId); await invalidateFiles(); showToast('파일이 삭제되었습니다.', 'success'); renderMenu(AppState.currentMenu); } catch (e) { showToast('삭제 실패: ' + e.message, 'error'); } } // ===== 유틸리티 ===== function fileToBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); }); } function getFileType(name) { const ext = name.split('.').pop().toLowerCase(); if (['pdf'].includes(ext)) return 'pdf'; if (['jpg','jpeg','png','gif','webp'].includes(ext)) return 'image'; if (['xls','xlsx'].includes(ext)) return 'excel'; if (['doc','docx'].includes(ext)) return 'word'; return 'default'; } function formatFileSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB'; return (bytes/1024/1024).toFixed(1) + ' MB'; } function escapeHtml(str) { if (!str) return ''; return str.replace(/&/g,'&').replace(//g,'>') .replace(/"/g,'"').replace(/'/g,'''); } function showToast(message, type = 'info') { const container = document.getElementById('toast-container'); if (!container) return; const toast = document.createElement('div'); toast.className = `toast ${type}`; const icons = { success:'fa-check-circle', error:'fa-times-circle', info:'fa-info-circle' }; toast.innerHTML = ` ${message}`; container.appendChild(toast); setTimeout(() => { toast.style.animation = 'toastOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); }, 3000); } async function saveSchedulePDF() { const element = document.querySelector(".schedule-timeline"); const canvas = await html2canvas(element, { scale: 2, useCORS: true }); const imgData = canvas.toDataURL("image/png"); const { jsPDF } = window.jspdf; const imgWidth = 210; const imgHeight = canvas.height * imgWidth / canvas.width; // 페이지 높이를 일정표 높이에 맞춤 const pdf = new jsPDF({ orientation: "portrait", unit: "mm", format: [210, imgHeight] }); pdf.addImage(imgData, "PNG", 0, 0, imgWidth, imgHeight); pdf.save("schedule.pdf"); }