feat: 풀스택 할일관리 앱 구현 (통합 모달 + 간트차트)

- Backend: FastAPI + MongoDB + Redis (카테고리, 할일 CRUD, 파일 첨부, 검색, 대시보드)
- Frontend: Next.js 15 + Tailwind + React Query + Zustand
- 통합 TodoModal: 생성/수정 모달 통합, 탭 구조 (기본/태그와 첨부)
- 간트차트: 카테고리별 할일 타임라인 시각화
- TodoCard: 제목/카테고리/우선순위/태그/첨부 한줄 표시
- Docker Compose 배포 (Frontend:3010, Backend:8010, MongoDB:27021, Redis:6391)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2026-02-12 15:45:03 +09:00
parent b54811ad8d
commit 074b5133bf
81 changed files with 17027 additions and 19 deletions

View File

@ -4,5 +4,6 @@ RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
RUN mkdir -p /app/uploads
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

44
backend/app/config.py Normal file
View File

@ -0,0 +1,44 @@
from pydantic_settings import BaseSettings
from pydantic import Field
from functools import lru_cache
class Settings(BaseSettings):
mongodb_url: str = Field(
default="mongodb://mongodb:27017",
description="MongoDB 연결 URL",
)
mongodb_database: str = Field(
default="todos2",
alias="DB_NAME",
description="MongoDB 데이터베이스 이름",
)
redis_url: str = Field(
default="redis://redis:6379",
description="Redis 연결 URL",
)
frontend_url: str = Field(
default="http://localhost:3000",
description="프론트엔드 URL (CORS 허용)",
)
upload_dir: str = Field(
default="/app/uploads",
description="파일 업로드 디렉토리",
)
max_file_size: int = Field(
default=10 * 1024 * 1024,
description="최대 파일 크기 (bytes, 기본 10MB)",
)
max_files_per_todo: int = Field(
default=5,
description="Todo당 최대 첨부 파일 수",
)
class Config:
env_file = ".env"
populate_by_name = True # alias와 필드명 모두 허용
@lru_cache
def get_settings() -> Settings:
return Settings()

98
backend/app/database.py Normal file
View File

@ -0,0 +1,98 @@
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
import redis.asyncio as aioredis
from app.config import get_settings
class Database:
client: AsyncIOMotorClient | None = None
db: AsyncIOMotorDatabase | None = None
redis: aioredis.Redis | None = None
db = Database()
def get_database() -> AsyncIOMotorDatabase:
"""MongoDB 데이터베이스 인스턴스를 반환한다.
주의: `if not db.db:` 사용 금지 (pymongo 4.9.x NotImplementedError)
"""
if db.db is None:
raise RuntimeError("Database not initialized")
return db.db
def get_redis() -> aioredis.Redis:
"""Redis 클라이언트 인스턴스를 반환한다."""
if db.redis is None:
raise RuntimeError("Redis not initialized")
return db.redis
async def connect_db():
"""MongoDB + Redis 연결 초기화"""
settings = get_settings()
# MongoDB
db.client = AsyncIOMotorClient(settings.mongodb_url)
db.db = db.client[settings.mongodb_database]
# 인덱스 생성
await create_indexes(db.db)
# Redis
db.redis = aioredis.from_url(
settings.redis_url,
encoding="utf-8",
decode_responses=True,
)
async def disconnect_db():
"""연결 종료"""
if db.client is not None:
db.client.close()
if db.redis is not None:
await db.redis.close()
async def create_indexes(database: AsyncIOMotorDatabase):
"""컬렉션 인덱스 생성"""
todos = database["todos"]
categories = database["categories"]
# todos 텍스트 검색 인덱스
await todos.create_index(
[("title", "text"), ("content", "text"), ("tags", "text")],
name="text_search_index",
weights={"title": 10, "content": 5, "tags": 3},
)
# todos 복합 인덱스
await todos.create_index(
[("category_id", 1), ("created_at", -1)],
name="category_created",
)
await todos.create_index(
[("completed", 1), ("created_at", -1)],
name="completed_created",
)
await todos.create_index(
[("priority", 1), ("created_at", -1)],
name="priority_created",
)
await todos.create_index(
[("tags", 1)],
name="tags",
)
await todos.create_index(
[("due_date", 1)],
name="due_date",
)
await todos.create_index(
[("completed", 1), ("due_date", 1)],
name="completed_due_date",
)
# categories 인덱스
await categories.create_index("name", unique=True, name="category_name_unique")
await categories.create_index("order", name="category_order")

View File

