Initial commit: AI platform app (server, views, lib, data, deploy docs)

Made-with: Cursor
This commit is contained in:
2026-04-03 20:45:17 +09:00
commit da39cfeef9
70 changed files with 17506 additions and 0 deletions

View File

@@ -0,0 +1,238 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>썸네일 이벤트 로그</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'learning' }) %>
<div class="content-area">
<header class="topbar">
<h1>썸네일 이벤트 로그</h1>
<a class="top-action-link" href="/admin?token=<%= token %>">대시보드로</a>
</header>
<main class="container">
<section class="panel">
<div class="section-head">
<h2>실시간 요약</h2>
<div class="admin-inline">
<label class="inline">
<input type="checkbox" id="pollToggle" checked />
자동 새로고침
</label>
<label class="inline">
간격(초)
<select id="pollIntervalSec">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="20">20</option>
<option value="30">30</option>
</select>
</label>
</div>
</div>
<p class="api-hint">요약 API: <code>/api/queue/events-summary?token=***&hours=24</code></p>
<div class="kpi-grid">
<article class="kpi-card">
<small>처리 건수(24h)</small>
<b id="kpiProcessed">-</b>
</article>
<article class="kpi-card">
<small>실패율(24h)</small>
<b id="kpiFailureRate">-</b>
</article>
<article class="kpi-card">
<small>평균 소요(ms)</small>
<b id="kpiAvgDuration">-</b>
</article>
<article class="kpi-card">
<small>현재 큐</small>
<b id="kpiQueue">-</b>
</article>
</div>
<div class="chart-grid">
<section class="mini-panel">
<h3>시간대별 처리량(24h)</h3>
<div id="processedChart" class="bar-chart"></div>
</section>
<section class="mini-panel">
<h3>시간대별 실패량(24h)</h3>
<div id="failedChart" class="bar-chart"></div>
</section>
</div>
</section>
<section class="panel">
<h2>필터</h2>
<% if (typeof eventsCleared !== 'undefined' && eventsCleared) { %>
<p class="admin-ok">썸네일 이벤트 로그가 초기화되었습니다.</p>
<% } %>
<form action="/admin/thumbnail-events" method="get" class="filter-grid">
<input type="hidden" name="token" value="<%= token %>" />
<label>
이벤트 타입
<select name="eventType">
<option value="all" <%= filters.eventType === "all" ? "selected" : "" %>>전체</option>
<option value="enqueue" <%= filters.eventType === "enqueue" ? "selected" : "" %>>enqueue</option>
<option value="start" <%= filters.eventType === "start" ? "selected" : "" %>>start</option>
<option value="success" <%= filters.eventType === "success" ? "selected" : "" %>>success</option>
<option value="failed" <%= filters.eventType === "failed" ? "selected" : "" %>>failed</option>
<option value="worker-error" <%= filters.eventType === "worker-error" ? "selected" : "" %>>worker-error</option>
</select>
</label>
<label>
Lecture ID
<input type="text" name="lectureId" value="<%= filters.lectureId %>" />
</label>
<label>
Reason
<input type="text" name="reason" value="<%= filters.reason %>" />
</label>
<label>
From
<input type="date" name="from" value="<%= filters.from %>" />
</label>
<label>
To
<input type="date" name="to" value="<%= filters.to %>" />
</label>
<label>
페이지 크기
<input type="number" min="10" max="200" name="limit" value="<%= filters.limit %>" />
</label>
<div class="filter-actions">
<button type="submit">적용</button>
<a class="link-muted" href="/admin/thumbnail-events?token=<%= token %>">필터 초기화</a>
<a class="link-muted" href="/admin/thumbnail-events.csv?<%= csvQuery %>">CSV 다운로드</a>
</div>
</form>
<form action="/thumbnails/events/clear" method="post" class="admin-inline" style="margin-top:8px" onsubmit="return confirm('썸네일 이벤트 로그를 모두 삭제하시겠습니까?');">
<input type="hidden" name="token" value="<%= token %>" />
<input type="hidden" name="returnTo" value="/admin/thumbnail-events?token=<%= token %>" />
<button type="submit" class="ghost">로그 초기화</button>
</form>
</section>
<section class="panel">
<div class="section-head">
<h2>이벤트 목록</h2>
<span class="count-chip">총 <%= pagination.totalCount %>건</span>
</div>
<% if (!events.length) { %>
<p class="empty">조회 결과가 없습니다.</p>
<% } else { %>
<div class="event-table-wrap">
<table class="event-table">
<thead>
<tr>
<th>시간</th>
<th>타입</th>
<th>강의</th>
<th>사유</th>
<th>재시도</th>
<th>소요(ms)</th>
<th>에러</th>
</tr>
</thead>
<tbody>
<% events.forEach((evt) => { %>
<tr>
<td><%= new Date(evt.at).toLocaleString("ko-KR") %></td>
<td><span class="evt-type <%= evt.type %>"><%= evt.type %></span></td>
<td>
<div><%= evt.lectureTitle || "-" %></div>
<small><%= evt.lectureId || "-" %></small>
</td>
<td><%= evt.reason || "-" %></td>
<td><%= evt.retryCount ?? "-" %></td>
<td><%= evt.durationMs ?? "-" %></td>
<td><%= evt.error || "-" %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
<% if (pagination.totalPages > 1) { %>
<nav class="pagination">
<% if (pagination.hasPrev) { %>
<a href="/admin/thumbnail-events?<%= pagination.prevQuery %>">이전</a>
<% } %>
<% pagination.pages.forEach((p) => { %>
<a href="/admin/thumbnail-events?<%= p.query %>" class="<%= p.active ? "active" : "" %>"><%= p.page %></a>
<% }) %>
<% if (pagination.hasNext) { %>
<a href="/admin/thumbnail-events?<%= pagination.nextQuery %>">다음</a>
<% } %>
</nav>
<% } %>
</section>
</main>
</div>
</div>
<script>
const ADMIN_TOKEN = <%- JSON.stringify(token) %>;
const POLL_TOGGLE = document.getElementById("pollToggle");
const POLL_INTERVAL = document.getElementById("pollIntervalSec");
let timerId = null;
const fmtPercent = (value) => `${(Number(value || 0) * 100).toFixed(1)}%`;
const renderBars = (targetId, buckets, key, cls) => {
const el = document.getElementById(targetId);
if (!el) return;
const max = Math.max(1, ...buckets.map((b) => Number(b[key] || 0)));
el.innerHTML = "";
buckets.forEach((bucket) => {
const value = Number(bucket[key] || 0);
const item = document.createElement("div");
item.className = "bar-item";
const bar = document.createElement("div");
bar.className = `bar ${cls}`;
bar.style.height = `${Math.max((value / max) * 100, 2)}%`;
bar.title = `${bucket.label}: ${value}`;
const label = document.createElement("small");
label.textContent = bucket.label;
item.appendChild(bar);
item.appendChild(label);
el.appendChild(item);
});
};
const loadSummary = async () => {
try {
const res = await fetch(`/api/queue/events-summary?token=${encodeURIComponent(ADMIN_TOKEN)}&hours=24`);
if (!res.ok) return;
const data = await res.json();
document.getElementById("kpiProcessed").textContent = String(data.kpi.processedCount ?? 0);
document.getElementById("kpiFailureRate").textContent = fmtPercent(data.kpi.failureRate);
document.getElementById("kpiAvgDuration").textContent = String(data.kpi.avgDurationMs ?? 0);
document.getElementById("kpiQueue").textContent = `${data.queue.pending}${data.queue.working ? " (작동중)" : ""}`;
renderBars("processedChart", data.buckets || [], "processed", "processed");
renderBars("failedChart", data.buckets || [], "failed", "failed");
} catch {
// no-op
}
};
const refreshPolling = () => {
if (timerId) clearInterval(timerId);
if (!POLL_TOGGLE.checked) return;
const sec = Number(POLL_INTERVAL.value || 10);
timerId = setInterval(loadSummary, Math.max(sec, 3) * 1000);
};
POLL_TOGGLE.addEventListener("change", refreshPolling);
POLL_INTERVAL.addEventListener("change", refreshPolling);
loadSummary();
refreshPolling();
</script>
</body>
</html>

60
views/admin-users.ejs Normal file
View File

@@ -0,0 +1,60 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>사용자 현황관리 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'admin-users', adminMode: true }) %>
<div class="content-area">
<header class="topbar">
<h1>사용자 현황관리</h1>
<a class="top-action-link" href="/learning">학습센터</a>
</header>
<main class="container">
<section class="panel">
<p class="subtitle" style="margin-bottom: 16px">
OPS 이메일(<strong>@xavis.co.kr</strong>) 매직 링크로 <strong>로그인에 성공한</strong> 사용자 목록입니다. 이메일과 최근 접속일(마지막 로그인 시각)을 표시합니다.
</p>
<% if (typeof dbError !== 'undefined' && dbError) { %>
<p class="admin-error">목록을 불러오지 못했습니다: <%= dbError %></p>
<% } else if (!pgConnected) { %>
<p class="admin-warn">PostgreSQL이 비활성화되어 있어 사용자 목록을 조회할 수 없습니다.</p>
<% } else if (!users || users.length === 0) { %>
<p class="admin-hint">아직 로그인 기록이 없습니다.</p>
<% } else { %>
<div class="table-wrap">
<table class="data-table" aria-label="인증 사용자 목록">
<thead>
<tr>
<th scope="col">이메일</th>
<th scope="col">최근 접속일</th>
</tr>
</thead>
<tbody>
<% users.forEach(function (u) { %>
<tr>
<td><%= u.email %></td>
<td>
<% if (u.lastLoginAt) { %>
<%= new Date(u.lastLoginAt).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }) %>
<% } else { %>
<% } %>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<p class="admin-hint" style="margin-top: 12px">총 <%= users.length %>명</p>
<% } %>
</section>
</main>
</div>
</div>
</body>
</html>

152
views/ai-case-detail.ejs Normal file
View File

@@ -0,0 +1,152 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= story.title %> - AI 성공 사례 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<% var pdfUrlRaw = (typeof story.pdfUrl === 'string' ? story.pdfUrl : '').trim(); var showPdfViewer = pdfUrlRaw.length > 0; %>
<% if (typeof slideImageUrls === 'undefined') { slideImageUrls = []; } %>
<% if (typeof slides === 'undefined') { slides = []; } %>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'ai-cases' }) %>
<div class="content-area">
<% if (showPdfViewer) { %>
<main class="viewer-wrap ai-case-viewer">
<a href="/ai-cases" class="back-link">← AI 성공 사례로 돌아가기</a>
<h1><%= story.title %></h1>
<p class="description"><%= story.excerpt || (story.department + ' · ' + story.author) %></p>
<div class="ppt-tools ai-case-ppt-tools">
<span>총 <b><%= slides.length %></b>페이지</span>
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
<span><%= story.department %> · <%= story.author %><% if (story.publishedAt) { %> · <%= story.publishedAt %><% } %></span>
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
<a href="/ai-cases/write?edit=<%= story.slug %>" class="ai-case-inline-link">편집</a>
<% } %>
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
<a href="<%= pdfUrlRaw %>" download class="ai-case-inline-link">다운로드</a>
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
<button type="button" class="ai-case-inline-link js-ai-case-delete" data-id="<%= story.id %>" data-title="<%= story.title %>">삭제</button>
<% } %>
</div>
<% if ((story.tags || []).length) { %>
<div class="tag-row ai-case-tag-row">
<% (story.tags || []).forEach((oneTag) => { %>
<span class="tag-chip">#<%= oneTag %></span>
<% }) %>
</div>
<% } %>
<% if (!slideImageUrls || slideImageUrls.length === 0) { %>
<p class="admin-warn">PDF를 페이지 이미지로 보여 주지 못했습니다. <a href="<%= pdfUrlRaw %>" target="_blank" rel="noopener noreferrer">원본 PDF로 보기</a>. 서버에는 <code>pdftoppm</code>(Poppler)이 필요하고, 저장된 PDF 주소는 브라우저에서 열리는 것과 같이 <code>/public/...</code> 또는 그와 같은 경로의 전체 URL이어야 합니다.</p>
<% } %>
<section class="slide-list">
<% slides.forEach((slide, index) => { %>
<article class="slide-card">
<header>
<h2>페이지 <%= index + 1 %></h2>
<% if (slide.title) { %><p><%= slide.title %></p><% } %>
</header>
<% if (slideImageUrls[index]) { %>
<div class="slide-image-wrap">
<img src="<%= slideImageUrls[index] %>" alt="페이지 <%= index + 1 %>" class="slide-image" />
</div>
<% } %>
<% if (slide.lines && slide.lines.length > 0) { %>
<ul>
<% slide.lines.forEach((line) => { %>
<li><%= line %></li>
<% }) %>
</ul>
<% } %>
</article>
<% }) %>
</section>
</main>
<% } else { %>
<main class="viewer-wrap ai-case-viewer">
<a href="/ai-cases" class="back-link">← AI 성공 사례로 돌아가기</a>
<h1><%= story.title %></h1>
<p class="description"><%= story.excerpt || (story.department + ' · ' + story.author) %></p>
<div class="ppt-tools ai-case-ppt-tools">
<span><%= story.department %> · <%= story.author %><% if (story.publishedAt) { %> · <%= story.publishedAt %><% } %></span>
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
<a href="/ai-cases/write?edit=<%= story.slug %>" class="ai-case-inline-link">편집</a>
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
<button type="button" class="ai-case-inline-link js-ai-case-delete" data-id="<%= story.id %>" data-title="<%= story.title %>">삭제</button>
<% } %>
</div>
<% if ((story.tags || []).length) { %>
<div class="tag-row ai-case-tag-row">
<% (story.tags || []).forEach((oneTag) => { %>
<span class="tag-chip">#<%= oneTag %></span>
<% }) %>
</div>
<% } %>
<section class="slide-list">
<article class="slide-card">
<header>
<h2>본문</h2>
</header>
<div id="success-md-render" class="chat-md-body success-detail-body-in-card"></div>
</article>
</section>
<script type="application/json" id="success-md-json"><%- JSON.stringify(story.bodyMarkdown || '') %></script>
</main>
<% } %>
</div>
</div>
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
<script>
(function() {
var btn = document.querySelector('.js-ai-case-delete');
if (!btn) return;
btn.addEventListener('click', function() {
var id = btn.getAttribute('data-id');
var title = btn.getAttribute('data-title') || '';
if (!id || !confirm('삭제할까요? ' + title)) return;
fetch('/api/ai-success-stories/' + encodeURIComponent(id), { method: 'DELETE' })
.then(function(r) { return r.json(); })
.then(function() { window.location.href = '/ai-cases'; })
.catch(function() { alert('삭제 실패'); });
});
})();
</script>
<% } %>
<% if (!showPdfViewer) { %>
<script src="/vendor/marked/marked.umd.js"></script>
<script src="/vendor/dompurify/purify.min.js"></script>
<script>
(function() {
var el = document.getElementById('success-md-json');
var out = document.getElementById('success-md-render');
if (!el || !out) return;
var raw = '';
try { raw = JSON.parse(el.textContent || '""'); } catch (e) { raw = ''; }
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
out.textContent = raw;
return;
}
marked.setOptions({ breaks: true, gfm: true });
DOMPurify.addHook('afterSanitizeAttributes', function(node) {
if (node.tagName === 'A' && node.hasAttribute('href')) {
var href = node.getAttribute('href');
try {
var u = new URL(href, window.location.href);
if (u.protocol !== 'http:' && u.protocol !== 'https:') { node.removeAttribute('href'); return; }
} catch (e) { node.removeAttribute('href'); return; }
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
var html = marked.parse(String(raw || ''), { async: false });
out.innerHTML = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
})();
</script>
<% } %>
</body>
</html>

228
views/ai-cases-write.ejs Normal file
View File

@@ -0,0 +1,228 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI 성공 사례 관리 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
<style>
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.pdf-upload-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
}
.pdf-upload-status {
font-size: 0.9em;
}
</style>
</head>
<body>
<% if (typeof allStories === 'undefined') { allStories = []; } %>
<% if (typeof story === 'undefined') { story = null; } %>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'ai-cases' }) %>
<div class="content-area">
<header class="topbar">
<h1>AI 성공 사례 관리</h1>
<a class="top-action-link" href="/ai-cases">목록(사용자 화면)</a>
</header>
<main class="container">
<section class="panel">
<p class="breadcrumb"><a href="/ai-cases">AI 성공 사례</a> &gt; 관리자</p>
<p class="muted admin-hint">슬러그는 URL에 쓰이므로 영문·숫자·하이픈만 사용하세요. <strong>원문 PDF 경로</strong>(<code>/public/...</code>)가 있으면 상세는 PDF 페이지 이미지로 보여 주며, 이때 <strong>본문(Markdown)은 비워도 됩니다</strong>. PDF가 없을 때는 본문이 필수입니다.</p>
</section>
<% if (allStories.length) { %>
<section class="panel">
<h2>등록된 사례</h2>
<ul class="admin-story-list">
<% allStories.forEach(function(s) { %>
<li>
<a href="/ai-cases/write?edit=<%= s.slug %>"><strong><%= s.title %></strong></a>
<span class="muted">/<%= s.slug %></span>
<button type="button" class="btn-ghost btn-sm js-delete-story" data-id="<%= s.id %>" data-title="<%= s.title %>">삭제</button>
</li>
<% }) %>
</ul>
</section>
<% } %>
<section class="panel">
<h2><%= story ? '사례 수정' : '새 사례 등록' %></h2>
<form id="successStoryForm" class="form-grid">
<input type="hidden" id="storyId" value="<%= story ? story.id : '' %>" />
<label class="full">
슬러그 (URL, 영문·숫자·하이픈)
<input type="text" id="slug" name="slug" required placeholder="예: jojung-sook-hr-claude-cowork" value="<%= story ? story.slug : '' %>" />
</label>
<label class="full">
제목
<input type="text" id="title" name="title" required placeholder="제목" value="<%= story ? story.title : '' %>" />
</label>
<label class="full">
한 줄 요약 (카드에 표시)
<input type="text" id="excerpt" name="excerpt" placeholder="카드용 짧은 설명" value="<%= story ? story.excerpt : '' %>" />
</label>
<label>
부서
<input type="text" id="department" name="department" placeholder="예: 인사총무팀" value="<%= story ? story.department : '' %>" />
</label>
<label>
작성자
<input type="text" id="author" name="author" placeholder="예: 조정숙" value="<%= story ? story.author : '' %>" />
</label>
<label>
게시일 (YYYY-MM-DD)
<input type="text" id="publishedAt" name="publishedAt" placeholder="2026-03-25" value="<%= story ? story.publishedAt : '' %>" />
</label>
<label class="full">
태그 (쉼표로 구분)
<input type="text" id="tags" name="tags" placeholder="인사총무, Claude Cowork" value="<%= story ? (story.tags || []).join(', ') : '' %>" />
</label>
<label class="full">
원문 PDF (선택)
<span class="muted" style="display:block;font-weight:normal;font-size:0.9em;margin-bottom:0.35rem;">로컬 PDF를 선택하면 서버에 저장되고 아래 URL이 자동으로 채워집니다. 필요하면 URL을 직접 수정할 수 있습니다.</span>
<div class="pdf-upload-row">
<input type="file" id="pdfFileInput" accept="application/pdf,.pdf" class="visually-hidden" tabindex="-1" />
<button type="button" class="btn-ghost" id="pdfFilePickBtn" aria-label="PDF 파일 선택">PDF 파일 선택…</button>
<span id="pdfUploadStatus" class="muted pdf-upload-status" role="status" aria-live="polite"></span>
</div>
<input type="text" id="pdfUrl" name="pdfUrl" placeholder="/public/resources/ai-success/..." value="<%= story && story.pdfUrl ? story.pdfUrl : '' %>" autocomplete="off" />
</label>
<label class="full">
본문 (Markdown)
<textarea id="bodyMarkdown" name="bodyMarkdown" rows="22" placeholder="# 제목&#10;&#10;본문..."><% if (story) { %><%- story.bodyMarkdown %><% } %></textarea>
</label>
<div class="form-actions full">
<button type="button" class="btn-ghost" onclick="location.href='/ai-cases'">취소</button>
<button type="submit" class="top-action"><%= story ? '저장' : '등록' %></button>
</div>
</form>
<p id="formMessage" class="form-message" role="status" aria-live="polite"></p>
</section>
</main>
</div>
</div>
<script>
(function() {
var form = document.getElementById('successStoryForm');
var msg = document.getElementById('formMessage');
if (!form) return;
form.addEventListener('submit', function(e) {
e.preventDefault();
if (msg) msg.textContent = '';
var id = (document.getElementById('storyId') || {}).value || '';
var pdfVal = ((document.getElementById('pdfUrl') || {}).value || '').trim();
var mdVal = ((document.getElementById('bodyMarkdown') || {}).value || '').trim();
if (!mdVal && !pdfVal) {
if (msg) msg.textContent = '본문(Markdown) 또는 원문 PDF 중 하나는 입력해 주세요.';
return;
}
var payload = {
slug: (document.getElementById('slug') || {}).value || '',
title: (document.getElementById('title') || {}).value || '',
excerpt: (document.getElementById('excerpt') || {}).value || '',
department: (document.getElementById('department') || {}).value || '',
author: (document.getElementById('author') || {}).value || '',
publishedAt: (document.getElementById('publishedAt') || {}).value || '',
tags: (document.getElementById('tags') || {}).value || '',
pdfUrl: (document.getElementById('pdfUrl') || {}).value || '',
bodyMarkdown: (document.getElementById('bodyMarkdown') || {}).value || ''
};
var url = id ? '/api/ai-success-stories/' + encodeURIComponent(id) : '/api/ai-success-stories';
var method = id ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(function(r) { return r.json().then(function(j) { return { ok: r.ok, j: j }; }); })
.then(function(_ref) {
if (_ref.ok) {
if (msg) msg.textContent = '저장되었습니다.';
setTimeout(function() { window.location.href = '/ai-cases'; }, 600);
} else {
if (msg) msg.textContent = _ref.j.error || '저장에 실패했습니다.';
}
})
.catch(function() {
if (msg) msg.textContent = '네트워크 오류';
});
});
var pdfFileInput = document.getElementById('pdfFileInput');
var pdfPickBtn = document.getElementById('pdfFilePickBtn');
var pdfUploadStatus = document.getElementById('pdfUploadStatus');
if (pdfPickBtn && pdfFileInput) {
pdfPickBtn.addEventListener('click', function() {
pdfFileInput.click();
});
}
if (pdfFileInput) {
pdfFileInput.addEventListener('change', function() {
var f = pdfFileInput.files && pdfFileInput.files[0];
if (!f) return;
if (pdfUploadStatus) pdfUploadStatus.textContent = '업로드 중…';
var fd = new FormData();
fd.append('pdfFile', f);
fetch('/api/ai-success-stories/upload-pdf', {
method: 'POST',
body: fd,
credentials: 'same-origin'
})
.then(function(r) {
return r.json().then(function(j) {
return { ok: r.ok, j: j };
});
})
.then(function(_ref) {
if (_ref.ok && _ref.j.pdfUrl) {
var pdfEl = document.getElementById('pdfUrl');
if (pdfEl) pdfEl.value = _ref.j.pdfUrl;
if (pdfUploadStatus) {
pdfUploadStatus.textContent =
'업로드 완료 (' + (_ref.j.filename || 'PDF') + ')';
}
} else {
if (pdfUploadStatus) {
pdfUploadStatus.textContent = _ref.j.error || '업로드 실패';
}
}
pdfFileInput.value = '';
})
.catch(function() {
if (pdfUploadStatus) pdfUploadStatus.textContent = '네트워크 오류';
pdfFileInput.value = '';
});
});
}
document.querySelectorAll('.js-delete-story').forEach(function(btn) {
btn.addEventListener('click', function() {
var id = btn.getAttribute('data-id');
var title = btn.getAttribute('data-title') || '';
if (!id || !confirm('삭제할까요? ' + title)) return;
fetch('/api/ai-success-stories/' + encodeURIComponent(id), { method: 'DELETE' })
.then(function(r) { return r.json(); })
.then(function() { window.location.reload(); })
.catch(function() { alert('삭제 실패'); });
});
});
})();
</script>
</body>
</html>

