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 .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY app/ ./app/
|
COPY app/ ./app/
|
||||||
|
RUN mkdir -p /app/uploads
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
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 contextlib import asynccontextmanager
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from datetime import datetime
|
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(
|
from app.config import get_settings
|
||||||
|
from app.database import connect_db, disconnect_db
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""서버 시작/종료 이벤트: MongoDB + Redis 연결 관리"""
|
||||||
|
await connect_db()
|
||||||
|
yield
|
||||||
|
await disconnect_db()
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="todos2 API",
|
||||||
|
description="확장형 할일 관리 애플리케이션 API",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/health")
|
# 라우터 등록
|
||||||
async def health_check():
|
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 {"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
|
fastapi>=0.104
|
||||||
uvicorn[standard]>=0.24.0
|
uvicorn[standard]>=0.24
|
||||||
motor>=3.3.0
|
motor>=3.3
|
||||||
pydantic>=2.5.0
|
pymongo>=4.9,<4.10
|
||||||
aioredis>=2.0.0
|
pydantic>=2.5
|
||||||
python-dotenv>=1.0.0
|
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/
|
- MONGODB_URL=mongodb://${MONGO_USER:-admin}:${MONGO_PASSWORD:-password123}@mongodb:27017/
|
||||||
- DB_NAME=${DB_NAME:-app_db}
|
- DB_NAME=${DB_NAME:-app_db}
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
|
volumes:
|
||||||
|
- upload_data:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
mongodb:
|
mongodb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@ -77,6 +79,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
mongodb_data:
|
mongodb_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
upload_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app-network:
|
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