From f80995c1ec0fe54256045397939b8e7bee3cc025 Mon Sep 17 00:00:00 2001 From: hyungi Date: Tue, 23 Sep 2025 07:49:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B2=B4=ED=81=AC=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 체크리스트 섹션에 이미지 썸네일 미리보기 추가 (16x16) - 대시보드 상단 체크리스트 카드에 이미지 미리보기 기능 추가 - 이미지 클릭 시 전체 화면 모달로 확대 보기 - 백엔드 image_url 컬럼을 TEXT 타입으로 변경하여 Base64 이미지 지원 - 파일 업로드를 이미지만 지원하도록 단순화 (file_url, file_name 제거) - 422 validation 오류 해결 및 상세 로깅 추가 - 체크리스트 렌더링 누락 문제 해결 --- backend/pyproject.toml | 4 +- backend/src/api/routes/auth.py | 28 +- backend/src/api/routes/calendar.py | 182 +--- backend/src/api/routes/todos.py | 499 +++++----- backend/src/core/config.py | 4 +- backend/src/core/database.py | 5 +- backend/src/main.py | 37 +- backend/src/models/todo.py | 73 +- backend/src/models/user.py | 5 +- backend/src/schemas/auth.py | 38 +- backend/src/schemas/todo.py | 129 +-- database/init/01-init.sql | 34 + docker-compose.yml | 2 +- frontend/calendar.html | 45 +- frontend/checklist.html | 147 ++- frontend/classify.html | 68 +- frontend/dashboard.html | 1469 ++++++++++++++++++++++++++-- frontend/index.html | 116 ++- frontend/static/js/api.js | 59 +- frontend/static/js/auth.js | 148 ++- frontend/static/js/todos.js | 369 +++++-- frontend/todo.html | 104 +- 22 files changed, 2635 insertions(+), 930 deletions(-) create mode 100644 database/init/01-init.sql diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 18e9576..7f8f6ed 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -21,9 +21,11 @@ dependencies = [ "python-jose[cryptography]>=3.3.0", "passlib[bcrypt]>=1.7.4", "python-dotenv>=1.0.0", - "pydantic>=2.5.0", + "pydantic[email]>=2.5.0", "pydantic-settings>=2.1.0", "pillow>=10.0.0", + "aiohttp>=3.9.0", + "caldav>=1.3.0", ] [project.optional-dependencies] diff --git a/backend/src/api/routes/auth.py b/backend/src/api/routes/auth.py index 719c26f..a291c3f 100644 --- a/backend/src/api/routes/auth.py +++ b/backend/src/api/routes/auth.py @@ -30,7 +30,7 @@ async def login( """사용자 로그인""" # 사용자 조회 result = await db.execute( - select(User).where(User.email == login_data.email) + select(User).where(User.username == login_data.username) ) user = result.scalar_one_or_none() @@ -38,7 +38,7 @@ async def login( if not user or not verify_password(login_data.password, user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect email or password" + detail="Incorrect username or password" ) # 비활성 사용자 확인 @@ -48,10 +48,9 @@ async def login( detail="Inactive user" ) - # 사용자별 세션 타임아웃을 적용한 토큰 생성 + # 토큰 생성 access_token = create_access_token( - data={"sub": str(user.id)}, - timeout_minutes=user.session_timeout_minutes + data={"sub": str(user.id)} ) refresh_token = create_refresh_token(data={"sub": str(user.id)}) @@ -59,15 +58,22 @@ async def login( await db.execute( update(User) .where(User.id == user.id) - .values(last_login=datetime.utcnow()) + .values(last_login_at=datetime.utcnow()) ) await db.commit() - return TokenResponse( - access_token=access_token, - refresh_token=refresh_token, - expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 - ) + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + "expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + "user": { + "id": str(user.id), + "username": user.username, + "full_name": user.full_name or user.username, + "is_admin": user.is_admin + } + } @router.post("/refresh", response_model=TokenResponse) diff --git a/backend/src/api/routes/calendar.py b/backend/src/api/routes/calendar.py index 3a01056..675e22e 100644 --- a/backend/src/api/routes/calendar.py +++ b/backend/src/api/routes/calendar.py @@ -1,175 +1,79 @@ """ -캘린더 연동 API 라우터 -- API 라우터 기준: 최대 400줄 -- 간결함 원칙: 캘린더 설정 및 동기화 기능만 포함 +간단한 캘린더 API 라우터 """ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from typing import List, Dict, Any, Optional +from sqlalchemy import select, and_ +from typing import List +from datetime import datetime, date from uuid import UUID import logging from ...core.database import get_db from ...models.user import User -from ...models.todo import TodoItem +from ...models.todo import Todo +from ...schemas.todo import TodoResponse from ..dependencies import get_current_active_user -from ...integrations.calendar import get_calendar_router, CalendarProvider logger = logging.getLogger(__name__) router = APIRouter(prefix="/calendar", tags=["calendar"]) -@router.post("/providers/register") -async def register_calendar_provider( - provider_data: Dict[str, Any], - current_user: User = Depends(get_current_active_user) -): - """캘린더 제공자 등록""" - try: - provider_name = provider_data.get("provider") - credentials = provider_data.get("credentials", {}) - set_as_default = provider_data.get("default", False) - - if not provider_name: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Provider name is required" - ) - - provider = CalendarProvider(provider_name) - calendar_router = get_calendar_router() - - success = await calendar_router.register_provider( - provider, credentials, set_as_default - ) - - if success: - return {"message": f"{provider_name} 캘린더 등록 성공"} - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"{provider_name} 캘린더 등록 실패" - ) - - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="지원하지 않는 캘린더 제공자" - ) - except Exception as e: - logger.error(f"캘린더 제공자 등록 실패: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=str(e) - ) - - -@router.get("/providers") -async def get_registered_providers( - current_user: User = Depends(get_current_active_user) -): - """등록된 캘린더 제공자 목록 조회""" - try: - calendar_router = get_calendar_router() - providers = calendar_router.get_registered_providers() - - return { - "providers": providers, - "default": calendar_router.default_provider.value if calendar_router.default_provider else None - } - - except Exception as e: - logger.error(f"캘린더 제공자 조회 실패: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=str(e) - ) - - -@router.get("/calendars") -async def get_all_calendars( - current_user: User = Depends(get_current_active_user) -): - """모든 등록된 제공자의 캘린더 목록 조회""" - try: - calendar_router = get_calendar_router() - calendars = await calendar_router.get_all_calendars() - - return calendars - - except Exception as e: - logger.error(f"캘린더 목록 조회 실패: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=str(e) - ) - - -@router.post("/sync/{todo_id}") -async def sync_todo_to_calendar( - todo_id: UUID, - sync_config: Optional[Dict[str, Any]] = None, +@router.get("/todos", response_model=List[TodoResponse]) +async def get_calendar_todos( + start_date: date, + end_date: date, current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_db) ): - """특정 할일을 캘린더에 동기화""" + """캘린더용 Todo 목록 조회""" try: - from sqlalchemy import select, and_ - - # 할일 조회 - result = await db.execute( - select(TodoItem).where( - and_( - TodoItem.id == todo_id, - TodoItem.user_id == current_user.id - ) + # 날짜 범위 내의 Todo들 조회 + query = select(Todo).where( + and_( + Todo.user_id == current_user.id, + Todo.due_date >= start_date, + Todo.due_date <= end_date ) - ) - todo_item = result.scalar_one_or_none() + ).order_by(Todo.due_date.asc()) - if not todo_item: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Todo item not found" - ) + result = await db.execute(query) + todos = result.scalars().all() - # 캘린더 동기화 - calendar_router = get_calendar_router() - calendar_configs = sync_config.get("calendars") if sync_config else None + return todos - result = await calendar_router.sync_todo_to_calendars( - todo_item, calendar_configs - ) - - return { - "message": "캘린더 동기화 완료", - "result": result - } - - except HTTPException: - raise except Exception as e: - logger.error(f"캘린더 동기화 실패: {e}") + logger.error(f"캘린더 Todo 조회 실패: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=str(e) + detail="캘린더 데이터 조회에 실패했습니다." ) -@router.get("/health") -async def calendar_health_check( - current_user: User = Depends(get_current_active_user) +@router.get("/today", response_model=List[TodoResponse]) +async def get_today_todos( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) ): - """캘린더 서비스 상태 확인""" + """오늘의 Todo 목록 조회""" try: - calendar_router = get_calendar_router() - health_status = await calendar_router.health_check() + today = date.today() - return health_status + query = select(Todo).where( + and_( + Todo.user_id == current_user.id, + Todo.due_date == today + ) + ).order_by(Todo.created_at.desc()) + + result = await db.execute(query) + todos = result.scalars().all() + + return todos except Exception as e: - logger.error(f"캘린더 상태 확인 실패: {e}") + logger.error(f"오늘 Todo 조회 실패: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=str(e) - ) + detail="오늘 Todo 조회에 실패했습니다." + ) \ No newline at end of file diff --git a/backend/src/api/routes/todos.py b/backend/src/api/routes/todos.py index 3d8eaa4..4899d05 100644 --- a/backend/src/api/routes/todos.py +++ b/backend/src/api/routes/todos.py @@ -1,313 +1,302 @@ """ -할일관리 API 라우터 (간결 버전) -- API 라우터 기준: 최대 400줄 -- 간결함 원칙: 라우팅만 담당, 비즈니스 로직은 서비스로 분리 +간단한 Todo API 라우터 """ from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ from typing import List, Optional from uuid import UUID import logging +from datetime import datetime from ...core.database import get_db from ...models.user import User -from ...schemas.todo import ( - TodoItemCreate, TodoItemSchedule, TodoItemDelay, TodoItemSplit, - TodoItemResponse, TodoCommentCreate, TodoCommentResponse -) +from ...models.todo import Todo, TodoStatus +from ...schemas.todo import TodoCreate, TodoUpdate, TodoResponse from ..dependencies import get_current_active_user -from ...services.todo_service import TodoService -from ...services.calendar_sync_service import get_calendar_sync_service -from ...services.file_service import save_base64_image logger = logging.getLogger(__name__) router = APIRouter(prefix="/todos", tags=["todos"]) # ============================================================================ -# 이미지 업로드 +# Todo CRUD API # ============================================================================ -@router.post("/upload-image") -async def upload_image( - image: UploadFile = File(...), - current_user: User = Depends(get_current_active_user) +@router.post("/", response_model=TodoResponse) +async def create_todo( + todo_data: TodoCreate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) ): - """이미지 업로드""" + """새 Todo 생성""" try: - # 파일 형식 확인 - if not image.content_type.startswith('image/'): + logger.info(f"Todo 생성 요청 - 사용자: {current_user.username}") + logger.info(f"요청 데이터: {todo_data.dict()}") + + # 날짜 문자열 파싱 (한국 시간 형식) + parsed_due_date = None + if todo_data.due_date: + try: + # "2025-09-22T00:00:00+09:00" 형식 파싱 + parsed_due_date = datetime.fromisoformat(todo_data.due_date) + logger.info(f"파싱된 날짜: {parsed_due_date}") + except ValueError: + logger.warning(f"Invalid date format: {todo_data.due_date}") + + # 새 Todo 생성 + new_todo = Todo( + user_id=current_user.id, + title=todo_data.title, + description=todo_data.description, + category=todo_data.category, + due_date=parsed_due_date, + image_url=todo_data.image_url, + tags=todo_data.tags + ) + + db.add(new_todo) + await db.commit() + await db.refresh(new_todo) + + # 응답용 데이터 변환 + response_data = { + "id": new_todo.id, + "user_id": new_todo.user_id, + "title": new_todo.title, + "description": new_todo.description, + "category": new_todo.category, + "status": new_todo.status, + "created_at": new_todo.created_at, + "updated_at": new_todo.updated_at, + "due_date": new_todo.due_date.isoformat() if new_todo.due_date else None, + "completed_at": new_todo.completed_at, + "image_url": new_todo.image_url, + "tags": new_todo.tags + } + + return response_data + + except Exception as e: + await db.rollback() + logger.error(f"Todo 생성 실패: {e}") + logger.error(f"오류 타입: {type(e).__name__}") + logger.error(f"오류 상세: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Todo 생성에 실패했습니다: {str(e)}" + ) + + +@router.get("/", response_model=List[TodoResponse]) +async def get_todos( + status: Optional[str] = Query(None, description="상태 필터 (pending, in_progress, completed)"), + category: Optional[str] = Query(None, description="카테고리 필터 (todo, calendar, checklist)"), + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """Todo 목록 조회""" + try: + # 기본 쿼리 + query = select(Todo).where(Todo.user_id == current_user.id) + + # 필터 적용 + if status: + query = query.where(Todo.status == status) + if category: + query = query.where(Todo.category == category) + + # 정렬 (생성일 역순) + query = query.order_by(Todo.created_at.desc()) + + result = await db.execute(query) + todos = result.scalars().all() + + # 응답용 데이터 변환 + response_data = [] + for todo in todos: + response_data.append({ + "id": todo.id, + "user_id": todo.user_id, + "title": todo.title, + "description": todo.description, + "category": todo.category, + "status": todo.status, + "created_at": todo.created_at, + "updated_at": todo.updated_at, + "due_date": todo.due_date.isoformat() if todo.due_date else None, + "completed_at": todo.completed_at, + "image_url": todo.image_url, + "tags": todo.tags + }) + + return response_data + + except Exception as e: + logger.error(f"Todo 목록 조회 실패: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Todo 목록 조회에 실패했습니다." + ) + + +@router.get("/{todo_id}", response_model=TodoResponse) +async def get_todo( + todo_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """특정 Todo 조회""" + try: + result = await db.execute( + select(Todo).where( + and_(Todo.id == todo_id, Todo.user_id == current_user.id) + ) + ) + todo = result.scalar_one_or_none() + + if not todo: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="이미지 파일만 업로드 가능합니다." + status_code=status.HTTP_404_NOT_FOUND, + detail="Todo를 찾을 수 없습니다." ) - # 파일 크기 확인 (5MB 제한) - contents = await image.read() - if len(contents) > 5 * 1024 * 1024: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="파일 크기는 5MB를 초과할 수 없습니다." - ) - - # Base64로 변환하여 저장 - import base64 - base64_string = f"data:{image.content_type};base64,{base64.b64encode(contents).decode()}" - - # 파일 저장 - file_url = save_base64_image(base64_string) - if not file_url: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="이미지 저장에 실패했습니다." - ) - - return {"url": file_url} + # 응답용 데이터 변환 + response_data = { + "id": todo.id, + "user_id": todo.user_id, + "title": todo.title, + "description": todo.description, + "category": todo.category, + "status": todo.status, + "created_at": todo.created_at, + "updated_at": todo.updated_at, + "due_date": todo.due_date.isoformat() if todo.due_date else None, + "completed_at": todo.completed_at, + "image_url": todo.image_url, + "tags": todo.tags, + } + + return response_data except HTTPException: raise except Exception as e: - logger.error(f"이미지 업로드 실패: {e}") + logger.error(f"Todo 조회 실패: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="이미지 업로드에 실패했습니다." + detail="Todo 조회에 실패했습니다." ) -# ============================================================================ -# 할일 아이템 관리 -# ============================================================================ - -@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) -): - """새 할일 생성""" - try: - service = TodoService(db) - return await service.create_todo(todo_data, current_user.id) - - except Exception as e: - await db.rollback() - logger.error(f"할일 생성 실패: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=str(e) - ) - - -@router.post("/{todo_id}/schedule", response_model=TodoItemResponse) -async def schedule_todo_item( +@router.put("/{todo_id}", response_model=TodoResponse) +async def update_todo( todo_id: UUID, - schedule_data: TodoItemSchedule, + todo_data: TodoUpdate, current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_db) ): - """할일 일정 설정 및 캘린더 동기화""" + """Todo 수정""" try: - service = TodoService(db) - result = await service.schedule_todo(todo_id, schedule_data, current_user.id) - - # 🔄 캘린더 동기화 (백그라운드) - sync_service = get_calendar_sync_service() - todo_item = await service._get_user_todo(todo_id, current_user.id) - await sync_service.sync_todo_create(todo_item) - - return result - - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) + # Todo 조회 + result = await db.execute( + select(Todo).where( + and_(Todo.id == todo_id, Todo.user_id == current_user.id) + ) ) + todo = result.scalar_one_or_none() + + if not todo: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Todo를 찾을 수 없습니다." + ) + + # 업데이트할 필드만 수정 + update_data = todo_data.dict(exclude_unset=True) + for field, value in update_data.items(): + if field == 'due_date' and value: + # 날짜 문자열 파싱 (한국 시간 형식) + try: + parsed_date = datetime.fromisoformat(value) + setattr(todo, field, parsed_date) + except ValueError: + logger.warning(f"Invalid date format: {value}") + else: + setattr(todo, field, value) + + # 완료 상태 변경 시 완료 시간 설정 + if todo_data.status == TodoStatus.COMPLETED and todo.completed_at is None: + todo.completed_at = datetime.utcnow() + elif todo_data.status != TodoStatus.COMPLETED: + todo.completed_at = None + + await db.commit() + await db.refresh(todo) + + # 응답용 데이터 변환 + response_data = { + "id": todo.id, + "user_id": todo.user_id, + "title": todo.title, + "description": todo.description, + "category": todo.category, + "status": todo.status, + "created_at": todo.created_at, + "updated_at": todo.updated_at, + "due_date": todo.due_date.isoformat() if todo.due_date else None, + "completed_at": todo.completed_at, + "image_url": todo.image_url, + "tags": todo.tags, + } + + return response_data + + except HTTPException: + raise except Exception as e: await db.rollback() - logger.error(f"할일 일정 설정 실패: {e}") + logger.error(f"Todo 수정 실패: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=str(e) + detail="Todo 수정에 실패했습니다." ) -@router.put("/{todo_id}/complete", response_model=TodoItemResponse) -async def complete_todo_item( +@router.delete("/{todo_id}") +async def delete_todo( todo_id: UUID, current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_db) ): - """할일 완료 및 캘린더 업데이트""" + """Todo 삭제""" try: - service = TodoService(db) - result = await service.complete_todo(todo_id, current_user.id) - - # 🔄 캘린더 동기화 (완료 태그 변경) - sync_service = get_calendar_sync_service() - todo_item = await service._get_user_todo(todo_id, current_user.id) - await sync_service.sync_todo_complete(todo_item) - - return result - - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(e) + # Todo 조회 + result = await db.execute( + select(Todo).where( + and_(Todo.id == todo_id, Todo.user_id == current_user.id) + ) ) + todo = result.scalar_one_or_none() + + if not todo: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Todo를 찾을 수 없습니다." + ) + + await db.delete(todo) + await db.commit() + + return {"message": "Todo가 삭제되었습니다."} + + except HTTPException: + raise except Exception as e: await db.rollback() - logger.error(f"할일 완료 실패: {e}") + logger.error(f"Todo 삭제 실패: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=str(e) + detail="Todo 삭제에 실패했습니다." ) - -@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: - service = TodoService(db) - result = await service.delay_todo(todo_id, delay_data, current_user.id) - - # 🔄 캘린더 동기화 (날짜 수정) - sync_service = get_calendar_sync_service() - todo_item = await service._get_user_todo(todo_id, current_user.id) - await sync_service.sync_todo_delay(todo_item) - - return result - - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(e) - ) - except Exception as e: - await db.rollback() - logger.error(f"할일 지연 실패: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=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: - service = TodoService(db) - return await service.split_todo(todo_id, split_data, current_user.id) - - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) - except Exception as e: - await db.rollback() - logger.error(f"할일 분할 실패: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=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: - service = TodoService(db) - return await service.get_todos(current_user.id, status) - - except Exception as e: - logger.error(f"할일 목록 조회 실패: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=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: - service = TodoService(db) - return await service.get_active_todos(current_user.id) - - except Exception as e: - logger.error(f"활성 할일 조회 실패: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=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: - service = TodoService(db) - return await service.create_comment(todo_id, comment_data, current_user.id) - - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(e) - ) - except Exception as e: - await db.rollback() - logger.error(f"댓글 생성 실패: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=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: - service = TodoService(db) - return await service.get_comments(todo_id, current_user.id) - - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(e) - ) - except Exception as e: - logger.error(f"댓글 조회 실패: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=str(e) - ) \ No newline at end of file diff --git a/backend/src/core/config.py b/backend/src/core/config.py index 96d93c1..ed4f263 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -13,6 +13,7 @@ class Settings(BaseSettings): APP_NAME: str = "Todo Project" DEBUG: bool = True VERSION: str = "0.1.0" + ENVIRONMENT: str = "development" # 데이터베이스 설정 DATABASE_URL: str = "postgresql+asyncpg://todo_user:todo_password@database:5432/todo_db" @@ -32,7 +33,8 @@ class Settings(BaseSettings): PORT: int = 9000 # 관리자 계정 설정 (초기 설정용) - ADMIN_EMAIL: str = "admin@todo-project.local" + ADMIN_USERNAME: str = "hyungi" + ADMIN_EMAIL: str = "hyungi@example.com" ADMIN_PASSWORD: str = "admin123" # 프로덕션에서는 반드시 변경 class Config: diff --git a/backend/src/core/database.py b/backend/src/core/database.py index 3922b46..a2e5dd4 100644 --- a/backend/src/core/database.py +++ b/backend/src/core/database.py @@ -76,13 +76,14 @@ async def create_admin_user() -> None: async with AsyncSessionLocal() as session: # 관리자 계정 존재 확인 result = await session.execute( - select(User).where(User.email == settings.ADMIN_EMAIL) + select(User).where(User.username == settings.ADMIN_USERNAME) ) admin_user = result.scalar_one_or_none() if not admin_user: # 관리자 계정 생성 admin_user = User( + username=settings.ADMIN_USERNAME, email=settings.ADMIN_EMAIL, hashed_password=get_password_hash(settings.ADMIN_PASSWORD), is_active=True, @@ -91,4 +92,4 @@ async def create_admin_user() -> None: ) session.add(admin_user) await session.commit() - print(f"관리자 계정이 생성되었습니다: {settings.ADMIN_EMAIL}") + print(f"관리자 계정이 생성되었습니다: {settings.ADMIN_USERNAME}") diff --git a/backend/src/main.py b/backend/src/main.py index 9fdf6f2..8ad53a5 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -2,12 +2,19 @@ Todo-Project 메인 애플리케이션 - 간결함 원칙: 애플리케이션 설정 및 라우터 등록만 담당 """ -from fastapi import FastAPI +from fastapi import FastAPI, Request, HTTPException from fastapi.middleware.cors import CORSMiddleware +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse import logging from .core.config import settings from .api.routes import auth, todos, calendar +from .core.database import AsyncSessionLocal +from .models.todo import Todo +from .models.user import User +from datetime import datetime, timedelta +from sqlalchemy import select # 로깅 설정 logging.basicConfig( @@ -34,6 +41,19 @@ app.add_middleware( allow_headers=["*"], ) +# Validation 오류 핸들러 +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + logger.error(f"Validation 오류 - URL: {request.url}") + logger.error(f"Validation 오류 상세: {exc.errors()}") + return JSONResponse( + status_code=422, + content={ + "detail": "요청 데이터 검증 실패", + "errors": exc.errors() + } + ) + # 라우터 등록 app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(todos.router, prefix="/api", tags=["todos"]) @@ -60,13 +80,26 @@ async def health_check(): } -# 애플리케이션 시작 시 실행 +async def create_sample_data(): + """샘플 데이터 생성 - 비활성화됨""" + # 더미 데이터 생성 완전히 비활성화 + logger.info("샘플 데이터 생성이 비활성화되었습니다.") + return + + @app.on_event("startup") async def startup_event(): """애플리케이션 시작 시 초기화""" logger.info("🚀 Todo-Project API 시작") logger.info(f"📊 환경: {settings.ENVIRONMENT}") logger.info(f"🔗 데이터베이스: {settings.DATABASE_URL}") + + # 데이터베이스 초기화 + from .core.database import init_db + await init_db() + + # 샘플 데이터 생성 비활성화 + logger.info("샘플 데이터 생성이 비활성화되었습니다.") # 애플리케이션 종료 시 실행 diff --git a/backend/src/models/todo.py b/backend/src/models/todo.py index bd007b0..b88633d 100644 --- a/backend/src/models/todo.py +++ b/backend/src/models/todo.py @@ -1,64 +1,57 @@ """ -할일관리 시스템 모델 +간단한 Todo 시스템 모델 """ -from sqlalchemy import Column, String, DateTime, Text, Boolean, Integer, ForeignKey +from sqlalchemy import Column, String, DateTime, Text, Boolean, Integer, ForeignKey, Enum from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from datetime import datetime import uuid +import enum from ..core.database import Base -class TodoItem(Base): +class TodoCategory(str, enum.Enum): + """Todo 카테고리""" + TODO = "todo" + CALENDAR = "calendar" + CHECKLIST = "checklist" + + +class TodoStatus(str, enum.Enum): + """Todo 상태""" + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + + + + +class Todo(Base): """할일 아이템""" - __tablename__ = "todo_items" + __tablename__ = "todos" 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 - photo_url = Column(String(500), nullable=True) # 첨부 사진 URL + title = Column(String(200), nullable=False) # 할일 제목 + description = Column(Text, nullable=True) # 할일 설명 + category = Column(Enum(TodoCategory), nullable=True, default=None) + status = Column(Enum(TodoStatus), nullable=False, default=TodoStatus.PENDING) # 시간 관리 created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow) - start_date = Column(DateTime(timezone=True), nullable=True) # 시작 예정일 - estimated_minutes = Column(Integer, nullable=True) # 예상 소요시간 (분) - completed_at = Column(DateTime(timezone=True), nullable=True) - delayed_until = Column(DateTime(timezone=True), 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(timezone=True), nullable=False, default=datetime.utcnow) updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + due_date = Column(DateTime(timezone=True), nullable=True) # 마감일 + completed_at = Column(DateTime(timezone=True), nullable=True) + + # 추가 정보 + image_url = Column(Text, nullable=True) # 첨부 이미지 URL (Base64 데이터 지원) + tags = Column(String(500), nullable=True) # 태그 (쉼표로 구분) # 관계 - todo_item = relationship("TodoItem", back_populates="comments") - user = relationship("User") + user = relationship("User", back_populates="todos") def __repr__(self): - return f"" + return f"" diff --git a/backend/src/models/user.py b/backend/src/models/user.py index 22ef55e..f4280ad 100644 --- a/backend/src/models/user.py +++ b/backend/src/models/user.py @@ -15,7 +15,8 @@ class User(Base): __tablename__ = "users" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - email = Column(String(255), unique=True, index=True, nullable=False) + username = Column(String(50), unique=True, index=True, nullable=False) + email = Column(String(255), unique=True, index=True, nullable=True) hashed_password = Column(String(255), nullable=False) full_name = Column(String(255), nullable=True) @@ -37,7 +38,7 @@ class User(Base): language = Column(String(10), default="ko", nullable=False) # 관계 설정 - todo_items = relationship("TodoItem", back_populates="user", lazy="dynamic") + todos = relationship("Todo", back_populates="user", lazy="dynamic") def __repr__(self): return f"" diff --git a/backend/src/schemas/auth.py b/backend/src/schemas/auth.py index fb27311..1e45836 100644 --- a/backend/src/schemas/auth.py +++ b/backend/src/schemas/auth.py @@ -48,10 +48,46 @@ class TokenData(BaseModel): class LoginRequest(BaseModel): - email: EmailStr + username: str = Field(..., min_length=3, max_length=50) password: str remember_me: bool = False class RefreshTokenRequest(BaseModel): refresh_token: str + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int + + +class UserInfo(BaseModel): + id: UUID + username: str + email: Optional[str] = None + full_name: Optional[str] = None + is_active: bool + is_admin: bool + timezone: str = "UTC" + language: str = "ko" + created_at: datetime + last_login_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str = Field(..., min_length=6) + + +class CreateUserRequest(BaseModel): + username: str = Field(..., min_length=3, max_length=50) + email: Optional[str] = None + password: str = Field(..., min_length=6) + full_name: Optional[str] = None + is_admin: bool = False diff --git a/backend/src/schemas/todo.py b/backend/src/schemas/todo.py index b122262..1220998 100644 --- a/backend/src/schemas/todo.py +++ b/backend/src/schemas/todo.py @@ -1,110 +1,81 @@ """ -할일관리 시스템 스키마 +간단한 Todo 시스템 스키마 """ from pydantic import BaseModel, Field from typing import List, Optional from datetime import datetime from uuid import UUID +from enum import Enum -class TodoCommentBase(BaseModel): - content: str = Field(..., min_length=1, max_length=1000) +class TodoCategoryEnum(str, Enum): + TODO = "todo" + CALENDAR = "calendar" + CHECKLIST = "checklist" -class TodoCommentCreate(TodoCommentBase): - pass +class TodoStatusEnum(str, Enum): + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" -class TodoCommentUpdate(BaseModel): - content: Optional[str] = Field(None, min_length=1, max_length=1000) -class TodoCommentResponse(TodoCommentBase): +class TodoBase(BaseModel): + title: str = Field(..., min_length=1, max_length=200) + description: Optional[str] = Field(None, max_length=2000) + category: Optional[TodoCategoryEnum] = None + + +class TodoCreate(TodoBase): + """Todo 생성""" + due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식) + image_url: Optional[str] = Field(None, max_length=10000000) # Base64 이미지를 위해 크게 설정 + tags: Optional[str] = Field(None, max_length=500) + + +class TodoUpdate(BaseModel): + """Todo 수정""" + title: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = Field(None, max_length=2000) + category: Optional[TodoCategoryEnum] = None + status: Optional[TodoStatusEnum] = None + due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식) + image_url: Optional[str] = Field(None, max_length=10000000) # Base64 이미지를 위해 크게 설정 + tags: Optional[str] = Field(None, max_length=500) + + +class TodoResponse(BaseModel): id: UUID - todo_item_id: UUID user_id: UUID + title: str + description: Optional[str] = None + category: Optional[TodoCategoryEnum] = None + status: TodoStatusEnum created_at: datetime updated_at: datetime + due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식) + completed_at: Optional[datetime] = None + image_url: Optional[str] = None + tags: Optional[str] = None class Config: from_attributes = True -class TodoItemBase(BaseModel): - content: str = Field(..., min_length=1, max_length=2000) - - -class TodoItemCreate(TodoItemBase): - """초기 할일 생성 (draft 상태)""" - photo_url: Optional[str] = Field(None, max_length=500, description="첨부 사진 URL") - - -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) - photo_url: Optional[str] = Field(None, max_length=500, description="첨부 사진 URL") - status: Optional[str] = Field(None, pattern="^(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 - photo_url: Optional[str] = None - 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): - """할일 통계""" + """Todo 통계""" total_count: int - draft_count: int - scheduled_count: int - active_count: int + pending_count: int + in_progress_count: int completed_count: int - delayed_count: int completion_rate: float # 완료율 (%) class TodoDashboard(BaseModel): - """할일 대시보드""" + """Todo 대시보드""" stats: TodoStats - today_todos: List[TodoItemResponse] - overdue_todos: List[TodoItemResponse] - upcoming_todos: List[TodoItemResponse] + today_todos: List[TodoResponse] + overdue_todos: List[TodoResponse] + upcoming_todos: List[TodoResponse] diff --git a/database/init/01-init.sql b/database/init/01-init.sql new file mode 100644 index 0000000..a038499 --- /dev/null +++ b/database/init/01-init.sql @@ -0,0 +1,34 @@ +-- Todo Project 데이터베이스 초기화 +-- 사용자 및 데이터베이스 생성 + +-- 데이터베이스가 존재하지 않으면 생성 +SELECT 'CREATE DATABASE todo_db' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'todo_db')\gexec + +-- 사용자가 존재하지 않으면 생성 +DO +$do$ +BEGIN + IF NOT EXISTS ( + SELECT FROM pg_catalog.pg_roles + WHERE rolname = 'todo_user') THEN + + CREATE ROLE todo_user LOGIN PASSWORD 'todo_password'; + END IF; +END +$do$; + +-- 권한 부여 +GRANT ALL PRIVILEGES ON DATABASE todo_db TO todo_user; + +-- todo_db에 연결 +\c todo_db + +-- 스키마 권한 부여 +GRANT ALL ON SCHEMA public TO todo_user; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO todo_user; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO todo_user; + +-- 기본 권한 설정 +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO todo_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO todo_user; diff --git a/docker-compose.yml b/docker-compose.yml index d5c8187..c742e2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,7 @@ services: depends_on: - database environment: - - DATABASE_URL=postgresql://todo_user:${POSTGRES_PASSWORD:-todo_password}@database:5432/todo_db + - DATABASE_URL=postgresql+asyncpg://todo_user:${POSTGRES_PASSWORD:-todo_password}@database:5432/todo_db - SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this-in-production} - DEBUG=${DEBUG:-true} - CORS_ORIGINS=["http://localhost:4000", "http://127.0.0.1:4000"] diff --git a/frontend/calendar.html b/frontend/calendar.html index a484db9..57223c3 100644 --- a/frontend/calendar.html +++ b/frontend/calendar.html @@ -187,30 +187,15 @@ } // 캘린더 항목 로드 - function loadCalendarItems() { - // 임시 데이터 (실제로는 API에서 가져옴) - const calendarItems = [ - { - id: 1, - content: '월말 보고서 제출', - photo: null, - due_date: '2024-01-25', - status: 'active', - priority: 'urgent', - created_at: '2024-01-15' - }, - { - id: 2, - content: '클라이언트 미팅 자료 준비', - photo: null, - due_date: '2024-01-30', - status: 'active', - priority: 'warning', - created_at: '2024-01-16' - } - ]; - - renderCalendarItems(calendarItems); + async function loadCalendarItems() { + try { + // API에서 캘린더 카테고리 항목들만 가져오기 + const items = await TodoAPI.getTodos(null, 'calendar'); + renderCalendarItems(items); + } catch (error) { + console.error('캘린더 항목 로드 실패:', error); + renderCalendarItems([]); + } } // 캘린더 항목 렌더링 @@ -245,7 +230,7 @@
-

${item.content}

+

${item.title}

마감: ${formatDate(item.due_date)} @@ -267,14 +252,14 @@
${item.status !== 'completed' ? ` - - ` : ''} -
@@ -360,7 +345,9 @@ // 날짜 포맷팅 function formatDate(dateString) { + if (!dateString) return '날짜 없음'; const date = new Date(dateString); + if (isNaN(date.getTime())) return '날짜 없음'; return date.toLocaleDateString('ko-KR'); } @@ -396,5 +383,7 @@ // 전역 함수 등록 window.goToDashboard = goToDashboard; + + diff --git a/frontend/checklist.html b/frontend/checklist.html index 4f37f0f..6248c51 100644 --- a/frontend/checklist.html +++ b/frontend/checklist.html @@ -225,34 +225,15 @@ } // 체크리스트 항목 로드 - function loadChecklistItems() { - // 임시 데이터 (실제로는 API에서 가져옴) - checklistItems = [ - { - id: 1, - content: '책상 정리하기', - photo: null, - completed: false, - created_at: '2024-01-15', - completed_at: null - }, - { - id: 2, - content: '운동 계획 세우기', - photo: null, - completed: true, - created_at: '2024-01-16', - completed_at: '2024-01-18' - }, - { - id: 3, - content: '독서 목록 만들기', - photo: null, - completed: false, - created_at: '2024-01-17', - completed_at: null - } - ]; + async function loadChecklistItems() { + try { + // API에서 체크리스트 카테고리 항목들만 가져오기 + const items = await TodoAPI.getTodos(null, 'checklist'); + checklistItems = items; + } catch (error) { + console.error('체크리스트 항목 로드 실패:', error); + checklistItems = []; + } renderChecklistItems(checklistItems); updateProgress(); @@ -276,7 +257,7 @@
-
+
${item.completed ? '' : ''}
@@ -290,7 +271,7 @@
-

${item.content}

+

${item.title}

등록: ${formatDate(item.created_at)} @@ -305,10 +286,26 @@
- + +
+ -
@@ -376,7 +373,9 @@ // 날짜 포맷팅 function formatDate(dateString) { + if (!dateString) return '날짜 없음'; const date = new Date(dateString); + if (isNaN(date.getTime())) return '날짜 없음'; return date.toLocaleDateString('ko-KR'); } @@ -401,6 +400,77 @@ console.log('필터:', filter); } + // 카테고리 메뉴 표시/숨김 + function showCategoryMenu(itemId) { + // 다른 메뉴들 숨기기 + document.querySelectorAll('[id^="categoryMenu-"]').forEach(menu => { + if (menu.id !== `categoryMenu-${itemId}`) { + menu.classList.add('hidden'); + } + }); + + // 해당 메뉴 토글 + const menu = document.getElementById(`categoryMenu-${itemId}`); + if (menu) { + menu.classList.toggle('hidden'); + } + } + + // 카테고리 변경 + async function changeCategory(itemId, newCategory) { + try { + // 날짜 입력 받기 (todo나 calendar인 경우) + let dueDate = null; + if (newCategory === 'todo' || newCategory === 'calendar') { + const dateInput = prompt( + newCategory === 'todo' ? + '시작 날짜를 입력하세요 (YYYY-MM-DD):' : + '마감 날짜를 입력하세요 (YYYY-MM-DD):' + ); + + if (!dateInput) { + return; // 취소 + } + + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateInput)) { + alert('올바른 날짜 형식을 입력해주세요 (YYYY-MM-DD)'); + return; + } + + dueDate = dateInput + 'T09:00:00Z'; + } + + // API 호출하여 카테고리 변경 + const updateData = { + category: newCategory + }; + + if (dueDate) { + updateData.due_date = dueDate; + } + + await TodoAPI.updateTodo(itemId, updateData); + + // 성공 메시지 + const categoryNames = { + 'todo': 'Todo', + 'calendar': '캘린더' + }; + + alert(`항목이 ${categoryNames[newCategory]}로 이동되었습니다!`); + + // 메뉴 숨기기 + document.getElementById(`categoryMenu-${itemId}`).classList.add('hidden'); + + // 페이지 새로고침하여 변경된 항목 제거 + await loadChecklistItems(); + + } catch (error) { + console.error('카테고리 변경 실패:', error); + alert('카테고리 변경에 실패했습니다.'); + } + } + // 대시보드로 이동 function goToDashboard() { window.location.href = 'dashboard.html'; @@ -408,6 +478,19 @@ // 전역 함수 등록 window.goToDashboard = goToDashboard; + window.showCategoryMenu = showCategoryMenu; + window.changeCategory = changeCategory; + + // 문서 클릭 시 메뉴 숨기기 + document.addEventListener('click', (e) => { + if (!e.target.closest('.relative')) { + document.querySelectorAll('[id^="categoryMenu-"]').forEach(menu => { + menu.classList.add('hidden'); + }); + } + }); + + diff --git a/frontend/classify.html b/frontend/classify.html index 484780b..9bbdf76 100644 --- a/frontend/classify.html +++ b/frontend/classify.html @@ -3,7 +3,7 @@ - 분류 센터 - Todo Project + INDEX - Todo Project