74
views/ai-cases.ejs Normal file
View File

@@ -0,0 +1,74 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI 성공 사례 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<% if (typeof filters === 'undefined') { filters = { q: '', tag: '' }; } %>
<% if (typeof availableTags === 'undefined') { availableTags = []; } %>
<% if (typeof stories === 'undefined') { stories = []; } %>
<% var _opsLoggedIn = typeof opsUserEmail !== 'undefined' && opsUserEmail; %>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'ai-cases' }) %>
<div class="content-area">
<header class="topbar">
<h1>AI 성공 사례</h1>
<% if (typeof adminMode !== 'undefined' && adminMode && !_opsLoggedIn) { %>
<a class="top-action-link" href="/ai-cases/write">사례 등록·관리</a>
<% } %>
</header>
<main class="container">
<% if (typeof successStoryDetailAllowed !== 'undefined' && !successStoryDetailAllowed) { %>
<p class="chat-api-warning" style="margin-bottom: 16px">
로그인 후 이용 가능합니다.
</p>
<% } %>
<section class="hero panel success-hero">
<h2>현장에서 검증된 AI 업무 혁신 이야기</h2>
<p>부서별 도입 과정과 성과를 카드에서 확인하고, 본문에서 상세 내용을 읽을 수 있습니다.</p>
</section>
<section class="panel filter-panel">
<h2>검색·필터</h2>
<form action="/ai-cases" method="get" class="filter-grid success-filter">
<label>
검색어
<input type="text" name="q" value="<%= filters.q %>" placeholder="제목, 요약, 부서, 태그" />
</label>
<label>
태그
<select name="tag">
<option value="">전체</option>
<% (availableTags || []).forEach((oneTag) => { %>
<option value="<%= oneTag %>" <%= filters.tag === oneTag ? "selected" : "" %>><%= oneTag %></option>
<% }) %>
</select>
</label>
<div class="filter-actions">
<button type="submit">적용</button>
<a class="link-muted" href="/ai-cases">초기화</a>
</div>
</form>
</section>
<section class="panel">
<div class="section-head">
<h2>등록된 사례</h2>
<span class="count-chip">총 <%= stories.length %>건</span>
</div>
<% if (!stories.length) { %>
<p class="empty">조건에 맞는 사례가 없습니다.</p>
<% } else { %>
<div class="success-story-grid">
<% stories.forEach((story) => { %>
<%- include('partials/success-story-card', { story, successStoryDetailAllowed }) %>
<% }) %>
</div>
<% } %>
</section>
</main>
</div>
</div>
</body>
</html>

142
views/ai-explore.ejs Normal file
View File

@@ -0,0 +1,142 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body
data-ai-explore-dev-guest="<%= (typeof aiExploreDevGuestRestricted !== 'undefined' && aiExploreDevGuestRestricted) ? '1' : '0' %>"
class="<%= (typeof aiExploreDevGuestRestricted !== 'undefined' && aiExploreDevGuestRestricted) ? 'ai-explore-page ai-explore-dev-guest' : 'ai-explore-page' %>"
>
<% var aiGuestDev = typeof aiExploreDevGuestRestricted !== 'undefined' && aiExploreDevGuestRestricted; %>
<% var _opsLoggedIn = typeof opsUserEmail !== 'undefined' && opsUserEmail; %>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'ai-explore', adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %>
<div class="content-area">
<header class="topbar">
<h1>AI</h1>
<% if (!_opsLoggedIn) { %>
<% if (aiGuestDev) { %>
<span class="top-action-link ai-explore-action-disabled" aria-disabled="true">AI 추가하기</span>
<% } else { %>
<a class="top-action-link" href="#">AI 추가하기</a>
<% } %>
<% } %>
</header>
<main class="container container-ai-full">
<% if (aiGuestDev) { %>
<p class="chat-api-warning" style="margin-bottom: 16px">로그인 후 이용 가능합니다.</p>
<% } %>
<section class="panel">
<p class="subtitle">지식 시험이나 지식 보강은 물론, 스킬들을 다양하게 조합한 맞춤형 AI를 탐색하고 사용해보세요.</p>
<form class="search-bar-wrap" id="aiExploreSearchForm" role="search">
<input
type="search"
id="aiExploreSearch"
placeholder="제목, 설명으로 검색하세요"
class="search-input"
autocomplete="off"
aria-label="AI 서비스 제목·설명 검색"
<% if (aiGuestDev) { %>disabled aria-disabled="true"<% } %>
/>
</form>
</section>
<section class="panel">
<h2>AI 서비스</h2>
<div class="ai-card-grid">
<a href="/ai-explore/prompts" class="ai-card ai-card-link">
<div class="ai-card-header">
<span class="status-chip public">공개중</span>
</div>
<h3>프롬프트</h3>
<p>업무별 기본 프롬프트를 모아 두고, 복사해 바로 활용할 수 있는 라이브러리입니다.</p>
<div class="tag-row"><span class="tag-chip">#프롬프트</span></div>
</a>
<% if (aiGuestDev) { %>
<article class="ai-card ai-card-disabled" aria-disabled="true">
<div class="ai-card-header">
<span class="status-chip public">공개중</span>
</div>
<h3>회의록 AI</h3>
<p>회의록을 자동으로 작성·요약·정리해주는 AI 서비스입니다.</p>
<div class="tag-row"><span class="tag-chip">#업무관리</span><span class="tag-chip">#회의록</span></div>
</article>
<% } else { %>
<a href="/ai-explore/meeting-minutes" class="ai-card ai-card-link">
<div class="ai-card-header">
<span class="status-chip public">공개중</span>
</div>
<h3>회의록 AI</h3>
<p>회의록을 자동으로 작성·요약·정리해주는 AI 서비스입니다.</p>
<div class="tag-row"><span class="tag-chip">#업무관리</span><span class="tag-chip">#회의록</span></div>
</a>
<% } %>
<% if (aiGuestDev) { %>
<article class="ai-card ai-card-disabled" aria-disabled="true">
<div class="ai-card-header">
<span class="status-chip public">공개중</span>
</div>
<h3>업무 체크리스트 AI</h3>
<p>회의록에서 추출된 할 일과 개인 업무를 통합 관리하고, 등록·수정·삭제를 통해 완결성을 높이는 AI 비서입니다.</p>
<div class="tag-row"><span class="tag-chip">#업무관리</span><span class="tag-chip">#체크리스트</span></div>
</article>
<% } else { %>
<a href="/ai-explore/task-checklist" class="ai-card ai-card-link">
<div class="ai-card-header">
<span class="status-chip public">공개중</span>
</div>
<h3>업무 체크리스트 AI</h3>
<p>회의록에서 추출된 할 일과 개인 업무를 통합 관리하고, 등록·수정·삭제를 통해 완결성을 높이는 AI 비서입니다.</p>
<div class="tag-row"><span class="tag-chip">#업무관리</span><span class="tag-chip">#체크리스트</span></div>
</a>
<% } %>
</div>
</section>
</main>
</div>
</div>
<script>
(function () {
var form = document.getElementById("aiExploreSearchForm");
var input = document.getElementById("aiExploreSearch");
var grid = document.querySelector(".ai-card-grid");
if (!form || !input || !grid) return;
var devGuest = (document.body.getAttribute("data-ai-explore-dev-guest") || "0") === "1";
if (devGuest) return;
var cards = grid.querySelectorAll(".ai-card");
function cardTitleDescriptionText(el) {
var parts = [];
var h3 = el.querySelector("h3");
if (h3) parts.push(h3.textContent || "");
el.querySelectorAll("p").forEach(function (p) {
if (!p.closest(".tag-row")) parts.push(p.textContent || "");
});
return parts.join(" ").replace(/\s+/g, " ").trim().toLowerCase();
}
function applyFilter() {
var q = (input.value || "").trim().toLowerCase();
cards.forEach(function (el) {
var text = cardTitleDescriptionText(el);
var show = !q || text.indexOf(q) !== -1;
el.hidden = !show;
el.setAttribute("aria-hidden", show ? "false" : "true");
});
}
input.addEventListener("input", applyFilter);
input.addEventListener("search", applyFilter);
form.addEventListener("submit", function (e) {
e.preventDefault();
applyFilter();
});
})();
</script>
</body>
</html>

163
views/ai-prompts.ejs Normal file
View File

@@ -0,0 +1,163 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>프롬프트 라이브러리 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'ai-explore' }) %>
<div class="content-area">
<header class="topbar">
<h1>프롬프트</h1>
<a class="top-action-link" href="/ai-explore">AI 목록</a>
</header>
<main class="container container-ai-full">
<a href="/ai-explore" class="prompts-back" aria-label="AI 탐색으로 돌아가기">← AI</a>
<section class="prompts-hero">
<h1>프롬프트 라이브러리</h1>
<p class="prompts-lead">
업무별로 자주 쓰는 기본 프롬프트를 골라 바로 복사해 사용하세요. ChatGPT·Claude·자비스 채팅 등 어디에든 붙여 넣을 수 있습니다.
</p>
<p class="prompts-stats">
<%= prompts.length %>가지 템플릿 · 사내 업무 시나리오 중심 · 복사 후 [ ] 부분만 채워 완성
</p>
</section>
<% if (!prompts.length) { %>
<section class="panel">
<p class="empty">프롬프트 데이터를 불러오지 못했습니다. 관리자에게 문의해 주세요.</p>
</section>
<% } else { %>
<section class="panel prompts-layout">
<div>
<h2 class="prompts-grid-title">시나리오 선택</h2>
<div class="prompts-grid" id="promptCardList" role="list">
<% prompts.forEach(function (p) { %>
<button
type="button"
class="prompt-template-card"
role="listitem"
data-prompt-id="<%= p.id %>"
aria-pressed="false"
>
<h3><%= p.title %></h3>
<p><%= p.description %></p>
<span class="prompt-mini-tag"><%= p.tag %></span>
</button>
<% }); %>
</div>
</div>
<div class="prompts-preview-panel">
<h2 id="previewTitle">프롬프트 미리보기</h2>
<p class="prompts-preview-empty" id="previewEmpty">왼쪽에서 카드를 선택하세요.</p>
<div id="previewActive" hidden>
<div class="prompts-preview-toolbar">
<button type="button" class="prompts-copy-btn" id="copyPromptBtn" disabled>클립보드에 복사</button>
</div>
<label class="visually-hidden" for="promptBody">선택한 프롬프트 전문</label>
<textarea id="promptBody" class="prompts-body-textarea" readonly spellcheck="false" aria-describedby="previewHint"></textarea>
<p class="prompts-hint" id="previewHint">대괄호 [ ] 안은 상황에 맞게 수정한 뒤 사용하세요.</p>
</div>
</div>
</section>
<% } %>
</main>
</div>
</div>
<% if (prompts.length) { %>
<script>
(function () {
var library = <%- JSON.stringify(prompts) %>;
var byId = {};
library.forEach(function (p) {
byId[p.id] = p;
});
var cards = document.querySelectorAll(".prompt-template-card");
var titleEl = document.getElementById("previewTitle");
var bodyEl = document.getElementById("promptBody");
var copyBtn = document.getElementById("copyPromptBtn");
var emptyEl = document.getElementById("previewEmpty");
var activeWrap = document.getElementById("previewActive");
var selectedId = null;
function setSelected(id) {
selectedId = id;
cards.forEach(function (btn) {
var on = btn.getAttribute("data-prompt-id") === id;
btn.classList.toggle("is-selected", on);
btn.setAttribute("aria-pressed", on ? "true" : "false");
});
var p = byId[id];
if (!p) {
emptyEl.hidden = false;
activeWrap.hidden = true;
copyBtn.disabled = true;
titleEl.textContent = "프롬프트 미리보기";
return;
}
emptyEl.hidden = true;
activeWrap.hidden = false;
titleEl.textContent = p.title;
bodyEl.value = p.body;
copyBtn.disabled = false;
}
cards.forEach(function (btn) {
btn.addEventListener("click", function () {
setSelected(btn.getAttribute("data-prompt-id"));
});
});
copyBtn.addEventListener("click", function () {
if (!bodyEl.value) return;
navigator.clipboard.writeText(bodyEl.value).then(
function () {
var t = copyBtn.textContent;
copyBtn.textContent = "복사됨";
setTimeout(function () {
copyBtn.textContent = t;
}, 1600);
},
function () {
bodyEl.select();
try {
document.execCommand("copy");
} catch (e) {}
}
);
});
var params = new URLSearchParams(window.location.search);
var initial = params.get("id");
if (initial && byId[initial]) {
setSelected(initial);
} else if (library[0]) {
setSelected(library[0].id);
} else {
emptyEl.hidden = false;
activeWrap.hidden = true;
copyBtn.disabled = true;
}
})();
</script>
<% } %>
<style>
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
</body>
</html>

479
views/ax-apply.ejs Normal file
View File

