작업 파일 정리 및 실시간 회의록 플로우 추가

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hiondal
2025-10-22 14:15:59 +09:00
parent 2b58bed5ce
commit bb921e10eb
30 changed files with 8735 additions and 0 deletions
+289
View File
@@ -0,0 +1,289 @@
# 벡터DB 임베딩용 관련자료 샘플 데이터
**작성일**: 2025-01-22
**버전**: v1.0
**작성자**: AI 개발팀
---
## 1. 개요
### 1.1 목적
회의록 작성 시 AI가 참조할 수 있는 관련 자료를 벡터DB에 임베딩하여, 맥락 기반 용어 설명 및 관련 회의록 자동 연결 기능을 제공하기 위한 샘플 데이터를 생성합니다.
### 1.2 데이터 구성
- **도메인**: 통신 업무 도메인 15개
- **데이터 소스 유형**: 4가지 (이전 회의록, 조직문서, 프로젝트 문서, 운영문서)
- **샘플 개수**: 각 도메인별 × 각 소스별 5개 = **총 300개**
---
## 2. 통신 업무 도메인 (15개)
| 번호 | 도메인 | 설명 |
|------|--------|------|
| 1 | 네트워크 인프라 | 네트워크 구축 및 운영, 유무선 통신망 관리, 5G/LTE, 기지국 |
| 2 | 기술 개발 및 연구 | 신기술 연구개발, AI/빅데이터, IoT, 클라우드 기술 |
| 3 | 고객 서비스 | 고객 상담 및 지원, VoC 관리, 서비스 품질 관리 |
| 4 | 영업 및 마케팅 | 요금제 기획, 프로모션, 유통채널 관리, B2B/B2C 영업 |
| 5 | 요금 및 청구 | 요금 청구 시스템, 과금 관리, 미수금 관리, 정산 업무 |
| 6 | 네트워크 운용 | 네트워크 모니터링, 장애 대응 및 복구, 품질 최적화 |
| 7 | 서비스 기획 및 상품 개발 | 신규 서비스 기획, 요금제 설계, 콘텐츠 서비스 |
| 8 | 정보보안 | 통신 보안, 개인정보 보호, 사이버 보안, 보안 정책 수립 |
| 9 | 시스템 운영 및 관리 | IT 시스템 운영, 데이터센터 관리, 클라우드 인프라 |
| 10 | 가입자 관리 | 가입자 정보 관리, 번호 이동, 개통 및 해지, 명의 변경 |
| 11 | 망 품질 관리 | 통신 품질 측정, 품질 개선, 서비스 레벨 관리 |
| 12 | 규제 대응 및 준법 | 통신 규제 대응, 법률 준수, 정부 정책 대응 |
| 13 | 기업 영업 | B2B 솔루션, 전용선 서비스, AICC, 데이터센터 서비스 |
| 14 | 로밍 및 국제 업무 | 국제 로밍 서비스, 해외 통신사 제휴, 국제 전화 서비스 |
| 15 | 신사업 | OTT 서비스, 콘텐츠 사업, 핀테크, 스마트홈/IoT 서비스 |
---
## 3. 데이터 소스 유형 (4가지)
### 3.1 이전 회의록 (meeting_minutes)
**형식**:
- 회의 제목, 일시, 참석자
- 논의 내용, 결정 사항, 액션 아이템
**예시 토픽**:
- 프로젝트 킥오프, 월간 리뷰, 장애 대응, 정책 수립
**메타데이터**:
- 회의 유형, 참석자 목록, 태그
### 3.2 조직문서 (manual)
**유형**:
- 업무 매뉴얼, 정책 및 규정, 표준화 문서
**예시**:
- 프로세스 가이드, 보안 정책, 업무 표준
**메타데이터**:
- 문서 카테고리, 버전, 승인자
### 3.3 프로젝트 문서 (project_doc)
**유형**:
- 요구사항 정의서, 설계 문서, 수행 결과서
**예시**:
- 프로젝트 계획서, 기술 설계서, 완료 보고서
**메타데이터**:
- 프로젝트명, 단계, 담당자
### 3.4 운영문서 (operation_doc)
**유형**:
- 장애 보고서, 고객 응대 문서
**예시**:
- 장애 분석 보고서, 고객 이슈 처리 가이드
**메타데이터**:
- 심각도, 영향 범위, 해결 상태
---
## 4. 데이터 구조
### 4.1 JSON 스키마
```json
{
"document_id": "도메인명_소스유형_일련번호",
"document_type": "meeting_minutes|manual|project_doc|operation_doc",
"title": "문서 제목",
"content": "실제 문서 내용 (500-1000자)",
"metadata": {
"domain": "도메인명",
"date": "YYYY-MM-DD",
"author": "작성자명",
"tags": ["태그1", "태그2", "태그3"],
"organization_id": "org_telecom_001",
"folder_id": "folder_도메인명"
}
}
```
### 4.2 필드 설명
| 필드 | 타입 | 설명 |
|------|------|------|
| document_id | string | 문서 고유 식별자 |
| document_type | enum | 문서 유형 (4가지 중 1개) |
| title | string | 문서 제목 |
| content | text | 실제 문서 내용 (청킹 대상) |
| metadata.domain | string | 업무 도메인 |
| metadata.date | date | 작성일 (2024-01-01 ~ 2025-01-22) |
| metadata.author | string | 작성자명 |
| metadata.tags | array | 태그 배열 (3-5개) |
| metadata.organization_id | string | 조직 ID |
| metadata.folder_id | string | 폴더 ID |
---
## 5. 샘플 데이터 생성 방법
### 5.1 자동 생성 스크립트
**위치**: `tools/generate_vector_samples.py`
**실행 방법**:
```bash
# Windows PowerShell 또는 CMD에서 실행
cd C:\Users\hiond\home\workspace\HGZero
python tools\generate_vector_samples.py
```
**출력 파일**: `data/samples/vector_db_samples_300.json`
### 5.2 생성 로직
#### 도메인별 키워드 매핑
각 도메인마다 관련 키워드 및 토픽을 정의하여 실제 통신 업무 상황을 반영합니다.
**예시** (네트워크 인프라):
- **키워드**: 5G, LTE, 기지국, 광케이블, RAN, 코어망, 백홀, 전송망
- **토픽**: 5G 구축, 기지국 설치, 망 이중화, 광케이블 교체, 커버리지 확대
#### 랜덤 요소
- 날짜: 2024-01-01 ~ 2025-01-22 범위에서 랜덤 선택
- 작성자: 16명의 작성자 풀에서 랜덤 선택
- 키워드 조합: 각 문서마다 2-3개 키워드를 랜덤 조합
#### 템플릿 기반 생성
각 문서 유형별로 표준 템플릿을 정의하고, 도메인 및 키워드를 치환하여 실제 문서처럼 생성합니다.
---
## 6. 생성 통계
### 6.1 전체 통계
- **총 샘플 개수**: 300개
- **도메인별**: 각 20개 (15개 도메인)
- **소스별**: 각 75개 (4가지 소스)
### 6.2 문서 유형별 분포
| 문서 유형 | 개수 | 비율 |
|-----------|------|------|
| 이전 회의록 (meeting_minutes) | 75개 | 25% |
| 조직문서 (manual) | 75개 | 25% |
| 프로젝트 문서 (project_doc) | 75개 | 25% |
| 운영문서 (operation_doc) | 75개 | 25% |
| **합계** | **300개** | **100%** |
### 6.3 도메인별 분포
| 도메인 | 회의록 | 매뉴얼 | 프로젝트 | 운영 | 합계 |
|--------|--------|--------|----------|------|------|
| 네트워크 인프라 | 5 | 5 | 5 | 5 | 20 |
| 기술 개발 및 연구 | 5 | 5 | 5 | 5 | 20 |
| 고객 서비스 | 5 | 5 | 5 | 5 | 20 |
| 영업 및 마케팅 | 5 | 5 | 5 | 5 | 20 |
| 요금 및 청구 | 5 | 5 | 5 | 5 | 20 |
| 네트워크 운용 | 5 | 5 | 5 | 5 | 20 |
| 서비스 기획 및 상품 개발 | 5 | 5 | 5 | 5 | 20 |
| 정보보안 | 5 | 5 | 5 | 5 | 20 |
| 시스템 운영 및 관리 | 5 | 5 | 5 | 5 | 20 |
| 가입자 관리 | 5 | 5 | 5 | 5 | 20 |
| 망 품질 관리 | 5 | 5 | 5 | 5 | 20 |
| 규제 대응 및 준법 | 5 | 5 | 5 | 5 | 20 |
| 기업 영업 | 5 | 5 | 5 | 5 | 20 |
| 로밍 및 국제 업무 | 5 | 5 | 5 | 5 | 20 |
| 신사업 | 5 | 5 | 5 | 5 | 20 |
| **합계** | **75** | **75** | **75** | **75** | **300** |
---
## 7. 벡터DB 임베딩 프로세스
### 7.1 데이터 정제
1. **텍스트 정제**: HTML 태그 제거, 특수문자 정규화
2. **청킹**: 문서를 1000 토큰 단위로 분할 (200 토큰 오버랩)
3. **메타데이터 추출**: JSON 메타데이터 파싱
### 7.2 벡터화
- **임베딩 모델**: text-embedding-3-small (OpenAI)
- **차원**: 1536
- **비용**: $0.02 / 1M 토큰
### 7.3 PostgreSQL + pgvector 적재
```sql
INSERT INTO document_chunks (
document_id,
chunk_index,
content,
embedding,
metadata,
organization_id
) VALUES (?, ?, ?, ?, ?, ?);
```
---
## 8. 활용 방안
### 8.1 맥락 기반 용어 설명
1. 회의록 작성 중 전문 용어 감지
2. Vector DB에서 유사도 검색 (Top-5)
3. Claude AI에게 맥락 기반 설명 요청
### 8.2 관련 회의록 자동 연결
1. 현재 회의록 내용 벡터화
2. Vector DB에서 유사 회의록 검색
3. 관련도 점수 계산 (70% 이상)
4. 최대 5개 회의록 자동 연결
### 8.3 대시보드 참고자료
- 관련 회의록 탭: 유사 회의록 목록
- 프로젝트 문서 탭: 관련 프로젝트 문서
- 조직 문서 탭: 관련 매뉴얼 및 정책
---
## 9. 품질 검증
### 9.1 데이터 품질 기준
- **실무 반영도**: 실제 통신 업무 용어 및 상황 반영 여부
- **일관성**: 도메인 및 문서 유형별 일관성 유지
- **다양성**: 키워드 및 토픽의 다양성 확보
### 9.2 검증 방법
1. **샘플링 검사**: 각 도메인별 1-2개 샘플 수동 검토
2. **키워드 분석**: 도메인 관련 키워드 포함 여부 확인
3. **메타데이터 검증**: 필수 필드 누락 여부 확인
---
## 10. 향후 계획
### 10.1 데이터 확장
- **단계 1** (현재): 300개 샘플 (도메인별 × 소스별 5개)
- **단계 2** (Phase 1 완료 후): 600개 샘플 (도메인별 × 소스별 10개)
- **단계 3** (Phase 2 이후): 1,500개 샘플 (도메인별 × 소스별 25개)
### 10.2 품질 개선
- 실제 회의록 데이터 반영
- 도메인 전문가 검토 및 피드백 반영
- 사용자 피드백 기반 지속 업데이트
---
## 11. 참고 자료
### 11.1 관련 문서
- [회의 주제 관련 자료 수집 및 Claude AI 연동 구현 방안](../구현방안-관련자료.md)
- [ADR-001: Vector Database 및 Embedding 통합 아키텍처](../ADR-001-Vector-DB-통합아키텍처.md)
- [통신업무도메인](../../reference/통신업무도메인.md)
### 11.2 기술 스택
- **Vector DB**: PostgreSQL + pgvector
- **Embedding**: OpenAI text-embedding-3-small (1536 dim)
- **검색**: 하이브리드 (벡터 유사도 + 키워드 매칭)
- **캐싱**: Redis + Claude Prompt Cache
---
**문서 버전**: v1.0
**최종 수정**: 2025-01-22
**담당자**: AI 개발팀
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,753 @@
# 실시간 회의록 작성 전체 프로세스
## 1. 개요
### 1.1 문서 목적
회의 진행 중 참석자의 발언을 실시간으로 텍스트로 변환하고, AI 기반으로 회의록 내용을 정리하며, 전문용어와 관련 자료를 자동으로 추출하여 모든 참석자에게 실시간으로 제공하는 전체 프로세스를 정의합니다.
### 1.2 핵심 목표
- **자동 회의록 작성**: AI 기반 내용 정리 및 구조화
- **지능형 분석**: 전문용어 자동 감지 및 맥락 기반 설명
- **관련 자료 연결**: 과거 회의록 및 사내 문서 자동 링크
- **데이터 영속성**: 모든 처리 결과를 데이터베이스에 저장
- **실시간 동기화**: 모든 참석자에게 동일한 정보 제공
---
## 2. 전체 프로세스 개요
### 2.1 5단계 처리 플로우
```
Phase 1: 음성 → 텍스트 변환
Phase 2: AI 분석 (병렬 처리)
- 회의록 내용 정리
- 전문용어 추출
- 관련 자료 검색
Phase 3: 데이터 저장 (영속화)
Phase 4: 처리 결과 실시간 동기화
Phase 5: 통합 화면 표시
```
---
## 3. Phase 1: 음성 → 텍스트 변환
### 3.1 음성 녹음
**참여자**: 참석자, 웹 애플리케이션
**처리 흐름**:
1. 참석자가 회의 중 발언
2. 웹 애플리케이션이 마이크를 통해 음성 녹음
3. 실시간 스트리밍 방식으로 STT Service에 전송
**기술 스택**:
- Web Audio API
- WebRTC MediaStream
- 실시간 스트리밍 (WebSocket 또는 gRPC)
---
### 3.2 STT 변환
**참여자**: STT Service, Azure Speech
**처리 흐름**:
1. STT Service가 음성 스트림 수신
2. Azure Speech API로 실시간 변환 요청
3. 변환된 텍스트 세그먼트 수신 (화자, 타임스탬프)
4. STT DB에 텍스트 세그먼트 저장
**데이터 구조**:
```json
{
"segmentId": "seg-12345",
"meetingId": "meeting-123",
"speaker": "홍길동",
"timestamp": "2025-01-22T14:30:15.123Z",
"text": "다음 분기 OKR 달성을 위한 KPI 설정이 필요합니다",
"confidence": 0.97
}
```
**성능 목표**:
- 발언 → 텍스트 변환: **< 1초**
- STT 변환 정확도: **> 95%**
- 화자 식별 정확도: **> 90%**
---
### 3.3 이벤트 발행
**참여자**: STT Service, RabbitMQ
**처리 흐름**:
- 5초 간격으로 변환된 텍스트 세그먼트를 배치 처리
- RabbitMQ에 `TranscriptReady` 이벤트 발행
**이벤트 페이로드**:
```json
{
"eventType": "TranscriptReady",
"meetingId": "meeting-123",
"timestamp": "2025-01-22T14:30:20.000Z",
"segments": [
{
"segmentId": "seg-12345",
"speaker": "홍길동",
"text": "다음 분기 OKR 달성을 위한 KPI 설정이 필요합니다",
"timestamp": "2025-01-22T14:30:15.123Z"
},
{
"segmentId": "seg-12346",
"speaker": "김철수",
"text": "각 팀별 목표를 먼저 정의하고 진행하면 좋겠습니다",
"timestamp": "2025-01-22T14:30:18.456Z"
}
]
}
```
---
## 4. Phase 2: AI 분석 (병렬 처리)
### 4.1 회의록 내용 정리
**담당**: AI Service (LLM 기반)
**처리 단계**:
#### 4.1.1 발언 내용 분석
```
입력 텍스트:
"홍길동: 다음 분기 OKR 달성을 위한 KPI 설정이 필요합니다"
"김철수: 각 팀별 목표를 먼저 정의하고 진행하면 좋겠습니다"
↓ LLM 분석
주제 파악: "다음 분기 목표 설정"
핵심 내용: "OKR 및 KPI 설정 필요성"
액션 아이템: "팀별 목표 정의"
```
#### 4.1.2 주제별 분류
```
주제 1: 목표 설정
- OKR 설정 필요성
- KPI 정의
주제 2: 실행 계획
- 팀별 목표 정의 우선
- 진행 방안
```
#### 4.1.3 핵심 내용 요약
```
요약:
다음 분기 OKR 달성을 위해 KPI 설정이 필요하며,
각 팀별 목표를 먼저 정의한 후 진행하기로 합의함.
```
#### 4.1.4 구조화된 회의록 생성
```json
{
"meetingId": "meeting-123",
"summary": "다음 분기 OKR 달성을 위한 KPI 설정 논의",
"topics": [
{
"topicId": "topic-001",
"title": "목표 설정",
"content": "OKR 및 KPI 설정 필요성 논의",
"participants": ["홍길동", "김철수"],
"timestamp": "2025-01-22T14:30:15.123Z"
}
],
"actionItems": [
{
"actionId": "action-001",
"description": "팀별 목표 정의",
"assignee": "각 팀",
"dueDate": null
}
]
}
```
**성능 목표**:
- 회의록 정리: **< 5초** (5초 배치당)
---
### 4.2 전문용어 추출
**담당**: AI Service (NLP + LLM)
**처리 단계**:
#### 4.2.1 전문용어 자동 감지
```
입력: "다음 분기 OKR 달성을 위한 KPI 설정이 필요합니다"
↓ NLP 분석
감지된 용어:
- OKR (대문자 약어)
- KPI (대문자 약어)
```
**감지 알고리즘**:
- 대문자 약어 패턴 (2-5자)
- 용어 사전 기반 매칭
- 문맥 기반 전문용어 분류
#### 4.2.2 벡터 임베딩 생성
```
용어: "OKR"
OpenAI Embeddings API
벡터: [0.123, -0.456, 0.789, ...] (1536차원)
```
#### 4.2.3 맥락 기반 설명 생성
```
LLM 프롬프트:
"""
다음 용어를 우리 조직의 실제 사용 맥락에서 설명해주세요:
- 용어: OKR
- 발언 문맥: "다음 분기 OKR 달성을 위한 KPI 설정이 필요합니다"
- 관련 문서: [OKR 운영 가이드, 2024 Q4 OKR 회의록]
"""
↓ LLM 응답
설명:
"OKR(Objectives and Key Results)은 우리 조직에서 분기별 목표 관리
체계로 사용됩니다. 각 팀은 분기 초에 3-5개의 핵심 목표(Objectives)와
각 목표당 3-5개의 핵심 결과(Key Results)를 설정하며, KPI와 함께
성과 측정 지표로 활용됩니다."
```
**결과 데이터 구조**:
```json
{
"terms": [
{
"termId": "term-001",
"term": "OKR",
"position": [7, 10],
"explanation": "OKR(Objectives and Key Results)은...",
"category": "경영관리",
"relatedDocs": ["doc-123", "doc-456"]
},
{
"termId": "term-002",
"term": "KPI",
"position": [18, 21],
"explanation": "KPI(Key Performance Indicator)는...",
"category": "경영관리",
"relatedDocs": ["doc-789"]
}
]
}
```
**성능 목표**:
- 전문용어 감지 및 설명: **< 3초**
---
### 4.3 관련 자료 검색
**담당**: AI Service (RAG - 벡터 검색)
**처리 단계**:
#### 4.3.1 유사도 기반 문서 검색
```
쿼리 벡터: [OKR 임베딩]
Vector DB 검색 (Pinecone/Weaviate)
유사도 점수 계산 (Cosine Similarity)
관련도 70% 이상 문서 필터링
상위 5개 문서 선택
결과:
1. "OKR 운영 가이드" (관련도: 95%)
2. "2024 Q4 OKR 회의록" (관련도: 88%)
3. "OKR vs KPI 비교" (관련도: 82%)
4. "목표 설정 가이드" (관련도: 78%)
5. "분기별 성과 관리" (관련도: 73%)
```
#### 4.3.2 관련 회의록 검색
```
현재 회의 주제: "다음 분기 목표 설정"
과거 회의록 DB 검색
주제 유사도 기반 필터링
최근 6개월 이내 회의록 우선
결과:
1. "2024 Q4 목표 설정 회의" (2024-10-15)
2. "팀별 OKR 리뷰 회의" (2024-12-20)
3. "성과 지표 개선 논의" (2024-11-08)
```
#### 4.3.3 참고 자료 링크 생성
```json
{
"relatedDocs": [
{
"docId": "doc-123",
"title": "OKR 운영 가이드",
"type": "document",
"url": "/documents/okr-guide",
"relevance": 0.95,
"summary": "조직 내 OKR 설정 및 운영 방법"
},
{
"docId": "meeting-456",
"title": "2024 Q4 목표 설정 회의",
"type": "meeting",
"url": "/meetings/meeting-456",
"relevance": 0.88,
"summary": "지난 분기 목표 설정 과정 및 결과"
}
]
}
```
**성능 목표**:
- 관련 자료 검색: **< 2초**
---
## 5. Phase 3: 데이터 저장
### 5.1 AI Service 저장
**데이터베이스**: AI DB (PostgreSQL)
**저장 내용**:
- 전문용어 사전 (TermGlossary)
- 용어 설명 (TermExplanation)
- 문서 임베딩 (DocumentEmbedding)
- 처리 이력 (ProcessingHistory)
---
### 5.2 Meeting Service 저장
**데이터베이스**: Meeting DB (PostgreSQL)
**저장 내용**:
#### 5.2.1 회의록 데이터
```sql
-- Transcript 테이블
INSERT INTO transcripts (
meeting_id,
summary,
content,
status,
created_at
) VALUES (
'meeting-123',
'다음 분기 OKR 달성을 위한 KPI 설정 논의',
'{구조화된 회의록 JSON}',
'in_progress',
'2025-01-22T14:30:20.000Z'
);
```
#### 5.2.2 전문용어 매핑
```sql
-- TranscriptTerms 테이블
INSERT INTO transcript_terms (
transcript_id,
term_id,
position,
context
) VALUES (
'transcript-123',
'term-001',
'[7, 10]',
'다음 분기 OKR 달성을 위한 KPI 설정이 필요합니다'
);
```
#### 5.2.3 관련 자료 링크
```sql
-- TranscriptRelatedDocs 테이블
INSERT INTO transcript_related_docs (
transcript_id,
doc_id,
doc_type,
relevance,
created_at
) VALUES (
'transcript-123',
'doc-123',
'document',
0.95,
'2025-01-22T14:30:20.000Z'
);
```
**성능 목표**:
- 데이터 저장: **< 1초**
---
### 5.3 이벤트 발행
**참여자**: AI Service, RabbitMQ
**이벤트**: `TranscriptSummaryCreated`
**페이로드**:
```json
{
"eventType": "TranscriptSummaryCreated",
"meetingId": "meeting-123",
"transcriptId": "transcript-123",
"timestamp": "2025-01-22T14:30:20.000Z",
"summary": "다음 분기 OKR 달성을 위한 KPI 설정 논의",
"termCount": 2,
"relatedDocCount": 5
}
```
---
## 6. Phase 4: 처리 결과 실시간 동기화
### 6.1 Meeting Service 처리
**참여자**: Meeting Service
**처리 흐름**:
1. `TranscriptSummaryCreated` 이벤트 구독
2. 회의 상태 업데이트 (DB)
3. Collaboration Service에 회의록 업데이트 요청 (REST API)
**API 요청**:
```http
POST /api/collaboration/meetings/{meetingId}/transcript-update
Content-Type: application/json
{
"transcriptId": "transcript-123",
"summary": " OKR KPI ",
"terms": [...],
"relatedDocs": [...]
}
```
---
### 6.2 Collaboration Service 동기화
**참여자**: Collaboration Service, 모든 참석자
**처리 흐름**:
1. Meeting Service로부터 업데이트 요청 수신
2. WebSocket으로 모든 참석자에게 통합 결과 전송
**WebSocket 메시지**:
```json
{
"type": "transcript-summary-update",
"meetingId": "meeting-123",
"timestamp": "2025-01-22T14:30:22.000Z",
"data": {
"summary": "다음 분기 OKR 달성을 위한 KPI 설정 논의",
"topics": [
{
"title": "목표 설정",
"content": "OKR 및 KPI 설정 필요성 논의",
"participants": ["홍길동", "김철수"]
}
],
"terms": [
{
"term": "OKR",
"explanation": "OKR(Objectives and Key Results)은...",
"relatedDocs": [...]
},
{
"term": "KPI",
"explanation": "KPI(Key Performance Indicator)는...",
"relatedDocs": [...]
}
],
"relatedDocs": [
{
"title": "OKR 운영 가이드",
"url": "/documents/okr-guide",
"relevance": 0.95
}
],
"actionItems": [
{
"description": "팀별 목표 정의",
"assignee": "각 팀"
}
]
}
}
```
**성능 목표**:
- WebSocket 전송: **< 500ms**
---
## 7. Phase 5: 통합 화면 표시
### 7.1 UI 컴포넌트 구성
#### 7.1.1 메인 영역
**정리된 회의록**:
- 주제별 탭 구성
- 핵심 내용 요약
- 액션 아이템 하이라이트
- 전문용어 하이라이트 (밑줄)
#### 7.1.2 사이드바 (우측)
**전문용어 패널**:
- 감지된 전문용어 목록
- 클릭 시 상세 설명 표시
- 관련 문서 빠른 링크
**관련 자료 패널**:
- 관련도 순으로 정렬
- 문서 유형 아이콘 표시
- 클릭 시 새 탭에서 열기
**액션 아이템 패널**:
- 실시간 추출된 할 일 목록
- 담당자 할당 가능
- 진행 상황 추적
### 7.2 인터랙션
**전문용어 호버**:
```
사용자가 하이라이트된 용어에 마우스 오버
툴팁 표시 (간단한 정의)
클릭 시 상세 설명 팝업
```
**관련 자료 클릭**:
```
사용자가 관련 문서 링크 클릭
새 탭에서 문서 열기
문서 내용 표시 (PDF, 회의록, Wiki 등)
```
**액션 아이템 생성**:
```
AI가 자동 추출한 할 일 표시
사용자가 담당자 할당
Todo Service에 자동 등록
```
---
## 8. 전체 프로세스 성능 목표
### 8.1 단계별 성능
| Phase | 단계 | 목표 시간 |
|-------|------|----------|
| 1 | 음성 → 텍스트 변환 | < 1초 |
| 2-1 | 회의록 내용 정리 | < 5초 |
| 2-2 | 전문용어 추출 | < 3초 |
| 2-3 | 관련 자료 검색 | < 2초 |
| 3 | 데이터 저장 | < 1초 |
| 4 | 처리 결과 동기화 | < 1초 |
| 5 | 통합 화면 표시 | < 500ms |
| **전체** | **발언 → 완전 처리** | **< 13초** |
### 8.2 사용자 체감 성능
| 항목 | 목표 |
|------|------|
| 발언 → AI 분석 완료 | < 10초 |
| 분석 결과 → 화면 표시 | < 2초 |
| 용어 설명 표시 | 즉시 (클릭 시) |
| 관련 자료 로딩 | < 3초 |
---
## 9. 데이터 흐름 요약
```
음성 (User)
텍스트 (STT Service → STT DB)
이벤트 (RabbitMQ)
┌─────────┬─────────┬─────────┐
↓ ↓ ↓
회의록 전문용어 관련자료
정리 추출 검색
(AI) (AI) (AI)
↓ ↓ ↓
AI DB AI DB AI DB
↓ ↓ ↓
통합 결과 (Meeting DB)
실시간 동기화 (Collab)
통합 화면 표시 (WebApp)
```
---
## 10. 기술 스택
### 10.1 서비스별 기술
| 서비스 | 핵심 기술 | 용도 |
|--------|----------|------|
| STT Service | Azure Speech API | 음성-텍스트 변환 |
| AI Service | OpenAI GPT-4 | 회의록 정리, 용어 설명 |
| AI Service | OpenAI Embeddings | 벡터 임베딩 생성 |
| AI Service | Pinecone/Weaviate | 벡터 검색 |
| Meeting Service | PostgreSQL | 회의록 데이터 저장 |
| Collaboration Service | Socket.io | 실시간 WebSocket |
| 웹 애플리케이션 | React | UI 렌더링 |
| 웹 애플리케이션 | Web Audio API | 음성 녹음 |
### 10.2 인프라
| 컴포넌트 | 기술 | 용도 |
|----------|------|------|
| 메시지 브로커 | RabbitMQ | 이벤트 기반 통신 |
| 캐시 | Redis | 용어 설명 캐싱 |
| 데이터베이스 | PostgreSQL | 영속성 저장소 |
| 벡터 DB | Pinecone | 문서 임베딩 검색 |
---
## 11. 에러 처리 및 복구
### 11.1 STT 변환 실패
**시나리오**: Azure Speech API 장애, 네트워크 지연
**대응**:
1. 음성 데이터 로컬 버퍼링 (최대 5분)
2. 재시도 (Exponential Backoff, 최대 3회)
3. 실패 시 사용자 알림 + 수동 입력 옵션
4. 회의 종료 후 배치 재처리
### 11.2 AI 처리 실패
**시나리오**: LLM API 타임아웃, 용량 초과
**대응**:
1. AI 처리 백그라운드 재시도
2. 처리 완료 시 화면 업데이트
3. 최종 실패 시 회의 종료 후 재처리
4. 사용자에게 처리 상태 알림
### 11.3 데이터 저장 실패
**시나리오**: DB 연결 끊김, 디스크 풀
**대응**:
1. Redis에 임시 저장 (백업)
2. DB 복구 후 자동 동기화
3. 관리자 알림 발송
4. 데이터 무결성 검증
### 11.4 WebSocket 연결 끊김
**시나리오**: 네트워크 불안정, 클라이언트 재시작
**대응**:
1. 자동 재연결 (최대 5회, 10초 간격)
2. 재연결 성공 시 누락 데이터 동기화 (REST API)
3. 최종 실패 시 폴링 모드로 전환
4. 사용자에게 연결 상태 표시
---
## 12. 보안 및 프라이버시
### 12.1 음성 데이터 보안
- **전송 암호화**: TLS 1.3
- **저장 암호화**: AES-256
- **보관 기간**: 회의 종료 후 90일
- **자동 삭제**: 90일 경과 후 완전 삭제
### 12.2 회의록 접근 제어
- **참석자 전용**: 회의 참석자만 조회 가능
- **권한 관리**: 작성자/참석자/뷰어 권한 분리
- **공유 설정**: 링크 기반 공유 시 비밀번호 설정
- **감사 로그**: 모든 접근 기록 저장
### 12.3 전문용어 및 관련 자료
- **권한 기반 필터링**: 사용자 권한에 따른 자료 필터링
- **민감 정보 마스킹**: 개인정보 자동 마스킹
- **접근 로그**: 문서 조회 이력 기록
---
## 13. 향후 개선 방향
### 13.1 단기 (1-3개월)
- [ ] 다국어 지원 (영어, 일본어)
- [ ] 회의록 템플릿 자동 적용
- [ ] 감정 분석 (발언 톤 분석)
- [ ] 오프라인 모드 지원
### 13.2 중기 (3-6개월)
- [ ] 실시간 번역 (동시통역)
- [ ] AI 기반 회의 진행 가이드
- [ ] 회의 품질 점수 및 개선 제안
- [ ] 발언 시간 분석 및 밸런스 알림
### 13.3 장기 (6-12개월)
- [ ] 회의 효율성 예측 모델
- [ ] 자동 후속 회의 스케줄링
- [ ] 조직 지식 그래프 구축
- [ ] 회의록 기반 의사결정 추적
---
## 14. 다이어그램
**파일**: `design/backend/logical/realtime-meeting-transcript-flow.mmd`
### 14.1 렌더링 방법
1. [Mermaid Live Editor](https://mermaid.live/) 접속
2. `realtime-meeting-transcript-flow.mmd` 파일 내용 복사
3. 붙여넣기 → Sequence Diagram 확인
---
## 15. 참고 자료
- [논리 아키텍처 설계서](./logical-architecture.md)
- [실시간 음성-용어설명 프로세스](./realtime-stt-rag-flow.md)
- [Azure Speech 문서](https://learn.microsoft.com/azure/cognitive-services/speech-service/)
- [OpenAI API 문서](https://platform.openai.com/docs/)
- [Pinecone 벡터 DB](https://www.pinecone.io/)
- [Socket.io 문서](https://socket.io/)
---
## 16. 문서 이력
| 버전 | 작성일 | 작성자 | 변경 내용 |
|------|--------|--------|----------|
| 1.0 | 2025-01-22 | 길동 (Architect) | 초안 작성 |
| 1.1 | 2025-01-22 | 길동 (Architect) | 실시간 텍스트 표시 단계 제거, 5단계 프로세스로 단순화 |
@@ -0,0 +1,103 @@
sequenceDiagram
participant User as 참석자
participant WebApp as 웹 애플리케이션
participant STT as STT Service
participant Azure as Azure Speech
participant MQ as RabbitMQ
participant AI as AI Service<br/>(RAG 통합)
participant Meeting as Meeting Service
participant Collab as Collaboration Service
participant Others as 다른 참석자들
%% ========================================
%% Phase 1: 실시간 음성 변환
%% ========================================
Note over User,Azure: Phase 1: 실시간 음성 → 텍스트 변환
User->>WebApp: 발언 (음성)
WebApp->>STT: 음성 데이터 스트리밍
activate STT
STT->>Azure: 음성-텍스트 변환 요청
Azure-->>STT: 변환된 텍스트<br/>(화자, 타임스탬프)
STT->>STT: 텍스트 세그먼트 저장<br/>(STT DB)
deactivate STT
%% 5초마다 이벤트 발행
STT->>MQ: TranscriptReady 이벤트<br/>(5초 간격 배치)
%% ========================================
%% Phase 2: AI 처리 (병렬)
%% ========================================
Note over MQ,AI: Phase 2: AI 분석 (병렬 처리)
MQ->>AI: TranscriptReady 구독
activate AI
par 회의록 내용 정리
AI->>AI: 1. 발언 내용 분석<br/>(LLM)
AI->>AI: 2. 주제별 분류
AI->>AI: 3. 핵심 내용 요약
AI->>AI: 4. 구조화된 회의록 생성
and 전문용어 추출
AI->>AI: 5. 전문용어 자동 감지<br/>(NLP)
AI->>AI: 6. 벡터 임베딩 생성
AI->>AI: 7. 맥락 기반 설명 생성<br/>(LLM)
and 관련 자료 검색
AI->>AI: 8. 유사도 기반 문서 검색<br/>(관련도 70%+)
AI->>AI: 9. 관련 회의록 검색<br/>(최대 5개)
AI->>AI: 10. 참고 자료 링크 생성
end
deactivate AI
%% ========================================
%% Phase 3: 데이터 저장
%% ========================================
Note over AI,Meeting: Phase 3: 데이터 저장
AI->>AI: AI 처리 결과 저장<br/>(AI DB)
AI->>MQ: TranscriptSummaryCreated<br/>이벤트 발행
MQ->>Meeting: TranscriptSummaryCreated<br/>구독
activate Meeting
Meeting->>Meeting: 회의록 데이터 저장<br/>(Meeting DB):
Note right of Meeting: - 정리된 회의록<br/>- 전문용어 목록<br/>- 관련 자료 링크<br/>- 타임스탬프
deactivate Meeting
%% ========================================
%% Phase 4: 실시간 동기화 (결과 표시)
%% ========================================
Note over Meeting,Others: Phase 4: 처리 결과 실시간 동기화
Meeting->>Collab: 회의록 업데이트 요청<br/>(REST API)
activate Collab
Collab->>WebApp: 통합 결과 Push<br/>(WebSocket):
Note right of Collab: - 정리된 회의록<br/>- 전문용어 + 설명<br/>- 관련 자료 링크
Collab->>Others: 통합 결과 Push<br/>(WebSocket)
deactivate Collab
%% ========================================
%% Phase 5: 화면 표시
%% ========================================
Note over WebApp,Others: Phase 5: 통합 화면 표시
activate WebApp
WebApp->>WebApp: UI 업데이트:
Note right of WebApp: 1. 정리된 회의록<br/>2. 전문용어 하이라이트<br/>3. 용어 설명 팝업<br/>4. 관련 자료 사이드바
WebApp->>User: 통합 화면 표시
deactivate WebApp
activate Others
Others->>Others: 동일한 UI 업데이트
Others->>Others: 통합 화면 표시
deactivate Others
%% ========================================
%% 성능 요약
%% ========================================
Note over User,Others: 전체 프로세스 성능 목표<br/>- 음성 → 텍스트: < 2초<br/>- AI 처리: < 10초<br/>- 저장 및 동기화: < 2초
@@ -1,352 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그인 - 회의록 작성 및 공유 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
/* 페이지 전용 스타일 */
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #00D9B1 0%, #6366F1 100%);
}
.login-card {
background-color: var(--color-white);
border-radius: var(--radius-xl);
padding: var(--spacing-10);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 480px;
margin: var(--spacing-4);
}
.login-header {
text-align: center;
margin-bottom: var(--spacing-8);
}
.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-size: var(--font-size-h2);
font-weight: var(--font-weight-bold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-2);
}
.login-subtitle {
font-size: var(--font-size-body);
color: var(--color-gray-500);
}
#loginForm {
margin-bottom: var(--spacing-6);
}
.form-group {
margin-bottom: var(--spacing-5);
}
.form-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-5);
}
.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;
}
.forgot-password {
font-size: var(--font-size-body-small);
color: var(--color-primary-main);
text-decoration: none;
transition: color var(--transition-fast);
}
.forgot-password:hover {
color: var(--color-primary-dark);
}
.login-footer {
text-align: center;
padding-top: var(--spacing-6);
border-top: 1px solid var(--color-gray-200);
}
.login-footer-text {
font-size: var(--font-size-body-small);
color: var(--color-gray-500);
}
.login-footer a {
color: var(--color-primary-main);
font-weight: var(--font-weight-medium);
text-decoration: none;
transition: color var(--transition-fast);
}
.login-footer a:hover {
color: var(--color-primary-dark);
}
/* 예시 크리덴셜 표시 */
.credential-hint {
background-color: var(--color-gray-50);
border: 1px dashed var(--color-gray-300);
border-radius: var(--radius-md);
padding: var(--spacing-3);
margin-bottom: var(--spacing-5);
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
}
.credential-hint-title {
font-weight: var(--font-weight-medium);
color: var(--color-gray-700);
margin-bottom: var(--spacing-2);
}
.credential-hint code {
background-color: var(--color-gray-200);
padding: 2px 6px;
border-radius: var(--radius-sm);
font-family: 'Consolas', monospace;
font-size: var(--font-size-caption);
}
/* 반응형 */
@media (max-width: 767px) {
.login-card {
padding: var(--spacing-6);
}
.login-title {
font-size: var(--font-size-h3);
}
.form-footer {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-3);
}
}
</style>
</head>
<body>
<div class="login-card">
<!-- 헤더 -->
<div class="login-header">
<div class="login-logo">M</div>
<h1 class="login-title">회의록 서비스</h1>
<p class="login-subtitle">스마트한 협업의 시작</p>
</div>
<!-- 예시 크리덴셜 (프로토타입용) -->
<div class="credential-hint">
<div class="credential-hint-title">📝 테스트 계정</div>
<div>이메일: <code>test@example.com</code></div>
<div>비밀번호: <code>password123</code></div>
</div>
<!-- 로그인 폼 -->
<form id="loginForm">
<div class="form-group">
<label for="email" class="form-label">이메일</label>
<input
type="email"
id="email"
class="form-input"
placeholder="example@company.com"
required
autocomplete="email"
>
</div>
<div class="form-group">
<label for="password" class="form-label">비밀번호</label>
<input
type="password"
id="password"
class="form-input"
placeholder="비밀번호를 입력하세요"
required
autocomplete="current-password"
>
</div>
<div class="form-footer">
<div class="checkbox-wrapper">
<input type="checkbox" id="rememberMe">
<label for="rememberMe">로그인 상태 유지</label>
</div>
<a href="#" class="forgot-password">비밀번호 찾기</a>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">
로그인
</button>
</form>
<!-- 푸터 -->
<div class="login-footer">
<p class="login-footer-text">
아직 계정이 없으신가요? <a href="#">회원가입</a>
</p>
</div>
</div>
<!-- JavaScript -->
<script src="common.js"></script>
<script>
// 로그인 폼 처리
const loginForm = document.getElementById('loginForm');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
const rememberMeCheckbox = document.getElementById('rememberMe');
// 페이지 로드 시 저장된 이메일 불러오기
MeetingApp.ready(() => {
const savedEmail = MeetingApp.Storage.get('savedEmail');
if (savedEmail) {
emailInput.value = savedEmail;
rememberMeCheckbox.checked = true;
}
});
// 폼 제출 핸들러
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
// 에러 초기화
MeetingApp.Validator.clearError(emailInput);
MeetingApp.Validator.clearError(passwordInput);
const email = emailInput.value.trim();
const password = passwordInput.value.trim();
// 유효성 검사
let isValid = true;
if (!MeetingApp.Validator.required(email)) {
MeetingApp.Validator.showError(emailInput, '이메일을 입력해주세요.');
isValid = false;
} else if (!MeetingApp.Validator.isEmail(email)) {
MeetingApp.Validator.showError(emailInput, '올바른 이메일 형식이 아닙니다.');
isValid = false;
}
if (!MeetingApp.Validator.required(password)) {
MeetingApp.Validator.showError(passwordInput, '비밀번호를 입력해주세요.');
isValid = false;
} else if (!MeetingApp.Validator.minLength(password, 6)) {
MeetingApp.Validator.showError(passwordInput, '비밀번호는 최소 6자 이상이어야 합니다.');
isValid = false;
}
if (!isValid) return;
// 로딩 표시
const submitButton = loginForm.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
submitButton.disabled = true;
submitButton.innerHTML = '<div class="spinner spinner-sm" style="border-color: white; border-top-color: transparent;"></div>';
try {
// API 호출 시뮬레이션
await MeetingApp.API.post('/api/auth/login', { email, password });
// 로그인 성공 시뮬레이션 (테스트 계정 체크)
if (email === 'test@example.com' && password === 'password123') {
// 사용자 정보 저장
MeetingApp.Storage.set('currentUser', {
id: 'user-001',
name: '김민준',
email: email,
avatar: 'https://ui-avatars.com/api/?name=김민준&background=00D9B1&color=fff',
role: 'user'
});
// 로그인 상태 유지 체크
if (rememberMeCheckbox.checked) {
MeetingApp.Storage.set('savedEmail', email);
MeetingApp.Storage.set('rememberMe', true);
} else {
MeetingApp.Storage.remove('savedEmail');
MeetingApp.Storage.remove('rememberMe');
}
// JWT 토큰 시뮬레이션
MeetingApp.Storage.set('authToken', 'mock-jwt-token-' + Date.now());
// 성공 토스트
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;
}
});
// 비밀번호 찾기 (프로토타입용)
document.querySelector('.forgot-password').addEventListener('click', (e) => {
e.preventDefault();
MeetingApp.Toast.info('비밀번호 찾기 기능은 준비 중입니다.');
});
// 회원가입 (프로토타입용)
document.querySelector('.login-footer a').addEventListener('click', (e) => {
e.preventDefault();
MeetingApp.Toast.info('회원가입 기능은 준비 중입니다.');
});
</script>
</body>
</html>
@@ -1,691 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>대시보드 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
/* 레이아웃 */
body {
margin: 0;
padding: 0;
}
/* Header */
.dashboard-header {
position: sticky;
top: 0;
z-index: var(--z-sticky);
background-color: var(--color-white);
border-bottom: 1px solid var(--color-gray-200);
padding: 0 var(--spacing-6);
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.header-left {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.logo {
width: 40px;
height: 40px;
background-color: var(--color-primary-main);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-white);
font-weight: var(--font-weight-bold);
font-size: 20px;
}
.service-name {
font-size: var(--font-size-h4);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
}
.header-right {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.user-menu {
position: relative;
}
.user-button {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2);
border: none;
background: none;
cursor: pointer;
border-radius: var(--radius-md);
transition: background-color var(--transition-fast);
}
.user-button:hover {
background-color: var(--color-gray-100);
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: var(--radius-full);
background-color: var(--color-primary-main);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-white);
font-weight: var(--font-weight-medium);
}
.user-dropdown {
display: none;
position: absolute;
top: 48px;
right: 0;
background-color: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
min-width: 200px;
z-index: var(--z-dropdown);
}
.user-dropdown.show {
display: block;
}
.dropdown-item {
display: block;
padding: var(--spacing-3) var(--spacing-4);
color: var(--color-gray-700);
text-decoration: none;
transition: background-color var(--transition-fast);
}
.dropdown-item:hover {
background-color: var(--color-gray-50);
}
.dropdown-divider {
height: 1px;
background-color: var(--color-gray-200);
margin: var(--spacing-2) 0;
}
/* Layout */
.dashboard-layout {
display: flex;
min-height: calc(100vh - 64px);
}
/* Sidebar */
.sidebar {
width: 240px;
background-color: var(--color-gray-50);
border-right: 1px solid var(--color-gray-200);
padding: var(--spacing-6) 0;
}
.sidebar-nav {
list-style: none;
}
.nav-item {
margin-bottom: var(--spacing-2);
}
.nav-link {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-6);
color: var(--color-gray-700);
text-decoration: none;
transition: all var(--transition-fast);
}
.nav-link:hover {
background-color: var(--color-gray-200);
color: var(--color-gray-900);
}
.nav-link.active {
background-color: rgba(0, 217, 177, 0.1);
color: var(--color-primary-main);
font-weight: var(--font-weight-medium);
border-right: 3px solid var(--color-primary-main);
}
/* Main Content */
.main-content {
flex: 1;
padding: var(--spacing-8);
overflow-y: auto;
}
.welcome-section {
margin-bottom: var(--spacing-8);
}
.welcome-title {
font-size: var(--font-size-h1);
color: var(--color-gray-900);
margin-bottom: var(--spacing-2);
}
.welcome-subtitle {
font-size: var(--font-size-body);
color: var(--color-gray-500);
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--spacing-6);
margin-bottom: var(--spacing-8);
}
.stat-card {
padding: var(--spacing-6);
background-color: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: all var(--transition-base);
}
.stat-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-bottom: var(--spacing-3);
}
.stat-icon.primary { background-color: rgba(0, 217, 177, 0.1); color: var(--color-primary-main); }
.stat-icon.warning { background-color: rgba(245, 158, 11, 0.1); color: var(--color-warning-main); }
.stat-icon.success { background-color: rgba(16, 185, 129, 0.1); color: var(--color-success-main); }
.stat-label {
font-size: var(--font-size-body-small);
color: var(--color-gray-500);
margin-bottom: var(--spacing-2);
}
.stat-value {
font-size: var(--font-size-h2);
font-weight: var(--font-weight-bold);
color: var(--color-gray-900);
}
/* Section */
.section {
margin-bottom: var(--spacing-8);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-4);
}
.section-title {
font-size: var(--font-size-h3);
color: var(--color-gray-900);
}
.view-all-link {
font-size: var(--font-size-body-small);
color: var(--color-primary-main);
text-decoration: none;
font-weight: var(--font-weight-medium);
}
.view-all-link:hover {
color: var(--color-primary-dark);
}
/* Meeting Card */
.meeting-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--spacing-4);
}
.meeting-card {
background-color: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-5);
cursor: pointer;
transition: all var(--transition-base);
}
.meeting-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.meeting-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--spacing-3);
}
.meeting-title {
font-size: var(--font-size-h4);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-2);
}
.meeting-meta {
font-size: var(--font-size-body-small);
color: var(--color-gray-500);
margin-bottom: var(--spacing-1);
}
/* Todo Card */
.todo-list {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.todo-item {
background-color: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-md);
padding: var(--spacing-4);
cursor: pointer;
transition: all var(--transition-fast);
display: flex;
align-items: center;
justify-content: space-between;
}
.todo-item:hover {
box-shadow: var(--shadow-sm);
border-color: var(--color-primary-main);
}
.todo-left {
flex: 1;
}
.todo-title {
font-weight: var(--font-weight-medium);
color: var(--color-gray-900);
margin-bottom: var(--spacing-2);
}
.todo-meta {
font-size: var(--font-size-body-small);
color: var(--color-gray-500);
}
.todo-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--spacing-2);
}
.dday {
font-size: var(--font-size-body-small);
font-weight: var(--font-weight-medium);
}
.dday.urgent { color: var(--color-error-main); }
.dday.warning { color: var(--color-warning-main); }
.dday.normal { color: var(--color-gray-500); }
/* Bottom Navigation (Mobile) */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: var(--color-white);
border-top: 1px solid var(--color-gray-200);
display: flex;
justify-content: space-around;
padding: var(--spacing-2) 0;
z-index: var(--z-sticky);
}
.bottom-nav-item {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-2);
color: var(--color-gray-500);
text-decoration: none;
font-size: var(--font-size-caption);
min-width: 60px;
}
.bottom-nav-item.active {
color: var(--color-primary-main);
}
.bottom-nav-icon {
font-size: 24px;
}
/* Responsive */
@media (max-width: 1023px) {
.sidebar { display: none; }
.main-content { padding-bottom: 80px; }
}
@media (min-width: 1024px) {
.bottom-nav { display: none; }
}
@media (max-width: 767px) {
.dashboard-header { padding: 0 var(--spacing-4); }
.service-name { display: none; }
.main-content { padding: var(--spacing-4); }
.welcome-title { font-size: var(--font-size-h2); }
.stats-grid { grid-template-columns: 1fr; }
.meeting-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<!-- Header -->
<header class="dashboard-header">
<div class="header-left">
<div class="logo">M</div>
<span class="service-name">회의록 서비스</span>
</div>
<div class="header-right">
<div class="user-menu">
<button class="user-button" id="userMenuButton">
<div class="user-avatar" id="userAvatar">U</div>
<span class="hide-mobile" id="userName">사용자</span>
</button>
<div class="user-dropdown" id="userDropdown">
<a href="#" class="dropdown-item">내 프로필</a>
<a href="#" class="dropdown-item">설정</a>
<div class="dropdown-divider"></div>
<a href="#" class="dropdown-item" id="logoutButton">로그아웃</a>
</div>
</div>
</div>
</header>
<!-- Main Layout -->
<div class="dashboard-layout">
<!-- Sidebar -->
<aside class="sidebar">
<nav>
<ul class="sidebar-nav">
<li class="nav-item">
<a href="02-대시보드.html" class="nav-link active">
<span>📊</span> 대시보드
</a>
</li>
<li class="nav-item">
<a href="12-회의록목록.html" class="nav-link">
<span>📅</span> 회의 목록
</a>
</li>
<li class="nav-item">
<a href="09-Todo관리.html" class="nav-link">
<span></span> Todo 관리
</a>
</li>
<li class="nav-item">
<a href="02-대시보드.html" class="nav-link">
<span>⚙️</span> 설정
</a>
</li>
</ul>
</nav>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- Welcome Section -->
<section class="welcome-section">
<h1 class="welcome-title" id="welcomeTitle">안녕하세요!</h1>
<p class="welcome-subtitle" id="welcomeSubtitle">오늘의 일정을 확인하세요</p>
</section>
<!-- Stats Grid -->
<section class="stats-grid">
<div class="stat-card">
<div class="stat-icon primary">📅</div>
<div class="stat-label">예정된 회의</div>
<div class="stat-value" id="upcomingMeetingsCount">0</div>
</div>
<div class="stat-card">
<div class="stat-icon warning"></div>
<div class="stat-label">진행 중 Todo</div>
<div class="stat-value" id="inProgressTodosCount">0</div>
</div>
<div class="stat-card">
<div class="stat-icon success">📈</div>
<div class="stat-label">Todo 완료율</div>
<div class="stat-value" id="todoCompletionRate">0%</div>
</div>
</section>
<!-- Recent Meetings -->
<section class="section">
<div class="section-header">
<h2 class="section-title">최근 회의</h2>
<a href="12-회의록목록.html" class="view-all-link">전체 보기 →</a>
</div>
<div class="meeting-grid" id="meetingGrid">
<!-- Meetings will be rendered here -->
</div>
</section>
<!-- My Todos -->
<section class="section">
<div class="section-header">
<h2 class="section-title">할당된 Todo</h2>
<a href="09-Todo관리.html" class="view-all-link">전체 보기 →</a>
</div>
<div class="todo-list" id="todoList">
<!-- Todos will be rendered here -->
</div>
</section>
</main>
</div>
<!-- Bottom Navigation (Mobile) -->
<nav class="bottom-nav hide-desktop">
<a href="02-대시보드.html" class="bottom-nav-item active">
<div class="bottom-nav-icon">📊</div>
<div>대시보드</div>
</a>
<a href="12-회의록목록.html" class="bottom-nav-item">
<div class="bottom-nav-icon">📅</div>
<div>회의</div>
</a>
<a href="09-Todo관리.html" class="bottom-nav-item">
<div class="bottom-nav-icon"></div>
<div>Todo</div>
</a>
<a href="02-대시보드.html" class="bottom-nav-item">
<div class="bottom-nav-icon">⚙️</div>
<div>더보기</div>
</a>
</nav>
<!-- FAB -->
<button class="fab" id="fabButton" title="새 회의 예약">+</button>
<!-- JavaScript -->
<script src="common.js"></script>
<script>
// 인증 체크 및 초기화
window.MeetingApp.ready(() => {
const authToken = window.MeetingApp.Storage.get('authToken');
if (!authToken) {
// 개발 환경에서는 자동 로그인
window.MeetingApp.Storage.set('authToken', 'demo-token');
window.MeetingApp.Storage.set('currentUser', window.MeetingApp.AppState.currentUser);
}
const currentUser = window.MeetingApp.Storage.get('currentUser');
if (currentUser) {
// 사용자 정보 표시
document.getElementById('userName').textContent = currentUser.name;
document.getElementById('userAvatar').textContent = currentUser.name.charAt(0);
document.getElementById('welcomeTitle').textContent = `안녕하세요, ${currentUser.name}님!`;
// AppState 업데이트
window.MeetingApp.AppState.currentUser = currentUser;
}
// 데이터 로드 및 렌더링
loadDashboardData();
renderMeetings();
renderTodos();
});
// 대시보드 통계 로드
function loadDashboardData() {
const meetings = window.MeetingApp.Storage.get('meetings', []);
const todos = window.MeetingApp.Storage.get('todos', []);
// 예정된 회의 수
const upcomingMeetings = meetings.filter(m => m.status === 'scheduled' || m.status === 'confirmed').length;
document.getElementById('upcomingMeetingsCount').textContent = upcomingMeetings;
// 진행 중 Todo 수
const inProgressTodos = todos.filter(t => t.status === 'in_progress').length;
document.getElementById('inProgressTodosCount').textContent = inProgressTodos;
// Todo 완료율
const completedTodos = todos.filter(t => t.status === 'done').length;
const completionRate = todos.length > 0 ? Math.round((completedTodos / todos.length) * 100) : 0;
document.getElementById('todoCompletionRate').textContent = `${completionRate}%`;
}
// 회의 목록 렌더링
function renderMeetings() {
const meetings = window.MeetingApp.Storage.get('meetings', []).slice(0, 3);
const meetingGrid = document.getElementById('meetingGrid');
if (meetings.length === 0) {
meetingGrid.innerHTML = '<p style="color: var(--color-gray-500);">아직 등록된 회의가 없습니다.</p>';
return;
}
meetingGrid.innerHTML = meetings.map(meeting => `
<div class="meeting-card" onclick="window.MeetingApp.navigateTo('12-회의록목록.html')">
<div class="meeting-header">
<div>
<div class="meeting-title">${meeting.title}</div>
<div class="meeting-meta">📅 ${window.MeetingApp.formatDateTime(meeting.date)}</div>
<div class="meeting-meta">📍 ${meeting.location}</div>
</div>
<span class="badge ${window.MeetingApp.MeetingUtils.getStatusClass(meeting.status)}">
${window.MeetingApp.MeetingUtils.getStatusLabel(meeting.status)}
</span>
</div>
<div style="font-size: var(--font-size-body-small); color: var(--color-gray-600);">
${meeting.description || '설명 없음'}
</div>
</div>
`).join('');
}
// Todo 목록 렌더링
function renderTodos() {
const todos = window.MeetingApp.Storage.get('todos', []).filter(t => t.status !== 'done').slice(0, 5);
const todoList = document.getElementById('todoList');
console.log('Rendering todos:', todos);
if (todos.length === 0) {
todoList.innerHTML = '<p style="color: var(--color-gray-500);">할당된 Todo가 없습니다.</p>';
return;
}
todoList.innerHTML = todos.map(todo => {
const dday = window.MeetingApp.getDday(todo.dueDate);
const ddayClass = dday.includes('지남') ? 'urgent' : (dday === '오늘' ? 'warning' : 'normal');
const priorityLabel = window.MeetingApp.MeetingUtils.getPriorityLabel(todo.priority);
console.log(`Todo: ${todo.title}, Priority: ${todo.priority}, Label: ${priorityLabel}`);
return `
<div class="todo-item" onclick="window.MeetingApp.navigateTo('09-Todo관리.html')">
<div class="todo-left">
<div class="todo-title">${todo.title}</div>
<div class="todo-meta">담당: ${todo.assignee}</div>
</div>
<div class="todo-right">
<div class="dday ${ddayClass}">${dday}</div>
<span class="badge badge-${todo.priority === 'high' ? 'error' : 'neutral'}">
${priorityLabel}
</span>
</div>
</div>
`;
}).join('');
}
// 사용자 메뉴 토글
const userMenuButton = document.getElementById('userMenuButton');
const userDropdown = document.getElementById('userDropdown');
userMenuButton.addEventListener('click', (e) => {
e.stopPropagation();
userDropdown.classList.toggle('show');
});
document.addEventListener('click', () => {
userDropdown.classList.remove('show');
});
// 로그아웃
document.getElementById('logoutButton').addEventListener('click', (e) => {
e.preventDefault();
window.MeetingApp.Storage.remove('authToken');
window.MeetingApp.Storage.remove('currentUser');
window.MeetingApp.Toast.success('로그아웃 되었습니다.');
setTimeout(() => {
window.location.href = '01-로그인.html';
}, 1000);
});
// FAB 버튼
document.getElementById('fabButton').addEventListener('click', () => {
window.location.href = '03-회의예약.html';
});
</script>
</body>
</html>
@@ -1,133 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의 예약 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 800px;
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-4);
}
.page-header {
margin-bottom: var(--spacing-8);
}
.page-title {
font-size: var(--font-size-h1);
color: var(--color-gray-900);
margin-bottom: var(--spacing-2);
}
.page-subtitle {
font-size: var(--font-size-body);
color: var(--color-gray-500);
}
.form-container {
background-color: var(--color-white);
border-radius: var(--radius-lg);
padding: var(--spacing-8);
box-shadow: var(--shadow-sm);
}
.button-group {
display: flex;
gap: var(--spacing-3);
margin-top: var(--spacing-6);
}
@media (max-width: 767px) {
.page-title { font-size: var(--font-size-h2); }
.form-container { padding: var(--spacing-5); }
.button-group { flex-direction: column; }
}
</style>
</head>
<body>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">회의 예약</h1>
<p class="page-subtitle">새로운 회의를 예약하고 참석자를 초대하세요</p>
</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 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>
const form = document.getElementById('meetingForm');
// 최소 날짜를 오늘로 설정
document.getElementById('date').min = new Date().toISOString().split('T')[0];
form.addEventListener('submit', async (e) => {
e.preventDefault();
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();
// 새 회의 생성
const newMeeting = {
id: 'm-' + Date.now(),
title,
date: `${date} ${time}`,
location: location || '미정',
status: 'scheduled',
attendees: attendees.split(',').map(email => email.trim()),
description: description || ''
};
// 저장
const meetings = MeetingApp.Storage.get('meetings', []);
meetings.unshift(newMeeting);
MeetingApp.Storage.set('meetings', meetings);
MeetingApp.Toast.success('회의가 예약되었습니다!');
setTimeout(() => {
window.location.href = '04-템플릿선택.html?meetingId=' + newMeeting.id;
}, 1000);
});
</script>
</body>
</html>
@@ -1,235 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>템플릿 선택 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 1024px;
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-4);
}
.page-header {
margin-bottom: var(--spacing-8);
text-align: center;
}
.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);
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-6);
margin-bottom: var(--spacing-8);
}
.template-card {
background: var(--color-white);
border: 2px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
cursor: pointer;
transition: all var(--transition-base);
position: relative;
}
.template-card:hover {
border-color: var(--color-primary-main);
box-shadow: var(--shadow-md);
transform: translateY(-4px);
}
.template-card.selected {
border-color: var(--color-primary-main);
border-width: 3px;
background-color: rgba(0, 217, 177, 0.05);
}
.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;
justify-content: center;
font-weight: var(--font-weight-bold);
}
.template-icon {
font-size: 48px;
margin-bottom: var(--spacing-4);
text-align: center;
}
.template-title {
font-size: var(--font-size-h4);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-2);
}
.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;
flex-wrap: wrap;
gap: var(--spacing-1);
}
.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);
}
.action-buttons {
display: flex;
gap: var(--spacing-3);
justify-content: center;
}
@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="page-container">
<div class="page-header">
<h1 class="page-title">회의록 템플릿 선택</h1>
<p class="page-subtitle">회의 유형에 맞는 템플릿을 선택하여 효율적으로 회의록을 작성하세요</p>
</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="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 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="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-secondary" onclick="history.back()">이전으로</button>
<button type="button" class="btn btn-primary" id="startMeetingBtn" disabled>
회의 시작하기
</button>
</div>
</div>
<script src="common.js"></script>
<script>
let selectedTemplate = null;
const startBtn = document.getElementById('startMeetingBtn');
const templateCards = document.querySelectorAll('.template-card');
templateCards.forEach(card => {
card.addEventListener('click', () => {
// 기존 선택 해제
templateCards.forEach(c => c.classList.remove('selected'));
// 새로운 선택
card.classList.add('selected');
selectedTemplate = card.getAttribute('data-template');
startBtn.disabled = false;
});
});
startBtn.addEventListener('click', () => {
if (!selectedTemplate) {
MeetingApp.Toast.warning('템플릿을 선택해주세요');
return;
}
// URL에서 meetingId 가져오기
const urlParams = new URLSearchParams(window.location.search);
const meetingId = urlParams.get('meetingId');
// 선택한 템플릿 저장
MeetingApp.Storage.set('selectedTemplate', {
meetingId: meetingId,
template: selectedTemplate,
timestamp: new Date().toISOString()
});
MeetingApp.Toast.success('템플릿이 선택되었습니다');
setTimeout(() => {
window.location.href = '05-회의진행.html?meetingId=' + meetingId;
}, 500);
});
// 페이지 로드 시 일반 회의 템플릿 기본 선택 (선택적)
// templateCards[0].click();
</script>
</body>
</html>
@@ -1,647 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의 진행 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
body {
background-color: var(--color-gray-50);
margin: 0;
overflow: hidden;
}
.meeting-container {
display: flex;
flex-direction: column;
height: 100vh;
}
.meeting-header {
background: var(--color-white);
border-bottom: 1px solid var(--color-gray-200);
padding: var(--spacing-4) var(--spacing-6);
display: flex;
justify-content: space-between;
align-items: center;
}
.meeting-info {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.meeting-title {
font-size: var(--font-size-h4);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
}
.recording-indicator {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-3);
background-color: var(--color-error-light);
color: var(--color-error-dark);
border-radius: var(--radius-md);
font-size: var(--font-size-body-small);
font-weight: var(--font-weight-medium);
}
.recording-dot {
width: 8px;
height: 8px;
background-color: var(--color-error-main);
border-radius: var(--radius-full);
animation: blink 1.5s ease-in-out infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.meeting-body {
display: flex;
flex: 1;
overflow: hidden;
}
.editor-panel {
flex: 1;
display: flex;
flex-direction: column;
background: var(--color-white);
border-right: 1px solid var(--color-gray-200);
}
.editor-toolbar {
padding: var(--spacing-3) var(--spacing-4);
border-bottom: 1px solid var(--color-gray-200);
display: flex;
gap: var(--spacing-2);
}
.editor-content {
flex: 1;
overflow-y: auto;
padding: var(--spacing-6);
}
.editor-textarea {
width: 100%;
min-height: 300px;
border: none;
font-family: inherit;
font-size: var(--font-size-body);
line-height: var(--line-height-relaxed);
resize: none;
outline: none;
}
.side-panel {
width: 400px;
background: var(--color-white);
display: flex;
flex-direction: column;
border-left: 1px solid var(--color-gray-200);
}
.side-panel-tabs {
display: flex;
border-bottom: 1px solid var(--color-gray-200);
}
.side-tab {
flex: 1;
padding: var(--spacing-3) var(--spacing-4);
background: transparent;
border: none;
border-bottom: 3px solid transparent;
font-size: var(--font-size-body-small);
font-weight: var(--font-weight-medium);
color: var(--color-gray-600);
cursor: pointer;
transition: all var(--transition-fast);
}
.side-tab.active {
color: var(--color-primary-main);
border-bottom-color: var(--color-primary-main);
}
.side-panel-content {
flex: 1;
overflow-y: auto;
padding: var(--spacing-4);
}
.attendee-item {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-3);
margin-bottom: var(--spacing-2);
background: var(--color-gray-50);
border-radius: var(--radius-md);
}
.attendee-avatar {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
background-color: var(--color-primary-main);
color: var(--color-white);
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-weight-semibold);
}
.attendee-info {
flex: 1;
}
.attendee-name {
font-weight: var(--font-weight-medium);
color: var(--color-gray-900);
}
.attendee-status {
font-size: var(--font-size-caption);
color: var(--color-gray-500);
}
.ai-suggestion {
background-color: var(--color-gray-50);
border: 1px dashed var(--color-primary-main);
border-radius: var(--radius-md);
padding: var(--spacing-4);
margin-bottom: var(--spacing-4);
}
.ai-suggestion-header {
display: flex;
align-items: center;
gap: var(--spacing-2);
margin-bottom: var(--spacing-3);
font-weight: var(--font-weight-medium);
color: var(--color-primary-main);
}
.ai-suggestion-text {
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
line-height: var(--line-height-relaxed);
margin-bottom: var(--spacing-3);
}
.ai-actions {
display: flex;
gap: var(--spacing-2);
}
/* 용어 사전 탭 스타일 */
.term-search-box {
margin-bottom: var(--spacing-4);
}
.term-search-input {
width: 100%;
padding: var(--spacing-3);
border: 1px solid var(--color-gray-300);
border-radius: var(--radius-md);
font-size: var(--font-size-body);
outline: none;
transition: all var(--transition-fast);
}
.term-search-input:focus {
border-color: var(--color-primary-main);
box-shadow: 0 0 0 3px rgba(0, 217, 177, 0.1);
}
.term-list {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.term-item {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-md);
padding: var(--spacing-4);
cursor: pointer;
transition: all var(--transition-fast);
}
.term-item:hover {
border-color: var(--color-primary-main);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.term-item.highlight {
background-color: var(--color-primary-light);
border-color: var(--color-primary-main);
}
.term-name {
font-size: var(--font-size-body);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-2);
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.term-badge {
font-size: var(--font-size-caption);
padding: 2px var(--spacing-2);
background-color: var(--color-primary-light);
color: var(--color-primary-dark);
border-radius: var(--radius-sm);
font-weight: var(--font-weight-medium);
}
.term-definition {
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
line-height: var(--line-height-relaxed);
margin-bottom: var(--spacing-2);
}
.term-context {
font-size: var(--font-size-caption);
color: var(--color-gray-500);
padding-top: var(--spacing-2);
border-top: 1px solid var(--color-gray-100);
}
.no-results {
text-align: center;
padding: var(--spacing-8);
color: var(--color-gray-500);
}
@media (max-width: 1023px) {
.side-panel {
position: fixed;
right: -400px;
top: 0;
height: 100vh;
z-index: var(--z-sticky);
box-shadow: var(--shadow-lg);
transition: right var(--transition-base);
}
.side-panel.open {
right: 0;
}
}
</style>
</head>
<body>
<div class="meeting-container">
<!-- 헤더 -->
<div class="meeting-header">
<div class="meeting-info">
<h1 class="meeting-title" id="meetingTitle">회의 진행 중</h1>
<div class="recording-indicator">
<div class="recording-dot"></div>
<span id="recordingTime">00:00</span>
</div>
</div>
<div style="display: flex; gap: var(--spacing-3);">
<button class="btn btn-text btn-icon hide-desktop" id="toggleSidePanel"></button>
<button class="btn btn-secondary" onclick="if(confirm('회의를 종료하시겠습니까?')) window.location.href='06-검증완료.html'">
회의 종료
</button>
</div>
</div>
<!-- 본문 -->
<div class="meeting-body">
<!-- 에디터 패널 -->
<div class="editor-panel">
<div class="editor-toolbar">
<button class="btn btn-text btn-icon-sm">B</button>
<button class="btn btn-text btn-icon-sm">I</button>
<button class="btn btn-text btn-icon-sm">U</button>
<button class="btn btn-text btn-icon-sm">📝</button>
<button class="btn btn-text btn-icon-sm">🔗</button>
</div>
<div class="editor-content">
<textarea class="editor-textarea" id="meetingContent" placeholder="회의 내용을 작성하거나 AI가 자동으로 작성합니다...
# 참석자
- 김민준
- 박서연
- 이준호
# 안건
1. 신규 기능 개발 일정 논의
2. 예산 편성 검토
# 논의 내용
Mobile First 설계 방침으로 진행하기로 결정
AI 기반 회의록 자동 작성 기능을 핵심으로 개발
API Gateway 구축 및 마이크로서비스 아키텍처 적용
"></textarea>
</div>
</div>
<!-- 사이드 패널 -->
<div class="side-panel" id="sidePanel">
<div class="side-panel-tabs">
<button class="side-tab active" data-tab="attendees">참석자</button>
<button class="side-tab" data-tab="ai">AI 제안</button>
<button class="side-tab" data-tab="terms">용어 사전</button>
<button class="side-tab" data-tab="related">관련 자료</button>
</div>
<div class="side-panel-content">
<!-- 참석자 탭 -->
<div class="tab-content" data-content="attendees">
<h3 style="margin-bottom: var(--spacing-4); font-size: var(--font-size-h4);">참석자 (3명)</h3>
<div class="attendee-item">
<div class="attendee-avatar"></div>
<div class="attendee-info">
<div class="attendee-name">김민준</div>
<div class="attendee-status">발언 중 ✍️</div>
</div>
</div>
<div class="attendee-item">
<div class="attendee-avatar" style="background-color: var(--color-secondary-main);"></div>
<div class="attendee-info">
<div class="attendee-name">박서연</div>
<div class="attendee-status">온라인</div>
</div>
</div>
<div class="attendee-item">
<div class="attendee-avatar" style="background-color: var(--color-info-main);"></div>
<div class="attendee-info">
<div class="attendee-name">이준호</div>
<div class="attendee-status">온라인</div>
</div>
</div>
</div>
<!-- AI 제안 탭 -->
<div class="tab-content" data-content="ai" style="display: none;">
<h3 style="margin-bottom: var(--spacing-4); font-size: var(--font-size-h4);">AI 제안</h3>
<div class="ai-suggestion">
<div class="ai-suggestion-header">
✨ 회의록 요약 제안
</div>
<div class="ai-suggestion-text">
Mobile First 설계 방침 확정 및 AI 기반 자동 작성 기능 개발 착수 결정. 마이크로서비스 아키텍처와 API Gateway 구축 계획 수립.
</div>
<div class="ai-actions">
<button class="btn btn-primary btn-sm">회의록에 적용</button>
<button class="btn btn-text btn-sm">수정</button>
</div>
</div>
<div class="ai-suggestion">
<div class="ai-suggestion-header">
📋 액션 아이템(Todo) 자동 추출
</div>
<div class="ai-suggestion-text">
1. API 명세서 작성 (이준호, 3/25까지)<br>
2. UI 프로토타입 완성 (최유진, 3/15까지)<br>
3. 예산 편성안 최종 검토 (박서연, 3/20까지)
</div>
<div class="ai-actions">
<button class="btn btn-primary btn-sm">3개 Todo 생성</button>
<button class="btn btn-text btn-sm">수정</button>
</div>
</div>
</div>
<!-- 용어 사전 탭 (NEW) -->
<div class="tab-content" data-content="terms" style="display: none;">
<h3 style="margin-bottom: var(--spacing-4); font-size: var(--font-size-h4);">용어 사전</h3>
<!-- 검색 입력 -->
<div class="term-search-box">
<input
type="text"
class="term-search-input"
id="termSearchInput"
placeholder="용어를 검색하세요 (예: API, Mobile First)"
/>
</div>
<!-- 용어 목록 -->
<div class="term-list" id="termList">
<!-- JavaScript로 동적 생성 -->
</div>
<!-- 검색 결과 없음 -->
<div class="no-results" id="noResults" style="display: none;">
<div style="font-size: 48px; opacity: 0.3; margin-bottom: var(--spacing-3);">🔍</div>
<div>검색 결과가 없습니다</div>
</div>
</div>
<!-- 관련 자료 탭 -->
<div class="tab-content" data-content="related" style="display: none;">
<h3 style="margin-bottom: var(--spacing-4); font-size: var(--font-size-h4);">관련 자료</h3>
<div style="margin-bottom: var(--spacing-6);">
<h4 style="font-size: var(--font-size-body); font-weight: var(--font-weight-semibold); color: var(--color-gray-700); margin-bottom: var(--spacing-3);">
📄 관련 회의록 (3건)
</h4>
<div class="ai-suggestion" style="cursor: pointer;">
<div class="ai-suggestion-header" style="color: var(--color-gray-900);">
2024년 4분기 제품 기획 회의
</div>
<div class="ai-suggestion-text">
<div style="display: flex; gap: var(--spacing-2); margin-bottom: var(--spacing-2); font-size: var(--font-size-caption);">
<span>2024-10-15</span> | <span style="color: var(--color-success-main);">관련도 92%</span>
</div>
<div>신규 회의록 서비스 MVP 개발 일정 논의. AI 기능 우선순위와 예산 확정.</div>
</div>
</div>
<div class="ai-suggestion" style="cursor: pointer;">
<div class="ai-suggestion-header" style="color: var(--color-gray-900);">
API 설계 리뷰 회의
</div>
<div class="ai-suggestion-text">
<div style="display: flex; gap: var(--spacing-2); margin-bottom: var(--spacing-2); font-size: var(--font-size-caption);">
<span>2024-09-28</span> | <span style="color: var(--color-warning-main);">관련도 78%</span>
</div>
<div>RESTful API 설계 원칙과 보안 정책 확정. 담당자별 역할 분담.</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
// 용어 사전 데이터
const TERMINOLOGY = [
{
id: 'mobile-first',
name: 'Mobile First',
category: '설계 방법론',
definition: '모바일 환경을 우선적으로 고려하여 디자인하고, 이후 더 큰 화면으로 확장하는 설계 방법론입니다.',
context: '회의에서 언급됨 (14:23)',
usedInMeeting: true
},
{
id: 'ai',
name: 'AI',
category: '기술',
definition: 'Artificial Intelligence의 약자로, 인공지능을 의미합니다. 이 프로젝트에서는 회의록 자동 작성에 활용됩니다.',
context: '회의에서 5회 언급됨',
usedInMeeting: true
},
{
id: 'api',
name: 'API',
category: '기술',
definition: 'Application Programming Interface의 약자로, 소프트웨어 간 상호작용을 위한 인터페이스입니다.',
context: '회의에서 3회 언급됨',
usedInMeeting: true
},
{
id: 'api-gateway',
name: 'API Gateway',
category: '아키텍처',
definition: '클라이언트와 백엔드 마이크로서비스 사이의 단일 진입점 역할을 하는 서버. 요청 라우팅, 인증, 속도 제한, 로드 밸런싱 등을 처리합니다.',
context: 'API 설계 리뷰 회의 (2024-09-28)에서 AWS API Gateway 채택 결정',
usedInMeeting: true
},
{
id: 'microservice',
name: '마이크로서비스',
category: '아키텍처',
definition: '애플리케이션을 작고 독립적인 서비스들로 분리하여 개발하고 배포하는 아키텍처 패턴입니다.',
context: '회의에서 언급됨',
usedInMeeting: true
},
{
id: 'mvp',
name: 'MVP',
category: '방법론',
definition: 'Minimum Viable Product의 약자. 최소한의 기능만 갖춘 제품으로, 시장 반응을 빠르게 확인하기 위해 개발합니다.',
context: '개발 일정 논의에서 언급',
usedInMeeting: true
},
{
id: 'restful',
name: 'RESTful API',
category: '기술',
definition: 'REST(Representational State Transfer) 아키텍처 스타일을 따르는 웹 서비스 API 설계 방식입니다.',
context: 'API 설계 리뷰 회의 참조',
usedInMeeting: false
},
{
id: 'jwt',
name: 'JWT',
category: '보안',
definition: 'JSON Web Token의 약자. 사용자 인증 정보를 안전하게 전송하기 위한 토큰 기반 인증 방식입니다.',
context: 'API Gateway 보안 정책에서 채택',
usedInMeeting: false
}
];
// 용어 렌더링
function renderTerms(terms) {
const termList = document.getElementById('termList');
const noResults = document.getElementById('noResults');
if (terms.length === 0) {
termList.innerHTML = '';
noResults.style.display = 'block';
return;
}
noResults.style.display = 'none';
termList.innerHTML = terms.map(term => `
<div class="term-item ${term.usedInMeeting ? 'highlight' : ''}" onclick="showTermDetail('${term.id}')">
<div class="term-name">
${term.name}
<span class="term-badge">${term.category}</span>
${term.usedInMeeting ? '<span style="font-size: 16px;">💬</span>' : ''}
</div>
<div class="term-definition">${term.definition}</div>
<div class="term-context">${term.context}</div>
</div>
`).join('');
}
// 용어 검색
function searchTerms(query) {
if (!query || query.trim() === '') {
renderTerms(TERMINOLOGY);
return;
}
const lowerQuery = query.toLowerCase();
const filtered = TERMINOLOGY.filter(term =>
term.name.toLowerCase().includes(lowerQuery) ||
term.definition.toLowerCase().includes(lowerQuery) ||
term.category.toLowerCase().includes(lowerQuery)
);
renderTerms(filtered);
}
// 용어 상세 보기
function showTermDetail(termId) {
const term = TERMINOLOGY.find(t => t.id === termId);
if (!term) return;
window.MeetingApp.Toast.show(
`📚 ${term.name}\n\n${term.definition}\n\n${term.context}`,
'info',
5000
);
}
// 검색 입력 이벤트
const searchInput = document.getElementById('termSearchInput');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
searchTerms(e.target.value);
});
}
// 탭 전환
const tabs = document.querySelectorAll('.side-tab');
const tabContents = document.querySelectorAll('.tab-content');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetTab = tab.getAttribute('data-tab');
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
tabContents.forEach(content => {
if (content.getAttribute('data-content') === targetTab) {
content.style.display = 'block';
// 용어 사전 탭 열 때 초기 렌더링
if (targetTab === 'terms' && !content.dataset.rendered) {
renderTerms(TERMINOLOGY);
content.dataset.rendered = 'true';
}
} else {
content.style.display = 'none';
}
});
});
});
// 사이드 패널 토글 (모바일)
const toggleBtn = document.getElementById('toggleSidePanel');
const sidePanel = document.getElementById('sidePanel');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
sidePanel.classList.toggle('open');
});
}
// 녹음 시간 업데이트
let seconds = 0;
const recordingTimeEl = document.getElementById('recordingTime');
setInterval(() => {
seconds++;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
recordingTimeEl.textContent = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}, 1000);
// 자동 저장 시뮬레이션
const editorTextarea = document.getElementById('meetingContent');
let saveTimeout;
editorTextarea.addEventListener('input', () => {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
console.log('자동 저장됨');
}, 2000);
});
</script>
</body>
</html>
@@ -1,183 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>검증 완료 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 800px;
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-4);
}
.completion-icon {
text-align: center;
font-size: 80px;
margin-bottom: var(--spacing-6);
}
.page-title {
font-size: var(--font-size-h1);
color: var(--color-gray-900);
margin-bottom: var(--spacing-3);
text-align: center;
}
.page-subtitle {
font-size: var(--font-size-body);
color: var(--color-gray-500);
text-align: center;
margin-bottom: var(--spacing-8);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--spacing-4);
margin-bottom: var(--spacing-8);
}
.stat-card {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-5);
text-align: center;
}
.stat-value {
font-size: var(--font-size-h2);
font-weight: var(--font-weight-bold);
color: var(--color-primary-main);
margin-bottom: var(--spacing-2);
}
.stat-label {
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
}
.summary-card {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
margin-bottom: var(--spacing-6);
}
.summary-title {
font-size: var(--font-size-h4);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-4);
}
.keyword-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
}
.keyword-tag {
padding: var(--spacing-2) var(--spacing-3);
background-color: var(--color-primary-light);
color: var(--color-primary-dark);
border-radius: var(--radius-md);
font-size: var(--font-size-body-small);
font-weight: var(--font-weight-medium);
}
.action-buttons {
display: flex;
gap: var(--spacing-3);
justify-content: center;
}
@media (max-width: 767px) {
.completion-icon { font-size: 60px; }
.page-title { font-size: var(--font-size-h2); }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.action-buttons { flex-direction: column; }
.action-buttons .btn { width: 100%; }
}
</style>
</head>
<body>
<div class="page-container">
<div class="completion-icon"></div>
<h1 class="page-title">AI 검증이 완료되었습니다</h1>
<p class="page-subtitle">회의 내용이 분석되었습니다. 통계를 확인하고 회의를 종료하세요</p>
<!-- 통계 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">45분</div>
<div class="stat-label">회의 시간</div>
</div>
<div class="stat-card">
<div class="stat-value">3명</div>
<div class="stat-label">참석자</div>
</div>
<div class="stat-card">
<div class="stat-value">12회</div>
<div class="stat-label">발언 횟수</div>
</div>
<div class="stat-card">
<div class="stat-value">5개</div>
<div class="stat-label">Todo 생성</div>
</div>
</div>
<!-- 주요 키워드 -->
<div class="summary-card">
<h2 class="summary-title">주요 키워드</h2>
<div class="keyword-list">
<span class="keyword-tag">신규 기능</span>
<span class="keyword-tag">개발 일정</span>
<span class="keyword-tag">API 설계</span>
<span class="keyword-tag">예산</span>
<span class="keyword-tag">테스트</span>
<span class="keyword-tag">배포</span>
<span class="keyword-tag">마케팅</span>
</div>
</div>
<!-- 발언 분포 -->
<div class="summary-card">
<h2 class="summary-title">발언 분포</h2>
<div style="margin-bottom: var(--spacing-3);">
<div style="display: flex; justify-content: space-between; margin-bottom: var(--spacing-1);">
<span style="font-size: var(--font-size-body-small); color: var(--color-gray-600);">김민준</span>
<span style="font-size: var(--font-size-body-small); color: var(--color-gray-600);">5회 (42%)</span>
</div>
<div style="height: 8px; background-color: var(--color-gray-200); border-radius: var(--radius-sm); overflow: hidden;">
<div style="width: 42%; height: 100%; background-color: var(--color-primary-main);"></div>
</div>
</div>
<div style="margin-bottom: var(--spacing-3);">
<div style="display: flex; justify-content: space-between; margin-bottom: var(--spacing-1);">
<span style="font-size: var(--font-size-body-small); color: var(--color-gray-600);">박서연</span>
<span style="font-size: var(--font-size-body-small); color: var(--color-gray-600);">4회 (33%)</span>
</div>
<div style="height: 8px; background-color: var(--color-gray-200); border-radius: var(--radius-sm); overflow: hidden;">
<div style="width: 33%; height: 100%; background-color: var(--color-secondary-main);"></div>
</div>
</div>
<div>
<div style="display: flex; justify-content: space-between; margin-bottom: var(--spacing-1);">
<span style="font-size: var(--font-size-body-small); color: var(--color-gray-600);">이준호</span>
<span style="font-size: var(--font-size-body-small); color: var(--color-gray-600);">3회 (25%)</span>
</div>
<div style="height: 8px; background-color: var(--color-gray-200); border-radius: var(--radius-sm); overflow: hidden;">
<div style="width: 25%; height: 100%; background-color: var(--color-info-main);"></div>
</div>
</div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons">
<button class="btn btn-secondary" onclick="history.back()">회의로 돌아가기</button>
<button class="btn btn-primary" onclick="window.location.href='07-회의종료.html'">
회의 종료하기
</button>
</div>
</div>
<script src="common.js"></script>
<script>
MeetingApp.ready(() => {
console.log('검증 완료 페이지 로드됨');
});
</script>
</body>
</html>
@@ -1,112 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의 종료 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 600px;
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-4);
text-align: center;
}
.completion-icon {
font-size: 100px;
margin-bottom: var(--spacing-6);
}
.page-title {
font-size: var(--font-size-h1);
color: var(--color-gray-900);
margin-bottom: var(--spacing-3);
}
.page-subtitle {
font-size: var(--font-size-body);
color: var(--color-gray-500);
margin-bottom: var(--spacing-8);
}
.info-card {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
margin-bottom: var(--spacing-6);
text-align: left;
}
.info-item {
display: flex;
justify-content: space-between;
padding: var(--spacing-3) 0;
border-bottom: 1px solid var(--color-gray-100);
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-weight: var(--font-weight-medium);
color: var(--color-gray-700);
}
.info-value {
color: var(--color-gray-900);
font-weight: var(--font-weight-semibold);
}
.action-buttons {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
@media (max-width: 767px) {
.completion-icon { font-size: 80px; }
.page-title { font-size: var(--font-size-h2); }
}
</style>
</head>
<body>
<div class="page-container">
<div class="completion-icon">🏁</div>
<h1 class="page-title">회의가 종료되었습니다</h1>
<p class="page-subtitle">회의록이 자동으로 저장되었습니다</p>
<!-- 회의 정보 -->
<div class="info-card">
<div class="info-item">
<span class="info-label">회의 제목</span>
<span class="info-value">2025년 1분기 제품 기획 회의</span>
</div>
<div class="info-item">
<span class="info-label">회의 시간</span>
<span class="info-value">45분</span>
</div>
<div class="info-item">
<span class="info-label">참석자</span>
<span class="info-value">3명</span>
</div>
<div class="info-item">
<span class="info-label">생성된 Todo</span>
<span class="info-value">5개</span>
</div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons">
<button class="btn btn-primary" onclick="window.location.href='08-회의록공유.html'">
회의록 확정하기
</button>
<button class="btn btn-secondary" onclick="window.location.href='02-대시보드.html'">
대시보드로 이동
</button>
</div>
</div>
<script src="common.js"></script>
<script>
MeetingApp.ready(() => {
console.log('회의 종료 페이지 로드됨');
// 회의 종료 알림
MeetingApp.Toast.success('회의가 성공적으로 종료되었습니다');
});
</script>
</body>
</html>
@@ -1,316 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의록 공유 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 800px;
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-4);
}
.success-icon {
text-align: center;
font-size: 80px;
margin-bottom: var(--spacing-6);
}
.page-title {
font-size: var(--font-size-h1);
color: var(--color-gray-900);
margin-bottom: var(--spacing-3);
text-align: center;
}
.page-subtitle {
font-size: var(--font-size-body);
color: var(--color-gray-500);
text-align: center;
margin-bottom: var(--spacing-8);
}
.share-card {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
margin-bottom: var(--spacing-6);
}
.share-title {
font-size: var(--font-size-h4);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-4);
}
.share-option {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4);
margin-bottom: var(--spacing-3);
background: var(--color-gray-50);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.share-option:hover {
background: var(--color-gray-100);
}
.share-icon {
font-size: 32px;
}
.share-info {
flex: 1;
}
.share-label {
font-weight: var(--font-weight-medium);
color: var(--color-gray-900);
margin-bottom: var(--spacing-1);
}
.share-desc {
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
}
.link-box {
display: flex;
gap: var(--spacing-2);
align-items: center;
}
.link-input {
flex: 1;
padding: var(--spacing-3) var(--spacing-4);
border: 1px solid var(--color-gray-300);
border-radius: var(--radius-md);
font-size: var(--font-size-body-small);
background-color: var(--color-gray-50);
font-family: monospace;
}
.attendee-list {
margin-top: var(--spacing-4);
}
.attendee-item {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-3);
margin-bottom: var(--spacing-2);
background: var(--color-gray-50);
border-radius: var(--radius-md);
}
.attendee-avatar {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
background-color: var(--color-primary-main);
color: var(--color-white);
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-weight-semibold);
}
.attendee-info {
flex: 1;
}
.attendee-name {
font-weight: var(--font-weight-medium);
color: var(--color-gray-900);
}
.attendee-email {
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
}
.sent-badge {
padding: var(--spacing-1) var(--spacing-3);
background-color: var(--color-success-light);
color: var(--color-success-dark);
border-radius: var(--radius-md);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-medium);
}
.action-buttons {
display: flex;
gap: var(--spacing-3);
justify-content: center;
}
@media (max-width: 767px) {
.success-icon { font-size: 60px; }
.page-title { font-size: var(--font-size-h2); }
.action-buttons { flex-direction: column; }
.action-buttons .btn { width: 100%; }
.link-box { flex-direction: column; }
.link-input { width: 100%; }
}
</style>
</head>
<body>
<div class="page-container">
<div class="success-icon">🎉</div>
<h1 class="page-title">회의록이 확정되었습니다</h1>
<p class="page-subtitle">이제 참석자들과 회의록을 공유하세요</p>
<!-- 공유 링크 -->
<div class="share-card">
<h2 class="share-title">공유 링크</h2>
<div class="link-box">
<input type="text" class="link-input" id="shareLink" value="https://meeting.example.com/share/m-001-abc123" readonly>
<button class="btn btn-primary" onclick="copyLink()">복사</button>
</div>
</div>
<!-- 공유 방식 -->
<div class="share-card">
<h2 class="share-title">공유 방식 선택</h2>
<div class="share-option" onclick="shareViaEmail()">
<div class="share-icon">📧</div>
<div class="share-info">
<div class="share-label">이메일로 공유</div>
<div class="share-desc">참석자들에게 이메일을 발송합니다</div>
</div>
</div>
<div class="share-option" onclick="shareViaSlack()">
<div class="share-icon">💬</div>
<div class="share-info">
<div class="share-label">슬랙으로 공유</div>
<div class="share-desc">슬랙 채널에 회의록을 공유합니다</div>
</div>
</div>
<div class="share-option" onclick="downloadPDF()">
<div class="share-icon">📄</div>
<div class="share-info">
<div class="share-label">PDF로 다운로드</div>
<div class="share-desc">회의록을 PDF 파일로 저장합니다</div>
</div>
</div>
</div>
<!-- 생성된 Todo -->
<div class="share-card">
<h2 class="share-title">생성된 Todo (3개)</h2>
<div class="attendee-list">
<div class="attendee-item">
<div style="display: flex; align-items: center; gap: var(--spacing-3); flex: 1;">
<div class="attendee-avatar" style="background-color: var(--color-primary-main);"></div>
<div class="attendee-info">
<div class="attendee-name">API 명세서 작성</div>
<div class="attendee-email" style="display: flex; align-items: center; gap: var(--spacing-2);">
<span>담당: 이준호</span> | <span>📅 3월 25일</span>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: var(--spacing-1);">
<span class="sent-badge" style="background-color: var(--color-warning-light); color: var(--color-warning-dark);">진행중 60%</span>
<a href="09-Todo관리.html" style="font-size: var(--font-size-caption); color: var(--color-primary-main); text-decoration: none;">Todo 보기 →</a>
</div>
</div>
<div class="attendee-item">
<div style="display: flex; align-items: center; gap: var(--spacing-3); flex: 1;">
<div class="attendee-avatar" style="background-color: var(--color-info-main);"></div>
<div class="attendee-info">
<div class="attendee-name">UI 프로토타입 완성</div>
<div class="attendee-email" style="display: flex; align-items: center; gap: var(--spacing-2);">
<span>담당: 최유진</span> | <span>📅 3월 15일</span>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: var(--spacing-1);">
<span class="sent-badge">완료 100%</span>
<a href="09-Todo관리.html" style="font-size: var(--font-size-caption); color: var(--color-primary-main); text-decoration: none;">Todo 보기 →</a>
</div>
</div>
<div class="attendee-item">
<div style="display: flex; align-items: center; gap: var(--spacing-3); flex: 1;">
<div class="attendee-avatar" style="background-color: var(--color-secondary-main);"></div>
<div class="attendee-info">
<div class="attendee-name">예산 편성안 검토</div>
<div class="attendee-email" style="display: flex; align-items: center; gap: var(--spacing-2);">
<span>담당: 박서연</span> | <span>📅 3월 20일</span>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: var(--spacing-1);">
<span class="sent-badge" style="background-color: var(--color-error-light); color: var(--color-error-dark);">지연 30%</span>
<a href="09-Todo관리.html" style="font-size: var(--font-size-caption); color: var(--color-primary-main); text-decoration: none;">Todo 보기 →</a>
</div>
</div>
</div>
</div>
<!-- 참석자 목록 -->
<div class="share-card">
<h2 class="share-title">참석자 (3명)</h2>
<div class="attendee-list">
<div class="attendee-item">
<div class="attendee-avatar"></div>
<div class="attendee-info">
<div class="attendee-name">김민준</div>
<div class="attendee-email">minjun.kim@example.com</div>
</div>
<span class="sent-badge">발송 완료</span>
</div>
<div class="attendee-item">
<div class="attendee-avatar" style="background-color: var(--color-secondary-main);"></div>
<div class="attendee-info">
<div class="attendee-name">박서연</div>
<div class="attendee-email">seoyeon.park@example.com</div>
</div>
<span class="sent-badge">발송 완료</span>
</div>
<div class="attendee-item">
<div class="attendee-avatar" style="background-color: var(--color-info-main);"></div>
<div class="attendee-info">
<div class="attendee-name">이준호</div>
<div class="attendee-email">junho.lee@example.com</div>
</div>
<span class="sent-badge">발송 완료</span>
</div>
</div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons">
<button class="btn btn-secondary" onclick="window.location.href='02-대시보드.html'">
대시보드로 이동
</button>
<button class="btn btn-primary" onclick="window.location.href='09-Todo관리.html'">
Todo 관리하기
</button>
</div>
</div>
<script src="common.js"></script>
<script>
function copyLink() {
const linkInput = document.getElementById('shareLink');
linkInput.select();
document.execCommand('copy');
MeetingApp.Toast.success('링크가 복사되었습니다');
}
function shareViaEmail() {
MeetingApp.Loading.show();
setTimeout(() => {
MeetingApp.Loading.hide();
MeetingApp.Toast.success('이메일이 발송되었습니다');
}, 1500);
}
function shareViaSlack() {
MeetingApp.Loading.show();
setTimeout(() => {
MeetingApp.Loading.hide();
MeetingApp.Toast.success('슬랙에 공유되었습니다');
}, 1500);
}
function downloadPDF() {
MeetingApp.Toast.info('PDF 파일을 준비 중입니다...');
setTimeout(() => {
MeetingApp.Toast.success('PDF 다운로드가 시작되었습니다');
}, 1000);
}
</script>
</body>
</html>
@@ -1,469 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo 관리 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 1400px;
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-4);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-8);
}
.page-title {
font-size: var(--font-size-h1);
color: var(--color-gray-900);
}
.view-toggle {
display: flex;
gap: var(--spacing-2);
}
.view-btn {
padding: var(--spacing-2) var(--spacing-4);
background: var(--color-white);
border: 1px solid var(--color-gray-300);
border-radius: var(--radius-md);
font-size: var(--font-size-body-small);
cursor: pointer;
transition: all var(--transition-fast);
}
.view-btn.active {
background-color: var(--color-primary-main);
color: var(--color-white);
border-color: var(--color-primary-main);
}
.kanban-board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-6);
}
.kanban-column {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-4);
min-height: 500px;
}
.column-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-4);
padding-bottom: var(--spacing-3);
border-bottom: 2px solid var(--color-gray-200);
}
.column-title {
font-size: var(--font-size-h4);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
}
.column-count {
padding: var(--spacing-1) var(--spacing-2);
background-color: var(--color-gray-200);
color: var(--color-gray-700);
border-radius: var(--radius-md);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-medium);
}
.todo-card {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-md);
padding: var(--spacing-4);
margin-bottom: var(--spacing-3);
cursor: grab;
transition: all var(--transition-fast);
}
.todo-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.todo-card.priority-high {
border-left: 4px solid var(--color-error-main);
}
.todo-card.priority-medium {
border-left: 4px solid var(--color-warning-main);
}
.todo-title {
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-2);
}
.todo-meta {
display: flex;
align-items: center;
gap: var(--spacing-3);
margin-bottom: var(--spacing-3);
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
}
.todo-assignee {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.avatar-sm {
width: 24px;
height: 24px;
border-radius: var(--radius-full);
background-color: var(--color-primary-main);
color: var(--color-white);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-caption);
font-weight: var(--font-weight-semibold);
}
.todo-duedate {
display: flex;
align-items: center;
gap: var(--spacing-1);
}
.todo-duedate.overdue {
color: var(--color-error-main);
font-weight: var(--font-weight-medium);
}
.todo-progress {
height: 4px;
background-color: var(--color-gray-200);
border-radius: 2px;
overflow: hidden;
}
.todo-progress-bar {
height: 100%;
background-color: var(--color-primary-main);
transition: width var(--transition-slow);
}
.todo-source {
margin-top: var(--spacing-3);
padding-top: var(--spacing-3);
border-top: 1px dashed var(--color-gray-200);
font-size: var(--font-size-caption);
color: var(--color-gray-500);
}
.todo-source-link {
display: flex;
align-items: center;
gap: var(--spacing-2);
color: var(--color-primary-main);
text-decoration: none;
transition: color var(--transition-fast);
cursor: pointer;
}
.todo-source-link:hover {
color: var(--color-primary-dark);
text-decoration: underline;
}
.list-view {
display: none;
}
.list-view.active {
display: block;
}
.todo-list-item {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-md);
padding: var(--spacing-4);
margin-bottom: var(--spacing-3);
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.todo-checkbox {
width: 20px;
height: 20px;
border: 2px solid var(--color-gray-300);
border-radius: var(--radius-sm);
cursor: pointer;
}
.todo-list-content {
flex: 1;
}
@media (max-width: 1023px) {
.kanban-board {
grid-template-columns: 1fr;
}
}
@media (max-width: 767px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-4);
}
.page-title { font-size: var(--font-size-h2); }
}
</style>
</head>
<body>
<div class="page-container">
<div class="page-header">
<div style="display: flex; align-items: center; gap: var(--spacing-3);">
<button class="btn btn-secondary" onclick="window.location.href='02-대시보드.html'">← 대시보드</button>
<h1 class="page-title">Todo 관리</h1>
</div>
<div style="display: flex; gap: var(--spacing-3); align-items: center;">
<div class="view-toggle">
<button class="view-btn active" data-view="kanban">칸반</button>
<button class="view-btn" data-view="list">리스트</button>
</div>
<button class="btn btn-primary" onclick="addTodo()">+ 새 Todo</button>
</div>
</div>
<!-- 칸반 보드 뷰 -->
<div class="kanban-board" id="kanbanView">
<!-- 시작 전 -->
<div class="kanban-column">
<div class="column-header">
<h2 class="column-title">시작 전</h2>
<span class="column-count">2</span>
</div>
<div class="todo-card priority-high">
<div class="todo-title">데이터베이스 스키마 설계</div>
<div class="todo-meta">
<div class="todo-assignee">
<div class="avatar-sm"></div>
<span>이준호</span>
</div>
<div class="todo-duedate">
📅 D-3
</div>
</div>
<div class="todo-progress">
<div class="todo-progress-bar" style="width: 0%;"></div>
</div>
<div class="todo-source">
<a href="12-회의록상세조회.html#todo-section" class="todo-source-link" onclick="MeetingApp.Toast.info('회의록으로 이동합니다'); return true;">
📄 2025년 1분기 제품 기획 회의 (2025-10-25)
</a>
</div>
</div>
<div class="todo-card">
<div class="todo-title">사용자 피드백 분석</div>
<div class="todo-meta">
<div class="todo-assignee">
<div class="avatar-sm" style="background-color: var(--color-secondary-main);"></div>
<span>박서연</span>
</div>
<div class="todo-duedate">
📅 D-5
</div>
</div>
<div class="todo-progress">
<div class="todo-progress-bar" style="width: 0%;"></div>
</div>
<div class="todo-source">
<a href="12-회의록상세조회.html#todo-section" class="todo-source-link" onclick="MeetingApp.Toast.info('회의록으로 이동합니다'); return true;">
📄 고객 만족도 개선 회의 (2025-10-18)
</a>
</div>
</div>
</div>
<!-- 진행 중 -->
<div class="kanban-column">
<div class="column-header">
<h2 class="column-title">진행 중</h2>
<span class="column-count">2</span>
</div>
<div class="todo-card priority-high">
<div class="todo-title">API 명세서 작성</div>
<div class="todo-meta">
<div class="todo-assignee">
<div class="avatar-sm"></div>
<span>이준호</span>
</div>
<div class="todo-duedate">
📅 오늘
</div>
</div>
<div class="todo-progress">
<div class="todo-progress-bar" style="width: 60%;"></div>
</div>
<div class="todo-source">
<a href="12-회의록상세조회.html#todo-section" class="todo-source-link" onclick="MeetingApp.Toast.info('회의록으로 이동합니다'); return true;">
📄 2025년 1분기 제품 기획 회의 (2025-10-25)
</a>
</div>
</div>
<div class="todo-card priority-medium">
<div class="todo-title">예산 편성안 검토</div>
<div class="todo-meta">
<div class="todo-assignee">
<div class="avatar-sm" style="background-color: var(--color-secondary-main);"></div>
<span>박서연</span>
</div>
<div class="todo-duedate overdue">
📅 D+2 (지남)
</div>
</div>
<div class="todo-progress">
<div class="todo-progress-bar" style="width: 30%;"></div>
</div>
<div class="todo-source">
<a href="12-회의록상세조회.html#todo-section" class="todo-source-link" onclick="MeetingApp.Toast.info('회의록으로 이동합니다'); return true;">
📄 2025년 1분기 제품 기획 회의 (2025-10-25)
</a>
</div>
</div>
</div>
<!-- 완료 -->
<div class="kanban-column">
<div class="column-header">
<h2 class="column-title">완료</h2>
<span class="column-count">1</span>
</div>
<div class="todo-card">
<div class="todo-title">UI 프로토타입 디자인</div>
<div class="todo-meta">
<div class="todo-assignee">
<div class="avatar-sm" style="background-color: var(--color-info-main);"></div>
<span>최유진</span>
</div>
<div class="todo-duedate">
✅ 완료
</div>
</div>
<div class="todo-progress">
<div class="todo-progress-bar" style="width: 100%; background-color: var(--color-success-main);"></div>
</div>
<div class="todo-source">
<a href="12-회의록상세조회.html#todo-section" class="todo-source-link" onclick="MeetingApp.Toast.info('회의록으로 이동합니다'); return true;">
📄 2025년 1분기 제품 기획 회의 (2025-10-25)
</a>
</div>
</div>
</div>
</div>
<!-- 리스트 뷰 -->
<div class="list-view" id="listView">
<div class="todo-list-item">
<input type="checkbox" class="todo-checkbox">
<div class="todo-list-content">
<div class="todo-title">API 명세서 작성</div>
<div class="todo-meta">
<div class="todo-assignee">
<div class="avatar-sm"></div>
<span>이준호</span>
</div>
<div class="todo-duedate">📅 오늘</div>
<span class="badge badge-warning">진행 중</span>
</div>
<div class="todo-source" style="margin-top: var(--spacing-2);">
<a href="12-회의록상세조회.html#todo-section" class="todo-source-link" onclick="MeetingApp.Toast.info('회의록으로 이동합니다'); return true;">
📄 2025년 1분기 제품 기획 회의 (2025-10-25)
</a>
</div>
</div>
</div>
<div class="todo-list-item">
<input type="checkbox" class="todo-checkbox">
<div class="todo-list-content">
<div class="todo-title">예산 편성안 검토</div>
<div class="todo-meta">
<div class="todo-assignee">
<div class="avatar-sm" style="background-color: var(--color-secondary-main);"></div>
<span>박서연</span>
</div>
<div class="todo-duedate overdue">📅 D+2 (지남)</div>
<span class="badge badge-warning">진행 중</span>
</div>
<div class="todo-source" style="margin-top: var(--spacing-2);">
<a href="12-회의록상세조회.html#todo-section" class="todo-source-link" onclick="MeetingApp.Toast.info('회의록으로 이동합니다'); return true;">
📄 2025년 1분기 제품 기획 회의 (2025-10-25)
</a>
</div>
</div>
</div>
<div class="todo-list-item">
<input type="checkbox" class="todo-checkbox" checked>
<div class="todo-list-content">
<div class="todo-title" style="text-decoration: line-through; color: var(--color-gray-500);">UI 프로토타입 디자인</div>
<div class="todo-meta">
<div class="todo-assignee">
<div class="avatar-sm" style="background-color: var(--color-info-main);"></div>
<span>최유진</span>
</div>
<span class="badge badge-success">완료</span>
</div>
<div class="todo-source" style="margin-top: var(--spacing-2);">
<a href="12-회의록상세조회.html#todo-section" class="todo-source-link" onclick="MeetingApp.Toast.info('회의록으로 이동합니다'); return true;">
📄 2025년 1분기 제품 기획 회의 (2025-10-25)
</a>
</div>
</div>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
// 뷰 전환
const viewBtns = document.querySelectorAll('.view-btn');
const kanbanView = document.getElementById('kanbanView');
const listView = document.getElementById('listView');
viewBtns.forEach(btn => {
btn.addEventListener('click', () => {
const view = btn.getAttribute('data-view');
viewBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (view === 'kanban') {
kanbanView.style.display = 'grid';
listView.classList.remove('active');
} else {
kanbanView.style.display = 'none';
listView.classList.add('active');
}
});
});
// Todo 추가
function addTodo() {
MeetingApp.Toast.info('Todo 추가 기능은 준비 중입니다');
}
// Todo 카드 클릭
const todoCards = document.querySelectorAll('.todo-card');
todoCards.forEach(card => {
card.addEventListener('click', () => {
MeetingApp.Toast.info('Todo 상세 정보를 표시합니다');
});
});
// 드래그 앤 드롭 (간단한 시뮬레이션)
todoCards.forEach(card => {
card.addEventListener('dragstart', (e) => {
e.dataTransfer.effectAllowed = 'move';
e.target.style.opacity = '0.5';
});
card.addEventListener('dragend', (e) => {
e.target.style.opacity = '1';
});
card.setAttribute('draggable', 'true');
});
</script>
</body>
</html>
@@ -1,303 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의록 최종 확정 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
body { background-color: var(--color-gray-50); }
.page-container {
max-width: 1200px;
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-4);
}
.page-header {
margin-bottom: var(--spacing-8);
}
.page-title {
font-size: var(--font-size-h1);
color: var(--color-gray-900);
margin-bottom: var(--spacing-2);
}
.page-subtitle {
font-size: var(--font-size-body);
color: var(--color-gray-500);
}
.content-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--spacing-6);
margin-bottom: var(--spacing-8);
}
.preview-panel {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
}
.preview-title {
font-size: var(--font-size-h3);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-4);
}
.meeting-content {
font-size: var(--font-size-body);
line-height: var(--line-height-relaxed);
color: var(--color-gray-700);
}
.meeting-content h2 {
font-size: var(--font-size-h4);
margin-top: var(--spacing-6);
margin-bottom: var(--spacing-3);
color: var(--color-gray-900);
}
.meeting-content ul {
margin-left: var(--spacing-5);
margin-bottom: var(--spacing-4);
}
.checklist-panel {
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
height: fit-content;
}
.checklist-title {
font-size: var(--font-size-h4);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-4);
}
.checklist-item {
display: flex;
align-items: flex-start;
gap: var(--spacing-3);
padding: var(--spacing-3);
margin-bottom: var(--spacing-2);
background: var(--color-gray-50);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.checklist-item:hover {
background: var(--color-gray-100);
}
.checklist-item.checked {
background: rgba(0, 217, 177, 0.1);
}
.checklist-checkbox {
width: 20px;
height: 20px;
border: 2px solid var(--color-gray-300);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.checklist-item.checked .checklist-checkbox {
background-color: var(--color-success-main);
border-color: var(--color-success-main);
color: var(--color-white);
}
.checklist-text {
flex: 1;
font-size: var(--font-size-body-small);
color: var(--color-gray-700);
}
.action-buttons {
display: flex;
gap: var(--spacing-3);
justify-content: center;
}
.warning-message {
background-color: var(--color-warning-light);
border-left: 4px solid var(--color-warning-main);
padding: var(--spacing-4);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-4);
display: none;
}
.warning-message.show {
display: block;
}
@media (max-width: 1023px) {
.content-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 767px) {
.page-title { font-size: var(--font-size-h2); }
.action-buttons { flex-direction: column; }
.action-buttons .btn { width: 100%; }
}
</style>
</head>
<body>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">회의록 최종 확정</h1>
<p class="page-subtitle">필수 항목을 확인하고 회의록을 최종 확정하세요</p>
</div>
<div id="warningMessage" class="warning-message">
⚠️ 아래 필수 항목을 모두 확인해주세요.
</div>
<div class="content-grid">
<!-- 회의록 미리보기 -->
<div class="preview-panel">
<h2 class="preview-title">2025년 1분기 제품 기획 회의</h2>
<div class="meeting-content">
<p><strong>날짜:</strong> 2025-10-25 14:00<br>
<strong>장소:</strong> 본사 2층 대회의실<br>
<strong>참석자:</strong> 김민준, 박서연, 이준호</p>
<h2>안건</h2>
<ul>
<li>신규 기능 개발 일정 논의</li>
<li>예산 편성 검토</li>
</ul>
<h2>논의 내용</h2>
<p>신규 회의록 서비스의 핵심 기능에 대해 논의했습니다. AI 기반 자동 작성 기능과 실시간 협업 기능을 우선적으로 개발하기로 결정했습니다.</p>
<p>개발 일정은 3월 말 완료를 목표로 하며, 주요 마일스톤은 다음과 같습니다:</p>
<ul>
<li>3월 10일: 기본 UI 완성</li>
<li>3월 20일: AI 기능 통합</li>
<li>3월 30일: 베타 테스트 시작</li>
</ul>
<h2>결정 사항</h2>
<ul>
<li>신규 기능 개발은 3월 말 완료 목표</li>
<li>이준호님이 API 설계 담당</li>
<li>예산은 5천만원으로 확정</li>
</ul>
<h2>Todo</h2>
<ul>
<li>API 명세서 작성 (담당: 이준호, 마감: 3월 25일)</li>
<li>UI 프로토타입 완성 (담당: 최유진, 마감: 3월 15일)</li>
<li>예산 편성안 검토 (담당: 박서연, 마감: 3월 20일)</li>
</ul>
</div>
</div>
<!-- 확인 체크리스트 -->
<div class="checklist-panel">
<h3 class="checklist-title">필수 항목 확인</h3>
<div class="checklist-item" data-required="true">
<div class="checklist-checkbox"></div>
<div class="checklist-text">
<strong>회의 제목</strong><br>
회의 제목이 명확하게 작성되었습니다
</div>
</div>
<div class="checklist-item" data-required="true">
<div class="checklist-checkbox"></div>
<div class="checklist-text">
<strong>참석자 목록</strong><br>
모든 참석자가 기록되었습니다
</div>
</div>
<div class="checklist-item" data-required="true">
<div class="checklist-checkbox"></div>
<div class="checklist-text">
<strong>주요 논의 내용</strong><br>
핵심 논의 내용이 포함되었습니다
</div>
</div>
<div class="checklist-item" data-required="true">
<div class="checklist-checkbox"></div>
<div class="checklist-text">
<strong>결정 사항</strong><br>
회의 중 결정된 사항이 명시되었습니다
</div>
</div>
<div class="checklist-item">
<div class="checklist-checkbox"></div>
<div class="checklist-text">
<strong>Todo 생성</strong><br>
실행 항목이 Todo로 생성되었습니다
</div>
</div>
<div class="checklist-item">
<div class="checklist-checkbox"></div>
<div class="checklist-text">
<strong>전문용어 설명</strong><br>
필요한 용어에 설명이 추가되었습니다
</div>
</div>
</div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons">
<button class="btn btn-secondary" onclick="history.back()">이전으로</button>
<button class="btn btn-primary" id="confirmBtn" disabled>회의록 확정하기</button>
</div>
</div>
<script src="common.js"></script>
<script>
const checklistItems = document.querySelectorAll('.checklist-item');
const confirmBtn = document.getElementById('confirmBtn');
const warningMessage = document.getElementById('warningMessage');
// 체크리스트 항목 클릭
checklistItems.forEach(item => {
item.addEventListener('click', () => {
item.classList.toggle('checked');
const checkbox = item.querySelector('.checklist-checkbox');
if (item.classList.contains('checked')) {
checkbox.textContent = '✓';
} else {
checkbox.textContent = '';
}
checkCompletion();
});
});
// 완료 여부 확인
function checkCompletion() {
const requiredItems = document.querySelectorAll('.checklist-item[data-required="true"]');
const checkedRequired = document.querySelectorAll('.checklist-item[data-required="true"].checked');
if (requiredItems.length === checkedRequired.length) {
confirmBtn.disabled = false;
warningMessage.classList.remove('show');
} else {
confirmBtn.disabled = true;
warningMessage.classList.add('show');
}
}
// 확정 버튼 클릭
confirmBtn.addEventListener('click', () => {
MeetingApp.Loading.show();
setTimeout(() => {
MeetingApp.Loading.hide();
MeetingApp.Toast.success('회의록이 확정되었습니다!');
setTimeout(() => {
window.location.href = '09-회의록공유.html';
}, 1000);
}, 1500);
});
// 초기 확인
checkCompletion();
</script>
</body>
</html>
@@ -1,845 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의록 수정 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200">
<style>
.auto-save-indicator {
position: fixed;
top: 70px;
right: 16px;
padding: 8px 12px;
background: var(--color-white);
border-radius: 20px;
box-shadow: var(--shadow-sm);
font-size: 12px;
color: var(--color-gray-600);
z-index: var(--z-sticky);
display: none;
}
.auto-save-indicator.active {
display: flex;
align-items: center;
gap: 6px;
}
/* NEW - UFR-MEET-055: 섹션 잠금 해제 버튼 스타일 */
.section-lock-area {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--color-gray-50);
border-radius: 8px;
margin-top: 12px;
}
.btn-unlock {
padding: 6px 12px;
font-size: 14px;
background: var(--color-primary-main);
color: var(--color-white);
border: none;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.btn-unlock:hover {
background: var(--color-primary-dark);
}
/* NEW - UFR-COLLAB-020: 충돌 해결 UI 스타일 */
.conflict-banner {
position: fixed;
top: 60px;
left: 16px;
right: 16px;
padding: 12px 16px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid #EF4444;
border-radius: 8px;
z-index: var(--z-sticky);
display: none;
align-items: center;
gap: 12px;
}
.conflict-banner.active {
display: flex;
}
.conflict-icon {
color: #EF4444;
font-size: 24px;
}
.conflict-content {
flex: 1;
}
.conflict-title {
font-weight: 600;
color: #B91C1C;
margin-bottom: 4px;
}
.conflict-description {
font-size: 12px;
color: #DC2626;
}
.btn-resolve {
padding: 6px 12px;
font-size: 14px;
background: #EF4444;
color: var(--color-white);
border: none;
border-radius: 6px;
cursor: pointer;
}
.btn-resolve:hover {
background: #DC2626;
}
/* 충돌 해결 모달 스타일 */
.conflict-resolution {
padding: 0;
}
.conflict-header {
padding: 20px;
background: rgba(239, 68, 68, 0.1);
border-bottom: 1px solid var(--color-gray-200);
}
.conflict-body {
padding: 20px;
}
.conflict-section {
margin-bottom: 20px;
}
.conflict-label {
font-weight: 600;
font-size: 14px;
color: var(--color-gray-700);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.conflict-diff {
padding: 12px;
background: var(--color-gray-50);
border-radius: 8px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.conflict-diff:hover {
border-color: var(--color-primary-light);
}
.conflict-diff.selected {
border-color: var(--color-primary-main);
background: rgba(0, 217, 177, 0.1);
}
.conflict-user {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--color-gray-600);
margin-bottom: 8px;
}
.conflict-time {
font-size: 11px;
color: var(--color-gray-500);
}
.conflict-content-box {
padding: 12px;
background: var(--color-white);
border-radius: 6px;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.conflict-actions {
display: flex;
gap: 8px;
padding: 20px;
border-top: 1px solid var(--color-gray-200);
}
/* 직접 작성 모드 */
.merge-editor {
width: 100%;
min-height: 150px;
padding: 12px;
border: 1px solid var(--color-gray-300);
border-radius: 8px;
font-family: inherit;
font-size: 14px;
resize: vertical;
}
.merge-editor:focus {
outline: none;
border-color: var(--color-primary-main);
}
/* 충돌 표시 배지 */
.conflict-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: rgba(239, 68, 68, 0.2);
color: #B91C1C;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
</style>
</head>
<body>
<div class="page">
<!-- 헤더 -->
<div class="header">
<button class="btn-icon" onclick="handleBack()" aria-label="뒤로가기">
<span class="material-symbols-outlined">arrow_back</span>
</button>
<h1 class="header-title">회의록 수정</h1>
<button class="btn btn-primary btn-sm" onclick="saveMeeting()">저장</button>
</div>
<!-- 자동 저장 인디케이터 -->
<div class="auto-save-indicator" id="autoSaveIndicator">
<span class="material-symbols-outlined" style="font-size: 16px;">check_circle</span>
<span id="autoSaveText">저장됨</span>
</div>
<!-- NEW - 충돌 알림 배너 (UFR-COLLAB-020) -->
<div class="conflict-banner" id="conflictBanner">
<span class="material-symbols-outlined conflict-icon">warning</span>
<div class="conflict-content">
<div class="conflict-title">동시 수정 충돌 감지</div>
<div class="conflict-description" id="conflictDescription">
다른 사용자가 동일한 섹션을 수정했습니다. 충돌을 해결해주세요.
</div>
</div>
<button class="btn-resolve" onclick="showConflictResolution()">
해결하기
</button>
</div>
<!-- 메인 컨텐츠 -->
<div class="content">
<!-- 회의록 목록 모드 -->
<div id="listMode">
<!-- 필터 및 검색 -->
<div class="d-flex gap-2 mb-4">
<select id="statusFilter" class="form-select" style="flex: 1;" onchange="renderMeetingList()">
<option value="all">전체</option>
<option value="draft">작성중</option>
<option value="confirmed">확정완료</option>
</select>
<select id="sortOrder" class="form-select" style="flex: 1;" onchange="renderMeetingList()">
<option value="recent">최신순</option>
<option value="date">회의일시순</option>
<option value="title">제목순</option>
</select>
</div>
<div class="form-group">
<input
type="text"
id="searchInput"
class="form-input"
placeholder="회의 제목, 참석자, 키워드 검색"
oninput="renderMeetingList()"
>
</div>
<!-- 회의록 목록 -->
<div id="meetingList">
<!-- JavaScript로 동적 생성 -->
</div>
</div>
<!-- 수정 모드 -->
<div id="editMode" style="display: none;">
<!-- 기본 정보 수정 -->
<div class="card mb-4">
<h3 class="text-h5 mb-3">기본 정보</h3>
<div class="form-group">
<label for="editTitle" class="form-label required">회의 제목</label>
<input type="text" id="editTitle" class="form-input" maxlength="100">
</div>
<div class="d-flex gap-2">
<div class="form-group" style="flex: 1;">
<label for="editDate" class="form-label">날짜</label>
<input type="date" id="editDate" class="form-input">
</div>
<div class="form-group" style="flex: 1;">
<label for="editStartTime" class="form-label">시작</label>
<input type="time" id="editStartTime" class="form-input">
</div>
<div class="form-group" style="flex: 1;">
<label for="editEndTime" class="form-label">종료</label>
<input type="time" id="editEndTime" class="form-input">
</div>
</div>
</div>
<!-- 섹션별 수정 -->
<div id="editSectionList">
<!-- JavaScript로 동적 생성 -->
</div>
<!-- 하단 액션 -->
<div class="d-flex gap-2 mt-4">
<button class="btn btn-secondary" onclick="cancelEdit()">
취소
</button>
<button class="btn btn-primary" style="flex: 1;" onclick="saveMeeting()">
저장
</button>
</div>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
if (!NavigationHelper.requireAuth()) {}
const currentUser = StorageManager.getCurrentUser();
const meetingId = NavigationHelper.getQueryParam('id');
let currentMeeting = null;
let isEditMode = false;
let autoSaveTimer = null;
let hasUnsavedChanges = false;
// NEW - UFR-COLLAB-020: 충돌 관리 변수
let conflicts = [];
let currentConflict = null;
// 회의록 목록 렌더링
function renderMeetingList() {
const meetings = StorageManager.getMeetings();
const myMeetings = meetings.filter(m =>
m.createdBy === currentUser.id || m.attendees.includes(currentUser.name)
);
// 필터링
const statusFilter = document.getElementById('statusFilter').value;
let filtered = myMeetings;
if (statusFilter !== 'all') {
filtered = myMeetings.filter(m => m.status === statusFilter);
}
// 검색
const searchQuery = document.getElementById('searchInput').value.toLowerCase();
if (searchQuery) {
filtered = filtered.filter(m =>
m.title.toLowerCase().includes(searchQuery) ||
m.attendees.some(a => a.toLowerCase().includes(searchQuery))
);
}
// 정렬
const sortOrder = document.getElementById('sortOrder').value;
if (sortOrder === 'recent') {
filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
} else if (sortOrder === 'date') {
filtered.sort((a, b) => new Date(b.date) - new Date(a.date));
} else if (sortOrder === 'title') {
filtered.sort((a, b) => a.title.localeCompare(b.title));
}
// 렌더링
const container = document.getElementById('meetingList');
if (filtered.length === 0) {
container.innerHTML = '<p class="text-body text-gray text-center" style="padding: 48px 0;">회의록이 없습니다</p>';
return;
}
container.innerHTML = filtered.map(meeting => `
<div class="meeting-item" onclick="editMeetingById('${meeting.id}')">
<div style="flex: 1;">
<h3 class="text-h5">${meeting.title}</h3>
<p class="text-caption text-gray">${Utils.formatDate(meeting.date)} ${meeting.startTime || ''} · ${meeting.attendees?.length || 0}명</p>
<p class="text-caption text-gray mt-1">최종 수정: ${Utils.formatTimeAgo(meeting.updatedAt)}</p>
</div>
<div class="d-flex flex-column align-end gap-2">
${meeting.status === 'confirmed' ? '<span class="badge badge-confirmed">확정완료</span>' : '<span class="badge badge-draft">작성중</span>'}
${meeting.createdBy === currentUser.id ? '' : '<span class="text-caption text-gray">조회 전용</span>'}
</div>
</div>
`).join('');
}
// 회의록 수정 모드로 전환
function editMeetingById(id) {
const meeting = StorageManager.getMeetingById(id);
if (!meeting) {
UIComponents.showToast('회의록을 찾을 수 없습니다', 'error');
return;
}
// 권한 체크
const canEdit = meeting.createdBy === currentUser.id;
if (!canEdit) {
UIComponents.showToast('본인이 작성한 회의록만 수정할 수 있습니다', 'warning');
setTimeout(() => {
NavigationHelper.navigate('MEETING_DETAIL', { id });
}, 1500);
return;
}
currentMeeting = { ...meeting };
isEditMode = true;
// 확정완료 → 작성중으로 변경
if (currentMeeting.status === 'confirmed') {
currentMeeting.status = 'draft';
UIComponents.showToast('확정완료 회의록이 작성중으로 변경되었습니다', 'info');
}
// UI 전환
document.getElementById('listMode').style.display = 'none';
document.getElementById('editMode').style.display = 'block';
// 기본 정보 설정
document.getElementById('editTitle').value = currentMeeting.title;
document.getElementById('editDate').value = currentMeeting.date;
document.getElementById('editStartTime').value = currentMeeting.startTime || '';
document.getElementById('editEndTime').value = currentMeeting.endTime || '';
// 섹션 렌더링
renderEditSections();
// NEW - 충돌 감지 (UFR-COLLAB-020)
detectConflicts();
// 자동 저장 시작
startAutoSave();
}
// NEW - UFR-COLLAB-020: 충돌 감지
function detectConflicts() {
// 시뮬레이션: 30% 확률로 충돌 발생
if (Math.random() < 0.3 && currentMeeting.sections.length > 0) {
const conflictSectionIndex = Math.floor(Math.random() * currentMeeting.sections.length);
const conflictSection = currentMeeting.sections[conflictSectionIndex];
const otherUsers = DUMMY_USERS.filter(u => u.id !== currentUser.id);
const conflictUser = otherUsers[Math.floor(Math.random() * otherUsers.length)];
conflicts.push({
sectionId: conflictSection.id,
sectionName: conflictSection.name,
myVersion: {
content: conflictSection.content || '(내용 없음)',
modifiedAt: new Date().toISOString(),
modifiedBy: currentUser.name
},
theirVersion: {
content: generateRandomConflictContent(conflictSection.content),
modifiedAt: new Date(Date.now() - 5000).toISOString(),
modifiedBy: conflictUser.name
}
});
showConflictBanner();
}
}
// 충돌 내용 생성 (시뮬레이션)
function generateRandomConflictContent(originalContent) {
if (!originalContent) return '다른 사용자가 추가한 내용입니다.';
const variations = [
originalContent + '\n\n추가 논의사항: 예산 검토 필요',
originalContent.replace('결정', '잠정 결정'),
'수정된 내용:\n' + originalContent,
originalContent + '\n\n※ 재논의 필요'
];
return variations[Math.floor(Math.random() * variations.length)];
}
// 충돌 배너 표시
function showConflictBanner() {
const banner = document.getElementById('conflictBanner');
const description = document.getElementById('conflictDescription');
if (conflicts.length > 0) {
description.textContent = `${conflicts.length}개 섹션에서 충돌이 감지되었습니다. 충돌을 해결해주세요.`;
banner.classList.add('active');
} else {
banner.classList.remove('active');
}
}
// NEW - UFR-COLLAB-020: 충돌 해결 모달 표시
function showConflictResolution() {
if (conflicts.length === 0) return;
currentConflict = conflicts[0];
let selectedVersion = 'mine'; // 기본값: 내 버전
const modalContent = `
<div class="conflict-resolution">
<div class="conflict-header">
<h3 class="text-h5" style="color: #B91C1C;">
<span class="material-symbols-outlined" style="vertical-align: middle;">warning</span>
충돌 해결 필요
</h3>
<p class="text-caption text-gray mt-2">
"${currentConflict.sectionName}" 섹션에서 충돌이 감지되었습니다. 최종 버전을 선택하거나 직접 작성하세요.
</p>
</div>
<div class="conflict-body">
<!-- 내 버전 -->
<div class="conflict-section">
<div class="conflict-label">
<span class="material-symbols-outlined" style="color: var(--color-primary-main);">person</span>
내 수정 내용
</div>
<div class="conflict-diff selected" id="myVersion" onclick="selectVersion('mine')">
<div class="conflict-user">
<span class="material-symbols-outlined" style="font-size: 14px;">account_circle</span>
${currentConflict.myVersion.modifiedBy}
<span class="conflict-time">· ${Utils.formatTimeAgo(currentConflict.myVersion.modifiedAt)}</span>
</div>
<div class="conflict-content-box">${currentConflict.myVersion.content}</div>
</div>
</div>
<!-- 타인 버전 -->
<div class="conflict-section">
<div class="conflict-label">
<span class="material-symbols-outlined" style="color: #F59E0B;">group</span>
다른 사용자 수정 내용
</div>
<div class="conflict-diff" id="theirVersion" onclick="selectVersion('theirs')">
<div class="conflict-user">
<span class="material-symbols-outlined" style="font-size: 14px;">account_circle</span>
${currentConflict.theirVersion.modifiedBy}
<span class="conflict-time">· ${Utils.formatTimeAgo(currentConflict.theirVersion.modifiedAt)}</span>
</div>
<div class="conflict-content-box">${currentConflict.theirVersion.content}</div>
</div>
</div>
<!-- 직접 작성 -->
<div class="conflict-section">
<div class="conflict-label">
<span class="material-symbols-outlined" style="color: #10B981;">edit</span>
직접 작성하기
</div>
<div class="conflict-diff" id="manualVersion" onclick="selectVersion('manual')">
<textarea
class="merge-editor"
id="manualContent"
placeholder="양쪽 내용을 참고하여 직접 작성하세요..."
>${currentConflict.myVersion.content}</textarea>
</div>
</div>
</div>
<div class="conflict-actions">
<button class="btn btn-secondary" onclick="UIComponents.closeModal()">
취소
</button>
<button class="btn btn-primary" style="flex: 1;" onclick="resolveConflict()">
이 버전으로 확정
</button>
</div>
</div>
`;
UIComponents.showModal('충돌 해결', modalContent, null, 'large');
// 버전 선택 함수
window.selectVersion = function(version) {
selectedVersion = version;
document.getElementById('myVersion').classList.remove('selected');
document.getElementById('theirVersion').classList.remove('selected');
document.getElementById('manualVersion').classList.remove('selected');
if (version === 'mine') {
document.getElementById('myVersion').classList.add('selected');
} else if (version === 'theirs') {
document.getElementById('theirVersion').classList.add('selected');
} else if (version === 'manual') {
document.getElementById('manualVersion').classList.add('selected');
document.getElementById('manualContent').focus();
}
};
// 충돌 해결 함수
window.resolveConflict = function() {
let finalContent = '';
if (selectedVersion === 'mine') {
finalContent = currentConflict.myVersion.content;
} else if (selectedVersion === 'theirs') {
finalContent = currentConflict.theirVersion.content;
} else if (selectedVersion === 'manual') {
finalContent = document.getElementById('manualContent').value;
}
// 섹션 내용 업데이트
const section = currentMeeting.sections.find(s => s.id === currentConflict.sectionId);
if (section) {
section.content = finalContent;
// textarea 업데이트
const textarea = document.querySelector(`textarea[data-section-id="${currentConflict.sectionId}"]`);
if (textarea) {
textarea.value = finalContent;
}
}
// 충돌 목록에서 제거
conflicts.shift();
UIComponents.closeModal();
UIComponents.showToast('충돌이 해결되었습니다', 'success');
// 남은 충돌 처리
if (conflicts.length > 0) {
setTimeout(() => {
showConflictResolution();
}, 500);
} else {
showConflictBanner();
markAsChanged();
}
};
}
// 섹션 수정 렌더링
function renderEditSections() {
const container = document.getElementById('editSectionList');
container.innerHTML = currentMeeting.sections.map((section, index) => {
const hasConflict = conflicts.some(c => c.sectionId === section.id);
return `
<div class="card mb-4">
<div class="d-flex justify-between align-center mb-3">
<div class="d-flex align-center gap-2">
<h3 class="text-h5">${section.name}</h3>
${hasConflict ? '<span class="conflict-badge"><span class="material-symbols-outlined" style="font-size: 14px;">warning</span> 충돌</span>' : ''}
</div>
${section.locked ? '<span class="material-symbols-outlined" style="color: var(--color-gray-600);">lock</span>' : ''}
</div>
<textarea
class="form-textarea"
rows="5"
data-section-id="${section.id}"
onchange="markAsChanged()"
${section.locked ? 'disabled' : ''}
>${section.content || ''}</textarea>
${section.locked ? `
<!-- NEW - UFR-MEET-055: 섹션 잠금 해제 버튼 -->
<div class="section-lock-area">
<span class="material-symbols-outlined" style="color: #F59E0B; font-size: 18px;">lock</span>
<div style="flex: 1;">
<p class="text-caption text-gray" style="margin: 0;">
이 섹션은 잠겨있습니다. 수정하려면 잠금을 해제하세요.
</p>
</div>
<button class="btn-unlock" onclick="unlockSection('${section.id}')">
<span class="material-symbols-outlined" style="font-size: 16px;">lock_open</span>
잠금 해제
</button>
</div>
` : ''}
</div>
`;
}).join('');
}
// NEW - UFR-MEET-055: 섹션 잠금 해제
function unlockSection(sectionId) {
UIComponents.confirm(
'이 섹션의 잠금을 해제하시겠습니까? 해제 후에는 내용을 수정할 수 있습니다.',
() => {
const section = currentMeeting.sections.find(s => s.id === sectionId);
if (section) {
section.locked = false;
renderEditSections();
UIComponents.showToast('섹션 잠금이 해제되었습니다', 'success');
markAsChanged();
}
},
() => {}
);
}
// 변경사항 표시
function markAsChanged() {
hasUnsavedChanges = true;
}
// 자동 저장 시작
function startAutoSave() {
if (autoSaveTimer) clearInterval(autoSaveTimer);
autoSaveTimer = setInterval(() => {
if (hasUnsavedChanges) {
autoSaveMeeting();
}
}, 30000); // 30초마다 자동 저장
}
// 자동 저장
function autoSaveMeeting() {
const indicator = document.getElementById('autoSaveIndicator');
document.getElementById('autoSaveText').textContent = '저장 중...';
indicator.classList.add('active');
// 데이터 수집
collectMeetingData();
// 저장
setTimeout(() => {
StorageManager.updateMeeting(currentMeeting.id, currentMeeting);
hasUnsavedChanges = false;
document.getElementById('autoSaveText').textContent = '저장됨';
setTimeout(() => {
indicator.classList.remove('active');
}, 2000);
}, 500);
}
// 회의록 데이터 수집
function collectMeetingData() {
currentMeeting.title = document.getElementById('editTitle').value;
currentMeeting.date = document.getElementById('editDate').value;
currentMeeting.startTime = document.getElementById('editStartTime').value;
currentMeeting.endTime = document.getElementById('editEndTime').value;
// 섹션 내용 수집
currentMeeting.sections.forEach(section => {
const textarea = document.querySelector(`textarea[data-section-id="${section.id}"]`);
if (textarea) {
section.content = textarea.value;
}
});
currentMeeting.updatedAt = new Date().toISOString();
}
// 회의록 저장
function saveMeeting() {
if (!currentMeeting) return;
// 충돌 확인
if (conflicts.length > 0) {
UIComponents.showToast('먼저 충돌을 해결해주세요', 'warning');
showConflictResolution();
return;
}
collectMeetingData();
UIComponents.showLoading('저장하는 중...');
setTimeout(() => {
StorageManager.updateMeeting(currentMeeting.id, currentMeeting);
hasUnsavedChanges = false;
UIComponents.hideLoading();
UIComponents.showToast('회의록이 저장되었습니다', 'success');
setTimeout(() => {
window.location.href = '12-회의록상세조회.html';
}, 1000);
}, 800);
}
// 수정 취소
function cancelEdit() {
if (hasUnsavedChanges) {
UIComponents.confirm(
'저장하지 않은 변경사항이 있습니다. 정말 취소하시겠습니까?',
() => {
resetEditMode();
},
() => {}
);
} else {
resetEditMode();
}
}
// 수정 모드 리셋
function resetEditMode() {
if (autoSaveTimer) clearInterval(autoSaveTimer);
currentMeeting = null;
isEditMode = false;
hasUnsavedChanges = false;
conflicts = [];
currentConflict = null;
document.getElementById('listMode').style.display = 'block';
document.getElementById('editMode').style.display = 'none';
document.getElementById('conflictBanner').classList.remove('active');
renderMeetingList();
}
// 뒤로가기 처리
function handleBack() {
if (isEditMode) {
cancelEdit();
} else {
NavigationHelper.navigate('DASHBOARD');
}
}
// 페이지 이탈 방지
window.addEventListener('beforeunload', (e) => {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
});
// 초기화
if (meetingId) {
editMeetingById(meetingId);
} else {
renderMeetingList();
}
</script>
</body>
</html>
@@ -1,765 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의록 목록 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
body {
margin: 0;
padding: 0;
background-color: var(--color-gray-50);
}
/* Header */
.page-header {
background-color: var(--color-white);
border-bottom: 1px solid var(--color-gray-200);
padding: var(--spacing-6);
position: sticky;
top: 0;
z-index: var(--z-sticky);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
}
.back-button {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
color: var(--color-gray-600);
text-decoration: none;
font-size: var(--font-size-body-small);
margin-bottom: var(--spacing-4);
transition: color var(--transition-fast);
}
.back-button:hover {
color: var(--color-primary-main);
}
.header-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-4);
}
.page-title {
font-size: var(--font-size-h1);
font-weight: var(--font-weight-bold);
color: var(--color-gray-900);
}
/* Filters */
.filters-section {
display: flex;
gap: var(--spacing-6);
align-items: center;
flex-wrap: wrap;
padding: var(--spacing-4) 0;
border-bottom: 1px solid var(--color-gray-200);
}
.filter-group {
display: flex;
gap: var(--spacing-2);
align-items: center;
}
.filter-label {
font-size: var(--font-size-body);
color: var(--color-gray-700);
font-weight: var(--font-weight-semibold);
margin-right: var(--spacing-2);
}
.filter-button {
padding: var(--spacing-2) var(--spacing-4);
border: none;
background-color: transparent;
color: var(--color-gray-600);
font-size: var(--font-size-body);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all var(--transition-fast);
position: relative;
border-radius: var(--radius-md);
}
.filter-button:hover {
background-color: var(--color-gray-100);
color: var(--color-gray-900);
}
.filter-button.active {
background-color: var(--color-primary-main);
color: var(--color-white);
box-shadow: 0 2px 4px rgba(0, 217, 177, 0.2);
}
.search-box {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-3);
border: 1px solid var(--color-gray-300);
border-radius: var(--radius-md);
background-color: var(--color-white);
flex: 1;
max-width: 400px;
}
.search-box input {
border: none;
outline: none;
flex: 1;
font-size: var(--font-size-body);
}
/* Main Content */
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: var(--spacing-6);
}
.stats-bar {
display: flex;
gap: var(--spacing-4);
margin-bottom: var(--spacing-6);
padding: var(--spacing-4);
background-color: var(--color-white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.stat-item {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.stat-icon {
font-size: 20px;
}
.stat-text {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
}
.stat-value {
font-size: var(--font-size-h4);
font-weight: var(--font-weight-bold);
color: var(--color-gray-900);
}
/* Meeting List */
.meeting-list {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.meeting-item {
background-color: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-5);
cursor: pointer;
transition: all var(--transition-fast);
box-shadow: var(--shadow-sm);
}
.meeting-item:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-primary-main);
transform: translateY(-2px);
}
.meeting-item-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--spacing-3);
gap: var(--spacing-4);
}
.meeting-item-left {
flex: 1;
}
.meeting-item-title {
font-size: var(--font-size-h3);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
margin-bottom: var(--spacing-2);
}
.meeting-item-meta {
display: flex;
gap: var(--spacing-4);
flex-wrap: wrap;
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
}
.meta-item {
display: flex;
align-items: center;
gap: var(--spacing-1);
}
.meeting-item-status {
display: flex;
align-items: flex-start;
gap: var(--spacing-2);
}
.status-badge {
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--radius-full);
font-size: var(--font-size-body-small);
font-weight: var(--font-weight-medium);
}
.status-badge.confirmed {
background-color: rgba(0, 217, 177, 0.1);
color: var(--color-primary-main);
}
.status-badge.scheduled {
background-color: rgba(99, 102, 241, 0.1);
color: #6366f1;
}
.status-badge.in-progress {
background-color: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.meeting-item-description {
font-size: var(--font-size-body);
color: var(--color-gray-700);
line-height: 1.6;
margin-bottom: var(--spacing-3);
}
.meeting-item-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--spacing-3);
border-top: 1px solid var(--color-gray-100);
}
.attendees-list {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.attendee-avatar {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background-color: var(--color-primary-main);
color: var(--color-white);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-body-small);
font-weight: var(--font-weight-bold);
border: 2px solid var(--color-white);
margin-left: -8px;
}
.attendee-avatar:first-child {
margin-left: 0;
}
.attendee-count {
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
margin-left: var(--spacing-2);
}
.meeting-stats {
display: flex;
gap: var(--spacing-3);
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
}
.stat {
display: flex;
align-items: center;
gap: var(--spacing-1);
}
/* Empty State */
.empty-state {
text-align: center;
padding: var(--spacing-10) var(--spacing-4);
color: var(--color-gray-500);
}
.empty-icon {
font-size: 64px;
margin-bottom: var(--spacing-4);
}
.empty-message {
font-size: var(--font-size-h4);
margin-bottom: var(--spacing-2);
}
.empty-description {
font-size: var(--font-size-body);
color: var(--color-gray-400);
}
/* Responsive */
@media (max-width: 767px) {
.page-header {
padding: var(--spacing-3);
}
.main-content {
padding: var(--spacing-3);
}
.page-title {
font-size: var(--font-size-h3);
margin-bottom: var(--spacing-3);
}
.header-top {
flex-direction: column;
align-items: stretch;
gap: var(--spacing-3);
}
/* 필터 섹션 개선 */
.filters-section {
gap: var(--spacing-3);
padding: var(--spacing-3) 0;
flex-direction: column;
align-items: stretch;
}
.filter-group {
display: flex;
align-items: center;
gap: var(--spacing-2);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.filter-group::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.filter-label {
font-size: var(--font-size-body-small);
white-space: nowrap;
flex-shrink: 0;
}
.filter-button {
padding: var(--spacing-1) var(--spacing-3);
font-size: var(--font-size-body-small);
white-space: nowrap;
flex-shrink: 0;
}
/* 요약카드 개선 - 가로 스크롤 */
.stats-bar {
flex-direction: row;
gap: var(--spacing-3);
padding: var(--spacing-3);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
}
.stats-bar::-webkit-scrollbar {
display: none;
}
.stat-item {
flex-shrink: 0;
min-width: 140px;
padding: var(--spacing-2);
background-color: var(--color-gray-50);
border-radius: var(--radius-md);
}
.stat-icon {
font-size: 18px;
}
.stat-value {
font-size: var(--font-size-h3);
}
.stat-label {
font-size: var(--font-size-caption);
}
/* 회의록 카드 */
.meeting-item {
padding: var(--spacing-4);
}
.meeting-item-title {
font-size: var(--font-size-h4);
}
.meeting-item-header {
flex-direction: column;
gap: var(--spacing-2);
}
.meeting-item-status {
align-self: flex-start;
}
.meeting-item-meta {
gap: var(--spacing-2);
}
.meeting-item-footer {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-2);
}
.search-box {
max-width: 100%;
width: 100%;
}
.back-button {
margin-bottom: var(--spacing-2);
}
}
</style>
</head>
<body>
<!-- Header -->
<header class="page-header">
<div class="header-content">
<a href="02-대시보드.html" class="back-button">
← 대시보드
</a>
<div class="header-top">
<h1 class="page-title">회의록 목록</h1>
<div class="search-box">
<span>🔍</span>
<input type="text" placeholder="회의록 검색..." id="searchInput">
</div>
</div>
<div class="filters-section">
<div class="filter-group">
<span class="filter-label">상태:</span>
<button class="filter-button active" data-filter="all">전체</button>
<button class="filter-button" data-filter="confirmed">확정</button>
<button class="filter-button" data-filter="scheduled">예정</button>
<button class="filter-button" data-filter="in-progress">진행중</button>
</div>
<div class="filter-group">
<span class="filter-label">기간:</span>
<button class="filter-button active" data-period="all">전체</button>
<button class="filter-button" data-period="week">1주</button>
<button class="filter-button" data-period="month">1개월</button>
<button class="filter-button" data-period="quarter">3개월</button>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="main-content">
<!-- Stats Bar -->
<div class="stats-bar">
<div class="stat-item">
<span class="stat-icon">📋</span>
<div class="stat-text">
<span class="stat-label">전체 회의록</span>
<span class="stat-value" id="totalCount">0</span>
</div>
</div>
<div class="stat-item">
<span class="stat-icon"></span>
<div class="stat-text">
<span class="stat-label">확정 완료</span>
<span class="stat-value" id="confirmedCount">0</span>
</div>
</div>
<div class="stat-item">
<span class="stat-icon">📌</span>
<div class="stat-text">
<span class="stat-label">진행 중 Todo</span>
<span class="stat-value" id="todoCount">0</span>
</div>
</div>
</div>
<!-- Meeting List -->
<div class="meeting-list" id="meetingList">
<!-- Meetings will be rendered here -->
</div>
<!-- Empty State -->
<div class="empty-state" id="emptyState" style="display: none;">
<div class="empty-icon">📭</div>
<div class="empty-message">회의록이 없습니다</div>
<div class="empty-description">새로운 회의를 예약하고 회의록을 작성해보세요</div>
</div>
</main>
<!-- JavaScript -->
<script src="common.js"></script>
<script>
// Use MeetingApp utilities directly without destructuring
// Sample meeting data
const meetings = [
{
id: 'm-001',
title: '2025년 1분기 제품 기획 회의',
date: '2025-10-25 14:00',
location: '본사 2층 대회의실',
status: 'confirmed',
attendees: ['김민준', '박서연', '이준호', '최유진'],
description: '신규 회의록 서비스 기획 논의 및 개발 일정 수립',
duration: 90,
decisions: 3,
todos: 5
},
{
id: 'm-002',
title: '주간 스크럼 회의',
date: '2025-10-21 10:00',
location: 'Zoom',
status: 'confirmed',
attendees: ['김민준', '이준호', '최유진'],
description: '지난 주 진행 상황 공유 및 이번 주 계획 수립',
duration: 30,
decisions: 2,
todos: 8
},
{
id: 'm-003',
title: 'AI 기능 개선 회의',
date: '2025-10-23 15:00',
location: '본사 3층 소회의실',
status: 'in-progress',
attendees: ['박서연', '이준호'],
description: 'LLM 기반 회의록 자동 작성 개선 방안 논의',
duration: 60,
decisions: 4,
todos: 3
},
{
id: 'm-004',
title: '2024 Q4 마케팅 전략 회의',
date: '2024-01-15 14:00',
location: '본사 대회의실',
status: 'confirmed',
attendees: ['김민준', '박서연', '이준호', '최유진', '정도현'],
description: 'Q4 마케팅 예산 증액 및 인플루언서 마케팅 캠페인 론칭 결정',
duration: 90,
decisions: 3,
todos: 12
},
{
id: 'm-005',
title: 'UI/UX 개선 워크샵',
date: '2025-10-18 13:00',
location: '본사 4층 세미나실',
status: 'confirmed',
attendees: ['최유진', '김민준', '박서연'],
description: '사용자 피드백 기반 UI/UX 개선 방안 도출',
duration: 120,
decisions: 5,
todos: 7
},
{
id: 'm-006',
title: '월간 전체 회의',
date: '2025-11-01 16:00',
location: '본사 대강당',
status: 'scheduled',
attendees: ['김민준', '박서연', '이준호', '최유진', '정도현', '송주영'],
description: '월간 실적 공유 및 다음 달 목표 설정',
duration: 60,
decisions: 0,
todos: 0
}
];
// Render meetings
function renderMeetings(filteredMeetings = meetings) {
const meetingList = document.getElementById('meetingList');
const emptyState = document.getElementById('emptyState');
if (filteredMeetings.length === 0) {
meetingList.style.display = 'none';
emptyState.style.display = 'block';
return;
}
meetingList.style.display = 'flex';
emptyState.style.display = 'none';
meetingList.innerHTML = filteredMeetings.map(meeting => {
const attendeeAvatars = meeting.attendees.slice(0, 4).map((name, index) => {
const initial = name.charAt(0);
const colors = ['#00d9b1', '#6366f1', '#f59e0b', '#ec4899'];
return `<div class="attendee-avatar" style="background-color: ${colors[index % 4]}">${initial}</div>`;
}).join('');
const extraCount = meeting.attendees.length > 4 ? `+${meeting.attendees.length - 4}` : '';
const statusClass = {
'confirmed': 'confirmed',
'scheduled': 'scheduled',
'in-progress': 'in-progress'
}[meeting.status] || 'scheduled';
const statusLabel = {
'confirmed': '✓ 확정 완료',
'scheduled': '📅 예정',
'in-progress': '🔄 진행중'
}[meeting.status] || '예정';
return `
<div class="meeting-item" onclick="window.MeetingApp.navigateTo('13-회의록상세조회.html')">
<div class="meeting-item-header">
<div class="meeting-item-left">
<h3 class="meeting-item-title">${meeting.title}</h3>
<div class="meeting-item-meta">
<span class="meta-item">📅 ${formatDateTime(meeting.date)}</span>
<span class="meta-item">📍 ${meeting.location}</span>
<span class="meta-item">⏱️ ${meeting.duration}분</span>
</div>
</div>
<div class="meeting-item-status">
<span class="status-badge ${statusClass}">${statusLabel}</span>
</div>
</div>
<div class="meeting-item-description">
${meeting.description}
</div>
<div class="meeting-item-footer">
<div class="attendees-list">
${attendeeAvatars}
${extraCount ? `<span class="attendee-count">${extraCount}</span>` : ''}
</div>
<div class="meeting-stats">
<span class="stat">✅ 결정사항 ${meeting.decisions}개</span>
<span class="stat">📌 Todo ${meeting.todos}개</span>
</div>
</div>
</div>
`;
}).join('');
// Update stats
updateStats(filteredMeetings);
}
// Format date time
function formatDateTime(dateStr) {
const date = new Date(dateStr);
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
// Update stats
function updateStats(filteredMeetings) {
const totalCount = filteredMeetings.length;
const confirmedCount = filteredMeetings.filter(m => m.status === 'confirmed').length;
const todoCount = filteredMeetings.reduce((sum, m) => sum + m.todos, 0);
document.getElementById('totalCount').textContent = totalCount;
document.getElementById('confirmedCount').textContent = confirmedCount;
document.getElementById('todoCount').textContent = todoCount;
}
// Filter by status
const filterButtons = document.querySelectorAll('.filter-button[data-filter]');
filterButtons.forEach(button => {
button.addEventListener('click', () => {
filterButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
const filterValue = button.dataset.filter;
const filtered = filterValue === 'all'
? meetings
: meetings.filter(m => m.status === filterValue);
renderMeetings(filtered);
});
});
// Filter by period
const periodButtons = document.querySelectorAll('.filter-button[data-period]');
periodButtons.forEach(button => {
button.addEventListener('click', () => {
periodButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
const periodValue = button.dataset.period;
let filtered = meetings;
if (periodValue !== 'all') {
const now = new Date();
const daysMap = { week: 7, month: 30, quarter: 90 };
const days = daysMap[periodValue];
filtered = meetings.filter(m => {
const meetingDate = new Date(m.date);
const diffTime = now - meetingDate;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays <= days;
});
}
renderMeetings(filtered);
});
});
// Search
const searchInput = document.getElementById('searchInput');
searchInput.addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase();
const filtered = meetings.filter(m =>
m.title.toLowerCase().includes(searchTerm) ||
m.description.toLowerCase().includes(searchTerm) ||
m.location.toLowerCase().includes(searchTerm)
);
renderMeetings(filtered);
});
// Initial render
renderMeetings();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-780
View File
@@ -1,780 +0,0 @@
/*
* 회의록 작성 및 공유 개선 서비스 - 공통 스타일시트
* 버전: 1.0
* 작성일: 2025-10-20
* 레퍼런스: 스타일 가이드 v1.0
*/
/* ===== CSS Reset ===== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* ===== Root Variables (CSS Custom Properties) ===== */
:root {
/* Primary Colors */
--color-primary-light: #4DFFDB;
--color-primary-main: #00D9B1;
--color-primary-dark: #00A88A;
/* Secondary Colors */
--color-secondary-light: #A5B4FC;
--color-secondary-main: #6366F1;
--color-secondary-dark: #4F46E5;
/* Semantic Colors */
--color-success-light: #6EE7B7;
--color-success-main: #10B981;
--color-success-dark: #059669;
--color-warning-light: #FCD34D;
--color-warning-main: #F59E0B;
--color-warning-dark: #D97706;
--color-error-light: #FCA5A5;
--color-error-main: #EF4444;
--color-error-dark: #DC2626;
--color-info-light: #93C5FD;
--color-info-main: #3B82F6;
--color-info-dark: #2563EB;
/* Neutral Colors */
--color-gray-50: #F9FAFB;
--color-gray-100: #F3F4F6;
--color-gray-200: #E5E7EB;
--color-gray-300: #D1D5DB;
--color-gray-400: #9CA3AF;
--color-gray-500: #6B7280;
--color-gray-600: #4B5563;
--color-gray-700: #374151;
--color-gray-800: #1F2937;
--color-gray-900: #111827;
--color-white: #FFFFFF;
--color-black: #000000;
/* Spacing System (8px base) */
--spacing-0: 0;
--spacing-1: 4px;
--spacing-2: 8px;
--spacing-3: 12px;
--spacing-4: 16px;
--spacing-5: 20px;
--spacing-6: 24px;
--spacing-8: 32px;
--spacing-10: 40px;
--spacing-12: 48px;
--spacing-16: 64px;
--spacing-20: 80px;
/* Font Sizes */
--font-size-display: 48px;
--font-size-h1: 36px;
--font-size-h2: 30px;
--font-size-h3: 24px;
--font-size-h4: 20px;
--font-size-body-large: 18px;
--font-size-body: 16px;
--font-size-body-small: 14px;
--font-size-caption: 12px;
/* Font Weights */
--font-weight-light: 300;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Line Heights */
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.75;
/* Border Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 50%;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 20px 25px rgba(0, 0, 0, 0.15);
/* Transitions */
--transition-fast: 150ms ease-out;
--transition-base: 200ms ease-out;
--transition-slow: 300ms ease-out;
/* Z-index */
--z-dropdown: 1000;
--z-sticky: 1100;
--z-modal-backdrop: 1200;
--z-modal: 1300;
--z-toast: 1400;
--z-tooltip: 1500;
}
/* ===== Typography ===== */
body {
font-family: 'Pretendard', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI',
'Apple SD Gothic Neo', sans-serif;
font-size: var(--font-size-body);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-normal);
color: var(--color-gray-600);
background-color: var(--color-gray-50);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
color: var(--color-gray-900);
font-weight: var(--font-weight-bold);
line-height: var(--line-height-tight);
}
h1 { font-size: var(--font-size-h1); letter-spacing: -0.02em; }
h2 { font-size: var(--font-size-h2); font-weight: var(--font-weight-semibold); }
h3 { font-size: var(--font-size-h3); font-weight: var(--font-weight-semibold); line-height: var(--line-height-normal); }
h4 { font-size: var(--font-size-h4); font-weight: var(--font-weight-semibold); }
a {
color: var(--color-primary-main);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--color-primary-dark);
}
/* ===== Layout Utilities ===== */
.container {
width: 100%;
padding: 0 var(--spacing-6);
margin: 0 auto;
}
@media (min-width: 768px) {
.container { padding: 0 var(--spacing-8); }
}
@media (min-width: 1024px) {
.container { padding: 0 var(--spacing-16); }
}
.container-small { max-width: 640px; }
.container-medium { max-width: 768px; }
.container-large { max-width: 1024px; }
.container-xlarge { max-width: 1280px; }
.container-2xlarge { max-width: 1536px; }
/* ===== Button Styles ===== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-2);
padding: var(--spacing-3) var(--spacing-6);
border: none;
border-radius: var(--radius-md);
font-size: var(--font-size-body);
font-weight: var(--font-weight-medium);
line-height: 1;
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
white-space: nowrap;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* Primary Button */
.btn-primary {
background-color: var(--color-primary-main);
color: var(--color-white);
}
.btn-primary:hover:not(:disabled) {
background-color: var(--color-primary-light);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.btn-primary:active:not(:disabled) {
background-color: var(--color-primary-dark);
transform: scale(0.98);
}
.btn-primary:disabled {
background-color: var(--color-gray-300);
color: var(--color-gray-500);
}
/* Secondary Button */
.btn-secondary {
background-color: transparent;
color: var(--color-primary-main);
border: 1px solid var(--color-primary-main);
}
.btn-secondary:hover:not(:disabled) {
background-color: rgba(0, 217, 177, 0.1);
}
.btn-secondary:active:not(:disabled) {
background-color: rgba(0, 217, 177, 0.2);
}
.btn-secondary:disabled {
border-color: var(--color-gray-300);
color: var(--color-gray-400);
}
/* Text Button */
.btn-text {
background-color: transparent;
color: var(--color-gray-700);
padding: var(--spacing-2) var(--spacing-4);
}
.btn-text:hover:not(:disabled) {
background-color: var(--color-gray-100);
}
.btn-text:active:not(:disabled) {
background-color: var(--color-gray-200);
}
/* Button Sizes */
.btn-sm { padding: var(--spacing-2) var(--spacing-4); font-size: var(--font-size-body-small); }
.btn-lg { padding: var(--spacing-4) var(--spacing-8); font-size: var(--font-size-body-large); }
/* Icon Button */
.btn-icon {
width: 40px;
height: 40px;
padding: 0;
border-radius: var(--radius-md);
background-color: transparent;
}
.btn-icon:hover:not(:disabled) {
background-color: var(--color-gray-100);
}
.btn-icon-sm { width: 32px; height: 32px; }
.btn-icon-lg { width: 48px; height: 48px; }
/* Floating Action Button */
.fab {
position: fixed;
right: var(--spacing-4);
bottom: var(--spacing-4);
width: 56px;
height: 56px;
padding: 0;
border-radius: var(--radius-full);
background-color: var(--color-primary-main);
color: var(--color-white);
box-shadow: var(--shadow-md);
z-index: var(--z-sticky);
}
.fab:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
transform: translateY(-2px);
}
.fab:active {
transform: scale(0.95);
}
/* ===== Form Styles ===== */
.form-group {
margin-bottom: var(--spacing-4);
}
.form-label {
display: block;
margin-bottom: var(--spacing-2);
font-size: var(--font-size-body-small);
font-weight: var(--font-weight-medium);
color: var(--color-gray-700);
}
.form-input,
.form-textarea,
.form-select {
width: 100%;
padding: var(--spacing-3) var(--spacing-4);
border: 1px solid var(--color-gray-300);
border-radius: var(--radius-md);
font-size: var(--font-size-body);
font-family: inherit;
background-color: var(--color-white);
transition: all var(--transition-fast);
}
.form-input {
height: 48px;
}
.form-textarea {
min-height: 120px;
resize: vertical;
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: 4px solid rgba(0, 217, 177, 0.2);
border-color: var(--color-primary-main);
border-width: 2px;
}
.form-input::placeholder,
.form-textarea::placeholder {
color: var(--color-gray-400);
}
.form-input:disabled,
.form-textarea:disabled,
.form-select:disabled {
background-color: var(--color-gray-100);
color: var(--color-gray-400);
cursor: not-allowed;
}
.form-input.error,
.form-textarea.error,
.form-select.error {
border-color: var(--color-error-main);
}
.form-error {
display: block;
margin-top: var(--spacing-1);
font-size: var(--font-size-body-small);
color: var(--color-error-main);
}
/* ===== Card Styles ===== */
.card {
background-color: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
box-shadow: var(--shadow-sm);
transition: all var(--transition-base);
}
.card.interactive:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
cursor: pointer;
}
.card.interactive:active {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
transform: scale(0.99);
}
.card-header {
margin-bottom: var(--spacing-4);
}
.card-title {
font-size: var(--font-size-h4);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
}
.card-body {
color: var(--color-gray-600);
}
/* ===== Badge Styles ===== */
.badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--radius-lg);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-medium);
line-height: 1;
}
.badge-primary {
background-color: var(--color-primary-light);
color: var(--color-primary-dark);
}
.badge-success {
background-color: var(--color-success-light);
color: var(--color-success-dark);
}
.badge-warning {
background-color: var(--color-warning-light);
color: var(--color-warning-dark);
}
.badge-error {
background-color: var(--color-error-light);
color: var(--color-error-dark);
}
.badge-neutral {
background-color: var(--color-gray-200);
color: var(--color-gray-700);
}
/* ===== Modal Styles ===== */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: var(--z-modal-backdrop);
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-4);
opacity: 0;
animation: fadeIn var(--transition-base);
animation-fill-mode: forwards;
}
.modal {
background-color: var(--color-white);
border-radius: var(--radius-xl);
padding: var(--spacing-8);
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
z-index: var(--z-modal);
opacity: 0;
transform: scale(0.95);
animation: modalIn var(--transition-base);
animation-fill-mode: forwards;
}
.modal-header {
margin-bottom: var(--spacing-6);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-title {
font-size: var(--font-size-h3);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-900);
}
.modal-close {
padding: var(--spacing-2);
background: none;
border: none;
cursor: pointer;
color: var(--color-gray-500);
font-size: 24px;
line-height: 1;
transition: color var(--transition-fast);
}
.modal-close:hover {
color: var(--color-gray-700);
}
.modal-body {
margin-bottom: var(--spacing-8);
}
.modal-footer {
display: flex;
gap: var(--spacing-3);
justify-content: flex-end;
}
@keyframes fadeIn {
to { opacity: 1; }
}
@keyframes modalIn {
to {
opacity: 1;
transform: scale(1);
}
}
/* ===== Toast Styles ===== */
.toast-container {
position: fixed;
top: var(--spacing-4);
right: var(--spacing-4);
z-index: var(--z-toast);
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.toast {
background-color: var(--color-white);
border-radius: var(--radius-md);
padding: var(--spacing-4) var(--spacing-5);
max-width: 400px;
box-shadow: var(--shadow-md);
display: flex;
align-items: flex-start;
gap: var(--spacing-3);
border-left: 4px solid var(--color-gray-400);
animation: slideInRight var(--transition-base);
}
.toast-success { border-left-color: var(--color-success-main); }
.toast-error { border-left-color: var(--color-error-main); }
.toast-warning { border-left-color: var(--color-warning-main); }
.toast-info { border-left-color: var(--color-info-main); }
.toast-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
}
.toast-content {
flex: 1;
}
.toast-title {
font-weight: var(--font-weight-medium);
color: var(--color-gray-900);
margin-bottom: var(--spacing-1);
}
.toast-message {
font-size: var(--font-size-body-small);
color: var(--color-gray-600);
}
.toast-close {
padding: 0;
background: none;
border: none;
cursor: pointer;
color: var(--color-gray-500);
font-size: 16px;
line-height: 1;
}
@keyframes slideInRight {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* ===== Loading Styles ===== */
.spinner {
width: 40px;
height: 40px;
border: 4px solid var(--color-gray-200);
border-top-color: var(--color-primary-main);
border-radius: var(--radius-full);
animation: spin 1s linear infinite;
}
.spinner-sm { width: 24px; height: 24px; border-width: 3px; }
.spinner-lg { width: 56px; height: 56px; border-width: 5px; }
@keyframes spin {
to { transform: rotate(360deg); }
}
.skeleton {
background-color: var(--color-gray-200);
border-radius: var(--radius-sm);
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ===== Utility Classes ===== */
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.mt-1 { margin-top: var(--spacing-1); }
.mt-2 { margin-top: var(--spacing-2); }
.mt-3 { margin-top: var(--spacing-3); }
.mt-4 { margin-top: var(--spacing-4); }
.mt-6 { margin-top: var(--spacing-6); }
.mt-8 { margin-top: var(--spacing-8); }
.mb-1 { margin-bottom: var(--spacing-1); }
.mb-2 { margin-bottom: var(--spacing-2); }
.mb-3 { margin-bottom: var(--spacing-3); }
.mb-4 { margin-bottom: var(--spacing-4); }
.mb-6 { margin-bottom: var(--spacing-6); }
.mb-8 { margin-bottom: var(--spacing-8); }
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: var(--spacing-2); }
.gap-3 { gap: var(--spacing-3); }
.gap-4 { gap: var(--spacing-4); }
.hidden { display: none; }
.block { display: block; }
/* ===== 회의록 서비스 특화 스타일 ===== */
/* 상태 배지 - 회의록 전용 */
.status-draft {
background-color: var(--color-warning-light);
color: var(--color-warning-dark);
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--radius-lg);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-medium);
}
.status-verifying {
background-color: var(--color-info-light);
color: var(--color-info-dark);
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--radius-lg);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-medium);
}
.status-confirmed {
background-color: var(--color-success-light);
color: var(--color-success-dark);
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--radius-lg);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-medium);
}
/* 전문용어 하이라이트 */
.term-highlight {
border-bottom: 2px dotted var(--color-primary-main);
cursor: pointer;
transition: color var(--transition-fast);
}
.term-highlight:hover {
color: var(--color-primary-dark);
}
/* AI 제안 영역 */
.ai-suggestion {
background-color: var(--color-gray-50);
border: 1px dashed var(--color-primary-main);
border-radius: var(--radius-md);
padding: var(--spacing-4);
margin: var(--spacing-4) 0;
position: relative;
}
.ai-suggestion::before {
content: "✨ AI 제안";
position: absolute;
top: -10px;
left: var(--spacing-4);
background-color: var(--color-white);
padding: 0 var(--spacing-2);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-medium);
color: var(--color-primary-main);
}
/* Todo 카드 */
.todo-card {
background-color: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-md);
padding: var(--spacing-4);
box-shadow: var(--shadow-sm);
position: relative;
}
.todo-card.priority-high {
border-left: 4px solid var(--color-error-main);
}
.todo-card.priority-medium {
border-left: 4px solid var(--color-warning-main);
}
.todo-progress {
height: 4px;
background-color: var(--color-gray-200);
border-radius: 2px;
overflow: hidden;
margin-top: var(--spacing-3);
}
.todo-progress-bar {
height: 100%;
background-color: var(--color-primary-main);
transition: width var(--transition-slow);
}
/* 협업 커서 (예시) */
.collab-cursor {
position: absolute;
width: 2px;
height: 20px;
pointer-events: none;
z-index: 100;
}
.collab-cursor-label {
position: absolute;
top: -24px;
left: 0;
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--radius-sm);
font-size: var(--font-size-caption);
color: var(--color-white);
white-space: nowrap;
}
/* 반응형 유틸리티 */
@media (max-width: 767px) {
.hide-mobile { display: none !important; }
}
@media (min-width: 768px) and (max-width: 1023px) {
.hide-tablet { display: none !important; }
}
@media (min-width: 1024px) {
.hide-desktop { display: none !important; }
}
-556
View File
@@ -1,556 +0,0 @@
/*
* 회의록 작성 및 공유 개선 서비스 - 공통 자바스크립트
* 버전: 1.0
* 작성일: 2025-10-20
* 레퍼런스: 스타일 가이드 v1.0
*/
// ===== 전역 상태 관리 =====
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: []
};
// ===== 유틸리티 함수 =====
/**
* DOM 준비 완료 시 콜백 실행
*/
function ready(callback) {
if (document.readyState !== 'loading') {
callback();
} else {
document.addEventListener('DOMContentLoaded', callback);
}
}
/**
* 날짜 포맷팅 (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}`;
}
/**
* 상대 시간 표현 (방금 전, 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)}년 전`;
}
/**
* 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));
if (diff === 0) return '오늘';
if (diff > 0) return `D-${diff}`;
return `D+${Math.abs(diff)} (지남)`;
}
/**
* 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);
});
}
// ===== 모달 관리 =====
const Modal = {
/**
* 모달 열기
*/
open(modalId) {
const modal = document.getElementById(modalId);
if (!modal) return;
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
// backdrop 클릭 시 모달 닫기
const backdrop = modal.querySelector('.modal-backdrop');
if (backdrop) {
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) {
this.close(modalId);
}
});
}
// 닫기 버튼
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';
}
};
// ===== 토스트 알림 =====
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;
}
}
};
// ===== 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;
}
// ===== 폼 유효성 검사 =====
const Validator = {
/**
* 이메일 유효성 검사
*/
isEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
},
/**
* 필수 입력 검사
*/
required(value) {
return value !== null && value !== undefined && value.trim() !== '';
},
/**
* 최소 길이 검사
*/
minLength(value, min) {
return value && value.length >= min;
},
/**
* 최대 길이 검사
*/
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();
}
}
};
// ===== 로딩 상태 관리 =====
const Loading = {
/**
* 로딩 표시
*/
show(target = 'body') {
const element = typeof target === 'string' ? document.querySelector(target) : target;
if (!element) return;
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.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
};
-991
View File
@@ -1,991 +0,0 @@
# 회의록 작성 및 공유 개선 서비스 - 유저스토리 (v2.1)
- [회의록 작성 및 공유 개선 서비스 - 유저스토리 (v2.1)](#회의록-작성-및-공유-개선-서비스---유저스토리-v21)
- [차별화 전략](#차별화-전략)
- [마이크로서비스 구성](#마이크로서비스-구성)
- [유저스토리](#유저스토리)
---
## 차별화 전략
본 서비스는 다음과 같은 차별화 포인트를 통해 경쟁 우위를 확보합니다:
### 1. 기본 기능 (Hygiene Factors)
- **STT(Speech To Text)**: 음성을 텍스트로 변환하는 기본 기능
- 시장의 대부분 서비스가 제공하는 기능으로 차별화 포인트가 아님
- 필수 기능이지만 경쟁 우위를 가져다주지 않음
### 2. 핵심 차별화 포인트 (Differentiators)
- **맥락 기반 용어 설명**: 단순 용어 설명을 넘어, 관련 회의록과 업무이력을 바탕으로 실용적인 정보 제공
- **강화된 Todo 연결**: Action item이 담당자의 Todo와 실시간으로 연결되고, 진행 상황이 회의록에 자동 반영
- **프롬프팅 기반 회의록 개선**: AI를 활용한 다양한 형식의 회의록 생성 (1Page 요약, 핵심 요약 등)
- **지능형 회의 진행 지원**: 회의 패턴 분석을 통한 안건 추천, 효율성 분석 및 개선 제안
---
## 마이크로서비스 구성
1. **User** - 사용자 인증 및 권한 관리
2. **Meeting** - 회의 관리, 회의록 생성 및 관리, 회의록 공유
3. **STT** - 음성 녹음 관리, 음성-텍스트 변환, 화자 식별 (기본 기능)
4. **AI** - LLM 기반 회의록 자동 작성, Todo 자동 추출, 프롬프팅 기반 회의록 개선
5. **RAG** - 맥락 기반 용어 설명, 관련된 이전 회의록 검색 및 연결, 업무 이력 통합
6. **Collaboration** - 실시간 동기화, 버전 관리, 충돌 해결
7. **Todo** - Todo 할당 및 관리, 진행 상황 추적, 회의록 실시간 연동
---
## 유저스토리
```
1. User 서비스
1) 사용자 인증 관리
AFR-USER-010: [사용자관리] 시스템 관리자로서 | 나는, 서비스 보안을 위해 | 사용자 인증 기능을 원한다.
- 시나리오: 사용자 인증 관리
사용자가 로그인을 시도한 상황에서 | 사번과 비밀번호를 입력하면 | LDAP 연동을 통해 인증이 완료되고 권한에 따라 서비스에 접근할 수 있다.
- [ ] 사용자 인증 (사번, 비밀번호)
- [ ] 세션 관리
- M/8
---
2. Meeting 서비스
1) 회의 준비 및 관리
UFR-MEET-010: [회의예약] 회의록 작성자로서 | 나는, 회의를 효율적으로 준비하기 위해 | 회의를 예약하고 참석자를 초대하고 싶다.
- 시나리오: 회의 예약 및 참석자 초대
회의 예약 화면에 접근한 상황에서 | 회의 제목, 날짜/시간, 장소, 참석자 목록을 입력하고 예약 버튼을 클릭하면 | 회의가 예약되고 참석자에게 초대 이메일이 자동 발송된다.
[입력 요구사항]
- 회의 제목: 최대 100자 (필수)
- 날짜/시간: 날짜 및 시간 선택 (필수)
- 장소: 최대 200자 (선택)
- 참석자 목록: 이메일 주소 입력 (최소 1명 필수)
[처리 결과]
- 회의가 예약됨 (회의 ID 생성)
- 일정이 캘린더에 자동 등록됨
- 참석자에게 초대 이메일 발송됨
- 회의 시작 30분 전 리마인더 자동 발송
- M/13
---
UFR-MEET-020: [템플릿선택] 회의록 작성자로서 | 나는, 회의록을 효율적으로 작성하기 위해 | 회의 유형에 맞는 템플릿을 선택하고 싶다.
- 시나리오: 회의록 템플릿 선택
회의 시작 전 템플릿 선택 화면에 접근한 상황에서 | 제공되는 템플릿 중 하나를 선택하고 커스터마이징하면 | 회의록 도구가 준비된다.
[템플릿 유형]
- 일반 회의: 기본 구조 (참석자, 안건, 논의 내용, 결정 사항, Todo)
- 스크럼 회의: 어제 한 일, 오늘 할 일, 이슈
- 프로젝트 킥오프: 프로젝트 개요, 목표, 일정, 역할, 리스크
- 주간 회의: 주간 실적, 주요 이슈, 다음 주 계획
[커스터마이징 옵션]
- 섹션 추가/삭제
- 섹션 순서 변경
- 기본 항목 설정
[처리 결과]
- 선택된 템플릿으로 회의록 도구가 준비됨
- S/5
---
UFR-MEET-030: [회의시작] 회의록 작성자로서 | 나는, 회의를 시작하고 회의록을 작성하기 위해 | 회의를 시작하고 음성 녹음을 준비하고 싶다.
- 시나리오: 회의 시작
예약된 회의 시간에 회의 시작 버튼을 클릭한 상황에서 | 회의 ID를 확인하고 시작하면 | 회의 세션이 생성되고 음성 녹음이 준비된다.
[회의 시작 조건]
- 예약된 회의가 존재함
- 회의 시작 시간 10분 전부터 회의 시작 버튼 활성화
- 회의록 작성자가 시작 권한을 가짐
- 이미 시작된 회의일 경우, 진행중으로 표시
[참석자 상태 실시간 표시]
- 참석자 목록 표시
- 참석자 아바타 (이니셜 또는 프로필 이미지)
- 참석자 이름
- 실시간 상태 표시
- 발언 중 ✍️
- 온라인 (초록색 점)
- 오프라인 (회색 점)
- 참석자 수 표시 (예: 3명)
- 참석자 상태 변경 시 실시간 업데이트 (웹소켓)
[처리 결과]
- 회의 세션이 생성됨 (세션 ID)
- 음성 녹음 준비 완료
- 참석자 목록 및 상태 표시
- 회의 시작 시간 기록
- 실시간 회의록 주요 항목 추천
- 회의 타이머 시작 (00:00부터 카운트업)
- M/8
---
2) 회의 종료 및 완료
UFR-MEET-015: [대시보드] 사용자로서 | 나는, 나의 회의 및 Todo 현황을 한눈에 파악하기 위해 | 대시보드에서 요약 정보를 확인하고 싶다.
- 시나리오: 대시보드 조회
로그인한 상황에서 | 대시보드에 접근하면 | 예정된 회의, 진행 중 Todo, 완료율 등 핵심 정보가 표시된다.
[대시보드 표시 정보]
- 통계 카드
- 📅 예정된 회의 개수
- ✅ 진행 중 Todo 개수
- 📈 Todo 완료율 (%)
- 최근 회의 목록 (최대 5개)
- 회의 제목
- 회의 일시
- 참석자 수
- 결정사항/Todo 개수
- 할당된 Todo 목록 (최대 5개)
- Todo 제목
- 마감일
- 담당자
- 진행 상태
- 빠른 회의 생성 버튼 (+)
[처리 결과]
- 대시보드 정보가 표시됨
- "전체 보기" 링크로 상세 화면 이동 가능
- Mobile/Tablet/Desktop 반응형 레이아웃
- M/8
---
UFR-MEET-040: [회의종료] 회의록 작성자로서 | 나는, 회의를 종료하고 회의록을 정리하기 위해 | 회의를 종료하고 통계를 확인하고 싶다.
- 시나리오: 회의 종료
회의가 진행 중인 상황에서 | 회의 종료 버튼을 클릭하면 | 음성 녹음이 중지되고 회의 통계가 생성된다.
[회의 종료 처리]
- 음성 녹음 즉시 중지
- 회의 종료 시간 기록
- 회의 통계 자동 생성
- 회의 총 시간
- 참석자 수
- 발언 횟수 (화자별)
- 주요 키워드
[처리 결과]
- 회의가 종료됨
- 회의 통계 표시
- 검증 완료 시 최종 회의록 확정 단계로 이동
[검증 미완료 시]
- 검증이 안된 항목이 있다면 회의록 히스토리 페이지에서 추후 수정 가능
- M/8
---
UFR-MEET-050: [최종확정] 회의록 작성자로서 | 나는, 회의록을 완성하기 위해 | 최종 회의록을 확정하고 버전을 생성하고 싶다.
- 시나리오: 최종 회의록 확정
회의가 종료된 상황에서 | 회의록 내용을 최종 검토하고 확정 버튼을 클릭하면 | 필수 항목이 검사되고 최종 버전이 생성된다.
[필수 항목 검사]
- 회의 제목 입력 여부
- 참석자 목록 작성 여부
- 주요 논의 내용 작성 여부
- 결정 사항 작성 여부
[처리 결과]
- 최종 회의록 확정됨 (확정 버전 번호)
- 확정 시간 기록
- AI가 자동으로 Todo 항목 추출 (UFR-AI-020 연동)
- 회의록 공유 가능 상태로 전환
[필수 항목 미작성 시]
- 누락된 항목 안내 메시지 표시
- 해당 섹션으로 자동 이동
- M/13
---
UFR-MEET-045: [회의록상세조회] 회의록 작성자로서 | 나는, 지난 회의록의 상세 정보와 전체 내용을 | 한눈에 확인하고 싶다.
- 시나리오: 회의록 상세 정보 조회
"내 회의록" 메뉴에서 특정 회의록을 클릭하면 | 해당 회의의 기본 정보와 섹션별 상세 내용이 표시되고 | 필요한 경우 수정, 공유, 다운로드 등의 작업을 수행할 수 있다.
[회의 기본 정보 표시]
- 회의 제목
- 회의 일시 (날짜 및 시간)
- 참석자 목록 (역할 구분: 주관자/참석자/불참자)
- 참석자 수
- 회의 장소 (온라인/오프라인)
- 사용된 템플릿 유형
- 회의록 상태 (작성중/확정완료)
- 작성자 및 최종 수정 시간
[섹션별 상세 내용 표시]
- 각 섹션 구분 표시 (논의사항, 결정사항, Todo, 기타 등)
- 섹션별 검증 상태 표시 (검증완료 섹션은 체크 표시)
- Todo 항목:
- 담당자 이름
- 마감일
- 완료/미완료 상태 (시각적 구분)
- 우선순위 (있는 경우)
- 첨부파일 목록 및 다운로드 링크
[부가 기능]
- 회의록 수정 버튼 (수정 권한이 있는 경우만 표시)
- 회의록 공유 버튼 (공유 설정 화면으로 이동)
- 이전/다음 회의록으로 이동하는 네비게이션
- 뒤로가기 버튼 (회의록 목록으로 복귀)
[처리 결과]
- 모바일/태블릿 환경에서도 가독성 높은 레이아웃
- 긴 내용은 적절한 단락 구분 및 여백 적용
- 섹션별 접기/펼치기 기능 (선택사항)
- 페이지 로딩 시 스크롤 위치는 최상단
[권한별 표시]
- 조회 권한만 있는 경우: 수정 버튼 비활성화
- 수정 권한이 있는 경우: 수정 버튼 활성화
- M/5
---
UFR-MEET-046: [회의록목록조회] 회의록 작성자로서 | 나는, 과거 회의록을 쉽게 찾기 위해 | 회의록 목록을 조회하고 검색하고 싶다.
- 시나리오: 회의록 목록 조회 및 검색
대시보드에서 "회의 목록" 메뉴를 클릭하면 | 회의록 목록이 표시되고 | 상태별/기간별 필터링 및 키워드 검색이 가능하다.
[필터링 옵션]
- 상태별 필터: 전체 / 확정 / 예정 / 진행중
- 기간별 필터: 전체 / 1주 / 1개월 / 3개월
- 검색 기능: 회의록 제목, 참석자, 키워드
[회의록 목록 표시 정보]
- 통계 카드
- 📋 전체 회의록 개수
- ✅ 확정 완료 개수
- 📌 진행 중 Todo 개수
- 회의록 카드 (각 항목별)
- 회의 제목
- 회의 일시 (날짜 및 시간)
- 회의 장소
- 회의 시간 (분)
- 참석자 아바타 (최대 4명 표시, 나머지는 +N)
- 회의록 상태 (✓ 확정 완료 / 🔄 진행중 / 📅 예정)
- 결정사항 개수
- Todo 개수
- 요약 설명 (1줄)
[정렬 옵션]
- 최신순 (기본)
- 회의일시순
- 제목순
[처리 결과]
- 회의록 목록이 표시됨
- 카드 클릭 시 회의록 상세 화면으로 이동
- Mobile/Tablet/Desktop 반응형 레이아웃
- 무한 스크롤 또는 페이지네이션
- M/8
---
UFR-MEET-047: [회의록대시보드] 회의록 작성자로서 | 나는, 회의의 핵심 정보를 빠르게 파악하기 위해 | 회의록 대시보드를 통해 요약 정보를 확인하고 싶다.
- 시나리오: 회의록 대시보드 조회
회의록 목록에서 특정 회의록을 클릭하면 | 회의록 대시보드 화면이 표시되고 | 핵심내용, 결정사항, Todo 진행상황, 참고자료를 한눈에 확인할 수 있다.
[회의 기본 정보]
- 회의 제목
- 회의 일시 (📅 날짜 시간)
- 회의 장소 (📍 장소)
- 참석자 수 (👥 N명)
[💡 핵심내용 섹션]
- 주요 포인트 요약 (번호 매김 리스트)
- 태그/키워드 (해시태그 형태)
- 회의 통계
- 참석자 수
- 회의 시간 (분)
- 발언 횟수
- 주요 의제 개수
[✅ 결정사항 섹션]
- 결정사항 목록 (각 항목별)
- 결정 내용
- 결정자 정보 (👤 이름 + 직책)
- 결정 시간 (🕐 시간)
- 배경 설명
- 결정사항이 없는 경우 "결정사항이 없습니다" 표시
[📋 Todo 진행상황 섹션]
- 상태별 필터: 전체 / 시작 전 / 진행 중 / 완료
- 담당자별 그룹핑
- 담당자 정보 (👤 이름 + 직책)
- 담당 Todo 개수
- Todo 항목 (각 항목별)
- Todo 내용
- 진행률 표시 (프로그레스 바)
- 마감일 표시 (D-N, 오늘, D+N 지남, 완료)
- 우선순위 표시 (긴급/높음/보통/낮음)
- 펼치기/접기 기능
[📚 참고자료 섹션]
- 탭 메뉴: 관련 회의록 / 프로젝트 문서 / 이슈 트래커 / 위키 페이지
- 관련 회의록 (각 항목별)
- 📄 아이콘
- 회의 제목
- 회의 일시
- 작성자
- 관련도 점수 (%)
- 연관성 설명 (1-2문장)
- 참고자료가 없는 경우 "참고자료가 없습니다" 표시
[네비게이션]
- ← 회의록 목록으로 돌아가기 버튼
[처리 결과]
- 회의록 대시보드가 표시됨
- 각 섹션별로 정보 그룹핑
- Mobile/Tablet/Desktop 반응형 레이아웃
- 섹션별 접기/펼치기 기능
- M/13
---
UFR-MEET-055: [회의록수정] 회의록 작성자로서 | 나는, 검증이 완료되지 않았거나 수정이 필요한 | 지난 회의록을 조회하고 수정하고 싶다.
- 시나리오: 지난 회의록 조회 및 수정
대시보드에서 "내 회의록" 메뉴를 클릭하면 | 작성한 회의록 목록이 표시되고 | 특정 회의록을 선택하여 수정할 수 있다.
[회의록 목록 조회]
- 회의록 상태별 필터링: 전체 / 작성중 / 확정완료
- 정렬 옵션: 최신순 / 회의일시순 / 제목순
- 검색 기능: 회의 제목, 참석자, 키워드로 검색
- 목록 표시 정보:
- 회의 제목
- 회의 일시
- 회의록 상태 (작성중/확정완료)
- 마지막 수정 시간
- 검증 완료율 (작성중인 경우)
[회의록 수정]
- 회의록 선택 시 상세 화면으로 이동
- 상태에 따른 수정 가능 범위:
- 작성중: 모든 섹션 수정 가능
- 확인완료: 회의록 생성자에게 수정 권한 승인요청
- 수정 중 자동 저장 (30초 간격)
- 수정 이력 관리 (누가, 언제, 무엇을 수정했는지)
[처리 결과]
- 수정 내용 즉시 반영
- 수정 시간 업데이트
- 확정완료 상태였던 경우 → 작성중 상태로 변경
[권한 제어]
- 본인이 작성한 회의록만 수정 가능
- 검증완료 후 검증된 섹션 잠금 기능은 회의록 생성자만 가능
- 모든 섹션이 검증완료일경우 회의록 상태를 확정완료로 변경
- M/13
3) 회의록 공유
UFR-MEET-060: [회의록공유] 회의록 작성자로서 | 나는, 회의 내용을 참석자들과 공유하기 위해 | 최종 회의록을 공유하고 싶다.
- 시나리오: 회의록 공유
최종 회의록이 확정된 상황에서 | 공유 버튼을 클릭하고 공유 대상과 권한을 설정하면 | 공유 링크가 생성되고 참석자 전원에게 알림이 발송된다.
[공유 설정]
- 공유 대상: 참석자 전체 (기본) / 특정 참석자 선택
- 공유 권한: 읽기 전용 / 댓글 가능 / 편집 가능
- 공유 방식: 이메일 / 링크 복사
[처리 결과]
- 공유 링크 생성 (고유 URL)
- 참석자에게 이메일 알림 발송
- 공유 시간 기록
- 다음 회의 일정이 언급된 경우 캘린더에 자동 등록
[공유 링크 보안]
- 링크 유효 기간 설정 (선택)
- 비밀번호 설정 (선택)
- M/13
---
3. STT 서비스 (기본 기능)
1) 음성 인식 및 변환
UFR-STT-010: [음성녹음인식] 회의 참석자로서 | 나는, 발언 내용이 자동으로 기록되기 위해 | 음성이 실시간으로 녹음되고 인식되기를 원한다.
- 시나리오: 음성 녹음 및 발언 인식
회의가 시작된 상황에서 | 참석자가 발언을 시작하면 | 음성이 자동으로 녹음되고 화자가 식별되며 발언이 인식된다.
[음성 녹음 처리]
- 오디오 스트림 실시간 캡처
- 회의 ID와 연결
- 음성 데이터 저장
[발언 인식 처리]
- AI 음성인식 엔진 연동
- 화자 자동 식별
- 참석자 목록 매칭
- 음성 특징 분석
- 타임스탬프 기록
- 발언 구간 구분
[처리 결과]
- 음성 녹음이 시작됨 (녹음 ID)
- 발언이 인식됨 (발언 ID, 화자, 타임스탬프)
- 실시간으로 텍스트 변환 요청 (UFR-STT-020 연동)
[성능 요구사항]
- 발언 인식 지연 시간: 1초 이내
- 화자 식별 정확도: 90% 이상
[비고]
- STT는 기본 기능으로 경쟁사 대부분이 제공하는 기능임
- 차별화 포인트가 아닌 필수 기능
- M/21
---
UFR-STT-020: [텍스트변환] 회의록 시스템으로서 | 나는, 인식된 발언을 회의록에 기록하기 위해 | 음성을 텍스트로 변환하고 싶다.
- 시나리오: 음성-텍스트 변환
발언이 인식된 상황에서 | AI 음성인식 엔진에 텍스트 변환을 요청하면 | 음성이 텍스트로 변환되고 정확도와 함께 반환된다.
[텍스트 변환 처리]
- 인식된 발언 데이터 전달
- 언어 설정 (한국어, 영어 등)
- AI 음성인식 엔진 처리
- 문장 부호 자동 추가
- 숫자/날짜 형식 정규화
[처리 결과]
- 텍스트가 변환됨 (텍스트 ID)
- 변환된 내용 (원문 텍스트)
- 정확도 점수 (0-100%)
- AI 회의록 자동 작성 요청 (UFR-AI-010 연동)
[정확도 낮은 경우]
- 정확도 60% 미만 시 경고 표시
- 수동 수정 인터페이스 제공
[비고]
- STT는 기본 기능으로 차별화 포인트가 아님
- M/13
---
4. AI 서비스 (차별화 포인트)
1) AI 회의록 작성
UFR-AI-010: [회의록자동작성] 회의록 작성자로서 | 나는, 회의록 작성 부담을 줄이기 위해 | AI가 발언 내용을 자동으로 정리하여 회의록을 작성하기를 원한다.
- 시나리오: AI 회의록 자동 작성
텍스트가 변환된 상황에서 | LLM에 회의록 자동 작성을 요청하면 | 회의 맥락을 이해하고 구조화된 회의록 초안이 생성된다.
[AI 처리 과정]
- 변환된 텍스트와 회의 맥락(제목, 참석자, 이전 내용) 분석
- 회의 내용 이해
- 주제별 분류
- 발언자별 의견 정리
- 중요 키워드 추출
- 문장 다듬기
- 구어체 → 문어체 변환
- 불필요한 표현 제거
- 문법 교정
- 구조화
- 회의록 템플릿에 맞춰 정리
- 주제, 발언자, 내용 구조화
- 요약문 생성
[처리 결과]
- 회의록 초안이 생성됨 (회의록 버전)
- 생성 시간 기록
- 구조화된 내용
- 논의 주제
- 발언자별 의견
- 결정 사항
- 보류 사항
- 참석자에게 실시간 동기화 (UFR-COLLAB-010 연동)
[Policy/Rule]
- 텍스트 변환되면 자동으로 회의록 구조에 맞춰 정리
- 실시간 업데이트 (3-5초 간격)
- M/34
---
2) Todo 자동 추출
UFR-AI-020: [Todo자동추출] 회의록 작성자로서 | 나는, 회의 후 실행 사항을 명확히 하기 위해 | AI가 회의록에서 Todo 항목을 자동으로 추출하고 담당자를 식별하기를 원한다.
- 시나리오: AI Todo 자동 추출
회의가 종료된 상황에서 | 최종 회의록을 분석하여 Todo 자동 추출을 요청하면 | 액션 아이템이 식별되고 담당자가 자동으로 지정된다.
[AI 분석 과정]
- 회의록 전체 내용 분석
- 액션 아이템 식별
- "~하기로 함", "~까지 완료", "~담당" 등 키워드 탐지
- 명령형 문장 분석
- 마감일 언급 추출
- 담당자 자동 식별
- 발언 내용 기반 ("제가 하겠습니다", "~님이 담당")
- 직책/역할 기반 매칭
- 과거 회의록 패턴 학습
[처리 결과]
- Todo가 자동 추출됨
- 추출된 항목 수
- 각 Todo별 정보
- Todo 내용
- 담당자 (자동 식별)
- 마감일 (언급된 경우)
- 우선순위 (언급된 경우)
- 관련 회의록 섹션 링크
- Todo 서비스에 자동 전달 (UFR-TODO-010 연동)
[담당자 식별 실패 시]
- 미지정 상태로 Todo 생성
- 수동 할당 요청 알림
- M/21
---
3) 프롬프팅 기반 회의록 개선 (신규, 차별화 포인트)
UFR-AI-030: [회의록개선] 회의록 작성자로서 | 나는, 회의록을 다양한 형식으로 변환하기 위해 | 프롬프팅을 통해 회의록을 개선하고 재구성하고 싶다.
- 시나리오: 프롬프팅 기반 회의록 개선
회의록이 작성된 상황에서 | "1Page 요약", "핵심 요약", "상세 보고서" 등의 프롬프트를 입력하면 | AI가 해당 형식에 맞춰 회의록을 재구성하여 제공한다.
[지원 프롬프트 유형]
- "1Page 요약": A4 1장 분량의 요약본 생성
- "핵심 요약": 3-5개 핵심 포인트만 추출
- "상세 보고서": 시간순 상세 기록 with 타임스탬프
- "의사결정 중심": 결정 사항과 근거만 정리
- "액션 아이템 중심": Todo와 담당자만 강조
- "경영진 보고용": 임원진에게 보고할 형식으로 재구성
- "커스텀 프롬프트": 사용자 정의 형식
[AI 처리 과정]
- 원본 회의록 분석
- 프롬프트 의도 파악
- 내용 재구성
- 중요도 기반 필터링
- 형식에 맞춘 재배치
- 불필요한 내용 제거
- 스타일 조정
- 문체 변환 (격식체, 구어체 등)
- 길이 조정 (압축 또는 확장)
[처리 결과]
- 개선된 회의록이 생성됨 (새 버전)
- 원본 회의록 링크 유지
- 생성 시간 및 프롬프트 기록
[Policy/Rule]
- 원본 회의록은 항상 보존
- 여러 버전 동시 생성 가능
- 버전 간 비교 기능 제공
- M/21
---
4) 관련 회의록 자동 연결 (신규, 차별화 포인트)
UFR-AI-040: [관련회의록연결] 회의록 작성자로서 | 나는, 이전 회의 내용을 쉽게 참조하기 위해 | AI가 같은 폴더 내 관련 있는 과거 회의록을 자동으로 찾아 연결해주기를 원한다.
- 시나리오: 관련 회의록 자동 연결
회의록이 작성되는 상황에서 | AI가 회의 주제와 내용을 분석하면 | 같은 폴더 내 유사한 주제의 과거 회의록을 찾아 자동으로 연결한다.
[AI 분석 과정]
- 현재 회의록 주제 및 키워드 추출
- 벡터 유사도 검색
- 과거 회의록 DB에서 검색
- 주제 유사도 계산
- 관련도 점수 계산 (0-100%)
- 같은 폴더 내 상위 5개 회의록 선정
[연결 기준]
- 주제 유사도 70% 이상
- 동일 참석자가 50% 이상
- 키워드 3개 이상 일치
- 시간적 연관성 (후속 회의, 분기별 회의 등)
[처리 결과]
- 관련 회의록 목록 생성
- 각 회의록별 정보
- 제목
- 날짜
- 참석자
- 관련도 점수 (%) - 시각적으로 강조 표시
- 연관 키워드
- 연관성 설명 (1-2문장)
- 회의록 상단에 "관련 회의록" 섹션 자동 추가
- 클릭 시 해당 회의록으로 이동
[관련도 점수 표시 방식]
- 관련도 90% 이상: 매우 높음 (진한 초록색)
- 관련도 80-89%: 높음 (초록색)
- 관련도 70-79%: 보통 (주황색)
[Policy/Rule]
- 관련도 70% 이상만 자동 연결
- 최대 5개까지 표시
- 관련도 점수 내림차순 정렬
- S/13
---
5. RAG 서비스 (차별화 포인트)
1) 맥락 기반 용어 설명 (강화)
UFR-RAG-010: [전문용어감지] 회의록 작성자로서 | 나는, 업무 지식이 없어도 회의록을 정확히 작성하기 위해 | 전문용어가 자동으로 감지되고 맥락에 맞는 설명을 제공받고 싶다.
- 시나리오: 맥락 기반 전문용어 자동 감지
회의록이 작성되는 상황에서 | 시스템이 회의록 텍스트를 분석하면 | 전문용어가 자동으로 감지되고 맥락에 맞는 설명이 준비된다.
[전문용어 감지 처리]
- 회의록 텍스트 실시간 분석
- 용어 사전과 비교
- 조직별 전문용어 DB
- 산업별 표준 용어 DB
- 신뢰도 계산 (0-100%)
- 감지된 용어 위치 기록
[처리 결과]
- 전문용어가 감지됨
- 감지된 용어 정보
- 용어명
- 감지 위치 (줄 번호, 문단)
- 신뢰도 점수
- 용어 하이라이트 표시
- 맥락 기반 설명 자동 생성 (UFR-RAG-020 연동)
[Policy/Rule]
- 신뢰도 70% 이상만 자동 감지
- 중복 용어는 첫 번째만 하이라이트
[비고]
- 단순 용어 설명이 아닌 맥락 기반 실용적 정보 제공이 차별화 포인트
- S/13
---
UFR-RAG-020: [맥락기반용어설명] 회의록 작성자로서 | 나는, 전문용어를 맥락에 맞게 이해하기 위해 | 관련 회의록과 업무 이력을 바탕으로 실용적인 설명을 제공받고 싶다.
- 시나리오: 맥락 기반 용어 설명 자동 제공
전문용어가 감지된 상황에서 | RAG 시스템이 관련 이전 회의록를 검색하면 | 과거 회의록 및 업무 이력에서 맥락에 맞는 실용적인 설명이 생성되어 제공된다.
[RAG 검색 수행]
- 벡터 유사도 검색
- 과거 회의록 검색 (동일 용어 사용 사례)
- 사내 문서 저장소 검색 (위키, 매뉴얼, 보고서)
- 업무 이력 검색 (프로젝트 문서, 이메일 등)
- 관련 이전 회의록 추출 (관련도 점수순)
- 최대 5개 문서 선택
[맥락 기반 설명 생성]
- 검색된 문서 내용 분석
- 용어 정의 추출
- 실제 사용 사례 추출
- 현재 회의 맥락에 맞는 설명 생성
- 간단한 정의 (1-2문장)
- 이 회의에서의 의미 (맥락 기반)
- 관련 프로젝트/이슈 연결
- 과거 논의 요약 (언제, 누가, 어떻게 사용했는지)
- 참조 출처 링크
[처리 결과]
- 맥락 기반 용어 설명이 생성됨 (설명 ID)
- 설명 내용
- 간단한 정의
- 맥락 기반 상세 설명
- 실제 사용 사례
- 관련 프로젝트/이슈
- 과거 회의록 링크 (최대 3개)
- 사내 문서 링크
- 툴팁 또는 사이드 패널로 표시
- 설명 제공 시간 기록
[설명을 찾지 못한 경우]
- "관련 정보를 찾을 수 없습니다" 메시지 표시
- 전문가(회의 참석자)에게 설명 요청 버튼 제공
- 수동 입력된 설명은 용어 사전에 자동 저장
[비고]
- **차별화 포인트**: 단순 용어 설명이 아닌, 조직 내 실제 사용 맥락과 이력을 제공
- 업무 지식이 없어도 실질적인 도움을 받을 수 있음
- S/21
---
6. Collaboration 서비스
1) 실시간 협업
UFR-COLLAB-010: [회의록수정동기화] 회의 참석자로서 | 나는, 회의록을 함께 검증하기 위해 | 회의록을 수정하고 실시간으로 다른 참석자와 동기화하고 싶다.
- 시나리오: 회의록 실시간 수정 및 동기화
회의록 초안이 작성된 상황에서 | 참석자가 회의록 내용을 수정하면 | 수정 사항이 버전 관리되고 웹소켓을 통해 모든 참석자에게 즉시 동기화된다.
[회의록 수정 처리]
- 수정 내용 검증
- 수정 권한 확인
- 수정 범위 제한 (잠긴 섹션은 수정 불가)
- 수정 이력 저장
- 수정자
- 수정 시간
- 수정 전/후 내용
- 수정 위치
- 버전 관리
- 새 버전 번호 생성
- 이전 버전 보관
[실시간 동기화]
- 웹소켓을 통해 수정 델타 전송
- 전체 내용이 아닌 변경 부분만 전송 (효율성)
- 모든 참석자 화면에 실시간 반영
- 수정자 표시 (이름)
- 수정 영역 하이라이트 (3초간)
[처리 결과]
- 참석자가 회의록을 수정함 (수정 ID)
- 수정 사항이 동기화됨
- 동기화 시간
- 영향받은 참석자 목록
- 수정 완료될 때마다 수정된 내용이 메일로 알림이 발송된다. (알림 여부 설정 가능)
[Policy/Rule]
- 회의록 수정 시 웹소켓을 통해 모든 참석자에게 즉시 동기화
- M/34
---
UFR-COLLAB-020: [충돌해결] 회의 참석자로서 | 나는, 동시 수정 상황에서도 내용을 잃지 않기 위해 | 충돌을 감지하고 해결하고 싶다.
- 시나리오: 동시 수정 충돌 해결
두 명의 참석자가 동일한 위치를 동시에 수정한 상황에서 | 시스템이 충돌을 감지하면 | 충돌 알림이 표시되고 해결 방법을 선택할 수 있다.
[충돌 감지]
- 동일 위치 동시 수정 탐지
- 라인 단위 비교
- 버전 기반 충돌 확인
- 충돌 정보 생성
- 충돌 위치
- 관련 수정자 2명
- 각자의 수정 내용
[충돌 해결 방식]
- Last Write Wins (기본)
- 가장 최근 수정이 우선
- 이전 수정은 버전 이력에 보관
- 수동 병합 (선택)
- 충돌 내용 비교 UI 표시
- 사용자가 최종 내용 선택
- A 선택 / B 선택 / 직접 작성
[처리 결과]
- 충돌이 감지됨 (충돌 ID)
- 충돌 위치
- 관련 수정자
- 충돌이 해결됨
- 해결 방법 (Last Write Wins / 수동 병합)
- 최종 내용
- 해결된 내용 실시간 동기화
[Policy/Rule]
- 동시 수정 발생 시 최종 수정이 우선 (Last Write Wins) 또는 충돌 알림
- M/21
---
UFR-COLLAB-030: [검증완료] 회의 참석자로서 | 나는, 회의록의 정확성을 보장하기 위해 | 주요 섹션을 검증하고 완료 표시를 하고 싶다.
- 시나리오: 회의록 검증 완료
회의록 내용을 확인한 상황에서 | 참석자가 검증 완료 버튼을 클릭하면 | 검증 상태가 업데이트되고 다른 참석자에게 동기화된다.
[검증 처리]
- 검증자 정보 기록
- 검증 시간 기록
- 검증 대상 섹션 기록
- 검증 상태 업데이트
- 미검증 → 검증 중 → 검증 완료
- 검증 완료율 계산
- (검증 완료 섹션 수 / 전체 섹션 수) × 100
[섹션 잠금 기능]
- 회의 생성자만 가능
- 주요 섹션 검증 완료 시 잠금 가능 (선택)
- 잠긴 섹션은 추가 수정 불가
- 회의 생성자가 잠그면 검증 완료로 표시
[검증 상태 표시]
- 검증 완료 아이콘: ✓ (체크 마크)
- 검증 중 아이콘: ⏳ (모래시계)
- 미검증 아이콘: ⊝ (빈 원)
- 검증 완료율 표시: 프로그레스 바 또는 퍼센트 (예: 75%)
[처리 결과]
- 검증이 완료됨
- 검증자 정보
- 검증 상태 (검증 완료)
- 완료 시간
- 검증 완료율 업데이트
- 검증 완료 상태 실시간 동기화
- 검증 배지 표시 (✓ 체크 아이콘)
- 검증 완료 시 전체 메일로 알림이 발송된다.
[Policy/Rule]
- 주요 섹션 검증 완료 시 해당 섹션 잠금 가능
- 모든 섹션 검증 완료 시 회의록 상태를 확정완료로 변경
- M/8
---
7. Todo 서비스 (차별화 포인트)
1) 실시간 Todo 연결 (강화)
UFR-TODO-010: [Todo할당] Todo 시스템으로서 | 나는, AI가 추출한 Todo를 담당자에게 전달하기 위해 | Todo를 실시간으로 할당하고 회의록과 연결하고 싶다.
- 시나리오: Todo 실시간 할당 및 회의록 연결
AI가 Todo를 추출한 상황에서 | 시스템이 Todo를 등록하고 담당자를 지정하면 | Todo가 실시간으로 할당되고 회의록의 해당 위치와 연결되며 담당자에게 즉시 알림이 발송된다.
[Todo 등록]
- Todo 정보 저장
- Todo ID 생성
- Todo 내용
- 담당자 (AI 자동 식별 또는 수동 지정)
- 마감일 (언급된 경우 자동 설정, 없으면 수동 설정)
- 우선순위 (긴급/높음/보통/낮음)
- 관련 회의록 링크 (섹션 위치 포함)
- 진행 상태 (시작 전/진행 중/완료)
- 진행률 (0-100%)
[회의록 실시간 연결]
- 회의록 해당 섹션에 Todo 뱃지 표시
- Todo 클릭 시 Todo 상세 정보 표시
- 양방향 연결 (Todo → 회의록, 회의록 → Todo)
- 회의록 링크 표시 형식: 📄 [회의 제목] (날짜)
[담당자별 그룹핑]
- Todo 관리 화면에서 담당자별로 그룹화
- 각 담당자별 Todo 개수 표시
- 담당자 정보 표시 (아바타 + 이름 + 직책)
[알림 발송]
- 담당자에게 즉시 알림
- 이메일
- 알림 내용
- Todo 내용
- 마감일
- 회의록 링크 (해당 섹션으로 바로 이동)
[캘린더 연동]
- 마감일이 있는 경우 캘린더에 자동 등록
- 마감일 3일 전 리마인더 일정 생성
[처리 결과]
- Todo가 할당됨 (Todo ID)
- 담당자 정보
- 마감일
- 할당 시간
- 회의록 연결 정보 (섹션 ID, 타임스탬프)
- 담당자에게 알림이 발송됨
- 캘린더 등록 완료
[Policy/Rule]
- Todo 할당 시 담당자에게 즉시 알림 발송
- 회의록과 실시간 양방향 연결
[비고]
- **차별화 포인트**: Todo와 회의록의 강력한 연결, 원문 맥락 추적 가능
- M/13
---
UFR-TODO-020: [Todo칸반관리] Todo 담당자로서 | 나는, Todo 진행 상황을 시각적으로 관리하기 위해 | 칸반 보드 형태로 Todo를 관리하고 싶다.
- 시나리오: Todo 칸반 보드 관리
Todo 관리 화면에 접근한 상황에서 | 칸반 보드 또는 리스트 뷰를 선택하면 | Todo가 상태별(시작 전/진행 중/완료)로 분류되어 표시되고 드래그앤드롭으로 상태를 변경할 수 있다.
[뷰 전환]
- 칸반 뷰 / 리스트 뷰 토글 버튼
- 사용자 선택 상태 저장 (쿠키 또는 로컬스토리지)
[칸반 보드 구성]
- 3개 컬럼: 시작 전 / 진행 중 / 완료
- 각 컬럼별 Todo 개수 표시
- 컬럼별 색상 구분
- 시작 전: 빨간 테두리
- 진행 중: 주황 테두리
- 완료: 초록 채움
[Todo 카드 표시 정보]
- Todo 내용 (제목)
- 담당자 아바타 + 이름
- 마감일 표시
- D-N (남은 일수)
- 오늘
- D+N (지남) - 빨간색 표시
- 완료
- 진행률 표시 (프로그레스 바)
- 우선순위 표시 (긴급/높음/보통/낮음)
- 회의록 링크 (📄 [회의 제목])
[드래그앤드롭 기능]
- Todo 카드를 다른 컬럼으로 드래그
- 드롭 시 상태 자동 변경
- 변경 애니메이션 효과
- 변경 완료 알림 (토스트 메시지)
[필터링 및 정렬]
- 담당자별 필터
- 우선순위별 필터
- 마감일 임박순 정렬
- 생성일순 정렬
[처리 결과]
- Todo 칸반 보드가 표시됨
- 드래그앤드롭으로 상태 변경 가능
- 상태 변경 시 실시간 동기화
- Mobile/Tablet/Desktop 반응형 레이아웃
[비고]
- 모바일에서는 드래그 대신 터치 후 상태 선택 메뉴 표시
- M/13
---
UFR-TODO-030: [Todo완료처리] Todo 담당자로서 | 나는, 완료된 Todo를 처리하고 회의록에 반영하기 위해 | Todo를 완료하고 회의록에 자동 반영하고 싶다.
- 시나리오: Todo 완료 처리 및 회의록 자동 반영
Todo 작업이 완료된 상황에서 | 담당자가 완료 버튼을 클릭하면 | Todo가 완료 상태로 변경되고 연결된 회의록에 완료 상태가 실시간으로 반영된다.
[완료 처리]
- 완료 시간 자동 기록
- 완료자 정보 저장
- 완료 상태로 변경
- 완료 여부 확인 다이얼로그 표시
[회의록 실시간 반영]
- 관련 회의록의 Todo 섹션 자동 업데이트
- 완료 표시 (체크 아이콘)
- 완료 시간 기록
- 완료자 정보 표시
[알림 발송]
- 완료 알림
- 모든 Todo 완료 시 전체 완료 알림
[처리 결과]
- Todo가 완료됨
- 완료 시간
- 완료자 정보
- 회의록에 완료 상태가 반영됨
- 반영 시간
- 회의록 버전 업데이트
[Policy/Rule]
- Todo 완료 시 회의록에 완료 상태 즉시 반영
- 모든 Todo 완료 시 완료 알림
[비고]
- **차별화 포인트**: Todo 완료가 회의록에 실시간 반영되어 회의 결과 추적 용이
- M/8
---