@ -1,9 +1,32 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from datetime import datetime
app = FastAPI(title="API", version="1.0.0")
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
from app.database import connect_db, disconnect_db
@asynccontextmanager
async def lifespan(app: FastAPI):
"""서버 시작/종료 이벤트: MongoDB + Redis 연결 관리"""
await connect_db()
yield
await disconnect_db()
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(
title="todos2 API",
description="확장형 할일 관리 애플리케이션 API",
version="1.0.0",
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@ -12,6 +35,21 @@ app.add_middleware(
allow_headers=["*"],
)
@app.get("/health")
# 라우터 등록
from app.routers import todos, categories, tags, search, dashboard, uploads
app.include_router(todos.router)
app.include_router(categories.router)
app.include_router(tags.router)
app.include_router(search.router)
app.include_router(dashboard.router)
app.include_router(uploads.router)
# 헬스 체크
@app.get("/health", tags=["health"])
async def health_check():
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
return app
app = create_app()

View File

@ -0,0 +1,47 @@
from app.models.common import PyObjectId, ErrorResponse, PaginatedResponse
from app.models.todo import (
Priority,
TodoCreate,
TodoUpdate,
TodoResponse,
TodoListResponse,
BatchRequest,
BatchResponse,
ToggleResponse,
TagInfo,
SearchResponse,
DashboardStats,
OverviewStats,
CategoryStats,
PriorityStats,
UpcomingDeadline,
)
from app.models.category import (
CategoryCreate,
CategoryUpdate,
CategoryResponse,
)
__all__ = [
"PyObjectId",
"ErrorResponse",
"PaginatedResponse",
"Priority",
"TodoCreate",
"TodoUpdate",
"TodoResponse",
"TodoListResponse",
"BatchRequest",
"BatchResponse",
"ToggleResponse",
"TagInfo",
"SearchResponse",
"DashboardStats",
"OverviewStats",
"CategoryStats",
"PriorityStats",
"UpcomingDeadline",
"CategoryCreate",
"CategoryUpdate",
"CategoryResponse",
]

View File

@ -0,0 +1,49 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator
# === Request 스키마 ===
class CategoryCreate(BaseModel):
"""카테고리 생성 요청 (F-007)"""
name: str = Field(..., min_length=1, max_length=50)
color: str = Field("#6B7280", pattern=r"^#[0-9A-Fa-f]{6}$")
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("카테고리 이름은 공백만으로 구성할 수 없습니다")
return v
class CategoryUpdate(BaseModel):
"""카테고리 수정 요청 (F-009) - Partial Update"""
name: Optional[str] = Field(None, min_length=1, max_length=50)
color: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
order: Optional[int] = Field(None, ge=0)
@field_validator("name")
@classmethod
def name_not_blank(cls, v: Optional[str]) -> Optional[str]:
if v is not None:
v = v.strip()
if not v:
raise ValueError("카테고리 이름은 공백만으로 구성할 수 없습니다")
return v
# === Response 스키마 ===
class CategoryResponse(BaseModel):
"""카테고리 응답 (F-008)"""
id: str
name: str
color: str
order: int
todo_count: int = 0
created_at: datetime

View File

@ -0,0 +1,29 @@
from typing import Annotated, Any
from bson import ObjectId
from pydantic import BaseModel, BeforeValidator
def validate_object_id(v: Any) -> str:
"""ObjectId <-> str 변환을 위한 유효성 검증기"""
if isinstance(v, ObjectId):
return str(v)
if isinstance(v, str) and ObjectId.is_valid(v):
return v
raise ValueError(f"Invalid ObjectId: {v}")
PyObjectId = Annotated[str, BeforeValidator(validate_object_id)]
class ErrorResponse(BaseModel):
"""표준 에러 응답"""
detail: str
class PaginatedResponse(BaseModel):
"""페이지네이션 응답 래퍼"""
items: list[Any]
total: int
page: int
limit: int
total_pages: int

221
backend/app/models/todo.py Normal file
View File

@ -0,0 +1,221 @@
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, field_validator
class Priority(str, Enum):
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
# === Request 스키마 ===
class TodoCreate(BaseModel):
"""할일 생성 요청 (F-001)"""
title: str = Field(..., min_length=1, max_length=200)
content: Optional[str] = Field(None, max_length=2000)
category_id: Optional[str] = None
tags: list[str] = Field(default_factory=list, max_length=10)
priority: Priority = Priority.MEDIUM
start_date: Optional[datetime] = None
due_date: Optional[datetime] = None
@field_validator("title")
@classmethod
def title_not_blank(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("제목은 공백만으로 구성할 수 없습니다")
return v
@field_validator("tags")
@classmethod
def normalize_tags(cls, v: list[str]) -> list[str]:
"""소문자 정규화, 중복 제거, 공백 trim"""
seen: set[str] = set()
result: list[str] = []
for tag in v:
tag = tag.strip().lower()
if tag and len(tag) <= 30 and tag not in seen:
seen.add(tag)
result.append(tag)
return result
@field_validator("category_id")
@classmethod
def validate_category_id(cls, v: Optional[str]) -> Optional[str]:
from bson import ObjectId
if v is not None and not ObjectId.is_valid(v):
raise ValueError("유효하지 않은 카테고리 ID 형식입니다")
return v
class TodoUpdate(BaseModel):
"""할일 수정 요청 (F-004) - Partial Update"""
title: Optional[str] = Field(None, min_length=1, max_length=200)
content: Optional[str] = Field(None, max_length=2000)
completed: Optional[bool] = None
category_id: Optional[str] = None
tags: Optional[list[str]] = None
priority: Optional[Priority] = None
start_date: Optional[datetime] = None
due_date: Optional[datetime] = None
@field_validator("title")
@classmethod
def title_not_blank(cls, v: Optional[str]) -> Optional[str]:
if v is not None:
v = v.strip()
if not v:
raise ValueError("제목은 공백만으로 구성할 수 없습니다")
return v
@field_validator("tags")
@classmethod
def normalize_tags(cls, v: Optional[list[str]]) -> Optional[list[str]]:
if v is None:
return None
seen: set[str] = set()
result: list[str] = []
for tag in v:
tag = tag.strip().lower()
if tag and len(tag) <= 30 and tag not in seen:
seen.add(tag)
result.append(tag)
return result
@field_validator("category_id")
@classmethod
def validate_category_id(cls, v: Optional[str]) -> Optional[str]:
from bson import ObjectId
if v is not None and not ObjectId.is_valid(v):
raise ValueError("유효하지 않은 카테고리 ID 형식입니다")
return v
class BatchRequest(BaseModel):
"""일괄 작업 요청 (F-019, F-020, F-021)"""
action: str = Field(..., pattern="^(complete|delete|move_category)$")
ids: list[str] = Field(..., min_length=1)
category_id: Optional[str] = None # move_category 시에만 사용
@field_validator("ids")
@classmethod
def validate_ids(cls, v: list[str]) -> list[str]:
from bson import ObjectId
for id_str in v:
if not ObjectId.is_valid(id_str):
raise ValueError(f"유효하지 않은 ID 형식: {id_str}")
return v
# === Attachment 스키마 ===
class Attachment(BaseModel):
"""첨부파일 메타데이터"""
id: str
filename: str
stored_filename: str
content_type: str
size: int
uploaded_at: datetime
# === Response 스키마 ===
class TodoResponse(BaseModel):
"""할일 응답 (카테고리 정보 포함 가능)"""
id: str
title: str
content: Optional[str] = None
completed: bool
priority: Priority
category_id: Optional[str] = None
category_name: Optional[str] = None
category_color: Optional[str] = None
tags: list[str] = []
start_date: Optional[datetime] = None
due_date: Optional[datetime] = None
attachments: list[Attachment] = []
created_at: datetime
updated_at: datetime
class TodoListResponse(BaseModel):
"""할일 목록 페이지네이션 응답 (F-002)"""
items: list[TodoResponse]
total: int
page: int
limit: int
total_pages: int
class ToggleResponse(BaseModel):
"""완료 토글 응답 (F-006)"""
id: str
completed: bool
class BatchResponse(BaseModel):
"""일괄 작업 응답 (F-019, F-020, F-021)"""
action: str
processed: int
failed: int
class TagInfo(BaseModel):
"""태그 정보 (F-013)"""
name: str
count: int
class SearchResponse(BaseModel):
"""검색 응답 (F-017)"""
items: list[TodoResponse]
total: int
query: str
page: int
limit: int
# === 대시보드 통계 모델 ===
class OverviewStats(BaseModel):
total: int
completed: int
incomplete: int
completion_rate: float
class CategoryStats(BaseModel):
category_id: Optional[str] = None
name: str
color: str
count: int
class PriorityStats(BaseModel):
high: int
medium: int
low: int
class UpcomingDeadline(BaseModel):
id: str
title: str
due_date: datetime
priority: Priority
class DashboardStats(BaseModel):
"""대시보드 통계 응답 (F-018)"""
overview: OverviewStats
by_category: list[CategoryStats]
by_priority: PriorityStats
upcoming_deadlines: list[UpcomingDeadline]

View File

@ -0,0 +1,9 @@
from app.routers import todos, categories, tags, search, dashboard
__all__ = [
"todos",
"categories",
"tags",
"search",
"dashboard",
]

View File

@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, Response, status
from app.database import get_database, get_redis
from app.models.category import (
CategoryCreate,
CategoryUpdate,
CategoryResponse,
)
from app.services.category_service import CategoryService
router = APIRouter(prefix="/api/categories", tags=["categories"])
def _get_service(
db=Depends(get_database),
redis=Depends(get_redis),
) -> CategoryService:
return CategoryService(db, redis)
@router.get("", response_model=list[CategoryResponse])
async def list_categories(
service: CategoryService = Depends(_get_service),
):
"""F-008: 카테고리 목록 조회 (order 오름차순, todo_count 포함)"""
return await service.list_categories()
@router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_category(
data: CategoryCreate,
service: CategoryService = Depends(_get_service),
):
"""F-007: 카테고리 생성"""
return await service.create_category(data)
@router.put("/{category_id}", response_model=CategoryResponse)
async def update_category(
category_id: str,
data: CategoryUpdate,
service: CategoryService = Depends(_get_service),
):
"""F-009: 카테고리 수정"""
return await service.update_category(category_id, data)
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_category(
category_id: str,
service: CategoryService = Depends(_get_service),
):
"""F-010: 카테고리 삭제 (해당 카테고리의 할일 category_id null 처리)"""
await service.delete_category(category_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)

View File

@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends
from app.database import get_database, get_redis
from app.models.todo import DashboardStats
from app.services.dashboard_service import DashboardService
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("/stats", response_model=DashboardStats)
async def get_dashboard_stats(
db=Depends(get_database),
redis=Depends(get_redis),
):
"""F-018: 대시보드 통계 (Redis 캐싱, TTL 60초)"""
service = DashboardService(db, redis)
return await service.get_stats()

View File

@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends, Query
from app.database import get_database
from app.models.todo import SearchResponse
from app.services.search_service import SearchService
router = APIRouter(prefix="/api/search", tags=["search"])
@router.get("", response_model=SearchResponse)
async def search_todos(
q: str = Query(..., min_length=1, max_length=200, description="검색 키워드"),
page: int = Query(1, ge=1, description="페이지 번호"),
limit: int = Query(20, ge=1, le=100, description="페이지당 항목 수"),
db=Depends(get_database),
):
"""F-017: 전문 검색 (제목/내용/태그, text score 기준 정렬)"""
service = SearchService(db)
return await service.search(query=q, page=page, limit=limit)

View File

@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends
from app.database import get_database
from app.models.todo import TagInfo
from app.services.todo_service import TodoService
router = APIRouter(prefix="/api/tags", tags=["tags"])
@router.get("", response_model=list[TagInfo])
async def list_tags(
db=Depends(get_database),
):
"""F-013: 사용 중인 태그 목록 조회 (사용 횟수 포함, 내림차순)"""
service = TodoService(db)
return await service.get_tags()

View File

@ -0,0 +1,106 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query, Response, status
from app.database import get_database, get_redis
from app.models.todo import (
TodoCreate,
TodoUpdate,
TodoResponse,
TodoListResponse,
BatchRequest,
BatchResponse,
ToggleResponse,
)
from app.services.todo_service import TodoService
router = APIRouter(prefix="/api/todos", tags=["todos"])
def _get_service(
db=Depends(get_database),
redis=Depends(get_redis),
) -> TodoService:
return TodoService(db, redis)
# 중요: /batch를 /{id} 보다 위에 등록
@router.post("/batch", response_model=BatchResponse)
async def batch_action(
data: BatchRequest,
service: TodoService = Depends(_get_service),
):
"""F-019~F-021: 일괄 작업 (완료/삭제/카테고리 변경)"""
return await service.batch_action(data)
@router.get("", response_model=TodoListResponse)
async def list_todos(
page: int = Query(1, ge=1, description="페이지 번호"),
limit: int = Query(20, ge=1, le=100, description="페이지당 항목 수"),
completed: Optional[bool] = Query(None, description="완료 상태 필터"),
category_id: Optional[str] = Query(None, description="카테고리 필터"),
priority: Optional[str] = Query(None, description="우선순위 필터 (high/medium/low)"),
tag: Optional[str] = Query(None, description="태그 필터"),
sort: str = Query("created_at", description="정렬 기준 (created_at/due_date/priority)"),
order: str = Query("desc", description="정렬 방향 (asc/desc)"),
service: TodoService = Depends(_get_service),
):
"""F-002: 할일 목록 조회 (필터/정렬/페이지네이션)"""
return await service.list_todos(
page=page,
limit=limit,
completed=completed,
category_id=category_id,
priority=priority,
tag=tag,
sort=sort,
order=order,
)
@router.post("", response_model=TodoResponse, status_code=status.HTTP_201_CREATED)
async def create_todo(
data: TodoCreate,
service: TodoService = Depends(_get_service),
):
"""F-001: 할일 생성"""
return await service.create_todo(data)
@router.get("/{todo_id}", response_model=TodoResponse)
async def get_todo(
todo_id: str,
service: TodoService = Depends(_get_service),
):
"""F-003: 할일 상세 조회"""
return await service.get_todo(todo_id)
@router.put("/{todo_id}", response_model=TodoResponse)
async def update_todo(
todo_id: str,
data: TodoUpdate,
service: TodoService = Depends(_get_service),
):
"""F-004: 할일 수정"""
return await service.update_todo(todo_id, data)
@router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_todo(
todo_id: str,
service: TodoService = Depends(_get_service),
):
"""F-005: 할일 삭제"""
await service.delete_todo(todo_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.patch("/{todo_id}/toggle", response_model=ToggleResponse)
async def toggle_todo(
todo_id: str,
service: TodoService = Depends(_get_service),
):
"""F-006: 할일 완료 토글"""
return await service.toggle_todo(todo_id)

View File

@ -0,0 +1,56 @@
from typing import List
from fastapi import APIRouter, Depends, File, UploadFile, status
from fastapi.responses import FileResponse
from app.database import get_database
from app.models.todo import Attachment
from app.services.file_service import FileService
router = APIRouter(prefix="/api/todos", tags=["attachments"])
def _get_service(db=Depends(get_database)) -> FileService:
return FileService(db)
@router.post(
"/{todo_id}/attachments",
response_model=list[Attachment],
status_code=status.HTTP_201_CREATED,
)
async def upload_attachments(
todo_id: str,
files: List[UploadFile] = File(...),
service: FileService = Depends(_get_service),
):
"""파일 업로드 (최대 5개, 파일당 10MB)"""
return await service.upload_files(todo_id, files)
@router.get("/{todo_id}/attachments/{attachment_id}/download")
async def download_attachment(
todo_id: str,
attachment_id: str,
service: FileService = Depends(_get_service),
):
"""파일 다운로드"""
file_path, filename = await service.get_file_info(todo_id, attachment_id)
return FileResponse(
path=str(file_path),
filename=filename,
media_type="application/octet-stream",
)
@router.delete(
"/{todo_id}/attachments/{attachment_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_attachment(
todo_id: str,
attachment_id: str,
service: FileService = Depends(_get_service),
):
"""첨부파일 삭제"""
await service.delete_attachment(todo_id, attachment_id)

View File

@ -0,0 +1,11 @@
from app.services.todo_service import TodoService
from app.services.category_service import CategoryService
from app.services.search_service import SearchService
from app.services.dashboard_service import DashboardService
__all__ = [
"TodoService",
"CategoryService",
"SearchService",
"DashboardService",
]

View File

@ -0,0 +1,161 @@
from datetime import datetime, timezone
from typing import Optional
from bson import ObjectId
from fastapi import HTTPException
from motor.motor_asyncio import AsyncIOMotorDatabase
import redis.asyncio as aioredis
from app.models.category import (
CategoryCreate,
CategoryUpdate,
CategoryResponse,
)
DASHBOARD_CACHE_KEY = "dashboard:stats"
class CategoryService:
"""카테고리 CRUD 비즈니스 로직"""
def __init__(self, db: AsyncIOMotorDatabase, redis_client: Optional[aioredis.Redis] = None):
self.collection = db["categories"]
self.todos = db["todos"]
self.redis = redis_client
async def _invalidate_cache(self):
"""대시보드 캐시 무효화"""
if self.redis is not None:
try:
await self.redis.delete(DASHBOARD_CACHE_KEY)
except Exception:
pass
async def list_categories(self) -> list[CategoryResponse]:
"""F-008: 카테고리 목록 조회 (order 오름차순, todo_count 포함)"""
categories = await self.collection.find().sort("order", 1).to_list(None)
result = []
for cat in categories:
cat_id_str = str(cat["_id"])
# 해당 카테고리의 할일 수 조회
todo_count = await self.todos.count_documents({"category_id": cat_id_str})
result.append(CategoryResponse(
id=cat_id_str,
name=cat["name"],
color=cat.get("color", "#6B7280"),
order=cat.get("order", 0),
todo_count=todo_count,
created_at=cat["created_at"],
))
return result
async def create_category(self, data: CategoryCreate) -> CategoryResponse:
"""F-007: 카테고리 생성"""
# 이름 중복 검사
existing = await self.collection.find_one({"name": data.name})
if existing:
raise HTTPException(status_code=409, detail="이미 존재하는 카테고리 이름입니다")
# order 자동 설정 (현재 최대값 + 1)
max_order_doc = await self.collection.find_one(
{},
sort=[("order", -1)],
)
next_order = (max_order_doc["order"] + 1) if max_order_doc and "order" in max_order_doc else 0
now = datetime.now(timezone.utc)
doc = {
"name": data.name,
"color": data.color,
"order": next_order,
"created_at": now,
}
result = await self.collection.insert_one(doc)
doc["_id"] = result.inserted_id
await self._invalidate_cache()
return CategoryResponse(
id=str(doc["_id"]),
name=doc["name"],
color=doc["color"],
order=doc["order"],
todo_count=0,
created_at=doc["created_at"],
)
async def update_category(self, category_id: str, data: CategoryUpdate) -> CategoryResponse:
"""F-009: 카테고리 수정 (Partial Update)"""
if not ObjectId.is_valid(category_id):
raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
# 존재 확인
existing = await self.collection.find_one({"_id": ObjectId(category_id)})
if not existing:
raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다")
# 변경 필드 추출
update_data = data.model_dump(exclude_unset=True)
if not update_data:
raise HTTPException(status_code=400, detail="수정할 필드가 없습니다")
# name 변경 시 중복 검사
if "name" in update_data:
dup = await self.collection.find_one({
"name": update_data["name"],
"_id": {"$ne": ObjectId(category_id)},
})
if dup:
raise HTTPException(status_code=409, detail="이미 존재하는 카테고리 이름입니다")
result = await self.collection.find_one_and_update(
{"_id": ObjectId(category_id)},
{"$set": update_data},
return_document=True,
)
if not result:
raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다")
# todo_count 조회
cat_id_str = str(result["_id"])
todo_count = await self.todos.count_documents({"category_id": cat_id_str})
await self._invalidate_cache()
return CategoryResponse(
id=cat_id_str,
name=result["name"],
color=result.get("color", "#6B7280"),
order=result.get("order", 0),
todo_count=todo_count,
created_at=result["created_at"],
)
async def delete_category(self, category_id: str) -> None:
"""F-010: 카테고리 삭제 + 해당 카테고리의 할일 category_id null 처리"""
if not ObjectId.is_valid(category_id):
raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
# 존재 확인
existing = await self.collection.find_one({"_id": ObjectId(category_id)})
if not existing:
raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다")
# 해당 카테고리의 모든 todo.category_id -> null 갱신
await self.todos.update_many(
{"category_id": category_id},
{"$set": {
"category_id": None,
"updated_at": datetime.now(timezone.utc),
}},
)
# 카테고리 삭제
await self.collection.delete_one({"_id": ObjectId(category_id)})
await self._invalidate_cache()

View File

@ -0,0 +1,182 @@
import json
from datetime import datetime, timezone
from bson import ObjectId
from motor.motor_asyncio import AsyncIOMotorDatabase
import redis.asyncio as aioredis
from app.models.todo import (
DashboardStats,
OverviewStats,
CategoryStats,
PriorityStats,
UpcomingDeadline,
)
DASHBOARD_CACHE_KEY = "dashboard:stats"
DASHBOARD_CACHE_TTL = 60 # 60초
class DashboardService:
"""대시보드 통계 + Redis 캐싱"""
def __init__(self, db: AsyncIOMotorDatabase, redis_client: aioredis.Redis):
self.todos = db["todos"]
self.categories = db["categories"]
self.redis = redis_client
async def get_stats(self) -> DashboardStats:
"""F-018: 대시보드 통계
1. Redis 캐시 확인
2. 캐시 히트 -> JSON 파싱 후 반환
3. 캐시 미스 -> MongoDB 집계 -> Redis 저장 (TTL 60s) -> 반환
"""
# Redis 캐시 확인
try:
cached = await self.redis.get(DASHBOARD_CACHE_KEY)
if cached:
data = json.loads(cached)
return DashboardStats(**data)
except Exception:
pass # Redis 에러 시 DB에서 직접 조회
# 캐시 미스: MongoDB 집계
stats = await self._compute_stats()
# Redis에 캐싱
try:
stats_json = stats.model_dump_json()
await self.redis.setex(DASHBOARD_CACHE_KEY, DASHBOARD_CACHE_TTL, stats_json)
except Exception:
pass # Redis 저장 실패는 무시
return stats
async def _compute_stats(self) -> DashboardStats:
"""MongoDB 집계 파이프라인으로 통계 계산"""
# 1. Overview (전체/완료/미완료/완료율)
total = await self.todos.count_documents({})
completed = await self.todos.count_documents({"completed": True})
incomplete = total - completed
completion_rate = round((completed / total * 100), 1) if total > 0 else 0.0
overview = OverviewStats(
total=total,
completed=completed,
incomplete=incomplete,
completion_rate=completion_rate,
)
# 2. By Category (카테고리별 할일 분포)
by_category = await self._compute_by_category()
# 3. By Priority (우선순위별 현황)
by_priority = await self._compute_by_priority()
# 4. Upcoming Deadlines (마감 임박 할일 상위 5개)
upcoming_deadlines = await self._compute_upcoming_deadlines()
return DashboardStats(
overview=overview,
by_category=by_category,
by_priority=by_priority,
upcoming_deadlines=upcoming_deadlines,
)
async def _compute_by_category(self) -> list[CategoryStats]:
"""카테고리별 할일 분포 집계"""
pipeline = [
{"$group": {
"_id": "$category_id",
"count": {"$sum": 1},
}},
{"$sort": {"count": -1}},
]
results = await self.todos.aggregate(pipeline).to_list(None)
# 카테고리 정보 조회
cat_ids = set()
for r in results:
if r["_id"]:
try:
cat_ids.add(ObjectId(r["_id"]))
except Exception:
pass
cat_map: dict[str, dict] = {}
if cat_ids:
async for cat in self.categories.find({"_id": {"$in": list(cat_ids)}}):
cat_map[str(cat["_id"])] = cat
category_stats = []
for r in results:
cat_id = r["_id"]
if cat_id and cat_id in cat_map:
cat = cat_map[cat_id]
category_stats.append(CategoryStats(
category_id=cat_id,
name=cat["name"],
color=cat.get("color", "#6B7280"),
count=r["count"],
))
else:
category_stats.append(CategoryStats(
category_id=None,
name="미분류",
color="#9CA3AF",
count=r["count"],
))
return category_stats
async def _compute_by_priority(self) -> PriorityStats:
"""우선순위별 현황 집계"""
pipeline = [
{"$group": {
"_id": "$priority",
"count": {"$sum": 1},
}},
]
results = await self.todos.aggregate(pipeline).to_list(None)
priority_map = {"high": 0, "medium": 0, "low": 0}
for r in results:
if r["_id"] in priority_map:
priority_map[r["_id"]] = r["count"]
return PriorityStats(
high=priority_map["high"],
medium=priority_map["medium"],
low=priority_map["low"],
)
async def _compute_upcoming_deadlines(self) -> list[UpcomingDeadline]:
"""마감 임박 할일 상위 5개 (미완료, 마감일 있는 것, 마감일 오름차순)"""
now = datetime.now(timezone.utc)
cursor = self.todos.find({
"completed": False,
"due_date": {"$ne": None, "$gte": now},
}).sort("due_date", 1).limit(5)
docs = await cursor.to_list(5)
deadlines = []
for doc in docs:
deadlines.append(UpcomingDeadline(
id=str(doc["_id"]),
title=doc["title"],
due_date=doc["due_date"],
priority=doc["priority"],
))
return deadlines
async def invalidate_cache(self) -> None:
"""캐시 무효화: 할일 CUD 작업 후 호출"""
try:
await self.redis.delete(DASHBOARD_CACHE_KEY)
except Exception:
pass

View File

@ -0,0 +1,154 @@
import shutil
import uuid
from datetime import datetime, timezone
from pathlib import Path
from bson import ObjectId
from fastapi import HTTPException, UploadFile
from motor.motor_asyncio import AsyncIOMotorDatabase
from app.config import get_settings
from app.models.todo import Attachment
class FileService:
"""파일 업로드/다운로드/삭제 서비스"""
def __init__(self, db: AsyncIOMotorDatabase):
self.collection = db["todos"]
self.settings = get_settings()
self.upload_dir = Path(self.settings.upload_dir)
def _ensure_todo_dir(self, todo_id: str) -> Path:
todo_dir = self.upload_dir / todo_id
todo_dir.mkdir(parents=True, exist_ok=True)
return todo_dir
async def upload_files(
self, todo_id: str, files: list[UploadFile]
) -> list[Attachment]:
if not ObjectId.is_valid(todo_id):
raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
todo = await self.collection.find_one({"_id": ObjectId(todo_id)})
if not todo:
raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
existing_count = len(todo.get("attachments", []))
if existing_count + len(files) > self.settings.max_files_per_todo:
raise HTTPException(
status_code=400,
detail=f"최대 {self.settings.max_files_per_todo}개의 파일만 첨부할 수 있습니다",
)
todo_dir = self._ensure_todo_dir(todo_id)
attachments: list[Attachment] = []
saved_paths: list[Path] = []
try:
for file in files:
content = await file.read()
if len(content) > self.settings.max_file_size:
raise HTTPException(
status_code=400,
detail=f"파일 '{file.filename}'이 최대 크기(10MB)를 초과합니다",
)
file_id = str(uuid.uuid4())
ext = Path(file.filename or "").suffix
stored_filename = f"{file_id}{ext}"
file_path = todo_dir / stored_filename
with open(file_path, "wb") as f:
f.write(content)
saved_paths.append(file_path)
attachments.append(
Attachment(
id=file_id,
filename=file.filename or "unknown",
stored_filename=stored_filename,
content_type=file.content_type or "application/octet-stream",
size=len(content),
uploaded_at=datetime.now(timezone.utc),
)
)
await self.collection.update_one(
{"_id": ObjectId(todo_id)},
{
"$push": {
"attachments": {
"$each": [a.model_dump() for a in attachments]
}
},
"$set": {"updated_at": datetime.now(timezone.utc)},
},
)
except HTTPException:
for p in saved_paths:
p.unlink(missing_ok=True)
raise
except Exception:
for p in saved_paths:
p.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail="파일 업로드에 실패했습니다")
return attachments
async def delete_attachment(self, todo_id: str, attachment_id: str) -> None:
if not ObjectId.is_valid(todo_id):
raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
todo = await self.collection.find_one({"_id": ObjectId(todo_id)})
if not todo:
raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
attachment = None
for att in todo.get("attachments", []):
if att["id"] == attachment_id:
attachment = att
break
if not attachment:
raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다")
file_path = self.upload_dir / todo_id / attachment["stored_filename"]
if file_path.exists():
file_path.unlink()
await self.collection.update_one(
{"_id": ObjectId(todo_id)},
{
"$pull": {"attachments": {"id": attachment_id}},
"$set": {"updated_at": datetime.now(timezone.utc)},
},
)
async def get_file_info(
self, todo_id: str, attachment_id: str
) -> tuple[Path, str]:
"""파일 경로와 원본 파일명을 반환"""
if not ObjectId.is_valid(todo_id):
raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
todo = await self.collection.find_one({"_id": ObjectId(todo_id)})
if not todo:
raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
for att in todo.get("attachments", []):
if att["id"] == attachment_id:
file_path = self.upload_dir / todo_id / att["stored_filename"]
if not file_path.exists():
raise HTTPException(
status_code=404, detail="파일이 디스크에서 찾을 수 없습니다"
)
return file_path, att["filename"]
raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다")
async def delete_all_todo_files(self, todo_id: str) -> None:
"""Todo 삭제 시 해당 Todo의 모든 파일 삭제"""
todo_dir = self.upload_dir / todo_id
if todo_dir.exists():
shutil.rmtree(todo_dir)

View File

@ -0,0 +1,92 @@
import math
from bson import ObjectId
from fastapi import HTTPException
from motor.motor_asyncio import AsyncIOMotorDatabase
from app.models.todo import TodoResponse, SearchResponse
class SearchService:
"""MongoDB text index 기반 검색 로직"""
def __init__(self, db: AsyncIOMotorDatabase):
self.collection = db["todos"]
self.categories = db["categories"]
async def _populate_categories_bulk(self, todo_docs: list[dict]) -> list[TodoResponse]:
"""여러 Todo 문서의 카테고리 정보를 일괄 조회하여 TodoResponse 리스트를 반환한다."""
# 카테고리 ID 수집
cat_ids = set()
for doc in todo_docs:
cat_id = doc.get("category_id")
if cat_id:
try:
cat_ids.add(ObjectId(cat_id))
except Exception:
pass
# 카테고리 일괄 조회
cat_map: dict[str, dict] = {}
if cat_ids:
async for cat in self.categories.find({"_id": {"$in": list(cat_ids)}}):
cat_map[str(cat["_id"])] = cat
# TodoResponse 변환
result = []
for doc in todo_docs:
cat_id = str(doc["category_id"]) if doc.get("category_id") else None
cat = cat_map.get(cat_id) if cat_id else None
result.append(TodoResponse(
id=str(doc["_id"]),
title=doc["title"],
content=doc.get("content"),
completed=doc["completed"],
priority=doc["priority"],
category_id=cat_id,
category_name=cat["name"] if cat else None,
category_color=cat["color"] if cat else None,
tags=doc.get("tags", []),
due_date=doc.get("due_date"),
created_at=doc["created_at"],
updated_at=doc["updated_at"],
))
return result
async def search(self, query: str, page: int = 1, limit: int = 20) -> SearchResponse:
"""F-017: 전문 검색
MongoDB text index를 활용한 전문 검색, text score 기준 정렬, 페이지네이션
"""
if not query or not query.strip():
raise HTTPException(status_code=422, detail="검색어를 입력해주세요")
query = query.strip()
# MongoDB text search
search_filter = {"$text": {"$search": query}}
# 총 개수 조회
total = await self.collection.count_documents(search_filter)
# text score 기준 정렬 + 페이지네이션
skip = (page - 1) * limit
cursor = self.collection.find(
search_filter,
{"score": {"$meta": "textScore"}},
).sort(
[("score", {"$meta": "textScore"})]
).skip(skip).limit(limit)
docs = await cursor.to_list(limit)
items = await self._populate_categories_bulk(docs)
return SearchResponse(
items=items,
total=total,
query=query,
page=page,
limit=limit,
)

View File

@ -0,0 +1,396 @@
import math
from datetime import datetime, timezone
from typing import Optional
from bson import ObjectId
from fastapi import HTTPException
from motor.motor_asyncio import AsyncIOMotorDatabase
import redis.asyncio as aioredis
from app.models.todo import (
Priority,
TodoCreate,
TodoUpdate,
TodoResponse,
TodoListResponse,
BatchRequest,
BatchResponse,
ToggleResponse,
TagInfo,
)
# 우선순위 정렬용 매핑 (높음이 먼저 오도록)
PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2}
DASHBOARD_CACHE_KEY = "dashboard:stats"
class TodoService:
"""할일 CRUD + 일괄 작업 + 태그 집계 비즈니스 로직"""
def __init__(self, db: AsyncIOMotorDatabase, redis_client: Optional[aioredis.Redis] = None):
self.collection = db["todos"]
self.categories = db["categories"]
self.redis = redis_client
async def _invalidate_cache(self):
"""대시보드 캐시 무효화"""
if self.redis is not None:
try:
await self.redis.delete(DASHBOARD_CACHE_KEY)
except Exception:
pass # Redis 오류 시 무시 (캐시 무효화 실패는 치명적이지 않음)
async def _populate_category(self, todo_doc: dict) -> TodoResponse:
"""Todo 문서에 카테고리 정보를 추가하여 TodoResponse를 반환한다."""
category_name = None
category_color = None
cat_id = todo_doc.get("category_id")
if cat_id:
category = await self.categories.find_one({"_id": ObjectId(cat_id)})
if category:
category_name = category["name"]
category_color = category["color"]
return TodoResponse(
id=str(todo_doc["_id"]),
title=todo_doc["title"],
content=todo_doc.get("content"),
completed=todo_doc["completed"],
priority=todo_doc["priority"],
category_id=str(todo_doc["category_id"]) if todo_doc.get("category_id") else None,
category_name=category_name,
category_color=category_color,
tags=todo_doc.get("tags", []),
start_date=todo_doc.get("start_date"),
due_date=todo_doc.get("due_date"),
attachments=todo_doc.get("attachments", []),
created_at=todo_doc["created_at"],
updated_at=todo_doc["updated_at"],
)
async def _populate_categories_bulk(self, todo_docs: list[dict]) -> list[TodoResponse]:
"""여러 Todo 문서의 카테고리 정보를 일괄 조회하여 TodoResponse 리스트를 반환한다."""
# 카테고리 ID 수집
cat_ids = set()
for doc in todo_docs:
cat_id = doc.get("category_id")
if cat_id:
try:
cat_ids.add(ObjectId(cat_id))
except Exception:
pass
# 카테고리 일괄 조회
cat_map: dict[str, dict] = {}
if cat_ids:
async for cat in self.categories.find({"_id": {"$in": list(cat_ids)}}):
cat_map[str(cat["_id"])] = cat
# TodoResponse 변환
result = []
for doc in todo_docs:
cat_id = str(doc["category_id"]) if doc.get("category_id") else None
cat = cat_map.get(cat_id) if cat_id else None
result.append(TodoResponse(
id=str(doc["_id"]),
title=doc["title"],
content=doc.get("content"),
completed=doc["completed"],
priority=doc["priority"],
category_id=cat_id,
category_name=cat["name"] if cat else None,
category_color=cat["color"] if cat else None,
tags=doc.get("tags", []),
start_date=doc.get("start_date"),
due_date=doc.get("due_date"),
attachments=doc.get("attachments", []),
created_at=doc["created_at"],
updated_at=doc["updated_at"],
))
return result
# --- CRUD ---
async def create_todo(self, data: TodoCreate) -> TodoResponse:
"""F-001: 할일 생성"""
# category_id 존재 검증
if data.category_id:
cat = await self.categories.find_one({"_id": ObjectId(data.category_id)})
if not cat:
raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다")
now = datetime.now(timezone.utc)
doc = {
"title": data.title,
"content": data.content,
"completed": False,
"priority": data.priority.value,
"category_id": data.category_id,
"tags": data.tags,
"start_date": data.start_date,
"due_date": data.due_date,
"created_at": now,
"updated_at": now,
}
result = await self.collection.insert_one(doc)
doc["_id"] = result.inserted_id
await self._invalidate_cache()
return await self._populate_category(doc)
async def list_todos(
self,
page: int = 1,
limit: int = 20,
completed: Optional[bool] = None,
category_id: Optional[str] = None,
priority: Optional[str] = None,
tag: Optional[str] = None,
sort: str = "created_at",
order: str = "desc",
) -> TodoListResponse:
"""F-002: 할일 목록 조회 (필터, 정렬, 페이지네이션)"""
# 필터 조건 구성 (AND)
query: dict = {}
if completed is not None:
query["completed"] = completed
if category_id:
query["category_id"] = category_id
if priority:
query["priority"] = priority
if tag:
query["tags"] = tag
# 정렬 조건 구성
sort_direction = 1 if order == "asc" else -1
if sort == "priority":
# priority 정렬 시 매핑 사용 (MongoDB에선 문자열 정렬이므로 보조 필드 필요)
# 대안: aggregation pipeline 사용
pipeline = []
if query:
pipeline.append({"$match": query})
pipeline.append({
"$addFields": {
"priority_order": {
"$switch": {
"branches": [
{"case": {"$eq": ["$priority", "high"]}, "then": 0},
{"case": {"$eq": ["$priority", "medium"]}, "then": 1},
{"case": {"$eq": ["$priority", "low"]}, "then": 2},
],
"default": 1,
}
}
}
})
pipeline.append({"$sort": {"priority_order": sort_direction, "created_at": -1}})
# 총 개수 조회
count_pipeline = list(pipeline) + [{"$count": "total"}]
count_result = await self.collection.aggregate(count_pipeline).to_list(1)
total = count_result[0]["total"] if count_result else 0
# 페이지네이션
skip = (page - 1) * limit
pipeline.append({"$skip": skip})
pipeline.append({"$limit": limit})
pipeline.append({"$project": {"priority_order": 0}})
docs = await self.collection.aggregate(pipeline).to_list(limit)
else:
# 일반 정렬
sort_field = sort if sort in ("created_at", "due_date") else "created_at"
sort_spec = [(sort_field, sort_direction)]
total = await self.collection.count_documents(query)
skip = (page - 1) * limit
cursor = self.collection.find(query).sort(sort_spec).skip(skip).limit(limit)
docs = await cursor.to_list(limit)
total_pages = math.ceil(total / limit) if limit > 0 else 0
items = await self._populate_categories_bulk(docs)
return TodoListResponse(
items=items,
total=total,
page=page,
limit=limit,
total_pages=total_pages,
)
async def get_todo(self, todo_id: str) -> TodoResponse:
"""F-003: 할일 상세 조회"""
if not ObjectId.is_valid(todo_id):
raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
doc = await self.collection.find_one({"_id": ObjectId(todo_id)})
if not doc:
raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
return await self._populate_category(doc)
async def update_todo(self, todo_id: str, data: TodoUpdate) -> TodoResponse:
"""F-004: 할일 수정 (Partial Update)"""
if not ObjectId.is_valid(todo_id):
raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
# 변경 필드만 추출
update_data = data.model_dump(exclude_unset=True)
if not update_data:
raise HTTPException(status_code=400, detail="수정할 필드가 없습니다")
# category_id 존재 검증
if "category_id" in update_data and update_data["category_id"] is not None:
cat = await self.categories.find_one({"_id": ObjectId(update_data["category_id"])})
if not cat:
raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다")
# priority enum -> string 변환
if "priority" in update_data and update_data["priority"] is not None:
update_data["priority"] = update_data["priority"].value if hasattr(update_data["priority"], "value") else update_data["priority"]
update_data["updated_at"] = datetime.now(timezone.utc)
result = await self.collection.find_one_and_update(
{"_id": ObjectId(todo_id)},
{"$set": update_data},
return_document=True,
)
if not result:
raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
await self._invalidate_cache()
return await self._populate_category(result)
async def delete_todo(self, todo_id: str) -> None:
"""F-005: 할일 삭제"""
if not ObjectId.is_valid(todo_id):
raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
result = await self.collection.delete_one({"_id": ObjectId(todo_id)})
if result.deleted_count == 0:
raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
# 첨부파일 삭제
from app.services.file_service import FileService
file_service = FileService(self.collection.database)
await file_service.delete_all_todo_files(todo_id)
await self._invalidate_cache()
async def toggle_todo(self, todo_id: str) -> ToggleResponse:
"""F-006: 완료 토글"""
if not ObjectId.is_valid(todo_id):
raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
doc = await self.collection.find_one({"_id": ObjectId(todo_id)})
if not doc:
raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
new_completed = not doc["completed"]
await self.collection.update_one(
{"_id": ObjectId(todo_id)},
{"$set": {
"completed": new_completed,
"updated_at": datetime.now(timezone.utc),
}},
)
await self._invalidate_cache()
return ToggleResponse(id=todo_id, completed=new_completed)
# --- 일괄 작업 ---
async def batch_action(self, data: BatchRequest) -> BatchResponse:
"""F-019~F-021: 일괄 작업 분기"""
if data.action == "complete":
return await self._batch_complete(data.ids)
elif data.action == "delete":
return await self._batch_delete(data.ids)
elif data.action == "move_category":
return await self._batch_move_category(data.ids, data.category_id)
else:
raise HTTPException(status_code=400, detail="지원하지 않는 작업입니다")
async def _batch_complete(self, ids: list[str]) -> BatchResponse:
"""F-019: 일괄 완료"""
object_ids = [ObjectId(id_str) for id_str in ids]
result = await self.collection.update_many(
{"_id": {"$in": object_ids}},
{"$set": {
"completed": True,
"updated_at": datetime.now(timezone.utc),
}},
)
await self._invalidate_cache()
return BatchResponse(
action="complete",
processed=result.modified_count,
failed=len(ids) - result.modified_count,
)
async def _batch_delete(self, ids: list[str]) -> BatchResponse:
"""F-020: 일괄 삭제"""
object_ids = [ObjectId(id_str) for id_str in ids]
result = await self.collection.delete_many({"_id": {"$in": object_ids}})
await self._invalidate_cache()
return BatchResponse(
action="delete",
processed=result.deleted_count,
failed=len(ids) - result.deleted_count,
)
async def _batch_move_category(self, ids: list[str], category_id: Optional[str]) -> BatchResponse:
"""F-021: 일괄 카테고리 변경"""
# category_id 검증 (null이 아닌 경우)
if category_id is not None:
if not ObjectId.is_valid(category_id):
raise HTTPException(status_code=422, detail="유효하지 않은 카테고리 ID 형식입니다")
cat = await self.categories.find_one({"_id": ObjectId(category_id)})
if not cat:
raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다")
object_ids = [ObjectId(id_str) for id_str in ids]
result = await self.collection.update_many(
{"_id": {"$in": object_ids}},
{"$set": {
"category_id": category_id,
"updated_at": datetime.now(timezone.utc),
}},
)
await self._invalidate_cache()
return BatchResponse(
action="move_category",
processed=result.modified_count,
failed=len(ids) - result.modified_count,
)
# --- 태그 ---
async def get_tags(self) -> list[TagInfo]:
"""F-013: 태그 목록 조회 (사용 횟수 포함, 사용 횟수 내림차순)"""
pipeline = [
{"$unwind": "$tags"},
{"$group": {"_id": "$tags", "count": {"$sum": 1}}},
{"$sort": {"count": -1}},
{"$project": {"name": "$_id", "count": 1, "_id": 0}},
]
results = await self.collection.aggregate(pipeline).to_list(None)
return [TagInfo(name=r["name"], count=r["count"]) for r in results]

View File

@ -1,6 +1,9 @@
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
motor>=3.3.0
pydantic>=2.5.0
aioredis>=2.0.0
python-dotenv>=1.0.0
fastapi>=0.104
uvicorn[standard]>=0.24
motor>=3.3
pymongo>=4.9,<4.10
pydantic>=2.5
pydantic-settings>=2.1
redis>=5.0
python-dotenv>=1.0
python-multipart>=0.0.6

View File

@ -52,6 +52,8 @@ services:
- MONGODB_URL=mongodb://${MONGO_USER:-admin}:${MONGO_PASSWORD:-password123}@mongodb:27017/
- DB_NAME=${DB_NAME:-app_db}
- REDIS_URL=redis://redis:6379
volumes:
- upload_data:/app/uploads
depends_on:
mongodb:
condition: service_healthy
@ -77,6 +79,7 @@ services:
volumes:
mongodb_data:
redis_data:
upload_data:
networks:
app-network:

2648
docs/ARCHITECTURE.md Normal file

File diff suppressed because it is too large Load Diff

789
docs/FEATURE_SPEC.md Normal file
View File

@ -0,0 +1,789 @@
# 기능 정의서 (Feature Specification)
> 프로젝트: todos2 | 버전: 1.0.0 | 작성일: 2026-02-10
---
## 1. 기능 목록 (Feature Inventory)
| # | 기능명 | 우선순위 | 카테고리 | 상태 |
|---|--------|---------|---------|------|
| F-001 | 할일 생성 | Must | 할일 CRUD | 미개발 |
| F-002 | 할일 목록 조회 | Must | 할일 CRUD | 미개발 |
| F-003 | 할일 상세 조회 | Must | 할일 CRUD | 미개발 |
| F-004 | 할일 수정 | Must | 할일 CRUD | 미개발 |
| F-005 | 할일 삭제 | Must | 할일 CRUD | 미개발 |
| F-006 | 할일 완료 토글 | Must | 할일 CRUD | 미개발 |
| F-007 | 카테고리 생성 | Must | 카테고리 | 미개발 |
| F-008 | 카테고리 목록 조회 | Must | 카테고리 | 미개발 |
| F-009 | 카테고리 수정 | Must | 카테고리 | 미개발 |
| F-010 | 카테고리 삭제 | Must | 카테고리 | 미개발 |
| F-011 | 태그 부여 | Must | 태그 | 미개발 |
| F-012 | 태그별 필터링 | Must | 태그 | 미개발 |
| F-013 | 태그 목록 조회 | Must | 태그 | 미개발 |
| F-014 | 우선순위 설정 | Must | 우선순위 | 미개발 |
| F-015 | 마감일 설정 | Must | 마감일 | 미개발 |
| F-016 | 마감일 알림 표시 | Must | 마감일 | 미개발 |
| F-017 | 검색 | Should | 검색 | 미개발 |
| F-018 | 대시보드 통계 | Should | 대시보드 | 미개발 |
| F-019 | 일괄 완료 처리 | Should | 일괄 작업 | 미개발 |
| F-020 | 일괄 삭제 | Should | 일괄 작업 | 미개발 |
| F-021 | 일괄 카테고리 변경 | Should | 일괄 작업 | 미개발 |
---
## 2. 기능 상세 정의
---
### F-001: 할일 생성
- **설명**: 사용자가 제목, 내용, 카테고리, 태그, 우선순위, 마감일을 입력하여 새로운 할일을 생성한다.
- **우선순위**: Must
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| title | string | Y | 1~200자, 공백만 불가 | 할일 제목 |
| content | string | N | 최대 2000자 | 할일 상세 내용 |
| category_id | string | N | 유효한 카테고리 ObjectId | 분류 카테고리 |
| tags | string[] | N | 각 태그 1~30자, 최대 10개 | 태그 목록 |
| priority | enum | N | high / medium / low (기본: medium) | 우선순위 |
| due_date | datetime | N | 현재 시각 이후 (생성 시점 기준) | 마감일 |
#### 처리 규칙 (Business Rules)
1. title 앞뒤 공백을 제거(trim)한다.
2. tags 배열 내 중복 태그를 제거하고, 각 태그를 소문자로 정규화한다.
3. category_id가 지정된 경우, 해당 카테고리가 존재하는지 검증한다.
4. created_at, updated_at을 현재 시각(UTC)으로 설정한다.
5. completed는 false로 초기화한다.
#### 출력 (Outputs)
| 상황 | HTTP 상태 | 응답 |
|------|-----------|------|
| 성공 | 201 Created | 생성된 Todo 객체 (id 포함) |
| 제목 누락/검증 실패 | 422 Unprocessable Entity | 필드별 에러 메시지 |
| 카테고리 미존재 | 404 Not Found | `{"detail": "카테고리를 찾을 수 없습니다"}` |
#### 수락 기준 (Acceptance Criteria)
- [ ] 제목만 입력하여 할일을 생성할 수 있다
- [ ] 모든 필드(제목, 내용, 카테고리, 태그, 우선순위, 마감일)를 입력하여 할일을 생성할 수 있다
- [ ] 제목 없이 생성 시 422 에러가 반환된다
- [ ] 201자 이상의 제목 입력 시 422 에러가 반환된다
- [ ] 존재하지 않는 카테고리 ID 입력 시 404 에러가 반환된다
- [ ] 생성 후 할일 목록에 즉시 반영된다
- [ ] 태그 중복이 자동으로 제거된다
---
### F-002: 할일 목록 조회
- **설명**: 필터링, 정렬, 페이지네이션을 적용하여 할일 목록을 조회한다.
- **우선순위**: Must
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| page | integer | N | >= 1, 기본: 1 | 페이지 번호 |
| limit | integer | N | 1~100, 기본: 20 | 페이지당 항목 수 |
| completed | boolean | N | true / false | 완료 상태 필터 |
| category_id | string | N | 유효한 ObjectId | 카테고리 필터 |
| priority | enum | N | high / medium / low | 우선순위 필터 |
| tag | string | N | - | 태그 필터 |
| sort | string | N | created_at / due_date / priority | 정렬 기준 (기본: created_at) |
| order | enum | N | asc / desc (기본: desc) | 정렬 방향 |
#### 처리 규칙 (Business Rules)
1. 필터 조건이 복수인 경우 AND 조건으로 적용한다.
2. 카테고리 필터 시 해당 category의 이름과 색상을 populate한다.
3. 페이지네이션 응답에 총 개수(total)와 총 페이지 수(total_pages)를 포함한다.
#### 출력 (Outputs)
| 상황 | HTTP 상태 | 응답 |
|------|-----------|------|
| 성공 | 200 OK | `{"items": [...], "total": N, "page": N, "limit": N, "total_pages": N}` |
| 잘못된 파라미터 | 422 Unprocessable Entity | 필드별 에러 메시지 |
#### 수락 기준 (Acceptance Criteria)
- [ ] 필터 없이 전체 할일 목록을 조회할 수 있다
- [ ] 완료/미완료 상태로 필터링할 수 있다
- [ ] 카테고리별로 필터링할 수 있다
- [ ] 우선순위별로 필터링할 수 있다
- [ ] 태그별로 필터링할 수 있다
- [ ] 페이지네이션이 정상 동작한다 (총 개수, 총 페이지 수 포함)
- [ ] 정렬 기준(생성일, 마감일, 우선순위)과 방향(오름차순/내림차순)을 지정할 수 있다
---
### F-003: 할일 상세 조회
- **설명**: 특정 할일의 전체 정보를 조회한다.
- **우선순위**: Must
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| id | string | Y | 유효한 ObjectId | 할일 ID |
#### 처리 규칙 (Business Rules)
1. 카테고리가 지정된 경우, 카테고리 이름과 색상을 함께 반환한다.
#### 출력 (Outputs)
| 상황 | HTTP 상태 | 응답 |
|------|-----------|------|
| 성공 | 200 OK | Todo 객체 (카테고리 정보 포함) |
| 미존재 | 404 Not Found | `{"detail": "할일을 찾을 수 없습니다"}` |
| 잘못된 ID 형식 | 422 Unprocessable Entity | `{"detail": "유효하지 않은 ID 형식입니다"}` |
#### 수락 기준 (Acceptance Criteria)
- [ ] 유효한 ID로 할일 상세 정보를 조회할 수 있다
- [ ] 카테고리 정보가 함께 반환된다
- [ ] 존재하지 않는 ID로 조회 시 404가 반환된다
---
### F-004: 할일 수정
- **설명**: 기존 할일의 제목, 내용, 카테고리, 태그, 우선순위, 마감일을 수정한다.
- **우선순위**: Must
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| id | string (path) | Y | 유효한 ObjectId | 할일 ID |
| title | string | N | 1~200자 | 할일 제목 |
| content | string | N | 최대 2000자 | 할일 상세 내용 |
| category_id | string \| null | N | 유효한 ObjectId 또는 null | 카테고리 (null이면 해제) |
| tags | string[] | N | 각 태그 1~30자, 최대 10개 | 태그 목록 |
| priority | enum | N | high / medium / low | 우선순위 |
| due_date | datetime \| null | N | null이면 해제 | 마감일 |
#### 처리 규칙 (Business Rules)
1. 요청에 포함된 필드만 업데이트한다 (Partial Update).
2. updated_at을 현재 시각(UTC)으로 갱신한다.
3. category_id가 지정된 경우 해당 카테고리의 존재를 검증한다.
4. tags 배열이 지정된 경우 중복 제거 및 소문자 정규화를 적용한다.
#### 출력 (Outputs)
| 상황 | HTTP 상태 | 응답 |
|------|-----------|------|
| 성공 | 200 OK | 수정된 Todo 객체 |
| 미존재 | 404 Not Found | `{"detail": "할일을 찾을 수 없습니다"}` |
| 검증 실패 | 422 Unprocessable Entity | 필드별 에러 메시지 |
#### 수락 기준 (Acceptance Criteria)
- [ ] 제목만 수정할 수 있다
- [ ] 카테고리를 변경할 수 있다
- [ ] 카테고리를 null로 설정하여 해제할 수 있다
- [ ] 태그를 추가/삭제할 수 있다
- [ ] 우선순위를 변경할 수 있다
- [ ] 마감일을 설정/해제할 수 있다
- [ ] 수정 후 updated_at이 갱신된다
- [ ] 존재하지 않는 할일 수정 시 404가 반환된다
---
### F-005: 할일 삭제
- **설명**: 특정 할일을 영구 삭제한다.
- **우선순위**: Must
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| id | string | Y | 유효한 ObjectId | 할일 ID |
#### 처리 규칙 (Business Rules)
1. 물리 삭제를 수행한다.
2. 삭제 전 확인 다이얼로그를 프론트엔드에서 표시한다.
#### 출력 (Outputs)
| 상황 | HTTP 상태 | 응답 |
|------|-----------|------|
| 성공 | 204 No Content | 빈 응답 |
| 미존재 | 404 Not Found | `{"detail": "할일을 찾을 수 없습니다"}` |
#### 수락 기준 (Acceptance Criteria)
- [ ] 할일을 삭제할 수 있다
- [ ] 삭제 전 확인 다이얼로그가 표시된다
- [ ] 삭제 후 목록에서 즉시 사라진다
- [ ] 존재하지 않는 할일 삭제 시 404가 반환된다
---
### F-006: 할일 완료 토글
- **설명**: 할일의 완료/미완료 상태를 전환한다.
- **우선순위**: Must
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| id | string (path) | Y | 유효한 ObjectId | 할일 ID |
#### 처리 규칙 (Business Rules)
1. 현재 completed 값의 반대값으로 토글한다.
2. updated_at을 갱신한다.
#### 출력 (Outputs)
| 상황 | HTTP 상태 | 응답 |
|------|-----------|------|
| 성공 | 200 OK | `{"id": "...", "completed": true/false}` |
| 미존재 | 404 Not Found | `{"detail": "할일을 찾을 수 없습니다"}` |
#### 수락 기준 (Acceptance Criteria)
- [ ] 체크박스 클릭으로 완료/미완료를 토글할 수 있다
- [ ] 완료된 할일은 시각적으로 구분된다 (취소선, 흐림 처리 등)
- [ ] 토글 후 대시보드 통계가 갱신된다
---
### F-007: 카테고리 생성
- **설명**: 할일 분류를 위한 새 카테고리를 생성한다.
- **우선순위**: Must
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| name | string | Y | 1~50자, 고유 | 카테고리 이름 |
| color | string | N | 유효한 hex color (기본: #6B7280) | 표시 색상 |
#### 처리 규칙 (Business Rules)
1. name 앞뒤 공백을 제거(trim)한다.
2. 동일한 이름의 카테고리가 이미 존재하면 409 Conflict를 반환한다.
3. order는 현재 최대값 + 1로 자동 설정한다.
#### 출력 (Outputs)
| 상황 | HTTP 상태 | 응답 |
|------|-----------|------|
| 성공 | 201 Created | 생성된 Category 객체 |
| 이름 중복 | 409 Conflict | `{"detail": "이미 존재하는 카테고리 이름입니다"}` |
| 검증 실패 | 422 Unprocessable Entity | 필드별 에러 메시지 |
#### 수락 기준 (Acceptance Criteria)
- [ ] 이름과 색상을 지정하여 카테고리를 생성할 수 있다
- [ ] 이름만으로 생성 시 기본 색상이 적용된다
- [ ] 중복 이름 생성 시 에러가 표시된다
- [ ] 생성 후 사이드바 카테고리 목록에 즉시 반영된다
---
### F-008: 카테고리 목록 조회
- **설명**: 전체 카테고리 목록을 조회한다.
- **우선순위**: Must
#### 입력 (Inputs)
없음 (파라미터 없이 전체 조회)
#### 처리 규칙 (Business Rules)
1. order 필드 기준 오름차순으로 정렬한다.
2. 각 카테고리에 속한 할일 수(todo_count)를 함께 반환한다.
#### 출력 (Outputs)
| 상황 | HTTP 상태 | 응답 |
|------|-----------|------|
| 성공 | 200 OK | `[{"_id": "...", "name": "...", "color": "...", "order": N, "todo_count": N}]` |
#### 수락 기준 (Acceptance Criteria)
- [ ] 전체 카테고리 목록을 조회할 수 있다
- [ ] 각 카테고리에 할일 수가 표시된다
- [ ] 정렬 순서대로 표시된다
---
### F-009: 카테고리 수정
- **설명**: 기존 카테고리의 이름, 색상, 순서를 수정한다.
- **우선순위**: Must
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| id | string (path) | Y | 유효한 ObjectId | 카테고리 ID |
| name | string | N | 1~50자, 고유 | 카테고리 이름 |
| color | string | N | 유효한 hex color | 표시 색상 |
| order | integer | N | >= 0 | 정렬 순서 |
#### 처리 규칙 (Business Rules)
1. 요청에 포함된 필드만 업데이트한다.
2. name 변경 시 중복 검사를 수행한다.
#### 출력 (Outputs)
| 상황 | HTTP 상태 | 응답 |
|------|-----------|------|
| 성공 | 200 OK | 수정된 Category 객체 |
| 미존재 | 404 Not Found | `{"detail": "카테고리를 찾을 수 없습니다"}` |
| 이름 중복 | 409 Conflict | `{"detail": "이미 존재하는 카테고리 이름입니다"}` |
#### 수락 기준 (Acceptance Criteria)
- [ ] 카테고리 이름을 변경할 수 있다
- [ ] 카테고리 색상을 변경할 수 있다
- [ ] 변경 사항이 해당 카테고리의 모든 할일 표시에 반영된다
- [ ] 중복 이름으로 변경 시 에러가 표시된다
---
### F-010: 카테고리 삭제
- **설명**: 카테고리를 삭제한다. 해당 카테고리에 속한 할일의 category_id는 null로 초기화된다.
- **우선순위**: Must
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| id | string (path) | Y | 유효한 ObjectId | 카테고리 ID |
#### 처리 규칙 (Business Rules)
1. 카테고리 삭제 시, 해당 카테고리에 속한 모든 할일의 category_id를 null로 변경한다.
2. 삭제 전 확인 다이얼로그에 영향받는 할일 수를 표시한다.
#### 출력 (Outputs)
| 상황 | HTTP 상태 | 응답 |
|------|-----------|------|
| 성공 | 204 No Content | 빈 응답 |
| 미존재 | 404 Not Found | `{"detail": "카테고리를 찾을 수 없습니다"}` |
#### 수락 기준 (Acceptance Criteria)
- [ ] 카테고리를 삭제할 수 있다
- [ ] 삭제 확인 다이얼로그에 영향받는 할일 수가 표시된다
- [ ] 삭제 후 해당 카테고리의 할일들이 "미분류"로 표시된다
- [ ] 사이드바에서 즉시 사라진다
---
### F-011: 태그 부여
- **설명**: 할일 생성 또는 수정 시 태그를 부여한다. 콤마로 구분하여 다중 태그를 입력한다.
- **우선순위**: Must
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| tags | string[] | N | 각 1~30자, 최대 10개, 특수문자 불가 | 태그 배열 |
#### 처리 규칙 (Business Rules)
1. F-001(할일 생성), F-004(할일 수정)에서 tags 필드로 처리된다.
2. 입력 태그를 소문자로 정규화하고, 중복을 제거한다.
3. 기존 태그 자동완성을 위해 사용 중인 태그 목록을 제공한다.
#### 출력 (Outputs)
F-001, F-004의 출력과 동일 (Todo 객체에 tags 포함)
#### 수락 기준 (Acceptance Criteria)
- [ ] 할일 생성/수정 폼에서 태그를 입력할 수 있다
- [ ] 기존 태그 자동완성이 동작한다
- [ ] 태그가 뱃지 형태로 표시된다
- [ ] 태그를 개별 삭제할 수 있다 (x 버튼)
---
### F-012: 태그별 필터링
- **설명**: 특정 태그가 부여된 할일만 필터링하여 조회한다.
- **우선순위**: Must
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| tag | string | Y | 존재하는 태그 | 필터링할 태그명 |
#### 처리 규칙 (Business Rules)
1. F-002(할일 목록 조회)의 tag 파라미터로 처리된다.
2. 태그 뱃지 클릭 시 해당 태그로 필터링된다.
#### 출력 (Outputs)
F-002의 출력과 동일
#### 수락 기준 (Acceptance Criteria)
- [ ] 태그 뱃지 클릭으로 해당 태그의 할일만 필터링된다
- [ ] 필터 적용 중 태그명이 표시된다
- [ ] 필터 해제 버튼으로 전체 목록으로 돌아갈 수 있다
---
### F-013: 태그 목록 조회
- **설명**: 현재 사용 중인 모든 태그의 목록과 사용 횟수를 조회한다.
- **우선순위**: Must
#### 입력 (Inputs)
없음
#### 처리 규칙 (Business Rules)
1. todos 컬렉션에서 tags 필드를 distinct로 추출한다.
2. 각 태그의 사용 횟수(count)를 집계한다.
3. 사용 횟수 내림차순으로 정렬한다.
#### 출력 (Outputs)
| 상황 | HTTP 상태 | 응답 |
|------|-----------|------|
| 성공 | 200 OK | `[{"name": "업무", "count": 5}, ...]` |
#### 수락 기준 (Acceptance Criteria)
- [ ] 사용 중인 모든 태그 목록을 조회할 수 있다
- [ ] 각 태그의 사용 횟수가 표시된다
- [ ] 할일이 없는 태그는 목록에 표시되지 않는다
---
### F-014: 우선순위 설정
- **설명**: 할일에 높음(high), 중간(medium), 낮음(low) 3단계 우선순위를 설정한다.
- **우선순위**: Must
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| priority | enum | N | high / medium / low | 우선순위 (기본: medium) |
#### 처리 규칙 (Business Rules)
1. F-001(할일 생성), F-004(할일 수정)의 priority 필드로 처리된다.
2. UI에서 색상으로 구분: high=빨강, medium=노랑, low=파랑.
#### 출력 (Outputs)
F-001, F-004의 출력과 동일
#### 수락 기준 (Acceptance Criteria)
- [ ] 할일 생성/수정 시 우선순위를 선택할 수 있다
- [ ] 우선순위별 색상 구분이 된다 (high=빨강, medium=노랑, low=파랑)
- [ ] 기본 우선순위는 medium이다
- [ ] 목록에서 우선순위별 필터링이 가능하다
---
### F-015: 마감일 설정
- **설명**: 할일에 마감일을 설정한다. 달력 UI로 날짜를 선택한다.
- **우선순위**: Must
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| due_date | datetime | N | ISO 8601 형식 | 마감일 |
#### 처리 규칙 (Business Rules)
1. F-001(할일 생성), F-004(할일 수정)의 due_date 필드로 처리된다.
2. null을 전송하면 마감일을 해제한다.
#### 출력 (Outputs)
F-001, F-004의 출력과 동일
#### 수락 기준 (Acceptance Criteria)
- [ ] 달력 UI로 마감일을 선택할 수 있다
- [ ] 마감일이 설정된 할일에 날짜가 표시된다
- [ ] 마감일을 해제할 수 있다
---
### F-016: 마감일 알림 표시
- **설명**: 마감일이 임박하거나 초과한 할일에 시각적 알림을 표시한다.
- **우선순위**: Must
#### 입력 (Inputs)
없음 (프론트엔드 렌더링 로직)
#### 처리 규칙 (Business Rules)
1. 마감일 1일 이내: 주황색 "임박" 뱃지 표시
2. 마감일 초과: 빨간색 "초과" 뱃지 표시
3. 마감일 3일 이내: 노란색 "곧 마감" 표시
4. 완료된 할일은 알림을 표시하지 않는다.
#### 출력 (Outputs)
UI 렌더링 (API 응답 없음)
#### 수락 기준 (Acceptance Criteria)
- [ ] 마감 3일 이내 할일에 "곧 마감" 표시가 된다
- [ ] 마감 1일 이내 할일에 "임박" 표시가 된다
- [ ] 마감 초과 할일에 "초과" 표시가 된다
- [ ] 완료된 할일에는 알림이 표시되지 않는다
- [ ] 뱃지 색상이 긴급도에 따라 구분된다 (노랑/주황/빨강)
---
### F-017: 검색
- **설명**: 제목, 내용, 태그를 기반으로 할일을 검색한다.
- **우선순위**: Should
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| q | string | Y | 1~200자 | 검색 키워드 |
| page | integer | N | >= 1, 기본: 1 | 페이지 번호 |
| limit | integer | N | 1~100, 기본: 20 | 페이지당 항목 수 |
#### 처리 규칙 (Business Rules)
1. MongoDB text index를 활용한 전문 검색을 수행한다.
2. 검색 대상: title, content, tags 필드.
3. 검색 결과는 관련도(text score) 기준으로 정렬한다.
4. 검색어가 태그와 정확히 일치하는 경우 해당 태그의 할일을 우선 표시한다.
#### 출력 (Outputs)
| 상황 | HTTP 상태 | 응답 |
|------|-----------|------|
| 성공 | 200 OK | `{"items": [...], "total": N, "query": "...", "page": N, "limit": N}` |
| 검색어 누락 | 422 Unprocessable Entity | `{"detail": "검색어를 입력해주세요"}` |
#### 수락 기준 (Acceptance Criteria)
- [ ] 헤더 검색바에 키워드를 입력하여 검색할 수 있다
- [ ] 제목에 포함된 키워드로 검색된다
- [ ] 내용에 포함된 키워드로 검색된다
- [ ] 태그명으로 검색된다
- [ ] 검색 결과가 관련도순으로 정렬된다
- [ ] 검색 결과가 없을 때 "결과 없음" 메시지가 표시된다
- [ ] 검색 결과에서 검색어가 하이라이트된다
---
### F-018: 대시보드 통계
- **설명**: 할일 현황을 한눈에 파악할 수 있는 대시보드 통계를 제공한다.
- **우선순위**: Should
#### 입력 (Inputs)
없음
#### 처리 규칙 (Business Rules)
1. 통계 항목:
- 전체 할일 수, 완료 수, 미완료 수
- 완료율 (%)
- 카테고리별 할일 분포 (도넛 차트)
- 우선순위별 현황 (가로 막대 차트)
- 마감 임박 할일 목록 (상위 5개)
2. Redis에 60초 TTL로 캐싱한다.
3. 캐시 무효화: 할일 CUD 작업 시.
#### 출력 (Outputs)
| 상황 | HTTP 상태 | 응답 |
|------|-----------|------|
| 성공 | 200 OK | 아래 JSON 구조 참고 |
```json
{
"overview": {
"total": 50,
"completed": 30,
"incomplete": 20,
"completion_rate": 60.0
},
"by_category": [
{"category_id": "...", "name": "업무", "color": "#EF4444", "count": 15}
],
"by_priority": {
"high": 10,
"medium": 25,
"low": 15
},
"upcoming_deadlines": [
{"id": "...", "title": "...", "due_date": "...", "priority": "high"}
]
}
```
#### 수락 기준 (Acceptance Criteria)
- [ ] 전체/완료/미완료 수가 카드 형태로 표시된다
- [ ] 완료율이 퍼센트로 표시된다
- [ ] 카테고리별 할일 분포가 도넛 차트로 표시된다
- [ ] 우선순위별 현황이 막대 차트로 표시된다
- [ ] 마감 임박 할일 상위 5개가 리스트로 표시된다
- [ ] 데이터가 없을 때 빈 상태 안내가 표시된다
---
### F-019: 일괄 완료 처리
- **설명**: 여러 할일을 선택하여 한번에 완료 처리한다.
- **우선순위**: Should
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| action | string | Y | "complete" | 작업 유형 |
| ids | string[] | Y | 1개 이상의 유효한 ObjectId | 대상 할일 ID 배열 |
#### 처리 규칙 (Business Rules)
1. 선택된 모든 할일의 completed를 true로 설정한다.
2. 각 항목의 updated_at을 갱신한다.
3. 존재하지 않는 ID는 무시하고 나머지를 처리한다.
4. 처리된 수와 실패 수를 반환한다.
#### 출력 (Outputs)
| 상황 | HTTP 상태 | 응답 |
|------|-----------|------|
| 성공 | 200 OK | `{"action": "complete", "processed": 5, "failed": 0}` |
| ID 누락 | 422 Unprocessable Entity | `{"detail": "대상 할일을 선택해주세요"}` |
#### 수락 기준 (Acceptance Criteria)
- [ ] 체크박스로 여러 할일을 선택할 수 있다
- [ ] "일괄 완료" 버튼으로 선택된 할일을 한번에 완료할 수 있다
- [ ] 처리 결과(성공 수)가 토스트로 표시된다
- [ ] 처리 후 목록이 갱신된다
---
### F-020: 일괄 삭제
- **설명**: 여러 할일을 선택하여 한번에 삭제한다.
- **우선순위**: Should
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| action | string | Y | "delete" | 작업 유형 |
| ids | string[] | Y | 1개 이상의 유효한 ObjectId | 대상 할일 ID 배열 |
#### 처리 규칙 (Business Rules)
1. 선택된 모든 할일을 물리 삭제한다.
2. 삭제 전 확인 다이얼로그를 표시한다.
3. 존재하지 않는 ID는 무시한다.
#### 출력 (Outputs)
| 상황 | HTTP 상태 | 응답 |
|------|-----------|------|
| 성공 | 200 OK | `{"action": "delete", "processed": 3, "failed": 0}` |
| ID 누락 | 422 Unprocessable Entity | `{"detail": "대상 할일을 선택해주세요"}` |
#### 수락 기준 (Acceptance Criteria)
- [ ] "일괄 삭제" 버튼으로 선택된 할일을 한번에 삭제할 수 있다
- [ ] 삭제 전 "N개의 할일을 삭제하시겠습니까?" 확인 다이얼로그가 표시된다
- [ ] 처리 결과가 토스트로 표시된다
- [ ] 삭제 후 목록이 갱신된다
---
### F-021: 일괄 카테고리 변경
- **설명**: 여러 할일을 선택하여 한번에 카테고리를 변경한다.
- **우선순위**: Should
#### 입력 (Inputs)
| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
|--------|------|------|-----------|------|
| action | string | Y | "move_category" | 작업 유형 |
| ids | string[] | Y | 1개 이상의 유효한 ObjectId | 대상 할일 ID 배열 |
| category_id | string \| null | Y | 유효한 카테고리 ObjectId 또는 null | 변경할 카테고리 |
#### 처리 규칙 (Business Rules)
1. 선택된 모든 할일의 category_id를 지정된 값으로 변경한다.
2. category_id가 null이면 "미분류"로 설정한다.
3. 대상 카테고리의 존재를 검증한다.
#### 출력 (Outputs)
| 상황 | HTTP 상태 | 응답 |
|------|-----------|------|
| 성공 | 200 OK | `{"action": "move_category", "processed": 4, "failed": 0}` |
| 카테고리 미존재 | 404 Not Found | `{"detail": "카테고리를 찾을 수 없습니다"}` |
#### 수락 기준 (Acceptance Criteria)
- [ ] 카테고리 선택 드롭다운으로 일괄 변경할 수 있다
- [ ] "미분류"로 일괄 변경할 수 있다
- [ ] 처리 결과가 토스트로 표시된다
- [ ] 변경 후 목록이 갱신된다
---
## 3. API 엔드포인트 요약
| Method | Path | 기능 ID | 설명 |
|--------|------|---------|------|
| POST | `/api/todos` | F-001 | 할일 생성 |
| GET | `/api/todos` | F-002 | 할일 목록 조회 (필터/정렬/페이지네이션) |
| GET | `/api/todos/{id}` | F-003 | 할일 상세 조회 |
| PUT | `/api/todos/{id}` | F-004 | 할일 수정 |
| DELETE | `/api/todos/{id}` | F-005 | 할일 삭제 |
| PATCH | `/api/todos/{id}/toggle` | F-006 | 할일 완료 토글 |
| POST | `/api/categories` | F-007 | 카테고리 생성 |
| GET | `/api/categories` | F-008 | 카테고리 목록 조회 |
| PUT | `/api/categories/{id}` | F-009 | 카테고리 수정 |
| DELETE | `/api/categories/{id}` | F-010 | 카테고리 삭제 |
| GET | `/api/tags` | F-013 | 태그 목록 조회 |
| GET | `/api/search` | F-017 | 검색 |
| GET | `/api/dashboard/stats` | F-018 | 대시보드 통계 |
| POST | `/api/todos/batch` | F-019, F-020, F-021 | 일괄 작업 (완료/삭제/카테고리 변경) |

259
docs/PLAN.md Normal file
View File

@ -0,0 +1,259 @@
# todos2 - 전략 기획안 (Strategic Plan)
> 버전: 1.0.0 | 작성일: 2026-02-10 | 상태: 기획 완료
## 1. 프로젝트 개요
**todos2**는 단순 할일 관리를 넘어, 카테고리/태그/우선순위/마감일/검색/대시보드 기능을 갖춘 확장형 할일 관리 애플리케이션이다.
### 1.1 목표
- 할일의 체계적 분류 및 관리 (카테고리, 태그, 우선순위)
- 마감일 기반 일정 관리 및 시각적 알림
- 검색 및 필터링을 통한 빠른 할일 탐색
- 대시보드를 통한 진행 현황 한눈에 파악
- 일괄 작업을 통한 효율적인 다중 할일 처리
### 1.2 대상 사용자
- 개인 할일 관리가 필요한 사용자
- 프로젝트별/카테고리별 작업 분류가 필요한 사용자
---
## 2. 핵심 기능
| # | 기능 | 설명 | 우선순위 |
|---|------|------|---------|
| 1 | 할일 CRUD | 할일 생성, 조회, 수정, 삭제 | Must |
| 2 | 카테고리 관리 | 카테고리 CRUD, 할일별 카테고리 분류 | Must |
| 3 | 태그 시스템 | 할일에 다중 태그 부여, 태그별 필터링 | Must |
| 4 | 우선순위 | 높음/중간/낮음 3단계 우선순위 설정 | Must |
| 5 | 마감일 관리 | 마감일 설정, 임박/초과 알림 표시 | Must |
| 6 | 검색 | 제목/내용/태그 기반 전문 검색 | Should |
| 7 | 대시보드 | 완료율, 카테고리별/우선순위별 통계 차트 | Should |
| 8 | 일괄 작업 | 다중 선택 후 완료/삭제/카테고리 변경 | Should |
---
## 3. 기술 스택
### 3.1 백엔드
| 기술 | 버전 | 용도 |
|------|------|------|
| Python | 3.11 | 런타임 |
| FastAPI | >= 0.104 | REST API 프레임워크 |
| Motor | >= 3.3 | MongoDB 비동기 드라이버 |
| Pydantic v2 | >= 2.5 | 데이터 검증 및 직렬화 |
| Uvicorn | >= 0.24 | ASGI 서버 |
| Redis (aioredis) | >= 2.0 | 캐싱 (대시보드 통계) |
### 3.2 프론트엔드
| 기술 | 버전 | 용도 |
|------|------|------|
| Next.js | 15 (App Router) | React 프레임워크 |
| TypeScript | 5.x | 타입 안정성 |
| Tailwind CSS | 4.x | 유틸리티 기반 스타일링 |
| shadcn/ui | latest | UI 컴포넌트 라이브러리 |
| Recharts | 2.x | 대시보드 차트 |
| Tanstack Query | 5.x | 서버 상태 관리 및 캐싱 |
| Zustand | 5.x | 클라이언트 상태 관리 |
### 3.3 인프라
| 기술 | 용도 |
|------|------|
| MongoDB 7.0 | 메인 데이터베이스 |
| Redis 7 | 캐싱 레이어 |
| Docker Compose | 로컬 개발 환경 |
---
## 4. 프로젝트 구조
```
todos2/
├── docker-compose.yml
├── CLAUDE.md
├── PLAN.md
├── FEATURE_SPEC.md
├── SCREEN_DESIGN.pptx
├── SCREEN_DESIGN.md
├── backend/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── app/
│ ├── __init__.py
│ ├── main.py # FastAPI 앱 진입점
│ ├── database.py # MongoDB 연결 설정
│ ├── models/
│ │ ├── __init__.py
│ │ ├── todo.py # Todo 모델
│ │ ├── category.py # Category 모델
│ │ └── tag.py # Tag 모델
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── todos.py # 할일 CRUD API
│ │ ├── categories.py # 카테고리 API
│ │ ├── tags.py # 태그 API
│ │ ├── search.py # 검색 API
│ │ ├── dashboard.py # 대시보드 통계 API
│ │ └── batch.py # 일괄 작업 API
│ └── services/
│ ├── __init__.py
│ ├── todo_service.py
│ ├── category_service.py
│ ├── tag_service.py
│ ├── search_service.py
│ └── dashboard_service.py
└── frontend/
├── Dockerfile
├── package.json
├── next.config.ts
├── tailwind.config.ts
├── tsconfig.json
└── src/
├── app/
│ ├── layout.tsx # 루트 레이아웃
│ ├── page.tsx # 대시보드 (메인)
│ ├── todos/
│ │ └── page.tsx # 할일 목록
│ ├── categories/
│ │ └── page.tsx # 카테고리 관리
│ └── search/
│ └── page.tsx # 검색 결과
├── components/
│ ├── layout/
│ │ ├── Header.tsx
│ │ ├── Sidebar.tsx
│ │ └── MainLayout.tsx
│ ├── todos/
│ │ ├── TodoCard.tsx
│ │ ├── TodoForm.tsx
│ │ ├── TodoList.tsx
│ │ ├── TodoFilter.tsx
│ │ └── BatchActions.tsx
│ ├── categories/
│ │ ├── CategoryList.tsx
│ │ └── CategoryForm.tsx
│ ├── tags/
│ │ ├── TagBadge.tsx
│ │ ├── TagSelect.tsx
│ │ └── TagManager.tsx
│ ├── dashboard/
│ │ ├── StatsCards.tsx
│ │ ├── CompletionChart.tsx
│ │ ├── CategoryChart.tsx
│ │ └── PriorityChart.tsx
│ └── search/
│ ├── SearchBar.tsx
│ └── SearchResults.tsx
├── hooks/
│ ├── useTodos.ts
│ ├── useCategories.ts
│ ├── useTags.ts
│ ├── useDashboard.ts
│ └── useSearch.ts
├── lib/
│ ├── api.ts # API 클라이언트
│ └── utils.ts # 유틸리티 함수
├── store/
│ └── uiStore.ts # UI 상태 (사이드바, 필터 등)
└── types/
└── index.ts # TypeScript 타입 정의
```
---
## 5. 데이터 모델
### 5.1 Todo
```json
{
"_id": "ObjectId",
"title": "string (필수, 1~200자)",
"content": "string (선택, 최대 2000자)",
"completed": "boolean (기본: false)",
"priority": "enum: high | medium | low (기본: medium)",
"category_id": "ObjectId | null",
"tags": ["string"],
"due_date": "datetime | null",
"created_at": "datetime",
"updated_at": "datetime"
}
```
### 5.2 Category
```json
{
"_id": "ObjectId",
"name": "string (필수, 1~50자, 고유)",
"color": "string (hex color, 기본: #6B7280)",
"order": "integer (정렬 순서)",
"created_at": "datetime"
}
```
### 5.3 Tag (인라인)
태그는 별도 컬렉션 없이 Todo 문서 내 배열로 관리한다.
태그 목록은 todos 컬렉션에서 distinct 쿼리로 추출한다.
---
## 6. 개발 우선순위 (Phase별)
### Phase 1: 핵심 기반 (MVP)
> 목표: 기본 할일 관리가 가능한 최소 기능 제품
1. 프로젝트 초기 세팅 (백엔드/프론트엔드 스캐폴딩)
2. MongoDB 연결 및 데이터 모델 정의
3. 할일 CRUD API + UI
4. 카테고리 CRUD API + UI
5. 기본 레이아웃 (Header, Sidebar, MainLayout)
### Phase 2: 확장 기능
> 목표: 분류/필터/우선순위로 할일 관리 고도화
6. 태그 시스템 (태그 입력, 표시, 필터링)
7. 우선순위 설정 (높음/중간/낮음)
8. 마감일 설정 및 임박/초과 알림 UI
9. 검색 기능 (제목/내용/태그)
### Phase 3: 분석 및 효율
> 목표: 통계 및 일괄 작업으로 생산성 향상
10. 대시보드 통계 API
11. 대시보드 차트 UI (완료율, 카테고리별, 우선순위별)
12. 일괄 작업 (다중 선택, 일괄 완료/삭제/카테고리 변경)
---
## 7. API 설계 요약
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET | `/api/todos` | 할일 목록 (필터/페이지네이션) |
| POST | `/api/todos` | 할일 생성 |
| GET | `/api/todos/{id}` | 할일 상세 |
| PUT | `/api/todos/{id}` | 할일 수정 |
| DELETE | `/api/todos/{id}` | 할일 삭제 |
| POST | `/api/todos/batch` | 일괄 작업 |
| GET | `/api/categories` | 카테고리 목록 |
| POST | `/api/categories` | 카테고리 생성 |
| PUT | `/api/categories/{id}` | 카테고리 수정 |
| DELETE | `/api/categories/{id}` | 카테고리 삭제 |
| GET | `/api/tags` | 사용 중인 태그 목록 |
| GET | `/api/search` | 검색 |
| GET | `/api/dashboard/stats` | 대시보드 통계 |
---
## 8. 비기능 요구사항
| 항목 | 기준 |
|------|------|
| API 응답 시간 | 95% 요청 200ms 이내 |
| 페이지네이션 | 기본 20건, 최대 100건 |
| 검색 | MongoDB text index 활용 |
| 캐싱 | 대시보드 통계 Redis 캐시 (TTL 60초) |
| CORS | 프론트엔드 도메인 허용 |
| 에러 처리 | 표준 HTTP 상태 코드 + JSON 에러 응답 |

312
docs/SCREEN_DESIGN.md Normal file
View File

@ -0,0 +1,312 @@
# todos2 — 화면설계서
> 자동 생성: `pptx_to_md.py` | 원본: `SCREEN_DESIGN.pptx`
> 생성 시각: 2026-02-10 07:12
> **이 파일을 직접 수정하지 마세요. PPTX를 수정 후 스크립트를 재실행하세요.**
## 페이지 목록
| ID | 페이지명 | 경로 | 설명 |
|-----|---------|------|------|
| P-001 | 대시보드 | `/` | 메인 페이지. 통계 카드, 차트, 마감 임박 목록 |
| P-002 | 할일 목록 | `/todos` | 할일 CRUD, 필터링, 정렬, 일괄 작업 |
| P-003 | 할일 상세/편집 | `/todos/[id]` | 할일 상세 보기 및 수정 폼 |
| P-004 | 카테고리 관리 | `/categories` | 카테고리 CRUD, 색상 지정 |
| P-005 | 검색 결과 | `/search` | 제목/내용/태그 기반 검색 결과 |
---
## P-001: 대시보드 (`/`)
### 레이아웃
[로고 todos2] | [검색바 _______________] | [알림]
● 대시보드
할일 목록
카테고리 관리
카테고리
업무 (12)
개인 (8)
학습 (5)
인기 태그
#긴급
#회의
#프로젝트
전체 할일 50
완료 30
미완료 20
완료율 60%
[카테고리별 분포 - 도넛 차트] 업무 40% | 개인 30% 학습 20% | 기타 10%
[우선순위별 현황 - 막대 차트] high: 10 | medium: 25 | low: 15
[마감 임박 할일] 1. API 문서 작성 (D-1) 2. 디자인 리뷰 (D-2) 3. 테스트 코드 (D-3)
| 컴포넌트 | 기능 | 상태 |
| --- | --- | --- |
| StatsCards | 전체/완료/미완료/완료율 카드 | loading, data, empty |
| CategoryChart | 카테고리별 도넛 차트 | loading, data, empty |
| PriorityChart | 우선순위별 막대 차트 | loading, data, empty |
| UpcomingDeadlines | 마감 임박 할일 Top 5 | loading, data, empty |
| Sidebar | 카테고리/태그 네비게이션 | default |
### 컴포넌트
| 컴포넌트 | Props | 상태 |
|---------|-------|------|
| `StatsCards` | stats | loading, empty, data |
| `CategoryChart` | categoryData | loading, empty, data |
| `PriorityChart` | priorityData | loading, empty, data |
| `UpcomingDeadlines` | deadlines, onItemClick | loading, empty, data |
| `Sidebar` | categories, tags, activePath | default |
### 인터랙션
| 트리거 | 동작 | 결과 |
|--------|------|------|
| 마감 임박 항목 클릭 | `router.push(/todos/{id})` | 해당 할일 상세 페이지로 이동 |
| 사이드바 카테고리 클릭 | `router.push(/todos?category_id={id})` | 해당 카테고리의 할일 목록으로 이동 |
| 사이드바 태그 클릭 | `router.push(/todos?tag={name})` | 해당 태그의 할일 목록으로 이동 |
### 반응형: sm, md, lg
---
## P-002: 할일 목록 (`/todos`)
### 레이아웃
[로고 todos2] | [검색바 _______________] | [알림]
대시보드
● 할일 목록
카테고리 관리
필터:
상태 ▾
우선순위 ▾
정렬 ▾
+ 새 할일
3개 선택됨
일괄 완료
카테고리 변경
일괄 삭제
API 문서 작성
업무
#긴급
D-1
[수정] [삭제]
회의록 정리
업무
#회의
D-5
[수정] [삭제]
Next.js 학습
학습
#학습
D-7
[수정] [삭제]
장보기 목록 작성
개인
#생활
-
[수정] [삭제]
< 1 2 3 4 5 >
| 컴포넌트 | 기능 | 상태 |
| --- | --- | --- |
| TodoFilter | 상태/우선순위/정렬 필터 | default, applied |
| TodoList | 할일 카드 리스트 | loading, empty, error, data |
| TodoCard | 개별 할일 행 | default, completed, overdue |
| BatchActions | 일괄 작업 바 | hidden, visible |
| TodoForm (Modal) | 할일 생성/수정 모달 | create, edit |
| Pagination | 페이지 네비게이션 | default |
### 컴포넌트
| 컴포넌트 | Props | 상태 |
|---------|-------|------|
| `TodoFilter` | filters, onFilterChange | default, applied |
| `TodoList` | todos, selectedIds, onToggle, onSelect, onEdit, onDelete | loading, empty, error, data |
| `TodoCard` | todo, isSelected, onToggle, onSelect, onEdit, onDelete | default, completed, overdue |
| `BatchActions` | selectedIds, categories, onBatchComplete, onBatchDelete, onBatchMove | hidden, visible |
| `TodoForm` | mode, todo, categories, tags, onSubmit, onClose | create, edit |
| `Pagination` | currentPage, totalPages, onPageChange | default |
### 인터랙션
| 트리거 | 동작 | 결과 |
|--------|------|------|
| "+ 새 할일" 버튼 클릭 | `openTodoForm(mode='create')` | 할일 생성 모달 열림 |
| 체크박스 클릭 | `toggleTodo(id)` | 완료 상태 토글 |
| 행 선택 체크박스 | `toggleSelect(id)` | 일괄 작업 대상에 추가/제거 |
| 필터 변경 | `applyFilter(filters)` | 목록 재조회 |
| "일괄 완료" 클릭 | `batchComplete(selectedIds)` | 선택된 할일 일괄 완료 |
| "일괄 삭제" 클릭 | `batchDelete(selectedIds)` | 확인 후 일괄 삭제 |
| "카테고리 변경" 클릭 | `batchMoveCategory(selectedIds, categoryId)` | 카테고리 선택 후 변경 |
| 태그 뱃지 클릭 | `applyFilter({tag: tagName})` | 해당 태그로 필터링 |
### 반응형: sm, md, lg
---
## P-003: 할일 상세/편집 (`/todos/[id]`)
### 레이아웃
[로고 todos2] | [검색바] | [알림]
(Sidebar)
할일 목록 > API 문서 작성
제목 *
API 문서 작성
내용
Swagger UI 기반 API 문서를 작성하고 엔드포인트별 요청/응답 예시를 추가한다.
카테고리
업무 ▾
우선순위
높음 ▾
마감일
2026-02-11 📅
태그
#긴급 ×
#문서 ×
태그 입력 (자동완성)...
취소
저장
| 컴포넌트 | 기능 | 상태 |
| --- | --- | --- |
| TodoDetailForm | 할일 상세 폼 | loading, view, edit, saving |
| CategorySelect | 카테고리 드롭다운 | default |
| PrioritySelect | 우선순위 드롭다운 (색상) | default |
| DatePicker | 달력 마감일 선택 | default, open |
| TagInput | 태그 입력 + 자동완성 + 뱃지 | default, suggesting |
### 컴포넌트
| 컴포넌트 | Props | 상태 |
|---------|-------|------|
| `TodoDetailForm` | todo, categories, tags, onSave, onCancel, onDelete | loading, view, edit, saving |
| `CategorySelect` | categories, selectedId, onChange | default |
| `PrioritySelect` | selectedPriority, onChange | default |
| `DatePicker` | selectedDate, onChange | default, open |
| `TagInput` | tags, suggestions, onAdd, onRemove | default, suggesting |
### 인터랙션
| 트리거 | 동작 | 결과 |
|--------|------|------|
| "저장" 버튼 클릭 | `updateTodo(id, formData)` | 할일 업데이트 후 성공 토스트 |
| "취소" 버튼 클릭 | `router.back()` | 이전 페이지로 이동 |
| 태그 입력 키워드 타이핑 | `fetchTagSuggestions(keyword)` | 자동완성 드롭다운 표시 |
| 태그 뱃지 × 클릭 | `removeTag(tagName)` | 태그 제거 |
| 달력 아이콘 클릭 | `openDatePicker()` | 달력 팝업 표시 |
### 반응형: sm, md, lg
---
## P-004: 카테고리 관리 (`/categories`)
### 레이아웃
[로고 todos2] | [검색바] | [알림]
대시보드
할일 목록
● 카테고리 관리
카테고리 관리
+ 새 카테고리
업무
12개 할일
[색상] [수정] [삭제]
개인
8개 할일
[색상] [수정] [삭제]
학습
5개 할일
[색상] [수정] [삭제]
건강
3개 할일
[색상] [수정] [삭제]
새 카테고리 이름...
추가
| 컴포넌트 | 기능 | 상태 |
| --- | --- | --- |
| CategoryList | 카테고리 목록 | loading, empty, data |
| CategoryItem | 개별 카테고리 행 | default, editing |
| CategoryForm | 카테고리 생성/수정 인라인 폼 | create, edit |
| ColorPicker | 카테고리 색상 선택기 | default, open |
### 컴포넌트
| 컴포넌트 | Props | 상태 |
|---------|-------|------|
| `CategoryList` | categories, onEdit, onDelete | loading, empty, data |
| `CategoryItem` | category, onEdit, onDelete | default, editing |
| `CategoryForm` | mode, category, onSubmit, onCancel | create, edit |
| `ColorPicker` | selectedColor, onChange | default, open |
### 인터랙션
| 트리거 | 동작 | 결과 |
|--------|------|------|
| "+ 새 카테고리" 버튼 클릭 | `showCategoryForm(mode='create')` | 인라인 생성 폼 표시 |
| "추가" 버튼 클릭 | `createCategory({name, color})` | 카테고리 생성 후 목록 갱신 |
| "수정" 클릭 | `showCategoryForm(mode='edit', category)` | 해당 행이 수정 폼으로 전환 |
| "삭제" 클릭 | `deleteCategory(id)` | 확인 다이얼로그 후 삭제 |
| 색상 변경 클릭 | `openColorPicker(category)` | 색상 선택기 팝업 |
### 반응형: sm, md, lg
---
## P-005: 검색 결과 (`/search`)
### 레이아웃
todos2
API 문서 [×]
[알림]
(Sidebar)
"API 문서" 검색 결과 (3건)
API 문서 작성
Swagger UI 기반 API 문서를 작성하고...
업무
D-1
API 문서 리뷰
팀원들과 API 문서 리뷰 미팅을...
업무
D-5
REST API 문서화 학습
OpenAPI 스펙과 자동화 도구를...
학습
D-14
* 결과 없을 때: "검색 결과가 없습니다. 다른 키워드로 검색해보세요."
| 컴포넌트 | 기능 | 상태 |
| --- | --- | --- |
| SearchBar | 헤더 내 검색 입력 + 클리어 | default, active, has_query |
| SearchResults | 검색 결과 리스트 | loading, empty, data |
| SearchResultItem | 개별 결과 (제목 하이라이트, 설명) | default |
| Pagination | 결과 페이지네이션 | default |
### 컴포넌트
| 컴포넌트 | Props | 상태 |
|---------|-------|------|
| `SearchBar` | query, onSearch, onClear | default, active, has_query |
| `SearchResults` | results, query, total, onItemClick | loading, empty, data |
| `SearchResultItem` | result, query, onClick | default |
| `Pagination` | currentPage, totalPages, onPageChange | default |
### 인터랙션
| 트리거 | 동작 | 결과 |
|--------|------|------|
| 검색바에 키워드 입력 후 Enter | `search(query)` | 검색 API 호출 후 결과 표시 |
| 검색바 × 버튼 클릭 | `clearSearch()` | 검색어 클리어, 이전 페이지로 이동 |
| 검색 결과 항목 클릭 | `router.push(/todos/{id})` | 해당 할일 상세 페이지로 이동 |
| 페이지 번호 클릭 | `search(query, page)` | 해당 페이지의 검색 결과 |
### 반응형: sm, md, lg

BIN
docs/SCREEN_DESIGN.pptx Normal file

Binary file not shown.

320
docs/TEST_REPORT.md Normal file
View File

@ -0,0 +1,320 @@
# todos2 -- 테스트 보고서
> 테스트 일시: 2026-02-10 10:30 KST
> 테스트 환경: macOS Darwin 25.2.0, Python 3.11, Node 20
> 테스터: Senior System Tester + DevOps (Claude Opus 4.6)
---
## 1. 백엔드 테스트
### 1.1 구문 검증 (Python AST)
| 파일 | 결과 |
|------|------|
| `app/__init__.py` | OK |
| `app/config.py` | OK |
| `app/database.py` | OK |
| `app/main.py` | OK |
| `app/models/__init__.py` | OK |
| `app/models/category.py` | OK |
| `app/models/common.py` | OK |
| `app/models/todo.py` | OK |
| `app/routers/__init__.py` | OK |
| `app/routers/categories.py` | OK |
| `app/routers/dashboard.py` | OK |
| `app/routers/search.py` | OK |
| `app/routers/tags.py` | OK |
| `app/routers/todos.py` | OK |
| `app/services/__init__.py` | OK |
| `app/services/category_service.py` | OK |
| `app/services/dashboard_service.py` | OK |
| `app/services/search_service.py` | OK |
| `app/services/todo_service.py` | OK |
**결과: 19/19 파일 통과 (100%)**
### 1.2 Import 정합성
| 파일 | Import | 참조 대상 | 결과 |
|------|--------|----------|------|
| `app/main.py` | `from contextlib import asynccontextmanager` | stdlib | OK |
| `app/main.py` | `from datetime import datetime` | stdlib | OK |
| `app/main.py` | `from fastapi import FastAPI` | fastapi (requirements.txt) | OK |
| `app/main.py` | `from fastapi.middleware.cors import CORSMiddleware` | fastapi | OK |
| `app/main.py` | `from app.config import get_settings` | app/config.py | OK |
| `app/main.py` | `from app.database import connect_db, disconnect_db` | app/database.py | OK |
| `app/main.py` | `from app.routers import todos, categories, tags, search, dashboard` | app/routers/*.py | OK |
| `app/config.py` | `from pydantic_settings import BaseSettings` | pydantic-settings (requirements.txt) | OK |
| `app/config.py` | `from pydantic import Field` | pydantic (requirements.txt) | OK |
| `app/config.py` | `from functools import lru_cache` | stdlib | OK |
| `app/database.py` | `from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase` | motor (requirements.txt) | OK |
| `app/database.py` | `import redis.asyncio as aioredis` | redis (requirements.txt) | OK |
| `app/database.py` | `from app.config import get_settings` | app/config.py | OK |
| `app/models/common.py` | `from bson import ObjectId` | pymongo (requirements.txt) | OK |
| `app/models/common.py` | `from pydantic import BaseModel, BeforeValidator` | pydantic | OK |
| `app/models/todo.py` | `from datetime import datetime` | stdlib | OK |
| `app/models/todo.py` | `from enum import Enum` | stdlib | OK |
| `app/models/todo.py` | `from pydantic import BaseModel, Field, field_validator` | pydantic | OK |
| `app/models/todo.py` | `from bson import ObjectId` (inside validator) | pymongo | OK |
| `app/models/category.py` | `from datetime import datetime` | stdlib | OK |
| `app/models/category.py` | `from pydantic import BaseModel, Field, field_validator` | pydantic | OK |
| `app/models/__init__.py` | `from app.models.common import ...` | app/models/common.py | OK |
| `app/models/__init__.py` | `from app.models.todo import ...` | app/models/todo.py | OK |
| `app/models/__init__.py` | `from app.models.category import ...` | app/models/category.py | OK |
| `app/routers/__init__.py` | `from app.routers import todos, categories, tags, search, dashboard` | app/routers/*.py | OK |
| `app/routers/todos.py` | `from fastapi import APIRouter, Depends, Query, Response, status` | fastapi | OK |
| `app/routers/todos.py` | `from app.database import get_database, get_redis` | app/database.py | OK |
| `app/routers/todos.py` | `from app.models.todo import ...` | app/models/todo.py | OK |
| `app/routers/todos.py` | `from app.services.todo_service import TodoService` | app/services/todo_service.py | OK |
| `app/routers/categories.py` | `from app.database import get_database, get_redis` | app/database.py | OK |
| `app/routers/categories.py` | `from app.models.category import ...` | app/models/category.py | OK |
| `app/routers/categories.py` | `from app.services.category_service import CategoryService` | app/services/category_service.py | OK |
| `app/routers/tags.py` | `from app.database import get_database` | app/database.py | OK |
| `app/routers/tags.py` | `from app.models.todo import TagInfo` | app/models/todo.py | OK |
| `app/routers/tags.py` | `from app.services.todo_service import TodoService` | app/services/todo_service.py | OK |
| `app/routers/search.py` | `from app.database import get_database` | app/database.py | OK |
| `app/routers/search.py` | `from app.models.todo import SearchResponse` | app/models/todo.py | OK |
| `app/routers/search.py` | `from app.services.search_service import SearchService` | app/services/search_service.py | OK |
| `app/routers/dashboard.py` | `from app.database import get_database, get_redis` | app/database.py | OK |
| `app/routers/dashboard.py` | `from app.models.todo import DashboardStats` | app/models/todo.py | OK |
| `app/routers/dashboard.py` | `from app.services.dashboard_service import DashboardService` | app/services/dashboard_service.py | OK |
| `app/services/__init__.py` | `from app.services.todo_service import TodoService` | app/services/todo_service.py | OK |
| `app/services/__init__.py` | `from app.services.category_service import CategoryService` | app/services/category_service.py | OK |
| `app/services/__init__.py` | `from app.services.search_service import SearchService` | app/services/search_service.py | OK |
| `app/services/__init__.py` | `from app.services.dashboard_service import DashboardService` | app/services/dashboard_service.py | OK |
| `app/services/todo_service.py` | `from bson import ObjectId` | pymongo | OK |
| `app/services/todo_service.py` | `from fastapi import HTTPException` | fastapi | OK |
| `app/services/todo_service.py` | `from motor.motor_asyncio import AsyncIOMotorDatabase` | motor | OK |
| `app/services/todo_service.py` | `import redis.asyncio as aioredis` | redis | OK |
| `app/services/todo_service.py` | `from app.models.todo import ...` | app/models/todo.py | OK |
| `app/services/category_service.py` | `from app.models.category import ...` | app/models/category.py | OK |
| `app/services/search_service.py` | `from app.models.todo import TodoResponse, SearchResponse` | app/models/todo.py | OK |
| `app/services/dashboard_service.py` | `import json` | stdlib | OK |
| `app/services/dashboard_service.py` | `from app.models.todo import ...` | app/models/todo.py | OK |
**결과: 53/53 import 통과 (100%)**
### 1.3 API 엔드포인트 매핑
| 기능 (FEATURE_SPEC) | API 엔드포인트 | Router 파일 | 결과 |
|---------------------|---------------|------------|------|
| F-001: 할일 생성 | `POST /api/todos` | `routers/todos.py` (L62-68) | OK |
| F-002: 할일 목록 조회 | `GET /api/todos` | `routers/todos.py` (L37-59) | OK |
| F-003: 할일 상세 조회 | `GET /api/todos/{id}` | `routers/todos.py` (L71-77) | OK |
| F-004: 할일 수정 | `PUT /api/todos/{id}` | `routers/todos.py` (L80-87) | OK |
| F-005: 할일 삭제 | `DELETE /api/todos/{id}` | `routers/todos.py` (L90-97) | OK |
| F-006: 할일 완료 토글 | `PATCH /api/todos/{id}/toggle` | `routers/todos.py` (L100-106) | OK |
| F-007: 카테고리 생성 | `POST /api/categories` | `routers/categories.py` (L29-35) | OK |
| F-008: 카테고리 목록 조회 | `GET /api/categories` | `routers/categories.py` (L21-26) | OK |
| F-009: 카테고리 수정 | `PUT /api/categories/{id}` | `routers/categories.py` (L38-45) | OK |
| F-010: 카테고리 삭제 | `DELETE /api/categories/{id}` | `routers/categories.py` (L48-55) | OK |
| F-011: 태그 부여 | F-001/F-004의 `tags` 필드 | `models/todo.py` + `services/todo_service.py` | OK |
| F-012: 태그별 필터링 | `GET /api/todos?tag=...` | `routers/todos.py` (L44) | OK |
| F-013: 태그 목록 조회 | `GET /api/tags` | `routers/tags.py` (L10-16) | OK |
| F-014: 우선순위 설정 | F-001/F-004의 `priority` 필드 | `models/todo.py` Priority enum | OK |
| F-015: 마감일 설정 | F-001/F-004의 `due_date` 필드 | `models/todo.py` | OK |
| F-016: 마감일 알림 표시 | 프론트엔드 UI 로직 | `lib/utils.ts` getDueDateStatus/Label/Color | OK |
| F-017: 검색 | `GET /api/search?q=...` | `routers/search.py` (L10-19) | OK |
| F-018: 대시보드 통계 | `GET /api/dashboard/stats` | `routers/dashboard.py` (L10-17) | OK |
| F-019: 일괄 완료 처리 | `POST /api/todos/batch` (action=complete) | `routers/todos.py` (L28-34) | OK |
| F-020: 일괄 삭제 | `POST /api/todos/batch` (action=delete) | `routers/todos.py` (L28-34) | OK |
| F-021: 일괄 카테고리 변경 | `POST /api/todos/batch` (action=move_category) | `routers/todos.py` (L28-34) | OK |
**결과: 21/21 기능 매핑 완료 (100%)**
---
## 2. 프론트엔드 테스트
### 2.1 빌드 검증
- Next.js 빌드: **OK** (이미 성공 확인됨)
### 2.2 화면설계서 컴포넌트 매핑
#### P-001: 대시보드 (`/`)
| 컴포넌트 (SCREEN_DESIGN) | 파일 | 결과 |
|--------------------------|------|------|
| `StatsCards` | `src/components/dashboard/StatsCards.tsx` | OK |
| `CategoryChart` | `src/components/dashboard/CategoryChart.tsx` | OK |
| `PriorityChart` | `src/components/dashboard/PriorityChart.tsx` | OK |
| `UpcomingDeadlines` | `src/components/dashboard/UpcomingDeadlines.tsx` | OK |
| `Sidebar` | `src/components/layout/Sidebar.tsx` | OK |
| 페이지 | `src/app/page.tsx` | OK |
#### P-002: 할일 목록 (`/todos`)
| 컴포넌트 (SCREEN_DESIGN) | 파일 | 결과 |
|--------------------------|------|------|
| `TodoFilter` | `src/components/todos/TodoFilter.tsx` | OK |
| `TodoList` | `src/components/todos/TodoList.tsx` | OK |
| `TodoCard` | `src/components/todos/TodoCard.tsx` | OK |
| `BatchActions` | `src/components/todos/BatchActions.tsx` | OK |
| `TodoForm` (Modal) | `src/components/todos/TodoForm.tsx` | OK |
| `Pagination` | `src/components/common/Pagination.tsx` | OK |
| 페이지 | `src/app/todos/page.tsx` | OK |
#### P-003: 할일 상세/편집 (`/todos/[id]`)
| 컴포넌트 (SCREEN_DESIGN) | 파일 | 결과 |
|--------------------------|------|------|
| `TodoDetailForm` | `src/components/todos/TodoDetailForm.tsx` | OK |
| `CategorySelect` (inline select) | `TodoDetailForm.tsx``<select>` (L146-158) | OK |
| `PrioritySelect` (inline select) | `TodoDetailForm.tsx``<select>` (L162-174) | OK |
| `DatePicker` | `src/components/common/DatePicker.tsx` | OK |
| `TagInput` | `src/components/common/TagInput.tsx` | OK |
| 페이지 | `src/app/todos/[id]/page.tsx` | OK |
#### P-004: 카테고리 관리 (`/categories`)
| 컴포넌트 (SCREEN_DESIGN) | 파일 | 결과 |
|--------------------------|------|------|
| `CategoryList` | `src/components/categories/CategoryList.tsx` | OK |
| `CategoryItem` | `src/components/categories/CategoryItem.tsx` | OK |
| `CategoryForm` | `src/components/categories/CategoryForm.tsx` | OK |
| `ColorPicker` | `src/components/categories/ColorPicker.tsx` | OK |
| 페이지 | `src/app/categories/page.tsx` | OK |
#### P-005: 검색 결과 (`/search`)
| 컴포넌트 (SCREEN_DESIGN) | 파일 | 결과 |
|--------------------------|------|------|
| `SearchBar` | `src/components/search/SearchBar.tsx` | OK |
| `SearchResults` | `src/components/search/SearchResults.tsx` | OK |
| `SearchResultItem` | `SearchResults.tsx` 내 인라인 구현 (검색 결과 항목 렌더링 + 하이라이트) | OK |
| `Pagination` | `src/components/common/Pagination.tsx` (공유) | OK |
| 페이지 | `src/app/search/page.tsx` | OK |
**결과: 5개 페이지, 27개 컴포넌트 모두 매핑 완료 (100%)**
### 2.3 타입 정합성
- `types/index.ts`: 14개 인터페이스/타입 정의 (Todo, TodoCreate, TodoUpdate, TodoListResponse, ToggleResponse, BatchRequest, BatchResponse, Category, CategoryCreate, CategoryUpdate, TagInfo, SearchResponse, DashboardStats, TodoFilters)
- `hooks/useTodos.ts`: types/index.ts의 Todo, TodoCreate, TodoUpdate, TodoListResponse, TodoFilters, ToggleResponse, BatchRequest, BatchResponse 사용 - **OK**
- `hooks/useCategories.ts`: types/index.ts의 Category, CategoryCreate, CategoryUpdate 사용 - **OK**
- `hooks/useDashboard.ts`: types/index.ts의 DashboardStats 사용 - **OK**
- `hooks/useSearch.ts`: types/index.ts의 SearchResponse 사용 - **OK**
- `hooks/useTags.ts`: types/index.ts의 TagInfo 사용 - **OK**
- `lib/api.ts`: types/index.ts의 ApiError 사용 - **OK**
- `store/uiStore.ts`: types/index.ts의 TodoFilters, SortField, SortOrder 사용 - **OK**
- 컴포넌트 -> hooks -> types 연결: **OK**
### 2.4 공통 컴포넌트
| 컴포넌트 | 파일 | 용도 | 결과 |
|---------|------|------|------|
| `Header` | `src/components/layout/Header.tsx` | 상단 헤더 (로고, 검색바, 알림) | OK |
| `MainLayout` | `src/components/layout/MainLayout.tsx` | 전체 레이아웃 래퍼 | OK |
| `Sidebar` | `src/components/layout/Sidebar.tsx` | 사이드바 네비게이션 | OK |
| `QueryProvider` | `src/components/providers/QueryProvider.tsx` | Tanstack Query 프로바이더 | OK |
| `Pagination` | `src/components/common/Pagination.tsx` | 페이지네이션 | OK |
| `PriorityBadge` | `src/components/common/PriorityBadge.tsx` | 우선순위 뱃지 | OK |
| `TagBadge` | `src/components/common/TagBadge.tsx` | 태그 뱃지 | OK |
| `TagInput` | `src/components/common/TagInput.tsx` | 태그 입력 + 자동완성 | OK |
| `DatePicker` | `src/components/common/DatePicker.tsx` | 날짜 선택 | OK |
---
## 3. Docker 검증
### 3.1 docker-compose.yml 검증
- **문법 검증**: `docker compose config --quiet` -- **OK** (오류 없음)
- **서비스 구성**:
| 서비스 | 이미지 | 포트 | healthcheck | volumes | networks | 결과 |
|--------|--------|------|-------------|---------|----------|------|
| mongodb | `mongo:7.0` | 27017:27017 | `mongosh --eval` | mongodb_data:/data/db | app-network | OK |
| redis | `redis:7-alpine` | 6379:6379 | `redis-cli ping` | redis_data:/data | app-network | OK |
| backend | `./backend/Dockerfile` | 8000:8000 | - | - | app-network | OK |
| frontend | `./frontend/Dockerfile` | 3000:3000 | - | - | app-network | OK |
- **환경변수**: MONGODB_URL, DB_NAME, REDIS_URL (backend) - **OK**
- **의존성**: backend -> mongodb (service_healthy), redis (service_healthy) - **OK**
- **네트워크**: `app-network` (bridge) - **OK**
- **볼륨**: `mongodb_data`, `redis_data` - **OK**
### 3.2 Dockerfile 검증
| 서비스 | Dockerfile | 베이스 이미지 | 빌드 단계 | CMD | 결과 |
|--------|-----------|------------|----------|-----|------|
| backend | `backend/Dockerfile` | `python:3.11-slim` | apt curl, pip install, COPY app/ | `uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload` | OK |
| frontend | `frontend/Dockerfile` | `node:20-alpine` | npm ci, COPY ., npm run build | `npm run start` | OK |
### 3.3 .env 파일 검증
| 변수 | 값 | 사용처 | 결과 |
|------|-----|--------|------|
| `PROJECT_NAME` | `todos2` | container_name 프리픽스 | OK |
| `MONGO_USER` | `admin` | MONGO_INITDB_ROOT_USERNAME | OK |
| `MONGO_PASSWORD` | `password123` | MONGO_INITDB_ROOT_PASSWORD | OK |
| `MONGO_PORT` | `27017` | mongodb 포트 매핑 | OK |
| `REDIS_PORT` | `6379` | redis 포트 매핑 | OK |
| `DB_NAME` | `todos2` | MongoDB 데이터베이스 이름 | OK (수정됨) |
| `BACKEND_PORT` | `8000` | backend 포트 매핑 | OK |
| `FRONTEND_PORT` | `3000` | frontend 포트 매핑 | OK |
---
## 4. 종합 결과
| 항목 | 테스트 수 | 통과 | 실패 | 통과율 |
|------|----------|------|------|--------|
| 백엔드 구문 (AST) | 19 | 19 | 0 | 100% |
| 백엔드 Import | 53 | 53 | 0 | 100% |
| API 엔드포인트 매핑 | 21 | 21 | 0 | 100% |
| 프론트엔드 빌드 | 1 | 1 | 0 | 100% |
| 화면 컴포넌트 매핑 | 27 | 27 | 0 | 100% |
| Docker 서비스 | 4 | 4 | 0 | 100% |
| Docker 파일 | 2 | 2 | 0 | 100% |
| .env 변수 | 8 | 8 | 0 | 100% |
| **합계** | **135** | **135** | **0** | **100%** |
---
## 5. 발견된 이슈 및 수정 사항
### 5.1 수정 완료
| # | 파일 | 이슈 | 수정 내역 |
|---|------|------|----------|
| 1 | `.env` | `DB_NAME=app_db` (요구사항: `todos2`) | `DB_NAME=todos2`로 수정 |
**설명**: `.env` 파일의 `DB_NAME` 값이 `app_db`로 설정되어 있었으나, 프로젝트 요구사항 및 `config.py`의 기본값(`todos2`)과 불일치. `todos2`로 수정하여 docker-compose.yml의 `DB_NAME=${DB_NAME:-app_db}` 환경변수가 올바르게 `todos2`를 참조하도록 변경.
### 5.2 참고 사항 (이슈 아님)
| # | 항목 | 설명 |
|---|------|------|
| 1 | `routers/tags.py` | `TodoService(db)` -- redis 없이 초기화. 태그 조회는 캐시 불필요하므로 정상 동작. TodoService 생성자에서 `redis_client``Optional[aioredis.Redis] = None`이므로 문제 없음. |
| 2 | `SearchService` | `_populate_categories_bulk` 메서드가 `TodoService`와 중복 존재. 리팩토링 여지가 있으나 기능상 문제 없음. |
| 3 | `SCREEN_DESIGN.md``SearchResultItem` | 별도 컴포넌트가 아닌 `SearchResults.tsx` 내부 인라인 구현. 기능 동작에는 문제 없음. |
| 4 | backend Dockerfile | `--reload` 플래그가 프로덕션에서는 제거되어야 하지만, 현재 개발 환경 설정이므로 허용. |
---
## 6. 아키텍처 준수 확인
| 아키텍처 항목 (ARCHITECTURE.md) | 구현 상태 | 결과 |
|-------------------------------|----------|------|
| Frontend: Next.js App Router | `src/app/` 디렉토리 사용 | OK |
| Frontend: TypeScript | `.tsx`, `.ts` 파일 사용 | OK |
| Frontend: Tailwind CSS | `globals.css` + className 유틸리티 | OK |
| Frontend: Recharts | `CategoryChart.tsx`, `PriorityChart.tsx`에서 사용 | OK |
| Frontend: Tanstack Query | `QueryProvider.tsx` + 5개 hooks | OK |
| Frontend: Zustand | `store/uiStore.ts` | OK |
| Backend: FastAPI | `main.py` + 5개 라우터 | OK |
| Backend: Motor (MongoDB) | `database.py` AsyncIOMotorClient | OK |
| Backend: Pydantic v2 | `models/` BaseModel, field_validator | OK |
| Backend: Redis (aioredis) | `database.py` redis.asyncio | OK |
| Backend: 3-Layer (Router -> Service -> DB) | routers/ -> services/ -> database.py | OK |
| Database: MongoDB 7.0 | docker-compose.yml `mongo:7.0` | OK |
| Database: Redis 7 | docker-compose.yml `redis:7-alpine` | OK |
| Infra: Docker Compose | `docker-compose.yml` 4 서비스 | OK |
| 캐싱: Redis TTL 60s | `dashboard_service.py` DASHBOARD_CACHE_TTL=60 | OK |
| 캐시 무효화: CUD 시 | `todo_service.py`, `category_service.py` _invalidate_cache() | OK |
| Text Search Index | `database.py` create_indexes() title/content/tags | OK |
**아키텍처 준수율: 17/17 (100%)**

39
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

19
frontend/Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM node:20-alpine
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json* ./
RUN npm ci
# Copy source code
COPY . .
# Build
RUN npm run build
# Expose port
EXPOSE 3000
# Start production server
CMD ["npm", "run", "start"]

View File

@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

7
frontend/next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

6439
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
frontend/package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "todos2-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.468.0",
"next": "15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.15.0",
"tailwind-merge": "^2.6.0",
"zustand": "^5.0.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.1.0",
"postcss": "^8",
"tailwindcss": "^4",
"@tailwindcss/postcss": "^4",
"typescript": "^5"
}
}

View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -0,0 +1,122 @@
"use client";
import { useState } from "react";
import { Plus } from "lucide-react";
import { Category, CategoryCreate, CategoryUpdate } from "@/types";
import {
useCategoryList,
useCreateCategory,
useUpdateCategory,
useDeleteCategory,
} from "@/hooks/useCategories";
import CategoryList from "@/components/categories/CategoryList";
import CategoryForm from "@/components/categories/CategoryForm";
export default function CategoriesPage() {
const { data: categories, isLoading } = useCategoryList();
const createCategory = useCreateCategory();
const updateCategory = useUpdateCategory();
const deleteCategory = useDeleteCategory();
const [showForm, setShowForm] = useState(false);
const [formMode, setFormMode] = useState<"create" | "edit">("create");
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [error, setError] = useState("");
const handleCreate = () => {
setFormMode("create");
setEditingCategory(null);
setShowForm(true);
setError("");
};
const handleEdit = (category: Category) => {
setFormMode("edit");
setEditingCategory(category);
setShowForm(true);
setError("");
};
const handleDelete = async (category: Category) => {
try {
await deleteCategory.mutateAsync(category.id);
} catch (err: unknown) {
const apiErr = err as { detail?: string };
setError(apiErr.detail || "삭제에 실패했습니다");
}
};
const handleSubmit = async (data: CategoryCreate | CategoryUpdate) => {
setError("");
try {
if (formMode === "create") {
await createCategory.mutateAsync(data as CategoryCreate);
} else if (editingCategory) {
await updateCategory.mutateAsync({
id: editingCategory.id,
data: data as CategoryUpdate,
});
}
setShowForm(false);
setEditingCategory(null);
} catch (err: unknown) {
const apiErr = err as { detail?: string };
setError(apiErr.detail || "저장에 실패했습니다");
}
};
const handleCancel = () => {
setShowForm(false);
setEditingCategory(null);
setError("");
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="text-sm text-gray-500 mt-1">
</p>
</div>
<button
onClick={handleCreate}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* Error */}
{error && (
<div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg">
{error}
</div>
)}
{/* Create/Edit Form */}
{showForm && (
<CategoryForm
mode={formMode}
category={editingCategory}
onSubmit={handleSubmit}
onCancel={handleCancel}
isSubmitting={
createCategory.isPending || updateCategory.isPending
}
/>
)}
{/* Category List */}
<CategoryList
categories={categories}
isLoading={isLoading}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</div>
);
}

