From a4fd233ba11b223f58ab6c8f9989bc9992a5fe98 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Thu, 4 Sep 2025 10:40:49 +0900 Subject: [PATCH] =?UTF-8?q?=ED=95=A0=EC=9D=BC=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 기능: - memos/트위터 스타일 할일 입력 - 5단계 워크플로우: draft → scheduled → active → completed/delayed - 2시간 이상 작업 자동 분할 제안 (1분/30분/1시간 선택) - 시작날짜 기반 자동 활성화 - 할일별 댓글/메모 기능 - 개인별 할일 관리 백엔드: - TodoItem, TodoComment 모델 추가 - 완전한 REST API 구현 - 자동 상태 전환 로직 - 분할 기능 지원 프론트엔드: - 직관적인 탭 기반 UI - 실시간 상태 업데이트 - 모달 기반 상세 관리 - 반응형 디자인 데이터베이스: - PostgreSQL 테이블 및 인덱스 생성 - 트리거 기반 자동 업데이트 --- .../migrations/006_create_todo_tables.sql | 58 ++ backend/src/api/routes/todos.py | 663 ++++++++++++++++++ backend/src/main.py | 3 +- backend/src/models/todo.py | 63 ++ backend/src/models/user.py | 1 + backend/src/schemas/todo.py | 108 +++ frontend/components/header.html | 6 + frontend/static/js/api.js | 47 ++ frontend/static/js/todos.js | 421 +++++++++++ frontend/todos.html | 513 ++++++++++++++ 10 files changed, 1882 insertions(+), 1 deletion(-) create mode 100644 backend/database/migrations/006_create_todo_tables.sql create mode 100644 backend/src/api/routes/todos.py create mode 100644 backend/src/models/todo.py create mode 100644 backend/src/schemas/todo.py create mode 100644 frontend/static/js/todos.js create mode 100644 frontend/todos.html diff --git a/backend/database/migrations/006_create_todo_tables.sql b/backend/database/migrations/006_create_todo_tables.sql new file mode 100644 index 0000000..7428e21 --- /dev/null +++ b/backend/database/migrations/006_create_todo_tables.sql @@ -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(); diff --git a/backend/src/api/routes/todos.py b/backend/src/api/routes/todos.py new file mode 100644 index 0000000..6ad323d --- /dev/null +++ b/backend/src/api/routes/todos.py @@ -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)}" + ) diff --git a/backend/src/main.py b/backend/src/main.py index 918ef94..1fa1439 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -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("/") diff --git a/backend/src/models/todo.py b/backend/src/models/todo.py new file mode 100644 index 0000000..88f8994 --- /dev/null +++ b/backend/src/models/todo.py @@ -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"" + + +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"" diff --git a/backend/src/models/user.py b/backend/src/models/user.py index e4a16b5..9aed184 100644 --- a/backend/src/models/user.py +++ b/backend/src/models/user.py @@ -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"" diff --git a/backend/src/schemas/todo.py b/backend/src/schemas/todo.py new file mode 100644 index 0000000..3033fa2 --- /dev/null +++ b/backend/src/schemas/todo.py @@ -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] diff --git a/frontend/components/header.html b/frontend/components/header.html index 74ec264..5fe279f 100644 --- a/frontend/components/header.html +++ b/frontend/components/header.html @@ -51,6 +51,12 @@ 통합 검색 + + + + 할일관리 + +
+
+ + + + + +
+
+ + + + +
+
+ + +
+ +
+ + +
+ +

검토가 필요한 할일이 없습니다

+
+
+ + +
+ + +
+ +

오늘 할 일이 없습니다

+
+
+ + +
+ + +
+ +

예정된 할일이 없습니다

+
+
+ + +
+ + +
+ +

완료된 할일이 없습니다

+
+
+
+ + + +
+
+
+

일정 설정

+
+ +
+
+ + +
+ +
+ + +

2시간 이상의 작업은 분할하는 것을 권장합니다

+
+ +
+ + +
+
+
+
+ + +
+
+
+

할일 지연

+
+ +
+
+ + +
+ +
+ + +
+
+
+
+ + +
+
+
+

할일 분할

+

2시간 이상의 작업을 작은 단위로 나누어 관리하세요

+
+ +
+
+ +
+ +
+ + 최대 10개까지 가능 +
+ +
+ + +
+
+
+
+ + +
+
+
+

메모 작성

+
+ +
+ +
+ +
+ + +
+ +
+ +
+ + +
+
+
+
+ + + + + + + + +