@@ -0,0 +1,479 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AX 과제 신청 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body
data-ax-admin-mode="<%= (typeof adminMode !== 'undefined' && adminMode) ? '1' : '0' %>"
data-ax-apply-download-submit-allowed="<%= (typeof axApplyDownloadSubmitAllowed !== 'undefined' && axApplyDownloadSubmitAllowed) ? '1' : '0' %>"
>
<% var axDlSubmitOk = typeof axApplyDownloadSubmitAllowed !== 'undefined' ? axApplyDownloadSubmitAllowed : true; %>
<% if (typeof assignments === 'undefined') { assignments = []; } %>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'ax-apply' }) %>
<div class="content-area">
<header class="topbar">
<h1>AX 과제 신청</h1>
</header>
<main class="container">
<% if (!axDlSubmitOk) { %>
<p class="chat-api-warning" style="margin-bottom: 16px">
로그인 후 이용 가능합니다.
</p>
<% } %>
<section class="panel">
<h2>신청된 과제 목록</h2>
<p class="list-hint">소속 부서, 이름, 이메일을 입력하면 조회됩니다.</p>
<div id="axAssignmentsListWrap">
<% if (!assignments.length) { %>
<p class="empty">신청된 과제가 없습니다.</p>
<% } else { %>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>순번</th>
<th>신청일</th>
<th>소속 부서</th>
<th>이름</th>
<th>사번</th>
<th>직급</th>
<th>이메일</th>
<th>AI에게 원하는 것</th>
<th>신청서</th>
<th>상태</th>
<th>수정<% if (typeof adminMode !== 'undefined' && adminMode) { %> / 삭제<% } %></th>
</tr>
</thead>
<tbody>
<% assignments.forEach((a, idx) => { %>
<tr>
<td><%= assignments.length - idx %></td>
<td><%= new Date(a.createdAt).toLocaleDateString('ko-KR') %></td>
<td><%= a.department || '-' %></td>
<td><%= a.name || '-' %></td>
<td><%= a.employeeId || '-' %></td>
<td><%= a.position || '-' %></td>
<td><%= a.email || '-' %></td>
<td class="cell-truncate" title="<%= (a.aiExpectation || '').replace(/"/g, '&quot;') %>"><%= (a.aiExpectation || '-').length > 30 ? (a.aiExpectation || '').slice(0, 30) + '…' : (a.aiExpectation || '-') %></td>
<td><% if (a.applicationFile) { %><a href="<%= a.applicationFile %>" target="_blank" download>보기</a><% } else { %>-<% } %></td>
<td><%= a.status || '신청' %></td>
<td>
<a href="#" class="ax-edit-link" data-id="<%= a.id %>">수정</a><% if (typeof adminMode !== 'undefined' && adminMode) { %> | <a href="#" class="ax-delete-link" data-id="<%= a.id %>" data-created-at="<%= (a.createdAt || '').toString().replace(/"/g, '&quot;') %>" data-department="<%= (a.department || '').toString().replace(/"/g, '&quot;') %>" data-name="<%= (a.name || '').toString().replace(/"/g, '&quot;') %>" data-employee-id="<%= (a.employeeId || '').toString().replace(/"/g, '&quot;') %>" data-position="<%= (a.position || '').toString().replace(/"/g, '&quot;') %>" data-email="<%= (a.email || '').toString().replace(/"/g, '&quot;') %>">삭제</a><% } %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</div>
<% if (typeof adminMode !== 'undefined' && !adminMode) { %>
<div class="list-query-actions">
<button type="button" id="axApplyListQueryBtn" class="btn-ghost">조회</button>
</div>
<% } %>
</section>
<section class="panel" id="ax-apply-form">
<h2>
AX 과제 신청서
<% if (axDlSubmitOk) { %>
<a href="/resources/ax-apply/AX_과제_신청서.docx" download="AX_과제_신청서.docx" class="link-download">(신청서 다운로드)</a>
<% } else { %>
<span class="link-muted" tabindex="-1" aria-disabled="true">(신청서 다운로드)</span>
<% } %>
</h2>
<p class="subtitle">※ 모든 항목을 성실하게 작성해 주세요.</p>
<form id="axApplyForm" class="form-grid" enctype="multipart/form-data">
<input type="hidden" name="editId" id="axEditId" value="" />
<h3>1. 기본 정보</h3>
<label><span class="label-text">소속 부서<span class="required">*</span></span> <input type="text" name="department" required placeholder="부서명 입력" /></label>
<label><span class="label-text">이름<span class="required">*</span></span> <input type="text" name="name" required placeholder="이름 입력" /></label>
<label>사번 <input type="text" name="employeeId" placeholder="사번 입력" /></label>
<label>직급/직책 <input type="text" name="position" placeholder="직급 입력" /></label>
<label>연락처 <input type="text" name="phone" placeholder="연락처 입력" /></label>
<label><span class="label-text">이메일<span class="required">*</span></span> <input type="email" name="email" required placeholder="이메일 입력" /></label>
<h3 class="full">2. 현재 업무 현황 (As-Is)</h3>
<label class="full"><span class="label-text">업무 프로세스 설명<span class="required">*</span></span> <textarea name="workProcessDescription" rows="4" required placeholder="현재 업무의 전체 흐름을 단계별로 설명해 주세요."></textarea></label>
<label class="full"><span class="label-text">Pain Point<span class="required">*</span></span> <textarea name="painPoint" rows="3" required placeholder="현재 업무에서 가장 불편하거나 비효율적인 점을 구체적으로 기재해 주세요."></textarea></label>
<label><span class="label-text">현재 소요 시간<span class="required">*</span></span> <input type="text" name="currentTimeSpent" required placeholder="예: 1건 처리에 30분 소요" /></label>
<label><span class="label-text">AX 전환 전 오류율<span class="required">*</span></span> <input type="text" name="errorRateBefore" required placeholder="예: 약 5%" /></label>
<label class="full">협업 필요 부서 <input type="text" name="collaborationDepts" placeholder="AI혁신팀 외 협업이 필요한 부서가 있다면 기재해 주세요." /></label>
<label class="full"><span class="label-text">지금 해결해야 하는 이유<span class="required">*</span></span> <textarea name="reasonToSolve" rows="2" required placeholder="왜 지금 이 문제를 해결해야 하는지 배경을 설명해 주세요."></textarea></label>
<h3 class="full">3. 희망하는 결과 (To-Be)</h3>
<label class="full"><span class="label-text">AI에게 원하는 것<span class="required">*</span></span> <textarea name="aiExpectation" rows="3" required placeholder="예: 폴더에 파일만 넣으면 자동으로 합쳐진 엑셀 파일이 1개 생성되었으면 함"></textarea></label>
<label class="full"><span class="label-text">산출물 형태<span class="required">*</span></span> <textarea name="outputType" rows="2" required placeholder="원하는 결과물의 형태를 설명해 주세요."></textarea></label>
<label class="full">기대 자동화 수준
<select name="automationLevel">
<option value="">선택</option>
<option value="단순 자동화">단순 자동화 (사람이 결과를 검토·확정)</option>
<option value="AI 기반 의사결정">AI 기반 의사결정 (AI가 판단하고 사람이 승인)</option>
<option value="완전 무인화">완전 무인화 (AI가 처음부터 끝까지 수행)</option>
</select>
</label>
<h3 class="full">4. 데이터 준비 상태</h3>
<label>데이터 준비 여부
<select name="dataReadiness">
<option value="">선택</option>
<option value="준비완료">준비완료</option>
<option value="준비중">준비중</option>
<option value="미준비">미준비</option>
</select>
</label>
<label>데이터 위치 <input type="text" name="dataLocation" placeholder="예: 사내 DB / 업무 PC / 외부 API" /></label>
<label>개인정보 포함
<select name="personalInfo">
<option value="">선택</option>
<option value="포함">포함</option>
<option value="미포함">미포함</option>
<option value="확인 필요">확인 필요</option>
</select>
</label>
<label>데이터 정합성
<select name="dataQuality">
<option value="">선택</option>
<option value="양호">양호</option>
<option value="보통">보통</option>
<option value="정제 필요">정제 필요</option>
</select>
</label>
<label>데이터 건수 <input type="text" name="dataCount" placeholder="예: 약 10,000건" /></label>
<h3 class="full">5. 목표 및 기대 효과</h3>
<label>업무 시간 단축 <input type="text" name="timeReduction" placeholder="예: 30% 단축" /></label>
<label>오류율 감소 <input type="text" name="errorReduction" placeholder="예: 10% → 3%" /></label>
<label>월 처리량 증가 <input type="text" name="volumeIncrease" placeholder="예: 2배 증가" /></label>
<label>비용 절감 <input type="text" name="costReduction" placeholder="예: 월 200만원" /></label>
<label>응답/처리 시간 <input type="text" name="responseTime" placeholder="예: 2분 → 30초" /></label>
<label>기타 지표 <input type="text" name="otherMetrics" placeholder="직접 기재" /></label>
<label>연간 절감 비용 <input type="text" name="annualSavings" placeholder="예: 약 2,400만원/년" /></label>
<label>인력 대체 시간 <input type="text" name="laborReplacement" placeholder="예: 월 80시간" /></label>
<label>매출 증가 예상 <input type="text" name="revenueIncrease" placeholder="예: 해당 없음" /></label>
<label class="full">기타 <input type="text" name="otherEffects" placeholder="직접 기재" /></label>
<h3 class="full">6. 예상 AI 툴 및 인프라</h3>
<label class="full">필요 기술 스택 (쉼표 구분) <input type="text" name="techStackStr" placeholder="예: 노코드 워크플로우, LLM, OCR" /></label>
<h3 class="full">7. 리스크 및 제약사항</h3>
<label class="full">해당되는 리스크 (쉼표 구분) <input type="text" name="risksStr" placeholder="예: 개인정보 규제, 법적 이슈" /></label>
<label class="full">상세 설명 <textarea name="riskDetail" rows="2" placeholder="리스크 및 제약사항에 대한 추가 설명"></textarea></label>
<h3 class="full">8. 작성 완료 신청서 업로드</h3>
<label class="full">
<span class="label-text">AX_과제_신청서.docx<span class="required">*</span></span>
<input type="file" name="applicationFile" id="axApplicationFile" accept=".docx,.doc" />
<span class="form-hint" id="axFileHint">다운로드한 신청서를 작성 후 업로드해 주세요.</span>
<span class="form-hint" id="axFileEditHint" style="display:none">수정 시 기존 파일이 있습니다. 변경 시에만 새 파일을 선택하세요.</span>
</label>
<h3 class="full">9. 현업 참여 확약</h3>
<label class="full">
<span class="label-text">현업 참여 확약<span class="required">*</span></span>
<input type="checkbox" name="participationPledge" value="1" required />
프로젝트 수행 기간(약 1주일) 동안 매일 6시간 이상 AI담당팀과 협업, 실제 업무 데이터 제공, 결과물 테스트·피드백, 유지보수 1차 책임 인지에 동의합니다.
</label>
<div class="form-actions full">
<button type="button" class="btn-ghost" onclick="document.getElementById('axApplyForm').reset()">초기화</button>
<button
type="submit"
class="top-action"
id="axSubmitBtn"
<% if (!axDlSubmitOk) { %>disabled aria-disabled="true" title="로그인 후 이용 가능합니다."<% } %>
>
신청하기
</button>
<button type="button" class="btn-ghost" id="axCancelEditBtn" style="display:none">취소하기</button>
</div>
</form>
</section>
</main>
</div>
</div>
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
<div id="axDeleteModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;align-items:center;justify-content:center" class="flex-center">
<div style="background:#fff;padding:1.5rem;border-radius:8px;max-width:400px;width:90%;box-shadow:0 4px 20px rgba(0,0,0,.2)">
<h3 style="margin:0 0 1rem">삭제 확인</h3>
<div id="axDeleteModalBody" style="font-size:0.9rem;line-height:1.6;margin-bottom:1rem"></div>
<p style="margin:0 0 1rem"><strong>이 신청을 삭제할까요?</strong></p>
<div style="display:flex;gap:0.5rem;justify-content:flex-end">
<button type="button" id="axDeleteModalNo" class="btn-ghost">아니오</button>
<button type="button" id="axDeleteModalYes" class="top-action" style="background:#c00">네</button>
</div>
</div>
</div>
<% } %>
<script>
(function() {
var form = document.getElementById('axApplyForm');
if (!form) return;
var axDlSubmitOk = (document.body.getAttribute('data-ax-apply-download-submit-allowed') || '0') === '1';
form.addEventListener('submit', function(e) {
e.preventDefault();
if (!axDlSubmitOk) return;
var editId = (form.querySelector('#axEditId') || {}).value || '';
var fileInput = form.querySelector('input[name="applicationFile"]');
var isEdit = !!editId.trim();
var isAdmin = (document.body.getAttribute('data-ax-admin-mode') || '0') === '1';
var dept = (form.querySelector('input[name="department"]') || {}).value || '';
var nameVal = (form.querySelector('input[name="name"]') || {}).value || '';
var isSample = ((dept || '').trim() === '샘플' && (nameVal || '').trim() === '데이터');
if (isEdit && isSample && !isAdmin) {
alert('샘플 데이터는 참고용으로 수정할 수 없습니다.');
return;
}
if (!isEdit && (!fileInput || !fileInput.files || !fileInput.files.length)) {
alert('작성 완료 신청서(.docx) 파일을 업로드해 주세요.');
return;
}
var fd = new FormData(form);
var btn = form.querySelector('button[type="submit"]');
btn.disabled = true;
var url = isEdit ? '/api/ax-apply/' + editId : '/api/ax-apply';
var method = isEdit ? 'PUT' : 'POST';
fetch(url, {
method: method,
body: fd
})
.then(function(r) {
return r.json().then(function(res) {
if (r.ok && res.ok) { alert(isEdit ? '수정되었습니다.' : '신청이 완료되었습니다.'); window.location.reload(); }
else { alert(res.error || res.message || '저장 실패'); }
}).catch(function() {
if (r.status === 403) alert('접근이 거부되었습니다. 서버 설정을 확인해주세요.');
else alert('저장 중 오류가 발생했습니다.');
});
})
.catch(function() { alert('저장 중 오류가 발생했습니다.'); })
.finally(function() { btn.disabled = false; });
});
})();
(function() {
var btn = document.getElementById('axApplyListQueryBtn');
var form = document.getElementById('axApplyForm');
var wrap = document.getElementById('axAssignmentsListWrap');
if (!btn || !form || !wrap) return;
btn.addEventListener('click', function() {
var dept = (form.querySelector('input[name="department"]') || {}).value || '';
var name = (form.querySelector('input[name="name"]') || {}).value || '';
var email = (form.querySelector('input[name="email"]') || {}).value || '';
if (!dept.trim() || !name.trim() || !email.trim()) {
alert('소속 부서, 이름, 이메일을 모두 입력해 주세요.');
return;
}
btn.disabled = true;
fetch('/api/ax-apply-list?department=' + encodeURIComponent(dept) + '&name=' + encodeURIComponent(name) + '&email=' + encodeURIComponent(email))
.then(function(r) { return r.json(); })
.then(function(list) {
if (!Array.isArray(list)) list = [];
if (list.length === 0) {
wrap.innerHTML = '<p class="empty">신청된 과제가 없습니다.</p>';
return;
}
var html = '<div class="table-wrap"><table class="data-table"><thead><tr><th>순번</th><th>신청일</th><th>소속 부서</th><th>이름</th><th>사번</th><th>직급</th><th>이메일</th><th>AI에게 원하는 것</th><th>신청서</th><th>상태</th><th>수정</th></tr></thead><tbody>';
list.forEach(function(a, idx) {
var aiExp = (a.aiExpectation || '-');
var aiExpTitle = (a.aiExpectation || '').replace(/"/g, '&quot;');
var aiExpShort = aiExp.length > 30 ? (a.aiExpectation || '').slice(0, 30) + '…' : aiExp;
var fileLink = a.applicationFile ? '<a href="' + a.applicationFile + '" target="_blank" download>보기</a>' : '-';
html += '<tr><td>' + (list.length - idx) + '</td><td>' + new Date(a.createdAt).toLocaleDateString('ko-KR') + '</td><td>' + (a.department || '-') + '</td><td>' + (a.name || '-') + '</td><td>' + (a.employeeId || '-') + '</td><td>' + (a.position || '-') + '</td><td>' + (a.email || '-') + '</td><td class="cell-truncate" title="' + aiExpTitle + '">' + aiExpShort + '</td><td>' + fileLink + '</td><td>' + (a.status || '신청') + '</td><td><a href="#" class="ax-edit-link" data-id="' + (a.id || '') + '">수정</a></td></tr>';
});
html += '</tbody></table></div>';
wrap.innerHTML = html;
})
.catch(function() { wrap.innerHTML = '<p class="empty">조회 중 오류가 발생했습니다.</p>'; })
.finally(function() { btn.disabled = false; });
});
})();
(function() {
var wrap = document.getElementById('axAssignmentsListWrap');
var form = document.getElementById('axApplyForm');
var editIdEl = document.getElementById('axEditId');
var submitBtn = document.getElementById('axSubmitBtn');
var cancelBtn = document.getElementById('axCancelEditBtn');
var initBtn = form ? form.querySelector('button[onclick*="reset"]') : null;
var fileHint = document.getElementById('axFileHint');
var fileEditHint = document.getElementById('axFileEditHint');
var axDlSubmitOk = (document.body.getAttribute('data-ax-apply-download-submit-allowed') || '0') === '1';
function applyAxDlSubmitGate() {
if (!submitBtn || axDlSubmitOk) return;
submitBtn.disabled = true;
submitBtn.setAttribute('disabled', 'disabled');
submitBtn.setAttribute('aria-disabled', 'true');
submitBtn.title = '로그인 후 이용 가능합니다.';
submitBtn.style.pointerEvents = '';
submitBtn.classList.remove('ax-sample-readonly');
}
function setEditMode(on) {
if (!editIdEl || !submitBtn || !cancelBtn) return;
if (on) {
editIdEl.value = editIdEl.value || '';
submitBtn.textContent = '수정하기';
submitBtn.style.display = '';
cancelBtn.style.display = '';
if (initBtn) initBtn.style.display = 'none';
if (fileHint) fileHint.style.display = 'none';
if (fileEditHint) fileEditHint.style.display = '';
applyAxDlSubmitGate();
} else {
editIdEl.value = '';
submitBtn.textContent = '신청하기';
submitBtn.style.display = '';
cancelBtn.style.display = 'none';
if (initBtn) initBtn.style.display = '';
if (fileHint) fileHint.style.display = '';
if (fileEditHint) fileEditHint.style.display = 'none';
if (axDlSubmitOk) {
submitBtn.disabled = false;
submitBtn.removeAttribute('disabled');
submitBtn.removeAttribute('aria-disabled');
submitBtn.classList.remove('ax-sample-readonly');
submitBtn.title = '';
submitBtn.style.pointerEvents = '';
} else {
applyAxDlSubmitGate();
}
}
}
function populateForm(a) {
if (!form || !a) return;
var set = function(name, val) { var el = form.querySelector('[name="' + name + '"]'); if (el) el.value = val || ''; };
var setCheck = function(name, val) { var el = form.querySelector('[name="' + name + '"]'); if (el) el.checked = !!val; };
set('department', a.department);
set('name', a.name);
set('employeeId', a.employeeId);
set('position', a.position);
set('phone', a.phone);
set('email', a.email);
set('workProcessDescription', a.workProcessDescription);
set('painPoint', a.painPoint);
set('currentTimeSpent', a.currentTimeSpent);
set('errorRateBefore', a.errorRateBefore);
set('collaborationDepts', a.collaborationDepts);
set('reasonToSolve', a.reasonToSolve);
set('aiExpectation', a.aiExpectation);
set('outputType', a.outputType);
set('automationLevel', a.automationLevel);
set('dataReadiness', a.dataReadiness);
set('dataLocation', a.dataLocation);
set('personalInfo', a.personalInfo);
set('dataQuality', a.dataQuality);
set('dataCount', a.dataCount);
set('timeReduction', a.timeReduction);
set('errorReduction', a.errorReduction);
set('volumeIncrease', a.volumeIncrease);
set('costReduction', a.costReduction);
set('responseTime', a.responseTime);
set('otherMetrics', a.otherMetrics);
set('annualSavings', a.annualSavings);
set('laborReplacement', a.laborReplacement);
set('revenueIncrease', a.revenueIncrease);
set('otherEffects', a.otherEffects);
set('techStackStr', Array.isArray(a.techStack) ? a.techStack.join(', ') : (a.techStack || ''));
set('risksStr', Array.isArray(a.risks) ? a.risks.join(', ') : (a.risks || ''));
set('riskDetail', a.riskDetail);
setCheck('participationPledge', a.participationPledge);
if (editIdEl) editIdEl.value = a.id || '';
setEditMode(true);
var isAdmin = (document.body.getAttribute('data-ax-admin-mode') || '0') === '1';
var deptVal = (a.department || a.department_name || '').toString().trim();
var nameVal = (a.name || a.name_ko || '').toString().trim();
var isSample = (deptVal === '샘플' && nameVal === '데이터');
if (submitBtn) {
if (isSample && !isAdmin) {
submitBtn.disabled = true;
submitBtn.setAttribute('disabled', 'disabled');
submitBtn.setAttribute('aria-disabled', 'true');
submitBtn.classList.add('ax-sample-readonly');
submitBtn.title = '샘플 데이터는 참고용으로 수정할 수 없습니다.';
submitBtn.style.pointerEvents = 'none';
} else if (axDlSubmitOk) {
submitBtn.disabled = false;
submitBtn.removeAttribute('disabled');
submitBtn.removeAttribute('aria-disabled');
submitBtn.classList.remove('ax-sample-readonly');
submitBtn.title = '';
submitBtn.style.pointerEvents = '';
} else {
applyAxDlSubmitGate();
}
}
}
if (wrap) {
wrap.addEventListener('click', function(e) {
var editLink = e.target && e.target.closest && e.target.closest('.ax-edit-link');
var deleteLink = e.target && e.target.closest && e.target.closest('.ax-delete-link');
if (editLink && form) {
e.preventDefault();
var id = (editLink.getAttribute('data-id') || '').trim();
if (!id) return;
var dept = (form.querySelector('input[name="department"]') || {}).value || '';
var name = (form.querySelector('input[name="name"]') || {}).value || '';
var email = (form.querySelector('input[name="email"]') || {}).value || '';
var q = '?department=' + encodeURIComponent(dept) + '&name=' + encodeURIComponent(name) + '&email=' + encodeURIComponent(email);
fetch('/api/ax-apply/' + id + q)
.then(function(r) { return r.json(); })
.then(function(a) { if (a && a.id) populateForm(a); else alert(a && a.error ? a.error : '조회 실패'); })
.catch(function() { alert('조회 중 오류가 발생했습니다.'); });
return;
}
if (deleteLink) {
e.preventDefault();
var modal = document.getElementById('axDeleteModal');
var modalBody = document.getElementById('axDeleteModalBody');
if (!modal || !modalBody) return;
var id = (deleteLink.getAttribute('data-id') || '').trim();
var createdAt = deleteLink.getAttribute('data-created-at') || '-';
var dept = deleteLink.getAttribute('data-department') || '-';
var name = deleteLink.getAttribute('data-name') || '-';
var empId = deleteLink.getAttribute('data-employee-id') || '-';
var pos = deleteLink.getAttribute('data-position') || '-';
var email = deleteLink.getAttribute('data-email') || '-';
if (createdAt !== '-' && createdAt) {
try { createdAt = new Date(createdAt).toLocaleDateString('ko-KR'); } catch(err) {}
}
modalBody.innerHTML = '신청일: ' + createdAt + '<br>소속 부서: ' + dept + '<br>이름: ' + name + '<br>사번: ' + empId + '<br>직급: ' + pos + '<br>이메일: ' + email;
modal.dataset.deleteId = id;
modal.style.display = 'flex';
}
});
}
(function() {
var modal = document.getElementById('axDeleteModal');
var btnYes = document.getElementById('axDeleteModalYes');
var btnNo = document.getElementById('axDeleteModalNo');
if (!modal || !btnYes || !btnNo) return;
btnNo.addEventListener('click', function() { modal.style.display = 'none'; modal.dataset.deleteId = ''; });
modal.addEventListener('click', function(e) { if (e.target === modal) { modal.style.display = 'none'; modal.dataset.deleteId = ''; } });
btnYes.addEventListener('click', function() {
var id = (modal.dataset.deleteId || '').trim();
if (!id) { modal.style.display = 'none'; return; }
btnYes.disabled = true;
fetch('/api/ax-apply/' + id, { method: 'DELETE' })
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.ok) { modal.style.display = 'none'; modal.dataset.deleteId = ''; window.location.reload(); }
else { alert(res.error || '삭제 실패'); }
})
.catch(function() { alert('삭제 중 오류가 발생했습니다.'); })
.finally(function() { btnYes.disabled = false; });
});
})();
if (cancelBtn) {
cancelBtn.addEventListener('click', function() {
if (editIdEl) editIdEl.value = '';
setEditMode(false);
if (form) form.reset();
});
}
})();
</script>
</body>
</html>

342
views/chat.ejs Normal file
View File

@@ -0,0 +1,342 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>채팅 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'chat' }) %>
<div class="content-area chat-area">
<header class="topbar">
<h1>채팅</h1>
</header>
<main class="chat-main">
<p id="chatApiWarning" class="chat-api-warning" hidden>
<strong>API 키 없음.</strong> 프로젝트 루트 <code>.env</code>에 <code>OPENAI_API_KEY</code>를 설정한 뒤 서버를 재시작하세요.
</p>
<p id="chatGateNotice" class="chat-api-warning" hidden></p>
<div class="chat-messages" id="chatMessages">
<div class="chat-welcome" id="chatWelcome">
<h2>안녕하세요, 오늘 무엇을 도와드릴까요?</h2>
<p>AI와 대화하며 업무를 효율적으로 처리해보세요.</p>
</div>
</div>
<div class="chat-input-wrap">
<form class="chat-form" id="chatForm">
<select id="chatModel" class="chat-model-select" title="채팅 모델">
<option value="gpt-5-mini" selected>gpt-5-mini</option>
<option value="gpt-5.4">gpt-5.4</option>
</select>
<textarea id="chatInput" placeholder="무엇이든 물어보세요" rows="1"></textarea>
<button type="submit" id="sendBtn" class="chat-send-btn" title="전송">↑</button>
</form>
<p class="chat-disclaimer">자비스는 실수를 할 수 있습니다. 중요한 정보는 재차 확인하세요.</p>
</div>
</main>
</div>
</div>
<script src="/vendor/marked/marked.umd.js"></script>
<script src="/vendor/dompurify/purify.min.js"></script>
<script>
(function() {
var chatGptAllowed = <%= JSON.stringify(!!chatGptAllowed) %>;
var opsState = <%- JSON.stringify(typeof opsState !== 'undefined' ? opsState : 'DEV') %>;
var adminMode = <%= JSON.stringify(!!adminMode) %>;
var opsUserEmail = <%= JSON.stringify(!!(typeof opsUserEmail !== 'undefined' && opsUserEmail)) %>;
const messagesEl = document.getElementById('chatMessages');
const welcomeEl = document.getElementById('chatWelcome');
const form = document.getElementById('chatForm');
const input = document.getElementById('chatInput');
const sendBtn = document.getElementById('sendBtn');
const modelSelect = document.getElementById('chatModel');
let conversationHistory = [];
function applyChatGptGate(allowed) {
chatGptAllowed = !!allowed;
var notice = document.getElementById('chatGateNotice');
if (chatGptAllowed) {
if (notice) notice.hidden = true;
return;
}
if (input) input.disabled = true;
if (sendBtn) sendBtn.disabled = true;
if (modelSelect) modelSelect.disabled = true;
if (notice) {
notice.hidden = false;
if (opsState === 'DEV' && !adminMode) {
notice.textContent = '로그인 후 이용 가능합니다.';
} else if (opsState === 'PROD' && !opsUserEmail) {
notice.textContent = '회사 이메일 인증(로그인) 후 이용할 수 있습니다.';
} else {
notice.textContent = '이 환경에서는 GPT 채팅을 사용할 수 없습니다.';
}
}
}
applyChatGptGate(chatGptAllowed);
fetch('/api/chat/config')
.then(function(r) { return r.json(); })
.then(function(cfg) {
var w = document.getElementById('chatApiWarning');
if (w && cfg && !cfg.configured) w.hidden = false;
if (cfg && typeof cfg.chatGptAllowed === 'boolean') {
applyChatGptGate(cfg.chatGptAllowed);
}
})
.catch(function() {});
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
var chatMarkdownConfigured = false;
function configureChatMarkdown() {
if (chatMarkdownConfigured) return;
chatMarkdownConfigured = true;
if (typeof marked !== 'undefined') {
marked.setOptions({ breaks: true, gfm: true });
}
if (typeof DOMPurify !== 'undefined') {
DOMPurify.addHook('afterSanitizeAttributes', function(node) {
if (node.tagName === 'A' && node.hasAttribute('href')) {
var href = node.getAttribute('href');
try {
var u = new URL(href, window.location.href);
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
node.removeAttribute('href');
return;
}
} catch (e) {
node.removeAttribute('href');
return;
}
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
}
}
/** 마크다운 → HTML 후 DOMPurify로 정제 (marked 출력은 신뢰하지 않음) */
function renderAssistantMarkdown(text) {
configureChatMarkdown();
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
return escapeHtml(text).replace(/\n/g, '<br>');
}
try {
var raw = marked.parse(String(text || ''), { async: false });
return DOMPurify.sanitize(raw, { USE_PROFILES: { html: true } });
} catch (err) {
return escapeHtml(text).replace(/\n/g, '<br>');
}
}
function addMessage(role, content) {
if (welcomeEl) welcomeEl.style.display = 'none';
const div = document.createElement('div');
div.className = 'chat-msg ' + (role === 'user' ? 'chat-msg-user' : 'chat-msg-assistant');
var inner =
role === 'assistant'
? renderAssistantMarkdown(content)
: escapeHtml(content).replace(/\n/g, '<br>');
var contentClass =
role === 'assistant' ? 'chat-msg-content chat-md-body' : 'chat-msg-content';
div.innerHTML = '<div class="' + contentClass + '">' + inner + '</div>';
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function appendAssistantStreamingPlaceholder() {
if (welcomeEl) welcomeEl.style.display = 'none';
const div = document.createElement('div');
div.className = 'chat-msg chat-msg-assistant chat-msg-streaming';
const inner = document.createElement('div');
inner.className = 'chat-msg-content chat-md-body';
inner.innerHTML =
'<span class="chat-typing-dots" aria-label="응답 생성 중"><span class="chat-typing-dot">·</span><span class="chat-typing-dot">·</span><span class="chat-typing-dot">·</span></span>';
div.appendChild(inner);
const statusEl = document.createElement('div');
statusEl.className = 'chat-status-line';
statusEl.hidden = true;
statusEl.setAttribute('aria-live', 'polite');
div.appendChild(statusEl);
const sourcesEl = document.createElement('div');
sourcesEl.className = 'chat-sources';
sourcesEl.hidden = true;
div.appendChild(sourcesEl);
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
return { bubble: div, contentEl: inner, statusEl: statusEl, sourcesEl: sourcesEl };
}
function appendSourceLinks(sourcesEl, items) {
if (!sourcesEl || !items || !items.length) return;
sourcesEl.innerHTML = '';
const title = document.createElement('div');
title.className = 'chat-sources-title';
title.textContent = '출처';
sourcesEl.appendChild(title);
const ol = document.createElement('ol');
ol.className = 'chat-sources-list';
for (let i = 0; i < items.length; i++) {
const item = items[i];
const url = item && item.url;
if (!url || typeof url !== 'string') continue;
let href;
try {
const u = new URL(url);
if (u.protocol !== 'http:' && u.protocol !== 'https:') continue;
href = u.href;
} catch (e) {
continue;
}
const li = document.createElement('li');
const a = document.createElement('a');
a.href = href;
a.target = '_blank';
a.rel = 'noopener noreferrer';
const t = item.title && String(item.title).trim();
a.textContent = t || href;
li.appendChild(a);
ol.appendChild(li);
}
if (ol.children.length) {
sourcesEl.appendChild(ol);
sourcesEl.hidden = false;
}
}
function setStreamingContent(contentEl, text) {
contentEl.innerHTML = renderAssistantMarkdown(text);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function setLoading(loading) {
sendBtn.disabled = loading;
sendBtn.textContent = loading ? '...' : '↑';
sendBtn.classList.toggle('is-busy', loading);
sendBtn.setAttribute('aria-busy', loading ? 'true' : 'false');
if (modelSelect) modelSelect.disabled = loading;
if (input) input.disabled = loading;
}
async function readSseStream(response, body) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let fullText = '';
let started = false;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep;
while ((sep = buffer.indexOf('\n\n')) !== -1) {
const block = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const lines = block.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.indexOf('data: ') !== 0) continue;
let obj;
try {
obj = JSON.parse(line.slice(6));
} catch (parseErr) {
continue;
}
if (obj.type === 'error') {
throw new Error(obj.error || '스트림 오류');
}
if (obj.type === 'status' && obj.phase === 'web_search' && body.statusEl) {
body.statusEl.textContent = '웹 검색 중…';
body.statusEl.hidden = false;
messagesEl.scrollTop = messagesEl.scrollHeight;
}
if (obj.type === 'sources' && body.sourcesEl && obj.items) {
appendSourceLinks(body.sourcesEl, obj.items);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
if (obj.type === 'done' && body.statusEl) {
body.statusEl.hidden = true;
}
if (obj.type === 'delta' && obj.text) {
if (body.statusEl) body.statusEl.hidden = true;
if (!started) {
started = true;
body.bubble.classList.remove('chat-msg-streaming');
}
fullText += obj.text;
setStreamingContent(body.contentEl, fullText);
}
}
}
}
return fullText;
}
form.addEventListener('submit', async function(e) {
e.preventDefault();
if (!chatGptAllowed) return;
const text = (input.value || '').trim();
if (!text) return;
addMessage('user', text);
conversationHistory.push({ role: 'user', content: text });
input.value = '';
input.style.height = 'auto';
const body = appendAssistantStreamingPlaceholder();
setLoading(true);
try {
const model = document.getElementById('chatModel').value;
const res = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: model, messages: conversationHistory.slice(-20) })
});
if (!res.ok) {
const errData = await res.json().catch(function() { return {}; });
body.bubble.remove();
addMessage('assistant', '오류: ' + (errData.error || res.statusText));
return;
}
const fullReply = await readSseStream(res, body);
body.bubble.classList.remove('chat-msg-streaming');
if (!fullReply.trim()) {
setStreamingContent(body.contentEl, '(응답이 비어 있습니다.)');
}
conversationHistory.push({ role: 'assistant', content: fullReply || '(빈 응답)' });
} catch (err) {
body.bubble.remove();
addMessage('assistant', '오류: ' + (err.message || '네트워크 오류'));
} finally {
if (body && body.statusEl) body.statusEl.hidden = true;
setLoading(false);
}
});
input.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.dispatchEvent(new Event('submit'));
}
});
})();
</script>
</body>
</html>

