mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 06:46:24 +00:00
프로젝트 구조 정리 및 프로토타입 업데이트
- 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:
parent
10a071c2e5
commit
bd34b40991
@ -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": []
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
@ -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>
|
||||
@ -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>
|
||||
@ -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
1100
design-last/uiux/prototype/common.js
vendored
1100
design-last/uiux/prototype/common.js
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
990
design-last/uiux_다람지/prototype/common.js
vendored
990
design-last/uiux_다람지/prototype/common.js
vendored
@ -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
@ -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
|
||||
|
||||
---
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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"> </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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
408
design-v1/uiux/prototype/common.js
vendored
408
design-v1/uiux/prototype/common.js
vendored
@ -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
@ -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
|
||||
|
||||
---
|
||||
```
|
||||
@ -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
@ -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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 예시 크리덴셜 (프로토타입용) -->
|
||||
<div class="credential-hint">
|
||||
<div class="credential-hint-title">📝 테스트 계정</div>
|
||||
<div>이메일: <code>test@example.com</code></div>
|
||||
<div>비밀번호: <code>password123</code></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">
|
||||
<div class="form-group">
|
||||
<label for="email" class="form-label">이메일</label>
|
||||
<form id="loginForm" class="login-form" novalidate>
|
||||
<!-- 사번 입력 -->
|
||||
<div class="input-group">
|
||||
<label for="employeeId" class="input-label required">사번</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
class="form-input"
|
||||
placeholder="example@company.com"
|
||||
type="text"
|
||||
id="employeeId"
|
||||
name="employeeId"
|
||||
class="input-field"
|
||||
placeholder="사번을 입력하세요"
|
||||
autocomplete="username"
|
||||
required
|
||||
autocomplete="email"
|
||||
aria-label="사번"
|
||||
aria-describedby="employeeIdError"
|
||||
aria-required="true"
|
||||
>
|
||||
<span id="employeeIdError" class="input-error-message" role="alert"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label">비밀번호</label>
|
||||
<!-- 비밀번호 입력 -->
|
||||
<div class="input-group">
|
||||
<label for="password" class="input-label required">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
class="form-input"
|
||||
name="password"
|
||||
class="input-field"
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
required
|
||||
aria-label="비밀번호"
|
||||
aria-describedby="passwordError"
|
||||
aria-required="true"
|
||||
>
|
||||
<span id="passwordError" class="input-error-message" role="alert"></span>
|
||||
</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
|
||||
type="submit"
|
||||
class="button button-primary button-large login-button"
|
||||
id="loginButton"
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 푸터 -->
|
||||
<div class="login-footer">
|
||||
<p class="login-footer-text">
|
||||
아직 계정이 없으신가요? <a href="#">회원가입</a>
|
||||
<!-- 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 emailInput = document.getElementById('email');
|
||||
const employeeIdInput = document.getElementById('employeeId');
|
||||
const passwordInput = document.getElementById('password');
|
||||
const rememberMeCheckbox = document.getElementById('rememberMe');
|
||||
const loginButton = document.getElementById('loginButton');
|
||||
const loadingOverlay = document.getElementById('loadingOverlay');
|
||||
|
||||
// 페이지 로드 시 저장된 이메일 불러오기
|
||||
MeetingApp.ready(() => {
|
||||
const savedEmail = MeetingApp.Storage.get('savedEmail');
|
||||
if (savedEmail) {
|
||||
emailInput.value = savedEmail;
|
||||
rememberMeCheckbox.checked = true;
|
||||
// 에러 메시지 엘리먼트
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
// 폼 제출 핸들러
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
passwordInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
// 에러 초기화
|
||||
MeetingApp.Validator.clearError(emailInput);
|
||||
MeetingApp.Validator.clearError(passwordInput);
|
||||
|
||||
const email = emailInput.value.trim();
|
||||
const password = passwordInput.value.trim();
|
||||
|
||||
// 유효성 검사
|
||||
let isValid = true;
|
||||
|
||||
if (!MeetingApp.Validator.required(email)) {
|
||||
MeetingApp.Validator.showError(emailInput, '이메일을 입력해주세요.');
|
||||
isValid = false;
|
||||
} else if (!MeetingApp.Validator.isEmail(email)) {
|
||||
MeetingApp.Validator.showError(emailInput, '올바른 이메일 형식이 아닙니다.');
|
||||
isValid = false;
|
||||
loginForm.dispatchEvent(new Event('submit'));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// JWT 토큰 시뮬레이션
|
||||
MeetingApp.Storage.set('authToken', 'mock-jwt-token-' + Date.now());
|
||||
/**
|
||||
* 사번 검증
|
||||
*/
|
||||
function validateEmployeeId() {
|
||||
const value = employeeIdInput.value.trim();
|
||||
|
||||
// 성공 토스트
|
||||
MeetingApp.Toast.success('로그인에 성공했습니다!');
|
||||
if (!value) {
|
||||
showError(employeeIdInput, employeeIdError, '사번을 입력해주세요');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 대시보드로 이동
|
||||
setTimeout(() => {
|
||||
window.location.href = '02-대시보드.html';
|
||||
}, 1000);
|
||||
// 사번 형식 검증 (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 {
|
||||
// 로그인 실패
|
||||
MeetingApp.Toast.error('이메일 또는 비밀번호가 올바르지 않습니다.');
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = originalText;
|
||||
hideLoading();
|
||||
|
||||
// 실패 메시지 표시
|
||||
showToast('사번 또는 비밀번호가 올바르지 않습니다', 'error', 3000);
|
||||
|
||||
// 비밀번호 필드 초기화 및 포커스
|
||||
passwordInput.value = '';
|
||||
passwordInput.focus();
|
||||
|
||||
// 입력 필드에 에러 표시
|
||||
showError(employeeIdInput, employeeIdError, '');
|
||||
showError(passwordInput, passwordError, '인증에 실패했습니다');
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
MeetingApp.Toast.error('로그인 중 오류가 발생했습니다. 다시 시도해주세요.');
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = originalText;
|
||||
/**
|
||||
* 폼 제출 이벤트 핸들러
|
||||
*/
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// 입력 검증
|
||||
const isEmployeeIdValid = validateEmployeeId();
|
||||
const isPasswordValid = validatePassword();
|
||||
|
||||
if (!isEmployeeIdValid || !isPasswordValid) {
|
||||
// 검증 실패 시 첫 번째 에러 필드로 포커스
|
||||
if (!isEmployeeIdValid) {
|
||||
employeeIdInput.focus();
|
||||
} else if (!isPasswordValid) {
|
||||
passwordInput.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// 비밀번호 찾기 (프로토타입용)
|
||||
document.querySelector('.forgot-password').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
MeetingApp.Toast.info('비밀번호 찾기 기능은 준비 중입니다.');
|
||||
});
|
||||
// 로그인 처리
|
||||
const employeeId = employeeIdInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
|
||||
// 회원가입 (프로토타입용)
|
||||
document.querySelector('.login-footer a').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
MeetingApp.Toast.info('회원가입 기능은 준비 중입니다.');
|
||||
});
|
||||
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
@ -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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.page-title { font-size: var(--font-size-h2); }
|
||||
.form-container { padding: var(--spacing-5); }
|
||||
.button-group { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">회의 예약</h1>
|
||||
<p class="page-subtitle">새로운 회의를 예약하고 참석자를 초대하세요</p>
|
||||
<!-- 헤더 -->
|
||||
<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">
|
||||
<form id="meetingForm" novalidate>
|
||||
<!-- 기본 정보 -->
|
||||
<div class="form-section">
|
||||
<h2 class="form-section-title">기본 정보</h2>
|
||||
|
||||
<!-- 회의 제목 -->
|
||||
<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">
|
||||
<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 for="date" class="form-label">날짜 *</label>
|
||||
<input type="date" id="date" class="form-input" required>
|
||||
<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="time" class="form-label">시간 *</label>
|
||||
<input type="time" id="time" class="form-input" required>
|
||||
<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="location" class="form-label">장소</label>
|
||||
<input type="text" id="location" class="form-input" placeholder="예: 본사 2층 대회의실" maxlength="200">
|
||||
</div>
|
||||
<!-- 참석자 -->
|
||||
<div class="form-section">
|
||||
<h2 class="form-section-title">참석자</h2>
|
||||
|
||||
<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>
|
||||
<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">
|
||||
<label for="description" class="form-label">회의 설명</label>
|
||||
<textarea id="description" class="form-textarea" placeholder="회의 목적과 안건을 간략히 작성하세요"></textarea>
|
||||
<div class="checkbox-wrapper" onclick="toggleReminder()">
|
||||
<div id="reminderCheckbox" class="custom-checkbox checked"></div>
|
||||
<label class="checkbox-label">회의 시작 30분 전 리마인더 발송</label>
|
||||
</div>
|
||||
</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>
|
||||
</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) => {
|
||||
// 초기화
|
||||
function init() {
|
||||
setupEventListeners();
|
||||
setMinDate();
|
||||
loadDraft();
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
function setupEventListeners() {
|
||||
const attendeeInput = $('#attendeeEmail');
|
||||
attendeeInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddAttendee();
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
// 실시간 검증
|
||||
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: 'm-' + Date.now(),
|
||||
title,
|
||||
date: `${date} ${time}`,
|
||||
location: location || '미정',
|
||||
status: 'scheduled',
|
||||
attendees: attendees.split(',').map(email => email.trim()),
|
||||
description: description || ''
|
||||
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 = MeetingApp.Storage.get('meetings', []);
|
||||
const meetings = loadData('meetings') || [];
|
||||
meetings.unshift(newMeeting);
|
||||
MeetingApp.Storage.set('meetings', meetings);
|
||||
saveData('meetings', meetings);
|
||||
|
||||
MeetingApp.Toast.success('회의가 예약되었습니다!');
|
||||
// 임시 저장 삭제
|
||||
removeData('meetingDraft');
|
||||
|
||||
// 성공 메시지
|
||||
showToast('회의 예약이 완료되었습니다', 'success', 2000);
|
||||
|
||||
// 템플릿 선택 화면으로 이동
|
||||
setTimeout(() => {
|
||||
window.location.href = '04-템플릿선택.html?meetingId=' + newMeeting.id;
|
||||
}, 1000);
|
||||
});
|
||||
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
@ -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>
|
||||
|
||||
@ -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>
|
||||
<div class="info-item">
|
||||
<span class="info-label">회의 시간</span>
|
||||
<span class="info-value">45분</span>
|
||||
</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="info-item">
|
||||
<span class="info-label">참석자</span>
|
||||
<span class="info-value">3명</span>
|
||||
<div class="h3" style="color: var(--text-primary);">45분</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">생성된 Todo</span>
|
||||
<span class="info-value">5개</span>
|
||||
|
||||
<!-- 참석자 -->
|
||||
<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 class="action-buttons">
|
||||
<button class="btn btn-primary" onclick="window.location.href='08-최종확정.html'">
|
||||
회의록 확정하기
|
||||
<!-- 발언 횟수 -->
|
||||
<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>
|
||||
<button class="btn btn-secondary" onclick="window.location.href='02-대시보드.html'">
|
||||
대시보드로 이동
|
||||
</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>
|
||||
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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
1506
design/uiux/prototype/common.js
vendored
1506
design/uiux/prototype/common.js
vendored
File diff suppressed because it is too large
Load Diff
@ -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개 탭 추가, 관련 자료 섹션 구현)
|
||||
1932
design/uiux/uiux.md
1932
design/uiux/uiux.md
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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');
|
||||
// 간단한 폼 검증
|
||||
if (!employeeId || !password) {
|
||||
UIComponents.showToast('사번과 비밀번호를 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading
|
||||
UI.showLoading();
|
||||
// 로딩 표시
|
||||
UIComponents.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');
|
||||
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 {
|
||||
UI.showToast('아이디 또는 비밀번호가 올바르지 않습니다', 'error');
|
||||
// 로그인 실패
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// Enter key support
|
||||
document.getElementById('password').addEventListener('keypress', (e) => {
|
||||
// 엔터키 처리
|
||||
document.querySelectorAll('.form-input').forEach(input => {
|
||||
input.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
document.getElementById('loginForm').dispatchEvent(new Event('submit'));
|
||||
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>
|
||||
|
||||
@ -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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- FAB -->
|
||||
<button class="fab" aria-label="새 회의 예약" onclick="window.location.href='03-회의예약.html'">
|
||||
+
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<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>
|
||||
</main>
|
||||
<button class="btn btn-secondary" onclick="NavigationHelper.navigate('MEETING_SCHEDULE')">
|
||||
<span class="material-symbols-outlined">calendar_today</span>
|
||||
회의 예약
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
<nav class="bottom-nav" aria-label="주요 네비게이션">
|
||||
<!-- 내 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="bottom-nav-icon" aria-hidden="true">🏠</span>
|
||||
<span class="material-symbols-outlined bottom-nav-icon">home</span>
|
||||
<span>홈</span>
|
||||
</a>
|
||||
<a href="02-대시보드.html" class="bottom-nav-item">
|
||||
<span class="bottom-nav-icon" aria-hidden="true">📅</span>
|
||||
<span>회의</span>
|
||||
<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="bottom-nav-icon" aria-hidden="true">✓</span>
|
||||
<span class="material-symbols-outlined bottom-nav-icon">check_box</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 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)}
|
||||
</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>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
// 진행 중 Todo 개수
|
||||
const inProgressCount = myTodos.filter(t => !t.completed).length;
|
||||
|
||||
function renderRecentMinutes(minutes) {
|
||||
const container = document.getElementById('recentMinutes');
|
||||
// 마감 임박 Todo (3일 이내)
|
||||
const dueSoonTodos = myTodos.filter(todo => isDueSoon(todo.dueDate)).slice(0, 3);
|
||||
|
||||
if (minutes.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state"><p>최근 회의록이 없습니다</p></div>';
|
||||
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 = `
|
||||
<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="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 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>
|
||||
</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')
|
||||
}
|
||||
]
|
||||
if (dueSoonTodos.length > 0) {
|
||||
dueSoonTodos.forEach(todo => {
|
||||
html += UIComponents.createTodoItem(todo);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize dashboard
|
||||
loadDashboard();
|
||||
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>
|
||||
|
||||
@ -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;">
|
||||
<label for="meetingDate" class="form-label required">회의 날짜</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
name="date"
|
||||
id="meetingDate"
|
||||
class="form-input"
|
||||
required
|
||||
data-validate="required"
|
||||
aria-label="회의 날짜"
|
||||
aria-required="true"
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
|
||||
<!-- 시작 시간 / 종료 시간 -->
|
||||
<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"
|
||||
name="startTime"
|
||||
class="form-input"
|
||||
required
|
||||
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">
|
||||
<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">
|
||||
<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 -->
|
||||
placeholder="이름 또는 이메일로 검색"
|
||||
aria-label="참석자 검색"
|
||||
>
|
||||
</div>
|
||||
<span class="form-error" id="attendee-error"></span>
|
||||
<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>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button type="submit" class="btn btn-primary btn-full mt-8">
|
||||
회의 예약
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
`).join('')}
|
||||
</div>
|
||||
`,
|
||||
footer: '<button class="btn btn-secondary" onclick="closeModal()">닫기</button>',
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
<script src="common.js"></script>
|
||||
<script>
|
||||
const { Auth, API, UI, Validator, Navigation, Storage } = window.App;
|
||||
|
||||
// Check authentication
|
||||
if (!Auth.requireAuth()) return;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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}`;
|
||||
|
||||
// Attendee search with autocomplete
|
||||
let searchTimeout;
|
||||
// 검색 기능
|
||||
document.getElementById('attendeeSearch').addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimeout);
|
||||
const query = e.target.value.toLowerCase();
|
||||
|
||||
if (query.length < 2) return;
|
||||
|
||||
searchTimeout = setTimeout(() => {
|
||||
const results = mockAttendees.filter(attendee =>
|
||||
(attendee.name.toLowerCase().includes(query) ||
|
||||
attendee.email.toLowerCase().includes(query)) &&
|
||||
!selectedAttendees.find(a => a.id === attendee.id)
|
||||
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>
|
||||
`).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: '회의 제목을 입력해주세요'
|
||||
// 모달 닫기
|
||||
function closeModal() {
|
||||
const modal = document.querySelector('.modal-overlay');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
],
|
||||
date: [
|
||||
{
|
||||
validator: (value) => Validator.required(value),
|
||||
message: '날짜를 선택해주세요'
|
||||
}
|
||||
],
|
||||
startTime: [
|
||||
{
|
||||
validator: (value) => Validator.required(value),
|
||||
message: '시간을 선택해주세요'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// 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;
|
||||
if (attendees.length === 0) {
|
||||
UIComponents.showToast('최소 1명의 참석자를 추가해주세요', 'error');
|
||||
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)
|
||||
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()
|
||||
};
|
||||
|
||||
UI.showLoading();
|
||||
UIComponents.showLoading('회의를 예약하는 중...');
|
||||
|
||||
try {
|
||||
const response = await API.createMeeting(meetingData);
|
||||
setTimeout(() => {
|
||||
StorageManager.addMeeting(formData);
|
||||
UIComponents.hideLoading();
|
||||
|
||||
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();
|
||||
UIComponents.confirm(
|
||||
'회의가 예약되었습니다. 참석자에게 초대 이메일을 발송하시겠습니까?',
|
||||
() => {
|
||||
UIComponents.showToast('초대 이메일이 발송되었습니다', 'success');
|
||||
setTimeout(() => {
|
||||
NavigationHelper.navigate('DASHBOARD');
|
||||
}, 1000);
|
||||
},
|
||||
() => {
|
||||
NavigationHelper.navigate('DASHBOARD');
|
||||
}
|
||||
);
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
Storage.set('meeting-draft', draft);
|
||||
UI.showToast('임시 저장되었습니다', 'success');
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Initialize
|
||||
renderAttendees();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -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>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<div class="content">
|
||||
<p class="text-body mb-6">회의 유형에 맞는 템플릿을 선택하세요. 건너뛰면 일반 템플릿이 사용됩니다.</p>
|
||||
|
||||
<!-- 템플릿 카드 리스트 -->
|
||||
<div id="templateList">
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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;
|
||||
// 템플릿 선택
|
||||
function selectTemplate(type) {
|
||||
selectedTemplate = type;
|
||||
showCustomizeModal(type);
|
||||
}
|
||||
|
||||
// Show customization modal
|
||||
showCustomizationModal(template);
|
||||
}
|
||||
// 템플릿 미리보기
|
||||
function previewTemplate(type) {
|
||||
const template = TEMPLATES[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>' : ''}
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
<div style="border: 1px solid var(--border-light); border-radius: 8px; margin-bottom: var(--space-4);">
|
||||
${sectionsHtml}
|
||||
`).join('')}
|
||||
</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)
|
||||
}
|
||||
]
|
||||
`,
|
||||
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('');
|
||||
}
|
||||
|
||||
function useTemplate(template) {
|
||||
// Save template to storage
|
||||
Storage.set('selected-template', template);
|
||||
window.moveSectionUp = (index) => {
|
||||
if (index > 0) {
|
||||
[customSections[index], customSections[index - 1]] = [customSections[index - 1], customSections[index]];
|
||||
renderSections();
|
||||
}
|
||||
};
|
||||
|
||||
UI.showToast('템플릿이 선택되었습니다', 'success');
|
||||
window.moveSectionDown = (index) => {
|
||||
if (index < customSections.length - 1) {
|
||||
[customSections[index], customSections[index + 1]] = [customSections[index + 1], customSections[index]];
|
||||
renderSections();
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to meeting progress
|
||||
setTimeout(() => {
|
||||
Navigation.goTo('05-회의진행.html');
|
||||
}, 500);
|
||||
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;
|
||||
}
|
||||
|
||||
function startWithoutTemplate() {
|
||||
Storage.remove('selected-template');
|
||||
// 템플릿 데이터 저장
|
||||
const templateData = {
|
||||
type: type,
|
||||
name: template.name,
|
||||
sections: customSections.map((section, index) => ({
|
||||
...section,
|
||||
order: index + 1
|
||||
}))
|
||||
};
|
||||
|
||||
UI.showToast('빈 회의록으로 시작합니다', 'info');
|
||||
localStorage.setItem('selected_template', JSON.stringify(templateData));
|
||||
closeModal();
|
||||
|
||||
setTimeout(() => {
|
||||
Navigation.goTo('05-회의진행.html');
|
||||
}, 500);
|
||||
// 회의 진행 화면으로 이동
|
||||
const params = meetingId ? { meetingId } : {};
|
||||
NavigationHelper.navigate('MEETING_IN_PROGRESS', params);
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize
|
||||
loadTemplates();
|
||||
// 모달 닫기
|
||||
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>
|
||||
|
||||
@ -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 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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="flex gap-4">
|
||||
<button id="pauseBtn" class="btn btn-secondary flex-1" onclick="togglePause()">
|
||||
<span>⏸️ 일시정지</span>
|
||||
<!-- 하단 액션 바 -->
|
||||
<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-error flex-1" onclick="endMeeting()">
|
||||
<span>⏹️ 종료</span>
|
||||
<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>
|
||||
</main>
|
||||
|
||||
<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() {
|
||||
isPaused = !isPaused;
|
||||
const statusDiv = document.getElementById('recordingStatus');
|
||||
const pauseBtn = document.getElementById('pauseBtn');
|
||||
elapsedInterval = setInterval(updateElapsedTime, 1000);
|
||||
|
||||
if (isPaused) {
|
||||
statusDiv.classList.add('paused');
|
||||
statusDiv.innerHTML = '⏸️ 일시정지 <span id="recordingTime">' +
|
||||
document.getElementById('recordingTime').textContent + '</span>';
|
||||
pauseBtn.innerHTML = '<span>▶️ 재개</span>';
|
||||
UI.showToast('녹음이 일시정지되었습니다', 'info');
|
||||
} else {
|
||||
statusDiv.classList.remove('paused');
|
||||
statusDiv.innerHTML = '🔴 녹음 중 <span id="recordingTime">' +
|
||||
document.getElementById('recordingTime').textContent + '</span>';
|
||||
pauseBtn.innerHTML = '<span>⏸️ 일시정지</span>';
|
||||
UI.showToast('녹음이 재개되었습니다', 'success');
|
||||
}
|
||||
// 섹션 렌더링
|
||||
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();
|
||||
}
|
||||
|
||||
async function endMeeting() {
|
||||
const confirmed = await Modal.confirm('회의를 종료하시겠습니까?');
|
||||
|
||||
if (confirmed) {
|
||||
isRecording = false;
|
||||
clearInterval(timerInterval);
|
||||
UI.showToast('회의가 종료되었습니다', 'success');
|
||||
// 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(() => {
|
||||
Navigation.goTo('07-회의종료.html');
|
||||
}, 500);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmExit() {
|
||||
const confirmed = await Modal.confirm('회의를 종료하지 않고 나가시겠습니까? 작성 중인 내용이 저장되지 않을 수 있습니다.');
|
||||
// 전문용어 하이라이트
|
||||
function highlightTerms(sectionId) {
|
||||
const contentEl = document.getElementById(`content-${sectionId}`);
|
||||
if (!contentEl) return;
|
||||
|
||||
if (confirmed) {
|
||||
clearInterval(timerInterval);
|
||||
Navigation.goBack();
|
||||
}
|
||||
}
|
||||
const terms = ['Mobile First', 'AI', 'API', 'PostgreSQL', 'React'];
|
||||
let html = contentEl.textContent;
|
||||
|
||||
// Simulate real-time collaboration
|
||||
const editor = document.getElementById('editor');
|
||||
|
||||
// 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');
|
||||
|
||||
// Add new typing indicator
|
||||
const newIndicator = document.createElement('p');
|
||||
newIndicator.className = 'typing-indicator';
|
||||
newIndicator.textContent = '[이준호 typing...]';
|
||||
editor.appendChild(newIndicator);
|
||||
}
|
||||
}
|
||||
}, 8000);
|
||||
|
||||
// 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>'
|
||||
);
|
||||
}
|
||||
terms.forEach(term => {
|
||||
const regex = new RegExp(term, 'g');
|
||||
html = html.replace(regex, `<span class="term-highlight" onclick="showTermExplanation('${term}')">${term}</span>`);
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (isRecording) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '회의가 진행 중입니다. 페이지를 나가시겠습니까?';
|
||||
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>
|
||||
|
||||
@ -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>
|
||||
|
||||
<!-- 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="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>
|
||||
|
||||
<!-- 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 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 class="verification-progress">
|
||||
<div id="progressBar" class="verification-progress-bar" style="width: 0%"></div>
|
||||
</div>
|
||||
<span class="text-h5" id="progressPercent">0%</span>
|
||||
</div>
|
||||
<p class="text-body-sm text-gray" id="progressText">0 / 0 섹션 검증 완료</p>
|
||||
</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>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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' : ''}
|
||||
${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>
|
||||
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 ? '<span aria-label="잠김">🔒</span>' : ''}
|
||||
${section.locked && isCreator ? '<span class="material-symbols-outlined" style="color: var(--gray-600);">lock</span>' : ''}
|
||||
</div>
|
||||
|
||||
${section.verified ? `
|
||||
<div class="text-success mb-3" style="font-size: var(--font-sm);">
|
||||
✓ ${section.verifiedBy} 검증완료
|
||||
<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="flex gap-2">
|
||||
<div class="d-flex gap-2">
|
||||
<button
|
||||
class="btn ${section.verified ? 'btn-secondary' : 'btn-primary'}"
|
||||
style="flex: 1;"
|
||||
onclick="${section.verified ? '' : `verifySection('${section.id}')`}"
|
||||
class="btn ${isVerified ? 'btn-secondary' : 'btn-primary'} btn-sm"
|
||||
onclick="toggleSectionVerify('${section.id}')"
|
||||
${section.locked ? 'disabled' : ''}
|
||||
>
|
||||
${section.verified ? '검증 완료됨' : '검증 완료'}
|
||||
${isVerified ? '검증 취소' : '검증 완료'}
|
||||
</button>
|
||||
|
||||
${section.verified && !section.locked ? `
|
||||
<button class="btn btn-secondary" onclick="toggleLock('${section.id}', true)">
|
||||
잠금
|
||||
</button>
|
||||
` : ''}
|
||||
|
||||
${section.locked ? `
|
||||
<button class="btn btn-secondary" onclick="toggleLock('${section.id}', false)">
|
||||
잠금 해제
|
||||
${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>
|
||||
|
||||
${!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');
|
||||
}
|
||||
|
||||
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) {
|
||||
// 검증 완료
|
||||
UIComponents.confirm(
|
||||
`"${section.name}" 섹션을 검증 완료 처리하시겠습니까?`,
|
||||
() => {
|
||||
section.verified = true;
|
||||
section.verifiedBy = '김민준';
|
||||
section.verifiedBy = [...(section.verifiedBy || []), currentUser.name];
|
||||
UIComponents.showToast('검증이 완료되었습니다', 'success');
|
||||
renderSections();
|
||||
UI.showToast('검증이 완료되었습니다', 'success');
|
||||
|
||||
// 회의록 업데이트
|
||||
if (meeting) {
|
||||
meeting.sections = sections;
|
||||
StorageManager.updateMeeting(meeting.id, meeting);
|
||||
}
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
renderSections();
|
||||
|
||||
// 회의록 업데이트
|
||||
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);
|
||||
|
||||
if (confirmed) {
|
||||
section.locked = lock;
|
||||
renderSections();
|
||||
UI.showToast(
|
||||
lock ? '섹션이 잠겼습니다' : '잠금이 해제되었습니다',
|
||||
'success'
|
||||
|
||||
// 회의록 업데이트
|
||||
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);
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function viewSectionContent(sectionId) {
|
||||
const section = sections.find(s => s.id === sectionId);
|
||||
if (!section) return;
|
||||
|
||||
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)
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize
|
||||
// 초기 렌더링
|
||||
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>
|
||||
|
||||
@ -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>
|
||||
|
||||
<!-- 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="page">
|
||||
<!-- 헤더 -->
|
||||
<div class="header">
|
||||
<h1 class="header-title">회의가 종료되었습니다</h1>
|
||||
<div></div>
|
||||
</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 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>
|
||||
|
||||
<!-- 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}명`;
|
||||
}
|
||||
|
||||
// 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()
|
||||
};
|
||||
|
||||
function renderStats() {
|
||||
// Render speaker statistics
|
||||
const speakerContainer = document.getElementById('speakerStats');
|
||||
const maxCount = Math.max(...stats.speakers.map(s => s.count));
|
||||
|
||||
speakerContainer.innerHTML = stats.speakers.map(speaker => `
|
||||
<div class="speaker-item">
|
||||
<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>
|
||||
</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('');
|
||||
// 중복 체크 후 저장
|
||||
const existing = StorageManager.getTodos().find(t =>
|
||||
t.meetingId === meeting.id && t.content === todo.content
|
||||
);
|
||||
if (!existing) {
|
||||
StorageManager.addTodo(todoData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
// Todo 수정
|
||||
function editTodos() {
|
||||
UIComponents.showToast('Todo 수정 기능은 Todo 관리 화면에서 이용하실 수 있습니다', 'info');
|
||||
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'
|
||||
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);
|
||||
}
|
||||
},
|
||||
{
|
||||
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);
|
||||
});
|
||||
}, 100);
|
||||
// 초기 렌더링
|
||||
renderTodos();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -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>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<div class="container">
|
||||
<!-- Step 1: Validation -->
|
||||
<div id="validationStep">
|
||||
<h2 class="mb-4">필수 항목 확인</h2>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<button class="btn btn-primary btn-full" onclick="goToShareSettings()">
|
||||
최종 확정
|
||||
<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>
|
||||
|
||||
<!-- Step 2: Share Settings -->
|
||||
<div id="shareStep" class="hidden">
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<div class="content">
|
||||
<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 />
|
||||
<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="radio-item">
|
||||
<input type="radio" name="recipients" value="selected" />
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<!-- 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 />
|
||||
<label class="form-checkbox mb-2">
|
||||
<input type="checkbox" id="sendEmail" 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>
|
||||
<button type="button" class="btn btn-secondary btn-sm w-full" onclick="copyLink()">
|
||||
<span class="material-symbols-outlined">link</span>
|
||||
링크 복사
|
||||
</button>
|
||||
</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" />
|
||||
<!-- 링크 보안 설정 -->
|
||||
<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>
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" name="security" value="password" />
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-full mt-6">
|
||||
공유하기
|
||||
</button>
|
||||
<div id="passwordGroup" style="display: none;">
|
||||
<input
|
||||
type="password"
|
||||
id="linkPassword"
|
||||
class="form-input"
|
||||
placeholder="링크 접근 비밀번호"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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);
|
||||
}
|
||||
|
||||
function goToDashboard() {
|
||||
Navigation.goTo('02-대시보드.html');
|
||||
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>
|
||||
|
||||
@ -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>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<!-- Todo List -->
|
||||
<div id="todoList" role="tabpanel">
|
||||
<!-- Todo cards will be inserted here -->
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 필터 탭 -->
|
||||
<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>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
<nav class="bottom-nav" aria-label="주요 네비게이션">
|
||||
<!-- 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="bottom-nav-icon" aria-hidden="true">🏠</span>
|
||||
<span class="material-symbols-outlined bottom-nav-icon">home</span>
|
||||
<span>홈</span>
|
||||
</a>
|
||||
<a href="02-대시보드.html" class="bottom-nav-item">
|
||||
<span class="bottom-nav-icon" aria-hidden="true">📅</span>
|
||||
<span>회의</span>
|
||||
<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="bottom-nav-icon" aria-hidden="true">✓</span>
|
||||
<span class="material-symbols-outlined bottom-nav-icon">check_box</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 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();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
currentFilter = filter;
|
||||
|
||||
// Update active tab
|
||||
document.querySelectorAll('.filter-tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
tab.setAttribute('aria-selected', 'false');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
event.target.setAttribute('aria-selected', 'true');
|
||||
|
||||
renderTodos();
|
||||
}
|
||||
|
||||
// Todo 렌더링
|
||||
function renderTodos() {
|
||||
const container = document.getElementById('todoList');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
let filteredTodos = todos;
|
||||
const todos = StorageManager.getTodos();
|
||||
const myTodos = todos.filter(todo => todo.assigneeId === currentUser.id);
|
||||
|
||||
// 필터링
|
||||
let filteredTodos = myTodos;
|
||||
if (currentFilter === 'inprogress') {
|
||||
filteredTodos = todos.filter(t => t.status === 'inprogress');
|
||||
filteredTodos = myTodos.filter(t => !t.completed);
|
||||
} else if (currentFilter === 'completed') {
|
||||
filteredTodos = todos.filter(t => t.status === '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.classList.add('hidden');
|
||||
emptyState.classList.remove('hidden');
|
||||
container.innerHTML = '<p class="text-body text-gray text-center" style="padding: 48px 0;">해당하는 Todo가 없습니다</p>';
|
||||
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>
|
||||
</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>
|
||||
|
||||
<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
|
||||
// 마감일 순 정렬
|
||||
filteredTodos.sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
return new Date(a.dueDate) - new Date(b.dueDate);
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
todo.status = newStatus;
|
||||
todo.progress = newProgress;
|
||||
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();
|
||||
|
||||
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>
|
||||
// 필터 모달
|
||||
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="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 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>
|
||||
|
||||
${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
1342
design/uiux_다람지/prototype/common.js
vendored
1342
design/uiux_다람지/prototype/common.js
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
**문서 끝**
|
||||
@ -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,7 +758,7 @@ UFR-TODO-030: [Todo완료처리] Todo 담당자로서 | 나는, 완료된 Todo
|
||||
|
||||
[Policy/Rule]
|
||||
- Todo 완료 시 회의록에 완료 상태 즉시 반영
|
||||
- 모든 Todo 완료 시 회의 주최자에게 완료 알림
|
||||
- 모든 Todo 완료 시 완료 알림
|
||||
|
||||
[비고]
|
||||
- **차별화 포인트**: Todo 완료가 회의록에 실시간 반영되어 회의 결과 추적 용이
|
||||
@ -837,226 +766,3 @@ UFR-TODO-030: [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
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
|
||||
@ -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 | 하이브리드형 로드맵으로 최종 승인 | 전체 팀 |
|
||||
|
||||
---
|
||||
|
||||
**문서 끝**
|
||||
Loading…
x
Reference in New Issue
Block a user