hgzero/.backup/prototype/11-회의록수정.html
hiondal bb921e10eb 작업 파일 정리 및 실시간 회의록 플로우 추가
- 가파팀 프로토타입 파일 삭제
- 가파팀 유저스토리 삭제
- 실시간 회의록 작성 플로우 설계서 추가 (Mermaid, Markdown)
- 백업 및 데이터 디렉토리 추가
- AI 데이터 샘플 생성 도구 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 14:16:10 +09:00

846 lines
27 KiB
HTML

<!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="common.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200">
<style>
.auto-save-indicator {
position: fixed;
top: 70px;
right: 16px;
padding: 8px 12px;
background: var(--color-white);
border-radius: 20px;
box-shadow: var(--shadow-sm);
font-size: 12px;
color: var(--color-gray-600);
z-index: var(--z-sticky);
display: none;
}
.auto-save-indicator.active {
display: flex;
align-items: center;
gap: 6px;
}
/* NEW - UFR-MEET-055: 섹션 잠금 해제 버튼 스타일 */
.section-lock-area {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--color-gray-50);
border-radius: 8px;
margin-top: 12px;
}
.btn-unlock {
padding: 6px 12px;
font-size: 14px;
background: var(--color-primary-main);
color: var(--color-white);
border: none;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.btn-unlock:hover {
background: var(--color-primary-dark);
}
/* NEW - UFR-COLLAB-020: 충돌 해결 UI 스타일 */
.conflict-banner {
position: fixed;
top: 60px;
left: 16px;
right: 16px;
padding: 12px 16px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid #EF4444;
border-radius: 8px;
z-index: var(--z-sticky);
display: none;
align-items: center;
gap: 12px;
}
.conflict-banner.active {
display: flex;
}
.conflict-icon {
color: #EF4444;
font-size: 24px;
}
.conflict-content {
flex: 1;
}
.conflict-title {
font-weight: 600;
color: #B91C1C;
margin-bottom: 4px;
}
.conflict-description {
font-size: 12px;
color: #DC2626;
}
.btn-resolve {
padding: 6px 12px;
font-size: 14px;
background: #EF4444;
color: var(--color-white);
border: none;
border-radius: 6px;
cursor: pointer;
}
.btn-resolve:hover {
background: #DC2626;
}
/* 충돌 해결 모달 스타일 */
.conflict-resolution {
padding: 0;
}
.conflict-header {
padding: 20px;
background: rgba(239, 68, 68, 0.1);
border-bottom: 1px solid var(--color-gray-200);
}
.conflict-body {
padding: 20px;
}
.conflict-section {
margin-bottom: 20px;
}
.conflict-label {
font-weight: 600;
font-size: 14px;
color: var(--color-gray-700);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.conflict-diff {
padding: 12px;
background: var(--color-gray-50);
border-radius: 8px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.conflict-diff:hover {
border-color: var(--color-primary-light);
}
.conflict-diff.selected {
border-color: var(--color-primary-main);
background: rgba(0, 217, 177, 0.1);
}
.conflict-user {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--color-gray-600);
margin-bottom: 8px;
}
.conflict-time {
font-size: 11px;
color: var(--color-gray-500);
}
.conflict-content-box {
padding: 12px;
background: var(--color-white);
border-radius: 6px;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.conflict-actions {
display: flex;
gap: 8px;
padding: 20px;
border-top: 1px solid var(--color-gray-200);
}
/* 직접 작성 모드 */
.merge-editor {
width: 100%;
min-height: 150px;
padding: 12px;
border: 1px solid var(--color-gray-300);
border-radius: 8px;
font-family: inherit;
font-size: 14px;
resize: vertical;
}
.merge-editor:focus {
outline: none;
border-color: var(--color-primary-main);
}
/* 충돌 표시 배지 */
.conflict-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: rgba(239, 68, 68, 0.2);
color: #B91C1C;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
</style>
</head>
<body>
<div class="page">
<!-- 헤더 -->
<div class="header">
<button class="btn-icon" onclick="handleBack()" aria-label="뒤로가기">
<span class="material-symbols-outlined">arrow_back</span>
</button>
<h1 class="header-title">회의록 수정</h1>
<button class="btn btn-primary btn-sm" onclick="saveMeeting()">저장</button>
</div>
<!-- 자동 저장 인디케이터 -->
<div class="auto-save-indicator" id="autoSaveIndicator">
<span class="material-symbols-outlined" style="font-size: 16px;">check_circle</span>
<span id="autoSaveText">저장됨</span>
</div>
<!-- NEW - 충돌 알림 배너 (UFR-COLLAB-020) -->
<div class="conflict-banner" id="conflictBanner">
<span class="material-symbols-outlined conflict-icon">warning</span>
<div class="conflict-content">
<div class="conflict-title">동시 수정 충돌 감지</div>
<div class="conflict-description" id="conflictDescription">
다른 사용자가 동일한 섹션을 수정했습니다. 충돌을 해결해주세요.
</div>
</div>
<button class="btn-resolve" onclick="showConflictResolution()">
해결하기
</button>
</div>
<!-- 메인 컨텐츠 -->
<div class="content">
<!-- 회의록 목록 모드 -->
<div id="listMode">
<!-- 필터 및 검색 -->
<div class="d-flex gap-2 mb-4">
<select id="statusFilter" class="form-select" style="flex: 1;" onchange="renderMeetingList()">
<option value="all">전체</option>
<option value="draft">작성중</option>
<option value="confirmed">확정완료</option>
</select>
<select id="sortOrder" class="form-select" style="flex: 1;" onchange="renderMeetingList()">
<option value="recent">최신순</option>
<option value="date">회의일시순</option>
<option value="title">제목순</option>
</select>
</div>
<div class="form-group">
<input
type="text"
id="searchInput"
class="form-input"
placeholder="회의 제목, 참석자, 키워드 검색"
oninput="renderMeetingList()"
>
</div>
<!-- 회의록 목록 -->
<div id="meetingList">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
<!-- 수정 모드 -->
<div id="editMode" style="display: none;">
<!-- 기본 정보 수정 -->
<div class="card mb-4">
<h3 class="text-h5 mb-3">기본 정보</h3>
<div class="form-group">
<label for="editTitle" class="form-label required">회의 제목</label>
<input type="text" id="editTitle" class="form-input" maxlength="100">
</div>
<div class="d-flex gap-2">
<div class="form-group" style="flex: 1;">
<label for="editDate" class="form-label">날짜</label>
<input type="date" id="editDate" class="form-input">
</div>
<div class="form-group" style="flex: 1;">
<label for="editStartTime" class="form-label">시작</label>
<input type="time" id="editStartTime" class="form-input">
</div>
<div class="form-group" style="flex: 1;">
<label for="editEndTime" class="form-label">종료</label>
<input type="time" id="editEndTime" class="form-input">
</div>
</div>
</div>
<!-- 섹션별 수정 -->
<div id="editSectionList">
<!-- JavaScript로 동적 생성 -->
</div>
<!-- 하단 액션 -->
<div class="d-flex gap-2 mt-4">
<button class="btn btn-secondary" onclick="cancelEdit()">
취소
</button>
<button class="btn btn-primary" style="flex: 1;" onclick="saveMeeting()">
저장
</button>
</div>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
if (!NavigationHelper.requireAuth()) {}
const currentUser = StorageManager.getCurrentUser();
const meetingId = NavigationHelper.getQueryParam('id');
let currentMeeting = null;
let isEditMode = false;
let autoSaveTimer = null;
let hasUnsavedChanges = false;
// NEW - UFR-COLLAB-020: 충돌 관리 변수
let conflicts = [];
let currentConflict = null;
// 회의록 목록 렌더링
function renderMeetingList() {
const meetings = StorageManager.getMeetings();
const myMeetings = meetings.filter(m =>
m.createdBy === currentUser.id || m.attendees.includes(currentUser.name)
);
// 필터링
const statusFilter = document.getElementById('statusFilter').value;
let filtered = myMeetings;
if (statusFilter !== 'all') {
filtered = myMeetings.filter(m => m.status === statusFilter);
}
// 검색
const searchQuery = document.getElementById('searchInput').value.toLowerCase();
if (searchQuery) {
filtered = filtered.filter(m =>
m.title.toLowerCase().includes(searchQuery) ||
m.attendees.some(a => a.toLowerCase().includes(searchQuery))
);
}
// 정렬
const sortOrder = document.getElementById('sortOrder').value;
if (sortOrder === 'recent') {
filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
} else if (sortOrder === 'date') {
filtered.sort((a, b) => new Date(b.date) - new Date(a.date));
} else if (sortOrder === 'title') {
filtered.sort((a, b) => a.title.localeCompare(b.title));
}
// 렌더링
const container = document.getElementById('meetingList');
if (filtered.length === 0) {
container.innerHTML = '<p class="text-body text-gray text-center" style="padding: 48px 0;">회의록이 없습니다</p>';
return;
}
container.innerHTML = filtered.map(meeting => `
<div class="meeting-item" onclick="editMeetingById('${meeting.id}')">
<div style="flex: 1;">
<h3 class="text-h5">${meeting.title}</h3>
<p class="text-caption text-gray">${Utils.formatDate(meeting.date)} ${meeting.startTime || ''} · ${meeting.attendees?.length || 0}명</p>
<p class="text-caption text-gray mt-1">최종 수정: ${Utils.formatTimeAgo(meeting.updatedAt)}</p>
</div>
<div class="d-flex flex-column align-end gap-2">
${meeting.status === 'confirmed' ? '<span class="badge badge-confirmed">확정완료</span>' : '<span class="badge badge-draft">작성중</span>'}
${meeting.createdBy === currentUser.id ? '' : '<span class="text-caption text-gray">조회 전용</span>'}
</div>
</div>
`).join('');
}
// 회의록 수정 모드로 전환
function editMeetingById(id) {
const meeting = StorageManager.getMeetingById(id);
if (!meeting) {
UIComponents.showToast('회의록을 찾을 수 없습니다', 'error');
return;
}
// 권한 체크
const canEdit = meeting.createdBy === currentUser.id;
if (!canEdit) {
UIComponents.showToast('본인이 작성한 회의록만 수정할 수 있습니다', 'warning');
setTimeout(() => {
NavigationHelper.navigate('MEETING_DETAIL', { id });
}, 1500);
return;
}
currentMeeting = { ...meeting };
isEditMode = true;
// 확정완료 → 작성중으로 변경
if (currentMeeting.status === 'confirmed') {
currentMeeting.status = 'draft';
UIComponents.showToast('확정완료 회의록이 작성중으로 변경되었습니다', 'info');
}
// UI 전환
document.getElementById('listMode').style.display = 'none';
document.getElementById('editMode').style.display = 'block';
// 기본 정보 설정
document.getElementById('editTitle').value = currentMeeting.title;
document.getElementById('editDate').value = currentMeeting.date;
document.getElementById('editStartTime').value = currentMeeting.startTime || '';
document.getElementById('editEndTime').value = currentMeeting.endTime || '';
// 섹션 렌더링
renderEditSections();
// NEW - 충돌 감지 (UFR-COLLAB-020)
detectConflicts();
// 자동 저장 시작
startAutoSave();
}
// NEW - UFR-COLLAB-020: 충돌 감지
function detectConflicts() {
// 시뮬레이션: 30% 확률로 충돌 발생
if (Math.random() < 0.3 && currentMeeting.sections.length > 0) {
const conflictSectionIndex = Math.floor(Math.random() * currentMeeting.sections.length);
const conflictSection = currentMeeting.sections[conflictSectionIndex];
const otherUsers = DUMMY_USERS.filter(u => u.id !== currentUser.id);
const conflictUser = otherUsers[Math.floor(Math.random() * otherUsers.length)];
conflicts.push({
sectionId: conflictSection.id,
sectionName: conflictSection.name,
myVersion: {
content: conflictSection.content || '(내용 없음)',
modifiedAt: new Date().toISOString(),
modifiedBy: currentUser.name
},
theirVersion: {
content: generateRandomConflictContent(conflictSection.content),
modifiedAt: new Date(Date.now() - 5000).toISOString(),
modifiedBy: conflictUser.name
}
});
showConflictBanner();
}
}
// 충돌 내용 생성 (시뮬레이션)
function generateRandomConflictContent(originalContent) {
if (!originalContent) return '다른 사용자가 추가한 내용입니다.';
const variations = [
originalContent + '\n\n추가 논의사항: 예산 검토 필요',
originalContent.replace('결정', '잠정 결정'),
'수정된 내용:\n' + originalContent,
originalContent + '\n\n※ 재논의 필요'
];
return variations[Math.floor(Math.random() * variations.length)];
}
// 충돌 배너 표시
function showConflictBanner() {
const banner = document.getElementById('conflictBanner');
const description = document.getElementById('conflictDescription');
if (conflicts.length > 0) {
description.textContent = `${conflicts.length}개 섹션에서 충돌이 감지되었습니다. 충돌을 해결해주세요.`;
banner.classList.add('active');
} else {
banner.classList.remove('active');
}
}
// NEW - UFR-COLLAB-020: 충돌 해결 모달 표시
function showConflictResolution() {
if (conflicts.length === 0) return;
currentConflict = conflicts[0];
let selectedVersion = 'mine'; // 기본값: 내 버전
const modalContent = `
<div class="conflict-resolution">
<div class="conflict-header">
<h3 class="text-h5" style="color: #B91C1C;">
<span class="material-symbols-outlined" style="vertical-align: middle;">warning</span>
충돌 해결 필요
</h3>
<p class="text-caption text-gray mt-2">
"${currentConflict.sectionName}" 섹션에서 충돌이 감지되었습니다. 최종 버전을 선택하거나 직접 작성하세요.
</p>
</div>
<div class="conflict-body">
<!-- 내 버전 -->
<div class="conflict-section">
<div class="conflict-label">
<span class="material-symbols-outlined" style="color: var(--color-primary-main);">person</span>
내 수정 내용
</div>
<div class="conflict-diff selected" id="myVersion" onclick="selectVersion('mine')">
<div class="conflict-user">
<span class="material-symbols-outlined" style="font-size: 14px;">account_circle</span>
${currentConflict.myVersion.modifiedBy}
<span class="conflict-time">· ${Utils.formatTimeAgo(currentConflict.myVersion.modifiedAt)}</span>
</div>
<div class="conflict-content-box">${currentConflict.myVersion.content}</div>
</div>
</div>
<!-- 타인 버전 -->
<div class="conflict-section">
<div class="conflict-label">
<span class="material-symbols-outlined" style="color: #F59E0B;">group</span>
다른 사용자 수정 내용
</div>
<div class="conflict-diff" id="theirVersion" onclick="selectVersion('theirs')">
<div class="conflict-user">
<span class="material-symbols-outlined" style="font-size: 14px;">account_circle</span>
${currentConflict.theirVersion.modifiedBy}
<span class="conflict-time">· ${Utils.formatTimeAgo(currentConflict.theirVersion.modifiedAt)}</span>
</div>
<div class="conflict-content-box">${currentConflict.theirVersion.content}</div>
</div>
</div>
<!-- 직접 작성 -->
<div class="conflict-section">
<div class="conflict-label">
<span class="material-symbols-outlined" style="color: #10B981;">edit</span>
직접 작성하기
</div>
<div class="conflict-diff" id="manualVersion" onclick="selectVersion('manual')">
<textarea
class="merge-editor"
id="manualContent"
placeholder="양쪽 내용을 참고하여 직접 작성하세요..."
>${currentConflict.myVersion.content}</textarea>
</div>
</div>
</div>
<div class="conflict-actions">
<button class="btn btn-secondary" onclick="UIComponents.closeModal()">
취소
</button>
<button class="btn btn-primary" style="flex: 1;" onclick="resolveConflict()">
이 버전으로 확정
</button>
</div>
</div>
`;
UIComponents.showModal('충돌 해결', modalContent, null, 'large');
// 버전 선택 함수
window.selectVersion = function(version) {
selectedVersion = version;
document.getElementById('myVersion').classList.remove('selected');
document.getElementById('theirVersion').classList.remove('selected');
document.getElementById('manualVersion').classList.remove('selected');
if (version === 'mine') {
document.getElementById('myVersion').classList.add('selected');
} else if (version === 'theirs') {
document.getElementById('theirVersion').classList.add('selected');
} else if (version === 'manual') {
document.getElementById('manualVersion').classList.add('selected');
document.getElementById('manualContent').focus();
}
};
// 충돌 해결 함수
window.resolveConflict = function() {
let finalContent = '';
if (selectedVersion === 'mine') {
finalContent = currentConflict.myVersion.content;
} else if (selectedVersion === 'theirs') {
finalContent = currentConflict.theirVersion.content;
} else if (selectedVersion === 'manual') {
finalContent = document.getElementById('manualContent').value;
}
// 섹션 내용 업데이트
const section = currentMeeting.sections.find(s => s.id === currentConflict.sectionId);
if (section) {
section.content = finalContent;
// textarea 업데이트
const textarea = document.querySelector(`textarea[data-section-id="${currentConflict.sectionId}"]`);
if (textarea) {
textarea.value = finalContent;
}
}
// 충돌 목록에서 제거
conflicts.shift();
UIComponents.closeModal();
UIComponents.showToast('충돌이 해결되었습니다', 'success');
// 남은 충돌 처리
if (conflicts.length > 0) {
setTimeout(() => {
showConflictResolution();
}, 500);
} else {
showConflictBanner();
markAsChanged();
}
};
}
// 섹션 수정 렌더링
function renderEditSections() {
const container = document.getElementById('editSectionList');
container.innerHTML = currentMeeting.sections.map((section, index) => {
const hasConflict = conflicts.some(c => c.sectionId === section.id);
return `
<div class="card mb-4">
<div class="d-flex justify-between align-center mb-3">
<div class="d-flex align-center gap-2">
<h3 class="text-h5">${section.name}</h3>
${hasConflict ? '<span class="conflict-badge"><span class="material-symbols-outlined" style="font-size: 14px;">warning</span> 충돌</span>' : ''}
</div>
${section.locked ? '<span class="material-symbols-outlined" style="color: var(--color-gray-600);">lock</span>' : ''}
</div>
<textarea
class="form-textarea"
rows="5"
data-section-id="${section.id}"
onchange="markAsChanged()"
${section.locked ? 'disabled' : ''}
>${section.content || ''}</textarea>
${section.locked ? `
<!-- NEW - UFR-MEET-055: 섹션 잠금 해제 버튼 -->
<div class="section-lock-area">
<span class="material-symbols-outlined" style="color: #F59E0B; font-size: 18px;">lock</span>
<div style="flex: 1;">
<p class="text-caption text-gray" style="margin: 0;">
이 섹션은 잠겨있습니다. 수정하려면 잠금을 해제하세요.
</p>
</div>
<button class="btn-unlock" onclick="unlockSection('${section.id}')">
<span class="material-symbols-outlined" style="font-size: 16px;">lock_open</span>
잠금 해제
</button>
</div>
` : ''}
</div>
`;
}).join('');
}
// NEW - UFR-MEET-055: 섹션 잠금 해제
function unlockSection(sectionId) {
UIComponents.confirm(
'이 섹션의 잠금을 해제하시겠습니까? 해제 후에는 내용을 수정할 수 있습니다.',
() => {
const section = currentMeeting.sections.find(s => s.id === sectionId);
if (section) {
section.locked = false;
renderEditSections();
UIComponents.showToast('섹션 잠금이 해제되었습니다', 'success');
markAsChanged();
}
},
() => {}
);
}
// 변경사항 표시
function markAsChanged() {
hasUnsavedChanges = true;
}
// 자동 저장 시작
function startAutoSave() {
if (autoSaveTimer) clearInterval(autoSaveTimer);
autoSaveTimer = setInterval(() => {
if (hasUnsavedChanges) {
autoSaveMeeting();
}
}, 30000); // 30초마다 자동 저장
}
// 자동 저장
function autoSaveMeeting() {
const indicator = document.getElementById('autoSaveIndicator');
document.getElementById('autoSaveText').textContent = '저장 중...';
indicator.classList.add('active');
// 데이터 수집
collectMeetingData();
// 저장
setTimeout(() => {
StorageManager.updateMeeting(currentMeeting.id, currentMeeting);
hasUnsavedChanges = false;
document.getElementById('autoSaveText').textContent = '저장됨';
setTimeout(() => {
indicator.classList.remove('active');
}, 2000);
}, 500);
}
// 회의록 데이터 수집
function collectMeetingData() {
currentMeeting.title = document.getElementById('editTitle').value;
currentMeeting.date = document.getElementById('editDate').value;
currentMeeting.startTime = document.getElementById('editStartTime').value;
currentMeeting.endTime = document.getElementById('editEndTime').value;
// 섹션 내용 수집
currentMeeting.sections.forEach(section => {
const textarea = document.querySelector(`textarea[data-section-id="${section.id}"]`);
if (textarea) {
section.content = textarea.value;
}
});
currentMeeting.updatedAt = new Date().toISOString();
}
// 회의록 저장
function saveMeeting() {
if (!currentMeeting) return;
// 충돌 확인
if (conflicts.length > 0) {
UIComponents.showToast('먼저 충돌을 해결해주세요', 'warning');
showConflictResolution();
return;
}
collectMeetingData();
UIComponents.showLoading('저장하는 중...');
setTimeout(() => {
StorageManager.updateMeeting(currentMeeting.id, currentMeeting);
hasUnsavedChanges = false;
UIComponents.hideLoading();
UIComponents.showToast('회의록이 저장되었습니다', 'success');
setTimeout(() => {
window.location.href = '12-회의록상세조회.html';
}, 1000);
}, 800);
}
// 수정 취소
function cancelEdit() {
if (hasUnsavedChanges) {
UIComponents.confirm(
'저장하지 않은 변경사항이 있습니다. 정말 취소하시겠습니까?',
() => {
resetEditMode();
},
() => {}
);
} else {
resetEditMode();
}
}
// 수정 모드 리셋
function resetEditMode() {
if (autoSaveTimer) clearInterval(autoSaveTimer);
currentMeeting = null;
isEditMode = false;
hasUnsavedChanges = false;
conflicts = [];
currentConflict = null;
document.getElementById('listMode').style.display = 'block';
document.getElementById('editMode').style.display = 'none';
document.getElementById('conflictBanner').classList.remove('active');
renderMeetingList();
}
// 뒤로가기 처리
function handleBack() {
if (isEditMode) {
cancelEdit();
} else {
NavigationHelper.navigate('DASHBOARD');
}
}
// 페이지 이탈 방지
window.addEventListener('beforeunload', (e) => {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
});
// 초기화
if (meetingId) {
editMeetingById(meetingId);
} else {
renderMeetingList();
}
</script>
</body>
</html>