333
views/index.ejs Normal file
View File

@@ -0,0 +1,333 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>학습센터</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<% if (typeof filters === 'undefined') { filters = { q: '', type: 'all', tag: '' }; } %>
<% if (typeof availableTags === 'undefined') { availableTags = []; } %>
<% if (typeof pagination === 'undefined') { pagination = { page: 1, totalPages: 1, totalCount: 0, hasPrev: false, hasNext: false, prevQuery: '', nextQuery: '', pages: [] }; } %>
<% if (typeof thumbnailQueueInfo === 'undefined') { thumbnailQueueInfo = { pending: 0, working: false, maxRetry: 2 }; } %>
<% if (typeof thumbnailStatusSummary === 'undefined') { thumbnailStatusSummary = { ready: 0, processing: 0, failed: 0, pending: 0 }; } %>
<% if (typeof recentEvents === 'undefined') { recentEvents = []; } %>
<% if (typeof failureReasons === 'undefined') { failureReasons = []; } %>
<% if (typeof adminRequested === 'undefined') { adminRequested = false; } %>
<% if (typeof adminMode === 'undefined') { adminMode = false; } %>
<% if (typeof tokenRaw === 'undefined') { tokenRaw = ''; } %>
<% if (typeof tokenMasked === 'undefined') { tokenMasked = ''; } %>
<% if (typeof returnQuery === 'undefined') { returnQuery = ''; } %>
<% if (typeof retryQueued === 'undefined') { retryQueued = 0; } %>
<% if (typeof lectures === 'undefined') { lectures = []; } %>
<% if (typeof escapeHtml === 'undefined') { escapeHtml = function(s) { return String(s || ''); }; } %>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'learning' }) %>
<div class="content-area">
<header class="topbar">
<h1>학습센터</h1>
<button type="button" class="top-action-link" onclick="openAdminTokenModal()">강의 등록하기</button>
</header>
<main class="container">
<section class="hero panel">
<h2>최신 컨텐츠로 학습하고, 바로 업무에 적용하세요.</h2>
<p>유튜브 링크 또는 PPT를 등록한 뒤, 목록에서 클릭하여 강의를 시청할 수 있습니다.</p>
</section>
<section class="panel filter-panel">
<h2>강의 검색/필터</h2>
<form action="/" method="get" class="filter-grid">
<label>
검색어
<input type="text" name="q" value="<%= filters.q %>" placeholder="제목" />
</label>
<label>
타입
<select name="type">
<option value="all" <%= filters.type === "all" ? "selected" : "" %>>전체</option>
<option value="youtube" <%= filters.type === "youtube" ? "selected" : "" %>>YouTube</option>
<option value="ppt" <%= filters.type === "ppt" ? "selected" : "" %>>PPT</option>
<option value="video" <%= filters.type === "video" ? "selected" : "" %>>동영상</option>
</select>
</label>
<label>
태그
<select name="tag">
<option value="">전체</option>
<% availableTags.forEach((oneTag) => { %>
<option value="<%= oneTag %>" <%= filters.tag === oneTag ? "selected" : "" %>><%= oneTag %></option>
<% }) %>
</select>
</label>
<% if (adminRequested) { %>
<input type="hidden" name="admin" value="1" />
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<% } %>
<div class="filter-actions">
<button type="submit">필터 적용</button>
<a class="link-muted" href="/">초기화</a>
</div>
</form>
</section>
<section class="panel admin-panel">
<h2>관리자 모드</h2>
<% if (retryQueued > 0) { %>
<p class="admin-ok">실패 건 재시도 <%= retryQueued %>건이 큐에 등록되었습니다.</p>
<% } %>
<% if (adminMode) { %>
<p class="admin-ok">관리자 모드 활성화됨</p>
<div class="queue-status">
<span>큐: <b><%= thumbnailQueueInfo.pending %></b></span>
<span>워커: <b><%= thumbnailQueueInfo.working ? "작동중" : "대기" %></b></span>
<span>실패 재시도 최대: <b><%= thumbnailQueueInfo.maxRetry %></b></span>
</div>
<div class="queue-status">
<span>PPT 썸네일 - 준비완료 <b><%= thumbnailStatusSummary.ready %></b></span>
<span>처리중 <b><%= thumbnailStatusSummary.processing %></b></span>
<span>대기 <b><%= thumbnailStatusSummary.pending %></b></span>
<span>실패 <b><%= thumbnailStatusSummary.failed %></b></span>
</div>
<% } else { %>
<p class="admin-warn">삭제 기능은 관리자 토큰이 있어야 활성화됩니다.</p>
<% } %>
<form action="/" method="get" class="admin-inline">
<input type="hidden" name="q" value="<%= filters.q %>" />
<input type="hidden" name="type" value="<%= filters.type %>" />
<input type="hidden" name="tag" value="<%= filters.tag %>" />
<input type="hidden" name="page" value="<%= pagination.page %>" />
<input type="hidden" name="admin" value="1" />
<input type="password" name="token" placeholder="관리자 토큰" />
<button type="submit">관리자 활성화</button>
</form>
<% if (adminMode) { %>
<form action="/thumbnails/retry-failed" method="post" class="admin-inline">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="?<%= returnQuery %>" />
<button type="submit" class="ghost">실패 썸네일 일괄 재시도</button>
</form>
<p class="api-hint">메트릭 API: <code>/api/queue/metrics?token=***</code></p>
<p class="api-hint">
<a href="/admin/thumbnail-events?token=<%= tokenRaw %>" class="link-muted">이벤트 로그 페이지 열기</a>
</p>
<div class="dashboard-grid">
<section class="mini-panel">
<h3>실패 원인 TOP</h3>
<% if (!failureReasons.length) { %>
<p class="empty">최근 실패 원인이 없습니다.</p>
<% } else { %>
<ul class="plain-list">
<% failureReasons.slice(0, 5).forEach((item) => { %>
<li>
<b><%= item.count %>회</b>
<span><%= item.reason %></span>
</li>
<% }) %>
</ul>
<% } %>
</section>
<section class="mini-panel">
<h3>최근 썸네일 이벤트</h3>
<% if (!recentEvents.length) { %>
<p class="empty">이벤트 로그가 없습니다.</p>
<% } else { %>
<ul class="plain-list">
<% recentEvents.forEach((evt) => { %>
<li>
<span class="evt-type <%= evt.type %>"><%= evt.type %></span>
<span><%= evt.lectureTitle || evt.lectureId %></span>
<small><%= new Date(evt.at).toLocaleString("ko-KR") %></small>
</li>
<% }) %>
</ul>
<% } %>
</section>
</div>
<% } %>
</section>
<section id="register" class="panel">
<h2>유튜브 강의 등록</h2>
<form action="/lectures/youtube" method="post" class="form-grid">
<label>
제목
<input type="text" name="title" required />
</label>
<label>
유튜브 링크
<input type="url" name="youtubeUrl" placeholder="https://www.youtube.com/watch?v=..." required />
</label>
<label class="full">
설명
<textarea name="description" rows="3" placeholder="강의 설명"></textarea>
</label>
<label class="full">
태그 (쉼표 구분)
<input type="text" name="tags" placeholder="예: AI에이전트, 바이브코딩" />
</label>
<button type="submit">유튜브 강의 등록</button>
</form>
</section>
<section class="panel">
<h2>PDF/PowerPoint 강의 등록</h2>
<form action="/lectures/ppt" method="post" enctype="multipart/form-data" class="form-grid">
<label>
제목
<input type="text" name="title" required />
</label>
<label>
PDF 또는 PPT 파일
<input type="file" name="pptFile" accept=".pdf,.pptx" required />
</label>
<label class="full">
설명
<textarea name="description" rows="3" placeholder="강의 설명"></textarea>
</label>
<label class="full">
태그 (쉼표 구분)
<input type="text" name="tags" placeholder="예: 프롬프트, 생성형AI" />
</label>
<button type="submit">강의 등록</button>
</form>
</section>
<section class="panel">
<h2>동영상 파일 등록</h2>
<form action="/lectures/video" method="post" enctype="multipart/form-data" class="form-grid">
<label>
제목
<input type="text" name="title" required />
</label>
<label>
동영상 파일 (MP4·WebM·MOV)
<input type="file" name="videoFile" accept=".mp4,.webm,.mov,video/mp4,video/webm,video/quicktime" required />
</label>
<label class="full">
설명
<textarea name="description" rows="3" placeholder="강의 설명"></textarea>
</label>
<label class="full">
태그 (쉼표 구분)
<input type="text" name="tags" placeholder="예: 온보딩, 내부교육" />
</label>
<button type="submit">동영상 등록</button>
</form>
</section>
<section class="panel">
<div class="section-head">
<h2>등록된 강의</h2>
<span class="count-chip">총 <%= pagination.totalCount %>건</span>
</div>
<% if (!lectures.length) { %>
<p class="empty">등록된 강의가 없습니다.</p>
<% } %>
<div class="lecture-grid">
<% lectures.forEach((lecture) => { %>
<% var _externalUrl = (lecture.type === "link" || lecture.type === "news") && (lecture.newsUrl || "").trim(); %>
<article class="lecture-card">
<% if (_externalUrl) { %>
<a class="lecture-link lecture-link-external" href="<%= _externalUrl %>" target="_blank" rel="noopener noreferrer">
<% } else { %>
<a class="lecture-link" href="/lectures/<%= lecture.id %>">
<% } %>
<div class="thumb <%= lecture.type %>">
<% if (lecture.type === "ppt") { %>
<% if (lecture.thumbnailUrl) { %>
<img src="<%= lecture.thumbnailUrl %>" alt="<%= lecture.title %> 썸네일" class="thumb-image" />
<% } else { %>
<span class="thumb-fallback">썸네일 <%= lecture.thumbnailStatus || "pending" %></span>
<% } %>
<span class="thumb-kicker">PPT 프리뷰</span>
<% if (lecture.previewTitle && lecture.previewTitle !== "제목 없음") { %><strong><%= lecture.previewTitle %></strong><% } %>
<% } else if (lecture.type === "news") { %>
<% if (lecture.thumbnailUrl) { %>
<img src="<%= lecture.thumbnailUrl %>" alt="" class="thumb-image thumb-image-og" loading="lazy" referrerpolicy="no-referrer" />
<% } %>
<span class="thumb-kicker">뉴스</span>
<% if (!lecture.thumbnailUrl) { %><strong>외부 링크</strong><% } %>
<% } else if (lecture.type === "link") { %>
<% if (lecture.thumbnailUrl) { %>
<img src="<%= lecture.thumbnailUrl %>" alt="" class="thumb-image thumb-image-og" loading="lazy" referrerpolicy="no-referrer" />
<% } %>
<span class="thumb-kicker">웹 링크</span>
<% if (!lecture.thumbnailUrl) { %><strong>외부 페이지</strong><% } %>
<% } else if (lecture.type === "video") { %>
<span class="thumb-fallback thumb-fallback-video">▶</span>
<span class="thumb-kicker">동영상 파일</span>
<strong>업로드 영상</strong>
<% } else { %>
<% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %>
<% if (ytThumb) { %>
<img src="<%= ytThumb %>" alt="<%= lecture.title %> 썸네일" class="thumb-image thumb-image-youtube" />
<% } %>
<span class="thumb-kicker">YouTube</span>
<strong>영상 강의</strong>
<% } %>
</div>
<div class="badge <%= lecture.type %>">
<% if (lecture.type === "youtube") { %>YouTube<% } else if (lecture.type === "news") { %>뉴스<% } else if (lecture.type === "link") { %>링크<% } else if (lecture.type === "video") { %>동영상<% } else { %>PPT<% } %>
</div>
<h3><%= lecture.title %></h3>
<p><%= lecture.description || "설명이 없습니다." %></p>
<div class="tag-row">
<% (lecture.tags || []).forEach((oneTag) => { %>
<span class="tag-chip">#<%= oneTag %></span>
<% }) %>
</div>
<small><%= new Date(lecture.createdAt).toLocaleString("ko-KR") %></small>
</a>
<% if (adminMode) { %>
<form action="/lectures/<%= lecture.id %>/delete" method="post" class="delete-form">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="?<%= returnQuery %>" />
<button type="submit" class="danger">삭제</button>
</form>
<% if (lecture.type === "ppt") { %>
<div class="thumb-state-row">
<span class="state-chip <%= lecture.thumbnailStatus || "pending" %>">
<%= lecture.thumbnailStatus || "pending" %>
</span>
<% if (lecture.thumbnailError) { %>
<small class="error-text"><%= lecture.thumbnailError %></small>
<% } %>
</div>
<form action="/lectures/<%= lecture.id %>/thumbnail/regenerate" method="post" class="delete-form">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="?<%= returnQuery %>" />
<button type="submit" class="ghost">썸네일 재생성</button>
</form>
<% } %>
<% } %>
</article>
<% }) %>
</div>
<% if (pagination.totalPages > 1) { %>
<nav class="pagination">
<% if (pagination.hasPrev) { %>
<a href="/?<%= pagination.prevQuery %>">이전</a>
<% } %>
<% pagination.pages.forEach((p) => { %>
<a href="/?<%= p.query %>" class="<%= p.active ? "active" : "" %>"><%= p.page %></a>
<% }) %>
<% if (pagination.hasNext) { %>
<a href="/?<%= pagination.nextQuery %>">다음</a>
<% } %>
</nav>
<% } %>
</section>
</main>
</div>
</div>
</body>
</html>

335
views/learning-admin.ejs Normal file
View File

