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

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

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

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

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