프로젝트 구조 정리 및 프로토타입 업데이트

- design-last, design-v1 디렉토리 정리
- UI/UX 프로토타입 개선 및 통합
- 스타일 가이드 및 테스트 결과 업데이트
- 유저스토리 목록 추가
- 불필요한 문서 제거

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Minseo-Jo 2025-10-21 10:12:18 +09:00
parent 10a071c2e5
commit bd34b40991
79 changed files with 12954 additions and 37541 deletions

View File

@ -25,7 +25,10 @@
"Bash(git add design-last/uiux/)",
"Bash(git commit -m \"$(cat <<''EOF''\nUI/UX 설계서 작성 완료\n\n- Mobile First 설계 원칙에 따라 UI/UX 설계서 작성\n- 11개 주요 화면 설계 (로그인, 대시보드, 회의예약, 템플릿선택, 회의진행, 검증완료, 회의종료, 회의록공유, Todo관리, 회의록상세조회, 회의록수정)\n- 화면별 상세 설계 (개요, 기능, UI 구성, 인터랙션, 데이터 요구사항, 에러 처리)\n- 화면 간 사용자 플로우 및 네비게이션 전략 정의\n- 반응형 설계 전략 (Mobile/Tablet/Desktop 브레이크포인트)\n- WCAG 2.1 Level AA 접근성 보장 방안\n- 성능 최적화 방안 (코드 스플리팅, 캐싱, WebSocket 최적화)\n- 유저스토리와 1:1 매칭 확인\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
"Bash(git add \"design-last/uiux_다람지/\")",
"Bash(git commit -m \"$(cat <<''EOF''\n프로토타입 개발 완료 (다람지팀)\n\n- 스타일 가이드 작성 (style-guide.md)\n - 14개 섹션으로 구성된 완전한 디자인 시스템\n - Mobile First 철학 및 접근성 기준 정의\n \n- 공통 리소스 개발\n - common.css: 1,007줄 완전한 반응형 스타일시트\n - common.js: 400+줄 유틸리티 라이브러리\n \n- 11개 프로토타입 화면 개발\n - 01-로그인: 사용자 인증\n - 02-대시보드: 메인 대시보드\n - 03-회의예약: 회의 생성 폼\n - 04-템플릿선택: 회의록 템플릿 선택\n - 05-회의진행: 실시간 회의 진행\n - 06-검증완료: 섹션별 검증\n - 07-회의종료: 회의 통계\n - 08-회의록공유: 공유 설정\n - 09-Todo관리: Todo 목록 및 진행 관리\n - 10-회의록상세조회: 회의록 상세 보기\n - 11-회의록수정: 지난 회의록 수정\n \n- 주요 특징\n - Mobile First 반응형 디자인\n - WCAG 2.1 Level AA 접근성 준수\n - 실제 동작하는 인터랙션 구현\n - 일관된 예제 데이터 활용\n - 완전한 사용자 플로우 구현\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")"
"Bash(git commit -m \"$(cat <<''EOF''\n프로토타입 개발 완료 (다람지팀)\n\n- 스타일 가이드 작성 (style-guide.md)\n - 14개 섹션으로 구성된 완전한 디자인 시스템\n - Mobile First 철학 및 접근성 기준 정의\n \n- 공통 리소스 개발\n - common.css: 1,007줄 완전한 반응형 스타일시트\n - common.js: 400+줄 유틸리티 라이브러리\n \n- 11개 프로토타입 화면 개발\n - 01-로그인: 사용자 인증\n - 02-대시보드: 메인 대시보드\n - 03-회의예약: 회의 생성 폼\n - 04-템플릿선택: 회의록 템플릿 선택\n - 05-회의진행: 실시간 회의 진행\n - 06-검증완료: 섹션별 검증\n - 07-회의종료: 회의 통계\n - 08-회의록공유: 공유 설정\n - 09-Todo관리: Todo 목록 및 진행 관리\n - 10-회의록상세조회: 회의록 상세 보기\n - 11-회의록수정: 지난 회의록 수정\n \n- 주요 특징\n - Mobile First 반응형 디자인\n - WCAG 2.1 Level AA 접근성 준수\n - 실제 동작하는 인터랙션 구현\n - 일관된 예제 데이터 활용\n - 완전한 사용자 플로우 구현\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
"Bash(git add \"design-last/uiux_다람지/prototype/01-로그인.html\")",
"Bash(git commit -m \"로그인 페이지 버그 수정\n\n- CSS keyframes를 script 태그에서 style 태그로 이동\n- FormValidator 검증을 간단한 검증으로 변경하여 안정성 향상\n- 로그인 후 대시보드 이동 기능 정상화\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\")",
"Bash(git commit -m \"프로젝트 구조 정리 및 프로토타입 업데이트\n\n- design-last, design-v1 디렉토리 정리\n- UI/UX 프로토타입 개선 및 통합\n- 스타일 가이드 및 테스트 결과 업데이트\n- 유저스토리 목록 추가\n- 불필요한 문서 제거\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\")"
],
"deny": [],
"ask": []

View File

@ -1,541 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="회의록 작성 및 공유 개선 서비스 - 로그인">
<title>로그인 - 회의록 작성 및 공유 개선 서비스</title>
<!-- CSS -->
<link rel="stylesheet" href="common.css">
<!-- Pretendard Font -->
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<style>
/* 로그인 화면 특화 스타일 */
.login-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: var(--space-4);
background-color: var(--bg-secondary);
}
.login-box {
width: 100%;
max-width: 400px;
background-color: var(--bg-primary);
border-radius: var(--radius-large);
padding: var(--space-8);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
animation: fade-in var(--duration-normal) ease-out;
}
@media (min-width: 768px) {
.login-box {
padding: var(--space-10);
}
}
/* 로고 영역 */
.login-logo {
text-align: center;
margin-bottom: var(--space-8);
}
.login-logo-icon {
width: 80px;
height: 80px;
margin: 0 auto var(--space-4);
background: linear-gradient(135deg, var(--primary-500), var(--primary-700));
border-radius: var(--radius-large);
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
}
.login-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--space-2);
}
@media (min-width: 768px) {
.login-title {
font-size: 1.75rem;
}
}
.login-subtitle {
font-size: 0.875rem;
color: var(--text-secondary);
}
/* 폼 영역 */
.login-form {
margin-bottom: var(--space-4);
}
.login-form .input-group {
margin-bottom: var(--space-4);
}
.login-form .input-group:last-of-type {
margin-bottom: var(--space-6);
}
.login-button {
width: 100%;
margin-bottom: var(--space-4);
}
/* LDAP 안내 */
.ldap-notice {
text-align: center;
padding: var(--space-3);
background-color: var(--info-50);
border-radius: var(--radius-small);
border: var(--border-thin) solid var(--info-100);
}
.ldap-notice-text {
font-size: 0.75rem;
color: var(--info-700);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.ldap-notice-icon {
font-size: 1rem;
}
/* 로딩 상태 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
}
.loading-overlay.active {
display: flex;
}
.loading-content {
background-color: var(--bg-primary);
padding: var(--space-6);
border-radius: var(--radius-large);
text-align: center;
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.2);
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid var(--gray-200);
border-top-color: var(--primary-500);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto var(--space-4);
}
.loading-text {
font-size: 0.875rem;
color: var(--text-secondary);
}
/* 입력 필드 포커스 효과 강화 */
.input-field:focus {
border-color: var(--primary-500);
box-shadow: 0 0 0 3px rgba(0, 200, 150, 0.1);
transition: all var(--duration-fast) ease-in-out;
}
/* 에러 메시지 스타일 */
.input-error-message {
display: block;
min-height: 18px;
font-size: 0.75rem;
color: var(--error-500);
margin-top: var(--space-1);
}
/* 접근성: Skip to main content */
.skip-to-main {
position: absolute;
top: -40px;
left: 0;
background: var(--primary-500);
color: white;
padding: var(--space-2) var(--space-4);
text-decoration: none;
z-index: 100;
}
.skip-to-main:focus {
top: 0;
}
</style>
</head>
<body>
<!-- Skip to main content (접근성) -->
<a href="#main-content" class="skip-to-main">본문으로 바로가기</a>
<!-- 로딩 오버레이 -->
<div class="loading-overlay" id="loadingOverlay" role="status" aria-live="polite" aria-label="로그인 진행중">
<div class="loading-content">
<div class="loading-spinner"></div>
<p class="loading-text">로그인 중입니다...</p>
</div>
</div>
<!-- 메인 컨텐츠 -->
<main id="main-content" class="login-container">
<div class="login-box">
<!-- 로고 및 타이틀 -->
<div class="login-logo">
<div class="login-logo-icon" role="img" aria-label="회의록 서비스 로고">
📝
</div>
<h1 class="login-title">회의록 작성 서비스</h1>
<p class="login-subtitle">효율적이고 정확한 회의록, 누구나 쉽게</p>
</div>
<!-- 로그인 폼 -->
<form id="loginForm" class="login-form" novalidate>
<!-- 사번 입력 -->
<div class="input-group">
<label for="employeeId" class="input-label required">사번</label>
<input
type="text"
id="employeeId"
name="employeeId"
class="input-field"
placeholder="사번을 입력하세요"
autocomplete="username"
required
aria-label="사번"
aria-describedby="employeeIdError"
aria-required="true"
>
<span id="employeeIdError" class="input-error-message" role="alert"></span>
</div>
<!-- 비밀번호 입력 -->
<div class="input-group">
<label for="password" class="input-label required">비밀번호</label>
<input
type="password"
id="password"
name="password"
class="input-field"
placeholder="비밀번호를 입력하세요"
autocomplete="current-password"
required
aria-label="비밀번호"
aria-describedby="passwordError"
aria-required="true"
>
<span id="passwordError" class="input-error-message" role="alert"></span>
</div>
<!-- 로그인 버튼 -->
<button
type="submit"
class="button button-primary button-large login-button"
id="loginButton"
>
로그인
</button>
</form>
<!-- LDAP 인증 안내 -->
<div class="ldap-notice" role="note">
<p class="ldap-notice-text">
<span class="ldap-notice-icon" aria-hidden="true">🔒</span>
<span>LDAP 연동 인증 시스템</span>
</p>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="common.js"></script>
<script>
/**
* 로그인 페이지 초기화 및 이벤트 핸들러
*/
(function() {
'use strict';
// DOM 엘리먼트
const loginForm = document.getElementById('loginForm');
const employeeIdInput = document.getElementById('employeeId');
const passwordInput = document.getElementById('password');
const loginButton = document.getElementById('loginButton');
const loadingOverlay = document.getElementById('loadingOverlay');
// 에러 메시지 엘리먼트
const employeeIdError = document.getElementById('employeeIdError');
const passwordError = document.getElementById('passwordError');
// 예제 로그인 정보
const VALID_CREDENTIALS = {
employeeId: 'E2024001',
password: 'password123'
};
/**
* 입력 필드 실시간 검증
*/
function setupRealtimeValidation() {
// 사번 입력 검증
employeeIdInput.addEventListener('blur', function() {
validateEmployeeId();
});
employeeIdInput.addEventListener('input', function() {
// 입력 중에는 에러 클래스 제거
employeeIdInput.classList.remove('error');
employeeIdError.textContent = '';
});
// 비밀번호 입력 검증
passwordInput.addEventListener('blur', function() {
validatePassword();
});
passwordInput.addEventListener('input', function() {
// 입력 중에는 에러 클래스 제거
passwordInput.classList.remove('error');
passwordError.textContent = '';
});
// Enter 키로 로그인 실행
employeeIdInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
passwordInput.focus();
}
});
passwordInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
loginForm.dispatchEvent(new Event('submit'));
}
});
}
/**
* 사번 검증
*/
function validateEmployeeId() {
const value = employeeIdInput.value.trim();
if (!value) {
showError(employeeIdInput, employeeIdError, '사번을 입력해주세요');
return false;
}
// 사번 형식 검증 (E + 7자리 숫자)
const employeeIdPattern = /^E\d{7}$/;
if (!employeeIdPattern.test(value)) {
showError(employeeIdInput, employeeIdError, '올바른 사번 형식이 아닙니다 (예: E2024001)');
return false;
}
clearError(employeeIdInput, employeeIdError);
return true;
}
/**
* 비밀번호 검증
*/
function validatePassword() {
const value = passwordInput.value;
if (!value) {
showError(passwordInput, passwordError, '비밀번호를 입력해주세요');
return false;
}
if (value.length < 6) {
showError(passwordInput, passwordError, '비밀번호는 최소 6자 이상이어야 합니다');
return false;
}
clearError(passwordInput, passwordError);
return true;
}
/**
* 에러 표시
*/
function showError(inputElement, errorElement, message) {
inputElement.classList.add('error');
errorElement.textContent = message;
inputElement.setAttribute('aria-invalid', 'true');
}
/**
* 에러 제거
*/
function clearError(inputElement, errorElement) {
inputElement.classList.remove('error');
errorElement.textContent = '';
inputElement.setAttribute('aria-invalid', 'false');
}
/**
* 로딩 표시
*/
function showLoading() {
loadingOverlay.classList.add('active');
loginButton.disabled = true;
employeeIdInput.disabled = true;
passwordInput.disabled = true;
}
/**
* 로딩 숨김
*/
function hideLoading() {
loadingOverlay.classList.remove('active');
loginButton.disabled = false;
employeeIdInput.disabled = false;
passwordInput.disabled = false;
}
/**
* 로그인 처리
*/
function handleLogin(employeeId, password) {
// 로딩 표시
showLoading();
// 실제 환경에서는 API 호출
// 여기서는 시뮬레이션 (1.5초 지연)
setTimeout(function() {
// 인증 검증
if (employeeId === VALID_CREDENTIALS.employeeId &&
password === VALID_CREDENTIALS.password) {
// 로그인 성공
// 사용자 정보 저장
const userData = {
id: 1,
employeeId: employeeId,
name: '김민준',
email: 'minjun.kim@company.com',
role: 'Product Owner',
loginTime: new Date().toISOString()
};
// 로컬 스토리지에 저장
localStorage.setItem('currentUser', JSON.stringify(userData));
localStorage.setItem('isLoggedIn', 'true');
localStorage.setItem('authToken', 'mock-jwt-token-' + Date.now());
// 성공 메시지 표시
showToast('로그인 성공! 대시보드로 이동합니다', 'success', 1500);
// 대시보드로 이동 (1.5초 후)
setTimeout(function() {
navigateTo('02-대시보드.html');
}, 1500);
} else {
// 로그인 실패
hideLoading();
// 실패 메시지 표시
showToast('사번 또는 비밀번호가 올바르지 않습니다', 'error', 3000);
// 비밀번호 필드 초기화 및 포커스
passwordInput.value = '';
passwordInput.focus();
// 입력 필드에 에러 표시
showError(employeeIdInput, employeeIdError, '');
showError(passwordInput, passwordError, '인증에 실패했습니다');
}
}, 1500);
}
/**
* 폼 제출 이벤트 핸들러
*/
function handleSubmit(e) {
e.preventDefault();
// 입력 검증
const isEmployeeIdValid = validateEmployeeId();
const isPasswordValid = validatePassword();
if (!isEmployeeIdValid || !isPasswordValid) {
// 검증 실패 시 첫 번째 에러 필드로 포커스
if (!isEmployeeIdValid) {
employeeIdInput.focus();
} else if (!isPasswordValid) {
passwordInput.focus();
}
return;
}
// 로그인 처리
const employeeId = employeeIdInput.value.trim();
const password = passwordInput.value;
handleLogin(employeeId, password);
}
/**
* 초기화
*/
function init() {
// 이미 로그인되어 있는지 확인
const isLoggedIn = localStorage.getItem('isLoggedIn');
if (isLoggedIn === 'true') {
// 이미 로그인된 경우 대시보드로 리다이렉트
navigateTo('02-대시보드.html');
return;
}
// 이벤트 리스너 등록
setupRealtimeValidation();
loginForm.addEventListener('submit', handleSubmit);
// 첫 번째 입력 필드에 포커스
employeeIdInput.focus();
// 페이드인 효과
document.body.style.opacity = '1';
// 개발 모드 안내 (콘솔)
console.log('%c로그인 테스트 정보', 'color: #00C896; font-size: 14px; font-weight: bold;');
console.log('사번: E2024001');
console.log('비밀번호: password123');
}
// DOM 로드 완료 시 초기화
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>

View File

@ -1,709 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="회의록 작성 및 공유 개선 서비스 - 대시보드">
<title>대시보드 - 회의록 작성 서비스</title>
<!-- CSS -->
<link rel="stylesheet" href="common.css">
<!-- Pretendard Font -->
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<style>
body {
padding-bottom: 80px; /* 하단 네비게이션 공간 확보 */
}
/* 헤더 */
.header {
position: sticky;
top: 0;
background-color: var(--bg-primary);
border-bottom: var(--border-thin) solid var(--gray-200);
padding: var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
z-index: 10;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.header-left {
display: flex;
align-items: center;
gap: var(--space-3);
}
.header-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
}
.header-right {
display: flex;
align-items: center;
gap: var(--space-2);
}
.icon-button {
position: relative;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
background-color: transparent;
border: none;
cursor: pointer;
transition: background-color var(--duration-instant) ease-in-out;
}
.icon-button:hover {
background-color: var(--gray-100);
}
.notification-badge {
position: absolute;
top: 4px;
right: 4px;
width: 8px;
height: 8px;
background-color: var(--error-500);
border-radius: 50%;
}
/* 빠른 액션 */
.quick-actions {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.action-button-primary {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
height: 56px;
font-size: 1rem;
font-weight: 600;
border-radius: var(--radius-medium);
}
.ongoing-meeting {
background-color: var(--error-50);
border: var(--border-thin) solid var(--error-200);
border-radius: var(--radius-medium);
padding: var(--space-3);
display: flex;
align-items: center;
gap: var(--space-3);
cursor: pointer;
transition: all var(--duration-fast) ease-in-out;
}
.ongoing-meeting:hover {
background-color: var(--error-100);
}
.ongoing-indicator {
width: 12px;
height: 12px;
background-color: var(--error-500);
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
flex-shrink: 0;
}
.ongoing-text {
flex: 1;
font-size: 0.875rem;
color: var(--error-700);
font-weight: 600;
}
/* 필터 영역 */
.filters {
padding: 0 var(--space-4) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.filter-row {
display: flex;
gap: var(--space-2);
}
.filter-select {
flex: 1;
height: 40px;
padding: 0 var(--space-3);
border: var(--border-thin) solid var(--gray-200);
border-radius: var(--radius-small);
background-color: var(--bg-primary);
color: var(--text-secondary);
font-size: 0.875rem;
cursor: pointer;
}
.search-input-wrapper {
position: relative;
flex: 1;
}
.search-icon {
position: absolute;
left: var(--space-3);
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
}
.search-input {
width: 100%;
height: 40px;
padding: 0 var(--space-3) 0 40px;
border: var(--border-thin) solid var(--gray-200);
border-radius: var(--radius-small);
font-size: 0.875rem;
}
/* 섹션 헤더 */
.section-header {
padding: var(--space-4);
padding-bottom: var(--space-3);
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.section-count {
font-size: 0.875rem;
color: var(--text-tertiary);
}
/* 회의록 목록 */
.meeting-list {
padding: 0 var(--space-4) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.meeting-card {
background-color: var(--bg-primary);
border: var(--border-thin) solid var(--gray-200);
border-radius: var(--radius-medium);
padding: var(--space-4);
cursor: pointer;
transition: all var(--duration-fast) ease-in-out;
}
.meeting-card:hover {
border-color: var(--primary-500);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.meeting-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-2);
}
.meeting-icon {
font-size: 1.25rem;
margin-right: var(--space-2);
}
.meeting-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.meeting-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: var(--space-3);
}
.meeting-meta-item {
display: flex;
align-items: center;
gap: var(--space-1);
}
.meeting-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.meeting-attendees {
font-size: 0.75rem;
color: var(--text-tertiary);
}
.progress-wrapper {
width: 100%;
margin-top: var(--space-2);
}
.progress-label {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: var(--space-1);
}
/* 하단 네비게이션 */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: var(--bg-primary);
border-top: var(--border-thin) solid var(--gray-200);
display: flex;
justify-content: space-around;
padding: var(--space-2) 0;
z-index: 100;
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.05);
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding: var(--space-2);
background: none;
border: none;
cursor: pointer;
transition: color var(--duration-instant) ease-in-out;
color: var(--text-tertiary);
}
.nav-item.active {
color: var(--primary-500);
}
.nav-item:hover {
color: var(--primary-600);
}
.nav-icon {
font-size: 1.5rem;
}
.nav-label {
font-size: 0.75rem;
font-weight: 500;
}
/* 상세 모달 */
.detail-modal .modal {
max-width: 600px;
max-height: 85vh;
}
.detail-section {
margin-bottom: var(--space-4);
}
.detail-section-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.detail-content {
font-size: 0.875rem;
color: var(--text-primary);
line-height: 1.75;
white-space: pre-line;
}
.detail-actions {
display: flex;
gap: var(--space-2);
margin-top: var(--space-6);
}
/* 빈 상태 */
.empty-state {
padding: var(--space-16) var(--space-4);
text-align: center;
}
.empty-icon {
font-size: 4rem;
margin-bottom: var(--space-4);
}
.empty-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-2);
}
.empty-description {
font-size: 0.875rem;
color: var(--text-secondary);
}
@media (min-width: 768px) {
.quick-actions {
flex-direction: row;
}
.meeting-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-4);
}
}
@media (min-width: 1024px) {
.meeting-list {
grid-template-columns: repeat(3, 1fr);
}
}
</style>
</head>
<body>
<!-- 헤더 -->
<header class="header">
<div class="header-left">
<h1 class="header-title">대시보드</h1>
</div>
<div class="header-right">
<button class="icon-button" aria-label="알림" onclick="showToast('알림이 없습니다', 'info')">
<span class="nav-icon">🔔</span>
<span class="notification-badge" style="display: none;"></span>
</button>
<button class="icon-button" aria-label="프로필" onclick="showToast('프로필 기능은 준비 중입니다', 'info')">
<span class="nav-icon">👤</span>
</button>
</div>
</header>
<!-- 빠른 액션 -->
<section class="quick-actions">
<button class="button button-primary action-button-primary" onclick="navigateTo('03-회의예약.html')">
<span></span>
<span>새 회의 예약</span>
</button>
<div id="ongoingMeeting" class="ongoing-meeting" style="display: none;" onclick="handleOngoingMeetingClick()">
<div class="ongoing-indicator"></div>
<div class="ongoing-text">진행 중인 회의 (1건) - 지금 참여</div>
</div>
</section>
<!-- 필터 영역 -->
<div class="filters">
<div class="filter-row">
<select id="statusFilter" class="filter-select" aria-label="상태 필터" onchange="applyFilters()">
<option value="all">전체</option>
<option value="confirmed">확정완료</option>
<option value="in-progress">작성중</option>
<option value="draft">임시저장</option>
</select>
<select id="sortFilter" class="filter-select" aria-label="정렬" onchange="applyFilters()">
<option value="latest">최신순</option>
<option value="date">회의일시순</option>
<option value="title">제목순</option>
</select>
</div>
<div class="search-input-wrapper">
<span class="search-icon">🔍</span>
<input
type="text"
id="searchInput"
class="search-input"
placeholder="회의 제목, 참석자, 키워드 검색..."
aria-label="검색"
onkeyup="handleSearch()"
>
</div>
</div>
<!-- 회의록 목록 -->
<section>
<div class="section-header">
<h2 class="section-title">내 회의록</h2>
<span class="section-count" id="meetingCount">0건</span>
</div>
<div id="meetingList" class="meeting-list">
<!-- 동적 생성 -->
</div>
</section>
<!-- 하단 네비게이션 -->
<nav class="bottom-nav" role="navigation" aria-label="메인 네비게이션">
<button class="nav-item active" onclick="navigateTo('02-대시보드.html')" data-nav-link>
<span class="nav-icon">🏠</span>
<span class="nav-label">대시보드</span>
</button>
<button class="nav-item" onclick="navigateTo('09-Todo관리.html')" data-nav-link>
<span class="nav-icon"></span>
<span class="nav-label">Todo</span>
</button>
<button class="nav-item" onclick="showToast('더보기 기능은 준비 중입니다', 'info')">
<span class="nav-icon">⚙️</span>
<span class="nav-label">더보기</span>
</button>
</nav>
<!-- 상세 모달 -->
<div id="detailModal" class="modal-overlay detail-modal" style="display: none;" aria-hidden="true" role="dialog" aria-labelledby="detailModalTitle">
<div class="modal">
<div class="modal-header">
<h2 id="detailModalTitle" class="modal-title">회의록 상세</h2>
<button class="modal-close" aria-label="닫기" onclick="hideModal('detailModal')"></button>
</div>
<div id="detailModalContent" class="modal-body">
<!-- 동적 생성 -->
</div>
</div>
</div>
<!-- JavaScript -->
<script src="common.js"></script>
<script>
(function() {
'use strict';
let meetings = [];
let filteredMeetings = [];
// 초기화
function init() {
loadMeetings();
renderMeetings();
checkOngoingMeeting();
}
// 회의록 로드
function loadMeetings() {
meetings = loadData('meetings') || mockMeetings;
filteredMeetings = [...meetings];
}
// 회의록 렌더링
function renderMeetings() {
const listElement = $('#meetingList');
const countElement = $('#meetingCount');
if (filteredMeetings.length === 0) {
listElement.innerHTML = `
<div class="empty-state">
<div class="empty-icon">📝</div>
<h3 class="empty-title">회의록이 없습니다</h3>
<p class="empty-description">새 회의를 예약하여 회의록을 작성해보세요</p>
</div>
`;
countElement.textContent = '0건';
return;
}
listElement.innerHTML = filteredMeetings.map(meeting => {
const statusBadge = getStatusBadge(meeting.status);
const progressBar = meeting.status === 'in-progress' ? `
<div class="progress-wrapper">
<div class="progress-label">
<span>진행률</span>
<span>${meeting.progress}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill ${meeting.progress >= 100 ? 'success' : ''}" style="width: ${meeting.progress}%"></div>
</div>
</div>
` : '';
return `
<div class="meeting-card" onclick="showMeetingDetail(${meeting.id})">
<div class="meeting-card-header">
<div style="flex: 1;">
<div class="meeting-title">
<span class="meeting-icon">📝</span>
${meeting.title}
</div>
<div class="meeting-meta">
<span class="meeting-meta-item">📅 ${formatDate(meeting.date)} ${meeting.time}</span>
<span class="meeting-meta-item">${statusBadge}</span>
</div>
</div>
</div>
<div class="meeting-footer">
<span class="meeting-attendees">👥 ${meeting.attendees.length}명 참석</span>
</div>
${progressBar}
</div>
`;
}).join('');
countElement.textContent = `${filteredMeetings.length}건`;
}
// 상태 배지
function getStatusBadge(status) {
const badges = {
'confirmed': '<span class="badge badge-confirmed">✅ 확정완료</span>',
'in-progress': '<span class="badge badge-in-progress">⚠️ 작성중</span>',
'draft': '<span class="badge badge-pending">📝 임시저장</span>'
};
return badges[status] || '';
}
// 필터 적용
window.applyFilters = function() {
const statusFilter = $('#statusFilter').value;
const sortFilter = $('#sortFilter').value;
const searchQuery = $('#searchInput').value.toLowerCase();
filteredMeetings = meetings.filter(meeting => {
const matchesStatus = statusFilter === 'all' || meeting.status === statusFilter;
const matchesSearch = !searchQuery ||
meeting.title.toLowerCase().includes(searchQuery) ||
meeting.keywords.some(k => k.toLowerCase().includes(searchQuery));
return matchesStatus && matchesSearch;
});
// 정렬
if (sortFilter === 'latest') {
filteredMeetings.sort((a, b) => new Date(b.date) - new Date(a.date));
} else if (sortFilter === 'date') {
filteredMeetings.sort((a, b) => new Date(a.date) - new Date(b.date));
} else if (sortFilter === 'title') {
filteredMeetings.sort((a, b) => a.title.localeCompare(b.title, 'ko'));
}
renderMeetings();
};
// 검색 (디바운싱)
window.handleSearch = debounce(function() {
applyFilters();
}, 300);
// 회의록 상세 보기
window.showMeetingDetail = function(meetingId) {
const meeting = getMeetingById(meetingId);
if (!meeting) return;
const modalContent = $('#detailModalContent');
modalContent.innerHTML = `
<div class="detail-section">
<div class="detail-section-title">회의 정보</div>
<div class="detail-content">
<strong>제목:</strong> ${meeting.title}<br>
<strong>일시:</strong> ${formatDateTime(meeting.date + ' ' + meeting.time)}<br>
<strong>장소:</strong> ${meeting.location}<br>
<strong>참석자:</strong> ${meeting.attendees.map(id => getUserById(id)?.name).join(', ')}<br>
<strong>상태:</strong> ${getStatusBadge(meeting.status)}
</div>
</div>
${meeting.sections.map(section => `
<div class="detail-section">
<div class="detail-section-title">
${section.title}
${section.verified ? '<span class="badge badge-verified" style="margin-left: 8px;">✅ 검증완료</span>' : ''}
</div>
<div class="detail-content">${section.content}</div>
</div>
`).join('')}
${meeting.todos && meeting.todos.length > 0 ? `
<div class="detail-section">
<div class="detail-section-title">Todo</div>
${meeting.todos.map(todo => {
const assignee = getUserById(todo.assignee);
return `
<div class="todo-card priority-${todo.priority}" style="margin-bottom: 8px;">
<div class="todo-checkbox ${todo.status === 'completed' ? 'checked' : ''}"></div>
<div class="todo-content">
<div class="todo-title ${todo.status === 'completed' ? 'completed' : ''}">${todo.content}</div>
<div class="todo-meta">
<span class="todo-assignee">👤 ${assignee?.name}</span>
<span class="todo-duedate">📅 ~ ${formatDate(todo.dueDate)} (${getDDay(todo.dueDate)})</span>
</div>
</div>
</div>
`;
}).join('')}
</div>
` : ''}
<div class="detail-actions">
<button class="button button-outline" style="flex: 1;" onclick="handleEdit(${meeting.id})">✏️ 수정</button>
<button class="button button-outline" style="flex: 1;" onclick="handleShare(${meeting.id})">📤 공유</button>
<button class="button button-secondary" style="flex: 1;" onclick="handleDownloadPDF(${meeting.id})">📄 PDF</button>
</div>
`;
showModal('detailModal');
};
// 수정
window.handleEdit = function(meetingId) {
showToast('수정 기능은 준비 중입니다', 'info');
};
// 공유
window.handleShare = function(meetingId) {
navigateTo('08-회의록공유.html');
};
// PDF 다운로드
window.handleDownloadPDF = function(meetingId) {
showToast('PDF 다운로드 중...', 'success', 2000);
};
// 진행 중인 회의 체크
function checkOngoingMeeting() {
const ongoingMeeting = meetings.find(m => m.status === 'in-progress');
if (ongoingMeeting) {
$('#ongoingMeeting').style.display = 'flex';
}
}
// 진행 중인 회의 클릭
window.handleOngoingMeetingClick = function() {
navigateTo('05-회의진행.html');
};
// 초기화
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>

View File

@ -1,612 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="회의록 작성 및 공유 개선 서비스 - 회의 예약">
<title>회의 예약 - 회의록 작성 서비스</title>
<!-- CSS -->
<link rel="stylesheet" href="common.css">
<!-- Pretendard Font -->
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<style>
.page-container {
min-height: 100vh;
background-color: var(--bg-secondary);
padding-bottom: var(--space-8);
}
/* 헤더 */
.header {
position: sticky;
top: 0;
background-color: var(--bg-primary);
border-bottom: var(--border-thin) solid var(--gray-200);
padding: var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
z-index: 10;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.header-left {
display: flex;
align-items: center;
gap: var(--space-3);
}
.back-button {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
background-color: transparent;
border: none;
cursor: pointer;
font-size: 1.25rem;
}
.back-button:hover {
background-color: var(--gray-100);
}
.header-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
}
/* 폼 영역 */
.form-container {
max-width: 600px;
margin: 0 auto;
padding: var(--space-4);
}
.form-section {
background-color: var(--bg-primary);
border-radius: var(--radius-large);
padding: var(--space-6);
margin-bottom: var(--space-4);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.form-section-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-6);
}
.form-group {
margin-bottom: var(--space-5);
}
.form-group:last-child {
margin-bottom: 0;
}
.datetime-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
/* 참석자 칩 */
.attendee-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-2);
background-color: var(--primary-50);
color: var(--primary-700);
border: var(--border-thin) solid var(--primary-200);
border-radius: var(--radius-full);
padding: var(--space-1) var(--space-3);
font-size: 0.875rem;
animation: fade-in var(--duration-fast) ease-out;
}
.chip-remove {
cursor: pointer;
color: var(--primary-500);
font-weight: 600;
font-size: 1rem;
line-height: 1;
transition: color var(--duration-instant) ease-in-out;
}
.chip-remove:hover {
color: var(--error-500);
}
.add-attendee-group {
display: flex;
gap: var(--space-2);
}
.add-attendee-input {
flex: 1;
}
/* 체크박스 */
.checkbox-wrapper {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
}
.custom-checkbox {
width: 20px;
height: 20px;
border: var(--border-medium) solid var(--gray-300);
border-radius: var(--radius-small);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--duration-instant) ease-in-out;
}
.custom-checkbox.checked {
background-color: var(--primary-500);
border-color: var(--primary-500);
}
.custom-checkbox.checked::after {
content: '✓';
color: white;
font-size: 0.875rem;
font-weight: 600;
}
.checkbox-label {
font-size: 0.875rem;
color: var(--text-secondary);
cursor: pointer;
}
/* 제출 버튼 */
.submit-section {
max-width: 600px;
margin: 0 auto;
padding: 0 var(--space-4);
}
.submit-button {
width: 100%;
height: 56px;
font-size: 1rem;
font-weight: 600;
}
/* 헬퍼 텍스트 */
.helper-text {
font-size: 0.75rem;
color: var(--text-tertiary);
margin-top: var(--space-1);
}
@media (min-width: 768px) {
.form-container {
padding: var(--space-6);
}
.datetime-group {
grid-template-columns: 2fr 1fr;
}
}
</style>
</head>
<body>
<div class="page-container">
<!-- 헤더 -->
<header class="header">
<div class="header-left">
<button class="back-button" onclick="goBack()" aria-label="뒤로가기">
</button>
<h1 class="header-title">회의 예약</h1>
</div>
<button class="button button-ghost button-small" onclick="handleSaveDraft()">
임시저장
</button>
</header>
<!---->
<div class="form-container">
<form id="meetingForm" novalidate>
<!-- 기본 정보 -->
<div class="form-section">
<h2 class="form-section-title">기본 정보</h2>
<!-- 회의 제목 -->
<div class="form-group">
<label for="meetingTitle" class="input-label required">회의 제목</label>
<input
type="text"
id="meetingTitle"
class="input-field"
placeholder="예: 프로젝트 킥오프 미팅"
maxlength="100"
required
aria-label="회의 제목"
aria-describedby="meetingTitleError"
>
<span id="meetingTitleError" class="input-error-message" role="alert"></span>
<p class="helper-text">최대 100자까지 입력 가능합니다</p>
</div>
<!-- 날짜 및 시간 -->
<div class="form-group">
<label class="input-label required">날짜 및 시간</label>
<div class="datetime-group">
<div>
<input
type="date"
id="meetingDate"
class="input-field"
required
aria-label="회의 날짜"
aria-describedby="meetingDateError"
>
<span id="meetingDateError" class="input-error-message" role="alert"></span>
</div>
<div>
<input
type="time"
id="meetingTime"
class="input-field"
required
aria-label="회의 시간"
aria-describedby="meetingTimeError"
>
<span id="meetingTimeError" class="input-error-message" role="alert"></span>
</div>
</div>
</div>
<!-- 장소 -->
<div class="form-group">
<label for="meetingLocation" class="input-label">장소 (선택)</label>
<input
type="text"
id="meetingLocation"
class="input-field"
placeholder="예: 회의실 A 또는 온라인"
maxlength="200"
aria-label="회의 장소"
>
<p class="helper-text">최대 200자까지 입력 가능합니다</p>
</div>
</div>
<!-- 참석자 -->
<div class="form-section">
<h2 class="form-section-title">참석자</h2>
<div class="form-group">
<label class="input-label required">참석자 목록</label>
<div id="attendeeChips" class="attendee-chips">
<!-- 동적 생성 -->
</div>
<div class="add-attendee-group">
<input
type="email"
id="attendeeEmail"
class="input-field add-attendee-input"
placeholder="이메일 주소 입력 후 Enter 또는 추가 버튼"
aria-label="참석자 이메일"
>
<button type="button" class="button button-primary" onclick="handleAddAttendee()">
추가
</button>
</div>
<span id="attendeeError" class="input-error-message" role="alert"></span>
<p class="helper-text">최소 1명 이상의 참석자를 추가해주세요</p>
</div>
</div>
<!-- 리마인더 -->
<div class="form-section">
<h2 class="form-section-title">알림 설정</h2>
<div class="form-group">
<div class="checkbox-wrapper" onclick="toggleReminder()">
<div id="reminderCheckbox" class="custom-checkbox checked"></div>
<label class="checkbox-label">회의 시작 30분 전 리마인더 발송</label>
</div>
</div>
</div>
</form>
</div>
<!-- 제출 버튼 -->
<div class="submit-section">
<button class="button button-primary submit-button" onclick="handleSubmit()">
회의 예약하기
</button>
</div>
</div>
<!-- JavaScript -->
<script src="common.js"></script>
<script>
(function() {
'use strict';
let attendees = [];
let reminderEnabled = true;
// 초기화
function init() {
setupEventListeners();
setMinDate();
loadDraft();
}
// 이벤트 리스너 설정
function setupEventListeners() {
const attendeeInput = $('#attendeeEmail');
attendeeInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddAttendee();
}
});
// 실시간 검증
setupRealtimeValidation($('#meetingTitle'));
setupRealtimeValidation($('#meetingDate'));
setupRealtimeValidation($('#meetingTime'));
}
// 최소 날짜 설정 (오늘)
function setMinDate() {
const today = new Date().toISOString().split('T')[0];
$('#meetingDate').setAttribute('min', today);
$('#meetingDate').value = today;
const currentTime = new Date();
const hours = String(currentTime.getHours()).padStart(2, '0');
const minutes = String(currentTime.getMinutes()).padStart(2, '0');
$('#meetingTime').value = `${hours}:${minutes}`;
}
// 참석자 추가
window.handleAddAttendee = function() {
const emailInput = $('#attendeeEmail');
const email = emailInput.value.trim();
const errorElement = $('#attendeeError');
if (!email) {
return;
}
if (!validateEmail(email)) {
errorElement.textContent = '올바른 이메일 주소를 입력해주세요';
addClass(emailInput, 'error');
return;
}
if (attendees.includes(email)) {
errorElement.textContent = '이미 추가된 참석자입니다';
addClass(emailInput, 'error');
return;
}
attendees.push(email);
emailInput.value = '';
removeClass(emailInput, 'error');
errorElement.textContent = '';
renderAttendees();
saveDraft();
};
// 참석자 제거
window.handleRemoveAttendee = function(email) {
attendees = attendees.filter(a => a !== email);
renderAttendees();
saveDraft();
};
// 참석자 렌더링
function renderAttendees() {
const chipsContainer = $('#attendeeChips');
if (attendees.length === 0) {
chipsContainer.innerHTML = '<p class="helper-text">참석자를 추가해주세요</p>';
return;
}
chipsContainer.innerHTML = attendees.map(email => `
<div class="chip">
<span>${email}</span>
<span class="chip-remove" onclick="handleRemoveAttendee('${email}')" aria-label="${email} 제거">×</span>
</div>
`).join('');
}
// 리마인더 토글
window.toggleReminder = function() {
reminderEnabled = !reminderEnabled;
const checkbox = $('#reminderCheckbox');
if (reminderEnabled) {
addClass(checkbox, 'checked');
} else {
removeClass(checkbox, 'checked');
}
};
// 임시 저장
window.handleSaveDraft = function() {
saveDraft();
showToast('임시 저장되었습니다', 'success');
};
function saveDraft() {
const draft = {
title: $('#meetingTitle').value,
date: $('#meetingDate').value,
time: $('#meetingTime').value,
location: $('#meetingLocation').value,
attendees: attendees,
reminderEnabled: reminderEnabled,
savedAt: new Date().toISOString()
};
saveData('meetingDraft', draft);
}
// 임시 저장 불러오기
function loadDraft() {
const draft = loadData('meetingDraft');
if (!draft) return;
// 30분 이내 임시 저장만 복원
const savedTime = new Date(draft.savedAt);
const now = new Date();
const diffMinutes = (now - savedTime) / (1000 * 60);
if (diffMinutes > 30) {
removeData('meetingDraft');
return;
}
$('#meetingTitle').value = draft.title || '';
$('#meetingDate').value = draft.date || '';
$('#meetingTime').value = draft.time || '';
$('#meetingLocation').value = draft.location || '';
attendees = draft.attendees || [];
reminderEnabled = draft.reminderEnabled !== false;
renderAttendees();
if (!reminderEnabled) {
removeClass($('#reminderCheckbox'), 'checked');
}
showToast('임시 저장된 내용을 불러왔습니다', 'info');
}
// 폼 검증
function validateForm() {
let isValid = true;
// 제목
const title = $('#meetingTitle').value.trim();
if (!title) {
showError($('#meetingTitle'), $('#meetingTitleError'), '회의 제목을 입력해주세요');
isValid = false;
}
// 날짜
const date = $('#meetingDate').value;
if (!date) {
showError($('#meetingDate'), $('#meetingDateError'), '날짜를 선택해주세요');
isValid = false;
} else {
const selectedDate = new Date(date);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (selectedDate < today) {
showError($('#meetingDate'), $('#meetingDateError'), '과거 날짜는 선택할 수 없습니다');
isValid = false;
}
}
// 시간
const time = $('#meetingTime').value;
if (!time) {
showError($('#meetingTime'), $('#meetingTimeError'), '시간을 선택해주세요');
isValid = false;
}
// 참석자
if (attendees.length === 0) {
showError($('#attendeeEmail'), $('#attendeeError'), '최소 1명 이상의 참석자를 추가해주세요');
isValid = false;
}
return isValid;
}
function showError(inputElement, errorElement, message) {
addClass(inputElement, 'error');
errorElement.textContent = message;
}
// 제출
window.handleSubmit = function() {
if (!validateForm()) {
showToast('입력 항목을 확인해주세요', 'error');
return;
}
// 로딩 표시
showToast('회의를 예약하고 있습니다...', 'info', 1500);
// 회의 예약 처리 (시뮬레이션)
setTimeout(() => {
const newMeeting = {
id: Date.now(),
title: $('#meetingTitle').value.trim(),
date: $('#meetingDate').value,
time: $('#meetingTime').value,
location: $('#meetingLocation').value.trim() || '미정',
attendees: attendees,
status: 'draft',
progress: 0,
sections: [],
todos: [],
keywords: [],
reminderEnabled: reminderEnabled,
createdAt: new Date().toISOString()
};
// 저장
const meetings = loadData('meetings') || [];
meetings.unshift(newMeeting);
saveData('meetings', meetings);
// 임시 저장 삭제
removeData('meetingDraft');
// 성공 메시지
showToast('회의 예약이 완료되었습니다', 'success', 2000);
// 템플릿 선택 화면으로 이동
setTimeout(() => {
saveData('currentMeetingId', newMeeting.id);
navigateTo('04-템플릿선택.html');
}, 2000);
}, 1500);
};
// 초기화
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -1,537 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="회의록 작성 및 공유 개선 서비스 - 회의진행">
<title>회의 진행 - 회의록 작성 서비스</title>
<link rel="stylesheet" href="common.css">
</head>
<body>
<!-- Skip to Main Content (접근성) -->
<a href="#main-content" class="skip-to-main">본문으로 건너뛰기</a>
<!-- Header -->
<header style="position: sticky; top: 0; background: var(--bg-primary); border-bottom: var(--border-thin) solid var(--gray-200); z-index: 10; padding: var(--space-4);">
<div style="display: flex; align-items: center; justify-content: space-between; max-width: 1440px; margin: 0 auto;">
<button class="button-icon button-ghost" onclick="goBack()" aria-label="뒤로가기">
<span style="font-size: 24px;"></span>
</button>
<h1 class="h4" style="margin: 0;">프로젝트 킥오프</h1>
<button class="button-secondary button-small" onclick="endMeeting()" aria-label="회의 종료">
종료
</button>
</div>
</header>
<!-- Main Content -->
<main id="main-content" class="container" style="padding-top: var(--space-4); padding-bottom: 80px; max-width: 1024px;">
<!-- Voice Recording Section -->
<section aria-labelledby="recording-section" style="margin-bottom: var(--space-6);">
<div class="voice-recording">
<div class="recording-indicator" aria-label="녹음 중"></div>
<div class="recording-timer" id="timer" aria-live="polite">23:45</div>
<div class="waveform" aria-hidden="true">
<div class="waveform-bar" style="height: 20px;"></div>
<div class="waveform-bar" style="height: 30px;"></div>
<div class="waveform-bar" style="height: 40px;"></div>
<div class="waveform-bar" style="height: 25px;"></div>
<div class="waveform-bar" style="height: 35px;"></div>
<div class="waveform-bar" style="height: 30px;"></div>
<div class="waveform-bar" style="height: 20px;"></div>
<div class="waveform-bar" style="height: 15px;"></div>
<div class="waveform-bar" style="height: 25px;"></div>
<div class="waveform-bar" style="height: 35px;"></div>
</div>
</div>
</section>
<!-- Attendees Section -->
<section aria-labelledby="attendees-section" style="margin-bottom: var(--space-6);">
<h2 class="h4" id="attendees-section" style="margin-bottom: var(--space-3);">👥 참석자 (3/5명)</h2>
<div style="display: flex; gap: var(--space-3); flex-wrap: wrap;">
<div class="badge badge-verified" style="padding: var(--space-2) var(--space-3); font-size: 0.875rem;">
<span style="font-size: 20px; margin-right: var(--space-1);">👨‍💼</span>
<span>김민준</span>
</div>
<div class="badge badge-verified" style="padding: var(--space-2) var(--space-3); font-size: 0.875rem;">
<span style="font-size: 20px; margin-right: var(--space-1);">👩‍💻</span>
<span>박서연</span>
</div>
<div class="badge badge-verified" style="padding: var(--space-2) var(--space-3); font-size: 0.875rem;">
<span style="font-size: 20px; margin-right: var(--space-1);">👨‍💻</span>
<span>이준호</span>
</div>
</div>
</section>
<!-- Meeting Minutes Sections -->
<section aria-labelledby="minutes-section" style="margin-bottom: var(--space-6);">
<h2 class="h4" id="minutes-section" style="margin-bottom: var(--space-4);">📝 실시간 회의록</h2>
<!-- 참석자 섹션 -->
<div class="card" style="margin-bottom: var(--space-3);">
<button class="w-full" style="display: flex; justify-content: space-between; align-items: center; text-align: left; padding: var(--space-3);" onclick="toggleSection('attendees')" aria-expanded="false" aria-controls="attendees-content">
<h3 class="h4" style="margin: 0;">▼ 참석자</h3>
<span class="badge badge-verified">검증완료</span>
</button>
<div id="attendees-content" style="padding: 0 var(--space-3) var(--space-3); display: none;">
<p class="text-body" style="margin: var(--space-2) 0;">- 김민준 (주관자)</p>
<p class="text-body" style="margin: var(--space-2) 0;">- 박서연</p>
<p class="text-body" style="margin: var(--space-2) 0;">- 이준호</p>
</div>
</div>
<!-- 안건 섹션 -->
<div class="card" style="margin-bottom: var(--space-3);">
<button class="w-full" style="display: flex; justify-content: space-between; align-items: center; text-align: left; padding: var(--space-3);" onclick="toggleSection('agenda')" aria-expanded="false" aria-controls="agenda-content">
<h3 class="h4" style="margin: 0;">▼ 안건</h3>
<span class="badge badge-verified">검증완료</span>
</button>
<div id="agenda-content" style="padding: 0 var(--space-3) var(--space-3); display: none;">
<p class="text-body" style="margin: var(--space-2) 0;">- 프로젝트 목표 정의</p>
<p class="text-body" style="margin: var(--space-2) 0;">- 일정 및 마일스톤</p>
</div>
</div>
<!-- 논의 내용 섹션 (차별화 기능 포함) -->
<div class="card" style="margin-bottom: var(--space-3);">
<button class="w-full" style="display: flex; justify-content: space-between; align-items: center; text-align: left; padding: var(--space-3);" onclick="toggleSection('discussion')" aria-expanded="true" aria-controls="discussion-content">
<h3 class="h4" style="margin: 0;">▼ 논의 내용</h3>
<span class="badge badge-in-progress">작성중</span>
</button>
<div id="discussion-content" style="padding: 0 var(--space-3) var(--space-3);">
<!-- 실시간 텍스트 영역 -->
<div class="realtime-text" style="margin-bottom: var(--space-3);">
<div style="margin-bottom: var(--space-2);">
<span class="speaker-name">김민준</span>
<span class="timestamp">14:23</span>
</div>
<div class="text-content">
우리는 <span class="term-highlight" onclick="showTermTooltip(event, 'q1')" role="button" tabindex="0" aria-label="Q1 용어 설명 보기">Q1</span>까지
<span class="term-highlight" onclick="showTermTooltip(event, 'mvp')" role="button" tabindex="0" aria-label="MVP 용어 설명 보기">MVP</span>를 완성해야 합니다.
개발 프레임워크는 <span class="term-highlight" onclick="showTermTooltip(event, 'react')" role="button" tabindex="0" aria-label="React 용어 설명 보기">React</span>를 사용하고,
배포 환경은 <span class="term-highlight" onclick="showTermTooltip(event, 'aws')" role="button" tabindex="0" aria-label="AWS 용어 설명 보기">AWS</span>로 결정했습니다.
<span class="typing-indicator" aria-label="입력 중" aria-live="polite"></span>
</div>
</div>
<!-- AI 자동 정리 영역 -->
<div style="background-color: var(--primary-50); border: var(--border-thin) solid var(--primary-200); border-radius: var(--radius-medium); padding: var(--space-3); margin-bottom: var(--space-3);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2);">
<span style="font-size: 18px;">💡</span>
<span class="text-caption" style="color: var(--primary-700); font-weight: 600;">AI 자동 정리</span>
</div>
<p class="text-body" style="color: var(--gray-700);">
Q1(1분기)까지 MVP(최소 기능 제품) 완성을 목표로 설정했습니다.
개발 프레임워크로 React를 선택하고, 배포 환경은 AWS를 사용하기로 결정했습니다.
</p>
</div>
<!-- 실시간 협업 표시 -->
<div class="realtime-text" style="background-color: var(--warning-50); border: var(--border-thin) solid var(--warning-200); animation: highlight-fade 3s ease-out;">
<div style="margin-bottom: var(--space-2);">
<span class="speaker-name" style="background-color: var(--info-100); color: var(--info-700);">박서연</span>
<span class="timestamp">14:24</span>
<span class="text-caption" style="color: var(--warning-700); margin-left: var(--space-2);">수정 중...</span>
</div>
<div class="text-content">
<span class="term-highlight" onclick="showTermTooltip(event, 'sprint')" role="button" tabindex="0" aria-label="Sprint 용어 설명 보기">Sprint</span> 주기는 2주로 하는 게 좋을 것 같습니다.
</div>
</div>
<!-- 액션 버튼 -->
<div style="display: flex; gap: var(--space-2); margin-top: var(--space-4);">
<button class="button-secondary button-small" onclick="editSection('discussion')">
<span>📝</span> 수정
</button>
<button class="button-secondary button-small" onclick="addComment('discussion')">
<span>💬</span> 댓글
</button>
</div>
</div>
</div>
<!-- 결정 사항 섹션 -->
<div class="card" style="margin-bottom: var(--space-3);">
<button class="w-full" style="display: flex; justify-content: space-between; align-items: center; text-align: left; padding: var(--space-3);" onclick="toggleSection('decisions')" aria-expanded="false" aria-controls="decisions-content">
<h3 class="h4" style="margin: 0;">▼ 결정 사항</h3>
<span class="badge badge-pending">검증 필요</span>
</button>
<div id="decisions-content" style="padding: 0 var(--space-3) var(--space-3); display: none;">
<p class="text-body" style="margin: var(--space-2) 0;">- 개발 프레임워크: React</p>
<p class="text-body" style="margin: var(--space-2) 0;">- 배포 환경: AWS</p>
</div>
</div>
<!-- Todo 섹션 -->
<div class="card">
<button class="w-full" style="display: flex; justify-content: space-between; align-items: center; text-align: left; padding: var(--space-3);" onclick="toggleSection('todos')" aria-expanded="false" aria-controls="todos-content">
<h3 class="h4" style="margin: 0;">▼ Todo</h3>
<span class="badge badge-pending">검증 필요</span>
</button>
<div id="todos-content" style="padding: 0 var(--space-3) var(--space-3); display: none;">
<div class="todo-card priority-high" style="margin-bottom: var(--space-2);">
<div class="todo-checkbox" onclick="toggleTodo(this)" role="checkbox" aria-checked="false" tabindex="0"></div>
<div class="todo-content">
<div class="todo-title">요구사항 정의</div>
<div class="todo-meta">
<span class="todo-assignee">@김민준</span>
<span class="todo-duedate urgent">(~ 10/25)</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Conflict Resolution Alert (충돌 알림 예시 - 숨김 상태) -->
<div id="conflict-alert" style="display: none; background-color: var(--warning-50); border: var(--border-thin) solid var(--warning-500); border-radius: var(--radius-medium); padding: var(--space-4); margin-bottom: var(--space-4);" role="alert">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3);">
<span style="font-size: 24px;">⚠️</span>
<h3 class="h4" style="margin: 0; color: var(--warning-700);">충돌 감지</h3>
</div>
<p class="text-body" style="margin-bottom: var(--space-3);">
박서연님이 동일한 부분을 수정하고 있습니다. 충돌을 해결해주세요.
</p>
<div style="display: flex; gap: var(--space-2);">
<button class="button-primary button-small" onclick="resolveConflict('accept')">
내 변경 사항 유지
</button>
<button class="button-secondary button-small" onclick="resolveConflict('merge')">
수동 병합
</button>
</div>
</div>
</main>
<!-- Term Tooltip (맥락 기반 용어 설명 - 숨김 상태) -->
<div id="term-tooltip" class="tooltip" style="display: none;" role="tooltip">
<div id="tooltip-content">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
<!-- End Meeting Confirmation Modal -->
<div id="end-meeting-modal" class="modal-overlay" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="end-meeting-title">
<div class="modal">
<div class="modal-header">
<h2 id="end-meeting-title" class="modal-title">회의를 종료하시겠습니까?</h2>
<button class="modal-close" onclick="hideModal('end-meeting-modal')" aria-label="닫기">×</button>
</div>
<div class="modal-body">
<p class="text-body">
녹음이 중지되고 검증 화면으로 이동합니다.
작성 중인 내용은 자동 저장됩니다.
</p>
</div>
<div class="modal-footer">
<button class="button-secondary" onclick="hideModal('end-meeting-modal')">
취소
</button>
<button class="button-primary" onclick="confirmEndMeeting()">
종료
</button>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
// ============================================================================
// 타이머 업데이트
// ============================================================================
let seconds = 23 * 60 + 45; // 23분 45초
function updateTimer() {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
const timerElement = $('#timer');
if (timerElement) {
timerElement.textContent = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
seconds++;
}
setInterval(updateTimer, 1000);
// ============================================================================
// 섹션 토글
// ============================================================================
function toggleSection(sectionId) {
const content = $(`#${sectionId}-content`);
const button = content.previousElementSibling;
if (content.style.display === 'none') {
content.style.display = 'block';
button.setAttribute('aria-expanded', 'true');
button.querySelector('h3').textContent = button.querySelector('h3').textContent.replace('▼', '▲');
} else {
content.style.display = 'none';
button.setAttribute('aria-expanded', 'false');
button.querySelector('h3').textContent = button.querySelector('h3').textContent.replace('▲', '▼');
}
}
// ============================================================================
// 맥락 기반 용어 설명 툴팁 (차별화 기능)
// ============================================================================
const termData = {
mvp: {
term: 'MVP',
fullName: 'Minimum Viable Product',
definition: '최소 기능 제품. 핵심 기능만 구현하여 시장 검증을 목적으로 출시하는 제품.',
contextMeaning: 'Q1까지 사용자 인증, 대시보드, 회의록 작성 핵심 기능만 구현하여 출시 예정',
relatedProjects: [
{ name: '2024 고객 포털 프로젝트', link: '#' },
{ name: '2023 모바일 앱 리뉴얼', link: '#' }
],
relatedMeetings: [
{ title: '2024-09-15 기획 회의', date: '2024-09-15', link: '#' },
{ title: '2024-08-20 킥오프 회의', date: '2024-08-20', link: '#' }
]
},
q1: {
term: 'Q1',
fullName: '1분기 (First Quarter)',
definition: '회계 연도 또는 사업 연도의 첫 3개월 기간 (1월~3월).',
contextMeaning: '2025년 1월부터 3월까지의 기간을 의미하며, 이 기간 내 MVP 출시 목표',
relatedProjects: [
{ name: '2025 사업 계획', link: '#' }
],
relatedMeetings: [
{ title: '2024-12-10 연간 계획 회의', date: '2024-12-10', link: '#' }
]
},
react: {
term: 'React',
fullName: 'React.js',
definition: 'Facebook(Meta)에서 개발한 JavaScript UI 라이브러리. 컴포넌트 기반 개발.',
contextMeaning: '프론트엔드 개발 프레임워크로 React를 사용하여 사용자 인터페이스를 구현',
relatedProjects: [
{ name: '2024 웹 포털 개선', link: '#' },
{ name: '2023 관리자 대시보드', link: '#' }
],
relatedMeetings: [
{ title: '2024-10-01 기술 스택 검토', date: '2024-10-01', link: '#' }
]
},
aws: {
term: 'AWS',
fullName: 'Amazon Web Services',
definition: 'Amazon에서 제공하는 클라우드 컴퓨팅 플랫폼. EC2, S3, RDS 등 다양한 서비스 제공.',
contextMeaning: '서비스 배포 및 운영을 위한 클라우드 인프라로 AWS를 사용',
relatedProjects: [
{ name: '2024 인프라 마이그레이션', link: '#' }
],
relatedMeetings: [
{ title: '2024-09-25 인프라 설계 회의', date: '2024-09-25', link: '#' }
]
},
sprint: {
term: 'Sprint',
fullName: 'Sprint (Scrum)',
definition: 'Scrum 방법론에서 정해진 기간(보통 1-4주) 동안 개발 작업을 진행하는 주기.',
contextMeaning: '2주 단위로 개발 작업을 진행하고 검토하는 주기',
relatedProjects: [
{ name: '2024 애자일 전환 프로젝트', link: '#' }
],
relatedMeetings: [
{ title: '2024-10-05 애자일 도입 회의', date: '2024-10-05', link: '#' }
]
}
};
function showTermTooltip(event, termKey) {
event.stopPropagation();
const tooltip = $('#term-tooltip');
const target = event.target;
const term = termData[termKey];
if (!term || !tooltip) return;
// 툴팁 콘텐츠 생성
const content = `
<div class="tooltip-section">
<div style="margin-bottom: var(--space-2);">
<strong style="font-size: 1rem; color: var(--text-primary);">${term.term}</strong>
${term.fullName ? `<span class="text-caption" style="margin-left: var(--space-1);">(${term.fullName})</span>` : ''}
</div>
</div>
<div class="tooltip-section">
<div class="tooltip-title">📘 정의</div>
<div class="tooltip-content">${term.definition}</div>
</div>
<div class="tooltip-section">
<div class="tooltip-title">🏢 이 회의에서의 의미</div>
<div class="tooltip-content">${term.contextMeaning}</div>
</div>
${term.relatedProjects.length > 0 ? `
<div class="tooltip-section">
<div class="tooltip-title">📂 관련 프로젝트</div>
<div class="tooltip-content">
${term.relatedProjects.map(p => `<div style="margin-bottom: var(--space-1);"><a href="${p.link}" style="color: var(--primary-500); text-decoration: underline;">${p.name}</a></div>`).join('')}
</div>
</div>
` : ''}
${term.relatedMeetings.length > 0 ? `
<div class="tooltip-section">
<div class="tooltip-title">📄 과거 회의록</div>
<div class="tooltip-content">
${term.relatedMeetings.map(m => `<div style="margin-bottom: var(--space-1);"><a href="${m.link}" style="color: var(--primary-500); text-decoration: underline;">${m.title}</a> <span class="text-caption">(${m.date})</span></div>`).join('')}
</div>
</div>
` : ''}
<div style="text-align: center; margin-top: var(--space-3);">
<button class="button-secondary button-small" onclick="hideTooltip()">자세히 보기</button>
</div>
`;
$('#tooltip-content').innerHTML = content;
// 툴팁 위치 계산
const rect = target.getBoundingClientRect();
tooltip.style.display = 'block';
tooltip.style.position = 'absolute';
tooltip.style.top = `${rect.bottom + window.scrollY + 10}px`;
tooltip.style.left = `${rect.left + window.scrollX}px`;
// 툴팁이 화면 밖으로 나가지 않도록 조정
setTimeout(() => {
const tooltipRect = tooltip.getBoundingClientRect();
if (tooltipRect.right > window.innerWidth) {
tooltip.style.left = `${window.innerWidth - tooltipRect.width - 20}px`;
}
if (tooltipRect.left < 0) {
tooltip.style.left = '20px';
}
}, 10);
// 외부 클릭 시 닫기
setTimeout(() => {
document.addEventListener('click', closeTooltipOutside);
}, 100);
}
function closeTooltipOutside(event) {
const tooltip = $('#term-tooltip');
if (tooltip && !tooltip.contains(event.target) && !event.target.classList.contains('term-highlight')) {
hideTooltip();
document.removeEventListener('click', closeTooltipOutside);
}
}
function hideTooltip() {
const tooltip = $('#term-tooltip');
if (tooltip) {
tooltip.style.display = 'none';
}
document.removeEventListener('click', closeTooltipOutside);
}
// ============================================================================
// Todo 체크박스 토글
// ============================================================================
function toggleTodo(checkbox) {
toggleClass(checkbox, 'checked');
const isChecked = checkbox.classList.contains('checked');
checkbox.setAttribute('aria-checked', isChecked);
const todoTitle = checkbox.nextElementSibling.querySelector('.todo-title');
if (isChecked) {
addClass(todoTitle, 'completed');
} else {
removeClass(todoTitle, 'completed');
}
}
// ============================================================================
// 회의 종료
// ============================================================================
function endMeeting() {
showModal('end-meeting-modal');
}
function confirmEndMeeting() {
hideModal('end-meeting-modal');
showToast('회의가 종료되었습니다', 'success', 2000);
// 자동 저장 시뮬레이션
setTimeout(() => {
navigateTo('06-검증완료.html');
}, 2000);
}
// ============================================================================
// 섹션 편집
// ============================================================================
function editSection(sectionId) {
showToast('편집 모드로 전환되었습니다', 'info');
}
// ============================================================================
// 댓글 추가
// ============================================================================
function addComment(sectionId) {
showToast('댓글 기능은 개발 중입니다', 'info');
}
// ============================================================================
// 충돌 해결
// ============================================================================
function resolveConflict(action) {
const alert = $('#conflict-alert');
if (alert) {
alert.style.display = 'none';
}
if (action === 'accept') {
showToast('내 변경 사항이 적용되었습니다', 'success');
} else {
showToast('수동 병합 모드로 전환되었습니다', 'info');
}
}
// ============================================================================
// 초기화
// ============================================================================
// 논의 내용 섹션 기본 열림
toggleSection('discussion');
// 자동 저장 시뮬레이션 (30초마다)
setInterval(() => {
console.log('자동 저장 완료');
}, 30000);
// 키보드 접근성: Enter/Space로 용어 설명 열기
$$('.term-highlight').forEach(term => {
term.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
term.click();
}
});
});
// 키보드 접근성: Esc로 툴팁 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const tooltip = $('#term-tooltip');
if (tooltip && tooltip.style.display !== 'none') {
hideTooltip();
}
}
});
console.log('회의진행 화면 초기화 완료');
</script>
</body>
</html>

View File

@ -1,517 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="회의록 작성 및 공유 개선 서비스 - 검증완료">
<title>회의록 검증 - 회의록 작성 서비스</title>
<link rel="stylesheet" href="common.css">
</head>
<body>
<!-- Skip to Main Content (접근성) -->
<a href="#main-content" class="skip-to-main">본문으로 건너뛰기</a>
<!-- Header -->
<header style="position: sticky; top: 0; background: var(--bg-primary); border-bottom: var(--border-thin) solid var(--gray-200); z-index: 10; padding: var(--space-4);">
<div style="display: flex; align-items: center; justify-content: space-between; max-width: 1440px; margin: 0 auto;">
<button class="button-icon button-ghost" onclick="goBack()" aria-label="뒤로가기">
<span style="font-size: 24px;"></span>
</button>
<h1 class="h4" style="margin: 0;">회의록 검증</h1>
<button class="button-primary button-small" onclick="proceedToEnd()" aria-label="다음 단계" id="next-button">
다음
</button>
</div>
</header>
<!-- Main Content -->
<main id="main-content" class="container" style="padding-top: var(--space-4); padding-bottom: var(--space-6); max-width: 1024px;">
<!-- Progress Section -->
<section aria-labelledby="progress-section" style="margin-bottom: var(--space-6);">
<div style="margin-bottom: var(--space-3);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-2);">
<h2 class="h4" id="progress-section">전체 진행률</h2>
<span class="h4" id="progress-text" style="color: var(--primary-500);">60% (3/5)</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill" style="width: 60%;"></div>
</div>
</div>
<p class="text-body" style="color: var(--text-tertiary);">
회의록 섹션별로 검증해주세요. 모든 섹션이 검증되면 회의를 종료할 수 있습니다.
</p>
</section>
<!-- Verification Sections -->
<section aria-labelledby="sections-title" style="margin-bottom: var(--space-6);">
<h2 class="h4" id="sections-title" style="margin-bottom: var(--space-4);">섹션별 검증</h2>
<!-- 참석자 섹션 (검증완료) -->
<div class="card" style="margin-bottom: var(--space-3);" data-section="attendees" data-verified="true">
<div class="card-header" style="border-bottom: var(--border-thin) solid var(--gray-200); padding-bottom: var(--space-3);">
<div style="display: flex; justify-content: between; align-items: center; margin-bottom: var(--space-2);">
<h3 class="h4" style="margin: 0; flex: 1;">✅ 참석자</h3>
<span class="badge badge-verified">검증완료</span>
</div>
<div style="display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center;">
<span class="text-caption" style="color: var(--text-tertiary);">검증자: 김민준</span>
<span class="text-caption" style="color: var(--text-tertiary);"></span>
<span class="text-caption" style="color: var(--text-tertiary);">시간: 14:35</span>
</div>
</div>
<div class="card-body">
<p class="text-body" style="margin: var(--space-2) 0;">- 김민준 (주관자)</p>
<p class="text-body" style="margin: var(--space-2) 0;">- 박서연</p>
<p class="text-body" style="margin: var(--space-2) 0;">- 이준호</p>
</div>
<div class="card-footer">
<button class="button-secondary button-small" onclick="editSection('attendees')">
수정
</button>
<button class="button-ghost button-small" onclick="lockSection('attendees')" aria-label="섹션 잠금" title="회의 생성자만 사용 가능">
🔒 잠금
</button>
</div>
</div>
<!-- 안건 섹션 (검증 필요) -->
<div class="card" style="margin-bottom: var(--space-3);" data-section="agenda" data-verified="false">
<div class="card-header" style="border-bottom: var(--border-thin) solid var(--gray-200); padding-bottom: var(--space-3);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3 class="h4" style="margin: 0;">⚠️ 안건</h3>
<span class="badge badge-pending">검증 필요</span>
</div>
</div>
<div class="card-body">
<p class="text-body" style="margin: var(--space-2) 0;">- 프로젝트 목표 정의</p>
<p class="text-body" style="margin: var(--space-2) 0;">- 일정 및 마일스톤</p>
</div>
<div class="card-footer">
<button class="button-secondary button-small" onclick="editSection('agenda')">
수정
</button>
<button class="button-primary button-small" onclick="verifySection('agenda')">
✓ 검증완료
</button>
</div>
</div>
<!-- 논의 내용 섹션 (검증 필요) -->
<div class="card" style="margin-bottom: var(--space-3);" data-section="discussion" data-verified="false">
<div class="card-header" style="border-bottom: var(--border-thin) solid var(--gray-200); padding-bottom: var(--space-3);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3 class="h4" style="margin: 0;">⚠️ 논의 내용</h3>
<span class="badge badge-pending">검증 필요</span>
</div>
</div>
<div class="card-body">
<p class="text-body">
우리는 Q1까지 MVP를 완성해야 합니다. 개발 프레임워크는 React를 사용하고, 배포 환경은 AWS로 결정했습니다.
Sprint 주기는 2주로 설정합니다.
</p>
</div>
<div class="card-footer">
<button class="button-secondary button-small" onclick="editSection('discussion')">
수정
</button>
<button class="button-primary button-small" onclick="verifySection('discussion')">
✓ 검증완료
</button>
</div>
</div>
<!-- 결정 사항 섹션 (검증완료) -->
<div class="card" style="margin-bottom: var(--space-3);" data-section="decisions" data-verified="true">
<div class="card-header" style="border-bottom: var(--border-thin) solid var(--gray-200); padding-bottom: var(--space-3);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-2);">
<h3 class="h4" style="margin: 0; flex: 1;">✅ 결정 사항</h3>
<span class="badge badge-verified">검증완료</span>
</div>
<div style="display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center;">
<span class="text-caption" style="color: var(--text-tertiary);">검증자: 박서연</span>
<span class="text-caption" style="color: var(--text-tertiary);"></span>
<span class="text-caption" style="color: var(--text-tertiary);">시간: 14:40</span>
</div>
</div>
<div class="card-body">
<p class="text-body" style="margin: var(--space-2) 0;">- 개발 프레임워크: React</p>
<p class="text-body" style="margin: var(--space-2) 0;">- 배포 환경: AWS</p>
<p class="text-body" style="margin: var(--space-2) 0;">- Sprint 주기: 2주</p>
</div>
<div class="card-footer">
<button class="button-secondary button-small" onclick="editSection('decisions')">
수정
</button>
<button class="button-ghost button-small" onclick="lockSection('decisions')" aria-label="섹션 잠금">
🔒 잠금
</button>
</div>
</div>
<!-- Todo 섹션 (검증완료) -->
<div class="card" data-section="todos" data-verified="true">
<div class="card-header" style="border-bottom: var(--border-thin) solid var(--gray-200); padding-bottom: var(--space-3);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-2);">
<h3 class="h4" style="margin: 0; flex: 1;">✅ Todo</h3>
<span class="badge badge-verified">검증완료</span>
</div>
<div style="display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center;">
<span class="text-caption" style="color: var(--text-tertiary);">검증자: 이준호</span>
<span class="text-caption" style="color: var(--text-tertiary);"></span>
<span class="text-caption" style="color: var(--text-tertiary);">시간: 14:42</span>
</div>
</div>
<div class="card-body">
<div class="todo-card priority-high" style="margin-bottom: var(--space-2);">
<div class="todo-checkbox" role="checkbox" aria-checked="false" tabindex="0" style="pointer-events: none;"></div>
<div class="todo-content">
<div class="todo-title">요구사항 정의</div>
<div class="todo-meta">
<span class="todo-assignee">@김민준</span>
<span class="todo-duedate">(~ 10/25)</span>
</div>
</div>
</div>
<div class="todo-card priority-medium">
<div class="todo-checkbox" role="checkbox" aria-checked="false" tabindex="0" style="pointer-events: none;"></div>
<div class="todo-content">
<div class="todo-title">기술 스택 검토</div>
<div class="todo-meta">
<span class="todo-assignee">@박서연</span>
<span class="todo-duedate">(~ 10/27)</span>
</div>
</div>
</div>
</div>
<div class="card-footer">
<button class="button-secondary button-small" onclick="editSection('todos')">
수정
</button>
<button class="button-ghost button-small" onclick="lockSection('todos')" aria-label="섹션 잠금">
🔒 잠금
</button>
</div>
</div>
</section>
<!-- Info Card -->
<div class="card" style="background-color: var(--info-50); border-color: var(--info-200);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2);">
<span style="font-size: 20px;">💡</span>
<span class="text-body" style="font-weight: 600; color: var(--info-700);">안내</span>
</div>
<p class="text-body" style="color: var(--info-700);">
검증 미완료 섹션이 있어도 다음 단계로 진행할 수 있습니다. 나중에 수정하고 다시 확정할 수 있습니다.
</p>
</div>
</main>
<!-- Edit Section Modal -->
<div id="edit-modal" class="modal-overlay" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="edit-modal-title">
<div class="modal">
<div class="modal-header">
<h2 id="edit-modal-title" class="modal-title">섹션 수정</h2>
<button class="modal-close" onclick="hideModal('edit-modal')" aria-label="닫기">×</button>
</div>
<div class="modal-body">
<div class="input-group">
<label for="edit-textarea" class="input-label">내용</label>
<textarea id="edit-textarea" class="input-field" rows="6" placeholder="내용을 입력하세요"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="button-secondary" onclick="hideModal('edit-modal')">
취소
</button>
<button class="button-primary" onclick="saveEdit()">
저장
</button>
</div>
</div>
</div>
<!-- Lock Section Confirmation Modal -->
<div id="lock-modal" class="modal-overlay" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="lock-modal-title">
<div class="modal">
<div class="modal-header">
<h2 id="lock-modal-title" class="modal-title">섹션 잠금</h2>
<button class="modal-close" onclick="hideModal('lock-modal')" aria-label="닫기">×</button>
</div>
<div class="modal-body">
<p class="text-body" style="margin-bottom: var(--space-3);">
이 섹션을 잠그시겠습니까?<br>
잠금 후에는 추가 수정이 불가능합니다.
</p>
<div class="card" style="background-color: var(--warning-50); border-color: var(--warning-200);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2);">
<span>⚠️</span>
<span class="text-caption" style="color: var(--warning-700); font-weight: 600;">주의</span>
</div>
<p class="text-caption" style="color: var(--warning-700);">
회의 생성자만 섹션을 잠글 수 있습니다. 잠금 후에는 회의 생성자만 잠금을 해제할 수 있습니다.
</p>
</div>
</div>
<div class="modal-footer">
<button class="button-secondary" onclick="hideModal('lock-modal')">
취소
</button>
<button class="button-primary" onclick="confirmLock()">
잠금
</button>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
// ============================================================================
// 상태 변수
// ============================================================================
let currentEditSection = null;
let currentLockSection = null;
const currentUser = getCurrentUser(); // common.js에서 가져옴
// ============================================================================
// 진행률 업데이트
// ============================================================================
function updateProgress() {
const sections = $$('[data-section]');
const totalSections = sections.length;
let verifiedCount = 0;
sections.forEach(section => {
if (section.dataset.verified === 'true') {
verifiedCount++;
}
});
const percentage = Math.round((verifiedCount / totalSections) * 100);
// 진행률 바 업데이트
const progressFill = $('#progress-fill');
const progressText = $('#progress-text');
if (progressFill) {
progressFill.style.width = `${percentage}%`;
}
if (progressText) {
progressText.textContent = `${percentage}% (${verifiedCount}/${totalSections})`;
}
// 진행률에 따른 색상 변경
if (progressFill) {
if (percentage === 100) {
removeClass(progressFill, 'warning');
addClass(progressFill, 'success');
} else if (percentage >= 50) {
removeClass(progressFill, 'error');
addClass(progressFill, 'warning');
}
}
}
// ============================================================================
// 섹션 검증
// ============================================================================
function verifySection(sectionId) {
const section = $(`[data-section="${sectionId}"]`);
if (!section) return;
// 검증 상태 업데이트
section.dataset.verified = 'true';
// UI 업데이트
const header = section.querySelector('.card-header');
const badge = header.querySelector('.badge');
const h3 = header.querySelector('h3');
// 아이콘 변경
h3.innerHTML = h3.innerHTML.replace('⚠️', '✅');
// 배지 변경
badge.textContent = '검증완료';
removeClass(badge, 'badge-pending');
addClass(badge, 'badge-verified');
// 검증자 정보 추가
const verifiedInfo = document.createElement('div');
verifiedInfo.style.cssText = 'display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center;';
verifiedInfo.innerHTML = `
<span class="text-caption" style="color: var(--text-tertiary);">검증자: ${currentUser.name}</span>
<span class="text-caption" style="color: var(--text-tertiary);"></span>
<span class="text-caption" style="color: var(--text-tertiary);">시간: ${formatTime(new Date())}</span>
`;
header.appendChild(verifiedInfo);
// 버튼 변경
const footer = section.querySelector('.card-footer');
footer.innerHTML = `
<button class="button-secondary button-small" onclick="editSection('${sectionId}')">
수정
</button>
<button class="button-ghost button-small" onclick="lockSection('${sectionId}')" aria-label="섹션 잠금">
🔒 잠금
</button>
`;
// 진행률 업데이트
updateProgress();
// 성공 메시지
showToast('섹션이 검증되었습니다', 'success');
// 실시간 동기화 시뮬레이션
setTimeout(() => {
showToast('다른 참석자에게 알림이 전송되었습니다', 'info', 2000);
}, 1000);
}
// ============================================================================
// 섹션 수정
// ============================================================================
function editSection(sectionId) {
currentEditSection = sectionId;
const section = $(`[data-section="${sectionId}"]`);
if (!section) return;
// 현재 내용 가져오기
const cardBody = section.querySelector('.card-body');
const currentContent = cardBody.textContent.trim();
// 모달에 내용 설정
$('#edit-textarea').value = currentContent;
$('#edit-modal-title').textContent = `${section.querySelector('h3').textContent.replace('✅ ', '').replace('⚠️ ', '')} 수정`;
showModal('edit-modal');
}
function saveEdit() {
if (!currentEditSection) return;
const newContent = $('#edit-textarea').value.trim();
if (!newContent) {
showToast('내용을 입력해주세요', 'error');
return;
}
const section = $(`[data-section="${currentEditSection}"]`);
if (!section) return;
// 내용 업데이트
const cardBody = section.querySelector('.card-body');
cardBody.innerHTML = `<p class="text-body">${newContent}</p>`;
// 검증 상태를 "검증 필요"로 변경
section.dataset.verified = 'false';
const header = section.querySelector('.card-header');
const badge = header.querySelector('.badge');
const h3 = header.querySelector('h3');
// 아이콘 변경
h3.innerHTML = h3.innerHTML.replace('✅', '⚠️');
// 배지 변경
badge.textContent = '검증 필요';
removeClass(badge, 'badge-verified');
addClass(badge, 'badge-pending');
// 검증자 정보 제거
const verifiedInfo = header.querySelectorAll('.text-caption');
verifiedInfo.forEach(info => {
if (info.textContent.includes('검증자')) {
info.parentElement.remove();
}
});
// 버튼 변경
const footer = section.querySelector('.card-footer');
footer.innerHTML = `
<button class="button-secondary button-small" onclick="editSection('${currentEditSection}')">
수정
</button>
<button class="button-primary button-small" onclick="verifySection('${currentEditSection}')">
✓ 검증완료
</button>
`;
// 진행률 업데이트
updateProgress();
// 모달 닫기
hideModal('edit-modal');
// 성공 메시지
showToast('섹션이 수정되었습니다. 검증이 필요합니다.', 'info');
}
// ============================================================================
// 섹션 잠금
// ============================================================================
function lockSection(sectionId) {
// 회의 생성자 권한 체크 (예제에서는 김민준만 가능)
if (!currentUser || currentUser.id !== 1) {
showToast('회의 생성자만 섹션을 잠글 수 있습니다', 'error');
return;
}
currentLockSection = sectionId;
showModal('lock-modal');
}
function confirmLock() {
if (!currentLockSection) return;
const section = $(`[data-section="${currentLockSection}"]`);
if (!section) return;
// 잠금 표시
const footer = section.querySelector('.card-footer');
footer.innerHTML = `
<button class="button-secondary button-small" disabled style="opacity: 0.5; cursor: not-allowed;">
🔒 잠금됨
</button>
`;
// 모달 닫기
hideModal('lock-modal');
// 성공 메시지
showToast('섹션이 잠금되었습니다', 'success');
}
// ============================================================================
// 다음 단계
// ============================================================================
function proceedToEnd() {
// 모든 섹션이 검증되었는지 확인
const sections = $$('[data-section]');
const allVerified = Array.from(sections).every(section => section.dataset.verified === 'true');
if (allVerified) {
showToast('모든 섹션이 검증되었습니다', 'success', 2000);
} else {
showToast('검증되지 않은 섹션이 있습니다. 나중에 수정할 수 있습니다.', 'info', 3000);
}
setTimeout(() => {
navigateTo('07-회의종료.html');
}, 2000);
}
// ============================================================================
// 초기화
// ============================================================================
updateProgress();
console.log('검증완료 화면 초기화 완료');
</script>
</body>
</html>

View File

@ -1,472 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="회의록 작성 및 공유 개선 서비스 - 회의종료">
<title>회의 종료 - 회의록 작성 서비스</title>
<link rel="stylesheet" href="common.css">
</head>
<body>
<!-- Skip to Main Content (접근성) -->
<a href="#main-content" class="skip-to-main">본문으로 건너뛰기</a>
<!-- Header -->
<header style="position: sticky; top: 0; background: var(--bg-primary); border-bottom: var(--border-thin) solid var(--gray-200); z-index: 10; padding: var(--space-4);">
<div style="display: flex; align-items: center; justify-content: space-between; max-width: 1440px; margin: 0 auto;">
<button class="button-icon button-ghost" onclick="goBack()" aria-label="뒤로가기">
<span style="font-size: 24px;"></span>
</button>
<h1 class="h4" style="margin: 0;">회의 종료</h1>
<button class="button-primary button-small" onclick="confirmMeeting()" aria-label="최종 확정">
확정
</button>
</div>
</header>
<!-- Main Content -->
<main id="main-content" class="container" style="padding-top: var(--space-4); padding-bottom: var(--space-6); max-width: 1024px;">
<!-- Completion Message -->
<section aria-labelledby="completion-section" style="text-align: center; margin-bottom: var(--space-6); padding: var(--space-6) 0;">
<div style="font-size: 64px; margin-bottom: var(--space-3);">🎉</div>
<h2 class="h2" id="completion-section" style="margin-bottom: var(--space-2);">회의가 종료되었습니다</h2>
<p class="text-body" style="color: var(--text-tertiary);">
회의록을 확인하고 최종 확정해주세요
</p>
</section>
<!-- Meeting Statistics Card -->
<section aria-labelledby="stats-section" style="margin-bottom: var(--space-6);">
<h2 class="h4" id="stats-section" style="margin-bottom: var(--space-4);">📊 회의 통계</h2>
<div class="card">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-4);">
<!-- 총 시간 -->
<div style="padding: var(--space-3); background-color: var(--bg-secondary); border-radius: var(--radius-medium);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2);">
<span style="font-size: 24px;">⏱️</span>
<span class="text-caption" style="color: var(--text-tertiary); font-weight: 600;">총 시간</span>
</div>
<div class="h3" style="color: var(--text-primary);">45분</div>
</div>
<!-- 참석자 -->
<div style="padding: var(--space-3); background-color: var(--bg-secondary); border-radius: var(--radius-medium);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2);">
<span style="font-size: 24px;">👥</span>
<span class="text-caption" style="color: var(--text-tertiary); font-weight: 600;">참석자</span>
</div>
<div class="h3" style="color: var(--text-primary);">3명</div>
</div>
</div>
<!-- 발언 횟수 -->
<div style="margin-top: var(--space-4); padding: var(--space-3); background-color: var(--bg-secondary); border-radius: var(--radius-medium);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3);">
<span style="font-size: 24px;">💬</span>
<span class="text-caption" style="color: var(--text-tertiary); font-weight: 600;">발언 횟수</span>
</div>
<div style="display: flex; flex-direction: column; gap: var(--space-2);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span class="text-body">김민준</span>
<div style="display: flex; align-items: center; gap: var(--space-2);">
<div class="progress-bar" style="width: 120px; height: 8px;">
<div class="progress-fill" style="width: 60%; background-color: var(--primary-500);"></div>
</div>
<span class="text-body" style="font-weight: 600;">12회</span>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span class="text-body">박서연</span>
<div style="display: flex; align-items: center; gap: var(--space-2);">
<div class="progress-bar" style="width: 120px; height: 8px;">
<div class="progress-fill" style="width: 40%; background-color: var(--info-500);"></div>
</div>
<span class="text-body" style="font-weight: 600;">8회</span>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span class="text-body">이준호</span>
<div style="display: flex; align-items: center; gap: var(--space-2);">
<div class="progress-bar" style="width: 120px; height: 8px;">
<div class="progress-fill" style="width: 25%; background-color: var(--success-500);"></div>
</div>
<span class="text-body" style="font-weight: 600;">5회</span>
</div>
</div>
</div>
</div>
<!-- 주요 키워드 -->
<div style="margin-top: var(--space-4); padding: var(--space-3); background-color: var(--bg-secondary); border-radius: var(--radius-medium);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3);">
<span style="font-size: 24px;">🔑</span>
<span class="text-caption" style="color: var(--text-tertiary); font-weight: 600;">주요 키워드</span>
</div>
<div style="display: flex; gap: var(--space-2); flex-wrap: wrap;">
<span class="badge badge-in-progress" style="cursor: pointer;" onclick="showKeywordContext('MVP')">#MVP</span>
<span class="badge badge-in-progress" style="cursor: pointer;" onclick="showKeywordContext('React')">#React</span>
<span class="badge badge-in-progress" style="cursor: pointer;" onclick="showKeywordContext('AWS')">#AWS</span>
<span class="badge badge-in-progress" style="cursor: pointer;" onclick="showKeywordContext('Sprint')">#Sprint</span>
<span class="badge badge-in-progress" style="cursor: pointer;" onclick="showKeywordContext('Q1')">#Q1</span>
</div>
</div>
</div>
</section>
<!-- AI Todo Auto Extraction -->
<section aria-labelledby="todos-section" style="margin-bottom: var(--space-6);">
<h2 class="h4" id="todos-section" style="margin-bottom: var(--space-4);">✅ AI Todo 자동 추출</h2>
<div class="card" style="background-color: var(--primary-50); border-color: var(--primary-200);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3);">
<span style="font-size: 24px;">💡</span>
<span class="text-body" style="font-weight: 600; color: var(--primary-700);">AI가 회의록에서 3개의 Todo를 자동으로 추출했습니다</span>
</div>
<!-- Todo 1 -->
<div class="todo-card priority-high" style="margin-bottom: var(--space-2); background-color: var(--bg-primary);">
<div class="todo-checkbox" onclick="toggleTodo(this, 1)" role="checkbox" aria-checked="false" tabindex="0"></div>
<div class="todo-content">
<div class="todo-title">요구사항 정의서 작성</div>
<div class="todo-meta">
<span class="todo-assignee">@김민준</span>
<span class="todo-duedate">📅 ~ 10/25</span>
<button class="button-ghost button-small" onclick="editTodo(1)" style="margin-left: var(--space-2); padding: var(--space-1) var(--space-2);">
✏️ 수정
</button>
</div>
</div>
</div>
<!-- Todo 2 -->
<div class="todo-card priority-medium" style="margin-bottom: var(--space-2); background-color: var(--bg-primary);">
<div class="todo-checkbox" onclick="toggleTodo(this, 2)" role="checkbox" aria-checked="false" tabindex="0"></div>
<div class="todo-content">
<div class="todo-title">기술 스택 상세 검토</div>
<div class="todo-meta">
<span class="todo-assignee">@박서연</span>
<span class="todo-duedate">📅 ~ 10/27</span>
<button class="button-ghost button-small" onclick="editTodo(2)" style="margin-left: var(--space-2); padding: var(--space-1) var(--space-2);">
✏️ 수정
</button>
</div>
</div>
</div>
<!-- Todo 3 -->
<div class="todo-card priority-high" style="background-color: var(--bg-primary);">
<div class="todo-checkbox" onclick="toggleTodo(this, 3)" role="checkbox" aria-checked="false" tabindex="0"></div>
<div class="todo-content">
<div class="todo-title">인프라 설계 문서 작성</div>
<div class="todo-meta">
<span class="todo-assignee">@이준호</span>
<span class="todo-duedate">📅 ~ 10/30</span>
<button class="button-ghost button-small" onclick="editTodo(3)" style="margin-left: var(--space-2); padding: var(--space-1) var(--space-2);">
✏️ 수정
</button>
</div>
</div>
</div>
<div style="margin-top: var(--space-4); text-align: center;">
<button class="button-secondary button-small" onclick="addNewTodo()">
Todo 추가
</button>
</div>
</div>
</section>
<!-- Required Items Checklist -->
<section aria-labelledby="checklist-section" style="margin-bottom: var(--space-6);">
<h2 class="h4" id="checklist-section" style="margin-bottom: var(--space-4);">필수 항목 확인</h2>
<div class="card">
<div style="display: flex; flex-direction: column; gap: var(--space-3);">
<div style="display: flex; align-items: center; gap: var(--space-3);">
<span style="font-size: 24px; color: var(--success-500);"></span>
<span class="text-body">회의 제목</span>
</div>
<div style="display: flex; align-items: center; gap: var(--space-3);">
<span style="font-size: 24px; color: var(--success-500);"></span>
<span class="text-body">참석자 목록</span>
</div>
<div style="display: flex; align-items: center; gap: var(--space-3);">
<span style="font-size: 24px; color: var(--success-500);"></span>
<span class="text-body">주요 논의 내용</span>
</div>
<div style="display: flex; align-items: center; gap: var(--space-3);">
<span style="font-size: 24px; color: var(--success-500);"></span>
<span class="text-body">결정 사항</span>
</div>
</div>
</div>
</section>
<!-- Action Buttons -->
<section style="display: flex; flex-direction: column; gap: var(--space-3);">
<button class="button-primary w-full" style="height: 48px; font-size: 1rem;" onclick="confirmMeeting()">
최종 회의록 확정
</button>
<button class="button-secondary w-full" onclick="saveLater()">
나중에 확정
</button>
</section>
</main>
<!-- Edit Todo Modal -->
<div id="edit-todo-modal" class="modal-overlay" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="edit-todo-title">
<div class="modal">
<div class="modal-header">
<h2 id="edit-todo-title" class="modal-title">Todo 수정</h2>
<button class="modal-close" onclick="hideModal('edit-todo-modal')" aria-label="닫기">×</button>
</div>
<div class="modal-body">
<div class="input-group" style="margin-bottom: var(--space-3);">
<label for="todo-content" class="input-label required">내용</label>
<input type="text" id="todo-content" class="input-field" placeholder="Todo 내용을 입력하세요" required>
</div>
<div class="input-group" style="margin-bottom: var(--space-3);">
<label for="todo-assignee" class="input-label required">담당자</label>
<select id="todo-assignee" class="input-field" required>
<option value="">선택하세요</option>
<option value="김민준">김민준</option>
<option value="박서연">박서연</option>
<option value="이준호">이준호</option>
<option value="최유진">최유진</option>
<option value="정도현">정도현</option>
</select>
</div>
<div class="input-group" style="margin-bottom: var(--space-3);">
<label for="todo-duedate" class="input-label required">마감일</label>
<input type="date" id="todo-duedate" class="input-field" required>
</div>
<div class="input-group">
<label for="todo-priority" class="input-label required">우선순위</label>
<select id="todo-priority" class="input-field" required>
<option value="">선택하세요</option>
<option value="high">높음</option>
<option value="medium">보통</option>
<option value="low">낮음</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="button-secondary" onclick="hideModal('edit-todo-modal')">
취소
</button>
<button class="button-primary" onclick="saveTodo()">
저장
</button>
</div>
</div>
</div>
<!-- Keyword Context Modal -->
<div id="keyword-modal" class="modal-overlay" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="keyword-title">
<div class="modal">
<div class="modal-header">
<h2 id="keyword-title" class="modal-title">키워드 맥락</h2>
<button class="modal-close" onclick="hideModal('keyword-modal')" aria-label="닫기">×</button>
</div>
<div class="modal-body" id="keyword-content">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
</div>
<script src="common.js"></script>
<script>
// ============================================================================
// 상태 변수
// ============================================================================
let currentEditTodoId = null;
// ============================================================================
// Todo 토글
// ============================================================================
function toggleTodo(checkbox, todoId) {
toggleClass(checkbox, 'checked');
const isChecked = checkbox.classList.contains('checked');
checkbox.setAttribute('aria-checked', isChecked);
const todoTitle = checkbox.nextElementSibling.querySelector('.todo-title');
if (isChecked) {
addClass(todoTitle, 'completed');
} else {
removeClass(todoTitle, 'completed');
}
}
// ============================================================================
// Todo 수정
// ============================================================================
function editTodo(todoId) {
currentEditTodoId = todoId;
// 예제 데이터 로드
const todoData = {
1: { content: '요구사항 정의서 작성', assignee: '김민준', dueDate: '2025-10-25', priority: 'high' },
2: { content: '기술 스택 상세 검토', assignee: '박서연', dueDate: '2025-10-27', priority: 'medium' },
3: { content: '인프라 설계 문서 작성', assignee: '이준호', dueDate: '2025-10-30', priority: 'high' }
};
const todo = todoData[todoId];
if (!todo) return;
// 모달에 데이터 설정
$('#todo-content').value = todo.content;
$('#todo-assignee').value = todo.assignee;
$('#todo-duedate').value = todo.dueDate;
$('#todo-priority').value = todo.priority;
showModal('edit-todo-modal');
}
function saveTodo() {
// 폼 검증
const content = $('#todo-content').value.trim();
const assignee = $('#todo-assignee').value;
const dueDate = $('#todo-duedate').value;
const priority = $('#todo-priority').value;
if (!content || !assignee || !dueDate || !priority) {
showToast('모든 필드를 입력해주세요', 'error');
return;
}
// Todo 업데이트 시뮬레이션
hideModal('edit-todo-modal');
showToast('Todo가 수정되었습니다', 'success');
}
function addNewTodo() {
currentEditTodoId = null;
// 모달 초기화
$('#todo-content').value = '';
$('#todo-assignee').value = '';
$('#todo-duedate').value = '';
$('#todo-priority').value = '';
showModal('edit-todo-modal');
}
// ============================================================================
// 키워드 맥락 표시
// ============================================================================
function showKeywordContext(keyword) {
const keywordData = {
'MVP': {
contexts: [
'"우리는 Q1까지 MVP를 완성해야 합니다" - 김민준 (14:23)',
'"MVP는 핵심 기능만 구현하여 빠르게 시장 검증을 하는 것이 목표입니다" - 박서연 (14:25)'
]
},
'React': {
contexts: [
'"개발 프레임워크는 React를 사용하기로 결정했습니다" - 김민준 (14:28)',
'"React는 컴포넌트 기반이라 유지보수가 용이합니다" - 최유진 (14:30)'
]
},
'AWS': {
contexts: [
'"배포 환경은 AWS로 결정했습니다" - 김민준 (14:28)',
'"AWS는 확장성이 좋고 관리 도구가 풍부합니다" - 이준호 (14:31)'
]
},
'Sprint': {
contexts: [
'"Sprint 주기는 2주로 설정합니다" - 박서연 (14:35)'
]
},
'Q1': {
contexts: [
'"우리는 Q1까지 MVP를 완성해야 합니다" - 김민준 (14:23)',
'"Q1 목표를 달성하기 위해서는 주간 단위로 진행 상황을 체크해야 합니다" - 박서연 (14:26)'
]
}
};
const data = keywordData[keyword];
if (!data) return;
const content = `
<div style="margin-bottom: var(--space-4);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3);">
<span class="badge badge-in-progress">#${keyword}</span>
<span class="text-caption" style="color: var(--text-tertiary);">회의록 내 ${data.contexts.length}회 언급</span>
</div>
<h3 class="h4" style="margin-bottom: var(--space-3);">💬 언급된 맥락</h3>
${data.contexts.map(context => `
<div style="padding: var(--space-3); background-color: var(--bg-secondary); border-radius: var(--radius-medium); margin-bottom: var(--space-2);">
<p class="text-body">${context}</p>
</div>
`).join('')}
<button class="button-secondary button-small w-full" onclick="hideModal('keyword-modal'); navigateTo('05-회의진행.html')">
회의록에서 확인하기
</button>
</div>
`;
$('#keyword-content').innerHTML = content;
showModal('keyword-modal');
}
// ============================================================================
// 최종 확정
// ============================================================================
function confirmMeeting() {
// 필수 항목 검증 (이미 모두 완료된 상태)
showToast('회의록을 최종 확정합니다...', 'info', 2000);
// Todo 서비스로 데이터 전달 시뮬레이션
setTimeout(() => {
showToast('Todo가 생성되었습니다', 'success', 2000);
}, 2000);
// 회의록 공유 화면으로 이동
setTimeout(() => {
navigateTo('08-회의록공유.html');
}, 4000);
}
// ============================================================================
// 나중에 확정
// ============================================================================
function saveLater() {
showToast('회의록이 저장되었습니다', 'success', 2000);
setTimeout(() => {
navigateTo('02-대시보드.html');
}, 2000);
}
// ============================================================================
// 키보드 접근성
// ============================================================================
// Enter/Space로 체크박스 토글
$$('.todo-checkbox').forEach(checkbox => {
checkbox.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
checkbox.click();
}
});
});
// ============================================================================
// 초기화
// ============================================================================
// 오늘 날짜 기본값 설정
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
$('#todo-duedate').setAttribute('min', formatDate(tomorrow));
console.log('회의종료 화면 초기화 완료');
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,170 +0,0 @@
<!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">
</head>
<body>
<div class="page">
<!-- 로그인 컨테이너 -->
<div class="content d-flex flex-column align-center justify-center" style="min-height: 100vh;">
<div class="card" style="max-width: 400px; width: 100%; text-align: center;">
<!-- 로고 및 타이틀 -->
<div class="mb-6">
<div style="font-size: 48px; margin-bottom: 16px;">📝</div>
<h1 class="text-h2">회의록 서비스</h1>
<p class="text-body text-gray">AI 기반 회의록 작성 및 공유</p>
</div>
<!-- 로그인 폼 -->
<form id="loginForm" class="text-left">
<div class="form-group">
<label for="employeeId" class="form-label required">사번</label>
<input
type="text"
id="employeeId"
class="form-input"
placeholder="EMP001"
data-validate="required|employeeId"
aria-label="사번"
aria-required="true"
>
</div>
<div class="form-group">
<label for="password" class="form-label required">비밀번호</label>
<input
type="password"
id="password"
class="form-input"
placeholder="비밀번호를 입력하세요"
data-validate="required|minLength:4"
aria-label="비밀번호"
aria-required="true"
>
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" id="rememberMe">
<span>로그인 상태 유지</span>
</label>
</div>
<button type="submit" class="btn btn-primary w-full" style="margin-top: 24px;">
로그인
</button>
</form>
<!-- 비밀번호 찾기 -->
<div class="mt-4 text-center">
<a href="#" class="text-body-sm text-primary" style="text-decoration: none;">비밀번호 찾기</a>
</div>
<!-- 테스트 계정 안내 -->
<div class="mt-6 p-4" style="background: var(--gray-100); border-radius: 8px;">
<p class="text-caption text-gray mb-2">테스트 계정</p>
<p class="text-body-sm">사번: EMP001 ~ EMP005</p>
<p class="text-body-sm">비밀번호: 1234</p>
</div>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
// 로그인 폼 제출 처리
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const employeeId = document.getElementById('employeeId').value.trim();
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('rememberMe').checked;
// 간단한 폼 검증
if (!employeeId || !password) {
UIComponents.showToast('사번과 비밀번호를 입력해주세요.', 'error');
return;
}
// 로딩 표시
UIComponents.showLoading('로그인 중...');
// 사용자 인증 시뮬레이션
setTimeout(() => {
const user = DUMMY_USERS.find(u => u.id === employeeId && u.password === password);
UIComponents.hideLoading();
if (user) {
// 로그인 성공
StorageManager.setCurrentUser({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
position: user.position,
rememberMe: rememberMe,
loginAt: new Date().toISOString()
});
UIComponents.showToast('로그인 성공', 'success');
// 대시보드로 이동
setTimeout(() => {
NavigationHelper.navigate('DASHBOARD');
}, 500);
} else {
// 로그인 실패
UIComponents.showToast('사번 또는 비밀번호가 올바르지 않습니다.', 'error');
// 필드 애니메이션 (shake)
const form = document.getElementById('loginForm');
form.style.animation = 'shake 0.5s';
setTimeout(() => {
form.style.animation = '';
}, 500);
}
}, 1000);
});
// 엔터키 처리
document.querySelectorAll('.form-input').forEach(input => {
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const form = document.getElementById('loginForm');
const inputs = Array.from(form.querySelectorAll('.form-input'));
const index = inputs.indexOf(e.target);
if (index < inputs.length - 1) {
// 다음 필드로 포커스 이동
inputs[index + 1].focus();
} else {
// 마지막 필드면 폼 제출
form.dispatchEvent(new Event('submit'));
}
}
});
});
// 자동 로그인 체크 (개발 편의)
const savedUser = StorageManager.getCurrentUser();
if (savedUser && savedUser.rememberMe) {
// 이미 로그인된 사용자는 대시보드로 이동
NavigationHelper.navigate('DASHBOARD');
}
</script>
<style>
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
20%, 40%, 60%, 80% { transform: translateX(5px); }
}
</style>
</body>
</html>

View File

@ -1,225 +0,0 @@
<!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">
</head>
<body>
<div class="page">
<!-- 헤더 -->
<div class="header">
<h1 class="header-title">회의록 서비스</h1>
<div class="d-flex align-center gap-2">
<button class="btn-icon" aria-label="검색" title="검색">
<span class="material-symbols-outlined">search</span>
</button>
<button class="btn-icon" aria-label="프로필" title="프로필" onclick="showProfileMenu()">
<span class="material-symbols-outlined">account_circle</span>
</button>
</div>
</div>
<!-- 메인 컨텐츠 -->
<div class="content" style="padding-bottom: 80px;">
<!-- 환영 메시지 -->
<div class="mb-6">
<h2 class="text-h3" id="welcomeMessage">안녕하세요!</h2>
<p class="text-body-sm text-gray">오늘도 효율적인 회의록 작성을 시작하세요</p>
</div>
<!-- 빠른 액션 -->
<div class="d-flex gap-2 mb-6">
<button class="btn btn-primary" onclick="NavigationHelper.navigate('TEMPLATE_SELECT')" style="flex: 1;">
<span class="material-symbols-outlined">play_circle</span>
새 회의 시작
</button>
<button class="btn btn-secondary" onclick="NavigationHelper.navigate('MEETING_SCHEDULE')">
<span class="material-symbols-outlined">calendar_today</span>
회의 예약
</button>
</div>
<!-- 내 Todo 카드 -->
<div class="card mb-4">
<div class="d-flex justify-between align-center mb-4">
<h3 class="text-h4">내 Todo</h3>
<a href="javascript:NavigationHelper.navigate('TODO_MANAGE')" class="text-body-sm text-primary" style="text-decoration: none;">전체 보기 →</a>
</div>
<div id="todoDashboard">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
<!-- 내 회의록 카드 -->
<div class="card mb-4">
<div class="d-flex justify-between align-center mb-4">
<h3 class="text-h4">내 회의록</h3>
<a href="#" class="text-body-sm text-primary" style="text-decoration: none;">전체 보기 →</a>
</div>
<div id="meetingsDashboard">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
<!-- 공유받은 회의록 카드 -->
<div class="card mb-4">
<div class="d-flex justify-between align-center mb-4">
<h3 class="text-h4">공유받은 회의록</h3>
<a href="#" class="text-body-sm text-primary" style="text-decoration: none;">전체 보기 →</a>
</div>
<div id="sharedMeetingsDashboard">
<p class="text-body-sm text-gray text-center" style="padding: 24px 0;">공유받은 회의록이 없습니다</p>
</div>
</div>
</div>
<!-- 하단 네비게이션 -->
<nav class="bottom-nav" role="navigation" aria-label="주요 메뉴">
<a href="02-대시보드.html" class="bottom-nav-item active" aria-current="page">
<span class="material-symbols-outlined bottom-nav-icon">home</span>
<span></span>
</a>
<a href="11-회의록수정.html" class="bottom-nav-item">
<span class="material-symbols-outlined bottom-nav-icon">description</span>
<span>회의록</span>
</a>
<a href="09-Todo관리.html" class="bottom-nav-item">
<span class="material-symbols-outlined bottom-nav-icon">check_box</span>
<span>Todo</span>
</a>
<a href="javascript:showProfileMenu()" class="bottom-nav-item">
<span class="material-symbols-outlined bottom-nav-icon">account_circle</span>
<span>프로필</span>
</a>
</nav>
</div>
<script src="common.js"></script>
<script>
// 인증 확인
if (!NavigationHelper.requireAuth()) {
// 로그인 필요
}
const currentUser = StorageManager.getCurrentUser();
// 환영 메시지
document.getElementById('welcomeMessage').textContent = `안녕하세요, ${currentUser.name}님!`;
// Todo 대시보드 렌더링
function renderTodoDashboard() {
const todos = StorageManager.getTodos();
const myTodos = todos.filter(todo => todo.assigneeId === currentUser.id && !todo.completed);
const container = document.getElementById('todoDashboard');
if (myTodos.length === 0) {
container.innerHTML = '<p class="text-body-sm text-gray text-center" style="padding: 24px 0;">할당된 Todo가 없습니다</p>';
return;
}
// 진행 중 Todo 개수
const inProgressCount = myTodos.filter(t => !t.completed).length;
// 마감 임박 Todo (3일 이내)
const dueSoonTodos = myTodos.filter(todo => isDueSoon(todo.dueDate)).slice(0, 3);
let html = `
<div class="d-flex align-center gap-4 mb-4">
<div class="d-flex align-center gap-2">
<div class="badge-count">${inProgressCount}</div>
<span class="text-body-sm">진행 중</span>
</div>
<div class="d-flex align-center gap-2">
<span class="material-symbols-outlined" style="color: var(--warning); font-size: 20px;">schedule</span>
<span class="text-body-sm">${dueSoonTodos.length}개 마감 임박</span>
</div>
</div>
`;
if (dueSoonTodos.length > 0) {
dueSoonTodos.forEach(todo => {
html += UIComponents.createTodoItem(todo);
});
}
container.innerHTML = html;
}
// 회의록 대시보드 렌더링
function renderMeetingsDashboard() {
const meetings = StorageManager.getMeetings();
const myMeetings = meetings
.filter(m => m.createdBy === currentUser.id || m.attendees.includes(currentUser.name))
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.slice(0, 5);
const container = document.getElementById('meetingsDashboard');
if (myMeetings.length === 0) {
container.innerHTML = '<p class="text-body-sm text-gray text-center" style="padding: 24px 0;">작성한 회의록이 없습니다. 첫 회의를 시작해보세요!</p>';
return;
}
let html = '';
myMeetings.forEach(meeting => {
html += UIComponents.createMeetingItem(meeting);
});
container.innerHTML = html;
}
// 프로필 메뉴 표시
function showProfileMenu() {
UIComponents.showModal({
title: '프로필',
content: `
<div class="d-flex flex-column gap-4">
<div class="d-flex align-center gap-3">
${UIComponents.createAvatar(currentUser.name, 60)}
<div>
<h3 class="text-h4">${currentUser.name}</h3>
<p class="text-body-sm text-gray">${currentUser.role} · ${currentUser.position}</p>
<p class="text-body-sm text-gray">${currentUser.email}</p>
</div>
</div>
<div style="border-top: 1px solid var(--gray-200); padding-top: 16px;">
<button class="btn btn-text w-full" style="justify-content: flex-start;">
<span class="material-symbols-outlined">settings</span>
설정
</button>
<button class="btn btn-text w-full" style="justify-content: flex-start; color: var(--error);" onclick="handleLogout()">
<span class="material-symbols-outlined">logout</span>
로그아웃
</button>
</div>
</div>
`,
footer: '',
onClose: () => {}
});
}
// 로그아웃 처리
function handleLogout() {
UIComponents.confirm(
'로그아웃 하시겠습니까?',
() => {
StorageManager.logout();
},
() => {}
);
}
// 초기 렌더링
renderTodoDashboard();
renderMeetingsDashboard();
</script>
</body>
</html>

View File

@ -1,350 +0,0 @@
<!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">
</head>
<body>
<div class="page">
<!-- 헤더 -->
<div class="header">
<button class="btn-icon" onclick="NavigationHelper.goBack()" aria-label="뒤로가기">
<span class="material-symbols-outlined">arrow_back</span>
</button>
<h1 class="header-title">회의 예약</h1>
<button type="submit" form="meetingForm" class="btn btn-primary btn-sm">저장</button>
</div>
<!-- 메인 컨텐츠 -->
<div class="content">
<form id="meetingForm">
<!-- 회의 제목 -->
<div class="form-group">
<label for="meetingTitle" class="form-label required">회의 제목</label>
<input
type="text"
id="meetingTitle"
class="form-input"
placeholder="회의 제목을 입력하세요"
maxlength="100"
data-validate="required|maxLength:100"
aria-label="회의 제목"
aria-required="true"
>
<p class="text-caption text-right mt-1" id="titleCounter">0 / 100</p>
</div>
<!-- 날짜 -->
<div class="form-group">
<label for="meetingDate" class="form-label required">회의 날짜</label>
<input
type="date"
id="meetingDate"
class="form-input"
data-validate="required"
aria-label="회의 날짜"
aria-required="true"
>
</div>
<!-- 시작 시간 / 종료 시간 -->
<div class="d-flex gap-2">
<div class="form-group" style="flex: 1;">
<label for="startTime" class="form-label required">시작 시간</label>
<input
type="time"
id="startTime"
class="form-input"
data-validate="required"
aria-label="시작 시간"
aria-required="true"
>
</div>
<div class="form-group" style="flex: 1;">
<label for="endTime" class="form-label required">종료 시간</label>
<input
type="time"
id="endTime"
class="form-input"
data-validate="required"
aria-label="종료 시간"
aria-required="true"
>
</div>
</div>
<!-- 종일 토글 -->
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" id="allDay" onchange="toggleAllDay()">
<span>종일</span>
</label>
</div>
<!-- 장소 -->
<div class="form-group">
<label for="location" class="form-label">장소</label>
<input
type="text"
id="location"
class="form-input"
placeholder="회의실 또는 온라인 링크"
maxlength="200"
aria-label="회의 장소"
>
</div>
<!-- 온라인/오프라인 선택 -->
<div class="form-group">
<div class="d-flex gap-2">
<button type="button" class="btn btn-secondary btn-sm" id="btnOffline" onclick="setLocationType('offline')" style="flex: 1;">
오프라인
</button>
<button type="button" class="btn btn-secondary btn-sm" id="btnOnline" onclick="setLocationType('online')" style="flex: 1;">
온라인
</button>
</div>
</div>
<!-- 참석자 -->
<div class="form-group">
<label class="form-label required">참석자 (최소 1명)</label>
<div id="attendeeChips" class="d-flex gap-2 mb-2" style="flex-wrap: wrap;">
<!-- JavaScript로 동적 생성 -->
</div>
<button type="button" class="btn btn-secondary btn-sm w-full" onclick="showAttendeeSearch()">
<span class="material-symbols-outlined">person_add</span>
참석자 추가
</button>
</div>
<!-- 안건 -->
<div class="form-group">
<label for="agenda" class="form-label">안건</label>
<textarea
id="agenda"
class="form-textarea"
rows="5"
placeholder="회의 안건을 입력하세요"
aria-label="회의 안건"
></textarea>
<button type="button" class="btn btn-text btn-sm mt-2" onclick="suggestAgenda()">
<span class="material-symbols-outlined">auto_awesome</span>
AI 안건 추천
</button>
</div>
</form>
</div>
</div>
<script src="common.js"></script>
<script>
if (!NavigationHelper.requireAuth()) {}
const currentUser = StorageManager.getCurrentUser();
let attendees = [];
let locationType = 'offline';
// 오늘 날짜 이전은 선택 불가
const today = new Date().toISOString().split('T')[0];
document.getElementById('meetingDate').setAttribute('min', today);
document.getElementById('meetingDate').value = today;
// 제목 글자 수 카운터
document.getElementById('meetingTitle').addEventListener('input', (e) => {
const counter = document.getElementById('titleCounter');
counter.textContent = `${e.target.value.length} / 100`;
});
// 종일 토글
function toggleAllDay() {
const allDay = document.getElementById('allDay').checked;
document.getElementById('startTime').disabled = allDay;
document.getElementById('endTime').disabled = allDay;
if (allDay) {
document.getElementById('startTime').value = '00:00';
document.getElementById('endTime').value = '23:59';
}
}
// 장소 유형 선택
function setLocationType(type) {
locationType = type;
const locationInput = document.getElementById('location');
document.getElementById('btnOffline').classList.toggle('btn-primary', type === 'offline');
document.getElementById('btnOffline').classList.toggle('btn-secondary', type !== 'offline');
document.getElementById('btnOnline').classList.toggle('btn-primary', type === 'online');
document.getElementById('btnOnline').classList.toggle('btn-secondary', type !== 'online');
if (type === 'online') {
locationInput.placeholder = '온라인 회의 링크 (자동 생성 가능)';
locationInput.value = 'https://meet.example.com/' + Utils.generateId('ROOM').toLowerCase();
} else {
locationInput.placeholder = '회의실 이름';
locationInput.value = '';
}
}
// 참석자 추가 모달
function showAttendeeSearch() {
const modal = UIComponents.showModal({
title: '참석자 추가',
content: `
<div class="form-group">
<input
type="text"
id="attendeeSearch"
class="form-input"
placeholder="이름 또는 이메일로 검색"
aria-label="참석자 검색"
>
</div>
<div id="attendeeSearchResults" style="max-height: 300px; overflow-y: auto;">
${DUMMY_USERS.map(user => `
<div class="meeting-item" onclick="addAttendee('${user.name}', '${user.email}', '${user.id}')">
<div style="flex: 1;">
<h4 class="text-body">${user.name}</h4>
<p class="text-caption text-gray">${user.role} · ${user.email}</p>
</div>
</div>
`).join('')}
</div>
`,
footer: '<button class="btn btn-secondary" onclick="closeModal()">닫기</button>',
onClose: () => {}
});
// 검색 기능
document.getElementById('attendeeSearch').addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const results = DUMMY_USERS.filter(user =>
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query) ||
user.role.toLowerCase().includes(query)
);
document.getElementById('attendeeSearchResults').innerHTML = results.map(user => `
<div class="meeting-item" onclick="addAttendee('${user.name}', '${user.email}', '${user.id}')">
<div style="flex: 1;">
<h4 class="text-body">${user.name}</h4>
<p class="text-caption text-gray">${user.role} · ${user.email}</p>
</div>
</div>
`).join('');
});
}
// 참석자 추가
function addAttendee(name, email, id) {
if (attendees.find(a => a.id === id)) {
UIComponents.showToast('이미 추가된 참석자입니다', 'warning');
return;
}
attendees.push({ id, name, email });
renderAttendees();
closeModal();
UIComponents.showToast(`${name} 님이 추가되었습니다`, 'success');
}
// 참석자 제거
function removeAttendee(id) {
attendees = attendees.filter(a => a.id !== id);
renderAttendees();
}
// 참석자 렌더링
function renderAttendees() {
const container = document.getElementById('attendeeChips');
container.innerHTML = attendees.map(attendee => `
<div class="badge badge-status" style="padding: 6px 12px; background: var(--primary-50); color: var(--primary-700);">
${attendee.name}
<button type="button" onclick="removeAttendee('${attendee.id}')" style="background: none; border: none; color: inherit; cursor: pointer; padding: 0; margin-left: 4px;">×</button>
</div>
`).join('');
}
// 모달 닫기
function closeModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) modal.remove();
}
// AI 안건 추천 (시뮬레이션)
function suggestAgenda() {
UIComponents.showLoading('AI가 안건을 추천하고 있습니다...');
setTimeout(() => {
const suggestions = [
'프로젝트 진행 상황 공유',
'이슈 및 리스크 논의',
'다음 주 일정 계획',
'역할 분담 및 업무 조율'
];
document.getElementById('agenda').value = suggestions.join('\n');
UIComponents.hideLoading();
UIComponents.showToast('AI 추천 안건이 추가되었습니다', 'success');
}, 1500);
}
// 폼 제출
document.getElementById('meetingForm').addEventListener('submit', (e) => {
e.preventDefault();
// 검증
if (!FormValidator.validate(e.target)) {
return;
}
if (attendees.length === 0) {
UIComponents.showToast('최소 1명의 참석자를 추가해주세요', 'error');
return;
}
const formData = {
id: Utils.generateId('MTG'),
title: document.getElementById('meetingTitle').value,
date: document.getElementById('meetingDate').value,
startTime: document.getElementById('startTime').value,
endTime: document.getElementById('endTime').value,
location: document.getElementById('location').value,
locationType: locationType,
attendees: attendees.map(a => a.name),
attendeeIds: attendees.map(a => a.id),
agenda: document.getElementById('agenda').value,
template: 'general',
status: 'scheduled',
createdBy: currentUser.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
UIComponents.showLoading('회의를 예약하는 중...');
setTimeout(() => {
StorageManager.addMeeting(formData);
UIComponents.hideLoading();
UIComponents.confirm(
'회의가 예약되었습니다. 참석자에게 초대 이메일을 발송하시겠습니까?',
() => {
UIComponents.showToast('초대 이메일이 발송되었습니다', 'success');
setTimeout(() => {
NavigationHelper.navigate('DASHBOARD');
}, 1000);
},
() => {
NavigationHelper.navigate('DASHBOARD');
}
);
}, 1000);
});
</script>
</body>
</html>

View File

@ -1,234 +0,0 @@
<!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">
</head>
<body>
<div class="page">
<!-- 헤더 -->
<div class="header">
<button class="btn-icon" onclick="NavigationHelper.goBack()" aria-label="뒤로가기">
<span class="material-symbols-outlined">arrow_back</span>
</button>
<h1 class="header-title">템플릿 선택</h1>
<button class="btn btn-text" onclick="skipTemplate()">건너뛰기</button>
</div>
<!-- 메인 컨텐츠 -->
<div class="content">
<p class="text-body mb-6">회의 유형에 맞는 템플릿을 선택하세요. 건너뛰면 일반 템플릿이 사용됩니다.</p>
<!-- 템플릿 카드 리스트 -->
<div id="templateList">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
</div>
<script src="common.js"></script>
<script>
if (!NavigationHelper.requireAuth()) {}
const currentUser = StorageManager.getCurrentUser();
const meetingId = NavigationHelper.getQueryParam('meetingId');
let selectedTemplate = null;
// 템플릿 렌더링
function renderTemplates() {
const templates = Object.values(TEMPLATES);
const container = document.getElementById('templateList');
container.innerHTML = templates.map(template => `
<div class="card mb-4 clickable" onclick="selectTemplate('${template.type}')">
<div class="d-flex align-center gap-4">
<div style="font-size: 48px;">${template.icon}</div>
<div style="flex: 1;">
<h3 class="text-h4">${template.name}</h3>
<p class="text-body-sm text-gray">${template.description}</p>
<p class="text-caption mt-2">섹션 ${template.sections.length}개</p>
</div>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); previewTemplate('${template.type}')">미리보기</button>
</div>
</div>
`).join('');
}
// 템플릿 선택
function selectTemplate(type) {
selectedTemplate = type;
showCustomizeModal(type);
}
// 템플릿 미리보기
function previewTemplate(type) {
const template = TEMPLATES[type];
UIComponents.showModal({
title: template.name + ' 미리보기',
content: `
<div class="d-flex align-center gap-3 mb-4">
<div style="font-size: 40px;">${template.icon}</div>
<div>
<h3 class="text-h4">${template.name}</h3>
<p class="text-body-sm text-gray">${template.description}</p>
</div>
</div>
<div>
<h4 class="text-h5 mb-3">포함된 섹션</h4>
${template.sections.map((section, index) => `
<div class="d-flex align-center gap-2 mb-2">
<span class="badge badge-status" style="min-width: 24px; background: var(--gray-200); color: var(--gray-700);">${index + 1}</span>
<span class="text-body">${section.name}</span>
</div>
`).join('')}
</div>
`,
footer: `
<button class="btn btn-secondary" onclick="closeModal()">닫기</button>
<button class="btn btn-primary" onclick="closeModal(); selectTemplate('${type}')">이 템플릿 선택</button>
`,
onClose: () => {}
});
}
// 커스터마이징 모달
function showCustomizeModal(type) {
const template = TEMPLATES[type];
let customSections = [...template.sections];
const modal = UIComponents.showModal({
title: '템플릿 커스터마이징',
content: `
<p class="text-body mb-4">섹션 순서를 변경하거나 추가/삭제할 수 있습니다.</p>
<div id="sectionList">
<!-- JavaScript로 동적 생성 -->
</div>
<button type="button" class="btn btn-secondary btn-sm w-full mt-3" onclick="addCustomSection()">
<span class="material-symbols-outlined">add</span>
섹션 추가
</button>
`,
footer: `
<button class="btn btn-secondary" onclick="closeModal()">취소</button>
<button class="btn btn-primary" onclick="startMeetingWithTemplate()">이 템플릿으로 시작</button>
`,
onClose: () => {}
});
renderSections();
function renderSections() {
const container = document.getElementById('sectionList');
container.innerHTML = customSections.map((section, index) => `
<div class="d-flex align-center gap-2 mb-2 p-2" style="background: var(--gray-50); border-radius: 8px;">
<span class="material-symbols-outlined" style="cursor: move; color: var(--gray-600);">drag_indicator</span>
<span class="text-body" style="flex: 1;">${section.name}</span>
<button type="button" class="btn-icon" onclick="moveSectionUp(${index})" ${index === 0 ? 'disabled' : ''}>
<span class="material-symbols-outlined">arrow_upward</span>
</button>
<button type="button" class="btn-icon" onclick="moveSectionDown(${index})" ${index === customSections.length - 1 ? 'disabled' : ''}>
<span class="material-symbols-outlined">arrow_downward</span>
</button>
<button type="button" class="btn-icon" onclick="removeSection(${index})" ${customSections.length <= 1 ? 'disabled' : ''}>
<span class="material-symbols-outlined" style="color: var(--error);">delete</span>
</button>
</div>
`).join('');
}
window.moveSectionUp = (index) => {
if (index > 0) {
[customSections[index], customSections[index - 1]] = [customSections[index - 1], customSections[index]];
renderSections();
}
};
window.moveSectionDown = (index) => {
if (index < customSections.length - 1) {
[customSections[index], customSections[index + 1]] = [customSections[index + 1], customSections[index]];
renderSections();
}
};
window.removeSection = (index) => {
if (customSections.length > 1) {
customSections.splice(index, 1);
renderSections();
} else {
UIComponents.showToast('최소 1개의 섹션이 필요합니다', 'warning');
}
};
window.addCustomSection = () => {
const sectionName = prompt('섹션 이름을 입력하세요:');
if (sectionName && sectionName.trim()) {
customSections.push({
id: Utils.generateId('SEC'),
name: sectionName.trim(),
order: customSections.length + 1,
content: '',
custom: true
});
renderSections();
}
};
window.startMeetingWithTemplate = () => {
if (customSections.length === 0) {
UIComponents.showToast('최소 1개의 섹션이 필요합니다', 'error');
return;
}
// 템플릿 데이터 저장
const templateData = {
type: type,
name: template.name,
sections: customSections.map((section, index) => ({
...section,
order: index + 1
}))
};
localStorage.setItem('selected_template', JSON.stringify(templateData));
closeModal();
// 회의 진행 화면으로 이동
const params = meetingId ? { meetingId } : {};
NavigationHelper.navigate('MEETING_IN_PROGRESS', params);
};
}
// 모달 닫기
function closeModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) modal.remove();
}
// 건너뛰기 (기본 템플릿 사용)
function skipTemplate() {
UIComponents.confirm(
'기본 템플릿으로 회의를 시작하시겠습니까?',
() => {
const templateData = {
type: 'general',
name: TEMPLATES.general.name,
sections: [...TEMPLATES.general.sections]
};
localStorage.setItem('selected_template', JSON.stringify(templateData));
const params = meetingId ? { meetingId } : {};
NavigationHelper.navigate('MEETING_IN_PROGRESS', params);
},
() => {}
);
}
// 초기 렌더링
renderTemplates();
</script>
</body>
</html>

View File

@ -1,434 +0,0 @@
<!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>
.live-speech {
background: var(--accent-50);
border-left: 4px solid var(--accent-500);
padding: 16px;
border-radius: 8px;
position: sticky;
top: 60px;
z-index: 10;
}
.speaking-indicator {
width: 8px;
height: 8px;
background: var(--error);
border-radius: 50%;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.section-content {
min-height: 100px;
padding: 12px;
background: var(--white);
border: 1px solid var(--gray-300);
border-radius: 8px;
white-space: pre-wrap;
word-wrap: break-word;
}
.section-content[contenteditable="true"] {
outline: 2px solid var(--primary-500);
}
.term-highlight {
background: linear-gradient(180deg, transparent 60%, var(--accent-200) 60%);
cursor: pointer;
border-bottom: 1px dotted var(--accent-500);
}
.recording-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--error-bg);
border-radius: 20px;
font-size: 13px;
color: var(--error);
}
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--white);
border-top: 1px solid var(--gray-200);
padding: 12px 16px;
display: flex;
gap: 8px;
z-index: var(--z-fixed);
}
</style>
</head>
<body>
<div class="page">
<!-- 헤더 -->
<div class="header">
<div style="flex: 1;">
<h1 class="header-title" id="meetingTitle">회의 진행</h1>
<div class="d-flex align-center gap-3 mt-1">
<span class="text-caption" id="elapsedTime">00:00:00</span>
<div class="recording-status">
<div class="speaking-indicator"></div>
<span>녹음 중</span>
</div>
</div>
</div>
<button class="btn-icon" onclick="showMenu()" aria-label="메뉴">
<span class="material-symbols-outlined">more_vert</span>
</button>
</div>
<!-- 메인 컨텐츠 -->
<div class="content" style="padding-bottom: 80px;">
<!-- 실시간 발언 영역 -->
<div class="live-speech mb-4">
<div class="d-flex align-center gap-2 mb-2">
<span class="material-symbols-outlined" style="color: var(--accent-700);">mic</span>
<span class="text-h6" style="color: var(--accent-700);" id="currentSpeaker">김철수</span>
</div>
<p class="text-body" id="liveText">회의를 시작하겠습니다. 오늘은 프로젝트 킥오프 회의로...</p>
</div>
<!-- AI 처리 인디케이터 -->
<div class="ai-processing mb-4">
<span class="material-symbols-outlined ai-icon">auto_awesome</span>
<span>AI가 발언 내용을 분석하여 회의록을 작성하고 있습니다</span>
</div>
<!-- 회의록 섹션들 -->
<div id="sectionList">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
<!-- 하단 액션 바 -->
<div class="action-bar">
<button class="btn btn-secondary" onclick="pauseRecording()" id="pauseBtn">
<span class="material-symbols-outlined">pause</span>
일시정지
</button>
<button class="btn btn-text" onclick="addManualNote()">
<span class="material-symbols-outlined">edit_note</span>
메모 추가
</button>
<button class="btn btn-primary" onclick="endMeeting()" style="flex: 1;">
<span class="material-symbols-outlined">stop_circle</span>
회의 종료
</button>
</div>
</div>
<script src="common.js"></script>
<script>
if (!NavigationHelper.requireAuth()) {}
const currentUser = StorageManager.getCurrentUser();
const meetingId = NavigationHelper.getQueryParam('meetingId') || Utils.generateId('MTG');
let templateData = JSON.parse(localStorage.getItem('selected_template') || 'null') || {
type: 'general',
name: '일반 회의',
sections: TEMPLATES.general.sections
};
let isRecording = true;
let isPaused = false;
let startTime = Date.now();
let elapsedInterval;
// 경과 시간 표시
function updateElapsedTime() {
const elapsed = Date.now() - startTime;
document.getElementById('elapsedTime').textContent = Utils.formatDuration(elapsed);
}
elapsedInterval = setInterval(updateElapsedTime, 1000);
// 섹션 렌더링
function renderSections() {
const container = document.getElementById('sectionList');
container.innerHTML = templateData.sections.map((section, index) => `
<div class="card mb-4" id="section-${section.id}">
<div class="d-flex justify-between align-center mb-3">
<h3 class="text-h4">${section.name}</h3>
<div class="d-flex align-center gap-2">
${section.verified ? '<span class="verified-badge"><span class="material-symbols-outlined" style="font-size: 14px;">check_circle</span> 검증완료</span>' : ''}
<button class="btn-icon" onclick="toggleEdit('${section.id}')">
<span class="material-symbols-outlined">edit</span>
</button>
</div>
</div>
<div
class="section-content"
id="content-${section.id}"
contenteditable="false"
>${section.content || '(AI가 발언 내용을 분석하여 자동으로 작성합니다)'}</div>
<div class="d-flex justify-between align-center mt-3">
<button class="btn btn-text btn-sm" onclick="improveSection('${section.id}')">
<span class="material-symbols-outlined">auto_awesome</span>
AI 개선
</button>
<label class="form-checkbox">
<input type="checkbox" ${section.verified ? 'checked' : ''} onchange="toggleVerify('${section.id}', this.checked)">
<span class="text-body-sm">검증 완료</span>
</label>
</div>
</div>
`).join('');
// 실시간 AI 작성 시뮬레이션
simulateAIWriting();
}
// AI 자동 작성 시뮬레이션
function simulateAIWriting() {
const sampleContent = {
'참석자': '김철수 (기획팀 팀장), 이영희 (개발팀 선임), 박민수 (디자인팀 사원)',
'안건': '신규 회의록 서비스 프로젝트 킥오프\n- 프로젝트 목표 및 범위 확정\n- 역할 분담 및 일정 계획',
'논의 내용': 'Mobile First 설계 방침으로 진행하기로 결정\nAI 기반 회의록 자동 작성 기능을 핵심으로 개발\n템플릿 시스템 및 실시간 협업 기능 포함',
'결정 사항': '개발 기간: 2025년 Q4까지\n기술 스택: React, Node.js, PostgreSQL\n주간 스크럼 회의 매주 월요일 09:00',
'Todo': '김철수: 프로젝트 계획서 작성 (10/25까지)\n이영희: API 문서 작성 (10/24까지)\n박민수: 디자인 시안 1차 검토 (10/23까지)'
};
templateData.sections.forEach((section, index) => {
setTimeout(() => {
const content = sampleContent[section.name] || `${section.name}에 대한 내용이 자동으로 작성됩니다...`;
const contentEl = document.getElementById(`content-${section.id}`);
if (contentEl) {
contentEl.textContent = content;
section.content = content;
// 전문용어 하이라이트 추가
highlightTerms(section.id);
}
}, (index + 1) * 2000);
});
}
// 전문용어 하이라이트
function highlightTerms(sectionId) {
const contentEl = document.getElementById(`content-${sectionId}`);
if (!contentEl) return;
const terms = ['Mobile First', 'AI', 'API', 'PostgreSQL', 'React'];
let html = contentEl.textContent;
terms.forEach(term => {
const regex = new RegExp(term, 'g');
html = html.replace(regex, `<span class="term-highlight" onclick="showTermExplanation('${term}')">${term}</span>`);
});
contentEl.innerHTML = html;
}
// 전문용어 설명 표시
function showTermExplanation(term) {
const explanations = {
'Mobile First': 'Mobile First는 모바일 환경을 우선적으로 고려하여 디자인하고, 이후 더 큰 화면으로 확장하는 설계 방법론입니다.',
'AI': 'Artificial Intelligence의 약자로, 인공지능을 의미합니다. 이 프로젝트에서는 회의록 자동 작성에 활용됩니다.',
'API': 'Application Programming Interface의 약자로, 소프트웨어 간 상호작용을 위한 인터페이스입니다.',
'PostgreSQL': '오픈소스 관계형 데이터베이스 관리 시스템(RDBMS)입니다.',
'React': 'Facebook에서 개발한 사용자 인터페이스 구축을 위한 JavaScript 라이브러리입니다.'
};
UIComponents.showToast(explanations[term] || '설명을 불러오는 중...', 'info', 5000);
}
// 섹션 편집 토글
function toggleEdit(sectionId) {
const contentEl = document.getElementById(`content-${sectionId}`);
const isEditable = contentEl.getAttribute('contenteditable') === 'true';
contentEl.setAttribute('contenteditable', !isEditable);
if (!isEditable) {
contentEl.focus();
UIComponents.showToast('수정 모드 활성화', 'info');
} else {
// 저장
const section = templateData.sections.find(s => s.id === sectionId);
if (section) {
section.content = contentEl.textContent;
}
UIComponents.showToast('변경사항이 저장되었습니다', 'success');
}
}
// 섹션 검증 토글
function toggleVerify(sectionId, checked) {
const section = templateData.sections.find(s => s.id === sectionId);
if (section) {
section.verified = checked;
section.verifiedBy = checked ? [currentUser.name] : [];
}
renderSections();
UIComponents.showToast(checked ? '섹션이 검증되었습니다' : '검증이 취소되었습니다', checked ? 'success' : 'info');
}
// AI 개선
function improveSection(sectionId) {
UIComponents.showLoading('AI가 내용을 개선하고 있습니다...');
setTimeout(() => {
UIComponents.hideLoading();
UIComponents.showToast('AI 개선이 완료되었습니다', 'success');
}, 2000);
}
// 녹음 일시정지/재개
function pauseRecording() {
isPaused = !isPaused;
const btn = document.getElementById('pauseBtn');
const indicator = document.querySelector('.recording-status');
if (isPaused) {
btn.innerHTML = '<span class="material-symbols-outlined">play_arrow</span> 재개';
indicator.style.background = 'var(--gray-200)';
indicator.style.color = 'var(--gray-600)';
indicator.querySelector('span:last-child').textContent = '일시정지';
UIComponents.showToast('녹음이 일시정지되었습니다', 'info');
} else {
btn.innerHTML = '<span class="material-symbols-outlined">pause</span> 일시정지';
indicator.style.background = 'var(--error-bg)';
indicator.style.color = 'var(--error)';
indicator.querySelector('span:last-child').textContent = '녹음 중';
UIComponents.showToast('녹음이 재개되었습니다', 'success');
}
}
// 수동 메모 추가
function addManualNote() {
const note = prompt('추가할 메모를 입력하세요:');
if (note && note.trim()) {
UIComponents.showToast('메모가 추가되었습니다', 'success');
// 실제로는 해당 섹션에 추가
}
}
// 메뉴 표시
function showMenu() {
UIComponents.showModal({
title: '회의 설정',
content: `
<div class="d-flex flex-column gap-2">
<button class="btn btn-text" style="justify-content: flex-start;" onclick="closeModal(); viewParticipants()">
<span class="material-symbols-outlined">group</span>
참석자 목록
</button>
<button class="btn btn-text" style="justify-content: flex-start;" onclick="closeModal(); viewKeywords()">
<span class="material-symbols-outlined">sell</span>
주요 키워드
</button>
<button class="btn btn-text" style="justify-content: flex-start;" onclick="closeModal(); viewStatistics()">
<span class="material-symbols-outlined">bar_chart</span>
발언 통계
</button>
</div>
`,
footer: '<button class="btn btn-secondary" onclick="closeModal()">닫기</button>',
onClose: () => {}
});
}
// 참석자 목록 표시
function viewParticipants() {
UIComponents.showToast('참석자: ' + DUMMY_USERS.slice(0, 3).map(u => u.name).join(', '), 'info', 3000);
}
// 주요 키워드 표시
function viewKeywords() {
UIComponents.showToast('주요 키워드: Mobile First, AI, 프로젝트, 개발', 'info', 3000);
}
// 발언 통계 표시
function viewStatistics() {
UIComponents.showToast('발언 통계: 김철수 40%, 이영희 35%, 박민수 25%', 'info', 3000);
}
// 모달 닫기
function closeModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) modal.remove();
}
// 회의 종료
function endMeeting() {
UIComponents.confirm(
'회의를 종료하시겠습니까? 회의록이 저장됩니다.',
() => {
clearInterval(elapsedInterval);
// 회의록 저장
const duration = Date.now() - startTime;
const meetingData = {
id: meetingId,
title: document.getElementById('meetingTitle').textContent || '제목 없는 회의',
date: new Date().toISOString().split('T')[0],
startTime: new Date(startTime).toTimeString().slice(0, 5),
endTime: new Date().toTimeString().slice(0, 5),
duration: duration,
location: '온라인',
attendees: DUMMY_USERS.slice(0, 3).map(u => u.name),
template: templateData.type,
status: 'draft',
sections: templateData.sections,
createdBy: currentUser.id,
createdAt: new Date(startTime).toISOString(),
updatedAt: new Date().toISOString()
};
StorageManager.addMeeting(meetingData);
localStorage.setItem('current_meeting', JSON.stringify(meetingData));
UIComponents.showToast('회의가 종료되었습니다', 'success');
setTimeout(() => {
NavigationHelper.navigate('MEETING_END', { meetingId });
}, 1000);
},
() => {}
);
}
// 초기 렌더링
renderSections();
// 실시간 발언 시뮬레이션
const speeches = [
{ speaker: '김철수', text: '프로젝트 킥오프 회의를 시작하겠습니다...' },
{ speaker: '이영희', text: '개발 일정에 대해 의견을 드리겠습니다...' },
{ speaker: '박민수', text: '디자인 시안은 다음 주까지 준비하겠습니다...' }
];
let speechIndex = 0;
setInterval(() => {
const speech = speeches[speechIndex % speeches.length];
document.getElementById('currentSpeaker').textContent = speech.speaker;
document.getElementById('liveText').textContent = speech.text;
speechIndex++;
}, 5000);
// 페이지 이탈 방지
window.addEventListener('beforeunload', (e) => {
e.preventDefault();
e.returnValue = '';
});
</script>
</body>
</html>

View File

@ -1,219 +0,0 @@
<!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">
</head>
<body>
<div class="page">
<!-- 헤더 -->
<div class="header">
<button class="btn-icon" onclick="NavigationHelper.goBack()" aria-label="뒤로가기">
<span class="material-symbols-outlined">arrow_back</span>
</button>
<h1 class="header-title">검증 완료</h1>
<div></div>
</div>
<!-- 메인 컨텐츠 -->
<div class="content">
<!-- 진행률 바 -->
<div class="card mb-4">
<h3 class="text-h5 mb-3">전체 검증 진행률</h3>
<div class="d-flex align-center gap-3 mb-2">
<div style="flex: 1;">
<div class="progress-bar" style="height: 8px;">
<div class="progress-fill" id="progressFill" style="width: 0%;"></div>
</div>
</div>
<span class="text-h5" id="progressPercent">0%</span>
</div>
<p class="text-body-sm text-gray" id="progressText">0 / 0 섹션 검증 완료</p>
</div>
<!-- 섹션 리스트 -->
<h3 class="text-h4 mb-4">섹션별 검증 상태</h3>
<div id="sectionList">
<!-- JavaScript로 동적 생성 -->
</div>
<!-- 하단 액션 -->
<div class="mt-6">
<button class="btn btn-primary w-full mb-2" id="completeBtn" onclick="completeVerification()" disabled>
모두 검증 완료
</button>
<button class="btn btn-secondary w-full" onclick="NavigationHelper.goBack()">
나중에 하기
</button>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
if (!NavigationHelper.requireAuth()) {}
const currentUser = StorageManager.getCurrentUser();
const meetingId = NavigationHelper.getQueryParam('meetingId');
let meeting = meetingId ? StorageManager.getMeetingById(meetingId) : JSON.parse(localStorage.getItem('current_meeting') || 'null');
if (!meeting) {
UIComponents.showToast('회의 정보를 찾을 수 없습니다', 'error');
setTimeout(() => NavigationHelper.navigate('DASHBOARD'), 2000);
}
let sections = meeting ? [...meeting.sections] : [];
// 섹션 렌더링
function renderSections() {
const container = document.getElementById('sectionList');
container.innerHTML = sections.map(section => {
const isVerified = section.verified || false;
const verifiers = section.verifiedBy || [];
const isCreator = meeting.createdBy === currentUser.id;
return `
<div class="card mb-3" style="border-left: 4px solid ${isVerified ? 'var(--success)' : 'var(--gray-300)'};">
<div class="d-flex justify-between align-center mb-3">
<div class="d-flex align-center gap-2">
<span class="material-symbols-outlined" style="color: ${isVerified ? 'var(--success)' : 'var(--gray-400)'}; font-size: 24px;">
${isVerified ? 'check_circle' : 'radio_button_unchecked'}
</span>
<h4 class="text-h5">${section.name}</h4>
</div>
${section.locked && isCreator ? '<span class="material-symbols-outlined" style="color: var(--gray-600);">lock</span>' : ''}
</div>
<div class="d-flex align-center gap-2 mb-3">
${verifiers.length > 0 ? verifiers.map(name => UIComponents.createAvatar(name, 28)).join('') : '<p class="text-caption text-gray">아직 검증되지 않았습니다</p>'}
</div>
<div class="d-flex gap-2">
<button
class="btn ${isVerified ? 'btn-secondary' : 'btn-primary'} btn-sm"
onclick="toggleSectionVerify('${section.id}')"
${section.locked ? 'disabled' : ''}
>
${isVerified ? '검증 취소' : '검증 완료'}
</button>
${isCreator && isVerified ? `
<button class="btn btn-text btn-sm" onclick="toggleSectionLock('${section.id}')">
<span class="material-symbols-outlined">${section.locked ? 'lock_open' : 'lock'}</span>
${section.locked ? '잠금 해제' : '잠금'}
</button>
` : ''}
</div>
</div>
`;
}).join('');
updateProgress();
}
// 섹션 검증 토글
function toggleSectionVerify(sectionId) {
const section = sections.find(s => s.id === sectionId);
if (!section) return;
if (section.verified) {
// 검증 취소
section.verified = false;
section.verifiedBy = (section.verifiedBy || []).filter(name => name !== currentUser.name);
UIComponents.showToast('검증이 취소되었습니다', 'info');
} else {
// 검증 완료
UIComponents.confirm(
`"${section.name}" 섹션을 검증 완료 처리하시겠습니까?`,
() => {
section.verified = true;
section.verifiedBy = [...(section.verifiedBy || []), currentUser.name];
UIComponents.showToast('검증이 완료되었습니다', 'success');
renderSections();
// 회의록 업데이트
if (meeting) {
meeting.sections = sections;
StorageManager.updateMeeting(meeting.id, meeting);
}
},
() => {}
);
return;
}
renderSections();
// 회의록 업데이트
if (meeting) {
meeting.sections = sections;
StorageManager.updateMeeting(meeting.id, meeting);
}
}
// 섹션 잠금 토글 (회의 생성자만)
function toggleSectionLock(sectionId) {
const section = sections.find(s => s.id === sectionId);
if (!section || !section.verified) return;
section.locked = !section.locked;
UIComponents.showToast(
section.locked ? '섹션이 잠겼습니다. 더 이상 수정할 수 없습니다.' : '섹션 잠금이 해제되었습니다.',
section.locked ? 'warning' : 'info'
);
renderSections();
// 회의록 업데이트
if (meeting) {
meeting.sections = sections;
StorageManager.updateMeeting(meeting.id, meeting);
}
}
// 진행률 업데이트
function updateProgress() {
const total = sections.length;
const verified = sections.filter(s => s.verified).length;
const percent = total > 0 ? Math.round((verified / total) * 100) : 0;
document.getElementById('progressFill').style.width = `${percent}%`;
document.getElementById('progressPercent').textContent = `${percent}%`;
document.getElementById('progressText').textContent = `${verified} / ${total} 섹션 검증 완료`;
// 모두 검증 완료 버튼 활성화
const completeBtn = document.getElementById('completeBtn');
if (percent === 100) {
completeBtn.disabled = false;
completeBtn.classList.remove('btn-secondary');
completeBtn.classList.add('btn-primary');
} else {
completeBtn.disabled = true;
completeBtn.classList.add('btn-secondary');
completeBtn.classList.remove('btn-primary');
}
}
// 검증 완료
function completeVerification() {
UIComponents.confirm(
'모든 섹션이 검증되었습니다. 계속 진행하시겠습니까?',
() => {
UIComponents.showToast('검증이 완료되었습니다', 'success');
setTimeout(() => {
NavigationHelper.goBack();
}, 1000);
},
() => {}
);
}
// 초기 렌더링
renderSections();
</script>
</body>
</html>

View File

@ -1,211 +0,0 @@
<!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">
</head>
<body>
<div class="page">
<!-- 헤더 -->
<div class="header">
<h1 class="header-title">회의가 종료되었습니다</h1>
<div></div>
</div>
<!-- 메인 컨텐츠 -->
<div class="content">
<!-- 회의 정보 -->
<div class="card mb-4 text-center">
<div style="font-size: 48px; margin-bottom: 16px;"></div>
<h2 class="text-h3 mb-2" id="meetingTitle">회의 제목</h2>
<p class="text-body text-gray" id="meetingInfo">2025-10-21 10:00 ~ 11:30</p>
</div>
<!-- 회의 통계 -->
<div class="card mb-4">
<h3 class="text-h4 mb-4">회의 통계</h3>
<div class="d-flex justify-between mb-3">
<span class="text-body">회의 총 시간</span>
<span class="text-h5" id="totalTime">01:30:00</span>
</div>
<div class="d-flex justify-between mb-3">
<span class="text-body">참석자 수</span>
<span class="text-h5" id="attendeeCount">3명</span>
</div>
<div class="d-flex justify-between">
<span class="text-body">주요 키워드</span>
<div class="d-flex gap-1" style="flex-wrap: wrap;">
<span class="badge badge-status">Mobile First</span>
<span class="badge badge-status">AI</span>
<span class="badge badge-status">프로젝트</span>
</div>
</div>
</div>
<!-- AI Todo 추출 결과 -->
<div class="card mb-4">
<div class="d-flex justify-between align-center mb-3">
<h3 class="text-h4">AI가 추출한 Todo</h3>
<button class="btn btn-text btn-sm" onclick="editTodos()">
<span class="material-symbols-outlined">edit</span>
수정
</button>
</div>
<div id="todoList">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
<!-- 최종 확정 체크리스트 -->
<div class="card mb-4">
<h3 class="text-h4 mb-3">최종 확정 체크리스트</h3>
<label class="form-checkbox mb-2">
<input type="checkbox" id="check1" checked disabled>
<span>회의 제목 작성</span>
</label>
<label class="form-checkbox mb-2">
<input type="checkbox" id="check2" checked disabled>
<span>참석자 목록 작성</span>
</label>
<label class="form-checkbox mb-2">
<input type="checkbox" id="check3" checked disabled>
<span>주요 논의 내용 작성</span>
</label>
<label class="form-checkbox mb-2">
<input type="checkbox" id="check4" checked disabled>
<span>결정 사항 작성</span>
</label>
</div>
<!-- 액션 버튼 -->
<div class="d-flex flex-column gap-2">
<button class="btn btn-primary w-full" onclick="confirmMeeting()">
<span class="material-symbols-outlined">check_circle</span>
최종 회의록 확정
</button>
<button class="btn btn-secondary w-full" onclick="NavigationHelper.navigate('MEETING_SHARE', { meetingId: meeting.id })">
<span class="material-symbols-outlined">share</span>
회의록 공유하기
</button>
<button class="btn btn-text w-full" onclick="NavigationHelper.navigate('MEETING_EDIT', { id: meeting.id })">
회의록 수정하기
</button>
<button class="btn btn-text w-full" onclick="NavigationHelper.navigate('DASHBOARD')">
대시보드로 돌아가기
</button>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
if (!NavigationHelper.requireAuth()) {}
const currentUser = StorageManager.getCurrentUser();
const meetingId = NavigationHelper.getQueryParam('meetingId');
let meeting = meetingId ? StorageManager.getMeetingById(meetingId) : JSON.parse(localStorage.getItem('current_meeting') || 'null');
if (!meeting) {
UIComponents.showToast('회의 정보를 찾을 수 없습니다', 'error');
setTimeout(() => NavigationHelper.navigate('DASHBOARD'), 2000);
}
// 회의 정보 표시
if (meeting) {
document.getElementById('meetingTitle').textContent = meeting.title;
document.getElementById('meetingInfo').textContent = `${Utils.formatDate(meeting.date)} ${meeting.startTime} ~ ${meeting.endTime}`;
document.getElementById('totalTime').textContent = Utils.formatDuration(meeting.duration || 5400000);
document.getElementById('attendeeCount').textContent = `${meeting.attendees?.length || 0}명`;
}
// AI Todo 추출 및 렌더링
function renderTodos() {
const todos = [
{ content: '프로젝트 계획서 작성 및 공유', assignee: '김철수', dueDate: '2025-10-25', priority: 'high' },
{ content: 'API 문서 작성', assignee: '이영희', dueDate: '2025-10-24', priority: 'high' },
{ content: '디자인 시안 1차 검토', assignee: '박민수', dueDate: '2025-10-23', priority: 'medium' }
];
const container = document.getElementById('todoList');
container.innerHTML = todos.map(todo => `
<div class="d-flex align-center gap-2 mb-3 p-3" style="background: var(--gray-50); border-radius: 8px;">
<span class="material-symbols-outlined" style="color: var(--primary-500);">check_box_outline_blank</span>
<div style="flex: 1;">
<p class="text-body">${todo.content}</p>
<div class="d-flex align-center gap-3 mt-1">
<span class="text-caption">👤 ${todo.assignee}</span>
<span class="text-caption">📅 ${Utils.formatDate(todo.dueDate)}</span>
${todo.priority === 'high' ? '<span class="badge badge-priority-high">높음</span>' : '<span class="badge badge-priority-medium">보통</span>'}
</div>
</div>
</div>
`).join('');
// Todo 데이터 저장
todos.forEach(todo => {
const todoData = {
id: Utils.generateId('TODO'),
meetingId: meeting.id,
sectionId: 'SEC_todos',
content: todo.content,
assignee: todo.assignee,
assigneeId: DUMMY_USERS.find(u => u.name === todo.assignee)?.id || '',
dueDate: todo.dueDate,
priority: todo.priority,
status: 'in-progress',
completed: false,
createdAt: new Date().toISOString()
};
// 중복 체크 후 저장
const existing = StorageManager.getTodos().find(t =>
t.meetingId === meeting.id && t.content === todo.content
);
if (!existing) {
StorageManager.addTodo(todoData);
}
});
}
// Todo 수정
function editTodos() {
UIComponents.showToast('Todo 수정 기능은 Todo 관리 화면에서 이용하실 수 있습니다', 'info');
setTimeout(() => {
NavigationHelper.navigate('TODO_MANAGE');
}, 1500);
}
// 회의록 확정
function confirmMeeting() {
UIComponents.confirm(
'회의록을 최종 확정하시겠습니까? 확정 후에도 수정할 수 있습니다.',
() => {
if (meeting) {
meeting.status = 'confirmed';
meeting.confirmedAt = new Date().toISOString();
StorageManager.updateMeeting(meeting.id, meeting);
UIComponents.showToast('회의록이 최종 확정되었습니다', 'success');
// Todo 자동 할당 알림
setTimeout(() => {
UIComponents.showToast('Todo가 담당자에게 자동으로 할당되었습니다', 'info');
}, 1000);
setTimeout(() => {
NavigationHelper.navigate('MEETING_SHARE', { meetingId: meeting.id });
}, 2000);
}
},
() => {}
);
}
// 초기 렌더링
renderTodos();
</script>
</body>
</html>

View File

@ -1,253 +0,0 @@
<!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">
</head>
<body>
<div class="page">
<!-- 헤더 -->
<div class="header">
<button class="btn-icon" onclick="NavigationHelper.goBack()" aria-label="뒤로가기">
<span class="material-symbols-outlined">arrow_back</span>
</button>
<h1 class="header-title">회의록 공유</h1>
<button class="btn btn-primary btn-sm" onclick="shareMinutes()">공유하기</button>
</div>
<!-- 메인 컨텐츠 -->
<div class="content">
<form id="shareForm">
<!-- 공유 대상 -->
<div class="form-group">
<label class="form-label required">공유 대상</label>
<label class="form-checkbox mb-2">
<input type="radio" name="shareTarget" value="all" checked onchange="toggleAttendeeList()">
<span>참석자 전체</span>
</label>
<label class="form-checkbox">
<input type="radio" name="shareTarget" value="selected" onchange="toggleAttendeeList()">
<span>특정 참석자 선택</span>
</label>
</div>
<!-- 참석자 목록 (선택 시) -->
<div class="form-group" id="attendeeListGroup" style="display: none;">
<div id="attendeeList">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
<!-- 공유 권한 -->
<div class="form-group">
<label for="sharePermission" class="form-label required">공유 권한</label>
<select id="sharePermission" class="form-select">
<option value="read" selected>읽기 전용</option>
<option value="comment">댓글 가능</option>
<option value="edit">편집 가능</option>
</select>
</div>
<!-- 공유 방식 -->
<div class="form-group">
<label class="form-label">공유 방식</label>
<label class="form-checkbox mb-2">
<input type="checkbox" id="sendEmail" checked>
<span>이메일 발송</span>
</label>
<button type="button" class="btn btn-secondary btn-sm w-full" onclick="copyLink()">
<span class="material-symbols-outlined">link</span>
링크 복사
</button>
</div>
<!-- 링크 보안 설정 -->
<div class="card mb-4">
<h3 class="text-h5 mb-3">링크 보안 설정</h3>
<label class="form-checkbox mb-3">
<input type="checkbox" id="enableExpiry" onchange="toggleExpiryDate()">
<span>유효기간 설정</span>
</label>
<div id="expiryDateGroup" style="display: none;">
<select id="expiryPeriod" class="form-select mb-3">
<option value="7">7일</option>
<option value="30" selected>30일</option>
<option value="90">90일</option>
<option value="unlimited">무제한</option>
</select>
</div>
<label class="form-checkbox mb-3">
<input type="checkbox" id="enablePassword" onchange="togglePassword()">
<span>비밀번호 설정</span>
</label>
<div id="passwordGroup" style="display: none;">
<input
type="password"
id="linkPassword"
class="form-input"
placeholder="링크 접근 비밀번호"
>
</div>
</div>
</form>
<!-- 공유 이력 -->
<div class="card">
<h3 class="text-h4 mb-3">공유 이력</h3>
<div id="shareHistory">
<p class="text-body-sm text-gray text-center" style="padding: 24px 0;">아직 공유 이력이 없습니다</p>
</div>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
if (!NavigationHelper.requireAuth()) {}
const currentUser = StorageManager.getCurrentUser();
const meetingId = NavigationHelper.getQueryParam('meetingId');
const meeting = meetingId ? StorageManager.getMeetingById(meetingId) : null;
if (!meeting) {
UIComponents.showToast('회의 정보를 찾을 수 없습니다', 'error');
setTimeout(() => NavigationHelper.navigate('DASHBOARD'), 2000);
}
// 참석자 목록 토글
function toggleAttendeeList() {
const selected = document.querySelector('input[name="shareTarget"]:checked').value === 'selected';
document.getElementById('attendeeListGroup').style.display = selected ? 'block' : 'none';
if (selected && meeting) {
renderAttendeeList();
}
}
// 참석자 목록 렌더링
function renderAttendeeList() {
const container = document.getElementById('attendeeList');
container.innerHTML = meeting.attendees.map((attendee, index) => `
<label class="form-checkbox mb-2">
<input type="checkbox" name="attendee" value="${attendee}" checked>
<span>${attendee}</span>
</label>
`).join('');
}
// 유효기간 토글
function toggleExpiryDate() {
const enabled = document.getElementById('enableExpiry').checked;
document.getElementById('expiryDateGroup').style.display = enabled ? 'block' : 'none';
}
// 비밀번호 토글
function togglePassword() {
const enabled = document.getElementById('enablePassword').checked;
document.getElementById('passwordGroup').style.display = enabled ? 'block' : 'none';
}
// 링크 복사
function copyLink() {
const link = `https://meeting.example.com/share/${meeting.id}`;
// 클립보드 복사
navigator.clipboard.writeText(link).then(() => {
UIComponents.showToast('링크가 복사되었습니다', 'success');
}).catch(() => {
// Fallback
const tempInput = document.createElement('input');
tempInput.value = link;
document.body.appendChild(tempInput);
tempInput.select();
document.execCommand('copy');
document.body.removeChild(tempInput);
UIComponents.showToast('링크가 복사되었습니다', 'success');
});
}
// 회의록 공유
function shareMinutes() {
const shareTarget = document.querySelector('input[name="shareTarget"]:checked').value;
const sharePermission = document.getElementById('sharePermission').value;
const sendEmail = document.getElementById('sendEmail').checked;
const enableExpiry = document.getElementById('enableExpiry').checked;
const enablePassword = document.getElementById('enablePassword').checked;
let recipients = [];
if (shareTarget === 'all') {
recipients = meeting.attendees;
} else {
const checked = Array.from(document.querySelectorAll('input[name="attendee"]:checked'));
recipients = checked.map(input => input.value);
}
if (recipients.length === 0) {
UIComponents.showToast('공유할 대상을 선택해주세요', 'error');
return;
}
const shareData = {
meetingId: meeting.id,
recipients: recipients,
permission: sharePermission,
sendEmail: sendEmail,
expiry: enableExpiry ? document.getElementById('expiryPeriod').value : null,
password: enablePassword ? document.getElementById('linkPassword').value : null,
sharedAt: new Date().toISOString(),
sharedBy: currentUser.name
};
UIComponents.showLoading('회의록을 공유하는 중...');
setTimeout(() => {
// 공유 처리 (시뮬레이션)
meeting.sharedWith = recipients.map(name => {
const user = DUMMY_USERS.find(u => u.name === name);
return user ? user.id : '';
}).filter(id => id);
StorageManager.updateMeeting(meeting.id, meeting);
UIComponents.hideLoading();
if (sendEmail) {
UIComponents.showToast(`${recipients.length}명에게 이메일이 발송되었습니다`, 'success');
} else {
UIComponents.showToast('회의록이 공유되었습니다', 'success');
}
// 공유 이력 추가
addShareHistory(shareData);
setTimeout(() => {
NavigationHelper.navigate('DASHBOARD');
}, 2000);
}, 1500);
}
// 공유 이력 추가
function addShareHistory(shareData) {
const container = document.getElementById('shareHistory');
const html = `
<div class="mb-3 p-3" style="background: var(--gray-50); border-radius: 8px;">
<div class="d-flex justify-between align-center mb-2">
<span class="text-body">${shareData.sharedAt.split('T')[0]} ${shareData.sharedAt.split('T')[1].slice(0, 5)}</span>
<span class="badge badge-status">${shareData.permission === 'read' ? '읽기 전용' : shareData.permission === 'comment' ? '댓글 가능' : '편집 가능'}</span>
</div>
<p class="text-body-sm">대상: ${shareData.recipients.join(', ')}</p>
</div>
`;
container.innerHTML = html + container.innerHTML;
}
</script>
</body>
</html>

View File

@ -1,280 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo 관리 - 회의록 서비스</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">
</head>
<body>
<div class="page">
<!-- 헤더 -->
<div class="header">
<h1 class="header-title">내 Todo</h1>
<button class="btn-icon" onclick="showFilter()" aria-label="필터">
<span class="material-symbols-outlined">filter_list</span>
</button>
</div>
<!-- 메인 컨텐츠 -->
<div class="content" style="padding-bottom: 120px;">
<!-- 통계 카드 -->
<div class="card mb-4">
<div class="d-flex justify-between align-center mb-4">
<div style="flex: 1;">
<div class="d-flex align-center gap-4">
<div>
<h3 class="text-h2" id="totalCount">0</h3>
<p class="text-caption text-gray">전체 Todo</p>
</div>
<div>
<h3 class="text-h2" style="color: var(--success);" id="completedCount">0</h3>
<p class="text-caption text-gray">완료</p>
</div>
<div>
<h3 class="text-h2" style="color: var(--warning);" id="dueSoonCount">0</h3>
<p class="text-caption text-gray">마감 임박</p>
</div>
</div>
</div>
${UIComponents.createCircularProgress(0)}
</div>
</div>
<!-- 필터 탭 -->
<div class="d-flex gap-2 mb-4" style="overflow-x: auto;">
<button class="btn btn-sm active" id="filter-all" onclick="setFilter('all')">전체</button>
<button class="btn btn-secondary btn-sm" id="filter-inprogress" onclick="setFilter('inprogress')">진행 중</button>
<button class="btn btn-secondary btn-sm" id="filter-completed" onclick="setFilter('completed')">완료</button>
<button class="btn btn-secondary btn-sm" id="filter-duesoon" onclick="setFilter('duesoon')">마감 임박</button>
</div>
<!-- Todo 리스트 -->
<div id="todoList">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
<!-- FAB -->
<button class="btn-fab" onclick="addTodo()" aria-label="Todo 추가">
<span class="material-symbols-outlined">add</span>
</button>
<!-- 하단 네비게이션 -->
<nav class="bottom-nav" role="navigation" aria-label="주요 메뉴">
<a href="02-대시보드.html" class="bottom-nav-item">
<span class="material-symbols-outlined bottom-nav-icon">home</span>
<span></span>
</a>
<a href="11-회의록수정.html" class="bottom-nav-item">
<span class="material-symbols-outlined bottom-nav-icon">description</span>
<span>회의록</span>
</a>
<a href="09-Todo관리.html" class="bottom-nav-item active" aria-current="page">
<span class="material-symbols-outlined bottom-nav-icon">check_box</span>
<span>Todo</span>
</a>
<a href="javascript:void(0)" class="bottom-nav-item">
<span class="material-symbols-outlined bottom-nav-icon">account_circle</span>
<span>프로필</span>
</a>
</nav>
</div>
<script src="common.js"></script>
<script>
if (!NavigationHelper.requireAuth()) {}
const currentUser = StorageManager.getCurrentUser();
let currentFilter = 'all';
// Todo 렌더링
function renderTodos() {
const todos = StorageManager.getTodos();
const myTodos = todos.filter(todo => todo.assigneeId === currentUser.id);
// 필터링
let filteredTodos = myTodos;
if (currentFilter === 'inprogress') {
filteredTodos = myTodos.filter(t => !t.completed);
} else if (currentFilter === 'completed') {
filteredTodos = myTodos.filter(t => t.completed);
} else if (currentFilter === 'duesoon') {
filteredTodos = myTodos.filter(t => !t.completed && isDueSoon(t.dueDate));
}
// 통계 업데이트
const total = myTodos.length;
const completed = myTodos.filter(t => t.completed).length;
const dueSoon = myTodos.filter(t => !t.completed && isDueSoon(t.dueDate)).length;
const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0;
document.getElementById('totalCount').textContent = total;
document.getElementById('completedCount').textContent = completed;
document.getElementById('dueSoonCount').textContent = dueSoon;
// 진행률 업데이트
const progressEl = document.querySelector('.circular-progress');
if (progressEl) {
progressEl.style.setProperty('--progress-percent', `${completionRate * 3.6}deg`);
progressEl.querySelector('.progress-percent').textContent = `${completionRate}%`;
}
// Todo 리스트 렌더링
const container = document.getElementById('todoList');
if (filteredTodos.length === 0) {
container.innerHTML = '<p class="text-body text-gray text-center" style="padding: 48px 0;">해당하는 Todo가 없습니다</p>';
return;
}
// 마감일 순 정렬
filteredTodos.sort((a, b) => {
if (a.completed !== b.completed) return a.completed ? 1 : -1;
return new Date(a.dueDate) - new Date(b.dueDate);
});
container.innerHTML = filteredTodos.map(todo => UIComponents.createTodoItem(todo)).join('');
}
// 필터 설정
function setFilter(filter) {
currentFilter = filter;
// 버튼 스타일 업데이트
document.querySelectorAll('[id^="filter-"]').forEach(btn => {
btn.classList.remove('btn-primary', 'active');
btn.classList.add('btn-secondary');
});
const activeBtn = document.getElementById(`filter-${filter}`);
activeBtn.classList.remove('btn-secondary');
activeBtn.classList.add('btn-primary', 'active');
renderTodos();
}
// 필터 모달
function showFilter() {
UIComponents.showModal({
title: '필터 및 정렬',
content: `
<div class="form-group">
<label class="form-label">정렬 기준</label>
<select id="sortBy" class="form-select">
<option value="dueDate">마감일순</option>
<option value="priority">우선순위순</option>
<option value="created">생성일순</option>
</select>
</div>
<div class="form-group">
<label class="form-label">우선순위</label>
<label class="form-checkbox mb-2">
<input type="checkbox" value="high" checked>
<span>높음</span>
</label>
<label class="form-checkbox mb-2">
<input type="checkbox" value="medium" checked>
<span>보통</span>
</label>
<label class="form-checkbox mb-2">
<input type="checkbox" value="low" checked>
<span>낮음</span>
</label>
</div>
`,
footer: `
<button class="btn btn-secondary" onclick="closeModal()">취소</button>
<button class="btn btn-primary" onclick="closeModal(); renderTodos()">적용</button>
`,
onClose: () => {}
});
}
// Todo 추가
function addTodo() {
UIComponents.showModal({
title: 'Todo 추가',
content: `
<form id="addTodoForm">
<div class="form-group">
<label for="todoContent" class="form-label required">내용</label>
<textarea
id="todoContent"
class="form-textarea"
rows="3"
placeholder="Todo 내용을 입력하세요"
required
></textarea>
</div>
<div class="form-group">
<label for="todoDueDate" class="form-label required">마감일</label>
<input
type="date"
id="todoDueDate"
class="form-input"
required
min="${new Date().toISOString().split('T')[0]}"
>
</div>
<div class="form-group">
<label for="todoPriority" class="form-label">우선순위</label>
<select id="todoPriority" class="form-select">
<option value="low">낮음</option>
<option value="medium" selected>보통</option>
<option value="high">높음</option>
</select>
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" onclick="closeModal()">취소</button>
<button class="btn btn-primary" onclick="saveTodo()">저장</button>
`,
onClose: () => {}
});
}
// Todo 저장
function saveTodo() {
const content = document.getElementById('todoContent').value.trim();
const dueDate = document.getElementById('todoDueDate').value;
const priority = document.getElementById('todoPriority').value;
if (!content || !dueDate) {
UIComponents.showToast('필수 항목을 입력해주세요', 'error');
return;
}
const todoData = {
id: Utils.generateId('TODO'),
meetingId: '',
sectionId: '',
content: content,
assignee: currentUser.name,
assigneeId: currentUser.id,
dueDate: dueDate,
priority: priority,
status: 'in-progress',
completed: false,
createdAt: new Date().toISOString()
};
StorageManager.addTodo(todoData);
closeModal();
UIComponents.showToast('Todo가 추가되었습니다', 'success');
renderTodos();
}
// 모달 닫기
function closeModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) modal.remove();
}
// 초기 렌더링
renderTodos();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -1,990 +0,0 @@
/**
* 회의록 작성 공유 개선 서비스 - 공통 JavaScript
* Mobile First Design
* 작성일: 2025-10-21
*/
/* ========================================
1. 전역 설정 상수
======================================== */
const APP_CONFIG = {
APP_NAME: '회의록 작성 및 공유 개선 서비스',
STORAGE_KEYS: {
USER: 'current_user',
MEETINGS: 'meetings_data',
TODOS: 'todos_data',
TEMPLATES: 'templates_data',
INITIALIZED: 'app_initialized'
},
ROUTES: {
LOGIN: '01-로그인.html',
DASHBOARD: '02-대시보드.html',
MEETING_SCHEDULE: '03-회의예약.html',
TEMPLATE_SELECT: '04-템플릿선택.html',
MEETING_IN_PROGRESS: '05-회의진행.html',
VERIFICATION: '06-검증완료.html',
MEETING_END: '07-회의종료.html',
MEETING_SHARE: '08-회의록공유.html',
TODO_MANAGE: '09-Todo관리.html',
MEETING_DETAIL: '10-회의록상세조회.html',
MEETING_EDIT: '11-회의록수정.html'
}
};
// 더미 사용자 데이터
const DUMMY_USERS = [
{ id: 'EMP001', password: '1234', name: '김철수', email: 'kim@company.com', role: '기획팀', position: '팀장' },
{ id: 'EMP002', password: '1234', name: '이영희', email: 'lee@company.com', role: '개발팀', position: '선임' },
{ id: 'EMP003', password: '1234', name: '박민수', email: 'park@company.com', role: '디자인팀', position: '사원' },
{ id: 'EMP004', password: '1234', name: '정수진', email: 'jung@company.com', role: '기획팀', position: '사원' },
{ id: 'EMP005', password: '1234', name: '최동욱', email: 'choi@company.com', role: '개발팀', position: '팀장' }
];
// 템플릿 데이터
const TEMPLATES = {
general: {
id: 'TPL001',
name: '일반 회의',
type: 'general',
icon: '📝',
description: '참석자, 안건, 논의 내용, 결정 사항, Todo',
sections: [
{ id: 'SEC_participants', name: '참석자', order: 1, content: '' },
{ id: 'SEC_agenda', name: '안건', order: 2, content: '' },
{ id: 'SEC_discussion', name: '논의 내용', order: 3, content: '' },
{ id: 'SEC_decisions', name: '결정 사항', order: 4, content: '' },
{ id: 'SEC_todos', name: 'Todo', order: 5, content: '' }
],
isDefault: true
},
scrum: {
id: 'TPL002',
name: '스크럼 회의',
type: 'scrum',
icon: '🏃',
description: '어제 한 일, 오늘 할 일, 이슈',
sections: [
{ id: 'SEC_yesterday', name: '어제 한 일', order: 1, content: '' },
{ id: 'SEC_today', name: '오늘 할 일', order: 2, content: '' },
{ id: 'SEC_issues', name: '이슈', order: 3, content: '' }
],
isDefault: false
},
kickoff: {
id: 'TPL003',
name: '프로젝트 킥오프',
type: 'kickoff',
icon: '🚀',
description: '프로젝트 개요, 목표, 일정, 역할, 리스크',
sections: [
{ id: 'SEC_overview', name: '프로젝트 개요', order: 1, content: '' },
{ id: 'SEC_goals', name: '목표', order: 2, content: '' },
{ id: 'SEC_schedule', name: '일정', order: 3, content: '' },
{ id: 'SEC_roles', name: '역할', order: 4, content: '' },
{ id: 'SEC_risks', name: '리스크', order: 5, content: '' }
],
isDefault: false
},
weekly: {
id: 'TPL004',
name: '주간 회의',
type: 'weekly',
icon: '📅',
description: '주간 실적, 주요 이슈, 다음 주 계획',
sections: [
{ id: 'SEC_performance', name: '주간 실적', order: 1, content: '' },
{ id: 'SEC_issues', name: '주요 이슈', order: 2, content: '' },
{ id: 'SEC_next_week', name: '다음 주 계획', order: 3, content: '' }
],
isDefault: false
}
};
/* ========================================
2. 유틸리티 함수
======================================== */
const Utils = {
// 고유 ID 생성
generateId: (prefix = 'ID') => {
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
},
// 날짜 포맷팅
formatDate: (date, format = 'YYYY-MM-DD') => {
if (!date) return '';
const d = new Date(date);
if (isNaN(d.getTime())) return '';
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
const formats = {
'YYYY-MM-DD': `${year}-${month}-${day}`,
'YYYY.MM.DD': `${year}.${month}.${day}`,
'MM/DD': `${month}/${day}`,
'YYYY-MM-DD HH:mm': `${year}-${month}-${day} ${hours}:${minutes}`,
'HH:mm': `${hours}:${minutes}`
};
return formats[format] || formats['YYYY-MM-DD'];
},
// 상대 시간 포맷팅
formatTimeAgo: (date) => {
if (!date) return '';
const now = new Date();
const past = new Date(date);
const diffMs = now - past;
const diffMinutes = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMinutes < 1) return '방금 전';
if (diffMinutes < 60) return `${diffMinutes}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
if (diffDays < 7) return `${diffDays}일 전`;
return Utils.formatDate(date);
},
// 경과 시간 포맷팅 (milliseconds → HH:mm:ss)
formatDuration: (ms) => {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
},
// 시간 포맷팅 (HH:mm → 분 단위)
timeToMinutes: (timeString) => {
const [hours, minutes] = timeString.split(':').map(Number);
return hours * 60 + minutes;
},
// 문자열 자르기
truncateText: (text, maxLength) => {
if (!text || text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
},
// 이메일 검증
isValidEmail: (email) => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
},
// 사번 검증 (EMP + 3자리 숫자)
isValidEmployeeId: (id) => {
const regex = /^EMP\d{3}$/;
return regex.test(id);
},
// DOM 헬퍼
$: (selector) => document.querySelector(selector),
$$: (selector) => document.querySelectorAll(selector),
// 엘리먼트 생성
createElement: (tag, className = '', attributes = {}) => {
const element = document.createElement(tag);
if (className) element.className = className;
Object.entries(attributes).forEach(([key, value]) => {
element.setAttribute(key, value);
});
return element;
},
// 디바운스
debounce: (func, wait = 300) => {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
// 스로틀
throttle: (func, limit = 100) => {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
},
// 배열 섞기
shuffleArray: (array) => {
const newArray = [...array];
for (let i = newArray.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
}
return newArray;
}
};
/* ========================================
3. 로컬 스토리지 관리
======================================== */
const StorageManager = {
// 기본 CRUD
get: (key) => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : null;
} catch (e) {
console.error('Storage get error:', e);
return null;
}
},
set: (key, value) => {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (e) {
console.error('Storage set error:', e);
return false;
}
},
remove: (key) => {
try {
localStorage.removeItem(key);
return true;
} catch (e) {
console.error('Storage remove error:', e);
return false;
}
},
clear: () => {
try {
localStorage.clear();
return true;
} catch (e) {
console.error('Storage clear error:', e);
return false;
}
},
// 사용자 관련
getCurrentUser: () => {
return StorageManager.get(APP_CONFIG.STORAGE_KEYS.USER);
},
setCurrentUser: (user) => {
return StorageManager.set(APP_CONFIG.STORAGE_KEYS.USER, user);
},
logout: () => {
StorageManager.remove(APP_CONFIG.STORAGE_KEYS.USER);
NavigationHelper.navigate('LOGIN');
},
// 회의록 관련
getMeetings: () => {
return StorageManager.get(APP_CONFIG.STORAGE_KEYS.MEETINGS) || [];
},
getMeetingById: (id) => {
const meetings = StorageManager.getMeetings();
return meetings.find(m => m.id === id);
},
addMeeting: (meeting) => {
const meetings = StorageManager.getMeetings();
meetings.push(meeting);
return StorageManager.set(APP_CONFIG.STORAGE_KEYS.MEETINGS, meetings);
},
updateMeeting: (id, updates) => {
const meetings = StorageManager.getMeetings();
const index = meetings.findIndex(m => m.id === id);
if (index !== -1) {
meetings[index] = { ...meetings[index], ...updates, updatedAt: new Date().toISOString() };
return StorageManager.set(APP_CONFIG.STORAGE_KEYS.MEETINGS, meetings);
}
return false;
},
deleteMeeting: (id) => {
const meetings = StorageManager.getMeetings();
const filtered = meetings.filter(m => m.id !== id);
return StorageManager.set(APP_CONFIG.STORAGE_KEYS.MEETINGS, filtered);
},
// Todo 관련
getTodos: () => {
return StorageManager.get(APP_CONFIG.STORAGE_KEYS.TODOS) || [];
},
getTodoById: (id) => {
const todos = StorageManager.getTodos();
return todos.find(t => t.id === id);
},
addTodo: (todo) => {
const todos = StorageManager.getTodos();
todos.push(todo);
return StorageManager.set(APP_CONFIG.STORAGE_KEYS.TODOS, todos);
},
updateTodo: (id, updates) => {
const todos = StorageManager.getTodos();
const index = todos.findIndex(t => t.id === id);
if (index !== -1) {
todos[index] = { ...todos[index], ...updates };
return StorageManager.set(APP_CONFIG.STORAGE_KEYS.TODOS, todos);
}
return false;
},
deleteTodo: (id) => {
const todos = StorageManager.getTodos();
const filtered = todos.filter(t => t.id !== id);
return StorageManager.set(APP_CONFIG.STORAGE_KEYS.TODOS, filtered);
},
// 템플릿 관련
getTemplates: () => {
return TEMPLATES;
},
getTemplateById: (type) => {
return TEMPLATES[type] || TEMPLATES.general;
}
};
/* ========================================
4. 네비게이션 헬퍼
======================================== */
const NavigationHelper = {
navigate: (routeKey, params = {}) => {
const route = APP_CONFIG.ROUTES[routeKey];
if (!route) {
console.error('Invalid route:', routeKey);
return;
}
// 파라미터를 query string으로 변환
const queryString = Object.keys(params).length > 0
? '?' + Object.entries(params).map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&')
: '';
window.location.href = route + queryString;
},
goBack: () => {
window.history.back();
},
reload: () => {
window.location.reload();
},
getCurrentPage: () => {
return window.location.pathname.split('/').pop();
},
getQueryParams: () => {
const params = {};
const queryString = window.location.search.substring(1);
const pairs = queryString.split('&');
pairs.forEach(pair => {
const [key, value] = pair.split('=');
if (key) {
params[decodeURIComponent(key)] = decodeURIComponent(value || '');
}
});
return params;
},
getQueryParam: (key) => {
const params = NavigationHelper.getQueryParams();
return params[key] || null;
},
requireAuth: () => {
const user = StorageManager.getCurrentUser();
if (!user) {
NavigationHelper.navigate('LOGIN');
return false;
}
return true;
},
redirectToLogin: () => {
NavigationHelper.navigate('LOGIN');
}
};
/* ========================================
5. UI 컴포넌트 생성기
======================================== */
const UIComponents = {
// Toast 메시지
showToast: (message, type = 'info', duration = 3000) => {
// 기존 toast 제거
const existing = Utils.$('.toast');
if (existing) existing.remove();
// 새 toast 생성
const toast = Utils.createElement('div', `toast ${type} active`);
toast.textContent = message;
document.body.appendChild(toast);
// 자동 제거
setTimeout(() => {
toast.classList.remove('active');
setTimeout(() => toast.remove(), 300);
}, duration);
},
// 로딩 인디케이터
showLoading: (message = '로딩 중...') => {
const existing = Utils.$('#loading-overlay');
if (existing) return;
const overlay = Utils.createElement('div', 'modal-overlay active', { id: 'loading-overlay' });
overlay.innerHTML = `
<div class="d-flex flex-column align-center gap-4" style="background: white; padding: 32px; border-radius: 12px;">
<div class="spinner"></div>
<p class="text-body">${message}</p>
</div>
`;
document.body.appendChild(overlay);
},
hideLoading: () => {
const overlay = Utils.$('#loading-overlay');
if (overlay) overlay.remove();
},
// 확인 다이얼로그
confirm: (message, onConfirm, onCancel) => {
const overlay = Utils.createElement('div', 'modal-overlay active');
overlay.innerHTML = `
<div class="modal-container">
<div class="modal-header">
<h2 class="modal-title">확인</h2>
</div>
<div class="modal-body">
<p class="text-body">${message}</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="modal-cancel">취소</button>
<button class="btn btn-primary" id="modal-confirm">확인</button>
</div>
</div>
`;
document.body.appendChild(overlay);
Utils.$('#modal-confirm').addEventListener('click', () => {
overlay.remove();
if (onConfirm) onConfirm();
});
Utils.$('#modal-cancel').addEventListener('click', () => {
overlay.remove();
if (onCancel) onCancel();
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.remove();
if (onCancel) onCancel();
}
});
},
// 모달 표시
showModal: (options) => {
const { title, content, footer, onClose } = options;
const overlay = Utils.createElement('div', 'modal-overlay active');
overlay.innerHTML = `
<div class="modal-container">
<div class="modal-header">
<h2 class="modal-title">${title}</h2>
<button class="modal-close" id="modal-close-btn">×</button>
</div>
<div class="modal-body">
${content}
</div>
${footer ? `<div class="modal-footer">${footer}</div>` : ''}
</div>
`;
document.body.appendChild(overlay);
// 닫기 버튼
Utils.$('#modal-close-btn').addEventListener('click', () => {
overlay.remove();
if (onClose) onClose();
});
// 오버레이 클릭 시 닫기
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.remove();
if (onClose) onClose();
}
});
return overlay;
},
// 배지 생성
createBadge: (text, type = 'status') => {
const badgeClass = `badge badge-${type}`;
return `<span class="${badgeClass}">${text}</span>`;
},
// 아바타 생성
createAvatar: (name, size = 40) => {
const initial = name ? name[0].toUpperCase() : '?';
const colors = ['#2196F3', '#4CAF50', '#FF9800', '#9C27B0', '#F44336'];
const colorIndex = name ? name.charCodeAt(0) % colors.length : 0;
const bgColor = colors[colorIndex];
return `
<div class="avatar" style="width: ${size}px; height: ${size}px; background: ${bgColor}; color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: ${size / 2}px;">
${initial}
</div>
`;
},
// 회의록 아이템 카드 생성
createMeetingItem: (meeting) => {
const statusText = {
'scheduled': '예정',
'in-progress': '진행중',
'draft': '작성중',
'confirmed': '확정완료'
};
const statusClass = {
'scheduled': 'badge-shared',
'in-progress': 'badge-shared',
'draft': 'badge-draft',
'confirmed': 'badge-confirmed'
};
return `
<div class="meeting-item" onclick="NavigationHelper.navigate('MEETING_DETAIL', { id: '${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>
</div>
<div class="d-flex align-center gap-2">
${UIComponents.createBadge(statusText[meeting.status] || '작성중', statusClass[meeting.status] || 'draft')}
</div>
</div>
`;
},
// Todo 아이템 카드 생성
createTodoItem: (todo) => {
const today = new Date();
const dueDate = new Date(todo.dueDate);
const diffDays = Math.ceil((dueDate - today) / (1000 * 60 * 60 * 24));
let itemClass = 'todo-item';
if (todo.completed) {
itemClass += ' completed';
} else if (diffDays < 0) {
itemClass += ' overdue';
} else if (diffDays <= 3) {
itemClass += ' due-soon';
}
const priorityBadge = todo.priority === 'high'
? '<span class="badge badge-priority-high">높음</span>'
: todo.priority === 'medium'
? '<span class="badge badge-priority-medium">보통</span>'
: '';
return `
<div class="${itemClass}">
<label class="form-checkbox">
<input type="checkbox" ${todo.completed ? 'checked' : ''}
onchange="handleTodoToggle('${todo.id}', this.checked)">
</label>
<div style="flex: 1;">
<p class="text-body" style="${todo.completed ? 'text-decoration: line-through; opacity: 0.6;' : ''}">${todo.content}</p>
<div class="d-flex align-center gap-3 mt-2">
<span class="text-caption">👤 ${todo.assignee}</span>
<span class="text-caption">📅 ${Utils.formatDate(todo.dueDate)}</span>
${priorityBadge}
</div>
</div>
<button class="btn-icon" onclick="NavigationHelper.navigate('MEETING_DETAIL', { id: '${todo.meetingId}' })"></button>
</div>
`;
},
// 진행률 바 생성
createProgressBar: (percent) => {
return `
<div class="progress-bar">
<div class="progress-fill" style="width: ${percent}%;"></div>
</div>
`;
},
// 원형 진행률 생성
createCircularProgress: (percent) => {
return `
<div class="circular-progress" style="--progress-percent: ${percent}%;">
<div class="progress-inner">
<span class="progress-percent">${Math.round(percent)}%</span>
<span class="progress-label">완료율</span>
</div>
</div>
`;
}
};
/* ========================================
6. 검증
======================================== */
const FormValidator = {
rules: {
required: (value) => {
return value.trim() !== '';
},
email: (value) => {
return Utils.isValidEmail(value);
},
minLength: (value, min) => {
return value.length >= min;
},
maxLength: (value, max) => {
return value.length <= max;
},
employeeId: (value) => {
return Utils.isValidEmployeeId(value);
}
},
messages: {
required: '필수 입력 항목입니다.',
email: '올바른 이메일 형식이 아닙니다.',
minLength: (min) => `최소 ${min}자 이상 입력해주세요.`,
maxLength: (max) => `최대 ${max}자까지 입력 가능합니다.`,
employeeId: '올바른 사번 형식이 아닙니다. (예: EMP001)'
},
validateField: (fieldElement, ruleName, ...args) => {
const value = fieldElement.value;
const rule = FormValidator.rules[ruleName];
if (!rule) {
console.error('Unknown validation rule:', ruleName);
return true;
}
const isValid = rule(value, ...args);
if (!isValid) {
const message = typeof FormValidator.messages[ruleName] === 'function'
? FormValidator.messages[ruleName](...args)
: FormValidator.messages[ruleName];
FormValidator.showError(fieldElement, message);
} else {
FormValidator.clearError(fieldElement);
}
return isValid;
},
validate: (formElement) => {
let isValid = true;
const fields = formElement.querySelectorAll('[data-validate]');
fields.forEach(field => {
const rules = field.dataset.validate.split('|');
rules.forEach(rule => {
const [ruleName, ...args] = rule.split(':');
if (!FormValidator.validateField(field, ruleName, ...args)) {
isValid = false;
}
});
});
return isValid;
},
showError: (fieldElement, message) => {
FormValidator.clearError(fieldElement);
fieldElement.classList.add('error');
const errorDiv = Utils.createElement('div', 'form-error');
errorDiv.textContent = message;
fieldElement.parentNode.appendChild(errorDiv);
},
clearError: (fieldElement) => {
fieldElement.classList.remove('error');
const errorDiv = fieldElement.parentNode.querySelector('.form-error');
if (errorDiv) errorDiv.remove();
}
};
/* ========================================
7. 데이터 초기화
======================================== */
const DataInitializer = {
initializeSampleData: () => {
// 이미 초기화되었는지 확인
if (StorageManager.get(APP_CONFIG.STORAGE_KEYS.INITIALIZED)) {
return;
}
// 샘플 회의록 데이터
const sampleMeetings = [
{
id: 'MTG001',
title: '프로젝트 킥오프 회의',
date: '2025-10-20',
startTime: '10:00',
endTime: '11:30',
duration: 5400000,
location: '회의실 A',
attendees: ['김철수', '이영희', '박민수'],
template: 'kickoff',
status: 'confirmed',
sections: [
{ id: 'SEC001', name: '프로젝트 개요', content: '신규 회의록 서비스 개발 프로젝트 킥오프', verified: true, verifiedBy: ['김철수', '이영희'] },
{ id: 'SEC002', name: '목표', content: '2025년 Q4 런칭, Mobile First 설계', verified: true, verifiedBy: ['김철수'] },
{ id: 'SEC003', name: '일정', content: '기획 2주, 설계 3주, 개발 8주, 테스트 2주', verified: true, verifiedBy: ['이영희'] },
{ id: 'SEC004', name: '역할', content: '김철수(PM), 이영희(개발리드), 박민수(디자인)', verified: false, verifiedBy: [] },
{ id: 'SEC005', name: '리스크', content: '일정 지연 가능성, AI 모델 성능', verified: false, verifiedBy: [] }
],
createdBy: 'EMP001',
createdAt: '2025-10-20T09:00:00Z',
updatedAt: '2025-10-20T11:30:00Z',
confirmedAt: '2025-10-20T12:00:00Z',
sharedWith: ['EMP002', 'EMP003']
},
{
id: 'MTG002',
title: '주간 스크럼 회의',
date: '2025-10-21',
startTime: '09:00',
endTime: '09:30',
duration: 1800000,
location: '온라인',
attendees: ['김철수', '이영희', '정수진'],
template: 'scrum',
status: 'confirmed',
sections: [
{ id: 'SEC011', name: '어제 한 일', content: 'API 설계 완료, 데이터베이스 스키마 정의', verified: true, verifiedBy: ['김철수'] },
{ id: 'SEC012', name: '오늘 할 일', content: '프론트엔드 프로토타입 개발 시작', verified: true, verifiedBy: ['이영희'] },
{ id: 'SEC013', name: '이슈', content: '외부 API 연동 지연 (해결 중)', verified: true, verifiedBy: ['정수진'] }
],
createdBy: 'EMP001',
createdAt: '2025-10-21T08:30:00Z',
updatedAt: '2025-10-21T09:30:00Z',
confirmedAt: '2025-10-21T10:00:00Z',
sharedWith: ['EMP002', 'EMP004']
},
{
id: 'MTG003',
title: '디자인 리뷰 회의',
date: '2025-10-19',
startTime: '14:00',
endTime: '15:00',
duration: 3600000,
location: '회의실 B',
attendees: ['박민수', '김철수', '정수진'],
template: 'general',
status: 'draft',
sections: [
{ id: 'SEC021', name: '참석자', content: '박민수, 김철수, 정수진', verified: false, verifiedBy: [] },
{ id: 'SEC022', name: '안건', content: 'UI/UX 초안 검토', verified: false, verifiedBy: [] },
{ id: 'SEC023', name: '논의 내용', content: 'Mobile First 접근 방식 확정, 컬러 시스템 논의 중', verified: false, verifiedBy: [] },
{ id: 'SEC024', name: '결정 사항', content: '', verified: false, verifiedBy: [] },
{ id: 'SEC025', name: 'Todo', content: '', verified: false, verifiedBy: [] }
],
createdBy: 'EMP003',
createdAt: '2025-10-19T13:30:00Z',
updatedAt: '2025-10-19T15:00:00Z',
confirmedAt: null,
sharedWith: []
}
];
// 샘플 Todo 데이터
const sampleTodos = [
{
id: 'TODO001',
meetingId: 'MTG001',
sectionId: 'SEC003',
content: '프로젝트 계획서 작성 및 공유',
assignee: '김철수',
assigneeId: 'EMP001',
dueDate: '2025-10-25',
priority: 'high',
status: 'in-progress',
completed: false,
completedAt: null,
createdAt: '2025-10-20T11:30:00Z'
},
{
id: 'TODO002',
meetingId: 'MTG001',
sectionId: 'SEC004',
content: '디자인 시안 1차 검토',
assignee: '박민수',
assigneeId: 'EMP003',
dueDate: '2025-10-23',
priority: 'medium',
status: 'completed',
completed: true,
completedAt: '2025-10-22T15:00:00Z',
createdAt: '2025-10-20T11:30:00Z'
},
{
id: 'TODO003',
meetingId: 'MTG002',
sectionId: 'SEC012',
content: 'API 문서 작성',
assignee: '이영희',
assigneeId: 'EMP002',
dueDate: '2025-10-24',
priority: 'high',
status: 'in-progress',
completed: false,
completedAt: null,
createdAt: '2025-10-21T09:30:00Z'
},
{
id: 'TODO004',
meetingId: 'MTG001',
sectionId: 'SEC005',
content: 'AI 모델 성능 테스트',
assignee: '정수진',
assigneeId: 'EMP004',
dueDate: '2025-10-22',
priority: 'high',
status: 'overdue',
completed: false,
completedAt: null,
createdAt: '2025-10-20T11:30:00Z'
},
{
id: 'TODO005',
meetingId: 'MTG002',
sectionId: 'SEC013',
content: '외부 API 연동 이슈 해결',
assignee: '이영희',
assigneeId: 'EMP002',
dueDate: '2025-10-26',
priority: 'medium',
status: 'in-progress',
completed: false,
completedAt: null,
createdAt: '2025-10-21T09:30:00Z'
}
];
// 데이터 저장
StorageManager.set(APP_CONFIG.STORAGE_KEYS.MEETINGS, sampleMeetings);
StorageManager.set(APP_CONFIG.STORAGE_KEYS.TODOS, sampleTodos);
StorageManager.set(APP_CONFIG.STORAGE_KEYS.INITIALIZED, true);
console.log('Sample data initialized successfully');
},
resetData: () => {
StorageManager.clear();
DataInitializer.initializeSampleData();
console.log('Data reset successfully');
}
};
/* ========================================
8. 전역 이벤트 핸들러
======================================== */
// Todo 완료/미완료 토글
function handleTodoToggle(todoId, completed) {
const todo = StorageManager.getTodoById(todoId);
if (!todo) return;
todo.completed = completed;
todo.status = completed ? 'completed' : 'in-progress';
todo.completedAt = completed ? new Date().toISOString() : null;
StorageManager.updateTodo(todoId, todo);
// 회의록의 Todo 섹션 업데이트
const meeting = StorageManager.getMeetingById(todo.meetingId);
if (meeting) {
// 실시간 반영 시뮬레이션
console.log(`Todo ${todoId} 완료 상태가 회의록 ${todo.meetingId}에 반영되었습니다.`);
}
UIComponents.showToast(
completed ? 'Todo가 완료되었습니다' : 'Todo가 미완료로 변경되었습니다',
completed ? 'success' : 'info'
);
// 현재 페이지가 Todo 관리 화면이면 리로드
if (NavigationHelper.getCurrentPage() === APP_CONFIG.ROUTES.TODO_MANAGE) {
setTimeout(() => window.location.reload(), 1000);
}
}
// 마감 임박 여부 확인 (3일 이내)
function isDueSoon(dueDate) {
if (!dueDate) return false;
const today = new Date();
const due = new Date(dueDate);
const diffDays = Math.ceil((due - today) / (1000 * 60 * 60 * 24));
return diffDays >= 0 && diffDays <= 3;
}
/* ========================================
9. 초기화
======================================== */
document.addEventListener('DOMContentLoaded', () => {
// 샘플 데이터 초기화 (최초 1회만)
DataInitializer.initializeSampleData();
// 하단 네비게이션 활성화
const currentPage = NavigationHelper.getCurrentPage();
const navItems = document.querySelectorAll('.bottom-nav-item');
navItems.forEach(item => {
const href = item.getAttribute('href');
if (href && href.includes(currentPage)) {
item.classList.add('active');
}
});
});
// 전역 함수로 노출
window.Utils = Utils;
window.StorageManager = StorageManager;
window.NavigationHelper = NavigationHelper;
window.UIComponents = UIComponents;
window.FormValidator = FormValidator;
window.DataInitializer = DataInitializer;
window.handleTodoToggle = handleTodoToggle;
window.isDueSoon = isDueSoon;
window.TEMPLATES = TEMPLATES;
window.DUMMY_USERS = DUMMY_USERS;

File diff suppressed because it is too large Load Diff

View File

@ -1,768 +0,0 @@
# 회의록 작성 및 공유 개선 서비스 - 유저스토리 (v2.0)
- [회의록 작성 및 공유 개선 서비스 - 유저스토리 (v2.0)](#회의록-작성-및-공유-개선-서비스---유저스토리-v20)
- [차별화 전략](#차별화-전략)
- [마이크로서비스 구성](#마이크로서비스-구성)
- [유저스토리](#유저스토리)
---
## 차별화 전략
본 서비스는 다음과 같은 차별화 포인트를 통해 경쟁 우위를 확보합니다:
### 1. 기본 기능 (Hygiene Factors)
- **STT(Speech To Text)**: 음성을 텍스트로 변환하는 기본 기능
- 시장의 대부분 서비스가 제공하는 기능으로 차별화 포인트가 아님
- 필수 기능이지만 경쟁 우위를 가져다주지 않음
### 2. 핵심 차별화 포인트 (Differentiators)
- **맥락 기반 용어 설명**: 단순 용어 설명을 넘어, 관련 회의록과 업무이력을 바탕으로 실용적인 정보 제공
- **강화된 Todo 연결**: Action item이 담당자의 Todo와 실시간으로 연결되고, 진행 상황이 회의록에 자동 반영
- **프롬프팅 기반 회의록 개선**: AI를 활용한 다양한 형식의 회의록 생성 (1Page 요약, 핵심 요약 등)
- **지능형 회의 진행 지원**: 회의 패턴 분석을 통한 안건 추천, 효율성 분석 및 개선 제안
---
## 마이크로서비스 구성
1. **User** - 사용자 인증 및 권한 관리
2. **Meeting** - 회의 관리, 회의록 생성 및 관리, 회의록 공유
3. **STT** - 음성 녹음 관리, 음성-텍스트 변환, 화자 식별 (기본 기능)
4. **AI** - LLM 기반 회의록 자동 작성, Todo 자동 추출, 프롬프팅 기반 회의록 개선
5. **RAG** - 맥락 기반 용어 설명, 관련 문서 검색 및 연결, 업무 이력 통합
6. **Collaboration** - 실시간 동기화, 버전 관리, 충돌 해결
7. **Todo** - Todo 할당 및 관리, 진행 상황 추적, 회의록 실시간 연동
8. **Notification** - 알림 발송 및 리마인더 관리
---
## 유저스토리
```
1. User 서비스
1) 사용자 인증 관리
AFR-USER-010: [사용자관리] 시스템 관리자로서 | 나는, 서비스 보안을 위해 | 사용자 인증 기능을 원한다.
- 시나리오: 사용자 인증 관리
사용자가 로그인을 시도한 상황에서 | 사번과 비밀번호를 입력하면 | LDAP 연동을 통해 인증이 완료되고 권한에 따라 서비스에 접근할 수 있다.
- [ ] 사용자 인증 (사번, 비밀번호)
- [ ] 세션 관리
- M/8
---
2. Meeting 서비스
1) 회의 준비 및 관리
UFR-MEET-010: [회의예약] 회의록 작성자로서 | 나는, 회의를 효율적으로 준비하기 위해 | 회의를 예약하고 참석자를 초대하고 싶다.
- 시나리오: 회의 예약 및 참석자 초대
회의 예약 화면에 접근한 상황에서 | 회의 제목, 날짜/시간, 장소, 참석자 목록을 입력하고 예약 버튼을 클릭하면 | 회의가 예약되고 참석자에게 초대 이메일이 자동 발송된다.
[입력 요구사항]
- 회의 제목: 최대 100자 (필수)
- 날짜/시간: 날짜 및 시간 선택 (필수)
- 장소: 최대 200자 (선택)
- 참석자 목록: 이메일 주소 입력 (최소 1명 필수)
[처리 결과]
- 회의가 예약됨 (회의 ID 생성)
- 일정이 캘린더에 자동 등록됨
- 참석자에게 초대 이메일 발송됨
- 회의 시작 30분 전 리마인더 자동 발송
- M/13
---
UFR-MEET-020: [템플릿선택] 회의록 작성자로서 | 나는, 회의록을 효율적으로 작성하기 위해 | 회의 유형에 맞는 템플릿을 선택하고 싶다.
- 시나리오: 회의록 템플릿 선택
회의 시작 전 템플릿 선택 화면에 접근한 상황에서 | 제공되는 템플릿 중 하나를 선택하고 커스터마이징하면 | 회의록 도구가 준비된다.
[템플릿 유형]
- 일반 회의: 기본 구조 (참석자, 안건, 논의 내용, 결정 사항, Todo)
- 스크럼 회의: 어제 한 일, 오늘 할 일, 이슈
- 프로젝트 킥오프: 프로젝트 개요, 목표, 일정, 역할, 리스크
- 주간 회의: 주간 실적, 주요 이슈, 다음 주 계획
[커스터마이징 옵션]
- 섹션 추가/삭제
- 섹션 순서 변경
- 기본 항목 설정
[처리 결과]
- 선택된 템플릿으로 회의록 도구가 준비됨
- S/5
---
UFR-MEET-030: [회의시작] 회의록 작성자로서 | 나는, 회의를 시작하고 회의록을 작성하기 위해 | 회의를 시작하고 음성 녹음을 준비하고 싶다.
- 시나리오: 회의 시작
예약된 회의 시간에 회의 시작 버튼을 클릭한 상황에서 | 회의 ID를 확인하고 시작하면 | 회의 세션이 생성되고 음성 녹음이 준비된다.
[회의 시작 조건]
- 예약된 회의가 존재함
- 회의 시작 시간 10분 전부터 회의 시작 버튼 활성화
- 회의록 작성자가 시작 권한을 가짐
- 이미 시작된 회의일 경우, 진행중으로 표시
[처리 결과]
- 회의 세션이 생성됨 (세션 ID)
- 음성 녹음 준비 완료
- 참석자 목록 표시
- 회의 시작 시간 기록
- 실시간 회의록 주요 항목 추천
- M/8
---
2) 회의 종료 및 완료
UFR-MEET-040: [회의종료] 회의록 작성자로서 | 나는, 회의를 종료하고 회의록을 정리하기 위해 | 회의를 종료하고 통계를 확인하고 싶다.
- 시나리오: 회의 종료
회의가 진행 중인 상황에서 | 회의 종료 버튼을 클릭하면 | 음성 녹음이 중지되고 회의 통계가 생성된다.
[회의 종료 처리]
- 음성 녹음 즉시 중지
- 회의 종료 시간 기록
- 회의 통계 자동 생성
- 회의 총 시간
- 참석자 수
- 발언 횟수 (화자별)
- 주요 키워드
[처리 결과]
- 회의가 종료됨
- 회의 통계 표시
- 검증 완료 시 최종 회의록 확정 단계로 이동
[검증 미완료 시]
- 검증이 안된 항목이 있다면 회의록 히스토리 페이지에서 추후 수정 가능
- M/8
---
UFR-MEET-050: [최종확정] 회의록 작성자로서 | 나는, 회의록을 완성하기 위해 | 최종 회의록을 확정하고 버전을 생성하고 싶다.
- 시나리오: 최종 회의록 확정
회의가 종료된 상황에서 | 회의록 내용을 최종 검토하고 확정 버튼을 클릭하면 | 필수 항목이 검사되고 최종 버전이 생성된다.
[필수 항목 검사]
- 회의 제목 입력 여부
- 참석자 목록 작성 여부
- 주요 논의 내용 작성 여부
- 결정 사항 작성 여부
[처리 결과]
- 최종 회의록 확정됨 (확정 버전 번호)
- 확정 시간 기록
- AI가 자동으로 Todo 항목 추출 (UFR-AI-020 연동)
- 회의록 공유 가능 상태로 전환
[필수 항목 미작성 시]
- 누락된 항목 안내 메시지 표시
- 해당 섹션으로 자동 이동
- M/13
---
UFR-MEET-045: [회의록상세조회] 회의록 작성자로서 | 나는, 지난 회의록의 상세 정보와 전체 내용을 | 한눈에 확인하고 싶다.
- 시나리오: 회의록 상세 정보 조회
"내 회의록" 메뉴에서 특정 회의록을 클릭하면 | 해당 회의의 기본 정보와 섹션별 상세 내용이 표시되고 | 필요한 경우 수정, 공유, 다운로드 등의 작업을 수행할 수 있다.
[회의 기본 정보 표시]
- 회의 제목
- 회의 일시 (날짜 및 시간)
- 참석자 목록 (역할 구분: 주관자/참석자/불참자)
- 회의 장소 (온라인/오프라인)
- 사용된 템플릿 유형
- 회의록 상태 (작성중/확정완료)
- 작성자 및 최종 수정 시간
[섹션별 상세 내용 표시]
- 각 섹션 구분 표시 (논의사항, 결정사항, Todo, 기타 등)
- 섹션별 검증 상태 표시 (검증완료 섹션은 체크 표시)
- Todo 항목:
- 담당자 이름
- 마감일
- 완료/미완료 상태 (시각적 구분)
- 우선순위 (있는 경우)
- 첨부파일 목록 및 다운로드 링크
[부가 기능]
- 회의록 수정 버튼 (수정 권한이 있는 경우만 표시)
- 회의록 공유 버튼 (공유 설정 화면으로 이동)
- 이전/다음 회의록으로 이동하는 네비게이션
- 뒤로가기 버튼 (회의록 목록으로 복귀)
[처리 결과]
- 모바일/태블릿 환경에서도 가독성 높은 레이아웃
- 긴 내용은 적절한 단락 구분 및 여백 적용
- 섹션별 접기/펼치기 기능 (선택사항)
- 페이지 로딩 시 스크롤 위치는 최상단
[권한별 표시]
- 조회 권한만 있는 경우: 수정 버튼 비활성화
- 수정 권한이 있는 경우: 수정 버튼 활성화
- M/5
---
UFR-MEET-055: [회의록수정] 회의록 작성자로서 | 나는, 검증이 완료되지 않았거나 수정이 필요한 | 지난 회의록을 조회하고 수정하고 싶다.
- 시나리오: 지난 회의록 조회 및 수정
대시보드에서 "내 회의록" 메뉴를 클릭하면 | 작성한 회의록 목록이 표시되고 | 특정 회의록을 선택하여 수정할 수 있다.
[회의록 목록 조회]
- 회의록 상태별 필터링: 전체 / 작성중 / 확정완료
- 정렬 옵션: 최신순 / 회의일시순 / 제목순
- 검색 기능: 회의 제목, 참석자, 키워드로 검색
- 목록 표시 정보:
- 회의 제목
- 회의 일시
- 회의록 상태 (작성중/확정완료)
- 마지막 수정 시간
- 검증 완료율 (작성중인 경우)
[회의록 수정]
- 회의록 선택 시 상세 화면으로 이동
- 상태에 따른 수정 가능 범위:
- 작성중: 모든 섹션 수정 가능
- 확인완료: 회의록 생성자에게 수정 권한 승인요청
- 수정 중 자동 저장 (30초 간격)
- 수정 이력 관리 (누가, 언제, 무엇을 수정했는지)
[처리 결과]
- 수정 내용 즉시 반영
- 수정 시간 업데이트
- 확정완료 상태였던 경우 → 작성중 상태로 변경
[권한 제어]
- 본인이 작성한 회의록만 수정 가능
- 검증완료 후 검증된 섹션 잠금 기능은 회의록 생성자만 가능
- 모든 섹션이 검증완료일경우 회의록 상태를 확정완료로 변경
- M/13
3) 회의록 공유
UFR-MEET-060: [회의록공유] 회의록 작성자로서 | 나는, 회의 내용을 참석자들과 공유하기 위해 | 최종 회의록을 공유하고 싶다.
- 시나리오: 회의록 공유
최종 회의록이 확정된 상황에서 | 공유 버튼을 클릭하고 공유 대상과 권한을 설정하면 | 공유 링크가 생성되고 참석자 전원에게 알림이 발송된다.
[공유 설정]
- 공유 대상: 참석자 전체 (기본) / 특정 참석자 선택
- 공유 권한: 읽기 전용 / 댓글 가능 / 편집 가능
- 공유 방식: 이메일 / 링크 복사
[처리 결과]
- 공유 링크 생성 (고유 URL)
- 참석자에게 이메일 알림 발송
- 공유 시간 기록
- 다음 회의 일정이 언급된 경우 캘린더에 자동 등록
[공유 링크 보안]
- 링크 유효 기간 설정 (선택)
- 비밀번호 설정 (선택)
- M/13
---
3. STT 서비스 (기본 기능)
1) 음성 인식 및 변환
UFR-STT-010: [음성녹음인식] 회의 참석자로서 | 나는, 발언 내용이 자동으로 기록되기 위해 | 음성이 실시간으로 녹음되고 인식되기를 원한다.
- 시나리오: 음성 녹음 및 발언 인식
회의가 시작된 상황에서 | 참석자가 발언을 시작하면 | 음성이 자동으로 녹음되고 화자가 식별되며 발언이 인식된다.
[음성 녹음 처리]
- 오디오 스트림 실시간 캡처
- 회의 ID와 연결
- 음성 데이터 저장 (Azure 스토리지)
[발언 인식 처리]
- AI 음성인식 엔진 연동 (Azure Speech 등)
- 화자 자동 식별
- 참석자 목록 매칭
- 음성 특징 분석
- 타임스탬프 기록
- 발언 구간 구분
[처리 결과]
- 음성 녹음이 시작됨 (녹음 ID)
- 발언이 인식됨 (발언 ID, 화자, 타임스탬프)
- 실시간으로 텍스트 변환 요청 (UFR-STT-020 연동)
[성능 요구사항]
- 발언 인식 지연 시간: 1초 이내
- 화자 식별 정확도: 90% 이상
[비고]
- STT는 기본 기능으로 경쟁사 대부분이 제공하는 기능임
- 차별화 포인트가 아닌 필수 기능
- M/21
---
UFR-STT-020: [텍스트변환] 회의록 시스템으로서 | 나는, 인식된 발언을 회의록에 기록하기 위해 | 음성을 텍스트로 변환하고 싶다.
- 시나리오: 음성-텍스트 변환
발언이 인식된 상황에서 | AI 음성인식 엔진에 텍스트 변환을 요청하면 | 음성이 텍스트로 변환되고 정확도와 함께 반환된다.
[텍스트 변환 처리]
- 인식된 발언 데이터 전달
- 언어 설정 (한국어, 영어 등)
- AI 음성인식 엔진 처리
- 문장 부호 자동 추가
- 숫자/날짜 형식 정규화
[처리 결과]
- 텍스트가 변환됨 (텍스트 ID)
- 변환된 내용 (원문 텍스트)
- 정확도 점수 (0-100%)
- AI 회의록 자동 작성 요청 (UFR-AI-010 연동)
[정확도 낮은 경우]
- 정확도 60% 미만 시 경고 표시
- 수동 수정 인터페이스 제공
[비고]
- STT는 기본 기능으로 차별화 포인트가 아님
- M/13
---
4. AI 서비스 (차별화 포인트)
1) AI 회의록 작성
UFR-AI-010: [회의록자동작성] 회의록 작성자로서 | 나는, 회의록 작성 부담을 줄이기 위해 | AI가 발언 내용을 자동으로 정리하여 회의록을 작성하기를 원한다.
- 시나리오: AI 회의록 자동 작성
텍스트가 변환된 상황에서 | LLM에 회의록 자동 작성을 요청하면 | 회의 맥락을 이해하고 구조화된 회의록 초안이 생성된다.
[AI 처리 과정]
- 변환된 텍스트와 회의 맥락(제목, 참석자, 이전 내용) 분석
- 회의 내용 이해
- 주제별 분류
- 발언자별 의견 정리
- 중요 키워드 추출
- 문장 다듬기
- 구어체 → 문어체 변환
- 불필요한 표현 제거
- 문법 교정
- 구조화
- 회의록 템플릿에 맞춰 정리
- 주제, 발언자, 내용 구조화
- 요약문 생성
[처리 결과]
- 회의록 초안이 생성됨 (회의록 버전)
- 생성 시간 기록
- 구조화된 내용
- 논의 주제
- 발언자별 의견
- 결정 사항
- 보류 사항
- 참석자에게 실시간 동기화 (UFR-COLLAB-010 연동)
[Policy/Rule]
- 텍스트 변환되면 자동으로 회의록 구조에 맞춰 정리
- 실시간 업데이트 (3-5초 간격)
- M/34
---
2) Todo 자동 추출
UFR-AI-020: [Todo자동추출] 회의록 작성자로서 | 나는, 회의 후 실행 사항을 명확히 하기 위해 | AI가 회의록에서 Todo 항목을 자동으로 추출하고 담당자를 식별하기를 원한다.
- 시나리오: AI Todo 자동 추출
회의가 종료된 상황에서 | 최종 회의록을 분석하여 Todo 자동 추출을 요청하면 | 액션 아이템이 식별되고 담당자가 자동으로 지정된다.
[AI 분석 과정]
- 회의록 전체 내용 분석
- 액션 아이템 식별
- "~하기로 함", "~까지 완료", "~담당" 등 키워드 탐지
- 명령형 문장 분석
- 마감일 언급 추출
- 담당자 자동 식별
- 발언 내용 기반 ("제가 하겠습니다", "~님이 담당")
- 직책/역할 기반 매칭
- 과거 회의록 패턴 학습
[처리 결과]
- Todo가 자동 추출됨
- 추출된 항목 수
- 각 Todo별 정보
- Todo 내용
- 담당자 (자동 식별)
- 마감일 (언급된 경우)
- 우선순위 (언급된 경우)
- 관련 회의록 섹션 링크
- Todo 서비스에 자동 전달 (UFR-TODO-010 연동)
[담당자 식별 실패 시]
- 미지정 상태로 Todo 생성
- 수동 할당 요청 알림
- M/21
---
3) 프롬프팅 기반 회의록 개선 (신규, 차별화 포인트)
UFR-AI-030: [회의록개선] 회의록 작성자로서 | 나는, 회의록을 다양한 형식으로 변환하기 위해 | 프롬프팅을 통해 회의록을 개선하고 재구성하고 싶다.
- 시나리오: 프롬프팅 기반 회의록 개선
회의록이 작성된 상황에서 | "1Page 요약", "핵심 요약", "상세 보고서" 등의 프롬프트를 입력하면 | AI가 해당 형식에 맞춰 회의록을 재구성하여 제공한다.
[지원 프롬프트 유형]
- "1Page 요약": A4 1장 분량의 요약본 생성
- "핵심 요약": 3-5개 핵심 포인트만 추출
- "상세 보고서": 시간순 상세 기록 with 타임스탬프
- "의사결정 중심": 결정 사항과 근거만 정리
- "액션 아이템 중심": Todo와 담당자만 강조
- "경영진 보고용": 임원진에게 보고할 형식으로 재구성
- "커스텀 프롬프트": 사용자 정의 형식
[AI 처리 과정]
- 원본 회의록 분석
- 프롬프트 의도 파악
- 내용 재구성
- 중요도 기반 필터링
- 형식에 맞춘 재배치
- 불필요한 내용 제거
- 스타일 조정
- 문체 변환 (격식체, 구어체 등)
- 길이 조정 (압축 또는 확장)
[처리 결과]
- 개선된 회의록이 생성됨 (새 버전)
- 원본 회의록 링크 유지
- 생성 시간 및 프롬프트 기록
[Policy/Rule]
- 원본 회의록은 항상 보존
- 여러 버전 동시 생성 가능
- 버전 간 비교 기능 제공
- M/21
---
4) 관련 회의록 자동 연결 (신규, 차별화 포인트)
UFR-AI-040: [관련회의록연결] 회의록 작성자로서 | 나는, 이전 회의 내용을 쉽게 참조하기 위해 | AI가 같은 폴더 내 관련 있는 과거 회의록을 자동으로 찾아 연결해주기를 원한다.
- 시나리오: 관련 회의록 자동 연결
회의록이 작성되는 상황에서 | AI가 회의 주제와 내용을 분석하면 | 같은 폴더 내 유사한 주제의 과거 회의록을 찾아 자동으로 연결한다.
[AI 분석 과정]
- 현재 회의록 주제 및 키워드 추출
- 벡터 유사도 검색
- 과거 회의록 DB에서 검색
- 주제 유사도 계산
- 관련도 점수 계산 (0-100%)
- 같은 폴더 내 상위 5개 회의록 선정
[연결 기준]
- 주제 유사도 70% 이상
- 동일 참석자가 50% 이상
- 키워드 3개 이상 일치
- 시간적 연관성 (후속 회의, 분기별 회의 등)
[처리 결과]
- 관련 회의록 목록 생성
- 각 회의록별 정보
- 제목
- 날짜
- 참석자
- 관련도 점수
- 연관 키워드
- 회의록 상단에 "관련 회의록" 섹션 자동 추가
- 클릭 시 해당 회의록으로 이동
[Policy/Rule]
- 관련도 70% 이상만 자동 연결
- 최대 5개까지 표시
- S/13
---
5. RAG 서비스 (차별화 포인트)
1) 맥락 기반 용어 설명 (강화)
UFR-RAG-010: [전문용어감지] 회의록 작성자로서 | 나는, 업무 지식이 없어도 회의록을 정확히 작성하기 위해 | 전문용어가 자동으로 감지되고 맥락에 맞는 설명을 제공받고 싶다.
- 시나리오: 맥락 기반 전문용어 자동 감지
회의록이 작성되는 상황에서 | 시스템이 회의록 텍스트를 분석하면 | 전문용어가 자동으로 감지되고 맥락에 맞는 설명이 준비된다.
[전문용어 감지 처리]
- 회의록 텍스트 실시간 분석
- 용어 사전과 비교
- 조직별 전문용어 DB
- 산업별 표준 용어 DB
- 신뢰도 계산 (0-100%)
- 감지된 용어 위치 기록
[처리 결과]
- 전문용어가 감지됨
- 감지된 용어 정보
- 용어명
- 감지 위치 (줄 번호, 문단)
- 신뢰도 점수
- 용어 하이라이트 표시
- 맥락 기반 설명 자동 생성 (UFR-RAG-020 연동)
[Policy/Rule]
- 신뢰도 70% 이상만 자동 감지
- 중복 용어는 첫 번째만 하이라이트
[비고]
- 단순 용어 설명이 아닌 맥락 기반 실용적 정보 제공이 차별화 포인트
- S/13
---
UFR-RAG-020: [맥락기반용어설명] 회의록 작성자로서 | 나는, 전문용어를 맥락에 맞게 이해하기 위해 | 관련 회의록과 업무 이력을 바탕으로 실용적인 설명을 제공받고 싶다.
- 시나리오: 맥락 기반 용어 설명 자동 제공
전문용어가 감지된 상황에서 | RAG 시스템이 관련 문서를 검색하면 | 과거 회의록 및 업무 이력에서 맥락에 맞는 실용적인 설명이 생성되어 제공된다.
[RAG 검색 수행]
- 벡터 유사도 검색
- 과거 회의록 검색 (동일 용어 사용 사례)
- 사내 문서 저장소 검색 (위키, 매뉴얼, 보고서)
- 업무 이력 검색 (프로젝트 문서, 이메일 등)
- 관련 문서 추출 (관련도 점수순)
- 최대 5개 문서 선택
[맥락 기반 설명 생성]
- 검색된 문서 내용 분석
- 용어 정의 추출
- 실제 사용 사례 추출
- 현재 회의 맥락에 맞는 설명 생성
- 간단한 정의 (1-2문장)
- 이 회의에서의 의미 (맥락 기반)
- 관련 프로젝트/이슈 연결
- 과거 논의 요약 (언제, 누가, 어떻게 사용했는지)
- 참조 출처 링크
[처리 결과]
- 맥락 기반 용어 설명이 생성됨 (설명 ID)
- 설명 내용
- 간단한 정의
- 맥락 기반 상세 설명
- 실제 사용 사례
- 관련 프로젝트/이슈
- 과거 회의록 링크 (최대 3개)
- 사내 문서 링크
- 툴팁 또는 사이드 패널로 표시
- 설명 제공 시간 기록
[설명을 찾지 못한 경우]
- "관련 정보를 찾을 수 없습니다" 메시지 표시
- 전문가(회의 참석자)에게 설명 요청 버튼 제공
- 수동 입력된 설명은 용어 사전에 자동 저장
[비고]
- **차별화 포인트**: 단순 용어 설명이 아닌, 조직 내 실제 사용 맥락과 이력을 제공
- 업무 지식이 없어도 실질적인 도움을 받을 수 있음
- S/21
---
6. Collaboration 서비스
1) 실시간 협업
UFR-COLLAB-010: [회의록수정동기화] 회의 참석자로서 | 나는, 회의록을 함께 검증하기 위해 | 회의록을 수정하고 실시간으로 다른 참석자와 동기화하고 싶다.
- 시나리오: 회의록 실시간 수정 및 동기화
회의록 초안이 작성된 상황에서 | 참석자가 회의록 내용을 수정하면 | 수정 사항이 버전 관리되고 웹소켓을 통해 모든 참석자에게 즉시 동기화된다.
[회의록 수정 처리]
- 수정 내용 검증
- 수정 권한 확인
- 수정 범위 제한 (잠긴 섹션은 수정 불가)
- 수정 이력 저장
- 수정자
- 수정 시간
- 수정 전/후 내용
- 수정 위치
- 버전 관리
- 새 버전 번호 생성
- 이전 버전 보관
[실시간 동기화]
- 웹소켓을 통해 수정 델타 전송
- 전체 내용이 아닌 변경 부분만 전송 (효율성)
- 모든 참석자 화면에 실시간 반영
- 수정자 표시 (이름)
- 수정 영역 하이라이트 (3초간)
[처리 결과]
- 참석자가 회의록을 수정함 (수정 ID)
- 수정 사항이 동기화됨
- 동기화 시간
- 영향받은 참석자 목록
- 수정 완료될 때마다 수정된 내용이 메일로 알림이 발송된다. (알림 여부 설정 가능)
[Policy/Rule]
- 회의록 수정 시 웹소켓을 통해 모든 참석자에게 즉시 동기화
- M/34
---
UFR-COLLAB-020: [충돌해결] 회의 참석자로서 | 나는, 동시 수정 상황에서도 내용을 잃지 않기 위해 | 충돌을 감지하고 해결하고 싶다.
- 시나리오: 동시 수정 충돌 해결
두 명의 참석자가 동일한 위치를 동시에 수정한 상황에서 | 시스템이 충돌을 감지하면 | 충돌 알림이 표시되고 해결 방법을 선택할 수 있다.
[충돌 감지]
- 동일 위치 동시 수정 탐지
- 라인 단위 비교
- 버전 기반 충돌 확인
- 충돌 정보 생성
- 충돌 위치
- 관련 수정자 2명
- 각자의 수정 내용
[충돌 해결 방식]
- Last Write Wins (기본)
- 가장 최근 수정이 우선
- 이전 수정은 버전 이력에 보관
- 수동 병합 (선택)
- 충돌 내용 비교 UI 표시
- 사용자가 최종 내용 선택
- A 선택 / B 선택 / 직접 작성
[처리 결과]
- 충돌이 감지됨 (충돌 ID)
- 충돌 위치
- 관련 수정자
- 충돌이 해결됨
- 해결 방법 (Last Write Wins / 수동 병합)
- 최종 내용
- 해결된 내용 실시간 동기화
[Policy/Rule]
- 동시 수정 발생 시 최종 수정이 우선 (Last Write Wins) 또는 충돌 알림
- M/21
---
UFR-COLLAB-030: [검증완료] 회의 참석자로서 | 나는, 회의록의 정확성을 보장하기 위해 | 주요 섹션을 검증하고 완료 표시를 하고 싶다.
- 시나리오: 회의록 검증 완료
회의록 내용을 확인한 상황에서 | 참석자가 검증 완료 버튼을 클릭하면 | 검증 상태가 업데이트되고 다른 참석자에게 동기화된다.
[검증 처리]
- 검증자 정보 기록
- 검증 시간 기록
- 검증 대상 섹션 기록
- 검증 상태 업데이트
- 미검증 → 검증 중 → 검증 완료
[섹션 잠금 기능]
- 회의 생성자만 가능
- 주요 섹션 검증 완료 시 잠금 가능 (선택)
- 잠긴 섹션은 추가 수정 불가
- 회의 생성자가 잠그면 검증 완료로 표시
[처리 결과]
- 검증이 완료됨
- 검증자 정보
- 검증 상태 (검증 완료)
- 완료 시간
- 검증 완료 상태 실시간 동기화
- 검증 배지 표시 (체크 아이콘)
- 검증 완료 시 전체 메일로 알림이 발송된다.
[Policy/Rule]
- 주요 섹션 검증 완료 시 해당 섹션 잠금 가능
- M/8
---
7. Todo 서비스 (차별화 포인트)
1) 실시간 Todo 연결 (강화)
UFR-TODO-010: [Todo할당] Todo 시스템으로서 | 나는, AI가 추출한 Todo를 담당자에게 전달하기 위해 | Todo를 실시간으로 할당하고 회의록과 연결하고 싶다.
- 시나리오: Todo 실시간 할당 및 회의록 연결
AI가 Todo를 추출한 상황에서 | 시스템이 Todo를 등록하고 담당자를 지정하면 | Todo가 실시간으로 할당되고 회의록의 해당 위치와 연결되며 담당자에게 즉시 알림이 발송된다.
[Todo 등록]
- Todo 정보 저장
- Todo ID 생성
- Todo 내용
- 담당자 (AI 자동 식별 또는 수동 지정)
- 마감일 (언급된 경우 자동 설정, 없으면 수동 설정)
- 우선순위 (높음/보통/낮음)
- 관련 회의록 링크 (섹션 위치 포함)
[회의록 실시간 연결]
- 회의록 해당 섹션에 Todo 뱃지 표시
- Todo 클릭 시 Todo 상세 정보 표시
- 양방향 연결 (Todo → 회의록, 회의록 → Todo)
[알림 발송]
- 담당자에게 즉시 알림
- 이메일
- 알림 내용
- Todo 내용
- 마감일
- 회의록 링크 (해당 섹션으로 바로 이동)
[캘린더 연동]
- 마감일이 있는 경우 캘린더에 자동 등록
- 마감일 3일 전 리마인더 일정 생성
[처리 결과]
- Todo가 할당됨 (Todo ID)
- 담당자 정보
- 마감일
- 할당 시간
- 회의록 연결 정보 (섹션 ID, 타임스탬프)
- 담당자에게 알림이 발송됨
- 캘린더 등록 완료
[Policy/Rule]
- Todo 할당 시 담당자에게 즉시 알림 발송
- 회의록과 실시간 양방향 연결
[비고]
- **차별화 포인트**: Todo와 회의록의 강력한 연결, 원문 맥락 추적 가능
- M/13
---
UFR-TODO-030: [Todo완료처리] Todo 담당자로서 | 나는, 완료된 Todo를 처리하고 회의록에 반영하기 위해 | Todo를 완료하고 회의록에 자동 반영하고 싶다.
- 시나리오: Todo 완료 처리 및 회의록 자동 반영
Todo 작업이 완료된 상황에서 | 담당자가 완료 버튼을 클릭하면 | Todo가 완료 상태로 변경되고 연결된 회의록에 완료 상태가 실시간으로 반영된다.
[완료 처리]
- 완료 시간 자동 기록
- 완료자 정보 저장
- 완료 상태로 변경
- 완료 여부 확인 다이얼로그 표시
[회의록 실시간 반영]
- 관련 회의록의 Todo 섹션 자동 업데이트
- 완료 표시 (체크 아이콘)
- 완료 시간 기록
- 완료자 정보 표시
[알림 발송]
- 완료 알림
- 모든 Todo 완료 시 전체 완료 알림
[처리 결과]
- Todo가 완료됨
- 완료 시간
- 완료자 정보
- 회의록에 완료 상태가 반영됨
- 반영 시간
- 회의록 버전 업데이트
[Policy/Rule]
- Todo 완료 시 회의록에 완료 상태 즉시 반영
- 모든 Todo 완료 시 완료 알림
[비고]
- **차별화 포인트**: Todo 완료가 회의록에 실시간 반영되어 회의 결과 추적 용이
- M/8
---

View File

@ -1,307 +0,0 @@
<!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">
<style>
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #4DFFDB 0%, #00D9B1 100%);
}
.login-container {
background-color: white;
border-radius: var(--radius-xl);
padding: var(--spacing-12);
max-width: 400px;
width: 90%;
box-shadow: var(--shadow-lg);
}
.login-header {
text-align: center;
margin-bottom: var(--spacing-8);
}
.logo-large {
font-size: 3rem;
color: var(--primary-main);
margin-bottom: var(--spacing-4);
}
.login-title {
font: var(--font-h2);
color: var(--gray-900);
margin-bottom: var(--spacing-2);
}
.login-subtitle {
font: var(--font-body);
color: var(--gray-500);
}
.login-form {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.password-toggle {
position: relative;
}
.password-toggle-btn {
position: absolute;
right: var(--spacing-3);
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
color: var(--gray-500);
font-size: 1.2rem;
}
.login-options {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--spacing-2);
cursor: pointer;
color: var(--gray-600);
}
.forgot-password {
color: var(--primary-main);
text-decoration: none;
}
.forgot-password:hover {
text-decoration: underline;
}
.divider {
text-align: center;
margin: var(--spacing-6) 0;
position: relative;
}
.divider::before,
.divider::after {
content: '';
position: absolute;
top: 50%;
width: 45%;
height: 1px;
background-color: var(--gray-200);
}
.divider::before { left: 0; }
.divider::after { right: 0; }
.divider-text {
background-color: white;
padding: 0 var(--spacing-3);
color: var(--gray-500);
font-size: 0.875rem;
}
.social-login {
display: flex;
gap: var(--spacing-3);
}
.social-btn {
flex: 1;
padding: var(--spacing-3);
border: 1px solid var(--gray-300);
border-radius: var(--radius-md);
background-color: white;
cursor: pointer;
transition: all var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-2);
font-size: 0.875rem;
}
.social-btn:hover {
background-color: var(--gray-50);
border-color: var(--gray-400);
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<div class="logo-large">📝</div>
<h1 class="login-title">회의록 작성 서비스</h1>
<p class="login-subtitle">AI와 함께하는 스마트한 회의록 관리</p>
</div>
<form class="login-form" id="loginForm">
<div class="input-group">
<label for="email" class="input-label">이메일</label>
<input
type="email"
id="email"
class="input"
placeholder="example@company.com"
required
>
<span class="input-error hidden" id="emailError"></span>
</div>
<div class="input-group">
<label for="password" class="input-label">비밀번호</label>
<div class="password-toggle">
<input
type="password"
id="password"
class="input"
placeholder="비밀번호를 입력하세요"
required
>
<button
type="button"
class="password-toggle-btn"
id="togglePassword"
aria-label="비밀번호 표시/숨김"
>
👁️
</button>
</div>
<span class="input-error hidden" id="passwordError"></span>
</div>
<div class="login-options">
<label class="checkbox-label">
<input type="checkbox" id="rememberMe">
<span>로그인 상태 유지</span>
</label>
<a href="#" class="forgot-password">비밀번호 찾기</a>
</div>
<button type="submit" class="btn btn-primary btn-lg">
로그인
</button>
</form>
<div class="divider">
<span class="divider-text">또는</span>
</div>
<div class="social-login">
<button class="social-btn" id="googleLogin">
<span>🔵</span>
<span>Google</span>
</button>
<button class="social-btn" id="microsoftLogin">
<span>🟦</span>
<span>Microsoft</span>
</button>
</div>
</div>
<script src="common.js"></script>
<script>
// 비밀번호 표시/숨김 토글
document.getElementById('togglePassword').addEventListener('click', function() {
const passwordInput = document.getElementById('password');
const type = passwordInput.type === 'password' ? 'text' : 'password';
passwordInput.type = type;
this.textContent = type === 'password' ? '👁️' : '👁️‍🗨️';
});
// 로그인 폼 제출
document.getElementById('loginForm').addEventListener('submit', function(e) {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('rememberMe').checked;
// 간단한 검증
const emailError = document.getElementById('emailError');
const passwordError = document.getElementById('passwordError');
emailError.classList.add('hidden');
passwordError.classList.add('hidden');
document.getElementById('email').classList.remove('error');
document.getElementById('password').classList.remove('error');
let hasError = false;
// 이메일 검증
if (!email.includes('@')) {
emailError.textContent = '올바른 이메일 주소를 입력하세요.';
emailError.classList.remove('hidden');
document.getElementById('email').classList.add('error');
hasError = true;
}
// 비밀번호 검증
if (password.length < 8) {
passwordError.textContent = '비밀번호는 최소 8자 이상이어야 합니다.';
passwordError.classList.remove('hidden');
document.getElementById('password').classList.add('error');
hasError = true;
}
if (hasError) {
return;
}
// 로그인 처리 (시뮬레이션)
const users = getFromStorage('users') || [];
const user = users.find(u => u.email === email);
if (!user) {
showToast('이메일 또는 비밀번호가 일치하지 않습니다.', 'error');
return;
}
// 로그인 성공
saveToStorage('currentUser', user);
if (rememberMe) {
localStorage.setItem('rememberMe', 'true');
}
showToast('로그인 성공!', 'success');
// 0.5초 후 대시보드로 이동
setTimeout(() => {
navigateTo('02-대시보드.html');
}, 500);
});
// 소셜 로그인 (시뮬레이션)
document.getElementById('googleLogin').addEventListener('click', function() {
showToast('Google 로그인은 프로토타입에서 지원하지 않습니다.', 'info');
});
document.getElementById('microsoftLogin').addEventListener('click', function() {
showToast('Microsoft 로그인은 프로토타입에서 지원하지 않습니다.', 'info');
});
// 비밀번호 찾기
document.querySelector('.forgot-password').addEventListener('click', function(e) {
e.preventDefault();
showToast('비밀번호 재설정 이메일이 발송되었습니다.', 'success');
});
</script>
</body>
</html>

View File

@ -1,539 +0,0 @@
<!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">
<style>
.dashboard-layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 250px;
background-color: white;
border-right: 1px solid var(--gray-200);
padding: var(--spacing-6);
position: fixed;
height: 100vh;
overflow-y: auto;
}
.sidebar-logo {
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-main);
margin-bottom: var(--spacing-8);
text-decoration: none;
display: block;
}
.sidebar-menu {
list-style: none;
}
.sidebar-menu-item {
margin-bottom: var(--spacing-2);
}
.sidebar-menu-link {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-3);
border-radius: var(--radius-md);
text-decoration: none;
color: var(--gray-700);
transition: all var(--transition-fast);
}
.sidebar-menu-link:hover {
background-color: var(--gray-100);
}
.sidebar-menu-link.active {
background-color: var(--primary-light);
color: var(--primary-dark);
font-weight: 600;
}
.main-layout {
margin-left: 250px;
flex: 1;
display: flex;
flex-direction: column;
}
.top-header {
background-color: white;
border-bottom: 1px solid var(--gray-200);
padding: var(--spacing-4);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
}
.search-bar {
flex: 1;
max-width: 600px;
position: relative;
}
.search-input {
width: 100%;
padding: var(--spacing-2) var(--spacing-4) var(--spacing-2) var(--spacing-10);
border: 1px solid var(--gray-300);
border-radius: var(--radius-md);
font-size: 0.875rem;
}
.search-icon {
position: absolute;
left: var(--spacing-3);
top: 50%;
transform: translateY(-50%);
color: var(--gray-400);
}
.header-actions {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.notification-btn {
position: relative;
}
.notification-badge {
position: absolute;
top: -4px;
right: -4px;
background-color: var(--error-main);
color: white;
font-size: 0.75rem;
padding: 2px 6px;
border-radius: var(--radius-full);
}
.profile-btn {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.profile-btn:hover {
background-color: var(--gray-100);
}
.profile-avatar {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background-color: var(--primary-main);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
}
.dashboard-content {
padding: var(--spacing-8);
background-color: var(--gray-50);
}
.dashboard-header {
margin-bottom: var(--spacing-6);
}
.dashboard-title {
font: var(--font-h1);
margin-bottom: var(--spacing-2);
}
.dashboard-subtitle {
color: var(--gray-500);
}
.widget-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--spacing-6);
}
.widget {
background-color: white;
border-radius: var(--radius-lg);
padding: var(--spacing-6);
box-shadow: var(--shadow-sm);
}
.widget-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-4);
}
.widget-title {
font: var(--font-h4);
}
.widget-link {
color: var(--primary-main);
font-size: 0.875rem;
text-decoration: none;
}
.widget-link:hover {
text-decoration: underline;
}
.meeting-item {
padding: var(--spacing-3);
border-bottom: 1px solid var(--gray-100);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.meeting-item:last-child {
border-bottom: none;
}
.meeting-item:hover {
background-color: var(--gray-50);
border-radius: var(--radius-md);
}
.meeting-title {
font-weight: 500;
margin-bottom: var(--spacing-1);
}
.meeting-meta {
font-size: 0.875rem;
color: var(--gray-500);
display: flex;
gap: var(--spacing-3);
}
.todo-item {
display: flex;
align-items: flex-start;
gap: var(--spacing-3);
padding: var(--spacing-3);
border-bottom: 1px solid var(--gray-100);
}
.todo-item:last-child {
border-bottom: none;
}
.todo-checkbox {
margin-top: 4px;
}
.todo-content {
flex: 1;
}
.todo-text {
font-size: 0.875rem;
margin-bottom: var(--spacing-1);
}
.todo-due {
font-size: 0.75rem;
color: var(--gray-500);
}
.todo-due.overdue {
color: var(--error-main);
}
.stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-4);
}
.stat-card {
text-align: center;
padding: var(--spacing-4);
background-color: var(--gray-50);
border-radius: var(--radius-md);
}
.stat-number {
font-size: 2rem;
font-weight: 700;
color: var(--primary-main);
}
.stat-label {
font-size: 0.875rem;
color: var(--gray-600);
}
</style>
</head>
<body>
<div class="dashboard-layout">
<!-- Sidebar -->
<aside class="sidebar">
<a href="02-대시보드.html" class="sidebar-logo">📝 회의록</a>
<nav>
<ul class="sidebar-menu">
<li class="sidebar-menu-item">
<a href="02-대시보드.html" class="sidebar-menu-link active">
<span>🏠</span>
<span>대시보드</span>
</a>
</li>
<li class="sidebar-menu-item">
<a href="#" class="sidebar-menu-link">
<span>📄</span>
<span>내 회의록</span>
</a>
</li>
<li class="sidebar-menu-item">
<a href="03-회의예약.html" class="sidebar-menu-link">
<span>📅</span>
<span>예정된 회의</span>
</a>
</li>
<li class="sidebar-menu-item">
<a href="09-Todo관리.html" class="sidebar-menu-link">
<span></span>
<span>Todo 목록</span>
</a>
</li>
<li class="sidebar-menu-item">
<a href="04-템플릿선택.html" class="sidebar-menu-link">
<span>📋</span>
<span>템플릿</span>
</a>
</li>
<li class="sidebar-menu-item">
<a href="#" class="sidebar-menu-link">
<span>⚙️</span>
<span>설정</span>
</a>
</li>
</ul>
</nav>
</aside>
<!-- Main Content -->
<div class="main-layout">
<!-- Header -->
<header class="top-header">
<div class="header-content">
<div class="search-bar">
<span class="search-icon">🔍</span>
<input type="text" class="search-input" placeholder="회의록 검색...">
</div>
<div class="header-actions">
<button class="btn-icon notification-btn">
<span>🔔</span>
<span class="notification-badge">3</span>
</button>
<div class="profile-btn" id="profileBtn">
<div class="profile-avatar" id="profileAvatar">👤</div>
<span id="profileName">사용자</span>
</div>
</div>
</div>
</header>
<!-- Dashboard Content -->
<main class="dashboard-content">
<div class="dashboard-header">
<h1 class="dashboard-title" id="welcomeMessage">안녕하세요!</h1>
<p class="dashboard-subtitle">오늘도 생산적인 하루 되세요</p>
</div>
<div class="widget-grid">
<!-- 예정된 회의 -->
<div class="widget">
<div class="widget-header">
<h3 class="widget-title">예정된 회의</h3>
<a href="03-회의예약.html" class="widget-link">모두 보기</a>
</div>
<div id="scheduledMeetings"></div>
</div>
<!-- 최근 회의록 -->
<div class="widget">
<div class="widget-header">
<h3 class="widget-title">최근 회의록</h3>
<a href="#" class="widget-link">모두 보기</a>
</div>
<div id="recentMeetings"></div>
</div>
<!-- 대기 중인 Todo -->
<div class="widget">
<div class="widget-header">
<h3 class="widget-title">내가 할 일</h3>
<a href="09-Todo관리.html" class="widget-link">모두 보기</a>
</div>
<div id="pendingTodos"></div>
</div>
<!-- 통계 -->
<div class="widget">
<div class="widget-header">
<h3 class="widget-title">이번 주 통계</h3>
</div>
<div class="stat-grid">
<div class="stat-card">
<div class="stat-number" id="weeklyMeetings">0</div>
<div class="stat-label">회의</div>
</div>
<div class="stat-card">
<div class="stat-number" id="completedTodos">0</div>
<div class="stat-label">완료 Todo</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- FAB -->
<button class="fab" id="fabBtn">+</button>
</div>
<script src="common.js"></script>
<script>
// 사용자 정보 표시
const currentUser = getCurrentUser();
if (currentUser) {
document.getElementById('welcomeMessage').textContent = `안녕하세요, ${currentUser.name}님!`;
document.getElementById('profileName').textContent = currentUser.name;
document.getElementById('profileAvatar').textContent = currentUser.avatar;
}
// 예정된 회의 표시
const scheduledMeetings = getScheduledMeetings();
const scheduledContainer = document.getElementById('scheduledMeetings');
if (scheduledMeetings.length > 0) {
scheduledContainer.innerHTML = scheduledMeetings.slice(0, 3).map(meeting => `
<div class="meeting-item" onclick="navigateTo('05-회의진행.html?id=${meeting.id}')">
<div class="meeting-title">${meeting.title}</div>
<div class="meeting-meta">
<span>📅 ${meeting.date}</span>
<span>🕐 ${meeting.time}</span>
</div>
</div>
`).join('');
} else {
scheduledContainer.innerHTML = '<p style="color: var(--gray-500); font-size: 0.875rem;">예정된 회의가 없습니다.</p>';
}
// 최근 회의록 표시
const recentMeetings = getRecentMeetings(3);
const recentContainer = document.getElementById('recentMeetings');
if (recentMeetings.length > 0) {
recentContainer.innerHTML = recentMeetings.map(meeting => {
const statusClass = meeting.status === 'completed' ? 'success' : meeting.status === 'in_progress' ? 'warning' : 'neutral';
const statusText = meeting.status === 'completed' ? '확정됨' : meeting.status === 'in_progress' ? '작성 중' : '예정됨';
return `
<div class="meeting-item" onclick="navigateTo('05-회의진행.html?id=${meeting.id}')">
<div class="meeting-title">${meeting.title}</div>
<div class="meeting-meta">
<span>📅 ${meeting.date}</span>
<span class="badge badge-${statusClass}">${statusText}</span>
</div>
</div>
`;
}).join('');
} else {
recentContainer.innerHTML = '<p style="color: var(--gray-500); font-size: 0.875rem;">회의록이 없습니다.</p>';
}
// 대기 중인 Todo 표시
if (currentUser) {
const pendingTodos = getPendingTodos(currentUser.id).slice(0, 3);
const todosContainer = document.getElementById('pendingTodos');
if (pendingTodos.length > 0) {
todosContainer.innerHTML = pendingTodos.map(todo => {
const dueClass = getDdayText(todo.dueDate).includes('지남') ? 'overdue' : '';
return `
<div class="todo-item">
<input type="checkbox" class="todo-checkbox" data-id="${todo.id}">
<div class="todo-content">
<div class="todo-text">${todo.content}</div>
<div class="todo-due ${dueClass}">${getDdayText(todo.dueDate)}</div>
</div>
</div>
`;
}).join('');
// Todo 체크박스 이벤트
document.querySelectorAll('.todo-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', function() {
const todoId = this.getAttribute('data-id');
const todo = getTodoById(todoId);
if (todo && this.checked) {
todo.status = 'completed';
todo.progress = 100;
saveTodo(todo);
showToast('Todo가 완료되었습니다.', 'success');
setTimeout(() => location.reload(), 1000);
}
});
});
} else {
todosContainer.innerHTML = '<p style="color: var(--gray-500); font-size: 0.875rem;">할 일이 없습니다.</p>';
}
}
// 통계 표시
const allMeetings = getAllMeetings();
document.getElementById('weeklyMeetings').textContent = allMeetings.length;
const completedTodos = getTodosByStatus('completed');
document.getElementById('completedTodos').textContent = completedTodos.length;
// FAB 클릭
document.getElementById('fabBtn').addEventListener('click', function() {
const menu = [
{ text: '새 회의 예약', url: '03-회의예약.html' },
{ text: '즉시 회의 시작', url: '04-템플릿선택.html' },
{ text: '새 Todo 추가', url: '09-Todo관리.html' }
];
// 간단한 메뉴 표시
const choice = confirm('새 회의를 예약하시겠습니까?\n확인: 예약\n취소: 즉시 시작');
if (choice) {
navigateTo('03-회의예약.html');
} else {
navigateTo('04-템플릿선택.html');
}
});
// 프로필 버튼 클릭
document.getElementById('profileBtn').addEventListener('click', function() {
const choice = confirm('로그아웃하시겠습니까?');
if (choice) {
localStorage.removeItem('currentUser');
showToast('로그아웃되었습니다.', 'success');
setTimeout(() => navigateTo('01-로그인.html'), 500);
}
});
</script>
</body>
</html>

View File

@ -1,699 +0,0 @@
<!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">
<style>
.reservation-container {
max-width: 1200px;
margin: 0 auto;
padding: var(--spacing-8);
}
.progress-bar {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-4);
margin-bottom: var(--spacing-8);
}
.progress-step {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.progress-circle {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--gray-300);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.progress-circle.active {
background-color: var(--primary-main);
}
.progress-circle.completed {
background-color: var(--success-main);
}
.progress-label {
font-weight: 500;
color: var(--gray-500);
}
.progress-label.active {
color: var(--gray-900);
}
.progress-arrow {
color: var(--gray-300);
font-size: 20px;
}
.reservation-layout {
display: grid;
grid-template-columns: 1fr 400px;
gap: var(--spacing-8);
}
.form-section {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-8);
box-shadow: var(--shadow-sm);
}
.preview-section {
position: sticky;
top: var(--spacing-8);
height: fit-content;
}
.preview-card {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-6);
box-shadow: var(--shadow-sm);
border: 2px solid var(--primary-main);
}
.preview-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--gray-900);
margin-bottom: var(--spacing-4);
}
.preview-item {
margin-bottom: var(--spacing-4);
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--gray-200);
}
.preview-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.preview-label {
font-size: 0.75rem;
color: var(--gray-500);
margin-bottom: var(--spacing-1);
}
.preview-value {
font-size: 1rem;
color: var(--gray-900);
font-weight: 500;
}
.datetime-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-4);
}
.participant-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
}
.participant-tag {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-3);
background-color: var(--gray-100);
border-radius: var(--radius-md);
font-size: 0.875rem;
}
.participant-tag .remove {
cursor: pointer;
color: var(--gray-500);
font-weight: bold;
}
.participant-tag .remove:hover {
color: var(--error-main);
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.checkbox-item {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.checkbox-item input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.action-buttons {
display: flex;
justify-content: space-between;
margin-top: var(--spacing-8);
padding-top: var(--spacing-6);
border-top: 1px solid var(--gray-200);
}
@media (max-width: 1023px) {
.reservation-layout {
grid-template-columns: 1fr;
}
.preview-section {
position: static;
}
}
</style>
</head>
<body>
<div class="reservation-container">
<!-- 진행 단계 표시 -->
<div class="progress-bar">
<div class="progress-step">
<div class="progress-circle completed"></div>
<span class="progress-label">회의 예약</span>
</div>
<span class="progress-arrow"></span>
<div class="progress-step">
<div class="progress-circle active">2</div>
<span class="progress-label active">템플릿 선택</span>
</div>
<span class="progress-arrow"></span>
<div class="progress-step">
<div class="progress-circle">3</div>
<span class="progress-label">회의 진행</span>
</div>
</div>
<div class="reservation-layout">
<!-- 입력 폼 -->
<div class="form-section">
<h1>회의 예약</h1>
<p style="color: var(--gray-500); margin-bottom: var(--spacing-8);">
회의 정보를 입력하고 참석자를 초대하세요
</p>
<form id="reservationForm">
<!-- 회의 제목 -->
<div class="input-group">
<label class="input-label" for="meetingTitle">
회의 제목 <span style="color: var(--error-main);">*</span>
</label>
<input
type="text"
id="meetingTitle"
class="input"
placeholder="예: 2025년 1분기 전략 회의"
maxlength="100"
required>
<span class="input-error hidden" id="titleError"></span>
</div>
<!-- 날짜 및 시간 -->
<div class="datetime-group">
<div class="input-group">
<label class="input-label" for="meetingDate">
날짜 <span style="color: var(--error-main);">*</span>
</label>
<input
type="date"
id="meetingDate"
class="input"
required>
<span class="input-error hidden" id="dateError"></span>
</div>
<div class="input-group">
<label class="input-label" for="startTime">
시작 시간 <span style="color: var(--error-main);">*</span>
</label>
<input
type="time"
id="startTime"
class="input"
required>
</div>
</div>
<div class="datetime-group">
<div class="input-group">
<label class="input-label" for="endTime">
종료 시간 <span style="color: var(--error-main);">*</span>
</label>
<input
type="time"
id="endTime"
class="input"
required>
<span class="input-error hidden" id="timeError"></span>
</div>
<div class="input-group">
<label class="input-label">&nbsp;</label>
<div style="padding: var(--spacing-3) 0; color: var(--gray-500); font-size: 0.875rem;">
예상 소요: <span id="duration">-</span>
</div>
</div>
</div>
<!-- 장소 -->
<div class="input-group">
<label class="input-label" for="location">장소</label>
<input
type="text"
id="location"
class="input"
placeholder="예: 3층 회의실"
maxlength="200"
:disabled="isOnline">
<div class="checkbox-item mt-2">
<input type="checkbox" id="isOnline">
<label for="isOnline">온라인 회의</label>
</div>
<input
type="url"
id="onlineLink"
class="input mt-2 hidden"
placeholder="Zoom, Teams 링크를 입력하세요">
</div>
<!-- 참석자 -->
<div class="input-group">
<label class="input-label" for="participantEmail">
참석자 <span style="color: var(--error-main);">*</span>
</label>
<input
type="email"
id="participantEmail"
class="input"
placeholder="참석자 이메일을 입력하세요">
<button type="button" class="btn btn-secondary btn-sm mt-2" id="addParticipant">
+ 참석자 추가
</button>
<div class="participant-tags" id="participantTags"></div>
<span class="input-error hidden" id="participantError"></span>
</div>
<!-- 회의 설명 -->
<div class="input-group">
<label class="input-label" for="description">회의 설명</label>
<textarea
id="description"
class="textarea"
placeholder="회의 목적 및 안건을 입력하세요"
maxlength="1000"></textarea>
</div>
<!-- 알림 설정 -->
<div class="input-group">
<label class="input-label">알림 설정</label>
<div class="checkbox-group">
<div class="checkbox-item">
<input type="checkbox" id="notify30" checked>
<label for="notify30">회의 30분 전 알림</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="notify60">
<label for="notify60">회의 1시간 전 알림</label>
</div>
</div>
</div>
<!-- 반복 설정 -->
<div class="input-group">
<label class="input-label" for="repeat">반복 설정</label>
<select id="repeat" class="select">
<option value="none">반복 안 함</option>
<option value="daily">매일</option>
<option value="weekly">매주</option>
<option value="monthly">매월</option>
</select>
<div class="input-group mt-4 hidden" id="repeatEndGroup">
<label class="input-label" for="repeatEnd">반복 종료일</label>
<input type="date" id="repeatEnd" class="input">
</div>
</div>
<!-- 캘린더 연동 -->
<div class="input-group">
<label class="input-label">캘린더 연동</label>
<div class="checkbox-group">
<div class="checkbox-item">
<input type="checkbox" id="googleCalendar">
<label for="googleCalendar">Google Calendar에 추가</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="outlook">
<label for="outlook">Outlook에 추가</label>
</div>
</div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons">
<button type="button" class="btn btn-text" onclick="navigateTo('02-대시보드.html')">
취소
</button>
<div style="display: flex; gap: var(--spacing-3);">
<button type="button" class="btn btn-secondary" id="saveDraft">
저장 후 나중에 계속
</button>
<button type="submit" class="btn btn-primary">
다음: 템플릿 선택 →
</button>
</div>
</div>
</form>
</div>
<!-- 미리보기 -->
<div class="preview-section">
<div class="preview-card">
<div class="preview-title">회의 미리보기</div>
<div class="preview-item">
<div class="preview-label">회의 제목</div>
<div class="preview-value" id="previewTitle">-</div>
</div>
<div class="preview-item">
<div class="preview-label">날짜 및 시간</div>
<div class="preview-value" id="previewDateTime">-</div>
</div>
<div class="preview-item">
<div class="preview-label">장소</div>
<div class="preview-value" id="previewLocation">-</div>
</div>
<div class="preview-item">
<div class="preview-label">참석자</div>
<div class="preview-value" id="previewParticipants">-</div>
</div>
<div class="preview-item">
<div class="preview-label">회의 설명</div>
<div class="preview-value" id="previewDescription" style="white-space: pre-wrap;">-</div>
</div>
</div>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
// 참석자 목록
let participants = [];
// 오늘 날짜를 최소값으로 설정
const today = new Date().toISOString().split('T')[0];
document.getElementById('meetingDate').min = today;
document.getElementById('meetingDate').value = today;
document.getElementById('repeatEnd').min = today;
// 시작 시간 기본값 설정
const now = new Date();
const currentHour = now.getHours();
const nextHour = currentHour + 1;
document.getElementById('startTime').value = `${String(nextHour).padStart(2, '0')}:00`;
document.getElementById('endTime').value = `${String(nextHour + 1).padStart(2, '0')}:00`;
// 실시간 미리보기 업데이트
function updatePreview() {
const title = document.getElementById('meetingTitle').value || '-';
const date = document.getElementById('meetingDate').value;
const startTime = document.getElementById('startTime').value;
const endTime = document.getElementById('endTime').value;
const isOnline = document.getElementById('isOnline').checked;
const location = isOnline ?
(document.getElementById('onlineLink').value || '온라인 회의') :
(document.getElementById('location').value || '-');
const description = document.getElementById('description').value || '-';
document.getElementById('previewTitle').textContent = title;
if (date && startTime && endTime) {
const dateObj = new Date(date);
const dateStr = `${dateObj.getFullYear()}년 ${dateObj.getMonth() + 1}월 ${dateObj.getDate()}일`;
document.getElementById('previewDateTime').textContent = `${dateStr} ${startTime} ~ ${endTime}`;
} else {
document.getElementById('previewDateTime').textContent = '-';
}
document.getElementById('previewLocation').textContent = location;
document.getElementById('previewParticipants').textContent =
participants.length > 0 ? participants.join(', ') : '-';
document.getElementById('previewDescription').textContent = description;
}
// 소요 시간 계산
function calculateDuration() {
const startTime = document.getElementById('startTime').value;
const endTime = document.getElementById('endTime').value;
if (startTime && endTime) {
const [startHour, startMin] = startTime.split(':').map(Number);
const [endHour, endMin] = endTime.split(':').map(Number);
const startMinutes = startHour * 60 + startMin;
const endMinutes = endHour * 60 + endMin;
const diff = endMinutes - startMinutes;
if (diff > 0) {
document.getElementById('duration').textContent = formatDuration(diff);
document.getElementById('timeError').classList.add('hidden');
document.getElementById('endTime').classList.remove('error');
return true;
} else {
document.getElementById('timeError').textContent = '종료 시간은 시작 시간보다 늦어야 합니다.';
document.getElementById('timeError').classList.remove('hidden');
document.getElementById('endTime').classList.add('error');
return false;
}
}
return true;
}
// 온라인 회의 체크박스
document.getElementById('isOnline').addEventListener('change', (e) => {
const isOnline = e.target.checked;
const locationInput = document.getElementById('location');
const onlineLinkInput = document.getElementById('onlineLink');
if (isOnline) {
locationInput.disabled = true;
locationInput.value = '';
onlineLinkInput.classList.remove('hidden');
} else {
locationInput.disabled = false;
onlineLinkInput.classList.add('hidden');
onlineLinkInput.value = '';
}
updatePreview();
});
// 참석자 추가
document.getElementById('addParticipant').addEventListener('click', () => {
const emailInput = document.getElementById('participantEmail');
const email = emailInput.value.trim();
const errorSpan = document.getElementById('participantError');
if (!email) {
errorSpan.textContent = '이메일을 입력하세요.';
errorSpan.classList.remove('hidden');
return;
}
// 간단한 이메일 검증
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
errorSpan.textContent = '올바른 이메일 주소를 입력하세요.';
errorSpan.classList.remove('hidden');
emailInput.classList.add('error');
return;
}
if (participants.includes(email)) {
errorSpan.textContent = '이미 추가된 참석자입니다.';
errorSpan.classList.remove('hidden');
return;
}
participants.push(email);
emailInput.value = '';
emailInput.classList.remove('error');
errorSpan.classList.add('hidden');
renderParticipants();
updatePreview();
});
// 참석자 렌더링
function renderParticipants() {
const container = document.getElementById('participantTags');
container.innerHTML = participants.map((email, index) => `
<div class="participant-tag">
<span>👤 ${email}</span>
<span class="remove" onclick="removeParticipant(${index})">×</span>
</div>
`).join('');
}
// 참석자 제거
function removeParticipant(index) {
participants.splice(index, 1);
renderParticipants();
updatePreview();
}
// 반복 설정
document.getElementById('repeat').addEventListener('change', (e) => {
const repeatEndGroup = document.getElementById('repeatEndGroup');
if (e.target.value !== 'none') {
repeatEndGroup.classList.remove('hidden');
} else {
repeatEndGroup.classList.add('hidden');
}
});
// 입력 필드 이벤트 리스너
document.querySelectorAll('input, textarea, select').forEach(el => {
el.addEventListener('input', updatePreview);
el.addEventListener('change', updatePreview);
});
document.getElementById('startTime').addEventListener('change', calculateDuration);
document.getElementById('endTime').addEventListener('change', calculateDuration);
// 폼 제출
document.getElementById('reservationForm').addEventListener('submit', (e) => {
e.preventDefault();
// 유효성 검사
const title = document.getElementById('meetingTitle').value.trim();
const date = document.getElementById('meetingDate').value;
if (!title) {
document.getElementById('titleError').textContent = '회의 제목을 입력하세요.';
document.getElementById('titleError').classList.remove('hidden');
document.getElementById('meetingTitle').classList.add('error');
document.getElementById('meetingTitle').focus();
return;
}
if (!date) {
document.getElementById('dateError').textContent = '날짜를 선택하세요.';
document.getElementById('dateError').classList.remove('hidden');
document.getElementById('meetingDate').classList.add('error');
return;
}
// 과거 날짜 검사
const selectedDate = new Date(date);
const todayDate = new Date(today);
if (selectedDate < todayDate) {
document.getElementById('dateError').textContent = '과거 날짜는 선택할 수 없습니다.';
document.getElementById('dateError').classList.remove('hidden');
document.getElementById('meetingDate').classList.add('error');
return;
}
if (!calculateDuration()) {
return;
}
if (participants.length === 0) {
document.getElementById('participantError').textContent = '최소 1명의 참석자를 추가하세요.';
document.getElementById('participantError').classList.remove('hidden');
document.getElementById('participantEmail').focus();
return;
}
// 회의 데이터 저장
const meetingData = {
id: generateId('meeting'),
title: title,
date: date,
time: document.getElementById('startTime').value,
endTime: document.getElementById('endTime').value,
location: document.getElementById('isOnline').checked ?
document.getElementById('onlineLink').value || '온라인 회의' :
document.getElementById('location').value,
participants: participants,
description: document.getElementById('description').value,
notifications: {
notify30: document.getElementById('notify30').checked,
notify60: document.getElementById('notify60').checked
},
repeat: document.getElementById('repeat').value,
repeatEnd: document.getElementById('repeatEnd').value,
calendar: {
google: document.getElementById('googleCalendar').checked,
outlook: document.getElementById('outlook').checked
},
status: 'scheduled',
createdAt: new Date().toISOString()
};
// LocalStorage에 임시 저장
sessionStorage.setItem('newMeeting', JSON.stringify(meetingData));
showToast('회의 정보가 저장되었습니다.', 'success');
// 템플릿 선택 화면으로 이동
setTimeout(() => {
navigateTo('04-템플릿선택.html');
}, 500);
});
// 임시 저장
document.getElementById('saveDraft').addEventListener('click', () => {
const meetingData = {
title: document.getElementById('meetingTitle').value,
date: document.getElementById('meetingDate').value,
time: document.getElementById('startTime').value,
endTime: document.getElementById('endTime').value,
location: document.getElementById('location').value,
participants: participants,
description: document.getElementById('description').value
};
sessionStorage.setItem('draftMeeting', JSON.stringify(meetingData));
showToast('회의 정보가 임시 저장되었습니다.', 'success');
setTimeout(() => {
navigateTo('02-대시보드.html');
}, 500);
});
// 초기 업데이트
calculateDuration();
updatePreview();
</script>
</body>
</html>

View File

@ -1,537 +0,0 @@
<!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">
<style>
.template-container {
max-width: 1536px;
margin: 0 auto;
padding: var(--spacing-8);
}
.progress-bar {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-4);
margin-bottom: var(--spacing-8);
}
.progress-step {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.progress-circle {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--gray-300);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.progress-circle.active {
background-color: var(--primary-main);
}
.progress-circle.completed {
background-color: var(--success-main);
}
.progress-label {
font-weight: 500;
color: var(--gray-500);
}
.progress-label.active {
color: var(--gray-900);
}
.progress-arrow {
color: var(--gray-300);
font-size: 20px;
}
.template-layout {
display: grid;
grid-template-columns: 1fr 400px;
gap: var(--spacing-8);
}
.template-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-6);
}
.template-card {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-6);
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: all var(--transition-base);
border: 2px solid transparent;
}
.template-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.template-card.selected {
border-color: var(--primary-main);
background-color: rgba(0, 217, 177, 0.05);
}
.template-header {
display: flex;
align-items: center;
gap: var(--spacing-3);
margin-bottom: var(--spacing-3);
}
.template-icon {
font-size: 32px;
}
.template-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--gray-900);
}
.template-description {
color: var(--gray-600);
font-size: 0.875rem;
margin-bottom: var(--spacing-4);
}
.template-usage {
display: flex;
align-items: center;
gap: var(--spacing-2);
color: var(--gray-500);
font-size: 0.75rem;
}
.template-sections {
margin-top: var(--spacing-4);
padding-top: var(--spacing-4);
border-top: 1px solid var(--gray-200);
}
.section-item {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) 0;
font-size: 0.875rem;
color: var(--gray-600);
}
.section-required {
color: var(--error-main);
font-size: 0.75rem;
}
.preview-panel {
position: sticky;
top: var(--spacing-8);
height: fit-content;
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-6);
box-shadow: var(--shadow-sm);
}
.preview-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--gray-900);
margin-bottom: var(--spacing-4);
}
.preview-empty {
text-align: center;
padding: var(--spacing-8);
color: var(--gray-400);
}
.section-list {
margin-top: var(--spacing-4);
}
.section-drag-item {
background: var(--gray-50);
padding: var(--spacing-3);
margin-bottom: var(--spacing-2);
border-radius: var(--radius-md);
display: flex;
align-items: center;
gap: var(--spacing-2);
cursor: move;
}
.drag-handle {
color: var(--gray-400);
cursor: grab;
}
.section-drag-item:active .drag-handle {
cursor: grabbing;
}
.section-name {
flex: 1;
font-weight: 500;
}
.section-actions {
display: flex;
gap: var(--spacing-2);
}
.icon-btn {
background: none;
border: none;
cursor: pointer;
color: var(--gray-500);
padding: var(--spacing-1);
font-size: 16px;
}
.icon-btn:hover {
color: var(--primary-main);
}
.action-buttons {
display: flex;
justify-content: space-between;
margin-top: var(--spacing-8);
padding-top: var(--spacing-6);
border-top: 1px solid var(--gray-200);
}
@media (max-width: 1023px) {
.template-layout {
grid-template-columns: 1fr;
}
.template-grid {
grid-template-columns: 1fr;
}
.preview-panel {
position: static;
}
}
</style>
</head>
<body>
<div class="template-container">
<!-- 진행 단계 표시 -->
<div class="progress-bar">
<div class="progress-step">
<div class="progress-circle completed"></div>
<span class="progress-label">회의 예약</span>
</div>
<span class="progress-arrow"></span>
<div class="progress-step">
<div class="progress-circle active">2</div>
<span class="progress-label active">템플릿 선택</span>
</div>
<span class="progress-arrow"></span>
<div class="progress-step">
<div class="progress-circle">3</div>
<span class="progress-label">회의 진행</span>
</div>
</div>
<h1>회의 템플릿 선택</h1>
<p style="color: var(--gray-500); margin-bottom: var(--spacing-8);">
회의 유형에 맞는 템플릿을 선택하거나 커스터마이징하세요
</p>
<div class="template-layout">
<!-- 템플릿 그리드 -->
<div>
<div class="template-grid" id="templateGrid"></div>
</div>
<!-- 미리보기 및 커스터마이징 -->
<div class="preview-panel">
<div class="preview-title">템플릿 미리보기</div>
<div id="previewContent" class="preview-empty">
템플릿을 선택하면<br>여기에 미리보기가 표시됩니다
</div>
<div id="customizePanel" class="hidden">
<div style="margin-top: var(--spacing-6); margin-bottom: var(--spacing-4);">
<strong>섹션 구성</strong>
<p style="font-size: 0.875rem; color: var(--gray-500); margin-top: var(--spacing-1);">
드래그하여 순서를 변경하거나 섹션을 추가/삭제할 수 있습니다
</p>
</div>
<div class="section-list" id="sectionList"></div>
<button type="button" class="btn btn-secondary btn-sm" style="width: 100%; margin-top: var(--spacing-4);" id="addSection">
+ 섹션 추가
</button>
<button type="button" class="btn btn-text btn-sm" style="width: 100%; margin-top: var(--spacing-2);" id="saveTemplate">
✓ 나만의 템플릿으로 저장
</button>
</div>
</div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons">
<button type="button" class="btn btn-text" onclick="navigateTo('03-회의예약.html')">
← 뒤로
</button>
<div style="display: flex; gap: var(--spacing-3);">
<button type="button" class="btn btn-secondary" id="startBlank">
템플릿 없이 시작
</button>
<button type="button" class="btn btn-primary" id="selectTemplate" disabled>
선택 완료 →
</button>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
let selectedTemplate = null;
let customSections = [];
let draggedItem = null;
// 템플릿 아이콘 매핑
const templateIcons = {
general: '📋',
scrum: '🔄',
kickoff: '🚀',
weekly: '📅'
};
// 템플릿 렌더링
function renderTemplates() {
const templates = getAllTemplates();
const grid = document.getElementById('templateGrid');
grid.innerHTML = templates.map(template => `
<div class="template-card" data-id="${template.id}" onclick="selectTemplate('${template.id}')">
<div class="template-header">
<span class="template-icon">${templateIcons[template.id] || '📄'}</span>
<div class="template-title">${template.name}</div>
</div>
<div class="template-description">${template.description}</div>
<div class="template-usage">
<span>👥</span>
<span>자주 사용됨</span>
</div>
<div class="template-sections">
${template.sections.map(section => `
<div class="section-item">
<span></span>
<span>${section.name}</span>
${section.required ? '<span class="section-required">*</span>' : ''}
</div>
`).join('')}
</div>
</div>
`).join('');
}
// 템플릿 선택
function selectTemplate(templateId) {
selectedTemplate = templateId;
const template = getTemplate(templateId);
customSections = JSON.parse(JSON.stringify(template.sections));
// 선택 표시
document.querySelectorAll('.template-card').forEach(card => {
card.classList.remove('selected');
});
document.querySelector(`[data-id="${templateId}"]`).classList.add('selected');
// 미리보기 업데이트
updatePreview();
// 선택 완료 버튼 활성화
document.getElementById('selectTemplate').disabled = false;
}
// 미리보기 업데이트
function updatePreview() {
const previewContent = document.getElementById('previewContent');
const customizePanel = document.getElementById('customizePanel');
if (selectedTemplate) {
previewContent.innerHTML = '';
customizePanel.classList.remove('hidden');
renderSections();
} else {
previewContent.className = 'preview-empty';
previewContent.innerHTML = '템플릿을 선택하면<br>여기에 미리보기가 표시됩니다';
customizePanel.classList.add('hidden');
}
}
// 섹션 렌더링
function renderSections() {
const sectionList = document.getElementById('sectionList');
sectionList.innerHTML = customSections.map((section, index) => `
<div class="section-drag-item" draggable="true" data-index="${index}">
<span class="drag-handle">⋮⋮</span>
<span class="section-name">
${section.name}
${section.required ? '<span class="section-required">*</span>' : ''}
</span>
<div class="section-actions">
<button class="icon-btn" onclick="editSection(${index})" title="수정">✏️</button>
${!section.required ? `<button class="icon-btn" onclick="deleteSection(${index})" title="삭제">🗑️</button>` : ''}
</div>
</div>
`).join('');
// 드래그 이벤트 추가
document.querySelectorAll('.section-drag-item').forEach(item => {
item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragover', handleDragOver);
item.addEventListener('drop', handleDrop);
item.addEventListener('dragend', handleDragEnd);
});
}
// 드래그 앤 드롭 핸들러
function handleDragStart(e) {
draggedItem = parseInt(e.target.dataset.index);
e.target.style.opacity = '0.5';
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
function handleDrop(e) {
e.preventDefault();
const targetIndex = parseInt(e.target.closest('.section-drag-item').dataset.index);
if (draggedItem !== targetIndex) {
const draggedSection = customSections[draggedItem];
customSections.splice(draggedItem, 1);
customSections.splice(targetIndex, 0, draggedSection);
renderSections();
}
}
function handleDragEnd(e) {
e.target.style.opacity = '1';
draggedItem = null;
}
// 섹션 추가
document.getElementById('addSection').addEventListener('click', () => {
const sectionName = prompt('섹션 이름을 입력하세요:');
if (sectionName && sectionName.trim()) {
customSections.push({
id: generateId('section'),
name: sectionName.trim(),
required: false
});
renderSections();
showToast('섹션이 추가되었습니다.', 'success');
}
});
// 섹션 수정
function editSection(index) {
const section = customSections[index];
const newName = prompt('섹션 이름 수정:', section.name);
if (newName && newName.trim()) {
customSections[index].name = newName.trim();
renderSections();
showToast('섹션이 수정되었습니다.', 'success');
}
}
// 섹션 삭제
function deleteSection(index) {
if (confirm('이 섹션을 삭제하시겠습니까?')) {
customSections.splice(index, 1);
renderSections();
showToast('섹션이 삭제되었습니다.', 'info');
}
}
// 나만의 템플릿 저장
document.getElementById('saveTemplate').addEventListener('click', () => {
const templateName = prompt('템플릿 이름을 입력하세요:');
if (templateName && templateName.trim()) {
const customTemplate = {
id: generateId('template'),
name: templateName.trim(),
description: '나만의 템플릿',
sections: customSections,
createdBy: getCurrentUser().id,
createdAt: new Date().toISOString()
};
// 여기서는 시뮬레이션만 (실제로는 서버에 저장)
showToast('템플릿이 저장되었습니다.', 'success');
}
});
// 템플릿 없이 시작
document.getElementById('startBlank').addEventListener('click', () => {
if (confirm('빈 회의록으로 시작하시겠습니까?')) {
sessionStorage.setItem('selectedTemplate', JSON.stringify({
id: 'blank',
name: '빈 회의록',
sections: []
}));
navigateTo('05-회의진행.html');
}
});
// 선택 완료
document.getElementById('selectTemplate').addEventListener('click', () => {
if (!selectedTemplate) {
showToast('템플릿을 선택하세요.', 'warning');
return;
}
const template = getTemplate(selectedTemplate);
const finalTemplate = {
...template,
sections: customSections
};
sessionStorage.setItem('selectedTemplate', JSON.stringify(finalTemplate));
showToast('템플릿이 선택되었습니다.', 'success');
setTimeout(() => {
navigateTo('05-회의진행.html');
}, 500);
});
// 초기화
renderTemplates();
</script>
</body>
</html>

View File

@ -1,858 +0,0 @@
<!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">
<style>
body {
margin: 0;
overflow: hidden;
}
.meeting-editor {
display: grid;
grid-template-columns: 250px 1fr 350px;
height: 100vh;
}
/* 좌측 패널 */
.left-panel {
background: white;
border-right: 1px solid var(--gray-200);
padding: var(--spacing-6);
overflow-y: auto;
}
.meeting-info {
margin-bottom: var(--spacing-6);
padding-bottom: var(--spacing-6);
border-bottom: 1px solid var(--gray-200);
}
.meeting-title-small {
font-weight: 600;
color: var(--gray-900);
margin-bottom: var(--spacing-2);
}
.meeting-time {
font-size: 0.875rem;
color: var(--gray-500);
}
.timer {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-3);
background: var(--gray-50);
border-radius: var(--radius-md);
margin-top: var(--spacing-3);
}
.timer-icon {
font-size: 20px;
}
.timer-text {
font-size: 1.125rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--primary-main);
}
.recording-status {
display: flex;
align-items: center;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
font-size: 0.875rem;
}
.recording-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--error-main);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.participants-list {
margin-bottom: var(--spacing-6);
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--gray-700);
margin-bottom: var(--spacing-3);
}
.participant-item {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) 0;
}
.participant-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--gray-200);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.participant-avatar.online {
border: 2px solid var(--success-main);
}
.participant-name {
flex: 1;
font-size: 0.875rem;
color: var(--gray-700);
}
.participant-status {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--success-main);
}
/* 중앙 에디터 */
.editor-panel {
display: flex;
flex-direction: column;
background: white;
overflow: hidden;
}
.editor-toolbar {
display: flex;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-3) var(--spacing-4);
border-bottom: 1px solid var(--gray-200);
background: var(--gray-50);
flex-wrap: wrap;
}
.toolbar-group {
display: flex;
gap: var(--spacing-1);
padding-right: var(--spacing-3);
border-right: 1px solid var(--gray-300);
}
.toolbar-group:last-child {
border-right: none;
}
.toolbar-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: var(--radius-md);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--gray-600);
font-size: 16px;
transition: all var(--transition-fast);
}
.toolbar-btn:hover {
background: white;
color: var(--primary-main);
}
.toolbar-btn.active {
background: var(--primary-main);
color: white;
}
.editor-content {
flex: 1;
overflow-y: auto;
padding: var(--spacing-8) var(--spacing-6);
}
.section-block {
margin-bottom: var(--spacing-8);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-4);
padding-bottom: var(--spacing-3);
border-bottom: 2px solid var(--gray-200);
}
.section-header h2 {
font-size: 1.5rem;
color: var(--gray-900);
margin: 0;
}
.section-verified {
display: flex;
align-items: center;
gap: var(--spacing-2);
font-size: 0.875rem;
color: var(--gray-600);
}
.section-verified input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.section-verified input[type="checkbox"]:checked {
accent-color: var(--success-main);
}
.section-body {
min-height: 150px;
padding: var(--spacing-4);
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
font-size: 1rem;
line-height: 1.75;
outline: none;
transition: all var(--transition-fast);
}
.section-body:focus {
border-color: var(--primary-main);
box-shadow: 0 0 0 3px rgba(0, 217, 177, 0.1);
}
.section-body.verified {
background-color: rgba(16, 185, 129, 0.05);
border-color: var(--success-main);
}
.ai-suggestion {
background: var(--gray-50);
border: 1px dashed var(--primary-main);
border-radius: var(--radius-md);
padding: var(--spacing-4);
margin-top: var(--spacing-4);
}
.ai-suggestion-header {
display: flex;
align-items: center;
gap: var(--spacing-2);
margin-bottom: var(--spacing-3);
font-weight: 600;
color: var(--primary-main);
}
.ai-suggestion-content {
color: var(--gray-700);
margin-bottom: var(--spacing-3);
}
.ai-suggestion-actions {
display: flex;
gap: var(--spacing-2);
}
.term-highlight {
color: var(--primary-main);
border-bottom: 2px dotted var(--primary-main);
cursor: pointer;
position: relative;
}
.term-highlight:hover {
background-color: rgba(0, 217, 177, 0.1);
}
/* 우측 패널 */
.right-panel {
background: white;
border-left: 1px solid var(--gray-200);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-tabs {
display: flex;
border-bottom: 1px solid var(--gray-200);
}
.panel-tab {
flex: 1;
padding: var(--spacing-3);
border: none;
background: transparent;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: var(--gray-600);
transition: all var(--transition-fast);
border-bottom: 2px solid transparent;
}
.panel-tab:hover {
background: var(--gray-50);
}
.panel-tab.active {
color: var(--primary-main);
border-bottom-color: var(--primary-main);
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: var(--spacing-4);
}
.ai-suggestion-card {
background: var(--gray-50);
border-radius: var(--radius-md);
padding: var(--spacing-4);
margin-bottom: var(--spacing-3);
}
.ai-suggestion-title {
font-weight: 600;
color: var(--gray-900);
margin-bottom: var(--spacing-2);
}
.ai-suggestion-text {
color: var(--gray-700);
font-size: 0.875rem;
line-height: 1.6;
margin-bottom: var(--spacing-3);
}
.ai-actions {
display: flex;
gap: var(--spacing-2);
}
.term-card {
background: var(--gray-50);
border-radius: var(--radius-md);
padding: var(--spacing-4);
margin-bottom: var(--spacing-3);
cursor: pointer;
transition: all var(--transition-fast);
}
.term-card:hover {
background: var(--gray-100);
}
.term-name {
font-weight: 600;
color: var(--primary-main);
margin-bottom: var(--spacing-2);
}
.term-definition {
color: var(--gray-700);
font-size: 0.875rem;
line-height: 1.6;
}
.term-source {
color: var(--gray-500);
font-size: 0.75rem;
margin-top: var(--spacing-2);
}
/* 하단 바 */
.bottom-bar {
position: fixed;
bottom: 0;
left: 250px;
right: 350px;
background: white;
border-top: 1px solid var(--gray-200);
padding: var(--spacing-4) var(--spacing-6);
display: flex;
align-items: center;
justify-content: space-between;
z-index: 100;
}
.status-indicator {
display: flex;
align-items: center;
gap: var(--spacing-2);
color: var(--gray-600);
font-size: 0.875rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--success-main);
}
.voice-status {
display: flex;
align-items: center;
gap: var(--spacing-2);
color: var(--gray-600);
}
.voice-wave {
display: flex;
gap: 2px;
align-items: center;
}
.voice-bar {
width: 3px;
height: 16px;
background: var(--primary-main);
border-radius: 2px;
animation: voiceWave 1s infinite;
}
.voice-bar:nth-child(2) { animation-delay: 0.1s; }
.voice-bar:nth-child(3) { animation-delay: 0.2s; }
.voice-bar:nth-child(4) { animation-delay: 0.3s; }
@keyframes voiceWave {
0%, 100% { height: 8px; }
50% { height: 16px; }
}
.bottom-actions {
display: flex;
gap: var(--spacing-3);
}
@media (max-width: 1023px) {
.meeting-editor {
grid-template-columns: 1fr;
}
.left-panel,
.right-panel {
display: none;
}
.bottom-bar {
left: 0;
right: 0;
}
}
</style>
</head>
<body>
<div class="meeting-editor">
<!-- 좌측 패널: 회의 정보 & 참석자 -->
<div class="left-panel">
<div class="meeting-info">
<div class="meeting-title-small" id="meetingTitle">2025년 1분기 전략 회의</div>
<div class="meeting-time" id="meetingTime">2025년 10월 20일 14:00</div>
<div class="timer">
<span class="timer-icon">⏱️</span>
<span class="timer-text" id="timer">00:00:00</span>
</div>
<div class="recording-status">
<span class="recording-dot"></span>
<span>녹음 중</span>
</div>
</div>
<div class="participants-list">
<div class="section-title">참석자 (3명)</div>
<div id="participantsList"></div>
</div>
<div>
<div class="section-title">타임라인</div>
<div id="timeline" style="font-size: 0.75rem; color: var(--gray-500);"></div>
</div>
</div>
<!-- 중앙 패널: 에디터 -->
<div class="editor-panel">
<!-- 툴바 -->
<div class="editor-toolbar">
<div class="toolbar-group">
<button class="toolbar-btn" title="굵게" onclick="execCommand('bold')"><strong>B</strong></button>
<button class="toolbar-btn" title="기울임" onclick="execCommand('italic')"><em>I</em></button>
<button class="toolbar-btn" title="밑줄" onclick="execCommand('underline')"><u>U</u></button>
<button class="toolbar-btn" title="취소선" onclick="execCommand('strikeThrough')"><s>S</s></button>
</div>
<div class="toolbar-group">
<button class="toolbar-btn" title="번호 목록" onclick="execCommand('insertOrderedList')">1.</button>
<button class="toolbar-btn" title="글머리 기호" onclick="execCommand('insertUnorderedList')"></button>
</div>
<div class="toolbar-group">
<button class="toolbar-btn" title="링크" onclick="insertLink()">🔗</button>
<button class="toolbar-btn" title="실행 취소" onclick="document.execCommand('undo')"></button>
<button class="toolbar-btn" title="다시 실행" onclick="document.execCommand('redo')"></button>
</div>
</div>
<!-- 에디터 콘텐츠 -->
<div class="editor-content" id="editorContent"></div>
<!-- 하단 바 -->
<div class="bottom-bar">
<div class="status-indicator">
<span class="status-dot"></span>
<span id="saveStatus">저장됨</span>
</div>
<div class="voice-status">
<div class="voice-wave">
<div class="voice-bar"></div>
<div class="voice-bar"></div>
<div class="voice-bar"></div>
<div class="voice-bar"></div>
</div>
<span>음성 인식 중...</span>
</div>
<div class="bottom-actions">
<button class="btn btn-secondary btn-sm" id="pauseRecording">
⏸️ 일시정지
</button>
<button class="btn btn-primary" id="endMeeting">
회의 종료
</button>
</div>
</div>
</div>
<!-- 우측 패널: AI 제안 & 용어 설명 -->
<div class="right-panel">
<div class="panel-tabs">
<button class="panel-tab active" data-tab="ai" onclick="switchTab('ai')">AI 제안</button>
<button class="panel-tab" data-tab="terms" onclick="switchTab('terms')">용어 설명</button>
<button class="panel-tab" data-tab="comments" onclick="switchTab('comments')">댓글</button>
</div>
<div class="panel-content">
<!-- AI 제안 탭 -->
<div id="aiTab" class="tab-pane">
<p style="color: var(--gray-500); font-size: 0.875rem; margin-bottom: var(--spacing-4);">
AI가 자동으로 회의록을 작성하고 제안합니다
</p>
<div id="aiSuggestions"></div>
</div>
<!-- 용어 설명 탭 -->
<div id="termsTab" class="tab-pane hidden">
<p style="color: var(--gray-500); font-size: 0.875rem; margin-bottom: var(--spacing-4);">
감지된 전문용어와 설명입니다
</p>
<div id="termsList"></div>
</div>
<!-- 댓글 탭 -->
<div id="commentsTab" class="tab-pane hidden">
<p style="color: var(--gray-500); font-size: 0.875rem; margin-bottom: var(--spacing-4);">
참석자의 댓글과 제안사항입니다
</p>
<div style="text-align: center; padding: var(--spacing-8); color: var(--gray-400);">
아직 댓글이 없습니다
</div>
</div>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
let elapsedSeconds = 0;
let timerInterval;
let saveTimeout;
let currentSuggestionIndex = 0;
// 회의 정보 로드
function loadMeetingInfo() {
const meetingData = JSON.parse(sessionStorage.getItem('newMeeting') || '{}');
const template = JSON.parse(sessionStorage.getItem('selectedTemplate') || '{}');
if (meetingData.title) {
document.getElementById('meetingTitle').textContent = meetingData.title;
}
if (meetingData.date && meetingData.time) {
const dateObj = new Date(meetingData.date);
const dateStr = `${dateObj.getFullYear()}년 ${dateObj.getMonth() + 1}월 ${dateObj.getDate()}일`;
document.getElementById('meetingTime').textContent = `${dateStr} ${meetingData.time}`;
}
// 참석자 렌더링
if (meetingData.participants) {
const users = getFromStorage(STORAGE_KEYS.USERS) || [];
const participantsList = document.getElementById('participantsList');
participantsList.innerHTML = meetingData.participants.map(email => {
const user = users.find(u => u.email === email) || { name: email, avatar: '👤' };
return `
<div class="participant-item">
<div class="participant-avatar online">${user.avatar}</div>
<div class="participant-name">${user.name}</div>
<div class="participant-status"></div>
</div>
`;
}).join('');
}
// 템플릿 섹션 렌더링
if (template.sections) {
const editorContent = document.getElementById('editorContent');
editorContent.innerHTML = template.sections.map(section => `
<div class="section-block" data-section="${section.id}">
<div class="section-header">
<h2>${section.name}${section.required ? '<span style="color: var(--error-main);">*</span>' : ''}</h2>
<div class="section-verified">
<input type="checkbox" id="verify-${section.id}">
<label for="verify-${section.id}">검증 완료</label>
</div>
</div>
<div class="section-body" contenteditable="true" data-section-id="${section.id}">
</div>
</div>
`).join('');
// 섹션 검증 이벤트
document.querySelectorAll('[id^="verify-"]').forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
const sectionId = e.target.id.replace('verify-', '');
const sectionBody = document.querySelector(`[data-section-id="${sectionId}"]`);
if (e.target.checked) {
sectionBody.classList.add('verified');
showToast('섹션이 검증되었습니다.', 'success');
} else {
sectionBody.classList.remove('verified');
}
});
});
// 자동 저장
document.querySelectorAll('.section-body').forEach(section => {
section.addEventListener('input', () => {
clearTimeout(saveTimeout);
document.getElementById('saveStatus').textContent = '저장 중...';
saveTimeout = setTimeout(() => {
document.getElementById('saveStatus').textContent = '저장됨';
// 실제로는 서버에 저장
}, 500);
});
});
}
}
// 타이머 시작
function startTimer() {
timerInterval = setInterval(() => {
elapsedSeconds++;
const hours = Math.floor(elapsedSeconds / 3600);
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
const seconds = elapsedSeconds % 60;
document.getElementById('timer').textContent =
`${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
// 5분마다 타임라인 추가
if (elapsedSeconds % 300 === 0) {
addTimelineEvent(`${hours}시간 ${minutes}분 경과`);
}
}, 1000);
}
// 타임라인 이벤트 추가
function addTimelineEvent(text) {
const timeline = document.getElementById('timeline');
const time = new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
const event = document.createElement('div');
event.style.marginBottom = 'var(--spacing-2)';
event.innerHTML = `<strong>${time}</strong> ${text}`;
timeline.appendChild(event);
}
// AI 제안 시뮬레이션
function generateAISuggestions() {
const suggestions = [
{
title: '논의 내용 요약',
text: '1분기 매출 목표를 20% 상향 조정하고, 마케팅 예산을 15% 증액하기로 논의했습니다.'
},
{
title: '결정 사항',
text: '신규 프로젝트 팀을 구성하고, 김민준님이 PM을 맡기로 결정했습니다.'
},
{
title: 'Todo 추출',
text: '• 마케팅 예산안 작성 (박서연, 10/25)\n• 경쟁사 분석 보고서 (이준호, 10/22)'
}
];
const container = document.getElementById('aiSuggestions');
function showNextSuggestion() {
if (currentSuggestionIndex < suggestions.length) {
const suggestion = suggestions[currentSuggestionIndex];
const card = document.createElement('div');
card.className = 'ai-suggestion-card';
card.innerHTML = `
<div class="ai-suggestion-title">✨ ${suggestion.title}</div>
<div class="ai-suggestion-text">${suggestion.text}</div>
<div class="ai-actions">
<button class="btn btn-primary btn-sm" onclick="applySuggestion(this, '${suggestion.title}')">적용</button>
<button class="btn btn-text btn-sm" onclick="dismissSuggestion(this)">무시</button>
</div>
`;
container.appendChild(card);
currentSuggestionIndex++;
// 5초 후 다음 제안
setTimeout(showNextSuggestion, 5000);
}
}
// 3초 후 첫 제안 시작
setTimeout(showNextSuggestion, 3000);
}
// 제안 적용
function applySuggestion(button, title) {
const card = button.closest('.ai-suggestion-card');
const text = card.querySelector('.ai-suggestion-text').textContent;
// 첫 번째 섹션에 텍스트 추가 (시뮬레이션)
const sections = document.querySelectorAll('.section-body');
if (sections.length > 0) {
const firstSection = sections[0];
if (!firstSection.textContent.trim()) {
firstSection.textContent = text;
} else {
firstSection.innerHTML += `<br><br>${text}`;
}
}
card.remove();
showToast('AI 제안이 적용되었습니다.', 'success');
}
// 제안 무시
function dismissSuggestion(button) {
button.closest('.ai-suggestion-card').remove();
}
// 용어 설명 렌더링
function renderTerms() {
const terms = [
{ name: 'ROI', definition: '투자 대비 수익률 (Return On Investment)', source: '과거 회의록: 2024년 4분기 전략 회의' },
{ name: 'KPI', definition: '핵심 성과 지표 (Key Performance Indicator)', source: '사내 용어집' },
{ name: 'MQL', definition: '마케팅 적격 리드 (Marketing Qualified Lead)', source: '마케팅 팀 문서' }
];
const termsList = document.getElementById('termsList');
termsList.innerHTML = terms.map(term => `
<div class="term-card">
<div class="term-name">${term.name}</div>
<div class="term-definition">${term.definition}</div>
<div class="term-source">출처: ${term.source}</div>
</div>
`).join('');
}
// 탭 전환
function switchTab(tabName) {
document.querySelectorAll('.panel-tab').forEach(tab => {
tab.classList.remove('active');
});
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
document.querySelectorAll('.tab-pane').forEach(pane => {
pane.classList.add('hidden');
});
document.getElementById(`${tabName}Tab`).classList.remove('hidden');
}
// 툴바 명령
function execCommand(command) {
document.execCommand(command, false, null);
}
function insertLink() {
const url = prompt('링크 URL을 입력하세요:');
if (url) {
document.execCommand('createLink', false, url);
}
}
// 녹음 일시정지
document.getElementById('pauseRecording').addEventListener('click', function() {
if (this.textContent.includes('일시정지')) {
clearInterval(timerInterval);
this.innerHTML = '▶️ 계속';
document.querySelector('.recording-dot').style.display = 'none';
document.querySelector('.voice-status').style.display = 'none';
showToast('녹음이 일시정지되었습니다.', 'info');
} else {
startTimer();
this.innerHTML = '⏸️ 일시정지';
document.querySelector('.recording-dot').style.display = 'block';
document.querySelector('.voice-status').style.display = 'flex';
showToast('녹음이 재개되었습니다.', 'success');
}
});
// 회의 종료
document.getElementById('endMeeting').addEventListener('click', () => {
if (confirm('회의를 종료하시겠습니까?')) {
clearInterval(timerInterval);
// 회의록 데이터 저장
const sections = {};
document.querySelectorAll('.section-body').forEach(section => {
const id = section.dataset.sectionId;
sections[id] = section.innerHTML;
});
sessionStorage.setItem('meetingContent', JSON.stringify(sections));
sessionStorage.setItem('meetingDuration', elapsedSeconds);
navigateTo('06-검증완료.html');
}
});
// 초기화
loadMeetingInfo();
startTimer();
generateAISuggestions();
renderTerms();
addTimelineEvent('회의 시작');
</script>
</body>
</html>

View File

@ -1,499 +0,0 @@
<!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">
<style>
.verification-container {
max-width: 1200px;
margin: 0 auto;
padding: var(--spacing-8);
}
.verification-header {
text-align: center;
margin-bottom: var(--spacing-8);
}
.progress-circle-large {
width: 120px;
height: 120px;
margin: 0 auto var(--spacing-4);
position: relative;
}
.progress-ring {
transform: rotate(-90deg);
}
.progress-ring-circle {
transition: stroke-dashoffset 0.5s;
stroke: var(--primary-main);
stroke-width: 8;
fill: transparent;
}
.progress-ring-bg {
stroke: var(--gray-200);
stroke-width: 8;
fill: transparent;
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-main);
}
.verification-layout {
display: grid;
grid-template-columns: 1fr 400px;
gap: var(--spacing-8);
}
.section-list-panel {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-6);
box-shadow: var(--shadow-sm);
}
.verification-item {
padding: var(--spacing-4);
margin-bottom: var(--spacing-3);
border-radius: var(--radius-md);
border: 1px solid var(--gray-200);
transition: all var(--transition-fast);
}
.verification-item:hover {
box-shadow: var(--shadow-sm);
}
.verification-item.verified {
background-color: rgba(16, 185, 129, 0.05);
border-color: var(--success-main);
}
.verification-item.pending {
background-color: rgba(245, 158, 11, 0.05);
border-color: var(--warning-main);
}
.verification-header-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-2);
}
.section-name-verify {
font-weight: 600;
color: var(--gray-900);
}
.verification-status {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 500;
}
.verification-status.verified {
background-color: var(--success-light);
color: var(--success-dark);
}
.verification-status.pending {
background-color: var(--warning-light);
color: var(--warning-dark);
}
.verifier-info {
display: flex;
align-items: center;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
font-size: 0.875rem;
color: var(--gray-600);
}
.verifier-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--gray-200);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.verify-btn {
margin-top: var(--spacing-3);
}
.lock-toggle {
display: flex;
align-items: center;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
font-size: 0.875rem;
color: var(--gray-600);
}
.stats-panel {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-6);
box-shadow: var(--shadow-sm);
position: sticky;
top: var(--spacing-8);
}
.stats-item {
padding: var(--spacing-4);
background: var(--gray-50);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-3);
}
.stats-label {
font-size: 0.875rem;
color: var(--gray-500);
margin-bottom: var(--spacing-1);
}
.stats-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--gray-900);
}
.participant-progress {
margin-top: var(--spacing-6);
}
.participant-progress-item {
display: flex;
align-items: center;
gap: var(--spacing-3);
margin-bottom: var(--spacing-3);
}
.participant-avatar-small {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--gray-200);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.progress-info {
flex: 1;
}
.progress-name {
font-weight: 500;
font-size: 0.875rem;
color: var(--gray-900);
margin-bottom: var(--spacing-1);
}
.progress-bar-small {
height: 6px;
background: var(--gray-200);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--primary-main);
transition: width 0.3s;
}
.progress-percentage {
font-size: 0.875rem;
font-weight: 600;
color: var(--primary-main);
}
.action-buttons {
display: flex;
justify-content: space-between;
margin-top: var(--spacing-8);
padding-top: var(--spacing-6);
border-top: 1px solid var(--gray-200);
}
@media (max-width: 1023px) {
.verification-layout {
grid-template-columns: 1fr;
}
.stats-panel {
position: static;
}
}
</style>
</head>
<body>
<div class="verification-container">
<div class="verification-header">
<div class="progress-circle-large">
<svg class="progress-ring" width="120" height="120">
<circle class="progress-ring-bg" cx="60" cy="60" r="52"></circle>
<circle class="progress-ring-circle" cx="60" cy="60" r="52"
stroke-dasharray="326.73" stroke-dashoffset="0" id="progressCircle"></circle>
</svg>
<div class="progress-text" id="progressText">0%</div>
</div>
<h1>섹션 검증</h1>
<p style="color: var(--gray-500);">
각 섹션을 확인하고 검증을 완료하세요
</p>
</div>
<div class="verification-layout">
<!-- 섹션 리스트 -->
<div class="section-list-panel">
<h3 style="margin-bottom: var(--spacing-4);">검증 항목</h3>
<div id="verificationList"></div>
</div>
<!-- 통계 패널 -->
<div class="stats-panel">
<h3 style="margin-bottom: var(--spacing-4);">검증 현황</h3>
<div class="stats-item">
<div class="stats-label">전체 진행률</div>
<div class="stats-value" id="totalProgress">0%</div>
</div>
<div class="stats-item">
<div class="stats-label">검증 완료</div>
<div class="stats-value">
<span id="verifiedCount">0</span> / <span id="totalCount">0</span> 섹션
</div>
</div>
<div class="stats-item">
<div class="stats-label">잠금 섹션</div>
<div class="stats-value" id="lockedCount">0</div>
</div>
<div class="participant-progress">
<div class="stats-label" style="margin-bottom: var(--spacing-3);">참석자별 진행률</div>
<div id="participantProgress"></div>
</div>
</div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons">
<button type="button" class="btn btn-text" onclick="navigateTo('05-회의진행.html')">
← 회의록으로 돌아가기
</button>
<button type="button" class="btn btn-primary" id="completeVerification" disabled>
검증 완료 및 회의 종료 →
</button>
</div>
</div>
<script src="common.js"></script>
<script>
let sections = [];
let verifiedSections = new Set();
// 섹션 데이터 로드
function loadSections() {
const template = JSON.parse(sessionStorage.getItem('selectedTemplate') || '{}');
if (template.sections) {
sections = template.sections.map(section => ({
...section,
verified: false,
locked: false,
verifier: null,
verifiedAt: null
}));
renderVerificationList();
renderParticipantProgress();
updateStats();
}
}
// 검증 리스트 렌더링
function renderVerificationList() {
const list = document.getElementById('verificationList');
const currentUser = getCurrentUser();
list.innerHTML = sections.map((section, index) => `
<div class="verification-item ${section.verified ? 'verified' : 'pending'}" id="section-${index}">
<div class="verification-header-item">
<div class="section-name-verify">
${section.name}
${section.required ? '<span style="color: var(--error-main);">*</span>' : ''}
</div>
<div class="verification-status ${section.verified ? 'verified' : 'pending'}">
${section.verified ? '✓ 검증 완료' : '⏳ 검증 대기'}
</div>
</div>
${section.verified && section.verifier ? `
<div class="verifier-info">
<div class="verifier-avatar">${getUserById(section.verifier)?.avatar || '👤'}</div>
<span>검증자: ${getUserName(section.verifier)}</span>
<span style="color: var(--gray-400);"></span>
<span>${new Date(section.verifiedAt).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
` : ''}
${!section.verified ? `
<button class="btn btn-primary btn-sm verify-btn" onclick="verifySectionItem(${index})">
검증 완료 표시
</button>
` : `
<div class="lock-toggle">
<input type="checkbox" id="lock-${index}" ${section.locked ? 'checked' : ''} onchange="toggleLock(${index})">
<label for="lock-${index}">섹션 잠금 (편집 불가)</label>
</div>
`}
</div>
`).join('');
}
// 섹션 검증
function verifySectionItem(index) {
const currentUser = getCurrentUser();
sections[index].verified = true;
sections[index].verifier = currentUser.id;
sections[index].verifiedAt = new Date().toISOString();
verifiedSections.add(index);
renderVerificationList();
updateStats();
showToast(`"${sections[index].name}" 섹션이 검증되었습니다.`, 'success');
// 모두 검증되면 버튼 활성화
checkAllVerified();
}
// 섹션 잠금 토글
function toggleLock(index) {
const checkbox = document.getElementById(`lock-${index}`);
sections[index].locked = checkbox.checked;
updateStats();
if (checkbox.checked) {
showToast('섹션이 잠겼습니다. 더 이상 편집할 수 없습니다.', 'info');
} else {
showToast('섹션 잠금이 해제되었습니다.', 'info');
}
}
// 통계 업데이트
function updateStats() {
const verifiedCount = sections.filter(s => s.verified).length;
const totalCount = sections.length;
const lockedCount = sections.filter(s => s.locked).length;
const progress = totalCount > 0 ? Math.round((verifiedCount / totalCount) * 100) : 0;
document.getElementById('verifiedCount').textContent = verifiedCount;
document.getElementById('totalCount').textContent = totalCount;
document.getElementById('lockedCount').textContent = lockedCount;
document.getElementById('totalProgress').textContent = `${progress}%`;
document.getElementById('progressText').textContent = `${progress}%`;
// 진행률 원 업데이트
const circle = document.getElementById('progressCircle');
const circumference = 2 * Math.PI * 52;
const offset = circumference - (progress / 100) * circumference;
circle.style.strokeDashoffset = offset;
}
// 참석자별 진행률
function renderParticipantProgress() {
const users = getFromStorage(STORAGE_KEYS.USERS) || [];
const meetingData = JSON.parse(sessionStorage.getItem('newMeeting') || '{}');
if (meetingData.participants) {
const progressContainer = document.getElementById('participantProgress');
progressContainer.innerHTML = meetingData.participants.map(email => {
const user = users.find(u => u.email === email) || { name: email, avatar: '👤', id: 'unknown' };
const userVerifications = sections.filter(s => s.verifier === user.id).length;
const userProgress = sections.length > 0 ? Math.round((userVerifications / sections.length) * 100) : 0;
return `
<div class="participant-progress-item">
<div class="participant-avatar-small">${user.avatar}</div>
<div class="progress-info">
<div class="progress-name">${user.name}</div>
<div class="progress-bar-small">
<div class="progress-fill" style="width: ${userProgress}%"></div>
</div>
</div>
<div class="progress-percentage">${userProgress}%</div>
</div>
`;
}).join('');
}
}
// 모든 섹션 검증 확인
function checkAllVerified() {
const requiredSections = sections.filter(s => s.required);
const allRequiredVerified = requiredSections.every(s => s.verified);
const completeBtn = document.getElementById('completeVerification');
completeBtn.disabled = !allRequiredVerified;
if (allRequiredVerified) {
showToast('모든 필수 섹션이 검증되었습니다!', 'success');
}
}
// 검증 완료 및 회의 종료
document.getElementById('completeVerification').addEventListener('click', () => {
const allVerified = sections.every(s => !s.required || s.verified);
if (!allVerified) {
showToast('모든 필수 섹션을 검증해주세요.', 'warning');
return;
}
if (confirm('검증을 완료하고 회의를 종료하시겠습니까?')) {
// 검증 데이터 저장
sessionStorage.setItem('verificationData', JSON.stringify(sections));
showToast('검증이 완료되었습니다.', 'success');
setTimeout(() => {
navigateTo('07-회의종료.html');
}, 1000);
}
});
// 초기화
loadSections();
</script>
</body>
</html>

View File

@ -1,573 +0,0 @@
<!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">
<style>
.completion-container {
max-width: 1400px;
margin: 0 auto;
padding: var(--spacing-8);
}
.completion-header {
text-align: center;
margin-bottom: var(--spacing-8);
}
.success-icon {
font-size: 80px;
margin-bottom: var(--spacing-4);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-6);
margin-bottom: var(--spacing-8);
}
.stat-card {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-6);
box-shadow: var(--shadow-sm);
text-align: center;
}
.stat-icon {
font-size: 32px;
margin-bottom: var(--spacing-3);
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--gray-900);
margin-bottom: var(--spacing-2);
}
.stat-label {
font-size: 0.875rem;
color: var(--gray-500);
}
.content-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-6);
margin-bottom: var(--spacing-8);
}
.content-panel {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-6);
box-shadow: var(--shadow-sm);
}
.panel-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--gray-900);
margin-bottom: var(--spacing-4);
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.checklist-item {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-3);
margin-bottom: var(--spacing-2);
border-radius: var(--radius-md);
background: var(--gray-50);
}
.checklist-icon {
font-size: 20px;
}
.checklist-text {
flex: 1;
font-size: 0.875rem;
}
.checklist-action {
font-size: 0.875rem;
color: var(--primary-main);
cursor: pointer;
text-decoration: underline;
}
.todo-item {
padding: var(--spacing-4);
background: var(--gray-50);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-3);
border-left: 4px solid var(--primary-main);
}
.todo-content {
font-weight: 500;
color: var(--gray-900);
margin-bottom: var(--spacing-2);
}
.todo-meta {
display: flex;
align-items: center;
gap: var(--spacing-4);
font-size: 0.875rem;
color: var(--gray-600);
}
.todo-meta-item {
display: flex;
align-items: center;
gap: var(--spacing-1);
}
.speaker-stats {
margin-top: var(--spacing-4);
}
.speaker-item {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-3);
margin-bottom: var(--spacing-2);
}
.speaker-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--gray-200);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.speaker-info {
flex: 1;
}
.speaker-name {
font-weight: 500;
color: var(--gray-900);
margin-bottom: var(--spacing-1);
}
.speaker-bar {
height: 6px;
background: var(--gray-200);
border-radius: 3px;
overflow: hidden;
}
.speaker-fill {
height: 100%;
background: var(--primary-main);
transition: width 0.3s;
}
.speaker-count {
font-size: 0.875rem;
font-weight: 600;
color: var(--gray-600);
}
.keyword-cloud {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-4);
}
.keyword-tag {
padding: var(--spacing-2) var(--spacing-4);
background: var(--primary-light);
color: var(--primary-dark);
border-radius: var(--radius-full);
font-size: 0.875rem;
font-weight: 500;
}
.next-meeting {
background: linear-gradient(135deg, var(--primary-main), var(--secondary-main));
color: white;
padding: var(--spacing-6);
border-radius: var(--radius-lg);
margin-top: var(--spacing-4);
}
.next-meeting-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: var(--spacing-2);
}
.next-meeting-date {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: var(--spacing-3);
}
.action-buttons {
display: flex;
justify-content: space-between;
margin-top: var(--spacing-8);
padding-top: var(--spacing-6);
border-top: 1px solid var(--gray-200);
}
@media (max-width: 1023px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.content-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="completion-container">
<div class="completion-header">
<div class="success-icon">🎉</div>
<h1>회의가 종료되었습니다</h1>
<p style="color: var(--gray-500); font-size: 1.125rem;">
회의 통계와 Todo를 확인하고 최종 확정하세요
</p>
</div>
<!-- 통계 카드 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">⏱️</div>
<div class="stat-value" id="meetingDuration">-</div>
<div class="stat-label">회의 총 시간</div>
</div>
<div class="stat-card">
<div class="stat-icon">👥</div>
<div class="stat-value" id="participantCount">-</div>
<div class="stat-label">참석자 수</div>
</div>
<div class="stat-card">
<div class="stat-icon">💬</div>
<div class="stat-value" id="speechCount">-</div>
<div class="stat-label">총 발언 횟수</div>
</div>
<div class="stat-card">
<div class="stat-icon"></div>
<div class="stat-value" id="todoCount">-</div>
<div class="stat-label">생성된 Todo</div>
</div>
</div>
<!-- 콘텐츠 그리드 -->
<div class="content-grid">
<!-- 필수 항목 검사 -->
<div class="content-panel">
<div class="panel-title">
<span>📋</span>
<span>필수 항목 검사</span>
</div>
<div id="checklistItems"></div>
</div>
<!-- AI Todo 추출 -->
<div class="content-panel">
<div class="panel-title">
<span></span>
<span>AI Todo 추출 결과</span>
</div>
<div id="todoList"></div>
<button class="btn btn-secondary btn-sm" style="width: 100%; margin-top: var(--spacing-3);" onclick="openModal('editTodoModal')">
Todo 수정
</button>
</div>
<!-- 발언 통계 -->
<div class="content-panel">
<div class="panel-title">
<span>📊</span>
<span>화자별 발언 통계</span>
</div>
<div class="speaker-stats" id="speakerStats"></div>
</div>
<!-- 주요 키워드 -->
<div class="content-panel">
<div class="panel-title">
<span>🔑</span>
<span>주요 키워드</span>
</div>
<div class="keyword-cloud" id="keywordCloud"></div>
<!-- 다음 회의 일정 감지 -->
<div class="next-meeting">
<div class="next-meeting-title">🗓️ AI가 감지한 다음 회의</div>
<div class="next-meeting-date">2025년 10월 27일 14:00</div>
<button class="btn" style="background: white; color: var(--primary-main);" onclick="addToCalendar()">
📅 캘린더에 추가
</button>
</div>
</div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons">
<button type="button" class="btn btn-text" onclick="navigateTo('05-회의진행.html')">
← 회의록으로 돌아가기
</button>
<div style="display: flex; gap: var(--spacing-3);">
<button type="button" class="btn btn-secondary" id="saveDraft">
나중에 확정
</button>
<button type="button" class="btn btn-primary" id="finalizeMinutes">
최종 확정 →
</button>
</div>
</div>
</div>
<!-- Todo 수정 모달 -->
<div class="modal-backdrop" id="editTodoModal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Todo 수정</h3>
</div>
<div class="modal-body">
<p style="color: var(--gray-600); margin-bottom: var(--spacing-4);">
AI가 추출한 Todo를 수정하거나 새로운 Todo를 추가할 수 있습니다.
</p>
<div id="editableTodoList"></div>
</div>
<div class="modal-footer">
<button class="btn btn-text" onclick="closeModal('editTodoModal')">취소</button>
<button class="btn btn-primary" onclick="saveTodos()">저장</button>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
let extractedTodos = [];
// 회의 통계 로드
function loadMeetingStats() {
const duration = parseInt(sessionStorage.getItem('meetingDuration') || '0');
const meetingData = JSON.parse(sessionStorage.getItem('newMeeting') || '{}');
const template = JSON.parse(sessionStorage.getItem('selectedTemplate') || '{}');
// 회의 시간
document.getElementById('meetingDuration').textContent = formatDuration(duration / 60);
// 참석자 수
const participantCount = meetingData.participants?.length || 0;
document.getElementById('participantCount').textContent = `${participantCount}명`;
// 발언 횟수 (시뮬레이션)
const totalSpeech = 45 + Math.floor(Math.random() * 20);
document.getElementById('speechCount').textContent = `${totalSpeech}회`;
// Todo 개수
extractedTodos = generateTodos();
document.getElementById('todoCount').textContent = `${extractedTodos.length}개`;
}
// 필수 항목 체크리스트
function renderChecklist() {
const template = JSON.parse(sessionStorage.getItem('selectedTemplate') || '{}');
const content = JSON.parse(sessionStorage.getItem('meetingContent') || '{}');
const requiredSections = template.sections?.filter(s => s.required) || [];
const checklist = requiredSections.map(section => {
const hasContent = content[section.id] && content[section.id].trim().length > 0;
return {
name: section.name,
completed: hasContent,
sectionId: section.id
};
});
const container = document.getElementById('checklistItems');
container.innerHTML = checklist.map(item => `
<div class="checklist-item">
<div class="checklist-icon">${item.completed ? '✅' : '❌'}</div>
<div class="checklist-text">${item.name}</div>
${!item.completed ? `<span class="checklist-action" onclick="goToSection('${item.sectionId}')">작성하기</span>` : ''}
</div>
`).join('');
// 모두 완료되었는지 확인
const allCompleted = checklist.every(item => item.completed);
document.getElementById('finalizeMinutes').disabled = !allCompleted;
return allCompleted;
}
// Todo 생성 (AI 시뮬레이션)
function generateTodos() {
return [
{
id: generateId('todo'),
content: '1분기 마케팅 예산안 작성',
assignee: 'user2',
dueDate: '2025-10-25',
priority: 'high',
source: '결정 사항 섹션'
},
{
id: generateId('todo'),
content: '경쟁사 분석 보고서 작성',
assignee: 'user3',
dueDate: '2025-10-22',
priority: 'high',
source: '논의 내용 섹션'
},
{
id: generateId('todo'),
content: '신규 프로젝트 팀 구성안 제출',
assignee: 'user1',
dueDate: '2025-10-23',
priority: 'normal',
source: '결정 사항 섹션'
}
];
}
// Todo 렌더링
function renderTodos() {
const container = document.getElementById('todoList');
container.innerHTML = extractedTodos.map(todo => `
<div class="todo-item">
<div class="todo-content">${todo.content}</div>
<div class="todo-meta">
<div class="todo-meta-item">
<span>👤</span>
<span>${getUserName(todo.assignee)}</span>
</div>
<div class="todo-meta-item">
<span>📅</span>
<span>${getDdayText(todo.dueDate)}</span>
</div>
<div class="todo-meta-item">
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${todo.priority === 'high' ? 'var(--error-main)' : 'var(--gray-400)'};"></span>
<span>${todo.priority === 'high' ? '높음' : '보통'}</span>
</div>
</div>
</div>
`).join('');
}
// 화자별 통계
function renderSpeakerStats() {
const meetingData = JSON.parse(sessionStorage.getItem('newMeeting') || '{}');
const speakers = (meetingData.participants || []).map(email => {
const user = getUserById(email) || { name: email, avatar: '👤' };
const count = 10 + Math.floor(Math.random() * 20);
return { ...user, count };
});
const maxCount = Math.max(...speakers.map(s => s.count));
const container = document.getElementById('speakerStats');
container.innerHTML = speakers.map(speaker => `
<div class="speaker-item">
<div class="speaker-avatar">${speaker.avatar}</div>
<div class="speaker-info">
<div class="speaker-name">${speaker.name}</div>
<div class="speaker-bar">
<div class="speaker-fill" style="width: ${(speaker.count / maxCount) * 100}%"></div>
</div>
</div>
<div class="speaker-count">${speaker.count}회</div>
</div>
`).join('');
}
// 키워드 렌더링
function renderKeywords() {
const keywords = ['1분기 목표', '마케팅', '예산', 'ROI', '경쟁사 분석', '프로젝트', '전략', '실적'];
const container = document.getElementById('keywordCloud');
container.innerHTML = keywords.map(keyword => `
<div class="keyword-tag">${keyword}</div>
`).join('');
}
// 섹션으로 이동
function goToSection(sectionId) {
sessionStorage.setItem('focusSection', sectionId);
navigateTo('05-회의진행.html');
}
// Todo 저장
function saveTodos() {
showToast('Todo가 저장되었습니다.', 'success');
closeModal('editTodoModal');
}
// 캘린더에 추가
function addToCalendar() {
showToast('다음 회의 일정이 캘린더에 추가되었습니다.', 'success');
}
// 나중에 확정
document.getElementById('saveDraft').addEventListener('click', () => {
if (confirm('나중에 확정하시겠습니까?')) {
showToast('임시 저장되었습니다.', 'info');
setTimeout(() => {
navigateTo('02-대시보드.html');
}, 1000);
}
});
// 최종 확정
document.getElementById('finalizeMinutes').addEventListener('click', () => {
const allCompleted = renderChecklist();
if (!allCompleted) {
showToast('모든 필수 항목을 작성해주세요.', 'warning');
return;
}
if (confirm('회의록을 최종 확정하시겠습니까?\n확정 후에는 수정이 제한됩니다.')) {
// Todo 저장
extractedTodos.forEach(todo => {
todo.meetingId = 'meeting_final';
todo.status = 'pending';
todo.progress = 0;
saveTodo(todo);
});
showToast('회의록이 최종 확정되었습니다.', 'success');
setTimeout(() => {
navigateTo('08-회의록공유.html');
}, 1000);
}
});
// 초기화
loadMeetingStats();
renderChecklist();
renderTodos();
renderSpeakerStats();
renderKeywords();
</script>
</body>
</html>

View File

@ -1,610 +0,0 @@
<!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">
<style>
.share-container {
max-width: 1000px;
margin: 0 auto;
padding: var(--spacing-8);
}
.share-header {
text-align: center;
margin-bottom: var(--spacing-8);
}
.share-icon {
font-size: 64px;
margin-bottom: var(--spacing-4);
}
.share-form {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-8);
box-shadow: var(--shadow-sm);
margin-bottom: var(--spacing-6);
}
.form-section {
margin-bottom: var(--spacing-6);
padding-bottom: var(--spacing-6);
border-bottom: 1px solid var(--gray-200);
}
.form-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.section-header {
font-size: 1.125rem;
font-weight: 600;
color: var(--gray-900);
margin-bottom: var(--spacing-4);
}
.radio-group {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.radio-item {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-3);
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.radio-item:hover {
background: var(--gray-50);
}
.radio-item input[type="radio"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.radio-item.selected {
border-color: var(--primary-main);
background: rgba(0, 217, 177, 0.05);
}
.recipient-selector {
margin-top: var(--spacing-3);
display: none;
}
.recipient-selector.active {
display: block;
}
.recipient-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-3);
}
.recipient-chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-3);
background: var(--primary-light);
color: var(--primary-dark);
border-radius: var(--radius-full);
font-size: 0.875rem;
}
.link-display {
display: flex;
gap: var(--spacing-3);
margin-top: var(--spacing-3);
}
.link-input {
flex: 1;
background: var(--gray-50);
border: 1px solid var(--gray-300);
padding: var(--spacing-3) var(--spacing-4);
border-radius: var(--radius-md);
font-family: monospace;
font-size: 0.875rem;
color: var(--gray-700);
}
.advanced-settings {
margin-top: var(--spacing-4);
padding: var(--spacing-4);
background: var(--gray-50);
border-radius: var(--radius-md);
}
.advanced-settings-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
}
.advanced-settings-content {
margin-top: var(--spacing-4);
display: none;
}
.advanced-settings-content.active {
display: block;
}
.share-history {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-6);
box-shadow: var(--shadow-sm);
}
.history-item {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4);
margin-bottom: var(--spacing-3);
border-radius: var(--radius-md);
background: var(--gray-50);
}
.history-time {
font-size: 0.875rem;
color: var(--gray-500);
min-width: 120px;
}
.history-method {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-1) var(--spacing-2);
background: var(--primary-light);
color: var(--primary-dark);
border-radius: var(--radius-md);
font-size: 0.75rem;
}
.history-recipients {
flex: 1;
font-size: 0.875rem;
color: var(--gray-700);
}
.history-status {
font-size: 0.75rem;
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--radius-md);
}
.history-status.sent {
background: var(--success-light);
color: var(--success-dark);
}
.history-status.read {
background: var(--info-light);
color: var(--info-dark);
}
.action-buttons {
display: flex;
justify-content: space-between;
margin-top: var(--spacing-8);
}
</style>
</head>
<body>
<div class="share-container">
<div class="share-header">
<div class="share-icon">📤</div>
<h1>회의록 공유</h1>
<p style="color: var(--gray-500); font-size: 1.125rem;">
참석자에게 회의록을 공유하고 배포하세요
</p>
</div>
<form id="shareForm" class="share-form">
<!-- 공유 대상 선택 -->
<div class="form-section">
<div class="section-header">공유 대상</div>
<div class="radio-group">
<label class="radio-item selected">
<input type="radio" name="shareTarget" value="all" checked onchange="updateRecipientSelector()">
<div>
<div style="font-weight: 500;">참석자 전체</div>
<div style="font-size: 0.875rem; color: var(--gray-500);">회의에 참석한 모든 사람에게 공유합니다</div>
</div>
</label>
<label class="radio-item">
<input type="radio" name="shareTarget" value="specific" onchange="updateRecipientSelector()">
<div>
<div style="font-weight: 500;">특정 참석자 선택</div>
<div style="font-size: 0.875rem; color: var(--gray-500);">선택한 참석자에게만 공유합니다</div>
</div>
</label>
</div>
<div class="recipient-selector" id="recipientSelector">
<div style="margin-top: var(--spacing-4); margin-bottom: var(--spacing-2); font-weight: 500;">참석자 선택</div>
<div id="participantCheckboxes"></div>
<div class="recipient-list" id="selectedRecipients"></div>
</div>
<div class="input-group" style="margin-top: var(--spacing-4);">
<label class="input-label">외부 공유 (선택)</label>
<input type="email" id="externalEmail" class="input" placeholder="외부 이메일 주소">
<button type="button" class="btn btn-secondary btn-sm mt-2" onclick="addExternalEmail()">
+ 외부 이메일 추가
</button>
<div class="recipient-list" id="externalRecipients"></div>
</div>
</div>
<!-- 공유 권한 설정 -->
<div class="form-section">
<div class="section-header">공유 권한</div>
<div class="input-group">
<select id="permission" class="select">
<option value="read_only" selected>읽기 전용 (기본)</option>
<option value="comment">댓글 가능</option>
<option value="edit">편집 가능</option>
</select>
<small style="display: block; margin-top: var(--spacing-2); color: var(--gray-500);">
권한에 따라 수신자가 회의록을 보거나 수정할 수 있는 범위가 결정됩니다
</small>
</div>
</div>
<!-- 공유 방식 선택 -->
<div class="form-section">
<div class="section-header">공유 방식</div>
<div class="checkbox-group">
<div class="checkbox-item">
<input type="checkbox" id="shareEmail" checked>
<label for="shareEmail">
<strong>이메일로 전송</strong>
<div style="font-size: 0.875rem; color: var(--gray-500); margin-top: 2px;">수신자 이메일로 회의록 링크를 발송합니다</div>
</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="shareSlack">
<label for="shareSlack">
<strong>슬랙으로 전송</strong>
<div style="font-size: 0.875rem; color: var(--gray-500); margin-top: 2px;">연동된 슬랙 채널에 알림을 발송합니다</div>
</label>
</div>
</div>
</div>
<!-- 공유 링크 설정 -->
<div class="form-section">
<div class="section-header">공유 링크</div>
<div class="link-display">
<input type="text" class="link-input" id="shareLink" value="https://meeting.example.com/share/abc123xyz" readonly>
<button type="button" class="btn btn-secondary" onclick="copyLink()">
📋 복사
</button>
</div>
<!-- 고급 설정 -->
<div class="advanced-settings">
<div class="advanced-settings-header" onclick="toggleAdvancedSettings()">
<span style="font-weight: 500;">고급 설정</span>
<span id="advancedToggle"></span>
</div>
<div class="advanced-settings-content" id="advancedContent">
<div class="input-group">
<label class="input-label">링크 유효 기간</label>
<select id="linkExpiration" class="select">
<option value="none">무제한</option>
<option value="7">7일</option>
<option value="30" selected>30일</option>
<option value="custom">직접 입력</option>
</select>
</div>
<div class="input-group mt-4">
<label class="input-label">비밀번호 설정 (선택)</label>
<input type="password" id="linkPassword" class="input" placeholder="링크 접근 시 필요한 비밀번호">
</div>
</div>
</div>
</div>
</form>
<!-- 공유 이력 -->
<div class="share-history">
<div class="section-header">공유 이력</div>
<div id="shareHistoryList"></div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons">
<button type="button" class="btn btn-text" onclick="navigateTo('07-회의종료.html')">
← 뒤로
</button>
<button type="button" class="btn btn-primary" onclick="shareMinutes()">
공유하기 →
</button>
</div>
</div>
<script src="common.js"></script>
<script>
let selectedParticipants = [];
let externalEmails = [];
// 참석자 체크박스 렌더링
function renderParticipantCheckboxes() {
const meetingData = JSON.parse(sessionStorage.getItem('newMeeting') || '{}');
const users = getFromStorage(STORAGE_KEYS.USERS) || [];
if (meetingData.participants) {
const container = document.getElementById('participantCheckboxes');
container.innerHTML = meetingData.participants.map(email => {
const user = users.find(u => u.email === email) || { name: email, avatar: '👤', id: email };
return `
<div class="checkbox-item">
<input type="checkbox" id="participant-${user.id}" value="${email}" onchange="updateSelectedParticipants()">
<label for="participant-${user.id}" style="display: flex; align-items: center; gap: var(--spacing-2);">
<span>${user.avatar}</span>
<span>${user.name}</span>
<span style="color: var(--gray-400);">(${email})</span>
</label>
</div>
`;
}).join('');
}
}
// 라디오 버튼 스타일 업데이트
document.querySelectorAll('input[name="shareTarget"]').forEach(radio => {
radio.addEventListener('change', () => {
document.querySelectorAll('.radio-item').forEach(item => {
item.classList.remove('selected');
});
radio.closest('.radio-item').classList.add('selected');
});
});
// 수신자 선택기 표시/숨김
function updateRecipientSelector() {
const target = document.querySelector('input[name="shareTarget"]:checked').value;
const selector = document.getElementById('recipientSelector');
if (target === 'specific') {
selector.classList.add('active');
} else {
selector.classList.remove('active');
// 모든 체크박스 해제
document.querySelectorAll('#participantCheckboxes input[type="checkbox"]').forEach(cb => {
cb.checked = false;
});
selectedParticipants = [];
updateSelectedDisplay();
}
}
// 선택된 참석자 업데이트
function updateSelectedParticipants() {
selectedParticipants = Array.from(
document.querySelectorAll('#participantCheckboxes input[type="checkbox"]:checked')
).map(cb => cb.value);
updateSelectedDisplay();
}
// 선택된 수신자 표시
function updateSelectedDisplay() {
const container = document.getElementById('selectedRecipients');
if (selectedParticipants.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = selectedParticipants.map(email => `
<div class="recipient-chip">
<span>${email}</span>
<span style="cursor: pointer;" onclick="removeParticipant('${email}')">×</span>
</div>
`).join('');
}
// 참석자 제거
function removeParticipant(email) {
const checkbox = document.querySelector(`#participantCheckboxes input[value="${email}"]`);
if (checkbox) {
checkbox.checked = false;
}
updateSelectedParticipants();
}
// 외부 이메일 추가
function addExternalEmail() {
const input = document.getElementById('externalEmail');
const email = input.value.trim();
if (!email) {
showToast('이메일을 입력하세요.', 'warning');
return;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
showToast('올바른 이메일 주소를 입력하세요.', 'error');
return;
}
if (externalEmails.includes(email)) {
showToast('이미 추가된 이메일입니다.', 'warning');
return;
}
externalEmails.push(email);
input.value = '';
renderExternalEmails();
}
// 외부 이메일 렌더링
function renderExternalEmails() {
const container = document.getElementById('externalRecipients');
container.innerHTML = externalEmails.map(email => `
<div class="recipient-chip">
<span>🌐 ${email}</span>
<span style="cursor: pointer;" onclick="removeExternalEmail('${email}')">×</span>
</div>
`).join('');
}
// 외부 이메일 제거
function removeExternalEmail(email) {
externalEmails = externalEmails.filter(e => e !== email);
renderExternalEmails();
}
// 링크 복사
function copyLink() {
const linkInput = document.getElementById('shareLink');
linkInput.select();
document.execCommand('copy');
showToast('링크가 클립보드에 복사되었습니다.', 'success');
}
// 고급 설정 토글
function toggleAdvancedSettings() {
const content = document.getElementById('advancedContent');
const toggle = document.getElementById('advancedToggle');
if (content.classList.contains('active')) {
content.classList.remove('active');
toggle.textContent = '▼';
} else {
content.classList.add('active');
toggle.textContent = '▲';
}
}
// 공유 이력 렌더링
function renderShareHistory() {
const history = [
{
time: '2025-10-18 15:30',
method: '이메일',
recipients: '김민준, 박서연, 이준호',
status: 'read'
},
{
time: '2025-10-18 15:30',
method: '슬랙',
recipients: '#전략팀',
status: 'sent'
}
];
const container = document.getElementById('shareHistoryList');
if (history.length === 0) {
container.innerHTML = '<div style="text-align: center; padding: var(--spacing-8); color: var(--gray-400);">아직 공유 이력이 없습니다</div>';
return;
}
container.innerHTML = history.map(item => `
<div class="history-item">
<div class="history-time">${item.time}</div>
<div class="history-method">${item.method}</div>
<div class="history-recipients">${item.recipients}</div>
<div class="history-status ${item.status}">
${item.status === 'read' ? '읽음' : '발송 완료'}
</div>
</div>
`).join('');
}
// 회의록 공유
function shareMinutes() {
const shareTarget = document.querySelector('input[name="shareTarget"]:checked').value;
const shareEmail = document.getElementById('shareEmail').checked;
const shareSlack = document.getElementById('shareSlack').checked;
// 공유 대상 검증
if (shareTarget === 'specific' && selectedParticipants.length === 0 && externalEmails.length === 0) {
showToast('공유할 대상을 선택하세요.', 'warning');
return;
}
// 공유 방식 검증
if (!shareEmail && !shareSlack) {
showToast('최소 하나의 공유 방식을 선택하세요.', 'warning');
return;
}
// 공유 데이터 구성
const shareData = {
target: shareTarget,
recipients: shareTarget === 'all' ? 'all' : selectedParticipants,
externalRecipients: externalEmails,
permission: document.getElementById('permission').value,
methods: {
email: shareEmail,
slack: shareSlack
},
link: {
url: document.getElementById('shareLink').value,
expiration: document.getElementById('linkExpiration').value,
password: document.getElementById('linkPassword').value
},
sharedAt: new Date().toISOString()
};
// 공유 실행 (시뮬레이션)
showToast('회의록을 공유하고 있습니다...', 'info');
setTimeout(() => {
const recipientCount = shareTarget === 'all' ?
(JSON.parse(sessionStorage.getItem('newMeeting') || '{}').participants?.length || 0) :
(selectedParticipants.length + externalEmails.length);
if (shareEmail) {
showToast(`${recipientCount}명에게 이메일이 발송되었습니다.`, 'success');
}
if (shareSlack) {
showToast('슬랙으로 알림이 발송되었습니다.', 'success');
}
// Todo 관리 화면으로 이동
setTimeout(() => {
navigateTo('09-Todo관리.html');
}, 1500);
}, 1000);
}
// 초기화
renderParticipantCheckboxes();
renderShareHistory();
</script>
</body>
</html>

View File

@ -1,749 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo 관리 - 회의록 작성 및 공유 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
.todo-container {
max-width: 1800px;
margin: 0 auto;
padding: var(--spacing-8);
}
.todo-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-6);
}
.view-toggle {
display: flex;
gap: var(--spacing-2);
}
.view-btn {
padding: var(--spacing-2) var(--spacing-4);
border: 1px solid var(--gray-300);
background: white;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.view-btn.active {
background: var(--primary-main);
color: white;
border-color: var(--primary-main);
}
.filter-bar {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4);
background: white;
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-6);
box-shadow: var(--shadow-sm);
}
.filter-group {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.filter-label {
font-size: 0.875rem;
color: var(--gray-600);
font-weight: 500;
}
.filter-select {
padding: var(--spacing-2) var(--spacing-3);
border: 1px solid var(--gray-300);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: white;
cursor: pointer;
}
/* 칸반 보드 뷰 */
.kanban-board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-6);
}
.kanban-column {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-4);
box-shadow: var(--shadow-sm);
}
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-4);
padding-bottom: var(--spacing-3);
border-bottom: 2px solid var(--gray-200);
}
.column-title {
font-weight: 600;
font-size: 1.125rem;
color: var(--gray-900);
}
.column-count {
background: var(--gray-200);
color: var(--gray-700);
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--radius-full);
font-size: 0.875rem;
font-weight: 600;
}
.todo-card {
background: white;
border: 1px solid var(--gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-4);
margin-bottom: var(--spacing-3);
cursor: move;
transition: all var(--transition-fast);
position: relative;
}
.todo-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.todo-card.dragging {
opacity: 0.5;
}
.todo-card.high-priority {
border-left: 4px solid var(--error-main);
}
.todo-content-card {
font-weight: 500;
color: var(--gray-900);
margin-bottom: var(--spacing-3);
line-height: 1.5;
}
.todo-meta-row {
display: flex;
align-items: center;
gap: var(--spacing-3);
margin-bottom: var(--spacing-2);
font-size: 0.875rem;
color: var(--gray-600);
}
.todo-assignee {
display: flex;
align-items: center;
gap: var(--spacing-1);
}
.assignee-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--gray-200);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.todo-due-date {
display: flex;
align-items: center;
gap: var(--spacing-1);
}
.due-date.overdue {
color: var(--error-main);
font-weight: 600;
}
.due-date.today {
color: var(--warning-main);
font-weight: 600;
}
.progress-container {
margin-top: var(--spacing-3);
padding-top: var(--spacing-3);
border-top: 1px solid var(--gray-200);
}
.progress-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-2);
font-size: 0.75rem;
color: var(--gray-500);
}
.progress-bar-todo {
height: 6px;
background: var(--gray-200);
border-radius: 3px;
overflow: hidden;
}
.progress-fill-todo {
height: 100%;
background: var(--primary-main);
transition: width 0.3s;
}
.todo-actions {
display: flex;
gap: var(--spacing-2);
margin-top: var(--spacing-3);
}
.icon-btn-small {
width: 32px;
height: 32px;
border-radius: var(--radius-md);
border: 1px solid var(--gray-300);
background: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
font-size: 14px;
}
.icon-btn-small:hover {
background: var(--gray-50);
border-color: var(--primary-main);
}
.meeting-link {
font-size: 0.75rem;
color: var(--primary-main);
text-decoration: underline;
cursor: pointer;
}
/* 리스트 뷰 */
.list-view {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-6);
box-shadow: var(--shadow-sm);
display: none;
}
.list-view.active {
display: block;
}
.todo-table {
width: 100%;
border-collapse: collapse;
}
.todo-table th {
text-align: left;
padding: var(--spacing-3) var(--spacing-4);
background: var(--gray-50);
font-weight: 600;
color: var(--gray-700);
font-size: 0.875rem;
border-bottom: 2px solid var(--gray-200);
}
.todo-table td {
padding: var(--spacing-3) var(--spacing-4);
border-bottom: 1px solid var(--gray-200);
font-size: 0.875rem;
}
.todo-table tr:hover {
background: var(--gray-50);
}
.status-badge {
display: inline-block;
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--radius-md);
font-size: 0.75rem;
font-weight: 500;
}
.status-badge.pending {
background: var(--gray-200);
color: var(--gray-700);
}
.status-badge.in_progress {
background: var(--info-light);
color: var(--info-dark);
}
.status-badge.completed {
background: var(--success-light);
color: var(--success-dark);
}
.priority-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: var(--spacing-1);
}
.priority-dot.high {
background: var(--error-main);
}
.priority-dot.normal {
background: var(--gray-400);
}
@media (max-width: 1200px) {
.kanban-board {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="todo-container">
<!-- 헤더 -->
<div class="todo-header">
<div>
<h1>Todo 관리</h1>
<p style="color: var(--gray-500);">회의에서 생성된 Todo를 추적하고 관리하세요</p>
</div>
<div class="view-toggle">
<button class="view-btn active" id="kanbanViewBtn" onclick="switchView('kanban')">
📋 칸반 보드
</button>
<button class="view-btn" id="listViewBtn" onclick="switchView('list')">
📄 리스트 뷰
</button>
</div>
</div>
<!-- 필터 바 -->
<div class="filter-bar">
<div class="filter-group">
<span class="filter-label">담당자:</span>
<select class="filter-select" id="filterAssignee" onchange="applyFilters()">
<option value="all">전체</option>
<option value="me"></option>
</select>
</div>
<div class="filter-group">
<span class="filter-label">상태:</span>
<select class="filter-select" id="filterStatus" onchange="applyFilters()">
<option value="all">전체</option>
<option value="pending">시작 전</option>
<option value="in_progress">진행 중</option>
<option value="completed">완료</option>
</select>
</div>
<div class="filter-group">
<span class="filter-label">우선순위:</span>
<select class="filter-select" id="filterPriority" onchange="applyFilters()">
<option value="all">전체</option>
<option value="high">높음</option>
<option value="normal">보통</option>
<option value="low">낮음</option>
</select>
</div>
<div class="filter-group">
<span class="filter-label">정렬:</span>
<select class="filter-select" id="sortBy" onchange="applyFilters()">
<option value="dueDate">마감일순</option>
<option value="priority">우선순위순</option>
<option value="created">생성일순</option>
</select>
</div>
</div>
<!-- 칸반 보드 뷰 -->
<div class="kanban-board" id="kanbanView">
<div class="kanban-column" data-status="pending">
<div class="column-header">
<div class="column-title">시작 전</div>
<div class="column-count" id="pendingCount">0</div>
</div>
<div id="pendingColumn" class="column-content"></div>
</div>
<div class="kanban-column" data-status="in_progress">
<div class="column-header">
<div class="column-title">진행 중</div>
<div class="column-count" id="inProgressCount">0</div>
</div>
<div id="inProgressColumn" class="column-content"></div>
</div>
<div class="kanban-column" data-status="completed">
<div class="column-header">
<div class="column-title">완료</div>
<div class="column-count" id="completedCount">0</div>
</div>
<div id="completedColumn" class="column-content"></div>
</div>
</div>
<!-- 리스트 뷰 -->
<div class="list-view" id="listView">
<table class="todo-table">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" id="selectAll" onchange="toggleSelectAll()">
</th>
<th style="width: 100px;">상태</th>
<th>내용</th>
<th style="width: 120px;">담당자</th>
<th style="width: 100px;">마감일</th>
<th style="width: 80px;">우선순위</th>
<th style="width: 100px;">진행률</th>
<th style="width: 80px;">액션</th>
</tr>
</thead>
<tbody id="todoTableBody"></tbody>
</table>
</div>
</div>
<button class="fab" onclick="navigateTo('02-대시보드.html')" title="대시보드로 이동">
🏠
</button>
<script src="common.js"></script>
<script>
let todos = [];
let filteredTodos = [];
let draggedTodo = null;
// Todo 데이터 로드
function loadTodos() {
todos = getAllTodos();
filteredTodos = todos;
renderKanban();
renderList();
}
// 칸반 보드 렌더링
function renderKanban() {
const columns = {
pending: document.getElementById('pendingColumn'),
in_progress: document.getElementById('inProgressColumn'),
completed: document.getElementById('completedColumn')
};
// 초기화
Object.values(columns).forEach(col => col.innerHTML = '');
// Todo 분류 및 렌더링
const grouped = {
pending: filteredTodos.filter(t => t.status === 'pending'),
in_progress: filteredTodos.filter(t => t.status === 'in_progress'),
completed: filteredTodos.filter(t => t.status === 'completed')
};
Object.entries(grouped).forEach(([status, todoList]) => {
columns[status].innerHTML = todoList.map(todo => renderTodoCard(todo)).join('');
});
// 카운트 업데이트
document.getElementById('pendingCount').textContent = grouped.pending.length;
document.getElementById('inProgressCount').textContent = grouped.in_progress.length;
document.getElementById('completedCount').textContent = grouped.completed.length;
// 드래그 이벤트 추가
addDragEvents();
}
// Todo 카드 렌더링
function renderTodoCard(todo) {
const user = getUserById(todo.assignee);
const ddayText = getDdayText(todo.dueDate);
const isOverdue = ddayText.includes('지남');
const isToday = ddayText === '오늘';
return `
<div class="todo-card ${todo.priority === 'high' ? 'high-priority' : ''}"
draggable="true"
data-id="${todo.id}">
<div class="todo-content-card">${todo.content}</div>
<div class="todo-meta-row">
<div class="todo-assignee">
<div class="assignee-avatar">${user?.avatar || '👤'}</div>
<span>${user?.name || '알 수 없음'}</span>
</div>
<div class="todo-due-date">
<span>📅</span>
<span class="due-date ${isOverdue ? 'overdue' : ''} ${isToday ? 'today' : ''}">
${ddayText}
</span>
</div>
</div>
${todo.status !== 'completed' ? `
<div class="progress-container">
<div class="progress-label-row">
<span>진행률</span>
<span>${todo.progress}%</span>
</div>
<div class="progress-bar-todo">
<div class="progress-fill-todo" style="width: ${todo.progress}%"></div>
</div>
</div>
` : ''}
<div class="todo-actions">
<button class="icon-btn-small" title="상세" onclick="viewTodoDetail('${todo.id}')">👁️</button>
<button class="icon-btn-small" title="수정" onclick="editTodo('${todo.id}')">✏️</button>
${todo.meetingId ? `<span class="meeting-link" onclick="goToMeeting('${todo.meetingId}')">📋 회의록</span>` : ''}
</div>
</div>
`;
}
// 드래그 이벤트 추가
function addDragEvents() {
const cards = document.querySelectorAll('.todo-card');
const columns = document.querySelectorAll('.column-content');
cards.forEach(card => {
card.addEventListener('dragstart', handleDragStart);
card.addEventListener('dragend', handleDragEnd);
});
columns.forEach(column => {
column.addEventListener('dragover', handleDragOver);
column.addEventListener('drop', handleDrop);
});
}
function handleDragStart(e) {
draggedTodo = e.target.dataset.id;
e.target.classList.add('dragging');
}
function handleDragEnd(e) {
e.target.classList.remove('dragging');
}
function handleDragOver(e) {
e.preventDefault();
}
function handleDrop(e) {
e.preventDefault();
const newStatus = e.target.closest('.kanban-column').dataset.status;
const todo = todos.find(t => t.id === draggedTodo);
if (todo && todo.status !== newStatus) {
todo.status = newStatus;
if (newStatus === 'completed') {
todo.progress = 100;
} else if (newStatus === 'in_progress' && todo.progress === 0) {
todo.progress = 10;
}
saveTodo(todo);
renderKanban();
showToast(`Todo 상태가 "${newStatus === 'pending' ? '시작 전' : newStatus === 'in_progress' ? '진행 중' : '완료'}"로 변경되었습니다.`, 'success');
}
draggedTodo = null;
}
// 리스트 뷰 렌더링
function renderList() {
const tbody = document.getElementById('todoTableBody');
if (filteredTodos.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; padding: var(--spacing-8); color: var(--gray-400);">Todo가 없습니다</td></tr>';
return;
}
tbody.innerHTML = filteredTodos.map(todo => {
const user = getUserById(todo.assignee);
const ddayText = getDdayText(todo.dueDate);
return `
<tr>
<td><input type="checkbox" class="todo-checkbox" data-id="${todo.id}"></td>
<td>
<select class="status-badge ${todo.status}" onchange="changeStatus('${todo.id}', this.value)">
<option value="pending" ${todo.status === 'pending' ? 'selected' : ''}>시작 전</option>
<option value="in_progress" ${todo.status === 'in_progress' ? 'selected' : ''}>진행 중</option>
<option value="completed" ${todo.status === 'completed' ? 'selected' : ''}>완료</option>
</select>
</td>
<td>${todo.content}</td>
<td>
<div style="display: flex; align-items: center; gap: var(--spacing-2);">
<span>${user?.avatar || '👤'}</span>
<span>${user?.name || '알 수 없음'}</span>
</div>
</td>
<td>${ddayText}</td>
<td>
<span class="priority-dot ${todo.priority}"></span>
${todo.priority === 'high' ? '높음' : todo.priority === 'low' ? '낮음' : '보통'}
</td>
<td>
<div style="display: flex; align-items: center; gap: var(--spacing-2);">
<div style="flex: 1; height: 6px; background: var(--gray-200); border-radius: 3px; overflow: hidden;">
<div style="height: 100%; background: var(--primary-main); width: ${todo.progress}%;"></div>
</div>
<span style="font-size: 0.75rem;">${todo.progress}%</span>
</div>
</td>
<td>
<button class="icon-btn-small" title="수정" onclick="editTodo('${todo.id}')">✏️</button>
</td>
</tr>
`;
}).join('');
}
// 뷰 전환
function switchView(view) {
if (view === 'kanban') {
document.getElementById('kanbanView').style.display = 'grid';
document.getElementById('listView').classList.remove('active');
document.getElementById('kanbanViewBtn').classList.add('active');
document.getElementById('listViewBtn').classList.remove('active');
} else {
document.getElementById('kanbanView').style.display = 'none';
document.getElementById('listView').classList.add('active');
document.getElementById('kanbanViewBtn').classList.remove('active');
document.getElementById('listViewBtn').classList.add('active');
}
}
// 필터 적용
function applyFilters() {
const assigneeFilter = document.getElementById('filterAssignee').value;
const statusFilter = document.getElementById('filterStatus').value;
const priorityFilter = document.getElementById('filterPriority').value;
const sortBy = document.getElementById('sortBy').value;
const currentUser = getCurrentUser();
filteredTodos = todos.filter(todo => {
if (assigneeFilter === 'me' && todo.assignee !== currentUser.id) return false;
if (statusFilter !== 'all' && todo.status !== statusFilter) return false;
if (priorityFilter !== 'all' && todo.priority !== priorityFilter) return false;
return true;
});
// 정렬
filteredTodos.sort((a, b) => {
if (sortBy === 'dueDate') {
return new Date(a.dueDate) - new Date(b.dueDate);
} else if (sortBy === 'priority') {
const priorityOrder = { high: 0, normal: 1, low: 2 };
return priorityOrder[a.priority] - priorityOrder[b.priority];
} else {
return new Date(b.createdAt || 0) - new Date(a.createdAt || 0);
}
});
renderKanban();
renderList();
}
// 상태 변경
function changeStatus(todoId, newStatus) {
const todo = todos.find(t => t.id === todoId);
if (todo) {
todo.status = newStatus;
if (newStatus === 'completed') {
todo.progress = 100;
}
saveTodo(todo);
applyFilters();
showToast('상태가 변경되었습니다.', 'success');
}
}
// Todo 수정
function editTodo(todoId) {
const todo = todos.find(t => t.id === todoId);
if (todo) {
const newProgress = prompt(`진행률을 입력하세요 (0-100):`, todo.progress);
if (newProgress !== null) {
const progress = parseInt(newProgress);
if (!isNaN(progress) && progress >= 0 && progress <= 100) {
todo.progress = progress;
if (progress === 100) {
todo.status = 'completed';
} else if (progress > 0 && todo.status === 'pending') {
todo.status = 'in_progress';
}
saveTodo(todo);
applyFilters();
showToast('진행률이 업데이트되었습니다.', 'success');
}
}
}
}
// Todo 상세 보기
function viewTodoDetail(todoId) {
const todo = todos.find(t => t.id === todoId);
if (todo) {
alert(`Todo 상세\n\n내용: ${todo.content}\n담당자: ${getUserName(todo.assignee)}\n마감일: ${todo.dueDate}\n우선순위: ${todo.priority}\n진행률: ${todo.progress}%\n상태: ${todo.status}`);
}
}
// 회의록으로 이동
function goToMeeting(meetingId) {
alert('회의록 화면으로 이동합니다.');
}
// 전체 선택
function toggleSelectAll() {
const selectAll = document.getElementById('selectAll').checked;
document.querySelectorAll('.todo-checkbox').forEach(cb => {
cb.checked = selectAll;
});
}
// 초기화
loadTodos();
</script>
</body>
</html>

View File

@ -1,596 +0,0 @@
/* ============================================
회의록 작성 공유 개선 서비스 - 공통 스타일
============================================ */
/* === CSS Reset === */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: 'Pretendard', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Apple SD Gothic Neo', sans-serif;
line-height: 1.5;
color: #4B5563;
background-color: #F9FAFB;
}
/* === CSS Variables === */
:root {
/* Primary Colors */
--primary-light: #4DFFDB;
--primary-main: #00D9B1;
--primary-dark: #00A88A;
/* Secondary Colors */
--secondary-light: #A5B4FC;
--secondary-main: #6366F1;
--secondary-dark: #4F46E5;
/* Semantic Colors */
--success-light: #6EE7B7;
--success-main: #10B981;
--success-dark: #059669;
--warning-light: #FCD34D;
--warning-main: #F59E0B;
--warning-dark: #D97706;
--error-light: #FCA5A5;
--error-main: #EF4444;
--error-dark: #DC2626;
--info-light: #93C5FD;
--info-main: #3B82F6;
--info-dark: #2563EB;
/* Neutral Colors */
--gray-50: #F9FAFB;
--gray-100: #F3F4F6;
--gray-200: #E5E7EB;
--gray-300: #D1D5DB;
--gray-400: #9CA3AF;
--gray-500: #6B7280;
--gray-600: #4B5563;
--gray-700: #374151;
--gray-800: #1F2937;
--gray-900: #111827;
/* Typography */
--font-display: 700 3rem/1.25 'Pretendard';
--font-h1: 700 2.25rem/1.25 'Pretendard';
--font-h2: 600 1.875rem/1.25 'Pretendard';
--font-h3: 600 1.5rem/1.5 'Pretendard';
--font-h4: 600 1.25rem/1.5 'Pretendard';
--font-body-large: 400 1.125rem/1.5 'Pretendard';
--font-body: 400 1rem/1.5 'Pretendard';
--font-body-small: 400 0.875rem/1.5 'Pretendard';
--font-caption: 400 0.75rem/1.5 'Pretendard';
/* Spacing (8px 기반) */
--spacing-1: 4px;
--spacing-2: 8px;
--spacing-3: 12px;
--spacing-4: 16px;
--spacing-5: 20px;
--spacing-6: 24px;
--spacing-8: 32px;
--spacing-10: 40px;
--spacing-12: 48px;
--spacing-16: 64px;
/* Border Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 20px 25px rgba(0, 0, 0, 0.15);
/* Transitions */
--transition-fast: 150ms ease-out;
--transition-base: 200ms ease-out;
--transition-slow: 300ms ease-out;
}
/* === Typography === */
h1 { font: var(--font-h1); color: var(--gray-900); }
h2 { font: var(--font-h2); color: var(--gray-900); }
h3 { font: var(--font-h3); color: var(--gray-900); }
h4 { font: var(--font-h4); color: var(--gray-900); }
p { font: var(--font-body); margin-bottom: var(--spacing-4); }
small { font: var(--font-caption); color: var(--gray-500); }
/* === Buttons === */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing-3) var(--spacing-6);
font-size: 1rem;
font-weight: 500;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
gap: var(--spacing-2);
}
.btn-primary {
background-color: var(--primary-main);
color: white;
}
.btn-primary:hover {
background-color: var(--primary-light);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.btn-primary:active {
background-color: var(--primary-dark);
transform: scale(0.98);
}
.btn-primary:disabled {
background-color: var(--gray-300);
color: var(--gray-500);
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background-color: transparent;
color: var(--primary-main);
border: 1px solid var(--primary-main);
}
.btn-secondary:hover {
background-color: rgba(0, 217, 177, 0.1);
}
.btn-secondary:active {
background-color: rgba(0, 217, 177, 0.2);
}
.btn-text {
background-color: transparent;
color: var(--gray-700);
}
.btn-text:hover {
background-color: var(--gray-100);
}
.btn-text:active {
background-color: var(--gray-200);
}
.btn-icon {
width: 40px;
height: 40px;
padding: 0;
border-radius: var(--radius-full);
background-color: transparent;
}
.btn-icon:hover {
background-color: var(--gray-100);
}
.btn-sm {
padding: var(--spacing-2) var(--spacing-4);
font-size: 0.875rem;
}
.btn-lg {
padding: var(--spacing-4) var(--spacing-8);
font-size: 1.125rem;
}
/* FAB (Floating Action Button) */
.fab {
position: fixed;
bottom: var(--spacing-4);
right: var(--spacing-4);
width: 56px;
height: 56px;
background-color: var(--primary-main);
color: white;
border-radius: var(--radius-full);
border: none;
box-shadow: var(--shadow-md);
cursor: pointer;
transition: all var(--transition-base);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
z-index: 1000;
}
.fab:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
transform: translateY(-2px);
}
.fab:active {
transform: scale(0.95);
}
/* === Input Fields === */
.input,
.textarea,
.select {
width: 100%;
padding: var(--spacing-3) var(--spacing-4);
font-size: 1rem;
font-family: inherit;
border: 1px solid var(--gray-300);
border-radius: var(--radius-md);
background-color: white;
transition: all var(--transition-fast);
}
.input:focus,
.textarea:focus,
.select:focus {
outline: 4px solid rgba(0, 217, 177, 0.2);
border-color: var(--primary-main);
border-width: 2px;
}
.input.error,
.textarea.error {
border-color: var(--error-main);
}
.input:disabled,
.textarea:disabled,
.select:disabled {
background-color: var(--gray-100);
color: var(--gray-400);
cursor: not-allowed;
}
.textarea {
min-height: 120px;
resize: vertical;
}
.input-group {
margin-bottom: var(--spacing-4);
}
.input-label {
display: block;
margin-bottom: var(--spacing-2);
font-weight: 500;
color: var(--gray-700);
}
.input-error {
display: block;
margin-top: var(--spacing-1);
font-size: 0.875rem;
color: var(--error-main);
}
/* === Cards === */
.card {
background-color: white;
border: 1px solid var(--gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
box-shadow: var(--shadow-sm);
transition: all var(--transition-base);
}
.card.interactive {
cursor: pointer;
}
.card.interactive:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.card.interactive:active {
box-shadow: var(--shadow-sm);
transform: scale(0.99);
}
/* === Badges === */
.badge {
display: inline-flex;
align-items: center;
padding: var(--spacing-1) var(--spacing-3);
font-size: 0.75rem;
font-weight: 500;
border-radius: var(--radius-full);
}
.badge-primary {
background-color: var(--primary-light);
color: var(--primary-dark);
}
.badge-success {
background-color: var(--success-light);
color: var(--success-dark);
}
.badge-warning {
background-color: var(--warning-light);
color: var(--warning-dark);
}
.badge-error {
background-color: var(--error-light);
color: var(--error-dark);
}
.badge-neutral {
background-color: var(--gray-200);
color: var(--gray-700);
}
/* === Modal === */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
}
.modal-backdrop.active {
display: flex;
}
.modal {
background-color: white;
border-radius: var(--radius-xl);
padding: var(--spacing-8);
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-header {
margin-bottom: var(--spacing-6);
}
.modal-title {
font: var(--font-h3);
color: var(--gray-900);
}
.modal-body {
margin-bottom: var(--spacing-8);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-3);
}
/* === Toast === */
.toast-container {
position: fixed;
top: var(--spacing-4);
right: var(--spacing-4);
z-index: 10000;
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.toast {
background-color: white;
border-radius: var(--radius-md);
padding: var(--spacing-4) var(--spacing-5);
max-width: 400px;
box-shadow: var(--shadow-md);
display: flex;
align-items: center;
gap: var(--spacing-3);
border-left: 4px solid;
animation: slideIn 200ms ease-out;
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.toast-success {
border-left-color: var(--success-main);
}
.toast-error {
border-left-color: var(--error-main);
}
.toast-warning {
border-left-color: var(--warning-main);
}
.toast-info {
border-left-color: var(--info-main);
}
/* === Layout === */
.container {
max-width: 1536px;
margin: 0 auto;
padding: 0 var(--spacing-6);
}
.header {
background-color: white;
border-bottom: 1px solid var(--gray-200);
padding: var(--spacing-4) 0;
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-main);
text-decoration: none;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
width: 250px;
height: 100vh;
background-color: white;
border-right: 1px solid var(--gray-200);
padding: var(--spacing-6);
overflow-y: auto;
}
.main-content {
margin-left: 250px;
padding: var(--spacing-8);
min-height: 100vh;
}
/* === Grid System === */
.grid {
display: grid;
gap: var(--spacing-4);
}
.grid-2 {
grid-template-columns: repeat(2, 1fr);
}
.grid-3 {
grid-template-columns: repeat(3, 1fr);
}
.grid-4 {
grid-template-columns: repeat(4, 1fr);
}
/* === Utility Classes === */
.hidden {
display: none !important;
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.mt-2 { margin-top: var(--spacing-2); }
.mt-4 { margin-top: var(--spacing-4); }
.mt-6 { margin-top: var(--spacing-6); }
.mt-8 { margin-top: var(--spacing-8); }
.mb-2 { margin-bottom: var(--spacing-2); }
.mb-4 { margin-bottom: var(--spacing-4); }
.mb-6 { margin-bottom: var(--spacing-6); }
.mb-8 { margin-bottom: var(--spacing-8); }
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-2 { gap: var(--spacing-2); }
.gap-4 { gap: var(--spacing-4); }
.gap-6 { gap: var(--spacing-6); }
/* === Responsive === */
@media (max-width: 1023px) {
.sidebar {
transform: translateX(-100%);
transition: transform 300ms ease-out;
}
.sidebar.active {
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
.grid-3 {
grid-template-columns: repeat(2, 1fr);
}
.grid-4 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 767px) {
.container {
padding: 0 var(--spacing-4);
}
.grid-2,
.grid-3,
.grid-4 {
grid-template-columns: 1fr;
}
.modal {
width: 95%;
padding: var(--spacing-6);
}
}

View File

@ -1,408 +0,0 @@
/* ============================================
회의록 작성 공유 개선 서비스 - 공통 Javascript
============================================ */
// === LocalStorage 키 상수 ===
const STORAGE_KEYS = {
CURRENT_USER: 'currentUser',
USERS: 'users',
MEETINGS: 'meetings',
TODOS: 'todos',
INITIALIZED: 'initialized'
};
// === 예제 데이터 초기화 ===
function initializeData() {
if (localStorage.getItem(STORAGE_KEYS.INITIALIZED)) {
return;
}
// 사용자 데이터
const users = [
{ id: 'user1', name: '김민준', email: 'minjun@example.com', avatar: '👤' },
{ id: 'user2', name: '박서연', email: 'seoyeon@example.com', avatar: '👩' },
{ id: 'user3', name: '이준호', email: 'junho@example.com', avatar: '👨' },
{ id: 'user4', name: '최유진', email: 'yujin@example.com', avatar: '👧' },
{ id: 'user5', name: '정도현', email: 'dohyun@example.com', avatar: '🧑' }
];
// 회의 데이터
const meetings = [
{
id: 'meeting1',
title: '2025년 1분기 전략 회의',
date: '2025-10-20',
time: '14:00',
endTime: '15:30',
location: '3층 회의실',
participants: ['user1', 'user2', 'user3'],
template: 'general',
status: 'scheduled', // scheduled, in_progress, completed
content: {
sections: {
participants: '김민준, 박서연, 이준호',
agenda: '1분기 목표 설정 및 전략 수립',
discussion: '',
decisions: '',
todos: ''
}
},
createdAt: new Date().toISOString()
},
{
id: 'meeting2',
title: '주간 스크럼 회의',
date: '2025-10-18',
time: '10:00',
endTime: '10:30',
location: '온라인',
participants: ['user1', 'user2', 'user3', 'user4'],
template: 'scrum',
status: 'completed',
content: {
sections: {
participants: '김민준, 박서연, 이준호, 최유진',
yesterday: '- API 개발 완료\n- 테스트 코드 작성',
today: '- 프론트엔드 개발 시작\n- 디자인 리뷰',
blockers: '없음'
}
},
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString()
}
];
// Todo 데이터
const todos = [
{
id: 'todo1',
meetingId: 'meeting1',
content: '1분기 마케팅 예산안 작성',
assignee: 'user2',
dueDate: '2025-10-25',
priority: 'high', // high, normal, low
status: 'pending', // pending, in_progress, completed
progress: 0
},
{
id: 'todo2',
meetingId: 'meeting1',
content: '경쟁사 분석 보고서 작성',
assignee: 'user3',
dueDate: '2025-10-22',
priority: 'high',
status: 'in_progress',
progress: 60
},
{
id: 'todo3',
meetingId: 'meeting2',
content: '테스트 커버리지 80% 달성',
assignee: 'user3',
dueDate: '2025-10-20',
priority: 'normal',
status: 'completed',
progress: 100
}
];
// 현재 로그인 사용자
const currentUser = users[0];
// LocalStorage에 저장
localStorage.setItem(STORAGE_KEYS.USERS, JSON.stringify(users));
localStorage.setItem(STORAGE_KEYS.MEETINGS, JSON.stringify(meetings));
localStorage.setItem(STORAGE_KEYS.TODOS, JSON.stringify(todos));
localStorage.setItem(STORAGE_KEYS.CURRENT_USER, JSON.stringify(currentUser));
localStorage.setItem(STORAGE_KEYS.INITIALIZED, 'true');
}
// === LocalStorage 헬퍼 함수 ===
function getFromStorage(key) {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : null;
}
function saveToStorage(key, data) {
localStorage.setItem(key, JSON.stringify(data));
}
// === 날짜/시간 유틸리티 ===
function formatDate(dateString) {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function formatTime(timeString) {
return timeString; // HH:MM 형식 그대로 반환
}
function formatDateTime(dateString, timeString) {
return `${formatDate(dateString)} ${formatTime(timeString)}`;
}
function getDdayText(dueDateString) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dueDate = new Date(dueDateString);
dueDate.setHours(0, 0, 0, 0);
const diff = Math.ceil((dueDate - today) / (1000 * 60 * 60 * 24));
if (diff < 0) return `D${diff} (지남)`;
if (diff === 0) return '오늘';
return `D-${diff}`;
}
function formatDuration(minutes) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0 && mins > 0) return `${hours}시간 ${mins}`;
if (hours > 0) return `${hours}시간`;
return `${mins}`;
}
// === 사용자 관련 함수 ===
function getCurrentUser() {
return getFromStorage(STORAGE_KEYS.CURRENT_USER);
}
function getUserById(userId) {
const users = getFromStorage(STORAGE_KEYS.USERS) || [];
return users.find(u => u.id === userId);
}
function getUserName(userId) {
const user = getUserById(userId);
return user ? user.name : '알 수 없음';
}
// === 회의 관련 함수 ===
function getAllMeetings() {
return getFromStorage(STORAGE_KEYS.MEETINGS) || [];
}
function getMeetingById(meetingId) {
const meetings = getAllMeetings();
return meetings.find(m => m.id === meetingId);
}
function saveMeeting(meeting) {
const meetings = getAllMeetings();
const index = meetings.findIndex(m => m.id === meeting.id);
if (index >= 0) {
meetings[index] = meeting;
} else {
meetings.push(meeting);
}
saveToStorage(STORAGE_KEYS.MEETINGS, meetings);
}
function getScheduledMeetings() {
const meetings = getAllMeetings();
return meetings.filter(m => m.status === 'scheduled');
}
function getRecentMeetings(limit = 6) {
const meetings = getAllMeetings();
return meetings
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.slice(0, limit);
}
// === Todo 관련 함수 ===
function getAllTodos() {
return getFromStorage(STORAGE_KEYS.TODOS) || [];
}
function getTodoById(todoId) {
const todos = getAllTodos();
return todos.find(t => t.id === todoId);
}
function saveTodo(todo) {
const todos = getAllTodos();
const index = todos.findIndex(t => t.id === todo.id);
if (index >= 0) {
todos[index] = todo;
} else {
todos.push(todo);
}
saveToStorage(STORAGE_KEYS.TODOS, todos);
}
function getPendingTodos(userId) {
const todos = getAllTodos();
return todos.filter(t => t.assignee === userId && t.status !== 'completed');
}
function getTodosByStatus(status) {
const todos = getAllTodos();
return todos.filter(t => t.status === status);
}
// === Toast 알림 ===
function showToast(message, type = 'info') {
// Toast 컨테이너 생성 (없으면)
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
}
// Toast 생성
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
const icons = {
success: '✓',
error: '✗',
warning: '⚠',
info: ''
};
toast.innerHTML = `
<span style="font-size: 20px;">${icons[type]}</span>
<span>${message}</span>
`;
container.appendChild(toast);
// 4초 후 자동 제거
setTimeout(() => {
toast.style.animation = 'slideOut 150ms ease-in';
setTimeout(() => toast.remove(), 150);
}, 4000);
}
// slideOut 애니메이션 추가
if (!document.querySelector('style[data-toast-style]')) {
const style = document.createElement('style');
style.setAttribute('data-toast-style', 'true');
style.textContent = `
@keyframes slideOut {
to {
transform: translateX(400px);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
// === 모달 제어 ===
function openModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add('active');
}
}
function closeModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove('active');
}
}
// 모달 백드롭 클릭 시 닫기
document.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-backdrop')) {
closeModal(e.target.id);
}
});
// === 화면 전환 ===
function navigateTo(page) {
window.location.href = page;
}
function goBack() {
window.history.back();
}
// === 유틸리티 함수 ===
function generateId(prefix = 'id') {
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// === 템플릿 데이터 ===
const TEMPLATES = {
general: {
id: 'general',
name: '일반 회의',
description: '기본 회의 구조',
sections: [
{ id: 'participants', name: '참석자', required: true },
{ id: 'agenda', name: '회의 목적', required: false },
{ id: 'discussion', name: '논의 내용', required: true },
{ id: 'decisions', name: '결정 사항', required: true },
{ id: 'todos', name: 'Todo', required: false }
]
},
scrum: {
id: 'scrum',
name: '스크럼 회의',
description: '데일리 스탠드업',
sections: [
{ id: 'participants', name: '참석자', required: true },
{ id: 'yesterday', name: '어제 한 일', required: true },
{ id: 'today', name: '오늘 할 일', required: true },
{ id: 'blockers', name: '이슈/블로커', required: false }
]
},
kickoff: {
id: 'kickoff',
name: '프로젝트 킥오프',
description: '프로젝트 시작 회의',
sections: [
{ id: 'participants', name: '참석자', required: true },
{ id: 'overview', name: '프로젝트 개요', required: true },
{ id: 'goals', name: '목표', required: true },
{ id: 'schedule', name: '일정', required: true },
{ id: 'roles', name: '역할 분담', required: true },
{ id: 'risks', name: '리스크', required: false }
]
},
weekly: {
id: 'weekly',
name: '주간 회의',
description: '주간 진행 상황 리뷰',
sections: [
{ id: 'participants', name: '참석자', required: true },
{ id: 'achievements', name: '주간 실적', required: true },
{ id: 'issues', name: '주요 이슈', required: false },
{ id: 'nextWeek', name: '다음 주 계획', required: true },
{ id: 'support', name: '지원 필요 사항', required: false }
]
}
};
function getTemplate(templateId) {
return TEMPLATES[templateId];
}
function getAllTemplates() {
return Object.values(TEMPLATES);
}
// === 페이지 로드 시 데이터 초기화 ===
document.addEventListener('DOMContentLoaded', () => {
initializeData();
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,674 +0,0 @@
# 회의록 작성 및 공유 개선 서비스 - 유저스토리
- [회의록 작성 및 공유 개선 서비스 - 유저스토리](#회의록-작성-및-공유-개선-서비스---유저스토리)
- [마이크로서비스 구성](#마이크로서비스-구성)
- [유저스토리](#유저스토리)
---
## 마이크로서비스 구성
1. **User** - 사용자 인증 및 권한 관리
2. **Meeting** - 회의 관리, 회의록 생성 및 관리, 회의록 공유
3. **STT** - 음성 녹음 관리, 음성-텍스트 변환, 화자 식별
4. **AI** - LLM 기반 회의록 자동 작성, Todo 자동 추출
5. **RAG** - 전문용어 감지 및 설명 제공, 문서 검색
6. **Collaboration** - 실시간 동기화, 버전 관리, 충돌 해결
7. **Todo** - Todo 할당 및 관리, 진행 상황 추적
8. **Notification** - 알림 발송 및 리마인더 관리
9. **Calendar** - 일정 생성 및 외부 캘린더 연동
---
## 유저스토리
```
1. User 서비스
1) 사용자 인증 및 관리
AFR-USER-010: [사용자관리] 시스템 관리자로서 | 나는, 서비스 보안을 위해 | 사용자 인증 및 권한 관리 기능을 원한다.
- 시나리오: 사용자 인증 및 권한 관리
사용자가 로그인을 시도한 상황에서 | 아이디와 비밀번호를 입력하면 | 인증이 완료되고 권한에 따라 서비스에 접근할 수 있다.
- [ ] 사용자 인증 (아이디, 비밀번호)
- [ ] JWT 토큰 기반 인증
- [ ] 사용자 권한 관리 (관리자, 일반 사용자)
- [ ] 세션 관리
- M/8
---
2. Meeting 서비스
1) 회의 준비 및 관리
UFR-MEET-010: [회의예약] 회의록 작성자로서 | 나는, 회의를 효율적으로 준비하기 위해 | 회의를 예약하고 참석자를 초대하고 싶다.
- 시나리오: 회의 예약 및 참석자 초대
회의 예약 화면에 접근한 상황에서 | 회의 제목, 날짜/시간, 장소, 참석자 목록을 입력하고 예약 버튼을 클릭하면 | 회의가 예약되고 참석자에게 초대 이메일이 자동 발송된다.
[입력 요구사항]
- 회의 제목: 최대 100자 (필수)
- 날짜/시간: 날짜 및 시간 선택 (필수)
- 장소: 최대 200자 (선택)
- 참석자 목록: 이메일 주소 입력 (최소 1명 필수)
[처리 결과]
- 회의가 예약됨 (회의 ID 생성)
- 일정이 캘린더에 자동 등록됨
- 참석자에게 초대 이메일 발송됨
- 회의 시작 30분 전 리마인더 자동 발송
- M/13
---
UFR-MEET-020: [템플릿선택] 회의록 작성자로서 | 나는, 회의록을 효율적으로 작성하기 위해 | 회의 유형에 맞는 템플릿을 선택하고 싶다.
- 시나리오: 회의록 템플릿 선택
회의 시작 전 템플릿 선택 화면에 접근한 상황에서 | 제공되는 템플릿 중 하나를 선택하고 커스터마이징하면 | 회의록 도구가 준비된다.
[템플릿 유형]
- 일반 회의: 기본 구조 (참석자, 안건, 논의 내용, 결정 사항, Todo)
- 스크럼 회의: 어제 한 일, 오늘 할 일, 이슈
- 프로젝트 킥오프: 프로젝트 개요, 목표, 일정, 역할, 리스크
- 주간 회의: 주간 실적, 주요 이슈, 다음 주 계획
[커스터마이징 옵션]
- 섹션 추가/삭제
- 섹션 순서 변경
- 기본 항목 설정
[처리 결과]
- 선택된 템플릿으로 회의록 도구가 준비됨
- 템플릿 ID와 설정 정보가 저장됨
- S/5
---
UFR-MEET-030: [회의시작] 회의록 작성자로서 | 나는, 회의를 시작하고 회의록을 작성하기 위해 | 회의를 시작하고 음성 녹음을 준비하고 싶다.
- 시나리오: 회의 시작
예약된 회의 시간에 회의 시작 버튼을 클릭한 상황에서 | 회의 ID를 확인하고 시작하면 | 회의 세션이 생성되고 음성 녹음이 준비된다.
[회의 시작 조건]
- 예약된 회의가 존재함
- 회의 시작 시간이 도래함
- 회의록 작성자가 시작 권한을 가짐
[처리 결과]
- 회의 세션이 생성됨 (세션 ID)
- 음성 녹음 준비 완료
- 참석자 목록 표시
- 회의 시작 시간 기록
- 실시간 회의록 작성 화면 활성화
- M/8
---
2) 회의 종료 및 완료
UFR-MEET-040: [회의종료] 회의록 작성자로서 | 나는, 회의를 종료하고 회의록을 정리하기 위해 | 회의를 종료하고 통계를 확인하고 싶다.
- 시나리오: 회의 종료
회의가 진행 중인 상황에서 | 회의 종료 버튼을 클릭하면 | 음성 녹음이 중지되고 회의 통계가 생성된다.
[회의 종료 처리]
- 음성 녹음 즉시 중지
- 회의 종료 시간 기록
- 회의 통계 자동 생성
- 회의 총 시간
- 참석자 수
- 발언 횟수 (화자별)
- 주요 키워드
[처리 결과]
- 회의가 종료됨
- 회의 통계 표시
- 최종 회의록 확정 단계로 이동
- M/8
---
UFR-MEET-050: [최종확정] 회의록 작성자로서 | 나는, 회의록을 완성하기 위해 | 최종 회의록을 확정하고 버전을 생성하고 싶다.
- 시나리오: 최종 회의록 확정
회의가 종료된 상황에서 | 회의록 내용을 최종 검토하고 확정 버튼을 클릭하면 | 필수 항목이 검사되고 최종 버전이 생성된다.
[필수 항목 검사]
- 회의 제목 입력 여부
- 참석자 목록 작성 여부
- 주요 논의 내용 작성 여부
- 결정 사항 작성 여부
[처리 결과]
- 최종 회의록 확정됨 (확정 버전 번호)
- 확정 시간 기록
- AI가 자동으로 Todo 항목 추출 (UFR-AI-020 연동)
- 회의록 공유 가능 상태로 전환
[필수 항목 미작성 시]
- 누락된 항목 안내 메시지 표시
- 해당 섹션으로 자동 이동
- M/13
---
3) 회의록 공유
UFR-MEET-060: [회의록공유] 회의록 작성자로서 | 나는, 회의 내용을 참석자들과 공유하기 위해 | 최종 회의록을 공유하고 싶다.
- 시나리오: 회의록 공유
최종 회의록이 확정된 상황에서 | 공유 버튼을 클릭하고 공유 대상과 권한을 설정하면 | 공유 링크가 생성되고 참석자 전원에게 알림이 발송된다.
[공유 설정]
- 공유 대상: 참석자 전체 (기본) / 특정 참석자 선택
- 공유 권한: 읽기 전용 / 댓글 가능 / 편집 가능
- 공유 방식: 이메일 / 슬랙 / 링크 복사
[처리 결과]
- 공유 링크 생성 (고유 URL)
- 참석자에게 이메일/슬랙 알림 발송
- 공유 시간 기록
- 다음 회의 일정이 언급된 경우 캘린더에 자동 등록 (UFR-CAL-010 연동)
[공유 링크 보안]
- 링크 유효 기간 설정 (선택)
- 비밀번호 설정 (선택)
- M/13
---
3. STT 서비스
1) 음성 인식 및 변환
UFR-STT-010: [음성녹음인식] 회의 참석자로서 | 나는, 발언 내용이 자동으로 기록되기 위해 | 음성이 실시간으로 녹음되고 인식되기를 원한다.
- 시나리오: 음성 녹음 및 발언 인식
회의가 시작된 상황에서 | 참석자가 발언을 시작하면 | 음성이 자동으로 녹음되고 화자가 식별되며 발언이 인식된다.
[음성 녹음 처리]
- 오디오 스트림 실시간 캡처
- 회의 ID와 연결
- 음성 데이터 저장 (클라우드 스토리지)
[발언 인식 처리]
- AI 음성인식 엔진 연동 (Whisper, Google STT 등)
- 화자 자동 식별
- 참석자 목록 매칭
- 음성 특징 분석
- 타임스탬프 기록
- 발언 구간 구분
[처리 결과]
- 음성 녹음이 시작됨 (녹음 ID)
- 발언이 인식됨 (발언 ID, 화자, 타임스탬프)
- 실시간으로 텍스트 변환 요청 (UFR-STT-020 연동)
[성능 요구사항]
- 발언 인식 지연 시간: 1초 이내
- 화자 식별 정확도: 90% 이상
- M/21
---
UFR-STT-020: [텍스트변환] 회의록 시스템으로서 | 나는, 인식된 발언을 회의록에 기록하기 위해 | 음성을 텍스트로 변환하고 싶다.
- 시나리오: 음성-텍스트 변환
발언이 인식된 상황에서 | AI 음성인식 엔진에 텍스트 변환을 요청하면 | 음성이 텍스트로 변환되고 정확도와 함께 반환된다.
[텍스트 변환 처리]
- 인식된 발언 데이터 전달
- 언어 설정 (한국어, 영어 등)
- AI 음성인식 엔진 처리
- 문장 부호 자동 추가
- 숫자/날짜 형식 정규화
[처리 결과]
- 텍스트가 변환됨 (텍스트 ID)
- 변환된 내용 (원문 텍스트)
- 정확도 점수 (0-100%)
- AI 회의록 자동 작성 요청 (UFR-AI-010 연동)
[정확도 낮은 경우]
- 정확도 60% 미만 시 경고 표시
- 수동 수정 인터페이스 제공
- M/13
---
4. AI 서비스
1) AI 회의록 작성
UFR-AI-010: [회의록자동작성] 회의록 작성자로서 | 나는, 회의록 작성 부담을 줄이기 위해 | AI가 발언 내용을 자동으로 정리하여 회의록을 작성하기를 원한다.
- 시나리오: AI 회의록 자동 작성
텍스트가 변환된 상황에서 | LLM에 회의록 자동 작성을 요청하면 | 회의 맥락을 이해하고 구조화된 회의록 초안이 생성된다.
[AI 처리 과정]
- 변환된 텍스트와 회의 맥락(제목, 참석자, 이전 내용) 분석
- 회의 내용 이해
- 주제별 분류
- 발언자별 의견 정리
- 중요 키워드 추출
- 문장 다듬기
- 구어체 → 문어체 변환
- 불필요한 표현 제거
- 문법 교정
- 구조화
- 회의록 템플릿에 맞춰 정리
- 주제, 발언자, 내용 구조화
- 요약문 생성
[처리 결과]
- 회의록 초안이 생성됨 (회의록 버전)
- 생성 시간 기록
- 구조화된 내용
- 논의 주제
- 발언자별 의견
- 결정 사항
- 보류 사항
- 참석자에게 실시간 동기화 (UFR-COLLAB-010 연동)
[Policy/Rule]
- 텍스트 변환되면 자동으로 회의록 구조에 맞춰 정리
- 실시간 업데이트 (3-5초 간격)
- M/34
---
2) Todo 자동 추출
UFR-AI-020: [Todo자동추출] 회의록 작성자로서 | 나는, 회의 후 실행 사항을 명확히 하기 위해 | AI가 회의록에서 Todo 항목을 자동으로 추출하고 담당자를 식별하기를 원한다.
- 시나리오: AI Todo 자동 추출
회의가 종료된 상황에서 | 최종 회의록을 분석하여 Todo 자동 추출을 요청하면 | 액션 아이템이 식별되고 담당자가 자동으로 지정된다.
[AI 분석 과정]
- 회의록 전체 내용 분석
- 액션 아이템 식별
- "~하기로 함", "~까지 완료", "~담당" 등 키워드 탐지
- 명령형 문장 분석
- 마감일 언급 추출
- 담당자 자동 식별
- 발언 내용 기반 ("제가 하겠습니다", "~님이 담당")
- 직책/역할 기반 매칭
- 과거 회의록 패턴 학습
[처리 결과]
- Todo가 자동 추출됨
- 추출된 항목 수
- 각 Todo별 정보
- Todo 내용
- 담당자 (자동 식별)
- 마감일 (언급된 경우)
- 우선순위 (언급된 경우)
- 관련 회의록 섹션 링크
- Todo 서비스에 자동 전달 (UFR-TODO-010 연동)
[담당자 식별 실패 시]
- 미지정 상태로 Todo 생성
- 회의 주최자에게 수동 할당 요청 알림
- M/21
---
5. RAG 서비스
1) 전문용어 지원
UFR-RAG-010: [전문용어감지] 회의록 작성자로서 | 나는, 업무 지식이 없어도 회의록을 정확히 작성하기 위해 | 전문용어가 자동으로 감지되고 설명을 제공받고 싶다.
- 시나리오: 전문용어 자동 감지
회의록이 작성되는 상황에서 | 시스템이 회의록 텍스트를 분석하면 | 전문용어가 자동으로 감지되고 하이라이트 표시된다.
[전문용어 감지 처리]
- 회의록 텍스트 실시간 분석
- 용어 사전과 비교
- 조직별 전문용어 DB
- 산업별 표준 용어 DB
- 신뢰도 계산 (0-100%)
- 감지된 용어 위치 기록
[처리 결과]
- 전문용어가 감지됨
- 감지된 용어 정보
- 용어명
- 감지 위치 (줄 번호, 문단)
- 신뢰도 점수
- 용어 하이라이트 표시
- RAG 검색 자동 실행 (UFR-RAG-020 연동)
[Policy/Rule]
- 신뢰도 70% 이상만 자동 감지
- 중복 용어는 첫 번째만 하이라이트
- S/13
---
UFR-RAG-020: [용어설명제공] 회의록 작성자로서 | 나는, 전문용어를 이해하기 위해 | 용어에 대한 설명을 자동으로 제공받고 싶다.
- 시나리오: 용어 설명 자동 제공
전문용어가 감지된 상황에서 | RAG 시스템이 용어 설명을 검색하면 | 과거 회의록 및 사내 문서에서 관련 설명이 생성되어 제공된다.
[RAG 검색 수행]
- 벡터 유사도 검색
- 과거 회의록 검색
- 사내 문서 저장소 검색 (위키, 매뉴얼, 보고서)
- 관련 문서 추출 (관련도 점수순)
- 최대 5개 문서 선택
[LLM 설명 생성]
- 검색된 문서 내용 분석
- 용어 정의 추출
- 회의 맥락에 맞는 설명 생성
- 간단한 정의 (1-2문장)
- 상세 설명
- 사용 예시
- 참조 출처
[처리 결과]
- 용어 설명이 생성됨 (설명 ID)
- 설명 내용
- 간단한 정의
- 상세 설명
- 참조 문서 링크
- 툴팁 또는 사이드 패널로 표시
- 설명 제공 시간 기록
[설명을 찾지 못한 경우]
- "설명을 찾을 수 없습니다" 메시지 표시
- 전문가(회의 참석자)에게 설명 요청 버튼 제공
- 수동 입력된 설명은 용어 사전에 자동 저장
- S/21
---
6. Collaboration 서비스
1) 실시간 협업
UFR-COLLAB-010: [회의록수정동기화] 회의 참석자로서 | 나는, 회의록을 함께 검증하기 위해 | 회의록을 수정하고 실시간으로 다른 참석자와 동기화하고 싶다.
- 시나리오: 회의록 실시간 수정 및 동기화
회의록이 작성된 상황에서 | 참석자가 회의록 내용을 수정하면 | 수정 사항이 버전 관리되고 웹소켓을 통해 모든 참석자에게 즉시 동기화된다.
[회의록 수정 처리]
- 수정 내용 검증
- 수정 권한 확인
- 수정 범위 제한 (잠긴 섹션은 수정 불가)
- 수정 이력 저장
- 수정자
- 수정 시간
- 수정 전/후 내용
- 수정 위치
- 버전 관리
- 새 버전 번호 생성
- 이전 버전 보관
[실시간 동기화]
- 웹소켓을 통해 수정 델타 전송
- 전체 내용이 아닌 변경 부분만 전송 (효율성)
- 모든 참석자 화면에 실시간 반영
- 수정자 표시 (아바타, 이름)
- 수정 영역 하이라이트 (3초간)
[처리 결과]
- 참석자가 회의록을 수정함 (수정 ID)
- 수정 사항이 동기화됨
- 동기화 시간
- 영향받은 참석자 목록
[Policy/Rule]
- 회의록 수정 시 웹소켓을 통해 모든 참석자에게 즉시 동기화
- M/34
---
UFR-COLLAB-020: [충돌해결] 회의 참석자로서 | 나는, 동시 수정 상황에서도 내용을 잃지 않기 위해 | 충돌을 감지하고 해결하고 싶다.
- 시나리오: 동시 수정 충돌 해결
두 명의 참석자가 동일한 위치를 동시에 수정한 상황에서 | 시스템이 충돌을 감지하면 | 충돌 알림이 표시되고 해결 방법을 선택할 수 있다.
[충돌 감지]
- 동일 위치 동시 수정 탐지
- 라인 단위 비교
- 버전 기반 충돌 확인
- 충돌 정보 생성
- 충돌 위치
- 관련 수정자 2명
- 각자의 수정 내용
[충돌 해결 방식]
- Last Write Wins (기본)
- 가장 최근 수정이 우선
- 이전 수정은 버전 이력에 보관
- 수동 병합 (선택)
- 충돌 내용 비교 UI 표시
- 사용자가 최종 내용 선택
- A 선택 / B 선택 / 직접 작성
[처리 결과]
- 충돌이 감지됨 (충돌 ID)
- 충돌 위치
- 관련 수정자
- 충돌이 해결됨
- 해결 방법 (Last Write Wins / 수동 병합)
- 최종 내용
- 해결된 내용 실시간 동기화
[Policy/Rule]
- 동시 수정 발생 시 최종 수정이 우선 (Last Write Wins) 또는 충돌 알림
- M/21
---
UFR-COLLAB-030: [검증완료] 회의 참석자로서 | 나는, 회의록의 정확성을 보장하기 위해 | 주요 섹션을 검증하고 완료 표시를 하고 싶다.
- 시나리오: 회의록 검증 완료
회의록 내용을 확인한 상황에서 | 참석자가 검증 완료 버튼을 클릭하면 | 검증 상태가 업데이트되고 다른 참석자에게 동기화된다.
[검증 처리]
- 검증자 정보 기록
- 검증 시간 기록
- 검증 대상 섹션 기록
- 검증 상태 업데이트
- 미검증 → 검증 중 → 검증 완료
[섹션 잠금 기능]
- 주요 섹션 검증 완료 시 잠금 가능 (선택)
- 잠긴 섹션은 추가 수정 불가
- 잠금 해제는 검증자 또는 회의 주최자만 가능
[처리 결과]
- 검증이 완료됨
- 검증자 정보
- 검증 상태 (검증 완료)
- 완료 시간
- 검증 완료 상태 실시간 동기화
- 검증 배지 표시 (체크 아이콘)
[Policy/Rule]
- 주요 섹션 검증 완료 시 해당 섹션 잠금 가능
- M/8
---
7. Todo 서비스
1) Todo 관리
UFR-TODO-010: [Todo할당] Todo 시스템으로서 | 나는, AI가 추출한 Todo를 담당자에게 전달하기 위해 | Todo를 할당하고 알림을 발송하고 싶다.
- 시나리오: Todo 자동 할당
AI가 Todo를 추출한 상황에서 | 시스템이 Todo를 등록하고 담당자를 지정하면 | Todo가 할당되고 담당자에게 즉시 알림이 발송되며 캘린더에 마감일이 등록된다.
[Todo 등록]
- Todo 정보 저장
- Todo ID 생성
- Todo 내용
- 담당자 (AI 자동 식별 또는 수동 지정)
- 마감일 (언급된 경우 자동 설정, 없으면 수동 설정)
- 우선순위 (높음/보통/낮음)
- 관련 회의록 링크
[알림 발송]
- 담당자에게 즉시 알림
- 이메일
- 슬랙 (연동된 경우)
- 알림 내용
- Todo 내용
- 마감일
- 회의록 링크
[캘린더 연동]
- 마감일이 있는 경우 캘린더에 자동 등록
- 마감일 3일 전 리마인더 일정 생성
[처리 결과]
- Todo가 할당됨 (Todo ID)
- 담당자 정보
- 마감일
- 할당 시간
- 담당자에게 알림이 발송됨
- 캘린더 등록 완료
[Policy/Rule]
- Todo 할당 시 담당자에게 즉시 알림 발송
- M/13
---
UFR-TODO-020: [Todo진행상황업데이트] Todo 담당자로서 | 나는, Todo 진행 상황을 공유하기 위해 | 진행률을 업데이트하고 상태를 변경하고 싶다.
- 시나리오: Todo 진행 상황 업데이트
할당된 Todo가 있는 상황에서 | 담당자가 진행률과 상태를 입력하면 | 진행 상황이 저장되고 회의 주최자에게 알림이 발송된다.
[진행 상황 입력]
- 진행률: 0-100% (슬라이더 또는 직접 입력)
- 상태: 시작 전 / 진행 중 / 완료
- 메모: 진행 상황 설명 (선택)
[진행 상황 저장]
- 업데이트 시간 기록
- 진행률 히스토리 저장
- 상태 변경 이력 저장
[알림 발송]
- 회의 주최자에게 진행 상황 알림
- 진행률이 50%, 100%에 도달하면 자동 알림
[처리 결과]
- Todo 진행 상황이 업데이트됨
- 업데이트 시간
- 진행률 (%)
- 상태 (시작 전/진행 중/완료)
- 회의록에 진행 상황 반영
- M/5
---
UFR-TODO-030: [Todo완료처리] Todo 담당자로서 | 나는, 완료된 Todo를 처리하기 위해 | Todo를 완료하고 회의록에 자동 반영하고 싶다.
- 시나리오: Todo 완료 처리
Todo 작업이 완료된 상황에서 | 담당자가 완료 버튼을 클릭하면 | Todo가 완료 상태로 변경되고 회의록에 자동 반영되며 회의 주최자에게 알림이 발송된다.
[완료 처리]
- 완료 시간 자동 기록
- 완료자 정보 저장
- 완료 상태로 변경
- 완료 여부 확인 다이얼로그 표시
[회의록 반영]
- 관련 회의록의 Todo 섹션 업데이트
- 완료 표시 (체크 아이콘)
- 완료 시간 기록
[알림 발송]
- 회의 주최자에게 완료 알림
- 모든 Todo 완료 시 전체 완료 알림
[처리 결과]
- Todo가 완료됨
- 완료 시간
- 완료자 정보
- 회의록에 완료 상태가 반영됨
- 반영 시간
- 회의록 버전 업데이트
[Policy/Rule]
- Todo 완료 시 회의록에 완료 상태 자동 반영
- 모든 Todo 완료 시 회의 주최자에게 완료 알림
- M/8
---
8. Notification 서비스
1) 알림 관리
UFR-NOTI-010: [알림리마인더] 회의 참석자로서 | 나는, 중요한 일정을 놓치지 않기 위해 | 회의 및 Todo 관련 알림과 리마인더를 받고 싶다.
- 시나리오 1: 회의 알림
회의가 예약된 상황에서 | 회의 시작 30분 전이 되면 | 참석자에게 리마인더가 자동 발송된다.
[회의 알림 유형]
- 회의 초대: 회의 예약 시
- 회의 시작 리마인더: 30분 전
- 회의록 공유: 회의 종료 후
- 시나리오 2: Todo 알림
Todo가 할당된 상황에서 | 마감일 3일 전이 되면 | 담당자에게 리마인더가 자동 발송된다.
[Todo 알림 유형]
- Todo 할당: 할당 즉시
- 마감일 3일 전 리마인더
- 마감일 당일 리마인더
- 마감일 경과 긴급 알림 (미완료 시)
- Todo 완료: 완료 시
[알림 채널]
- 이메일 (기본)
- 슬랙 (연동 시)
- 인앱 알림
[알림 설정]
- 알림 채널 선택
- 알림 시간 설정
- 알림 끄기/켜기
[처리 결과]
- 알림이 발송됨 (알림 ID)
- 알림 대상 (이메일 주소, 슬랙 ID)
- 알림 내용
- 발송 시간
- 발송 채널
- 발송 상태 (성공/실패)
[Policy/Rule]
- 회의 시작 30분 전 리마인더 자동 발송
- 마감일 3일 전 자동 리마인더 발송
- 마감일 당일 미완료 시 긴급 알림 발송
- M/13
---
9. Calendar 서비스
1) 일정 관리
UFR-CAL-010: [일정연동] 회의록 작성자로서 | 나는, 일정을 통합 관리하기 위해 | 회의 및 다음 회의 일정을 외부 캘린더에 자동으로 연동하고 싶다.
- 시나리오 1: 회의 일정 자동 등록
회의가 예약된 상황에서 | 시스템이 일정 동기화를 요청하면 | 회의 일정이 Google Calendar, Outlook 등 외부 캘린더에 자동으로 등록된다.
[일정 등록 정보]
- 회의 제목
- 날짜 및 시간
- 장소
- 참석자 목록
- 회의록 링크 (메모)
- 시나리오 2: 다음 회의 일정 연동
회의록에서 다음 회의 일정이 언급된 상황에서 | 시스템이 자동으로 감지하면 | 다음 회의 일정이 캘린더에 자동으로 생성된다.
[자동 감지 키워드]
- "다음 회의: ~"
- "~에 다시 모이기로 함"
- "후속 회의 일정: ~"
[처리 결과]
- 일정이 캘린더에 연동됨 (일정 ID)
- 연동 상태 (성공/실패)
- 캘린더 종류 (Google Calendar, Outlook)
- 연동 시간
[지원 캘린더]
- Google Calendar
- Microsoft Outlook
- Apple Calendar
[Policy/Rule]
- 다음 회의 일정이 언급되면 자동으로 캘린더에 등록
- S/13
---
```

View File

@ -1,715 +0,0 @@
# 회의록별 대시보드 API 설계서
## 개요
### 목적
회의록이 확정된 후 회의 결과를 한눈에 파악할 수 있는 대시보드 데이터를 제공하는 API
### 버전
- API Version: v1.0
- 작성일: 2024-01-15
- 작성자: 이준호 (Backend Developer)
### 관련 유저스토리
- **UFR-MEET-070**: [회의록대시보드] 회의록 작성자로서 | 나는, 회의 결과를 한눈에 파악하기 위해 | 회의록별 대시보드를 통해 핵심 정보를 조회하고 싶다.
---
## API 엔드포인트
### 1. 대시보드 전체 데이터 조회
#### 요청
```http
GET /api/v1/meetings/{meeting_id}/dashboard
```
**Path Parameters**
| 이름 | 타입 | 필수 | 설명 |
|------|------|------|------|
| meeting_id | string (UUID) | Y | 회의 ID |
**Query Parameters**
| 이름 | 타입 | 필수 | 기본값 | 설명 |
|------|------|------|--------|------|
| include | string[] | N | all | 포함할 섹션 (key_points, decisions, todos, references) |
| todo_status | string | N | all | Todo 필터 (all, not_started, in_progress, completed) |
**Headers**
```http
Authorization: Bearer {access_token}
Content-Type: application/json
```
#### 응답
**Success (200 OK)**
```json
{
"meeting_id": "uuid-1234",
"meeting_title": "2024 Q4 마케팅 전략 회의",
"meeting_date": "2024-01-15T14:00:00Z",
"location": "본사 대회의실",
"participants_count": 5,
"key_points": {
"points": [
{
"id": "kp-001",
"order": 1,
"content": "Q4 마케팅 예산을 전년 대비 30% 증액하여 디지털 채널 확대에 집중하기로 결정",
"meeting_section_id": "section-123",
"timestamp": "2024-01-15T14:25:00Z"
},
{
"id": "kp-002",
"order": 2,
"content": "신규 인플루언서 마케팅 캠페인을 2월부터 시작하며, 타겟 연령층을 20-30대로 설정",
"meeting_section_id": "section-124",
"timestamp": "2024-01-15T14:35:00Z"
}
],
"keywords": [
{
"tag": "#디지털마케팅",
"count": 15
},
{
"tag": "#예산증액",
"count": 8
}
],
"statistics": {
"participants_count": 5,
"duration_minutes": 90,
"speech_count": 32,
"agenda_count": 8
}
},
"decisions": {
"items": [
{
"id": "decision-001",
"content": "Q4 마케팅 예산 30% 증액 승인 (총 3억 → 3.9억)",
"decider": {
"user_id": "user-001",
"name": "김민준",
"position": "마케팅 본부장"
},
"decided_at": "2024-01-15T14:25:00Z",
"background": "디지털 채널 성과가 예상을 상회하며, 경쟁사 대비 투자 비중이 낮아 시장 점유율 확대를 위해 예산 증액 필요",
"meeting_section_id": "section-123",
"related_todo_ids": ["todo-001", "todo-002"]
}
],
"total_count": 3
},
"todos": {
"summary": {
"total": 12,
"not_started": 3,
"in_progress": 6,
"completed": 3
},
"groups": [
{
"assignee": {
"user_id": "user-002",
"name": "박서연",
"position": "디지털 마케팅 팀장"
},
"todos": [
{
"todo_id": "todo-001",
"title": "인플루언서 후보 리스트 작성 및 제안서 준비",
"progress": 75,
"status": "in_progress",
"due_date": "2024-01-20T23:59:59Z",
"priority": "high",
"meeting_section_id": "section-124",
"last_updated_at": "2024-01-16T10:30:00Z"
}
],
"total_count": 4
}
]
},
"references": {
"related_meetings": {
"items": [
{
"meeting_id": "meeting-456",
"title": "2024 Q3 마케팅 전략 회의",
"date": "2023-12-20T14:00:00Z",
"author": {
"user_id": "user-001",
"name": "김민준"
},
"relevance_score": 92,
"summary": "이전 분기 마케팅 전략 회의로, 디지털 채널 투자 확대 방향성이 처음 논의되었으며, 예산 증액 근거 자료로 활용 가능"
}
],
"total_count": 3
},
"project_documents": {
"items": [
{
"document_id": "doc-789",
"type": "project",
"title": "Q4 디지털 마케팅 프로젝트 기획서",
"created_at": "2024-01-10T09:00:00Z",
"author": {
"user_id": "user-002",
"name": "박서연"
},
"relevance_score": 88,
"summary": "Q4 디지털 채널 확대 계획 및 예산 배분 전략이 상세히 기술되어 있음"
}
],
"total_count": 5
},
"issues": {
"items": [],
"total_count": 0
},
"wiki_pages": {
"items": [],
"total_count": 0
}
},
"generated_at": "2024-01-16T10:00:00Z"
}
```
**Error Responses**
```json
// 401 Unauthorized
{
"error": {
"code": "UNAUTHORIZED",
"message": "인증이 필요합니다."
}
}
// 403 Forbidden
{
"error": {
"code": "FORBIDDEN",
"message": "이 회의록에 접근 권한이 없습니다."
}
}
// 404 Not Found
{
"error": {
"code": "MEETING_NOT_FOUND",
"message": "회의를 찾을 수 없습니다."
}
}
// 500 Internal Server Error
{
"error": {
"code": "INTERNAL_SERVER_ERROR",
"message": "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요."
}
}
```
---
### 2. 핵심내용 조회
#### 요청
```http
GET /api/v1/meetings/{meeting_id}/dashboard/key-points
```
**Path Parameters**
| 이름 | 타입 | 필수 | 설명 |
|------|------|------|------|
| meeting_id | string (UUID) | Y | 회의 ID |
#### 응답
**Success (200 OK)**
```json
{
"meeting_id": "uuid-1234",
"points": [
{
"id": "kp-001",
"order": 1,
"content": "Q4 마케팅 예산을 전년 대비 30% 증액하여 디지털 채널 확대에 집중하기로 결정",
"meeting_section_id": "section-123",
"timestamp": "2024-01-15T14:25:00Z"
}
],
"keywords": [
{
"tag": "#디지털마케팅",
"count": 15
}
],
"statistics": {
"participants_count": 5,
"duration_minutes": 90,
"speech_count": 32,
"agenda_count": 8
},
"generated_at": "2024-01-16T10:00:00Z"
}
```
---
### 3. 결정사항 조회
#### 요청
```http
GET /api/v1/meetings/{meeting_id}/dashboard/decisions
```
**Path Parameters**
| 이름 | 타입 | 필수 | 설명 |
|------|------|------|------|
| meeting_id | string (UUID) | Y | 회의 ID |
**Query Parameters**
| 이름 | 타입 | 필수 | 기본값 | 설명 |
|------|------|------|--------|------|
| page | integer | N | 1 | 페이지 번호 |
| size | integer | N | 10 | 페이지 크기 (최대 50) |
#### 응답
**Success (200 OK)**
```json
{
"meeting_id": "uuid-1234",
"decisions": {
"items": [
{
"id": "decision-001",
"content": "Q4 마케팅 예산 30% 증액 승인 (총 3억 → 3.9억)",
"decider": {
"user_id": "user-001",
"name": "김민준",
"position": "마케팅 본부장"
},
"decided_at": "2024-01-15T14:25:00Z",
"background": "디지털 채널 성과가 예상을 상회하며...",
"meeting_section_id": "section-123",
"related_todo_ids": ["todo-001", "todo-002"]
}
],
"pagination": {
"current_page": 1,
"total_pages": 1,
"total_items": 3,
"page_size": 10
}
},
"generated_at": "2024-01-16T10:00:00Z"
}
```
---
### 4. Todo 진행상황 조회
#### 요청
```http
GET /api/v1/meetings/{meeting_id}/dashboard/todos
```
**Path Parameters**
| 이름 | 타입 | 필수 | 설명 |
|------|------|------|------|
| meeting_id | string (UUID) | Y | 회의 ID |
**Query Parameters**
| 이름 | 타입 | 필수 | 기본값 | 설명 |
|------|------|------|--------|------|
| status | string | N | all | Todo 상태 필터 (all, not_started, in_progress, completed) |
| assignee_id | string (UUID) | N | - | 담당자 ID 필터 |
#### 응답
**Success (200 OK)**
```json
{
"meeting_id": "uuid-1234",
"summary": {
"total": 12,
"not_started": 3,
"in_progress": 6,
"completed": 3
},
"groups": [
{
"assignee": {
"user_id": "user-002",
"name": "박서연",
"position": "디지털 마케팅 팀장"
},
"todos": [
{
"todo_id": "todo-001",
"title": "인플루언서 후보 리스트 작성 및 제안서 준비",
"progress": 75,
"status": "in_progress",
"due_date": "2024-01-20T23:59:59Z",
"priority": "high",
"meeting_section_id": "section-124",
"last_updated_at": "2024-01-16T10:30:00Z"
}
],
"total_count": 4
}
],
"generated_at": "2024-01-16T10:00:00Z"
}
```
---
### 5. 참고자료 조회
#### 요청
```http
GET /api/v1/meetings/{meeting_id}/dashboard/references
```
**Path Parameters**
| 이름 | 타입 | 필수 | 설명 |
|------|------|------|------|
| meeting_id | string (UUID) | Y | 회의 ID |
**Query Parameters**
| 이름 | 타입 | 필수 | 기본값 | 설명 |
|------|------|------|--------|------|
| type | string | N | all | 참고자료 타입 (all, meetings, documents, issues, wiki) |
| page | integer | N | 1 | 페이지 번호 |
| size | integer | N | 5 | 페이지 크기 (최대 20) |
#### 응답
**Success (200 OK)**
```json
{
"meeting_id": "uuid-1234",
"type": "all",
"related_meetings": {
"items": [
{
"meeting_id": "meeting-456",
"title": "2024 Q3 마케팅 전략 회의",
"date": "2023-12-20T14:00:00Z",
"author": {
"user_id": "user-001",
"name": "김민준"
},
"relevance_score": 92,
"summary": "이전 분기 마케팅 전략 회의로..."
}
],
"pagination": {
"current_page": 1,
"total_pages": 1,
"total_items": 3,
"page_size": 5
}
},
"project_documents": {
"items": [],
"pagination": {
"current_page": 1,
"total_pages": 0,
"total_items": 0,
"page_size": 5
}
},
"issues": {
"items": [],
"pagination": {
"current_page": 1,
"total_pages": 0,
"total_items": 0,
"page_size": 5
}
},
"wiki_pages": {
"items": [],
"pagination": {
"current_page": 1,
"total_pages": 0,
"total_items": 0,
"page_size": 5
}
},
"generated_at": "2024-01-16T10:00:00Z"
}
```
---
## 데이터 모델
### KeyPoint
```typescript
interface KeyPoint {
id: string; // 핵심 포인트 ID
order: number; // 순서 (1, 2, 3...)
content: string; // 핵심 내용 텍스트
meeting_section_id: string; // 회의록 섹션 ID (링크용)
timestamp: string; // ISO 8601 형식 (언급 시간)
}
```
### Keyword
```typescript
interface Keyword {
tag: string; // 키워드 태그 (#디지털마케팅)
count: number; // 언급 횟수
}
```
### Statistics
```typescript
interface Statistics {
participants_count: number; // 참석자 수
duration_minutes: number; // 회의 시간 (분)
speech_count: number; // 발언 횟수
agenda_count: number; // 주요 의제 수
}
```
### Decision
```typescript
interface Decision {
id: string; // 결정사항 ID
content: string; // 결정 내용
decider: User; // 결정자 정보
decided_at: string; // ISO 8601 형식 (결정 시간)
background: string; // 결정 근거/배경
meeting_section_id: string; // 회의록 섹션 ID
related_todo_ids: string[]; // 관련 Todo ID 배열
}
```
### User
```typescript
interface User {
user_id: string; // 사용자 ID
name: string; // 이름
position?: string; // 직책 (선택)
}
```
### TodoSummary
```typescript
interface TodoSummary {
total: number; // 전체 Todo 수
not_started: number; // 시작 전 수
in_progress: number; // 진행 중 수
completed: number; // 완료 수
}
```
### TodoGroup
```typescript
interface TodoGroup {
assignee: User; // 담당자 정보
todos: Todo[]; // Todo 배열
total_count: number; // 담당자의 전체 Todo 수
}
```
### Todo
```typescript
interface Todo {
todo_id: string; // Todo ID
title: string; // Todo 제목
progress: number; // 진행률 (0-100)
status: string; // 상태 (not_started, in_progress, completed)
due_date: string; // ISO 8601 형식 (마감일)
priority: string; // 우선순위 (low, medium, high, urgent)
meeting_section_id: string; // 회의록 섹션 ID
last_updated_at: string; // ISO 8601 형식 (최종 업데이트 시간)
}
```
### Reference
```typescript
interface Reference {
id: string; // 참고자료 ID
type: string; // 타입 (meeting, document, issue, wiki)
title: string; // 제목
date?: string; // ISO 8601 형식 (날짜)
created_at?: string; // ISO 8601 형식 (생성일)
author: User; // 작성자
relevance_score: number; // 관련도 점수 (0-100)
summary: string; // 요약 (100자 이내)
}
```
---
## 캐싱 전략
### Redis 캐싱
**대시보드 전체 데이터**
- Key: `dashboard:meeting:{meeting_id}`
- TTL: 30분
- 캐시 무효화: 회의록 수정, Todo 업데이트 시
**핵심내용**
- Key: `dashboard:keypoints:{meeting_id}`
- TTL: 1시간
- 캐시 무효화: 회의록 수정 시
**Todo 진행상황**
- Key: `dashboard:todos:{meeting_id}`
- TTL: 5분 (실시간 업데이트)
- 캐시 무효화: Todo 상태 변경 시
**참고자료**
- Key: `dashboard:references:{meeting_id}:{type}`
- TTL: 24시간
- 캐시 무효화: 매일 자동 업데이트
---
## 성능 최적화
### 응답 시간 목표
- 대시보드 전체 조회: < 500ms
- 개별 섹션 조회: < 200ms
### 최적화 전략
1. **병렬 처리**
- 각 섹션(핵심내용, 결정사항, Todo, 참고자료)을 병렬로 조회
- Promise.all 활용
2. **데이터 선택적 로딩**
- `include` 파라미터로 필요한 섹션만 조회
- 프론트엔드에서 탭 전환 시 필요한 데이터만 요청
3. **페이지네이션**
- 결정사항, 참고자료에 페이지네이션 적용
- 대량 데이터 조회 시 성능 저하 방지
4. **인덱싱**
- meeting_id, user_id, status 등 주요 필드에 인덱스 생성
---
## 보안
### 인증 및 권한
**인증 방식**
- JWT Bearer Token 인증
**권한 검증**
- 회의 참석자 또는 조직 멤버만 조회 가능
- 회의록 공유 권한 설정 준수
### Rate Limiting
```
- 사용자당: 100 requests/minute
- IP당: 200 requests/minute
```
---
## 에러 코드
| HTTP Status | Error Code | 설명 |
|-------------|-----------|------|
| 400 | INVALID_PARAMETER | 잘못된 파라미터 |
| 401 | UNAUTHORIZED | 인증 필요 |
| 403 | FORBIDDEN | 권한 없음 |
| 404 | MEETING_NOT_FOUND | 회의를 찾을 수 없음 |
| 404 | DASHBOARD_NOT_GENERATED | 대시보드 미생성 (회의록 미확정) |
| 429 | RATE_LIMIT_EXCEEDED | 요청 한도 초과 |
| 500 | INTERNAL_SERVER_ERROR | 서버 오류 |
| 503 | SERVICE_UNAVAILABLE | 서비스 일시 중단 |
---
## 테스트 시나리오
### 1. 정상 케이스
**시나리오**: 회의록 확정 후 대시보드 조회
1. 회의록 확정
2. AI가 대시보드 데이터 생성 (핵심내용, 결정사항 추출)
3. `GET /api/v1/meetings/{meeting_id}/dashboard` 호출
4. 200 OK 응답 확인
5. 모든 섹션 데이터 포함 확인
### 2. 캐싱 테스트
**시나리오**: 동일 대시보드 연속 조회
1. 첫 번째 조회 (DB 조회)
2. 두 번째 조회 (캐시 조회)
3. 응답 시간 비교 (캐시 조회가 50% 이상 빠름)
### 3. 실시간 업데이트 테스트
**시나리오**: Todo 진행상황 실시간 반영
1. 대시보드 조회
2. Todo 진행률 업데이트 (75% → 100%)
3. 대시보드 재조회
4. 변경된 진행률 확인
### 4. 에러 케이스
**시나리오**: 권한 없는 사용자 접근
1. 다른 사용자 계정으로 로그인
2. 회의 ID로 대시보드 조회
3. 403 Forbidden 응답 확인
---
## 변경 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|------|------|--------|-----------|
| 1.0 | 2024-01-15 | 이준호 | 회의록별 대시보드 API 초안 작성 |

File diff suppressed because it is too large Load Diff

View File

@ -3,349 +3,539 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그인 - 회의록 작성 및 공유 서비스</title>
<meta name="description" content="회의록 작성 및 공유 개선 서비스 - 로그인">
<title>로그인 - 회의록 작성 및 공유 개선 서비스</title>
<!-- CSS -->
<link rel="stylesheet" href="common.css">
<!-- Pretendard Font -->
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<style>
/* 페이지 전용 스타일 */
body {
/* 로그인 화면 특화 스타일 */
.login-container {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #00D9B1 0%, #6366F1 100%);
padding: var(--space-4);
background-color: var(--bg-secondary);
}
.login-card {
background-color: var(--color-white);
border-radius: var(--radius-xl);
padding: var(--spacing-10);
box-shadow: var(--shadow-lg);
.login-box {
width: 100%;
max-width: 480px;
margin: var(--spacing-4);
max-width: 400px;
background-color: var(--bg-primary);
border-radius: var(--radius-large);
padding: var(--space-8);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
animation: fade-in var(--duration-normal) ease-out;
}
.login-header {
text-align: center;
margin-bottom: var(--spacing-8);
@media (min-width: 768px) {
.login-box {
padding: var(--space-10);
}
}
/* 로고 영역 */
.login-logo {
width: 64px;
height: 64px;
margin: 0 auto var(--spacing-4);
background-color: var(--color-primary-main);
border-radius: var(--radius-lg);
text-align: center;
margin-bottom: var(--space-8);
}
.login-logo-icon {
width: 80px;
height: 80px;
margin: 0 auto var(--space-4);
background: linear-gradient(135deg, var(--primary-500), var(--primary-700));
border-radius: var(--radius-large);
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: var(--color-white);
font-weight: var(--font-weight-bold);
font-size: 2.5rem;
}
.login-title {
font-size: var(--font-size-h2);
font-weight: var(--font-weight-bold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-2);
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--space-2);
}
@media (min-width: 768px) {
.login-title {
font-size: 1.75rem;
}
}
.login-subtitle {
font-size: var(--font-size-body);
color: var(--color-gray-500);
font-size: 0.875rem;
color: var(--text-secondary);
}
#loginForm {
margin-bottom: var(--spacing-6);
/* 폼 영역 */
.login-form {
margin-bottom: var(--space-4);
}
.form-group {
margin-bottom: var(--spacing-5);
.login-form .input-group {
margin-bottom: var(--space-4);
}
.form-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-5);
.login-form .input-group:last-of-type {
margin-bottom: var(--space-6);
}
.checkbox-wrapper {
display: flex;
align-items: center;
gap: var(--spacing-2);
.login-button {
width: 100%;
margin-bottom: var(--space-4);
}
.checkbox-wrapper input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--color-primary-main);
}
.checkbox-wrapper label {
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
cursor: pointer;
}
.forgot-password {
font-size: var(--font-size-body-small);
color: var(--color-primary-main);
text-decoration: none;
transition: color var(--transition-fast);
}
.forgot-password:hover {
color: var(--color-primary-dark);
}
.login-footer {
/* LDAP 안내 */
.ldap-notice {
text-align: center;
padding-top: var(--spacing-6);
border-top: 1px solid var(--color-gray-200);
padding: var(--space-3);
background-color: var(--info-50);
border-radius: var(--radius-small);
border: var(--border-thin) solid var(--info-100);
}
.login-footer-text {
font-size: var(--font-size-body-small);
color: var(--color-gray-500);
.ldap-notice-text {
font-size: 0.75rem;
color: var(--info-700);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.login-footer a {
color: var(--color-primary-main);
font-weight: var(--font-weight-medium);
.ldap-notice-icon {
font-size: 1rem;
}
/* 로딩 상태 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
}
.loading-overlay.active {
display: flex;
}
.loading-content {
background-color: var(--bg-primary);
padding: var(--space-6);
border-radius: var(--radius-large);
text-align: center;
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.2);
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid var(--gray-200);
border-top-color: var(--primary-500);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto var(--space-4);
}
.loading-text {
font-size: 0.875rem;
color: var(--text-secondary);
}
/* 입력 필드 포커스 효과 강화 */
.input-field:focus {
border-color: var(--primary-500);
box-shadow: 0 0 0 3px rgba(0, 200, 150, 0.1);
transition: all var(--duration-fast) ease-in-out;
}
/* 에러 메시지 스타일 */
.input-error-message {
display: block;
min-height: 18px;
font-size: 0.75rem;
color: var(--error-500);
margin-top: var(--space-1);
}
/* 접근성: Skip to main content */
.skip-to-main {
position: absolute;
top: -40px;
left: 0;
background: var(--primary-500);
color: white;
padding: var(--space-2) var(--space-4);
text-decoration: none;
transition: color var(--transition-fast);
z-index: 100;
}
.login-footer a:hover {
color: var(--color-primary-dark);
}
/* 예시 크리덴셜 표시 */
.credential-hint {
background-color: var(--color-gray-50);
border: 1px dashed var(--color-gray-300);
border-radius: var(--radius-md);
padding: var(--spacing-3);
margin-bottom: var(--spacing-5);
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
}
.credential-hint-title {
font-weight: var(--font-weight-medium);
color: var(--color-gray-700);
margin-bottom: var(--spacing-2);
}
.credential-hint code {
background-color: var(--color-gray-200);
padding: 2px 6px;
border-radius: var(--radius-sm);
font-family: 'Consolas', monospace;
font-size: var(--font-size-caption);
}
/* 반응형 */
@media (max-width: 767px) {
.login-card {
padding: var(--spacing-6);
}
.login-title {
font-size: var(--font-size-h3);
}
.form-footer {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-3);
}
.skip-to-main:focus {
top: 0;
}
</style>
</head>
<body>
<div class="login-card">
<!-- 헤더 -->
<div class="login-header">
<div class="login-logo">M</div>
<h1 class="login-title">회의록 서비스</h1>
<p class="login-subtitle">스마트한 협업의 시작</p>
</div>
<!-- Skip to main content (접근성) -->
<a href="#main-content" class="skip-to-main">본문으로 바로가기</a>
<!-- 예시 크리덴셜 (프로토타입용) -->
<div class="credential-hint">
<div class="credential-hint-title">📝 테스트 계정</div>
<div>이메일: <code>test@example.com</code></div>
<div>비밀번호: <code>password123</code></div>
</div>
<!-- 로그인 폼 -->
<form id="loginForm">
<div class="form-group">
<label for="email" class="form-label">이메일</label>
<input
type="email"
id="email"
class="form-input"
placeholder="example@company.com"
required
autocomplete="email"
>
</div>
<div class="form-group">
<label for="password" class="form-label">비밀번호</label>
<input
type="password"
id="password"
class="form-input"
placeholder="비밀번호를 입력하세요"
required
autocomplete="current-password"
>
</div>
<div class="form-footer">
<div class="checkbox-wrapper">
<input type="checkbox" id="rememberMe">
<label for="rememberMe">로그인 상태 유지</label>
</div>
<a href="#" class="forgot-password">비밀번호 찾기</a>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">
로그인
</button>
</form>
<!-- 푸터 -->
<div class="login-footer">
<p class="login-footer-text">
아직 계정이 없으신가요? <a href="#">회원가입</a>
</p>
<!-- 로딩 오버레이 -->
<div class="loading-overlay" id="loadingOverlay" role="status" aria-live="polite" aria-label="로그인 진행중">
<div class="loading-content">
<div class="loading-spinner"></div>
<p class="loading-text">로그인 중입니다...</p>
</div>
</div>
<!-- 메인 컨텐츠 -->
<main id="main-content" class="login-container">
<div class="login-box">
<!-- 로고 및 타이틀 -->
<div class="login-logo">
<div class="login-logo-icon" role="img" aria-label="회의록 서비스 로고">
📝
</div>
<h1 class="login-title">회의록 작성 서비스</h1>
<p class="login-subtitle">효율적이고 정확한 회의록, 누구나 쉽게</p>
</div>
<!-- 로그인 폼 -->
<form id="loginForm" class="login-form" novalidate>
<!-- 사번 입력 -->
<div class="input-group">
<label for="employeeId" class="input-label required">사번</label>
<input
type="text"
id="employeeId"
name="employeeId"
class="input-field"
placeholder="사번을 입력하세요"
autocomplete="username"
required
aria-label="사번"
aria-describedby="employeeIdError"
aria-required="true"
>
<span id="employeeIdError" class="input-error-message" role="alert"></span>
</div>
<!-- 비밀번호 입력 -->
<div class="input-group">
<label for="password" class="input-label required">비밀번호</label>
<input
type="password"
id="password"
name="password"
class="input-field"
placeholder="비밀번호를 입력하세요"
autocomplete="current-password"
required
aria-label="비밀번호"
aria-describedby="passwordError"
aria-required="true"
>
<span id="passwordError" class="input-error-message" role="alert"></span>
</div>
<!-- 로그인 버튼 -->
<button
type="submit"
class="button button-primary button-large login-button"
id="loginButton"
>
로그인
</button>
</form>
<!-- LDAP 인증 안내 -->
<div class="ldap-notice" role="note">
<p class="ldap-notice-text">
<span class="ldap-notice-icon" aria-hidden="true">🔒</span>
<span>LDAP 연동 인증 시스템</span>
</p>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="common.js"></script>
<script>
// 로그인 폼 처리
const loginForm = document.getElementById('loginForm');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
const rememberMeCheckbox = document.getElementById('rememberMe');
/**
* 로그인 페이지 초기화 및 이벤트 핸들러
*/
(function() {
'use strict';
// 페이지 로드 시 저장된 이메일 불러오기
MeetingApp.ready(() => {
const savedEmail = MeetingApp.Storage.get('savedEmail');
if (savedEmail) {
emailInput.value = savedEmail;
rememberMeCheckbox.checked = true;
}
});
// DOM 엘리먼트
const loginForm = document.getElementById('loginForm');
const employeeIdInput = document.getElementById('employeeId');
const passwordInput = document.getElementById('password');
const loginButton = document.getElementById('loginButton');
const loadingOverlay = document.getElementById('loadingOverlay');
// 폼 제출 핸들러
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
// 에러 메시지 엘리먼트
const employeeIdError = document.getElementById('employeeIdError');
const passwordError = document.getElementById('passwordError');
// 에러 초기화
MeetingApp.Validator.clearError(emailInput);
MeetingApp.Validator.clearError(passwordInput);
// 예제 로그인 정보
const VALID_CREDENTIALS = {
employeeId: 'E2024001',
password: 'password123'
};
const email = emailInput.value.trim();
const password = passwordInput.value.trim();
/**
* 입력 필드 실시간 검증
*/
function setupRealtimeValidation() {
// 사번 입력 검증
employeeIdInput.addEventListener('blur', function() {
validateEmployeeId();
});
// 유효성 검사
let isValid = true;
employeeIdInput.addEventListener('input', function() {
// 입력 중에는 에러 클래스 제거
employeeIdInput.classList.remove('error');
employeeIdError.textContent = '';
});
if (!MeetingApp.Validator.required(email)) {
MeetingApp.Validator.showError(emailInput, '이메일을 입력해주세요.');
isValid = false;
} else if (!MeetingApp.Validator.isEmail(email)) {
MeetingApp.Validator.showError(emailInput, '올바른 이메일 형식이 아닙니다.');
isValid = false;
}
// 비밀번호 입력 검증
passwordInput.addEventListener('blur', function() {
validatePassword();
});
if (!MeetingApp.Validator.required(password)) {
MeetingApp.Validator.showError(passwordInput, '비밀번호를 입력해주세요.');
isValid = false;
} else if (!MeetingApp.Validator.minLength(password, 6)) {
MeetingApp.Validator.showError(passwordInput, '비밀번호는 최소 6자 이상이어야 합니다.');
isValid = false;
}
passwordInput.addEventListener('input', function() {
// 입력 중에는 에러 클래스 제거
passwordInput.classList.remove('error');
passwordError.textContent = '';
});
if (!isValid) return;
// 로딩 표시
const submitButton = loginForm.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
submitButton.disabled = true;
submitButton.innerHTML = '<div class="spinner spinner-sm" style="border-color: white; border-top-color: transparent;"></div>';
try {
// API 호출 시뮬레이션
await MeetingApp.API.post('/api/auth/login', { email, password });
// 로그인 성공 시뮬레이션 (테스트 계정 체크)
if (email === 'test@example.com' && password === 'password123') {
// 사용자 정보 저장
MeetingApp.Storage.set('currentUser', {
id: 'user-001',
name: '김민준',
email: email,
avatar: 'https://ui-avatars.com/api/?name=김민준&background=00D9B1&color=fff',
role: 'user'
});
// 로그인 상태 유지 체크
if (rememberMeCheckbox.checked) {
MeetingApp.Storage.set('savedEmail', email);
MeetingApp.Storage.set('rememberMe', true);
} else {
MeetingApp.Storage.remove('savedEmail');
MeetingApp.Storage.remove('rememberMe');
// Enter 키로 로그인 실행
employeeIdInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
passwordInput.focus();
}
});
// JWT 토큰 시뮬레이션
MeetingApp.Storage.set('authToken', 'mock-jwt-token-' + Date.now());
passwordInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
loginForm.dispatchEvent(new Event('submit'));
}
});
}
// 성공 토스트
MeetingApp.Toast.success('로그인에 성공했습니다!');
/**
* 사번 검증
*/
function validateEmployeeId() {
const value = employeeIdInput.value.trim();
// 대시보드로 이동
setTimeout(() => {
window.location.href = '02-대시보드.html';
}, 1000);
} else {
// 로그인 실패
MeetingApp.Toast.error('이메일 또는 비밀번호가 올바르지 않습니다.');
submitButton.disabled = false;
submitButton.textContent = originalText;
if (!value) {
showError(employeeIdInput, employeeIdError, '사번을 입력해주세요');
return false;
}
} catch (error) {
console.error('Login error:', error);
MeetingApp.Toast.error('로그인 중 오류가 발생했습니다. 다시 시도해주세요.');
submitButton.disabled = false;
submitButton.textContent = originalText;
// 사번 형식 검증 (E + 7자리 숫자)
const employeeIdPattern = /^E\d{7}$/;
if (!employeeIdPattern.test(value)) {
showError(employeeIdInput, employeeIdError, '올바른 사번 형식이 아닙니다 (예: E2024001)');
return false;
}
clearError(employeeIdInput, employeeIdError);
return true;
}
});
// 비밀번호 찾기 (프로토타입용)
document.querySelector('.forgot-password').addEventListener('click', (e) => {
e.preventDefault();
MeetingApp.Toast.info('비밀번호 찾기 기능은 준비 중입니다.');
});
/**
* 비밀번호 검증
*/
function validatePassword() {
const value = passwordInput.value;
// 회원가입 (프로토타입용)
document.querySelector('.login-footer a').addEventListener('click', (e) => {
e.preventDefault();
MeetingApp.Toast.info('회원가입 기능은 준비 중입니다.');
});
if (!value) {
showError(passwordInput, passwordError, '비밀번호를 입력해주세요');
return false;
}
if (value.length < 6) {
showError(passwordInput, passwordError, '비밀번호는 최소 6자 이상이어야 합니다');
return false;
}
clearError(passwordInput, passwordError);
return true;
}
/**
* 에러 표시
*/
function showError(inputElement, errorElement, message) {
inputElement.classList.add('error');
errorElement.textContent = message;
inputElement.setAttribute('aria-invalid', 'true');
}
/**
* 에러 제거
*/
function clearError(inputElement, errorElement) {
inputElement.classList.remove('error');
errorElement.textContent = '';
inputElement.setAttribute('aria-invalid', 'false');
}
/**
* 로딩 표시
*/
function showLoading() {
loadingOverlay.classList.add('active');
loginButton.disabled = true;
employeeIdInput.disabled = true;
passwordInput.disabled = true;
}
/**
* 로딩 숨김
*/
function hideLoading() {
loadingOverlay.classList.remove('active');
loginButton.disabled = false;
employeeIdInput.disabled = false;
passwordInput.disabled = false;
}
/**
* 로그인 처리
*/
function handleLogin(employeeId, password) {
// 로딩 표시
showLoading();
// 실제 환경에서는 API 호출
// 여기서는 시뮬레이션 (1.5초 지연)
setTimeout(function() {
// 인증 검증
if (employeeId === VALID_CREDENTIALS.employeeId &&
password === VALID_CREDENTIALS.password) {
// 로그인 성공
// 사용자 정보 저장
const userData = {
id: 1,
employeeId: employeeId,
name: '김민준',
email: 'minjun.kim@company.com',
role: 'Product Owner',
loginTime: new Date().toISOString()
};
// 로컬 스토리지에 저장
localStorage.setItem('currentUser', JSON.stringify(userData));
localStorage.setItem('isLoggedIn', 'true');
localStorage.setItem('authToken', 'mock-jwt-token-' + Date.now());
// 성공 메시지 표시
showToast('로그인 성공! 대시보드로 이동합니다', 'success', 1500);
// 대시보드로 이동 (1.5초 후)
setTimeout(function() {
navigateTo('02-대시보드.html');
}, 1500);
} else {
// 로그인 실패
hideLoading();
// 실패 메시지 표시
showToast('사번 또는 비밀번호가 올바르지 않습니다', 'error', 3000);
// 비밀번호 필드 초기화 및 포커스
passwordInput.value = '';
passwordInput.focus();
// 입력 필드에 에러 표시
showError(employeeIdInput, employeeIdError, '');
showError(passwordInput, passwordError, '인증에 실패했습니다');
}
}, 1500);
}
/**
* 폼 제출 이벤트 핸들러
*/
function handleSubmit(e) {
e.preventDefault();
// 입력 검증
const isEmployeeIdValid = validateEmployeeId();
const isPasswordValid = validatePassword();
if (!isEmployeeIdValid || !isPasswordValid) {
// 검증 실패 시 첫 번째 에러 필드로 포커스
if (!isEmployeeIdValid) {
employeeIdInput.focus();
} else if (!isPasswordValid) {
passwordInput.focus();
}
return;
}
// 로그인 처리
const employeeId = employeeIdInput.value.trim();
const password = passwordInput.value;
handleLogin(employeeId, password);
}
/**
* 초기화
*/
function init() {
// 이미 로그인되어 있는지 확인
const isLoggedIn = localStorage.getItem('isLoggedIn');
if (isLoggedIn === 'true') {
// 이미 로그인된 경우 대시보드로 리다이렉트
navigateTo('02-대시보드.html');
return;
}
// 이벤트 리스너 등록
setupRealtimeValidation();
loginForm.addEventListener('submit', handleSubmit);
// 첫 번째 입력 필드에 포커스
employeeIdInput.focus();
// 페이드인 효과
document.body.style.opacity = '1';
// 개발 모드 안내 (콘솔)
console.log('%c로그인 테스트 정보', 'color: #00C896; font-size: 14px; font-weight: bold;');
console.log('사번: E2024001');
console.log('비밀번호: password123');
}
// DOM 로드 완료 시 초기화
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -3,131 +3,610 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의 예약 - 회의록 서비스</title>
<meta name="description" content="회의록 작성 및 공유 개선 서비스 - 회의 예약">
<title>회의 예약 - 회의록 작성 서비스</title>
<!-- CSS -->
<link rel="stylesheet" href="common.css">
<!-- Pretendard Font -->
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<style>
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 800px;
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-4);
min-height: 100vh;
background-color: var(--bg-secondary);
padding-bottom: var(--space-8);
}
.page-header {
margin-bottom: var(--spacing-8);
}
.page-title {
font-size: var(--font-size-h1);
color: var(--color-gray-900);
margin-bottom: var(--spacing-2);
}
.page-subtitle {
font-size: var(--font-size-body);
color: var(--color-gray-500);
}
.form-container {
background-color: var(--color-white);
border-radius: var(--radius-lg);
padding: var(--spacing-8);
box-shadow: var(--shadow-sm);
}
.button-group {
/* 헤더 */
.header {
position: sticky;
top: 0;
background-color: var(--bg-primary);
border-bottom: var(--border-thin) solid var(--gray-200);
padding: var(--space-4);
display: flex;
gap: var(--spacing-3);
margin-top: var(--spacing-6);
align-items: center;
justify-content: space-between;
z-index: 10;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
@media (max-width: 767px) {
.page-title { font-size: var(--font-size-h2); }
.form-container { padding: var(--spacing-5); }
.button-group { flex-direction: column; }
.header-left {
display: flex;
align-items: center;
gap: var(--space-3);
}
.back-button {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
background-color: transparent;
border: none;
cursor: pointer;
font-size: 1.25rem;
}
.back-button:hover {
background-color: var(--gray-100);
}
.header-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
}
/* 폼 영역 */
.form-container {
max-width: 600px;
margin: 0 auto;
padding: var(--space-4);
}
.form-section {
background-color: var(--bg-primary);
border-radius: var(--radius-large);
padding: var(--space-6);
margin-bottom: var(--space-4);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.form-section-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-6);
}
.form-group {
margin-bottom: var(--space-5);
}
.form-group:last-child {
margin-bottom: 0;
}
.datetime-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
/* 참석자 칩 */
.attendee-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-2);
background-color: var(--primary-50);
color: var(--primary-700);
border: var(--border-thin) solid var(--primary-200);
border-radius: var(--radius-full);
padding: var(--space-1) var(--space-3);
font-size: 0.875rem;
animation: fade-in var(--duration-fast) ease-out;
}
.chip-remove {
cursor: pointer;
color: var(--primary-500);
font-weight: 600;
font-size: 1rem;
line-height: 1;
transition: color var(--duration-instant) ease-in-out;
}
.chip-remove:hover {
color: var(--error-500);
}
.add-attendee-group {
display: flex;
gap: var(--space-2);
}
.add-attendee-input {
flex: 1;
}
/* 체크박스 */
.checkbox-wrapper {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
}
.custom-checkbox {
width: 20px;
height: 20px;
border: var(--border-medium) solid var(--gray-300);
border-radius: var(--radius-small);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--duration-instant) ease-in-out;
}
.custom-checkbox.checked {
background-color: var(--primary-500);
border-color: var(--primary-500);
}
.custom-checkbox.checked::after {
content: '✓';
color: white;
font-size: 0.875rem;
font-weight: 600;
}
.checkbox-label {
font-size: 0.875rem;
color: var(--text-secondary);
cursor: pointer;
}
/* 제출 버튼 */
.submit-section {
max-width: 600px;
margin: 0 auto;
padding: 0 var(--space-4);
}
.submit-button {
width: 100%;
height: 56px;
font-size: 1rem;
font-weight: 600;
}
/* 헬퍼 텍스트 */
.helper-text {
font-size: 0.75rem;
color: var(--text-tertiary);
margin-top: var(--space-1);
}
@media (min-width: 768px) {
.form-container {
padding: var(--space-6);
}
.datetime-group {
grid-template-columns: 2fr 1fr;
}
}
</style>
</head>
<body>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">회의 예약</h1>
<p class="page-subtitle">새로운 회의를 예약하고 참석자를 초대하세요</p>
</div>
<!-- 헤더 -->
<header class="header">
<div class="header-left">
<button class="back-button" onclick="goBack()" aria-label="뒤로가기">
</button>
<h1 class="header-title">회의 예약</h1>
</div>
<button class="button button-ghost button-small" onclick="handleSaveDraft()">
임시저장
</button>
</header>
<!---->
<div class="form-container">
<form id="meetingForm">
<div class="form-group">
<label for="title" class="form-label">회의 제목 *</label>
<input type="text" id="title" class="form-input" placeholder="예: 2025년 1분기 기획 회의" required maxlength="100">
<form id="meetingForm" novalidate>
<!-- 기본 정보 -->
<div class="form-section">
<h2 class="form-section-title">기본 정보</h2>
<!-- 회의 제목 -->
<div class="form-group">
<label for="meetingTitle" class="input-label required">회의 제목</label>
<input
type="text"
id="meetingTitle"
class="input-field"
placeholder="예: 프로젝트 킥오프 미팅"
maxlength="100"
required
aria-label="회의 제목"
aria-describedby="meetingTitleError"
>
<span id="meetingTitleError" class="input-error-message" role="alert"></span>
<p class="helper-text">최대 100자까지 입력 가능합니다</p>
</div>
<!-- 날짜 및 시간 -->
<div class="form-group">
<label class="input-label required">날짜 및 시간</label>
<div class="datetime-group">
<div>
<input
type="date"
id="meetingDate"
class="input-field"
required
aria-label="회의 날짜"
aria-describedby="meetingDateError"
>
<span id="meetingDateError" class="input-error-message" role="alert"></span>
</div>
<div>
<input
type="time"
id="meetingTime"
class="input-field"
required
aria-label="회의 시간"
aria-describedby="meetingTimeError"
>
<span id="meetingTimeError" class="input-error-message" role="alert"></span>
</div>
</div>
</div>
<!-- 장소 -->
<div class="form-group">
<label for="meetingLocation" class="input-label">장소 (선택)</label>
<input
type="text"
id="meetingLocation"
class="input-field"
placeholder="예: 회의실 A 또는 온라인"
maxlength="200"
aria-label="회의 장소"
>
<p class="helper-text">최대 200자까지 입력 가능합니다</p>
</div>
</div>
<div class="form-group">
<label for="date" class="form-label">날짜 *</label>
<input type="date" id="date" class="form-input" required>
<!-- 참석자 -->
<div class="form-section">
<h2 class="form-section-title">참석자</h2>
<div class="form-group">
<label class="input-label required">참석자 목록</label>
<div id="attendeeChips" class="attendee-chips">
<!-- 동적 생성 -->
</div>
<div class="add-attendee-group">
<input
type="email"
id="attendeeEmail"
class="input-field add-attendee-input"
placeholder="이메일 주소 입력 후 Enter 또는 추가 버튼"
aria-label="참석자 이메일"
>
<button type="button" class="button button-primary" onclick="handleAddAttendee()">
추가
</button>
</div>
<span id="attendeeError" class="input-error-message" role="alert"></span>
<p class="helper-text">최소 1명 이상의 참석자를 추가해주세요</p>
</div>
</div>
<div class="form-group">
<label for="time" class="form-label">시간 *</label>
<input type="time" id="time" class="form-input" required>
</div>
<!-- 리마인더 -->
<div class="form-section">
<h2 class="form-section-title">알림 설정</h2>
<div class="form-group">
<label for="location" class="form-label">장소</label>
<input type="text" id="location" class="form-input" placeholder="예: 본사 2층 대회의실" maxlength="200">
</div>
<div class="form-group">
<label for="attendees" class="form-label">참석자 (이메일, 쉼표로 구분) *</label>
<input type="text" id="attendees" class="form-input" placeholder="예: user1@example.com, user2@example.com" required>
</div>
<div class="form-group">
<label for="description" class="form-label">회의 설명</label>
<textarea id="description" class="form-textarea" placeholder="회의 목적과 안건을 간략히 작성하세요"></textarea>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary" style="flex: 1;">회의 예약하기</button>
<button type="button" class="btn btn-secondary" onclick="history.back()">취소</button>
<div class="form-group">
<div class="checkbox-wrapper" onclick="toggleReminder()">
<div id="reminderCheckbox" class="custom-checkbox checked"></div>
<label class="checkbox-label">회의 시작 30분 전 리마인더 발송</label>
</div>
</div>
</div>
</form>
</div>
<!-- 제출 버튼 -->
<div class="submit-section">
<button class="button button-primary submit-button" onclick="handleSubmit()">
회의 예약하기
</button>
</div>
</div>
<!-- JavaScript -->
<script src="common.js"></script>
<script>
const form = document.getElementById('meetingForm');
(function() {
'use strict';
// 최소 날짜를 오늘로 설정
document.getElementById('date').min = new Date().toISOString().split('T')[0];
let attendees = [];
let reminderEnabled = true;
form.addEventListener('submit', async (e) => {
e.preventDefault();
// 초기화
function init() {
setupEventListeners();
setMinDate();
loadDraft();
}
const title = document.getElementById('title').value.trim();
const date = document.getElementById('date').value;
const time = document.getElementById('time').value;
const location = document.getElementById('location').value.trim();
const attendees = document.getElementById('attendees').value.trim();
const description = document.getElementById('description').value.trim();
// 이벤트 리스너 설정
function setupEventListeners() {
const attendeeInput = $('#attendeeEmail');
attendeeInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddAttendee();
}
});
// 새 회의 생성
const newMeeting = {
id: 'm-' + Date.now(),
title,
date: `${date} ${time}`,
location: location || '미정',
status: 'scheduled',
attendees: attendees.split(',').map(email => email.trim()),
description: description || ''
// 실시간 검증
setupRealtimeValidation($('#meetingTitle'));
setupRealtimeValidation($('#meetingDate'));
setupRealtimeValidation($('#meetingTime'));
}
// 최소 날짜 설정 (오늘)
function setMinDate() {
const today = new Date().toISOString().split('T')[0];
$('#meetingDate').setAttribute('min', today);
$('#meetingDate').value = today;
const currentTime = new Date();
const hours = String(currentTime.getHours()).padStart(2, '0');
const minutes = String(currentTime.getMinutes()).padStart(2, '0');
$('#meetingTime').value = `${hours}:${minutes}`;
}
// 참석자 추가
window.handleAddAttendee = function() {
const emailInput = $('#attendeeEmail');
const email = emailInput.value.trim();
const errorElement = $('#attendeeError');
if (!email) {
return;
}
if (!validateEmail(email)) {
errorElement.textContent = '올바른 이메일 주소를 입력해주세요';
addClass(emailInput, 'error');
return;
}
if (attendees.includes(email)) {
errorElement.textContent = '이미 추가된 참석자입니다';
addClass(emailInput, 'error');
return;
}
attendees.push(email);
emailInput.value = '';
removeClass(emailInput, 'error');
errorElement.textContent = '';
renderAttendees();
saveDraft();
};
// 저장
const meetings = MeetingApp.Storage.get('meetings', []);
meetings.unshift(newMeeting);
MeetingApp.Storage.set('meetings', meetings);
// 참석자 제거
window.handleRemoveAttendee = function(email) {
attendees = attendees.filter(a => a !== email);
renderAttendees();
saveDraft();
};
MeetingApp.Toast.success('회의가 예약되었습니다!');
// 참석자 렌더링
function renderAttendees() {
const chipsContainer = $('#attendeeChips');
setTimeout(() => {
window.location.href = '04-템플릿선택.html?meetingId=' + newMeeting.id;
}, 1000);
});
if (attendees.length === 0) {
chipsContainer.innerHTML = '<p class="helper-text">참석자를 추가해주세요</p>';
return;
}
chipsContainer.innerHTML = attendees.map(email => `
<div class="chip">
<span>${email}</span>
<span class="chip-remove" onclick="handleRemoveAttendee('${email}')" aria-label="${email} 제거">×</span>
</div>
`).join('');
}
// 리마인더 토글
window.toggleReminder = function() {
reminderEnabled = !reminderEnabled;
const checkbox = $('#reminderCheckbox');
if (reminderEnabled) {
addClass(checkbox, 'checked');
} else {
removeClass(checkbox, 'checked');
}
};
// 임시 저장
window.handleSaveDraft = function() {
saveDraft();
showToast('임시 저장되었습니다', 'success');
};
function saveDraft() {
const draft = {
title: $('#meetingTitle').value,
date: $('#meetingDate').value,
time: $('#meetingTime').value,
location: $('#meetingLocation').value,
attendees: attendees,
reminderEnabled: reminderEnabled,
savedAt: new Date().toISOString()
};
saveData('meetingDraft', draft);
}
// 임시 저장 불러오기
function loadDraft() {
const draft = loadData('meetingDraft');
if (!draft) return;
// 30분 이내 임시 저장만 복원
const savedTime = new Date(draft.savedAt);
const now = new Date();
const diffMinutes = (now - savedTime) / (1000 * 60);
if (diffMinutes > 30) {
removeData('meetingDraft');
return;
}
$('#meetingTitle').value = draft.title || '';
$('#meetingDate').value = draft.date || '';
$('#meetingTime').value = draft.time || '';
$('#meetingLocation').value = draft.location || '';
attendees = draft.attendees || [];
reminderEnabled = draft.reminderEnabled !== false;
renderAttendees();
if (!reminderEnabled) {
removeClass($('#reminderCheckbox'), 'checked');
}
showToast('임시 저장된 내용을 불러왔습니다', 'info');
}
// 폼 검증
function validateForm() {
let isValid = true;
// 제목
const title = $('#meetingTitle').value.trim();
if (!title) {
showError($('#meetingTitle'), $('#meetingTitleError'), '회의 제목을 입력해주세요');
isValid = false;
}
// 날짜
const date = $('#meetingDate').value;
if (!date) {
showError($('#meetingDate'), $('#meetingDateError'), '날짜를 선택해주세요');
isValid = false;
} else {
const selectedDate = new Date(date);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (selectedDate < today) {
showError($('#meetingDate'), $('#meetingDateError'), '과거 날짜는 선택할 수 없습니다');
isValid = false;
}
}
// 시간
const time = $('#meetingTime').value;
if (!time) {
showError($('#meetingTime'), $('#meetingTimeError'), '시간을 선택해주세요');
isValid = false;
}
// 참석자
if (attendees.length === 0) {
showError($('#attendeeEmail'), $('#attendeeError'), '최소 1명 이상의 참석자를 추가해주세요');
isValid = false;
}
return isValid;
}
function showError(inputElement, errorElement, message) {
addClass(inputElement, 'error');
errorElement.textContent = message;
}
// 제출
window.handleSubmit = function() {
if (!validateForm()) {
showToast('입력 항목을 확인해주세요', 'error');
return;
}
// 로딩 표시
showToast('회의를 예약하고 있습니다...', 'info', 1500);
// 회의 예약 처리 (시뮬레이션)
setTimeout(() => {
const newMeeting = {
id: Date.now(),
title: $('#meetingTitle').value.trim(),
date: $('#meetingDate').value,
time: $('#meetingTime').value,
location: $('#meetingLocation').value.trim() || '미정',
attendees: attendees,
status: 'draft',
progress: 0,
sections: [],
todos: [],
keywords: [],
reminderEnabled: reminderEnabled,
createdAt: new Date().toISOString()
};
// 저장
const meetings = loadData('meetings') || [];
meetings.unshift(newMeeting);
saveData('meetings', meetings);
// 임시 저장 삭제
removeData('meetingDraft');
// 성공 메시지
showToast('회의 예약이 완료되었습니다', 'success', 2000);
// 템플릿 선택 화면으로 이동
setTimeout(() => {
saveData('currentMeetingId', newMeeting.id);
navigateTo('04-템플릿선택.html');
}, 2000);
}, 1500);
};
// 초기화
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -3,181 +3,515 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>검증 완료 - 회의록 서비스</title>
<meta name="description" content="회의록 작성 및 공유 개선 서비스 - 검증완료">
<title>회의록 검증 - 회의록 작성 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 800px;
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-4);
}
.completion-icon {
text-align: center;
font-size: 80px;
margin-bottom: var(--spacing-6);
}
.page-title {
font-size: var(--font-size-h1);
color: var(--color-gray-900);
margin-bottom: var(--spacing-3);
text-align: center;
}
.page-subtitle {
font-size: var(--font-size-body);
color: var(--color-gray-500);
text-align: center;
margin-bottom: var(--spacing-8);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--spacing-4);
margin-bottom: var(--spacing-8);
}
.stat-card {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-5);
text-align: center;
}
.stat-value {
font-size: var(--font-size-h2);
font-weight: var(--font-weight-bold);
color: var(--color-primary-main);
margin-bottom: var(--spacing-2);
}
.stat-label {
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
}
.summary-card {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
margin-bottom: var(--spacing-6);
}
.summary-title {
font-size: var(--font-size-h4);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-4);
}
.keyword-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
}
.keyword-tag {
padding: var(--spacing-2) var(--spacing-3);
background-color: var(--color-primary-light);
color: var(--color-primary-dark);
border-radius: var(--radius-md);
font-size: var(--font-size-body-small);
font-weight: var(--font-weight-medium);
}
.action-buttons {
display: flex;
gap: var(--spacing-3);
justify-content: center;
}
@media (max-width: 767px) {
.completion-icon { font-size: 60px; }
.page-title { font-size: var(--font-size-h2); }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.action-buttons { flex-direction: column; }
.action-buttons .btn { width: 100%; }
}
</style>
</head>
<body>
<div class="page-container">
<div class="completion-icon"></div>
<h1 class="page-title">AI 검증이 완료되었습니다</h1>
<p class="page-subtitle">회의 내용이 분석되었습니다. 통계를 확인하고 회의를 종료하세요</p>
<!-- Skip to Main Content (접근성) -->
<a href="#main-content" class="skip-to-main">본문으로 건너뛰기</a>
<!-- 통계 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">45분</div>
<div class="stat-label">회의 시간</div>
</div>
<div class="stat-card">
<div class="stat-value">3명</div>
<div class="stat-label">참석자</div>
</div>
<div class="stat-card">
<div class="stat-value">12회</div>
<div class="stat-label">발언 횟수</div>
</div>
<div class="stat-card">
<div class="stat-value">5개</div>
<div class="stat-label">Todo 생성</div>
</div>
</div>
<!-- 주요 키워드 -->
<div class="summary-card">
<h2 class="summary-title">주요 키워드</h2>
<div class="keyword-list">
<span class="keyword-tag">신규 기능</span>
<span class="keyword-tag">개발 일정</span>
<span class="keyword-tag">API 설계</span>
<span class="keyword-tag">예산</span>
<span class="keyword-tag">테스트</span>
<span class="keyword-tag">배포</span>
<span class="keyword-tag">마케팅</span>
</div>
</div>
<!-- 발언 분포 -->
<div class="summary-card">
<h2 class="summary-title">발언 분포</h2>
<div style="margin-bottom: var(--spacing-3);">
<div style="display: flex; justify-content: space-between; margin-bottom: var(--spacing-1);">
<span style="font-size: var(--font-size-body-small); color: var(--color-gray-600);">김민준</span>
<span style="font-size: var(--font-size-body-small); color: var(--color-gray-600);">5회 (42%)</span>
</div>
<div style="height: 8px; background-color: var(--color-gray-200); border-radius: var(--radius-sm); overflow: hidden;">
<div style="width: 42%; height: 100%; background-color: var(--color-primary-main);"></div>
</div>
</div>
<div style="margin-bottom: var(--spacing-3);">
<div style="display: flex; justify-content: space-between; margin-bottom: var(--spacing-1);">
<span style="font-size: var(--font-size-body-small); color: var(--color-gray-600);">박서연</span>
<span style="font-size: var(--font-size-body-small); color: var(--color-gray-600);">4회 (33%)</span>
</div>
<div style="height: 8px; background-color: var(--color-gray-200); border-radius: var(--radius-sm); overflow: hidden;">
<div style="width: 33%; height: 100%; background-color: var(--color-secondary-main);"></div>
</div>
</div>
<div>
<div style="display: flex; justify-content: space-between; margin-bottom: var(--spacing-1);">
<span style="font-size: var(--font-size-body-small); color: var(--color-gray-600);">이준호</span>
<span style="font-size: var(--font-size-body-small); color: var(--color-gray-600);">3회 (25%)</span>
</div>
<div style="height: 8px; background-color: var(--color-gray-200); border-radius: var(--radius-sm); overflow: hidden;">
<div style="width: 25%; height: 100%; background-color: var(--color-info-main);"></div>
</div>
</div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons">
<button class="btn btn-secondary" onclick="history.back()">회의로 돌아가기</button>
<button class="btn btn-primary" onclick="window.location.href='07-회의종료.html'">
회의 종료하기
<!-- Header -->
<header style="position: sticky; top: 0; background: var(--bg-primary); border-bottom: var(--border-thin) solid var(--gray-200); z-index: 10; padding: var(--space-4);">
<div style="display: flex; align-items: center; justify-content: space-between; max-width: 1440px; margin: 0 auto;">
<button class="button-icon button-ghost" onclick="goBack()" aria-label="뒤로가기">
<span style="font-size: 24px;"></span>
</button>
<h1 class="h4" style="margin: 0;">회의록 검증</h1>
<button class="button-primary button-small" onclick="proceedToEnd()" aria-label="다음 단계" id="next-button">
다음
</button>
</div>
</header>
<!-- Main Content -->
<main id="main-content" class="container" style="padding-top: var(--space-4); padding-bottom: var(--space-6); max-width: 1024px;">
<!-- Progress Section -->
<section aria-labelledby="progress-section" style="margin-bottom: var(--space-6);">
<div style="margin-bottom: var(--space-3);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-2);">
<h2 class="h4" id="progress-section">전체 진행률</h2>
<span class="h4" id="progress-text" style="color: var(--primary-500);">60% (3/5)</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill" style="width: 60%;"></div>
</div>
</div>
<p class="text-body" style="color: var(--text-tertiary);">
회의록 섹션별로 검증해주세요. 모든 섹션이 검증되면 회의를 종료할 수 있습니다.
</p>
</section>
<!-- Verification Sections -->
<section aria-labelledby="sections-title" style="margin-bottom: var(--space-6);">
<h2 class="h4" id="sections-title" style="margin-bottom: var(--space-4);">섹션별 검증</h2>
<!-- 참석자 섹션 (검증완료) -->
<div class="card" style="margin-bottom: var(--space-3);" data-section="attendees" data-verified="true">
<div class="card-header" style="border-bottom: var(--border-thin) solid var(--gray-200); padding-bottom: var(--space-3);">
<div style="display: flex; justify-content: between; align-items: center; margin-bottom: var(--space-2);">
<h3 class="h4" style="margin: 0; flex: 1;">✅ 참석자</h3>
<span class="badge badge-verified">검증완료</span>
</div>
<div style="display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center;">
<span class="text-caption" style="color: var(--text-tertiary);">검증자: 김민준</span>
<span class="text-caption" style="color: var(--text-tertiary);"></span>
<span class="text-caption" style="color: var(--text-tertiary);">시간: 14:35</span>
</div>
</div>
<div class="card-body">
<p class="text-body" style="margin: var(--space-2) 0;">- 김민준 (주관자)</p>
<p class="text-body" style="margin: var(--space-2) 0;">- 박서연</p>
<p class="text-body" style="margin: var(--space-2) 0;">- 이준호</p>
</div>
<div class="card-footer">
<button class="button-secondary button-small" onclick="editSection('attendees')">
수정
</button>
<button class="button-ghost button-small" onclick="lockSection('attendees')" aria-label="섹션 잠금" title="회의 생성자만 사용 가능">
🔒 잠금
</button>
</div>
</div>
<!-- 안건 섹션 (검증 필요) -->
<div class="card" style="margin-bottom: var(--space-3);" data-section="agenda" data-verified="false">
<div class="card-header" style="border-bottom: var(--border-thin) solid var(--gray-200); padding-bottom: var(--space-3);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3 class="h4" style="margin: 0;">⚠️ 안건</h3>
<span class="badge badge-pending">검증 필요</span>
</div>
</div>
<div class="card-body">
<p class="text-body" style="margin: var(--space-2) 0;">- 프로젝트 목표 정의</p>
<p class="text-body" style="margin: var(--space-2) 0;">- 일정 및 마일스톤</p>
</div>
<div class="card-footer">
<button class="button-secondary button-small" onclick="editSection('agenda')">
수정
</button>
<button class="button-primary button-small" onclick="verifySection('agenda')">
✓ 검증완료
</button>
</div>
</div>
<!-- 논의 내용 섹션 (검증 필요) -->
<div class="card" style="margin-bottom: var(--space-3);" data-section="discussion" data-verified="false">
<div class="card-header" style="border-bottom: var(--border-thin) solid var(--gray-200); padding-bottom: var(--space-3);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3 class="h4" style="margin: 0;">⚠️ 논의 내용</h3>
<span class="badge badge-pending">검증 필요</span>
</div>
</div>
<div class="card-body">
<p class="text-body">
우리는 Q1까지 MVP를 완성해야 합니다. 개발 프레임워크는 React를 사용하고, 배포 환경은 AWS로 결정했습니다.
Sprint 주기는 2주로 설정합니다.
</p>
</div>
<div class="card-footer">
<button class="button-secondary button-small" onclick="editSection('discussion')">
수정
</button>
<button class="button-primary button-small" onclick="verifySection('discussion')">
✓ 검증완료
</button>
</div>
</div>
<!-- 결정 사항 섹션 (검증완료) -->
<div class="card" style="margin-bottom: var(--space-3);" data-section="decisions" data-verified="true">
<div class="card-header" style="border-bottom: var(--border-thin) solid var(--gray-200); padding-bottom: var(--space-3);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-2);">
<h3 class="h4" style="margin: 0; flex: 1;">✅ 결정 사항</h3>
<span class="badge badge-verified">검증완료</span>
</div>
<div style="display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center;">
<span class="text-caption" style="color: var(--text-tertiary);">검증자: 박서연</span>
<span class="text-caption" style="color: var(--text-tertiary);"></span>
<span class="text-caption" style="color: var(--text-tertiary);">시간: 14:40</span>
</div>
</div>
<div class="card-body">
<p class="text-body" style="margin: var(--space-2) 0;">- 개발 프레임워크: React</p>
<p class="text-body" style="margin: var(--space-2) 0;">- 배포 환경: AWS</p>
<p class="text-body" style="margin: var(--space-2) 0;">- Sprint 주기: 2주</p>
</div>
<div class="card-footer">
<button class="button-secondary button-small" onclick="editSection('decisions')">
수정
</button>
<button class="button-ghost button-small" onclick="lockSection('decisions')" aria-label="섹션 잠금">
🔒 잠금
</button>
</div>
</div>
<!-- Todo 섹션 (검증완료) -->
<div class="card" data-section="todos" data-verified="true">
<div class="card-header" style="border-bottom: var(--border-thin) solid var(--gray-200); padding-bottom: var(--space-3);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-2);">
<h3 class="h4" style="margin: 0; flex: 1;">✅ Todo</h3>
<span class="badge badge-verified">검증완료</span>
</div>
<div style="display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center;">
<span class="text-caption" style="color: var(--text-tertiary);">검증자: 이준호</span>
<span class="text-caption" style="color: var(--text-tertiary);"></span>
<span class="text-caption" style="color: var(--text-tertiary);">시간: 14:42</span>
</div>
</div>
<div class="card-body">
<div class="todo-card priority-high" style="margin-bottom: var(--space-2);">
<div class="todo-checkbox" role="checkbox" aria-checked="false" tabindex="0" style="pointer-events: none;"></div>
<div class="todo-content">
<div class="todo-title">요구사항 정의</div>
<div class="todo-meta">
<span class="todo-assignee">@김민준</span>
<span class="todo-duedate">(~ 10/25)</span>
</div>
</div>
</div>
<div class="todo-card priority-medium">
<div class="todo-checkbox" role="checkbox" aria-checked="false" tabindex="0" style="pointer-events: none;"></div>
<div class="todo-content">
<div class="todo-title">기술 스택 검토</div>
<div class="todo-meta">
<span class="todo-assignee">@박서연</span>
<span class="todo-duedate">(~ 10/27)</span>
</div>
</div>
</div>
</div>
<div class="card-footer">
<button class="button-secondary button-small" onclick="editSection('todos')">
수정
</button>
<button class="button-ghost button-small" onclick="lockSection('todos')" aria-label="섹션 잠금">
🔒 잠금
</button>
</div>
</div>
</section>
<!-- Info Card -->
<div class="card" style="background-color: var(--info-50); border-color: var(--info-200);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2);">
<span style="font-size: 20px;">💡</span>
<span class="text-body" style="font-weight: 600; color: var(--info-700);">안내</span>
</div>
<p class="text-body" style="color: var(--info-700);">
검증 미완료 섹션이 있어도 다음 단계로 진행할 수 있습니다. 나중에 수정하고 다시 확정할 수 있습니다.
</p>
</div>
</main>
<!-- Edit Section Modal -->
<div id="edit-modal" class="modal-overlay" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="edit-modal-title">
<div class="modal">
<div class="modal-header">
<h2 id="edit-modal-title" class="modal-title">섹션 수정</h2>
<button class="modal-close" onclick="hideModal('edit-modal')" aria-label="닫기">×</button>
</div>
<div class="modal-body">
<div class="input-group">
<label for="edit-textarea" class="input-label">내용</label>
<textarea id="edit-textarea" class="input-field" rows="6" placeholder="내용을 입력하세요"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="button-secondary" onclick="hideModal('edit-modal')">
취소
</button>
<button class="button-primary" onclick="saveEdit()">
저장
</button>
</div>
</div>
</div>
<!-- Lock Section Confirmation Modal -->
<div id="lock-modal" class="modal-overlay" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="lock-modal-title">
<div class="modal">
<div class="modal-header">
<h2 id="lock-modal-title" class="modal-title">섹션 잠금</h2>
<button class="modal-close" onclick="hideModal('lock-modal')" aria-label="닫기">×</button>
</div>
<div class="modal-body">
<p class="text-body" style="margin-bottom: var(--space-3);">
이 섹션을 잠그시겠습니까?<br>
잠금 후에는 추가 수정이 불가능합니다.
</p>
<div class="card" style="background-color: var(--warning-50); border-color: var(--warning-200);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2);">
<span>⚠️</span>
<span class="text-caption" style="color: var(--warning-700); font-weight: 600;">주의</span>
</div>
<p class="text-caption" style="color: var(--warning-700);">
회의 생성자만 섹션을 잠글 수 있습니다. 잠금 후에는 회의 생성자만 잠금을 해제할 수 있습니다.
</p>
</div>
</div>
<div class="modal-footer">
<button class="button-secondary" onclick="hideModal('lock-modal')">
취소
</button>
<button class="button-primary" onclick="confirmLock()">
잠금
</button>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
MeetingApp.ready(() => {
console.log('검증 완료 페이지 로드됨');
});
// ============================================================================
// 상태 변수
// ============================================================================
let currentEditSection = null;
let currentLockSection = null;
const currentUser = getCurrentUser(); // common.js에서 가져옴
// ============================================================================
// 진행률 업데이트
// ============================================================================
function updateProgress() {
const sections = $$('[data-section]');
const totalSections = sections.length;
let verifiedCount = 0;
sections.forEach(section => {
if (section.dataset.verified === 'true') {
verifiedCount++;
}
});
const percentage = Math.round((verifiedCount / totalSections) * 100);
// 진행률 바 업데이트
const progressFill = $('#progress-fill');
const progressText = $('#progress-text');
if (progressFill) {
progressFill.style.width = `${percentage}%`;
}
if (progressText) {
progressText.textContent = `${percentage}% (${verifiedCount}/${totalSections})`;
}
// 진행률에 따른 색상 변경
if (progressFill) {
if (percentage === 100) {
removeClass(progressFill, 'warning');
addClass(progressFill, 'success');
} else if (percentage >= 50) {
removeClass(progressFill, 'error');
addClass(progressFill, 'warning');
}
}
}
// ============================================================================
// 섹션 검증
// ============================================================================
function verifySection(sectionId) {
const section = $(`[data-section="${sectionId}"]`);
if (!section) return;
// 검증 상태 업데이트
section.dataset.verified = 'true';
// UI 업데이트
const header = section.querySelector('.card-header');
const badge = header.querySelector('.badge');
const h3 = header.querySelector('h3');
// 아이콘 변경
h3.innerHTML = h3.innerHTML.replace('⚠️', '✅');
// 배지 변경
badge.textContent = '검증완료';
removeClass(badge, 'badge-pending');
addClass(badge, 'badge-verified');
// 검증자 정보 추가
const verifiedInfo = document.createElement('div');
verifiedInfo.style.cssText = 'display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center;';
verifiedInfo.innerHTML = `
<span class="text-caption" style="color: var(--text-tertiary);">검증자: ${currentUser.name}</span>
<span class="text-caption" style="color: var(--text-tertiary);"></span>
<span class="text-caption" style="color: var(--text-tertiary);">시간: ${formatTime(new Date())}</span>
`;
header.appendChild(verifiedInfo);
// 버튼 변경
const footer = section.querySelector('.card-footer');
footer.innerHTML = `
<button class="button-secondary button-small" onclick="editSection('${sectionId}')">
수정
</button>
<button class="button-ghost button-small" onclick="lockSection('${sectionId}')" aria-label="섹션 잠금">
🔒 잠금
</button>
`;
// 진행률 업데이트
updateProgress();
// 성공 메시지
showToast('섹션이 검증되었습니다', 'success');
// 실시간 동기화 시뮬레이션
setTimeout(() => {
showToast('다른 참석자에게 알림이 전송되었습니다', 'info', 2000);
}, 1000);
}
// ============================================================================
// 섹션 수정
// ============================================================================
function editSection(sectionId) {
currentEditSection = sectionId;
const section = $(`[data-section="${sectionId}"]`);
if (!section) return;
// 현재 내용 가져오기
const cardBody = section.querySelector('.card-body');
const currentContent = cardBody.textContent.trim();
// 모달에 내용 설정
$('#edit-textarea').value = currentContent;
$('#edit-modal-title').textContent = `${section.querySelector('h3').textContent.replace('✅ ', '').replace('⚠️ ', '')} 수정`;
showModal('edit-modal');
}
function saveEdit() {
if (!currentEditSection) return;
const newContent = $('#edit-textarea').value.trim();
if (!newContent) {
showToast('내용을 입력해주세요', 'error');
return;
}
const section = $(`[data-section="${currentEditSection}"]`);
if (!section) return;
// 내용 업데이트
const cardBody = section.querySelector('.card-body');
cardBody.innerHTML = `<p class="text-body">${newContent}</p>`;
// 검증 상태를 "검증 필요"로 변경
section.dataset.verified = 'false';
const header = section.querySelector('.card-header');
const badge = header.querySelector('.badge');
const h3 = header.querySelector('h3');
// 아이콘 변경
h3.innerHTML = h3.innerHTML.replace('✅', '⚠️');
// 배지 변경
badge.textContent = '검증 필요';
removeClass(badge, 'badge-verified');
addClass(badge, 'badge-pending');
// 검증자 정보 제거
const verifiedInfo = header.querySelectorAll('.text-caption');
verifiedInfo.forEach(info => {
if (info.textContent.includes('검증자')) {
info.parentElement.remove();
}
});
// 버튼 변경
const footer = section.querySelector('.card-footer');
footer.innerHTML = `
<button class="button-secondary button-small" onclick="editSection('${currentEditSection}')">
수정
</button>
<button class="button-primary button-small" onclick="verifySection('${currentEditSection}')">
✓ 검증완료
</button>
`;
// 진행률 업데이트
updateProgress();
// 모달 닫기
hideModal('edit-modal');
// 성공 메시지
showToast('섹션이 수정되었습니다. 검증이 필요합니다.', 'info');
}
// ============================================================================
// 섹션 잠금
// ============================================================================
function lockSection(sectionId) {
// 회의 생성자 권한 체크 (예제에서는 김민준만 가능)
if (!currentUser || currentUser.id !== 1) {
showToast('회의 생성자만 섹션을 잠글 수 있습니다', 'error');
return;
}
currentLockSection = sectionId;
showModal('lock-modal');
}
function confirmLock() {
if (!currentLockSection) return;
const section = $(`[data-section="${currentLockSection}"]`);
if (!section) return;
// 잠금 표시
const footer = section.querySelector('.card-footer');
footer.innerHTML = `
<button class="button-secondary button-small" disabled style="opacity: 0.5; cursor: not-allowed;">
🔒 잠금됨
</button>
`;
// 모달 닫기
hideModal('lock-modal');
// 성공 메시지
showToast('섹션이 잠금되었습니다', 'success');
}
// ============================================================================
// 다음 단계
// ============================================================================
function proceedToEnd() {
// 모든 섹션이 검증되었는지 확인
const sections = $$('[data-section]');
const allVerified = Array.from(sections).every(section => section.dataset.verified === 'true');
if (allVerified) {
showToast('모든 섹션이 검증되었습니다', 'success', 2000);
} else {
showToast('검증되지 않은 섹션이 있습니다. 나중에 수정할 수 있습니다.', 'info', 3000);
}
setTimeout(() => {
navigateTo('07-회의종료.html');
}, 2000);
}
// ============================================================================
// 초기화
// ============================================================================
updateProgress();
console.log('검증완료 화면 초기화 완료');
</script>
</body>
</html>

View File

@ -3,110 +3,470 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의 종료 - 회의록 서비스</title>
<meta name="description" content="회의록 작성 및 공유 개선 서비스 - 회의종료">
<title>회의 종료 - 회의록 작성 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 600px;
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-4);
text-align: center;
}
.completion-icon {
font-size: 100px;
margin-bottom: var(--spacing-6);
}
.page-title {
font-size: var(--font-size-h1);
color: var(--color-gray-900);
margin-bottom: var(--spacing-3);
}
.page-subtitle {
font-size: var(--font-size-body);
color: var(--color-gray-500);
margin-bottom: var(--spacing-8);
}
.info-card {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
margin-bottom: var(--spacing-6);
text-align: left;
}
.info-item {
display: flex;
justify-content: space-between;
padding: var(--spacing-3) 0;
border-bottom: 1px solid var(--color-gray-100);
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-weight: var(--font-weight-medium);
color: var(--color-gray-700);
}
.info-value {
color: var(--color-gray-900);
font-weight: var(--font-weight-semibold);
}
.action-buttons {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
@media (max-width: 767px) {
.completion-icon { font-size: 80px; }
.page-title { font-size: var(--font-size-h2); }
}
</style>
</head>
<body>
<div class="page-container">
<div class="completion-icon">🏁</div>
<h1 class="page-title">회의가 종료되었습니다</h1>
<p class="page-subtitle">회의록이 자동으로 저장되었습니다</p>
<!-- Skip to Main Content (접근성) -->
<a href="#main-content" class="skip-to-main">본문으로 건너뛰기</a>
<!-- 회의 정보 -->
<div class="info-card">
<div class="info-item">
<span class="info-label">회의 제목</span>
<span class="info-value">2025년 1분기 제품 기획 회의</span>
<!-- Header -->
<header style="position: sticky; top: 0; background: var(--bg-primary); border-bottom: var(--border-thin) solid var(--gray-200); z-index: 10; padding: var(--space-4);">
<div style="display: flex; align-items: center; justify-content: space-between; max-width: 1440px; margin: 0 auto;">
<button class="button-icon button-ghost" onclick="goBack()" aria-label="뒤로가기">
<span style="font-size: 24px;"></span>
</button>
<h1 class="h4" style="margin: 0;">회의 종료</h1>
<button class="button-primary button-small" onclick="confirmMeeting()" aria-label="최종 확정">
확정
</button>
</div>
</header>
<!-- Main Content -->
<main id="main-content" class="container" style="padding-top: var(--space-4); padding-bottom: var(--space-6); max-width: 1024px;">
<!-- Completion Message -->
<section aria-labelledby="completion-section" style="text-align: center; margin-bottom: var(--space-6); padding: var(--space-6) 0;">
<div style="font-size: 64px; margin-bottom: var(--space-3);">🎉</div>
<h2 class="h2" id="completion-section" style="margin-bottom: var(--space-2);">회의가 종료되었습니다</h2>
<p class="text-body" style="color: var(--text-tertiary);">
회의록을 확인하고 최종 확정해주세요
</p>
</section>
<!-- Meeting Statistics Card -->
<section aria-labelledby="stats-section" style="margin-bottom: var(--space-6);">
<h2 class="h4" id="stats-section" style="margin-bottom: var(--space-4);">📊 회의 통계</h2>
<div class="card">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-4);">
<!-- 총 시간 -->
<div style="padding: var(--space-3); background-color: var(--bg-secondary); border-radius: var(--radius-medium);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2);">
<span style="font-size: 24px;">⏱️</span>
<span class="text-caption" style="color: var(--text-tertiary); font-weight: 600;">총 시간</span>
</div>
<div class="h3" style="color: var(--text-primary);">45분</div>
</div>
<!-- 참석자 -->
<div style="padding: var(--space-3); background-color: var(--bg-secondary); border-radius: var(--radius-medium);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2);">
<span style="font-size: 24px;">👥</span>
<span class="text-caption" style="color: var(--text-tertiary); font-weight: 600;">참석자</span>
</div>
<div class="h3" style="color: var(--text-primary);">3명</div>
</div>
</div>
<!-- 발언 횟수 -->
<div style="margin-top: var(--space-4); padding: var(--space-3); background-color: var(--bg-secondary); border-radius: var(--radius-medium);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3);">
<span style="font-size: 24px;">💬</span>
<span class="text-caption" style="color: var(--text-tertiary); font-weight: 600;">발언 횟수</span>
</div>
<div style="display: flex; flex-direction: column; gap: var(--space-2);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span class="text-body">김민준</span>
<div style="display: flex; align-items: center; gap: var(--space-2);">
<div class="progress-bar" style="width: 120px; height: 8px;">
<div class="progress-fill" style="width: 60%; background-color: var(--primary-500);"></div>
</div>
<span class="text-body" style="font-weight: 600;">12회</span>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span class="text-body">박서연</span>
<div style="display: flex; align-items: center; gap: var(--space-2);">
<div class="progress-bar" style="width: 120px; height: 8px;">
<div class="progress-fill" style="width: 40%; background-color: var(--info-500);"></div>
</div>
<span class="text-body" style="font-weight: 600;">8회</span>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span class="text-body">이준호</span>
<div style="display: flex; align-items: center; gap: var(--space-2);">
<div class="progress-bar" style="width: 120px; height: 8px;">
<div class="progress-fill" style="width: 25%; background-color: var(--success-500);"></div>
</div>
<span class="text-body" style="font-weight: 600;">5회</span>
</div>
</div>
</div>
</div>
<!-- 주요 키워드 -->
<div style="margin-top: var(--space-4); padding: var(--space-3); background-color: var(--bg-secondary); border-radius: var(--radius-medium);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3);">
<span style="font-size: 24px;">🔑</span>
<span class="text-caption" style="color: var(--text-tertiary); font-weight: 600;">주요 키워드</span>
</div>
<div style="display: flex; gap: var(--space-2); flex-wrap: wrap;">
<span class="badge badge-in-progress" style="cursor: pointer;" onclick="showKeywordContext('MVP')">#MVP</span>
<span class="badge badge-in-progress" style="cursor: pointer;" onclick="showKeywordContext('React')">#React</span>
<span class="badge badge-in-progress" style="cursor: pointer;" onclick="showKeywordContext('AWS')">#AWS</span>
<span class="badge badge-in-progress" style="cursor: pointer;" onclick="showKeywordContext('Sprint')">#Sprint</span>
<span class="badge badge-in-progress" style="cursor: pointer;" onclick="showKeywordContext('Q1')">#Q1</span>
</div>
</div>
</div>
<div class="info-item">
<span class="info-label">회의 시간</span>
<span class="info-value">45분</span>
</section>
<!-- AI Todo Auto Extraction -->
<section aria-labelledby="todos-section" style="margin-bottom: var(--space-6);">
<h2 class="h4" id="todos-section" style="margin-bottom: var(--space-4);">✅ AI Todo 자동 추출</h2>
<div class="card" style="background-color: var(--primary-50); border-color: var(--primary-200);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3);">
<span style="font-size: 24px;">💡</span>
<span class="text-body" style="font-weight: 600; color: var(--primary-700);">AI가 회의록에서 3개의 Todo를 자동으로 추출했습니다</span>
</div>
<!-- Todo 1 -->
<div class="todo-card priority-high" style="margin-bottom: var(--space-2); background-color: var(--bg-primary);">
<div class="todo-checkbox" onclick="toggleTodo(this, 1)" role="checkbox" aria-checked="false" tabindex="0"></div>
<div class="todo-content">
<div class="todo-title">요구사항 정의서 작성</div>
<div class="todo-meta">
<span class="todo-assignee">@김민준</span>
<span class="todo-duedate">📅 ~ 10/25</span>
<button class="button-ghost button-small" onclick="editTodo(1)" style="margin-left: var(--space-2); padding: var(--space-1) var(--space-2);">
✏️ 수정
</button>
</div>
</div>
</div>
<!-- Todo 2 -->
<div class="todo-card priority-medium" style="margin-bottom: var(--space-2); background-color: var(--bg-primary);">
<div class="todo-checkbox" onclick="toggleTodo(this, 2)" role="checkbox" aria-checked="false" tabindex="0"></div>
<div class="todo-content">
<div class="todo-title">기술 스택 상세 검토</div>
<div class="todo-meta">
<span class="todo-assignee">@박서연</span>
<span class="todo-duedate">📅 ~ 10/27</span>
<button class="button-ghost button-small" onclick="editTodo(2)" style="margin-left: var(--space-2); padding: var(--space-1) var(--space-2);">
✏️ 수정
</button>
</div>
</div>
</div>
<!-- Todo 3 -->
<div class="todo-card priority-high" style="background-color: var(--bg-primary);">
<div class="todo-checkbox" onclick="toggleTodo(this, 3)" role="checkbox" aria-checked="false" tabindex="0"></div>
<div class="todo-content">
<div class="todo-title">인프라 설계 문서 작성</div>
<div class="todo-meta">
<span class="todo-assignee">@이준호</span>
<span class="todo-duedate">📅 ~ 10/30</span>
<button class="button-ghost button-small" onclick="editTodo(3)" style="margin-left: var(--space-2); padding: var(--space-1) var(--space-2);">
✏️ 수정
</button>
</div>
</div>
</div>
<div style="margin-top: var(--space-4); text-align: center;">
<button class="button-secondary button-small" onclick="addNewTodo()">
Todo 추가
</button>
</div>
</div>
<div class="info-item">
<span class="info-label">참석자</span>
<span class="info-value">3명</span>
</section>
<!-- Required Items Checklist -->
<section aria-labelledby="checklist-section" style="margin-bottom: var(--space-6);">
<h2 class="h4" id="checklist-section" style="margin-bottom: var(--space-4);">필수 항목 확인</h2>
<div class="card">
<div style="display: flex; flex-direction: column; gap: var(--space-3);">
<div style="display: flex; align-items: center; gap: var(--space-3);">
<span style="font-size: 24px; color: var(--success-500);"></span>
<span class="text-body">회의 제목</span>
</div>
<div style="display: flex; align-items: center; gap: var(--space-3);">
<span style="font-size: 24px; color: var(--success-500);"></span>
<span class="text-body">참석자 목록</span>
</div>
<div style="display: flex; align-items: center; gap: var(--space-3);">
<span style="font-size: 24px; color: var(--success-500);"></span>
<span class="text-body">주요 논의 내용</span>
</div>
<div style="display: flex; align-items: center; gap: var(--space-3);">
<span style="font-size: 24px; color: var(--success-500);"></span>
<span class="text-body">결정 사항</span>
</div>
</div>
</div>
<div class="info-item">
<span class="info-label">생성된 Todo</span>
<span class="info-value">5개</span>
</section>
<!-- Action Buttons -->
<section style="display: flex; flex-direction: column; gap: var(--space-3);">
<button class="button-primary w-full" style="height: 48px; font-size: 1rem;" onclick="confirmMeeting()">
최종 회의록 확정
</button>
<button class="button-secondary w-full" onclick="saveLater()">
나중에 확정
</button>
</section>
</main>
<!-- Edit Todo Modal -->
<div id="edit-todo-modal" class="modal-overlay" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="edit-todo-title">
<div class="modal">
<div class="modal-header">
<h2 id="edit-todo-title" class="modal-title">Todo 수정</h2>
<button class="modal-close" onclick="hideModal('edit-todo-modal')" aria-label="닫기">×</button>
</div>
<div class="modal-body">
<div class="input-group" style="margin-bottom: var(--space-3);">
<label for="todo-content" class="input-label required">내용</label>
<input type="text" id="todo-content" class="input-field" placeholder="Todo 내용을 입력하세요" required>
</div>
<div class="input-group" style="margin-bottom: var(--space-3);">
<label for="todo-assignee" class="input-label required">담당자</label>
<select id="todo-assignee" class="input-field" required>
<option value="">선택하세요</option>
<option value="김민준">김민준</option>
<option value="박서연">박서연</option>
<option value="이준호">이준호</option>
<option value="최유진">최유진</option>
<option value="정도현">정도현</option>
</select>
</div>
<div class="input-group" style="margin-bottom: var(--space-3);">
<label for="todo-duedate" class="input-label required">마감일</label>
<input type="date" id="todo-duedate" class="input-field" required>
</div>
<div class="input-group">
<label for="todo-priority" class="input-label required">우선순위</label>
<select id="todo-priority" class="input-field" required>
<option value="">선택하세요</option>
<option value="high">높음</option>
<option value="medium">보통</option>
<option value="low">낮음</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="button-secondary" onclick="hideModal('edit-todo-modal')">
취소
</button>
<button class="button-primary" onclick="saveTodo()">
저장
</button>
</div>
</div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons">
<button class="btn btn-primary" onclick="window.location.href='08-최종확정.html'">
회의록 확정하기
</button>
<button class="btn btn-secondary" onclick="window.location.href='02-대시보드.html'">
대시보드로 이동
</button>
<!-- Keyword Context Modal -->
<div id="keyword-modal" class="modal-overlay" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="keyword-title">
<div class="modal">
<div class="modal-header">
<h2 id="keyword-title" class="modal-title">키워드 맥락</h2>
<button class="modal-close" onclick="hideModal('keyword-modal')" aria-label="닫기">×</button>
</div>
<div class="modal-body" id="keyword-content">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
</div>
<script src="common.js"></script>
<script>
MeetingApp.ready(() => {
console.log('회의 종료 페이지 로드됨');
// 회의 종료 알림
MeetingApp.Toast.success('회의가 성공적으로 종료되었습니다');
// ============================================================================
// 상태 변수
// ============================================================================
let currentEditTodoId = null;
// ============================================================================
// Todo 토글
// ============================================================================
function toggleTodo(checkbox, todoId) {
toggleClass(checkbox, 'checked');
const isChecked = checkbox.classList.contains('checked');
checkbox.setAttribute('aria-checked', isChecked);
const todoTitle = checkbox.nextElementSibling.querySelector('.todo-title');
if (isChecked) {
addClass(todoTitle, 'completed');
} else {
removeClass(todoTitle, 'completed');
}
}
// ============================================================================
// Todo 수정
// ============================================================================
function editTodo(todoId) {
currentEditTodoId = todoId;
// 예제 데이터 로드
const todoData = {
1: { content: '요구사항 정의서 작성', assignee: '김민준', dueDate: '2025-10-25', priority: 'high' },
2: { content: '기술 스택 상세 검토', assignee: '박서연', dueDate: '2025-10-27', priority: 'medium' },
3: { content: '인프라 설계 문서 작성', assignee: '이준호', dueDate: '2025-10-30', priority: 'high' }
};
const todo = todoData[todoId];
if (!todo) return;
// 모달에 데이터 설정
$('#todo-content').value = todo.content;
$('#todo-assignee').value = todo.assignee;
$('#todo-duedate').value = todo.dueDate;
$('#todo-priority').value = todo.priority;
showModal('edit-todo-modal');
}
function saveTodo() {
// 폼 검증
const content = $('#todo-content').value.trim();
const assignee = $('#todo-assignee').value;
const dueDate = $('#todo-duedate').value;
const priority = $('#todo-priority').value;
if (!content || !assignee || !dueDate || !priority) {
showToast('모든 필드를 입력해주세요', 'error');
return;
}
// Todo 업데이트 시뮬레이션
hideModal('edit-todo-modal');
showToast('Todo가 수정되었습니다', 'success');
}
function addNewTodo() {
currentEditTodoId = null;
// 모달 초기화
$('#todo-content').value = '';
$('#todo-assignee').value = '';
$('#todo-duedate').value = '';
$('#todo-priority').value = '';
showModal('edit-todo-modal');
}
// ============================================================================
// 키워드 맥락 표시
// ============================================================================
function showKeywordContext(keyword) {
const keywordData = {
'MVP': {
contexts: [
'"우리는 Q1까지 MVP를 완성해야 합니다" - 김민준 (14:23)',
'"MVP는 핵심 기능만 구현하여 빠르게 시장 검증을 하는 것이 목표입니다" - 박서연 (14:25)'
]
},
'React': {
contexts: [
'"개발 프레임워크는 React를 사용하기로 결정했습니다" - 김민준 (14:28)',
'"React는 컴포넌트 기반이라 유지보수가 용이합니다" - 최유진 (14:30)'
]
},
'AWS': {
contexts: [
'"배포 환경은 AWS로 결정했습니다" - 김민준 (14:28)',
'"AWS는 확장성이 좋고 관리 도구가 풍부합니다" - 이준호 (14:31)'
]
},
'Sprint': {
contexts: [
'"Sprint 주기는 2주로 설정합니다" - 박서연 (14:35)'
]
},
'Q1': {
contexts: [
'"우리는 Q1까지 MVP를 완성해야 합니다" - 김민준 (14:23)',
'"Q1 목표를 달성하기 위해서는 주간 단위로 진행 상황을 체크해야 합니다" - 박서연 (14:26)'
]
}
};
const data = keywordData[keyword];
if (!data) return;
const content = `
<div style="margin-bottom: var(--space-4);">
<div style="display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3);">
<span class="badge badge-in-progress">#${keyword}</span>
<span class="text-caption" style="color: var(--text-tertiary);">회의록 내 ${data.contexts.length}회 언급</span>
</div>
<h3 class="h4" style="margin-bottom: var(--space-3);">💬 언급된 맥락</h3>
${data.contexts.map(context => `
<div style="padding: var(--space-3); background-color: var(--bg-secondary); border-radius: var(--radius-medium); margin-bottom: var(--space-2);">
<p class="text-body">${context}</p>
</div>
`).join('')}
<button class="button-secondary button-small w-full" onclick="hideModal('keyword-modal'); navigateTo('05-회의진행.html')">
회의록에서 확인하기
</button>
</div>
`;
$('#keyword-content').innerHTML = content;
showModal('keyword-modal');
}
// ============================================================================
// 최종 확정
// ============================================================================
function confirmMeeting() {
// 필수 항목 검증 (이미 모두 완료된 상태)
showToast('회의록을 최종 확정합니다...', 'info', 2000);
// Todo 서비스로 데이터 전달 시뮬레이션
setTimeout(() => {
showToast('Todo가 생성되었습니다', 'success', 2000);
}, 2000);
// 회의록 공유 화면으로 이동
setTimeout(() => {
navigateTo('08-회의록공유.html');
}, 4000);
}
// ============================================================================
// 나중에 확정
// ============================================================================
function saveLater() {
showToast('회의록이 저장되었습니다', 'success', 2000);
setTimeout(() => {
navigateTo('02-대시보드.html');
}, 2000);
}
// ============================================================================
// 키보드 접근성
// ============================================================================
// Enter/Space로 체크박스 토글
$$('.todo-checkbox').forEach(checkbox => {
checkbox.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
checkbox.click();
}
});
});
// ============================================================================
// 초기화
// ============================================================================
// 오늘 날짜 기본값 설정
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
$('#todo-duedate').setAttribute('min', formatDate(tomorrow));
console.log('회의종료 화면 초기화 완료');
</script>
</body>
</html>

View File

@ -1,303 +0,0 @@
<!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">
<style>
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 1200px;
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-4);
}
.page-header {
margin-bottom: var(--spacing-8);
}
.page-title {
font-size: var(--font-size-h1);
color: var(--color-gray-900);
margin-bottom: var(--spacing-2);
}
.page-subtitle {
font-size: var(--font-size-body);
color: var(--color-gray-500);
}
.content-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--spacing-6);
margin-bottom: var(--spacing-8);
}
.preview-panel {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
}
.preview-title {
font-size: var(--font-size-h3);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-4);
}
.meeting-content {
font-size: var(--font-size-body);
line-height: var(--line-height-relaxed);
color: var(--color-gray-700);
}
.meeting-content h2 {
font-size: var(--font-size-h4);
margin-top: var(--spacing-6);
margin-bottom: var(--spacing-3);
color: var(--color-gray-900);
}
.meeting-content ul {
margin-left: var(--spacing-5);
margin-bottom: var(--spacing-4);
}
.checklist-panel {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
height: fit-content;
}
.checklist-title {
font-size: var(--font-size-h4);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-4);
}
.checklist-item {
display: flex;
align-items: flex-start;
gap: var(--spacing-3);
padding: var(--spacing-3);
margin-bottom: var(--spacing-2);
background: var(--color-gray-50);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.checklist-item:hover {
background: var(--color-gray-100);
}
.checklist-item.checked {
background: rgba(0, 217, 177, 0.1);
}
.checklist-checkbox {
width: 20px;
height: 20px;
border: 2px solid var(--color-gray-300);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.checklist-item.checked .checklist-checkbox {
background-color: var(--color-success-main);
border-color: var(--color-success-main);
color: var(--color-white);
}
.checklist-text {
flex: 1;
font-size: var(--font-size-body-small);
color: var(--color-gray-700);
}
.action-buttons {
display: flex;
gap: var(--spacing-3);
justify-content: center;
}
.warning-message {
background-color: var(--color-warning-light);
border-left: 4px solid var(--color-warning-main);
padding: var(--spacing-4);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-4);
display: none;
}
.warning-message.show {
display: block;
}
@media (max-width: 1023px) {
.content-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 767px) {
.page-title { font-size: var(--font-size-h2); }
.action-buttons { flex-direction: column; }
.action-buttons .btn { width: 100%; }
}
</style>
</head>
<body>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">회의록 최종 확정</h1>
<p class="page-subtitle">필수 항목을 확인하고 회의록을 최종 확정하세요</p>
</div>
<div id="warningMessage" class="warning-message">
⚠️ 아래 필수 항목을 모두 확인해주세요.
</div>
<div class="content-grid">
<!-- 회의록 미리보기 -->
<div class="preview-panel">
<h2 class="preview-title">2025년 1분기 제품 기획 회의</h2>
<div class="meeting-content">
<p><strong>날짜:</strong> 2025-10-25 14:00<br>
<strong>장소:</strong> 본사 2층 대회의실<br>
<strong>참석자:</strong> 김민준, 박서연, 이준호</p>
<h2>안건</h2>
<ul>
<li>신규 기능 개발 일정 논의</li>
<li>예산 편성 검토</li>
</ul>
<h2>논의 내용</h2>
<p>신규 회의록 서비스의 핵심 기능에 대해 논의했습니다. AI 기반 자동 작성 기능과 실시간 협업 기능을 우선적으로 개발하기로 결정했습니다.</p>
<p>개발 일정은 3월 말 완료를 목표로 하며, 주요 마일스톤은 다음과 같습니다:</p>
<ul>
<li>3월 10일: 기본 UI 완성</li>
<li>3월 20일: AI 기능 통합</li>
<li>3월 30일: 베타 테스트 시작</li>
</ul>
<h2>결정 사항</h2>
<ul>
<li>신규 기능 개발은 3월 말 완료 목표</li>
<li>이준호님이 API 설계 담당</li>
<li>예산은 5천만원으로 확정</li>
</ul>
<h2>Todo</h2>
<ul>
<li>API 명세서 작성 (담당: 이준호, 마감: 3월 25일)</li>
<li>UI 프로토타입 완성 (담당: 최유진, 마감: 3월 15일)</li>
<li>예산 편성안 검토 (담당: 박서연, 마감: 3월 20일)</li>
</ul>
</div>
</div>
<!-- 확인 체크리스트 -->
<div class="checklist-panel">
<h3 class="checklist-title">필수 항목 확인</h3>
<div class="checklist-item" data-required="true">
<div class="checklist-checkbox"></div>
<div class="checklist-text">
<strong>회의 제목</strong><br>
회의 제목이 명확하게 작성되었습니다
</div>
</div>
<div class="checklist-item" data-required="true">
<div class="checklist-checkbox"></div>
<div class="checklist-text">
<strong>참석자 목록</strong><br>
모든 참석자가 기록되었습니다
</div>
</div>
<div class="checklist-item" data-required="true">
<div class="checklist-checkbox"></div>
<div class="checklist-text">
<strong>주요 논의 내용</strong><br>
핵심 논의 내용이 포함되었습니다
</div>
</div>
<div class="checklist-item" data-required="true">
<div class="checklist-checkbox"></div>
<div class="checklist-text">
<strong>결정 사항</strong><br>
회의 중 결정된 사항이 명시되었습니다
</div>
</div>
<div class="checklist-item">
<div class="checklist-checkbox"></div>
<div class="checklist-text">
<strong>Todo 생성</strong><br>
실행 항목이 Todo로 생성되었습니다
</div>
</div>
<div class="checklist-item">
<div class="checklist-checkbox"></div>
<div class="checklist-text">
<strong>전문용어 설명</strong><br>
필요한 용어에 설명이 추가되었습니다
</div>
</div>
</div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons">
<button class="btn btn-secondary" onclick="history.back()">이전으로</button>
<button class="btn btn-primary" id="confirmBtn" disabled>회의록 확정하기</button>
</div>
</div>
<script src="common.js"></script>
<script>
const checklistItems = document.querySelectorAll('.checklist-item');
const confirmBtn = document.getElementById('confirmBtn');
const warningMessage = document.getElementById('warningMessage');
// 체크리스트 항목 클릭
checklistItems.forEach(item => {
item.addEventListener('click', () => {
item.classList.toggle('checked');
const checkbox = item.querySelector('.checklist-checkbox');
if (item.classList.contains('checked')) {
checkbox.textContent = '✓';
} else {
checkbox.textContent = '';
}
checkCompletion();
});
});
// 완료 여부 확인
function checkCompletion() {
const requiredItems = document.querySelectorAll('.checklist-item[data-required="true"]');
const checkedRequired = document.querySelectorAll('.checklist-item[data-required="true"].checked');
if (requiredItems.length === checkedRequired.length) {
confirmBtn.disabled = false;
warningMessage.classList.remove('show');
} else {
confirmBtn.disabled = true;
warningMessage.classList.add('show');
}
}
// 확정 버튼 클릭
confirmBtn.addEventListener('click', () => {
MeetingApp.Loading.show();
setTimeout(() => {
MeetingApp.Loading.hide();
MeetingApp.Toast.success('회의록이 확정되었습니다!');
setTimeout(() => {
window.location.href = '09-회의록공유.html';
}, 1000);
}, 1500);
});
// 초기 확인
checkCompletion();
</script>
</body>
</html>

View File

@ -1,316 +0,0 @@
<!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">
<style>
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 800px;
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-4);
}
.success-icon {
text-align: center;
font-size: 80px;
margin-bottom: var(--spacing-6);
}
.page-title {
font-size: var(--font-size-h1);
color: var(--color-gray-900);
margin-bottom: var(--spacing-3);
text-align: center;
}
.page-subtitle {
font-size: var(--font-size-body);
color: var(--color-gray-500);
text-align: center;
margin-bottom: var(--spacing-8);
}
.share-card {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
margin-bottom: var(--spacing-6);
}
.share-title {
font-size: var(--font-size-h4);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-4);
}
.share-option {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4);
margin-bottom: var(--spacing-3);
background: var(--color-gray-50);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.share-option:hover {
background: var(--color-gray-100);
}
.share-icon {
font-size: 32px;
}
.share-info {
flex: 1;
}
.share-label {
font-weight: var(--font-weight-medium);
color: var(--color-gray-900);
margin-bottom: var(--spacing-1);
}
.share-desc {
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
}
.link-box {
display: flex;
gap: var(--spacing-2);
align-items: center;
}
.link-input {
flex: 1;
padding: var(--spacing-3) var(--spacing-4);
border: 1px solid var(--color-gray-300);
border-radius: var(--radius-md);
font-size: var(--font-size-body-small);
background-color: var(--color-gray-50);
font-family: monospace;
}
.attendee-list {
margin-top: var(--spacing-4);
}
.attendee-item {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-3);
margin-bottom: var(--spacing-2);
background: var(--color-gray-50);
border-radius: var(--radius-md);
}
.attendee-avatar {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
background-color: var(--color-primary-main);
color: var(--color-white);
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-weight-semibold);
}
.attendee-info {
flex: 1;
}
.attendee-name {
font-weight: var(--font-weight-medium);
color: var(--color-gray-900);
}
.attendee-email {
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
}
.sent-badge {
padding: var(--spacing-1) var(--spacing-3);
background-color: var(--color-success-light);
color: var(--color-success-dark);
border-radius: var(--radius-md);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-medium);
}
.action-buttons {
display: flex;
gap: var(--spacing-3);
justify-content: center;
}
@media (max-width: 767px) {
.success-icon { font-size: 60px; }
.page-title { font-size: var(--font-size-h2); }
.action-buttons { flex-direction: column; }
.action-buttons .btn { width: 100%; }
.link-box { flex-direction: column; }
.link-input { width: 100%; }
}
</style>
</head>
<body>
<div class="page-container">
<div class="success-icon">🎉</div>
<h1 class="page-title">회의록이 확정되었습니다</h1>
<p class="page-subtitle">이제 참석자들과 회의록을 공유하세요</p>
<!-- 공유 링크 -->
<div class="share-card">
<h2 class="share-title">공유 링크</h2>
<div class="link-box">
<input type="text" class="link-input" id="shareLink" value="https://meeting.example.com/share/m-001-abc123" readonly>
<button class="btn btn-primary" onclick="copyLink()">복사</button>
</div>
</div>
<!-- 공유 방식 -->
<div class="share-card">
<h2 class="share-title">공유 방식 선택</h2>
<div class="share-option" onclick="shareViaEmail()">
<div class="share-icon">📧</div>
<div class="share-info">
<div class="share-label">이메일로 공유</div>
<div class="share-desc">참석자들에게 이메일을 발송합니다</div>
</div>
</div>
<div class="share-option" onclick="shareViaSlack()">
<div class="share-icon">💬</div>
<div class="share-info">
<div class="share-label">슬랙으로 공유</div>
<div class="share-desc">슬랙 채널에 회의록을 공유합니다</div>
</div>
</div>
<div class="share-option" onclick="downloadPDF()">
<div class="share-icon">📄</div>
<div class="share-info">
<div class="share-label">PDF로 다운로드</div>
<div class="share-desc">회의록을 PDF 파일로 저장합니다</div>
</div>
</div>
</div>
<!-- 생성된 Todo -->
<div class="share-card">
<h2 class="share-title">생성된 Todo (3개)</h2>
<div class="attendee-list">
<div class="attendee-item">
<div style="display: flex; align-items: center; gap: var(--spacing-3); flex: 1;">
<div class="attendee-avatar" style="background-color: var(--color-primary-main);"></div>
<div class="attendee-info">
<div class="attendee-name">API 명세서 작성</div>
<div class="attendee-email" style="display: flex; align-items: center; gap: var(--spacing-2);">
<span>담당: 이준호</span> | <span>📅 3월 25일</span>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: var(--spacing-1);">
<span class="sent-badge" style="background-color: var(--color-warning-light); color: var(--color-warning-dark);">진행중 60%</span>
<a href="10-Todo관리.html" style="font-size: var(--font-size-caption); color: var(--color-primary-main); text-decoration: none;">Todo 보기 →</a>
</div>
</div>
<div class="attendee-item">
<div style="display: flex; align-items: center; gap: var(--spacing-3); flex: 1;">
<div class="attendee-avatar" style="background-color: var(--color-info-main);"></div>
<div class="attendee-info">
<div class="attendee-name">UI 프로토타입 완성</div>
<div class="attendee-email" style="display: flex; align-items: center; gap: var(--spacing-2);">
<span>담당: 최유진</span> | <span>📅 3월 15일</span>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: var(--spacing-1);">
<span class="sent-badge">완료 100%</span>
<a href="10-Todo관리.html" style="font-size: var(--font-size-caption); color: var(--color-primary-main); text-decoration: none;">Todo 보기 →</a>
</div>
</div>
<div class="attendee-item">
<div style="display: flex; align-items: center; gap: var(--spacing-3); flex: 1;">
<div class="attendee-avatar" style="background-color: var(--color-secondary-main);"></div>
<div class="attendee-info">
<div class="attendee-name">예산 편성안 검토</div>
<div class="attendee-email" style="display: flex; align-items: center; gap: var(--spacing-2);">
<span>담당: 박서연</span> | <span>📅 3월 20일</span>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: var(--spacing-1);">
<span class="sent-badge" style="background-color: var(--color-error-light); color: var(--color-error-dark);">지연 30%</span>
<a href="10-Todo관리.html" style="font-size: var(--font-size-caption); color: var(--color-primary-main); text-decoration: none;">Todo 보기 →</a>
</div>
</div>
</div>
</div>
<!-- 참석자 목록 -->
<div class="share-card">
<h2 class="share-title">참석자 (3명)</h2>
<div class="attendee-list">
<div class="attendee-item">
<div class="attendee-avatar"></div>
<div class="attendee-info">
<div class="attendee-name">김민준</div>
<div class="attendee-email">minjun.kim@example.com</div>
</div>
<span class="sent-badge">발송 완료</span>
</div>
<div class="attendee-item">
<div class="attendee-avatar" style="background-color: var(--color-secondary-main);"></div>
<div class="attendee-info">
<div class="attendee-name">박서연</div>
<div class="attendee-email">seoyeon.park@example.com</div>
</div>
<span class="sent-badge">발송 완료</span>
</div>
<div class="attendee-item">
<div class="attendee-avatar" style="background-color: var(--color-info-main);"></div>
<div class="attendee-info">
<div class="attendee-name">이준호</div>
<div class="attendee-email">junho.lee@example.com</div>
</div>
<span class="sent-badge">발송 완료</span>
</div>
</div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons">
<button class="btn btn-secondary" onclick="window.location.href='02-대시보드.html'">
대시보드로 이동
</button>
<button class="btn btn-primary" onclick="window.location.href='10-Todo관리.html'">
Todo 관리하기
</button>
</div>
</div>
<script src="common.js"></script>
<script>
function copyLink() {
const linkInput = document.getElementById('shareLink');
linkInput.select();
document.execCommand('copy');
MeetingApp.Toast.success('링크가 복사되었습니다');
}
function shareViaEmail() {
MeetingApp.Loading.show();
setTimeout(() => {
MeetingApp.Loading.hide();
MeetingApp.Toast.success('이메일이 발송되었습니다');
}, 1500);
}
function shareViaSlack() {
MeetingApp.Loading.show();
setTimeout(() => {
MeetingApp.Loading.hide();
MeetingApp.Toast.success('슬랙에 공유되었습니다');
}, 1500);
}
function downloadPDF() {
MeetingApp.Toast.info('PDF 파일을 준비 중입니다...');
setTimeout(() => {
MeetingApp.Toast.success('PDF 다운로드가 시작되었습니다');
}, 1000);
}
</script>
</body>
</html>

View File

@ -1,466 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo 관리 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 1400px;
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-4);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-8);
}
.page-title {
font-size: var(--font-size-h1);
color: var(--color-gray-900);
}
.view-toggle {
display: flex;
gap: var(--spacing-2);
}
.view-btn {
padding: var(--spacing-2) var(--spacing-4);
background: var(--color-white);
border: 1px solid var(--color-gray-300);
border-radius: var(--radius-md);
font-size: var(--font-size-body-small);
cursor: pointer;
transition: all var(--transition-fast);
}
.view-btn.active {
background-color: var(--color-primary-main);
color: var(--color-white);
border-color: var(--color-primary-main);
}
.kanban-board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-6);
}
.kanban-column {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-4);
min-height: 500px;
}
.column-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-4);
padding-bottom: var(--spacing-3);
border-bottom: 2px solid var(--color-gray-200);
}
.column-title {
font-size: var(--font-size-h4);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
}
.column-count {
padding: var(--spacing-1) var(--spacing-2);
background-color: var(--color-gray-200);
color: var(--color-gray-700);
border-radius: var(--radius-md);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-medium);
}
.todo-card {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-md);
padding: var(--spacing-4);
margin-bottom: var(--spacing-3);
cursor: grab;
transition: all var(--transition-fast);
}
.todo-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.todo-card.priority-high {
border-left: 4px solid var(--color-error-main);
}
.todo-card.priority-medium {
border-left: 4px solid var(--color-warning-main);
}
.todo-title {
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-2);
}
.todo-meta {
display: flex;
align-items: center;
gap: var(--spacing-3);
margin-bottom: var(--spacing-3);
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
}
.todo-assignee {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.avatar-sm {
width: 24px;
height: 24px;
border-radius: var(--radius-full);
background-color: var(--color-primary-main);
color: var(--color-white);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-caption);
font-weight: var(--font-weight-semibold);
}
.todo-duedate {
display: flex;
align-items: center;
gap: var(--spacing-1);
}
.todo-duedate.overdue {
color: var(--color-error-main);
font-weight: var(--font-weight-medium);
}
.todo-progress {
height: 4px;
background-color: var(--color-gray-200);
border-radius: 2px;
overflow: hidden;
}
.todo-progress-bar {
height: 100%;
background-color: var(--color-primary-main);
transition: width var(--transition-slow);
}
.todo-source {
margin-top: var(--spacing-3);
padding-top: var(--spacing-3);
border-top: 1px dashed var(--color-gray-200);
font-size: var(--font-size-caption);
color: var(--color-gray-500);
}
.todo-source-link {
display: flex;
align-items: center;
gap: var(--spacing-2);
color: var(--color-primary-main);
text-decoration: none;
transition: color var(--transition-fast);
cursor: pointer;
}
.todo-source-link:hover {
color: var(--color-primary-dark);
text-decoration: underline;
}
.list-view {
display: none;
}
.list-view.active {
display: block;
}
.todo-list-item {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-md);
padding: var(--spacing-4);
margin-bottom: var(--spacing-3);
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.todo-checkbox {
width: 20px;
height: 20px;
border: 2px solid var(--color-gray-300);
border-radius: var(--radius-sm);
cursor: pointer;
}
.todo-list-content {
flex: 1;
}
@media (max-width: 1023px) {
.kanban-board {
grid-template-columns: 1fr;
}
}
@media (max-width: 767px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-4);
}
.page-title { font-size: var(--font-size-h2); }
}
</style>
</head>
<body>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">Todo 관리</h1>
<div style="display: flex; gap: var(--spacing-3); align-items: center;">
<div class="view-toggle">
<button class="view-btn active" data-view="kanban">칸반</button>
<button class="view-btn" data-view="list">리스트</button>
</div>
<button class="btn btn-primary" onclick="addTodo()">+ 새 Todo</button>
</div>
</div>
<!-- 칸반 보드 뷰 -->
<div class="kanban-board" id="kanbanView">
<!-- 시작 전 -->
<div class="kanban-column">
<div class="column-header">
<h2 class="column-title">시작 전</h2>
<span class="column-count">2</span>
</div>
<div class="todo-card priority-high">
<div class="todo-title">데이터베이스 스키마 설계</div>
<div class="todo-meta">
<div class="todo-assignee">
<div class="avatar-sm"></div>
<span>이준호</span>
</div>
<div class="todo-duedate">
📅 D-3
</div>
</div>
<div class="todo-progress">
<div class="todo-progress-bar" style="width: 0%;"></div>
</div>
<div class="todo-source">
<a href="08-최종확정.html#todo-section" class="todo-source-link" onclick="MeetingApp.Toast.info('회의록으로 이동합니다'); return true;">
📄 2025년 1분기 제품 기획 회의 (2025-10-25)
</a>
</div>
</div>
<div class="todo-card">
<div class="todo-title">사용자 피드백 분석</div>
<div class="todo-meta">
<div class="todo-assignee">
<div class="avatar-sm" style="background-color: var(--color-secondary-main);"></div>
<span>박서연</span>
</div>
<div class="todo-duedate">
📅 D-5
</div>
</div>
<div class="todo-progress">
<div class="todo-progress-bar" style="width: 0%;"></div>
</div>
<div class="todo-source">
<a href="08-최종확정.html#todo-section" class="todo-source-link" onclick="MeetingApp.Toast.info('회의록으로 이동합니다'); return true;">
📄 고객 만족도 개선 회의 (2025-10-18)
</a>
</div>
</div>
</div>
<!-- 진행 중 -->
<div class="kanban-column">
<div class="column-header">
<h2 class="column-title">진행 중</h2>
<span class="column-count">2</span>
</div>
<div class="todo-card priority-high">
<div class="todo-title">API 명세서 작성</div>
<div class="todo-meta">
<div class="todo-assignee">
<div class="avatar-sm"></div>
<span>이준호</span>
</div>
<div class="todo-duedate">
📅 오늘
</div>
</div>
<div class="todo-progress">
<div class="todo-progress-bar" style="width: 60%;"></div>
</div>
<div class="todo-source">
<a href="08-최종확정.html#todo-section" class="todo-source-link" onclick="MeetingApp.Toast.info('회의록으로 이동합니다'); return true;">
📄 2025년 1분기 제품 기획 회의 (2025-10-25)
</a>
</div>
</div>
<div class="todo-card priority-medium">
<div class="todo-title">예산 편성안 검토</div>
<div class="todo-meta">
<div class="todo-assignee">
<div class="avatar-sm" style="background-color: var(--color-secondary-main);"></div>
<span>박서연</span>
</div>
<div class="todo-duedate overdue">
📅 D+2 (지남)
</div>
</div>
<div class="todo-progress">
<div class="todo-progress-bar" style="width: 30%;"></div>
</div>
<div class="todo-source">
<a href="08-최종확정.html#todo-section" class="todo-source-link" onclick="MeetingApp.Toast.info('회의록으로 이동합니다'); return true;">
📄 2025년 1분기 제품 기획 회의 (2025-10-25)
</a>
</div>
</div>
</div>
<!-- 완료 -->
<div class="kanban-column">
<div class="column-header">
<h2 class="column-title">완료</h2>
<span class="column-count">1</span>
</div>
<div class="todo-card">
<div class="todo-title">UI 프로토타입 디자인</div>
<div class="todo-meta">
<div class="todo-assignee">
<div class="avatar-sm" style="background-color: var(--color-info-main);"></div>
<span>최유진</span>
</div>
<div class="todo-duedate">
✅ 완료
</div>
</div>
<div class="todo-progress">
<div class="todo-progress-bar" style="width: 100%; background-color: var(--color-success-main);"></div>
</div>
<div class="todo-source">
<a href="08-최종확정.html#todo-section" class="todo-source-link" onclick="MeetingApp.Toast.info('회의록으로 이동합니다'); return true;">
📄 2025년 1분기 제품 기획 회의 (2025-10-25)
</a>
</div>
</div>
</div>
</div>
<!-- 리스트 뷰 -->
<div class="list-view" id="listView">
<div class="todo-list-item">
<input type="checkbox" class="todo-checkbox">
<div class="todo-list-content">
<div class="todo-title">API 명세서 작성</div>
<div class="todo-meta">
<div class="todo-assignee">
<div class="avatar-sm"></div>
<span>이준호</span>
</div>
<div class="todo-duedate">📅 오늘</div>
<span class="badge badge-warning">진행 중</span>
</div>
<div class="todo-source" style="margin-top: var(--spacing-2);">
<a href="08-최종확정.html#todo-section" class="todo-source-link" onclick="MeetingApp.Toast.info('회의록으로 이동합니다'); return true;">
📄 2025년 1분기 제품 기획 회의 (2025-10-25)
</a>
</div>
</div>
</div>
<div class="todo-list-item">
<input type="checkbox" class="todo-checkbox">
<div class="todo-list-content">
<div class="todo-title">예산 편성안 검토</div>
<div class="todo-meta">
<div class="todo-assignee">
<div class="avatar-sm" style="background-color: var(--color-secondary-main);"></div>
<span>박서연</span>
</div>
<div class="todo-duedate overdue">📅 D+2 (지남)</div>
<span class="badge badge-warning">진행 중</span>
</div>
<div class="todo-source" style="margin-top: var(--spacing-2);">
<a href="08-최종확정.html#todo-section" class="todo-source-link" onclick="MeetingApp.Toast.info('회의록으로 이동합니다'); return true;">
📄 2025년 1분기 제품 기획 회의 (2025-10-25)
</a>
</div>
</div>
</div>
<div class="todo-list-item">
<input type="checkbox" class="todo-checkbox" checked>
<div class="todo-list-content">
<div class="todo-title" style="text-decoration: line-through; color: var(--color-gray-500);">UI 프로토타입 디자인</div>
<div class="todo-meta">
<div class="todo-assignee">
<div class="avatar-sm" style="background-color: var(--color-info-main);"></div>
<span>최유진</span>
</div>
<span class="badge badge-success">완료</span>
</div>
<div class="todo-source" style="margin-top: var(--spacing-2);">
<a href="08-최종확정.html#todo-section" class="todo-source-link" onclick="MeetingApp.Toast.info('회의록으로 이동합니다'); return true;">
📄 2025년 1분기 제품 기획 회의 (2025-10-25)
</a>
</div>
</div>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
// 뷰 전환
const viewBtns = document.querySelectorAll('.view-btn');
const kanbanView = document.getElementById('kanbanView');
const listView = document.getElementById('listView');
viewBtns.forEach(btn => {
btn.addEventListener('click', () => {
const view = btn.getAttribute('data-view');
viewBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (view === 'kanban') {
kanbanView.style.display = 'grid';
listView.classList.remove('active');
} else {
kanbanView.style.display = 'none';
listView.classList.add('active');
}
});
});
// Todo 추가
function addTodo() {
MeetingApp.Toast.info('Todo 추가 기능은 준비 중입니다');
}
// Todo 카드 클릭
const todoCards = document.querySelectorAll('.todo-card');
todoCards.forEach(card => {
card.addEventListener('click', () => {
MeetingApp.Toast.info('Todo 상세 정보를 표시합니다');
});
});
// 드래그 앤 드롭 (간단한 시뮬레이션)
todoCards.forEach(card => {
card.addEventListener('dragstart', (e) => {
e.dataTransfer.effectAllowed = 'move';
e.target.style.opacity = '0.5';
});
card.addEventListener('dragend', (e) => {
e.target.style.opacity = '1';
});
card.setAttribute('draggable', 'true');
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,212 +0,0 @@
# 프로토타입 테스트 결과 보고서
## 테스트 일시
2025-10-20
## 테스트 범위
전체 화면 플로우 테스트 (01-로그인 ~ 10-Todo관리)
## 테스트 결과 요약
- 총 10개 화면 테스트
- 정상 작동: 7개 화면
- 버그 발견: 3개
---
## 발견된 버그
### 1. [HIGH] 대시보드 FAB 버튼 클릭 이벤트 미작동
- **파일**: `02-대시보드.html`
- **위치**: 라인 682 (JavaScript)
- **증상**: FAB 버튼 클릭 시 회의 예약 페이지로 이동하지 않음
- **원인**: JavaScript 이벤트 리스너가 제대로 바인딩되지 않음
- **영향도**: 높음 (주요 네비게이션 기능)
- **상태**: 미수정
### 2. [HIGH] 회의 예약 폼 제출 버그
- **파일**: `03-회의예약.html`
- **위치**: 라인 99 (form submit handler)
- **증상**: 필수 필드를 모두 입력해도 폼 제출 시 페이지 이동하지 않음
- **원인**: 폼 검증 로직 또는 이벤트 핸들러 문제
- **영향도**: 높음 (핵심 기능)
- **상태**: 미수정
### 3. [CRITICAL] 최종확정 페이지 링크 오류
- **파일**: `08-최종확정.html`
- **위치**: 라인 294
- **증상**: 회의록 확정 후 "08-회의록공유.html"로 이동 시도하여 404 오류 발생
- **원인**: 파일명 재정렬 후 링크 업데이트 누락
- **수정 내용**: `'08-회의록공유.html'``'09-회의록공유.html'`
- **영향도**: 매우 높음 (페이지 이동 불가)
- **상태**: ✅ 수정 완료
### 4. [LOW] common.js 중복 로드 경고
- **파일**: 모든 HTML 파일
- **증상**: 콘솔에 "AppState가 이미 선언되었다"는 경고 메시지
- **원인**: 페이지 전환 시 common.js가 중복으로 로드됨
- **영향도**: 낮음 (기능에는 영향 없음)
- **상태**: 미수정
---
## 정상 작동 화면
### ✅ 01-로그인.html
- 로그인 폼 정상 작동
- 인증 성공 시 대시보드로 정상 이동
- Toast 메시지 정상 표시
### ✅ 04-템플릿선택.html
- 템플릿 카드 선택 기능 정상
- 선택 시 체크마크 표시 정상
- "회의 시작하기" 버튼 활성화/비활성화 정상
### ✅ 05-회의진행.html
- 회의 에디터 정상 표시
- 녹음 타이머 정상 작동
- 참석자/AI 제안 탭 전환 정상
### ✅ 06-검증완료.html
- AI 검증 결과 정상 표시
- 통계 카드 정상 렌더링
- 발언 분포 그래프 정상
### ✅ 07-회의종료.html
- 회의 요약 정보 정상 표시
- 버튼 네비게이션 정상
### ✅ 08-최종확정.html
- 회의록 미리보기 정상 표시
- 필수 항목 체크리스트 기능 정상
- 모든 항목 체크 시 확정 버튼 활성화 정상
### ✅ 09-회의록공유.html
- 공유 링크 표시 정상
- 공유 방식 선택 UI 정상
- 참석자 목록 표시 정상
### ✅ 10-Todo관리.html
- 칸반 보드 레이아웃 정상
- Todo 카드 표시 정상
- 담당자 아바타 표시 정상
---
## 스크린샷
테스트 중 캡처한 스크린샷은 `.playwright-mcp/screenshots/` 디렉토리에 저장됨:
- 01-login.png
- 02-dashboard.png
- 03-meeting-reserve.png
- 03-form-filled.png
- 04-template-selection.png
- 05-meeting-progress.png
- 06-verification-complete.png
- 07-meeting-end.png
- 08-final-confirmation.png
- 09-meeting-share.png
- 10-todo-management.png
---
## 다음 작업
1. 대시보드 FAB 버튼 이벤트 핸들러 수정
2. 회의 예약 폼 제출 로직 수정
3. common.js 중복 로드 문제 해결 (선택적)
4. 전체 재테스트
---
## 테스트 환경
- 브라우저: Playwright (Chromium)
- 운영체제: Windows 10
- 테스트 도구: Claude Code + Playwright MCP
---
## 개선 사항 (2025-10-20 추가)
### Todo-회의록 자동 링크 기능 개선
**문제점**: 회의록과 업무이력(Todo)의 자동 링크가 명확하게 표현되지 않음
- Todo 관리 화면에서 어떤 회의에서 생성되었는지 알 수 없음
- 회의록 공유 화면에서 생성된 Todo 목록과 진행 상황이 표시되지 않음
- 양방향 연결이 누락됨
**개선 내용**:
1. **10-Todo관리.html 개선**
- 모든 Todo 카드에 "출처 회의록" 정보 추가
- 회의 제목, 날짜 표시
- 회의록으로 이동하는 클릭 가능한 링크 추가
- 칸반 보드 5개, 리스트 뷰 3개 항목 모두 적용
2. **09-회의록공유.html 개선**
- "생성된 Todo" 섹션 추가
- 3개 Todo 항목 표시 (제목, 담당자, 마감일)
- 진행 상황 표시 (진행중 60%, 완료 100%, 지연 30%)
- "Todo 보기" 링크로 Todo 관리 페이지 연결
**개선 효과**:
- ✅ Todo와 회의록 간 양방향 연결 구현
- ✅ 업무 이력 추적 가능성 향상
- ✅ 유저스토리 차별화 포인트 명확하게 구현
- UFR-TODO-010: "관련 회의록 링크 (섹션 위치 포함)"
- UFR-RAG-020: "과거 회의록 및 업무 이력 연결"
- ✅ 회의 결과물의 실행 상황 실시간 파악 가능
**변경된 파일**:
- design/uiux/prototype/10-Todo관리.html (8개 위치 수정)
- design/uiux/prototype/09-회의록공유.html (1개 섹션 추가)
---
### 회의 진행 중 관련 자료 실시간 제공 기능 추가
**문제점**: 회의 진행 중 현재 논의 주제와 관련된 과거 회의록 및 업무이력 정보 부재
- 참석자가 이전 논의 맥락을 알 수 없음
- 관련 Todo 진행 상황을 실시간으로 파악할 수 없음
- 중복 논의 또는 누락된 사항 발생 가능
**개선 내용**:
1. **05-회의진행.html 사이드 패널 개선**
- "관련 자료" 탭 신규 추가 (참석자, AI 제안 탭에 이어 3번째 탭)
- 실시간 컨텍스트 기반 정보 제공
2. **관련 회의록 섹션 (3건 표시)**
- 회의 제목, 날짜, 관련도 점수 표시
- 회의 요약 미리보기
- 공통 키워드 하이라이트
- 클릭 시 새 탭에서 회의록 열기
- 예시:
- "2024년 4분기 제품 기획 회의" (관련도 92%)
- "API 설계 리뷰 회의" (관련도 78%)
- "주간 진행 상황 점검" (관련도 71%)
3. **관련 업무이력 섹션 (2건 표시)**
- Todo 제목, 담당자, 마감일, 진행률 표시
- 실시간 상태 배지 (진행중/지연/완료)
- 출처 회의록 정보 표시
- 관련 사유 설명
- 클릭 시 Todo 관리 페이지로 이동
- 예시:
- "API 명세서 작성" (담당: 이준호, 진행중 60%)
- "예산 편성안 검토" (담당: 박서연, 지연 30%)
**개선 효과**:
- ✅ 회의 중 과거 맥락 실시간 파악 가능
- ✅ 중복 논의 방지 및 연속성 확보
- ✅ 관련 Todo 진행 상황 즉시 확인 가능
- ✅ 유저스토리 차별화 포인트 명확하게 구현
- UFR-AI-040: "관련 회의록 자동 연결" 구현
- UFR-RAG-020: "관련 회의록과 업무 이력을 바탕으로 실용적인 정보 제공" 구현
- UFR-RAG-030: "관련 문서 자동 연결" 구현
- ✅ AI 기반 지능형 회의 진행 지원
**기술적 구현**:
- RAG(Retrieval-Augmented Generation) 시스템 시뮬레이션
- 관련도 점수 알고리즘 (벡터 유사도 기반)
- 실시간 컨텍스트 분석 및 추천
**변경된 파일**:
- design/uiux/prototype/05-회의진행.html (1개 탭 추가, 관련 자료 섹션 구현)

File diff suppressed because it is too large Load Diff

View File

@ -3,141 +3,168 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그인 - 회의록 작성 서비스</title>
<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">
</head>
<body>
<main class="main-content">
<div class="container">
<div style="text-align: center; padding: var(--space-12) var(--space-4);">
<!-- Service Logo -->
<div style="width: 120px; height: 120px; margin: 0 auto var(--space-6); background: var(--primary-light); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 48px;">
📝
<div class="page">
<!-- 로그인 컨테이너 -->
<div class="content d-flex flex-column align-center justify-center" style="min-height: 100vh;">
<div class="card" style="max-width: 400px; width: 100%; text-align: center;">
<!-- 로고 및 타이틀 -->
<div class="mb-6">
<div style="font-size: 48px; margin-bottom: 16px;">📝</div>
<h1 class="text-h2">회의록 서비스</h1>
<p class="text-body text-gray">AI 기반 회의록 작성 및 공유</p>
</div>
<!-- Service Title -->
<h1 class="mb-4">회의록 자동 작성 서비스</h1>
<p class="text-secondary mb-8">AI가 도와주는 스마트한 회의록 관리</p>
<!-- Login Form -->
<form id="loginForm" style="max-width: 400px; margin: 0 auto;">
<!-- 로그인 폼 -->
<form id="loginForm" class="text-left">
<div class="form-group">
<label for="username" class="form-label">아이디 <span class="required">*</span></label>
<label for="employeeId" class="form-label required">사번</label>
<input
type="text"
id="username"
name="username"
id="employeeId"
class="form-input"
placeholder="아이디를 입력하세요"
required
placeholder="EMP001"
data-validate="required|employeeId"
aria-label="사번"
aria-required="true"
autocomplete="username"
/>
<span class="form-error" id="username-error"></span>
<span class="form-hint">테스트 계정: kimmin</span>
>
</div>
<div class="form-group">
<label for="password" class="form-label">비밀번호 <span class="required">*</span></label>
<label for="password" class="form-label required">비밀번호</label>
<input
type="password"
id="password"
name="password"
class="form-input"
placeholder="비밀번호를 입력하세요"
required
data-validate="required|minLength:4"
aria-label="비밀번호"
aria-required="true"
autocomplete="current-password"
/>
<span class="form-error" id="password-error"></span>
<span class="form-hint">테스트 비밀번호: password123</span>
>
</div>
<button type="submit" class="btn btn-primary btn-full">
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" id="rememberMe">
<span>로그인 상태 유지</span>
</label>
</div>
<button type="submit" class="btn btn-primary w-full" style="margin-top: 24px;">
로그인
</button>
</form>
<!-- 비밀번호 찾기 -->
<div class="mt-4 text-center">
<a href="#" class="text-body-sm text-primary" style="text-decoration: none;">비밀번호 찾기</a>
</div>
<!-- 테스트 계정 안내 -->
<div class="mt-6 p-4" style="background: var(--gray-100); border-radius: 8px;">
<p class="text-caption text-gray mb-2">테스트 계정</p>
<p class="text-body-sm">사번: EMP001 ~ EMP005</p>
<p class="text-body-sm">비밀번호: 1234</p>
</div>
</div>
</div>
</main>
</div>
<script src="common.js"></script>
<script>
const { Validator, Auth, UI, Navigation } = window.App;
// Set page title
UI.setTitle('로그인');
// Form validation rules
const validationRules = {
username: [
{
validator: (value) => Validator.required(value),
message: '아이디를 입력해주세요'
},
{
validator: (value) => Validator.minLength(value, 4),
message: '아이디는 4자 이상이어야 합니다'
}
],
password: [
{
validator: (value) => Validator.required(value),
message: '비밀번호를 입력해주세요'
},
{
validator: (value) => Validator.minLength(value, 8),
message: '비밀번호는 8자 이상이어야 합니다'
}
]
};
// Form submit handler
// 로그인 폼 제출 처리
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
// Validate form
const isValid = Validator.validateForm('loginForm', validationRules);
if (!isValid) return;
const employeeId = document.getElementById('employeeId').value.trim();
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('rememberMe').checked;
const formData = new FormData(e.target);
const username = formData.get('username');
const password = formData.get('password');
// Show loading
UI.showLoading();
// Simulate API call delay
await new Promise(resolve => setTimeout(resolve, 500));
// Attempt login
const success = Auth.login(username, password);
UI.hideLoading();
if (success) {
UI.showToast('로그인 성공!', 'success');
setTimeout(() => {
Navigation.goTo('02-대시보드.html');
}, 500);
} else {
UI.showToast('아이디 또는 비밀번호가 올바르지 않습니다', 'error');
// 간단한 폼 검증
if (!employeeId || !password) {
UIComponents.showToast('사번과 비밀번호를 입력해주세요.', 'error');
return;
}
// 로딩 표시
UIComponents.showLoading('로그인 중...');
// 사용자 인증 시뮬레이션
setTimeout(() => {
const user = DUMMY_USERS.find(u => u.id === employeeId && u.password === password);
UIComponents.hideLoading();
if (user) {
// 로그인 성공
StorageManager.setCurrentUser({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
position: user.position,
rememberMe: rememberMe,
loginAt: new Date().toISOString()
});
UIComponents.showToast('로그인 성공', 'success');
// 대시보드로 이동
setTimeout(() => {
NavigationHelper.navigate('DASHBOARD');
}, 500);
} else {
// 로그인 실패
UIComponents.showToast('사번 또는 비밀번호가 올바르지 않습니다.', 'error');
// 필드 애니메이션 (shake)
const form = document.getElementById('loginForm');
form.style.animation = 'shake 0.5s';
setTimeout(() => {
form.style.animation = '';
}, 500);
}
}, 1000);
});
// Real-time validation on blur
document.querySelectorAll('#loginForm input').forEach(input => {
input.addEventListener('blur', () => {
Validator.validateForm('loginForm', validationRules);
// 엔터키 처리
document.querySelectorAll('.form-input').forEach(input => {
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const form = document.getElementById('loginForm');
const inputs = Array.from(form.querySelectorAll('.form-input'));
const index = inputs.indexOf(e.target);
if (index < inputs.length - 1) {
// 다음 필드로 포커스 이동
inputs[index + 1].focus();
} else {
// 마지막 필드면 폼 제출
form.dispatchEvent(new Event('submit'));
}
}
});
});
// Enter key support
document.getElementById('password').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
document.getElementById('loginForm').dispatchEvent(new Event('submit'));
}
});
// 자동 로그인 체크 (개발 편의)
const savedUser = StorageManager.getCurrentUser();
if (savedUser && savedUser.rememberMe) {
// 이미 로그인된 사용자는 대시보드로 이동
NavigationHelper.navigate('DASHBOARD');
}
</script>
<style>
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
20%, 40%, 60%, 80% { transform: translateX(5px); }
}
</style>
</body>
</html>

View File

@ -3,210 +3,223 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>대시보드 - 회의록 작성 서비스</title>
<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">
</head>
<body>
<!-- Header -->
<header class="header">
<div class="avatar" aria-label="프로필">KM</div>
<h1 class="header-title">회의록</h1>
<div class="header-actions">
<button class="btn-icon" aria-label="알림">
<span style="font-size: 24px; position: relative;">
🔔
<span class="badge badge-error" style="position: absolute; top: -4px; right: -4px; width: 8px; height: 8px; border-radius: 50%; padding: 0;"></span>
</span>
</button>
</div>
</header>
<!-- Main Content -->
<main class="main-content">
<div class="container">
<!-- Today's Meetings Section -->
<section aria-labelledby="today-meetings" class="mb-8">
<h2 id="today-meetings">오늘의 회의</h2>
<div id="todayMeetings" style="display: flex; gap: var(--space-4); overflow-x: auto; padding-bottom: var(--space-2);">
<!-- Meeting cards will be inserted here -->
</div>
</section>
<!-- Recent Minutes Section -->
<section aria-labelledby="recent-minutes" class="mb-8">
<h2 id="recent-minutes">최근 회의록</h2>
<div id="recentMinutes" class="flex flex-col gap-4">
<!-- Minutes cards will be inserted here -->
</div>
</section>
<!-- Todo Summary Section -->
<section aria-labelledby="todo-summary" class="mb-8">
<h2 id="todo-summary">Todo 요약</h2>
<div class="card" style="cursor: pointer;" onclick="window.location.href='09-Todo관리.html'">
<div class="flex justify-between items-center">
<div>
<div class="text-secondary" style="font-size: var(--font-sm); margin-bottom: var(--space-2);">진행 중</div>
<div style="font-size: var(--font-2xl); font-weight: var(--font-bold);" id="todoInProgress">-</div>
</div>
<div>
<div class="text-secondary" style="font-size: var(--font-sm); margin-bottom: var(--space-2);">완료</div>
<div style="font-size: var(--font-2xl); font-weight: var(--font-bold);" id="todoCompleted">-</div>
</div>
<div>
<div class="text-secondary" style="font-size: var(--font-sm); margin-bottom: var(--space-2);">전체</div>
<div style="font-size: var(--font-2xl); font-weight: var(--font-bold);" id="todoTotal">-</div>
</div>
</div>
</div>
</section>
<div class="page">
<!-- 헤더 -->
<div class="header">
<h1 class="header-title">회의록 서비스</h1>
<div class="d-flex align-center gap-2">
<button class="btn-icon" aria-label="검색" title="검색">
<span class="material-symbols-outlined">search</span>
</button>
<button class="btn-icon" aria-label="프로필" title="프로필" onclick="showProfileMenu()">
<span class="material-symbols-outlined">account_circle</span>
</button>
</div>
</div>
<!-- FAB -->
<button class="fab" aria-label="새 회의 예약" onclick="window.location.href='03-회의예약.html'">
+
</button>
</main>
<!-- 메인 컨텐츠 -->
<div class="content" style="padding-bottom: 80px;">
<!-- 환영 메시지 -->
<div class="mb-6">
<h2 class="text-h3" id="welcomeMessage">안녕하세요!</h2>
<p class="text-body-sm text-gray">오늘도 효율적인 회의록 작성을 시작하세요</p>
</div>
<!-- Bottom Navigation -->
<nav class="bottom-nav" aria-label="주요 네비게이션">
<a href="02-대시보드.html" class="bottom-nav-item active" aria-current="page">
<span class="bottom-nav-icon" aria-hidden="true">🏠</span>
<span></span>
</a>
<a href="02-대시보드.html" class="bottom-nav-item">
<span class="bottom-nav-icon" aria-hidden="true">📅</span>
<span>회의</span>
</a>
<a href="09-Todo관리.html" class="bottom-nav-item">
<span class="bottom-nav-icon" aria-hidden="true"></span>
<span>Todo</span>
</a>
<a href="#" class="bottom-nav-item">
<span class="bottom-nav-icon" aria-hidden="true">🔔</span>
<span>알림</span>
</a>
<a href="#" class="bottom-nav-item">
<span class="bottom-nav-icon" aria-hidden="true">⚙️</span>
<span>설정</span>
</a>
</nav>
<!-- 빠른 액션 -->
<div class="d-flex gap-2 mb-6">
<button class="btn btn-primary" onclick="NavigationHelper.navigate('TEMPLATE_SELECT')" style="flex: 1;">
<span class="material-symbols-outlined">play_circle</span>
새 회의 시작
</button>
<button class="btn btn-secondary" onclick="NavigationHelper.navigate('MEETING_SCHEDULE')">
<span class="material-symbols-outlined">calendar_today</span>
회의 예약
</button>
</div>
<!-- 내 Todo 카드 -->
<div class="card mb-4">
<div class="d-flex justify-between align-center mb-4">
<h3 class="text-h4">내 Todo</h3>
<a href="javascript:NavigationHelper.navigate('TODO_MANAGE')" class="text-body-sm text-primary" style="text-decoration: none;">전체 보기 →</a>
</div>
<div id="todoDashboard">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
<!-- 내 회의록 카드 -->
<div class="card mb-4">
<div class="d-flex justify-between align-center mb-4">
<h3 class="text-h4">내 회의록</h3>
<a href="#" class="text-body-sm text-primary" style="text-decoration: none;">전체 보기 →</a>
</div>
<div id="meetingsDashboard">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
<!-- 공유받은 회의록 카드 -->
<div class="card mb-4">
<div class="d-flex justify-between align-center mb-4">
<h3 class="text-h4">공유받은 회의록</h3>
<a href="#" class="text-body-sm text-primary" style="text-decoration: none;">전체 보기 →</a>
</div>
<div id="sharedMeetingsDashboard">
<p class="text-body-sm text-gray text-center" style="padding: 24px 0;">공유받은 회의록이 없습니다</p>
</div>
</div>
</div>
<!-- 하단 네비게이션 -->
<nav class="bottom-nav" role="navigation" aria-label="주요 메뉴">
<a href="02-대시보드.html" class="bottom-nav-item active" aria-current="page">
<span class="material-symbols-outlined bottom-nav-icon">home</span>
<span></span>
</a>
<a href="11-회의록수정.html" class="bottom-nav-item">
<span class="material-symbols-outlined bottom-nav-icon">description</span>
<span>회의록</span>
</a>
<a href="09-Todo관리.html" class="bottom-nav-item">
<span class="material-symbols-outlined bottom-nav-icon">check_box</span>
<span>Todo</span>
</a>
<a href="javascript:showProfileMenu()" class="bottom-nav-item">
<span class="material-symbols-outlined bottom-nav-icon">account_circle</span>
<span>프로필</span>
</a>
</nav>
</div>
<script src="common.js"></script>
<script>
const { Auth, API, UI, DateTime, Navigation } = window.App;
// Check authentication
if (!Auth.requireAuth()) return;
// Set page title
UI.setTitle('대시보드');
// Load dashboard data
async function loadDashboard() {
UI.showLoading();
try {
// Load all data in parallel
const [meetingsRes, minutesRes, todosRes] = await Promise.all([
API.getMeetings(),
API.getMinutes(),
API.getTodos()
]);
// Render today's meetings
renderTodayMeetings(meetingsRes.data);
// Render recent minutes
renderRecentMinutes(minutesRes.data);
// Render todo summary
renderTodoSummary(todosRes.data.summary);
} catch (error) {
UI.showToast('데이터를 불러올 수 없습니다', 'error');
console.error('Dashboard load error:', error);
} finally {
UI.hideLoading();
}
// 인증 확인
if (!NavigationHelper.requireAuth()) {
// 로그인 필요
}
function renderTodayMeetings(meetings) {
const container = document.getElementById('todayMeetings');
const currentUser = StorageManager.getCurrentUser();
if (meetings.length === 0) {
container.innerHTML = '<div class="empty-state"><p>예정된 회의가 없습니다</p></div>';
// 환영 메시지
document.getElementById('welcomeMessage').textContent = `안녕하세요, ${currentUser.name}님!`;
// Todo 대시보드 렌더링
function renderTodoDashboard() {
const todos = StorageManager.getTodos();
const myTodos = todos.filter(todo => todo.assigneeId === currentUser.id && !todo.completed);
const container = document.getElementById('todoDashboard');
if (myTodos.length === 0) {
container.innerHTML = '<p class="text-body-sm text-gray text-center" style="padding: 24px 0;">할당된 Todo가 없습니다</p>';
return;
}
container.innerHTML = meetings.map(meeting => `
<div class="card card-hover" style="min-width: 280px; flex-shrink: 0;" onclick="showMeetingDetail('${meeting.id}')">
<h3 class="card-title">${meeting.title}</h3>
<div class="text-secondary mb-2">
<span aria-label="시간"></span> ${DateTime.formatTime(meeting.startTime)} - ${DateTime.formatTime(meeting.endTime)}
// 진행 중 Todo 개수
const inProgressCount = myTodos.filter(t => !t.completed).length;
// 마감 임박 Todo (3일 이내)
const dueSoonTodos = myTodos.filter(todo => isDueSoon(todo.dueDate)).slice(0, 3);
let html = `
<div class="d-flex align-center gap-4 mb-4">
<div class="d-flex align-center gap-2">
<div class="badge-count">${inProgressCount}</div>
<span class="text-body-sm">진행 중</span>
</div>
<div class="text-secondary mb-2">
<span aria-label="장소">📍</span> ${meeting.location}
</div>
<div class="text-secondary">
<span aria-label="참석자">👥</span> ${meeting.attendeesCount}명
<div class="d-flex align-center gap-2">
<span class="material-symbols-outlined" style="color: var(--warning); font-size: 20px;">schedule</span>
<span class="text-body-sm">${dueSoonTodos.length}개 마감 임박</span>
</div>
</div>
`).join('');
`;
if (dueSoonTodos.length > 0) {
dueSoonTodos.forEach(todo => {
html += UIComponents.createTodoItem(todo);
});
}
container.innerHTML = html;
}
function renderRecentMinutes(minutes) {
const container = document.getElementById('recentMinutes');
// 회의록 대시보드 렌더링
function renderMeetingsDashboard() {
const meetings = StorageManager.getMeetings();
const myMeetings = meetings
.filter(m => m.createdBy === currentUser.id || m.attendees.includes(currentUser.name))
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.slice(0, 5);
if (minutes.length === 0) {
container.innerHTML = '<div class="empty-state"><p>최근 회의록이 없습니다</p></div>';
const container = document.getElementById('meetingsDashboard');
if (myMeetings.length === 0) {
container.innerHTML = '<p class="text-body-sm text-gray text-center" style="padding: 24px 0;">작성한 회의록이 없습니다. 첫 회의를 시작해보세요!</p>';
return;
}
container.innerHTML = minutes.map(minute => `
<div class="card card-hover" onclick="alert('회의록 보기 기능은 개발 예정입니다')">
<div class="flex justify-between items-center">
<div>
<h3 class="card-title mb-2">${minute.title}</h3>
<div class="text-secondary">${DateTime.formatDate(minute.date)}</div>
let html = '';
myMeetings.forEach(meeting => {
html += UIComponents.createMeetingItem(meeting);
});
container.innerHTML = html;
}
// 프로필 메뉴 표시
function showProfileMenu() {
UIComponents.showModal({
title: '프로필',
content: `
<div class="d-flex flex-column gap-4">
<div class="d-flex align-center gap-3">
${UIComponents.createAvatar(currentUser.name, 60)}
<div>
<h3 class="text-h4">${currentUser.name}</h3>
<p class="text-body-sm text-gray">${currentUser.role} · ${currentUser.position}</p>
<p class="text-body-sm text-gray">${currentUser.email}</p>
</div>
</div>
<div class="avatar-group">
${minute.attendees.slice(0, 3).map(name => `
<div class="avatar avatar-sm">${name.charAt(0)}</div>
`).join('')}
${minute.attendees.length > 3 ? `<div class="avatar avatar-sm">+${minute.attendees.length - 3}</div>` : ''}
<div style="border-top: 1px solid var(--gray-200); padding-top: 16px;">
<button class="btn btn-text w-full" style="justify-content: flex-start;">
<span class="material-symbols-outlined">settings</span>
설정
</button>
<button class="btn btn-text w-full" style="justify-content: flex-start; color: var(--error);" onclick="handleLogout()">
<span class="material-symbols-outlined">logout</span>
로그아웃
</button>
</div>
</div>
</div>
`).join('');
}
function renderTodoSummary(summary) {
document.getElementById('todoInProgress').textContent = summary.inProgress;
document.getElementById('todoCompleted').textContent = summary.completed;
document.getElementById('todoTotal').textContent = summary.total;
}
function showMeetingDetail(meetingId) {
UI.showModal({
title: '회의 상세',
content: '<p>회의를 시작하시겠습니까?</p>',
buttons: [
{
text: '취소',
className: 'btn-secondary'
},
{
text: '회의 시작',
className: 'btn-primary',
onClick: () => Navigation.goTo('04-템플릿선택.html')
}
]
`,
footer: '',
onClose: () => {}
});
}
// Initialize dashboard
loadDashboard();
// 로그아웃 처리
function handleLogout() {
UIComponents.confirm(
'로그아웃 하시겠습니까?',
() => {
StorageManager.logout();
},
() => {}
);
}
// 초기 렌더링
renderTodoDashboard();
renderMeetingsDashboard();
</script>
</body>
</html>

View File

@ -3,326 +3,348 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의 예약 - 회의록 작성 서비스</title>
<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">
</head>
<body>
<!-- Header -->
<header class="header">
<button class="header-back" aria-label="뒤로가기"></button>
<h1 class="header-title">회의 예약</h1>
<div class="header-actions">
<button class="btn-icon" aria-label="임시저장" onclick="saveDraft()">
<span style="font-size: 24px;"></span>
<div class="page">
<!-- 헤더 -->
<div class="header">
<button class="btn-icon" onclick="NavigationHelper.goBack()" aria-label="뒤로가기">
<span class="material-symbols-outlined">arrow_back</span>
</button>
<h1 class="header-title">회의 예약</h1>
<button type="submit" form="meetingForm" class="btn btn-primary btn-sm">저장</button>
</div>
</header>
<!-- Main Content -->
<main class="main-content">
<div class="container">
<!-- 메인 컨텐츠 -->
<div class="content">
<form id="meetingForm">
<!-- Meeting Title -->
<!-- 회의 제목 -->
<div class="form-group">
<label for="title" class="form-label">
회의 제목 <span class="required">*</span>
</label>
<label for="meetingTitle" class="form-label required">회의 제목</label>
<input
type="text"
id="title"
name="title"
id="meetingTitle"
class="form-input"
placeholder="예: 주간 회의"
required
aria-required="true"
placeholder="회의 제목을 입력하세요"
maxlength="100"
/>
<span class="form-error"></span>
data-validate="required|maxLength:100"
aria-label="회의 제목"
aria-required="true"
>
<p class="text-caption text-right mt-1" id="titleCounter">0 / 100</p>
</div>
<!-- Date and Time -->
<!-- 날짜 -->
<div class="form-group">
<label class="form-label">
날짜 및 시간 <span class="required">*</span>
</label>
<div class="flex gap-4">
<div style="flex: 1;">
<input
type="date"
id="date"
name="date"
class="form-input"
required
aria-required="true"
/>
</div>
<div style="flex: 1;">
<input
type="time"
id="startTime"
name="startTime"
class="form-input"
required
aria-required="true"
/>
</div>
<label for="meetingDate" class="form-label required">회의 날짜</label>
<input
type="date"
id="meetingDate"
class="form-input"
data-validate="required"
aria-label="회의 날짜"
aria-required="true"
>
</div>
<!-- 시작 시간 / 종료 시간 -->
<div class="d-flex gap-2">
<div class="form-group" style="flex: 1;">
<label for="startTime" class="form-label required">시작 시간</label>
<input
type="time"
id="startTime"
class="form-input"
data-validate="required"
aria-label="시작 시간"
aria-required="true"
>
</div>
<div class="form-group" style="flex: 1;">
<label for="endTime" class="form-label required">종료 시간</label>
<input
type="time"
id="endTime"
class="form-input"
data-validate="required"
aria-label="종료 시간"
aria-required="true"
>
</div>
<span class="form-error"></span>
</div>
<!-- Location -->
<!-- 종일 토글 -->
<div class="form-group">
<label for="location" class="form-label">
장소 (선택)
<label class="form-checkbox">
<input type="checkbox" id="allDay" onchange="toggleAllDay()">
<span>종일</span>
</label>
</div>
<!-- 장소 -->
<div class="form-group">
<label for="location" class="form-label">장소</label>
<input
type="text"
id="location"
name="location"
class="form-input"
placeholder="예: 회의실 A"
placeholder="회의실 또는 온라인 링크"
maxlength="200"
/>
aria-label="회의 장소"
>
</div>
<!-- Attendees -->
<!-- 온라인/오프라인 선택 -->
<div class="form-group">
<label for="attendeeSearch" class="form-label">
참석자 <span class="required">*</span>
</label>
<input
type="text"
id="attendeeSearch"
class="form-input"
placeholder="🔍 이메일 검색"
autocomplete="off"
/>
<div id="attendeeList" class="flex flex-col gap-2 mt-4">
<!-- Selected attendees will be displayed here as chips -->
<div class="d-flex gap-2">
<button type="button" class="btn btn-secondary btn-sm" id="btnOffline" onclick="setLocationType('offline')" style="flex: 1;">
오프라인
</button>
<button type="button" class="btn btn-secondary btn-sm" id="btnOnline" onclick="setLocationType('online')" style="flex: 1;">
온라인
</button>
</div>
<span class="form-error" id="attendee-error"></span>
</div>
<!-- Submit Button -->
<button type="submit" class="btn btn-primary btn-full mt-8">
회의 예약
</button>
<!-- 참석자 -->
<div class="form-group">
<label class="form-label required">참석자 (최소 1명)</label>
<div id="attendeeChips" class="d-flex gap-2 mb-2" style="flex-wrap: wrap;">
<!-- JavaScript로 동적 생성 -->
</div>
<button type="button" class="btn btn-secondary btn-sm w-full" onclick="showAttendeeSearch()">
<span class="material-symbols-outlined">person_add</span>
참석자 추가
</button>
</div>
<!-- 안건 -->
<div class="form-group">
<label for="agenda" class="form-label">안건</label>
<textarea
id="agenda"
class="form-textarea"
rows="5"
placeholder="회의 안건을 입력하세요"
aria-label="회의 안건"
></textarea>
<button type="button" class="btn btn-text btn-sm mt-2" onclick="suggestAgenda()">
<span class="material-symbols-outlined">auto_awesome</span>
AI 안건 추천
</button>
</div>
</form>
</div>
</main>
</div>
<script src="common.js"></script>
<script>
const { Auth, API, UI, Validator, Navigation, Storage } = window.App;
if (!NavigationHelper.requireAuth()) {}
// Check authentication
if (!Auth.requireAuth()) return;
const currentUser = StorageManager.getCurrentUser();
let attendees = [];
let locationType = 'offline';
// Set page title
UI.setTitle('회의 예약');
// Attendees state
let selectedAttendees = [];
// Mock attendee data
const mockAttendees = [
{ id: 'user-001', name: '김민준', email: 'kimmin@example.com' },
{ id: 'user-002', name: '박서연', email: 'parksy@example.com' },
{ id: 'user-003', name: '이준호', email: 'leejh@example.com' },
{ id: 'user-004', name: '최유진', email: 'choiyj@example.com' },
{ id: 'user-005', name: '정도현', email: 'jeongdh@example.com' }
];
// Set minimum date to today
// 오늘 날짜 이전은 선택 불가
const today = new Date().toISOString().split('T')[0];
document.getElementById('date').min = today;
document.getElementById('date').value = today;
document.getElementById('meetingDate').setAttribute('min', today);
document.getElementById('meetingDate').value = today;
// Set default time
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
document.getElementById('startTime').value = `${hours}:${minutes}`;
// 제목 글자 수 카운터
document.getElementById('meetingTitle').addEventListener('input', (e) => {
const counter = document.getElementById('titleCounter');
counter.textContent = `${e.target.value.length} / 100`;
});
// Attendee search with autocomplete
let searchTimeout;
document.getElementById('attendeeSearch').addEventListener('input', (e) => {
clearTimeout(searchTimeout);
const query = e.target.value.toLowerCase();
// 종일 토글
function toggleAllDay() {
const allDay = document.getElementById('allDay').checked;
document.getElementById('startTime').disabled = allDay;
document.getElementById('endTime').disabled = allDay;
if (query.length < 2) return;
if (allDay) {
document.getElementById('startTime').value = '00:00';
document.getElementById('endTime').value = '23:59';
}
}
searchTimeout = setTimeout(() => {
const results = mockAttendees.filter(attendee =>
(attendee.name.toLowerCase().includes(query) ||
attendee.email.toLowerCase().includes(query)) &&
!selectedAttendees.find(a => a.id === attendee.id)
// 장소 유형 선택
function setLocationType(type) {
locationType = type;
const locationInput = document.getElementById('location');
document.getElementById('btnOffline').classList.toggle('btn-primary', type === 'offline');
document.getElementById('btnOffline').classList.toggle('btn-secondary', type !== 'offline');
document.getElementById('btnOnline').classList.toggle('btn-primary', type === 'online');
document.getElementById('btnOnline').classList.toggle('btn-secondary', type !== 'online');
if (type === 'online') {
locationInput.placeholder = '온라인 회의 링크 (자동 생성 가능)';
locationInput.value = 'https://meet.example.com/' + Utils.generateId('ROOM').toLowerCase();
} else {
locationInput.placeholder = '회의실 이름';
locationInput.value = '';
}
}
// 참석자 추가 모달
function showAttendeeSearch() {
const modal = UIComponents.showModal({
title: '참석자 추가',
content: `
<div class="form-group">
<input
type="text"
id="attendeeSearch"
class="form-input"
placeholder="이름 또는 이메일로 검색"
aria-label="참석자 검색"
>
</div>
<div id="attendeeSearchResults" style="max-height: 300px; overflow-y: auto;">
${DUMMY_USERS.map(user => `
<div class="meeting-item" onclick="addAttendee('${user.name}', '${user.email}', '${user.id}')">
<div style="flex: 1;">
<h4 class="text-body">${user.name}</h4>
<p class="text-caption text-gray">${user.role} · ${user.email}</p>
</div>
</div>
`).join('')}
</div>
`,
footer: '<button class="btn btn-secondary" onclick="closeModal()">닫기</button>',
onClose: () => {}
});
// 검색 기능
document.getElementById('attendeeSearch').addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const results = DUMMY_USERS.filter(user =>
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query) ||
user.role.toLowerCase().includes(query)
);
showSearchResults(results);
}, 300);
});
function showSearchResults(results) {
if (results.length === 0) return;
// Create dropdown
let dropdown = document.getElementById('attendeeDropdown');
if (!dropdown) {
dropdown = document.createElement('div');
dropdown.id = 'attendeeDropdown';
dropdown.style.cssText = 'position: absolute; background: var(--bg-white); border: 1px solid var(--border-light); border-radius: 8px; box-shadow: var(--shadow-md); margin-top: 4px; max-height: 200px; overflow-y: auto; z-index: 10; width: calc(100% - 32px);';
document.getElementById('attendeeSearch').parentElement.style.position = 'relative';
document.getElementById('attendeeSearch').after(dropdown);
}
dropdown.innerHTML = results.map(attendee => `
<div class="flex items-center gap-2" style="padding: var(--space-3); cursor: pointer; border-bottom: 1px solid var(--border-light);" onclick="addAttendee('${attendee.id}')">
<div class="avatar avatar-sm">${attendee.name.charAt(0)}</div>
<div>
<div style="font-weight: var(--font-medium);">${attendee.name}</div>
<div style="font-size: var(--font-xs); color: var(--text-secondary);">${attendee.email}</div>
document.getElementById('attendeeSearchResults').innerHTML = results.map(user => `
<div class="meeting-item" onclick="addAttendee('${user.name}', '${user.email}', '${user.id}')">
<div style="flex: 1;">
<h4 class="text-body">${user.name}</h4>
<p class="text-caption text-gray">${user.role} · ${user.email}</p>
</div>
</div>
</div>
`).join('');
`).join('');
});
}
function addAttendee(attendeeId) {
const attendee = mockAttendees.find(a => a.id === attendeeId);
if (!attendee || selectedAttendees.find(a => a.id === attendeeId)) return;
selectedAttendees.push(attendee);
renderAttendees();
// Clear search
document.getElementById('attendeeSearch').value = '';
const dropdown = document.getElementById('attendeeDropdown');
if (dropdown) dropdown.remove();
}
function removeAttendee(attendeeId) {
selectedAttendees = selectedAttendees.filter(a => a.id !== attendeeId);
renderAttendees();
}
function renderAttendees() {
const container = document.getElementById('attendeeList');
if (selectedAttendees.length === 0) {
container.innerHTML = '<p class="text-secondary" style="font-size: var(--font-sm);">참석자를 검색하여 추가하세요</p>';
// 참석자 추가
function addAttendee(name, email, id) {
if (attendees.find(a => a.id === id)) {
UIComponents.showToast('이미 추가된 참석자입니다', 'warning');
return;
}
container.innerHTML = selectedAttendees.map(attendee => `
<div class="chip">
<div class="avatar avatar-sm">${attendee.name.charAt(0)}</div>
<span>${attendee.name}</span>
<button type="button" class="chip-remove" onclick="removeAttendee('${attendee.id}')" aria-label="${attendee.name} 제거">
×
</button>
attendees.push({ id, name, email });
renderAttendees();
closeModal();
UIComponents.showToast(`${name} 님이 추가되었습니다`, 'success');
}
// 참석자 제거
function removeAttendee(id) {
attendees = attendees.filter(a => a.id !== id);
renderAttendees();
}
// 참석자 렌더링
function renderAttendees() {
const container = document.getElementById('attendeeChips');
container.innerHTML = attendees.map(attendee => `
<div class="badge badge-status" style="padding: 6px 12px; background: var(--primary-50); color: var(--primary-700);">
${attendee.name}
<button type="button" onclick="removeAttendee('${attendee.id}')" style="background: none; border: none; color: inherit; cursor: pointer; padding: 0; margin-left: 4px;">×</button>
</div>
`).join('');
}
// Form validation
const validationRules = {
title: [
{
validator: (value) => Validator.required(value),
message: '회의 제목을 입력해주세요'
}
],
date: [
{
validator: (value) => Validator.required(value),
message: '날짜를 선택해주세요'
}
],
startTime: [
{
validator: (value) => Validator.required(value),
message: '시간을 선택해주세요'
}
]
};
// 모달 닫기
function closeModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) modal.remove();
}
// Form submit
document.getElementById('meetingForm').addEventListener('submit', async (e) => {
// AI 안건 추천 (시뮬레이션)
function suggestAgenda() {
UIComponents.showLoading('AI가 안건을 추천하고 있습니다...');
setTimeout(() => {
const suggestions = [
'프로젝트 진행 상황 공유',
'이슈 및 리스크 논의',
'다음 주 일정 계획',
'역할 분담 및 업무 조율'
];
document.getElementById('agenda').value = suggestions.join('\n');
UIComponents.hideLoading();
UIComponents.showToast('AI 추천 안건이 추가되었습니다', 'success');
}, 1500);
}
// 폼 제출
document.getElementById('meetingForm').addEventListener('submit', (e) => {
e.preventDefault();
// Validate form
const isValid = Validator.validateForm('meetingForm', validationRules);
// Check attendees
if (selectedAttendees.length === 0) {
document.getElementById('attendee-error').textContent = '최소 1명의 참석자를 추가해주세요';
UI.showToast('최소 1명의 참석자를 추가해주세요', 'error');
// 검증
if (!FormValidator.validate(e.target)) {
return;
}
if (!isValid) return;
const formData = new FormData(e.target);
const meetingData = {
title: formData.get('title'),
startTime: `${formData.get('date')}T${formData.get('startTime')}:00Z`,
endTime: `${formData.get('date')}T${formData.get('startTime')}:00Z`, // Should calculate end time
location: formData.get('location') || '',
attendees: selectedAttendees.map(a => a.email)
};
UI.showLoading();
try {
const response = await API.createMeeting(meetingData);
if (response.success) {
UI.showToast('회의가 예약되었습니다', 'success');
Storage.remove('meeting-draft');
// Ask if user wants to select template
const proceed = await UI.confirm('템플릿을 선택하시겠습니까?');
if (proceed) {
Navigation.goTo('04-템플릿선택.html');
} else {
Navigation.goTo('02-대시보드.html');
}
}
} catch (error) {
UI.showToast('회의 예약에 실패했습니다', 'error');
} finally {
UI.hideLoading();
if (attendees.length === 0) {
UIComponents.showToast('최소 1명의 참석자를 추가해주세요', 'error');
return;
}
});
// Save draft
function saveDraft() {
const formData = new FormData(document.getElementById('meetingForm'));
const draft = {
title: formData.get('title'),
date: formData.get('date'),
startTime: formData.get('startTime'),
location: formData.get('location'),
attendees: selectedAttendees
const formData = {
id: Utils.generateId('MTG'),
title: document.getElementById('meetingTitle').value,
date: document.getElementById('meetingDate').value,
startTime: document.getElementById('startTime').value,
endTime: document.getElementById('endTime').value,
location: document.getElementById('location').value,
locationType: locationType,
attendees: attendees.map(a => a.name),
attendeeIds: attendees.map(a => a.id),
agenda: document.getElementById('agenda').value,
template: 'general',
status: 'scheduled',
createdBy: currentUser.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
Storage.set('meeting-draft', draft);
UI.showToast('임시 저장되었습니다', 'success');
}
UIComponents.showLoading('회의를 예약하는 중...');
// Load draft if exists
const draft = Storage.get('meeting-draft');
if (draft) {
document.getElementById('title').value = draft.title || '';
document.getElementById('date').value = draft.date || today;
document.getElementById('startTime').value = draft.startTime || '';
document.getElementById('location').value = draft.location || '';
selectedAttendees = draft.attendees || [];
renderAttendees();
}
setTimeout(() => {
StorageManager.addMeeting(formData);
UIComponents.hideLoading();
// Initialize
renderAttendees();
UIComponents.confirm(
'회의가 예약되었습니다. 참석자에게 초대 이메일을 발송하시겠습니까?',
() => {
UIComponents.showToast('초대 이메일이 발송되었습니다', 'success');
setTimeout(() => {
NavigationHelper.navigate('DASHBOARD');
}, 1000);
},
() => {
NavigationHelper.navigate('DASHBOARD');
}
);
}, 1000);
});
</script>
</body>
</html>

View File

@ -3,157 +3,232 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>템플릿 선택 - 회의록 작성 서비스</title>
<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">
</head>
<body>
<!-- Header -->
<header class="header">
<button class="header-back" aria-label="뒤로가기"></button>
<h1 class="header-title">템플릿 선택</h1>
</header>
<!-- Main Content -->
<main class="main-content">
<div class="container">
<h2 class="mb-6">회의 유형을 선택하세요</h2>
<div id="templateList" class="flex flex-col gap-4">
<!-- Template cards will be inserted here -->
</div>
<button class="btn btn-secondary btn-full mt-6" onclick="startWithoutTemplate()">
템플릿 없이 시작
<div class="page">
<!-- 헤더 -->
<div class="header">
<button class="btn-icon" onclick="NavigationHelper.goBack()" aria-label="뒤로가기">
<span class="material-symbols-outlined">arrow_back</span>
</button>
<h1 class="header-title">템플릿 선택</h1>
<button class="btn btn-text" onclick="skipTemplate()">건너뛰기</button>
</div>
</main>
<!-- 메인 컨텐츠 -->
<div class="content">
<p class="text-body mb-6">회의 유형에 맞는 템플릿을 선택하세요. 건너뛰면 일반 템플릿이 사용됩니다.</p>
<!-- 템플릿 카드 리스트 -->
<div id="templateList">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
</div>
<script src="common.js"></script>
<script>
const { Auth, API, UI, Navigation, Storage } = window.App;
if (!NavigationHelper.requireAuth()) {}
// Check authentication
if (!Auth.requireAuth()) return;
const currentUser = StorageManager.getCurrentUser();
const meetingId = NavigationHelper.getQueryParam('meetingId');
let selectedTemplate = null;
// Set page title
UI.setTitle('템플릿 선택');
// Load templates
async function loadTemplates() {
UI.showLoading();
try {
const response = await API.getTemplates();
if (response.success) {
renderTemplates(response.data);
}
} catch (error) {
UI.showToast('템플릿을 불러올 수 없습니다', 'error');
} finally {
UI.hideLoading();
}
}
function renderTemplates(templates) {
// 템플릿 렌더링
function renderTemplates() {
const templates = Object.values(TEMPLATES);
const container = document.getElementById('templateList');
container.innerHTML = templates.map(template => `
<div class="card card-hover" onclick="selectTemplate('${template.id}')">
<h3 class="card-title">${template.name}</h3>
<p class="card-subtitle">${template.description}</p>
<div class="text-secondary" style="font-size: var(--font-sm);">
${template.sections.map(s => s.name).join(', ')}
<div class="card mb-4 clickable" onclick="selectTemplate('${template.type}')">
<div class="d-flex align-center gap-4">
<div style="font-size: 48px;">${template.icon}</div>
<div style="flex: 1;">
<h3 class="text-h4">${template.name}</h3>
<p class="text-body-sm text-gray">${template.description}</p>
<p class="text-caption mt-2">섹션 ${template.sections.length}개</p>
</div>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); previewTemplate('${template.type}')">미리보기</button>
</div>
</div>
`).join('');
}
async function selectTemplate(templateId) {
// Load template details
const response = await API.getTemplates();
const template = response.data.find(t => t.id === templateId);
if (!template) {
UI.showToast('템플릿을 찾을 수 없습니다', 'error');
return;
}
// Show customization modal
showCustomizationModal(template);
// 템플릿 선택
function selectTemplate(type) {
selectedTemplate = type;
showCustomizeModal(type);
}
function showCustomizationModal(template) {
const sectionsHtml = template.sections.map((section, index) => `
<div class="flex items-center justify-between" style="padding: var(--space-3); border-bottom: 1px solid var(--border-light);" data-section-id="${section.id}">
<div class="flex items-center gap-2">
<span style="font-weight: var(--font-medium);">${index + 1}. ${section.name}</span>
${section.required ? '<span class="badge badge-info">필수</span>' : ''}
// 템플릿 미리보기
function previewTemplate(type) {
const template = TEMPLATES[type];
UIComponents.showModal({
title: template.name + ' 미리보기',
content: `
<div class="d-flex align-center gap-3 mb-4">
<div style="font-size: 40px;">${template.icon}</div>
<div>
<h3 class="text-h4">${template.name}</h3>
<p class="text-body-sm text-gray">${template.description}</p>
</div>
</div>
<button class="btn-icon" aria-label="순서 변경">
<span style="font-size: 20px;"></span>
</button>
</div>
`).join('');
const modalContent = `
<div class="mb-4">
<h3 class="mb-2">${template.name}</h3>
<p class="text-secondary" style="font-size: var(--font-sm);">섹션 순서를 변경하거나 추가할 수 있습니다</p>
</div>
<div style="border: 1px solid var(--border-light); border-radius: 8px; margin-bottom: var(--space-4);">
${sectionsHtml}
</div>
<button class="btn btn-secondary btn-full" onclick="addCustomSection()">
+ 섹션 추가
</button>
`;
UI.showModal({
title: '템플릿 커스터마이징',
content: modalContent,
buttons: [
{
text: '취소',
className: 'btn-secondary'
},
{
text: '이 템플릿 사용',
className: 'btn-primary',
onClick: () => useTemplate(template)
}
]
<div>
<h4 class="text-h5 mb-3">포함된 섹션</h4>
${template.sections.map((section, index) => `
<div class="d-flex align-center gap-2 mb-2">
<span class="badge badge-status" style="min-width: 24px; background: var(--gray-200); color: var(--gray-700);">${index + 1}</span>
<span class="text-body">${section.name}</span>
</div>
`).join('')}
</div>
`,
footer: `
<button class="btn btn-secondary" onclick="closeModal()">닫기</button>
<button class="btn btn-primary" onclick="closeModal(); selectTemplate('${type}')">이 템플릿 선택</button>
`,
onClose: () => {}
});
}
function addCustomSection() {
UI.showToast('섹션 추가 기능은 개발 예정입니다', 'info');
// 커스터마이징 모달
function showCustomizeModal(type) {
const template = TEMPLATES[type];
let customSections = [...template.sections];
const modal = UIComponents.showModal({
title: '템플릿 커스터마이징',
content: `
<p class="text-body mb-4">섹션 순서를 변경하거나 추가/삭제할 수 있습니다.</p>
<div id="sectionList">
<!-- JavaScript로 동적 생성 -->
</div>
<button type="button" class="btn btn-secondary btn-sm w-full mt-3" onclick="addCustomSection()">
<span class="material-symbols-outlined">add</span>
섹션 추가
</button>
`,
footer: `
<button class="btn btn-secondary" onclick="closeModal()">취소</button>
<button class="btn btn-primary" onclick="startMeetingWithTemplate()">이 템플릿으로 시작</button>
`,
onClose: () => {}
});
renderSections();
function renderSections() {
const container = document.getElementById('sectionList');
container.innerHTML = customSections.map((section, index) => `
<div class="d-flex align-center gap-2 mb-2 p-2" style="background: var(--gray-50); border-radius: 8px;">
<span class="material-symbols-outlined" style="cursor: move; color: var(--gray-600);">drag_indicator</span>
<span class="text-body" style="flex: 1;">${section.name}</span>
<button type="button" class="btn-icon" onclick="moveSectionUp(${index})" ${index === 0 ? 'disabled' : ''}>
<span class="material-symbols-outlined">arrow_upward</span>
</button>
<button type="button" class="btn-icon" onclick="moveSectionDown(${index})" ${index === customSections.length - 1 ? 'disabled' : ''}>
<span class="material-symbols-outlined">arrow_downward</span>
</button>
<button type="button" class="btn-icon" onclick="removeSection(${index})" ${customSections.length <= 1 ? 'disabled' : ''}>
<span class="material-symbols-outlined" style="color: var(--error);">delete</span>
</button>
</div>
`).join('');
}
window.moveSectionUp = (index) => {
if (index > 0) {
[customSections[index], customSections[index - 1]] = [customSections[index - 1], customSections[index]];
renderSections();
}
};
window.moveSectionDown = (index) => {
if (index < customSections.length - 1) {
[customSections[index], customSections[index + 1]] = [customSections[index + 1], customSections[index]];
renderSections();
}
};
window.removeSection = (index) => {
if (customSections.length > 1) {
customSections.splice(index, 1);
renderSections();
} else {
UIComponents.showToast('최소 1개의 섹션이 필요합니다', 'warning');
}
};
window.addCustomSection = () => {
const sectionName = prompt('섹션 이름을 입력하세요:');
if (sectionName && sectionName.trim()) {
customSections.push({
id: Utils.generateId('SEC'),
name: sectionName.trim(),
order: customSections.length + 1,
content: '',
custom: true
});
renderSections();
}
};
window.startMeetingWithTemplate = () => {
if (customSections.length === 0) {
UIComponents.showToast('최소 1개의 섹션이 필요합니다', 'error');
return;
}
// 템플릿 데이터 저장
const templateData = {
type: type,
name: template.name,
sections: customSections.map((section, index) => ({
...section,
order: index + 1
}))
};
localStorage.setItem('selected_template', JSON.stringify(templateData));
closeModal();
// 회의 진행 화면으로 이동
const params = meetingId ? { meetingId } : {};
NavigationHelper.navigate('MEETING_IN_PROGRESS', params);
};
}
function useTemplate(template) {
// Save template to storage
Storage.set('selected-template', template);
UI.showToast('템플릿이 선택되었습니다', 'success');
// Navigate to meeting progress
setTimeout(() => {
Navigation.goTo('05-회의진행.html');
}, 500);
// 모달 닫기
function closeModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) modal.remove();
}
function startWithoutTemplate() {
Storage.remove('selected-template');
// 건너뛰기 (기본 템플릿 사용)
function skipTemplate() {
UIComponents.confirm(
'기본 템플릿으로 회의를 시작하시겠습니까?',
() => {
const templateData = {
type: 'general',
name: TEMPLATES.general.name,
sections: [...TEMPLATES.general.sections]
};
UI.showToast('빈 회의록으로 시작합니다', 'info');
setTimeout(() => {
Navigation.goTo('05-회의진행.html');
}, 500);
localStorage.setItem('selected_template', JSON.stringify(templateData));
const params = meetingId ? { meetingId } : {};
NavigationHelper.navigate('MEETING_IN_PROGRESS', params);
},
() => {}
);
}
// Initialize
loadTemplates();
// 초기 렌더링
renderTemplates();
</script>
</body>
</html>

View File

@ -3,236 +3,431 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의 진행 - 회의록 작성 서비스</title>
<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>
.recording-status {
.live-speech {
background: var(--accent-50);
border-left: 4px solid var(--accent-500);
padding: 16px;
border-radius: 8px;
position: sticky;
top: 60px;
z-index: 10;
}
.speaking-indicator {
width: 8px;
height: 8px;
background: var(--error);
color: var(--text-inverse);
padding: var(--space-3) var(--space-4);
text-align: center;
font-weight: var(--font-semibold);
border-radius: 50%;
animation: pulse 1.5s infinite;
}
.recording-status.paused {
background: var(--warning);
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.attendees-panel {
background: var(--bg-white);
padding: var(--space-4);
border-radius: 12px;
margin-bottom: var(--space-4);
.section-content {
min-height: 100px;
padding: 12px;
background: var(--white);
border: 1px solid var(--gray-300);
border-radius: 8px;
white-space: pre-wrap;
word-wrap: break-word;
}
.editor {
background: var(--bg-white);
border: 1px solid var(--border-light);
border-radius: 12px;
padding: var(--space-4);
min-height: 400px;
font-family: var(--font-primary);
line-height: var(--leading-relaxed);
}
.editor:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
.section-content[contenteditable="true"] {
outline: 2px solid var(--primary-500);
}
.term-highlight {
border-bottom: 2px dotted var(--primary);
cursor: help;
background: linear-gradient(180deg, transparent 60%, var(--accent-200) 60%);
cursor: pointer;
border-bottom: 1px dotted var(--accent-500);
}
.typing-indicator {
color: var(--text-secondary);
font-size: var(--font-sm);
font-style: italic;
padding: var(--space-2);
.recording-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--error-bg);
border-radius: 20px;
font-size: 13px;
color: var(--error);
}
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--white);
border-top: 1px solid var(--gray-200);
padding: 12px 16px;
display: flex;
gap: 8px;
z-index: var(--z-fixed);
}
</style>
</head>
<body>
<!-- Header -->
<header class="header">
<button class="header-back" aria-label="뒤로가기" onclick="confirmExit()"></button>
<h1 class="header-title">주간 회의</h1>
<div class="header-actions">
<button class="btn-icon" aria-label="메뉴">
<span style="font-size: 24px;"></span>
<div class="page">
<!-- 헤더 -->
<div class="header">
<div style="flex: 1;">
<h1 class="header-title" id="meetingTitle">회의 진행</h1>
<div class="d-flex align-center gap-3 mt-1">
<span class="text-caption" id="elapsedTime">00:00:00</span>
<div class="recording-status">
<div class="speaking-indicator"></div>
<span>녹음 중</span>
</div>
</div>
</div>
<button class="btn-icon" onclick="showMenu()" aria-label="메뉴">
<span class="material-symbols-outlined">more_vert</span>
</button>
</div>
</header>
<!-- Recording Status -->
<div id="recordingStatus" class="recording-status">
🔴 녹음 중 <span id="recordingTime">00:00:00</span>
</div>
<!-- Main Content -->
<main class="main-content">
<div class="container">
<!-- Attendees Panel -->
<div class="attendees-panel">
<h3 class="mb-3">참석자 (<span id="attendeeCount">5</span>명)</h3>
<div class="avatar-group" id="attendeeAvatars">
<div class="avatar"></div>
<div class="avatar"></div>
<div class="avatar"></div>
<div class="avatar"></div>
<div class="avatar"></div>
<!-- 메인 컨텐츠 -->
<div class="content" style="padding-bottom: 80px;">
<!-- 실시간 발언 영역 -->
<div class="live-speech mb-4">
<div class="d-flex align-center gap-2 mb-2">
<span class="material-symbols-outlined" style="color: var(--accent-700);">mic</span>
<span class="text-h6" style="color: var(--accent-700);" id="currentSpeaker">김철수</span>
</div>
<p class="text-body" id="liveText">회의를 시작하겠습니다. 오늘은 프로젝트 킥오프 회의로...</p>
</div>
<!-- Minutes Editor -->
<div class="mb-4">
<h3 class="mb-3">📝 회의록</h3>
<div
id="editor"
class="editor"
contenteditable="true"
role="textbox"
aria-label="회의록 편집기"
aria-multiline="true"
>
<h2>## 참석자</h2>
<p>- 김민준<br>- 박서연<br>- 이준호<br>- 최유진<br>- 정도현</p>
<h2>## 논의 내용</h2>
<p>[김민준] 이번 분기 <span class="term-highlight" title="핵심성과지표(Key Performance Indicator)">KPI</span> 목표는 매출 20% 증가입니다.</p>
<p class="typing-indicator">[박서연 typing...]</p>
</div>
<!-- AI 처리 인디케이터 -->
<div class="ai-processing mb-4">
<span class="material-symbols-outlined ai-icon">auto_awesome</span>
<span>AI가 발언 내용을 분석하여 회의록을 작성하고 있습니다</span>
</div>
<!-- Control Buttons -->
<div class="flex gap-4">
<button id="pauseBtn" class="btn btn-secondary flex-1" onclick="togglePause()">
<span>⏸️ 일시정지</span>
</button>
<button class="btn btn-error flex-1" onclick="endMeeting()">
<span>⏹️ 종료</span>
</button>
<!-- 회의록 섹션들 -->
<div id="sectionList">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
</main>
<!-- 하단 액션 바 -->
<div class="action-bar">
<button class="btn btn-secondary" onclick="pauseRecording()" id="pauseBtn">
<span class="material-symbols-outlined">pause</span>
일시정지
</button>
<button class="btn btn-text" onclick="addManualNote()">
<span class="material-symbols-outlined">edit_note</span>
메모 추가
</button>
<button class="btn btn-primary" onclick="endMeeting()" style="flex: 1;">
<span class="material-symbols-outlined">stop_circle</span>
회의 종료
</button>
</div>
</div>
<script src="common.js"></script>
<script>
const { Auth, UI, Navigation, DateTime, Modal } = window.App;
if (!NavigationHelper.requireAuth()) {}
// Check authentication
if (!Auth.requireAuth()) return;
const currentUser = StorageManager.getCurrentUser();
const meetingId = NavigationHelper.getQueryParam('meetingId') || Utils.generateId('MTG');
let templateData = JSON.parse(localStorage.getItem('selected_template') || 'null') || {
type: 'general',
name: '일반 회의',
sections: TEMPLATES.general.sections
};
// Set page title
UI.setTitle('회의 진행');
// Recording state
let isRecording = true;
let isPaused = false;
let startTime = Date.now();
let elapsedSeconds = 0;
let elapsedInterval;
// Update recording time
const timerInterval = setInterval(() => {
if (!isPaused && isRecording) {
elapsedSeconds++;
updateRecordingTime();
}
}, 1000);
function updateRecordingTime() {
const hours = Math.floor(elapsedSeconds / 3600);
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
const seconds = elapsedSeconds % 60;
document.getElementById('recordingTime').textContent =
`${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
// 경과 시간 표시
function updateElapsedTime() {
const elapsed = Date.now() - startTime;
document.getElementById('elapsedTime').textContent = Utils.formatDuration(elapsed);
}
function togglePause() {
elapsedInterval = setInterval(updateElapsedTime, 1000);
// 섹션 렌더링
function renderSections() {
const container = document.getElementById('sectionList');
container.innerHTML = templateData.sections.map((section, index) => `
<div class="card mb-4" id="section-${section.id}">
<div class="d-flex justify-between align-center mb-3">
<h3 class="text-h4">${section.name}</h3>
<div class="d-flex align-center gap-2">
${section.verified ? '<span class="verified-badge"><span class="material-symbols-outlined" style="font-size: 14px;">check_circle</span> 검증완료</span>' : ''}
<button class="btn-icon" onclick="toggleEdit('${section.id}')">
<span class="material-symbols-outlined">edit</span>
</button>
</div>
</div>
<div
class="section-content"
id="content-${section.id}"
contenteditable="false"
>${section.content || '(AI가 발언 내용을 분석하여 자동으로 작성합니다)'}</div>
<div class="d-flex justify-between align-center mt-3">
<button class="btn btn-text btn-sm" onclick="improveSection('${section.id}')">
<span class="material-symbols-outlined">auto_awesome</span>
AI 개선
</button>
<label class="form-checkbox">
<input type="checkbox" ${section.verified ? 'checked' : ''} onchange="toggleVerify('${section.id}', this.checked)">
<span class="text-body-sm">검증 완료</span>
</label>
</div>
</div>
`).join('');
// 실시간 AI 작성 시뮬레이션
simulateAIWriting();
}
// AI 자동 작성 시뮬레이션
function simulateAIWriting() {
const sampleContent = {
'참석자': '김철수 (기획팀 팀장), 이영희 (개발팀 선임), 박민수 (디자인팀 사원)',
'안건': '신규 회의록 서비스 프로젝트 킥오프\n- 프로젝트 목표 및 범위 확정\n- 역할 분담 및 일정 계획',
'논의 내용': 'Mobile First 설계 방침으로 진행하기로 결정\nAI 기반 회의록 자동 작성 기능을 핵심으로 개발\n템플릿 시스템 및 실시간 협업 기능 포함',
'결정 사항': '개발 기간: 2025년 Q4까지\n기술 스택: React, Node.js, PostgreSQL\n주간 스크럼 회의 매주 월요일 09:00',
'Todo': '김철수: 프로젝트 계획서 작성 (10/25까지)\n이영희: API 문서 작성 (10/24까지)\n박민수: 디자인 시안 1차 검토 (10/23까지)'
};
templateData.sections.forEach((section, index) => {
setTimeout(() => {
const content = sampleContent[section.name] || `${section.name}에 대한 내용이 자동으로 작성됩니다...`;
const contentEl = document.getElementById(`content-${section.id}`);
if (contentEl) {
contentEl.textContent = content;
section.content = content;
// 전문용어 하이라이트 추가
highlightTerms(section.id);
}
}, (index + 1) * 2000);
});
}
// 전문용어 하이라이트
function highlightTerms(sectionId) {
const contentEl = document.getElementById(`content-${sectionId}`);
if (!contentEl) return;
const terms = ['Mobile First', 'AI', 'API', 'PostgreSQL', 'React'];
let html = contentEl.textContent;
terms.forEach(term => {
const regex = new RegExp(term, 'g');
html = html.replace(regex, `<span class="term-highlight" onclick="showTermExplanation('${term}')">${term}</span>`);
});
contentEl.innerHTML = html;
}
// 전문용어 설명 표시
function showTermExplanation(term) {
const explanations = {
'Mobile First': 'Mobile First는 모바일 환경을 우선적으로 고려하여 디자인하고, 이후 더 큰 화면으로 확장하는 설계 방법론입니다.',
'AI': 'Artificial Intelligence의 약자로, 인공지능을 의미합니다. 이 프로젝트에서는 회의록 자동 작성에 활용됩니다.',
'API': 'Application Programming Interface의 약자로, 소프트웨어 간 상호작용을 위한 인터페이스입니다.',
'PostgreSQL': '오픈소스 관계형 데이터베이스 관리 시스템(RDBMS)입니다.',
'React': 'Facebook에서 개발한 사용자 인터페이스 구축을 위한 JavaScript 라이브러리입니다.'
};
UIComponents.showToast(explanations[term] || '설명을 불러오는 중...', 'info', 5000);
}
// 섹션 편집 토글
function toggleEdit(sectionId) {
const contentEl = document.getElementById(`content-${sectionId}`);
const isEditable = contentEl.getAttribute('contenteditable') === 'true';
contentEl.setAttribute('contenteditable', !isEditable);
if (!isEditable) {
contentEl.focus();
UIComponents.showToast('수정 모드 활성화', 'info');
} else {
// 저장
const section = templateData.sections.find(s => s.id === sectionId);
if (section) {
section.content = contentEl.textContent;
}
UIComponents.showToast('변경사항이 저장되었습니다', 'success');
}
}
// 섹션 검증 토글
function toggleVerify(sectionId, checked) {
const section = templateData.sections.find(s => s.id === sectionId);
if (section) {
section.verified = checked;
section.verifiedBy = checked ? [currentUser.name] : [];
}
renderSections();
UIComponents.showToast(checked ? '섹션이 검증되었습니다' : '검증이 취소되었습니다', checked ? 'success' : 'info');
}
// AI 개선
function improveSection(sectionId) {
UIComponents.showLoading('AI가 내용을 개선하고 있습니다...');
setTimeout(() => {
UIComponents.hideLoading();
UIComponents.showToast('AI 개선이 완료되었습니다', 'success');
}, 2000);
}
// 녹음 일시정지/재개
function pauseRecording() {
isPaused = !isPaused;
const statusDiv = document.getElementById('recordingStatus');
const pauseBtn = document.getElementById('pauseBtn');
const btn = document.getElementById('pauseBtn');
const indicator = document.querySelector('.recording-status');
if (isPaused) {
statusDiv.classList.add('paused');
statusDiv.innerHTML = '⏸️ 일시정지 <span id="recordingTime">' +
document.getElementById('recordingTime').textContent + '</span>';
pauseBtn.innerHTML = '<span>▶️ 재개</span>';
UI.showToast('녹음이 일시정지되었습니다', 'info');
btn.innerHTML = '<span class="material-symbols-outlined">play_arrow</span> 재개';
indicator.style.background = 'var(--gray-200)';
indicator.style.color = 'var(--gray-600)';
indicator.querySelector('span:last-child').textContent = '일시정지';
UIComponents.showToast('녹음이 일시정지되었습니다', 'info');
} else {
statusDiv.classList.remove('paused');
statusDiv.innerHTML = '🔴 녹음 중 <span id="recordingTime">' +
document.getElementById('recordingTime').textContent + '</span>';
pauseBtn.innerHTML = '<span>⏸️ 일시정지</span>';
UI.showToast('녹음이 재개되었습니다', 'success');
btn.innerHTML = '<span class="material-symbols-outlined">pause</span> 일시정지';
indicator.style.background = 'var(--error-bg)';
indicator.style.color = 'var(--error)';
indicator.querySelector('span:last-child').textContent = '녹음 중';
UIComponents.showToast('녹음이 재개되었습니다', 'success');
}
}
async function endMeeting() {
const confirmed = await Modal.confirm('회의를 종료하시겠습니까?');
if (confirmed) {
isRecording = false;
clearInterval(timerInterval);
UI.showToast('회의가 종료되었습니다', 'success');
setTimeout(() => {
Navigation.goTo('07-회의종료.html');
}, 500);
// 수동 메모 추가
function addManualNote() {
const note = prompt('추가할 메모를 입력하세요:');
if (note && note.trim()) {
UIComponents.showToast('메모가 추가되었습니다', 'success');
// 실제로는 해당 섹션에 추가
}
}
async function confirmExit() {
const confirmed = await Modal.confirm('회의를 종료하지 않고 나가시겠습니까? 작성 중인 내용이 저장되지 않을 수 있습니다.');
if (confirmed) {
clearInterval(timerInterval);
Navigation.goBack();
}
// 메뉴 표시
function showMenu() {
UIComponents.showModal({
title: '회의 설정',
content: `
<div class="d-flex flex-column gap-2">
<button class="btn btn-text" style="justify-content: flex-start;" onclick="closeModal(); viewParticipants()">
<span class="material-symbols-outlined">group</span>
참석자 목록
</button>
<button class="btn btn-text" style="justify-content: flex-start;" onclick="closeModal(); viewKeywords()">
<span class="material-symbols-outlined">sell</span>
주요 키워드
</button>
<button class="btn btn-text" style="justify-content: flex-start;" onclick="closeModal(); viewStatistics()">
<span class="material-symbols-outlined">bar_chart</span>
발언 통계
</button>
</div>
`,
footer: '<button class="btn btn-secondary" onclick="closeModal()">닫기</button>',
onClose: () => {}
});
}
// Simulate real-time collaboration
const editor = document.getElementById('editor');
// 참석자 목록 표시
function viewParticipants() {
UIComponents.showToast('참석자: ' + DUMMY_USERS.slice(0, 3).map(u => u.name).join(', '), 'info', 3000);
}
// Add new content periodically (simulating STT)
let addContentInterval = setInterval(() => {
if (!isPaused && isRecording) {
const typingIndicator = editor.querySelector('.typing-indicator');
if (typingIndicator && Math.random() > 0.5) {
typingIndicator.textContent = '[박서연] 네, 목표 달성을 위한 구체적인 실행 계획이 필요합니다.';
typingIndicator.classList.remove('typing-indicator');
// 주요 키워드 표시
function viewKeywords() {
UIComponents.showToast('주요 키워드: Mobile First, AI, 프로젝트, 개발', 'info', 3000);
}
// Add new typing indicator
const newIndicator = document.createElement('p');
newIndicator.className = 'typing-indicator';
newIndicator.textContent = '[이준호 typing...]';
editor.appendChild(newIndicator);
}
}
}, 8000);
// 발언 통계 표시
function viewStatistics() {
UIComponents.showToast('발언 통계: 김철수 40%, 이영희 35%, 박민수 25%', 'info', 3000);
}
// Highlight technical terms
editor.addEventListener('input', () => {
const text = editor.innerHTML;
// This is a simple example - in real app, use proper term detection
if (text.includes('KPI') && !text.includes('term-highlight')) {
editor.innerHTML = text.replace(
/KPI/g,
'<span class="term-highlight" title="핵심성과지표(Key Performance Indicator)">KPI</span>'
);
}
});
// 모달 닫기
function closeModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) modal.remove();
}
// Cleanup on page unload
// 회의 종료
function endMeeting() {
UIComponents.confirm(
'회의를 종료하시겠습니까? 회의록이 저장됩니다.',
() => {
clearInterval(elapsedInterval);
// 회의록 저장
const duration = Date.now() - startTime;
const meetingData = {
id: meetingId,
title: document.getElementById('meetingTitle').textContent || '제목 없는 회의',
date: new Date().toISOString().split('T')[0],
startTime: new Date(startTime).toTimeString().slice(0, 5),
endTime: new Date().toTimeString().slice(0, 5),
duration: duration,
location: '온라인',
attendees: DUMMY_USERS.slice(0, 3).map(u => u.name),
template: templateData.type,
status: 'draft',
sections: templateData.sections,
createdBy: currentUser.id,
createdAt: new Date(startTime).toISOString(),
updatedAt: new Date().toISOString()
};
StorageManager.addMeeting(meetingData);
localStorage.setItem('current_meeting', JSON.stringify(meetingData));
UIComponents.showToast('회의가 종료되었습니다', 'success');
setTimeout(() => {
NavigationHelper.navigate('MEETING_END', { meetingId });
}, 1000);
},
() => {}
);
}
// 초기 렌더링
renderSections();
// 실시간 발언 시뮬레이션
const speeches = [
{ speaker: '김철수', text: '프로젝트 킥오프 회의를 시작하겠습니다...' },
{ speaker: '이영희', text: '개발 일정에 대해 의견을 드리겠습니다...' },
{ speaker: '박민수', text: '디자인 시안은 다음 주까지 준비하겠습니다...' }
];
let speechIndex = 0;
setInterval(() => {
const speech = speeches[speechIndex % speeches.length];
document.getElementById('currentSpeaker').textContent = speech.speaker;
document.getElementById('liveText').textContent = speech.text;
speechIndex++;
}, 5000);
// 페이지 이탈 방지
window.addEventListener('beforeunload', (e) => {
if (isRecording) {
e.preventDefault();
e.returnValue = '회의가 진행 중입니다. 페이지를 나가시겠습니까?';
}
e.preventDefault();
e.returnValue = '';
});
</script>
</body>

View File

@ -3,266 +3,217 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의록 검증 - 회의록 작성 서비스</title>
<title>검증 완료 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
.section-card {
background: var(--bg-white);
border: 1px solid var(--border-light);
border-radius: 12px;
padding: var(--space-4);
margin-bottom: var(--space-4);
transition: all var(--duration-base);
}
.section-card.verified {
background: var(--primary-light);
border-color: var(--primary);
}
.section-card.locked {
background: var(--bg-gray);
opacity: 0.8;
}
.verification-progress {
background: var(--bg-gray);
height: 8px;
border-radius: 4px;
overflow: hidden;
margin-top: var(--space-2);
}
.verification-progress-bar {
background: var(--success);
height: 100%;
transition: width var(--duration-slow);
}
</style>
<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">
</head>
<body>
<!-- Header -->
<header class="header">
<button class="header-back" aria-label="뒤로가기"></button>
<h1 class="header-title">회의록 검증</h1>
</header>
<div class="page">
<!-- 헤더 -->
<div class="header">
<button class="btn-icon" onclick="NavigationHelper.goBack()" aria-label="뒤로가기">
<span class="material-symbols-outlined">arrow_back</span>
</button>
<h1 class="header-title">검증 완료</h1>
<div></div>
</div>
<!-- Main Content -->
<main class="main-content">
<div class="container">
<div class="mb-6">
<h2 class="mb-2">주간 회의</h2>
<p class="text-secondary">2025-01-15</p>
<!-- 메인 컨텐츠 -->
<div class="content">
<!-- 진행률 바 -->
<div class="card mb-4">
<h3 class="text-h5 mb-3">전체 검증 진행률</h3>
<div class="d-flex align-center gap-3 mb-2">
<div style="flex: 1;">
<div class="progress-bar" style="height: 8px;">
<div class="progress-fill" id="progressFill" style="width: 0%;"></div>
</div>
</div>
<span class="text-h5" id="progressPercent">0%</span>
</div>
<p class="text-body-sm text-gray" id="progressText">0 / 0 섹션 검증 완료</p>
</div>
<!-- Verification Progress -->
<div class="mb-6">
<div class="flex justify-between items-center mb-2">
<span class="text-secondary">검증 현황</span>
<span class="text-primary" style="font-weight: var(--font-semibold);">
<span id="verifiedCount">0</span>/<span id="totalCount">5</span>
</span>
</div>
<div class="verification-progress">
<div id="progressBar" class="verification-progress-bar" style="width: 0%"></div>
</div>
</div>
<!-- Section Cards -->
<!-- 섹션 리스트 -->
<h3 class="text-h4 mb-4">섹션별 검증 상태</h3>
<div id="sectionList">
<!-- Section cards will be inserted here -->
<!-- JavaScript로 동적 생성 -->
</div>
<!-- 하단 액션 -->
<div class="mt-6">
<button class="btn btn-primary w-full mb-2" id="completeBtn" onclick="completeVerification()" disabled>
모두 검증 완료
</button>
<button class="btn btn-secondary w-full" onclick="NavigationHelper.goBack()">
나중에 하기
</button>
</div>
</div>
</main>
</div>
<script src="common.js"></script>
<script>
const { Auth, UI, Modal, Navigation } = window.App;
if (!NavigationHelper.requireAuth()) {}
// Check authentication
if (!Auth.requireAuth()) return;
const currentUser = StorageManager.getCurrentUser();
const meetingId = NavigationHelper.getQueryParam('meetingId');
let meeting = meetingId ? StorageManager.getMeetingById(meetingId) : JSON.parse(localStorage.getItem('current_meeting') || 'null');
// Set page title
UI.setTitle('회의록 검증');
if (!meeting) {
UIComponents.showToast('회의 정보를 찾을 수 없습니다', 'error');
setTimeout(() => NavigationHelper.navigate('DASHBOARD'), 2000);
}
// Mock sections data
const sections = [
{ id: 'attendees', name: '참석자', verified: true, verifiedBy: '김민준', locked: true },
{ id: 'agenda', name: '안건', verified: false, verifiedBy: null, locked: false },
{ id: 'discussion', name: '논의 내용', verified: false, verifiedBy: null, locked: false },
{ id: 'decisions', name: '결정 사항', verified: true, verifiedBy: '박서연', locked: false },
{ id: 'todos', name: 'Todo', verified: false, verifiedBy: null, locked: false }
];
let sections = meeting ? [...meeting.sections] : [];
// 섹션 렌더링
function renderSections() {
const container = document.getElementById('sectionList');
container.innerHTML = sections.map(section => `
<div class="section-card ${section.verified ? 'verified' : ''} ${section.locked ? 'locked' : ''}" id="section-${section.id}">
<div class="flex justify-between items-center mb-3">
<div class="flex items-center gap-2">
<input
type="checkbox"
id="check-${section.id}"
${section.verified ? 'checked' : ''}
container.innerHTML = sections.map(section => {
const isVerified = section.verified || false;
const verifiers = section.verifiedBy || [];
const isCreator = meeting.createdBy === currentUser.id;
return `
<div class="card mb-3" style="border-left: 4px solid ${isVerified ? 'var(--success)' : 'var(--gray-300)'};">
<div class="d-flex justify-between align-center mb-3">
<div class="d-flex align-center gap-2">
<span class="material-symbols-outlined" style="color: ${isVerified ? 'var(--success)' : 'var(--gray-400)'}; font-size: 24px;">
${isVerified ? 'check_circle' : 'radio_button_unchecked'}
</span>
<h4 class="text-h5">${section.name}</h4>
</div>
${section.locked && isCreator ? '<span class="material-symbols-outlined" style="color: var(--gray-600);">lock</span>' : ''}
</div>
<div class="d-flex align-center gap-2 mb-3">
${verifiers.length > 0 ? verifiers.map(name => UIComponents.createAvatar(name, 28)).join('') : '<p class="text-caption text-gray">아직 검증되지 않았습니다</p>'}
</div>
<div class="d-flex gap-2">
<button
class="btn ${isVerified ? 'btn-secondary' : 'btn-primary'} btn-sm"
onclick="toggleSectionVerify('${section.id}')"
${section.locked ? 'disabled' : ''}
onchange="toggleVerification('${section.id}')"
style="width: 20px; height: 20px; cursor: pointer;"
/>
<label for="check-${section.id}" style="font-weight: var(--font-semibold); cursor: pointer;">
${section.name}
</label>
</div>
${section.locked ? '<span aria-label="잠김">🔒</span>' : ''}
</div>
${section.verified ? `
<div class="text-success mb-3" style="font-size: var(--font-sm);">
✓ ${section.verifiedBy} 검증완료
</div>
` : ''}
<div class="flex gap-2">
<button
class="btn ${section.verified ? 'btn-secondary' : 'btn-primary'}"
style="flex: 1;"
onclick="${section.verified ? '' : `verifySection('${section.id}')`}"
${section.locked ? 'disabled' : ''}
>
${section.verified ? '검증 완료됨' : '검증 완료'}
</button>
${section.verified && !section.locked ? `
<button class="btn btn-secondary" onclick="toggleLock('${section.id}', true)">
잠금
>
${isVerified ? '검증 취소' : '검증 완료'}
</button>
` : ''}
${section.locked ? `
<button class="btn btn-secondary" onclick="toggleLock('${section.id}', false)">
잠금 해제
</button>
` : ''}
${isCreator && isVerified ? `
<button class="btn btn-text btn-sm" onclick="toggleSectionLock('${section.id}')">
<span class="material-symbols-outlined">${section.locked ? 'lock_open' : 'lock'}</span>
${section.locked ? '잠금 해제' : '잠금'}
</button>
` : ''}
</div>
</div>
${!section.verified ? `
<button
class="btn btn-ghost"
style="width: 100%; margin-top: var(--space-2);"
onclick="viewSectionContent('${section.id}')"
>
내용 보기
</button>
` : ''}
</div>
`).join('');
`;
}).join('');
updateProgress();
}
function updateProgress() {
const verifiedCount = sections.filter(s => s.verified).length;
const totalCount = sections.length;
const percentage = (verifiedCount / totalCount) * 100;
document.getElementById('verifiedCount').textContent = verifiedCount;
document.getElementById('totalCount').textContent = totalCount;
document.getElementById('progressBar').style.width = percentage + '%';
}
function toggleVerification(sectionId) {
// 섹션 검증 토글
function toggleSectionVerify(sectionId) {
const section = sections.find(s => s.id === sectionId);
if (!section || section.locked) return;
section.verified = !section.verified;
if (!section) return;
if (section.verified) {
section.verifiedBy = '김민준'; // Current user
UI.showToast(`"${section.name}" 섹션이 검증되었습니다`, 'success');
// 검증 취소
section.verified = false;
section.verifiedBy = (section.verifiedBy || []).filter(name => name !== currentUser.name);
UIComponents.showToast('검증이 취소되었습니다', 'info');
} else {
section.verifiedBy = null;
UI.showToast(`"${section.name}" 섹션 검증이 취소되었습니다`, 'info');
// 검증 완료
UIComponents.confirm(
`"${section.name}" 섹션을 검증 완료 처리하시겠습니까?`,
() => {
section.verified = true;
section.verifiedBy = [...(section.verifiedBy || []), currentUser.name];
UIComponents.showToast('검증이 완료되었습니다', 'success');
renderSections();
// 회의록 업데이트
if (meeting) {
meeting.sections = sections;
StorageManager.updateMeeting(meeting.id, meeting);
}
},
() => {}
);
return;
}
renderSections();
}
async function verifySection(sectionId) {
const section = sections.find(s => s.id === sectionId);
if (!section) return;
// Show content first
const proceed = await Modal.confirm(`"${section.name}" 섹션을 검증하시겠습니까?`);
if (proceed) {
section.verified = true;
section.verifiedBy = '김민준';
renderSections();
UI.showToast('검증이 완료되었습니다', 'success');
// 회의록 업데이트
if (meeting) {
meeting.sections = sections;
StorageManager.updateMeeting(meeting.id, meeting);
}
}
async function toggleLock(sectionId, lock) {
// 섹션 잠금 토글 (회의 생성자만)
function toggleSectionLock(sectionId) {
const section = sections.find(s => s.id === sectionId);
if (!section) return;
if (!section || !section.verified) return;
const message = lock
? '이 섹션을 잠그시겠습니까? 잠긴 섹션은 수정할 수 없습니다.'
: '잠금을 해제하시겠습니까?';
section.locked = !section.locked;
UIComponents.showToast(
section.locked ? '섹션이 잠겼습니다. 더 이상 수정할 수 없습니다.' : '섹션 잠금이 해제되었습니다.',
section.locked ? 'warning' : 'info'
);
const confirmed = await Modal.confirm(message);
renderSections();
if (confirmed) {
section.locked = lock;
renderSections();
UI.showToast(
lock ? '섹션이 잠겼습니다' : '잠금이 해제되었습니다',
'success'
);
// 회의록 업데이트
if (meeting) {
meeting.sections = sections;
StorageManager.updateMeeting(meeting.id, meeting);
}
}
function viewSectionContent(sectionId) {
const section = sections.find(s => s.id === sectionId);
if (!section) return;
// 진행률 업데이트
function updateProgress() {
const total = sections.length;
const verified = sections.filter(s => s.verified).length;
const percent = total > 0 ? Math.round((verified / total) * 100) : 0;
Modal.show({
title: section.name,
content: `
<div class="mb-4">
<p>섹션 내용이 여기에 표시됩니다.</p>
<p class="text-secondary mt-2" style="font-size: var(--font-sm);">
실제 구현에서는 해당 섹션의 회의록 내용이 표시됩니다.
</p>
</div>
`,
buttons: [
{
text: '닫기',
className: 'btn-secondary'
},
{
text: '검증 완료',
className: 'btn-primary',
onClick: () => verifySection(sectionId)
}
]
});
document.getElementById('progressFill').style.width = `${percent}%`;
document.getElementById('progressPercent').textContent = `${percent}%`;
document.getElementById('progressText').textContent = `${verified} / ${total} 섹션 검증 완료`;
// 모두 검증 완료 버튼 활성화
const completeBtn = document.getElementById('completeBtn');
if (percent === 100) {
completeBtn.disabled = false;
completeBtn.classList.remove('btn-secondary');
completeBtn.classList.add('btn-primary');
} else {
completeBtn.disabled = true;
completeBtn.classList.add('btn-secondary');
completeBtn.classList.remove('btn-primary');
}
}
// Initialize
// 검증 완료
function completeVerification() {
UIComponents.confirm(
'모든 섹션이 검증되었습니다. 계속 진행하시겠습니까?',
() => {
UIComponents.showToast('검증이 완료되었습니다', 'success');
setTimeout(() => {
NavigationHelper.goBack();
}, 1000);
},
() => {}
);
}
// 초기 렌더링
renderSections();
// Simulate real-time sync (another user verifies)
setTimeout(() => {
const agenda = sections.find(s => s.id === 'agenda');
if (agenda && !agenda.verified) {
agenda.verified = true;
agenda.verifiedBy = '박서연';
renderSections();
UI.showToast('박서연 님이 "안건"을 검증했습니다', 'info');
}
}, 5000);
</script>
</body>
</html>

View File

@ -3,241 +3,209 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의 종료 - 회의록 작성 서비스</title>
<title>회의 종료 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
.stat-card {
background: var(--bg-white);
border: 1px solid var(--border-light);
border-radius: 12px;
padding: var(--space-6);
margin-bottom: var(--space-4);
text-align: center;
}
.stat-icon {
font-size: 48px;
margin-bottom: var(--space-3);
}
.stat-value {
font-size: var(--font-3xl);
font-weight: var(--font-bold);
color: var(--primary);
margin-bottom: var(--space-2);
}
.stat-label {
color: var(--text-secondary);
font-size: var(--font-sm);
}
.speaker-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3);
border-bottom: 1px solid var(--border-light);
}
.speaker-item:last-child {
border-bottom: none;
}
.speaker-bar {
height: 8px;
background: var(--primary-light);
border-radius: 4px;
margin-top: var(--space-2);
overflow: hidden;
}
.speaker-bar-fill {
height: 100%;
background: var(--primary);
transition: width var(--duration-slow);
}
</style>
<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">
</head>
<body>
<!-- Header -->
<header class="header">
<button class="header-back" aria-label="뒤로가기"></button>
<h1 class="header-title">회의 종료</h1>
</header>
<div class="page">
<!-- 헤더 -->
<div class="header">
<h1 class="header-title">회의가 종료되었습니다</h1>
<div></div>
</div>
<!-- Main Content -->
<main class="main-content">
<div class="container">
<div class="mb-6">
<h2 class="mb-2">주간 회의</h2>
<p class="text-secondary">2025-01-15 14:00 - 14:45</p>
<!-- 메인 컨텐츠 -->
<div class="content">
<!-- 회의 정보 -->
<div class="card mb-4 text-center">
<div style="font-size: 48px; margin-bottom: 16px;"></div>
<h2 class="text-h3 mb-2" id="meetingTitle">회의 제목</h2>
<p class="text-body text-gray" id="meetingInfo">2025-10-21 10:00 ~ 11:30</p>
</div>
<h3 class="mb-4">📊 회의 통계</h3>
<!-- Duration Stat -->
<div class="stat-card">
<div class="stat-icon" aria-hidden="true">⏱️</div>
<div class="stat-value" id="duration">45분 30초</div>
<div class="stat-label">총 시간</div>
</div>
<!-- Attendees Stat -->
<div class="stat-card">
<div class="stat-icon" aria-hidden="true">👥</div>
<div class="stat-value" id="attendees">5명</div>
<div class="stat-label">참석자</div>
</div>
<!-- Speaking Stats -->
<!-- 회의 통계 -->
<div class="card mb-4">
<h4 class="mb-3">💬 발언 횟수</h4>
<div id="speakerStats">
<!-- Speaker stats will be inserted here -->
<h3 class="text-h4 mb-4">회의 통계</h3>
<div class="d-flex justify-between mb-3">
<span class="text-body">회의 총 시간</span>
<span class="text-h5" id="totalTime">01:30:00</span>
</div>
<div class="d-flex justify-between mb-3">
<span class="text-body">참석자 수</span>
<span class="text-h5" id="attendeeCount">3명</span>
</div>
<div class="d-flex justify-between">
<span class="text-body">주요 키워드</span>
<div class="d-flex gap-1" style="flex-wrap: wrap;">
<span class="badge badge-status">Mobile First</span>
<span class="badge badge-status">AI</span>
<span class="badge badge-status">프로젝트</span>
</div>
</div>
</div>
<!-- Keywords -->
<div class="card mb-8">
<h4 class="mb-3">🔑 주요 키워드</h4>
<div class="flex" style="gap: var(--space-2); flex-wrap: wrap;" id="keywords">
<!-- Keywords will be inserted here -->
<!-- AI Todo 추출 결과 -->
<div class="card mb-4">
<div class="d-flex justify-between align-center mb-3">
<h3 class="text-h4">AI가 추출한 Todo</h3>
<button class="btn btn-text btn-sm" onclick="editTodos()">
<span class="material-symbols-outlined">edit</span>
수정
</button>
</div>
<div id="todoList">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col gap-4">
<button class="btn btn-primary btn-full" onclick="confirmMinutes()">
회의록 확정하기
<!-- 최종 확정 체크리스트 -->
<div class="card mb-4">
<h3 class="text-h4 mb-3">최종 확정 체크리스트</h3>
<label class="form-checkbox mb-2">
<input type="checkbox" id="check1" checked disabled>
<span>회의 제목 작성</span>
</label>
<label class="form-checkbox mb-2">
<input type="checkbox" id="check2" checked disabled>
<span>참석자 목록 작성</span>
</label>
<label class="form-checkbox mb-2">
<input type="checkbox" id="check3" checked disabled>
<span>주요 논의 내용 작성</span>
</label>
<label class="form-checkbox mb-2">
<input type="checkbox" id="check4" checked disabled>
<span>결정 사항 작성</span>
</label>
</div>
<!-- 액션 버튼 -->
<div class="d-flex flex-column gap-2">
<button class="btn btn-primary w-full" onclick="confirmMeeting()">
<span class="material-symbols-outlined">check_circle</span>
최종 회의록 확정
</button>
<button class="btn btn-secondary btn-full" onclick="saveLater()">
나중에 하기
<button class="btn btn-secondary w-full" onclick="NavigationHelper.navigate('MEETING_SHARE', { meetingId: meeting.id })">
<span class="material-symbols-outlined">share</span>
회의록 공유하기
</button>
<button class="btn btn-text w-full" onclick="NavigationHelper.navigate('MEETING_EDIT', { id: meeting.id })">
회의록 수정하기
</button>
<button class="btn btn-text w-full" onclick="NavigationHelper.navigate('DASHBOARD')">
대시보드로 돌아가기
</button>
</div>
</div>
</main>
</div>
<script src="common.js"></script>
<script>
const { Auth, UI, Navigation } = window.App;
if (!NavigationHelper.requireAuth()) {}
// Check authentication
if (!Auth.requireAuth()) return;
const currentUser = StorageManager.getCurrentUser();
const meetingId = NavigationHelper.getQueryParam('meetingId');
let meeting = meetingId ? StorageManager.getMeetingById(meetingId) : JSON.parse(localStorage.getItem('current_meeting') || 'null');
// Set page title
UI.setTitle('회의 종료');
if (!meeting) {
UIComponents.showToast('회의 정보를 찾을 수 없습니다', 'error');
setTimeout(() => NavigationHelper.navigate('DASHBOARD'), 2000);
}
// Mock statistics data
const stats = {
duration: '00:45:30',
attendees: 5,
speakers: [
{ name: '김민준', count: 12, percentage: 34 },
{ name: '박서연', count: 8, percentage: 23 },
{ name: '최유진', count: 6, percentage: 17 },
{ name: '이준호', count: 5, percentage: 14 },
{ name: '정도현', count: 4, percentage: 12 }
],
keywords: [
{ keyword: 'KPI', count: 15 },
{ keyword: '목표', count: 12 },
{ keyword: '분기계획', count: 8 },
{ keyword: '실적', count: 7 },
{ keyword: '리스크', count: 5 }
]
};
// 회의 정보 표시
if (meeting) {
document.getElementById('meetingTitle').textContent = meeting.title;
document.getElementById('meetingInfo').textContent = `${Utils.formatDate(meeting.date)} ${meeting.startTime} ~ ${meeting.endTime}`;
document.getElementById('totalTime').textContent = Utils.formatDuration(meeting.duration || 5400000);
document.getElementById('attendeeCount').textContent = `${meeting.attendees?.length || 0}명`;
}
function renderStats() {
// Render speaker statistics
const speakerContainer = document.getElementById('speakerStats');
const maxCount = Math.max(...stats.speakers.map(s => s.count));
// AI Todo 추출 및 렌더링
function renderTodos() {
const todos = [
{ content: '프로젝트 계획서 작성 및 공유', assignee: '김철수', dueDate: '2025-10-25', priority: 'high' },
{ content: 'API 문서 작성', assignee: '이영희', dueDate: '2025-10-24', priority: 'high' },
{ content: '디자인 시안 1차 검토', assignee: '박민수', dueDate: '2025-10-23', priority: 'medium' }
];
speakerContainer.innerHTML = stats.speakers.map(speaker => `
<div class="speaker-item">
const container = document.getElementById('todoList');
container.innerHTML = todos.map(todo => `
<div class="d-flex align-center gap-2 mb-3 p-3" style="background: var(--gray-50); border-radius: 8px;">
<span class="material-symbols-outlined" style="color: var(--primary-500);">check_box_outline_blank</span>
<div style="flex: 1;">
<div class="flex justify-between items-center mb-1">
<span style="font-weight: var(--font-medium);">${speaker.name}</span>
<span class="text-primary" style="font-weight: var(--font-semibold);">${speaker.count}회</span>
</div>
<div class="speaker-bar">
<div class="speaker-bar-fill" style="width: ${(speaker.count / maxCount) * 100}%"></div>
<p class="text-body">${todo.content}</p>
<div class="d-flex align-center gap-3 mt-1">
<span class="text-caption">👤 ${todo.assignee}</span>
<span class="text-caption">📅 ${Utils.formatDate(todo.dueDate)}</span>
${todo.priority === 'high' ? '<span class="badge badge-priority-high">높음</span>' : '<span class="badge badge-priority-medium">보통</span>'}
</div>
</div>
</div>
`).join('');
// Render keywords
const keywordsContainer = document.getElementById('keywords');
keywordsContainer.innerHTML = stats.keywords.map(item => `
<span class="badge badge-info" style="font-size: var(--font-sm);">
#${item.keyword}
</span>
`).join('');
}
// Todo 데이터 저장
todos.forEach(todo => {
const todoData = {
id: Utils.generateId('TODO'),
meetingId: meeting.id,
sectionId: 'SEC_todos',
content: todo.content,
assignee: todo.assignee,
assigneeId: DUMMY_USERS.find(u => u.name === todo.assignee)?.id || '',
dueDate: todo.dueDate,
priority: todo.priority,
status: 'in-progress',
completed: false,
createdAt: new Date().toISOString()
};
async function confirmMinutes() {
UI.showLoading();
// Simulate validation
await new Promise(resolve => setTimeout(resolve, 1000));
UI.hideLoading();
// Check if all required fields are filled
const isValid = Math.random() > 0.3; // 70% success rate
if (isValid) {
UI.showToast('회의록 검증 완료', 'success');
setTimeout(() => {
Navigation.goTo('08-회의록공유.html');
}, 500);
} else {
// Show missing fields modal
UI.showModal({
title: '필수 항목 누락',
content: `
<p class="mb-4">다음 항목을 작성해주세요:</p>
<ul style="list-style: none; padding: 0;">
<li class="mb-2">❌ 주요 논의 내용</li>
<li class="mb-2">❌ 결정 사항</li>
</ul>
`,
buttons: [
{
text: '취소',
className: 'btn-secondary'
},
{
text: '항목 작성하기',
className: 'btn-primary',
onClick: () => Navigation.goTo('05-회의진행.html')
}
]
});
}
}
function saveLater() {
UI.showToast('임시 저장되었습니다', 'success');
setTimeout(() => {
Navigation.goTo('02-대시보드.html');
}, 500);
}
// Initialize
renderStats();
// Animate stats on load
setTimeout(() => {
document.querySelectorAll('.stat-value').forEach(el => {
el.style.transform = 'scale(1.1)';
setTimeout(() => {
el.style.transform = 'scale(1)';
}, 300);
// 중복 체크 후 저장
const existing = StorageManager.getTodos().find(t =>
t.meetingId === meeting.id && t.content === todo.content
);
if (!existing) {
StorageManager.addTodo(todoData);
}
});
}, 100);
}
// Todo 수정
function editTodos() {
UIComponents.showToast('Todo 수정 기능은 Todo 관리 화면에서 이용하실 수 있습니다', 'info');
setTimeout(() => {
NavigationHelper.navigate('TODO_MANAGE');
}, 1500);
}
// 회의록 확정
function confirmMeeting() {
UIComponents.confirm(
'회의록을 최종 확정하시겠습니까? 확정 후에도 수정할 수 있습니다.',
() => {
if (meeting) {
meeting.status = 'confirmed';
meeting.confirmedAt = new Date().toISOString();
StorageManager.updateMeeting(meeting.id, meeting);
UIComponents.showToast('회의록이 최종 확정되었습니다', 'success');
// Todo 자동 할당 알림
setTimeout(() => {
UIComponents.showToast('Todo가 담당자에게 자동으로 할당되었습니다', 'info');
}, 1000);
setTimeout(() => {
NavigationHelper.navigate('MEETING_SHARE', { meetingId: meeting.id });
}, 2000);
}
},
() => {}
);
}
// 초기 렌더링
renderTodos();
</script>
</body>
</html>

View File

@ -3,330 +3,250 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의록 공유 - 회의록 작성 서비스</title>
<title>회의록 공유 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
.radio-group {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.radio-item {
display: flex;
align-items: center;
padding: var(--space-3);
border: 2px solid var(--border-light);
border-radius: 8px;
cursor: pointer;
transition: all var(--duration-base);
}
.radio-item:hover {
border-color: var(--primary);
background: var(--primary-light);
}
.radio-item input[type="radio"] {
width: 20px;
height: 20px;
margin-right: var(--space-3);
cursor: pointer;
}
.radio-item.checked {
border-color: var(--primary);
background: var(--primary-light);
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.checkbox-item {
display: flex;
align-items: center;
}
.checkbox-item input[type="checkbox"] {
width: 20px;
height: 20px;
margin-right: var(--space-3);
cursor: pointer;
}
.success-screen {
text-align: center;
padding: var(--space-12) var(--space-4);
}
.success-icon {
font-size: 80px;
margin-bottom: var(--space-6);
}
.link-box {
background: var(--bg-gray);
padding: var(--space-4);
border-radius: 8px;
display: flex;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-4);
}
.link-text {
flex: 1;
font-size: var(--font-sm);
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
<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">
</head>
<body>
<!-- Header -->
<header class="header">
<button class="header-back" aria-label="뒤로가기"></button>
<h1 class="header-title" id="pageTitle">회의록 확정</h1>
</header>
<div class="page">
<!-- 헤더 -->
<div class="header">
<button class="btn-icon" onclick="NavigationHelper.goBack()" aria-label="뒤로가기">
<span class="material-symbols-outlined">arrow_back</span>
</button>
<h1 class="header-title">회의록 공유</h1>
<button class="btn btn-primary btn-sm" onclick="shareMinutes()">공유하기</button>
</div>
<!-- Main Content -->
<main class="main-content">
<div class="container">
<!-- Step 1: Validation -->
<div id="validationStep">
<h2 class="mb-4">필수 항목 확인</h2>
<!-- 메인 컨텐츠 -->
<div class="content">
<form id="shareForm">
<!-- 공유 대상 -->
<div class="form-group">
<label class="form-label required">공유 대상</label>
<label class="form-checkbox mb-2">
<input type="radio" name="shareTarget" value="all" checked onchange="toggleAttendeeList()">
<span>참석자 전체</span>
</label>
<label class="form-checkbox">
<input type="radio" name="shareTarget" value="selected" onchange="toggleAttendeeList()">
<span>특정 참석자 선택</span>
</label>
</div>
<div class="card mb-6">
<div class="flex items-center gap-2 mb-3">
<span style="font-size: 24px; color: var(--success);"></span>
<span>회의 제목</span>
</div>
<div class="flex items-center gap-2 mb-3">
<span style="font-size: 24px; color: var(--success);"></span>
<span>참석자 목록</span>
</div>
<div class="flex items-center gap-2 mb-3">
<span style="font-size: 24px; color: var(--success);"></span>
<span>주요 논의 내용</span>
</div>
<div class="flex items-center gap-2">
<span style="font-size: 24px; color: var(--success);"></span>
<span>결정 사항</span>
<!-- 참석자 목록 (선택 시) -->
<div class="form-group" id="attendeeListGroup" style="display: none;">
<div id="attendeeList">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
<h3 class="mb-3">선택 항목</h3>
<div class="card mb-6">
<div class="flex items-center gap-2">
<span style="font-size: 24px; color: var(--text-secondary);"></span>
<span>Todo 항목</span>
</div>
<!-- 공유 권한 -->
<div class="form-group">
<label for="sharePermission" class="form-label required">공유 권한</label>
<select id="sharePermission" class="form-select">
<option value="read" selected>읽기 전용</option>
<option value="comment">댓글 가능</option>
<option value="edit">편집 가능</option>
</select>
</div>
<button class="btn btn-primary btn-full" onclick="goToShareSettings()">
최종 확정
</button>
</div>
<!-- Step 2: Share Settings -->
<div id="shareStep" class="hidden">
<form id="shareForm">
<!-- Recipients -->
<div class="form-group">
<label class="form-label">공유 대상</label>
<div class="radio-group">
<label class="radio-item checked">
<input type="radio" name="recipients" value="all" checked />
<span>참석자 전체</span>
</label>
<label class="radio-item">
<input type="radio" name="recipients" value="selected" />
<span>특정 참석자 선택</span>
</label>
</div>
</div>
<!-- Permissions -->
<div class="form-group">
<label class="form-label">공유 권한</label>
<div class="radio-group">
<label class="radio-item checked">
<input type="radio" name="permission" value="read" checked />
<span>읽기 전용</span>
</label>
<label class="radio-item">
<input type="radio" name="permission" value="comment" />
<span>댓글 가능</span>
</label>
<label class="radio-item">
<input type="radio" name="permission" value="edit" />
<span>편집 가능</span>
</label>
</div>
</div>
<!-- Share Methods -->
<div class="form-group">
<label class="form-label">공유 방식</label>
<div class="checkbox-group">
<label class="checkbox-item">
<input type="checkbox" name="shareMethod" value="email" checked />
<span>이메일 발송</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="shareMethod" value="slack" />
<span>슬랙 알림</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="shareMethod" value="link" />
<span>링크만 생성</span>
</label>
</div>
</div>
<!-- Security Settings (Optional) -->
<div class="form-group">
<label class="form-label">보안 설정 (선택)</label>
<div class="checkbox-group">
<label class="checkbox-item">
<input type="checkbox" name="security" value="expiry" id="expiryCheck" />
<span>유효기간 설정</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="security" value="password" />
<span>비밀번호 설정</span>
</label>
</div>
</div>
<button type="submit" class="btn btn-primary btn-full mt-6">
공유하기
<!-- 공유 방식 -->
<div class="form-group">
<label class="form-label">공유 방식</label>
<label class="form-checkbox mb-2">
<input type="checkbox" id="sendEmail" checked>
<span>이메일 발송</span>
</label>
<button type="button" class="btn btn-secondary btn-sm w-full" onclick="copyLink()">
<span class="material-symbols-outlined">link</span>
링크 복사
</button>
</form>
</div>
<!-- Step 3: Success -->
<div id="successStep" class="hidden success-screen">
<div class="success-icon" aria-hidden="true"></div>
<h2 class="mb-4">공유 완료!</h2>
<p class="text-secondary mb-6">
회의록이 참석자들에게 공유되었습니다.
</p>
<!-- Share Link -->
<div class="card mb-6">
<h3 class="mb-3">📎 공유 링크</h3>
<div class="link-box">
<span class="link-text" id="shareLink">https://example.com/minutes/abc123</span>
<button class="btn btn-secondary" onclick="copyLink()">복사</button>
</div>
</div>
<!-- Todo Extraction Result -->
<div class="card mb-6">
<h3 class="mb-3">📋 Todo 자동 추출</h3>
<div class="flex items-center gap-2 text-success">
<span style="font-size: 24px;"></span>
<span>3개 항목이 추출되어 담당자에게 할당되었습니다</span>
<!-- 링크 보안 설정 -->
<div class="card mb-4">
<h3 class="text-h5 mb-3">링크 보안 설정</h3>
<label class="form-checkbox mb-3">
<input type="checkbox" id="enableExpiry" onchange="toggleExpiryDate()">
<span>유효기간 설정</span>
</label>
<div id="expiryDateGroup" style="display: none;">
<select id="expiryPeriod" class="form-select mb-3">
<option value="7">7일</option>
<option value="30" selected>30일</option>
<option value="90">90일</option>
<option value="unlimited">무제한</option>
</select>
</div>
<label class="form-checkbox mb-3">
<input type="checkbox" id="enablePassword" onchange="togglePassword()">
<span>비밀번호 설정</span>
</label>
<div id="passwordGroup" style="display: none;">
<input
type="password"
id="linkPassword"
class="form-input"
placeholder="링크 접근 비밀번호"
>
</div>
</div>
</form>
<!-- Next Meeting -->
<div class="card mb-8">
<h3 class="mb-3">📅 다음 회의 일정</h3>
<div class="flex items-center gap-2 text-success">
<span style="font-size: 24px;"></span>
<span>2025-01-22 14:00 캘린더에 등록</span>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col gap-4">
<button class="btn btn-primary btn-full" onclick="viewMinutes()">
회의록 보기
</button>
<button class="btn btn-secondary btn-full" onclick="goToDashboard()">
대시보드로
</button>
<!-- 공유 이력 -->
<div class="card">
<h3 class="text-h4 mb-3">공유 이력</h3>
<div id="shareHistory">
<p class="text-body-sm text-gray text-center" style="padding: 24px 0;">아직 공유 이력이 없습니다</p>
</div>
</div>
</div>
</main>
</div>
<script src="common.js"></script>
<script>
const { Auth, UI, Navigation } = window.App;
if (!NavigationHelper.requireAuth()) {}
// Check authentication
if (!Auth.requireAuth()) return;
const currentUser = StorageManager.getCurrentUser();
const meetingId = NavigationHelper.getQueryParam('meetingId');
const meeting = meetingId ? StorageManager.getMeetingById(meetingId) : null;
// Set page title
UI.setTitle('회의록 공유');
// Handle radio buttons
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.radio-item').forEach(item => {
item.addEventListener('click', function() {
const radio = this.querySelector('input[type="radio"]');
radio.checked = true;
// Update visual state
this.closest('.radio-group').querySelectorAll('.radio-item').forEach(r => {
r.classList.remove('checked');
});
this.classList.add('checked');
});
});
});
function goToShareSettings() {
document.getElementById('validationStep').classList.add('hidden');
document.getElementById('shareStep').classList.remove('hidden');
document.getElementById('pageTitle').textContent = '회의록 공유';
if (!meeting) {
UIComponents.showToast('회의 정보를 찾을 수 없습니다', 'error');
setTimeout(() => NavigationHelper.navigate('DASHBOARD'), 2000);
}
// Share form submission
document.getElementById('shareForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
// 참석자 목록 토글
function toggleAttendeeList() {
const selected = document.querySelector('input[name="shareTarget"]:checked').value === 'selected';
document.getElementById('attendeeListGroup').style.display = selected ? 'block' : 'none';
UI.showLoading();
if (selected && meeting) {
renderAttendeeList();
}
}
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000));
// 참석자 목록 렌더링
function renderAttendeeList() {
const container = document.getElementById('attendeeList');
container.innerHTML = meeting.attendees.map((attendee, index) => `
<label class="form-checkbox mb-2">
<input type="checkbox" name="attendee" value="${attendee}" checked>
<span>${attendee}</span>
</label>
`).join('');
}
UI.hideLoading();
// 유효기간 토글
function toggleExpiryDate() {
const enabled = document.getElementById('enableExpiry').checked;
document.getElementById('expiryDateGroup').style.display = enabled ? 'block' : 'none';
}
// Show success screen
document.getElementById('shareStep').classList.add('hidden');
document.getElementById('successStep').classList.remove('hidden');
document.getElementById('pageTitle').textContent = '공유 완료';
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
});
// 비밀번호 토글
function togglePassword() {
const enabled = document.getElementById('enablePassword').checked;
document.getElementById('passwordGroup').style.display = enabled ? 'block' : 'none';
}
// 링크 복사
function copyLink() {
const link = document.getElementById('shareLink').textContent;
const link = `https://meeting.example.com/share/${meeting.id}`;
// Copy to clipboard
// 클립보드 복사
navigator.clipboard.writeText(link).then(() => {
UI.showToast('링크가 복사되었습니다', 'success');
UIComponents.showToast('링크가 복사되었습니다', 'success');
}).catch(() => {
UI.showToast('복사에 실패했습니다', 'error');
// Fallback
const tempInput = document.createElement('input');
tempInput.value = link;
document.body.appendChild(tempInput);
tempInput.select();
document.execCommand('copy');
document.body.removeChild(tempInput);
UIComponents.showToast('링크가 복사되었습니다', 'success');
});
}
function viewMinutes() {
UI.showToast('회의록 보기 기능은 개발 예정입니다', 'info');
// 회의록 공유
function shareMinutes() {
const shareTarget = document.querySelector('input[name="shareTarget"]:checked').value;
const sharePermission = document.getElementById('sharePermission').value;
const sendEmail = document.getElementById('sendEmail').checked;
const enableExpiry = document.getElementById('enableExpiry').checked;
const enablePassword = document.getElementById('enablePassword').checked;
let recipients = [];
if (shareTarget === 'all') {
recipients = meeting.attendees;
} else {
const checked = Array.from(document.querySelectorAll('input[name="attendee"]:checked'));
recipients = checked.map(input => input.value);
}
if (recipients.length === 0) {
UIComponents.showToast('공유할 대상을 선택해주세요', 'error');
return;
}
const shareData = {
meetingId: meeting.id,
recipients: recipients,
permission: sharePermission,
sendEmail: sendEmail,
expiry: enableExpiry ? document.getElementById('expiryPeriod').value : null,
password: enablePassword ? document.getElementById('linkPassword').value : null,
sharedAt: new Date().toISOString(),
sharedBy: currentUser.name
};
UIComponents.showLoading('회의록을 공유하는 중...');
setTimeout(() => {
// 공유 처리 (시뮬레이션)
meeting.sharedWith = recipients.map(name => {
const user = DUMMY_USERS.find(u => u.name === name);
return user ? user.id : '';
}).filter(id => id);
StorageManager.updateMeeting(meeting.id, meeting);
UIComponents.hideLoading();
if (sendEmail) {
UIComponents.showToast(`${recipients.length}명에게 이메일이 발송되었습니다`, 'success');
} else {
UIComponents.showToast('회의록이 공유되었습니다', 'success');
}
// 공유 이력 추가
addShareHistory(shareData);
setTimeout(() => {
NavigationHelper.navigate('DASHBOARD');
}, 2000);
}, 1500);
}
function goToDashboard() {
Navigation.goTo('02-대시보드.html');
// 공유 이력 추가
function addShareHistory(shareData) {
const container = document.getElementById('shareHistory');
const html = `
<div class="mb-3 p-3" style="background: var(--gray-50); border-radius: 8px;">
<div class="d-flex justify-between align-center mb-2">
<span class="text-body">${shareData.sharedAt.split('T')[0]} ${shareData.sharedAt.split('T')[1].slice(0, 5)}</span>
<span class="badge badge-status">${shareData.permission === 'read' ? '읽기 전용' : shareData.permission === 'comment' ? '댓글 가능' : '편집 가능'}</span>
</div>
<p class="text-body-sm">대상: ${shareData.recipients.join(', ')}</p>
</div>
`;
container.innerHTML = html + container.innerHTML;
}
</script>
</body>

View File

@ -3,401 +3,278 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo 관리 - 회의록 작성 서비스</title>
<title>Todo 관리 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
.filter-tabs {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-6);
border-bottom: 2px solid var(--border-light);
}
.filter-tab {
padding: var(--space-3) var(--space-4);
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
font-weight: var(--font-medium);
cursor: pointer;
transition: all var(--duration-base);
margin-bottom: -2px;
}
.filter-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.todo-card {
background: var(--bg-white);
border: 1px solid var(--border-light);
border-radius: 12px;
padding: var(--space-4);
margin-bottom: var(--space-4);
cursor: pointer;
transition: all var(--duration-base);
}
.todo-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.todo-card.completed {
opacity: 0.7;
}
.todo-card.completed .todo-title {
text-decoration: line-through;
color: var(--text-secondary);
}
.progress-bar {
height: 8px;
background: var(--bg-gray);
border-radius: 4px;
overflow: hidden;
margin-top: var(--space-2);
}
.progress-fill {
height: 100%;
background: var(--primary);
transition: width var(--duration-slow);
}
.progress-fill.completed {
background: var(--success);
}
.priority-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: var(--font-xs);
font-weight: var(--font-medium);
}
.priority-high {
background: var(--error);
color: var(--text-inverse);
}
.priority-medium {
background: var(--warning);
color: var(--text-inverse);
}
.priority-low {
background: var(--bg-gray);
color: var(--text-secondary);
}
</style>
<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">
</head>
<body>
<!-- Header -->
<header class="header">
<button class="header-back" aria-label="뒤로가기"></button>
<h1 class="header-title">Todo</h1>
</header>
<div class="page">
<!-- 헤더 -->
<div class="header">
<h1 class="header-title">내 Todo</h1>
<button class="btn-icon" onclick="showFilter()" aria-label="필터">
<span class="material-symbols-outlined">filter_list</span>
</button>
</div>
<!-- Main Content -->
<main class="main-content">
<div class="container">
<!-- Filter Tabs -->
<div class="filter-tabs" role="tablist">
<button class="filter-tab active" role="tab" aria-selected="true" onclick="filterTodos('all')">
전체 <span id="count-all">(0)</span>
</button>
<button class="filter-tab" role="tab" aria-selected="false" onclick="filterTodos('inprogress')">
진행 중 <span id="count-inprogress">(0)</span>
</button>
<button class="filter-tab" role="tab" aria-selected="false" onclick="filterTodos('completed')">
완료 <span id="count-completed">(0)</span>
</button>
<!-- 메인 컨텐츠 -->
<div class="content" style="padding-bottom: 120px;">
<!-- 통계 카드 -->
<div class="card mb-4">
<div class="d-flex justify-between align-center mb-4">
<div style="flex: 1;">
<div class="d-flex align-center gap-4">
<div>
<h3 class="text-h2" id="totalCount">0</h3>
<p class="text-caption text-gray">전체 Todo</p>
</div>
<div>
<h3 class="text-h2" style="color: var(--success);" id="completedCount">0</h3>
<p class="text-caption text-gray">완료</p>
</div>
<div>
<h3 class="text-h2" style="color: var(--warning);" id="dueSoonCount">0</h3>
<p class="text-caption text-gray">마감 임박</p>
</div>
</div>
</div>
${UIComponents.createCircularProgress(0)}
</div>
</div>
<!-- Todo List -->
<div id="todoList" role="tabpanel">
<!-- Todo cards will be inserted here -->
<!-- 필터 탭 -->
<div class="d-flex gap-2 mb-4" style="overflow-x: auto;">
<button class="btn btn-sm active" id="filter-all" onclick="setFilter('all')">전체</button>
<button class="btn btn-secondary btn-sm" id="filter-inprogress" onclick="setFilter('inprogress')">진행 중</button>
<button class="btn btn-secondary btn-sm" id="filter-completed" onclick="setFilter('completed')">완료</button>
<button class="btn btn-secondary btn-sm" id="filter-duesoon" onclick="setFilter('duesoon')">마감 임박</button>
</div>
<!-- Empty State -->
<div id="emptyState" class="empty-state hidden">
<div class="empty-state-icon" aria-hidden="true"></div>
<h3 class="empty-state-title">Todo가 없습니다</h3>
<p class="empty-state-description">회의록에서 Todo가 자동으로 추출됩니다</p>
<!-- Todo 리스트 -->
<div id="todoList">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
</main>
<!-- Bottom Navigation -->
<nav class="bottom-nav" aria-label="주요 네비게이션">
<a href="02-대시보드.html" class="bottom-nav-item">
<span class="bottom-nav-icon" aria-hidden="true">🏠</span>
<span></span>
</a>
<a href="02-대시보드.html" class="bottom-nav-item">
<span class="bottom-nav-icon" aria-hidden="true">📅</span>
<span>회의</span>
</a>
<a href="09-Todo관리.html" class="bottom-nav-item active" aria-current="page">
<span class="bottom-nav-icon" aria-hidden="true"></span>
<span>Todo</span>
</a>
<a href="#" class="bottom-nav-item">
<span class="bottom-nav-icon" aria-hidden="true">🔔</span>
<span>알림</span>
</a>
<a href="#" class="bottom-nav-item">
<span class="bottom-nav-icon" aria-hidden="true">⚙️</span>
<span>설정</span>
</a>
</nav>
<!-- FAB -->
<button class="btn-fab" onclick="addTodo()" aria-label="Todo 추가">
<span class="material-symbols-outlined">add</span>
</button>
<!-- 하단 네비게이션 -->
<nav class="bottom-nav" role="navigation" aria-label="주요 메뉴">
<a href="02-대시보드.html" class="bottom-nav-item">
<span class="material-symbols-outlined bottom-nav-icon">home</span>
<span></span>
</a>
<a href="11-회의록수정.html" class="bottom-nav-item">
<span class="material-symbols-outlined bottom-nav-icon">description</span>
<span>회의록</span>
</a>
<a href="09-Todo관리.html" class="bottom-nav-item active" aria-current="page">
<span class="material-symbols-outlined bottom-nav-icon">check_box</span>
<span>Todo</span>
</a>
<a href="javascript:void(0)" class="bottom-nav-item">
<span class="material-symbols-outlined bottom-nav-icon">account_circle</span>
<span>프로필</span>
</a>
</nav>
</div>
<script src="common.js"></script>
<script>
const { Auth, API, UI, DateTime, Modal } = window.App;
// Check authentication
if (!Auth.requireAuth()) return;
// Set page title
UI.setTitle('Todo 관리');
if (!NavigationHelper.requireAuth()) {}
const currentUser = StorageManager.getCurrentUser();
let currentFilter = 'all';
let todos = [];
// Load todos
async function loadTodos() {
UI.showLoading();
// Todo 렌더링
function renderTodos() {
const todos = StorageManager.getTodos();
const myTodos = todos.filter(todo => todo.assigneeId === currentUser.id);
try {
const response = await API.getTodos();
if (response.success) {
todos = response.data.todos;
updateCounts(response.data);
renderTodos();
}
} catch (error) {
UI.showToast('Todo 목록을 불러올 수 없습니다', 'error');
} finally {
UI.hideLoading();
// 필터링
let filteredTodos = myTodos;
if (currentFilter === 'inprogress') {
filteredTodos = myTodos.filter(t => !t.completed);
} else if (currentFilter === 'completed') {
filteredTodos = myTodos.filter(t => t.completed);
} else if (currentFilter === 'duesoon') {
filteredTodos = myTodos.filter(t => !t.completed && isDueSoon(t.dueDate));
}
// 통계 업데이트
const total = myTodos.length;
const completed = myTodos.filter(t => t.completed).length;
const dueSoon = myTodos.filter(t => !t.completed && isDueSoon(t.dueDate)).length;
const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0;
document.getElementById('totalCount').textContent = total;
document.getElementById('completedCount').textContent = completed;
document.getElementById('dueSoonCount').textContent = dueSoon;
// 진행률 업데이트
const progressEl = document.querySelector('.circular-progress');
if (progressEl) {
progressEl.style.setProperty('--progress-percent', `${completionRate * 3.6}deg`);
progressEl.querySelector('.progress-percent').textContent = `${completionRate}%`;
}
// Todo 리스트 렌더링
const container = document.getElementById('todoList');
if (filteredTodos.length === 0) {
container.innerHTML = '<p class="text-body text-gray text-center" style="padding: 48px 0;">해당하는 Todo가 없습니다</p>';
return;
}
// 마감일 순 정렬
filteredTodos.sort((a, b) => {
if (a.completed !== b.completed) return a.completed ? 1 : -1;
return new Date(a.dueDate) - new Date(b.dueDate);
});
container.innerHTML = filteredTodos.map(todo => UIComponents.createTodoItem(todo)).join('');
}
function updateCounts(data) {
document.getElementById('count-all').textContent = `(${data.todos.length})`;
document.getElementById('count-inprogress').textContent = `(${data.summary.inProgress})`;
document.getElementById('count-completed').textContent = `(${data.summary.completed})`;
}
function filterTodos(filter) {
// 필터 설정
function setFilter(filter) {
currentFilter = filter;
// Update active tab
document.querySelectorAll('.filter-tab').forEach(tab => {
tab.classList.remove('active');
tab.setAttribute('aria-selected', 'false');
// 버튼 스타일 업데이트
document.querySelectorAll('[id^="filter-"]').forEach(btn => {
btn.classList.remove('btn-primary', 'active');
btn.classList.add('btn-secondary');
});
event.target.classList.add('active');
event.target.setAttribute('aria-selected', 'true');
const activeBtn = document.getElementById(`filter-${filter}`);
activeBtn.classList.remove('btn-secondary');
activeBtn.classList.add('btn-primary', 'active');
renderTodos();
}
function renderTodos() {
const container = document.getElementById('todoList');
const emptyState = document.getElementById('emptyState');
let filteredTodos = todos;
if (currentFilter === 'inprogress') {
filteredTodos = todos.filter(t => t.status === 'inprogress');
} else if (currentFilter === 'completed') {
filteredTodos = todos.filter(t => t.status === 'completed');
}
if (filteredTodos.length === 0) {
container.classList.add('hidden');
emptyState.classList.remove('hidden');
return;
}
container.classList.remove('hidden');
emptyState.classList.add('hidden');
container.innerHTML = filteredTodos.map(todo => `
<div class="todo-card ${todo.status === 'completed' ? 'completed' : ''}" onclick="showTodoDetail('${todo.id}')">
<div class="flex items-center gap-3 mb-3">
<input
type="checkbox"
${todo.status === 'completed' ? 'checked' : ''}
onclick="event.stopPropagation(); toggleComplete('${todo.id}')"
style="width: 20px; height: 20px; cursor: pointer;"
aria-label="${todo.content} ${todo.status === 'completed' ? '완료됨' : '완료 안됨'}"
/>
<h3 class="todo-title" style="flex: 1; margin: 0;">${todo.content}</h3>
// 필터 모달
function showFilter() {
UIComponents.showModal({
title: '필터 및 정렬',
content: `
<div class="form-group">
<label class="form-label">정렬 기준</label>
<select id="sortBy" class="form-select">
<option value="dueDate">마감일순</option>
<option value="priority">우선순위순</option>
<option value="created">생성일순</option>
</select>
</div>
<div class="flex items-center gap-2 text-secondary mb-2" style="font-size: var(--font-sm);">
<span>${todo.assignee}</span>
<span></span>
<span>${getDaysLeft(todo.dueDate)}</span>
${todo.priority ? `<span class="priority-badge priority-${todo.priority}">${getPriorityLabel(todo.priority)}</span>` : ''}
<div class="form-group">
<label class="form-label">우선순위</label>
<label class="form-checkbox mb-2">
<input type="checkbox" value="high" checked>
<span>높음</span>
</label>
<label class="form-checkbox mb-2">
<input type="checkbox" value="medium" checked>
<span>보통</span>
</label>
<label class="form-checkbox mb-2">
<input type="checkbox" value="low" checked>
<span>낮음</span>
</label>
</div>
<div>
<div class="flex justify-between items-center mb-1">
<span class="text-secondary" style="font-size: var(--font-xs);">진행률</span>
<span class="text-primary" style="font-size: var(--font-sm); font-weight: var(--font-semibold);">
${todo.progress}%
</span>
</div>
<div class="progress-bar">
<div class="progress-fill ${todo.status === 'completed' ? 'completed' : ''}" style="width: ${todo.progress}%"></div>
</div>
</div>
</div>
`).join('');
}
function getDaysLeft(dueDate) {
const due = new Date(dueDate);
const today = new Date();
const diffTime = due - today;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return `D+${Math.abs(diffDays)}일 지남`;
} else if (diffDays === 0) {
return '오늘';
} else {
return `D-${diffDays}일`;
}
}
function getPriorityLabel(priority) {
const labels = {
high: '높음',
medium: '보통',
low: '낮음'
};
return labels[priority] || '';
}
async function toggleComplete(todoId) {
const todo = todos.find(t => t.id === todoId);
if (!todo) return;
const newStatus = todo.status === 'completed' ? 'inprogress' : 'completed';
const newProgress = newStatus === 'completed' ? 100 : todo.progress;
UI.showLoading();
try {
const response = await API.updateTodo(todoId, {
status: newStatus,
progress: newProgress
});
if (response.success) {
todo.status = newStatus;
todo.progress = newProgress;
renderTodos();
UI.showToast(
newStatus === 'completed' ? 'Todo가 완료되었습니다' : 'Todo가 진행 중으로 변경되었습니다',
'success'
);
// Update counts
const inProgress = todos.filter(t => t.status === 'inprogress').length;
const completed = todos.filter(t => t.status === 'completed').length;
updateCounts({
todos,
summary: { inProgress, completed, total: todos.length }
});
}
} catch (error) {
UI.showToast('업데이트에 실패했습니다', 'error');
} finally {
UI.hideLoading();
}
}
function showTodoDetail(todoId) {
const todo = todos.find(t => t.id === todoId);
if (!todo) return;
const content = `
<div class="mb-4">
<h3 class="mb-3">${todo.content}</h3>
<div class="mb-4">
<div class="text-secondary" style="font-size: var(--font-sm); margin-bottom: var(--space-1);">담당자</div>
<div>${todo.assignee}</div>
</div>
<div class="mb-4">
<div class="text-secondary" style="font-size: var(--font-sm); margin-bottom: var(--space-1);">마감일</div>
<div>${DateTime.formatDate(todo.dueDate)}</div>
</div>
${todo.priority ? `
<div class="mb-4">
<div class="text-secondary" style="font-size: var(--font-sm); margin-bottom: var(--space-1);">우선순위</div>
<span class="priority-badge priority-${todo.priority}">${getPriorityLabel(todo.priority)}</span>
</div>
` : ''}
<div class="mb-4">
<div class="text-secondary" style="font-size: var(--font-sm); margin-bottom: var(--space-2);">진행률</div>
<div class="progress-bar" style="height: 12px;">
<div class="progress-fill" style="width: ${todo.progress}%"></div>
</div>
<div class="text-center mt-2" style="font-weight: var(--font-semibold);">${todo.progress}%</div>
</div>
<div class="mb-4">
<div class="text-secondary" style="font-size: var(--font-sm); margin-bottom: var(--space-1);">상태</div>
<div>${todo.status === 'completed' ? '✅ 완료' : '🔄 진행 중'}</div>
</div>
${todo.relatedMinutesId ? `
<div>
<div class="text-secondary" style="font-size: var(--font-sm); margin-bottom: var(--space-1);">관련 회의록</div>
<div class="card" style="padding: var(--space-3); cursor: pointer;">
📝 Q4 기획 회의<br>
<span style="font-size: var(--font-sm); color: var(--text-secondary);">2025-01-15</span>
</div>
</div>
` : ''}
</div>
`;
Modal.show({
title: 'Todo 상세',
content,
buttons: [
{
text: '닫기',
className: 'btn-secondary'
},
{
text: todo.status === 'completed' ? '진행 중으로' : '완료 처리',
className: 'btn-primary',
onClick: () => toggleComplete(todoId)
}
]
`,
footer: `
<button class="btn btn-secondary" onclick="closeModal()">취소</button>
<button class="btn btn-primary" onclick="closeModal(); renderTodos()">적용</button>
`,
onClose: () => {}
});
}
// Initialize
loadTodos();
// Todo 추가
function addTodo() {
UIComponents.showModal({
title: 'Todo 추가',
content: `
<form id="addTodoForm">
<div class="form-group">
<label for="todoContent" class="form-label required">내용</label>
<textarea
id="todoContent"
class="form-textarea"
rows="3"
placeholder="Todo 내용을 입력하세요"
required
></textarea>
</div>
<div class="form-group">
<label for="todoDueDate" class="form-label required">마감일</label>
<input
type="date"
id="todoDueDate"
class="form-input"
required
min="${new Date().toISOString().split('T')[0]}"
>
</div>
<div class="form-group">
<label for="todoPriority" class="form-label">우선순위</label>
<select id="todoPriority" class="form-select">
<option value="low">낮음</option>
<option value="medium" selected>보통</option>
<option value="high">높음</option>
</select>
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" onclick="closeModal()">취소</button>
<button class="btn btn-primary" onclick="saveTodo()">저장</button>
`,
onClose: () => {}
});
}
// Todo 저장
function saveTodo() {
const content = document.getElementById('todoContent').value.trim();
const dueDate = document.getElementById('todoDueDate').value;
const priority = document.getElementById('todoPriority').value;
if (!content || !dueDate) {
UIComponents.showToast('필수 항목을 입력해주세요', 'error');
return;
}
const todoData = {
id: Utils.generateId('TODO'),
meetingId: '',
sectionId: '',
content: content,
assignee: currentUser.name,
assigneeId: currentUser.id,
dueDate: dueDate,
priority: priority,
status: 'in-progress',
completed: false,
createdAt: new Date().toISOString()
};
StorageManager.addTodo(todoData);
closeModal();
UIComponents.showToast('Todo가 추가되었습니다', 'success');
renderTodos();
}
// 모달 닫기
function closeModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) modal.remove();
}
// 초기 렌더링
renderTodos();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,300 +0,0 @@
# 회의록 작성 및 공유 개선 서비스 - 유저스토리 목록
## 문서 정보
- **작성일**: 2025-01-20
- **버전**: 1.0
- **기반 문서**: design-last/userstory.md
---
## 목차
1. [차별화 전략](#차별화-전략)
2. [마이크로서비스 구성](#마이크로서비스-구성)
3. [전체 유저스토리 목록](#전체-유저스토리-목록)
4. [서비스별 유저스토리](#서비스별-유저스토리)
---
## 차별화 전략
### 기본 기능 (Hygiene Factors)
- **STT(Speech To Text)**: 음성을 텍스트로 변환하는 기본 기능
- 시장의 대부분 서비스가 제공
- 차별화 포인트 아님
### 핵심 차별화 포인트 (Differentiators)
| 차별화 기능 | 설명 |
|------------|------|
| **맥락 기반 용어 설명** | 관련 회의록과 업무이력 기반 실용적 정보 제공 |
| **강화된 Todo 연결** | Action item과 담당자 Todo 실시간 연결 및 자동 반영 |
| **프롬프팅 기반 회의록 개선** | AI를 활용한 다양한 형식의 회의록 생성 |
| **지능형 회의 진행 지원** | 회의 패턴 분석을 통한 안건 추천 및 효율성 분석 |
---
## 마이크로서비스 구성
| 순번 | 서비스명 | 책임 | 차별화 여부 |
|------|---------|------|------------|
| 1 | User | 사용자 인증 및 권한 관리 | 기본 |
| 2 | Meeting | 회의 관리, 회의록 생성/관리/공유 | 기본 |
| 3 | STT | 음성 녹음 관리, 음성-텍스트 변환, 화자 식별 | 기본 |
| 4 | AI | LLM 기반 회의록 자동 작성, Todo 자동 추출, 프롬프팅 기반 회의록 개선 | **차별화** |
| 5 | RAG | 맥락 기반 용어 설명, 관련 문서 검색 및 연결, 업무 이력 통합 | **차별화** |
| 6 | Collaboration | 실시간 동기화, 버전 관리, 충돌 해결 | 기본 |
| 7 | Todo | Todo 할당 및 관리, 진행 상황 추적, 회의록 실시간 연동 | **차별화** |
| 8 | Notification | 알림 발송 및 리마인더 관리 | 기본 |
---
## 전체 유저스토리 목록
| 번호 | ID | 서비스 | 기능명 | As a | I want | So that | 복잡도 | 차별화 |
|------|----|---------|---------|-----------------------------|------------------------------------------|----------------------------------------|--------|--------|
| 1 | AFR-USER-010 | User | 사용자 인증 관리 | 시스템 관리자 | 사용자 인증 기능 | 서비스 보안 유지 | M/8 | ❌ |
| 2 | UFR-MEET-010 | Meeting | 회의 예약 | 회의록 작성자 | 회의를 예약하고 참석자를 초대 | 회의를 효율적으로 준비 | M/13 | ❌ |
| 3 | UFR-MEET-020 | Meeting | 템플릿 선택 | 회의록 작성자 | 회의 유형에 맞는 템플릿을 선택 | 회의록을 효율적으로 작성 | S/5 | ❌ |
| 4 | UFR-MEET-030 | Meeting | 회의 시작 | 회의록 작성자 | 회의를 시작하고 음성 녹음을 준비 | 회의록을 작성 | M/8 | ❌ |
| 5 | UFR-MEET-040 | Meeting | 회의 종료 | 회의록 작성자 | 회의를 종료하고 통계를 확인 | 회의를 정리 | M/8 | ❌ |
| 6 | UFR-MEET-050 | Meeting | 최종 확정 | 회의록 작성자 | 최종 회의록을 확정하고 버전을 생성 | 회의록을 완성 | M/13 | ❌ |
| 7 | UFR-MEET-060 | Meeting | 회의록 공유 | 회의록 작성자 | 최종 회의록을 공유 | 회의 내용을 참석자들과 공유 | M/13 | ❌ |
| 8 | UFR-STT-010 | STT | 음성 녹음 인식 | 회의 참석자 | 음성이 실시간으로 녹음되고 인식 | 발언 내용이 자동으로 기록 | M/21 | ❌ |
| 9 | UFR-STT-020 | STT | 텍스트 변환 | 회의록 시스템 | 음성을 텍스트로 변환 | 인식된 발언을 회의록에 기록 | M/13 | ❌ |
| 10 | UFR-AI-010 | AI | 회의록 자동 작성 | 회의록 작성자 | AI가 발언 내용을 자동으로 정리하여 회의록을 작성 | 회의록 작성 부담을 줄임 | M/34 | ❌ |
| 11 | UFR-AI-020 | AI | Todo 자동 추출 | 회의록 작성자 | AI가 회의록에서 Todo 항목을 자동으로 추출하고 담당자를 식별 | 회의 후 실행 사항을 명확히 함 | M/21 | ✅ |
| 12 | UFR-AI-030 | AI | 회의록 개선 | 회의록 작성자 | 프롬프팅을 통해 회의록을 개선하고 재구성 | 회의록을 다양한 형식으로 변환 | M/21 | ✅ |
| 13 | UFR-AI-040 | AI | 관련 회의록 연결 | 회의록 작성자 | AI가 관련 있는 과거 회의록을 자동으로 찾아 연결 | 이전 회의 내용을 쉽게 참조 | S/13 | ✅ |
| 14 | UFR-RAG-010 | RAG | 전문용어 감지 | 회의록 작성자 | 전문용어가 자동으로 감지되고 맥락에 맞는 설명을 제공 | 업무 지식이 없어도 회의록을 정확히 작성 | S/13 | ✅ |
| 15 | UFR-RAG-020 | RAG | 맥락 기반 용어 설명 | 회의록 작성자 | 관련 회의록과 업무 이력을 바탕으로 실용적인 설명을 제공 | 전문용어를 맥락에 맞게 이해 | S/21 | ✅ |
| 16 | UFR-COLLAB-010 | Collaboration | 회의록 수정 동기화 | 회의 참석자 | 회의록을 수정하고 실시간으로 다른 참석자와 동기화 | 회의록을 함께 검증 | M/34 | ❌ |
| 17 | UFR-COLLAB-020 | Collaboration | 충돌 해결 | 회의 참석자 | 충돌을 감지하고 해결 | 동시 수정 상황에서도 내용을 잃지 않음 | M/21 | ❌ |
| 18 | UFR-COLLAB-030 | Collaboration | 검증 완료 | 회의 참석자 | 주요 섹션을 검증하고 완료 표시 | 회의록의 정확성을 보장 | M/8 | ❌ |
| 19 | UFR-TODO-010 | Todo | Todo 할당 | Todo 시스템 | Todo를 실시간으로 할당하고 회의록과 연결 | AI가 추출한 Todo를 담당자에게 전달 | M/13 | ✅ |
| 20 | UFR-TODO-030 | Todo | Todo 완료 처리 | Todo 담당자 | Todo를 완료하고 회의록에 자동 반영 | 완료된 Todo를 처리하고 회의록에 반영 | M/8 | ✅ |
**총 20개 유저스토리** (차별화 기능 7개 ✅)
---
## 서비스별 유저스토리
### 1. User 서비스 (1개)
| ID | 기능명 | As a | I want | So that | 복잡도 |
|----|--------|------|--------|---------|--------|
| AFR-USER-010 | 사용자 인증 관리 | 시스템 관리자 | 사용자 인증 기능을 원한다 | 서비스 보안을 위해 | M/8 |
#### AFR-USER-010: 사용자 인증 관리
**시나리오**: 사용자 인증 관리
- 사용자가 로그인을 시도한 상황에서
- 사번과 비밀번호를 입력하면
- LDAP 연동을 통해 인증이 완료되고 권한에 따라 서비스에 접근할 수 있다
**주요 기능**:
- 사용자 인증 (사번, 비밀번호)
- 세션 관리
---
### 2. Meeting 서비스 (6개)
| ID | 기능명 | As a | I want | So that | 복잡도 |
|----|--------|------|--------|---------|--------|
| UFR-MEET-010 | 회의 예약 | 회의록 작성자 | 회의를 예약하고 참석자를 초대하고 싶다 | 회의를 효율적으로 준비하기 위해 | M/13 |
| UFR-MEET-020 | 템플릿 선택 | 회의록 작성자 | 회의 유형에 맞는 템플릿을 선택하고 싶다 | 회의록을 효율적으로 작성하기 위해 | S/5 |
| UFR-MEET-030 | 회의 시작 | 회의록 작성자 | 회의를 시작하고 음성 녹음을 준비하고 싶다 | 회의를 시작하고 회의록을 작성하기 위해 | M/8 |
| UFR-MEET-040 | 회의 종료 | 회의록 작성자 | 회의를 종료하고 통계를 확인하고 싶다 | 회의를 종료하고 회의록을 정리하기 위해 | M/8 |
| UFR-MEET-050 | 최종 확정 | 회의록 작성자 | 최종 회의록을 확정하고 버전을 생성하고 싶다 | 회의록을 완성하기 위해 | M/13 |
| UFR-MEET-060 | 회의록 공유 | 회의록 작성자 | 최종 회의록을 공유하고 싶다 | 회의 내용을 참석자들과 공유하기 위해 | M/13 |
#### UFR-MEET-010: 회의 예약
**시나리오**: 회의 예약 및 참석자 초대
- 회의 예약 화면에 접근한 상황에서
- 회의 제목, 날짜/시간, 장소, 참석자 목록을 입력하고 예약 버튼을 클릭하면
- 회의가 예약되고 참석자에게 초대 이메일이 자동 발송된다
**입력 요구사항**:
- 회의 제목: 최대 100자 (필수)
- 날짜/시간: 날짜 및 시간 선택 (필수)
- 장소: 최대 200자 (선택)
- 참석자 목록: 이메일 주소 입력 (최소 1명 필수)
**처리 결과**:
- 회의가 예약됨 (회의 ID 생성)
- 일정이 캘린더에 자동 등록됨
- 참석자에게 초대 이메일 발송됨
- 회의 시작 30분 전 리마인더 자동 발송
---
### 3. STT 서비스 (2개) - 기본 기능
| ID | 기능명 | As a | I want | So that | 복잡도 |
|----|--------|------|--------|---------|--------|
| UFR-STT-010 | 음성 녹음 인식 | 회의 참석자 | 음성이 실시간으로 녹음되고 인식되기를 원한다 | 발언 내용이 자동으로 기록되기 위해 | M/21 |
| UFR-STT-020 | 텍스트 변환 | 회의록 시스템 | 음성을 텍스트로 변환하고 싶다 | 인식된 발언을 회의록에 기록하기 위해 | M/13 |
**비고**: STT는 기본 기능으로 경쟁사 대부분이 제공하는 기능임 (차별화 포인트 아님)
---
### 4. AI 서비스 (4개) - 차별화 포인트 ✅
| ID | 기능명 | As a | I want | So that | 복잡도 | 차별화 |
|----|--------|------|--------|---------|--------|--------|
| UFR-AI-010 | 회의록 자동 작성 | 회의록 작성자 | AI가 발언 내용을 자동으로 정리하여 회의록을 작성하기를 원한다 | 회의록 작성 부담을 줄이기 위해 | M/34 | ❌ |
| UFR-AI-020 | Todo 자동 추출 | 회의록 작성자 | AI가 회의록에서 Todo 항목을 자동으로 추출하고 담당자를 식별하기를 원한다 | 회의 후 실행 사항을 명확히 하기 위해 | M/21 | ✅ |
| UFR-AI-030 | 회의록 개선 | 회의록 작성자 | 프롬프팅을 통해 회의록을 개선하고 재구성하고 싶다 | 회의록을 다양한 형식으로 변환하기 위해 | M/21 | ✅ |
| UFR-AI-040 | 관련 회의록 연결 | 회의록 작성자 | AI가 관련 있는 과거 회의록을 자동으로 찾아 연결해주기를 원한다 | 이전 회의 내용을 쉽게 참조하기 위해 | S/13 | ✅ |
#### UFR-AI-030: 회의록 개선 (차별화 포인트)
**시나리오**: 프롬프팅 기반 회의록 개선
- 회의록이 작성된 상황에서
- "1Page 요약", "핵심 요약", "상세 보고서" 등의 프롬프트를 입력하면
- AI가 해당 형식에 맞춰 회의록을 재구성하여 제공한다
**지원 프롬프트 유형**:
- "1Page 요약": A4 1장 분량의 요약본 생성
- "핵심 요약": 3-5개 핵심 포인트만 추출
- "상세 보고서": 시간순 상세 기록 with 타임스탬프
- "의사결정 중심": 결정 사항과 근거만 정리
- "액션 아이템 중심": Todo와 담당자만 강조
- "경영진 보고용": 임원진에게 보고할 형식으로 재구성
- "커스텀 프롬프트": 사용자 정의 형식
**처리 결과**:
- 개선된 회의록이 생성됨 (새 버전)
- 원본 회의록 링크 유지
- 생성 시간 및 프롬프트 기록
- 다운로드 가능 (PDF, DOCX, MD)
---
### 5. RAG 서비스 (2개) - 차별화 포인트 ✅
| ID | 기능명 | As a | I want | So that | 복잡도 | 차별화 |
|----|--------|------|--------|---------|--------|--------|
| UFR-RAG-010 | 전문용어 감지 | 회의록 작성자 | 전문용어가 자동으로 감지되고 맥락에 맞는 설명을 제공받고 싶다 | 업무 지식이 없어도 회의록을 정확히 작성하기 위해 | S/13 | ✅ |
| UFR-RAG-020 | 맥락 기반 용어 설명 | 회의록 작성자 | 관련 회의록과 업무 이력을 바탕으로 실용적인 설명을 제공받고 싶다 | 전문용어를 맥락에 맞게 이해하기 위해 | S/21 | ✅ |
#### UFR-RAG-020: 맥락 기반 용어 설명 (핵심 차별화)
**시나리오**: 맥락 기반 용어 설명 자동 제공
- 전문용어가 감지된 상황에서
- RAG 시스템이 관련 문서를 검색하면
- 과거 회의록 및 업무 이력에서 맥락에 맞는 실용적인 설명이 생성되어 제공된다
**RAG 검색 수행**:
- 벡터 유사도 검색
- 과거 회의록 검색 (동일 용어 사용 사례)
- 사내 문서 저장소 검색 (위키, 매뉴얼, 보고서)
- 업무 이력 검색 (프로젝트 문서, 이메일 등)
- 관련 문서 추출 (관련도 점수순)
- 최대 5개 문서 선택
**맥락 기반 설명 생성**:
- 간단한 정의 (1-2문장)
- 이 회의에서의 의미 (맥락 기반)
- 관련 프로젝트/이슈 연결
- 과거 논의 요약 (언제, 누가, 어떻게 사용했는지)
- 참조 출처 링크
**처리 결과**:
- 맥락 기반 용어 설명이 생성됨
- 툴팁 또는 사이드 패널로 표시
- 관련 회의록 링크 (최대 3개)
- 사내 문서 링크
**차별화 포인트**: 단순 용어 설명이 아닌, 조직 내 실제 사용 맥락과 이력을 제공
---
### 6. Collaboration 서비스 (3개)
| ID | 기능명 | As a | I want | So that | 복잡도 |
|----|--------|------|--------|---------|--------|
| UFR-COLLAB-010 | 회의록 수정 동기화 | 회의 참석자 | 회의록을 수정하고 실시간으로 다른 참석자와 동기화하고 싶다 | 회의록을 함께 검증하기 위해 | M/34 |
| UFR-COLLAB-020 | 충돌 해결 | 회의 참석자 | 충돌을 감지하고 해결하고 싶다 | 동시 수정 상황에서도 내용을 잃지 않기 위해 | M/21 |
| UFR-COLLAB-030 | 검증 완료 | 회의 참석자 | 주요 섹션을 검증하고 완료 표시를 하고 싶다 | 회의록의 정확성을 보장하기 위해 | M/8 |
---
### 7. Todo 서비스 (2개) - 차별화 포인트 ✅
| ID | 기능명 | As a | I want | So that | 복잡도 | 차별화 |
|----|--------|------|--------|---------|--------|--------|
| UFR-TODO-010 | Todo 할당 | Todo 시스템 | Todo를 실시간으로 할당하고 회의록과 연결하고 싶다 | AI가 추출한 Todo를 담당자에게 전달하기 위해 | M/13 | ✅ |
| UFR-TODO-030 | Todo 완료 처리 | Todo 담당자 | Todo를 완료하고 회의록에 자동 반영하고 싶다 | 완료된 Todo를 처리하고 회의록에 반영하기 위해 | M/8 | ✅ |
#### UFR-TODO-010: Todo 할당 (차별화 포인트)
**시나리오**: Todo 실시간 할당 및 회의록 연결
- AI가 Todo를 추출한 상황에서
- 시스템이 Todo를 등록하고 담당자를 지정하면
- Todo가 실시간으로 할당되고 회의록의 해당 위치와 연결되며 담당자에게 즉시 알림이 발송된다
**회의록 실시간 연결**:
- 회의록 해당 섹션에 Todo 뱃지 표시
- Todo 클릭 시 Todo 상세 정보 표시
- 양방향 연결 (Todo → 회의록, 회의록 → Todo)
**처리 결과**:
- Todo가 할당됨 (Todo ID)
- 담당자 정보
- 마감일
- 회의록 연결 정보 (섹션 ID, 타임스탬프)
- 담당자에게 알림이 발송됨
- 캘린더 등록 완료
**차별화 포인트**: Todo와 회의록의 강력한 연결, 원문 맥락 추적 가능
---
## 복잡도 분류
| 분류 | 개수 | 유저스토리 ID |
|------|------|--------------|
| **Small (S)** | 3개 | UFR-MEET-020, UFR-AI-040, UFR-RAG-010 |
| **Medium (M)** | 17개 | AFR-USER-010, UFR-MEET-010, UFR-MEET-030, UFR-MEET-040, UFR-MEET-050, UFR-MEET-060, UFR-STT-010, UFR-STT-020, UFR-AI-010, UFR-AI-020, UFR-AI-030, UFR-RAG-020, UFR-COLLAB-010, UFR-COLLAB-020, UFR-COLLAB-030, UFR-TODO-010, UFR-TODO-030 |
| **Large (L)** | 0개 | - |
**총 Story Point**:
- Small: 3 × 5 = 15 SP
- Medium: 17개 (8-34점 범위) ≈ 평균 16 SP × 17 = 272 SP
- **합계**: 약 287 SP
---
## 우선순위별 분류
### 높은 우선순위 (Must Have)
- AFR-USER-010 (인증)
- UFR-MEET-010 ~ 060 (회의 전체 플로우)
- UFR-STT-010, 020 (음성 인식)
- UFR-AI-010 (회의록 자동 작성)
- UFR-COLLAB-010 (실시간 동기화)
### 중간 우선순위 (Should Have)
- UFR-AI-020 (Todo 자동 추출) ✅ 차별화
- UFR-RAG-010, 020 (용어 설명) ✅ 차별화
- UFR-TODO-010, 030 (Todo 관리) ✅ 차별화
- UFR-COLLAB-020, 030 (충돌 해결, 검증)
### 낮은 우선순위 (Nice to Have)
- UFR-AI-030 (회의록 개선) ✅ 차별화
- UFR-AI-040 (관련 회의록 연결) ✅ 차별화
---
## 변경 이력
| 버전 | 날짜 | 변경 내용 | 작성자 |
|------|------|-----------|--------|
| 1.0 | 2025-01-20 | 유저스토리를 마크다운 표 형식으로 변환하여 작성 | Claude |
---
**문서 끝**

View File

@ -34,8 +34,6 @@
6. **Collaboration** - 실시간 동기화, 버전 관리, 충돌 해결
7. **Todo** - Todo 할당 및 관리, 진행 상황 추적, 회의록 실시간 연동
8. **Notification** - 알림 발송 및 리마인더 관리
9. **Calendar** - 일정 생성 및 외부 캘린더 연동
10. **Analytics** - 회의 효율성 분석, 패턴 분석, 개선 제안 (신규)
---
@ -43,13 +41,11 @@
```
1. User 서비스
1) 사용자 인증 및 관리
AFR-USER-010: [사용자관리] 시스템 관리자로서 | 나는, 서비스 보안을 위해 | 사용자 인증 및 권한 관리 기능을 원한다.
- 시나리오: 사용자 인증 및 권한 관리
사용자가 로그인을 시도한 상황에서 | 아이디와 비밀번호를 입력하면 | 인증이 완료되고 권한에 따라 서비스에 접근할 수 있다.
- [ ] 사용자 인증 (아이디, 비밀번호)
- [ ] JWT 토큰 기반 인증
- [ ] 사용자 권한 관리 (관리자, 일반 사용자)
1) 사용자 인증 관리
AFR-USER-010: [사용자관리] 시스템 관리자로서 | 나는, 서비스 보안을 위해 | 사용자 인증 기능을 원한다.
- 시나리오: 사용자 인증 관리
사용자가 로그인을 시도한 상황에서 | 사번과 비밀번호를 입력하면 | LDAP 연동을 통해 인증이 완료되고 권한에 따라 서비스에 접근할 수 있다.
- [ ] 사용자 인증 (사번, 비밀번호)
- [ ] 세션 관리
- M/8
@ -94,7 +90,6 @@ UFR-MEET-020: [템플릿선택] 회의록 작성자로서 | 나는, 회의록을
[처리 결과]
- 선택된 템플릿으로 회의록 도구가 준비됨
- 템플릿 ID와 설정 정보가 저장됨
- S/5
@ -106,15 +101,16 @@ UFR-MEET-030: [회의시작] 회의록 작성자로서 | 나는, 회의를 시
[회의 시작 조건]
- 예약된 회의가 존재함
- 회의 시작 시간이 도래함
- 회의 시작 시간 10분 전부터 회의 시작 버튼 활성화
- 회의록 작성자가 시작 권한을 가짐
- 이미 시작된 회의일 경우, 진행중으로 표시
[처리 결과]
- 회의 세션이 생성됨 (세션 ID)
- 음성 녹음 준비 완료
- 참석자 목록 표시
- 회의 시작 시간 기록
- 실시간 회의록 작성 화면 활성화
- 실시간 회의록 주요 항목 추천
- M/8
@ -137,7 +133,10 @@ UFR-MEET-040: [회의종료] 회의록 작성자로서 | 나는, 회의를 종
[처리 결과]
- 회의가 종료됨
- 회의 통계 표시
- 최종 회의록 확정 단계로 이동
- 검증 완료 시 최종 회의록 확정 단계로 이동
[검증 미완료 시]
- 검증이 안된 항목이 있다면 회의록 히스토리 페이지에서 추후 수정 가능
- M/8
@ -167,6 +166,84 @@ UFR-MEET-050: [최종확정] 회의록 작성자로서 | 나는, 회의록을
---
UFR-MEET-045: [회의록상세조회] 회의록 작성자로서 | 나는, 지난 회의록의 상세 정보와 전체 내용을 | 한눈에 확인하고 싶다.
- 시나리오: 회의록 상세 정보 조회
"내 회의록" 메뉴에서 특정 회의록을 클릭하면 | 해당 회의의 기본 정보와 섹션별 상세 내용이 표시되고 | 필요한 경우 수정, 공유, 다운로드 등의 작업을 수행할 수 있다.
[회의 기본 정보 표시]
- 회의 제목
- 회의 일시 (날짜 및 시간)
- 참석자 목록 (역할 구분: 주관자/참석자/불참자)
- 회의 장소 (온라인/오프라인)
- 사용된 템플릿 유형
- 회의록 상태 (작성중/확정완료)
- 작성자 및 최종 수정 시간
[섹션별 상세 내용 표시]
- 각 섹션 구분 표시 (논의사항, 결정사항, Todo, 기타 등)
- 섹션별 검증 상태 표시 (검증완료 섹션은 체크 표시)
- Todo 항목:
- 담당자 이름
- 마감일
- 완료/미완료 상태 (시각적 구분)
- 우선순위 (있는 경우)
- 첨부파일 목록 및 다운로드 링크
[부가 기능]
- 회의록 수정 버튼 (수정 권한이 있는 경우만 표시)
- 회의록 공유 버튼 (공유 설정 화면으로 이동)
- 이전/다음 회의록으로 이동하는 네비게이션
- 뒤로가기 버튼 (회의록 목록으로 복귀)
[처리 결과]
- 모바일/태블릿 환경에서도 가독성 높은 레이아웃
- 긴 내용은 적절한 단락 구분 및 여백 적용
- 섹션별 접기/펼치기 기능 (선택사항)
- 페이지 로딩 시 스크롤 위치는 최상단
[권한별 표시]
- 조회 권한만 있는 경우: 수정 버튼 비활성화
- 수정 권한이 있는 경우: 수정 버튼 활성화
- M/5
---
UFR-MEET-055: [회의록수정] 회의록 작성자로서 | 나는, 검증이 완료되지 않았거나 수정이 필요한 | 지난 회의록을 조회하고 수정하고 싶다.
- 시나리오: 지난 회의록 조회 및 수정
대시보드에서 "내 회의록" 메뉴를 클릭하면 | 작성한 회의록 목록이 표시되고 | 특정 회의록을 선택하여 수정할 수 있다.
[회의록 목록 조회]
- 회의록 상태별 필터링: 전체 / 작성중 / 확정완료
- 정렬 옵션: 최신순 / 회의일시순 / 제목순
- 검색 기능: 회의 제목, 참석자, 키워드로 검색
- 목록 표시 정보:
- 회의 제목
- 회의 일시
- 회의록 상태 (작성중/확정완료)
- 마지막 수정 시간
- 검증 완료율 (작성중인 경우)
[회의록 수정]
- 회의록 선택 시 상세 화면으로 이동
- 상태에 따른 수정 가능 범위:
- 작성중: 모든 섹션 수정 가능
- 확인완료: 회의록 생성자에게 수정 권한 승인요청
- 수정 중 자동 저장 (30초 간격)
- 수정 이력 관리 (누가, 언제, 무엇을 수정했는지)
[처리 결과]
- 수정 내용 즉시 반영
- 수정 시간 업데이트
- 확정완료 상태였던 경우 → 작성중 상태로 변경
[권한 제어]
- 본인이 작성한 회의록만 수정 가능
- 검증완료 후 검증된 섹션 잠금 기능은 회의록 생성자만 가능
- 모든 섹션이 검증완료일경우 회의록 상태를 확정완료로 변경
- M/13
3) 회의록 공유
UFR-MEET-060: [회의록공유] 회의록 작성자로서 | 나는, 회의 내용을 참석자들과 공유하기 위해 | 최종 회의록을 공유하고 싶다.
- 시나리오: 회의록 공유
@ -175,13 +252,13 @@ UFR-MEET-060: [회의록공유] 회의록 작성자로서 | 나는, 회의 내
[공유 설정]
- 공유 대상: 참석자 전체 (기본) / 특정 참석자 선택
- 공유 권한: 읽기 전용 / 댓글 가능 / 편집 가능
- 공유 방식: 이메일 / 슬랙 / 링크 복사
- 공유 방식: 이메일 / 링크 복사
[처리 결과]
- 공유 링크 생성 (고유 URL)
- 참석자에게 이메일/슬랙 알림 발송
- 참석자에게 이메일 알림 발송
- 공유 시간 기록
- 다음 회의 일정이 언급된 경우 캘린더에 자동 등록 (UFR-CAL-010 연동)
- 다음 회의 일정이 언급된 경우 캘린더에 자동 등록
[공유 링크 보안]
- 링크 유효 기간 설정 (선택)
@ -191,61 +268,6 @@ UFR-MEET-060: [회의록공유] 회의록 작성자로서 | 나는, 회의 내
---
UFR-MEET-070: [회의록대시보드] 회의록 작성자로서 | 나는, 회의 결과를 한눈에 파악하기 위해 | 회의록별 대시보드를 통해 핵심 정보를 조회하고 싶다.
- 시나리오: 회의록별 대시보드 조회
회의록이 확정된 상황에서 | 대시보드 탭을 클릭하면 | 핵심내용, 결정사항, Todo 진행상황, 참고자료가 요약되어 표시된다.
[대시보드 구성 요소]
1. 핵심내용
- AI가 추출한 회의의 핵심 논의사항 (3-5개 포인트)
- 주요 키워드 태그
- 회의 통계 (참석자 수, 회의 시간, 발언 횟수)
2. 결정사항
- 회의에서 결정된 사항 목록
- 각 결정사항별 결정자, 결정 시간
- 결정 근거 및 배경 (간략)
3. Todo 진행상황
- 할당된 Todo 목록 (UFR-TODO-010 연동)
- 각 Todo별 진행률 (0-100%) 표시
- 상태별 필터링 (시작 전/진행 중/완료)
- 담당자별 그룹핑
- 마감일 임박 알림 (3일 이내)
4. 참고자료
- 관련 회의록 (UFR-AI-040 연동)
- 이전 회의록 링크 (시간순)
- 관련도 점수 표시
- 업무 이력 (UFR-RAG-030 연동)
- 관련 프로젝트 문서
- 이슈 트래커 링크
- 사내 위키 페이지
[처리 결과]
- 대시보드가 생성됨
- 각 섹션별 데이터 로딩 상태 표시
- 실시간 업데이트 (Todo 진행상황)
- 섹션별 상세보기 링크 제공
[데이터 업데이트]
- Todo 진행상황: 실시간 업데이트 (UFR-TODO-020 연동)
- 참고자료: 매일 자동 업데이트
- 핵심내용/결정사항: 회의록 수정 시 재생성
[Policy/Rule]
- 대시보드는 회의록 확정 후 자동 생성
- Todo 진행상황은 실시간 반영
- 모바일 최적화 (반응형 디자인)
[비고]
- **차별화 포인트**: 회의 결과를 한눈에 파악할 수 있는 통합 뷰 제공
- 관련 정보를 한 화면에서 접근 가능하여 업무 효율성 향상
- M/21
---
3. STT 서비스 (기본 기능)
1) 음성 인식 및 변환
UFR-STT-010: [음성녹음인식] 회의 참석자로서 | 나는, 발언 내용이 자동으로 기록되기 위해 | 음성이 실시간으로 녹음되고 인식되기를 원한다.
@ -255,10 +277,10 @@ UFR-STT-010: [음성녹음인식] 회의 참석자로서 | 나는, 발언 내용
[음성 녹음 처리]
- 오디오 스트림 실시간 캡처
- 회의 ID와 연결
- 음성 데이터 저장 (클라우드 스토리지)
- 음성 데이터 저장 (Azure 스토리지)
[발언 인식 처리]
- AI 음성인식 엔진 연동 (Whisper, Google STT 등)
- AI 음성인식 엔진 연동 (Azure Speech 등)
- 화자 자동 식별
- 참석자 목록 매칭
- 음성 특징 분석
@ -378,7 +400,7 @@ UFR-AI-020: [Todo자동추출] 회의록 작성자로서 | 나는, 회의 후
[담당자 식별 실패 시]
- 미지정 상태로 Todo 생성
- 회의 주최자에게 수동 할당 요청 알림
- 수동 할당 요청 알림
- M/21
@ -413,7 +435,6 @@ UFR-AI-030: [회의록개선] 회의록 작성자로서 | 나는, 회의록을
- 개선된 회의록이 생성됨 (새 버전)
- 원본 회의록 링크 유지
- 생성 시간 및 프롬프트 기록
- 다운로드 가능 (PDF, DOCX, MD)
[Policy/Rule]
- 원본 회의록은 항상 보존
@ -425,9 +446,9 @@ UFR-AI-030: [회의록개선] 회의록 작성자로서 | 나는, 회의록을
---
4) 관련 회의록 자동 연결 (신규, 차별화 포인트)
UFR-AI-040: [관련회의록연결] 회의록 작성자로서 | 나는, 이전 회의 내용을 쉽게 참조하기 위해 | AI가 관련 있는 과거 회의록을 자동으로 찾아 연결해주기를 원한다.
UFR-AI-040: [관련회의록연결] 회의록 작성자로서 | 나는, 이전 회의 내용을 쉽게 참조하기 위해 | AI가 같은 폴더 내 관련 있는 과거 회의록을 자동으로 찾아 연결해주기를 원한다.
- 시나리오: 관련 회의록 자동 연결
회의록이 작성되는 상황에서 | AI가 회의 주제와 내용을 분석하면 | 유사한 주제의 과거 회의록을 찾아 자동으로 연결한다.
회의록이 작성되는 상황에서 | AI가 회의 주제와 내용을 분석하면 | 같은 폴더 내 유사한 주제의 과거 회의록을 찾아 자동으로 연결한다.
[AI 분석 과정]
- 현재 회의록 주제 및 키워드 추출
@ -435,7 +456,7 @@ UFR-AI-040: [관련회의록연결] 회의록 작성자로서 | 나는, 이전
- 과거 회의록 DB에서 검색
- 주제 유사도 계산
- 관련도 점수 계산 (0-100%)
- 상위 5개 회의록 선정
- 같은 폴더 내 상위 5개 회의록 선정
[연결 기준]
- 주제 유사도 70% 이상
@ -544,59 +565,11 @@ UFR-RAG-020: [맥락기반용어설명] 회의록 작성자로서 | 나는, 전
---
2) 관련 문서 자동 연결 (신규, 차별화 포인트)
UFR-RAG-030: [관련문서연결] 회의록 작성자로서 | 나는, 회의 내용을 더 잘 이해하기 위해 | 관련된 사내 문서와 업무 이력이 자동으로 연결되기를 원한다.
- 시나리오: 관련 문서 자동 연결
회의록이 작성되는 상황에서 | RAG 시스템이 주제와 키워드를 분석하면 | 관련된 사내 문서와 업무 이력이 자동으로 검색되어 연결된다.
[문서 검색 범위]
- 과거 회의록
- 프로젝트 문서
- 위키 페이지
- 이메일 스레드
- 보고서 및 기획서
- 이슈 트래커 (Jira, Asana 등)
[RAG 검색 수행]
- 회의 주제 및 키워드 추출
- 벡터 유사도 검색
- 관련도 점수 계산 (0-100%)
- 문서 타입별 상위 3개 선정
[연결 기준]
- 주제 유사도 70% 이상
- 키워드 3개 이상 일치
- 최근 3개월 이내 문서 우선
- 동일 프로젝트/팀 문서 가중치
[처리 결과]
- 관련 문서 목록 생성
- 각 문서별 정보
- 제목
- 문서 타입
- 작성자
- 작성일
- 관련도 점수
- 핵심 내용 요약 (2-3줄)
- 회의록 하단에 "관련 문서" 섹션 자동 추가
- 클릭 시 해당 문서로 이동 또는 미리보기
[Policy/Rule]
- 관련도 70% 이상만 자동 연결
- 문서 타입별 최대 3개까지 표시
[비고]
- **차별화 포인트**: 회의록만 보는 것이 아니라 관련 업무 이력 전체를 통합 제공
- S/13
---
6. Collaboration 서비스
1) 실시간 협업
UFR-COLLAB-010: [회의록수정동기화] 회의 참석자로서 | 나는, 회의록을 함께 검증하기 위해 | 회의록을 수정하고 실시간으로 다른 참석자와 동기화하고 싶다.
- 시나리오: 회의록 실시간 수정 및 동기화
회의록이 작성된 상황에서 | 참석자가 회의록 내용을 수정하면 | 수정 사항이 버전 관리되고 웹소켓을 통해 모든 참석자에게 즉시 동기화된다.
회의록 초안이 작성된 상황에서 | 참석자가 회의록 내용을 수정하면 | 수정 사항이 버전 관리되고 웹소켓을 통해 모든 참석자에게 즉시 동기화된다.
[회의록 수정 처리]
- 수정 내용 검증
@ -615,7 +588,7 @@ UFR-COLLAB-010: [회의록수정동기화] 회의 참석자로서 | 나는, 회
- 웹소켓을 통해 수정 델타 전송
- 전체 내용이 아닌 변경 부분만 전송 (효율성)
- 모든 참석자 화면에 실시간 반영
- 수정자 표시 (아바타, 이름)
- 수정자 표시 (이름)
- 수정 영역 하이라이트 (3초간)
[처리 결과]
@ -623,6 +596,7 @@ UFR-COLLAB-010: [회의록수정동기화] 회의 참석자로서 | 나는, 회
- 수정 사항이 동기화됨
- 동기화 시간
- 영향받은 참석자 목록
- 수정 완료될 때마다 수정된 내용이 메일로 알림이 발송된다. (알림 여부 설정 가능)
[Policy/Rule]
- 회의록 수정 시 웹소켓을 통해 모든 참석자에게 즉시 동기화
@ -681,9 +655,10 @@ UFR-COLLAB-030: [검증완료] 회의 참석자로서 | 나는, 회의록의 정
- 미검증 → 검증 중 → 검증 완료
[섹션 잠금 기능]
- 회의 생성자만 가능
- 주요 섹션 검증 완료 시 잠금 가능 (선택)
- 잠긴 섹션은 추가 수정 불가
- 잠금 해제는 검증자 또는 회의 주최자만 가능
- 회의 생성자가 잠그면 검증 완료로 표시
[처리 결과]
- 검증이 완료됨
@ -692,6 +667,7 @@ UFR-COLLAB-030: [검증완료] 회의 참석자로서 | 나는, 회의록의 정
- 완료 시간
- 검증 완료 상태 실시간 동기화
- 검증 배지 표시 (체크 아이콘)
- 검증 완료 시 전체 메일로 알림이 발송된다.
[Policy/Rule]
- 주요 섹션 검증 완료 시 해당 섹션 잠금 가능
@ -714,7 +690,6 @@ UFR-TODO-010: [Todo할당] Todo 시스템으로서 | 나는, AI가 추출한 Tod
- 마감일 (언급된 경우 자동 설정, 없으면 수동 설정)
- 우선순위 (높음/보통/낮음)
- 관련 회의록 링크 (섹션 위치 포함)
- 원문 발언 링크 (타임스탬프 포함)
[회의록 실시간 연결]
- 회의록 해당 섹션에 Todo 뱃지 표시
@ -724,12 +699,10 @@ UFR-TODO-010: [Todo할당] Todo 시스템으로서 | 나는, AI가 추출한 Tod
[알림 발송]
- 담당자에게 즉시 알림
- 이메일
- 슬랙 (연동된 경우)
- 알림 내용
- Todo 내용
- 마감일
- 회의록 링크 (해당 섹션으로 바로 이동)
- 원문 발언 링크
[캘린더 연동]
- 마감일이 있는 경우 캘린더에 자동 등록
@ -755,53 +728,9 @@ UFR-TODO-010: [Todo할당] Todo 시스템으로서 | 나는, AI가 추출한 Tod
---
UFR-TODO-020: [Todo진행상황업데이트] Todo 담당자로서 | 나는, Todo 진행 상황을 공유하고 회의록에 반영하기 위해 | 진행률을 업데이트하고 상태를 변경하고 싶다.
- 시나리오: Todo 진행 상황 업데이트 및 회의록 자동 반영
할당된 Todo가 있는 상황에서 | 담당자가 진행률과 상태를 입력하면 | 진행 상황이 저장되고 연결된 회의록에 실시간으로 반영되며 회의 주최자에게 알림이 발송된다.
[진행 상황 입력]
- 진행률: 0-100% (슬라이더 또는 직접 입력)
- 상태: 시작 전 / 진행 중 / 완료
- 메모: 진행 상황 설명 (선택)
[진행 상황 저장]
- 업데이트 시간 기록
- 진행률 히스토리 저장
- 상태 변경 이력 저장
[회의록 실시간 반영]
- 연결된 회의록의 Todo 섹션 자동 업데이트
- 진행률 표시 (프로그레스 바)
- 상태 배지 업데이트 (시작 전/진행 중/완료)
- 마지막 업데이트 시간 표시
- 담당자 메모 표시 (있는 경우)
[알림 발송]
- 회의 주최자에게 진행 상황 알림
- 진행률이 50%, 100%에 도달하면 자동 알림
[처리 결과]
- Todo 진행 상황이 업데이트됨
- 업데이트 시간
- 진행률 (%)
- 상태 (시작 전/진행 중/완료)
- 회의록에 진행 상황이 실시간 반영됨
- 반영 시간 기록
[Policy/Rule]
- Todo 진행 상황 업데이트 시 회의록에 즉시 반영
- 진행률 50%, 100% 도달 시 자동 알림
[비고]
- **차별화 포인트**: Todo 진행 상황이 회의록에 실시간 반영되어 추적 용이
- M/5
---
UFR-TODO-030: [Todo완료처리] Todo 담당자로서 | 나는, 완료된 Todo를 처리하고 회의록에 반영하기 위해 | Todo를 완료하고 회의록에 자동 반영하고 싶다.
- 시나리오: Todo 완료 처리 및 회의록 자동 반영
Todo 작업이 완료된 상황에서 | 담당자가 완료 버튼을 클릭하면 | Todo가 완료 상태로 변경되고 연결된 회의록에 완료 상태가 실시간으로 반영되며 회의 주최자에게 알림이 발송된다.
Todo 작업이 완료된 상황에서 | 담당자가 완료 버튼을 클릭하면 | Todo가 완료 상태로 변경되고 연결된 회의록에 완료 상태가 실시간으로 반영된다.
[완료 처리]
- 완료 시간 자동 기록
@ -816,7 +745,7 @@ UFR-TODO-030: [Todo완료처리] Todo 담당자로서 | 나는, 완료된 Todo
- 완료자 정보 표시
[알림 발송]
- 회의 주최자에게 완료 알림
- 완료 알림
- 모든 Todo 완료 시 전체 완료 알림
[처리 결과]
@ -829,234 +758,11 @@ UFR-TODO-030: [Todo완료처리] Todo 담당자로서 | 나는, 완료된 Todo
[Policy/Rule]
- Todo 완료 시 회의록에 완료 상태 즉시 반영
- 모든 Todo 완료 시 회의 주최자에게 완료 알림
- 모든 Todo 완료 시 완료 알림
[비고]
- **차별화 포인트**: Todo 완료가 회의록에 실시간 반영되어 회의 결과 추적 용이
- M/8
---
2) 회의 중 실시간 Todo 생성 (신규, 차별화 포인트)
UFR-TODO-040: [실시간Todo생성] 회의 참석자로서 | 나는, 회의 중 논의된 액션 아이템을 즉시 기록하기 위해 | 회의 진행 중 실시간으로 Todo를 생성하고 회의록과 연결하고 싶다.
- 시나리오: 회의 중 실시간 Todo 생성
회의가 진행 중인 상황에서 | 참석자가 "Todo 추가" 버튼을 클릭하고 내용을 입력하면 | Todo가 즉시 생성되고 현재 회의록 위치와 연결되며 타임스탬프가 기록된다.
[실시간 Todo 생성]
- Todo 내용 입력 (필수)
- 담당자 선택 (필수)
- 마감일 설정 (선택)
- 우선순위 설정 (선택)
- 현재 회의 시간 자동 기록 (타임스탬프)
[회의록 자동 연결]
- 현재 작성 중인 회의록 섹션과 자동 연결
- Todo 생성 시점의 타임스탬프 저장
- 회의록에 Todo 뱃지 자동 추가
- 음성 녹음 링크 연결 (해당 시간대)
[실시간 동기화]
- 모든 참석자 화면에 즉시 표시
- Todo 추가 알림 (인앱)
- 담당자에게 즉시 알림 발송
[처리 결과]
- Todo가 생성됨 (Todo ID)
- Todo 내용, 담당자, 마감일
- 회의록 연결 정보 (섹션 ID, 타임스탬프)
- 생성 시간 및 생성자
- 모든 참석자에게 동기화됨
[Policy/Rule]
- 회의 중 생성된 Todo는 회의록과 자동 연결
- 담당자에게 즉시 알림 발송
[비고]
- **차별화 포인트**: 회의 중 실시간 Todo 생성으로 액션 아이템 누락 방지
- S/8
---
8. Notification 서비스
1) 알림 관리
UFR-NOTI-010: [알림리마인더] 회의 참석자로서 | 나는, 중요한 일정을 놓치지 않기 위해 | 회의 및 Todo 관련 알림과 리마인더를 받고 싶다.
- 시나리오 1: 회의 알림
회의가 예약된 상황에서 | 회의 시작 30분 전이 되면 | 참석자에게 리마인더가 자동 발송된다.
[회의 알림 유형]
- 회의 초대: 회의 예약 시
- 회의 시작 리마인더: 30분 전
- 회의록 공유: 회의 종료 후
- 시나리오 2: Todo 알림
Todo가 할당된 상황에서 | 마감일 3일 전이 되면 | 담당자에게 리마인더가 자동 발송된다.
[Todo 알림 유형]
- Todo 할당: 할당 즉시
- 마감일 3일 전 리마인더
- 마감일 당일 리마인더
- 마감일 경과 긴급 알림 (미완료 시)
- Todo 완료: 완료 시
[알림 채널]
- 이메일 (기본)
- 슬랙 (연동 시)
- 인앱 알림
[알림 설정]
- 알림 채널 선택
- 알림 시간 설정
- 알림 끄기/켜기
[처리 결과]
- 알림이 발송됨 (알림 ID)
- 알림 대상 (이메일 주소, 슬랙 ID)
- 알림 내용
- 발송 시간
- 발송 채널
- 발송 상태 (성공/실패)
[Policy/Rule]
- 회의 시작 30분 전 리마인더 자동 발송
- 마감일 3일 전 자동 리마인더 발송
- 마감일 당일 미완료 시 긴급 알림 발송
- M/13
---
9. Calendar 서비스
1) 일정 관리
UFR-CAL-010: [일정연동] 회의록 작성자로서 | 나는, 일정을 통합 관리하기 위해 | 회의 및 다음 회의 일정을 외부 캘린더에 자동으로 연동하고 싶다.
- 시나리오 1: 회의 일정 자동 등록
회의가 예약된 상황에서 | 시스템이 일정 동기화를 요청하면 | 회의 일정이 Google Calendar, Outlook 등 외부 캘린더에 자동으로 등록된다.
[일정 등록 정보]
- 회의 제목
- 날짜 및 시간
- 장소
- 참석자 목록
- 회의록 링크 (메모)
- 시나리오 2: 다음 회의 일정 연동
회의록에서 다음 회의 일정이 언급된 상황에서 | 시스템이 자동으로 감지하면 | 다음 회의 일정이 캘린더에 자동으로 생성된다.
[자동 감지 키워드]
- "다음 회의: ~"
- "~에 다시 모이기로 함"
- "후속 회의 일정: ~"
[처리 결과]
- 일정이 캘린더에 연동됨 (일정 ID)
- 연동 상태 (성공/실패)
- 캘린더 종류 (Google Calendar, Outlook)
- 연동 시간
[지원 캘린더]
- Google Calendar
- Microsoft Outlook
- Apple Calendar
[Policy/Rule]
- 다음 회의 일정이 언급되면 자동으로 캘린더에 등록
- S/13
---
10. Analytics 서비스 (신규, 차별화 포인트)
1) 회의 효율성 분석
UFR-ANAL-010: [회의효율성분석] 회의 주최자로서 | 나는, 회의를 개선하기 위해 | 회의 효율성을 분석하고 개선 제안을 받고 싶다.
- 시나리오: 회의 효율성 분석 및 개선 제안
회의가 종료된 상황에서 | Analytics 시스템이 회의 데이터를 분석하면 | 회의 효율성 점수와 구체적인 개선 제안이 제공된다.
[분석 지표]
- 회의 시간 준수율 (예정 시간 대비 실제 시간)
- 참석자 참여도 (발언 분포, 침묵 시간)
- 안건 소화율 (계획된 안건 대비 논의된 안건)
- 의사결정 효율성 (결정 사항 수 / 회의 시간)
- Todo 생성률 (액션 아이템 명확성)
[AI 분석 과정]
- 회의 통계 데이터 수집
- 과거 유사 회의와 비교
- 업계 벤치마크 대조
- 비효율 패턴 감지
- 너무 긴 회의 (2시간 이상)
- 참여도 불균형 (1명이 50% 이상 발언)
- 안건 없이 진행
- 결정 사항 없음
- Todo 미생성
[개선 제안 생성]
- 구체적인 개선 사항 제시
- "회의 시간을 30분 단축 권장"
- "참석자 A의 발언 시간이 과도합니다. 타임박스 적용 권장"
- "안건을 사전에 공유하여 준비도를 높이세요"
- "결정 사항이 없습니다. 회의 목적을 재검토하세요"
[처리 결과]
- 회의 효율성 점수 (0-100점)
- 각 지표별 점수 및 벤치마크 비교
- 개선 제안 리스트 (우선순위순)
- 다음 회의 시 적용할 액션 아이템
[Policy/Rule]
- 모든 회의 종료 시 자동 분석
- 효율성 점수 70점 미만 시 개선 알림
[비고]
- **차별화 포인트**: 회의 효율성을 정량적으로 측정하고 실질적 개선 제안 제공
- M/21
---
2) 회의 패턴 분석 및 안건 추천 (신규, 차별화 포인트)
UFR-ANAL-020: [회의패턴분석] 회의 주최자로서 | 나는, 더 나은 회의를 준비하기 위해 | 과거 회의 패턴을 분석하고 안건을 추천받고 싶다.
- 시나리오: 회의 패턴 분석 및 안건 추천
새로운 회의를 예약하는 상황에서 | Analytics 시스템이 과거 유사 회의를 분석하면 | 회의 패턴 인사이트와 안건 추천이 제공된다.
[패턴 분석]
- 회의 유형 분류 (주간 회의, 프로젝트 회의, 의사결정 회의 등)
- 주기성 분석 (주간, 격주, 월간)
- 참석자 패턴 (핵심 멤버, 선택 멤버)
- 주요 논의 주제 추출
- 평균 회의 시간 및 최적 시간대
[안건 추천]
- 과거 회의록 분석
- 미해결 이슈 추출
- 후속 논의 필요 사항 식별
- 주기적 확인 사항 (KPI, 진행 상황)
- 관련 프로젝트/업무 이력 검토
- 추천 안건 생성
- 안건 제목
- 논의 배경 (과거 회의록 링크)
- 예상 소요 시간
[최적 회의 구성 제안]
- 추천 참석자 (과거 패턴 기반)
- 추천 회의 시간 (참석자 캘린더 분석)
- 추천 회의 길이 (안건 수 기반)
[처리 결과]
- 회의 패턴 인사이트
- 추천 안건 리스트 (최대 5개)
- 최적 회의 구성 제안
- 과거 유사 회의 링크
[Policy/Rule]
- 회의 예약 시 자동으로 패턴 분석 및 추천 제공
- 사용자가 수락/거부 가능
[비고]
- **차별화 포인트**: 과거 회의 데이터를 활용한 지능형 회의 준비 지원
- M/13
---
```
---

View File

@ -1,976 +0,0 @@
# 맥락기반 용어설명 구현방안
## 문서 정보
- **작성일**: 2025-01-20
- **작성자**: AI Specialist 박서연, Backend Developer 이준호/이동욱, Architect 홍길동
- **버전**: 2.0 (하이브리드형 - 단계별 확장 방식)
- **상태**: 최종 승인
---
## 목차
1. [개요](#개요)
2. [아키텍처 설계](#아키텍처-설계)
3. [데이터 수집 및 정제](#데이터-수집-및-정제)
4. [벡터라이징 전략](#벡터라이징-전략)
5. [Claude API 호출 구조](#claude-api-호출-구조)
6. [단계별 구현 로드맵](#단계별-구현-로드맵)
7. [성능 및 비용 최적화](#성능-및-비용-최적화)
8. [품질 검증 기준](#품질-검증-기준)
9. [운영 및 모니터링](#운영-및-모니터링)
---
## 개요
### 목적
회의록 작성자가 업무 지식이 없어도, AI가 **맥락에 맞는 실용적인 용어 설명**을 자동으로 제공하여 정확한 회의록 작성을 지원합니다.
### 핵심 차별화 포인트
- ❌ 단순 용어 정의 (Wikipedia 스타일)
- ✅ **조직 내 실제 사용 맥락** 제공
- ✅ **관련 회의록 및 프로젝트 연결**
- ✅ **과거 논의 요약** (언제, 누가, 어떻게 사용했는지)
### 관련 유저스토리
- **UFR-RAG-010**: 전문용어 자동 감지
- **UFR-RAG-020**: 맥락 기반 용어 설명 생성
---
## 아키텍처 설계
### 전체 아키텍처 (최종 목표)
```
┌─────────────────────────────────────────────────────────┐
│ 회의록 작성 중 │
│ (전문용어 "RAG" 감지) │
└──────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ RAG 서비스 (Node.js) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 1. 용어 감지 엔진 │ │
│ │ - 용어 사전 매칭 (Trie 자료구조) │ │
│ │ - 신뢰도 계산 (0-100%) │ │
│ └───────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 2. Redis 캐시 조회 │ │
│ │ Key: term:{용어명}:{회의ID} │ │
│ │ TTL: 자주 쓰이는 용어 7일, 드문 용어 1일 │ │
│ └───────────────────────────────────────────────────┘ │
│ ▼ (캐시 미스) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 3. 벡터 검색 (Pinecone) │ │
│ │ - Query Embedding (OpenAI text-embedding-3) │ │
│ │ - 하이브리드 검색 (벡터 + 키워드) │ │
│ │ - Top 5 관련 문서 추출 │ │
│ └───────────────────────────────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 4. Claude API 호출 │ │
│ │ - 프롬프트: System + User + Few-shot │ │
│ │ - 응답: JSON {definition, context, related} │ │
│ └───────────────────────────────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 5. 응답 캐싱 (Redis) │ │
│ │ - 다음 요청 시 즉시 반환 │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 사용자에게 설명 표시 │
│ - 간단한 정의 (1-2문장) │
│ - 맥락 기반 설명 │
│ - 관련 회의록 링크 (최대 3개) │
│ - 과거 사용 사례 │
└─────────────────────────────────────────────────────────┘
```
### 기술 스택
| 계층 | 기술 | 선택 이유 |
|------|------|-----------|
| **임베딩 모델** | OpenAI text-embedding-3-large | 높은 정확도 (1536 차원), 안정적 API |
| **벡터 DB** | Pinecone | 관리형 서비스, 빠른 검색, Kubernetes 호환 |
| **LLM** | Claude 3.5 Sonnet | 긴 컨텍스트, 한국어 성능 우수, JSON 응답 안정적 |
| **캐시** | Redis | 빠른 응답, TTL 지원, 분산 캐시 가능 |
| **메시지 큐** | RabbitMQ | 배치 작업 비동기 처리 |
| **오케스트레이션** | Kubernetes | 스케일링, 배포 자동화 |
---
## 데이터 수집 및 정제
### 1. 데이터 수집 범위
#### Phase 1 (2주): 회의록만
```
회의록 DB (Meeting 서비스)
├─ meeting_id
├─ title
├─ content (Markdown)
├─ participants
├─ date
└─ project_id
```
#### Phase 2 (4주): 위키 추가
```
사내 위키 (Confluence, Notion 등)
├─ page_id
├─ title
├─ content
├─ author
├─ last_updated
└─ tags
```
#### Phase 3 (6주): 프로젝트 문서 + 이메일
```
프로젝트 문서 (Google Drive, SharePoint)
├─ doc_id
├─ title
├─ content
├─ project_id
└─ created_at
이메일 (Outlook, Gmail)
├─ email_id
├─ subject
├─ body (HTML → Plain Text 변환)
├─ sender
└─ date
```
### 2. 데이터 정제 파이프라인
```mermaid
graph LR
A[원본 수집] --> B[전처리]
B --> C[메타데이터 추가]
C --> D[벡터화]
D --> E[Pinecone 저장]
B --> B1[불용어 제거]
B --> B2[토큰화]
B --> B3[정규화]
C --> C1[날짜]
C --> C2[참석자/작성자]
C --> C3[프로젝트]
C --> C4[부서]
```
#### 전처리 상세
**1) 불용어 제거**
```python
STOPWORDS = [
'그', '저', '것', '수', '등', '들', '및', '때문', '위해', '통해',
'하지만', '그러나', '따라서', '또한', '즉', '예를 들어'
]
def remove_stopwords(text):
tokens = text.split()
return ' '.join([t for t in tokens if t not in STOPWORDS])
```
**2) 토큰화 (한국어)**
```python
from konlpy.tag import Okt
okt = Okt()
def tokenize_korean(text):
return okt.morphs(text, stem=True)
```
**3) 정규화**
```python
import re
def normalize(text):
# 이메일 제거
text = re.sub(r'\S+@\S+', '[EMAIL]', text)
# URL 제거
text = re.sub(r'http\S+', '[URL]', text)
# 특수문자 제거 (단, -_ 유지)
text = re.sub(r'[^\w\s-_]', '', text)
# 공백 정리
text = re.sub(r'\s+', ' ', text)
return text.strip()
```
### 3. 메타데이터 설계
```json
{
"id": "doc_12345",
"content": "RAG 시스템은 Retrieval-Augmented Generation의 약자로...",
"metadata": {
"source": "meeting", // meeting | wiki | doc | email
"title": "프로젝트 회의",
"date": "2025-01-20T14:00:00Z",
"participants": ["김민준", "박서연", "이준호"],
"project_id": "proj_001",
"project_name": "회의록 시스템",
"department": "개발팀",
"tags": ["RAG", "AI", "회의록"],
"language": "ko"
}
}
```
---
## 벡터라이징 전략
### 1. Chunking 전략
**목표**: 회의록/문서를 의미 있는 단위로 분할하여 검색 정확도 향상
```python
def chunk_text(text, chunk_size=500, overlap=50):
"""
텍스트를 chunk로 분할
Args:
text: 원본 텍스트
chunk_size: 청크 크기 (토큰 수)
overlap: 청크 간 중복 크기
Returns:
List[str]: 청크 리스트
"""
tokens = tokenize_korean(text)
chunks = []
for i in range(0, len(tokens), chunk_size - overlap):
chunk = tokens[i:i + chunk_size]
chunks.append(' '.join(chunk))
return chunks
```
**Chunking 전략 비교**
| 방식 | 크기 | Overlap | 장점 | 단점 |
|------|------|---------|------|------|
| **고정 크기** | 500 토큰 | 50 토큰 | 단순, 빠름 | 문맥 끊김 가능 |
| **문단 기반** | 가변 | 0 | 자연스러운 구분 | 크기 불균등 |
| **문장 기반** | 가변 | 1 문장 | 의미 보존 | 너무 작을 수 있음 |
| **하이브리드** | 500 토큰 | 50 토큰 + 문단 경계 | 균형잡힘 | 복잡함 |
**선택**: **하이브리드 방식** (Phase 2 이후 적용)
### 2. Embedding 생성
```python
import openai
def generate_embedding(text, model="text-embedding-3-large"):
"""
OpenAI API로 임베딩 생성
Returns:
List[float]: 1536 차원 벡터
"""
response = openai.embeddings.create(
input=text,
model=model
)
return response.data[0].embedding
```
**비용 계산**:
- text-embedding-3-large: $0.00013 / 1K tokens
- 예상 월 비용: 500 회의록 × 2K tokens × $0.00013 = $0.13
### 3. Pinecone 저장
```python
import pinecone
# 초기화
pinecone.init(api_key="YOUR_API_KEY", environment="us-west1-gcp")
index = pinecone.Index("meeting-rag")
def upsert_to_pinecone(doc_id, embedding, metadata):
"""
Pinecone에 벡터 저장
"""
index.upsert(vectors=[{
"id": doc_id,
"values": embedding,
"metadata": metadata
}])
```
**Pinecone 설정**:
- Index: meeting-rag
- Dimension: 1536
- Metric: cosine
- Replicas: 1 (Phase 1), 2 (Phase 3, HA)
- Pods: p1.x1 (Phase 1), p1.x2 (Phase 2+)
---
## Claude API 호출 구조
### 1. 프롬프트 설계
#### System Prompt
```
당신은 조직 내 전문용어를 쉽게 설명하는 전문가입니다.
- 사내 회의록, 위키, 프로젝트 문서를 기반으로 실용적인 설명을 제공합니다.
- 단순 정의가 아닌, 조직에서 실제로 어떻게 사용되는지 맥락을 포함합니다.
- 과거 논의 내용을 요약하여 제공합니다.
응답 형식은 반드시 JSON으로 작성하세요:
{
"definition": "간단한 정의 (1-2문장)",
"context": "이 회의에서의 의미 (맥락 기반 설명)",
"usage_examples": ["실제 사용 사례 1", "사용 사례 2"],
"related_projects": ["관련 프로젝트 1", "프로젝트 2"],
"past_discussions": [
{"date": "2025-01-15", "meeting": "프로젝트 회의", "summary": "RAG 시스템 도입 결정"}
],
"references": ["doc_id_1", "doc_id_2", "doc_id_3"]
}
```
#### User Prompt (Few-shot Learning)
```
아래는 검색된 관련 문서들입니다:
---
문서 1 (회의록, 2025-01-15):
제목: 프로젝트 회의
내용: RAG 시스템을 도입하기로 결정했습니다. Retrieval-Augmented Generation은 문서 검색과 생성을 결합한 AI 기술입니다...
문서 2 (위키, 2025-01-10):
제목: AI 기술 가이드
내용: RAG는 벡터 DB를 활용하여 관련 문서를 찾고, LLM이 이를 기반으로 답변을 생성하는 방식입니다...
문서 3 (프로젝트 문서, 2025-01-05):
제목: 회의록 시스템 설계서
내용: 맥락 기반 용어 설명 기능에 RAG 시스템을 적용합니다...
---
현재 회의 맥락:
- 회의: "주간 스크럼"
- 날짜: 2025-01-20
- 참석자: 김민준, 박서연, 이준호
- 프로젝트: "회의록 시스템"
용어: "RAG"
위 정보를 바탕으로 "RAG"에 대한 설명을 JSON 형식으로 작성해주세요.
예시:
{
"definition": "Retrieval-Augmented Generation의 약자로, 문서 검색과 AI 생성을 결합한 기술입니다.",
"context": "우리 팀은 회의록 시스템에 RAG를 적용하여 과거 회의록과 사내 문서를 검색하고, 맥락에 맞는 용어 설명을 자동 생성합니다.",
"usage_examples": [
"회의 중 전문용어가 나오면 RAG 시스템이 관련 문서를 찾아 설명을 제공합니다",
"신입사원도 업무 지식 없이 정확한 회의록을 작성할 수 있습니다"
],
"related_projects": ["회의록 시스템", "AI 자동화 프로젝트"],
"past_discussions": [
{"date": "2025-01-15", "meeting": "프로젝트 회의", "summary": "RAG 시스템 도입 결정"},
{"date": "2025-01-10", "meeting": "기술 세미나", "summary": "RAG 아키텍처 소개"}
],
"references": ["doc_12345", "doc_12346", "doc_12347"]
}
```
### 2. API 호출 코드
```typescript
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic({
apiKey: process.env.CLAUDE_API_KEY,
});
interface TermExplanation {
definition: string;
context: string;
usage_examples: string[];
related_projects: string[];
past_discussions: Array<{
date: string;
meeting: string;
summary: string;
}>;
references: string[];
}
async function explainTerm(
term: string,
relatedDocs: any[],
meetingContext: any
): Promise<TermExplanation> {
const systemPrompt = `당신은 조직 내 전문용어를 쉽게 설명하는 전문가입니다...`;
const userPrompt = buildUserPrompt(term, relatedDocs, meetingContext);
const response = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 2000,
temperature: 0.3, // 일관된 응답을 위해 낮은 temperature
system: systemPrompt,
messages: [
{
role: 'user',
content: userPrompt
}
]
});
// JSON 파싱
const content = response.content[0].text;
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('Invalid JSON response from Claude');
}
return JSON.parse(jsonMatch[0]);
}
function buildUserPrompt(term: string, docs: any[], context: any): string {
const docsSummary = docs.map((doc, idx) => {
return `문서 ${idx + 1} (${doc.metadata.source}, ${doc.metadata.date}):
제목: ${doc.metadata.title}
내용: ${doc.content.substring(0, 500)}...`;
}).join('\n\n');
return `아래는 검색된 관련 문서들입니다:
---
${docsSummary}
---
현재 회의 맥락:
- 회의: "${context.meeting_title}"
- 날짜: ${context.date}
- 참석자: ${context.participants.join(', ')}
- 프로젝트: "${context.project_name}"
용어: "${term}"
위 정보를 바탕으로 "${term}"에 대한 설명을 JSON 형식으로 작성해주세요.
예시:
{
"definition": "...",
"context": "...",
...
}`;
}
```
### 3. API 요청/응답 예시
#### 요청 (Request)
```json
{
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 2000,
"temperature": 0.3,
"system": "당신은 조직 내 전문용어를 쉽게 설명하는 전문가입니다...",
"messages": [
{
"role": "user",
"content": "아래는 검색된 관련 문서들입니다...\n용어: \"RAG\""
}
]
}
```
#### 응답 (Response)
```json
{
"id": "msg_01ABC123",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "{\n \"definition\": \"Retrieval-Augmented Generation의 약자로, 문서 검색과 AI 생성을 결합한 기술입니다.\",\n \"context\": \"우리 팀은 회의록 시스템에 RAG를 적용하여 과거 회의록과 사내 문서를 검색하고, 맥락에 맞는 용어 설명을 자동 생성합니다.\",\n \"usage_examples\": [\n \"회의 중 전문용어가 나오면 RAG 시스템이 관련 문서를 찾아 설명을 제공합니다\",\n \"신입사원도 업무 지식 없이 정확한 회의록을 작성할 수 있습니다\"\n ],\n \"related_projects\": [\"회의록 시스템\", \"AI 자동화 프로젝트\"],\n \"past_discussions\": [\n {\"date\": \"2025-01-15\", \"meeting\": \"프로젝트 회의\", \"summary\": \"RAG 시스템 도입 결정\"},\n {\"date\": \"2025-01-10\", \"meeting\": \"기술 세미나\", \"summary\": \"RAG 아키텍처 소개\"}\n ],\n \"references\": [\"doc_12345\", \"doc_12346\", \"doc_12347\"]\n}"
}
],
"model": "claude-3-5-sonnet-20241022",
"stop_reason": "end_turn",
"usage": {
"input_tokens": 1250,
"output_tokens": 320
}
}
```
---
## 단계별 구현 로드맵
### Phase 1: 기본 기능 (2주)
**목표**: 회의록 기반 최소 기능 구현 및 사용자 테스트
#### 구현 범위
- [x] 회의록 DB 연동
- [x] 용어 감지 엔진 (Trie 자료구조)
- [x] OpenAI Embedding API 연동
- [x] Pinecone 벡터 검색
- [x] Claude API 호출 (기본 프롬프트)
- [x] UI: 점선 밑줄 하이라이트, 바텀 시트 툴팁
#### 성능 목표
- 응답 시간: **5초 이내**
- 용어 감지 정확도: **70% 이상**
- 관련 문서 정확도: **60% 이상**
#### 제약 사항
- 캐시 없음 (모든 요청 실시간 처리)
- 회의록만 검색 (위키, 문서, 이메일 미포함)
#### 배포 전략
- Beta 테스트: 20명 (개발팀 10명, 기획팀 5명, 경영지원팀 5명)
- 피드백 수집: Google Forms 설문 + 주간 인터뷰
---
### Phase 2: 성능 개선 (4주)
**목표**: 캐시 도입 + 위키 추가 + 하이브리드 검색
#### 구현 범위
- [x] Redis 캐시 레이어
- TTL: 자주 쓰이는 용어 7일, 드문 용어 1일
- Cache Warming: 상위 50개 용어 사전 캐싱
- [x] 사내 위키 연동 (Confluence, Notion)
- [x] 하이브리드 검색 (벡터 + 키워드)
- 벡터 유사도: 70% 가중치
- 키워드 매칭: 30% 가중치
- [x] 프롬프트 최적화 (Few-shot learning)
#### 성능 목표
- 응답 시간: **3초 이내** (캐시 히트 시 0.5초)
- 용어 감지 정확도: **85% 이상**
- 관련 문서 정확도: **75% 이상**
- 캐시 히트율: **60% 이상**
#### 배포 전략
- Beta 테스트 확대: 50명
- A/B 테스트: 캐시 vs 캐시 없음, 하이브리드 vs 벡터 단독
---
### Phase 3: 고도화 (6주)
**목표**: 전체 데이터 통합 + 시맨틱 필터링 + 부서별 커스터마이징
#### 구현 범위
- [x] 프로젝트 문서 연동 (Google Drive, SharePoint)
- [x] 이메일 연동 (Outlook, Gmail)
- [x] 시맨틱 필터링
- 부서별 용어 우선순위
- 프로젝트별 문맥 가중치
- [x] 2단계 캐싱
- L1: Redis (Hot data, TTL 7일)
- L2: CDN (Static explanations, TTL 30일)
- [x] 용어 추천 시스템
- "이 용어를 사전에 추가할까요?" 제안
#### 성능 목표
- 응답 시간: **2초 이내** (캐시 히트 시 0.3초)
- 용어 감지 정확도: **90% 이상**
- 관련 문서 정확도: **80% 이상**
- 캐시 히트율: **80% 이상**
#### 배포 전략
- 전사 배포 (200명+)
- 부서별 용어 사전 큐레이션 워크숍
---
## 성능 및 비용 최적화
### 1. 캐싱 전략
#### Redis 캐시 구조
```
Key: term:{term_name}:{meeting_id}
Value: JSON (TermExplanation)
TTL:
- 자주 쓰이는 용어 (요청 >10회/월): 7일
- 드문 용어 (요청 <10회/): 1일
```
#### Cache Warming
```python
# 매일 새벽 2시 실행
def cache_warming():
# 상위 50개 빈도 높은 용어
top_terms = get_top_terms(limit=50)
for term in top_terms:
# 최근 회의 맥락으로 미리 캐싱
recent_meetings = get_recent_meetings(limit=5)
for meeting in recent_meetings:
explanation = explain_term(term, meeting)
cache_key = f"term:{term}:{meeting.id}"
redis.setex(cache_key, ttl=7*24*3600, value=json.dumps(explanation))
```
### 2. 하이브리드 검색
```python
def hybrid_search(query, top_k=5):
"""
벡터 검색 + 키워드 검색 결합
"""
# 1. 벡터 검색 (70% 가중치)
query_embedding = generate_embedding(query)
vector_results = pinecone_index.query(
vector=query_embedding,
top_k=top_k * 2, # 2배수 조회
include_metadata=True
)
# 2. 키워드 검색 (30% 가중치)
keyword_results = elasticsearch.search(
index="meetings",
body={
"query": {
"multi_match": {
"query": query,
"fields": ["title^3", "content", "tags^2"]
}
}
},
size=top_k * 2
)
# 3. 점수 결합 (Normalized)
combined_scores = {}
for match in vector_results.matches:
combined_scores[match.id] = match.score * 0.7
for hit in keyword_results['hits']['hits']:
doc_id = hit['_id']
if doc_id in combined_scores:
combined_scores[doc_id] += hit['_score'] * 0.3
else:
combined_scores[doc_id] = hit['_score'] * 0.3
# 4. 상위 k개 반환
sorted_results = sorted(combined_scores.items(), key=lambda x: x[1], reverse=True)
return sorted_results[:top_k]
```
### 3. Claude API 비용 절감
#### 비용 구조
```
Claude 3.5 Sonnet:
- Input: $3 / 1M tokens
- Output: $15 / 1M tokens
월 예상 비용 (500 회의록, 평균 10회 용어 설명):
- 총 요청: 5,000회
- 평균 Input: 1,500 tokens/요청
- 평균 Output: 300 tokens/요청
비용 = (5000 * 1500 * $3 / 1M) + (5000 * 300 * $15 / 1M)
= $22.5 + $22.5
= $45/월
캐시 적용 시 (60% 히트율):
= $45 * 0.4 = $18/월
```
#### Rate Limiting
```typescript
import { RateLimiter } from 'limiter';
const limiter = new RateLimiter({
tokensPerInterval: 60, // 분당 60회
interval: 'minute'
});
async function callClaudeWithRateLimit(prompt: string) {
await limiter.removeTokens(1);
return await anthropic.messages.create({...});
}
```
---
## 품질 검증 기준
### 1. 자동 검증
#### 용어 감지 정확도
```python
def calculate_term_detection_accuracy():
"""
테스트 세트: 100개 회의록, 200개 용어
"""
test_data = load_test_data("term_detection_test.json")
correct = 0
total = len(test_data)
for sample in test_data:
detected_terms = detect_terms(sample.content)
expected_terms = sample.expected_terms
# F1 Score 계산
precision = len(set(detected_terms) & set(expected_terms)) / len(detected_terms)
recall = len(set(detected_terms) & set(expected_terms)) / len(expected_terms)
f1 = 2 * (precision * recall) / (precision + recall)
correct += f1
return (correct / total) * 100
```
#### 설명 품질 평가
```python
def evaluate_explanation_quality():
"""
사람 평가 + 자동 평가 결합
"""
test_cases = load_test_cases("explanation_quality.json")
scores = []
for case in test_cases:
explanation = explain_term(case.term, case.context)
# 자동 평가 (1-5점)
auto_score = 0
# 1) 정의 포함 여부 (1점)
if len(explanation.definition) > 10:
auto_score += 1
# 2) 맥락 설명 포함 여부 (1점)
if len(explanation.context) > 20:
auto_score += 1
# 3) 사용 사례 포함 여부 (1점)
if len(explanation.usage_examples) >= 1:
auto_score += 1
# 4) 관련 문서 연결 여부 (1점)
if len(explanation.references) >= 1:
auto_score += 1
# 5) 과거 논의 포함 여부 (1점)
if len(explanation.past_discussions) >= 1:
auto_score += 1
scores.append(auto_score)
return sum(scores) / len(scores)
```
### 2. 사람 평가
#### 평가 기준
| 항목 | 배점 | 기준 |
|------|------|------|
| **정확성** | 30점 | 용어 정의가 정확한가? |
| **맥락 적합성** | 30점 | 현재 회의 맥락과 관련성이 높은가? |
| **실용성** | 20점 | 실제 업무에 도움이 되는가? |
| **가독성** | 10점 | 이해하기 쉬운가? |
| **완성도** | 10점 | 관련 문서, 과거 논의 포함 여부 |
#### 평가 프로세스
1. 매 Sprint 종료 시 20개 샘플 평가
2. 평가자: Beta 테스터 10명 (무작위 선정)
3. 목표 점수: 80점 이상
---
## 운영 및 모니터링
### 1. 성능 메트릭
#### Prometheus 메트릭
```typescript
import { Counter, Histogram, Gauge } from 'prom-client';
// 요청 수
const requestCounter = new Counter({
name: 'rag_requests_total',
help: 'Total number of RAG requests',
labelNames: ['status', 'cache_hit']
});
// 응답 시간
const responseTimeHistogram = new Histogram({
name: 'rag_response_time_seconds',
help: 'RAG response time in seconds',
buckets: [0.5, 1, 2, 3, 5]
});
// 캐시 히트율
const cacheHitRate = new Gauge({
name: 'rag_cache_hit_rate',
help: 'Cache hit rate percentage'
});
// Claude API 비용
const apiCostCounter = new Counter({
name: 'claude_api_cost_usd',
help: 'Estimated Claude API cost in USD'
});
```
#### Grafana 대시보드
```
┌────────────────────────────────────────────┐
│ RAG 시스템 성능 모니터링 │
├────────────────────────────────────────────┤
│ [ 실시간 요청 수 ] [ 평균 응답 시간 ] │
│ 125 req/min 2.3s │
├────────────────────────────────────────────┤
│ [ 캐시 히트율 ] [ Claude API 비용 ] │
│ 65% $18/월 │
├────────────────────────────────────────────┤
│ [ 용어 감지 정확도 ] [ 설명 품질 점수 ] │
│ 88% 85/100 │
└────────────────────────────────────────────┘
```
### 2. 알림 설정
#### Alertmanager 규칙
```yaml
groups:
- name: rag_alerts
rules:
- alert: HighResponseTime
expr: rag_response_time_seconds > 5
for: 5m
labels:
severity: warning
annotations:
summary: "RAG 응답 시간 초과 (>5초)"
- alert: LowCacheHitRate
expr: rag_cache_hit_rate < 50
for: 10m
labels:
severity: info
annotations:
summary: "캐시 히트율 낮음 (<50%)"
- alert: ClaudeAPIError
expr: increase(rag_requests_total{status="error"}[5m]) > 10
labels:
severity: critical
annotations:
summary: "Claude API 오류 급증"
```
### 3. 로깅 전략
```typescript
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'rag-error.log', level: 'error' }),
new winston.transports.File({ filename: 'rag-combined.log' })
]
});
// 로그 예시
logger.info('Term explanation generated', {
term: 'RAG',
meeting_id: 'meeting_12345',
response_time_ms: 2300,
cache_hit: false,
related_docs_count: 5,
claude_tokens: { input: 1500, output: 320 }
});
```
---
## 부록
### A. API 명세서
#### POST /api/rag/explain
**요청**:
```json
{
"term": "RAG",
"meeting_id": "meeting_12345",
"context": {
"meeting_title": "주간 스크럼",
"date": "2025-01-20T14:00:00Z",
"participants": ["김민준", "박서연"],
"project_name": "회의록 시스템"
}
}
```
**응답**:
```json
{
"term": "RAG",
"explanation": {
"definition": "Retrieval-Augmented Generation의 약자로...",
"context": "우리 팀은 회의록 시스템에 RAG를 적용하여...",
"usage_examples": ["..."],
"related_projects": ["..."],
"past_discussions": [...],
"references": ["doc_12345"]
},
"metadata": {
"response_time_ms": 2300,
"cache_hit": false,
"related_docs_count": 5,
"confidence_score": 0.92
}
}
```
### B. 배치 작업 스케줄
| 작업 | 주기 | 시간 | 설명 |
|------|------|------|------|
| 회의록 벡터화 | 실시간 | - | 회의 종료 후 즉시 |
| 위키 동기화 | 매일 | 새벽 2시 | Confluence API 호출 |
| 캐시 워밍 | 매일 | 새벽 3시 | 상위 50개 용어 캐싱 |
| 용어 사전 갱신 | 주간 | 일요일 새벽 1시 | 신규 용어 자동 추가 |
| 성능 리포트 생성 | 주간 | 월요일 오전 9시 | 주간 성능 분석 |
---
## 변경 이력
| 버전 | 날짜 | 변경 내용 | 작성자 |
|------|------|-----------|--------|
| 1.0 | 2025-01-20 | 초기 구현방안 작성 | 박서연, 이준호, 홍길동 |
| 2.0 | 2025-01-20 | 하이브리드형 로드맵으로 최종 승인 | 전체 팀 |
---
**문서 끝**