@@ -0,0 +1,335 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<% var admTitle = typeof adminPageTitle !== 'undefined' ? adminPageTitle : '학습센터 관리'; %>
<title><%= admTitle %> - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<% if (typeof filters === 'undefined') { filters = { q: '', type: 'all', tag: '' }; } %>
<% if (typeof availableTags === 'undefined') { availableTags = []; } %>
<% if (typeof pagination === 'undefined') { pagination = { page: 1, totalPages: 1, totalCount: 0, hasPrev: false, hasNext: false, prevQuery: '', nextQuery: '', pages: [] }; } %>
<% if (typeof adminRequested === 'undefined') { adminRequested = false; } %>
<% if (typeof adminMode === 'undefined') { adminMode = false; } %>
<% if (typeof tokenRaw === 'undefined') { tokenRaw = ''; } %>
<% if (typeof returnQuery === 'undefined') { returnQuery = ''; } %>
<% if (typeof retryQueued === 'undefined') { retryQueued = 0; } %>
<% if (typeof eventsCleared === 'undefined') { eventsCleared = false; } %>
<% if (typeof lectures === 'undefined') { lectures = []; } %>
<% if (typeof thumbnailQueueInfo === 'undefined') { thumbnailQueueInfo = { pending: 0, working: false, maxRetry: 2 }; } %>
<% if (typeof thumbnailStatusSummary === 'undefined') { thumbnailStatusSummary = { ready: 0, processing: 0, failed: 0, pending: 0 }; } %>
<% if (typeof recentEvents === 'undefined') { recentEvents = []; } %>
<% if (typeof failureReasons === 'undefined') { failureReasons = []; } %>
<% var adminBasePath = typeof adminBasePath !== 'undefined' ? adminBasePath : '/admin'; %>
<% var viewerBasePath = typeof viewerBasePath !== 'undefined' ? viewerBasePath : '/learning'; %>
<% var navMenu = typeof navActiveMenu !== 'undefined' ? navActiveMenu : 'learning'; %>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: navMenu, adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %>
<div class="content-area">
<header class="topbar">
<h1><%= admTitle %></h1>
<a class="top-action-link" href="<%= viewerBasePath %>">목록 보기</a>
</header>
<main class="container">
<section class="panel admin-panel">
<div class="admin-mode-header">
<h2>관리자 모드</h2>
<div class="admin-mode-toggle">
<% if (adminMode) { %>
<span class="admin-status active">활성</span>
<a href="<%= adminBasePath %>?q=<%= filters.q %>&type=<%= filters.type %>&tag=<%= filters.tag %>&category=<%= filters.category || '' %>&page=<%= pagination.page %>" class="btn-admin-off">비활성화</a>
<% } else { %>
<span class="admin-status inactive">비활성</span>
<form action="<%= adminBasePath %>" method="get" class="admin-activate-inline">
<input type="hidden" name="q" value="<%= filters.q %>" />
<input type="hidden" name="type" value="<%= filters.type %>" />
<input type="hidden" name="tag" value="<%= filters.tag %>" />
<input type="hidden" name="category" value="<%= filters.category || '' %>" />
<input type="hidden" name="page" value="<%= pagination.page %>" />
<input type="password" name="token" placeholder="관리자 토큰" required autocomplete="off" />
<button type="submit">활성화</button>
</form>
<% } %>
</div>
</div>
<% if (retryQueued > 0) { %>
<p class="admin-ok">실패 건 재시도 <%= retryQueued %>건이 큐에 등록되었습니다.</p>
<% } %>
<% if (adminMode) { %>
<div class="queue-status">
<span>큐: <b><%= thumbnailQueueInfo.pending %></b></span>
<span>워커: <b><%= thumbnailQueueInfo.working ? "작동중" : "대기" %></b></span>
<span>실패 재시도 최대: <b><%= thumbnailQueueInfo.maxRetry %></b></span>
</div>
<div class="queue-status">
<span>PPT 썸네일 - 준비완료 <b><%= thumbnailStatusSummary.ready %></b></span>
<span>처리중 <b><%= thumbnailStatusSummary.processing %></b></span>
<span>대기 <b><%= thumbnailStatusSummary.pending %></b></span>
<span>실패 <b><%= thumbnailStatusSummary.failed %></b></span>
</div>
<% } else { %>
<% if (adminRequested) { %>
<p class="admin-error">입력한 토큰이 올바르지 않습니다. 다시 확인해주세요.</p>
<% } %>
<p class="admin-warn">삭제·썸네일 관리는 관리자 토큰을 입력한 뒤 <strong>활성화</strong>를 눌러주세요.</p>
<% } %>
</section>
<div
class="admin-body-gated <%= adminMode ? 'admin-body-gated--unlocked' : 'admin-body-gated--locked' %>"
<%= !adminMode ? 'inert' : '' %>
>
<section id="register" class="panel">
<h2>유튜브 강의 등록</h2>
<form action="/lectures/youtube" method="post" class="form-grid">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<label>제목 <input type="text" name="title" required /></label>
<label>유튜브 링크 <input type="url" name="youtubeUrl" placeholder="https://www.youtube.com/watch?v=..." required /></label>
<label class="full">설명 <textarea name="description" rows="3" placeholder="강의 설명"></textarea></label>
<label class="full">카테고리
<div class="category-radios-inline">
<label><input type="radio" name="category" value="" checked />선택 안함</label>
<label><input type="radio" name="category" value="AX 사고 전환" />AX 사고 전환</label>
<label><input type="radio" name="category" value="AI 툴 활용" />AI 툴 활용</label>
<label><input type="radio" name="category" value="AI Agent" />AI Agent</label>
<label><input type="radio" name="category" value="바이브 코딩" />바이브 코딩</label>
</div>
</label>
<label class="full">태그 (쉼표 구분) <input type="text" name="tags" placeholder="예: AI에이전트, 바이브코딩" /></label>
<button type="submit">유튜브 등록</button>
</form>
</section>
<section class="panel">
<h2>PDF/PowerPoint 강의 등록</h2>
<form action="/lectures/ppt" method="post" enctype="multipart/form-data" class="form-grid">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<label>제목 <input type="text" name="title" required /></label>
<label>PDF 또는 PPT 파일 <input type="file" name="pptFile" accept=".pdf,.pptx" required /></label>
<label class="full">설명 <textarea name="description" rows="3" placeholder="강의 설명"></textarea></label>
<label class="full">카테고리
<div class="category-radios-inline">
<label><input type="radio" name="category" value="" checked />선택 안함</label>
<label><input type="radio" name="category" value="AX 사고 전환" />AX 사고 전환</label>
<label><input type="radio" name="category" value="AI 툴 활용" />AI 툴 활용</label>
<label><input type="radio" name="category" value="AI Agent" />AI Agent</label>
<label><input type="radio" name="category" value="바이브 코딩" />바이브 코딩</label>
</div>
</label>
<label class="full">태그 (쉼표 구분) <input type="text" name="tags" placeholder="예: 프롬프트, 생성형AI" /></label>
<button type="submit">파일 등록</button>
</form>
</section>
<section class="panel">
<h2>동영상 파일 등록</h2>
<p class="muted" style="margin-top:0;font-size:13px;">MP4·WebM·MOV 파일을 서버에 저장한 뒤 브라우저에서 재생합니다. (용량 제한은 서버 설정 <code>LECTURE_VIDEO_MAX_MB</code>, 기본 500MB)</p>
<form action="/lectures/video" method="post" enctype="multipart/form-data" class="form-grid">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<label>제목 <input type="text" name="title" required /></label>
<label>동영상 파일 <input type="file" name="videoFile" accept=".mp4,.webm,.mov,video/mp4,video/webm,video/quicktime" required /></label>
<label class="full">설명 <textarea name="description" rows="3" placeholder="강의 설명"></textarea></label>
<label class="full">카테고리
<div class="category-radios-inline">
<label><input type="radio" name="category" value="" checked />선택 안함</label>
<label><input type="radio" name="category" value="AX 사고 전환" />AX 사고 전환</label>
<label><input type="radio" name="category" value="AI 툴 활용" />AI 툴 활용</label>
<label><input type="radio" name="category" value="AI Agent" />AI Agent</label>
<label><input type="radio" name="category" value="바이브 코딩" />바이브 코딩</label>
</div>
</label>
<label class="full">태그 (쉼표 구분) <input type="text" name="tags" placeholder="예: 온보딩, 내부교육" /></label>
<button type="submit">동영상 등록</button>
</form>
</section>
<section class="panel">
<h2>웹 링크 강의 등록</h2>
<p class="muted" style="margin-top:0;font-size:13px;">외부 사이트(http/https)를 카드로 등록합니다. (유튜브는 위 «유튜브 강의 등록»을 사용하세요.)</p>
<form action="/lectures/link" method="post" class="form-grid">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<label>제목 <input type="text" name="title" required /></label>
<label>URL <input type="url" name="linkUrl" placeholder="https://..." required /></label>
<label class="full">설명 <textarea name="description" rows="3" placeholder="강의 설명"></textarea></label>
<label class="full">카테고리
<div class="category-radios-inline">
<label><input type="radio" name="category" value="" checked />선택 안함</label>
<label><input type="radio" name="category" value="AX 사고 전환" />AX 사고 전환</label>
<label><input type="radio" name="category" value="AI 툴 활용" />AI 툴 활용</label>
<label><input type="radio" name="category" value="AI Agent" />AI Agent</label>
<label><input type="radio" name="category" value="바이브 코딩" />바이브 코딩</label>
</div>
</label>
<label class="full">태그 (쉼표 구분) <input type="text" name="tags" placeholder="예: 참고자료, 링크모음" /></label>
<button type="submit">웹 링크 등록</button>
</form>
</section>
<section class="panel">
<div class="section-head">
<h2>등록된 강의</h2>
<span class="count-chip">총 <%= pagination.totalCount %>건</span>
</div>
<% if (!adminMode && lectures.length > 0) { %>
<p class="admin-warn" style="margin-bottom:12px">수정·삭제를 하려면 위에서 관리자 토큰을 입력하고 <strong>관리자 활성화</strong>를 눌러주세요.</p>
<% } %>
<form action="<%= adminBasePath %>" method="get" class="filter-grid" style="margin-bottom:12px">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<label>검색 <input type="text" name="q" value="<%= filters.q %>" /></label>
<label>타입 <select name="type"><option value="all" <%= filters.type === "all" ? "selected" : "" %>>전체</option><option value="youtube" <%= filters.type === "youtube" ? "selected" : "" %>>YouTube</option><option value="ppt" <%= filters.type === "ppt" ? "selected" : "" %>>PPT</option><option value="video" <%= filters.type === "video" ? "selected" : "" %>>동영상 파일</option><option value="link" <%= filters.type === "link" ? "selected" : "" %>>웹 링크</option><option value="news" <%= filters.type === "news" ? "selected" : "" %>>뉴스 URL</option></select></label>
<label>태그 <select name="tag"><option value="">전체</option><% (availableTags || []).forEach((t) => { %><option value="<%= t %>" <%= filters.tag === t ? "selected" : "" %>><%= t %></option><% }) %></select></label>
<label>카테고리 <select name="category"><option value="">전체</option><option value="AX 사고 전환" <%= filters.category === 'AX 사고 전환' ? 'selected' : '' %>>AX 사고 전환</option><option value="AI 툴 활용" <%= filters.category === 'AI 툴 활용' ? 'selected' : '' %>>AI 툴 활용</option><option value="AI Agent" <%= filters.category === 'AI Agent' ? 'selected' : '' %>>AI Agent</option><option value="바이브 코딩" <%= filters.category === '바이브 코딩' ? 'selected' : '' %>>바이브 코딩</option></select></label>
<button type="submit">필터</button>
</form>
<% if (!lectures.length) { %>
<p class="empty">등록된 항목이 없습니다.</p>
<% } else { %>
<div class="lecture-grid">
<% lectures.forEach((lecture) => { %>
<article class="lecture-card">
<% if (adminMode) { %>
<div class="lecture-card-actions">
<a href="/lectures/<%= lecture.id %>/edit?<%= returnQuery %>" class="btn-edit">수정</a>
<form action="/lectures/<%= lecture.id %>/delete" method="post" class="delete-form-inline" onsubmit="return confirm('정말 삭제하시겠습니까?');">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<button type="submit" class="danger">삭제</button>
</form>
</div>
<% } %>
<% var _externalUrl = (lecture.type === "link" || lecture.type === "news") && (lecture.newsUrl || "").trim(); %>
<% if (_externalUrl) { %>
<a class="lecture-link lecture-link-external" href="<%= _externalUrl %>" target="_blank" rel="noopener noreferrer">
<% } else { %>
<a class="lecture-link" href="/lectures/<%= lecture.id %>">
<% } %>
<div class="thumb <%= lecture.type %>">
<% if (lecture.type === "ppt") { %>
<% if (lecture.thumbnailUrl) { %>
<img src="<%= lecture.thumbnailUrl %>" alt="<%= lecture.title %> 썸네일" class="thumb-image" />
<% } else { %>
<span class="thumb-fallback">썸네일 <%= lecture.thumbnailStatus || "pending" %></span>
<% } %>
<span class="thumb-kicker">PPT</span>
<% if (lecture.previewTitle && lecture.previewTitle !== "제목 없음") { %><strong><%= lecture.previewTitle %></strong><% } %>
<% } else if (lecture.type === "news") { %>
<% if (lecture.thumbnailUrl) { %>
<img src="<%= lecture.thumbnailUrl %>" alt="" class="thumb-image thumb-image-og" loading="lazy" referrerpolicy="no-referrer" />
<% } %>
<span class="thumb-kicker">뉴스</span>
<% if (!lecture.thumbnailUrl) { %><strong>외부 링크</strong><% } %>
<% } else if (lecture.type === "link") { %>
<% if (lecture.thumbnailUrl) { %>
<img src="<%= lecture.thumbnailUrl %>" alt="" class="thumb-image thumb-image-og" loading="lazy" referrerpolicy="no-referrer" />
<% } %>
<span class="thumb-kicker">웹 링크</span>
<% if (!lecture.thumbnailUrl) { %><strong>외부 페이지</strong><% } %>
<% } else if (lecture.type === "video") { %>
<span class="thumb-fallback thumb-fallback-video">▶</span>
<span class="thumb-kicker">동영상 파일</span>
<strong>업로드 영상</strong>
<% } else { %>
<% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %>
<% if (ytThumb) { %>
<img src="<%= ytThumb %>" alt="<%= lecture.title %> 썸네일" class="thumb-image thumb-image-youtube" />
<% } %>
<span class="thumb-kicker">YouTube</span>
<strong>영상 강의</strong>
<% } %>
</div>
<div class="badge <%= lecture.type %>"><% if (lecture.type === "youtube") { %>YouTube<% } else if (lecture.type === "news") { %>뉴스<% } else if (lecture.type === "link") { %>링크<% } else if (lecture.type === "video") { %>동영상<% } else { %>PPT<% } %></div>
<h3><%= lecture.title %></h3>
<p><%= lecture.description || "" %></p>
<div class="tag-row"><% (lecture.tags || []).forEach((t) => { %><span class="tag-chip">#<%= t %></span><% }) %></div>
<small><%= new Date(lecture.createdAt).toLocaleString("ko-KR") %></small>
</a>
<% if (adminMode && lecture.type === "ppt") { %>
<div class="thumb-state-row">
<span class="state-chip <%= lecture.thumbnailStatus || "pending" %>"><%= lecture.thumbnailStatus || "pending" %></span>
<% if (lecture.thumbnailError) { %><small class="error-text"><%= lecture.thumbnailError %></small><% } %>
</div>
<div class="thumb-state-row" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<form action="/lectures/<%= lecture.id %>/thumbnail/regenerate" method="post" class="delete-form">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<button type="submit" class="ghost">썸네일 재생성</button>
</form>
<form action="/lectures/<%= lecture.id %>/slides/regenerate" method="post" class="delete-form">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<button type="submit" class="ghost">슬라이드 이미지 재생성</button>
</form>
</div>
<% } %>
</article>
<% }) %>
</div>
<% if (pagination.totalPages > 1) { %>
<nav class="pagination">
<% if (pagination.hasPrev) { %><a href="<%= adminBasePath %>?<%= pagination.prevQuery %>">이전</a><% } %>
<% pagination.pages.forEach((p) => { %><a href="<%= adminBasePath %>?<%= p.query %>" class="<%= p.active ? "active" : "" %>"><%= p.page %></a><% }) %>
<% if (pagination.hasNext) { %><a href="<%= adminBasePath %>?<%= pagination.nextQuery %>">다음</a><% } %>
</nav>
<% } %>
<% } %>
</section>
</div>
<% if (adminMode) { %>
<section class="panel admin-logs-panel">
<h2>썸네일 로그</h2>
<div class="admin-inline" style="margin-bottom:8px">
<form action="/thumbnails/retry-failed" method="post" class="admin-inline">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<button type="submit" class="ghost">실패 썸네일 일괄 재시도</button>
</form>
<form action="/thumbnails/events/clear" method="post" class="admin-inline" onsubmit="return confirm('썸네일 이벤트 로그를 모두 삭제하시겠습니까?');">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<button type="submit" class="ghost">초기화</button>
</form>
</div>
<% if (eventsCleared) { %>
<p class="admin-ok">썸네일 이벤트 로그가 초기화되었습니다.</p>
<% } %>
<p class="api-hint">
<a href="/admin/thumbnail-events?token=<%= tokenRaw %>" class="link-muted">이벤트 로그 페이지 열기</a>
</p>
<div class="dashboard-grid">
<section class="mini-panel">
<h3>실패 원인 TOP</h3>
<% if (!failureReasons.length) { %>
<p class="empty">최근 실패 원인이 없습니다.</p>
<% } else { %>
<ul class="plain-list">
<% failureReasons.slice(0, 5).forEach((item) => { %>
<li><b><%= item.count %>회</b> <span><%= item.reason %></span></li>
<% }) %>
</ul>
<% } %>
</section>
<section class="mini-panel">
<h3>최근 썸네일 이벤트</h3>
<% if (!recentEvents.length) { %>
<p class="empty">이벤트 로그가 없습니다.</p>
<% } else { %>
<ul class="plain-list">
<% recentEvents.forEach((evt) => { %>
<li>
<span class="evt-type <%= evt.type %>"><%= evt.type %></span>
<span><%= evt.lectureTitle || evt.lectureId %></span>
<small><%= new Date(evt.at).toLocaleString("ko-KR") %></small>
</li>
<% }) %>
</ul>
<% } %>
</section>
</div>
</section>
<% } %>
</main>
</div>
</div>
</body>
</html>

64
views/learning-edit.ejs Normal file
View File

@@ -0,0 +1,64 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>콘텐츠 수정 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<% var adminBasePath = typeof adminBasePath !== 'undefined' ? adminBasePath : '/admin'; %>
<% var navMenu = typeof navActiveMenu !== 'undefined' ? navActiveMenu : 'learning'; %>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: navMenu }) %>
<div class="content-area">
<header class="topbar">
<h1>콘텐츠 수정</h1>
<a class="top-action-link" href="<%= adminBasePath %>?<%= returnQuery %>">목록으로</a>
</header>
<main class="container">
<section class="panel">
<h2><% if (lecture.type === 'youtube') { %>유튜브<% } else if (lecture.type === 'news') { %>뉴스 URL<% } else if (lecture.type === 'link') { %>웹 링크<% } else if (lecture.type === 'video') { %>동영상 파일<% } else { %>PDF/PPT<% } %> 수정</h2>
<form action="/lectures/<%= lecture.id %>/update" method="post" class="form-grid">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<label>제목 <input type="text" name="title" value="<%= lecture.title %>" required /></label>
<% if (lecture.type === 'youtube') { %>
<label>유튜브 링크 <input type="url" name="youtubeUrl" value="<%= lecture.youtubeUrl || '' %>" placeholder="https://www.youtube.com/watch?v=..." required /></label>
<% } else if (lecture.type === 'news') { %>
<label>뉴스 URL <input type="url" name="newsUrl" value="<%= lecture.newsUrl || '' %>" placeholder="https://..." required /></label>
<% } else if (lecture.type === 'link') { %>
<label>웹 링크 URL <input type="url" name="newsUrl" value="<%= lecture.newsUrl || '' %>" placeholder="https://..." required /></label>
<% } else if (lecture.type === 'video') { %>
<label class="full">동영상 파일 <span class="muted">(변경 불가)</span> <input type="text" value="<%= lecture.originalName || lecture.fileName || '' %>" disabled /></label>
<% } else { %>
<label class="full">PDF/PPT 파일 <span class="muted">(변경 불가)</span> <input type="text" value="<%= lecture.originalName || lecture.fileName || '' %>" disabled /></label>
<% } %>
<label class="full">설명 <textarea name="description" rows="3"><%= lecture.description || '' %></textarea></label>
<label class="full">카테고리
<div class="category-radios-inline">
<%
const cats = ['AX 사고 전환','AI 툴 활용','AI Agent','바이브 코딩'];
const lectureTags = lecture.tags || [];
const cat = cats.find(function(c){ return lectureTags.indexOf(c) >= 0; });
const otherTags = lectureTags.filter(function(t){ return cats.indexOf(t) < 0; });
%>
<label><input type="radio" name="category" value="" <%= !cat ? 'checked' : '' %> />선택 안함</label>
<label><input type="radio" name="category" value="AX 사고 전환" <%= cat === 'AX 사고 전환' ? 'checked' : '' %> />AX 사고 전환</label>
<label><input type="radio" name="category" value="AI 툴 활용" <%= cat === 'AI 툴 활용' ? 'checked' : '' %> />AI 툴 활용</label>
<label><input type="radio" name="category" value="AI Agent" <%= cat === 'AI Agent' ? 'checked' : '' %> />AI Agent</label>
<label><input type="radio" name="category" value="바이브 코딩" <%= cat === '바이브 코딩' ? 'checked' : '' %> />바이브 코딩</label>
</div>
</label>
<label class="full">태그 (쉼표 구분) <input type="text" name="tags" value="<%= otherTags.join(', ') %>" placeholder="예: AI에이전트, 바이브코딩" /></label>
<div class="form-actions">
<button type="submit">저장</button>
<a href="<%= adminBasePath %>?<%= returnQuery %>" class="link-muted">취소</a>
</div>
</form>
</section>
</main>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1 @@
<%- include('partials/lecture-cards') %>

110
views/learning-viewer.ejs Normal file
View File

@@ -0,0 +1,110 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= typeof pageTitle !== 'undefined' ? pageTitle : '학습센터' %> - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<% if (typeof filters === 'undefined') { filters = { q: '', type: 'all', tag: '' }; } %>
<% if (typeof availableTags === 'undefined') { availableTags = []; } %>
<% if (typeof pagination === 'undefined') { pagination = { page: 1, totalPages: 1, totalCount: 0, hasPrev: false, hasNext: false, prevQuery: '', nextQuery: '', pages: [] }; } %>
<% if (typeof lectures === 'undefined') { lectures = []; } %>
<% var viewerBasePath = typeof viewerBasePath !== 'undefined' ? viewerBasePath : '/learning'; %>
<% var learningApiPath = typeof learningApiPath !== 'undefined' ? learningApiPath : '/api/learning/lectures'; %>
<% var adminRegisterHref = typeof adminRegisterHref !== 'undefined' ? adminRegisterHref : '/admin'; %>
<% var navMenu = typeof navActiveMenu !== 'undefined' ? navActiveMenu : 'learning'; %>
<% var pTitle = typeof pageTitle !== 'undefined' ? pageTitle : '학습센터'; %>
<% var hTitle = typeof heroTitle !== 'undefined' ? heroTitle : '최신 컨텐츠로 학습하고, 바로 업무에 적용하세요.'; %>
<% var hDesc = typeof heroDesc !== 'undefined' ? heroDesc : '유튜브·PPT·동영상 파일·웹 링크를 등록한 뒤, 목록에서 클릭하여 강의를 시청하거나 외부 자료를 열 수 있습니다.'; %>
<% var listHeading = typeof sectionListHeading !== 'undefined' ? sectionListHeading : '등록된 강의'; %>
<% var filterTitle = typeof filterPanelTitle !== 'undefined' ? filterPanelTitle : '강의 검색/필터'; %>
<% var _opsLoggedIn = typeof opsUserEmail !== 'undefined' && opsUserEmail; %>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: navMenu }) %>
<div class="content-area">
<header class="topbar">
<h1><%= pTitle %></h1>
<% if (typeof adminMode !== 'undefined' && adminMode && !_opsLoggedIn) { %>
<a href="<%= adminRegisterHref %>" class="top-action-link">콘텐츠 등록하기</a>
<% } %>
</header>
<main class="container">
<section class="hero panel">
<h2><%= hTitle %></h2>
<p><%= hDesc %></p>
</section>
<section class="panel filter-panel">
<h2><%= filterTitle %></h2>
<form action="<%= viewerBasePath %>" method="get" class="filter-grid">
<label>
검색어
<input type="text" name="q" id="learning-filter-q" value="<%= filters.q %>" placeholder="제목·설명·미리보기·파일명 (클로드↔claude)" autocomplete="off" />
</label>
<label>
타입
<select name="type">
<option value="all" <%= filters.type === "all" ? "selected" : "" %>>전체</option>
<option value="youtube" <%= filters.type === "youtube" ? "selected" : "" %>>YouTube</option>
<option value="ppt" <%= filters.type === "ppt" ? "selected" : "" %>>PPT/PDF</option>
<option value="video" <%= filters.type === "video" ? "selected" : "" %>>동영상 파일</option>
<option value="link" <%= filters.type === "link" ? "selected" : "" %>>웹 링크</option>
<option value="news" <%= filters.type === "news" ? "selected" : "" %>>뉴스 URL</option>
</select>
</label>
<label>
태그
<select name="tag">
<option value="">전체</option>
<% (availableTags || []).forEach((oneTag) => { %>
<option value="<%= oneTag %>" <%= filters.tag === oneTag ? "selected" : "" %>><%= oneTag %></option>
<% }) %>
</select>
</label>
<div class="filter-row-bottom">
<div class="category-radios" id="category-filter">
<label><input type="radio" name="category" value="" <%= !filters.category ? 'checked' : '' %> />전체</label>
<label><input type="radio" name="category" value="AX 사고 전환" <%= filters.category === 'AX 사고 전환' ? 'checked' : '' %> />AX 사고 전환</label>
<label><input type="radio" name="category" value="AI 툴 활용" <%= filters.category === 'AI 툴 활용' ? 'checked' : '' %> />AI 툴 활용</label>
<label><input type="radio" name="category" value="AI Agent" <%= filters.category === 'AI Agent' ? 'checked' : '' %> />AI Agent</label>
<label><input type="radio" name="category" value="바이브 코딩" <%= filters.category === '바이브 코딩' ? 'checked' : '' %> />바이브 코딩</label>
</div>
<div class="filter-actions">
<button type="submit">필터 적용</button>
<a class="link-muted" href="<%= viewerBasePath %>">초기화</a>
</div>
</div>
</form>
</section>
<section class="panel">
<div class="section-head">
<h2><%= listHeading %></h2>
<span class="count-chip">총 <span id="lecture-total-count"><%= pagination.totalCount %></span>건</span>
</div>
<div id="lecture-results-root">
<% if (!lectures.length) { %>
<p class="empty" id="lecture-empty-msg">등록된 항목이 없습니다.</p>
<% } else { %>
<div class="lecture-grid" id="lecture-grid">
<%- include('partials/lecture-cards') %>
</div>
<% if (pagination.hasNext) { %>
<div id="lecture-infinite-footer">
<div id="infinite-scroll-sentinel" class="infinite-scroll-sentinel" data-next-page="<%= pagination.page + 1 %>" data-has-next="true"></div>
<p class="infinite-scroll-loading" id="infinite-scroll-loading" style="display:none;text-align:center;padding:16px;color:#666;">불러오는 중...</p>
<div class="lecture-load-more-wrap" id="lecture-load-more-wrap">
<button type="button" class="lecture-load-more-btn" id="lecture-load-more-btn">더 불러오기</button>
</div>
</div>
<% } %>
<% } %>
</div>
</section>
</main>
</div>
</div>
<div id="lecture-page-config" data-learning-api="<%= learningApiPath %>" data-viewer-base="<%= viewerBasePath %>" hidden aria-hidden="true"></div>
<script src="/public/js/learning-infinite.js" defer></script>
</body>
</html>

31
views/lecture-link.ejs Normal file
View File

@@ -0,0 +1,31 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= lecture.title %> - 학습센터 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'learning' }) %>
<div class="content-area">
<header class="topbar">
<h1><%= lecture.title %></h1>
<a class="top-action-link" href="/learning">목록으로</a>
</header>
<main class="container">
<section class="panel">
<% if (lecture.description) { %>
<p class="lecture-news-desc"><%= lecture.description %></p>
<% } %>
<p class="lecture-news-actions">
<a href="<%= lecture.newsUrl %>" target="_blank" rel="noopener noreferrer" class="top-action-link lecture-link-open">링크 열기 (새 탭)</a>
</p>
<p class="muted" style="font-size:13px;word-break:break-all;">URL: <%= lecture.newsUrl %></p>
</section>
</main>
</div>
</div>
</body>
</html>

31
views/lecture-news.ejs Normal file
View File

@@ -0,0 +1,31 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= lecture.title %> - 학습센터 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'learning' }) %>
<div class="content-area">
<header class="topbar">
<h1><%= lecture.title %></h1>
<a class="top-action-link" href="/learning">목록으로</a>
</header>
<main class="container">
<section class="panel">
<% if (lecture.description) { %>
<p class="lecture-news-desc"><%= lecture.description %></p>
<% } %>
<p class="lecture-news-actions">
<a href="<%= lecture.newsUrl %>" target="_blank" rel="noopener noreferrer" class="top-action-link">기사·원문 열기 (새 탭)</a>
</p>
<p class="muted" style="font-size:13px;word-break:break-all;">URL: <%= lecture.newsUrl %></p>
</section>
</main>
</div>
</div>
</body>
</html>

57
views/lecture-ppt.ejs Normal file
View File