View File

@ -0,0 +1 @@
@import "tailwindcss";

View File

@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import QueryProvider from "@/components/providers/QueryProvider";
import MainLayout from "@/components/layout/MainLayout";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "todos2 - 할일 관리",
description: "카테고리, 태그, 우선순위, 마감일을 갖춘 확장형 할일 관리 애플리케이션",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body className={inter.className}>
<QueryProvider>
<MainLayout>{children}</MainLayout>
</QueryProvider>
</body>
</html>
);
}

48
frontend/src/app/page.tsx Normal file
View File

@ -0,0 +1,48 @@
"use client";
import { useDashboardStats } from "@/hooks/useDashboard";
import StatsCards from "@/components/dashboard/StatsCards";
import CategoryChart from "@/components/dashboard/CategoryChart";
import PriorityChart from "@/components/dashboard/PriorityChart";
import UpcomingDeadlines from "@/components/dashboard/UpcomingDeadlines";
export default function DashboardPage() {
const { data: stats, isLoading, isError } = useDashboardStats();
if (isError) {
return (
<div className="flex flex-col items-center justify-center py-16 text-red-500">
<p className="text-sm font-medium"> </p>
<p className="text-xs text-gray-400 mt-1">
</p>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-sm text-gray-500 mt-1">
</p>
</div>
{/* Stats Cards */}
<StatsCards stats={stats?.overview} isLoading={isLoading} />
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<CategoryChart data={stats?.by_category} isLoading={isLoading} />
<PriorityChart data={stats?.by_priority} isLoading={isLoading} />
</div>
{/* Upcoming Deadlines */}
<UpcomingDeadlines
deadlines={stats?.upcoming_deadlines}
isLoading={isLoading}
/>
</div>
);
}

View File

@ -0,0 +1,60 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { useSearch } from "@/hooks/useSearch";
import SearchResults from "@/components/search/SearchResults";
import Pagination from "@/components/common/Pagination";
function SearchContent() {
const searchParams = useSearchParams();
const queryParam = searchParams.get("q") || "";
const [page, setPage] = useState(1);
useEffect(() => {
setPage(1);
}, [queryParam]);
const { data, isLoading } = useSearch(queryParam, page);
const totalPages = data
? Math.ceil(data.total / (data.limit || 20))
: 0;
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold text-gray-900"> </h1>
</div>
<SearchResults
results={data?.items}
query={queryParam}
total={data?.total || 0}
isLoading={isLoading}
/>
{totalPages > 1 && (
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
/>
)}
</div>
);
}
export default function SearchPage() {
return (
<Suspense
fallback={
<div className="flex items-center justify-center py-16">
<span className="text-gray-500"> ...</span>
</div>
}
>
<SearchContent />
</Suspense>
);
}

