feat(project): 아사나식 프로젝트 관리 모듈 추가

- project_db 신규(projects/members/stages/tasks/comments/activity, project_app CRUD)
- 프로젝트/서브프로젝트(self-FK), 업무·진행단계(칸반), 멤버 배정
- 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 토글 + 보드/리스트
- 진입은 로그인 회사 직원 전원, 생성/배정은 관리자 전용
- 업무 배정·완료 시 관리자 메일 알림(app/mail.py, SMTP_* env, 미설정 시 skip)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 11:22:05 +09:00
parent 8d7fe01c2d
commit c192d61c71
14 changed files with 2404 additions and 1 deletions
+21 -1
View File
@@ -21,6 +21,8 @@ from .modules.expense import CategoryStore, build_expense_store
from .modules.expense import router as expense_router
from .modules.malaysia import build_malaysia_itemcode, build_malaysia_store
from .modules.malaysia import router as malaysia_router
from .modules.project import build_project_store
from .modules.project import router as project_router
from .modules.vacation import build_vacation_store
from .modules.vacation import router as vacation_router
from .store import (
@@ -42,6 +44,7 @@ MODULE_LABELS: dict[str, str] = {
"cupang": "쿠팡 밀크런",
"malaysia": "말레이시아 재고관리",
"dispatch": "말레이시아 배송",
"project": "프로젝트 관리",
"expense_approver": "개인경비",
"vacation_approver": "휴가",
}
@@ -116,6 +119,7 @@ _MODULE_TEMPLATE_DIRS = [
BASE_DIR / "modules" / "vacation" / "templates",
BASE_DIR / "modules" / "malaysia" / "templates",
BASE_DIR / "modules" / "dispatch" / "templates",
BASE_DIR / "modules" / "project" / "templates",
]
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
templates.env.loader = ChoiceLoader(
@@ -163,6 +167,9 @@ app.state.malaysia_itemcode = build_malaysia_itemcode()
# 말레이시아 배송(TikTok 출고): DISPATCH_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
# 업로드 원본은 DATA_DIR/dispatch/ 아래 저장(개인정보는 저장하지 않음).
app.state.dispatch_store = build_dispatch_store(dsn=env("DISPATCH_DB_URL") or None)
# 프로젝트 관리(아사나식): PROJECT_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
# 메일 알림은 SMTP_* 환경변수 기반(app/mail.py). 미설정 시 조용히 skip.
app.state.project_store = build_project_store(dsn=env("PROJECT_DB_URL") or None)
# 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄.
app.include_router(expense_router)
@@ -170,6 +177,7 @@ app.include_router(cupang_router)
app.include_router(vacation_router)
app.include_router(malaysia_router)
app.include_router(dispatch_router)
app.include_router(project_router)
def public_url_for(request: Request, route_name: str) -> str:
@@ -332,10 +340,21 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
"status": "ready",
"category": "운영",
},
{
"key": "project",
"title": "프로젝트 관리",
"subtitle": "Projects",
"description": "프로젝트·서브프로젝트·업무를 달력/타임라인/보드로 관리합니다(아사나식).",
"url": "/project/",
"health_url": "/project/health",
"status": "ready",
"category": "관리",
},
]
allowed = allowed_modules(user_rec)
for item in items:
item["allowed"] = item["key"] in allowed
# project 는 권한키 없이 로그인한 회사 직원 전원 진입(전사 협업).
item["allowed"] = item["key"] == "project" or item["key"] in allowed
return items
@@ -350,6 +369,7 @@ def _icon_svg(name: str) -> str:
"cupang": '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/>',
"malaysia": '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
"dispatch": '<rect x="1" y="3" width="15" height="13"/><path d="M16 8h4l3 3v5h-7V8z"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/>',
"project": '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="13" x2="13" y2="13"/><line x1="8" y1="16" x2="11" y2="16"/>',
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
}
body = paths.get(name, paths["modules"])