482 lines
20 KiB
Plaintext
482 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" />
|
|
<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>
|