Files
ai_platform/views/task-checklist.ejs

483 lines
20 KiB
Plaintext

<!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="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>