View File

@ -0,0 +1,22 @@
"use client";
import { use, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useUIStore } from "@/store/uiStore";
export default function TodoDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const router = useRouter();
const openTodoForm = useUIStore((s) => s.openTodoForm);
useEffect(() => {
openTodoForm("edit", id);
router.replace("/todos");
}, [id, router, openTodoForm]);
return null;
}

View File

@ -0,0 +1,239 @@
"use client";
import { Suspense, useState, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { Plus, Loader2, List, BarChart3 } from "lucide-react";
import { useUIStore } from "@/store/uiStore";
import {
useTodoList,
useToggleTodo,
useDeleteTodo,
useBatchAction,
useTodoDetail,
} from "@/hooks/useTodos";
import { cn } from "@/lib/utils";
import TodoFilter from "@/components/todos/TodoFilter";
import TodoList from "@/components/todos/TodoList";
import TodoModal from "@/components/todos/TodoModal";
import BatchActions from "@/components/todos/BatchActions";
import Pagination from "@/components/common/Pagination";
import GanttChart from "@/components/todos/GanttChart";
function TodosContent() {
const searchParams = useSearchParams();
const [viewMode, setViewMode] = useState<"list" | "gantt">("list");
const {
filters,
setFilter,
setFilters,
selectedIds,
toggleSelect,
clearSelection,
todoFormOpen,
todoFormMode,
editingTodoId,
openTodoForm,
closeTodoForm,
} = useUIStore();
// Apply URL query params as filters
useEffect(() => {
const categoryId = searchParams.get("category_id");
const tag = searchParams.get("tag");
if (categoryId || tag) {
setFilters({
...(categoryId ? { category_id: categoryId } : {}),
...(tag ? { tag } : {}),
});
}
}, [searchParams, setFilters]);
// Queries
const {
data: todosData,
isLoading,
isError,
error,
} = useTodoList(filters);
// Get detail for editing
const { data: editingTodo } = useTodoDetail(editingTodoId || "");
// Mutations
const toggleTodo = useToggleTodo();
const deleteTodo = useDeleteTodo();
const batchAction = useBatchAction();
const handleToggle = useCallback(
(id: string) => {
toggleTodo.mutate(id);
},
[toggleTodo]
);
const handleDelete = useCallback(
(id: string) => {
if (window.confirm("이 할일을 삭제하시겠습니까?")) {
deleteTodo.mutate(id);
}
},
[deleteTodo]
);
const handleEdit = useCallback(
(id: string) => {
openTodoForm("edit", id);
},
[openTodoForm]
);
const handleTagClick = useCallback(
(tag: string) => {
setFilter("tag", tag);
},
[setFilter]
);
const handleBatchComplete = useCallback(async () => {
await batchAction.mutateAsync({
action: "complete",
ids: selectedIds,
});
clearSelection();
}, [batchAction, selectedIds, clearSelection]);
const handleBatchDelete = useCallback(async () => {
await batchAction.mutateAsync({
action: "delete",
ids: selectedIds,
});
clearSelection();
}, [batchAction, selectedIds, clearSelection]);
const handleBatchMove = useCallback(
async (categoryId: string | null) => {
await batchAction.mutateAsync({
action: "move_category",
ids: selectedIds,
category_id: categoryId,
});
clearSelection();
},
[batchAction, selectedIds, clearSelection]
);
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="text-sm text-gray-500 mt-1">
{todosData ? `${todosData.total}` : ""}
</p>
</div>
<div className="flex items-center gap-3">
{/* View toggle */}
<div className="flex items-center bg-gray-100 rounded-lg p-0.5">
<button
onClick={() => setViewMode("list")}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
viewMode === "list"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-500 hover:text-gray-700"
)}
>
<List className="h-4 w-4" />
</button>
<button
onClick={() => setViewMode("gantt")}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
viewMode === "gantt"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-500 hover:text-gray-700"
)}
>
<BarChart3 className="h-4 w-4" />
</button>
</div>
<button
onClick={() => openTodoForm("create")}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
{viewMode === "list" ? (
<>
{/* Filters */}
<TodoFilter />
{/* Batch Actions */}
<BatchActions
selectedCount={selectedIds.length}
onBatchComplete={handleBatchComplete}
onBatchDelete={handleBatchDelete}
onBatchMove={handleBatchMove}
onClearSelection={clearSelection}
/>
{/* Todo List */}
<TodoList
todos={todosData?.items}
selectedIds={selectedIds}
isLoading={isLoading}
isError={isError}
error={error}
onToggle={handleToggle}
onSelect={toggleSelect}
onEdit={handleEdit}
onDelete={handleDelete}
onTagClick={handleTagClick}
/>
{/* Pagination */}
{todosData && todosData.total_pages > 1 && (
<Pagination
currentPage={filters.page}
totalPages={todosData.total_pages}
onPageChange={(page) => setFilter("page", page)}
/>
)}
</>
) : (
<GanttChart categoryId={filters.category_id} />
)}
{/* Create/Edit Modal */}
<TodoModal
mode={todoFormMode}
todo={todoFormMode === "edit" ? editingTodo : null}
isOpen={todoFormOpen}
onClose={closeTodoForm}
/>
</div>
);
}
export default function TodosPage() {
return (
<Suspense
fallback={
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 text-blue-600 animate-spin" />
<span className="ml-3 text-gray-500"> ...</span>
</div>
}
>
<TodosContent />
</Suspense>
);
}

