Initial commit: AI platform app (server, views, lib, data, deploy docs)
Made-with: Cursor
This commit is contained in:
238
views/admin-thumbnail-events.ejs
Normal file
238
views/admin-thumbnail-events.ejs
Normal 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
60
views/admin-users.ejs
Normal 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
152
views/ai-case-detail.ejs
Normal 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
228
views/ai-cases-write.ejs
Normal 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> > 관리자</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="# 제목 본문..."><% 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
74
views/ai-cases.ejs
Normal 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
142
views/ai-explore.ejs
Normal 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
163
views/ai-prompts.ejs
Normal 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
479
views/ax-apply.ejs
Normal 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, '"') %>"><%= (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, '"') %>" data-department="<%= (a.department || '').toString().replace(/"/g, '"') %>" data-name="<%= (a.name || '').toString().replace(/"/g, '"') %>" data-employee-id="<%= (a.employeeId || '').toString().replace(/"/g, '"') %>" data-position="<%= (a.position || '').toString().replace(/"/g, '"') %>" data-email="<%= (a.email || '').toString().replace(/"/g, '"') %>">삭제</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, '"');
|
||||
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
342
views/chat.ejs
Normal 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
333
views/index.ejs
Normal 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
335
views/learning-admin.ejs
Normal 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
64
views/learning-edit.ejs
Normal 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>
|
||||
1
views/learning-lectures-partial.ejs
Normal file
1
views/learning-lectures-partial.ejs
Normal file
@@ -0,0 +1 @@
|
||||
<%- include('partials/lecture-cards') %>
|
||||
110
views/learning-viewer.ejs
Normal file
110
views/learning-viewer.ejs
Normal 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
31
views/lecture-link.ejs
Normal 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
31
views/lecture-news.ejs
Normal 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
57
views/lecture-ppt.ejs
Normal 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
32
views/lecture-video.ejs
Normal 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
30
views/lecture-youtube.ejs
Normal 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
236
views/login.ejs
Normal 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
780
views/meeting-minutes.ejs
Normal 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>
|
||||
8
views/partials/admin-button.ejs
Normal file
8
views/partials/admin-button.ejs
Normal 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>
|
||||
<% } %>
|
||||
86
views/partials/admin-token-modal.ejs
Normal file
86
views/partials/admin-token-modal.ejs
Normal 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>
|
||||
54
views/partials/lecture-card.ejs
Normal file
54
views/partials/lecture-card.ejs
Normal 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>
|
||||
3
views/partials/lecture-cards.ejs
Normal file
3
views/partials/lecture-cards.ejs
Normal file
@@ -0,0 +1,3 @@
|
||||
<% (lectures || []).forEach((lecture) => { %>
|
||||
<%- include('lecture-card', { lecture }) %>
|
||||
<% }) %>
|
||||
120
views/partials/nav.ejs
Normal file
120
views/partials/nav.ejs
Normal 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') %>
|
||||
43
views/partials/success-story-card.ejs
Normal file
43
views/partials/success-story-card.ejs
Normal 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
481
views/task-checklist.ejs
Normal 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>
|
||||
Reference in New Issue
Block a user