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:
@ -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
44
backend/app/config.py
Normal 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
98
backend/app/database.py
Normal 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")
|
||||
@ -1,17 +1,55 @@
|
||||
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
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
from app.config import get_settings
|
||||
from app.database import connect_db, disconnect_db
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
|
||||
|
||||
@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=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 라우터 등록
|
||||
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()
|
||||
|
||||
47
backend/app/models/__init__.py
Normal file
47
backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
49
backend/app/models/category.py
Normal file
49
backend/app/models/category.py
Normal 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
|
||||
29
backend/app/models/common.py
Normal file
29
backend/app/models/common.py
Normal 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
221
backend/app/models/todo.py
Normal 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]
|
||||
9
backend/app/routers/__init__.py
Normal file
9
backend/app/routers/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
from app.routers import todos, categories, tags, search, dashboard
|
||||
|
||||
__all__ = [
|
||||
"todos",
|
||||
"categories",
|
||||
"tags",
|
||||
"search",
|
||||
"dashboard",
|
||||
]
|
||||
55
backend/app/routers/categories.py
Normal file
55
backend/app/routers/categories.py
Normal 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)
|
||||
17
backend/app/routers/dashboard.py
Normal file
17
backend/app/routers/dashboard.py
Normal 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()
|
||||
19
backend/app/routers/search.py
Normal file
19
backend/app/routers/search.py
Normal 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)
|
||||
16
backend/app/routers/tags.py
Normal file
16
backend/app/routers/tags.py
Normal 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()
|
||||
106
backend/app/routers/todos.py
Normal file
106
backend/app/routers/todos.py
Normal 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)
|
||||
56
backend/app/routers/uploads.py
Normal file
56
backend/app/routers/uploads.py
Normal 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)
|
||||
11
backend/app/services/__init__.py
Normal file
11
backend/app/services/__init__.py
Normal 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",
|
||||
]
|
||||
161
backend/app/services/category_service.py
Normal file
161
backend/app/services/category_service.py
Normal 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()
|
||||
182
backend/app/services/dashboard_service.py
Normal file
182
backend/app/services/dashboard_service.py
Normal 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
|
||||
154
backend/app/services/file_service.py
Normal file
154
backend/app/services/file_service.py
Normal 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)
|
||||
92
backend/app/services/search_service.py
Normal file
92
backend/app/services/search_service.py
Normal 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,
|
||||
)
|
||||
396
backend/app/services/todo_service.py
Normal file
396
backend/app/services/todo_service.py
Normal 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]
|
||||
@ -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
|
||||
|
||||
@ -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
2648
docs/ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load Diff
789
docs/FEATURE_SPEC.md
Normal file
789
docs/FEATURE_SPEC.md
Normal 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
259
docs/PLAN.md
Normal 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
312
docs/SCREEN_DESIGN.md
Normal 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
BIN
docs/SCREEN_DESIGN.pptx
Normal file
Binary file not shown.
320
docs/TEST_REPORT.md
Normal file
320
docs/TEST_REPORT.md
Normal 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
39
frontend/.gitignore
vendored
Normal 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
19
frontend/Dockerfile
Normal 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"]
|
||||
16
frontend/eslint.config.mjs
Normal file
16
frontend/eslint.config.mjs
Normal 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
7
frontend/next.config.ts
Normal 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
6439
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
frontend/package.json
Normal file
35
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
frontend/postcss.config.mjs
Normal file
8
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
122
frontend/src/app/categories/page.tsx
Normal file
122
frontend/src/app/categories/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
frontend/src/app/globals.css
Normal file
1
frontend/src/app/globals.css
Normal file
@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
28
frontend/src/app/layout.tsx
Normal file
28
frontend/src/app/layout.tsx
Normal 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
48
frontend/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
frontend/src/app/search/page.tsx
Normal file
60
frontend/src/app/search/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/app/todos/[id]/page.tsx
Normal file
22
frontend/src/app/todos/[id]/page.tsx
Normal 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;
|
||||
}
|
||||
239
frontend/src/app/todos/page.tsx
Normal file
239
frontend/src/app/todos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/categories/CategoryForm.tsx
Normal file
106
frontend/src/components/categories/CategoryForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/categories/CategoryItem.tsx
Normal file
63
frontend/src/components/categories/CategoryItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/categories/CategoryList.tsx
Normal file
51
frontend/src/components/categories/CategoryList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
frontend/src/components/categories/ColorPicker.tsx
Normal file
65
frontend/src/components/categories/ColorPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/common/AttachmentList.tsx
Normal file
78
frontend/src/components/common/AttachmentList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/common/DatePicker.tsx
Normal file
53
frontend/src/components/common/DatePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
frontend/src/components/common/FileUpload.tsx
Normal file
73
frontend/src/components/common/FileUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
frontend/src/components/common/Pagination.tsx
Normal file
99
frontend/src/components/common/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/common/PriorityBadge.tsx
Normal file
28
frontend/src/components/common/PriorityBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/common/TagBadge.tsx
Normal file
46
frontend/src/components/common/TagBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
frontend/src/components/common/TagInput.tsx
Normal file
99
frontend/src/components/common/TagInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
frontend/src/components/dashboard/CategoryChart.tsx
Normal file
95
frontend/src/components/dashboard/CategoryChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/dashboard/PriorityChart.tsx
Normal file
106
frontend/src/components/dashboard/PriorityChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/dashboard/StatsCards.tsx
Normal file
107
frontend/src/components/dashboard/StatsCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/dashboard/UpcomingDeadlines.tsx
Normal file
100
frontend/src/components/dashboard/UpcomingDeadlines.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/layout/Header.tsx
Normal file
51
frontend/src/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/layout/MainLayout.tsx
Normal file
22
frontend/src/components/layout/MainLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
frontend/src/components/layout/Sidebar.tsx
Normal file
157
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/providers/QueryProvider.tsx
Normal file
28
frontend/src/components/providers/QueryProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/search/SearchBar.tsx
Normal file
53
frontend/src/components/search/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
frontend/src/components/search/SearchResults.tsx
Normal file
137
frontend/src/components/search/SearchResults.tsx
Normal 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">
|
||||
"{query}" 검색 결과 ({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>
|
||||
);
|
||||
}
|
||||
109
frontend/src/components/todos/BatchActions.tsx
Normal file
109
frontend/src/components/todos/BatchActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
318
frontend/src/components/todos/GanttChart.tsx
Normal file
318
frontend/src/components/todos/GanttChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
frontend/src/components/todos/TodoCard.tsx
Normal file
160
frontend/src/components/todos/TodoCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
frontend/src/components/todos/TodoFilter.tsx
Normal file
111
frontend/src/components/todos/TodoFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/todos/TodoList.tsx
Normal file
78
frontend/src/components/todos/TodoList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
420
frontend/src/components/todos/TodoModal.tsx
Normal file
420
frontend/src/components/todos/TodoModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
frontend/src/hooks/useCategories.ts
Normal file
63
frontend/src/hooks/useCategories.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
15
frontend/src/hooks/useDashboard.ts
Normal file
15
frontend/src/hooks/useDashboard.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
128
frontend/src/hooks/useGantt.ts
Normal file
128
frontend/src/hooks/useGantt.ts
Normal 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 };
|
||||
}
|
||||
24
frontend/src/hooks/useSearch.ts
Normal file
24
frontend/src/hooks/useSearch.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
15
frontend/src/hooks/useTags.ts
Normal file
15
frontend/src/hooks/useTags.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
190
frontend/src/hooks/useTodos.ts
Normal file
190
frontend/src/hooks/useTodos.ts
Normal 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
121
frontend/src/lib/api.ts
Normal 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
135
frontend/src/lib/utils.ts
Normal 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
|
||||
];
|
||||
90
frontend/src/store/uiStore.ts
Normal file
90
frontend/src/store/uiStore.ts
Normal 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
169
frontend/src/types/index.ts
Normal 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
27
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user