View File

@ -0,0 +1,106 @@
"use client";
import { useState, useEffect } from "react";
import { Plus, Save, X } from "lucide-react";
import { Category, CategoryCreate, CategoryUpdate } from "@/types";
import ColorPicker from "./ColorPicker";
interface CategoryFormProps {
mode: "create" | "edit";
category?: Category | null;
onSubmit: (data: CategoryCreate | CategoryUpdate) => void;
onCancel: () => void;
isSubmitting?: boolean;
}
export default function CategoryForm({
mode,
category,
onSubmit,
onCancel,
isSubmitting,
}: CategoryFormProps) {
const [name, setName] = useState("");
const [color, setColor] = useState("#6B7280");
const [error, setError] = useState("");
useEffect(() => {
if (mode === "edit" && category) {
setName(category.name);
setColor(category.color);
} else {
setName("");
setColor("#6B7280");
}
setError("");
}, [mode, category]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError("");
const trimmedName = name.trim();
if (!trimmedName) {
setError("카테고리 이름을 입력해주세요");
return;
}
if (trimmedName.length > 50) {
setError("이름은 50자 이하로 입력해주세요");
return;
}
onSubmit({ name: trimmedName, color });
};
return (
<form
onSubmit={handleSubmit}
className="flex items-end gap-3 p-4 bg-gray-50 rounded-lg"
>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
{mode === "create" ? "새 카테고리 이름" : "카테고리 이름"}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="카테고리 이름..."
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
maxLength={50}
autoFocus
/>
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
</div>
<ColorPicker selectedColor={color} onChange={setColor} />
<div className="flex items-center gap-2">
<button
type="submit"
disabled={isSubmitting}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{mode === "create" ? (
<>
<Plus className="h-4 w-4" />
</>
) : (
<>
<Save className="h-4 w-4" />
</>
)}
</button>
<button
type="button"
onClick={onCancel}
className="p-2 text-gray-400 hover:text-gray-600 rounded transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</form>
);
}

