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
+34
View File
@@ -0,0 +1,34 @@
"""프로젝트 관리(아사나식) 모듈.
라우터/저장소/템플릿을 한 디렉토리에서 관리한다(malaysia/dispatch 패턴).
- 라우터: `router.py` (FastAPI APIRouter, prefix=/project)
- 저장소: `db.py` (project_db / PostgreSQL 전용) + `store.py` (상수/순수 검증)
- 템플릿: `templates/project/`
- 메일: 전역 `app.mail.send_email` (업무 배정/완료 시 관리자 알림)
데이터 저장은 project_db 전용이다. PROJECT_DB_URL 미설정 시
build_project_store 는 None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를
보여준다(앱은 죽지 않음).
권한: 로그인한 회사(dbxcorp.co.kr) 직원은 모듈 진입 가능. 프로젝트 생성/삭제·
사용자 배정은 ERP 관리자(is_admin)만. 배정된 멤버는 서브프로젝트/업무/단계 관리.
"""
from typing import Any
from . import store
from .router import router
__all__ = ["router", "store", "build_project_store"]
def build_project_store(*, dsn: str | None) -> Any:
"""PROJECT_DB_URL 이 있으면 ProjectStore, 없으면 None.
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 페이지 표시.
"""
if not dsn:
return None
from .db import ProjectStore # 지연 import (개발 환경 deps 없을 수 있음)
return ProjectStore(dsn)
+519
View File
@@ -0,0 +1,519 @@
"""project_db PostgreSQL 저장소.
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — 다른 모듈과 동일 패턴.
- 연결 정보: 환경변수 `PROJECT_DB_URL`
(예: postgresql://project_app:<pwd>@postgres-db:5432/project_db)
- 스키마는 앱이 만들지 않는다. `scripts/sql/project_db_init.sql` 을 superuser 가
사전 적용한다. 앱 계정(project_app)은 CRUD 권한만 받는다.
- 연결 풀은 lazy open.
순수 검증/상수는 store.py 에 있고, 여기서는 DB I/O 와 직렬화만 담당한다.
"""
from __future__ import annotations
import logging
from datetime import date, datetime
from typing import Any
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from app.timezone import KST, now_kst
from . import store
logger = logging.getLogger("project.db")
class ProjectStore:
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
self._pool = ConnectionPool(
conninfo=dsn,
min_size=min_size,
max_size=max_size,
kwargs={"row_factory": dict_row, "autocommit": True},
open=False,
)
self._pool.open(wait=False)
def close(self) -> None:
self._pool.close()
# ════════════════════════════════════════════════════════════
# 프로젝트 / 서브프로젝트
# ════════════════════════════════════════════════════════════
def list_projects(
self, *, include_archived: bool = False, member_email: str | None = None
) -> list[dict[str, Any]]:
"""전체 프로젝트(서브 포함) 평면 목록. member_email 지정 시 그 사용자가
멤버이거나 owner 인 프로젝트만(관리자는 라우터에서 None 으로 전체 조회).
"""
clauses: list[str] = []
params: list[Any] = []
if not include_archived:
clauses.append("p.status <> 'archived'")
if member_email:
clauses.append(
"(p.owner_email = %s OR EXISTS ("
" SELECT 1 FROM project_members m "
" WHERE m.project_id = p.id AND m.user_email = %s))"
)
params.extend([member_email, member_email])
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT p.* FROM projects p {where} "
"ORDER BY p.parent_id NULLS FIRST, p.sort_order ASC, p.id ASC",
params,
).fetchall()
return [self._serialize(r) for r in rows]
def get_project(self, *, project_id: int) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM projects WHERE id = %s", (project_id,)
).fetchone()
return self._serialize(row) if row else None
def create_project(
self,
*,
name: str,
parent_id: int | None = None,
description: str = "",
color: str = "",
owner_email: str = "",
start_date: str | None = None,
due_date: str | None = None,
created_by: str = "",
seed_stages: bool = True,
) -> dict[str, Any]:
nm = store.validate_project_name(name)
col = store.validate_color(color)
sd = store.validate_date(start_date)
dd = store.validate_date(due_date)
with self._pool.connection() as conn:
with conn.transaction():
if parent_id is not None:
parent = conn.execute(
"SELECT id FROM projects WHERE id = %s", (parent_id,)
).fetchone()
if not parent:
raise KeyError(f"상위 프로젝트를 찾을 수 없습니다: {parent_id}")
row = conn.execute(
"""
INSERT INTO projects
(parent_id, name, description, color, owner_email,
start_date, due_date, created_by)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
parent_id,
nm,
store.norm_str(description),
col,
store.norm_str(owner_email, lower=True),
sd,
dd,
store.norm_str(created_by, lower=True),
),
).fetchone()
pid = row["id"]
if seed_stages:
for i, (stage_name, is_done) in enumerate(store.DEFAULT_STAGES):
conn.execute(
"INSERT INTO project_stages "
"(project_id, name, sort_order, is_done_stage) "
"VALUES (%s,%s,%s,%s)",
(pid, stage_name, i, is_done),
)
self._log(conn, project_id=pid, actor=created_by,
action=store.ACTION_CREATED, detail=nm)
return self._serialize(row)
def update_project(self, *, project_id: int, fields: dict[str, Any]) -> dict[str, Any]:
allowed = {
"name": lambda v: store.validate_project_name(v),
"description": lambda v: store.norm_str(v),
"color": lambda v: store.validate_color(v),
"owner_email": lambda v: store.norm_str(v, lower=True),
"start_date": lambda v: store.validate_date(v),
"due_date": lambda v: store.validate_date(v),
"status": lambda v: (store.norm_str(v, lower=True)
if store.norm_str(v, lower=True) in store.PROJECT_STATUSES
else "active"),
}
sets: list[str] = []
params: list[Any] = []
for key, fn in allowed.items():
if key in fields:
sets.append(f"{key} = %s")
params.append(fn(fields[key]))
if not sets:
existing = self.get_project(project_id=project_id)
if existing is None:
raise KeyError(project_id)
return existing
params.append(project_id)
with self._pool.connection() as conn:
row = conn.execute(
f"UPDATE projects SET {', '.join(sets)} WHERE id = %s RETURNING *",
params,
).fetchone()
if not row:
raise KeyError(project_id)
return self._serialize(row)
def delete_project(self, *, project_id: int) -> None:
"""프로젝트(서브프로젝트·단계·업무 CASCADE) 삭제."""
with self._pool.connection() as conn:
cur = conn.execute("DELETE FROM projects WHERE id = %s", (project_id,))
if cur.rowcount == 0:
raise KeyError(project_id)
# ════════════════════════════════════════════════════════════
# 멤버
# ════════════════════════════════════════════════════════════
def list_members(self, *, project_id: int) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM project_members WHERE project_id = %s "
"ORDER BY role DESC, user_email ASC",
(project_id,),
).fetchall()
return [self._serialize(r) for r in rows]
def is_member(self, *, project_id: int, user_email: str) -> bool:
email = store.norm_str(user_email, lower=True)
with self._pool.connection() as conn:
row = conn.execute(
"SELECT 1 FROM projects p "
"LEFT JOIN project_members m "
" ON m.project_id = p.id AND m.user_email = %s "
"WHERE p.id = %s AND (p.owner_email = %s OR m.id IS NOT NULL) LIMIT 1",
(email, project_id, email),
).fetchone()
return bool(row)
def add_member(
self, *, project_id: int, user_email: str, user_name: str = "", role: str = "member"
) -> dict[str, Any]:
email = store.norm_str(user_email, lower=True)
if not email or "@" not in email:
raise ValueError("올바른 이메일이 아닙니다.")
rl = store.validate_role(role)
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO project_members (project_id, user_email, user_name, role)
VALUES (%s,%s,%s,%s)
ON CONFLICT (project_id, user_email) DO UPDATE
SET role = EXCLUDED.role, user_name = EXCLUDED.user_name
RETURNING *
""",
(project_id, email, store.norm_str(user_name), rl),
).fetchone()
return self._serialize(row)
def remove_member(self, *, project_id: int, user_email: str) -> None:
email = store.norm_str(user_email, lower=True)
with self._pool.connection() as conn:
conn.execute(
"DELETE FROM project_members WHERE project_id = %s AND user_email = %s",
(project_id, email),
)
# ════════════════════════════════════════════════════════════
# 단계 (stages)
# ════════════════════════════════════════════════════════════
def list_stages(self, *, project_id: int) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM project_stages WHERE project_id = %s "
"ORDER BY sort_order ASC, id ASC",
(project_id,),
).fetchall()
return [self._serialize(r) for r in rows]
def add_stage(
self, *, project_id: int, name: str, is_done_stage: bool = False
) -> dict[str, Any]:
nm = store.norm_str(name)
if not nm:
raise ValueError("단계 이름은 필수입니다.")
with self._pool.connection() as conn:
nxt = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 AS n "
"FROM project_stages WHERE project_id = %s",
(project_id,),
).fetchone()
row = conn.execute(
"INSERT INTO project_stages (project_id, name, sort_order, is_done_stage) "
"VALUES (%s,%s,%s,%s) RETURNING *",
(project_id, nm, int(nxt["n"]), bool(is_done_stage)),
).fetchone()
return self._serialize(row)
def delete_stage(self, *, stage_id: int) -> None:
with self._pool.connection() as conn:
cur = conn.execute("DELETE FROM project_stages WHERE id = %s", (stage_id,))
if cur.rowcount == 0:
raise KeyError(stage_id)
# ════════════════════════════════════════════════════════════
# 업무 (tasks)
# ════════════════════════════════════════════════════════════
def list_tasks(
self,
*,
project_id: int | None = None,
assignee_email: str | None = None,
include_subprojects: bool = False,
limit: int = 1000,
) -> list[dict[str, Any]]:
clauses: list[str] = []
params: list[Any] = []
if project_id is not None:
if include_subprojects:
clauses.append(
"t.project_id IN ("
" WITH RECURSIVE tree AS ("
" SELECT id FROM projects WHERE id = %s "
" UNION ALL "
" SELECT p.id FROM projects p JOIN tree ON p.parent_id = tree.id"
" ) SELECT id FROM tree)"
)
params.append(project_id)
else:
clauses.append("t.project_id = %s")
params.append(project_id)
if assignee_email:
clauses.append("t.assignee_email = %s")
params.append(store.norm_str(assignee_email, lower=True))
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
params.append(int(limit))
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT t.*, p.name AS project_name, p.color AS project_color, "
f" s.name AS stage_name, s.is_done_stage "
f"FROM tasks t "
f"JOIN projects p ON p.id = t.project_id "
f"LEFT JOIN project_stages s ON s.id = t.stage_id "
f"{where} "
"ORDER BY t.sort_order ASC, t.id ASC LIMIT %s",
params,
).fetchall()
return [self._serialize(r) for r in rows]
def get_task(self, *, task_id: int) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT t.*, p.name AS project_name, p.color AS project_color, "
" s.name AS stage_name, s.is_done_stage "
"FROM tasks t JOIN projects p ON p.id = t.project_id "
"LEFT JOIN project_stages s ON s.id = t.stage_id "
"WHERE t.id = %s",
(task_id,),
).fetchone()
return self._serialize(row) if row else None
def create_task(
self,
*,
project_id: int,
title: str,
stage_id: int | None = None,
description: str = "",
assignee_email: str = "",
assignee_name: str = "",
priority: str = "normal",
start_date: str | None = None,
due_date: str | None = None,
created_by: str = "",
) -> dict[str, Any]:
ttl = store.validate_task_title(title)
pr = store.validate_priority(priority)
sd = store.validate_date(start_date)
dd = store.validate_date(due_date)
assignee = store.norm_str(assignee_email, lower=True)
with self._pool.connection() as conn:
with conn.transaction():
# stage 미지정 시 프로젝트의 첫 단계로
if stage_id is None:
st = conn.execute(
"SELECT id FROM project_stages WHERE project_id = %s "
"ORDER BY sort_order ASC, id ASC LIMIT 1",
(project_id,),
).fetchone()
stage_id = st["id"] if st else None
nxt = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 AS n FROM tasks "
"WHERE project_id = %s",
(project_id,),
).fetchone()
row = conn.execute(
"""
INSERT INTO tasks
(project_id, stage_id, title, description, assignee_email,
assignee_name, priority, start_date, due_date, sort_order, created_by)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
project_id, stage_id, ttl, store.norm_str(description),
assignee, store.norm_str(assignee_name), pr, sd, dd,
int(nxt["n"]), store.norm_str(created_by, lower=True),
),
).fetchone()
if assignee:
self._log(conn, project_id=project_id, task_id=row["id"],
actor=created_by, action=store.ACTION_ASSIGNED,
detail=f"{ttl}{assignee}")
return self.get_task(task_id=row["id"])
def update_task(
self, *, task_id: int, fields: dict[str, Any], actor: str = ""
) -> dict[str, Any]:
"""업무 수정. 반환에 알림 힌트를 담는다(라우터가 메일 발송 판단).
반환 dict 에 추가되는 키:
_newly_assigned: 새 담당자 이메일 or None
_newly_completed: 완료로 전환됐으면 True
"""
prev = self.get_task(task_id=task_id)
if prev is None:
raise KeyError(task_id)
sets: list[str] = []
params: list[Any] = []
newly_assigned: str | None = None
newly_completed = False
if "title" in fields:
sets.append("title = %s")
params.append(store.validate_task_title(fields["title"]))
if "description" in fields:
sets.append("description = %s")
params.append(store.norm_str(fields["description"]))
if "priority" in fields:
sets.append("priority = %s")
params.append(store.validate_priority(fields["priority"]))
if "start_date" in fields:
sets.append("start_date = %s")
params.append(store.validate_date(fields["start_date"]))
if "due_date" in fields:
sets.append("due_date = %s")
params.append(store.validate_date(fields["due_date"]))
if "assignee_email" in fields:
new_assignee = store.norm_str(fields["assignee_email"], lower=True)
sets.append("assignee_email = %s")
params.append(new_assignee)
if "assignee_name" in fields:
sets.append("assignee_name = %s")
params.append(store.norm_str(fields["assignee_name"]))
if new_assignee and new_assignee != (prev.get("assignee_email") or ""):
newly_assigned = new_assignee
elif "assignee_name" in fields:
sets.append("assignee_name = %s")
params.append(store.norm_str(fields["assignee_name"]))
# 단계 변경 → 완료단계 진입 시 completed_at 자동 세팅
if "stage_id" in fields:
new_stage = fields["stage_id"]
sets.append("stage_id = %s")
params.append(new_stage)
done = self._stage_is_done(new_stage)
if done and not prev.get("completed_at"):
sets.append("completed_at = %s")
params.append(now_kst())
newly_completed = True
elif not done and prev.get("completed_at"):
sets.append("completed_at = NULL")
if not sets:
return {**prev, "_newly_assigned": None, "_newly_completed": False}
params.append(task_id)
with self._pool.connection() as conn:
with conn.transaction():
conn.execute(
f"UPDATE tasks SET {', '.join(sets)} WHERE id = %s", params
)
if newly_assigned:
self._log(conn, project_id=prev["project_id"], task_id=task_id,
actor=actor, action=store.ACTION_ASSIGNED,
detail=f"{prev['title']}{newly_assigned}")
if newly_completed:
self._log(conn, project_id=prev["project_id"], task_id=task_id,
actor=actor, action=store.ACTION_COMPLETED,
detail=prev["title"])
out = self.get_task(task_id=task_id)
out["_newly_assigned"] = newly_assigned
out["_newly_completed"] = newly_completed
return out
def delete_task(self, *, task_id: int) -> None:
with self._pool.connection() as conn:
cur = conn.execute("DELETE FROM tasks WHERE id = %s", (task_id,))
if cur.rowcount == 0:
raise KeyError(task_id)
def _stage_is_done(self, stage_id: Any) -> bool:
if stage_id is None:
return False
with self._pool.connection() as conn:
row = conn.execute(
"SELECT is_done_stage FROM project_stages WHERE id = %s", (stage_id,)
).fetchone()
return bool(row and row["is_done_stage"])
# ════════════════════════════════════════════════════════════
# 활동 이력
# ════════════════════════════════════════════════════════════
def list_activity(self, *, project_id: int, limit: int = 100) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM project_activity WHERE project_id = %s "
"ORDER BY created_at DESC, id DESC LIMIT %s",
(project_id, int(limit)),
).fetchall()
return [self._serialize(r) for r in rows]
@staticmethod
def _log(
conn: Any, *, project_id: int | None = None, task_id: int | None = None,
actor: str = "", action: str = "", detail: str = "",
) -> None:
conn.execute(
"INSERT INTO project_activity "
"(project_id, task_id, actor_email, action, detail) "
"VALUES (%s,%s,%s,%s,%s)",
(project_id, task_id, store.norm_str(actor, lower=True), action,
store.norm_str(detail)),
)
# ════════════════════════════════════════════════════════════
# 직렬화
# ════════════════════════════════════════════════════════════
@staticmethod
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
for k, v in list(out.items()):
if isinstance(v, datetime):
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
elif isinstance(v, date):
out[k] = v.isoformat()
for k in ("id", "parent_id", "project_id", "stage_id", "task_id", "sort_order"):
if k in out and out[k] is not None:
try:
out[k] = int(out[k])
except (TypeError, ValueError):
pass
for k in ("is_done_stage",):
if k in out and out[k] is not None:
out[k] = bool(out[k])
return out
+562
View File
@@ -0,0 +1,562 @@
"""프로젝트 관리(아사나식) 모듈 라우터.
- 경로: /project
- 권한:
· 모듈 진입: 로그인한 회사 직원 전원(도메인 게이트는 OAuth 단에서 처리).
· 프로젝트 생성/삭제·사용자 배정: ERP 관리자(is_admin) 전용.
· 서브프로젝트/업무/단계 관리: 해당 프로젝트 멤버(또는 owner) 또는 관리자.
- 데이터: ProjectStore (project_db / PostgreSQL) 전용.
PROJECT_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내.
- 메일: 업무가 직원에게 '배정'되거나 '완료'되면 관리자에게 알림(BackgroundTasks).
뷰: 메인은 달력(FullCalendar)/타임라인(vis-timeline) 토글. 추가로 보드(칸반)·리스트.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.timezone import today_kst
from . import store
logger = logging.getLogger("project.router")
router = APIRouter(prefix="/project", tags=["project"])
# ────────────────────────────────────────────────────────────
# 공용 헬퍼
# ────────────────────────────────────────────────────────────
def _store(request: Request) -> Any:
return getattr(request.app.state, "project_store", None)
def _require_user(request: Request) -> dict[str, Any]:
from app.main import get_current_user_record # noqa: WPS433
user = get_current_user_record(request)
if user is None:
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
return user
def _require_admin(request: Request) -> dict[str, Any]:
from app.store import is_admin # noqa: WPS433
user = _require_user(request)
if not is_admin(user):
raise HTTPException(status_code=403, detail="관리자만 할 수 있는 작업입니다.")
return user
def _can_manage(request: Request, st: Any, user: dict[str, Any], project_id: int) -> bool:
"""프로젝트 단위 관리 권한: 관리자이거나 멤버/owner."""
from app.store import is_admin # noqa: WPS433
if is_admin(user):
return True
return bool(st.is_member(project_id=project_id, user_email=user["email"]))
def _require_manage(request: Request, st: Any, user: dict[str, Any], project_id: int) -> None:
if not _can_manage(request, st, user, project_id):
raise HTTPException(status_code=403, detail="이 프로젝트에 대한 권한이 없습니다.")
def _admin_emails(request: Request) -> list[str]:
"""알림 수신 관리자 이메일 목록.
PROJECT_NOTIFY_EMAIL(쉼표구분)이 있으면 우선, 없으면 user_store 의 admin 전원.
"""
import os
override = (os.getenv("PROJECT_NOTIFY_EMAIL", "") or "").strip()
if override:
return [e.strip() for e in override.split(",") if e.strip()]
from app.main import user_store # noqa: WPS433
from app.store import is_admin # noqa: WPS433
return [u["email"] for u in user_store.list_all() if is_admin(u)]
def _notify(
bg: BackgroundTasks, request: Request, *, subject: str, body: str
) -> None:
"""관리자에게 백그라운드 메일 발송 예약. 수신자 없거나 SMTP 미설정 시 조용히 skip."""
from app.mail import send_email # noqa: WPS433
recipients = _admin_emails(request)
if not recipients:
return
bg.add_task(send_email, to=recipients, subject=subject, body=body)
def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
return render_template(
request,
"denied.html",
{
"reason": "프로젝트 관리 모듈이 아직 설정되지 않았습니다. "
"PROJECT_DB_URL 환경변수를 설정하고 "
"scripts/sql/project_db_init.sql 로 project_db 를 초기화한 뒤 "
"컨테이너를 재기동하세요.",
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="project"),
},
status_code=503,
)
def _build_tree(projects: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""평면 프로젝트 목록 → parent_id 기준 트리(children 키)."""
by_id: dict[int, dict[str, Any]] = {}
roots: list[dict[str, Any]] = []
for p in projects:
p = {**p, "children": []}
by_id[p["id"]] = p
for p in by_id.values():
parent_id = p.get("parent_id")
if parent_id and parent_id in by_id:
by_id[parent_id]["children"].append(p)
else:
roots.append(p)
return roots
# ────────────────────────────────────────────────────────────
# 페이지
# ────────────────────────────────────────────────────────────
@router.get("/health")
async def health(request: Request) -> JSONResponse:
st = _store(request)
if st is None:
return JSONResponse({"status": "not_configured"}, status_code=503)
try:
st.list_projects(include_archived=False)
return JSONResponse({"status": "ok"})
except Exception as exc: # noqa: BLE001
return JSONResponse({"status": "error", "detail": str(exc)}, status_code=500)
@router.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
user = _require_user(request)
st = _store(request)
if st is None:
return _render_config_needed(request, user)
admin = is_admin(user)
projects = st.list_projects(
include_archived=False,
member_email=None if admin else user["email"],
)
tree = _build_tree(projects)
my_tasks = st.list_tasks(assignee_email=user["email"], limit=500)
return render_template(
request,
"project/index.html",
{
"user": user,
"is_admin": admin,
"nav_items": build_erp_nav(user, active="project"),
"page_title": "프로젝트 관리",
"page_subtitle": "달력·타임라인·보드로 프로젝트를 관리하세요.",
"tree": tree,
"my_tasks": my_tasks,
"today": today_kst().isoformat(),
},
)
@router.get("/p/{project_id}", response_class=HTMLResponse)
async def project_page(request: Request, project_id: int) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
user = _require_user(request)
st = _store(request)
if st is None:
return _render_config_needed(request, user)
project = st.get_project(project_id=project_id)
if project is None:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
admin = is_admin(user)
all_projects = st.list_projects(
include_archived=False,
member_email=None if admin else user["email"],
)
tree = _build_tree(all_projects)
stages = st.list_stages(project_id=project_id)
tasks = st.list_tasks(project_id=project_id, include_subprojects=True, limit=2000)
members = st.list_members(project_id=project_id)
can_manage = _can_manage(request, st, user, project_id)
subprojects = [p for p in all_projects if p.get("parent_id") == project_id]
return render_template(
request,
"project/project.html",
{
"user": user,
"is_admin": admin,
"can_manage": can_manage,
"nav_items": build_erp_nav(user, active="project"),
"page_title": project["name"],
"page_subtitle": project.get("description") or "프로젝트 보드",
"project": project,
"tree": tree,
"stages": stages,
"tasks": tasks,
"members": members,
"subprojects": subprojects,
"priority_labels": store.PRIORITY_LABELS,
"color_palette": list(store.COLOR_PALETTE),
"today": today_kst().isoformat(),
},
)
# ────────────────────────────────────────────────────────────
# JSON API — 프로젝트
# ────────────────────────────────────────────────────────────
def _db_or_503(request: Request) -> Any:
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="프로젝트 모듈이 설정되지 않았습니다.")
return st
@router.get("/api/projects")
async def api_list_projects(request: Request) -> JSONResponse:
from app.store import is_admin # noqa: WPS433
user = _require_user(request)
st = _db_or_503(request)
admin = is_admin(user)
projects = st.list_projects(
include_archived=False,
member_email=None if admin else user["email"],
)
return JSONResponse({"projects": projects, "tree": _build_tree(projects)})
@router.post("/api/projects")
async def api_create_project(
request: Request, payload: dict[str, Any] = Body(...)
) -> JSONResponse:
"""최상위 프로젝트 생성 — 관리자 전용."""
user = _require_admin(request)
st = _db_or_503(request)
try:
project = st.create_project(
name=payload.get("name", ""),
parent_id=None,
description=payload.get("description", ""),
color=payload.get("color", ""),
owner_email=payload.get("owner_email", "") or user["email"],
start_date=payload.get("start_date"),
due_date=payload.get("due_date"),
created_by=user["email"],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"project": project}, status_code=201)
@router.post("/api/projects/{project_id}/subprojects")
async def api_create_subproject(
request: Request, project_id: int, payload: dict[str, Any] = Body(...)
) -> JSONResponse:
"""서브프로젝트 생성 — 멤버(또는 관리자)."""
user = _require_user(request)
st = _db_or_503(request)
_require_manage(request, st, user, project_id)
try:
project = st.create_project(
name=payload.get("name", ""),
parent_id=project_id,
description=payload.get("description", ""),
color=payload.get("color", ""),
owner_email=payload.get("owner_email", "") or user["email"],
start_date=payload.get("start_date"),
due_date=payload.get("due_date"),
created_by=user["email"],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc))
return JSONResponse({"project": project}, status_code=201)
@router.put("/api/projects/{project_id}")
async def api_update_project(
request: Request, project_id: int, payload: dict[str, Any] = Body(...)
) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
_require_manage(request, st, user, project_id)
try:
project = st.update_project(project_id=project_id, fields=payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except KeyError:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
return JSONResponse({"project": project})
@router.delete("/api/projects/{project_id}")
async def api_delete_project(request: Request, project_id: int) -> JSONResponse:
"""프로젝트 삭제 — 서브프로젝트는 멤버도, 최상위는 관리자만.
아사나 요구사항: 배정된 사용자는 '서브프로젝트' 추가/삭제 가능.
최상위 프로젝트 삭제는 관리자 전용.
"""
from app.store import is_admin # noqa: WPS433
user = _require_user(request)
st = _db_or_503(request)
project = st.get_project(project_id=project_id)
if project is None:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
is_sub = project.get("parent_id") is not None
if is_sub:
_require_manage(request, st, user, project_id)
elif not is_admin(user):
raise HTTPException(status_code=403, detail="최상위 프로젝트 삭제는 관리자만 가능합니다.")
st.delete_project(project_id=project_id)
return JSONResponse({"ok": True})
# ────────────────────────────────────────────────────────────
# JSON API — 멤버 (관리자가 사용자 배정)
# ────────────────────────────────────────────────────────────
@router.get("/api/projects/{project_id}/members")
async def api_list_members(request: Request, project_id: int) -> JSONResponse:
_require_user(request)
st = _db_or_503(request)
return JSONResponse({"members": st.list_members(project_id=project_id)})
@router.post("/api/projects/{project_id}/members")
async def api_add_member(
request: Request, project_id: int, payload: dict[str, Any] = Body(...)
) -> JSONResponse:
"""사용자를 프로젝트에 배정 — 관리자 전용."""
_require_admin(request)
st = _db_or_503(request)
try:
member = st.add_member(
project_id=project_id,
user_email=payload.get("user_email", ""),
user_name=payload.get("user_name", ""),
role=payload.get("role", "member"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"member": member}, status_code=201)
@router.delete("/api/projects/{project_id}/members/{user_email}")
async def api_remove_member(
request: Request, project_id: int, user_email: str
) -> JSONResponse:
_require_admin(request)
st = _db_or_503(request)
st.remove_member(project_id=project_id, user_email=user_email)
return JSONResponse({"ok": True})
# ────────────────────────────────────────────────────────────
# JSON API — 단계 (stages)
# ────────────────────────────────────────────────────────────
@router.get("/api/projects/{project_id}/stages")
async def api_list_stages(request: Request, project_id: int) -> JSONResponse:
_require_user(request)
st = _db_or_503(request)
return JSONResponse({"stages": st.list_stages(project_id=project_id)})
@router.post("/api/projects/{project_id}/stages")
async def api_add_stage(
request: Request, project_id: int, payload: dict[str, Any] = Body(...)
) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
_require_manage(request, st, user, project_id)
try:
stage = st.add_stage(
project_id=project_id,
name=payload.get("name", ""),
is_done_stage=bool(payload.get("is_done_stage", False)),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"stage": stage}, status_code=201)
@router.delete("/api/projects/{project_id}/stages/{stage_id}")
async def api_delete_stage(
request: Request, project_id: int, stage_id: int
) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
_require_manage(request, st, user, project_id)
try:
st.delete_stage(stage_id=stage_id)
except KeyError:
raise HTTPException(status_code=404, detail="단계를 찾을 수 없습니다.")
return JSONResponse({"ok": True})
# ────────────────────────────────────────────────────────────
# JSON API — 업무 (tasks)
# ────────────────────────────────────────────────────────────
@router.get("/api/projects/{project_id}/tasks")
async def api_list_tasks(request: Request, project_id: int) -> JSONResponse:
_require_user(request)
st = _db_or_503(request)
tasks = st.list_tasks(project_id=project_id, include_subprojects=True, limit=2000)
return JSONResponse({"tasks": tasks})
@router.get("/api/my-tasks")
async def api_my_tasks(request: Request) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
return JSONResponse({"tasks": st.list_tasks(assignee_email=user["email"], limit=1000)})
@router.post("/api/projects/{project_id}/tasks")
async def api_create_task(
request: Request,
project_id: int,
bg: BackgroundTasks,
payload: dict[str, Any] = Body(...),
) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
_require_manage(request, st, user, project_id)
project = st.get_project(project_id=project_id)
if project is None:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
try:
task = st.create_task(
project_id=project_id,
title=payload.get("title", ""),
stage_id=payload.get("stage_id"),
description=payload.get("description", ""),
assignee_email=payload.get("assignee_email", ""),
assignee_name=payload.get("assignee_name", ""),
priority=payload.get("priority", "normal"),
start_date=payload.get("start_date"),
due_date=payload.get("due_date"),
created_by=user["email"],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
# 생성과 동시에 담당자 지정 시 관리자 알림
if task.get("assignee_email"):
_notify(
bg, request,
subject=store.build_subject_assigned(
project_name=project["name"],
task_title=task["title"],
assignee=task["assignee_email"],
),
body=(
f"프로젝트: {project['name']}\n"
f"업무: {task['title']}\n"
f"담당자: {task.get('assignee_name') or task['assignee_email']} "
f"({task['assignee_email']})\n"
f"마감일: {task.get('due_date') or '-'}\n"
f"등록자: {user['email']}\n"
),
)
return JSONResponse({"task": task}, status_code=201)
@router.put("/api/tasks/{task_id}")
async def api_update_task(
request: Request,
task_id: int,
bg: BackgroundTasks,
payload: dict[str, Any] = Body(...),
) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
existing = st.get_task(task_id=task_id)
if existing is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, existing["project_id"])
try:
task = st.update_task(task_id=task_id, fields=payload, actor=user["email"])
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except KeyError:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
project_name = task.get("project_name") or ""
if task.get("_newly_assigned"):
_notify(
bg, request,
subject=store.build_subject_assigned(
project_name=project_name,
task_title=task["title"],
assignee=task["_newly_assigned"],
),
body=(
f"프로젝트: {project_name}\n"
f"업무: {task['title']}\n"
f"담당자: {task.get('assignee_name') or task['_newly_assigned']} "
f"({task['_newly_assigned']})\n"
f"마감일: {task.get('due_date') or '-'}\n"
f"변경자: {user['email']}\n"
),
)
if task.get("_newly_completed"):
_notify(
bg, request,
subject=store.build_subject_completed(
project_name=project_name, task_title=task["title"]
),
body=(
f"프로젝트: {project_name}\n"
f"완료된 업무: {task['title']}\n"
f"담당자: {task.get('assignee_name') or task.get('assignee_email') or '-'}\n"
f"완료 처리: {user['email']}\n"
),
)
return JSONResponse({"task": task})
@router.delete("/api/tasks/{task_id}")
async def api_delete_task(request: Request, task_id: int) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
existing = st.get_task(task_id=task_id)
if existing is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, existing["project_id"])
st.delete_task(task_id=task_id)
return JSONResponse({"ok": True})
@router.get("/api/projects/{project_id}/activity")
async def api_activity(request: Request, project_id: int) -> JSONResponse:
_require_user(request)
st = _db_or_503(request)
return JSONResponse({"activity": st.list_activity(project_id=project_id, limit=100)})
+120
View File
@@ -0,0 +1,120 @@
"""프로젝트 관리 모듈 — 상수 / 기본값 / 순수 검증 로직.
DB I/O 는 db.py(ProjectStore)가 담당하고, 여기서는 상수와 입력 검증 등
DB 없이 단위 테스트 가능한 순수 함수만 둔다(malaysia.store 패턴).
"""
from __future__ import annotations
from typing import Any
# 프로젝트 생성 시 자동으로 깔리는 기본 진행 단계(칸반 컬럼).
# (name, is_done_stage) — 마지막 '완료' 단계로 옮기면 업무 완료 처리.
DEFAULT_STAGES: tuple[tuple[str, bool], ...] = (
("할 일", False),
("진행 중", False),
("검토", False),
("완료", True),
)
# 업무 우선순위
PRIORITIES: tuple[str, ...] = ("low", "normal", "high")
PRIORITY_LABELS: dict[str, str] = {"low": "낮음", "normal": "보통", "high": "높음"}
# 프로젝트 상태
PROJECT_STATUSES: tuple[str, ...] = ("active", "archived")
# 멤버 역할
MEMBER_ROLES: tuple[str, ...] = ("manager", "member")
# 활동(activity) 종류 — 메일 트리거/타임라인 표시용
ACTION_CREATED = "created"
ACTION_ASSIGNED = "assigned"
ACTION_COMPLETED = "completed"
ACTION_STAGE_CHANGED = "stage_changed"
ACTION_REOPENED = "reopened"
# 프로젝트 색상 팔레트(아사나 느낌) — UI 선택지
COLOR_PALETTE: tuple[str, ...] = (
"#4573d2", # blue
"#37a3a3", # teal
"#62a420", # green
"#e8a33d", # amber
"#e8384f", # red
"#aa62e3", # purple
"#f06a6a", # coral
"#5a6772", # slate
)
def norm_str(s: Any, *, lower: bool = False) -> str:
out = str(s or "").strip()
return out.lower() if lower else out
def validate_priority(p: Any) -> str:
v = norm_str(p, lower=True)
return v if v in PRIORITIES else "normal"
def validate_role(r: Any) -> str:
v = norm_str(r, lower=True)
return v if v in MEMBER_ROLES else "member"
def validate_project_name(name: Any) -> str:
v = norm_str(name)
if not v:
raise ValueError("프로젝트 이름은 필수입니다.")
if len(v) > 200:
raise ValueError("프로젝트 이름이 너무 깁니다(200자 이내).")
return v
def validate_task_title(title: Any) -> str:
v = norm_str(title)
if not v:
raise ValueError("업무 제목은 필수입니다.")
if len(v) > 300:
raise ValueError("업무 제목이 너무 깁니다(300자 이내).")
return v
def validate_color(color: Any) -> str:
v = norm_str(color)
if not v:
return COLOR_PALETTE[0]
# 매우 단순한 hex 검증 — #RGB / #RRGGBB
if v.startswith("#") and len(v) in (4, 7):
try:
int(v[1:], 16)
return v.lower()
except ValueError:
pass
return COLOR_PALETTE[0]
def validate_date(d: Any) -> str | None:
"""'YYYY-MM-DD' 형식만 통과. 빈값은 None."""
from datetime import date as _date # noqa: WPS433
v = norm_str(d)
if not v:
return None
try:
_date.fromisoformat(v[:10])
except (ValueError, TypeError):
raise ValueError(f"날짜 형식이 올바르지 않습니다: {d}")
return v[:10]
def build_subject_assigned(*, project_name: str, task_title: str, assignee: str) -> str:
return f"[DBX 프로젝트] 업무 배정: {project_name} · {task_title}{assignee}"
def build_subject_completed(*, project_name: str, task_title: str) -> str:
return f"[DBX 프로젝트] 업무 완료: {project_name} · {task_title}"
def build_subject_project_done(*, project_name: str) -> str:
return f"[DBX 프로젝트] 프로젝트 완료: {project_name}"
@@ -0,0 +1,98 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625a" />
{% endblock %}
{% block content %}
<div class="pj-wrap" data-is-admin="{{ 'true' if is_admin else 'false' }}">
<div class="pj-home">
<!-- 프로젝트 목록 -->
<section class="pj-home-main">
<div class="pj-section-head">
<h2>프로젝트</h2>
{% if is_admin %}
<button type="button" class="pj-btn pj-btn-primary" id="pj-new-project">
+ 새 프로젝트
</button>
{% endif %}
</div>
{% if not tree %}
<div class="pj-empty">
아직 프로젝트가 없습니다.
{% if is_admin %}상단의 <b>새 프로젝트</b> 버튼으로 만들어 보세요.
{% else %}관리자가 프로젝트에 배정하면 여기에 표시됩니다.{% endif %}
</div>
{% endif %}
<div class="pj-project-grid">
{% for p in tree %}
<a class="pj-project-card" href="/project/p/{{ p.id }}"
style="--pj-color: {{ p.color }}">
<span class="pj-project-dot"></span>
<div class="pj-project-body">
<div class="pj-project-name">{{ p.name }}</div>
{% if p.description %}<div class="pj-project-desc">{{ p.description }}</div>{% endif %}
<div class="pj-project-meta">
{% if p.children %}<span class="pj-chip">서브 {{ p.children|length }}</span>{% endif %}
{% if p.due_date %}<span class="pj-chip">마감 {{ p.due_date }}</span>{% endif %}
</div>
</div>
</a>
{% endfor %}
</div>
</section>
<!-- 내 업무 -->
<aside class="pj-home-side">
<div class="pj-section-head"><h2>내 업무</h2></div>
{% if not my_tasks %}
<div class="pj-empty pj-empty-sm">배정된 업무가 없습니다.</div>
{% else %}
<ul class="pj-mytask-list">
{% for t in my_tasks %}
<li class="pj-mytask {% if t.completed_at %}is-done{% endif %}">
<a href="/project/p/{{ t.project_id }}">
<span class="pj-mytask-dot" style="background: {{ t.project_color }}"></span>
<span class="pj-mytask-title">{{ t.title }}</span>
<span class="pj-mytask-meta">
{{ t.project_name }}{% if t.due_date %} · {{ t.due_date }}{% endif %}
</span>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</aside>
</div>
</div>
<!-- 새 프로젝트 모달 -->
{% if is_admin %}
<div class="pj-modal" id="pj-modal-project" hidden>
<div class="pj-modal-card">
<h3>새 프로젝트</h3>
<label>이름<input type="text" id="pj-f-name" placeholder="프로젝트 이름" /></label>
<label>설명<textarea id="pj-f-desc" rows="2"></textarea></label>
<div class="pj-form-row">
<label>시작일<input type="date" id="pj-f-start" /></label>
<label>마감일<input type="date" id="pj-f-due" /></label>
</div>
<label>색상
<span class="pj-color-palette" id="pj-f-colors"></span>
</label>
<div class="pj-modal-actions">
<button type="button" class="pj-btn" data-close>취소</button>
<button type="button" class="pj-btn pj-btn-primary" id="pj-save-project">만들기</button>
</div>
</div>
</div>
{% endif %}
<script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script>
<script src="/static/project.js?v=20260625a" defer></script>
{% endblock %}
@@ -0,0 +1,166 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625a" />
<!-- FullCalendar (달력) · vis-timeline (타임라인) — 스켈레톤 단계는 CDN, 추후 self-host -->
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.3/styles/vis-timeline-graph2d.min.css" rel="stylesheet" />
{% endblock %}
{% block content %}
<div class="pj-wrap pj-project-view"
data-project-id="{{ project.id }}"
data-can-manage="{{ 'true' if can_manage else 'false' }}"
data-is-admin="{{ 'true' if is_admin else 'false' }}"
data-me="{{ user.email }}">
<div class="pj-layout">
<!-- ── 좌측: 서브프로젝트 + 멤버 ── -->
<aside class="pj-side">
<div class="pj-side-block">
<div class="pj-side-head">
<span>서브프로젝트</span>
{% if can_manage %}
<button type="button" class="pj-icon-btn" id="pj-new-sub" title="서브프로젝트 추가">+</button>
{% endif %}
</div>
<ul class="pj-sub-list" id="pj-sub-list">
{% for s in subprojects %}
<li>
<a href="/project/p/{{ s.id }}">
<span class="pj-sub-dot" style="background: {{ s.color }}"></span>{{ s.name }}
</a>
{% if can_manage %}
<button type="button" class="pj-icon-btn pj-del-sub" data-id="{{ s.id }}" title="삭제">×</button>
{% endif %}
</li>
{% else %}
<li class="pj-empty-sm">없음</li>
{% endfor %}
</ul>
</div>
<div class="pj-side-block">
<div class="pj-side-head">
<span>멤버</span>
{% if is_admin %}
<button type="button" class="pj-icon-btn" id="pj-add-member" title="멤버 배정">+</button>
{% endif %}
</div>
<ul class="pj-member-list" id="pj-member-list">
{% for m in members %}
<li>
<span class="pj-avatar">{{ (m.user_name or m.user_email)[0] | upper }}</span>
<span class="pj-member-name">{{ m.user_name or m.user_email }}</span>
{% if m.role == 'manager' %}<span class="pj-chip pj-chip-sm">관리</span>{% endif %}
{% if is_admin %}
<button type="button" class="pj-icon-btn pj-del-member" data-email="{{ m.user_email }}" title="제외">×</button>
{% endif %}
</li>
{% else %}
<li class="pj-empty-sm">없음</li>
{% endfor %}
</ul>
</div>
</aside>
<!-- ── 우측: 뷰 ── -->
<section class="pj-main">
<div class="pj-toolbar">
<div class="pj-view-tabs" id="pj-view-tabs">
<button type="button" class="pj-tab is-active" data-view="calendar">달력</button>
<button type="button" class="pj-tab" data-view="timeline">타임라인</button>
<button type="button" class="pj-tab" data-view="board">보드</button>
<button type="button" class="pj-tab" data-view="list">리스트</button>
</div>
{% if can_manage %}
<button type="button" class="pj-btn pj-btn-primary" id="pj-new-task">+ 업무 추가</button>
{% endif %}
</div>
<!-- 달력 -->
<div class="pj-view" data-view="calendar">
<div id="pj-calendar"></div>
</div>
<!-- 타임라인 -->
<div class="pj-view" data-view="timeline" hidden>
<div id="pj-timeline"></div>
</div>
<!-- 보드(칸반) -->
<div class="pj-view" data-view="board" hidden>
<div class="pj-board" id="pj-board"></div>
</div>
<!-- 리스트 -->
<div class="pj-view" data-view="list" hidden>
<table class="pj-table" id="pj-list">
<thead>
<tr><th>업무</th><th>담당자</th><th>단계</th><th>우선순위</th><th>시작</th><th>마감</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
</div>
</div>
<!-- 업무 모달 -->
<div class="pj-modal" id="pj-modal-task" hidden>
<div class="pj-modal-card">
<h3 id="pj-task-modal-title">새 업무</h3>
<input type="hidden" id="pj-t-id" />
<label>제목<input type="text" id="pj-t-title" placeholder="업무 제목" /></label>
<label>설명<textarea id="pj-t-desc" rows="2"></textarea></label>
<div class="pj-form-row">
<label>담당자
<select id="pj-t-assignee">
<option value="">(미지정)</option>
{% for m in members %}
<option value="{{ m.user_email }}" data-name="{{ m.user_name or m.user_email }}">
{{ m.user_name or m.user_email }}
</option>
{% endfor %}
</select>
</label>
<label>단계
<select id="pj-t-stage">
{% for s in stages %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</label>
</div>
<div class="pj-form-row">
<label>우선순위
<select id="pj-t-priority">
<option value="low">낮음</option>
<option value="normal" selected>보통</option>
<option value="high">높음</option>
</select>
</label>
<label>시작일<input type="date" id="pj-t-start" /></label>
<label>마감일<input type="date" id="pj-t-due" /></label>
</div>
<div class="pj-modal-actions">
<button type="button" class="pj-btn pj-btn-danger" id="pj-delete-task" hidden>삭제</button>
<span style="flex:1"></span>
<button type="button" class="pj-btn" data-close>취소</button>
<button type="button" class="pj-btn pj-btn-primary" id="pj-save-task">저장</button>
</div>
</div>
</div>
<!-- 데이터 -->
<script id="pj-data" type="application/json">
{
"project": {{ project | tojson }},
"stages": {{ stages | tojson }},
"tasks": {{ tasks | tojson }},
"members": {{ members | tojson }},
"priorityLabels": {{ priority_labels | tojson }}
}
</script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.3/standalone/umd/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260625a" defer></script>
{% endblock %}