@@ -0,0 +1,57 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= lecture.title %> - PPT 뷰어</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<% if (typeof slideImageUrls === 'undefined') { slideImageUrls = []; } %>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'learning' }) %>
<div class="content-area">
<main class="viewer-wrap">
<a href="/learning" class="back-link">← 학습센터로 돌아가기</a>
<h1><%= lecture.title %></h1>
<p class="description"><%= lecture.description || "설명이 없습니다." %></p>
<div class="ppt-tools">
<span>총 <b><%= slides.length %></b>장</span>
</div>
<% if (typeof slidesError !== 'undefined' && slidesError && (!slideImageUrls || slideImageUrls.length === 0)) { %>
<p class="admin-warn">슬라이드 이미지 생성에 실패했습니다. LibreOffice가 설치되어 있는지 확인하세요. (macOS: <code>brew install --cask libreoffice</code>)</p>
<% } %>
<% if (!slides.length) { %>
<p class="empty">슬라이드 내용을 불러올 수 없습니다.</p>
<% } %>
<section class="slide-list">
<% slides.forEach((slide, index) => { %>
<article class="slide-card">
<header>
<h2>슬라이드 <%= index + 1 %></h2>
<% if (slide.title) { %><p><%= slide.title %></p><% } %>
</header>
<% if (slideImageUrls[index]) { %>
<div class="slide-image-wrap">
<img src="<%= slideImageUrls[index] %>" alt="슬라이드 <%= index + 1 %>" class="slide-image" />
</div>
<% } %>
<% if (slide.lines && slide.lines.length > 0) { %>
<ul>
<% slide.lines.forEach((line) => { %>
<li><%= line %></li>
<% }) %>
</ul>
<% } %>
</article>
<% }) %>
</section>
</main>
</div>
</div>
</body>
</html>

32
views/lecture-video.ejs Normal file
View File

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= lecture.title %> - 동영상 강의</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'learning' }) %>
<div class="content-area">
<main class="viewer-wrap">
<a href="/learning" class="back-link">← 학습센터로 돌아가기</a>
<h1><%= lecture.title %></h1>
<p class="description"><%= lecture.description || "설명이 없습니다." %></p>
<div class="lecture-video-wrap">
<video
class="lecture-video-player"
controls
playsinline
preload="metadata"
src="<%= videoSrc %>"
>
이 브라우저는 HTML5 동영상 재생을 지원하지 않습니다.
</video>
</div>
</main>
</div>
</div>
</body>
</html>

30
views/lecture-youtube.ejs Normal file
View File

@@ -0,0 +1,30 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= lecture.title %> - YouTube 강의</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'learning' }) %>
<div class="content-area">
<main class="viewer-wrap">
<a href="/learning" class="back-link">← 학습센터로 돌아가기</a>
<h1><%= lecture.title %></h1>
<p class="description"><%= lecture.description || "설명이 없습니다." %></p>
<div class="youtube-frame">
<iframe
src="<%= embedUrl %>"
title="<%= lecture.title %>"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
</main>
</div>
</div>
</body>
</html>

236
views/login.ejs Normal file
View File

@@ -0,0 +1,236 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>서비스 접속 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
<style>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: linear-gradient(160deg, #f0f4ff 0%, #f3f4f7 45%, #eef2f7 100%);
}
.login-card {
width: 100%;
max-width: 420px;
background: #fff;
border-radius: 16px;
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.1);
padding: 32px 28px 28px;
border: 1px solid #e5e7eb;
}
.login-card .logo-img {
max-width: 200px;
margin: 0 auto 20px;
}
.login-card h1 {
margin: 0 0 8px;
font-size: 1.35rem;
color: #111827;
text-align: center;
}
.login-lead {
margin: 0 0 20px;
font-size: 14px;
color: #4b5563;
line-height: 1.6;
text-align: center;
}
.login-steps {
margin: 0 0 22px;
padding: 14px 16px;
background: #f8fafc;
border-radius: 10px;
font-size: 13px;
color: #374151;
line-height: 1.65;
}
.login-steps ol {
margin: 0;
padding-left: 1.25rem;
}
.login-steps li {
margin-bottom: 6px;
}
.login-steps li:last-child {
margin-bottom: 0;
}
.login-form label {
display: block;
font-size: 13px;
font-weight: 600;
color: #374151;
margin-bottom: 6px;
}
.login-form input[type="email"] {
width: 100%;
padding: 11px 12px;
border: 1px solid #d1d5db;
border-radius: 10px;
font-size: 15px;
}
.login-form input[type="email"]:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.login-form .btn-verify {
width: 100%;
margin-top: 16px;
padding: 12px 16px;
border: none;
border-radius: 10px;
background: #2563eb;
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.login-form .btn-verify:hover {
background: #1d4ed8;
}
.login-form .btn-verify:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.login-hint {
margin-top: 14px;
font-size: 12px;
color: #6b7280;
text-align: center;
}
.login-msg {
margin-top: 12px;
font-size: 13px;
text-align: center;
min-height: 1.2em;
}
.login-msg.ok {
color: #166534;
}
.login-msg.err {
color: #b91c1c;
}
</style>
</head>
<body>
<div class="login-page">
<div class="login-card">
<div class="login-logo-stack" style="display: flex; flex-direction: column; align-items: center; gap: 8px; margin-bottom: 12px">
<a
href="https://xavis.co.kr"
class="logo-link"
target="_blank"
rel="noopener noreferrer"
aria-label="XAVIS 회사 사이트(새 탭)"
style="margin-bottom: 0"
><img src="/public/images/xavis-logo.png" alt="XAVIS" class="logo-img logo-img-xavis"
/></a>
<img
src="/public/images/aiplatform-logo.png"
alt="AI PLATFORM"
class="logo-img"
style="cursor: default; margin-bottom: 0"
width="168"
height="auto"
decoding="async"
/>
</div>
<h1>서비스 접속</h1>
<p class="login-lead">회사 이메일로 본인 확인 후 서비스를 이용할 수 있습니다.</p>
<div class="login-steps">
<ol>
<li>아래에 <strong>@xavis.co.kr</strong> 이메일을 입력하고 <strong>검증</strong>을 누릅니다.</li>
<li>해당 메일함으로 전송된 <strong>인증 링크</strong>를 엽니다.</li>
<li>인증이 완료되면 바로 서비스 화면으로 이동합니다.</li>
</ol>
</div>
<form class="login-form" id="opsLoginForm" novalidate>
<input type="hidden" name="returnTo" id="returnTo" value="<%= typeof returnTo !== 'undefined' ? returnTo : '/learning' %>" />
<label for="opsEmail">회사 이메일</label>
<input
type="email"
id="opsEmail"
name="email"
required
autocomplete="email"
placeholder="name@xavis.co.kr"
inputmode="email"
/>
<button type="submit" class="btn-verify" id="opsVerifyBtn">검증</button>
<p class="login-msg" id="opsLoginMsg" role="status" aria-live="polite"></p>
<p class="login-hint">허용 도메인: @xavis.co.kr 만 가능합니다.</p>
</form>
</div>
</div>
<script>
(function () {
var form = document.getElementById("opsLoginForm");
var btn = document.getElementById("opsVerifyBtn");
var msg = document.getElementById("opsLoginMsg");
var emailEl = document.getElementById("opsEmail");
var returnToEl = document.getElementById("returnTo");
if (!form) return;
form.addEventListener("submit", function (e) {
e.preventDefault();
if (msg) {
msg.textContent = "";
msg.className = "login-msg";
}
var email = (emailEl && emailEl.value) ? emailEl.value.trim() : "";
if (!email) {
if (msg) {
msg.textContent = "이메일을 입력해 주세요.";
msg.className = "login-msg err";
}
return;
}
if (btn) btn.disabled = true;
fetch("/api/auth/request-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: email,
returnTo: (returnToEl && returnToEl.value) || "/learning",
}),
})
.then(function (r) {
return r.json().then(function (j) {
return { ok: r.ok, j: j };
});
})
.then(function (x) {
if (x.ok) {
if (msg) {
msg.textContent = x.j.message || "메일을 확인해 주세요.";
msg.className = "login-msg ok";
}
} else {
var err = (x.j && x.j.error) || "요청에 실패했습니다.";
if (msg) {
msg.textContent = err;
msg.className = "login-msg err";
}
if (err.indexOf("허용된 임직원") !== -1) {
alert("허용된 임직원이 아닙니다.");
}
}
})
.catch(function () {
if (msg) {
msg.textContent = "네트워크 오류가 발생했습니다.";
msg.className = "login-msg err";
}
})
.finally(function () {
if (btn) btn.disabled = false;
});
});
})();
</script>
</body>
</html>

780
views/meeting-minutes.ejs Normal file
View File