View File

@ -0,0 +1,63 @@
"use client";
import { Pencil, Trash2 } from "lucide-react";
import { Category } from "@/types";
interface CategoryItemProps {
category: Category;
onEdit: (category: Category) => void;
onDelete: (category: Category) => void;
}
export default function CategoryItem({
category,
onEdit,
onDelete,
}: CategoryItemProps) {
const handleDelete = () => {
const message =
category.todo_count > 0
? `"${category.name}" 카테고리를 삭제하시겠습니까?\n이 카테고리에 속한 ${category.todo_count}개의 할일이 "미분류"로 변경됩니다.`
: `"${category.name}" 카테고리를 삭제하시겠습니까?`;
if (window.confirm(message)) {
onDelete(category);
}
};
return (
<div className="flex items-center justify-between px-4 py-3 bg-white border border-gray-200 rounded-lg hover:shadow-sm transition-shadow">
<div className="flex items-center gap-3">
<span
className="w-4 h-4 rounded-full flex-shrink-0"
style={{ backgroundColor: category.color }}
/>
<div>
<h3 className="text-sm font-medium text-gray-900">
{category.name}
</h3>
<p className="text-xs text-gray-500">
{category.todo_count}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onEdit(category)}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="수정"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={handleDelete}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="삭제"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,51 @@
"use client";
import { FolderOpen, Loader2 } from "lucide-react";
import { Category } from "@/types";
import CategoryItem from "./CategoryItem";
interface CategoryListProps {
categories: Category[] | undefined;
isLoading: boolean;
onEdit: (category: Category) => void;
onDelete: (category: Category) => void;
}
export default function CategoryList({
categories,
isLoading,
onEdit,
onDelete,
}: CategoryListProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 text-blue-600 animate-spin" />
<span className="ml-3 text-gray-500"> ...</span>
</div>
);
}
if (!categories || categories.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<FolderOpen className="h-12 w-12 mb-3" />
<p className="text-sm font-medium"> </p>
<p className="text-xs mt-1"> </p>
</div>
);
}
return (
<div className="space-y-2">
{categories.map((category) => (
<CategoryItem
key={category.id}
category={category}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
);
}

View File

@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import { Check } from "lucide-react";
import { COLOR_PRESETS, cn } from "@/lib/utils";
interface ColorPickerProps {
selectedColor: string;
onChange: (color: string) => void;
}
export default function ColorPicker({
selectedColor,
onChange,
}: ColorPickerProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<span
className="w-5 h-5 rounded-full border border-gray-200"
style={{ backgroundColor: selectedColor }}
/>
<span className="text-sm text-gray-600">{selectedColor}</span>
</button>
{isOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
<div className="absolute top-full left-0 mt-1 p-3 bg-white border border-gray-200 rounded-lg shadow-lg z-20">
<div className="grid grid-cols-6 gap-2">
{COLOR_PRESETS.map((color) => (
<button
key={color}
type="button"
onClick={() => {
onChange(color);
setIsOpen(false);
}}
className={cn(
"w-7 h-7 rounded-full flex items-center justify-center transition-transform hover:scale-110",
selectedColor === color && "ring-2 ring-offset-2 ring-gray-400"
)}
style={{ backgroundColor: color }}
>
{selectedColor === color && (
<Check className="h-3.5 w-3.5 text-white" />
)}
</button>
))}
</div>
</div>
</>
)}
</div>
);
}

View File

@ -0,0 +1,78 @@
"use client";
import { Download, Trash2, FileText, Image as ImageIcon, File } from "lucide-react";
import { Attachment } from "@/types";
interface AttachmentListProps {
attachments: Attachment[];
onDownload: (attachment: Attachment) => void;
onDelete?: (attachmentId: string) => void;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function FileIcon({ contentType }: { contentType: string }) {
if (contentType.startsWith("image/")) {
return <ImageIcon className="h-4 w-4" />;
}
if (contentType === "application/pdf") {
return <FileText className="h-4 w-4" />;
}
return <File className="h-4 w-4" />;
}
export default function AttachmentList({
attachments,
onDownload,
onDelete,
}: AttachmentListProps) {
if (attachments.length === 0) return null;
return (
<div className="space-y-2">
{attachments.map((attachment) => (
<div
key={attachment.id}
className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200"
>
<div className="flex-shrink-0 text-gray-500">
<FileIcon contentType={attachment.content_type} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{attachment.filename}
</p>
<p className="text-xs text-gray-500">
{formatFileSize(attachment.size)}
</p>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onDownload(attachment)}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="다운로드"
>
<Download className="h-4 w-4" />
</button>
{onDelete && (
<button
onClick={() => onDelete(attachment.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="삭제"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,53 @@
"use client";
import { Calendar } from "lucide-react";
interface DatePickerProps {
value: string | null | undefined;
onChange: (date: string | null) => void;
label?: string;
clearLabel?: string;
}
export default function DatePicker({ value, onChange, label, clearLabel = "날짜 해제" }: DatePickerProps) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (val) {
onChange(new Date(val + "T00:00:00Z").toISOString());
} else {
onChange(null);
}
};
const displayValue = value
? new Date(value).toISOString().split("T")[0]
: "";
return (
<div className="relative">
{label && (
<label className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
)}
<div className="relative">
<input
type="date"
value={displayValue}
onChange={handleChange}
className="w-full px-3 py-2 pr-10 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<Calendar className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
</div>
{value && (
<button
type="button"
onClick={() => onChange(null)}
className="mt-1 text-xs text-gray-400 hover:text-gray-600"
>
{clearLabel}
</button>
)}
</div>
);
}

View File

@ -0,0 +1,73 @@
"use client";
import { useRef } from "react";
import { Upload, Loader2 } from "lucide-react";
interface FileUploadProps {
onFilesSelected: (files: File[]) => void;
maxFiles?: number;
maxSizeMB?: number;
isUploading?: boolean;
disabled?: boolean;
}
export default function FileUpload({
onFilesSelected,
maxFiles = 5,
maxSizeMB = 10,
isUploading = false,
disabled = false,
}: FileUploadProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
if (files.length > maxFiles) {
alert(`최대 ${maxFiles}개의 파일만 선택할 수 있습니다.`);
return;
}
const maxBytes = maxSizeMB * 1024 * 1024;
const oversized = files.filter((f) => f.size > maxBytes);
if (oversized.length > 0) {
alert(`파일 크기는 ${maxSizeMB}MB를 초과할 수 없습니다.`);
return;
}
onFilesSelected(files);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
return (
<div>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileChange}
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || isUploading}
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{isUploading ? "업로드 중..." : "파일 첨부"}
</button>
<p className="mt-1 text-xs text-gray-500">
{maxFiles}, {maxSizeMB}MB
</p>
</div>
);
}

View File

@ -0,0 +1,99 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export default function Pagination({
currentPage,
totalPages,
onPageChange,
}: PaginationProps) {
if (totalPages <= 1) return null;
const getPageNumbers = (): (number | "...")[] => {
const pages: (number | "...")[] = [];
const maxVisible = 5;
if (totalPages <= maxVisible + 2) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (currentPage > 3) {
pages.push("...");
}
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) {
pages.push("...");
}
pages.push(totalPages);
}
return pages;
};
return (
<div className="flex items-center justify-center gap-1 mt-6">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className={cn(
"p-2 rounded-lg transition-colors",
currentPage <= 1
? "text-gray-300 cursor-not-allowed"
: "text-gray-600 hover:bg-gray-100"
)}
>
<ChevronLeft className="h-4 w-4" />
</button>
{getPageNumbers().map((page, idx) =>
page === "..." ? (
<span key={`dots-${idx}`} className="px-2 py-1 text-gray-400 text-sm">
...
</span>
) : (
<button
key={page}
onClick={() => onPageChange(page)}
className={cn(
"min-w-[2rem] h-8 px-2 text-sm font-medium rounded-lg transition-colors",
currentPage === page
? "bg-blue-600 text-white"
: "text-gray-600 hover:bg-gray-100"
)}
>
{page}
</button>
)
)}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className={cn(
"p-2 rounded-lg transition-colors",
currentPage >= totalPages
? "text-gray-300 cursor-not-allowed"
: "text-gray-600 hover:bg-gray-100"
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
);
}

View File

@ -0,0 +1,28 @@
import { Priority } from "@/types";
import { getPriorityConfig } from "@/lib/utils";
import { cn } from "@/lib/utils";
interface PriorityBadgeProps {
priority: Priority;
className?: string;
}
export default function PriorityBadge({
priority,
className,
}: PriorityBadgeProps) {
const config = getPriorityConfig(priority);
return (
<span
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full",
config.color,
className
)}
>
<span className={cn("w-1.5 h-1.5 rounded-full", config.dotColor)} />
{config.label}
</span>
);
}

View File

@ -0,0 +1,46 @@
"use client";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
interface TagBadgeProps {
name: string;
onClick?: () => void;
onRemove?: () => void;
className?: string;
}
export default function TagBadge({
name,
onClick,
onRemove,
className,
}: TagBadgeProps) {
return (
<span
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full transition-colors",
onClick
? "bg-blue-50 text-blue-700 hover:bg-blue-100 cursor-pointer"
: "bg-gray-100 text-gray-700",
className
)}
>
<span onClick={onClick} className={onClick ? "cursor-pointer" : ""}>
#{name}
</span>
{onRemove && (
<button
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="ml-0.5 hover:text-red-500 transition-colors"
aria-label={`${name} 태그 제거`}
>
<X className="h-3 w-3" />
</button>
)}
</span>
);
}

View File

@ -0,0 +1,99 @@
"use client";
import { useState, useRef, useCallback, KeyboardEvent } from "react";
import TagBadge from "./TagBadge";
import { useTagList } from "@/hooks/useTags";
interface TagInputProps {
tags: string[];
onAdd: (tag: string) => void;
onRemove: (tag: string) => void;
}
export default function TagInput({ tags, onAdd, onRemove }: TagInputProps) {
const [input, setInput] = useState("");
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data: allTags } = useTagList();
const suggestions = allTags
?.filter(
(t) =>
t.name.toLowerCase().includes(input.toLowerCase()) &&
!tags.includes(t.name)
)
.slice(0, 5);
const addTag = useCallback(
(tag: string) => {
const trimmed = tag.trim().toLowerCase();
if (trimmed && !tags.includes(trimmed) && tags.length < 10) {
onAdd(trimmed);
setInput("");
setShowSuggestions(false);
}
},
[tags, onAdd]
);
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
if (input.trim()) {
addTag(input);
}
} else if (e.key === "Backspace" && !input && tags.length > 0) {
onRemove(tags[tags.length - 1]);
}
};
return (
<div className="relative">
<div className="flex flex-wrap gap-1.5 p-2 border border-gray-300 rounded-lg bg-white min-h-[2.5rem] focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-transparent">
{tags.map((tag) => (
<TagBadge key={tag} name={tag} onRemove={() => onRemove(tag)} />
))}
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
setShowSuggestions(true);
}}
onKeyDown={handleKeyDown}
onFocus={() => setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
placeholder={tags.length === 0 ? "태그 입력 (Enter로 추가)..." : ""}
className="flex-1 min-w-[120px] text-sm outline-none bg-transparent"
disabled={tags.length >= 10}
/>
</div>
{/* Suggestions dropdown */}
{showSuggestions && input && suggestions && suggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-10 overflow-hidden">
{suggestions.map((suggestion) => (
<button
key={suggestion.name}
onMouseDown={(e) => {
e.preventDefault();
addTag(suggestion.name);
}}
className="flex items-center justify-between w-full px-3 py-2 text-sm text-left hover:bg-gray-50 transition-colors"
>
<span>#{suggestion.name}</span>
<span className="text-xs text-gray-400">
{suggestion.count}
</span>
</button>
))}
</div>
)}
{tags.length >= 10 && (
<p className="mt-1 text-xs text-gray-400"> 10 .</p>
)}
</div>
);
}

View File

@ -0,0 +1,95 @@
"use client";
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
Legend,
} from "recharts";
import { CategoryStat } from "@/types";
interface CategoryChartProps {
data: CategoryStat[] | undefined;
isLoading: boolean;
}
export default function CategoryChart({ data, isLoading }: CategoryChartProps) {
if (isLoading) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="text-sm font-semibold text-gray-900 mb-4">
</h3>
<div className="h-[250px] flex items-center justify-center">
<div className="w-40 h-40 rounded-full border-8 border-gray-200 animate-pulse" />
</div>
</div>
);
}
if (!data || data.length === 0) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="text-sm font-semibold text-gray-900 mb-4">
</h3>
<div className="h-[250px] flex items-center justify-center text-gray-400 text-sm">
</div>
</div>
);
}
const chartData = data.map((item) => ({
name: item.name,
value: item.count,
color: item.color,
}));
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="text-sm font-semibold text-gray-900 mb-4">
</h3>
<div className="h-[250px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={2}
dataKey="value"
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value: number, name: string) => [
`${value}`,
name,
]}
contentStyle={{
borderRadius: "8px",
border: "1px solid #e5e7eb",
fontSize: "12px",
}}
/>
<Legend
verticalAlign="bottom"
height={36}
formatter={(value) => (
<span className="text-xs text-gray-600">{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@ -0,0 +1,106 @@
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
import { PriorityStat } from "@/types";
import { PRIORITY_CONFIG } from "@/lib/utils";
interface PriorityChartProps {
data: PriorityStat | undefined;
isLoading: boolean;
}
export default function PriorityChart({ data, isLoading }: PriorityChartProps) {
if (isLoading) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="text-sm font-semibold text-gray-900 mb-4">
</h3>
<div className="h-[250px] flex items-center justify-center">
<div className="w-full space-y-4 animate-pulse px-4">
<div className="h-8 bg-gray-200 rounded w-3/4" />
<div className="h-8 bg-gray-200 rounded w-full" />
<div className="h-8 bg-gray-200 rounded w-1/2" />
</div>
</div>
</div>
);
}
if (!data) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="text-sm font-semibold text-gray-900 mb-4">
</h3>
<div className="h-[250px] flex items-center justify-center text-gray-400 text-sm">
</div>
</div>
);
}
const chartData = [
{
name: PRIORITY_CONFIG.high.label,
value: data.high,
color: PRIORITY_CONFIG.high.barColor,
},
{
name: PRIORITY_CONFIG.medium.label,
value: data.medium,
color: PRIORITY_CONFIG.medium.barColor,
},
{
name: PRIORITY_CONFIG.low.label,
value: data.low,
color: PRIORITY_CONFIG.low.barColor,
},
];
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="text-sm font-semibold text-gray-900 mb-4">
</h3>
<div className="h-[250px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 5, right: 20, left: 20, bottom: 5 }}
>
<XAxis type="number" allowDecimals={false} fontSize={12} />
<YAxis
type="category"
dataKey="name"
width={40}
fontSize={12}
/>
<Tooltip
formatter={(value: number) => [`${value}`]}
contentStyle={{
borderRadius: "8px",
border: "1px solid #e5e7eb",
fontSize: "12px",
}}
/>
<Bar dataKey="value" radius={[0, 4, 4, 0]} barSize={24}>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@ -0,0 +1,107 @@
"use client";
import {
ListChecks,
CheckCircle2,
Circle,
TrendingUp,
} from "lucide-react";
import { DashboardOverview } from "@/types";
interface StatsCardsProps {
stats: DashboardOverview | undefined;
isLoading: boolean;
}
export default function StatsCards({ stats, isLoading }: StatsCardsProps) {
const cards = [
{
label: "전체 할일",
value: stats?.total ?? 0,
icon: ListChecks,
color: "text-blue-600",
bgColor: "bg-blue-50",
},
{
label: "완료",
value: stats?.completed ?? 0,
icon: CheckCircle2,
color: "text-green-600",
bgColor: "bg-green-50",
},
{
label: "미완료",
value: stats?.incomplete ?? 0,
icon: Circle,
color: "text-orange-600",
bgColor: "bg-orange-50",
},
{
label: "완료율",
value: `${(stats?.completion_rate ?? 0).toFixed(0)}%`,
icon: TrendingUp,
color: "text-purple-600",
bgColor: "bg-purple-50",
},
];
if (isLoading) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="bg-white rounded-xl border border-gray-200 p-5 animate-pulse"
>
<div className="h-4 w-20 bg-gray-200 rounded mb-3" />
<div className="h-8 w-16 bg-gray-200 rounded" />
</div>
))}
</div>
);
}
if (!stats) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map((card) => (
<div
key={card.label}
className="bg-white rounded-xl border border-gray-200 p-5"
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-500">
{card.label}
</span>
<div className={`${card.bgColor} p-2 rounded-lg`}>
<card.icon className={`h-4 w-4 ${card.color}`} />
</div>
</div>
<p className="text-2xl font-bold text-gray-900">0</p>
</div>
))}
</div>
);
}
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map((card) => (
<div
key={card.label}
className="bg-white rounded-xl border border-gray-200 p-5 hover:shadow-sm transition-shadow"
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-500">
{card.label}
</span>
<div className={`${card.bgColor} p-2 rounded-lg`}>
<card.icon className={`h-4 w-4 ${card.color}`} />
</div>
</div>
<p className="text-2xl font-bold text-gray-900">{card.value}</p>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,100 @@
"use client";
import { useRouter } from "next/navigation";
import { Clock, AlertTriangle } from "lucide-react";
import { UpcomingDeadline } from "@/types";
import { getDDayText, getPriorityConfig } from "@/lib/utils";
import { cn } from "@/lib/utils";
interface UpcomingDeadlinesProps {
deadlines: UpcomingDeadline[] | undefined;
isLoading: boolean;
}
export default function UpcomingDeadlines({
deadlines,
isLoading,
}: UpcomingDeadlinesProps) {
const router = useRouter();
if (isLoading) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="text-sm font-semibold text-gray-900 mb-4">
</h3>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="animate-pulse flex items-center gap-3">
<div className="h-4 w-4 bg-gray-200 rounded" />
<div className="flex-1">
<div className="h-4 w-32 bg-gray-200 rounded" />
</div>
<div className="h-5 w-12 bg-gray-200 rounded" />
</div>
))}
</div>
</div>
);
}
if (!deadlines || deadlines.length === 0) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="text-sm font-semibold text-gray-900 mb-4">
</h3>
<div className="flex flex-col items-center justify-center py-8 text-gray-400">
<Clock className="h-8 w-8 mb-2" />
<p className="text-sm"> </p>
</div>
</div>
);
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="text-sm font-semibold text-gray-900 mb-4">
</h3>
<div className="space-y-2">
{deadlines.map((item, index) => {
const dday = getDDayText(item.due_date);
const priorityConfig = getPriorityConfig(item.priority);
return (
<button
key={item.id}
onClick={() => router.push(`/todos/${item.id}`)}
className="flex items-center gap-3 w-full px-3 py-2.5 text-left rounded-lg hover:bg-gray-50 transition-colors group"
>
<span className="text-sm font-medium text-gray-400 w-5">
{index + 1}.
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate group-hover:text-blue-600">
{item.title}
</p>
</div>
<span
className={cn(
"flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full",
priorityConfig.color
)}
>
<span
className={cn("w-1.5 h-1.5 rounded-full", priorityConfig.dotColor)}
/>
{priorityConfig.label}
</span>
<span className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 text-xs font-bold rounded-full bg-red-50 text-red-700">
<AlertTriangle className="h-3 w-3" />
{dday}
</span>
</button>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,51 @@
"use client";
import Link from "next/link";
import { Menu, Bell } from "lucide-react";
import { useUIStore } from "@/store/uiStore";
import SearchBar from "@/components/search/SearchBar";
export default function Header() {
const { toggleSidebar } = useUIStore();
return (
<header className="sticky top-0 z-30 flex items-center justify-between h-16 px-4 bg-white border-b border-gray-200 lg:px-6">
{/* Left: Logo + Menu Toggle */}
<div className="flex items-center gap-3">
<button
onClick={toggleSidebar}
className="p-2 rounded-lg hover:bg-gray-100 lg:hidden"
aria-label="메뉴 토글"
>
<Menu className="h-5 w-5 text-gray-600" />
</button>
<Link href="/" className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 bg-blue-600 rounded-lg">
<span className="text-white font-bold text-sm">T</span>
</div>
<span className="text-xl font-bold text-gray-900 hidden sm:inline">
todos2
</span>
</Link>
</div>
{/* Center: Search Bar */}
<div className="flex-1 max-w-md mx-4 hidden sm:block">
<SearchBar />
</div>
{/* Right: Notifications */}
<div className="flex items-center gap-2">
<div className="sm:hidden">
<SearchBar />
</div>
<button
className="relative p-2 rounded-lg hover:bg-gray-100"
aria-label="알림"
>
<Bell className="h-5 w-5 text-gray-600" />
</button>
</div>
</header>
);
}

View File

@ -0,0 +1,22 @@
"use client";
import Header from "./Header";
import Sidebar from "./Sidebar";
export default function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="flex">
<Sidebar />
<main className="flex-1 min-h-[calc(100vh-4rem)] p-4 lg:p-6 overflow-auto">
{children}
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,157 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import {
LayoutDashboard,
ListTodo,
FolderOpen,
Tag,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useUIStore } from "@/store/uiStore";
import { useCategoryList } from "@/hooks/useCategories";
import { useTagList } from "@/hooks/useTags";
const navItems = [
{ href: "/", label: "대시보드", icon: LayoutDashboard },
{ href: "/todos", label: "할일 목록", icon: ListTodo },
{ href: "/categories", label: "카테고리 관리", icon: FolderOpen },
];
export default function Sidebar() {
const pathname = usePathname();
const router = useRouter();
const { sidebarOpen, setSidebarOpen } = useUIStore();
const { data: categories } = useCategoryList();
const { data: tags } = useTagList();
const handleCategoryClick = (categoryId: string) => {
router.push(`/todos?category_id=${categoryId}`);
setSidebarOpen(false);
};
const handleTagClick = (tagName: string) => {
router.push(`/todos?tag=${encodeURIComponent(tagName)}`);
setSidebarOpen(false);
};
const handleNavClick = (href: string) => {
router.push(href);
setSidebarOpen(false);
};
return (
<>
{/* Overlay for mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={cn(
"fixed top-16 left-0 z-40 h-[calc(100vh-4rem)] w-64 bg-white border-r border-gray-200 transition-transform duration-200 ease-in-out overflow-y-auto",
"lg:translate-x-0 lg:static lg:z-auto",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
{/* Mobile close button */}
<div className="flex items-center justify-end p-2 lg:hidden">
<button
onClick={() => setSidebarOpen(false)}
className="p-2 rounded-lg hover:bg-gray-100"
>
<X className="h-5 w-5 text-gray-600" />
</button>
</div>
{/* Navigation */}
<nav className="p-4">
<ul className="space-y-1">
{navItems.map((item) => {
const isActive =
item.href === "/"
? pathname === "/"
: pathname.startsWith(item.href);
return (
<li key={item.href}>
<button
onClick={() => handleNavClick(item.href)}
className={cn(
"flex items-center gap-3 w-full px-3 py-2 text-sm font-medium rounded-lg transition-colors",
isActive
? "bg-blue-50 text-blue-700"
: "text-gray-700 hover:bg-gray-100"
)}
>
<item.icon className="h-5 w-5" />
{item.label}
</button>
</li>
);
})}
</ul>
</nav>
{/* Categories */}
<div className="px-4 pb-4">
<h3 className="px-3 mb-2 text-xs font-semibold text-gray-500 uppercase tracking-wider">
</h3>
<ul className="space-y-1">
{categories?.map((category) => (
<li key={category.id}>
<button
onClick={() => handleCategoryClick(category.id)}
className="flex items-center justify-between w-full px-3 py-1.5 text-sm text-gray-700 rounded-lg hover:bg-gray-100 transition-colors"
>
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: category.color }}
/>
<span className="truncate">{category.name}</span>
</div>
<span className="text-xs text-gray-400">
{category.todo_count}
</span>
</button>
</li>
))}
{(!categories || categories.length === 0) && (
<li className="px-3 py-1.5 text-sm text-gray-400">
</li>
)}
</ul>
</div>
{/* Popular Tags */}
<div className="px-4 pb-4">
<h3 className="px-3 mb-2 text-xs font-semibold text-gray-500 uppercase tracking-wider">
</h3>
<div className="flex flex-wrap gap-1.5 px-3">
{tags?.slice(0, 10).map((tag) => (
<button
key={tag.name}
onClick={() => handleTagClick(tag.name)}
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-blue-700 bg-blue-50 rounded-full hover:bg-blue-100 transition-colors"
>
<Tag className="h-3 w-3" />
{tag.name}
</button>
))}
{(!tags || tags.length === 0) && (
<span className="text-sm text-gray-400"> </span>
)}
</div>
</div>
</aside>
</>
);
}

View File

@ -0,0 +1,28 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export default function QueryProvider({
children,
}: {
children: React.ReactNode;
}) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@ -0,0 +1,53 @@
"use client";
import { useState, useCallback, KeyboardEvent } from "react";
import { useRouter } from "next/navigation";
import { Search, X } from "lucide-react";
import { useUIStore } from "@/store/uiStore";
export default function SearchBar() {
const router = useRouter();
const { searchQuery, setSearchQuery } = useUIStore();
const [inputValue, setInputValue] = useState(searchQuery);
const handleSearch = useCallback(() => {
const trimmed = inputValue.trim();
if (trimmed) {
setSearchQuery(trimmed);
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
}
}, [inputValue, setSearchQuery, router]);
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleSearch();
}
};
const handleClear = () => {
setInputValue("");
setSearchQuery("");
};
return (
<div className="relative flex items-center w-full max-w-md">
<Search className="absolute left-3 h-4 w-4 text-gray-400" />
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="할일 검색..."
className="w-full pl-10 pr-10 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{inputValue && (
<button
onClick={handleClear}
className="absolute right-3 text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
);
}

