파일 재정렬 및 프로토타입 구조 변경

This commit is contained in:
hiondal
2025-10-20 13:12:50 +09:00
parent 301eed5a38
commit d3c8b57116
25 changed files with 9817 additions and 3428 deletions
+241 -197
View File
@@ -3,24 +3,26 @@
<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>
/* 페이지 전용 스타일 */
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #4DFFDB 0%, #00D9B1 100%);
background: linear-gradient(135deg, #00D9B1 0%, #6366F1 100%);
}
.login-container {
background-color: white;
.login-card {
background-color: var(--color-white);
border-radius: var(--radius-xl);
padding: var(--spacing-12);
max-width: 400px;
width: 90%;
padding: var(--spacing-10);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 480px;
margin: var(--spacing-4);
}
.login-header {
@@ -28,279 +30,321 @@
margin-bottom: var(--spacing-8);
}
.logo-large {
font-size: 3rem;
color: var(--primary-main);
margin-bottom: var(--spacing-4);
.login-logo {
width: 64px;
height: 64px;
margin: 0 auto var(--spacing-4);
background-color: var(--color-primary-main);
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: var(--color-white);
font-weight: var(--font-weight-bold);
}
.login-title {
font: var(--font-h2);
color: var(--gray-900);
font-size: var(--font-size-h2);
font-weight: var(--font-weight-bold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-2);
}
.login-subtitle {
font: var(--font-body);
color: var(--gray-500);
font-size: var(--font-size-body);
color: var(--color-gray-500);
}
.login-form {
#loginForm {
margin-bottom: var(--spacing-6);
}
.form-group {
margin-bottom: var(--spacing-5);
}
.form-footer {
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;
justify-content: space-between;
margin-bottom: var(--spacing-5);
}
.checkbox-label {
.checkbox-wrapper {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.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;
color: var(--gray-600);
}
.forgot-password {
color: var(--primary-main);
font-size: var(--font-size-body-small);
color: var(--color-primary-main);
text-decoration: none;
transition: color var(--transition-fast);
}
.forgot-password:hover {
text-decoration: underline;
color: var(--color-primary-dark);
}
.divider {
.login-footer {
text-align: center;
margin: var(--spacing-6) 0;
position: relative;
padding-top: var(--spacing-6);
border-top: 1px solid var(--color-gray-200);
}
.divider::before,
.divider::after {
content: '';
position: absolute;
top: 50%;
width: 45%;
height: 1px;
background-color: var(--gray-200);
.login-footer-text {
font-size: var(--font-size-body-small);
color: var(--color-gray-500);
}
.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;
.login-footer a {
color: var(--color-primary-main);
font-weight: var(--font-weight-medium);
text-decoration: none;
transition: color var(--transition-fast);
}
.social-login {
display: flex;
gap: var(--spacing-3);
.login-footer a:hover {
color: var(--color-primary-dark);
}
.social-btn {
flex: 1;
padding: var(--spacing-3);
border: 1px solid var(--gray-300);
/* 예시 크리덴셜 표시 */
.credential-hint {
background-color: var(--color-gray-50);
border: 1px dashed var(--color-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;
padding: var(--spacing-3);
margin-bottom: var(--spacing-5);
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
}
.social-btn:hover {
background-color: var(--gray-50);
border-color: var(--gray-400);
.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);
}
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<!-- 헤더 -->
<div class="login-header">
<div class="logo-large">📝</div>
<h1 class="login-title">회의록 작성 서비스</h1>
<p class="login-subtitle">AI와 함께하는 스마트한 회의록 관리</p>
<div class="login-logo">M</div>
<h1 class="login-title">회의록 서비스</h1>
<p class="login-subtitle">스마트한 협업의 시작</p>
</div>
<form class="login-form" id="loginForm">
<div class="input-group">
<label for="email" class="input-label">이메일</label>
<!-- 예시 크리덴셜 (프로토타입용) -->
<div class="credential-hint">
<div class="credential-hint-title">📝 테스트 계정</div>
<div>이메일: <code>test@example.com</code></div>
<div>비밀번호: <code>password123</code></div>
</div>
<!-- 로그인 폼 -->
<form id="loginForm">
<div class="form-group">
<label for="email" class="form-label">이메일</label>
<input
type="email"
id="email"
class="input"
class="form-input"
placeholder="example@company.com"
required
autocomplete="email"
>
<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 class="form-group">
<label for="password" class="form-label">비밀번호</label>
<input
type="password"
id="password"
class="form-input"
placeholder="비밀번호를 입력하세요"
required
autocomplete="current-password"
>
</div>
<div class="login-options">
<label class="checkbox-label">
<div class="form-footer">
<div class="checkbox-wrapper">
<input type="checkbox" id="rememberMe">
<span>로그인 상태 유지</span>
</label>
<label for="rememberMe">로그인 상태 유지</label>
</div>
<a href="#" class="forgot-password">비밀번호 찾기</a>
</div>
<button type="submit" class="btn btn-primary btn-lg">
<button type="submit" class="btn btn-primary" style="width: 100%;">
로그인
</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 class="login-footer">
<p class="login-footer-text">
아직 계정이 없으신가요? <a href="#">회원가입</a>
</p>
</div>
</div>
<!-- JavaScript -->
<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' ? '👁️' : '👁️‍🗨️';
// 로그인 폼 처리
const loginForm = document.getElementById('loginForm');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
const rememberMeCheckbox = document.getElementById('rememberMe');
// 페이지 로드 시 저장된 이메일 불러오기
MeetingApp.ready(() => {
const savedEmail = MeetingApp.Storage.get('savedEmail');
if (savedEmail) {
emailInput.value = savedEmail;
rememberMeCheckbox.checked = true;
}
});
// 로그인 폼 제출
document.getElementById('loginForm').addEventListener('submit', function(e) {
// 폼 제출 핸들러
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('rememberMe').checked;
// 에러 초기화
MeetingApp.Validator.clearError(emailInput);
MeetingApp.Validator.clearError(passwordInput);
// 간단한 검증
const emailError = document.getElementById('emailError');
const passwordError = document.getElementById('passwordError');
const email = emailInput.value.trim();
const password = passwordInput.value.trim();
emailError.classList.add('hidden');
passwordError.classList.add('hidden');
document.getElementById('email').classList.remove('error');
document.getElementById('password').classList.remove('error');
// 유효성 검사
let isValid = true;
let hasError = false;
// 이메일 검증
if (!email.includes('@')) {
emailError.textContent = '올바른 이메일 주소를 입력하세요.';
emailError.classList.remove('hidden');
document.getElementById('email').classList.add('error');
hasError = true;
if (!MeetingApp.Validator.required(email)) {
MeetingApp.Validator.showError(emailInput, '이메일을 입력해주세요.');
isValid = false;
} else if (!MeetingApp.Validator.isEmail(email)) {
MeetingApp.Validator.showError(emailInput, '올바른 이메일 형식이 아닙니다.');
isValid = false;
}
// 비밀번호 검증
if (password.length < 8) {
passwordError.textContent = '비밀번호는 최소 8자 이상이어야 합니다.';
passwordError.classList.remove('hidden');
document.getElementById('password').classList.add('error');
hasError = true;
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 (hasError) {
return;
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());
// 성공 토스트
MeetingApp.Toast.success('로그인에 성공했습니다!');
// 대시보드로 이동
setTimeout(() => {
window.location.href = '02-대시보드.html';
}, 1000);
} else {
// 로그인 실패
MeetingApp.Toast.error('이메일 또는 비밀번호가 올바르지 않습니다.');
submitButton.disabled = false;
submitButton.textContent = originalText;
}
} catch (error) {
console.error('Login error:', error);
MeetingApp.Toast.error('로그인 중 오류가 발생했습니다. 다시 시도해주세요.');
submitButton.disabled = false;
submitButton.textContent = originalText;
}
// 로그인 처리 (시뮬레이션)
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) {
// 비밀번호 찾기 (프로토타입용)
document.querySelector('.forgot-password').addEventListener('click', (e) => {
e.preventDefault();
showToast('비밀번호 재설정 이메일이 발송되었습니다.', 'success');
MeetingApp.Toast.info('비밀번호 찾기 기능은 준비 중입니다.');
});
// 회원가입 (프로토타입용)
document.querySelector('.login-footer a').addEventListener('click', (e) => {
e.preventDefault();
MeetingApp.Toast.info('회원가입 기능은 준비 중입니다.');
});
</script>
</body>
File diff suppressed because it is too large Load Diff
+87 -653
View File
@@ -3,697 +3,131 @@
<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>
.reservation-container {
max-width: 1200px;
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 800px;
margin: 0 auto;
padding: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-4);
}
.progress-bar {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-4);
.page-header {
margin-bottom: var(--spacing-8);
}
.progress-step {
display: flex;
align-items: center;
gap: var(--spacing-2);
.page-title {
font-size: var(--font-size-h1);
color: var(--color-gray-900);
margin-bottom: 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;
.page-subtitle {
font-size: var(--font-size-body);
color: var(--color-gray-500);
}
.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;
.form-container {
background-color: var(--color-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 {
.button-group {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
gap: var(--spacing-3);
margin-top: var(--spacing-6);
}
.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;
}
@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="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 class="page-container">
<div class="page-header">
<h1 class="page-title">회의 예약</h1>
<p class="page-subtitle">새로운 회의를 예약하고 참석자를 초대하세요</p>
</div>
<div class="reservation-layout">
<!-- 입력 폼 -->
<div class="form-section">
<h1>회의 예약</h1>
<p style="color: var(--gray-500); margin-bottom: var(--spacing-8);">
회의 정보를 입력하고 참석자를 초대하세요
</p>
<form id="reservationForm">
<!-- 회의 제목 -->
<div class="input-group">
<label class="input-label" for="meetingTitle">
회의 제목 <span style="color: var(--error-main);">*</span>
</label>
<input
type="text"
id="meetingTitle"
class="input"
placeholder="예: 2025년 1분기 전략 회의"
maxlength="100"
required>
<span class="input-error hidden" id="titleError"></span>
</div>
<!-- 날짜 및 시간 -->
<div class="datetime-group">
<div class="input-group">
<label class="input-label" for="meetingDate">
날짜 <span style="color: var(--error-main);">*</span>
</label>
<input
type="date"
id="meetingDate"
class="input"
required>
<span class="input-error hidden" id="dateError"></span>
</div>
<div class="input-group">
<label class="input-label" for="startTime">
시작 시간 <span style="color: var(--error-main);">*</span>
</label>
<input
type="time"
id="startTime"
class="input"
required>
</div>
</div>
<div class="datetime-group">
<div class="input-group">
<label class="input-label" for="endTime">
종료 시간 <span style="color: var(--error-main);">*</span>
</label>
<input
type="time"
id="endTime"
class="input"
required>
<span class="input-error hidden" id="timeError"></span>
</div>
<div class="input-group">
<label class="input-label">&nbsp;</label>
<div style="padding: var(--spacing-3) 0; color: var(--gray-500); font-size: 0.875rem;">
예상 소요: <span id="duration">-</span>
</div>
</div>
</div>
<!-- 장소 -->
<div class="input-group">
<label class="input-label" for="location">장소</label>
<input
type="text"
id="location"
class="input"
placeholder="예: 3층 회의실"
maxlength="200"
:disabled="isOnline">
<div class="checkbox-item mt-2">
<input type="checkbox" id="isOnline">
<label for="isOnline">온라인 회의</label>
</div>
<input
type="url"
id="onlineLink"
class="input mt-2 hidden"
placeholder="Zoom, Teams 링크를 입력하세요">
</div>
<!-- 참석자 -->
<div class="input-group">
<label class="input-label" for="participantEmail">
참석자 <span style="color: var(--error-main);">*</span>
</label>
<input
type="email"
id="participantEmail"
class="input"
placeholder="참석자 이메일을 입력하세요">
<button type="button" class="btn btn-secondary btn-sm mt-2" id="addParticipant">
+ 참석자 추가
</button>
<div class="participant-tags" id="participantTags"></div>
<span class="input-error hidden" id="participantError"></span>
</div>
<!-- 회의 설명 -->
<div class="input-group">
<label class="input-label" for="description">회의 설명</label>
<textarea
id="description"
class="textarea"
placeholder="회의 목적 및 안건을 입력하세요"
maxlength="1000"></textarea>
</div>
<!-- 알림 설정 -->
<div class="input-group">
<label class="input-label">알림 설정</label>
<div class="checkbox-group">
<div class="checkbox-item">
<input type="checkbox" id="notify30" checked>
<label for="notify30">회의 30분 전 알림</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="notify60">
<label for="notify60">회의 1시간 전 알림</label>
</div>
</div>
</div>
<!-- 반복 설정 -->
<div class="input-group">
<label class="input-label" for="repeat">반복 설정</label>
<select id="repeat" class="select">
<option value="none">반복 안 함</option>
<option value="daily">매일</option>
<option value="weekly">매주</option>
<option value="monthly">매월</option>
</select>
<div class="input-group mt-4 hidden" id="repeatEndGroup">
<label class="input-label" for="repeatEnd">반복 종료일</label>
<input type="date" id="repeatEnd" class="input">
</div>
</div>
<!-- 캘린더 연동 -->
<div class="input-group">
<label class="input-label">캘린더 연동</label>
<div class="checkbox-group">
<div class="checkbox-item">
<input type="checkbox" id="googleCalendar">
<label for="googleCalendar">Google Calendar에 추가</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="outlook">
<label for="outlook">Outlook에 추가</label>
</div>
</div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons">
<button type="button" class="btn btn-text" onclick="navigateTo('02-대시보드.html')">
취소
</button>
<div style="display: flex; gap: var(--spacing-3);">
<button type="button" class="btn btn-secondary" id="saveDraft">
저장 후 나중에 계속
</button>
<button type="submit" class="btn btn-primary">
다음: 템플릿 선택 →
</button>
</div>
</div>
</form>
</div>
<!-- 미리보기 -->
<div class="preview-section">
<div class="preview-card">
<div class="preview-title">회의 미리보기</div>
<div class="preview-item">
<div class="preview-label">회의 제목</div>
<div class="preview-value" id="previewTitle">-</div>
</div>
<div class="preview-item">
<div class="preview-label">날짜 및 시간</div>
<div class="preview-value" id="previewDateTime">-</div>
</div>
<div class="preview-item">
<div class="preview-label">장소</div>
<div class="preview-value" id="previewLocation">-</div>
</div>
<div class="preview-item">
<div class="preview-label">참석자</div>
<div class="preview-value" id="previewParticipants">-</div>
</div>
<div class="preview-item">
<div class="preview-label">회의 설명</div>
<div class="preview-value" id="previewDescription" style="white-space: pre-wrap;">-</div>
</div>
<div class="form-container">
<form id="meetingForm">
<div class="form-group">
<label for="title" class="form-label">회의 제목 *</label>
<input type="text" id="title" class="form-input" placeholder="예: 2025년 1분기 기획 회의" required maxlength="100">
</div>
</div>
<div class="form-group">
<label for="date" class="form-label">날짜 *</label>
<input type="date" id="date" class="form-input" required>
</div>
<div class="form-group">
<label for="time" class="form-label">시간 *</label>
<input type="time" id="time" class="form-input" required>
</div>
<div class="form-group">
<label for="location" class="form-label">장소</label>
<input type="text" id="location" class="form-input" placeholder="예: 본사 2층 대회의실" maxlength="200">
</div>
<div class="form-group">
<label for="attendees" class="form-label">참석자 (이메일, 쉼표로 구분) *</label>
<input type="text" id="attendees" class="form-input" placeholder="예: user1@example.com, user2@example.com" required>
</div>
<div class="form-group">
<label for="description" class="form-label">회의 설명</label>
<textarea id="description" class="form-textarea" placeholder="회의 목적과 안건을 간략히 작성하세요"></textarea>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary" style="flex: 1;">회의 예약하기</button>
<button type="button" class="btn btn-secondary" onclick="history.back()">취소</button>
</div>
</form>
</div>
</div>
<script src="common.js"></script>
<script>
// 참석자 목록
let participants = [];
const form = document.getElementById('meetingForm');
// 오늘 날짜를 최소값으로 설정
const today = new Date().toISOString().split('T')[0];
document.getElementById('meetingDate').min = today;
document.getElementById('meetingDate').value = today;
document.getElementById('repeatEnd').min = today;
// 최소 날짜를 오늘로 설정
document.getElementById('date').min = new Date().toISOString().split('T')[0];
// 시작 시간 기본값 설정
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) => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
// 유효성 검사
const title = document.getElementById('meetingTitle').value.trim();
const date = document.getElementById('meetingDate').value;
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();
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
},
// 새 회의 생성
const newMeeting = {
id: 'm-' + Date.now(),
title,
date: `${date} ${time}`,
location: location || '미정',
status: 'scheduled',
createdAt: new Date().toISOString()
attendees: attendees.split(',').map(email => email.trim()),
description: description || ''
};
// LocalStorage에 임시 저장
sessionStorage.setItem('newMeeting', JSON.stringify(meetingData));
// 저장
const meetings = MeetingApp.Storage.get('meetings', []);
meetings.unshift(newMeeting);
MeetingApp.Storage.set('meetings', meetings);
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');
MeetingApp.Toast.success('회의가 예약되었습니다!');
setTimeout(() => {
navigateTo('02-대시보드.html');
}, 500);
window.location.href = '04-템플릿선택.html?meetingId=' + newMeeting.id;
}, 1000);
});
// 초기 업데이트
calculateDuration();
updatePreview();
</script>
</body>
</html>
+151 -453
View File
@@ -3,535 +3,233 @@
<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>
.template-container {
max-width: 1536px;
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 1024px;
margin: 0 auto;
padding: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-4);
}
.progress-bar {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-4);
.page-header {
margin-bottom: var(--spacing-8);
text-align: center;
}
.progress-step {
display: flex;
align-items: center;
gap: var(--spacing-2);
.page-title {
font-size: var(--font-size-h1);
color: var(--color-gray-900);
margin-bottom: 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;
.page-subtitle {
font-size: var(--font-size-body);
color: var(--color-gray-500);
}
.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);
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-6);
margin-bottom: var(--spacing-8);
}
.template-card {
background: white;
background: var(--color-white);
border: 2px solid var(--color-gray-200);
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;
position: relative;
}
.template-card:hover {
border-color: var(--color-primary-main);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
transform: translateY(-4px);
}
.template-card.selected {
border-color: var(--primary-main);
border-color: var(--color-primary-main);
border-width: 3px;
background-color: rgba(0, 217, 177, 0.05);
}
.template-header {
.template-card.selected::after {
content: '✓';
position: absolute;
top: var(--spacing-3);
right: var(--spacing-3);
background-color: var(--color-primary-main);
color: var(--color-white);
width: 28px;
height: 28px;
border-radius: var(--radius-full);
display: flex;
align-items: center;
gap: var(--spacing-3);
margin-bottom: var(--spacing-3);
justify-content: center;
font-weight: var(--font-weight-bold);
}
.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;
font-size: 48px;
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);
.template-title {
font-size: var(--font-size-h4);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-2);
border-radius: var(--radius-md);
}
.template-description {
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
line-height: var(--line-height-relaxed);
margin-bottom: var(--spacing-4);
}
.template-sections {
display: flex;
align-items: center;
gap: var(--spacing-2);
cursor: move;
flex-wrap: wrap;
gap: var(--spacing-1);
}
.drag-handle {
color: var(--gray-400);
cursor: grab;
.section-tag {
font-size: var(--font-size-caption);
padding: var(--spacing-1) var(--spacing-2);
background-color: var(--color-gray-100);
color: var(--color-gray-600);
border-radius: var(--radius-sm);
}
.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);
gap: var(--spacing-3);
justify-content: center;
}
@media (max-width: 1023px) {
.template-layout {
grid-template-columns: 1fr;
}
.template-grid {
grid-template-columns: 1fr;
}
.preview-panel {
position: static;
}
@media (max-width: 767px) {
.page-title { font-size: var(--font-size-h2); }
.template-grid { grid-template-columns: 1fr; }
.action-buttons { flex-direction: column; }
.action-buttons .btn { width: 100%; }
}
</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 class="page-container">
<div class="page-header">
<h1 class="page-title">회의록 템플릿 선택</h1>
<p class="page-subtitle">회의 유형에 맞는 템플릿을 선택하여 효율적으로 회의록을 작성하세요</p>
</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 class="template-grid">
<!-- 일반 회의 템플릿 -->
<div class="template-card" data-template="general">
<div class="template-icon">📋</div>
<h3 class="template-title">일반 회의</h3>
<p class="template-description">
가장 기본적인 회의록 형식입니다. 모든 유형의 회의에 적합합니다.
</p>
<div class="template-sections">
<span class="section-tag">참석자</span>
<span class="section-tag">안건</span>
<span class="section-tag">논의 내용</span>
<span class="section-tag">결정 사항</span>
<span class="section-tag">Todo</span>
</div>
</div>
<!-- 미리보기 및 커스터마이징 -->
<div class="preview-panel">
<div class="preview-title">템플릿 미리보기</div>
<div id="previewContent" class="preview-empty">
템플릿을 선택하면<br>여기에 미리보기가 표시됩니다
<!-- 스크럼 회의 템플릿 -->
<div class="template-card" data-template="scrum">
<div class="template-icon">🏃</div>
<h3 class="template-title">스크럼 회의</h3>
<p class="template-description">
데일리 스탠드업이나 스프린트 회의에 최적화된 템플릿입니다.
</p>
<div class="template-sections">
<span class="section-tag">어제 한 일</span>
<span class="section-tag">오늘 할 일</span>
<span class="section-tag">이슈/블로커</span>
<span class="section-tag">다음 스프린트</span>
</div>
</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="template-card" data-template="kickoff">
<div class="template-icon">🚀</div>
<h3 class="template-title">프로젝트 킥오프</h3>
<p class="template-description">
새 프로젝트 시작 시 필요한 모든 정보를 담는 템플릿입니다.
</p>
<div class="template-sections">
<span class="section-tag">프로젝트 개요</span>
<span class="section-tag">목표</span>
<span class="section-tag">일정</span>
<span class="section-tag">역할 분담</span>
<span class="section-tag">리스크</span>
</div>
</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 class="template-card" data-template="weekly">
<div class="template-icon">📅</div>
<h3 class="template-title">주간 회의</h3>
<p class="template-description">
매주 반복되는 정기 회의에 적합한 템플릿입니다.
</p>
<div class="template-sections">
<span class="section-tag">주간 실적</span>
<span class="section-tag">주요 이슈</span>
<span class="section-tag">다음 주 계획</span>
<span class="section-tag">공지사항</span>
</div>
</div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons">
<button type="button" class="btn btn-text" onclick="navigateTo('03-회의예약.html')">
← 뒤로
<button type="button" class="btn btn-secondary" onclick="history.back()">이전으로</button>
<button type="button" class="btn btn-primary" id="startMeetingBtn" disabled>
회의 시작하기
</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 startBtn = document.getElementById('startMeetingBtn');
const templateCards = document.querySelectorAll('.template-card');
// 템플릿 아이콘 매핑
const templateIcons = {
general: '📋',
scrum: '🔄',
kickoff: '🚀',
weekly: '📅'
};
templateCards.forEach(card => {
card.addEventListener('click', () => {
// 기존 선택 해제
templateCards.forEach(c => c.classList.remove('selected'));
// 템플릿 렌더링
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');
// 새로운 선택
card.classList.add('selected');
selectedTemplate = card.getAttribute('data-template');
startBtn.disabled = false;
});
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', () => {
startBtn.addEventListener('click', () => {
if (!selectedTemplate) {
showToast('템플릿을 선택하세요.', 'warning');
MeetingApp.Toast.warning('템플릿을 선택해주세요');
return;
}
const template = getTemplate(selectedTemplate);
const finalTemplate = {
...template,
sections: customSections
};
// URL에서 meetingId 가져오기
const urlParams = new URLSearchParams(window.location.search);
const meetingId = urlParams.get('meetingId');
sessionStorage.setItem('selectedTemplate', JSON.stringify(finalTemplate));
showToast('템플릿이 선택되었습니다.', 'success');
// 선택한 템플릿 저장
MeetingApp.Storage.set('selectedTemplate', {
meetingId: meetingId,
template: selectedTemplate,
timestamp: new Date().toISOString()
});
MeetingApp.Toast.success('템플릿이 선택되었습니다');
setTimeout(() => {
navigateTo('05-회의진행.html');
window.location.href = '05-회의진행.html?meetingId=' + meetingId;
}, 500);
});
// 초기화
renderTemplates();
// 페이지 로드 시 일반 회의 템플릿 기본 선택 (선택적)
// templateCards[0].click();
</script>
</body>
</html>
+128 -444
View File
@@ -3,497 +3,181 @@
<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>
.verification-container {
max-width: 1200px;
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 800px;
margin: 0 auto;
padding: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-4);
}
.verification-header {
.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);
}
.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 {
.stats-grid {
display: grid;
grid-template-columns: 1fr 400px;
gap: var(--spacing-8);
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--spacing-4);
margin-bottom: var(--spacing-8);
}
.section-list-panel {
background: white;
.stat-card {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
box-shadow: var(--shadow-sm);
padding: var(--spacing-5);
text-align: center;
}
.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;
.stat-value {
font-size: var(--font-size-h2);
font-weight: var(--font-weight-bold);
color: var(--color-primary-main);
margin-bottom: var(--spacing-2);
}
.section-name-verify {
font-weight: 600;
color: var(--gray-900);
.stat-label {
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
}
.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;
.summary-card {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
box-shadow: var(--shadow-sm);
position: sticky;
top: var(--spacing-8);
margin-bottom: var(--spacing-6);
}
.stats-item {
padding: var(--spacing-4);
background: var(--gray-50);
.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);
margin-bottom: var(--spacing-3);
font-size: var(--font-size-body-small);
font-weight: var(--font-weight-medium);
}
.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);
gap: var(--spacing-3);
justify-content: center;
}
@media (max-width: 1023px) {
.verification-layout {
grid-template-columns: 1fr;
}
.stats-panel {
position: static;
}
@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="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 class="page-container">
<div class="completion-icon"></div>
<h1 class="page-title">AI 검증이 완료되었습니다</h1>
<p class="page-subtitle">회의 내용이 분석되었습니다. 통계를 확인하고 회의를 종료하세요</p>
<!-- 통계 -->
<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>
<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 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="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 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 class="stats-item">
<div class="stats-label">검증 완료</div>
<div class="stats-value">
<span id="verifiedCount">0</span> / <span id="totalCount">0</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 class="stats-item">
<div class="stats-label">잠금 섹션</div>
<div class="stats-value" id="lockedCount">0</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 class="participant-progress">
<div class="stats-label" style="margin-bottom: var(--spacing-3);">참석자별 진행률</div>
<div id="participantProgress"></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 type="button" class="btn btn-text" onclick="navigateTo('05-회의진행.html')">
← 회의록으로 돌아가기
</button>
<button type="button" class="btn btn-primary" id="completeVerification" disabled>
검증 완료 및 회의 종료 →
<button class="btn btn-secondary" onclick="history.back()">회의로 돌아가기</button>
<button class="btn btn-primary" onclick="window.location.href='07-회의종료.html'">
회의 종료하기
</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);
}
MeetingApp.ready(() => {
console.log('검증 완료 페이지 로드됨');
});
// 초기화
loadSections();
</script>
</body>
</html>
+69 -530
View File
@@ -3,571 +3,110 @@
<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>
.completion-container {
max-width: 1400px;
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 600px;
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);
padding: var(--spacing-8) var(--spacing-4);
text-align: center;
}
.stat-icon {
font-size: 32px;
.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);
}
.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);
.page-subtitle {
font-size: var(--font-size-body);
color: var(--color-gray-500);
margin-bottom: var(--spacing-8);
}
.content-panel {
background: white;
.info-card {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
box-shadow: var(--shadow-sm);
margin-bottom: var(--spacing-6);
text-align: left;
}
.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 {
.info-item {
display: flex;
justify-content: space-between;
margin-top: var(--spacing-8);
padding-top: var(--spacing-6);
border-top: 1px solid var(--gray-200);
padding: var(--spacing-3) 0;
border-bottom: 1px solid var(--color-gray-100);
}
@media (max-width: 1023px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.content-grid {
grid-template-columns: 1fr;
}
.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="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="page-container">
<div class="completion-icon">🏁</div>
<h1 class="page-title">회의가 종료되었습니다</h1>
<p class="page-subtitle">회의록이 자동으로 저장되었습니다</p>
<!-- 통계 카드 -->
<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 class="info-card">
<div class="info-item">
<span class="info-label">회의 제목</span>
<span class="info-value">2025년 1분기 제품 기획 회의</span>
</div>
<div class="stat-card">
<div class="stat-icon">👥</div>
<div class="stat-value" id="participantCount">-</div>
<div class="stat-label">참석자 수</div>
<div class="info-item">
<span class="info-label">회의 시간</span>
<span class="info-value">45분</span>
</div>
<div class="stat-card">
<div class="stat-icon">💬</div>
<div class="stat-value" id="speechCount">-</div>
<div class="stat-label">총 발언 횟수</div>
<div class="info-item">
<span class="info-label">참석자</span>
<span class="info-value">3명</span>
</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 class="info-item">
<span class="info-label">생성된 Todo</span>
<span class="info-value">5개</span>
</div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons">
<button type="button" class="btn btn-text" onclick="navigateTo('05-회의진행.html')">
회의록으로 돌아가
<button class="btn btn-primary" onclick="window.location.href='08-최종확정.html'">
회의록 확정하
</button>
<button class="btn btn-secondary" onclick="window.location.href='02-대시보드.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);
}
MeetingApp.ready(() => {
console.log('회의 종료 페이지 로드됨');
// 회의 종료 알림
MeetingApp.Toast.success('회의가 성공적으로 종료되었습니다');
});
// 최종 확정
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>
+303
View File
@@ -0,0 +1,303 @@
<!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,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>
-749
View File
@@ -1,749 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo 관리 - 회의록 작성 및 공유 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
.todo-container {
max-width: 1800px;
margin: 0 auto;
padding: var(--spacing-8);
}
.todo-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-6);
}
.view-toggle {
display: flex;
gap: var(--spacing-2);
}
.view-btn {
padding: var(--spacing-2) var(--spacing-4);
border: 1px solid var(--gray-300);
background: white;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.view-btn.active {
background: var(--primary-main);
color: white;
border-color: var(--primary-main);
}
.filter-bar {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4);
background: white;
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-6);
box-shadow: var(--shadow-sm);
}
.filter-group {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.filter-label {
font-size: 0.875rem;
color: var(--gray-600);
font-weight: 500;
}
.filter-select {
padding: var(--spacing-2) var(--spacing-3);
border: 1px solid var(--gray-300);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: white;
cursor: pointer;
}
/* 칸반 보드 뷰 */
.kanban-board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-6);
}
.kanban-column {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-4);
box-shadow: var(--shadow-sm);
}
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-4);
padding-bottom: var(--spacing-3);
border-bottom: 2px solid var(--gray-200);
}
.column-title {
font-weight: 600;
font-size: 1.125rem;
color: var(--gray-900);
}
.column-count {
background: var(--gray-200);
color: var(--gray-700);
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--radius-full);
font-size: 0.875rem;
font-weight: 600;
}
.todo-card {
background: white;
border: 1px solid var(--gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-4);
margin-bottom: var(--spacing-3);
cursor: move;
transition: all var(--transition-fast);
position: relative;
}
.todo-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.todo-card.dragging {
opacity: 0.5;
}
.todo-card.high-priority {
border-left: 4px solid var(--error-main);
}
.todo-content-card {
font-weight: 500;
color: var(--gray-900);
margin-bottom: var(--spacing-3);
line-height: 1.5;
}
.todo-meta-row {
display: flex;
align-items: center;
gap: var(--spacing-3);
margin-bottom: var(--spacing-2);
font-size: 0.875rem;
color: var(--gray-600);
}
.todo-assignee {
display: flex;
align-items: center;
gap: var(--spacing-1);
}
.assignee-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--gray-200);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.todo-due-date {
display: flex;
align-items: center;
gap: var(--spacing-1);
}
.due-date.overdue {
color: var(--error-main);
font-weight: 600;
}
.due-date.today {
color: var(--warning-main);
font-weight: 600;
}
.progress-container {
margin-top: var(--spacing-3);
padding-top: var(--spacing-3);
border-top: 1px solid var(--gray-200);
}
.progress-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-2);
font-size: 0.75rem;
color: var(--gray-500);
}
.progress-bar-todo {
height: 6px;
background: var(--gray-200);
border-radius: 3px;
overflow: hidden;
}
.progress-fill-todo {
height: 100%;
background: var(--primary-main);
transition: width 0.3s;
}
.todo-actions {
display: flex;
gap: var(--spacing-2);
margin-top: var(--spacing-3);
}
.icon-btn-small {
width: 32px;
height: 32px;
border-radius: var(--radius-md);
border: 1px solid var(--gray-300);
background: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
font-size: 14px;
}
.icon-btn-small:hover {
background: var(--gray-50);
border-color: var(--primary-main);
}
.meeting-link {
font-size: 0.75rem;
color: var(--primary-main);
text-decoration: underline;
cursor: pointer;
}
/* 리스트 뷰 */
.list-view {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-6);
box-shadow: var(--shadow-sm);
display: none;
}
.list-view.active {
display: block;
}
.todo-table {
width: 100%;
border-collapse: collapse;
}
.todo-table th {
text-align: left;
padding: var(--spacing-3) var(--spacing-4);
background: var(--gray-50);
font-weight: 600;
color: var(--gray-700);
font-size: 0.875rem;
border-bottom: 2px solid var(--gray-200);
}
.todo-table td {
padding: var(--spacing-3) var(--spacing-4);
border-bottom: 1px solid var(--gray-200);
font-size: 0.875rem;
}
.todo-table tr:hover {
background: var(--gray-50);
}
.status-badge {
display: inline-block;
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--radius-md);
font-size: 0.75rem;
font-weight: 500;
}
.status-badge.pending {
background: var(--gray-200);
color: var(--gray-700);
}
.status-badge.in_progress {
background: var(--info-light);
color: var(--info-dark);
}
.status-badge.completed {
background: var(--success-light);
color: var(--success-dark);
}
.priority-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: var(--spacing-1);
}
.priority-dot.high {
background: var(--error-main);
}
.priority-dot.normal {
background: var(--gray-400);
}
@media (max-width: 1200px) {
.kanban-board {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="todo-container">
<!-- 헤더 -->
<div class="todo-header">
<div>
<h1>Todo 관리</h1>
<p style="color: var(--gray-500);">회의에서 생성된 Todo를 추적하고 관리하세요</p>
</div>
<div class="view-toggle">
<button class="view-btn active" id="kanbanViewBtn" onclick="switchView('kanban')">
📋 칸반 보드
</button>
<button class="view-btn" id="listViewBtn" onclick="switchView('list')">
📄 리스트 뷰
</button>
</div>
</div>
<!-- 필터 바 -->
<div class="filter-bar">
<div class="filter-group">
<span class="filter-label">담당자:</span>
<select class="filter-select" id="filterAssignee" onchange="applyFilters()">
<option value="all">전체</option>
<option value="me"></option>
</select>
</div>
<div class="filter-group">
<span class="filter-label">상태:</span>
<select class="filter-select" id="filterStatus" onchange="applyFilters()">
<option value="all">전체</option>
<option value="pending">시작 전</option>
<option value="in_progress">진행 중</option>
<option value="completed">완료</option>
</select>
</div>
<div class="filter-group">
<span class="filter-label">우선순위:</span>
<select class="filter-select" id="filterPriority" onchange="applyFilters()">
<option value="all">전체</option>
<option value="high">높음</option>
<option value="normal">보통</option>
<option value="low">낮음</option>
</select>
</div>
<div class="filter-group">
<span class="filter-label">정렬:</span>
<select class="filter-select" id="sortBy" onchange="applyFilters()">
<option value="dueDate">마감일순</option>
<option value="priority">우선순위순</option>
<option value="created">생성일순</option>
</select>
</div>
</div>
<!-- 칸반 보드 뷰 -->
<div class="kanban-board" id="kanbanView">
<div class="kanban-column" data-status="pending">
<div class="column-header">
<div class="column-title">시작 전</div>
<div class="column-count" id="pendingCount">0</div>
</div>
<div id="pendingColumn" class="column-content"></div>
</div>
<div class="kanban-column" data-status="in_progress">
<div class="column-header">
<div class="column-title">진행 중</div>
<div class="column-count" id="inProgressCount">0</div>
</div>
<div id="inProgressColumn" class="column-content"></div>
</div>
<div class="kanban-column" data-status="completed">
<div class="column-header">
<div class="column-title">완료</div>
<div class="column-count" id="completedCount">0</div>
</div>
<div id="completedColumn" class="column-content"></div>
</div>
</div>
<!-- 리스트 뷰 -->
<div class="list-view" id="listView">
<table class="todo-table">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" id="selectAll" onchange="toggleSelectAll()">
</th>
<th style="width: 100px;">상태</th>
<th>내용</th>
<th style="width: 120px;">담당자</th>
<th style="width: 100px;">마감일</th>
<th style="width: 80px;">우선순위</th>
<th style="width: 100px;">진행률</th>
<th style="width: 80px;">액션</th>
</tr>
</thead>
<tbody id="todoTableBody"></tbody>
</table>
</div>
</div>
<button class="fab" onclick="navigateTo('02-대시보드.html')" title="대시보드로 이동">
🏠
</button>
<script src="common.js"></script>
<script>
let todos = [];
let filteredTodos = [];
let draggedTodo = null;
// Todo 데이터 로드
function loadTodos() {
todos = getAllTodos();
filteredTodos = todos;
renderKanban();
renderList();
}
// 칸반 보드 렌더링
function renderKanban() {
const columns = {
pending: document.getElementById('pendingColumn'),
in_progress: document.getElementById('inProgressColumn'),
completed: document.getElementById('completedColumn')
};
// 초기화
Object.values(columns).forEach(col => col.innerHTML = '');
// Todo 분류 및 렌더링
const grouped = {
pending: filteredTodos.filter(t => t.status === 'pending'),
in_progress: filteredTodos.filter(t => t.status === 'in_progress'),
completed: filteredTodos.filter(t => t.status === 'completed')
};
Object.entries(grouped).forEach(([status, todoList]) => {
columns[status].innerHTML = todoList.map(todo => renderTodoCard(todo)).join('');
});
// 카운트 업데이트
document.getElementById('pendingCount').textContent = grouped.pending.length;
document.getElementById('inProgressCount').textContent = grouped.in_progress.length;
document.getElementById('completedCount').textContent = grouped.completed.length;
// 드래그 이벤트 추가
addDragEvents();
}
// Todo 카드 렌더링
function renderTodoCard(todo) {
const user = getUserById(todo.assignee);
const ddayText = getDdayText(todo.dueDate);
const isOverdue = ddayText.includes('지남');
const isToday = ddayText === '오늘';
return `
<div class="todo-card ${todo.priority === 'high' ? 'high-priority' : ''}"
draggable="true"
data-id="${todo.id}">
<div class="todo-content-card">${todo.content}</div>
<div class="todo-meta-row">
<div class="todo-assignee">
<div class="assignee-avatar">${user?.avatar || '👤'}</div>
<span>${user?.name || '알 수 없음'}</span>
</div>
<div class="todo-due-date">
<span>📅</span>
<span class="due-date ${isOverdue ? 'overdue' : ''} ${isToday ? 'today' : ''}">
${ddayText}
</span>
</div>
</div>
${todo.status !== 'completed' ? `
<div class="progress-container">
<div class="progress-label-row">
<span>진행률</span>
<span>${todo.progress}%</span>
</div>
<div class="progress-bar-todo">
<div class="progress-fill-todo" style="width: ${todo.progress}%"></div>
</div>
</div>
` : ''}
<div class="todo-actions">
<button class="icon-btn-small" title="상세" onclick="viewTodoDetail('${todo.id}')">👁️</button>
<button class="icon-btn-small" title="수정" onclick="editTodo('${todo.id}')">✏️</button>
${todo.meetingId ? `<span class="meeting-link" onclick="goToMeeting('${todo.meetingId}')">📋 회의록</span>` : ''}
</div>
</div>
`;
}
// 드래그 이벤트 추가
function addDragEvents() {
const cards = document.querySelectorAll('.todo-card');
const columns = document.querySelectorAll('.column-content');
cards.forEach(card => {
card.addEventListener('dragstart', handleDragStart);
card.addEventListener('dragend', handleDragEnd);
});
columns.forEach(column => {
column.addEventListener('dragover', handleDragOver);
column.addEventListener('drop', handleDrop);
});
}
function handleDragStart(e) {
draggedTodo = e.target.dataset.id;
e.target.classList.add('dragging');
}
function handleDragEnd(e) {
e.target.classList.remove('dragging');
}
function handleDragOver(e) {
e.preventDefault();
}
function handleDrop(e) {
e.preventDefault();
const newStatus = e.target.closest('.kanban-column').dataset.status;
const todo = todos.find(t => t.id === draggedTodo);
if (todo && todo.status !== newStatus) {
todo.status = newStatus;
if (newStatus === 'completed') {
todo.progress = 100;
} else if (newStatus === 'in_progress' && todo.progress === 0) {
todo.progress = 10;
}
saveTodo(todo);
renderKanban();
showToast(`Todo 상태가 "${newStatus === 'pending' ? '시작 전' : newStatus === 'in_progress' ? '진행 중' : '완료'}"로 변경되었습니다.`, 'success');
}
draggedTodo = null;
}
// 리스트 뷰 렌더링
function renderList() {
const tbody = document.getElementById('todoTableBody');
if (filteredTodos.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; padding: var(--spacing-8); color: var(--gray-400);">Todo가 없습니다</td></tr>';
return;
}
tbody.innerHTML = filteredTodos.map(todo => {
const user = getUserById(todo.assignee);
const ddayText = getDdayText(todo.dueDate);
return `
<tr>
<td><input type="checkbox" class="todo-checkbox" data-id="${todo.id}"></td>
<td>
<select class="status-badge ${todo.status}" onchange="changeStatus('${todo.id}', this.value)">
<option value="pending" ${todo.status === 'pending' ? 'selected' : ''}>시작 전</option>
<option value="in_progress" ${todo.status === 'in_progress' ? 'selected' : ''}>진행 중</option>
<option value="completed" ${todo.status === 'completed' ? 'selected' : ''}>완료</option>
</select>
</td>
<td>${todo.content}</td>
<td>
<div style="display: flex; align-items: center; gap: var(--spacing-2);">
<span>${user?.avatar || '👤'}</span>
<span>${user?.name || '알 수 없음'}</span>
</div>
</td>
<td>${ddayText}</td>
<td>
<span class="priority-dot ${todo.priority}"></span>
${todo.priority === 'high' ? '높음' : todo.priority === 'low' ? '낮음' : '보통'}
</td>
<td>
<div style="display: flex; align-items: center; gap: var(--spacing-2);">
<div style="flex: 1; height: 6px; background: var(--gray-200); border-radius: 3px; overflow: hidden;">
<div style="height: 100%; background: var(--primary-main); width: ${todo.progress}%;"></div>
</div>
<span style="font-size: 0.75rem;">${todo.progress}%</span>
</div>
</td>
<td>
<button class="icon-btn-small" title="수정" onclick="editTodo('${todo.id}')">✏️</button>
</td>
</tr>
`;
}).join('');
}
// 뷰 전환
function switchView(view) {
if (view === 'kanban') {
document.getElementById('kanbanView').style.display = 'grid';
document.getElementById('listView').classList.remove('active');
document.getElementById('kanbanViewBtn').classList.add('active');
document.getElementById('listViewBtn').classList.remove('active');
} else {
document.getElementById('kanbanView').style.display = 'none';
document.getElementById('listView').classList.add('active');
document.getElementById('kanbanViewBtn').classList.remove('active');
document.getElementById('listViewBtn').classList.add('active');
}
}
// 필터 적용
function applyFilters() {
const assigneeFilter = document.getElementById('filterAssignee').value;
const statusFilter = document.getElementById('filterStatus').value;
const priorityFilter = document.getElementById('filterPriority').value;
const sortBy = document.getElementById('sortBy').value;
const currentUser = getCurrentUser();
filteredTodos = todos.filter(todo => {
if (assigneeFilter === 'me' && todo.assignee !== currentUser.id) return false;
if (statusFilter !== 'all' && todo.status !== statusFilter) return false;
if (priorityFilter !== 'all' && todo.priority !== priorityFilter) return false;
return true;
});
// 정렬
filteredTodos.sort((a, b) => {
if (sortBy === 'dueDate') {
return new Date(a.dueDate) - new Date(b.dueDate);
} else if (sortBy === 'priority') {
const priorityOrder = { high: 0, normal: 1, low: 2 };
return priorityOrder[a.priority] - priorityOrder[b.priority];
} else {
return new Date(b.createdAt || 0) - new Date(a.createdAt || 0);
}
});
renderKanban();
renderList();
}
// 상태 변경
function changeStatus(todoId, newStatus) {
const todo = todos.find(t => t.id === todoId);
if (todo) {
todo.status = newStatus;
if (newStatus === 'completed') {
todo.progress = 100;
}
saveTodo(todo);
applyFilters();
showToast('상태가 변경되었습니다.', 'success');
}
}
// Todo 수정
function editTodo(todoId) {
const todo = todos.find(t => t.id === todoId);
if (todo) {
const newProgress = prompt(`진행률을 입력하세요 (0-100):`, todo.progress);
if (newProgress !== null) {
const progress = parseInt(newProgress);
if (!isNaN(progress) && progress >= 0 && progress <= 100) {
todo.progress = progress;
if (progress === 100) {
todo.status = 'completed';
} else if (progress > 0 && todo.status === 'pending') {
todo.status = 'in_progress';
}
saveTodo(todo);
applyFilters();
showToast('진행률이 업데이트되었습니다.', 'success');
}
}
}
}
// Todo 상세 보기
function viewTodoDetail(todoId) {
const todo = todos.find(t => t.id === todoId);
if (todo) {
alert(`Todo 상세\n\n내용: ${todo.content}\n담당자: ${getUserName(todo.assignee)}\n마감일: ${todo.dueDate}\n우선순위: ${todo.priority}\n진행률: ${todo.progress}%\n상태: ${todo.status}`);
}
}
// 회의록으로 이동
function goToMeeting(meetingId) {
alert('회의록 화면으로 이동합니다.');
}
// 전체 선택
function toggleSelectAll() {
const selectAll = document.getElementById('selectAll').checked;
document.querySelectorAll('.todo-checkbox').forEach(cb => {
cb.checked = selectAll;
});
}
// 초기화
loadTodos();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+517 -369
View File
@@ -1,408 +1,556 @@
/* ============================================
회의록 작성 및 공유 개선 서비스 - 공통 Javascript
============================================ */
/*
* 회의록 작성 및 공유 개선 서비스 - 공통 자바스크립트
* 버전: 1.0
* 작성일: 2025-10-20
* 레퍼런스: 스타일 가이드 v1.0
*/
// === LocalStorage 키 상수 ===
const STORAGE_KEYS = {
CURRENT_USER: 'currentUser',
USERS: 'users',
MEETINGS: 'meetings',
TODOS: 'todos',
INITIALIZED: 'initialized'
// ===== 전역 상태 관리 =====
const AppState = {
currentUser: {
id: 'user-001',
name: '김민준',
email: 'minjun.kim@example.com',
avatar: 'https://ui-avatars.com/api/?name=김민준&background=00D9B1&color=fff'
},
meetings: [],
todos: []
};
// === 예제 데이터 초기화 ===
function initializeData() {
if (localStorage.getItem(STORAGE_KEYS.INITIALIZED)) {
return;
// ===== 유틸리티 함수 =====
/**
* DOM 준비 완료 시 콜백 실행
*/
function ready(callback) {
if (document.readyState !== 'loading') {
callback();
} else {
document.addEventListener('DOMContentLoaded', callback);
}
// 사용자 데이터
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;
/**
* 날짜 포맷팅 (YYYY-MM-DD HH:mm)
*/
function formatDateTime(date) {
const d = new Date(date);
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');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function saveToStorage(key, data) {
localStorage.setItem(key, JSON.stringify(data));
/**
* 상대 시간 표현 (방금 전, 3분 전, 2시간 전 등)
*/
function timeAgo(date) {
const now = new Date();
const past = new Date(date);
const diff = Math.floor((now - past) / 1000); // 초 단위
if (diff < 60) return '방금 전';
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`;
if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`;
if (diff < 2592000) return `${Math.floor(diff / 86400)}일 전`;
if (diff < 31536000) return `${Math.floor(diff / 2592000)}개월 전`;
return `${Math.floor(diff / 31536000)}년 전`;
}
// === 날짜/시간 유틸리티 ===
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}`;
}
/**
* D-day 계산
*/
function getDday(targetDate) {
const now = new Date();
now.setHours(0, 0, 0, 0);
const target = new Date(targetDate);
target.setHours(0, 0, 0, 0);
const diff = Math.floor((target - now) / (1000 * 60 * 60 * 24));
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}`;
if (diff > 0) return `D-${diff}`;
return `D+${Math.abs(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}`;
/**
* UUID 생성 (간단한 버전)
*/
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// === 사용자 관련 함수 ===
function getCurrentUser() {
return getFromStorage(STORAGE_KEYS.CURRENT_USER);
}
// ===== 모달 관리 =====
const Modal = {
/**
* 모달 열기
*/
open(modalId) {
const modal = document.getElementById(modalId);
if (!modal) return;
function getUserById(userId) {
const users = getFromStorage(STORAGE_KEYS.USERS) || [];
return users.find(u => u.id === userId);
}
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
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;
}
// backdrop 클릭 시 모달 닫기
const backdrop = modal.querySelector('.modal-backdrop');
if (backdrop) {
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) {
this.close(modalId);
}
});
}
`;
document.head.appendChild(style);
}
// === 모달 제어 ===
function openModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add('active');
// 닫기 버튼
const closeBtn = modal.querySelector('.modal-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => this.close(modalId));
}
},
/**
* 모달 닫기
*/
close(modalId) {
const modal = document.getElementById(modalId);
if (!modal) return;
modal.style.display = 'none';
document.body.style.overflow = 'auto';
}
}
};
function closeModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove('active');
// ===== 토스트 알림 =====
const Toast = {
container: null,
/**
* 토스트 컨테이너 초기화
*/
init() {
if (!this.container) {
this.container = document.createElement('div');
this.container.className = 'toast-container';
document.body.appendChild(this.container);
}
},
/**
* 토스트 표시
*/
show(message, type = 'info', duration = 4000) {
this.init();
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
const icons = {
success: '✓',
error: '✕',
warning: '⚠',
info: ''
};
toast.innerHTML = `
<div class="toast-icon">${icons[type] || icons.info}</div>
<div class="toast-content">
<div class="toast-message">${message}</div>
</div>
<button class="toast-close" onclick="this.parentElement.remove()">&times;</button>
`;
this.container.appendChild(toast);
// 자동 제거
setTimeout(() => {
if (toast.parentElement) {
toast.remove();
}
}, duration);
},
success(message) { this.show(message, 'success'); },
error(message) { this.show(message, 'error'); },
warning(message) { this.show(message, 'warning'); },
info(message) { this.show(message, 'info'); }
};
// ===== 로컬 스토리지 관리 =====
const Storage = {
/**
* 데이터 저장
*/
set(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (e) {
console.error('Storage.set error:', e);
return false;
}
},
/**
* 데이터 가져오기
*/
get(key, defaultValue = null) {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch (e) {
console.error('Storage.get error:', e);
return defaultValue;
}
},
/**
* 데이터 삭제
*/
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;
}
}
}
};
// 모달 백드롭 클릭 시 닫기
document.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-backdrop')) {
closeModal(e.target.id);
// ===== API 호출 (Mock) =====
const API = {
/**
* 지연 시뮬레이션
*/
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
},
/**
* GET 요청 (Mock)
*/
async get(endpoint) {
await this.delay(500);
console.log(`API GET: ${endpoint}`);
return { success: true, data: {} };
},
/**
* POST 요청 (Mock)
*/
async post(endpoint, data) {
await this.delay(500);
console.log(`API POST: ${endpoint}`, data);
return { success: true, data: {} };
},
/**
* PUT 요청 (Mock)
*/
async put(endpoint, data) {
await this.delay(500);
console.log(`API PUT: ${endpoint}`, data);
return { success: true, data: {} };
},
/**
* DELETE 요청 (Mock)
*/
async delete(endpoint) {
await this.delay(500);
console.log(`API DELETE: ${endpoint}`);
return { success: true };
}
});
};
// === 화면 전환 ===
// ===== 페이지 네비게이션 =====
function navigateTo(page) {
// 실제로는 SPA 라우팅이나 페이지 이동 처리
// 프로토타입에서는 링크 클릭으로 처리
console.log(`Navigate to: ${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 }
]
// ===== 폼 유효성 검사 =====
const Validator = {
/**
* 이메일 유효성 검사
*/
isEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
},
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 }
]
/**
* 필수 입력 검사
*/
required(value) {
return value !== null && value !== undefined && value.trim() !== '';
},
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 }
]
/**
* 최소 길이 검사
*/
minLength(value, min) {
return value && value.length >= min;
},
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 }
]
/**
* 최대 길이 검사
*/
maxLength(value, max) {
return value && value.length <= max;
},
/**
* 폼 필드 에러 표시
*/
showError(inputElement, message) {
inputElement.classList.add('error');
let errorElement = inputElement.nextElementSibling;
if (!errorElement || !errorElement.classList.contains('form-error')) {
errorElement = document.createElement('span');
errorElement.className = 'form-error';
inputElement.parentElement.appendChild(errorElement);
}
errorElement.textContent = message;
},
/**
* 폼 필드 에러 제거
*/
clearError(inputElement) {
inputElement.classList.remove('error');
const errorElement = inputElement.nextElementSibling;
if (errorElement && errorElement.classList.contains('form-error')) {
errorElement.remove();
}
}
};
function getTemplate(templateId) {
return TEMPLATES[templateId];
}
// ===== 로딩 상태 관리 =====
const Loading = {
/**
* 로딩 표시
*/
show(target = 'body') {
const element = typeof target === 'string' ? document.querySelector(target) : target;
if (!element) return;
function getAllTemplates() {
return Object.values(TEMPLATES);
}
const spinner = document.createElement('div');
spinner.className = 'spinner';
spinner.id = 'global-spinner';
spinner.style.position = 'fixed';
spinner.style.top = '50%';
spinner.style.left = '50%';
spinner.style.transform = 'translate(-50%, -50%)';
spinner.style.zIndex = '9999';
// === 페이지 로드 시 데이터 초기화 ===
document.addEventListener('DOMContentLoaded', () => {
initializeData();
document.body.appendChild(spinner);
},
/**
* 로딩 숨김
*/
hide() {
const spinner = document.getElementById('global-spinner');
if (spinner) {
spinner.remove();
}
}
};
// ===== 회의록 관련 유틸리티 =====
const MeetingUtils = {
/**
* 회의 상태 레이블
*/
getStatusLabel(status) {
const labels = {
'scheduled': '예정',
'in_progress': '진행 중',
'ended': '종료',
'draft': '작성 중',
'verifying': '검증 중',
'confirmed': '확정됨'
};
return labels[status] || status;
},
/**
* 회의 상태 클래스
*/
getStatusClass(status) {
const classes = {
'draft': 'status-draft',
'verifying': 'status-verifying',
'confirmed': 'status-confirmed'
};
return classes[status] || 'badge-neutral';
},
/**
* Todo 우선순위 레이블
*/
getPriorityLabel(priority) {
const labels = {
'high': '높음',
'medium': '보통',
'low': '낮음'
};
return labels[priority] || priority;
},
/**
* Todo 상태 레이블
*/
getTodoStatusLabel(status) {
const labels = {
'todo': '시작 전',
'in_progress': '진행 중',
'done': '완료'
};
return labels[status] || status;
}
};
// ===== 예시 데이터 생성 =====
const MockData = {
/**
* 샘플 회의 데이터
*/
generateMeetings() {
return [
{
id: 'm-001',
title: '2025년 1분기 제품 기획 회의',
date: '2025-10-25 14:00',
location: '본사 2층 대회의실',
status: 'scheduled',
attendees: ['김민준', '박서연', '이준호', '최유진'],
description: '신규 회의록 서비스 기획 논의'
},
{
id: 'm-002',
title: '주간 스크럼 회의',
date: '2025-10-21 10:00',
location: 'Zoom',
status: 'confirmed',
attendees: ['김민준', '이준호', '최유진'],
description: '지난 주 진행 상황 공유 및 이번 주 계획'
},
{
id: 'm-003',
title: 'AI 기능 개선 회의',
date: '2025-10-23 15:00',
location: '본사 3층 소회의실',
status: 'in_progress',
attendees: ['박서연', '이준호'],
description: 'LLM 기반 회의록 자동 작성 개선 방안'
}
];
},
/**
* 샘플 Todo 데이터
*/
generateTodos() {
return [
{
id: 't-001',
title: 'API 명세서 작성',
assignee: '이준호',
dueDate: '2025-10-25',
priority: 'high',
status: 'in_progress',
progress: 60,
meetingId: 'm-002'
},
{
id: 't-002',
title: 'UI 프로토타입 디자인',
assignee: '최유진',
dueDate: '2025-10-23',
priority: 'medium',
status: 'done',
progress: 100,
meetingId: 'm-002'
},
{
id: 't-003',
title: '데이터베이스 스키마 설계',
assignee: '이준호',
dueDate: '2025-10-28',
priority: 'high',
status: 'todo',
progress: 0,
meetingId: 'm-001'
}
];
}
};
// ===== 초기화 =====
ready(() => {
console.log('Common.js loaded');
// 로컬 스토리지에서 상태 복원
const savedMeetings = Storage.get('meetings');
const savedTodos = Storage.get('todos');
if (!savedMeetings) {
AppState.meetings = MockData.generateMeetings();
Storage.set('meetings', AppState.meetings);
} else {
AppState.meetings = savedMeetings;
}
if (!savedTodos) {
AppState.todos = MockData.generateTodos();
Storage.set('todos', AppState.todos);
} else {
AppState.todos = savedTodos;
}
console.log('AppState initialized:', AppState);
});
// ===== Export (전역 네임스페이스) =====
window.MeetingApp = {
AppState,
Modal,
Toast,
Storage,
API,
Validator,
Loading,
MeetingUtils,
MockData,
navigateTo,
formatDateTime,
timeAgo,
getDday,
generateUUID,
ready
};
File diff suppressed because it is too large Load Diff
-1449
View File
File diff suppressed because it is too large Load Diff