@@ -0,0 +1,780 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>회의록 AI - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body class="meeting-minutes-page">
<% var mmDefaultCustomInstructions = `아래 회의 내용(또는 녹취/메모)을 바탕으로 다음 형식으로 정리해 주세요.
원문·전사 전체를 회의록에 다시 붙여 넣지 마세요. ‘스크립트’·‘스크랩트’(오타)·‘원문 전사’ 같은 섹션은 만들지 말고, 요약·결정·액션·체크리스트만 작성하세요. 회의 제목·참석자·요약 등은 ## 마크다운 제목으로 구분하세요.
1) 회의 개요: 일시, 참석자(알 수 있는 경우), 목적
2) 논의 안건별 요약
3) 결정 사항 (명확한 문장으로)
4) 액션 아이템: 별도 섹션. 각 항목에 What(할 일)·Who(담당자)·When(기한)을 구체적으로
5) 회의 체크리스트: 전·중·후 준비·검토 항목을 [ ]로 (완료 여부 표시 가능하게)
체크리스트·액션 아이템 다음에 "추가 메모 / 확인 필요 사항" 같은 말미 섹션이나 CSV·템플릿 제안은 넣지 마세요.
체크리스트를 마지막 섹션으로 두고, 시연·피드백 회신 방식, 우선순위 재정렬·담당·기한 확정, DRM·후보군 추가 작성 제안 등 운영 안내 문단은 붙이지 마세요.
‘추가 권고’, ‘회의록 작성자의 제안’, 엑셀 템플릿·추적 체크리스트 제안 등은 넣지 마세요.`; %>
<% var mmNow = new Date(); var mmTodayIso = mmNow.getFullYear() + '-' + ('0' + (mmNow.getMonth() + 1)).slice(-2) + '-' + ('0' + mmNow.getDate()).slice(-2); %>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'ai-explore', adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %>
<div class="content-area">
<header class="topbar">
<h1>회의록 AI</h1>
<a class="top-action-link" href="/ai-explore">AI 목록</a>
</header>
<main class="container meeting-minutes-main">
<a href="/ai-explore" class="prompts-back" aria-label="AI 탐색으로 돌아가기">← AI</a>
<% var hasEmail = typeof meetingUserEmail !== 'undefined' && meetingUserEmail; %>
<% if (!hasEmail) { %>
<p class="chat-api-warning" style="margin-bottom: 16px">
이메일 인증(OPS <code>PROD</code>) 또는 DEV에서 <strong>관리자 모드</strong>, SUPER(데모)에서는 별도 로그인 없이 이용할 수 있습니다. (DEV 테스트: <code>MEETING_DEV_EMAIL</code>)
</p>
<% } %>
<div class="mm-layout">
<aside class="mm-sidebar panel">
<h2 class="mm-sidebar-title">내 회의록</h2>
<button type="button" class="btn-ghost mm-refresh" id="mmListRefresh" <%= hasEmail ? '' : 'disabled' %>>새로고침</button>
<ul class="mm-meeting-list" id="mmMeetingList" role="list"></ul>
<p class="mm-list-empty" id="mmListEmpty" hidden>저장된 회의록이 없습니다.</p>
</aside>
<div class="mm-workspace">
<section class="panel mm-prompt-panel mm-prompt-collapsed" id="mmPromptPanel">
<div class="mm-prompt-panel-head">
<h2 id="mmPromptHeading">출력 형식 (프롬프트)</h2>
<button type="button" class="mm-prompt-toggle" id="mmPromptToggle" aria-expanded="false" aria-controls="mmPromptBody" aria-labelledby="mmPromptHeading" title="펼치기·접기">
<span class="mm-prompt-toggle-icon" aria-hidden="true">▼</span>
</button>
</div>
<div class="mm-prompt-body" id="mmPromptBody" hidden>
<p class="subtitle">회의록에 포함할 항목을 선택하고, 추가 지시를 입력할 수 있습니다. 시스템 프롬프트에 액션 아이템(What·Who·When)과 회의 체크리스트(전·중·후) 정의가 포함되며, 체크리스트는 업무 체크리스트 AI 연동을 위해 항상 포함됩니다.</p>
<div class="mm-prompt-form" id="mmPromptForm">
<div class="mm-checkbox-row" role="group" aria-label="회의록에 포함할 항목">
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncTitle" checked /> <span>제목 한 줄</span></label>
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncAtt" checked /> <span>참석자</span></label>
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncSum" checked /> <span>요약</span></label>
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncAct" checked /> <span>Action Item</span></label>
<label class="mm-checkbox-item mm-checkbox-item-locked" title="업무 체크리스트 AI에서 활용되므로 항상 켜져 있습니다.">
<input type="checkbox" id="mmIncChk" checked disabled aria-checked="true" aria-disabled="true" />
<span>체크리스트 <span class="mm-checkbox-badge">필수</span></span>
</label>
</div>
<div class="mm-custom-block">
<label class="mm-field-label" for="mmCustomInstr">추가 지시</label>
<textarea id="mmCustomInstr" class="mm-textarea mm-custom-instr-textarea" rows="9" placeholder=""><%= mmDefaultCustomInstructions %></textarea>
</div>
<div class="mm-prompt-actions">
<button type="button" class="top-action" id="mmSavePrompt" <%= hasEmail ? '' : 'disabled' %>>프롬프트 저장</button>
</div>
</div>
</div>
</section>
<section class="panel">
<div class="mm-tabs" role="tablist">
<button type="button" class="mm-tab is-active" id="mmTabText" data-tab="text" role="tab" aria-selected="true" aria-controls="mmPanelText">텍스트 입력</button>
<button type="button" class="mm-tab" id="mmTabAudio" data-tab="audio" role="tab" aria-selected="false" aria-controls="mmPanelAudio">음성 파일</button>
</div>
<div class="mm-tab-panel mm-field-stack" id="mmPanelText" role="tabpanel" aria-labelledby="mmTabText" aria-hidden="false">
<div class="mm-field mm-title-date-field">
<div class="mm-title-date-labels">
<span class="mm-field-label">제목</span>
<span class="mm-field-label">날짜</span>
</div>
<div class="mm-title-date-box">
<input type="text" id="mmTitleText" class="mm-input mm-title-input" placeholder="회의 제목" maxlength="500" <%= hasEmail ? '' : 'disabled' %> />
<div class="mm-date-wrap">
<input type="date" id="mmDateText" class="mm-date-native" value="<%= mmTodayIso %>" <%= hasEmail ? '' : 'disabled' %> />
<button type="button" class="mm-date-trigger" id="mmDateTextBtn" <%= hasEmail ? '' : 'disabled' %> aria-label="달력 열기">▼</button>
</div>
</div>
</div>
<label class="mm-field">
<span class="mm-field-label">회의 원문</span>
<textarea id="mmSourceText" class="mm-textarea" rows="12" placeholder="회의 내용을 붙여 넣거나 입력하세요." <%= hasEmail ? '' : 'disabled' %>></textarea>
</label>
<label class="mm-field mm-field-narrow">
<span class="mm-field-label">회의록 생성 모델</span>
<select id="mmModelText" class="mm-select" <%= hasEmail ? '' : 'disabled' %>>
<option value="gpt-5-mini" selected>gpt-5-mini</option>
<option value="gpt-5.4">gpt-5.4</option>
</select>
</label>
<div class="form-actions mm-form-actions">
<button type="button" class="top-action" id="mmGenText" <%= hasEmail ? '' : 'disabled' %>>회의록 생성</button>
</div>
</div>
<div class="mm-tab-panel mm-field-stack mm-audio-panel" id="mmPanelAudio" role="tabpanel" aria-labelledby="mmTabAudio" aria-hidden="true" hidden>
<div class="mm-audio-phase">
<h3 class="mm-section-heading">1. 음성 전사</h3>
<label class="mm-field">
<span class="mm-field-label">음성 파일</span>
<input type="file" id="mmAudioFile" class="mm-file-input" accept=".mp3,.mp4,.mpeg,.mpga,.m4a,.wav,.webm,.ogg,.flac" <%= hasEmail ? '' : 'disabled' %> />
</label>
<p class="mm-audio-hint">지원 포맷: mp3, m4a, wav 등. 파일당 최대 300MB입니다.</p>
<label class="mm-field mm-transcribe-model-only">
<span class="mm-field-label">전사 모델 (OpenAI)</span>
<select id="mmWhisperModel" class="mm-select" <%= hasEmail ? '' : 'disabled' %> title="음성→텍스트 API 모델">
<option value="gpt-4o-mini-transcribe" selected>gpt-4o-mini-transcribe (기본)</option>
<option value="gpt-4o-transcribe">gpt-4o-transcribe (고성능)</option>
</select>
<span class="mm-field-help">OpenAI 전사 API 공식 모델 ID와 동일합니다. 기본은 mini, 더 높은 인식 품질이 필요하면 gpt-4o-transcribe를 선택하세요.</span>
</label>
</div>
<div class="mm-audio-phase">
<h3 class="mm-section-heading">2. 회의록 작성</h3>
<div class="mm-field mm-title-date-field">
<div class="mm-title-date-labels">
<span class="mm-field-label">제목</span>
<span class="mm-field-label">날짜</span>
</div>
<div class="mm-title-date-box">
<input type="text" id="mmTitleAudio" class="mm-input mm-title-input" placeholder="회의 제목" maxlength="500" <%= hasEmail ? '' : 'disabled' %> />
<div class="mm-date-wrap">
<input type="date" id="mmDateAudio" class="mm-date-native" value="<%= mmTodayIso %>" <%= hasEmail ? '' : 'disabled' %> />
<button type="button" class="mm-date-trigger" id="mmDateAudioBtn" <%= hasEmail ? '' : 'disabled' %> aria-label="달력 열기">▼</button>
</div>
</div>
</div>
<label class="mm-field mm-field-narrow">
<span class="mm-field-label">회의록 생성 모델</span>
<select id="mmModelAudio" class="mm-select" <%= hasEmail ? '' : 'disabled' %>>
<option value="gpt-5-mini" selected>gpt-5-mini</option>
<option value="gpt-5.4">gpt-5.4</option>
</select>
</label>
<p class="mm-audio-summary-hint">전사된 텍스트를 아래 프롬프트(출력 형식)에 맞게 요약·정리합니다.</p>
<div class="form-actions mm-form-actions">
<button type="button" class="top-action" id="mmGenAudio" <%= hasEmail ? '' : 'disabled' %>>전사 및 회의록 생성</button>
</div>
</div>
</div>
</section>
<div id="mmGenProgress" class="mm-gen-progress" hidden role="status" aria-live="polite" aria-busy="false">
<div class="mm-gen-progress-track" aria-hidden="true">
<div class="mm-gen-progress-bar"></div>
</div>
<p class="mm-gen-progress-msg" id="mmGenProgressMsg">처리 중…</p>
</div>
<section class="panel mm-result-panel" id="mmResultSection" hidden>
<div class="mm-result-head">
<h2 class="mm-result-title">생성 결과</h2>
<button type="button" class="top-action" id="mmSaveResult" disabled>저장</button>
</div>
<p class="mm-result-hint">
생성 결과에서 <strong>회의록</strong>을 수정한 뒤 <strong>저장</strong>하면 DB에 반영되며, 회의록 기준으로 액션 아이템·체크리스트가 업무 체크리스트 AI에 다시 연동됩니다. 음성 전사가 있는 회의는 <strong>음성 파일</strong> 탭을 선택하면 아래 <strong>전사 기록</strong>도 함께 수정할 수 있습니다. 텍스트 입력으로 만든 회의는 위쪽 <strong>회의 원문</strong>에서 원문을 고칩니다.
</p>
<div class="mm-result-split">
<div id="mmTranscriptPane" class="mm-result-transcript-pane" hidden aria-hidden="true">
<label class="mm-result-field">
<span class="mm-result-field-label" id="mmTranscriptLabel">전사 기록</span>
<textarea
id="mmTranscriptBody"
class="mm-result-body mm-result-textarea mm-result-textarea-half mm-transcript-textarea"
rows="14"
spellcheck="false"
placeholder="음성 전사 텍스트가 여기에 표시됩니다."
></textarea>
</label>
</div>
<div class="mm-result-field">
<div class="mm-minutes-header">
<span class="mm-result-field-label">회의록</span>
<div class="mm-minutes-actions" id="mmMinutesActionsView" role="toolbar" aria-label="회의록 보기">
<button type="button" class="btn-ghost mm-minutes-edit" id="mmMinutesEdit">마크다운 편집</button>
</div>
<div class="mm-minutes-actions" id="mmMinutesActionsEdit" role="toolbar" aria-label="회의록 편집" hidden>
<button type="button" class="top-action mm-minutes-apply" id="mmMinutesApply">저장</button>
<button type="button" class="btn-ghost mm-minutes-cancel" id="mmMinutesCancel">취소</button>
</div>
</div>
<div class="mm-minutes-editor-wrap">
<div
id="mmMinutesRendered"
class="mm-minutes-rendered mm-minutes-rendered-empty"
role="region"
aria-label="회의록 (마크다운 렌더링)"
tabindex="0"
>
회의록이 없습니다. 마크다운 편집으로 내용을 입력하세요.
</div>
<textarea
id="mmResultBody"
class="mm-result-body mm-result-textarea mm-result-textarea-half mm-minutes-source"
rows="14"
spellcheck="false"
hidden
placeholder="마크다운으로 회의록을 편집합니다. 「저장」으로 뷰로 돌아가거나 「취소」로 편집 전 내용으로 되돌립니다."
></textarea>
</div>
</div>
</div>
</section>
</div>
</div>
</main>
</div>
</div>
<script src="/vendor/marked/marked.umd.js"></script>
<script src="/vendor/dompurify/purify.min.js"></script>
<script>
(function () {
var hasEmail = <%= hasEmail ? 'true' : 'false' %>;
if (!hasEmail) return;
var listEl = document.getElementById('mmMeetingList');
var emptyEl = document.getElementById('mmListEmpty');
var resultSection = document.getElementById('mmResultSection');
var resultBody = document.getElementById('mmResultBody');
var minutesRenderedEl = document.getElementById('mmMinutesRendered');
var minutesActionsView = document.getElementById('mmMinutesActionsView');
var minutesActionsEdit = document.getElementById('mmMinutesActionsEdit');
var minutesEditBtn = document.getElementById('mmMinutesEdit');
var minutesApplyBtn = document.getElementById('mmMinutesApply');
var minutesCancelBtn = document.getElementById('mmMinutesCancel');
/** 편집 모드 진입 시점의 회의록 원문(취소 시 복원) */
var minutesEditSnapshot = '';
var transcriptBody = document.getElementById('mmTranscriptBody');
var transcriptLabel = document.getElementById('mmTranscriptLabel');
/** true: 음성 전사(transcript_text) 편집, false: 텍스트 회의 원문(source_text) 편집 */
var lastMeetingTranscriptIsAudio = false;
var saveResultBtn = document.getElementById('mmSaveResult');
var genProgressEl = document.getElementById('mmGenProgress');
var genProgressMsg = document.getElementById('mmGenProgressMsg');
var currentMeetingId = null;
function escapeHtmlMm(s) {
var div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
var minutesMarkdownConfigured = false;
function configureMinutesMarkdown() {
if (minutesMarkdownConfigured) return;
minutesMarkdownConfigured = true;
if (typeof marked !== 'undefined') {
marked.setOptions({ async: false, breaks: true, gfm: true });
}
var domPurify = typeof window !== 'undefined' && window.DOMPurify ? window.DOMPurify : typeof DOMPurify !== 'undefined' ? DOMPurify : null;
if (domPurify) {
domPurify.addHook('afterSanitizeAttributes', function (node) {
if (node.tagName === 'A' && node.hasAttribute('href')) {
var href = node.getAttribute('href');
try {
var u = new URL(href, window.location.href);
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
node.removeAttribute('href');
return;
}
} catch (e) {
node.removeAttribute('href');
return;
}
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
}
}
function getDomPurify() {
return typeof window !== 'undefined' && window.DOMPurify
? window.DOMPurify
: typeof DOMPurify !== 'undefined'
? DOMPurify
: null;
}
function getMarkedParseFn() {
if (typeof marked === 'undefined') return null;
if (typeof marked.parse === 'function') return marked.parse.bind(marked);
if (typeof marked === 'function') return marked;
return null;
}
function renderMinutesMarkdown(text) {
configureMinutesMarkdown();
var src = String(text || '');
var parseFn = getMarkedParseFn();
var purify = getDomPurify();
if (!parseFn || !purify) {
return escapeHtmlMm(src).replace(/\n/g, '<br>');
}
try {
var raw = parseFn(src, { async: false });
if (raw != null && typeof raw.then === 'function') {
return escapeHtmlMm(src).replace(/\n/g, '<br>');
}
return purify.sanitize(String(raw || ''), { USE_PROFILES: { html: true } });
} catch (err) {
return escapeHtmlMm(src).replace(/\n/g, '<br>');
}
}
function refreshMinutesRendered() {
if (!minutesRenderedEl) return;
var raw = resultBody ? resultBody.value : '';
if (!String(raw).trim()) {
minutesRenderedEl.classList.add('mm-minutes-rendered-empty');
minutesRenderedEl.innerHTML = '회의록이 없습니다. 마크다운 편집으로 내용을 입력하세요.';
return;
}
minutesRenderedEl.classList.remove('mm-minutes-rendered-empty');
minutesRenderedEl.innerHTML = renderMinutesMarkdown(raw);
}
function setMinutesToolbarMode(editing) {
if (minutesActionsView) minutesActionsView.hidden = !!editing;
if (minutesActionsEdit) minutesActionsEdit.hidden = !editing;
}
function setMinutesViewMode(showSource) {
if (!resultBody || !minutesRenderedEl) return;
if (!showSource) {
refreshMinutesRendered();
resultBody.hidden = true;
minutesRenderedEl.hidden = false;
setMinutesToolbarMode(false);
} else {
minutesRenderedEl.hidden = true;
resultBody.hidden = false;
setMinutesToolbarMode(true);
try {
resultBody.focus();
} catch (e) {}
}
}
if (minutesEditBtn) {
minutesEditBtn.addEventListener('click', function () {
if (!resultBody) return;
minutesEditSnapshot = resultBody.value;
setMinutesViewMode(true);
});
}
if (minutesApplyBtn) {
minutesApplyBtn.addEventListener('click', function () {
setMinutesViewMode(false);
});
}
if (minutesCancelBtn) {
minutesCancelBtn.addEventListener('click', function () {
if (resultBody) resultBody.value = minutesEditSnapshot;
setMinutesViewMode(false);
});
}
/** 음성 파일 탭일 때만 생성 결과의 전사 기록 영역 표시(텍스트 입력은 회의 원문과 중복이므로 숨김) */
function applyTranscriptPaneVisibility() {
var pane = document.getElementById('mmTranscriptPane');
var audioTab = document.getElementById('mmTabAudio');
if (!pane || !audioTab) return;
var show = audioTab.classList.contains('is-active');
pane.hidden = !show;
pane.setAttribute('aria-hidden', show ? 'false' : 'true');
}
function setCurrentMeetingId(id) {
currentMeetingId = id || null;
if (saveResultBtn) saveResultBtn.disabled = !currentMeetingId;
}
function setMeetingGenerating(on, msg) {
if (genProgressEl) {
genProgressEl.hidden = !on;
genProgressEl.setAttribute('aria-busy', on ? 'true' : 'false');
if (on) genProgressEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
if (genProgressMsg && msg) genProgressMsg.textContent = msg;
var gText = document.getElementById('mmGenText');
var gAudio = document.getElementById('mmGenAudio');
if (gText) gText.disabled = !!on;
if (gAudio) gAudio.disabled = !!on;
if (saveResultBtn) saveResultBtn.disabled = !!on || !currentMeetingId;
if (resultBody) resultBody.disabled = !!on;
if (transcriptBody) transcriptBody.disabled = !!on;
if (minutesEditBtn) minutesEditBtn.disabled = !!on;
if (minutesApplyBtn) minutesApplyBtn.disabled = !!on;
if (minutesCancelBtn) minutesCancelBtn.disabled = !!on;
if (on && resultBody && minutesRenderedEl) {
if (!resultBody.hidden) {
refreshMinutesRendered();
setMinutesViewMode(false);
}
}
}
function api(path, opts) {
return fetch(path, Object.assign({ credentials: 'same-origin' }, opts || {})).then(function (r) {
var ct = (r.headers.get('content-type') || '').toLowerCase();
if (ct.indexOf('application/json') === -1) {
return r.text().then(function (t) {
var msg = (t || '').replace(/\s+/g, ' ').trim().slice(0, 200);
throw new Error(msg || 'HTTP ' + r.status + ' ' + (r.statusText || ''));
});
}
return r.json().then(function (j) {
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
});
});
}
var MM_DEFAULT_CUSTOM_INSTRUCTIONS = <%- JSON.stringify(mmDefaultCustomInstructions) %>;
function loadPrompt() {
return api('/api/meeting-minutes/prompt').then(function (d) {
var p = d.prompt || {};
document.getElementById('mmIncTitle').checked = p.includeTitleLine !== false;
document.getElementById('mmIncAtt').checked = p.includeAttendees !== false;
document.getElementById('mmIncSum').checked = p.includeSummary !== false;
document.getElementById('mmIncAct').checked = p.includeActionItems !== false;
document.getElementById('mmIncChk').checked = true;
var saved = (p.customInstructions && String(p.customInstructions).trim()) || '';
document.getElementById('mmCustomInstr').value = saved || MM_DEFAULT_CUSTOM_INSTRUCTIONS;
});
}
function loadMeetings() {
return api('/api/meeting-minutes/meetings').then(function (d) {
var list = d.meetings || [];
listEl.innerHTML = '';
if (!list.length) {
emptyEl.hidden = false;
return;
}
emptyEl.hidden = true;
list.forEach(function (m) {
var li = document.createElement('li');
li.className = 'mm-meeting-item';
var t = document.createElement('button');
t.type = 'button';
t.className = 'mm-meeting-item-btn';
var title = (m.title || '제목 없음').slice(0, 60);
var md = m.meetingDate ? String(m.meetingDate).slice(0, 10) : '';
var mdKo = '';
if (md && md.length >= 10) {
var p = md.split('-');
if (p.length === 3) mdKo = p[0] + '. ' + p[1] + '. ' + p[2] + '.';
}
var dt = m.createdAt ? new Date(m.createdAt).toLocaleString('ko-KR') : '';
t.textContent = title + (mdKo ? ' · ' + mdKo : '') + (dt ? ' · ' + dt : '');
t.addEventListener('click', function () {
showMeeting(m.id);
});
var del = document.createElement('button');
del.type = 'button';
del.className = 'mm-meeting-del';
del.setAttribute('aria-label', '삭제');
del.textContent = '×';
del.addEventListener('click', function (e) {
e.stopPropagation();
if (!confirm('이 회의록을 삭제할까요?')) return;
api('/api/meeting-minutes/meetings/' + encodeURIComponent(m.id), { method: 'DELETE' }).then(loadMeetings);
});
li.appendChild(t);
li.appendChild(del);
listEl.appendChild(li);
});
});
}
function showMeeting(id) {
api('/api/meeting-minutes/meetings/' + encodeURIComponent(id)).then(function (d) {
var m = d.meeting;
resultSection.hidden = false;
setCurrentMeetingId(m.id);
if (transcriptLabel) {
transcriptLabel.textContent =
m.transcriptText && String(m.transcriptText).trim() ? '전사 기록' : '회의 원문';
}
lastMeetingTranscriptIsAudio = !!(m.transcriptText && String(m.transcriptText).trim());
if (transcriptBody) {
if (lastMeetingTranscriptIsAudio) {
transcriptBody.value = m.transcriptText != null ? String(m.transcriptText) : '';
} else {
transcriptBody.value = m.sourceText != null ? String(m.sourceText) : '';
}
}
resultBody.value = m.generatedMinutes != null ? String(m.generatedMinutes) : '';
setMinutesViewMode(false);
applyTranscriptPaneVisibility();
var title = m.title || '';
document.getElementById('mmTitleText').value = title;
document.getElementById('mmTitleAudio').value = title;
var md = m.meetingDate ? String(m.meetingDate).slice(0, 10) : '';
document.getElementById('mmDateText').value = md && md.length >= 10 ? md : '';
document.getElementById('mmDateAudio').value = md && md.length >= 10 ? md : '';
var src = '';
if (m.sourceText && String(m.sourceText).trim()) src = m.sourceText;
else if (m.transcriptText && String(m.transcriptText).trim()) src = m.transcriptText;
document.getElementById('mmSourceText').value = src;
if (m.chatModel) {
var mt = document.getElementById('mmModelText');
var ma = document.getElementById('mmModelAudio');
if (mt) {
for (var i = 0; i < mt.options.length; i++) {
if (mt.options[i].value === m.chatModel) {
mt.selectedIndex = i;
break;
}
}
}
if (ma) {
for (var j = 0; j < ma.options.length; j++) {
if (ma.options[j].value === m.chatModel) {
ma.selectedIndex = j;
break;
}
}
}
}
if (m.transcriptionModel) {
var wEl = document.getElementById('mmWhisperModel');
if (wEl) {
for (var k = 0; k < wEl.options.length; k++) {
if (wEl.options[k].value === m.transcriptionModel) {
wEl.selectedIndex = k;
break;
}
}
}
}
});
}
document.getElementById('mmSavePrompt').addEventListener('click', function () {
var body = {
includeTitleLine: document.getElementById('mmIncTitle').checked,
includeAttendees: document.getElementById('mmIncAtt').checked,
includeSummary: document.getElementById('mmIncSum').checked,
includeActionItems: document.getElementById('mmIncAct').checked,
includeChecklist: true,
customInstructions: document.getElementById('mmCustomInstr').value
};
api('/api/meeting-minutes/prompt', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(function () {
alert('저장되었습니다.');
}).catch(function (e) {
alert(e.message || '저장 실패');
});
});
document.getElementById('mmSaveResult').addEventListener('click', function () {
if (!currentMeetingId) return;
setMeetingGenerating(true, '저장 중…');
var savePayload = { generatedMinutes: resultBody.value };
if (lastMeetingTranscriptIsAudio) {
savePayload.transcriptText = transcriptBody ? transcriptBody.value : '';
} else {
savePayload.sourceText = transcriptBody ? transcriptBody.value : '';
}
api('/api/meeting-minutes/meetings/' + encodeURIComponent(currentMeetingId) + '/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(savePayload)
})
.then(function (d) {
if (d.meeting) {
if (d.meeting.generatedMinutes != null) resultBody.value = d.meeting.generatedMinutes;
setMinutesViewMode(false);
if (transcriptBody) {
if (lastMeetingTranscriptIsAudio && d.meeting.transcriptText != null) {
transcriptBody.value = String(d.meeting.transcriptText);
}
if (!lastMeetingTranscriptIsAudio && d.meeting.sourceText != null) {
transcriptBody.value = String(d.meeting.sourceText);
var stEl = document.getElementById('mmSourceText');
if (stEl) stEl.value = String(d.meeting.sourceText);
}
}
}
loadMeetings();
var cs = d.checklistSync;
if (cs && cs.imported > 0) {
alert('저장되었습니다. 업무 체크리스트에 ' + cs.imported + '건이 반영되었습니다.');
} else if (cs && cs.extractError && !cs.disabled) {
alert('저장되었습니다. (체크리스트 자동 연동: ' + cs.extractError + ')');
} else {
alert('저장되었습니다.');
}
})
.catch(function (e) {
alert(e.message || '저장 실패');
})
.finally(function () {
setMeetingGenerating(false);
});
});
document.getElementById('mmListRefresh').addEventListener('click', loadMeetings);
function wireMeetingDatePicker(btnId, inputId) {
var btn = document.getElementById(btnId);
var inp = document.getElementById(inputId);
if (!btn || !inp) return;
btn.addEventListener('click', function () {
if (typeof inp.showPicker === 'function') inp.showPicker();
else inp.focus();
});
}
wireMeetingDatePicker('mmDateTextBtn', 'mmDateText');
wireMeetingDatePicker('mmDateAudioBtn', 'mmDateAudio');
document.querySelectorAll('.mm-tab').forEach(function (tab) {
tab.addEventListener('click', function () {
var name = tab.getAttribute('data-tab');
document.querySelectorAll('.mm-tab').forEach(function (t) {
t.classList.toggle('is-active', t === tab);
t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
});
var panelText = document.getElementById('mmPanelText');
var panelAudio = document.getElementById('mmPanelAudio');
panelText.hidden = name !== 'text';
panelAudio.hidden = name !== 'audio';
panelText.setAttribute('aria-hidden', name !== 'text' ? 'true' : 'false');
panelAudio.setAttribute('aria-hidden', name !== 'audio' ? 'true' : 'false');
applyTranscriptPaneVisibility();
});
});
document.getElementById('mmGenText').addEventListener('click', function () {
var title = (document.getElementById('mmTitleText').value || '').trim();
if (!title) {
alert('제목을 입력해 주세요.');
return;
}
var sourceText = (document.getElementById('mmSourceText').value || '').trim();
if (!sourceText) {
alert('회의 원문을 입력해 주세요.');
return;
}
setMeetingGenerating(true, '회의 원문을 보내고 있습니다…');
api('/api/meeting-minutes/generate-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title,
meetingDate: document.getElementById('mmDateText').value,
sourceText: sourceText,
model: document.getElementById('mmModelText').value
})
})
.then(function (d) {
resultSection.hidden = false;
setCurrentMeetingId(d.meeting && d.meeting.id);
lastMeetingTranscriptIsAudio = false;
if (transcriptLabel) transcriptLabel.textContent = '회의 원문';
if (transcriptBody) transcriptBody.value = (d.meeting && d.meeting.sourceText) || '';
resultBody.value = (d.meeting && d.meeting.generatedMinutes) || '';
setMinutesViewMode(false);
applyTranscriptPaneVisibility();
loadMeetings();
var cs = d.checklistSync;
if (cs && cs.imported > 0) {
alert('업무 체크리스트에 ' + cs.imported + '건이 자동 반영되었습니다. (업무 체크리스트 AI에서 확인)');
} else if (cs && cs.extractError && !cs.disabled) {
console.warn('체크리스트 자동 추출:', cs.extractError);
}
})
.catch(function (e) {
alert(e.message || '생성 실패');
})
.finally(function () {
setMeetingGenerating(false);
});
});
document.getElementById('mmGenAudio').addEventListener('click', function () {
var fileInput = document.getElementById('mmAudioFile');
if (!fileInput.files || !fileInput.files.length) {
alert('음성 파일을 선택해 주세요.');
return;
}
var audioTitle = (document.getElementById('mmTitleAudio').value || '').trim();
if (!audioTitle) {
alert('제목을 입력해 주세요.');
return;
}
var fd = new FormData();
fd.append('audio', fileInput.files[0]);
fd.append('title', audioTitle);
fd.append('meetingDate', document.getElementById('mmDateAudio').value);
fd.append('model', document.getElementById('mmModelAudio').value);
fd.append('whisperModel', document.getElementById('mmWhisperModel').value);
setMeetingGenerating(true, '음성 파일을 업로드하고 전사합니다. 길이에 따라 1분 이상 걸릴 수 있습니다…');
fetch('/api/meeting-minutes/generate-audio', { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (r) {
return r.json().then(function (j) {
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
});
})
.then(function (d) {
resultSection.hidden = false;
setCurrentMeetingId(d.meeting && d.meeting.id);
lastMeetingTranscriptIsAudio = true;
if (transcriptLabel) transcriptLabel.textContent = '전사 기록';
if (transcriptBody) transcriptBody.value = (d.meeting && d.meeting.transcriptText) || '';
resultBody.value = (d.meeting && d.meeting.generatedMinutes) || '';
setMinutesViewMode(false);
applyTranscriptPaneVisibility();
fileInput.value = '';
loadMeetings();
var cs = d.checklistSync;
if (cs && cs.imported > 0) {
alert('업무 체크리스트에 ' + cs.imported + '건이 자동 반영되었습니다. (업무 체크리스트 AI에서 확인)');
} else if (cs && cs.extractError && !cs.disabled) {
console.warn('체크리스트 자동 추출:', cs.extractError);
}
})
.catch(function (e) {
alert(e.message || '생성 실패');
})
.finally(function () {
setMeetingGenerating(false);
});
});
applyTranscriptPaneVisibility();
loadPrompt().then(loadMeetings).catch(function () {});
})();
</script>
<script>
(function () {
var panel = document.getElementById('mmPromptPanel');
var btn = document.getElementById('mmPromptToggle');
var body = document.getElementById('mmPromptBody');
if (!panel || !btn || !body) return;
function applyOpen(open) {
if (open) {
panel.classList.remove('mm-prompt-collapsed');
body.hidden = false;
btn.setAttribute('aria-expanded', 'true');
} else {
panel.classList.add('mm-prompt-collapsed');
body.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
}
try {
if (localStorage.getItem('meetingMinutesPromptOpen') === '1') {
applyOpen(true);
}
} catch (e) {}
btn.addEventListener('click', function () {
var nowCollapsed = panel.classList.toggle('mm-prompt-collapsed');
body.hidden = nowCollapsed;
btn.setAttribute('aria-expanded', String(!nowCollapsed));
try {
localStorage.setItem('meetingMinutesPromptOpen', nowCollapsed ? '0' : '1');
} catch (e) {}
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
<%
const label = typeof buttonLabel !== 'undefined' && buttonLabel ? buttonLabel : '관리자';
%>
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
<a href="/admin" class="top-action-link"><%= label %></a>
<% } else { %>
<button type="button" class="top-action-link" onclick="openAdminTokenModal()"><%= label %></button>
<% } %>

View File

@@ -0,0 +1,86 @@
<div id="admin-token-modal" class="modal-overlay" role="dialog" aria-labelledby="admin-modal-title" aria-modal="true" hidden>
<div class="modal-backdrop" onclick="closeAdminTokenModal()"></div>
<div class="modal-content">
<h3 id="admin-modal-title">관리자 모드</h3>
<p class="modal-desc">강의 등록을 위해 관리자 토큰을 입력한 뒤 활성화해주세요.</p>
<p id="admin-token-error" class="admin-error" style="display:none; margin-bottom:12px">입력한 토큰이 올바르지 않습니다. 다시 확인해주세요.</p>
<form id="admin-token-form" class="admin-token-form">
<input type="password" id="admin-token-input" name="token" placeholder="관리자 토큰" required autocomplete="off" />
<div class="modal-actions">
<button type="submit" id="admin-token-submit">활성화</button>
<button type="button" class="ghost" onclick="closeAdminTokenModal()">닫기</button>
</div>
</form>
</div>
</div>
<script>
(function moveAdminModalToBody() {
function run() {
var m = document.getElementById("admin-token-modal");
if (m && m.parentNode && m.parentNode !== document.body) {
document.body.appendChild(m);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", run);
} else {
run();
}
})();
function openAdminTokenModal() {
var m = document.getElementById("admin-token-modal");
m.hidden = false;
document.body.style.overflow = "hidden";
document.getElementById("admin-token-error").style.display = "none";
document.getElementById("admin-token-input").value = "";
}
function closeAdminTokenModal() {
document.getElementById("admin-token-modal").hidden = true;
document.body.style.overflow = "";
}
function initAdminTokenForm() {
var form = document.getElementById("admin-token-form");
if (form) {
form.addEventListener("submit", function(e) {
e.preventDefault();
var input = document.getElementById("admin-token-input");
var token = (input && input.value || "").trim();
var errEl = document.getElementById("admin-token-error");
var btn = document.getElementById("admin-token-submit");
if (!token) {
if (errEl) errEl.style.display = "block";
return;
}
if (btn) btn.disabled = true;
if (errEl) errEl.style.display = "none";
fetch("/api/admin/validate-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: token })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data && data.valid) {
window.location.href = "/admin?token=" + encodeURIComponent(token);
} else {
if (errEl) errEl.style.display = "block";
}
})
.catch(function() {
if (errEl) {
errEl.textContent = "토큰 검증 중 오류가 발생했습니다.";
errEl.style.display = "block";
}
})
.finally(function() {
if (btn) btn.disabled = false;
});
});
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initAdminTokenForm);
} else {
initAdminTokenForm();
}
</script>

View File

@@ -0,0 +1,54 @@
<% var _externalUrl = (lecture.type === "link" || lecture.type === "news") && (lecture.newsUrl || "").trim(); %>
<article class="lecture-card">
<% if (_externalUrl) { %>
<a class="lecture-link lecture-link-external" href="<%= _externalUrl %>" target="_blank" rel="noopener noreferrer">
<% } else { %>
<a class="lecture-link" href="/lectures/<%= lecture.id %>">
<% } %>
<div class="thumb <%= lecture.type %>">
<% if (lecture.type === "ppt") { %>
<% if (lecture.thumbnailUrl) { %>
<img src="<%= lecture.thumbnailUrl %>" alt="<%= lecture.title %> 썸네일" class="thumb-image" />
<% } else { %>
<span class="thumb-fallback">썸네일 <%= lecture.thumbnailStatus || "pending" %></span>
<% } %>
<span class="thumb-kicker">PPT 프리뷰</span>
<% if (lecture.previewTitle && lecture.previewTitle !== "제목 없음") { %><strong><%= lecture.previewTitle %></strong><% } %>
<% } else if (lecture.type === "news") { %>
<% if (lecture.thumbnailUrl) { %>
<img src="<%= lecture.thumbnailUrl %>" alt="" class="thumb-image thumb-image-og" loading="lazy" referrerpolicy="no-referrer" />
<% } %>
<span class="thumb-kicker">뉴스</span>
<% if (!lecture.thumbnailUrl) { %><strong>외부 링크</strong><% } %>
<% } else if (lecture.type === "link") { %>
<% if (lecture.thumbnailUrl) { %>
<img src="<%= lecture.thumbnailUrl %>" alt="" class="thumb-image thumb-image-og" loading="lazy" referrerpolicy="no-referrer" />
<% } %>
<span class="thumb-kicker">웹 링크</span>
<% if (!lecture.thumbnailUrl) { %><strong>외부 페이지</strong><% } %>
<% } else if (lecture.type === "video") { %>
<span class="thumb-fallback thumb-fallback-video">▶</span>
<span class="thumb-kicker">동영상 파일</span>
<strong>업로드 영상</strong>
<% } else { %>
<% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %>
<% if (ytThumb) { %>
<img src="<%= ytThumb %>" alt="<%= lecture.title %> 썸네일" class="thumb-image thumb-image-youtube" />
<% } %>
<span class="thumb-kicker">YouTube</span>
<strong>영상 강의</strong>
<% } %>
</div>
<div class="badge <%= lecture.type %>">
<% if (lecture.type === "youtube") { %>YouTube<% } else if (lecture.type === "news") { %>뉴스<% } else if (lecture.type === "link") { %>링크<% } else if (lecture.type === "video") { %>동영상<% } else { %>PPT<% } %>
</div>
<h3><%= lecture.title %></h3>
<p><%= lecture.description || "설명이 없습니다." %></p>
<div class="tag-row">
<% (lecture.tags || []).forEach((oneTag) => { %>
<span class="tag-chip">#<%= oneTag %></span>
<% }) %>
</div>
<small><%= new Date(lecture.createdAt).toLocaleString("ko-KR") %></small>
</a>
</article>

View File

@@ -0,0 +1,3 @@
<% (lectures || []).forEach((lecture) => { %>
<%- include('lecture-card', { lecture }) %>
<% }) %>

120
views/partials/nav.ejs Normal file
View File

@@ -0,0 +1,120 @@
<button type="button" class="nav-mobile-toggle" id="nav-mobile-toggle" aria-label="메뉴 열기" aria-expanded="false" aria-controls="nav-drawer">
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M3 6h18v2H3V6zm0 5h18v2H3v-2zm0 5h18v2H3v-2z"/></svg>
</button>
<div class="nav-drawer-backdrop" id="nav-drawer-backdrop" hidden></div>
<aside class="left-nav" id="nav-drawer">
<div class="nav-logo-section">
<a
href="https://xavis.co.kr"
class="logo-link logo-link-xavis"
target="_blank"
rel="noopener noreferrer"
aria-label="XAVIS 회사 사이트(새 탭)"
>
<img src="/public/images/xavis-logo.png" alt="XAVIS" class="logo-img logo-img-xavis" />
</a>
<img
src="/public/images/aiplatform-logo.png"
alt="AI PLATFORM"
class="logo-img logo-img-aiplatform"
width="168"
height="auto"
decoding="async"
/>
<div class="nav-logo-divider" role="presentation" aria-hidden="true"></div>
</div>
<a href="/chat" class="nav-item <%= activeMenu === 'chat' ? 'active' : '' %>">채팅</a>
<a href="/ai-explore" class="nav-item <%= activeMenu === 'ai-explore' ? 'active' : '' %>">AI</a>
<a href="/learning" class="nav-item <%= activeMenu === 'learning' ? 'active' : '' %>">학습센터</a>
<a href="/ax-apply" class="nav-item <%= activeMenu === 'ax-apply' ? 'active' : '' %>">과제신청</a>
<a href="/ai-cases" class="nav-item <%= activeMenu === 'ai-cases' ? 'active' : '' %>">성공사례</a>
<div class="nav-footer">
<% var _opsLoggedIn = typeof opsUserEmail !== 'undefined' && opsUserEmail; %>
<% if (_opsLoggedIn) { %>
<a href="/logout" class="nav-item nav-item-ops-logout" title="이메일 인증 세션 종료">로그아웃</a>
<% } else { %>
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
<a href="/admin/users" class="nav-item <%= activeMenu === 'admin-users' ? 'active' : '' %>">사용자 현황관리</a>
<div class="nav-separator"></div>
<a href="/admin/logout" class="nav-item nav-item-ghost" title="관리자 세션 종료">로그오프</a>
<% } else { %>
<div class="nav-separator"></div>
<button type="button" class="nav-item nav-item-ghost" onclick="openAdminTokenModal()" title="관리자 모드">관리자</button>
<% } %>
<% } %>
</div>
</aside>
<script>
(function () {
var toggle = document.getElementById("nav-mobile-toggle");
var nav = document.getElementById("nav-drawer");
var backdrop = document.getElementById("nav-drawer-backdrop");
if (!toggle || !nav || !backdrop) return;
function isMobileNav() {
return typeof window.matchMedia === "function" && window.matchMedia("(max-width: 900px)").matches;
}
function openDrawer() {
if (!isMobileNav()) return;
nav.classList.add("nav-drawer-open");
backdrop.removeAttribute("hidden");
document.body.classList.add("nav-mobile-open");
toggle.setAttribute("aria-expanded", "true");
toggle.setAttribute("aria-label", "메뉴 닫기");
}
function closeDrawer() {
nav.classList.remove("nav-drawer-open");
backdrop.setAttribute("hidden", "");
document.body.classList.remove("nav-mobile-open");
toggle.setAttribute("aria-expanded", "false");
toggle.setAttribute("aria-label", "메뉴 열기");
}
function toggleDrawer() {
if (nav.classList.contains("nav-drawer-open")) closeDrawer();
else openDrawer();
}
toggle.addEventListener("click", function (e) {
e.stopPropagation();
toggleDrawer();
});
backdrop.addEventListener("click", closeDrawer);
nav.querySelectorAll("a").forEach(function (el) {
el.addEventListener("click", function () {
if (isMobileNav()) closeDrawer();
});
});
nav.querySelectorAll("button.nav-item").forEach(function (el) {
el.addEventListener("click", function () {
if (isMobileNav()) closeDrawer();
});
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && nav.classList.contains("nav-drawer-open")) closeDrawer();
});
if (typeof window.matchMedia === "function") {
window.matchMedia("(min-width: 901px)").addEventListener("change", function (ev) {
if (ev.matches) closeDrawer();
});
}
})();
(function () {
try {
var sp = new URLSearchParams(window.location.search);
if (sp.get("verified") === "1") {
alert("인증되었습니다.");
var u = new URL(window.location.href);
u.searchParams.delete("verified");
history.replaceState({}, "", u.pathname + (u.search ? u.search : "") + u.hash);
}
} catch (e) {}
})();
</script>
<%- include('admin-token-modal') %>

View File

@@ -0,0 +1,43 @@
<% var detailAllowed = typeof successStoryDetailAllowed !== 'undefined' ? successStoryDetailAllowed : true; %>
<article class="success-story-card<%= detailAllowed ? '' : ' success-story-card--locked' %>">
<% if (detailAllowed) { %>
<a class="success-story-link" href="/ai-cases/<%= story.slug %>">
<div class="success-thumb" aria-hidden="true">
<span class="success-thumb-icon">✦</span>
<span class="success-thumb-kicker"><%= story.department || "사내 사례" %></span>
</div>
<div class="success-badge"><%= story.department || "사내" %> · <%= story.author || "" %></div>
<h3><%= story.title %></h3>
<p class="success-excerpt"><%= story.excerpt || "" %></p>
<div class="tag-row">
<% (story.tags || []).forEach((oneTag) => { %>
<span class="tag-chip">#<%= oneTag %></span>
<% }) %>
</div>
<small class="success-meta">
<% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
</small>
</a>
<% } else { %>
<div
class="success-story-link"
title="로그인 후 이용 가능합니다."
>
<div class="success-thumb" aria-hidden="true">
<span class="success-thumb-icon">✦</span>
<span class="success-thumb-kicker"><%= story.department || "사내 사례" %></span>
</div>
<div class="success-badge"><%= story.department || "사내" %> · <%= story.author || "" %></div>
<h3><%= story.title %></h3>
<p class="success-excerpt"><%= story.excerpt || "" %></p>
<div class="tag-row">
<% (story.tags || []).forEach((oneTag) => { %>
<span class="tag-chip">#<%= oneTag %></span>
<% }) %>
</div>
<small class="success-meta">
<% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
</small>
</div>
<% } %>
</article>

481
views/task-checklist.ejs Normal file
View File

@@ -0,0 +1,481 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>업무 체크리스트 AI - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body class="task-checklist-page">
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'ai-explore', adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %>
<div class="content-area">
<header class="topbar">
<h1>업무 체크리스트 AI</h1>
<a class="top-action-link" href="/ai-explore">AI 목록</a>
</header>
<main class="container container-ai-full meeting-minutes-main">
<a href="/ai-explore" class="prompts-back" aria-label="AI 탐색으로 돌아가기">← AI</a>
<% var hasEmail = typeof meetingUserEmail !== 'undefined' && meetingUserEmail; %>
<% if (!hasEmail) { %>
<p class="chat-api-warning" style="margin-bottom: 16px">
이메일 인증(OPS <code>PROD</code>) 또는 DEV에서 <strong>관리자 모드</strong>, SUPER(데모)에서는 별도 로그인 없이 이용할 수 있습니다.
</p>
<% } else { %>
<p class="subtitle tcl-lead">
<strong>회의록 AI</strong>에서 저장한 내용을 바탕으로 할 일이 모입니다. 회의록의 <strong>액션 아이템(번호별)</strong>과 <strong>회의 체크리스트</strong>가 모두 항목으로 반영됩니다. <strong>진행상황</strong> 또는 <strong>회의록</strong>을 바꿀 때마다 선택한 범위에서 자동으로 가져온 뒤 목록을 맞춥니다. 항목에 마우스를 올리면 해당 <strong>회의 제목·일자·요약</strong>을 볼 수 있습니다.
</p>
<section class="panel tcl-toolbar">
<div class="tcl-toolbar-row">
<label class="tcl-filter">
<span class="tcl-filter-label">진행상황</span>
<select id="tclFilter" class="mm-select">
<option value="all" selected>전체</option>
<option value="open">진행 중</option>
<option value="done">완료</option>
</select>
</label>
<label class="tcl-filter">
<span class="tcl-filter-label">회의록</span>
<select id="tclMeetingPick" class="mm-select" title="목록 필터·가져오기 범위 (기본: 전체 회의)">
<option value="__all__" selected>전체 (모든 회의)</option>
</select>
</label>
</div>
</section>
<section class="panel">
<div class="tcl-sort-bar" role="group" aria-label="목록 정렬">
<span class="tcl-sort-label">정렬</span>
<button type="button" class="tcl-sort-btn" id="tclSortDate" data-sort="date">날짜순</button>
<button type="button" class="tcl-sort-btn" id="tclSortAlpha" data-sort="alpha">글자순</button>
<button type="button" class="tcl-sort-btn tcl-sort-btn-active" id="tclSortCompleted" data-sort="completed">완료여부순</button>
</div>
<h2 class="tcl-section-title">업무 체크리스트</h2>
<p class="tcl-empty" id="tclEmpty" hidden>표시할 항목이 없습니다.</p>
<ul class="tcl-list" id="tclList" role="list"></ul>
</section>
<div id="tclEditModal" class="tcl-modal" hidden>
<div class="tcl-modal-backdrop" id="tclEditBackdrop"></div>
<div class="tcl-modal-panel" role="dialog" aria-modal="true" aria-labelledby="tclEditModalTitle">
<h3 id="tclEditModalTitle" class="tcl-modal-title">체크리스트 항목 수정</h3>
<label class="tcl-modal-field">
<span class="mm-field-label">제목</span>
<input type="text" id="tclEditTitle" class="mm-input" maxlength="2000" />
</label>
<label class="tcl-modal-field">
<span class="mm-field-label">내용</span>
<textarea id="tclEditDetail" class="mm-textarea" rows="4" maxlength="8000" placeholder="할 일 제목에 대한 상세 내용"></textarea>
</label>
<div class="tcl-modal-actions">
<button type="button" class="btn-ghost" id="tclEditCancel">취소</button>
<button type="button" class="top-action" id="tclEditSave">저장</button>
</div>
</div>
</div>
<div id="tclCompleteModal" class="tcl-modal" hidden>
<div class="tcl-modal-backdrop" id="tclCompleteBackdrop"></div>
<div class="tcl-modal-panel" role="dialog" aria-modal="true" aria-labelledby="tclCompleteModalTitle">
<h3 id="tclCompleteModalTitle" class="tcl-modal-title">완료 처리</h3>
<p class="tcl-complete-preview" id="tclCompletePreview"></p>
<label class="tcl-modal-field">
<span class="mm-field-label">처리 내용</span>
<textarea
id="tclCompleteNote"
class="mm-textarea"
rows="4"
maxlength="8000"
placeholder="어떻게 처리했는지 간단히 적어 주세요."
></textarea>
</label>
<div class="tcl-modal-actions">
<button type="button" class="btn-ghost" id="tclCompleteCancel">취소</button>
<button type="button" class="top-action" id="tclCompleteSave">저장</button>
</div>
</div>
</div>
<% } %>
</main>
</div>
</div>
<% if (hasEmail) { %>
<script>
(function () {
var filterEl = document.getElementById('tclFilter');
var meetingPick = document.getElementById('tclMeetingPick');
var listEl = document.getElementById('tclList');
var emptyEl = document.getElementById('tclEmpty');
var editModal = document.getElementById('tclEditModal');
var editTitle = document.getElementById('tclEditTitle');
var editDetail = document.getElementById('tclEditDetail');
var editingId = null;
var completeModal = document.getElementById('tclCompleteModal');
var completeNoteEl = document.getElementById('tclCompleteNote');
var completePreviewEl = document.getElementById('tclCompletePreview');
var completePendingId = null;
var sortMode = 'completed';
function api(path, opts) {
return fetch(path, Object.assign({ credentials: 'same-origin' }, opts || {})).then(function (r) {
return r.json().then(function (j) {
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
});
});
}
function loadMeetingsDropdown() {
var prev = meetingPick.value;
return api('/api/meeting-minutes/meetings').then(function (d) {
var list = d.meetings || [];
meetingPick.innerHTML = '';
var optAll = document.createElement('option');
optAll.value = '__all__';
optAll.textContent = '전체 (모든 회의)';
meetingPick.appendChild(optAll);
list.forEach(function (m) {
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = (m.title || '제목 없음').slice(0, 80);
meetingPick.appendChild(opt);
});
if (prev) {
meetingPick.value = prev;
if (meetingPick.value !== prev) meetingPick.value = '__all__';
} else {
meetingPick.value = '__all__';
}
});
}
function openEdit(it) {
editingId = it.id;
editTitle.value = it.title || '';
editDetail.value = it.detail || '';
editModal.hidden = false;
editTitle.focus();
}
function closeEdit() {
editingId = null;
editModal.hidden = true;
}
function openCompleteModal(it) {
completePendingId = it.id;
completePreviewEl.textContent = it.title || '';
// 이전에 저장한 처리 내용 유지(완료 취소 후 재완료 시에도 동일)
completeNoteEl.value = it.completionNote ? String(it.completionNote) : '';
completeModal.hidden = false;
completeNoteEl.focus();
}
function closeCompleteModal() {
completePendingId = null;
completeModal.hidden = true;
}
function dateSortKey(it) {
var s = it.meetingDate && String(it.meetingDate).slice(0, 10);
if (!s || !/^\d{4}-\d{2}-\d{2}$/.test(s)) return '\uffff';
return s;
}
function titleSortKey(it) {
return (it.title || '').trim();
}
function sortItems(items, mode) {
var arr = items.slice();
if (mode === 'date') {
arr.sort(function (a, b) {
var d = dateSortKey(a).localeCompare(dateSortKey(b));
if (d !== 0) return d;
return titleSortKey(a).localeCompare(titleSortKey(b), 'ko');
});
} else if (mode === 'alpha') {
arr.sort(function (a, b) {
return titleSortKey(a).localeCompare(titleSortKey(b), 'ko');
});
} else {
arr.sort(function (a, b) {
var da = a.completed ? 0 : 1;
var db = b.completed ? 0 : 1;
if (da !== db) return da - db;
var cmpD = dateSortKey(a).localeCompare(dateSortKey(b));
if (cmpD !== 0) return cmpD;
return titleSortKey(a).localeCompare(titleSortKey(b), 'ko');
});
}
return arr;
}
function updateSortButtonsActive() {
document.querySelectorAll('.tcl-sort-btn').forEach(function (btn) {
var on = btn.getAttribute('data-sort') === sortMode;
btn.classList.toggle('tcl-sort-btn-active', on);
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
});
}
function renderItem(it) {
var li = document.createElement('li');
li.className = 'tcl-item' + (it.completed ? ' tcl-item-done' : '');
li.dataset.id = it.id;
var row = document.createElement('div');
row.className = 'tcl-item-row';
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'tcl-checkbox';
cb.checked = !!it.completed;
cb.setAttribute('aria-label', '완료');
cb.addEventListener('change', function () {
if (!cb.checked) {
api('/api/task-checklist/items/' + encodeURIComponent(it.id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: false })
})
.then(function () {
loadItems();
})
.catch(function (e) {
alert(e.message || '저장 실패');
cb.checked = !cb.checked;
});
return;
}
cb.checked = false;
openCompleteModal(it);
});
var body = document.createElement('div');
body.className = 'tcl-item-body';
var hasMeetingMeta = it.meetingId && (it.meetingTitle || it.meetingDate || it.meetingSummary);
if (hasMeetingMeta) {
var wrap = document.createElement('div');
wrap.className = 'tcl-tooltip-wrap';
wrap.setAttribute('tabindex', '0');
var tipId = 'tcl-tip-' + it.id;
var titleEl = document.createElement('div');
titleEl.className = 'tcl-item-title';
titleEl.textContent = it.title || '';
wrap.appendChild(titleEl);
var pop = document.createElement('div');
pop.className = 'tcl-popup';
pop.id = tipId;
pop.setAttribute('role', 'tooltip');
wrap.setAttribute('aria-describedby', tipId);
function addRow(label, text) {
if (!text) return;
var row = document.createElement('div');
row.className = 'tcl-popup-block';
var lb = document.createElement('div');
lb.className = 'tcl-popup-label';
lb.textContent = label;
var val = document.createElement('div');
val.className = 'tcl-popup-value';
val.textContent = text;
row.appendChild(lb);
row.appendChild(val);
pop.appendChild(row);
}
addRow('회의 제목', it.meetingTitle || '');
addRow('회의 일자', it.meetingDate || '—');
if (it.meetingSummary && String(it.meetingSummary).trim()) {
addRow('회의 요약', String(it.meetingSummary).trim());
}
wrap.appendChild(pop);
body.appendChild(wrap);
} else {
var titlePlain = document.createElement('div');
titlePlain.className = 'tcl-item-title';
titlePlain.textContent = it.title || '';
body.appendChild(titlePlain);
}
var meta = [];
if (it.assignee) meta.push('담당: ' + it.assignee);
if (it.dueNote) meta.push('기한: ' + it.dueNote);
if (it.detail) meta.push(it.detail);
if (it.completionNote) {
meta.push((it.completed ? '처리 내용: ' : '이전 완료 처리: ') + it.completionNote);
}
if (meta.length) {
var sub = document.createElement('div');
sub.className = 'tcl-item-meta';
sub.textContent = meta.join(' · ');
body.appendChild(sub);
}
var actions = document.createElement('div');
actions.className = 'tcl-item-actions';
var btnEdit = document.createElement('button');
btnEdit.type = 'button';
btnEdit.className = 'tcl-btn-outline';
btnEdit.textContent = '수정';
btnEdit.addEventListener('click', function () {
openEdit(it);
});
var btnDel = document.createElement('button');
btnDel.type = 'button';
btnDel.className = 'tcl-btn-outline tcl-btn-outline-danger';
btnDel.textContent = '삭제';
btnDel.addEventListener('click', function () {
if (!confirm('이 항목을 삭제할까요?')) return;
api('/api/task-checklist/items/' + encodeURIComponent(it.id), { method: 'DELETE' })
.then(loadItems)
.catch(function (e) {
alert(e.message || '삭제 실패');
});
});
actions.appendChild(btnEdit);
actions.appendChild(btnDel);
row.appendChild(cb);
row.appendChild(body);
row.appendChild(actions);
li.appendChild(row);
return li;
}
function loadItems() {
var v = filterEl.value;
var parts = [];
if (v === 'open') parts.push('completed=false');
else if (v === 'done') parts.push('completed=true');
var mid = meetingPick.value;
if (mid && mid !== '__all__') parts.push('meetingId=' + encodeURIComponent(mid));
var q = parts.length ? '?' + parts.join('&') : '';
return api('/api/task-checklist/items' + q).then(function (d) {
var items = sortItems(d.items || [], sortMode);
listEl.innerHTML = '';
emptyEl.hidden = items.length > 0;
items.forEach(function (it) {
listEl.appendChild(renderItem(it));
});
});
}
function importUrlsForScope() {
var mid = meetingPick.value;
return {
checklist:
mid === '__all__'
? '/api/task-checklist/import-all'
: '/api/task-checklist/import/' + encodeURIComponent(mid),
actions:
mid === '__all__'
? '/api/task-checklist/import-all?mode=actions'
: '/api/task-checklist/import/' + encodeURIComponent(mid) + '?mode=actions',
};
}
/** 선택한 회의록 범위에서 체크리스트·액션 가져오기(병렬). 실패는 콘솔만. */
function importChecklistAndActions() {
var u = importUrlsForScope();
return Promise.allSettled([
api(u.checklist, { method: 'POST' }),
api(u.actions, { method: 'POST' }),
]).then(function (results) {
if (results[0].status === 'rejected') {
console.warn('체크리스트 가져오기:', results[0].reason && results[0].reason.message);
}
if (results[1].status === 'rejected') {
console.warn('액션 가져오기:', results[1].reason && results[1].reason.message);
}
return loadItems();
});
}
filterEl.addEventListener('change', importChecklistAndActions);
meetingPick.addEventListener('change', importChecklistAndActions);
document.querySelectorAll('.tcl-sort-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var m = btn.getAttribute('data-sort');
if (!m || m === sortMode) return;
sortMode = m;
updateSortButtonsActive();
loadItems();
});
});
updateSortButtonsActive();
document.getElementById('tclEditSave').addEventListener('click', function () {
if (!editingId) return;
var title = editTitle.value.trim();
if (!title) {
alert('제목을 입력해 주세요.');
return;
}
api('/api/task-checklist/items/' + encodeURIComponent(editingId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title, detail: editDetail.value.trim() || null })
})
.then(function () {
closeEdit();
loadItems();
})
.catch(function (e) {
alert(e.message || '저장 실패');
});
});
document.getElementById('tclEditCancel').addEventListener('click', closeEdit);
document.getElementById('tclEditBackdrop').addEventListener('click', closeEdit);
document.getElementById('tclCompleteSave').addEventListener('click', function () {
if (!completePendingId) return;
var note = completeNoteEl.value.trim();
if (!note) {
alert('처리 내용을 입력해 주세요.');
return;
}
api('/api/task-checklist/items/' + encodeURIComponent(completePendingId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: true, completionNote: note })
})
.then(function () {
closeCompleteModal();
loadItems();
})
.catch(function (e) {
alert(e.message || '저장 실패');
});
});
document.getElementById('tclCompleteCancel').addEventListener('click', closeCompleteModal);
document.getElementById('tclCompleteBackdrop').addEventListener('click', closeCompleteModal);
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
if (!completeModal.hidden) {
closeCompleteModal();
e.preventDefault();
return;
}
if (!editModal.hidden) closeEdit();
});
loadMeetingsDropdown().then(importChecklistAndActions);
})();
</script>
<% } %>
</body>
</html>