View File

@ -0,0 +1,137 @@
"use client";
import { useRouter } from "next/navigation";
import { Search, Loader2 } from "lucide-react";
import { Todo } from "@/types";
import { cn, getDueDateStatus, getDueDateColor, getDueDateLabel, getDDayText } from "@/lib/utils";
import PriorityBadge from "@/components/common/PriorityBadge";
interface SearchResultsProps {
results: Todo[] | undefined;
query: string;
total: number;
isLoading: boolean;
}
function highlightText(text: string, query: string): React.ReactNode {
if (!query.trim()) return text;
const parts = text.split(new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, "gi"));
return parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase() ? (
<mark key={i} className="bg-yellow-200 text-yellow-900 rounded px-0.5">
{part}
</mark>
) : (
part
)
);
}
export default function SearchResults({
results,
query,
total,
isLoading,
}: SearchResultsProps) {
const router = useRouter();
if (isLoading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 text-blue-600 animate-spin" />
<span className="ml-3 text-gray-500"> ...</span>
</div>
);
}
if (!results || results.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<Search className="h-12 w-12 mb-3" />
<p className="text-sm font-medium"> </p>
<p className="text-xs mt-1"> </p>
</div>
);
}
return (
<div>
<p className="text-sm text-gray-500 mb-4">
&quot;{query}&quot; ({total})
</p>
<div className="space-y-3">
{results.map((todo) => {
const dueDateStatus = getDueDateStatus(todo.due_date, todo.completed);
const dday = getDDayText(todo.due_date);
return (
<button
key={todo.id}
onClick={() => router.push(`/todos/${todo.id}`)}
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:shadow-sm hover:border-blue-200 transition-all"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-gray-900">
{highlightText(todo.title, query)}
</h3>
{todo.content && (
<p className="mt-1 text-xs text-gray-500 line-clamp-2">
{highlightText(todo.content, query)}
</p>
)}
<div className="flex items-center gap-2 mt-2">
{todo.category_name && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
style={{
backgroundColor: `${todo.category_color}20`,
color: todo.category_color || "#6B7280",
}}
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{
backgroundColor: todo.category_color || "#6B7280",
}}
/>
{todo.category_name}
</span>
)}
<PriorityBadge priority={todo.priority} />
{todo.tags.map((tag) => (
<span
key={tag}
className="text-xs text-blue-600"
>
#{tag}
</span>
))}
</div>
</div>
{todo.due_date && (
<div className="flex-shrink-0 text-right">
{dueDateStatus && !todo.completed && (
<span
className={cn(
"inline-block px-1.5 py-0.5 text-xs font-medium rounded mb-1",
getDueDateColor(dueDateStatus)
)}
>
{getDueDateLabel(dueDateStatus)}
</span>
)}
<p className="text-xs text-gray-500">{dday}</p>
</div>
)}
</div>
</button>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,109 @@
"use client";
import { useState } from "react";
import { CheckCircle2, Trash2, FolderOpen, X } from "lucide-react";
import { useCategoryList } from "@/hooks/useCategories";
interface BatchActionsProps {
selectedCount: number;
onBatchComplete: () => void;
onBatchDelete: () => void;
onBatchMove: (categoryId: string | null) => void;
onClearSelection: () => void;
}
export default function BatchActions({
selectedCount,
onBatchComplete,
onBatchDelete,
onBatchMove,
onClearSelection,
}: BatchActionsProps) {
const { data: categories } = useCategoryList();
const [showCategoryDropdown, setShowCategoryDropdown] = useState(false);
if (selectedCount === 0) return null;
const handleDelete = () => {
if (
window.confirm(`${selectedCount}개의 할일을 삭제하시겠습니까?`)
) {
onBatchDelete();
}
};
return (
<div className="flex items-center gap-3 px-4 py-3 bg-blue-50 border border-blue-200 rounded-lg">
<span className="text-sm font-medium text-blue-700">
{selectedCount}
</span>
<div className="flex items-center gap-2 ml-auto">
<button
onClick={onBatchComplete}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-green-700 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
>
<CheckCircle2 className="h-4 w-4" />
</button>
{/* Category move dropdown */}
<div className="relative">
<button
onClick={() => setShowCategoryDropdown(!showCategoryDropdown)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-blue-700 bg-white border border-blue-200 rounded-lg hover:bg-blue-50 transition-colors"
>
<FolderOpen className="h-4 w-4" />
</button>
{showCategoryDropdown && (
<div className="absolute top-full left-0 mt-1 w-48 bg-white border border-gray-200 rounded-lg shadow-lg z-20 overflow-hidden">
<button
onClick={() => {
onBatchMove(null);
setShowCategoryDropdown(false);
}}
className="w-full px-3 py-2 text-sm text-left text-gray-700 hover:bg-gray-50"
>
</button>
{categories?.map((cat) => (
<button
key={cat.id}
onClick={() => {
onBatchMove(cat.id);
setShowCategoryDropdown(false);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left text-gray-700 hover:bg-gray-50"
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: cat.color }}
/>
{cat.name}
</button>
))}
</div>
)}
</div>
<button
onClick={handleDelete}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-red-700 bg-red-50 border border-red-200 rounded-lg hover:bg-red-100 transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
<button
onClick={onClearSelection}
className="p-1.5 text-gray-400 hover:text-gray-600 rounded transition-colors"
title="선택 해제"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,318 @@
"use client";
import { useMemo, useRef } from "react";
import { Loader2 } from "lucide-react";
import { useGanttData, GanttCategory, GanttTodo } from "@/hooks/useGantt";
import { useUIStore } from "@/store/uiStore";
const DAY_WIDTH = 32;
const ROW_HEIGHT = 32;
const LABEL_WIDTH = 160;
function formatDate(date: Date): string {
const m = date.getMonth() + 1;
const d = date.getDate();
return `${m}/${d}`;
}
function getMonthLabel(date: Date): string {
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, "0")}`;
}
function daysBetween(a: Date, b: Date): number {
return Math.ceil((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
}
function getPriorityColor(priority: string, baseColor: string): string {
if (priority === "high") return baseColor;
if (priority === "medium") return baseColor + "CC";
return baseColor + "99";
}
interface GanttBarProps {
todo: GanttTodo;
minDate: Date;
color: string;
}
function GanttBar({ todo, minDate, color }: GanttBarProps) {
const openTodoForm = useUIStore((s) => s.openTodoForm);
const offsetDays = daysBetween(minDate, todo.startDate);
const durationDays = Math.max(1, daysBetween(todo.startDate, todo.endDate));
const left = offsetDays * DAY_WIDTH;
const width = durationDays * DAY_WIDTH;
const barColor = getPriorityColor(todo.priority, color);
return (
<div
className="absolute top-1 cursor-pointer group"
style={{
left: `${left}px`,
width: `${width}px`,
height: `${ROW_HEIGHT - 8}px`,
}}
onClick={() => openTodoForm("edit", todo.id)}
>
<div
className="h-full rounded-md transition-shadow hover:shadow-md relative overflow-hidden"
style={{
backgroundColor: barColor,
opacity: todo.completed ? 0.5 : 1,
}}
>
<span className="absolute inset-0 flex items-center px-2 text-xs text-white font-medium truncate">
{todo.title}
</span>
{todo.completed && (
<div className="absolute inset-0 flex items-center justify-center">
<div
className="w-full border-t border-white/60"
style={{ marginLeft: "4px", marginRight: "4px" }}
/>
</div>
)}
</div>
{/* Tooltip */}
<div className="hidden group-hover:block absolute z-20 bottom-full left-0 mb-1 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg shadow-lg whitespace-nowrap">
<div className="font-medium">{todo.title}</div>
<div className="text-gray-300 mt-0.5">
{formatDate(todo.startDate)} ~ {formatDate(todo.endDate)} ({durationDays})
</div>
{todo.completed && <div className="text-green-400 mt-0.5"></div>}
</div>
</div>
);
}
interface GanttChartProps {
categoryId?: string;
}
export default function GanttChart({ categoryId }: GanttChartProps) {
const { data, isLoading, isError } = useGanttData(categoryId);
const scrollRef = useRef<HTMLDivElement>(null);
// 날짜 헤더 생성
const { dateHeaders, monthHeaders } = useMemo(() => {
if (!data) return { dateHeaders: [], monthHeaders: [] };
const dateHeaders: { date: Date; label: string; isWeekend: boolean }[] = [];
const monthHeaders: { label: string; span: number; startIdx: number }[] = [];
let currentMonth = "";
let monthStartIdx = 0;
let monthSpan = 0;
for (let i = 0; i <= data.totalDays; i++) {
const d = new Date(data.minDate);
d.setDate(d.getDate() + i);
const dayOfWeek = d.getDay();
dateHeaders.push({
date: d,
label: String(d.getDate()),
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
});
const monthKey = getMonthLabel(d);
if (monthKey !== currentMonth) {
if (currentMonth) {
monthHeaders.push({
label: currentMonth,
span: monthSpan,
startIdx: monthStartIdx,
});
}
currentMonth = monthKey;
monthStartIdx = i;
monthSpan = 1;
} else {
monthSpan++;
}
}
if (currentMonth) {
monthHeaders.push({
label: currentMonth,
span: monthSpan,
startIdx: monthStartIdx,
});
}
return { dateHeaders, monthHeaders };
}, [data]);
// 행 데이터: 카테고리 헤더 + Todo 행
const rows = useMemo(() => {
if (!data) return [];
const result: { type: "category" | "todo"; category: GanttCategory; todo?: GanttTodo }[] = [];
data.categories.forEach((cat) => {
result.push({ type: "category", category: cat });
cat.todos.forEach((todo) => {
result.push({ type: "todo", category: cat, todo });
});
});
return result;
}, [data]);
if (isLoading) {
return (
<div className="flex items-center justify-center py-20 text-gray-500">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
...
</div>
);
}
if (isError) {
return (
<div className="text-center py-20 text-red-500">
.
</div>
);
}
if (!data || data.categories.length === 0) {
return (
<div className="text-center py-20 text-gray-500">
<p className="text-lg font-medium"> </p>
<p className="text-sm mt-1">
.
</p>
</div>
);
}
const chartWidth = (data.totalDays + 1) * DAY_WIDTH;
// 오늘 표시 위치
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayOffset = daysBetween(data.minDate, today);
const showTodayLine =
todayOffset >= 0 && todayOffset <= data.totalDays;
return (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="flex">
{/* 좌측 라벨 영역 */}
<div
className="flex-shrink-0 border-r border-gray-200 bg-gray-50"
style={{ width: `${LABEL_WIDTH}px` }}
>
{/* 월/일 헤더 높이 맞춤 */}
<div className="h-[52px] border-b border-gray-200 flex items-end px-3 pb-1">
<span className="text-xs font-medium text-gray-500"> / </span>
</div>
{/* 행 라벨 */}
{rows.map((row) =>
row.type === "category" ? (
<div
key={`label-cat-${row.category.id}`}
className="flex items-center gap-2 px-3 border-b border-gray-100 bg-gray-50 font-medium text-sm text-gray-800"
style={{ height: `${ROW_HEIGHT}px` }}
>
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: row.category.color }}
/>
<span className="truncate">{row.category.name}</span>
</div>
) : (
<div
key={`label-todo-${row.todo!.id}`}
className="flex items-center px-3 pl-7 border-b border-gray-50 text-xs text-gray-600 truncate"
style={{ height: `${ROW_HEIGHT}px` }}
>
{row.todo!.title}
</div>
)
)}
</div>
{/* 우측 차트 영역 (스크롤) */}
<div className="flex-1 overflow-x-auto" ref={scrollRef}>
<div style={{ width: `${chartWidth}px` }}>
{/* 헤더: 월 */}
<div className="flex border-b border-gray-200">
{monthHeaders.map((mh) => (
<div
key={`month-${mh.label}-${mh.startIdx}`}
className="text-xs font-medium text-gray-700 text-center border-r border-gray-100 py-1"
style={{ width: `${mh.span * DAY_WIDTH}px` }}
>
{mh.label}
</div>
))}
</div>
{/* 헤더: 일 */}
<div className="flex border-b border-gray-200">
{dateHeaders.map((dh, i) => (
<div
key={`day-${i}`}
className={`text-center text-[10px] py-1 border-r border-gray-50 ${
dh.isWeekend ? "bg-gray-50 text-gray-400" : "text-gray-600"
}`}
style={{ width: `${DAY_WIDTH}px` }}
>
{dh.label}
</div>
))}
</div>
{/* 차트 바디 */}
<div className="relative">
{/* 그리드 라인 (주말 배경) */}
<div className="absolute inset-0 flex pointer-events-none">
{dateHeaders.map((dh, i) => (
<div
key={`grid-${i}`}
className={`border-r border-gray-50 ${
dh.isWeekend ? "bg-gray-50/50" : ""
}`}
style={{
width: `${DAY_WIDTH}px`,
height: `${rows.length * ROW_HEIGHT}px`,
}}
/>
))}
</div>
{/* 오늘 선 */}
{showTodayLine && (
<div
className="absolute top-0 bottom-0 w-0.5 bg-red-400 z-10 pointer-events-none"
style={{
left: `${todayOffset * DAY_WIDTH + DAY_WIDTH / 2}px`,
height: `${rows.length * ROW_HEIGHT}px`,
}}
/>
)}
{/* 행 */}
{rows.map((row) =>
row.type === "category" ? (
<div
key={`row-cat-${row.category.id}`}
className="relative border-b border-gray-100 bg-gray-50/30"
style={{ height: `${ROW_HEIGHT}px` }}
/>
) : (
<div
key={`row-todo-${row.todo!.id}`}
className="relative border-b border-gray-50"
style={{ height: `${ROW_HEIGHT}px` }}
>
<GanttBar
todo={row.todo!}
minDate={data!.minDate}
color={row.category.color}
/>
</div>
)
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,160 @@
"use client";
import { Pencil, Trash2, Check, Paperclip } from "lucide-react";
import { Todo } from "@/types";
import { cn, getDueDateStatus, getDueDateLabel, getDueDateColor, getDDayText } from "@/lib/utils";
import PriorityBadge from "@/components/common/PriorityBadge";
import TagBadge from "@/components/common/TagBadge";
interface TodoCardProps {
todo: Todo;
isSelected: boolean;
onToggle: (id: string) => void;
onSelect: (id: string) => void;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onTagClick?: (tag: string) => void;
}
export default function TodoCard({
todo,
isSelected,
onToggle,
onSelect,
onEdit,
onDelete,
onTagClick,
}: TodoCardProps) {
const dueDateStatus = getDueDateStatus(todo.due_date, todo.completed);
const dueDateLabel = getDueDateLabel(dueDateStatus);
const dueDateColor = getDueDateColor(dueDateStatus);
const dday = getDDayText(todo.due_date);
return (
<div
className={cn(
"flex items-center gap-3 px-4 py-3 bg-white border border-gray-200 rounded-lg transition-all hover:shadow-sm",
isSelected && "ring-2 ring-blue-200 bg-blue-50/30",
todo.completed && "opacity-60"
)}
>
{/* Selection checkbox */}
<input
type="checkbox"
checked={isSelected}
onChange={() => onSelect(todo.id)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer flex-shrink-0"
/>
{/* Completion toggle */}
<button
onClick={() => onToggle(todo.id)}
className={cn(
"flex items-center justify-center w-5 h-5 rounded-full border-2 transition-colors flex-shrink-0",
todo.completed
? "bg-green-500 border-green-500 text-white"
: "border-gray-300 hover:border-green-400"
)}
>
{todo.completed && <Check className="h-3 w-3" />}
</button>
{/* Content */}
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => onEdit(todo.id)}
>
<div className="flex items-center gap-1.5 flex-wrap">
<h3
className={cn(
"text-sm font-medium text-gray-900 truncate",
todo.completed && "line-through text-gray-500"
)}
>
{todo.title}
</h3>
{/* Category badge */}
{todo.category_name && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
style={{
backgroundColor: `${todo.category_color}20`,
color: todo.category_color || "#6B7280",
}}
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: todo.category_color || "#6B7280" }}
/>
{todo.category_name}
</span>
)}
<PriorityBadge priority={todo.priority} />
{/* Tags */}
{todo.tags.map((tag) => (
<TagBadge
key={tag}
name={tag}
onClick={onTagClick ? () => onTagClick(tag) : undefined}
/>
))}
{/* Attachment indicator */}
{todo.attachments?.length > 0 && (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs text-gray-500 bg-gray-100 rounded">
<Paperclip className="h-3 w-3" />
{todo.attachments.length}
</span>
)}
</div>
</div>
{/* Due date */}
{todo.due_date && (
<div className="flex-shrink-0 flex items-center gap-1">
{dueDateLabel && !todo.completed && (
<span
className={cn(
"px-1.5 py-0.5 text-xs font-medium rounded",
dueDateColor
)}
>
{dueDateLabel}
</span>
)}
<span
className={cn(
"text-xs font-medium",
dueDateStatus === "overdue" && !todo.completed
? "text-red-600"
: "text-gray-500"
)}
>
{dday}
</span>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => onEdit(todo.id)}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="수정"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => onDelete(todo.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="삭제"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,111 @@
"use client";
import { X } from "lucide-react";
import { useUIStore } from "@/store/uiStore";
import { Priority, SortField, SortOrder } from "@/types";
export default function TodoFilter() {
const { filters, setFilter, resetFilters } = useUIStore();
const hasActiveFilters =
filters.completed !== undefined ||
filters.priority !== undefined ||
filters.category_id !== undefined ||
filters.tag !== undefined;
return (
<div className="flex flex-wrap items-center gap-3">
{/* Status filter */}
<div>
<select
value={
filters.completed === undefined
? "all"
: filters.completed
? "completed"
: "incomplete"
}
onChange={(e) => {
const val = e.target.value;
if (val === "all") {
setFilter("completed", undefined);
} else {
setFilter("completed", val === "completed");
}
}}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all"> </option>
<option value="completed"></option>
<option value="incomplete"></option>
</select>
</div>
{/* Priority filter */}
<div>
<select
value={filters.priority || "all"}
onChange={(e) => {
const val = e.target.value;
setFilter(
"priority",
val === "all" ? undefined : (val as Priority)
);
}}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all"> </option>
<option value="high"></option>
<option value="medium"></option>
<option value="low"></option>
</select>
</div>
{/* Sort */}
<div>
<select
value={`${filters.sort}_${filters.order}`}
onChange={(e) => {
const [sort, order] = e.target.value.split("_") as [
SortField,
SortOrder
];
setFilter("sort", sort);
setFilter("order", order);
}}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="created_at_desc"></option>
<option value="created_at_asc"></option>
<option value="due_date_asc"> </option>
<option value="due_date_desc"> </option>
<option value="priority_asc"> </option>
<option value="priority_desc"> </option>
</select>
</div>
{/* Active tag filter */}
{filters.tag && (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded-full">
#{filters.tag}
<button
onClick={() => setFilter("tag", undefined)}
className="hover:text-red-500"
>
<X className="h-3 w-3" />
</button>
</span>
)}
{/* Reset button */}
{hasActiveFilters && (
<button
onClick={resetFilters}
className="px-3 py-2 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
</button>
)}
</div>
);
}

View File

@ -0,0 +1,78 @@
"use client";
import { ListX, Loader2 } from "lucide-react";
import { Todo } from "@/types";
import TodoCard from "./TodoCard";
interface TodoListProps {
todos: Todo[] | undefined;
selectedIds: string[];
isLoading: boolean;
isError: boolean;
error: Error | null;
onToggle: (id: string) => void;
onSelect: (id: string) => void;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onTagClick?: (tag: string) => void;
}
export default function TodoList({
todos,
selectedIds,
isLoading,
isError,
error,
onToggle,
onSelect,
onEdit,
onDelete,
onTagClick,
}: TodoListProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 text-blue-600 animate-spin" />
<span className="ml-3 text-gray-500"> ...</span>
</div>
);
}
if (isError) {
return (
<div className="flex flex-col items-center justify-center py-16 text-red-500">
<p className="text-sm font-medium"> </p>
<p className="text-xs text-gray-400 mt-1">
{error?.message || "할일 목록을 불러올 수 없습니다"}
</p>
</div>
);
}
if (!todos || todos.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<ListX className="h-12 w-12 mb-3" />
<p className="text-sm font-medium"> </p>
<p className="text-xs mt-1"> </p>
</div>
);
}
return (
<div className="space-y-2">
{todos.map((todo) => (
<TodoCard
key={todo.id}
todo={todo}
isSelected={selectedIds.includes(todo.id)}
onToggle={onToggle}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
onTagClick={onTagClick}
/>
))}
</div>
);
}

View File

@ -0,0 +1,420 @@
"use client";
import { useState, useEffect } from "react";
import { X, Trash2, Loader2 } from "lucide-react";
import { Todo, TodoCreate, TodoUpdate, Priority, Attachment } from "@/types";
import { useCategoryList } from "@/hooks/useCategories";
import {
useCreateTodo,
useUpdateTodo,
useDeleteTodo,
useUploadAttachments,
useDeleteAttachment,
} from "@/hooks/useTodos";
import { apiClient } from "@/lib/api";
import { cn } from "@/lib/utils";
import TagInput from "@/components/common/TagInput";
import DatePicker from "@/components/common/DatePicker";
import FileUpload from "@/components/common/FileUpload";
import AttachmentList from "@/components/common/AttachmentList";
interface TodoModalProps {
mode: "create" | "edit";
todo?: Todo | null;
isOpen: boolean;
onClose: () => void;
}
type TabType = "basic" | "extra";
export default function TodoModal({
mode,
todo,
isOpen,
onClose,
}: TodoModalProps) {
const { data: categories } = useCategoryList();
const createTodo = useCreateTodo();
const updateTodo = useUpdateTodo();
const deleteTodo = useDeleteTodo();
const uploadAttachments = useUploadAttachments();
const deleteAttachment = useDeleteAttachment();
const [activeTab, setActiveTab] = useState<TabType>("basic");
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [categoryId, setCategoryId] = useState<string | null>(null);
const [priority, setPriority] = useState<Priority>("medium");
const [startDate, setStartDate] = useState<string | null>(null);
const [dueDate, setDueDate] = useState<string | null>(null);
const [tags, setTags] = useState<string[]>([]);
const [error, setError] = useState("");
const [successMessage, setSuccessMessage] = useState("");
useEffect(() => {
if (mode === "edit" && todo) {
setTitle(todo.title);
setContent(todo.content || "");
setCategoryId(todo.category_id || null);
setPriority(todo.priority);
setStartDate(todo.start_date || null);
setDueDate(todo.due_date || null);
setTags(todo.tags || []);
} else if (mode === "create") {
setTitle("");
setContent("");
setCategoryId(null);
setPriority("medium");
setStartDate(null);
setDueDate(null);
setTags([]);
}
setActiveTab("basic");
setError("");
setSuccessMessage("");
}, [mode, todo, isOpen]);
const handleSave = async () => {
setError("");
setSuccessMessage("");
const trimmedTitle = title.trim();
if (!trimmedTitle) {
setError("제목을 입력해주세요");
return;
}
if (trimmedTitle.length > 200) {
setError("제목은 200자 이하로 입력해주세요");
return;
}
try {
if (mode === "create") {
const data: TodoCreate = {
title: trimmedTitle,
content: content.trim() || undefined,
category_id: categoryId,
priority,
start_date: startDate,
due_date: dueDate,
tags,
};
await createTodo.mutateAsync(data);
onClose();
} else if (todo) {
const data: TodoUpdate = {
title: trimmedTitle,
content: content.trim() || null,
category_id: categoryId,
priority,
start_date: startDate,
due_date: dueDate,
tags,
};
await updateTodo.mutateAsync({ id: todo.id, data });
setSuccessMessage("저장되었습니다");
setTimeout(() => setSuccessMessage(""), 3000);
}
} catch (err: unknown) {
const apiErr = err as { detail?: string };
setError(apiErr.detail || "저장에 실패했습니다");
}
};
const handleDelete = async () => {
if (!todo) return;
if (!window.confirm("이 할일을 삭제하시겠습니까?")) return;
try {
await deleteTodo.mutateAsync(todo.id);
onClose();
} catch (err: unknown) {
const apiErr = err as { detail?: string };
setError(apiErr.detail || "삭제에 실패했습니다");
}
};
const handleFileUpload = async (files: File[]) => {
if (!todo) return;
setError("");
try {
await uploadAttachments.mutateAsync({ todoId: todo.id, files });
setSuccessMessage("파일이 업로드되었습니다");
setTimeout(() => setSuccessMessage(""), 3000);
} catch (err: unknown) {
const apiErr = err as { detail?: string };
setError(apiErr.detail || "파일 업로드에 실패했습니다");
}
};
const handleFileDelete = async (attachmentId: string) => {
if (!todo) return;
if (!window.confirm("이 파일을 삭제하시겠습니까?")) return;
setError("");
try {
await deleteAttachment.mutateAsync({ todoId: todo.id, attachmentId });
setSuccessMessage("파일이 삭제되었습니다");
setTimeout(() => setSuccessMessage(""), 3000);
} catch (err: unknown) {
const apiErr = err as { detail?: string };
setError(apiErr.detail || "파일 삭제에 실패했습니다");
}
};
const handleFileDownload = (attachment: Attachment) => {
if (!todo) return;
apiClient.downloadFile(
`/api/todos/${todo.id}/attachments/${attachment.id}/download`,
attachment.filename
);
};
const isSubmitting =
createTodo.isPending || updateTodo.isPending || deleteTodo.isPending;
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Overlay */}
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
{/* Modal */}
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 flex-shrink-0">
<h2 className="text-lg font-semibold text-gray-900">
{mode === "create" ? "새 할일 추가" : "할일 수정"}
</h2>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Messages */}
{error && (
<div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg">
{error}
</div>
)}
{successMessage && (
<div className="p-3 text-sm text-green-700 bg-green-50 rounded-lg">
{successMessage}
</div>
)}
{/* Title (always visible, above tabs) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="할일 제목을 입력하세요"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
maxLength={200}
autoFocus
/>
</div>
{/* Tab buttons */}
<div className="flex border-b border-gray-200">
<button
type="button"
onClick={() => setActiveTab("basic")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
activeTab === "basic"
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
)}
>
</button>
<button
type="button"
onClick={() => setActiveTab("extra")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
activeTab === "extra"
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
)}
>
{mode === "edit" && todo?.attachments && todo.attachments.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center w-5 h-5 text-xs bg-blue-100 text-blue-600 rounded-full">
{todo.attachments.length}
</span>
)}
</button>
</div>
{/* Tab Content - both tabs rendered, inactive hidden via grid overlap */}
<div className="grid">
<div className={cn(
"col-start-1 row-start-1 space-y-4",
activeTab !== "basic" && "invisible pointer-events-none"
)}>
{/* Content */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="상세 내용을 입력하세요"
rows={4}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
maxLength={2000}
tabIndex={activeTab === "basic" ? 0 : -1}
/>
</div>
{/* Category + Priority (2 columns) */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
value={categoryId || ""}
onChange={(e) => setCategoryId(e.target.value || null)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
tabIndex={activeTab === "basic" ? 0 : -1}
>
<option value=""></option>
{categories?.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value as Priority)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
tabIndex={activeTab === "basic" ? 0 : -1}
>
<option value="high"></option>
<option value="medium"></option>
<option value="low"></option>
</select>
</div>
</div>
{/* Start Date + Due Date (2 columns) */}
<div className="grid grid-cols-2 gap-4">
<DatePicker
value={startDate}
onChange={setStartDate}
label="시작일"
clearLabel="시작일 해제"
/>
<DatePicker
value={dueDate}
onChange={setDueDate}
label="마감일"
clearLabel="마감일 해제"
/>
</div>
</div>
<div className={cn(
"col-start-1 row-start-1 space-y-4",
activeTab !== "extra" && "invisible pointer-events-none"
)}>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<TagInput
tags={tags}
onAdd={(tag) => setTags([...tags, tag])}
onRemove={(tag) => setTags(tags.filter((t) => t !== tag))}
/>
</div>
{/* Attachments */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
{mode === "edit" && todo ? (
<div className="space-y-3">
<FileUpload
onFilesSelected={handleFileUpload}
maxFiles={5 - (todo.attachments?.length || 0)}
isUploading={uploadAttachments.isPending}
disabled={(todo.attachments?.length || 0) >= 5}
/>
<AttachmentList
attachments={todo.attachments || []}
onDownload={handleFileDownload}
onDelete={handleFileDelete}
/>
</div>
) : (
<div className="p-4 text-sm text-gray-500 bg-gray-50 rounded-lg border border-gray-200 text-center">
.
</div>
)}
</div>
</div>
</div>
</div>
{/* Footer Actions */}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 flex-shrink-0">
{mode === "edit" && todo ? (
<button
type="button"
onClick={handleDelete}
disabled={isSubmitting}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-red-600 bg-white border border-red-200 rounded-lg hover:bg-red-50 disabled:opacity-50 transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
) : (
<div />
)}
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
</button>
<button
type="button"
onClick={handleSave}
disabled={isSubmitting}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
{mode === "create" ? "추가" : "저장"}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,63 @@
import {
useQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import {
Category,
CategoryCreate,
CategoryUpdate,
} from "@/types";
import { categoryKeys, todoKeys, dashboardKeys } from "./useTodos";
export { categoryKeys };
export function useCategoryList() {
return useQuery({
queryKey: categoryKeys.list(),
queryFn: () => apiClient.get<Category[]>("/api/categories"),
staleTime: 60 * 1000,
gcTime: 10 * 60 * 1000,
});
}
export function useCreateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CategoryCreate) =>
apiClient.post<Category>("/api/categories", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: categoryKeys.all });
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
},
});
}
export function useUpdateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: CategoryUpdate }) =>
apiClient.put<Category>(`/api/categories/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: categoryKeys.all });
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
},
});
}
export function useDeleteCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiClient.delete(`/api/categories/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: categoryKeys.all });
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
},
});
}

View File

@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import { DashboardStats } from "@/types";
import { dashboardKeys } from "./useTodos";
export { dashboardKeys };
export function useDashboardStats() {
return useQuery({
queryKey: dashboardKeys.stats(),
queryFn: () => apiClient.get<DashboardStats>("/api/dashboard/stats"),
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
});
}

View File

@ -0,0 +1,128 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import { Category, TodoListResponse } from "@/types";
import { categoryKeys, todoKeys } from "./useTodos";
export interface GanttTodo {
id: string;
title: string;
startDate: Date;
endDate: Date;
completed: boolean;
priority: string;
}
export interface GanttCategory {
id: string;
name: string;
color: string;
todos: GanttTodo[];
}
export interface GanttData {
categories: GanttCategory[];
minDate: Date;
maxDate: Date;
totalDays: number;
}
export function useGanttData(categoryId?: string) {
const categoriesQuery = useQuery({
queryKey: categoryKeys.list(),
queryFn: () => apiClient.get<Category[]>("/api/categories"),
staleTime: 60 * 1000,
});
const todosQuery = useQuery({
queryKey: [...todoKeys.all, "gantt", categoryId ?? "all"],
queryFn: () =>
apiClient.get<TodoListResponse>("/api/todos", {
page: 1,
limit: 100,
...(categoryId ? { category_id: categoryId } : {}),
}),
staleTime: 30 * 1000,
});
const isLoading = categoriesQuery.isLoading || todosQuery.isLoading;
const isError = categoriesQuery.isError || todosQuery.isError;
let data: GanttData | null = null;
if (categoriesQuery.data && todosQuery.data) {
const categories = categoriesQuery.data;
const todos = todosQuery.data.items;
// start_date와 due_date가 모두 있는 Todo만 포함
const validTodos = todos.filter((t) => t.start_date && t.due_date);
if (validTodos.length > 0) {
// 날짜 범위 계산
let minDate = new Date(validTodos[0].start_date!);
let maxDate = new Date(validTodos[0].due_date!);
validTodos.forEach((t) => {
const s = new Date(t.start_date!);
const e = new Date(t.due_date!);
if (s < minDate) minDate = s;
if (e > maxDate) maxDate = e;
});
// 앞뒤 여유 3일 추가
minDate = new Date(minDate);
minDate.setDate(minDate.getDate() - 3);
maxDate = new Date(maxDate);
maxDate.setDate(maxDate.getDate() + 3);
const totalDays = Math.ceil(
(maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24)
);
// 카테고리별 그룹핑
const categoryMap = new Map<string | null, GanttTodo[]>();
validTodos.forEach((t) => {
const key = t.category_id || null;
if (!categoryMap.has(key)) categoryMap.set(key, []);
categoryMap.get(key)!.push({
id: t.id,
title: t.title,
startDate: new Date(t.start_date!),
endDate: new Date(t.due_date!),
completed: t.completed,
priority: t.priority,
});
});
// 카테고리 목록 구성
const ganttCategories: GanttCategory[] = [];
categories.forEach((cat) => {
const catTodos = categoryMap.get(cat.id);
if (catTodos && catTodos.length > 0) {
ganttCategories.push({
id: cat.id,
name: cat.name,
color: cat.color,
todos: catTodos,
});
}
});
// 미분류 카테고리
const uncategorized = categoryMap.get(null);
if (uncategorized && uncategorized.length > 0) {
ganttCategories.push({
id: "uncategorized",
name: "미분류",
color: "#9CA3AF",
todos: uncategorized,
});
}
data = { categories: ganttCategories, minDate, maxDate, totalDays };
}
}
return { data, isLoading, isError };
}

View File

@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import { SearchResponse } from "@/types";
export const searchKeys = {
all: ["search"] as const,
results: (query: string, page: number) =>
[...searchKeys.all, query, page] as const,
};
export function useSearch(query: string, page: number = 1, limit: number = 20) {
return useQuery({
queryKey: searchKeys.results(query, page),
queryFn: () =>
apiClient.get<SearchResponse>("/api/search", {
q: query,
page,
limit,
}),
staleTime: 0,
gcTime: 5 * 60 * 1000,
enabled: !!query && query.trim().length > 0,
});
}

View File

@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import { TagInfo } from "@/types";
import { tagKeys } from "./useTodos";
export { tagKeys };
export function useTagList() {
return useQuery({
queryKey: tagKeys.list(),
queryFn: () => apiClient.get<TagInfo[]>("/api/tags"),
staleTime: 60 * 1000,
gcTime: 10 * 60 * 1000,
});
}

View File

@ -0,0 +1,190 @@
import {
useQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import {
Todo,
TodoCreate,
TodoUpdate,
TodoListResponse,
TodoFilters,
ToggleResponse,
BatchRequest,
BatchResponse,
Attachment,
} from "@/types";
// === Query Keys ===
export const todoKeys = {
all: ["todos"] as const,
lists: () => [...todoKeys.all, "list"] as const,
list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, "detail"] as const,
detail: (id: string) => [...todoKeys.details(), id] as const,
};
export const dashboardKeys = {
all: ["dashboard"] as const,
stats: () => [...dashboardKeys.all, "stats"] as const,
};
export const tagKeys = {
all: ["tags"] as const,
list: () => [...tagKeys.all, "list"] as const,
};
export const categoryKeys = {
all: ["categories"] as const,
list: () => [...categoryKeys.all, "list"] as const,
};
// === Queries ===
export function useTodoList(filters: TodoFilters) {
return useQuery({
queryKey: todoKeys.list(filters),
queryFn: () =>
apiClient.get<TodoListResponse>("/api/todos", {
page: filters.page,
limit: filters.limit,
completed: filters.completed,
category_id: filters.category_id,
priority: filters.priority,
tag: filters.tag,
sort: filters.sort,
order: filters.order,
}),
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
});
}
export function useTodoDetail(id: string) {
return useQuery({
queryKey: todoKeys.detail(id),
queryFn: () => apiClient.get<Todo>(`/api/todos/${id}`),
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
enabled: !!id,
});
}
// === Mutations ===
export function useCreateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: TodoCreate) =>
apiClient.post<Todo>("/api/todos", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
queryClient.invalidateQueries({ queryKey: tagKeys.all });
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
queryClient.invalidateQueries({ queryKey: categoryKeys.all });
},
});
}
export function useUpdateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: TodoUpdate }) =>
apiClient.put<Todo>(`/api/todos/${id}`, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
queryClient.invalidateQueries({
queryKey: todoKeys.detail(variables.id),
});
queryClient.invalidateQueries({ queryKey: tagKeys.all });
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
queryClient.invalidateQueries({ queryKey: categoryKeys.all });
},
});
}
export function useDeleteTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiClient.delete(`/api/todos/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
queryClient.invalidateQueries({ queryKey: tagKeys.all });
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
queryClient.invalidateQueries({ queryKey: categoryKeys.all });
},
});
}
export function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiClient.patch<ToggleResponse>(`/api/todos/${id}/toggle`),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
queryClient.invalidateQueries({ queryKey: todoKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
},
});
}
export function useBatchAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: BatchRequest) =>
apiClient.post<BatchResponse>("/api/todos/batch", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
queryClient.invalidateQueries({ queryKey: tagKeys.all });
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
queryClient.invalidateQueries({ queryKey: categoryKeys.all });
},
});
}
// === Attachment Mutations ===
export function useUploadAttachments() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ todoId, files }: { todoId: string; files: File[] }) =>
apiClient.uploadFiles<Attachment[]>(
`/api/todos/${todoId}/attachments`,
files
),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: todoKeys.detail(variables.todoId),
});
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
},
});
}
export function useDeleteAttachment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
todoId,
attachmentId,
}: {
todoId: string;
attachmentId: string;
}) => apiClient.delete(`/api/todos/${todoId}/attachments/${attachmentId}`),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: todoKeys.detail(variables.todoId),
});
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
},
});
}

