할일관리 시스템 구현 완료

주요 기능:
- memos/트위터 스타일 할일 입력
- 5단계 워크플로우: draft → scheduled → active → completed/delayed
- 2시간 이상 작업 자동 분할 제안 (1분/30분/1시간 선택)
- 시작날짜 기반 자동 활성화
- 할일별 댓글/메모 기능
- 개인별 할일 관리

백엔드:
- TodoItem, TodoComment 모델 추가
- 완전한 REST API 구현
- 자동 상태 전환 로직
- 분할 기능 지원

프론트엔드:
- 직관적인 탭 기반 UI
- 실시간 상태 업데이트
- 모달 기반 상세 관리
- 반응형 디자인

데이터베이스:
- PostgreSQL 테이블 및 인덱스 생성
- 트리거 기반 자동 업데이트
This commit is contained in:
Hyungi Ahn
2025-09-04 10:40:49 +09:00
parent f221a5611c
commit a4fd233ba1
10 changed files with 1882 additions and 1 deletions

View File

@@ -0,0 +1,58 @@
-- 할일관리 시스템 테이블 생성
-- 할일 아이템 테이블
CREATE TABLE IF NOT EXISTS todo_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- 기본 정보
content TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'scheduled', 'active', 'completed', 'delayed', 'split')),
-- 시간 관리
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
start_date TIMESTAMP WITH TIME ZONE,
estimated_minutes INTEGER CHECK (estimated_minutes > 0 AND estimated_minutes <= 120),
completed_at TIMESTAMP WITH TIME ZONE,
delayed_until TIMESTAMP WITH TIME ZONE,
-- 분할 관리
parent_id UUID REFERENCES todo_items(id) ON DELETE CASCADE,
split_order INTEGER,
-- 인덱스
CONSTRAINT unique_split_order UNIQUE (parent_id, split_order)
);
-- 할일 댓글 테이블
CREATE TABLE IF NOT EXISTS todo_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
todo_item_id UUID NOT NULL REFERENCES todo_items(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_todo_items_user_id ON todo_items(user_id);
CREATE INDEX IF NOT EXISTS idx_todo_items_status ON todo_items(status);
CREATE INDEX IF NOT EXISTS idx_todo_items_start_date ON todo_items(start_date);
CREATE INDEX IF NOT EXISTS idx_todo_items_parent_id ON todo_items(parent_id);
CREATE INDEX IF NOT EXISTS idx_todo_comments_todo_item_id ON todo_comments(todo_item_id);
CREATE INDEX IF NOT EXISTS idx_todo_comments_user_id ON todo_comments(user_id);
-- 트리거: updated_at 자동 업데이트
CREATE OR REPLACE FUNCTION update_todo_comments_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_todo_comments_updated_at
BEFORE UPDATE ON todo_comments
FOR EACH ROW
EXECUTE FUNCTION update_todo_comments_updated_at();

View File

@@ -0,0 +1,663 @@
"""
할일관리 시스템 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_
from sqlalchemy.orm import selectinload
from typing import List, Optional
from datetime import datetime, timedelta
from uuid import UUID
from ...core.database import get_db
from ...models.user import User
from ...models.todo import TodoItem, TodoComment
from ...schemas.todo import (
TodoItemCreate, TodoItemSchedule, TodoItemUpdate, TodoItemDelay, TodoItemSplit,
TodoItemResponse, TodoItemWithComments, TodoCommentCreate, TodoCommentUpdate,
TodoCommentResponse, TodoStats, TodoDashboard
)
from ..dependencies import get_current_active_user
router = APIRouter(prefix="/todos", tags=["todos"])
# ============================================================================
# 할일 아이템 관리
# ============================================================================
@router.post("/", response_model=TodoItemResponse)
async def create_todo_item(
todo_data: TodoItemCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""새 할일 생성 (draft 상태)"""
try:
new_todo = TodoItem(
user_id=current_user.id,
content=todo_data.content,
status="draft"
)
db.add(new_todo)
await db.commit()
await db.refresh(new_todo)
# 응답 데이터 구성
response_data = TodoItemResponse(
id=new_todo.id,
user_id=new_todo.user_id,
content=new_todo.content,
status=new_todo.status,
created_at=new_todo.created_at,
start_date=new_todo.start_date,
estimated_minutes=new_todo.estimated_minutes,
completed_at=new_todo.completed_at,
delayed_until=new_todo.delayed_until,
parent_id=new_todo.parent_id,
split_order=new_todo.split_order,
comment_count=0
)
return response_data
except Exception as e:
await db.rollback()
print(f"ERROR in create_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create todo item: {str(e)}"
)
@router.post("/{todo_id}/schedule", response_model=TodoItemResponse)
async def schedule_todo_item(
todo_id: UUID,
schedule_data: TodoItemSchedule,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 일정 설정 (draft -> scheduled)"""
try:
# 할일 조회
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id,
TodoItem.status == "draft"
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found or not in draft status"
)
# 2시간 이상인 경우 분할 제안
if schedule_data.estimated_minutes > 120:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tasks longer than 2 hours should be split into smaller tasks"
)
# 일정 설정
todo_item.start_date = schedule_data.start_date
todo_item.estimated_minutes = schedule_data.estimated_minutes
todo_item.status = "scheduled"
await db.commit()
await db.refresh(todo_item)
# 댓글 수 계산
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data = TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
)
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in schedule_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to schedule todo item: {str(e)}"
)
@router.post("/{todo_id}/split", response_model=List[TodoItemResponse])
async def split_todo_item(
todo_id: UUID,
split_data: TodoItemSplit,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 분할"""
try:
# 원본 할일 조회
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id,
TodoItem.status == "draft"
)
)
)
original_todo = result.scalar_one_or_none()
if not original_todo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found or not in draft status"
)
# 분할된 할일들 생성
subtasks = []
for i, (subtask_content, estimated_minutes) in enumerate(zip(split_data.subtasks, split_data.estimated_minutes_per_task)):
if estimated_minutes > 120:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Subtask {i+1} is longer than 2 hours"
)
subtask = TodoItem(
user_id=current_user.id,
content=subtask_content,
status="draft",
parent_id=original_todo.id,
split_order=i + 1
)
db.add(subtask)
subtasks.append(subtask)
# 원본 할일 상태 변경 (분할됨 표시)
original_todo.status = "split"
await db.commit()
# 응답 데이터 구성
response_data = []
for subtask in subtasks:
await db.refresh(subtask)
response_data.append(TodoItemResponse(
id=subtask.id,
user_id=subtask.user_id,
content=subtask.content,
status=subtask.status,
created_at=subtask.created_at,
start_date=subtask.start_date,
estimated_minutes=subtask.estimated_minutes,
completed_at=subtask.completed_at,
delayed_until=subtask.delayed_until,
parent_id=subtask.parent_id,
split_order=subtask.split_order,
comment_count=0
))
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in split_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to split todo item: {str(e)}"
)
@router.get("/", response_model=List[TodoItemResponse])
async def get_todo_items(
status: Optional[str] = Query(None, regex="^(draft|scheduled|active|completed|delayed)$"),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 목록 조회"""
try:
query = select(TodoItem).where(TodoItem.user_id == current_user.id)
if status:
query = query.where(TodoItem.status == status)
query = query.order_by(TodoItem.created_at.desc())
result = await db.execute(query)
todo_items = result.scalars().all()
# 각 할일의 댓글 수 계산
response_data = []
for todo_item in todo_items:
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data.append(TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
))
return response_data
except Exception as e:
print(f"ERROR in get_todo_items: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get todo items: {str(e)}"
)
@router.get("/active", response_model=List[TodoItemResponse])
async def get_active_todos(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""오늘 활성화된 할일들 조회"""
try:
now = datetime.utcnow()
# scheduled 상태이면서 시작일이 지난 것들을 active로 변경
update_result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.user_id == current_user.id,
TodoItem.status == "scheduled",
TodoItem.start_date <= now
)
)
)
scheduled_items = update_result.scalars().all()
for item in scheduled_items:
item.status = "active"
if scheduled_items:
await db.commit()
# active 상태인 할일들 조회
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.user_id == current_user.id,
TodoItem.status == "active"
)
).order_by(TodoItem.start_date.asc())
)
active_todos = result.scalars().all()
# 응답 데이터 구성
response_data = []
for todo_item in active_todos:
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data.append(TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
))
return response_data
except Exception as e:
print(f"ERROR in get_active_todos: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get active todos: {str(e)}"
)
@router.put("/{todo_id}/complete", response_model=TodoItemResponse)
async def complete_todo_item(
todo_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 완료"""
try:
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id,
TodoItem.status == "active"
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found or not active"
)
todo_item.status = "completed"
todo_item.completed_at = datetime.utcnow()
await db.commit()
await db.refresh(todo_item)
# 댓글 수 계산
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data = TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
)
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in complete_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to complete todo item: {str(e)}"
)
@router.put("/{todo_id}/delay", response_model=TodoItemResponse)
async def delay_todo_item(
todo_id: UUID,
delay_data: TodoItemDelay,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 지연"""
try:
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id,
TodoItem.status == "active"
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found or not active"
)
todo_item.status = "delayed"
todo_item.delayed_until = delay_data.delayed_until
todo_item.start_date = delay_data.delayed_until # 새로운 시작일로 업데이트
await db.commit()
await db.refresh(todo_item)
# 댓글 수 계산
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data = TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
)
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in delay_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delay todo item: {str(e)}"
)
# ============================================================================
# 댓글 관리
# ============================================================================
@router.post("/{todo_id}/comments", response_model=TodoCommentResponse)
async def create_todo_comment(
todo_id: UUID,
comment_data: TodoCommentCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일에 댓글 추가"""
try:
# 할일 존재 확인
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found"
)
new_comment = TodoComment(
todo_item_id=todo_id,
user_id=current_user.id,
content=comment_data.content
)
db.add(new_comment)
await db.commit()
await db.refresh(new_comment)
return TodoCommentResponse(
id=new_comment.id,
todo_item_id=new_comment.todo_item_id,
user_id=new_comment.user_id,
content=new_comment.content,
created_at=new_comment.created_at,
updated_at=new_comment.updated_at
)
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in create_todo_comment: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create todo comment: {str(e)}"
)
@router.get("/{todo_id}/comments", response_model=List[TodoCommentResponse])
async def get_todo_comments(
todo_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 댓글 목록 조회"""
try:
# 할일 존재 확인
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found"
)
# 댓글 조회
result = await db.execute(
select(TodoComment).where(TodoComment.todo_item_id == todo_id)
.order_by(TodoComment.created_at.asc())
)
comments = result.scalars().all()
return [
TodoCommentResponse(
id=comment.id,
todo_item_id=comment.todo_item_id,
user_id=comment.user_id,
content=comment.content,
created_at=comment.created_at,
updated_at=comment.updated_at
)
for comment in comments
]
except HTTPException:
raise
except Exception as e:
print(f"ERROR in get_todo_comments: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get todo comments: {str(e)}"
)
@router.get("/{todo_id}", response_model=TodoItemWithComments)
async def get_todo_item_with_comments(
todo_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""댓글이 포함된 할일 상세 조회"""
try:
# 할일 조회
result = await db.execute(
select(TodoItem).options(selectinload(TodoItem.comments))
.where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found"
)
# 댓글 데이터 구성
comments = [
TodoCommentResponse(
id=comment.id,
todo_item_id=comment.todo_item_id,
user_id=comment.user_id,
content=comment.content,
created_at=comment.created_at,
updated_at=comment.updated_at
)
for comment in todo_item.comments
]
return TodoItemWithComments(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=len(comments),
comments=comments
)
except HTTPException:
raise
except Exception as e:
print(f"ERROR in get_todo_item_with_comments: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get todo item with comments: {str(e)}"
)

View File

@@ -9,7 +9,7 @@ import uvicorn
from .core.config import settings
from .core.database import init_db
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes, setup
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes, setup, todos
from .api.routes import note_documents, note_links
@@ -62,6 +62,7 @@ app.include_router(note_links.router, prefix="/api", tags=["노트 링크"])
app.include_router(notebooks.router, prefix="/api/notebooks", tags=["노트북"])
app.include_router(note_highlights.router, prefix="/api", tags=["노트 하이라이트"])
app.include_router(note_notes.router, prefix="/api", tags=["노트 메모"])
app.include_router(todos.router, prefix="/api", tags=["할일관리"])
@app.get("/")

View File

@@ -0,0 +1,63 @@
"""
할일관리 시스템 모델
"""
from sqlalchemy import Column, String, DateTime, Text, Boolean, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime
import uuid
from ..core.database import Base
class TodoItem(Base):
"""할일 아이템"""
__tablename__ = "todo_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
# 기본 정보
content = Column(Text, nullable=False) # 할일 내용
status = Column(String(20), nullable=False, default="draft") # draft, scheduled, active, completed, delayed
# 시간 관리
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
start_date = Column(DateTime, nullable=True) # 시작 예정일
estimated_minutes = Column(Integer, nullable=True) # 예상 소요시간 (분)
completed_at = Column(DateTime, nullable=True)
delayed_until = Column(DateTime, nullable=True) # 지연된 경우 새로운 시작일
# 분할 관리
parent_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=True) # 분할된 할일의 부모
split_order = Column(Integer, nullable=True) # 분할 순서
# 관계
user = relationship("User", back_populates="todo_items")
comments = relationship("TodoComment", back_populates="todo_item", cascade="all, delete-orphan")
# 자기 참조 관계 (분할된 할일들)
subtasks = relationship("TodoItem", backref="parent_task", remote_side=[id])
def __repr__(self):
return f"<TodoItem(id={self.id}, content='{self.content[:50]}...', status='{self.status}')>"
class TodoComment(Base):
"""할일 댓글/메모"""
__tablename__ = "todo_comments"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
todo_item_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
content = Column(Text, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
# 관계
todo_item = relationship("TodoItem", back_populates="comments")
user = relationship("User")
def __repr__(self):
return f"<TodoComment(id={self.id}, content='{self.content[:30]}...')>"

View File

@@ -45,6 +45,7 @@ class User(Base):
# 관계 (lazy loading을 위해 문자열로 참조)
memo_trees = relationship("MemoTree", back_populates="user", lazy="dynamic")
memo_nodes = relationship("MemoNode", back_populates="user", lazy="dynamic")
todo_items = relationship("TodoItem", back_populates="user", lazy="dynamic")
def __repr__(self):
return f"<User(email='{self.email}', full_name='{self.full_name}')>"

108
backend/src/schemas/todo.py Normal file
View File

@@ -0,0 +1,108 @@
"""
할일관리 시스템 스키마
"""
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from uuid import UUID
class TodoCommentBase(BaseModel):
content: str = Field(..., min_length=1, max_length=1000)
class TodoCommentCreate(TodoCommentBase):
pass
class TodoCommentUpdate(BaseModel):
content: Optional[str] = Field(None, min_length=1, max_length=1000)
class TodoCommentResponse(TodoCommentBase):
id: UUID
todo_item_id: UUID
user_id: UUID
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TodoItemBase(BaseModel):
content: str = Field(..., min_length=1, max_length=2000)
class TodoItemCreate(TodoItemBase):
"""초기 할일 생성 (draft 상태)"""
pass
class TodoItemSchedule(BaseModel):
"""할일 일정 설정"""
start_date: datetime
estimated_minutes: int = Field(..., ge=1, le=120) # 1분~2시간
class TodoItemUpdate(BaseModel):
"""할일 수정"""
content: Optional[str] = Field(None, min_length=1, max_length=2000)
status: Optional[str] = Field(None, regex="^(draft|scheduled|active|completed|delayed)$")
start_date: Optional[datetime] = None
estimated_minutes: Optional[int] = Field(None, ge=1, le=120)
delayed_until: Optional[datetime] = None
class TodoItemDelay(BaseModel):
"""할일 지연"""
delayed_until: datetime
class TodoItemSplit(BaseModel):
"""할일 분할"""
subtasks: List[str] = Field(..., min_items=2, max_items=10)
estimated_minutes_per_task: List[int] = Field(..., min_items=2, max_items=10)
class TodoItemResponse(TodoItemBase):
id: UUID
user_id: UUID
status: str
created_at: datetime
start_date: Optional[datetime]
estimated_minutes: Optional[int]
completed_at: Optional[datetime]
delayed_until: Optional[datetime]
parent_id: Optional[UUID]
split_order: Optional[int]
# 댓글 수
comment_count: int = 0
class Config:
from_attributes = True
class TodoItemWithComments(TodoItemResponse):
"""댓글이 포함된 할일 응답"""
comments: List[TodoCommentResponse] = []
class TodoStats(BaseModel):
"""할일 통계"""
total_count: int
draft_count: int
scheduled_count: int
active_count: int
completed_count: int
delayed_count: int
completion_rate: float # 완료율 (%)
class TodoDashboard(BaseModel):
"""할일 대시보드"""
stats: TodoStats
today_todos: List[TodoItemResponse]
overdue_todos: List[TodoItemResponse]
upcoming_todos: List[TodoItemResponse]

View File

@@ -51,6 +51,12 @@
<span>통합 검색</span>
</a>
<!-- 할일관리 -->
<a href="todos.html" class="nav-link-modern" id="todos-nav-link">
<i class="fas fa-tasks text-indigo-600"></i>
<span>할일관리</span>
</a>
<!-- 소설 관리 -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<button class="nav-link-modern" id="novel-nav-link">

View File

@@ -697,6 +697,53 @@ class DocumentServerAPI {
async removeNoteFromNotebook(notebookId, noteId) {
return await this.delete(`/notebooks/${notebookId}/notes/${noteId}`);
}
// ============================================================================
// 할일관리 API
// ============================================================================
// 할일 아이템 관리
async getTodos(status = null) {
const params = status ? `?status=${status}` : '';
return await this.get(`/todos/${params}`);
}
async createTodo(todoData) {
return await this.post('/todos/', todoData);
}
async getTodo(todoId) {
return await this.get(`/todos/${todoId}`);
}
async scheduleTodo(todoId, scheduleData) {
return await this.post(`/todos/${todoId}/schedule`, scheduleData);
}
async splitTodo(todoId, splitData) {
return await this.post(`/todos/${todoId}/split`, splitData);
}
async getActiveTodos() {
return await this.get('/todos/active');
}
async completeTodo(todoId) {
return await this.put(`/todos/${todoId}/complete`);
}
async delayTodo(todoId, delayData) {
return await this.put(`/todos/${todoId}/delay`, delayData);
}
// 댓글 관리
async getTodoComments(todoId) {
return await this.get(`/todos/${todoId}/comments`);
}
async createTodoComment(todoId, commentData) {
return await this.post(`/todos/${todoId}/comments`, commentData);
}
}
// 전역 API 인스턴스

421
frontend/static/js/todos.js Normal file
View File

@@ -0,0 +1,421 @@
/**
* 할일관리 애플리케이션
*/
console.log('📋 할일관리 JavaScript 로드 완료');
function todosApp() {
return {
// 상태 관리
loading: false,
activeTab: 'active', // draft, active, scheduled, completed
// 할일 데이터
todos: [],
stats: {
total_count: 0,
draft_count: 0,
scheduled_count: 0,
active_count: 0,
completed_count: 0,
delayed_count: 0,
completion_rate: 0
},
// 입력 폼
newTodoContent: '',
// 모달 상태
showScheduleModal: false,
showDelayModal: false,
showCommentModal: false,
showSplitModal: false,
// 현재 선택된 할일
currentTodo: null,
currentTodoComments: [],
// 폼 데이터
scheduleForm: {
start_date: '',
estimated_minutes: 30
},
delayForm: {
delayed_until: ''
},
commentForm: {
content: ''
},
splitForm: {
subtasks: ['', ''],
estimated_minutes_per_task: [30, 30]
},
// 계산된 속성들
get draftTodos() {
return this.todos.filter(todo => todo.status === 'draft');
},
get activeTodos() {
return this.todos.filter(todo => todo.status === 'active');
},
get scheduledTodos() {
return this.todos.filter(todo => todo.status === 'scheduled');
},
get completedTodos() {
return this.todos.filter(todo => todo.status === 'completed');
},
// 초기화
async init() {
console.log('📋 할일관리 초기화 중...');
// API 로드 대기
let retryCount = 0;
while (!window.api && retryCount < 50) {
await new Promise(resolve => setTimeout(resolve, 100));
retryCount++;
}
if (!window.api) {
console.error('❌ API가 로드되지 않았습니다.');
return;
}
await this.loadTodos();
await this.loadStats();
// 주기적으로 활성 할일 업데이트 (1분마다)
setInterval(() => {
this.loadActiveTodos();
}, 60000);
},
// 할일 목록 로드
async loadTodos() {
try {
this.loading = true;
const response = await window.api.get('/todos/');
this.todos = response || [];
console.log(`${this.todos.length}개 할일 로드 완료`);
} catch (error) {
console.error('❌ 할일 목록 로드 실패:', error);
alert('할일 목록을 불러오는 중 오류가 발생했습니다.');
} finally {
this.loading = false;
}
},
// 활성 할일 로드 (시간 체크 포함)
async loadActiveTodos() {
try {
const response = await window.api.get('/todos/active');
const activeTodos = response || [];
// 기존 todos에서 active 상태 업데이트
this.todos = this.todos.map(todo => {
const activeVersion = activeTodos.find(active => active.id === todo.id);
return activeVersion || todo;
});
// 새로 활성화된 할일들 추가
activeTodos.forEach(activeTodo => {
if (!this.todos.find(todo => todo.id === activeTodo.id)) {
this.todos.push(activeTodo);
}
});
await this.loadStats();
} catch (error) {
console.error('❌ 활성 할일 로드 실패:', error);
}
},
// 통계 로드
async loadStats() {
const stats = {
total_count: this.todos.length,
draft_count: this.todos.filter(t => t.status === 'draft').length,
scheduled_count: this.todos.filter(t => t.status === 'scheduled').length,
active_count: this.todos.filter(t => t.status === 'active').length,
completed_count: this.todos.filter(t => t.status === 'completed').length,
delayed_count: this.todos.filter(t => t.status === 'delayed').length
};
stats.completion_rate = stats.total_count > 0
? Math.round((stats.completed_count / stats.total_count) * 100)
: 0;
this.stats = stats;
},
// 새 할일 생성
async createTodo() {
if (!this.newTodoContent.trim()) return;
try {
this.loading = true;
const response = await window.api.post('/todos/', {
content: this.newTodoContent.trim()
});
this.todos.unshift(response);
this.newTodoContent = '';
await this.loadStats();
console.log('✅ 새 할일 생성 완료');
// 검토필요 탭으로 이동
this.activeTab = 'draft';
} catch (error) {
console.error('❌ 할일 생성 실패:', error);
alert('할일 생성 중 오류가 발생했습니다.');
} finally {
this.loading = false;
}
},
// 일정 설정 모달 열기
openScheduleModal(todo) {
this.currentTodo = todo;
this.scheduleForm = {
start_date: this.formatDateTimeLocal(new Date()),
estimated_minutes: 30
};
this.showScheduleModal = true;
},
// 일정 설정 모달 닫기
closeScheduleModal() {
this.showScheduleModal = false;
this.currentTodo = null;
},
// 할일 일정 설정
async scheduleTodo() {
if (!this.currentTodo || !this.scheduleForm.start_date) return;
try {
const response = await window.api.post(`/todos/${this.currentTodo.id}/schedule`, {
start_date: new Date(this.scheduleForm.start_date).toISOString(),
estimated_minutes: parseInt(this.scheduleForm.estimated_minutes)
});
// 할일 업데이트
const index = this.todos.findIndex(t => t.id === this.currentTodo.id);
if (index !== -1) {
this.todos[index] = response;
}
await this.loadStats();
this.closeScheduleModal();
console.log('✅ 할일 일정 설정 완료');
} catch (error) {
console.error('❌ 일정 설정 실패:', error);
if (error.message.includes('split')) {
alert('2시간 이상의 작업은 분할하는 것을 권장합니다.');
} else {
alert('일정 설정 중 오류가 발생했습니다.');
}
}
},
// 할일 완료
async completeTodo(todoId) {
try {
const response = await window.api.put(`/todos/${todoId}/complete`);
// 할일 업데이트
const index = this.todos.findIndex(t => t.id === todoId);
if (index !== -1) {
this.todos[index] = response;
}
await this.loadStats();
console.log('✅ 할일 완료');
} catch (error) {
console.error('❌ 할일 완료 실패:', error);
alert('할일 완료 처리 중 오류가 발생했습니다.');
}
},
// 지연 모달 열기
openDelayModal(todo) {
this.currentTodo = todo;
this.delayForm = {
delayed_until: this.formatDateTimeLocal(new Date(Date.now() + 24 * 60 * 60 * 1000)) // 내일
};
this.showDelayModal = true;
},
// 지연 모달 닫기
closeDelayModal() {
this.showDelayModal = false;
this.currentTodo = null;
},
// 할일 지연
async delayTodo() {
if (!this.currentTodo || !this.delayForm.delayed_until) return;
try {
const response = await window.api.put(`/todos/${this.currentTodo.id}/delay`, {
delayed_until: new Date(this.delayForm.delayed_until).toISOString()
});
// 할일 업데이트
const index = this.todos.findIndex(t => t.id === this.currentTodo.id);
if (index !== -1) {
this.todos[index] = response;
}
await this.loadStats();
this.closeDelayModal();
console.log('✅ 할일 지연 설정 완료');
} catch (error) {
console.error('❌ 할일 지연 실패:', error);
alert('할일 지연 설정 중 오류가 발생했습니다.');
}
},
// 댓글 모달 열기
async openCommentModal(todo) {
this.currentTodo = todo;
this.commentForm = { content: '' };
try {
const response = await window.api.get(`/todos/${todo.id}/comments`);
this.currentTodoComments = response || [];
} catch (error) {
console.error('❌ 댓글 로드 실패:', error);
this.currentTodoComments = [];
}
this.showCommentModal = true;
},
// 댓글 모달 닫기
closeCommentModal() {
this.showCommentModal = false;
this.currentTodo = null;
this.currentTodoComments = [];
},
// 댓글 추가
async addComment() {
if (!this.currentTodo || !this.commentForm.content.trim()) return;
try {
const response = await window.api.post(`/todos/${this.currentTodo.id}/comments`, {
content: this.commentForm.content.trim()
});
this.currentTodoComments.push(response);
this.commentForm.content = '';
// 할일의 댓글 수 업데이트
const index = this.todos.findIndex(t => t.id === this.currentTodo.id);
if (index !== -1) {
this.todos[index].comment_count = this.currentTodoComments.length;
}
console.log('✅ 댓글 추가 완료');
} catch (error) {
console.error('❌ 댓글 추가 실패:', error);
alert('댓글 추가 중 오류가 발생했습니다.');
}
},
// 분할 모달 열기
openSplitModal(todo) {
this.currentTodo = todo;
this.splitForm = {
subtasks: ['', ''],
estimated_minutes_per_task: [30, 30]
};
this.showSplitModal = true;
},
// 분할 모달 닫기
closeSplitModal() {
this.showSplitModal = false;
this.currentTodo = null;
},
// 할일 분할
async splitTodo() {
if (!this.currentTodo) return;
const validSubtasks = this.splitForm.subtasks.filter(s => s.trim());
const validMinutes = this.splitForm.estimated_minutes_per_task.slice(0, validSubtasks.length);
if (validSubtasks.length < 2) {
alert('최소 2개의 하위 작업이 필요합니다.');
return;
}
try {
const response = await window.api.post(`/todos/${this.currentTodo.id}/split`, {
subtasks: validSubtasks,
estimated_minutes_per_task: validMinutes
});
// 원본 할일 제거하고 분할된 할일들 추가
this.todos = this.todos.filter(t => t.id !== this.currentTodo.id);
this.todos.unshift(...response);
await this.loadStats();
this.closeSplitModal();
console.log('✅ 할일 분할 완료');
} catch (error) {
console.error('❌ 할일 분할 실패:', error);
alert('할일 분할 중 오류가 발생했습니다.');
}
},
// 댓글 토글
async toggleComments(todoId) {
const todo = this.todos.find(t => t.id === todoId);
if (todo) {
await this.openCommentModal(todo);
}
},
// 유틸리티 함수들
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return '방금 전';
if (diffMins < 60) return `${diffMins}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
if (diffDays < 7) return `${diffDays}일 전`;
return date.toLocaleDateString('ko-KR');
},
formatDateTimeLocal(date) {
const d = new Date(date);
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 16);
}
};
}
console.log('📋 할일관리 컴포넌트 등록 완료');

513
frontend/todos.html Normal file
View File

@@ -0,0 +1,513 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>할일관리 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
[x-cloak] { display: none !important; }
/* memos/트위터 스타일 입력창 */
.todo-input-container {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20px;
padding: 2px;
}
.todo-input-inner {
background: white;
border-radius: 18px;
padding: 20px;
}
.todo-textarea {
resize: none;
border: none;
outline: none;
font-size: 18px;
line-height: 1.5;
min-height: 60px;
}
.todo-card {
transition: all 0.3s ease;
border-left: 4px solid transparent;
}
.todo-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
}
.todo-card.draft { border-left-color: #9ca3af; }
.todo-card.scheduled { border-left-color: #3b82f6; }
.todo-card.active { border-left-color: #f59e0b; }
.todo-card.completed { border-left-color: #10b981; }
.todo-card.delayed { border-left-color: #ef4444; }
.status-badge {
font-size: 12px;
padding: 4px 8px;
border-radius: 12px;
font-weight: 600;
}
.status-draft { background: #f3f4f6; color: #6b7280; }
.status-scheduled { background: #dbeafe; color: #1d4ed8; }
.status-active { background: #fef3c7; color: #d97706; }
.status-completed { background: #d1fae5; color: #065f46; }
.status-delayed { background: #fee2e2; color: #dc2626; }
.time-badge {
background: #f0f9ff;
color: #0369a1;
font-size: 11px;
padding: 2px 6px;
border-radius: 8px;
}
.comment-bubble {
background: #f8fafc;
border-radius: 12px;
padding: 12px;
margin-top: 8px;
border-left: 3px solid #e2e8f0;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen" x-data="todosApp()" x-init="init()" x-cloak>
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 pt-20 pb-8">
<!-- 페이지 헤더 -->
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-gray-900 mb-4">
<i class="fas fa-tasks text-purple-600 mr-3"></i>
할일관리
</h1>
<p class="text-xl text-gray-600">효율적인 할일 관리로 생산성을 높여보세요</p>
</div>
<!-- 할일 입력 (memos/트위터 스타일) -->
<div class="max-w-2xl mx-auto mb-8">
<div class="todo-input-container">
<div class="todo-input-inner">
<textarea
x-model="newTodoContent"
@keydown.ctrl.enter="createTodo()"
placeholder="새로운 할일을 입력하세요... (Ctrl+Enter로 저장)"
class="todo-textarea w-full"
rows="3"
></textarea>
<div class="flex items-center justify-between mt-4">
<div class="text-sm text-gray-500">
<i class="fas fa-lightbulb mr-1"></i>
Ctrl+Enter로 빠르게 저장하세요
</div>
<button
@click="createTodo()"
:disabled="!newTodoContent.trim() || loading"
class="px-6 py-2 bg-purple-600 text-white rounded-full hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<i class="fas fa-plus mr-2"></i>
<span x-show="!loading">추가</span>
<span x-show="loading">저장 중...</span>
</button>
</div>
</div>
</div>
</div>
<!-- 탭 네비게이션 -->
<div class="max-w-6xl mx-auto mb-6">
<div class="flex space-x-1 bg-white rounded-lg p-1 shadow-sm">
<button
@click="activeTab = 'draft'"
:class="activeTab === 'draft' ? 'bg-purple-600 text-white' : 'text-gray-600 hover:text-gray-900'"
class="flex-1 px-4 py-2 rounded-md font-medium transition-colors"
>
<i class="fas fa-inbox mr-2"></i>
검토필요 (<span x-text="stats.draft_count || 0"></span>)
</button>
<button
@click="activeTab = 'active'"
:class="activeTab === 'active' ? 'bg-purple-600 text-white' : 'text-gray-600 hover:text-gray-900'"
class="flex-1 px-4 py-2 rounded-md font-medium transition-colors"
>
<i class="fas fa-play mr-2"></i>
오늘할일 (<span x-text="stats.active_count || 0"></span>)
</button>
<button
@click="activeTab = 'scheduled'"
:class="activeTab === 'scheduled' ? 'bg-purple-600 text-white' : 'text-gray-600 hover:text-gray-900'"
class="flex-1 px-4 py-2 rounded-md font-medium transition-colors"
>
<i class="fas fa-calendar mr-2"></i>
예정된일 (<span x-text="stats.scheduled_count || 0"></span>)
</button>
<button
@click="activeTab = 'completed'"
:class="activeTab === 'completed' ? 'bg-purple-600 text-white' : 'text-gray-600 hover:text-gray-900'"
class="flex-1 px-4 py-2 rounded-md font-medium transition-colors"
>
<i class="fas fa-check mr-2"></i>
완료된일 (<span x-text="stats.completed_count || 0"></span>)
</button>
</div>
</div>
<!-- 할일 목록 -->
<div class="max-w-6xl mx-auto">
<!-- 검토필요 탭 -->
<div x-show="activeTab === 'draft'" class="space-y-4">
<template x-for="todo in draftTodos" :key="todo.id">
<div class="todo-card draft bg-white rounded-lg shadow-sm p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-gray-900 mb-3" x-text="todo.content"></p>
<div class="flex items-center space-x-2">
<span class="status-badge status-draft">검토필요</span>
<span class="text-xs text-gray-500" x-text="formatDate(todo.created_at)"></span>
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
<button
@click="openScheduleModal(todo)"
class="px-3 py-1 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
<i class="fas fa-calendar-plus mr-1"></i>일정설정
</button>
<button
@click="openSplitModal(todo)"
class="px-3 py-1 bg-green-600 text-white text-sm rounded-md hover:bg-green-700"
>
<i class="fas fa-cut mr-1"></i>분할
</button>
</div>
</div>
</div>
</template>
<div x-show="draftTodos.length === 0" class="text-center py-12">
<i class="fas fa-inbox text-6xl text-gray-300 mb-4"></i>
<p class="text-gray-500">검토가 필요한 할일이 없습니다</p>
</div>
</div>
<!-- 오늘할일 탭 -->
<div x-show="activeTab === 'active'" class="space-y-4">
<template x-for="todo in activeTodos" :key="todo.id">
<div class="todo-card active bg-white rounded-lg shadow-sm p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-gray-900 mb-3" x-text="todo.content"></p>
<div class="flex items-center space-x-2 mb-3">
<span class="status-badge status-active">진행중</span>
<span class="time-badge" x-text="`${todo.estimated_minutes}분`"></span>
<span class="text-xs text-gray-500" x-text="formatDate(todo.start_date)"></span>
</div>
<!-- 댓글 표시 -->
<div x-show="todo.comment_count > 0" class="mb-3">
<button
@click="toggleComments(todo.id)"
class="text-sm text-blue-600 hover:text-blue-800"
>
<i class="fas fa-comment mr-1"></i>
댓글 <span x-text="todo.comment_count"></span>
</button>
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
<button
@click="completeTodo(todo.id)"
class="px-3 py-1 bg-green-600 text-white text-sm rounded-md hover:bg-green-700"
>
<i class="fas fa-check mr-1"></i>완료
</button>
<button
@click="openDelayModal(todo)"
class="px-3 py-1 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
>
<i class="fas fa-clock mr-1"></i>지연
</button>
<button
@click="openCommentModal(todo)"
class="px-3 py-1 bg-gray-600 text-white text-sm rounded-md hover:bg-gray-700"
>
<i class="fas fa-comment mr-1"></i>메모
</button>
</div>
</div>
</div>
</template>
<div x-show="activeTodos.length === 0" class="text-center py-12">
<i class="fas fa-play text-6xl text-gray-300 mb-4"></i>
<p class="text-gray-500">오늘 할 일이 없습니다</p>
</div>
</div>
<!-- 예정된일 탭 -->
<div x-show="activeTab === 'scheduled'" class="space-y-4">
<template x-for="todo in scheduledTodos" :key="todo.id">
<div class="todo-card scheduled bg-white rounded-lg shadow-sm p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-gray-900 mb-3" x-text="todo.content"></p>
<div class="flex items-center space-x-2">
<span class="status-badge status-scheduled">예정됨</span>
<span class="time-badge" x-text="`${todo.estimated_minutes}분`"></span>
<span class="text-xs text-gray-500" x-text="formatDate(todo.start_date)"></span>
</div>
</div>
</div>
</div>
</template>
<div x-show="scheduledTodos.length === 0" class="text-center py-12">
<i class="fas fa-calendar text-6xl text-gray-300 mb-4"></i>
<p class="text-gray-500">예정된 할일이 없습니다</p>
</div>
</div>
<!-- 완료된일 탭 -->
<div x-show="activeTab === 'completed'" class="space-y-4">
<template x-for="todo in completedTodos" :key="todo.id">
<div class="todo-card completed bg-white rounded-lg shadow-sm p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-gray-900 mb-3" x-text="todo.content"></p>
<div class="flex items-center space-x-2">
<span class="status-badge status-completed">완료</span>
<span class="time-badge" x-text="`${todo.estimated_minutes}분`"></span>
<span class="text-xs text-gray-500" x-text="formatDate(todo.completed_at)"></span>
</div>
</div>
</div>
</div>
</template>
<div x-show="completedTodos.length === 0" class="text-center py-12">
<i class="fas fa-check text-6xl text-gray-300 mb-4"></i>
<p class="text-gray-500">완료된 할일이 없습니다</p>
</div>
</div>
</div>
</main>
<!-- 일정 설정 모달 -->
<div x-show="showScheduleModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full">
<div class="p-6 border-b">
<h3 class="text-xl font-bold text-gray-900">일정 설정</h3>
</div>
<div class="p-6">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">시작 날짜</label>
<input
type="datetime-local"
x-model="scheduleForm.start_date"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">예상 소요시간</label>
<select
x-model="scheduleForm.estimated_minutes"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
<option value="1">1분</option>
<option value="30">30분</option>
<option value="60">1시간</option>
</select>
<p class="text-xs text-gray-500 mt-1">2시간 이상의 작업은 분할하는 것을 권장합니다</p>
</div>
<div class="flex space-x-3">
<button
@click="scheduleTodo()"
:disabled="!scheduleForm.start_date || !scheduleForm.estimated_minutes"
class="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
일정 설정
</button>
<button
@click="closeScheduleModal()"
class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400"
>
취소
</button>
</div>
</div>
</div>
</div>
<!-- 지연 모달 -->
<div x-show="showDelayModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full">
<div class="p-6 border-b">
<h3 class="text-xl font-bold text-gray-900">할일 지연</h3>
</div>
<div class="p-6">
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">새로운 시작 날짜</label>
<input
type="datetime-local"
x-model="delayForm.delayed_until"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
</div>
<div class="flex space-x-3">
<button
@click="delayTodo()"
:disabled="!delayForm.delayed_until"
class="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
>
지연 설정
</button>
<button
@click="closeDelayModal()"
class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400"
>
취소
</button>
</div>
</div>
</div>
</div>
<!-- 분할 모달 -->
<div x-show="showSplitModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl max-w-lg w-full">
<div class="p-6 border-b">
<h3 class="text-xl font-bold text-gray-900">할일 분할</h3>
<p class="text-sm text-gray-600 mt-1">2시간 이상의 작업을 작은 단위로 나누어 관리하세요</p>
</div>
<div class="p-6">
<div class="space-y-4 mb-6">
<template x-for="(subtask, index) in splitForm.subtasks" :key="index">
<div class="flex items-center space-x-3">
<div class="flex-1">
<input
type="text"
x-model="splitForm.subtasks[index]"
:placeholder="`하위 작업 ${index + 1}`"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
</div>
<select
x-model="splitForm.estimated_minutes_per_task[index]"
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
<option value="1">1분</option>
<option value="30">30분</option>
<option value="60">1시간</option>
</select>
<button
x-show="splitForm.subtasks.length > 2"
@click="splitForm.subtasks.splice(index, 1); splitForm.estimated_minutes_per_task.splice(index, 1)"
class="px-2 py-2 text-red-600 hover:bg-red-50 rounded"
>
<i class="fas fa-trash"></i>
</button>
</div>
</template>
</div>
<div class="flex items-center justify-between mb-6">
<button
@click="splitForm.subtasks.push(''); splitForm.estimated_minutes_per_task.push(30)"
class="px-3 py-1 bg-gray-200 text-gray-700 text-sm rounded-md hover:bg-gray-300"
>
<i class="fas fa-plus mr-1"></i>하위 작업 추가
</button>
<span class="text-xs text-gray-500">최대 10개까지 가능</span>
</div>
<div class="flex space-x-3">
<button
@click="splitTodo()"
class="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
분할하기
</button>
<button
@click="closeSplitModal()"
class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400"
>
취소
</button>
</div>
</div>
</div>
</div>
<!-- 댓글 모달 -->
<div x-show="showCommentModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl max-w-lg w-full max-h-[80vh] overflow-y-auto">
<div class="p-6 border-b">
<h3 class="text-xl font-bold text-gray-900">메모 작성</h3>
</div>
<div class="p-6">
<!-- 기존 댓글들 -->
<div x-show="currentTodoComments.length > 0" class="mb-4 space-y-3">
<template x-for="comment in currentTodoComments" :key="comment.id">
<div class="comment-bubble">
<p class="text-gray-900 text-sm" x-text="comment.content"></p>
<p class="text-xs text-gray-500 mt-2" x-text="formatDate(comment.created_at)"></p>
</div>
</template>
</div>
<!-- 새 댓글 입력 -->
<div class="mb-4">
<textarea
x-model="commentForm.content"
placeholder="메모를 입력하세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
rows="3"
></textarea>
</div>
<div class="flex space-x-3">
<button
@click="addComment()"
:disabled="!commentForm.content.trim()"
class="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
메모 추가
</button>
<button
@click="closeCommentModal()"
class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400"
>
닫기
</button>
</div>
</div>
</div>
</div>
<!-- 헤더 로더 -->
<script src="static/js/header-loader.js"></script>
<!-- API 및 앱 스크립트 -->
<script src="static/js/api.js?v=2025012627"></script>
<script src="static/js/todos.js?v=2025012627"></script>
</body>
</html>