feat: 체크리스트 이미지 미리보기 기능 구현

- 체크리스트 섹션에 이미지 썸네일 미리보기 추가 (16x16)
- 대시보드 상단 체크리스트 카드에 이미지 미리보기 기능 추가
- 이미지 클릭 시 전체 화면 모달로 확대 보기
- 백엔드 image_url 컬럼을 TEXT 타입으로 변경하여 Base64 이미지 지원
- 파일 업로드를 이미지만 지원하도록 단순화 (file_url, file_name 제거)
- 422 validation 오류 해결 및 상세 로깅 추가
- 체크리스트 렌더링 누락 문제 해결
This commit is contained in:
hyungi
2025-09-23 07:49:54 +09:00
parent 5c9ea92fb8
commit f80995c1ec
22 changed files with 2635 additions and 930 deletions

View File

@@ -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]

View File

@@ -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)

View File

@@ -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 조회에 실패했습니다."
)

View File

@@ -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)
)

View File

@@ -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:

View File

@@ -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}")

View File

@@ -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")

View File

@@ -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}')>"

View File

@@ -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}')>"

View File

@@ -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

View File

@@ -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
View 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;

View File

@@ -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"]

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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 목록 로드

View File

@@ -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);
}

View File

@@ -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>