121
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,121 @@
import { ApiError } from "@/types";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
private async request<T>(
method: string,
path: string,
options?: {
body?: unknown;
params?: Record<string, string | number | boolean | undefined | null>;
}
): Promise<T> {
const url = new URL(`${this.baseUrl}${path}`);
if (options?.params) {
Object.entries(options.params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
}
});
}
const res = await fetch(url.toString(), {
method,
headers: {
"Content-Type": "application/json",
},
body: options?.body ? JSON.stringify(options.body) : undefined,
});
if (res.status === 204) {
return undefined as T;
}
if (!res.ok) {
const error = await res
.json()
.catch(() => ({ detail: "알 수 없는 오류가 발생했습니다" }));
throw {
detail: error.detail || `HTTP ${res.status} Error`,
status: res.status,
} as ApiError;
}
return res.json();
}
get<T>(
path: string,
params?: Record<string, string | number | boolean | undefined | null>
) {
return this.request<T>("GET", path, { params });
}
post<T>(path: string, body?: unknown) {
return this.request<T>("POST", path, { body });
}
put<T>(path: string, body?: unknown) {
return this.request<T>("PUT", path, { body });
}
patch<T>(path: string, body?: unknown) {
return this.request<T>("PATCH", path, { body });
}
delete<T>(path: string) {
return this.request<T>("DELETE", path);
}
async uploadFiles<T>(path: string, files: File[]): Promise<T> {
const url = new URL(`${this.baseUrl}${path}`);
const formData = new FormData();
files.forEach((file) => formData.append("files", file));
const res = await fetch(url.toString(), {
method: "POST",
body: formData,
});
if (!res.ok) {
const error = await res
.json()
.catch(() => ({ detail: "파일 업로드에 실패했습니다" }));
throw {
detail: error.detail || `HTTP ${res.status} Error`,
status: res.status,
} as ApiError;
}
return res.json();
}
async downloadFile(path: string, filename: string): Promise<void> {
const url = new URL(`${this.baseUrl}${path}`);
const res = await fetch(url.toString());
if (!res.ok) {
throw new Error("다운로드에 실패했습니다");
}
const blob = await res.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
}
}
export const apiClient = new ApiClient(API_BASE_URL);

135
frontend/src/lib/utils.ts Normal file
View File

@ -0,0 +1,135 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { format, formatDistanceToNow, differenceInDays } from "date-fns";
import { ko } from "date-fns/locale";
import { Priority } from "@/types";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// === Date Utilities ===
export function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return "";
return format(new Date(dateStr), "yyyy-MM-dd");
}
export function formatDateTime(dateStr: string | null | undefined): string {
if (!dateStr) return "";
return format(new Date(dateStr), "yyyy-MM-dd HH:mm");
}
export function formatRelativeDate(dateStr: string): string {
return formatDistanceToNow(new Date(dateStr), { addSuffix: true, locale: ko });
}
// === Due Date Status ===
export type DueDateStatus = "overdue" | "urgent" | "soon" | "normal" | null;
export function getDueDateStatus(
dueDate: string | null | undefined,
completed: boolean
): DueDateStatus {
if (!dueDate || completed) return null;
const now = new Date();
const due = new Date(dueDate);
const diffMs = due.getTime() - now.getTime();
const diffDays = diffMs / (1000 * 60 * 60 * 24);
if (diffDays < 0) return "overdue";
if (diffDays <= 1) return "urgent";
if (diffDays <= 3) return "soon";
return "normal";
}
export function getDueDateLabel(status: DueDateStatus): string {
switch (status) {
case "overdue":
return "초과";
case "urgent":
return "임박";
case "soon":
return "곧 마감";
default:
return "";
}
}
export function getDueDateColor(status: DueDateStatus): string {
switch (status) {
case "overdue":
return "bg-red-100 text-red-700";
case "urgent":
return "bg-orange-100 text-orange-700";
case "soon":
return "bg-yellow-100 text-yellow-700";
default:
return "bg-gray-100 text-gray-500";
}
}
export function getDDayText(dueDate: string | null | undefined): string {
if (!dueDate) return "";
const now = new Date();
now.setHours(0, 0, 0, 0);
const due = new Date(dueDate);
due.setHours(0, 0, 0, 0);
const diff = differenceInDays(due, now);
if (diff === 0) return "D-Day";
if (diff > 0) return `D-${diff}`;
return `D+${Math.abs(diff)}`;
}
// === Priority ===
export const PRIORITY_CONFIG = {
high: {
label: "높음",
color: "bg-red-100 text-red-700",
dotColor: "bg-red-500",
barColor: "#EF4444",
},
medium: {
label: "중간",
color: "bg-yellow-100 text-yellow-700",
dotColor: "bg-yellow-500",
barColor: "#F59E0B",
},
low: {
label: "낮음",
color: "bg-blue-100 text-blue-700",
dotColor: "bg-blue-500",
barColor: "#3B82F6",
},
} as const;
export function getPriorityConfig(priority: Priority) {
return PRIORITY_CONFIG[priority];
}
// === Color Presets ===
export const COLOR_PRESETS = [
"#EF4444", // red
"#F97316", // orange
"#F59E0B", // amber
"#EAB308", // yellow
"#84CC16", // lime
"#22C55E", // green
"#10B981", // emerald
"#14B8A6", // teal
"#06B6D4", // cyan
"#0EA5E9", // sky
"#3B82F6", // blue
"#6366F1", // indigo
"#8B5CF6", // violet
"#A855F7", // purple
"#D946EF", // fuchsia
"#EC4899", // pink
"#F43F5E", // rose
"#6B7280", // gray
];

View File

@ -0,0 +1,90 @@
import { create } from "zustand";
import { TodoFilters, SortField, SortOrder } from "@/types";
interface UIState {
// Sidebar
sidebarOpen: boolean;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
// Todo List Filters
filters: TodoFilters;
setFilter: <K extends keyof TodoFilters>(key: K, value: TodoFilters[K]) => void;
setFilters: (filters: Partial<TodoFilters>) => void;
resetFilters: () => void;
// Batch Selection
selectedIds: string[];
toggleSelect: (id: string) => void;
selectAll: (ids: string[]) => void;
clearSelection: () => void;
// Todo Form Modal
todoFormOpen: boolean;
todoFormMode: "create" | "edit";
editingTodoId: string | null;
openTodoForm: (mode: "create" | "edit", todoId?: string) => void;
closeTodoForm: () => void;
// Search
searchQuery: string;
setSearchQuery: (query: string) => void;
}
const DEFAULT_FILTERS: TodoFilters = {
sort: "created_at" as SortField,
order: "desc" as SortOrder,
page: 1,
limit: 20,
};
export const useUIStore = create<UIState>((set) => ({
// Sidebar
sidebarOpen: true,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
// Filters
filters: { ...DEFAULT_FILTERS },
setFilter: (key, value) =>
set((s) => ({
filters: {
...s.filters,
[key]: value,
...(key !== "page" ? { page: 1 } : {}),
},
})),
setFilters: (newFilters) =>
set((s) => ({
filters: { ...s.filters, ...newFilters, page: 1 },
})),
resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }),
// Batch Selection
selectedIds: [],
toggleSelect: (id) =>
set((s) => ({
selectedIds: s.selectedIds.includes(id)
? s.selectedIds.filter((i) => i !== id)
: [...s.selectedIds, id],
})),
selectAll: (ids) => set({ selectedIds: ids }),
clearSelection: () => set({ selectedIds: [] }),
// Todo Form Modal
todoFormOpen: false,
todoFormMode: "create",
editingTodoId: null,
openTodoForm: (mode, todoId) =>
set({
todoFormOpen: true,
todoFormMode: mode,
editingTodoId: todoId ?? null,
}),
closeTodoForm: () =>
set({ todoFormOpen: false, editingTodoId: null }),
// Search
searchQuery: "",
setSearchQuery: (query) => set({ searchQuery: query }),
}));

169
frontend/src/types/index.ts Normal file
View File

@ -0,0 +1,169 @@
// === Enums ===
export type Priority = "high" | "medium" | "low";
export type SortField = "created_at" | "due_date" | "priority";
export type SortOrder = "asc" | "desc";
export type BatchActionType = "complete" | "delete" | "move_category";
// === Attachment ===
export interface Attachment {
id: string;
filename: string;
stored_filename: string;
content_type: string;
size: number;
uploaded_at: string;
}
// === Todo ===
export interface Todo {
id: string;
title: string;
content?: string | null;
completed: boolean;
priority: Priority;
category_id?: string | null;
category_name?: string | null;
category_color?: string | null;
tags: string[];
start_date?: string | null;
due_date?: string | null;
attachments: Attachment[];
created_at: string;
updated_at: string;
}
export interface TodoCreate {
title: string;
content?: string;
category_id?: string | null;
tags?: string[];
priority?: Priority;
start_date?: string | null;
due_date?: string | null;
}
export interface TodoUpdate {
title?: string;
content?: string | null;
category_id?: string | null;
tags?: string[];
priority?: Priority;
start_date?: string | null;
due_date?: string | null;
completed?: boolean;
}
export interface TodoListResponse {
items: Todo[];
total: number;
page: number;
limit: number;
total_pages: number;
}
export interface ToggleResponse {
id: string;
completed: boolean;
}
// === Batch ===
export interface BatchRequest {
action: BatchActionType;
ids: string[];
category_id?: string | null;
}
export interface BatchResponse {
action: string;
processed: number;
failed: number;
}
// === Category ===
export interface Category {
id: string;
name: string;
color: string;
order: number;
todo_count: number;
created_at: string;
}
export interface CategoryCreate {
name: string;
color?: string;
}
export interface CategoryUpdate {
name?: string;
color?: string;
order?: number;
}
// === Tag ===
export interface TagInfo {
name: string;
count: number;
}
// === Search ===
export interface SearchResponse {
items: Todo[];
total: number;
query: string;
page: number;
limit: number;
}
// === Dashboard ===
export interface DashboardOverview {
total: number;
completed: number;
incomplete: number;
completion_rate: number;
}
export interface CategoryStat {
category_id: string | null;
name: string;
color: string;
count: number;
}
export interface PriorityStat {
high: number;
medium: number;
low: number;
}
export interface UpcomingDeadline {
id: string;
title: string;
due_date: string;
priority: Priority;
}
export interface DashboardStats {
overview: DashboardOverview;
by_category: CategoryStat[];
by_priority: PriorityStat;
upcoming_deadlines: UpcomingDeadline[];
}
// === Filters ===
export interface TodoFilters {
completed?: boolean;
category_id?: string;
priority?: Priority;
tag?: string;
sort: SortField;
order: SortOrder;
page: number;
limit: number;
}
// === Error ===
export interface ApiError {
detail: string;
status: number;
}

27
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}