Files
ai_platform/views/meeting-minutes.ejs
dsyoon 9f647cd783 feat(ui): 파비콘을 xavis-logo.png로 통일
- partials/favicon.ejs로 icon·apple-touch-icon 링크
- 전 페이지 head에 include, /favicon.ico는 동일 PNG 제공
- 인라인 403 HTML에도 favicon 링크

Made-with: Cursor
2026-04-05 22:33:36 +09:00

782 lines
41 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<%- include('partials/favicon') %>
<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>