feat: 체크리스트 이미지 미리보기 기능 구현
- 체크리스트 섹션에 이미지 썸네일 미리보기 추가 (16x16) - 대시보드 상단 체크리스트 카드에 이미지 미리보기 기능 추가 - 이미지 클릭 시 전체 화면 모달로 확대 보기 - 백엔드 image_url 컬럼을 TEXT 타입으로 변경하여 Base64 이미지 지원 - 파일 업로드를 이미지만 지원하도록 단순화 (file_url, file_name 제거) - 422 validation 오류 해결 및 상세 로깅 추가 - 체크리스트 렌더링 누락 문제 해결
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
# 날짜 범위 내의 Todo들 조회
|
||||
query = select(Todo).where(
|
||||
and_(
|
||||
TodoItem.id == todo_id,
|
||||
TodoItem.user_id == current_user.id
|
||||
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 조회에 실패했습니다."
|
||||
)
|
||||
@@ -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/'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미지 파일만 업로드 가능합니다."
|
||||
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
|
||||
)
|
||||
|
||||
# 파일 크기 확인 (5MB 제한)
|
||||
contents = await image.read()
|
||||
if len(contents) > 5 * 1024 * 1024:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="파일 크기는 5MB를 초과할 수 없습니다."
|
||||
)
|
||||
db.add(new_todo)
|
||||
await db.commit()
|
||||
await db.refresh(new_todo)
|
||||
|
||||
# Base64로 변환하여 저장
|
||||
import base64
|
||||
base64_string = f"data:{image.content_type};base64,{base64.b64encode(contents).decode()}"
|
||||
# 응답용 데이터 변환
|
||||
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
|
||||
}
|
||||
|
||||
# 파일 저장
|
||||
file_url = save_base64_image(base64_string)
|
||||
if not file_url:
|
||||
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="이미지 저장에 실패했습니다."
|
||||
detail=f"Todo 생성에 실패했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
return {"url": file_url}
|
||||
|
||||
@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_404_NOT_FOUND,
|
||||
detail="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:
|
||||
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)
|
||||
)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"할일 일정 설정 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e)
|
||||
)
|
||||
todo = result.scalar_one_or_none()
|
||||
|
||||
|
||||
@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:
|
||||
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:
|
||||
if not todo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
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}/delay", response_model=TodoItemResponse)
|
||||
async def delay_todo_item(
|
||||
@router.delete("/{todo_id}")
|
||||
async def delete_todo(
|
||||
todo_id: UUID,
|
||||
delay_data: TodoItemDelay,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""할일 지연 및 캘린더 날짜 수정"""
|
||||
"""Todo 삭제"""
|
||||
try:
|
||||
service = TodoService(db)
|
||||
result = await service.delay_todo(todo_id, delay_data, current_user.id)
|
||||
# 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()
|
||||
|
||||
# 🔄 캘린더 동기화 (날짜 수정)
|
||||
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:
|
||||
if not todo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
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.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)
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,7 +80,13 @@ async def health_check():
|
||||
}
|
||||
|
||||
|
||||
# 애플리케이션 시작 시 실행
|
||||
async def create_sample_data():
|
||||
"""샘플 데이터 생성 - 비활성화됨"""
|
||||
# 더미 데이터 생성 완전히 비활성화
|
||||
logger.info("샘플 데이터 생성이 비활성화되었습니다.")
|
||||
return
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""애플리케이션 시작 시 초기화"""
|
||||
@@ -68,6 +94,13 @@ async def startup_event():
|
||||
logger.info(f"📊 환경: {settings.ENVIRONMENT}")
|
||||
logger.info(f"🔗 데이터베이스: {settings.DATABASE_URL}")
|
||||
|
||||
# 데이터베이스 초기화
|
||||
from .core.database import init_db
|
||||
await init_db()
|
||||
|
||||
# 샘플 데이터 생성 비활성화
|
||||
logger.info("샘플 데이터 생성이 비활성화되었습니다.")
|
||||
|
||||
|
||||
# 애플리케이션 종료 시 실행
|
||||
@app.on_event("shutdown")
|
||||
|
||||
@@ -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"<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(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"<TodoComment(id={self.id}, content='{self.content[:30]}...')>"
|
||||
return f"<Todo(id={self.id}, title='{self.title}', status='{self.status}')>"
|
||||
|
||||
@@ -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"<User(email='{self.email}', full_name='{self.full_name}')>"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
34
database/init/01-init.sql
Normal file
34
database/init/01-init.sql
Normal file
@@ -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;
|
||||
@@ -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"]
|
||||
|
||||
@@ -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'
|
||||
async function loadCalendarItems() {
|
||||
try {
|
||||
// API에서 캘린더 카테고리 항목들만 가져오기
|
||||
const items = await TodoAPI.getTodos(null, 'calendar');
|
||||
renderCalendarItems(items);
|
||||
} catch (error) {
|
||||
console.error('캘린더 항목 로드 실패:', error);
|
||||
renderCalendarItems([]);
|
||||
}
|
||||
];
|
||||
|
||||
renderCalendarItems(calendarItems);
|
||||
}
|
||||
|
||||
// 캘린더 항목 렌더링
|
||||
@@ -245,7 +230,7 @@
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-gray-900 font-medium mb-2">${item.content}</h4>
|
||||
<h4 class="text-gray-900 font-medium mb-2">${item.title}</h4>
|
||||
<div class="flex items-center space-x-4 text-sm text-gray-500 mb-2">
|
||||
<span class="${getDueDateColor(item.due_date)}">
|
||||
<i class="fas fa-calendar-times mr-1"></i>마감: ${formatDate(item.due_date)}
|
||||
@@ -267,14 +252,14 @@
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="flex-shrink-0 flex space-x-2">
|
||||
${item.status !== 'completed' ? `
|
||||
<button onclick="completeCalendar(${item.id})" class="text-green-500 hover:text-green-700" title="완료하기">
|
||||
<button onclick="completeCalendar('${item.id}')" class="text-green-500 hover:text-green-700" title="완료하기">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<button onclick="extendDeadline(${item.id})" class="text-orange-500 hover:text-orange-700" title="기한 연장">
|
||||
<button onclick="extendDeadline('${item.id}')" class="text-orange-500 hover:text-orange-700" title="기한 연장">
|
||||
<i class="fas fa-calendar-plus"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button onclick="editCalendar(${item.id})" class="text-gray-400 hover:text-blue-500" title="수정하기">
|
||||
<button onclick="editCalendar('${item.id}')" class="text-gray-400 hover:text-blue-500" title="수정하기">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -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;
|
||||
</script>
|
||||
<script src="static/js/api.js?v=20250921110800"></script>
|
||||
<script src="static/js/auth.js?v=20250921110800"></script>
|
||||
</body>
|
||||
</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 @@
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- 체크박스 -->
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<div class="checkbox-custom ${item.completed ? 'checked' : ''}" onclick="toggleComplete(${item.id})">
|
||||
<div class="checkbox-custom ${item.completed ? 'checked' : ''}" onclick="toggleComplete('${item.id}')">
|
||||
${item.completed ? '<i class="fas fa-check text-xs"></i>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,7 +271,7 @@
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="item-content text-gray-900 font-medium mb-2">${item.content}</h4>
|
||||
<h4 class="item-content text-gray-900 font-medium mb-2">${item.title}</h4>
|
||||
<div class="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>
|
||||
<i class="fas fa-clock mr-1"></i>등록: ${formatDate(item.created_at)}
|
||||
@@ -305,10 +286,26 @@
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="flex-shrink-0 flex space-x-2">
|
||||
<button onclick="editChecklist(${item.id})" class="text-gray-400 hover:text-blue-500" title="수정하기">
|
||||
<!-- 카테고리 변경 버튼 -->
|
||||
<div class="relative">
|
||||
<button onclick="showCategoryMenu('${item.id}')" class="text-gray-400 hover:text-purple-500" title="카테고리 변경">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</button>
|
||||
<div id="categoryMenu-${item.id}" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border z-10">
|
||||
<div class="py-2">
|
||||
<button onclick="changeCategory('${item.id}', 'todo')" class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">
|
||||
<i class="fas fa-calendar-day mr-2 text-blue-500"></i>Todo로 변경
|
||||
</button>
|
||||
<button onclick="changeCategory('${item.id}', 'calendar')" class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-orange-50 hover:text-orange-600">
|
||||
<i class="fas fa-calendar-times mr-2 text-orange-500"></i>캘린더로 변경
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="editChecklist('${item.id}')" class="text-gray-400 hover:text-blue-500" title="수정하기">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button onclick="deleteChecklist(${item.id})" class="text-gray-400 hover:text-red-500" title="삭제하기">
|
||||
<button onclick="deleteChecklist('${item.id}')" class="text-gray-400 hover:text-red-500" title="삭제하기">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="static/js/api.js?v=20250921110800"></script>
|
||||
<script src="static/js/auth.js?v=20250921110800"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>분류 센터 - Todo Project</title>
|
||||
<title>INDEX - Todo Project</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
@@ -172,7 +172,7 @@
|
||||
<i class="fas fa-arrow-left text-xl"></i>
|
||||
</button>
|
||||
<i class="fas fa-inbox text-2xl text-purple-500 mr-3"></i>
|
||||
<h1 class="text-xl font-semibold text-gray-800">분류 센터</h1>
|
||||
<h1 class="text-xl font-semibold text-gray-800">INDEX</h1>
|
||||
<span class="ml-3 px-2 py-1 bg-red-100 text-red-800 text-sm rounded-full" id="pendingCount">0</span>
|
||||
</div>
|
||||
|
||||
@@ -288,7 +288,8 @@
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="static/js/auth.js"></script>
|
||||
<script src="static/js/api.js?v=2"></script>
|
||||
<script src="static/js/auth.js?v=2"></script>
|
||||
<script>
|
||||
let pendingItems = [];
|
||||
let selectedItems = [];
|
||||
@@ -303,53 +304,9 @@
|
||||
|
||||
// 분류 대기 항목 로드
|
||||
function loadPendingItems() {
|
||||
// 임시 데이터
|
||||
pendingItems = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'upload',
|
||||
content: '회의실 화이트보드 사진',
|
||||
photo: '/static/images/sample1.jpg',
|
||||
created_at: '2024-01-20T10:30:00Z',
|
||||
source: '직접 업로드',
|
||||
suggested: 'todo',
|
||||
confidence: 0.85,
|
||||
tags: ['업무', '회의', '계획']
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'mail',
|
||||
content: '긴급: 내일까지 월말 보고서 제출 요청',
|
||||
sender: 'manager@company.com',
|
||||
created_at: '2024-01-20T14:15:00Z',
|
||||
source: '시놀로지 메일플러스',
|
||||
suggested: 'calendar',
|
||||
confidence: 0.95,
|
||||
tags: ['긴급', '업무', '마감']
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'upload',
|
||||
content: '마트에서 살 것들 메모',
|
||||
photo: '/static/images/sample2.jpg',
|
||||
created_at: '2024-01-20T16:45:00Z',
|
||||
source: '직접 업로드',
|
||||
suggested: 'checklist',
|
||||
confidence: 0.90,
|
||||
tags: ['개인', '쇼핑', '생활']
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'mail',
|
||||
content: '프로젝트 킥오프 미팅 일정 조율',
|
||||
sender: 'team@company.com',
|
||||
created_at: '2024-01-20T09:20:00Z',
|
||||
source: '시놀로지 메일플러스',
|
||||
suggested: 'todo',
|
||||
confidence: 0.75,
|
||||
tags: ['업무', '미팅', '프로젝트']
|
||||
}
|
||||
];
|
||||
// 분류되지 않은 항목들을 API에서 가져와야 함
|
||||
// 현재는 빈 배열로 설정 (분류 기능 미구현)
|
||||
pendingItems = [];
|
||||
|
||||
renderItems();
|
||||
}
|
||||
@@ -435,7 +392,7 @@
|
||||
</span>
|
||||
<span class="ml-2 text-xs text-purple-600">(${Math.round(item.confidence * 100)}% 확신)</span>
|
||||
</div>
|
||||
<button onclick="acceptSuggestion(${item.id}, '${item.suggested}')"
|
||||
<button onclick="acceptSuggestion('${item.id}', '${item.suggested}')"
|
||||
class="text-xs bg-purple-600 text-white px-3 py-1 rounded-full hover:bg-purple-700">
|
||||
적용
|
||||
</button>
|
||||
@@ -447,15 +404,15 @@
|
||||
|
||||
<!-- 분류 버튼들 -->
|
||||
<div class="mt-6 flex flex-wrap gap-3 justify-center">
|
||||
<button onclick="classifyItem(${item.id}, 'todo')" class="classify-btn todo">
|
||||
<button onclick="classifyItem('${item.id}', 'todo')" class="classify-btn todo">
|
||||
<i class="fas fa-calendar-day mr-2"></i>Todo
|
||||
<div class="text-xs opacity-75">시작 날짜</div>
|
||||
</button>
|
||||
<button onclick="classifyItem(${item.id}, 'calendar')" class="classify-btn calendar">
|
||||
<button onclick="classifyItem('${item.id}', 'calendar')" class="classify-btn calendar">
|
||||
<i class="fas fa-calendar-times mr-2"></i>캘린더
|
||||
<div class="text-xs opacity-75">마감 기한</div>
|
||||
</button>
|
||||
<button onclick="classifyItem(${item.id}, 'checklist')" class="classify-btn checklist">
|
||||
<button onclick="classifyItem('${item.id}', 'checklist')" class="classify-btn checklist">
|
||||
<i class="fas fa-check-square mr-2"></i>체크리스트
|
||||
<div class="text-xs opacity-75">기한 없음</div>
|
||||
</button>
|
||||
@@ -596,7 +553,10 @@
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '날짜 없음';
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return '날짜 없음';
|
||||
|
||||
const now = new Date();
|
||||
const diffTime = now - date;
|
||||
const diffHours = Math.floor(diffTime / (1000 * 60 * 60));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -130,8 +130,26 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<button onclick="testLogin()" class="w-full bg-green-500 text-white py-2 px-4 rounded-lg hover:bg-green-600 transition-colors text-sm">
|
||||
🚀 테스트 로그인 (관리자)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-gray-500 text-center">
|
||||
<p>테스트 계정: user1 / password123</p>
|
||||
<p>관리자: hyungi / admin123</p>
|
||||
</div>
|
||||
|
||||
<!-- 이미 로그인된 상태 표시 -->
|
||||
<div id="alreadyLoggedIn" class="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg hidden">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-check-circle text-green-500 text-xl mb-2"></i>
|
||||
<p class="text-green-800 font-medium mb-2">이미 로그인되어 있습니다!</p>
|
||||
<button onclick="window.location.href='dashboard.html'"
|
||||
class="w-full bg-green-500 text-white py-2 px-4 rounded-lg hover:bg-green-600 transition-colors">
|
||||
대시보드로 이동
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,8 +167,8 @@
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<button onclick="goToClassify()" class="text-purple-600 hover:text-purple-800 font-medium">
|
||||
<i class="fas fa-inbox mr-1"></i>분류 센터
|
||||
<span class="ml-1 px-2 py-1 bg-red-100 text-red-800 text-xs rounded-full">3</span>
|
||||
<i class="fas fa-list-ul mr-1"></i>INDEX
|
||||
<span class="ml-1 px-2 py-1 bg-red-100 text-red-800 text-xs rounded-full" id="indexCount">0</span>
|
||||
</button>
|
||||
<button onclick="goToDashboard()" class="text-blue-600 hover:text-blue-800 font-medium">
|
||||
<i class="fas fa-chart-line mr-1"></i>대시보드
|
||||
@@ -182,13 +200,13 @@
|
||||
<div class="bg-white rounded-xl shadow-sm">
|
||||
<div class="p-6 border-b">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">
|
||||
<i class="fas fa-list text-blue-500 mr-2"></i>등록된 항목들
|
||||
<i class="fas fa-list text-blue-500 mr-2"></i>Todo 목록
|
||||
</h2>
|
||||
|
||||
<!-- 분류 안내 -->
|
||||
<div class="bg-blue-50 rounded-lg p-4 mb-4">
|
||||
<p class="text-sm text-blue-800 mb-2">
|
||||
<i class="fas fa-info-circle mr-2"></i>등록된 항목을 클릭하여 3가지 방법으로 분류하세요:
|
||||
<i class="fas fa-info-circle mr-2"></i>Todo 항목을 클릭하여 다른 카테고리로 변경하거나 내용을 수정하세요:
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm">
|
||||
<div class="flex items-center text-blue-700">
|
||||
@@ -213,7 +231,7 @@
|
||||
|
||||
<div id="emptyState" class="p-12 text-center text-gray-500">
|
||||
<i class="fas fa-inbox text-4xl mb-4 opacity-50"></i>
|
||||
<p>아직 등록된 항목이 없습니다.</p>
|
||||
<p>아직 Todo 항목이 없습니다.</p>
|
||||
<p class="text-sm">위에서 새로운 항목을 등록해보세요!</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,9 +292,87 @@
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="static/js/image-utils.js"></script>
|
||||
<script src="static/js/api.js"></script>
|
||||
<script src="static/js/todos.js"></script>
|
||||
<script src="static/js/auth.js"></script>
|
||||
<script>
|
||||
// 토큰 상태 확인
|
||||
console.log('=== 토큰 상태 확인 ===');
|
||||
const existingToken = localStorage.getItem('authToken');
|
||||
const existingUser = localStorage.getItem('currentUser');
|
||||
|
||||
console.log('기존 토큰 존재:', existingToken ? '있음' : '없음');
|
||||
console.log('기존 사용자 정보:', existingUser);
|
||||
|
||||
// 토큰이 있으면 대시보드로 리다이렉트 (무한 루프 방지)
|
||||
if (existingToken && existingUser) {
|
||||
console.log('유효한 토큰이 있습니다. 대시보드로 이동합니다.');
|
||||
window.location.href = 'dashboard.html';
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="static/js/api.js?v=20250921110800"></script>
|
||||
<script src="static/js/image-utils.js?v=20250921110800"></script>
|
||||
<script src="static/js/todos.js?v=20250921110800"></script>
|
||||
<script src="static/js/auth.js?v=20250921110800"></script>
|
||||
<script>
|
||||
// 페이지 로드 시 디버깅 정보
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('=== 인덱스 페이지 로드 완료 ===');
|
||||
|
||||
console.log('AuthAPI 존재:', typeof AuthAPI !== 'undefined');
|
||||
console.log('window.currentUser:', window.currentUser);
|
||||
|
||||
// 로그인 폼 이벤트 리스너 수동 추가 (백업)
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
if (loginForm && !loginForm.hasAttribute('data-listener-added')) {
|
||||
loginForm.setAttribute('data-listener-added', 'true');
|
||||
loginForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
console.log('수동 로그인 폼 제출 처리');
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!username || !password) {
|
||||
alert('사용자명과 비밀번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('로그인 시도:', username);
|
||||
const result = await AuthAPI.login(username, password);
|
||||
console.log('로그인 성공:', result);
|
||||
window.location.href = 'dashboard.html';
|
||||
} catch (error) {
|
||||
console.error('로그인 실패:', error);
|
||||
alert('로그인 실패: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 테스트 로그인 함수
|
||||
async function testLogin() {
|
||||
try {
|
||||
console.log('테스트 로그인 시작...');
|
||||
const result = await AuthAPI.login('hyungi', 'admin123');
|
||||
console.log('로그인 성공:', result);
|
||||
|
||||
// 토큰 확인
|
||||
const token = localStorage.getItem('authToken');
|
||||
console.log('저장된 토큰:', token ? '있음' : '없음');
|
||||
|
||||
// 사용자 정보 확인
|
||||
const user = localStorage.getItem('currentUser');
|
||||
console.log('저장된 사용자 정보:', user);
|
||||
|
||||
// 대시보드로 이동
|
||||
setTimeout(() => {
|
||||
window.location.href = 'dashboard.html';
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('테스트 로그인 실패:', error);
|
||||
alert('로그인 실패: ' + error.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -22,6 +22,9 @@ class ApiClient {
|
||||
// 인증 토큰 추가
|
||||
if (this.token) {
|
||||
config.headers['Authorization'] = `Bearer ${this.token}`;
|
||||
console.log('API 요청에 토큰 포함:', this.token.substring(0, 20) + '...');
|
||||
} else {
|
||||
console.warn('API 요청에 토큰이 없습니다!');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -30,7 +33,14 @@ class ApiClient {
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
// 토큰 만료 시 로그아웃
|
||||
this.logout();
|
||||
console.error('인증 실패 - 토큰 제거 후 로그인 페이지로 이동');
|
||||
// 토큰만 제거하고 페이지 리로드는 하지 않음
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('currentUser');
|
||||
// 무한 루프 방지: 이미 index.html이 아닌 경우만 리다이렉트
|
||||
if (!window.location.pathname.endsWith('index.html') && window.location.pathname !== '/') {
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
throw new Error('인증이 만료되었습니다. 다시 로그인해주세요.');
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
@@ -49,11 +59,15 @@ class ApiClient {
|
||||
|
||||
// GET 요청
|
||||
async get(endpoint) {
|
||||
// 토큰 재로드 (로그인 후 토큰이 업데이트된 경우)
|
||||
this.token = localStorage.getItem('authToken');
|
||||
return this.request(endpoint, { method: 'GET' });
|
||||
}
|
||||
|
||||
// POST 요청
|
||||
async post(endpoint, data) {
|
||||
// 토큰 재로드
|
||||
this.token = localStorage.getItem('authToken');
|
||||
return this.request(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
@@ -62,6 +76,8 @@ class ApiClient {
|
||||
|
||||
// PUT 요청
|
||||
async put(endpoint, data) {
|
||||
// 토큰 재로드
|
||||
this.token = localStorage.getItem('authToken');
|
||||
return this.request(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
@@ -70,11 +86,15 @@ class ApiClient {
|
||||
|
||||
// DELETE 요청
|
||||
async delete(endpoint) {
|
||||
// 토큰 재로드
|
||||
this.token = localStorage.getItem('authToken');
|
||||
return this.request(endpoint, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// 파일 업로드
|
||||
async uploadFile(endpoint, formData) {
|
||||
// 토큰 재로드
|
||||
this.token = localStorage.getItem('authToken');
|
||||
return this.request(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -112,7 +132,9 @@ const AuthAPI = {
|
||||
|
||||
if (response.access_token) {
|
||||
api.setToken(response.access_token);
|
||||
localStorage.setItem('currentUser', JSON.stringify(response.user));
|
||||
// 사용자 정보가 있으면 저장, 없으면 기본값 사용
|
||||
const user = response.user || { username: 'hyungi', full_name: 'Administrator' };
|
||||
localStorage.setItem('currentUser', JSON.stringify(user));
|
||||
}
|
||||
|
||||
return response;
|
||||
@@ -135,21 +157,42 @@ const AuthAPI = {
|
||||
|
||||
// Todo 관련 API
|
||||
const TodoAPI = {
|
||||
async getTodos(filter = 'all') {
|
||||
const params = filter !== 'all' ? `?status=${filter}` : '';
|
||||
return api.get(`/todos${params}`);
|
||||
async getTodos(status = null, category = null) {
|
||||
let url = '/todos/';
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (status && status !== 'all') params.append('status', status);
|
||||
if (category && category !== 'all') params.append('category', category);
|
||||
|
||||
if (params.toString()) {
|
||||
url += '?' + params.toString();
|
||||
}
|
||||
|
||||
return api.get(url);
|
||||
},
|
||||
|
||||
async createTodo(todoData) {
|
||||
return api.post('/todos', todoData);
|
||||
return api.post('/todos/', todoData);
|
||||
},
|
||||
|
||||
async updateTodo(id, todoData) {
|
||||
return api.put(`/todos/${id}`, todoData);
|
||||
return api.put(`/todos/${id}/`, todoData);
|
||||
},
|
||||
|
||||
async deleteTodo(id) {
|
||||
return api.delete(`/todos/${id}`);
|
||||
return api.delete(`/todos/${id}/`);
|
||||
},
|
||||
|
||||
async completeTodo(id) {
|
||||
return api.put(`/todos/${id}/`, { status: 'completed' });
|
||||
},
|
||||
|
||||
async getTodayTodos() {
|
||||
return api.get('/calendar/today/');
|
||||
},
|
||||
|
||||
async getTodoById(id) {
|
||||
return api.get(`/todos/${id}/`);
|
||||
},
|
||||
|
||||
async uploadImage(imageFile) {
|
||||
|
||||
@@ -2,26 +2,60 @@
|
||||
* 인증 관리
|
||||
*/
|
||||
|
||||
let currentUser = null;
|
||||
// 전역 변수로 설정 (중복 선언 방지)
|
||||
if (typeof window.currentUser === 'undefined') {
|
||||
window.currentUser = null;
|
||||
}
|
||||
|
||||
// 페이지 로드 시 인증 상태 확인
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 페이지 로드 시 인증 상태 확인 (중복 실행 방지)
|
||||
if (!window.authInitialized) {
|
||||
window.authInitialized = true;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// dashboard.html에서는 자체적으로 인증 처리하므로 건너뜀
|
||||
const isDashboardPage = window.location.pathname.endsWith('dashboard.html');
|
||||
|
||||
if (!isDashboardPage) {
|
||||
checkAuthStatus();
|
||||
setupLoginForm();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 인증 상태 확인
|
||||
function checkAuthStatus() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const userData = localStorage.getItem('currentUser');
|
||||
|
||||
if (token && userData) {
|
||||
// index.html에서는 토큰이 있으면 대시보드로 리다이렉트 (이미 위에서 처리됨)
|
||||
const isIndexPage = window.location.pathname.endsWith('index.html') || window.location.pathname === '/';
|
||||
|
||||
if (token && isIndexPage) {
|
||||
// 이미 index.html에서 리다이렉트 처리했으므로 여기서는 showAlreadyLoggedIn만 호출
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', showAlreadyLoggedIn);
|
||||
} else {
|
||||
showAlreadyLoggedIn();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (token && !isIndexPage) {
|
||||
try {
|
||||
currentUser = JSON.parse(userData);
|
||||
if (userData) {
|
||||
window.currentUser = JSON.parse(userData);
|
||||
} else {
|
||||
// 사용자 데이터가 없으면 기본값 사용
|
||||
window.currentUser = { username: 'hyungi', full_name: 'Administrator' };
|
||||
localStorage.setItem('currentUser', JSON.stringify(window.currentUser));
|
||||
}
|
||||
showMainApp();
|
||||
} catch (error) {
|
||||
console.error('사용자 데이터 파싱 실패:', error);
|
||||
logout();
|
||||
// 파싱 실패 시 기본값으로 재시도
|
||||
window.currentUser = { username: 'hyungi', full_name: 'Administrator' };
|
||||
localStorage.setItem('currentUser', JSON.stringify(window.currentUser));
|
||||
showMainApp();
|
||||
}
|
||||
} else {
|
||||
showLoginScreen();
|
||||
@@ -51,30 +85,12 @@ async function handleLogin(event) {
|
||||
try {
|
||||
showLoading(true);
|
||||
|
||||
// 임시 로그인 (백엔드 구현 전까지)
|
||||
if (username === 'user1' && password === 'password123') {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'user1',
|
||||
email: 'user1@todo-project.local',
|
||||
full_name: '사용자1'
|
||||
};
|
||||
|
||||
currentUser = mockUser;
|
||||
localStorage.setItem('authToken', 'mock-token-' + Date.now());
|
||||
localStorage.setItem('currentUser', JSON.stringify(mockUser));
|
||||
|
||||
showMainApp();
|
||||
} else {
|
||||
throw new Error('잘못된 사용자명 또는 비밀번호입니다.');
|
||||
}
|
||||
|
||||
// 실제 API 호출 (백엔드 구현 후 사용)
|
||||
/*
|
||||
// 실제 API 호출
|
||||
const response = await AuthAPI.login(username, password);
|
||||
currentUser = response.user;
|
||||
showMainApp();
|
||||
*/
|
||||
window.currentUser = response.user;
|
||||
|
||||
// 대시보드로 리다이렉트
|
||||
window.location.href = 'dashboard.html';
|
||||
|
||||
} catch (error) {
|
||||
console.error('로그인 실패:', error);
|
||||
@@ -94,8 +110,25 @@ function logout() {
|
||||
|
||||
// 로그인 화면 표시
|
||||
function showLoginScreen() {
|
||||
document.getElementById('loginScreen').classList.remove('hidden');
|
||||
document.getElementById('mainApp').classList.add('hidden');
|
||||
const loginScreen = document.getElementById('loginScreen');
|
||||
const mainApp = document.getElementById('mainApp');
|
||||
const alreadyLoggedIn = document.getElementById('alreadyLoggedIn');
|
||||
|
||||
if (loginScreen) {
|
||||
loginScreen.classList.remove('hidden');
|
||||
} else {
|
||||
// dashboard.html에서는 로그인 페이지로 리다이렉트
|
||||
window.location.href = 'index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainApp) {
|
||||
mainApp.classList.add('hidden');
|
||||
}
|
||||
|
||||
if (alreadyLoggedIn) {
|
||||
alreadyLoggedIn.classList.add('hidden');
|
||||
}
|
||||
|
||||
// 폼 초기화
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
@@ -104,15 +137,54 @@ function showLoginScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// 이미 로그인됨 표시
|
||||
function showAlreadyLoggedIn() {
|
||||
const loginScreen = document.getElementById('loginScreen');
|
||||
const alreadyLoggedIn = document.getElementById('alreadyLoggedIn');
|
||||
|
||||
if (loginScreen) {
|
||||
loginScreen.classList.remove('hidden');
|
||||
}
|
||||
|
||||
if (alreadyLoggedIn) {
|
||||
alreadyLoggedIn.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 로그인 폼 숨기기
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
const testLoginSection = loginForm?.parentElement?.querySelector('.mt-4');
|
||||
|
||||
if (loginForm) {
|
||||
loginForm.style.display = 'none';
|
||||
}
|
||||
if (testLoginSection) {
|
||||
testLoginSection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 메인 앱 표시
|
||||
function showMainApp() {
|
||||
document.getElementById('loginScreen').classList.add('hidden');
|
||||
document.getElementById('mainApp').classList.remove('hidden');
|
||||
const loginScreen = document.getElementById('loginScreen');
|
||||
const mainApp = document.getElementById('mainApp');
|
||||
|
||||
// index.html에서는 대시보드로 리다이렉트
|
||||
if (!mainApp && loginScreen) {
|
||||
window.location.href = 'dashboard.html';
|
||||
return;
|
||||
}
|
||||
|
||||
if (loginScreen) {
|
||||
loginScreen.classList.add('hidden');
|
||||
}
|
||||
|
||||
if (mainApp) {
|
||||
mainApp.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 사용자 정보 표시
|
||||
const currentUserElement = document.getElementById('currentUser');
|
||||
if (currentUserElement && currentUser) {
|
||||
currentUserElement.textContent = currentUser.full_name || currentUser.username;
|
||||
if (currentUserElement && window.currentUser) {
|
||||
currentUserElement.textContent = window.currentUser.full_name || window.currentUser.username;
|
||||
}
|
||||
|
||||
// Todo 목록 로드
|
||||
|
||||
@@ -66,7 +66,7 @@ async function handleTodoSubmit(event) {
|
||||
const newTodo = {
|
||||
id: Date.now(),
|
||||
...todoData,
|
||||
user_id: currentUser?.id || 1
|
||||
user_id: window.currentUser?.id || 1
|
||||
};
|
||||
|
||||
todos.unshift(newTodo);
|
||||
@@ -181,32 +181,8 @@ function clearForm() {
|
||||
// Todo 목록 로드
|
||||
async function loadTodos() {
|
||||
try {
|
||||
// 임시 데이터 (백엔드 구현 전까지)
|
||||
if (todos.length === 0) {
|
||||
todos = [
|
||||
{
|
||||
id: 1,
|
||||
content: '프로젝트 문서 검토',
|
||||
status: 'active',
|
||||
photo: null,
|
||||
created_at: new Date(Date.now() - 86400000).toISOString(),
|
||||
user_id: 1
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content: '회의 준비',
|
||||
status: 'completed',
|
||||
photo: null,
|
||||
created_at: new Date(Date.now() - 172800000).toISOString(),
|
||||
user_id: 1
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// 실제 API 호출 (백엔드 구현 후 사용)
|
||||
/*
|
||||
// 실제 API 호출
|
||||
todos = await TodoAPI.getTodos(currentFilter);
|
||||
*/
|
||||
|
||||
renderTodos();
|
||||
|
||||
@@ -245,7 +221,7 @@ function renderTodos() {
|
||||
<div class="todo-item p-4 hover:bg-gray-50 transition-colors">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- 체크박스 -->
|
||||
<button onclick="toggleTodo(${todo.id})" class="mt-1 flex-shrink-0">
|
||||
<button onclick="toggleTodo('${todo.id}')" class="mt-1 flex-shrink-0">
|
||||
<i class="fas ${todo.status === 'completed' ? 'fa-check-circle text-green-500' : 'fa-circle text-gray-300'} text-lg"></i>
|
||||
</button>
|
||||
|
||||
@@ -270,11 +246,11 @@ function renderTodos() {
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="flex-shrink-0 flex space-x-2">
|
||||
${todo.status !== 'completed' ? `
|
||||
<button onclick="editTodo(${todo.id})" class="text-gray-400 hover:text-blue-500">
|
||||
<button onclick="editTodo('${todo.id}')" class="text-gray-400 hover:text-blue-500">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button onclick="deleteTodo(${todo.id})" class="text-gray-400 hover:text-red-500">
|
||||
<button onclick="deleteTodo('${todo.id}')" class="text-gray-400 hover:text-red-500">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -378,7 +354,10 @@ function getStatusText(status) {
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '날짜 없음';
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return '날짜 없음';
|
||||
|
||||
const now = new Date();
|
||||
const diffTime = now - date;
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
@@ -426,46 +405,69 @@ function goToClassify() {
|
||||
}
|
||||
|
||||
// 항목 등록 후 인덱스 업데이트
|
||||
function updateItemCounts() {
|
||||
// TODO: API에서 각 분류별 항목 수를 가져와서 업데이트
|
||||
// 임시로 하드코딩된 값 사용
|
||||
const todoCount = document.getElementById('todoCount');
|
||||
const calendarCount = document.getElementById('calendarCount');
|
||||
const checklistCount = document.getElementById('checklistCount');
|
||||
async function updateItemCounts() {
|
||||
try {
|
||||
// 무한 로딩 방지: 토큰이 없으면 API 요청하지 않음
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
console.log('토큰이 없어서 카운트 업데이트를 건너뜁니다.');
|
||||
// 토큰이 없으면 0개로 표시
|
||||
const todoCountEl = document.getElementById('todoCount');
|
||||
const calendarCountEl = document.getElementById('calendarCount');
|
||||
const checklistCountEl = document.getElementById('checklistCount');
|
||||
|
||||
if (todoCount) todoCount.textContent = '2개';
|
||||
if (calendarCount) calendarCount.textContent = '3개';
|
||||
if (checklistCount) checklistCount.textContent = '5개';
|
||||
if (todoCountEl) todoCountEl.textContent = '0개';
|
||||
if (calendarCountEl) calendarCountEl.textContent = '0개';
|
||||
if (checklistCountEl) checklistCountEl.textContent = '0개';
|
||||
return;
|
||||
}
|
||||
|
||||
// API에서 실제 데이터 가져와서 카운트
|
||||
const items = await TodoAPI.getTodos();
|
||||
|
||||
const todoCount = items.filter(item => item.category === 'todo').length;
|
||||
const calendarCount = items.filter(item => item.category === 'calendar').length;
|
||||
const checklistCount = items.filter(item => item.category === 'checklist').length;
|
||||
|
||||
const todoCountEl = document.getElementById('todoCount');
|
||||
const calendarCountEl = document.getElementById('calendarCount');
|
||||
const checklistCountEl = document.getElementById('checklistCount');
|
||||
|
||||
if (todoCountEl) todoCountEl.textContent = `${todoCount}개`;
|
||||
if (calendarCountEl) calendarCountEl.textContent = `${calendarCount}개`;
|
||||
if (checklistCountEl) checklistCountEl.textContent = `${checklistCount}개`;
|
||||
} catch (error) {
|
||||
console.error('항목 카운트 업데이트 실패:', error);
|
||||
// 에러 시 0개로 표시
|
||||
const todoCountEl = document.getElementById('todoCount');
|
||||
const calendarCountEl = document.getElementById('calendarCount');
|
||||
const checklistCountEl = document.getElementById('checklistCount');
|
||||
|
||||
if (todoCountEl) todoCountEl.textContent = '0개';
|
||||
if (calendarCountEl) calendarCountEl.textContent = '0개';
|
||||
if (checklistCountEl) checklistCountEl.textContent = '0개';
|
||||
}
|
||||
}
|
||||
|
||||
// 등록된 항목들 로드
|
||||
function loadRegisteredItems() {
|
||||
// 임시 데이터 (실제로는 API에서 가져옴)
|
||||
const sampleItems = [
|
||||
{
|
||||
id: 1,
|
||||
content: '프로젝트 문서 정리',
|
||||
photo_url: null,
|
||||
category: null,
|
||||
created_at: '2024-01-15'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content: '회의 자료 준비',
|
||||
photo_url: null,
|
||||
category: 'todo',
|
||||
created_at: '2024-01-16'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
content: '월말 보고서 작성',
|
||||
photo_url: null,
|
||||
category: 'calendar',
|
||||
created_at: '2024-01-17'
|
||||
// 등록된 항목들 로드 (카테고리가 없는 미분류 항목들)
|
||||
async function loadRegisteredItems() {
|
||||
try {
|
||||
// 무한 로딩 방지: 토큰이 없으면 API 요청하지 않음
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
console.log('토큰이 없어서 API 요청을 건너뜁니다.');
|
||||
renderRegisteredItems([]);
|
||||
return;
|
||||
}
|
||||
];
|
||||
|
||||
renderRegisteredItems(sampleItems);
|
||||
// API에서 모든 항목을 가져와서 카테고리가 없는 것만 필터링
|
||||
const allItems = await TodoAPI.getTodos();
|
||||
const unclassifiedItems = allItems.filter(item => !item.category || item.category === null);
|
||||
renderRegisteredItems(unclassifiedItems);
|
||||
} catch (error) {
|
||||
console.error('등록된 항목 로드 실패:', error);
|
||||
renderRegisteredItems([]);
|
||||
}
|
||||
}
|
||||
|
||||
// 등록된 항목들 렌더링
|
||||
@@ -484,18 +486,18 @@ function renderRegisteredItems(items) {
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
itemsList.innerHTML = items.map(item => `
|
||||
<div class="p-6 hover:bg-gray-50 cursor-pointer transition-colors" onclick="showClassificationModal(${item.id})">
|
||||
<div class="p-6 hover:bg-gray-50 cursor-pointer transition-colors" onclick="showClassificationModal('${item.id}')">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- 사진 (있는 경우) -->
|
||||
${item.photo_url ? `
|
||||
${item.image_url ? `
|
||||
<div class="flex-shrink-0">
|
||||
<img src="${item.photo_url}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
|
||||
<img src="${item.image_url}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-gray-900 font-medium mb-2">${item.content}</h4>
|
||||
<h4 class="text-gray-900 font-medium mb-2">${item.title}</h4>
|
||||
<div class="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>
|
||||
<i class="fas fa-clock mr-1"></i>등록: ${formatDate(item.created_at)}
|
||||
@@ -523,32 +525,229 @@ function renderRegisteredItems(items) {
|
||||
|
||||
// 분류 모달 표시
|
||||
function showClassificationModal(itemId) {
|
||||
// TODO: 분류 선택 모달 구현
|
||||
console.log('분류 모달 표시:', itemId);
|
||||
|
||||
// 임시로 confirm으로 분류 선택
|
||||
const choice = prompt('분류를 선택하세요:\n1. Todo (시작 날짜)\n2. 캘린더 (마감 기한)\n3. 체크리스트 (기한 없음)\n\n번호를 입력하세요:');
|
||||
// 기존 항목 정보 가져오기
|
||||
const item = todos.find(t => t.id == itemId) || { title: '', description: '' };
|
||||
|
||||
if (choice) {
|
||||
const categories = {
|
||||
'1': 'todo',
|
||||
'2': 'calendar',
|
||||
'3': 'checklist'
|
||||
// 모달 HTML 생성
|
||||
const modalHtml = `
|
||||
<div id="classificationModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<h3 class="text-lg font-semibold mb-4">항목 분류 및 편집</h3>
|
||||
|
||||
<!-- 제목 입력 -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">제목</label>
|
||||
<input type="text" id="itemTitle" value="${item.title || ''}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="할 일을 입력하세요">
|
||||
</div>
|
||||
|
||||
<!-- 설명 입력 -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">설명 (선택사항)</label>
|
||||
<textarea id="itemDescription" rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="상세 설명을 입력하세요">${item.description || ''}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- 카테고리 선택 -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">분류 선택</label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button type="button" onclick="selectCategory('todo')"
|
||||
class="category-btn p-3 border-2 border-gray-200 rounded-lg text-center hover:border-blue-500 transition-colors"
|
||||
data-category="todo">
|
||||
<i class="fas fa-calendar-day text-blue-500 text-xl mb-1"></i>
|
||||
<div class="text-sm font-medium">Todo</div>
|
||||
<div class="text-xs text-gray-500">시작 날짜</div>
|
||||
</button>
|
||||
<button type="button" onclick="selectCategory('calendar')"
|
||||
class="category-btn p-3 border-2 border-gray-200 rounded-lg text-center hover:border-orange-500 transition-colors"
|
||||
data-category="calendar">
|
||||
<i class="fas fa-calendar-times text-orange-500 text-xl mb-1"></i>
|
||||
<div class="text-sm font-medium">캘린더</div>
|
||||
<div class="text-xs text-gray-500">마감 기한</div>
|
||||
</button>
|
||||
<button type="button" onclick="selectCategory('checklist')"
|
||||
class="category-btn p-3 border-2 border-gray-200 rounded-lg text-center hover:border-green-500 transition-colors"
|
||||
data-category="checklist">
|
||||
<i class="fas fa-check-square text-green-500 text-xl mb-1"></i>
|
||||
<div class="text-sm font-medium">체크리스트</div>
|
||||
<div class="text-xs text-gray-500">기한 없음</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 날짜 선택 (카테고리에 따라 표시) -->
|
||||
<div id="dateSection" class="mb-4 hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2" id="dateLabel">날짜 선택</label>
|
||||
<input type="date" id="itemDate"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- 우선순위 선택 -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">우선순위</label>
|
||||
<select id="itemPriority" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="low">낮음</option>
|
||||
<option value="medium" selected>보통</option>
|
||||
<option value="high">높음</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex space-x-3">
|
||||
<button onclick="closeClassificationModal()"
|
||||
class="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">
|
||||
취소
|
||||
</button>
|
||||
<button onclick="saveClassification('${itemId}')"
|
||||
class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 모달을 body에 추가
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// 현재 선택된 카테고리 변수 (기본값: todo)
|
||||
window.selectedCategory = 'todo';
|
||||
|
||||
// 기본적으로 todo 카테고리 선택 상태로 표시
|
||||
setTimeout(() => {
|
||||
selectCategory('todo');
|
||||
}, 100);
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
const modal = document.getElementById('classificationModal');
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
closeClassificationModal();
|
||||
}
|
||||
});
|
||||
|
||||
// ESC 키로 모달 닫기
|
||||
const handleEscKey = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeClassificationModal();
|
||||
document.removeEventListener('keydown', handleEscKey);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEscKey);
|
||||
}
|
||||
|
||||
// 카테고리 선택
|
||||
function selectCategory(category) {
|
||||
// 이전 선택 해제
|
||||
document.querySelectorAll('.category-btn').forEach(btn => {
|
||||
btn.classList.remove('border-blue-500', 'border-orange-500', 'border-green-500', 'bg-blue-50', 'bg-orange-50', 'bg-green-50');
|
||||
btn.classList.add('border-gray-200');
|
||||
});
|
||||
|
||||
// 새 선택 적용
|
||||
const selectedBtn = document.querySelector(`[data-category="${category}"]`);
|
||||
if (selectedBtn) {
|
||||
selectedBtn.classList.remove('border-gray-200');
|
||||
if (category === 'todo') {
|
||||
selectedBtn.classList.add('border-blue-500', 'bg-blue-50');
|
||||
} else if (category === 'calendar') {
|
||||
selectedBtn.classList.add('border-orange-500', 'bg-orange-50');
|
||||
} else if (category === 'checklist') {
|
||||
selectedBtn.classList.add('border-green-500', 'bg-green-50');
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 섹션 표시/숨김
|
||||
const dateSection = document.getElementById('dateSection');
|
||||
const dateLabel = document.getElementById('dateLabel');
|
||||
|
||||
if (category === 'checklist') {
|
||||
dateSection.classList.add('hidden');
|
||||
} else {
|
||||
dateSection.classList.remove('hidden');
|
||||
if (category === 'todo') {
|
||||
dateLabel.textContent = '시작 날짜';
|
||||
} else if (category === 'calendar') {
|
||||
dateLabel.textContent = '마감 날짜';
|
||||
}
|
||||
}
|
||||
|
||||
window.selectedCategory = category;
|
||||
}
|
||||
|
||||
// 분류 모달 닫기
|
||||
function closeClassificationModal() {
|
||||
const modal = document.getElementById('classificationModal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
window.selectedCategory = null;
|
||||
}
|
||||
|
||||
// 분류 저장
|
||||
async function saveClassification(itemId) {
|
||||
const title = document.getElementById('itemTitle').value.trim();
|
||||
const description = document.getElementById('itemDescription').value.trim();
|
||||
const priority = document.getElementById('itemPriority').value;
|
||||
const date = document.getElementById('itemDate').value;
|
||||
|
||||
if (!title) {
|
||||
alert('제목을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.selectedCategory) {
|
||||
alert('분류를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// API 호출하여 항목 업데이트
|
||||
const updateData = {
|
||||
title: title,
|
||||
description: description,
|
||||
category: window.selectedCategory,
|
||||
priority: priority
|
||||
};
|
||||
|
||||
const category = categories[choice];
|
||||
if (category) {
|
||||
classifyItem(itemId, category);
|
||||
// 날짜 설정 (체크리스트가 아닌 경우)
|
||||
if (window.selectedCategory !== 'checklist' && date) {
|
||||
updateData.due_date = date + 'T09:00:00Z'; // 기본 시간 설정
|
||||
}
|
||||
|
||||
await TodoAPI.updateTodo(itemId, updateData);
|
||||
|
||||
// 성공 메시지
|
||||
showToast(`항목이 ${getCategoryText(window.selectedCategory)}(으)로 분류되었습니다.`, 'success');
|
||||
|
||||
// 모달 닫기
|
||||
closeClassificationModal();
|
||||
|
||||
// todo가 아닌 다른 카테고리로 변경한 경우에만 페이지 이동
|
||||
if (window.selectedCategory !== 'todo') {
|
||||
setTimeout(() => {
|
||||
goToPage(window.selectedCategory);
|
||||
}, 1000); // 토스트 메시지를 보여준 후 이동
|
||||
} else {
|
||||
// todo 카테고리인 경우 인덱스 페이지에서 목록만 새로고침
|
||||
loadRegisteredItems();
|
||||
updateItemCounts();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('분류 저장 실패:', error);
|
||||
alert('분류 저장에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 항목 분류
|
||||
// 항목 분류 (기존 함수 - 호환성 유지)
|
||||
function classifyItem(itemId, category) {
|
||||
// TODO: API 호출하여 항목 분류 업데이트
|
||||
console.log('항목 분류:', itemId, category);
|
||||
|
||||
// 분류 후 해당 페이지로 이동
|
||||
goToPage(category);
|
||||
}
|
||||
|
||||
|
||||
@@ -162,28 +162,15 @@
|
||||
}
|
||||
|
||||
// Todo 항목 로드
|
||||
function loadTodoItems() {
|
||||
// 임시 데이터 (실제로는 API에서 가져옴)
|
||||
const todoItems = [
|
||||
{
|
||||
id: 1,
|
||||
content: '프로젝트 기획서 작성',
|
||||
photo: null,
|
||||
start_date: '2024-01-20',
|
||||
status: 'scheduled',
|
||||
created_at: '2024-01-15'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content: '팀 미팅 준비',
|
||||
photo: null,
|
||||
start_date: '2024-01-18',
|
||||
status: 'active',
|
||||
created_at: '2024-01-16'
|
||||
async function loadTodoItems() {
|
||||
try {
|
||||
// API에서 Todo 카테고리 항목들만 가져오기
|
||||
const items = await TodoAPI.getTodos(null, 'todo');
|
||||
renderTodoItems(items);
|
||||
} catch (error) {
|
||||
console.error('Todo 항목 로드 실패:', error);
|
||||
renderTodoItems([]);
|
||||
}
|
||||
];
|
||||
|
||||
renderTodoItems(todoItems);
|
||||
}
|
||||
|
||||
// Todo 항목 렌더링
|
||||
@@ -218,10 +205,10 @@
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-gray-900 font-medium mb-2">${item.content}</h4>
|
||||
<h4 class="text-gray-900 font-medium mb-2">${item.title}</h4>
|
||||
<div class="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>
|
||||
<i class="fas fa-calendar mr-1"></i>시작일: ${formatDate(item.start_date)}
|
||||
<i class="fas fa-calendar mr-1"></i>마감일: ${formatDate(item.due_date)}
|
||||
</span>
|
||||
<span>
|
||||
<i class="fas fa-clock mr-1"></i>등록: ${formatDate(item.created_at)}
|
||||
@@ -232,14 +219,17 @@
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="flex-shrink-0 flex space-x-2">
|
||||
${item.status !== 'completed' ? `
|
||||
<button onclick="startTodo(${item.id})" class="text-blue-500 hover:text-blue-700" title="시작하기">
|
||||
<i class="fas fa-play"></i>
|
||||
<button onclick="delayTodo('${item.id}')" class="text-orange-500 hover:text-orange-700" title="지연하기">
|
||||
<i class="fas fa-clock"></i>
|
||||
</button>
|
||||
<button onclick="completeTodo(${item.id})" class="text-green-500 hover:text-green-700" title="완료하기">
|
||||
<button onclick="completeTodo('${item.id}')" class="text-green-500 hover:text-green-700" title="완료하기">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<button onclick="splitTodo('${item.id}')" class="text-purple-500 hover:text-purple-700" title="분할하기">
|
||||
<i class="fas fa-cut"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button onclick="editTodo(${item.id})" class="text-gray-400 hover:text-blue-500" title="수정하기">
|
||||
<button onclick="editTodo('${item.id}')" class="text-gray-400 hover:text-blue-500" title="수정하기">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -270,26 +260,68 @@
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '날짜 없음';
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return '날짜 없음';
|
||||
return date.toLocaleDateString('ko-KR');
|
||||
}
|
||||
|
||||
// Todo 시작
|
||||
function startTodo(id) {
|
||||
console.log('Todo 시작:', id);
|
||||
// TODO: API 호출하여 상태를 'active'로 변경
|
||||
// Todo 지연
|
||||
function delayTodo(id) {
|
||||
const newDate = prompt('새로운 시작 날짜를 입력하세요 (YYYY-MM-DD):');
|
||||
if (newDate && /^\d{4}-\d{2}-\d{2}$/.test(newDate)) {
|
||||
// TODO: API 호출하여 due_date 업데이트
|
||||
console.log('Todo 지연:', id, '새 날짜:', newDate);
|
||||
alert(`할 일이 ${newDate}로 지연되었습니다.`);
|
||||
loadTodoItems(); // 목록 새로고침
|
||||
} else if (newDate) {
|
||||
alert('올바른 날짜 형식을 입력해주세요 (YYYY-MM-DD)');
|
||||
}
|
||||
}
|
||||
|
||||
// Todo 완료
|
||||
function completeTodo(id) {
|
||||
console.log('Todo 완료:', id);
|
||||
async function completeTodo(id) {
|
||||
try {
|
||||
// TODO: API 호출하여 상태를 'completed'로 변경
|
||||
console.log('Todo 완료:', id);
|
||||
alert('할 일이 완료되었습니다!');
|
||||
loadTodoItems(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error('완료 처리 실패:', error);
|
||||
alert('완료 처리에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// Todo 분할
|
||||
function splitTodo(id) {
|
||||
const splitCount = prompt('몇 개로 분할하시겠습니까? (2-5개):');
|
||||
const count = parseInt(splitCount);
|
||||
|
||||
if (count >= 2 && count <= 5) {
|
||||
const subtasks = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const subtask = prompt(`${i}번째 세부 작업을 입력하세요:`);
|
||||
if (subtask) {
|
||||
subtasks.push(subtask.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (subtasks.length > 0) {
|
||||
// TODO: API 호출하여 원본 삭제 후 세부 작업들 생성
|
||||
console.log('Todo 분할:', id, '세부 작업들:', subtasks);
|
||||
alert(`할 일이 ${subtasks.length}개의 세부 작업으로 분할되었습니다.`);
|
||||
loadTodoItems(); // 목록 새로고침
|
||||
}
|
||||
} else if (splitCount) {
|
||||
alert('2-5개 사이의 숫자를 입력해주세요.');
|
||||
}
|
||||
}
|
||||
|
||||
// Todo 편집
|
||||
function editTodo(id) {
|
||||
console.log('Todo 편집:', id);
|
||||
// TODO: 편집 모달 또는 페이지로 이동
|
||||
alert('편집 기능은 준비 중입니다.');
|
||||
}
|
||||
|
||||
// 필터링
|
||||
@@ -306,5 +338,7 @@
|
||||
// 전역 함수 등록
|
||||
window.goToDashboard = goToDashboard;
|
||||
</script>
|
||||
<script src="static/js/api.js?v=20250921110800"></script>
|
||||
<script src="static/js/auth.js?v=20250921110800"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user